mixdog 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,501 @@
1
+ import { mkdirSync } from "fs";
2
+ import { createHash } from "crypto";
3
+ import { chunk } from "../lib/format.mjs";
4
+ import { readSection } from "../../shared/config.mjs";
5
+ import { toMarkdownV2, stripMdV2, isParseEntitiesError } from "../lib/telegram-format.mjs";
6
+
7
+ const MAX_TELEGRAM_MESSAGE = 4096;
8
+ const API_BASE = "https://api.telegram.org";
9
+
10
+ // Chunk raw text so that EACH chunk still fits Telegram's limit AFTER
11
+ // MarkdownV2 escaping. We first chunk normally (code-fence aware), then for any
12
+ // chunk whose converted (escaped) form exceeds `limit`, we split that raw chunk
13
+ // into smaller raw pieces until every piece converts to <= limit. Splitting the
14
+ // RAW text (not the converted text) keeps each piece a self-contained
15
+ // MarkdownV2 conversion (no entity/escape spans a piece boundary). A hard
16
+ // character-count floor prevents infinite recursion on pathological input.
17
+ const MDV2_MIN_RAW_SLICE = 256;
18
+ function chunkForMarkdownV2(text, limit) {
19
+ const raw = chunk(text, limit);
20
+ const out = [];
21
+ for (const piece of raw) {
22
+ out.push(...splitRawUntilConvertedFits(piece, limit));
23
+ }
24
+ return out.length > 0 ? out : [""];
25
+ }
26
+ function splitRawUntilConvertedFits(piece, limit) {
27
+ if (!piece) return [piece];
28
+ if (toMarkdownV2(piece).length <= limit || piece.length <= MDV2_MIN_RAW_SLICE) {
29
+ return [piece];
30
+ }
31
+ // Prefer a newline boundary near the midpoint so we don't cut mid-line;
32
+ // fall back to the raw midpoint. Re-chunk each half through chunk() first so
33
+ // code fences stay balanced, then recurse for the escape-size guarantee.
34
+ const mid = Math.floor(piece.length / 2);
35
+ const nl = piece.lastIndexOf("\n", mid);
36
+ const cut = nl > MDV2_MIN_RAW_SLICE ? nl + 1 : mid;
37
+ const left = piece.slice(0, cut);
38
+ const right = piece.slice(cut);
39
+ const result = [];
40
+ for (const half of [left, right]) {
41
+ for (const sub of chunk(half, limit)) {
42
+ result.push(...splitRawUntilConvertedFits(sub, limit));
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function defaultAccess() {
49
+ return {
50
+ dmPolicy: "allowlist",
51
+ allowFrom: [],
52
+ channels: {}
53
+ };
54
+ }
55
+
56
+ function normalizeAccess(parsed) {
57
+ const defaults = defaultAccess();
58
+ return {
59
+ dmPolicy: parsed?.dmPolicy === "pairing" ? "allowlist" : (parsed?.dmPolicy ?? defaults.dmPolicy),
60
+ allowFrom: parsed?.allowFrom ?? defaults.allowFrom,
61
+ channels: parsed?.channels ?? defaults.channels,
62
+ mentionPatterns: parsed?.mentionPatterns,
63
+ ackReaction: parsed?.ackReaction === true
64
+ ? "\uD83D\uDC4D"
65
+ : (typeof parsed?.ackReaction === "string" && parsed.ackReaction ? parsed.ackReaction : undefined),
66
+ replyToMode: parsed?.replyToMode,
67
+ textChunkLimit: parsed?.textChunkLimit,
68
+ };
69
+ }
70
+
71
+ // Telegram Bot API backend. Mirrors DiscordBackend's PUBLIC surface (the members
72
+ // index.mjs / output-forwarder rely on) so createBackend() can hand either one to
73
+ // the same runtime with no call-site changes. INBOUND via long-poll getUpdates;
74
+ // OUTBOUND as plain text (no parse_mode) so code fences survive verbatim.
75
+ class TelegramBackend {
76
+ name = "telegram";
77
+ MAX_MESSAGE_LENGTH = MAX_TELEGRAM_MESSAGE;
78
+ onMessage = null;
79
+ // Telegram has no Discord-style slash/component interaction parity. These
80
+ // hooks are kept present-but-inert so index.mjs assignments/reads don't throw.
81
+ onInteraction = null;
82
+ onModalRequest = null;
83
+ onCustomCommand = null;
84
+ token;
85
+ mainChannelId;
86
+ stateDir;
87
+ configFile;
88
+ isStatic;
89
+ initialAccess;
90
+ sendCount = 0;
91
+ // Long-poll state.
92
+ _polling = false;
93
+ _pollAbort = null;
94
+ _connectPromise = null;
95
+ _offset = 0;
96
+ _typingIntervals = /* @__PURE__ */ new Map();
97
+ constructor(config, stateDir) {
98
+ this.token = config.token;
99
+ this.mainChannelId = config.mainChannelId ?? "";
100
+ this.stateDir = stateDir;
101
+ this.configFile = config.configPath ?? "";
102
+ this.isStatic = config.accessMode === "static";
103
+ this.initialAccess = normalizeAccess(config.access);
104
+ try { mkdirSync(this.stateDir, { recursive: true }); } catch {}
105
+ }
106
+ // Passthrough by design. MarkdownV2 conversion happens PER-CHUNK inside
107
+ // sendMessage (after chunking), NOT here — converting the whole text here
108
+ // then chunking on raw length could split through the middle of a MarkdownV2
109
+ // entity and produce an unbalanced-entity 400. Keeping formatOutgoing a
110
+ // no-op also guarantees conversion runs exactly once (at send time) so there
111
+ // is no risk of double-escaping.
112
+ formatOutgoing(text) {
113
+ return text;
114
+ }
115
+ // Fold every channelsConfig entry's channelId into access.channels, mirroring
116
+ // DiscordBackend.readConfigAccess(). Read live so same-backend channel adds
117
+ // apply without a restart. Falls back to initialAccess if the read throws.
118
+ readConfigAccess() {
119
+ try {
120
+ const parsed = readSection("channels");
121
+ const access = normalizeAccess(parsed.access ?? this.initialAccess);
122
+ if (parsed.channelsConfig) {
123
+ for (const entry of Object.values(parsed.channelsConfig)) {
124
+ if (typeof entry === "object" && entry !== null) {
125
+ const id = entry.channelId;
126
+ if (id && !(id in access.channels)) {
127
+ access.channels[id] = { requireMention: false, allowFrom: [] };
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return access;
133
+ } catch {
134
+ return this.initialAccess;
135
+ }
136
+ }
137
+ loadAccess() {
138
+ // The main chat is auto-allowed (single-source channel setup, mirrors
139
+ // DiscordBackend.loadAccess) so a configured mainChannelId is enough for
140
+ // both inbound gating and outbound. Base off the live config so all
141
+ // configured channels (not just main) are seen without a restart.
142
+ const a = this.readConfigAccess();
143
+ if (this.mainChannelId && a && !(this.mainChannelId in (a.channels ?? {}))) {
144
+ return { ...a, channels: { ...(a.channels ?? {}), [this.mainChannelId]: { allowFrom: [], requireMention: false } } };
145
+ }
146
+ return a;
147
+ }
148
+ _isAllowedChat(chatId) {
149
+ const access = this.loadAccess();
150
+ if (!access || access.dmPolicy === "disabled") return false;
151
+ const key = String(chatId);
152
+ if (this.mainChannelId && key === String(this.mainChannelId)) return true;
153
+ if (key in (access.channels ?? {})) return true;
154
+ if ((access.allowFrom ?? []).includes(key)) return true;
155
+ return false;
156
+ }
157
+ // ── Bot API helper ─────────────────────────────────────────────────
158
+ // POST <method> with a JSON body; parse the JSON envelope and normalize
159
+ // Telegram's error shape into a thrown Error that carries `.status` and
160
+ // `.retryAfter` so sendMessage's retry loop can key off them exactly like
161
+ // the Discord backend keys off discord.js error fields.
162
+ async _api(method, body, { signal } = {}) {
163
+ const url = `${API_BASE}/bot${this.token}/${method}`;
164
+ let res;
165
+ try {
166
+ res = await fetch(url, {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify(body ?? {}),
170
+ signal: signal ?? AbortSignal.timeout(30_000)
171
+ });
172
+ } catch (err) {
173
+ // Network/timeout — surface as a transient error (no status).
174
+ const e = err instanceof Error ? err : new Error(String(err));
175
+ throw e;
176
+ }
177
+ let json = null;
178
+ try { json = await res.json(); } catch {}
179
+ if (!res.ok || !json || json.ok !== true) {
180
+ const code = json?.error_code ?? res.status;
181
+ const retryAfter = json?.parameters?.retry_after;
182
+ const desc = json?.description ?? `HTTP ${res.status}`;
183
+ const e = new Error(`telegram ${method} failed: ${desc}`);
184
+ e.status = code;
185
+ if (retryAfter != null) e.retryAfter = retryAfter;
186
+ throw e;
187
+ }
188
+ return json.result;
189
+ }
190
+ // ── Lifecycle ──────────────────────────────────────────────────────
191
+ async connect() {
192
+ // Re-entry guard mirrors DiscordBackend.connect(): a second connect() while
193
+ // one is in flight/settled returns the same promise (no duplicate loops).
194
+ if (this._connectPromise) return this._connectPromise;
195
+ this._connectPromise = (async () => {
196
+ // Validate the token once (also surfaces a bad token early). getMe is
197
+ // cheap and non-mutating; a failure here throws so startup can react.
198
+ try {
199
+ const me = await this._api("getMe", {});
200
+ process.stderr.write(`mixdog telegram: connected as @${me?.username ?? me?.id ?? "?"}\n`);
201
+ } catch (err) {
202
+ this._connectPromise = null;
203
+ throw err;
204
+ }
205
+ this._polling = true;
206
+ // Fire-and-forget the poll loop; it self-reschedules until _polling clears.
207
+ void this._pollLoop();
208
+ })();
209
+ return this._connectPromise;
210
+ }
211
+ async _pollLoop() {
212
+ while (this._polling) {
213
+ const ac = new AbortController();
214
+ this._pollAbort = ac;
215
+ // getUpdates long-poll: server holds up to `timeout`s; give the client
216
+ // a slightly longer abort budget so a normal empty poll isn't aborted.
217
+ const timer = setTimeout(() => ac.abort(), 30_000);
218
+ try {
219
+ const updates = await this._api("getUpdates", {
220
+ timeout: 25,
221
+ offset: this._offset > 0 ? this._offset : undefined,
222
+ allowed_updates: ["message"]
223
+ }, { signal: ac.signal });
224
+ clearTimeout(timer);
225
+ if (Array.isArray(updates)) {
226
+ for (const u of updates) {
227
+ // Advance offset past every update we observe so none reprocesses,
228
+ // even ones we ultimately drop (non-message / not allowlisted).
229
+ if (typeof u.update_id === "number" && u.update_id >= this._offset) {
230
+ this._offset = u.update_id + 1;
231
+ }
232
+ try { this._handleUpdate(u); } catch (e) {
233
+ process.stderr.write(`mixdog telegram: handleUpdate failed: ${e}\n`);
234
+ }
235
+ }
236
+ }
237
+ } catch (err) {
238
+ clearTimeout(timer);
239
+ if (!this._polling) break; // disconnect() aborted us — exit quietly.
240
+ // 409 = another getUpdates/webhook consumer is active; other errors are
241
+ // transient. Back off and continue rather than crashing the loop.
242
+ const status = err?.status;
243
+ const backoff = status === 409 ? 5_000 : 2_000;
244
+ process.stderr.write(`mixdog telegram: getUpdates error (${status ?? "net"}); retrying in ${backoff}ms\n`);
245
+ await new Promise((r) => setTimeout(r, backoff));
246
+ } finally {
247
+ this._pollAbort = null;
248
+ }
249
+ }
250
+ }
251
+ _handleUpdate(u) {
252
+ const msg = u.message;
253
+ if (!msg) return; // non-message update (we only asked for messages).
254
+ const chatId = msg.chat?.id;
255
+ if (chatId == null) return;
256
+ if (!this._isAllowedChat(chatId)) return;
257
+ const from = msg.from ?? {};
258
+ if (from.is_bot) return; // ignore bot echoes / other bots.
259
+ const text = msg.text ?? msg.caption ?? "";
260
+ const receivedAtMs = Date.now();
261
+ // Mirror discord.mjs handleInbound's onMessage object shape so downstream
262
+ // routing (resolveInboundRoute etc.) is backend-agnostic. Telegram has no
263
+ // thread/parent concept here → parentChatId null; attachments unsupported
264
+ // in scope 1 → [].
265
+ if (this.onMessage) {
266
+ this.onMessage({
267
+ chatId: String(chatId),
268
+ parentChatId: null,
269
+ messageId: String(msg.message_id),
270
+ receivedAtMs,
271
+ discordCreatedAtMs: typeof msg.date === "number" ? msg.date * 1000 : null,
272
+ user: from.username ?? [from.first_name, from.last_name].filter(Boolean).join(" ") ?? "user",
273
+ userId: String(from.id ?? ""),
274
+ text,
275
+ ts: new Date((typeof msg.date === "number" ? msg.date * 1000 : receivedAtMs)).toISOString(),
276
+ attachments: []
277
+ });
278
+ }
279
+ }
280
+ async disconnect() {
281
+ this._polling = false;
282
+ try { this._pollAbort?.abort(); } catch {}
283
+ this._pollAbort = null;
284
+ for (const interval of this._typingIntervals.values()) clearInterval(interval);
285
+ this._typingIntervals.clear();
286
+ this._connectPromise = null;
287
+ }
288
+ resetSendCount() {
289
+ this.sendCount = 0;
290
+ }
291
+ // ── Outbound ───────────────────────────────────────────────────────
292
+ // Mirrors DiscordBackend.sendMessage: OWNS chunking + per-chunk retry + the
293
+ // opaque resume-token contract so output-forwarder (which passes whole text
294
+ // and hands opts.resumeToken back on retry) works unchanged.
295
+ async sendMessage(chatId, text, opts) {
296
+ const limit = MAX_TELEGRAM_MESSAGE;
297
+ // Chunk the RAW text, then guarantee every chunk stays within Telegram's
298
+ // 4096 limit AFTER MarkdownV2 escaping. Escaping can up to ~double a chunk
299
+ // (every special char gains a leading backslash), so a raw 4096 chunk of
300
+ // specials would become ~8192 and Telegram rejects it with a 400 "message
301
+ // is too long" (NOT a parse-entities error, so the parse fallback would not
302
+ // catch it). We resplit any chunk whose converted form exceeds the limit,
303
+ // halving the raw slice until the converted output fits. This keeps the
304
+ // final `chunks` array authoritative for the resume-token index/length.
305
+ const chunks = chunkForMarkdownV2(text, limit);
306
+ // Opaque resume token. Shape parity with discord.mjs:
307
+ // { hash, nextChunkIdx, sentIds, prefixed, limit }. Telegram has no
308
+ // continuation-prefix requirement, so `prefixed` is always false — it is
309
+ // emitted only so the token shape matches across backends.
310
+ const contentHash = createHash("md5").update(String(text)).digest("hex");
311
+ const resumeToken = opts?.resumeToken;
312
+ let startIdx = 0;
313
+ const sentIds = [];
314
+ // Only honor the token when it pins to this exact text AND chunk limit;
315
+ // otherwise the chunk boundaries could differ → full resend from 0 (safe).
316
+ if (resumeToken && resumeToken.hash === contentHash && resumeToken.limit === limit) {
317
+ startIdx = Math.max(0, Math.min(resumeToken.nextChunkIdx ?? 0, chunks.length));
318
+ if (Array.isArray(resumeToken.sentIds)) sentIds.push(...resumeToken.sentIds);
319
+ }
320
+ try {
321
+ for (let i = startIdx; i < chunks.length; i++) {
322
+ // Per-chunk send with retry. On 429 honour Telegram's
323
+ // parameters.retry_after (seconds → ms, clamp 60000); other transient
324
+ // errors get a short backoff. Cap at 3 attempts; after the cap throw an
325
+ // error carrying a resume token so the caller requeues the WHOLE item
326
+ // and the next sendMessage resumes at this chunk index (no duplicates).
327
+ let attempt = 0;
328
+ for (;;) {
329
+ try {
330
+ // Convert THIS chunk to MarkdownV2 independently. Chunking happens
331
+ // on raw text first (above), then each chunk is converted on its
332
+ // own, so a MarkdownV2 entity can never span a chunk boundary →
333
+ // every sent chunk is guaranteed to be independently valid.
334
+ const md = toMarkdownV2(chunks[i]);
335
+ let result;
336
+ // Defensive length guard: chunkForMarkdownV2() already keeps every
337
+ // chunk's converted form within limit, but a pathological slice at
338
+ // the recursion floor could still exceed it. Rather than let
339
+ // Telegram 400 with "message is too long" (which isParseEntitiesError
340
+ // does NOT match, so the parse fallback below would be skipped and
341
+ // the item would retry-loop forever), send this chunk as plain text.
342
+ if (md.length > MAX_TELEGRAM_MESSAGE) {
343
+ process.stderr.write(`mixdog telegram: converted chunk ${i} exceeds ${MAX_TELEGRAM_MESSAGE} after escaping; sending as plain text\n`);
344
+ const plain = stripMdV2(md).slice(0, MAX_TELEGRAM_MESSAGE);
345
+ result = await this._api("sendMessage", { chat_id: chatId, text: plain });
346
+ sentIds.push(String(result?.message_id ?? ""));
347
+ break;
348
+ }
349
+ try {
350
+ result = await this._api("sendMessage", {
351
+ chat_id: chatId,
352
+ text: md,
353
+ parse_mode: "MarkdownV2"
354
+ });
355
+ } catch (mdErr) {
356
+ // Safety net: if the converter still produced something Telegram
357
+ // rejects as unparseable, resend this chunk ONCE as plain text so
358
+ // the user gets the content instead of a hard failure. Strip the
359
+ // MarkdownV2 markers/escapes so no stray backslashes show.
360
+ if (isParseEntitiesError(mdErr)) {
361
+ process.stderr.write(`mixdog telegram: MarkdownV2 parse rejected, resending chunk ${i} as plain text\n`);
362
+ result = await this._api("sendMessage", {
363
+ chat_id: chatId,
364
+ text: stripMdV2(md)
365
+ });
366
+ } else {
367
+ throw mdErr;
368
+ }
369
+ }
370
+ sentIds.push(String(result?.message_id ?? ""));
371
+ break;
372
+ } catch (err) {
373
+ attempt++;
374
+ const status = err?.status;
375
+ // Classify: PERMANENT = a 4xx client error (chat not found 400,
376
+ // unauthorized 401, blocked 403, 404 …) that will never succeed on
377
+ // retry. TRANSIENT = 429, any 5xx, or network/timeout (no status).
378
+ // A permanent error must NOT retry-loop and must NOT carry a resume
379
+ // token — throw it immediately flagged so the forwarder drops the
380
+ // item instead of requeuing forever. (MarkdownV2 parse-entity 400s
381
+ // are already handled by the plain-text fallback above and never
382
+ // reach here.)
383
+ const isPermanent = typeof status === "number" && status >= 400 && status < 500 && status !== 429;
384
+ if (isPermanent) {
385
+ const e = err instanceof Error ? err : new Error(String(err));
386
+ e.permanent = true;
387
+ throw e;
388
+ }
389
+ if (attempt >= 3) {
390
+ const e = err instanceof Error ? err : new Error(String(err));
391
+ e.resumeToken = { hash: contentHash, nextChunkIdx: i, sentIds: [...sentIds], prefixed: false, limit };
392
+ throw e;
393
+ }
394
+ if (status === 429) {
395
+ // Telegram's parameters.retry_after is ALWAYS in SECONDS, so
396
+ // convert seconds→ms. Clamp to [0, 60000]; fall back to 1s when
397
+ // the field is absent/invalid.
398
+ const retryAfterMs = Number(err?.retryAfter) * 1000;
399
+ const ms = Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? Math.min(retryAfterMs, 60_000) : 1000;
400
+ await new Promise((r) => setTimeout(r, ms));
401
+ } else {
402
+ // Other transient error (5xx / network): short backoff.
403
+ await new Promise((r) => setTimeout(r, 1000));
404
+ }
405
+ }
406
+ }
407
+ }
408
+ this.sendCount += sentIds.length;
409
+ } catch (err) {
410
+ const msg = err instanceof Error ? err.message : String(err);
411
+ const wrapped = new Error(`send failed after ${sentIds.length}/${chunks.length} chunk(s): ${msg}`);
412
+ // Preserve the opaque resume token across the rewrap so the caller can
413
+ // persist it on the queue item and resume on retry (matches discord.mjs).
414
+ if (err?.resumeToken) wrapped.resumeToken = err.resumeToken;
415
+ // Propagate the permanent flag so the forwarder drops (not requeues) it.
416
+ if (err?.permanent) wrapped.permanent = true;
417
+ throw wrapped;
418
+ }
419
+ return { sentIds };
420
+ }
421
+ // Telegram supports message reactions via setMessageReaction. Best-effort:
422
+ // an emoji Telegram doesn't allow as a reaction 400s → swallow (non-critical).
423
+ async react(chatId, messageId, emoji) {
424
+ try {
425
+ await this._api("setMessageReaction", {
426
+ chat_id: chatId,
427
+ message_id: Number(messageId),
428
+ reaction: [{ type: "emoji", emoji }]
429
+ });
430
+ } catch { /* reactions are non-critical */ }
431
+ }
432
+ async removeReaction(chatId, messageId, _emoji) {
433
+ try {
434
+ await this._api("setMessageReaction", {
435
+ chat_id: chatId,
436
+ message_id: Number(messageId),
437
+ reaction: []
438
+ });
439
+ } catch { /* best-effort */ }
440
+ }
441
+ // Bot API cannot read arbitrary chat history (no getChatHistory for bots);
442
+ // return [] so callers that expect a list degrade gracefully.
443
+ async fetchMessages() {
444
+ return [];
445
+ }
446
+ // editMessageText exists; kept minimal for scope-1 parity so index.mjs's
447
+ // edit path doesn't throw. Sends the first chunk's worth only (no overflow
448
+ // management like Discord's editOverflow — a later scope can extend this).
449
+ async editMessage(chatId, messageId, text, _opts) {
450
+ try {
451
+ const result = await this._api("editMessageText", {
452
+ chat_id: chatId,
453
+ message_id: Number(messageId),
454
+ text: chunk(text, MAX_TELEGRAM_MESSAGE)[0] ?? ""
455
+ });
456
+ return String(result?.message_id ?? messageId);
457
+ } catch {
458
+ return String(messageId);
459
+ }
460
+ }
461
+ async deleteMessage(chatId, messageId) {
462
+ try {
463
+ await this._api("deleteMessage", { chat_id: chatId, message_id: Number(messageId) });
464
+ } catch { /* best-effort */ }
465
+ }
466
+ // Attachment download is unsupported in scope 1. getFile + a download step
467
+ // can be added later; returning [] keeps the call site safe meanwhile.
468
+ async downloadAttachment() {
469
+ return [];
470
+ }
471
+ async validateChannel(chatId) {
472
+ // getChat confirms the bot can see the chat; throws (like Discord's
473
+ // fetchAllowedChannel) when it can't.
474
+ await this._api("getChat", { chat_id: chatId });
475
+ }
476
+ // ── Typing indicator ───────────────────────────────────────────────
477
+ // sendChatAction "typing" lasts ~5s server-side, so refresh on an interval
478
+ // while active — mirrors DiscordBackend.startTyping's interval model.
479
+ startTyping(channelId) {
480
+ this.stopTyping(channelId);
481
+ const fire = () => {
482
+ void this._api("sendChatAction", { chat_id: channelId, action: "typing" }).catch(() => {});
483
+ };
484
+ fire();
485
+ const interval = setInterval(fire, 4_000);
486
+ this._typingIntervals.set(String(channelId), interval);
487
+ }
488
+ stopTyping(channelId) {
489
+ const key = String(channelId);
490
+ const interval = this._typingIntervals.get(key);
491
+ if (interval) {
492
+ clearInterval(interval);
493
+ this._typingIntervals.delete(key);
494
+ }
495
+ }
496
+ }
497
+
498
+ export {
499
+ TelegramBackend,
500
+ MAX_TELEGRAM_MESSAGE
501
+ };