Author SHA1 Message Date
evandro 58b0f7777a docs: refresh README file counts and document the Windows test baseline and issue #1
Validate / validate (push) Successful in 10m0s
2026-10-10 04:02:21 -03:00
evandro 5ac99d4584 chore: pin typescript and @types/node to the versions resolved in the lockfile 2026-10-10 04:01:40 -03:00
evandro 6a6e75fe32 refactor: cut cyclomatic complexity of five hot spots and pin their behavior with tests
Risk labels, menu callback parsing, settings callbacks, table cell width and
markdown link targets now use tables and small helpers (CC <= 10).
2026-10-10 03:59:46 -03:00
evandrodevbr bf4a2021e8 Merge branch 'status-ui'
Validate / validate (push) Successful in 10m6s
2026-10-01 19:36:58 -03:00
11 changed files with 658 additions and 303 deletions

No files matched your search

+6 -5
View File
@@ -202,7 +202,7 @@ There is no build step: `omp` loads the TypeScript sources directly, and `npm pa
```bash
npm run validate # the gate: typecheck + tests + audit + pack:check
npm run pack:check # npm pack --dry-run, 87 files, no secrets
npm run pack:check # npm pack --dry-run, 89 files, no secrets
```
Release flow: bump `package.json` version, add a matching `## <version>` section to `CHANGELOG.md`, then push the tag.
@@ -219,9 +219,9 @@ git tag v0.22.0-evandro.2 && git push origin v0.22.0-evandro.2
```
index.ts extension entrypoint: composition root and runtime wiring
api/ public package subpaths (inbound, outbound, delivery, ...)
lib/ 54 domain modules: config, paths, polling, queue, routing,
lib/ 56 domain modules: config, paths, polling, queue, routing,
rendering, outbound, bus leader/follower, locks, voice, menus
tests/ 55 node:test suites (unit + integration, no network)
tests/ 57 node:test suites (unit + integration, no network)
docs/ architecture and per-surface contracts
.agents/skills/ project-local agent skills (Telegram Bot API reference, domain DAG)
AGENTS.md engineering and runtime conventions (project context, not docs)
@@ -238,7 +238,7 @@ Everything below was executed in this repository on Node 24.20.0 / npm 11.19.0 (
| Command | Result |
| --- | --- |
| `npm ci` | exit 0, lockfile consistent, `found 0 vulnerabilities` |
| `npm run typecheck` | exit 0, `tsc --noEmit` |
| `npm run typecheck` | exit 0, `tsc --noEmit` (TypeScript pinned to 7.0.2 in `devDependencies`) |
| `npm test` | exit 0, `tests 1230 / pass 1229 / fail 0 / skipped 1` (the skip is a Windows-only case) |
| `npm run audit` | exit 0, `found 0 vulnerabilities` |
| `npm run pack:check` | exit 0, tarball `evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz` |
@@ -256,7 +256,8 @@ The test suite is deterministic and offline: transport calls are injected, so no
- **Not published to npm**: `@evandrodevbr/oh-my-pi-telegram` returns 404 on the registry. Install from a local checkout; on top of that, `omp` 18.1.20's plugin source resolver rejects `npm` sources (`npm plugin sources are not yet supported. Use git-based sources instead.`).
- **No GitHub release yet**: the only tag, `v0.22.0-evandro.1`, produced a failed `Release` run (`CHANGELOG.md has no section for 0.22.0-evandro.1`, because the fork section was a level-3 heading). The heading is fixed in this commit, but the existing tag still points at the pre-fix commit, so the release must be created manually or the tag moved.
- **Fork lags upstream**: this fork stays on upstream `0.22.0` (SHA `afe09c5`), while `@llblab/pi-telegram` has since published 0.51.6. Upstream changes after 0.22.0 are not included. Upstream 0.51.6 loads in `omp` 18.4.8 but subscribes to five events `omp` never emits (`agent_settled`, `model_select`, `session_compact_failed`, `ui_prompt_start`, `ui_prompt_end`), so it is not a drop-in replacement.
- **Verified on one platform only**: Linux with Node 24.20.0. The suite contains Windows-specific cases (and the single skipped test is one of them), but Windows/macOS were not run here.
- **Verified on Linux (Node 24.20.0) and, partly, Windows (Node 26.7.0)**. On Windows `npm test` currently reports about 57 failures out of roughly 1270 tests: Unix-domain-socket bus tests (`listen EACCES` on `.sock` paths) and POSIX path assertions (`\tmp` vs `/tmp`). Typecheck, audit and pack:check pass there. macOS was not run.
- **Model menu on `omp`**: `settingsManager.reload is not a function` can break the `/start` and model menus on hosts whose settings object lacks `reload` ([issue #1](https://github.com/evandrodevbr/oh-my-pi-telegram/issues/1)); a fix is proposed in [PR #2](https://github.com/evandrodevbr/oh-my-pi-telegram/pull/2).
- **Requires a live `omp` instance and a real bot token** for end-to-end use; the repository suite does not cover the Telegram API surface, and the `omp` 18.4.8 checks above used a fake Bot API, so live Telegram behavior (Rich Messages, Threaded Mode, voice, files) is unverified on `omp`.
- **LLM/token cost applies**: a Telegram prompt is a normal model turn in the active session, so it inherits that session's post-compaction context.
- **Single-owner by design**: the first Telegram user to message the bot is the owner; other users are ignored. No multi-user or group-chat support.
+110 -66
View File
@@ -223,76 +223,120 @@ function hasSecretTouchingText(parts: string[]): boolean {
);
}
interface CommandTemplateRiskContext {
command: string;
args: string[];
parts: string[];
}
const COMMAND_TEMPLATE_NETWORK_EXECUTABLES = [
"curl",
"wget",
"ssh",
"scp",
"sftp",
"rsync",
"nc",
"ncat",
"telnet",
"ftp",
];
const COMMAND_TEMPLATE_PLATFORM_EXECUTABLES = [
"systemctl",
"launchctl",
"osascript",
"open",
"xdg-open",
"powershell",
"pwsh",
"cmd.exe",
"apt",
"apt-get",
"dnf",
"yum",
"brew",
"pacman",
"apk",
"xclip",
"wl-copy",
];
const COMMAND_TEMPLATE_RISK_RULES: Array<
[CommandTemplateRiskLabel, (context: CommandTemplateRiskContext) => boolean]
> = [
["risk.shell", ({ command }) => ["bash", "sh", "zsh", "fish"].includes(command)],
[
"risk.eval",
({ command, args }) =>
(["bash", "sh", "zsh", "fish"].includes(command) &&
hasAnyFlag(args, ["-c"])) ||
(["node", "deno", "bun"].includes(command) &&
hasAnyFlag(args, ["-e", "--eval"])) ||
(["python", "python3", "perl", "ruby"].includes(command) &&
hasAnyFlag(args, ["-c", "-e"])),
],
[
"risk.destructive_fs",
({ command, args }) =>
command === "rm" &&
(args.some((arg) => /^-[^-]*[rf]/.test(arg)) || hasRiskyPathArg(args)),
],
[
"risk.broad_fs_write",
({ command, args }) =>
["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args),
],
[
"risk.network",
({ command, args }) =>
COMMAND_TEMPLATE_NETWORK_EXECUTABLES.includes(command) ||
(command === "git" &&
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command),
],
[
"risk.external_side_effect",
({ command, args }) =>
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
(command === "git" && hasAnyArg(args, ["push"])) ||
(["npm", "pnpm", "yarn"].includes(command) &&
hasAnyArg(args, ["publish", "login", "logout", "deprecate"])),
],
[
"risk.long_running",
({ command, args }) =>
command === "sleep" ||
command === "watch" ||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
hasAnyArg(args, ["--watch", "--serve", "serve"]),
],
[
"risk.platform_specific",
({ command }) => COMMAND_TEMPLATE_PLATFORM_EXECUTABLES.includes(command),
],
[
"risk.secret_touching",
({ command, parts }) =>
["pass", "gpg", "ssh-add"].includes(command) ||
hasSecretTouchingText(parts),
],
];
function getLeafCommandTemplateRiskLabels(
config: CommandTemplateLeafConfig,
): CommandTemplateRiskLabel[] {
const parts = splitCommandTemplate(config.template);
const command = getExecutableName(parts[0]);
const args = parts.slice(1);
const labels = new Set<CommandTemplateRiskLabel>();
if (["bash", "sh", "zsh", "fish"].includes(command)) {
labels.add("risk.shell");
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
}
if (
["node", "deno", "bun"].includes(command) &&
hasAnyFlag(args, ["-e", "--eval"])
) {
labels.add("risk.eval");
}
if (
["python", "python3", "perl", "ruby"].includes(command) &&
hasAnyFlag(args, ["-c", "-e"])
) {
labels.add("risk.eval");
}
if (
command === "rm" &&
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
hasRiskyPathArg(args))
) {
labels.add("risk.destructive_fs");
}
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
labels.add("risk.broad_fs_write");
}
if (
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
command,
) ||
(command === "git" &&
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
) {
labels.add("risk.network");
}
if (
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
(command === "git" && hasAnyArg(args, ["push"])) ||
(["npm", "pnpm", "yarn"].includes(command) &&
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
) {
labels.add("risk.external_side_effect");
}
if (
command === "sleep" ||
command === "watch" ||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
hasAnyArg(args, ["--watch", "--serve", "serve"])
) {
labels.add("risk.long_running");
}
if (
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
command,
)
) {
labels.add("risk.platform_specific");
}
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
labels.add("risk.secret_touching");
}
return sortRiskLabels(labels);
const context: CommandTemplateRiskContext = {
command: getExecutableName(parts[0]),
args: parts.slice(1),
parts,
};
return sortRiskLabels(
COMMAND_TEMPLATE_RISK_RULES.filter(([, matches]) => matches(context)).map(
([label]) => label,
),
);
}
function getLeafCommandTemplateWarnings(
+115 -107
View File
@@ -492,122 +492,130 @@ export async function updateVoiceReplyModeSettingsMessage(
);
}
type TelegramSettingsPageUpdater = (
deps: TelegramSettingsMenuCallbackDeps,
) => Promise<void>;
const TELEGRAM_SETTINGS_PAGE_UPDATERS = new Map<
string,
TelegramSettingsPageUpdater
>([
[
"settings:list",
(deps) => updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry),
],
["settings:open:proactive", updateProactivePushSettingsMessage],
["settings:open:draft-previews", updateDraftPreviewsSettingsMessage],
["settings:open:rich-drafts", updateDraftPreviewsSettingsMessage],
["settings:open:assistant-rendering", updateAssistantRenderingSettingsMessage],
["settings:open:voice-reply", updateVoiceReplyModeSettingsMessage],
["settings:open:time-injection", updateTimeInjectionModeSettingsMessage],
["settings:open:time", updateTimeInjectionModeSettingsMessage],
]);
/** Applies one setting value; resolves the toast text, or undefined when the value is invalid */
type TelegramSettingsValueApplier = (
value: string,
deps: TelegramSettingsMenuCallbackDeps,
) => Promise<string | undefined>;
async function applyVoiceReplyModeSetting(
mode: string,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<string | undefined> {
if (
mode !== "hidden" &&
mode !== "manual" &&
mode !== "mirror" &&
mode !== "always"
) {
return undefined;
}
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
await updateVoiceReplyModeSettingsMessage(deps);
return `Voice reply mode: ${mode}`;
}
async function applyTimeInjectionModeSetting(
mode: string,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<string | undefined> {
const normalizedMode = mode === "off" ? "hidden" : mode;
if (
normalizedMode !== "hidden" &&
normalizedMode !== "always" &&
normalizedMode !== "interval"
) {
return undefined;
}
await deps.setTimeInjectionMode(normalizedMode);
await updateTimeInjectionModeSettingsMessage(deps);
return `Time injection: ${normalizedMode}`;
}
async function applyDraftPreviewsSetting(
value: string,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<string | undefined> {
if (value !== "on" && value !== "off") return undefined;
const enabled = value === "on";
await deps.setDraftPreviewsEnabled(enabled);
await updateDraftPreviewsSettingsMessage(deps);
return `Draft previews ${enabled ? "enabled" : "disabled"}`;
}
async function applyAssistantRenderingSetting(
mode: string,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<string | undefined> {
if (mode !== "rich" && mode !== "html") return undefined;
await deps.setAssistantRenderingMode(mode);
await updateAssistantRenderingSettingsMessage(deps);
return `Rendering: ${mode}`;
}
async function applyProactivePushSetting(
value: string,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<string | undefined> {
if (value !== "on" && value !== "off") return undefined;
const enabled = value === "on";
await deps.setProactivePushEnabled(enabled);
await updateProactivePushSettingsMessage(deps);
return `Proactive push ${enabled ? "enabled" : "disabled"}`;
}
const TELEGRAM_SETTINGS_VALUE_APPLIERS: Array<
[prefix: string, apply: TelegramSettingsValueApplier]
> = [
["settings:set:voice-reply:", applyVoiceReplyModeSetting],
["settings:set:time-injection:", applyTimeInjectionModeSetting],
["settings:set:time:", applyTimeInjectionModeSetting],
["settings:set:draft-previews:", applyDraftPreviewsSetting],
["settings:set:rich-drafts:", applyDraftPreviewsSetting],
["settings:set:assistant-rendering:", applyAssistantRenderingSetting],
["settings:set:proactive:", applyProactivePushSetting],
];
export async function handleTelegramSettingsMenuCallbackAction(
callbackQueryId: string,
data: string | undefined,
deps: TelegramSettingsMenuCallbackDeps,
): Promise<boolean> {
if (!data?.startsWith("settings:")) return false;
if (data === "settings:list") {
await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
const updatePage = TELEGRAM_SETTINGS_PAGE_UPDATERS.get(data);
if (updatePage) {
await updatePage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:proactive") {
await updateProactivePushSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
await updateDraftPreviewsSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:assistant-rendering") {
await updateAssistantRenderingSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data === "settings:open:voice-reply") {
await updateVoiceReplyModeSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (
data === "settings:open:time-injection" ||
data === "settings:open:time"
) {
await updateTimeInjectionModeSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId);
return true;
}
if (data.startsWith("settings:set:voice-reply:")) {
const mode = data.slice("settings:set:voice-reply:".length);
if (
mode === "hidden" ||
mode === "manual" ||
mode === "mirror" ||
mode === "always"
) {
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
await updateVoiceReplyModeSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Voice reply mode: ${mode}`,
);
return true;
}
}
if (
data.startsWith("settings:set:time-injection:") ||
data.startsWith("settings:set:time:")
) {
const mode = data.startsWith("settings:set:time-injection:")
? data.slice("settings:set:time-injection:".length)
: data.slice("settings:set:time:".length);
const normalizedMode = mode === "off" ? "hidden" : mode;
if (
normalizedMode === "hidden" ||
normalizedMode === "always" ||
normalizedMode === "interval"
) {
await deps.setTimeInjectionMode(normalizedMode);
await updateTimeInjectionModeSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Time injection: ${normalizedMode}`,
);
return true;
}
}
if (
data === "settings:set:draft-previews:on" ||
data === "settings:set:draft-previews:off" ||
data === "settings:set:rich-drafts:on" ||
data === "settings:set:rich-drafts:off"
) {
const enabled = data.endsWith(":on");
await deps.setDraftPreviewsEnabled(enabled);
await updateDraftPreviewsSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Draft previews ${enabled ? "enabled" : "disabled"}`,
);
return true;
}
if (data.startsWith("settings:set:assistant-rendering:")) {
const mode = data.slice("settings:set:assistant-rendering:".length);
if (mode === "rich" || mode === "html") {
await deps.setAssistantRenderingMode(mode);
await updateAssistantRenderingSettingsMessage(deps);
await deps.answerCallbackQuery(callbackQueryId, `Rendering: ${mode}`);
return true;
}
}
if (
data === "settings:set:proactive:on" ||
data === "settings:set:proactive:off"
) {
const enabled = data.endsWith(":on");
await deps.setProactivePushEnabled(enabled);
await updateProactivePushSettingsMessage(deps);
await deps.answerCallbackQuery(
callbackQueryId,
`Proactive push ${enabled ? "enabled" : "disabled"}`,
);
return true;
}
await deps.answerCallbackQuery(callbackQueryId);
const applier = TELEGRAM_SETTINGS_VALUE_APPLIERS.find(([prefix]) =>
data.startsWith(prefix),
);
const toast = applier
? await applier[1](data.slice(applier[0].length), deps)
: undefined;
await deps.answerCallbackQuery(callbackQueryId, toast);
return true;
}
+39 -31
View File
@@ -334,45 +334,53 @@ export type TelegramMenuCallbackAction =
value?: string;
};
const TELEGRAM_MENU_STATUS_ACTIONS = [
"model",
"thinking",
"queue",
"settings",
] as const;
const TELEGRAM_MENU_MODEL_ACTIONS = [
"noop",
"scope",
"page",
"pages",
"open",
"pick",
"pick-selected",
"scope-enable",
"scope-disable",
"scope-toggle",
] as const;
type TelegramMenuModelAction = Extract<
TelegramMenuCallbackAction,
{ kind: "model" }
>["action"];
export function parseTelegramMenuCallbackAction(
data: string | undefined,
): TelegramMenuCallbackAction {
if (data === "menu:model" || data === "status:model") {
return { kind: "status", action: "model" };
}
if (data === "menu:thinking" || data === "status:thinking") {
return { kind: "status", action: "thinking" };
}
if (data === "menu:queue" || data === "status:queue") {
return { kind: "status", action: "queue" };
}
if (data === "menu:settings" || data === "status:settings") {
return { kind: "status", action: "settings" };
}
if (data?.startsWith("thinking:set:")) {
if (!data) return { kind: "ignore" };
const statusAction = TELEGRAM_MENU_STATUS_ACTIONS.find(
(action) => data === `menu:${action}` || data === `status:${action}`,
);
if (statusAction) return { kind: "status", action: statusAction };
if (data.startsWith("thinking:set:")) {
return {
kind: "thinking:set",
level: data.slice("thinking:set:".length),
};
}
if (data?.startsWith("model:")) {
const [, action, value] = data.split(":");
if (
action === "noop" ||
action === "scope" ||
action === "page" ||
action === "pages" ||
action === "open" ||
action === "pick" ||
action === "pick-selected" ||
action === "scope-enable" ||
action === "scope-disable" ||
action === "scope-toggle"
) {
return { kind: "model", action, value };
}
}
return { kind: "ignore" };
if (!data.startsWith("model:")) return { kind: "ignore" };
const [, action, value] = data.split(":");
const modelAction = TELEGRAM_MENU_MODEL_ACTIONS.find(
(candidate): candidate is TelegramMenuModelAction => candidate === action,
);
return modelAction
? { kind: "model", action: modelAction, value }
: { kind: "ignore" };
}
export async function handleTelegramMenuCallbackEntry(
+116 -90
View File
@@ -191,81 +191,94 @@ function findMarkdownClosingBracket(
return undefined;
}
function skipMarkdownWhitespace(text: string, index: number): number {
while (index < text.length && /\s/.test(text[index] ?? "")) {
index += 1;
}
return index;
}
/** Index of the first unescaped `closing` character at or after `index`, or text length */
function findUnescapedMarkdownCharacter(
text: string,
index: number,
closing: string,
): number {
while (
index < text.length &&
(isEscapedMarkdownCharacter(text, index) || text[index] !== closing)
) {
index += 1;
}
return index;
}
function parseMarkdownAngleDestination(
text: string,
openIndex: number,
): { destination: string; nextIndex: number } | undefined {
const closeIndex = findUnescapedMarkdownCharacter(text, openIndex + 1, ">");
if (closeIndex >= text.length) return undefined;
return {
destination: text.slice(openIndex + 1, closeIndex).trim(),
nextIndex: closeIndex + 1,
};
}
function parseMarkdownBareDestination(
text: string,
startIndex: number,
): { destination: string; nextIndex: number } {
let index = startIndex;
let parenDepth = 0;
while (index < text.length) {
if (isEscapedMarkdownCharacter(text, index)) {
index += 1;
continue;
}
const char = text[index] ?? "";
if (parenDepth === 0 && (char === ")" || /\s/.test(char))) break;
if (char === "(") parenDepth += 1;
if (char === ")") parenDepth -= 1;
index += 1;
}
return { destination: text.slice(startIndex, index).trim(), nextIndex: index };
}
/** Skips an optional quoted or parenthesized title; undefined when it is unterminated */
function skipMarkdownLinkTitle(
text: string,
index: number,
): number | undefined {
const titleDelimiter = text[index];
if (titleDelimiter !== '"' && titleDelimiter !== "'" && titleDelimiter !== "(") {
return index;
}
const closingDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
const closeIndex = findUnescapedMarkdownCharacter(
text,
index + 1,
closingDelimiter,
);
if (closeIndex >= text.length) return undefined;
return skipMarkdownWhitespace(text, closeIndex + 1);
}
function parseMarkdownLinkTarget(
text: string,
openParenIndex: number,
): { destination: string; endIndex: number } | undefined {
let index = openParenIndex + 1;
while (index < text.length && /\s/.test(text[index] ?? "")) {
index += 1;
}
if (index >= text.length) return undefined;
let destination = "";
if (text[index] === "<") {
const destinationStart = index + 1;
index += 1;
while (index < text.length) {
if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
break;
}
index += 1;
}
if (index >= text.length) return undefined;
destination = text.slice(destinationStart, index).trim();
index += 1;
} else {
const destinationStart = index;
let parenDepth = 0;
while (index < text.length) {
if (isEscapedMarkdownCharacter(text, index)) {
index += 1;
continue;
}
const char = text[index] ?? "";
if (/\s/.test(char) && parenDepth === 0) break;
if (char === "(") {
parenDepth += 1;
index += 1;
continue;
}
if (char === ")") {
if (parenDepth === 0) break;
parenDepth -= 1;
index += 1;
continue;
}
index += 1;
}
destination = text.slice(destinationStart, index).trim();
}
if (!destination) return undefined;
while (index < text.length && /\s/.test(text[index] ?? "")) {
index += 1;
}
if (
index < text.length &&
(text[index] === '"' || text[index] === "'" || text[index] === "(")
) {
const titleDelimiter = text[index] ?? '"';
const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
index += 1;
while (index < text.length) {
if (
!isEscapedMarkdownCharacter(text, index) &&
text[index] === closingTitleDelimiter
) {
break;
}
index += 1;
}
if (index >= text.length) return undefined;
index += 1;
while (index < text.length && /\s/.test(text[index] ?? "")) {
index += 1;
}
}
if (text[index] !== ")") return undefined;
return { destination, endIndex: index };
const destinationIndex = skipMarkdownWhitespace(text, openParenIndex + 1);
if (destinationIndex >= text.length) return undefined;
const parsed =
text[destinationIndex] === "<"
? parseMarkdownAngleDestination(text, destinationIndex)
: parseMarkdownBareDestination(text, destinationIndex);
if (!parsed?.destination) return undefined;
const titleIndex = skipMarkdownWhitespace(text, parsed.nextIndex);
const endIndex = skipMarkdownLinkTitle(text, titleIndex);
if (endIndex === undefined || text[endIndex] !== ")") return undefined;
return { destination: parsed.destination, endIndex };
}
function isSupportedMarkdownLinkDestination(destination: string): boolean {
@@ -715,27 +728,40 @@ function isTelegramTableEmojiGrapheme(grapheme: string): boolean {
);
}
/** Inclusive code point ranges that occupy two terminal columns */
const TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES: ReadonlyArray<
readonly [number, number]
> = [
[0x1100, 0x115f],
[0x2329, 0x232a],
[0x2e80, 0xa4cf],
[0xac00, 0xd7a3],
[0xf900, 0xfaff],
[0xfe10, 0xfe19],
[0xfe30, 0xfe6f],
[0xff00, 0xff60],
[0xffe0, 0xffe6],
];
function isTelegramTableZeroWidthCodePoint(
codePoint: number,
char: string,
): boolean {
return (
codePoint < 32 ||
codePoint === 0x200d ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
/\p{Mark}/u.test(char)
);
}
function getTelegramTableCodePointWidth(char: string): number {
const codePoint = char.codePointAt(0) ?? 0;
if (codePoint === 0 || codePoint < 32) return 0;
if (/\p{Mark}/u.test(char)) return 0;
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || codePoint === 0x200d)
return 0;
if (
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
) {
return 2;
}
return 1;
if (isTelegramTableZeroWidthCodePoint(codePoint, char)) return 0;
const isWide = TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES.some(
([start, end]) => codePoint >= start && codePoint <= end,
);
return isWide ? 2 : 1;
}
function getTelegramTableGraphemes(text: string): string[] {
+2 -2
View File
@@ -9,8 +9,8 @@
"version": "0.22.0-evandro.1",
"license": "MIT",
"devDependencies": {
"@types/node": "latest",
"typescript": "latest"
"@types/node": "26.1.1",
"typescript": "7.0.2"
},
"engines": {
"node": ">=22.19.0"
+2 -2
View File
@@ -77,8 +77,8 @@
"@sinclair/typebox": "*"
},
"devDependencies": {
"@types/node": "latest",
"typescript": "latest"
"@types/node": "26.1.1",
"typescript": "7.0.2"
},
"overrides": {
"protobufjs": "7.6.6",
+44
View File
@@ -350,3 +350,47 @@ test("Command templates report missing required placeholders", () => {
/Missing command template value: missing/,
);
});
test("Command template risk labels cover each detection branch and its inverse", () => {
const cases: Array<[string, string[]]> = [
["echo hello", []],
["bash script.sh", ["risk.shell"]],
["sh -c {script}", ["risk.shell", "risk.eval"]],
["node app.js", []],
["node -e {code}", ["risk.eval"]],
["bun --eval {code}", ["risk.eval"]],
["python3 script.py", []],
["python3 -c {code}", ["risk.eval"]],
["ruby -e {code}", ["risk.eval"]],
["rm file.txt", []],
["rm -rf build", ["risk.destructive_fs"]],
["rm -f build", ["risk.destructive_fs"]],
["rm /etc/hosts", ["risk.destructive_fs"]],
["mv a.txt b.txt", []],
["cp a.txt ~/b.txt", ["risk.broad_fs_write"]],
["rsync a b", ["risk.network"]],
["rsync a /dst", ["risk.broad_fs_write", "risk.network"]],
["curl https://example.com", ["risk.network"]],
["git status", []],
["git clone {url}", ["risk.network"]],
["git push origin main", ["risk.external_side_effect", "risk.network"]],
["npm install", ["risk.network"]],
["npm publish", ["risk.external_side_effect", "risk.network"]],
["kubectl get pods", ["risk.external_side_effect"]],
["sleep 5", ["risk.long_running"]],
["tail file.log", []],
["tail -f file.log", ["risk.long_running"]],
["tool --serve", ["risk.long_running"]],
["systemctl restart x", ["risk.platform_specific"]],
["gpg --decrypt {file}", ["risk.secret_touching"]],
["tool --api-key {key}", ["risk.secret_touching"]],
["tool --name value", []],
];
for (const [template, expected] of cases) {
assert.deepEqual(
getCommandTemplateRiskLabels(template),
expected,
template,
);
}
});
+106
View File
@@ -261,3 +261,109 @@ test("Settings runtime opens menus and applies stale-message fallback toggles",
"answer:Time injection: hidden",
]);
});
function createSettingsCallbackHarness() {
const calls: string[] = [];
const deps = {
isProactivePushEnabled: () => false,
getVoiceReplyMode: () => "manual" as const,
isVoiceReplyModeConfigured: () => true,
getTimeInjectionMode: () => "hidden" as const,
areDraftPreviewsEnabled: () => false,
getAssistantRenderingMode: () => "rich" as const,
setProactivePushEnabled: async (enabled: boolean) => {
calls.push(`proactive:${enabled}`);
},
setDraftPreviewsEnabled: async (enabled: boolean) => {
calls.push(`draft-previews:${enabled}`);
},
setAssistantRenderingMode: async (mode: "rich" | "html") => {
calls.push(`rendering:${mode}`);
},
setVoiceReplyMode: async (
mode: "manual" | "mirror" | "always" | undefined,
) => {
calls.push(`voice:${mode ?? "hidden"}`);
},
setTimeInjectionMode: async (mode: "hidden" | "always" | "interval") => {
calls.push(`time:${mode}`);
},
updateSettingsMessage: async (text: string) => {
calls.push(`update:${text.split("\n")[0]}`);
},
answerCallbackQuery: async (_id: string, text?: string) => {
calls.push(`answer:${text ?? ""}`);
},
};
return { calls, deps };
}
test("Settings callback action opens each page, including legacy aliases", async () => {
const pages: Array<[string, string]> = [
["settings:list", "update:<b>⚙️ Settings:</b>"],
["settings:open:proactive", "update:<b>📌 Proactive push:</b> <code>off</code>"],
["settings:open:draft-previews", "update:<b>📝 Draft previews:</b> <code>off</code>"],
["settings:open:rich-drafts", "update:<b>📝 Draft previews:</b> <code>off</code>"],
["settings:open:assistant-rendering", "update:<b>🧾 Assistant rendering:</b> <code>rich</code>"],
["settings:open:voice-reply", "update:<b>👄 Voice reply mode:</b> <code>manual</code>"],
["settings:open:time-injection", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>"],
["settings:open:time", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>"],
];
for (const [data, update] of pages) {
const { calls, deps } = createSettingsCallbackHarness();
assert.equal(
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
true,
data,
);
assert.deepEqual(calls, [update, "answer:"], data);
}
});
test("Settings callback action applies each mode and the inverse branch of each toggle", async () => {
const cases: Array<[string, string[]]> = [
["settings:set:voice-reply:mirror", ["voice:mirror", "update:<b>👄 Voice reply mode:</b> <code>manual</code>", "answer:Voice reply mode: mirror"]],
["settings:set:voice-reply:always", ["voice:always", "update:<b>👄 Voice reply mode:</b> <code>manual</code>", "answer:Voice reply mode: always"]],
["settings:set:time-injection:interval", ["time:interval", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: interval"]],
["settings:set:time-injection:off", ["time:hidden", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: hidden"]],
["settings:set:time:always", ["time:always", "update:<b>🕒 Time injection mode:</b> <code>hidden</code>", "answer:Time injection: always"]],
["settings:set:draft-previews:off", ["draft-previews:false", "update:<b>📝 Draft previews:</b> <code>off</code>", "answer:Draft previews disabled"]],
["settings:set:rich-drafts:on", ["draft-previews:true", "update:<b>📝 Draft previews:</b> <code>off</code>", "answer:Draft previews enabled"]],
["settings:set:assistant-rendering:rich", ["rendering:rich", "update:<b>🧾 Assistant rendering:</b> <code>rich</code>", "answer:Rendering: rich"]],
["settings:set:proactive:off", ["proactive:false", "update:<b>📌 Proactive push:</b> <code>off</code>", "answer:Proactive push disabled"]],
];
for (const [data, expected] of cases) {
const { calls, deps } = createSettingsCallbackHarness();
assert.equal(
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
true,
data,
);
assert.deepEqual(calls, expected, data);
}
});
test("Settings callback action only acknowledges invalid or unknown settings values", async () => {
for (const data of [
"settings:set:voice-reply:bogus",
"settings:set:time:bogus",
"settings:set:assistant-rendering:bogus",
"settings:set:draft-previews:maybe",
"settings:set:proactive:maybe",
"settings:unknown",
]) {
const { calls, deps } = createSettingsCallbackHarness();
assert.equal(
await handleTelegramSettingsMenuCallbackAction("q", data, deps),
true,
data,
);
assert.deepEqual(calls, ["answer:"], data);
}
const { calls, deps } = createSettingsCallbackHarness();
assert.equal(
await handleTelegramSettingsMenuCallbackAction("q", undefined, deps),
false,
);
assert.deepEqual(calls, []);
});
+42
View File
@@ -2394,3 +2394,45 @@ test("Settings menu marks one-line on/off checkbox controls symmetrically", () =
],
);
});
test("Menu callback parser maps every status alias and model action, and ignores the rest", () => {
for (const action of ["model", "thinking", "queue", "settings"] as const) {
for (const prefix of ["menu", "status"]) {
assert.deepEqual(parseTelegramMenuCallbackAction(`${prefix}:${action}`), {
kind: "status",
action,
});
}
}
for (const action of [
"noop",
"scope",
"page",
"pages",
"open",
"pick",
"pick-selected",
"scope-enable",
"scope-disable",
"scope-toggle",
]) {
assert.deepEqual(parseTelegramMenuCallbackAction(`model:${action}:7`), {
kind: "model",
action,
value: "7",
});
}
assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:"), {
kind: "thinking:set",
level: "",
});
assert.deepEqual(parseTelegramMenuCallbackAction("model:unknown:1"), {
kind: "ignore",
});
assert.deepEqual(parseTelegramMenuCallbackAction("menu:other"), {
kind: "ignore",
});
assert.deepEqual(parseTelegramMenuCallbackAction(undefined), {
kind: "ignore",
});
});
+76
View File
@@ -294,6 +294,82 @@ test("Markdown table padding handles CJK and combining marks", () => {
assert.match(chunks[0]?.text ?? "", /東京 \| city /u);
});
test("Markdown table padding measures wide, zero-width, and control code points", () => {
// [cell, expected display width]
const cells: Array<[string, number]> = [
["a", 1],
["\u{d55c}", 2], // Hangul syllable
["\u{ff21}", 2], // fullwidth Latin
["\u{2329}", 2], // angle bracket
["\u{f900}", 2], // CJK compatibility ideograph
["\u{fe10}", 2], // vertical form
["\u{fe30}", 2], // CJK compatibility form
["\u{ffe0}", 2], // fullwidth sign
["\u{1100}", 2], // Hangul Jamo
["é", 1], // combining mark
["a‍b", 2], // zero-width joiner
["a️", 1], // variation selector
["a\u0001b", 2], // control character
["ab", 2],
];
const markdown = [
"| W | N |",
"| --- | --- |",
...cells.map(([cell], index) => `| ${cell} | c${index} |`),
].join("\n");
const chunks = renderTelegramMessage(markdown, { mode: "markdown" });
assert.equal(chunks.length, 1);
const expectedRows = [
"W | N ",
"--- | ---",
...cells.map(
([cell, width], index) =>
`${cell}${" ".repeat(3 - width)} | ${`c${index}`.padEnd(3)}`,
),
];
assert.equal(
chunks[0]?.text,
`<pre><code class="language-markdown">${expectedRows.join("\n")}</code></pre>`,
);
});
test("Markdown link targets accept valid destinations and titles and reject malformed ones", () => {
const link = (href: string) => `<a href="${href}">a</a>`;
const cases: Array<[string, string]> = [
// plain destinations
["[a](https://x.com)", link("https://x.com")],
["[a]( https://x.com )", link("https://x.com")],
["[a](https://x.com/a_(b))", link("https://x.com/a_(b)")],
[String.raw`[a](https://x.com/a\)b)`, link(String.raw`https://x.com/a\)b`)],
[String.raw`[a](https://x.com/a\ b)`, link(String.raw`https://x.com/a\ b`)],
["[a](https://x.com/a b)", "[a](https://x.com/a b)"],
["[a](https://x.com/a(b)", "[a](https://x.com/a(b)"],
// angle-bracket destinations
["[a](<https://x.com/a b>)", link("https://x.com/a b")],
[String.raw`[a](<https://x.com/a\>b>)`, link(String.raw`https://x.com/a\&gt;b`)],
["[a](<https://x.com/a", "[a](&lt;https://x.com/a"],
["[a](<>)", "[a](&lt;&gt;)"],
// missing destination or end of input
["[a]()", "[a]()"],
["[a](", "[a]("],
["[a]( ", "[a]( "],
// titles
['[a](https://x.com "T")', link("https://x.com")],
["[a](https://x.com 'T')", link("https://x.com")],
["[a](https://x.com (T))", link("https://x.com")],
['[a](<https://x.com> "T")', link("https://x.com")],
["[a](https://x.com )", link("https://x.com")],
[String.raw`[a](https://x.com "T\"q")`, link("https://x.com")],
['[a](https://x.com "T)', '[a](https://x.com "T)'],
[String.raw`[a](https://x.com "T\")`, String.raw`[a](https://x.com "T\")`],
["[a](https://x.com T)", "[a](https://x.com T)"],
];
for (const [input, expected] of cases) {
const chunks = renderTelegramMessage(input, { mode: "markdown" });
assert.equal(chunks[0]?.text, expected, input);
}
});
test("Links, code spans, and underscore-heavy text coexist safely", () => {
const chunks = renderTelegramMessage(
"See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",