diff --git a/lib/command-templates.ts b/lib/command-templates.ts index 27af495..ca4d524 100644 --- a/lib/command-templates.ts +++ b/lib/command-templates.ts @@ -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(); - 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( diff --git a/lib/menu-settings.ts b/lib/menu-settings.ts index 79008d5..cae9115 100644 --- a/lib/menu-settings.ts +++ b/lib/menu-settings.ts @@ -492,122 +492,130 @@ export async function updateVoiceReplyModeSettingsMessage( ); } +type TelegramSettingsPageUpdater = ( + deps: TelegramSettingsMenuCallbackDeps, +) => Promise; + +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; + +async function applyVoiceReplyModeSetting( + mode: string, + deps: TelegramSettingsMenuCallbackDeps, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { 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; } diff --git a/lib/menu.ts b/lib/menu.ts index aeff1e7..aefc970 100644 --- a/lib/menu.ts +++ b/lib/menu.ts @@ -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( diff --git a/lib/rendering.ts b/lib/rendering.ts index fb9d840..5fe7655 100644 --- a/lib/rendering.ts +++ b/lib/rendering.ts @@ -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[] { diff --git a/tests/command-templates.test.ts b/tests/command-templates.test.ts index 0e12a08..0fb9482 100644 --- a/tests/command-templates.test.ts +++ b/tests/command-templates.test.ts @@ -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, + ); + } +}); diff --git a/tests/menu-settings.test.ts b/tests/menu-settings.test.ts index bc1bed4..324f976 100644 --- a/tests/menu-settings.test.ts +++ b/tests/menu-settings.test.ts @@ -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:βš™οΈ Settings:"], + ["settings:open:proactive", "update:πŸ“Œ Proactive push: off"], + ["settings:open:draft-previews", "update:πŸ“ Draft previews: off"], + ["settings:open:rich-drafts", "update:πŸ“ Draft previews: off"], + ["settings:open:assistant-rendering", "update:🧾 Assistant rendering: rich"], + ["settings:open:voice-reply", "update:πŸ‘„ Voice reply mode: manual"], + ["settings:open:time-injection", "update:πŸ•’ Time injection mode: hidden"], + ["settings:open:time", "update:πŸ•’ Time injection mode: hidden"], + ]; + 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:πŸ‘„ Voice reply mode: manual", "answer:Voice reply mode: mirror"]], + ["settings:set:voice-reply:always", ["voice:always", "update:πŸ‘„ Voice reply mode: manual", "answer:Voice reply mode: always"]], + ["settings:set:time-injection:interval", ["time:interval", "update:πŸ•’ Time injection mode: hidden", "answer:Time injection: interval"]], + ["settings:set:time-injection:off", ["time:hidden", "update:πŸ•’ Time injection mode: hidden", "answer:Time injection: hidden"]], + ["settings:set:time:always", ["time:always", "update:πŸ•’ Time injection mode: hidden", "answer:Time injection: always"]], + ["settings:set:draft-previews:off", ["draft-previews:false", "update:πŸ“ Draft previews: off", "answer:Draft previews disabled"]], + ["settings:set:rich-drafts:on", ["draft-previews:true", "update:πŸ“ Draft previews: off", "answer:Draft previews enabled"]], + ["settings:set:assistant-rendering:rich", ["rendering:rich", "update:🧾 Assistant rendering: rich", "answer:Rendering: rich"]], + ["settings:set:proactive:off", ["proactive:false", "update:πŸ“Œ Proactive push: off", "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, []); +}); diff --git a/tests/menu.test.ts b/tests/menu.test.ts index e747492..2603a81 100644 --- a/tests/menu.test.ts +++ b/tests/menu.test.ts @@ -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", + }); +}); diff --git a/tests/rendering.test.ts b/tests/rendering.test.ts index 48902b2..0ce0326 100644 --- a/tests/rendering.test.ts +++ b/tests/rendering.test.ts @@ -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, + `
${expectedRows.join("\n")}
`, + ); +}); + +test("Markdown link targets accept valid destinations and titles and reject malformed ones", () => { + const link = (href: string) => `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]()", link("https://x.com/a b")], + [String.raw`[a](b>)`, link(String.raw`https://x.com/a\>b`)], + ["[a]()", "[a](<>)"], + // 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]( "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",