Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b0f7777a | ||
|
|
5ac99d4584 | ||
|
|
6a6e75fe32 | ||
|
|
bf4a2021e8 |
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[] {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
["ab", 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\>b`)],
|
||||
["[a](<https://x.com/a", "[a](<https://x.com/a"],
|
||||
["[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](<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",
|
||||
|
||||
Reference in new issue
Block a user