@bd7pil/ocrc 0.9.0

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 (292) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +330 -0
  3. package/dist/cli/host-backends.js +216 -0
  4. package/dist/cli/host-backends.js.map +1 -0
  5. package/dist/cli/host.js +185 -0
  6. package/dist/cli/host.js.map +1 -0
  7. package/dist/cli/index.js +67 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/cli/init.js +68 -0
  10. package/dist/cli/init.js.map +1 -0
  11. package/dist/cli/install.js +220 -0
  12. package/dist/cli/install.js.map +1 -0
  13. package/dist/cli/pair.js +11 -0
  14. package/dist/cli/pair.js.map +1 -0
  15. package/dist/cli/uninstall.js +75 -0
  16. package/dist/cli/uninstall.js.map +1 -0
  17. package/dist/connectivity/auth/cf-access.js +20 -0
  18. package/dist/connectivity/auth/cf-access.js.map +1 -0
  19. package/dist/connectivity/auth/index.js +2 -0
  20. package/dist/connectivity/auth/index.js.map +1 -0
  21. package/dist/connectivity/auth/select.js +19 -0
  22. package/dist/connectivity/auth/select.js.map +1 -0
  23. package/dist/connectivity/auth/token.js +157 -0
  24. package/dist/connectivity/auth/token.js.map +1 -0
  25. package/dist/connectivity/exposure/cloudflared.js +77 -0
  26. package/dist/connectivity/exposure/cloudflared.js.map +1 -0
  27. package/dist/connectivity/exposure/index.js +2 -0
  28. package/dist/connectivity/exposure/index.js.map +1 -0
  29. package/dist/connectivity/exposure/providers.js +49 -0
  30. package/dist/connectivity/exposure/providers.js.map +1 -0
  31. package/dist/connectivity/pairing.js +29 -0
  32. package/dist/connectivity/pairing.js.map +1 -0
  33. package/dist/core/agent/acp-backend.js +539 -0
  34. package/dist/core/agent/acp-backend.js.map +1 -0
  35. package/dist/core/agent/acp-connect.js +92 -0
  36. package/dist/core/agent/acp-connect.js.map +1 -0
  37. package/dist/core/agent/acp-normalizer.js +81 -0
  38. package/dist/core/agent/acp-normalizer.js.map +1 -0
  39. package/dist/core/agent/acp-replay.js +89 -0
  40. package/dist/core/agent/acp-replay.js.map +1 -0
  41. package/dist/core/agent/acp-store.js +129 -0
  42. package/dist/core/agent/acp-store.js.map +1 -0
  43. package/dist/core/agent/backend.js +2 -0
  44. package/dist/core/agent/backend.js.map +1 -0
  45. package/dist/core/agent/diff-util.js +70 -0
  46. package/dist/core/agent/diff-util.js.map +1 -0
  47. package/dist/core/agent/event.js +12 -0
  48. package/dist/core/agent/event.js.map +1 -0
  49. package/dist/core/agent/opencode-backend.js +598 -0
  50. package/dist/core/agent/opencode-backend.js.map +1 -0
  51. package/dist/core/agent/opencode-normalizer.js +69 -0
  52. package/dist/core/agent/opencode-normalizer.js.map +1 -0
  53. package/dist/core/agent/registry.js +55 -0
  54. package/dist/core/agent/registry.js.map +1 -0
  55. package/dist/core/agent/v2-backend.js +176 -0
  56. package/dist/core/agent/v2-backend.js.map +1 -0
  57. package/dist/core/card-bus.js +74 -0
  58. package/dist/core/card-bus.js.map +1 -0
  59. package/dist/core/channels.js +95 -0
  60. package/dist/core/channels.js.map +1 -0
  61. package/dist/core/history.js +91 -0
  62. package/dist/core/history.js.map +1 -0
  63. package/dist/core/list-files.js +50 -0
  64. package/dist/core/list-files.js.map +1 -0
  65. package/dist/core/opencode-events.js +29 -0
  66. package/dist/core/opencode-events.js.map +1 -0
  67. package/dist/core/primary-election.js +91 -0
  68. package/dist/core/primary-election.js.map +1 -0
  69. package/dist/core/push.js +152 -0
  70. package/dist/core/push.js.map +1 -0
  71. package/dist/core/relay.js +400 -0
  72. package/dist/core/relay.js.map +1 -0
  73. package/dist/core/scheduler.js +150 -0
  74. package/dist/core/scheduler.js.map +1 -0
  75. package/dist/core/state.js +222 -0
  76. package/dist/core/state.js.map +1 -0
  77. package/dist/core/stream-accumulator.js +76 -0
  78. package/dist/core/stream-accumulator.js.map +1 -0
  79. package/dist/core/structured-card.js +2 -0
  80. package/dist/core/structured-card.js.map +1 -0
  81. package/dist/core/types.js +2 -0
  82. package/dist/core/types.js.map +1 -0
  83. package/dist/index.js +3 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/opencode/global-events.js +57 -0
  86. package/dist/opencode/global-events.js.map +1 -0
  87. package/dist/opencode/list-sessions.js +122 -0
  88. package/dist/opencode/list-sessions.js.map +1 -0
  89. package/dist/opencode/submit.js +39 -0
  90. package/dist/opencode/submit.js.map +1 -0
  91. package/dist/opencode/workspaces.js +51 -0
  92. package/dist/opencode/workspaces.js.map +1 -0
  93. package/dist/plugin/config.js +128 -0
  94. package/dist/plugin/config.js.map +1 -0
  95. package/dist/plugin/control-plane.js +133 -0
  96. package/dist/plugin/control-plane.js.map +1 -0
  97. package/dist/plugin/entry.js +409 -0
  98. package/dist/plugin/entry.js.map +1 -0
  99. package/dist/plugin/v2/event-map.js +193 -0
  100. package/dist/plugin/v2/event-map.js.map +1 -0
  101. package/dist/plugin/v2/types.js +15 -0
  102. package/dist/plugin/v2/types.js.map +1 -0
  103. package/dist/transport/interface.js +2 -0
  104. package/dist/transport/interface.js.map +1 -0
  105. package/dist/transport/telegram/esc.js +9 -0
  106. package/dist/transport/telegram/esc.js.map +1 -0
  107. package/dist/transport/telegram/handlers/info-commands.js +101 -0
  108. package/dist/transport/telegram/handlers/info-commands.js.map +1 -0
  109. package/dist/transport/telegram/handlers.js +1348 -0
  110. package/dist/transport/telegram/handlers.js.map +1 -0
  111. package/dist/transport/telegram/i18n/en.js +538 -0
  112. package/dist/transport/telegram/i18n/en.js.map +1 -0
  113. package/dist/transport/telegram/i18n/index.js +83 -0
  114. package/dist/transport/telegram/i18n/index.js.map +1 -0
  115. package/dist/transport/telegram/i18n/zh.js +538 -0
  116. package/dist/transport/telegram/i18n/zh.js.map +1 -0
  117. package/dist/transport/telegram/index.js +792 -0
  118. package/dist/transport/telegram/index.js.map +1 -0
  119. package/dist/transport/telegram/main-keyboard.js +36 -0
  120. package/dist/transport/telegram/main-keyboard.js.map +1 -0
  121. package/dist/transport/telegram/managers/abort-suppression-manager.js +33 -0
  122. package/dist/transport/telegram/managers/abort-suppression-manager.js.map +1 -0
  123. package/dist/transport/telegram/managers/external-input-suppression-manager.js +51 -0
  124. package/dist/transport/telegram/managers/external-input-suppression-manager.js.map +1 -0
  125. package/dist/transport/telegram/managers/interaction-manager.js +254 -0
  126. package/dist/transport/telegram/managers/interaction-manager.js.map +1 -0
  127. package/dist/transport/telegram/managers/permission-manager.js +242 -0
  128. package/dist/transport/telegram/managers/permission-manager.js.map +1 -0
  129. package/dist/transport/telegram/managers/question-manager.js +191 -0
  130. package/dist/transport/telegram/managers/question-manager.js.map +1 -0
  131. package/dist/transport/telegram/managers/rename-manager.js +56 -0
  132. package/dist/transport/telegram/managers/rename-manager.js.map +1 -0
  133. package/dist/transport/telegram/menus.js +77 -0
  134. package/dist/transport/telegram/menus.js.map +1 -0
  135. package/dist/transport/telegram/permission-flow.js +156 -0
  136. package/dist/transport/telegram/permission-flow.js.map +1 -0
  137. package/dist/transport/telegram/question-flow.js +224 -0
  138. package/dist/transport/telegram/question-flow.js.map +1 -0
  139. package/dist/transport/telegram/render/block-parser.js +365 -0
  140. package/dist/transport/telegram/render/block-parser.js.map +1 -0
  141. package/dist/transport/telegram/render/block-plain-text.js +87 -0
  142. package/dist/transport/telegram/render/block-plain-text.js.map +1 -0
  143. package/dist/transport/telegram/render/block-splitter.js +152 -0
  144. package/dist/transport/telegram/render/block-splitter.js.map +1 -0
  145. package/dist/transport/telegram/render/chunker.js +71 -0
  146. package/dist/transport/telegram/render/chunker.js.map +1 -0
  147. package/dist/transport/telegram/render/limits.js +13 -0
  148. package/dist/transport/telegram/render/limits.js.map +1 -0
  149. package/dist/transport/telegram/render/markdown-normalizer.js +102 -0
  150. package/dist/transport/telegram/render/markdown-normalizer.js.map +1 -0
  151. package/dist/transport/telegram/render/markdown-to-telegram-v2.js +116 -0
  152. package/dist/transport/telegram/render/markdown-to-telegram-v2.js.map +1 -0
  153. package/dist/transport/telegram/render/part-signature.js +14 -0
  154. package/dist/transport/telegram/render/part-signature.js.map +1 -0
  155. package/dist/transport/telegram/render/pipeline.js +23 -0
  156. package/dist/transport/telegram/render/pipeline.js.map +1 -0
  157. package/dist/transport/telegram/render/rich-blocks.js +311 -0
  158. package/dist/transport/telegram/render/rich-blocks.js.map +1 -0
  159. package/dist/transport/telegram/render/text-splitter.js +64 -0
  160. package/dist/transport/telegram/render/text-splitter.js.map +1 -0
  161. package/dist/transport/telegram/render/types.js +2 -0
  162. package/dist/transport/telegram/render/types.js.map +1 -0
  163. package/dist/transport/telegram/renderer.js +305 -0
  164. package/dist/transport/telegram/renderer.js.map +1 -0
  165. package/dist/transport/telegram/streaming/duration-formatter.js +68 -0
  166. package/dist/transport/telegram/streaming/duration-formatter.js.map +1 -0
  167. package/dist/transport/telegram/streaming/finalize-assistant-response.js +37 -0
  168. package/dist/transport/telegram/streaming/finalize-assistant-response.js.map +1 -0
  169. package/dist/transport/telegram/streaming/response-streamer.js +400 -0
  170. package/dist/transport/telegram/streaming/response-streamer.js.map +1 -0
  171. package/dist/transport/telegram/streaming/running-tool-tracker.js +150 -0
  172. package/dist/transport/telegram/streaming/running-tool-tracker.js.map +1 -0
  173. package/dist/transport/telegram/streaming/stream-throttle.js +57 -0
  174. package/dist/transport/telegram/streaming/stream-throttle.js.map +1 -0
  175. package/dist/transport/telegram/streaming/tool-call-streamer.js +390 -0
  176. package/dist/transport/telegram/streaming/tool-call-streamer.js.map +1 -0
  177. package/dist/transport/telegram/streaming-render.js +182 -0
  178. package/dist/transport/telegram/streaming-render.js.map +1 -0
  179. package/dist/transport/telegram/types/interaction.js +2 -0
  180. package/dist/transport/telegram/types/interaction.js.map +1 -0
  181. package/dist/transport/telegram/types/model.js +25 -0
  182. package/dist/transport/telegram/types/model.js.map +1 -0
  183. package/dist/transport/telegram/types/permission.js +2 -0
  184. package/dist/transport/telegram/types/permission.js.map +1 -0
  185. package/dist/transport/telegram/types/question.js +2 -0
  186. package/dist/transport/telegram/types/question.js.map +1 -0
  187. package/dist/transport/telegram/types/rename.js +2 -0
  188. package/dist/transport/telegram/types/rename.js.map +1 -0
  189. package/dist/transport/telegram/types/scheduled-task.js +23 -0
  190. package/dist/transport/telegram/types/scheduled-task.js.map +1 -0
  191. package/dist/transport/telegram/types/session.js +2 -0
  192. package/dist/transport/telegram/types/session.js.map +1 -0
  193. package/dist/transport/telegram/ui.js +28 -0
  194. package/dist/transport/telegram/ui.js.map +1 -0
  195. package/dist/transport/web/index.js +148 -0
  196. package/dist/transport/web/index.js.map +1 -0
  197. package/dist/transport/web/middleware/cf-access.js +104 -0
  198. package/dist/transport/web/middleware/cf-access.js.map +1 -0
  199. package/dist/transport/web/opencode-config.js +15 -0
  200. package/dist/transport/web/opencode-config.js.map +1 -0
  201. package/dist/transport/web/routes/abort.js +12 -0
  202. package/dist/transport/web/routes/abort.js.map +1 -0
  203. package/dist/transport/web/routes/approval.js +16 -0
  204. package/dist/transport/web/routes/approval.js.map +1 -0
  205. package/dist/transport/web/routes/catalog.js +11 -0
  206. package/dist/transport/web/routes/catalog.js.map +1 -0
  207. package/dist/transport/web/routes/channels.js +53 -0
  208. package/dist/transport/web/routes/channels.js.map +1 -0
  209. package/dist/transport/web/routes/commands.js +15 -0
  210. package/dist/transport/web/routes/commands.js.map +1 -0
  211. package/dist/transport/web/routes/context.js +17 -0
  212. package/dist/transport/web/routes/context.js.map +1 -0
  213. package/dist/transport/web/routes/controls.js +34 -0
  214. package/dist/transport/web/routes/controls.js.map +1 -0
  215. package/dist/transport/web/routes/create-session.js +21 -0
  216. package/dist/transport/web/routes/create-session.js.map +1 -0
  217. package/dist/transport/web/routes/diff.js +7 -0
  218. package/dist/transport/web/routes/diff.js.map +1 -0
  219. package/dist/transport/web/routes/files.js +22 -0
  220. package/dist/transport/web/routes/files.js.map +1 -0
  221. package/dist/transport/web/routes/logs.js +9 -0
  222. package/dist/transport/web/routes/logs.js.map +1 -0
  223. package/dist/transport/web/routes/m8.js +69 -0
  224. package/dist/transport/web/routes/m8.js.map +1 -0
  225. package/dist/transport/web/routes/mcp.js +7 -0
  226. package/dist/transport/web/routes/mcp.js.map +1 -0
  227. package/dist/transport/web/routes/message.js +32 -0
  228. package/dist/transport/web/routes/message.js.map +1 -0
  229. package/dist/transport/web/routes/overrides.js +20 -0
  230. package/dist/transport/web/routes/overrides.js.map +1 -0
  231. package/dist/transport/web/routes/question.js +32 -0
  232. package/dist/transport/web/routes/question.js.map +1 -0
  233. package/dist/transport/web/routes/rename.js +12 -0
  234. package/dist/transport/web/routes/rename.js.map +1 -0
  235. package/dist/transport/web/routes/schedules.js +28 -0
  236. package/dist/transport/web/routes/schedules.js.map +1 -0
  237. package/dist/transport/web/routes/session.js +20 -0
  238. package/dist/transport/web/routes/session.js.map +1 -0
  239. package/dist/transport/web/routes/sessions.js +25 -0
  240. package/dist/transport/web/routes/sessions.js.map +1 -0
  241. package/dist/transport/web/routes/subagents.js +11 -0
  242. package/dist/transport/web/routes/subagents.js.map +1 -0
  243. package/dist/transport/web/routes/suggestions.js +7 -0
  244. package/dist/transport/web/routes/suggestions.js.map +1 -0
  245. package/dist/transport/web/routes/todo.js +7 -0
  246. package/dist/transport/web/routes/todo.js.map +1 -0
  247. package/dist/transport/web/routes/version.js +5 -0
  248. package/dist/transport/web/routes/version.js.map +1 -0
  249. package/dist/transport/web/routes/workspaces.js +7 -0
  250. package/dist/transport/web/routes/workspaces.js.map +1 -0
  251. package/dist/transport/web/server.js +111 -0
  252. package/dist/transport/web/server.js.map +1 -0
  253. package/dist/transport/web/session-summary.js +46 -0
  254. package/dist/transport/web/session-summary.js.map +1 -0
  255. package/dist/transport/web/ws-hub.js +83 -0
  256. package/dist/transport/web/ws-hub.js.map +1 -0
  257. package/dist/utils/logger.js +105 -0
  258. package/dist/utils/logger.js.map +1 -0
  259. package/dist/utils/markdown.js +129 -0
  260. package/dist/utils/markdown.js.map +1 -0
  261. package/dist/utils/paths.js +18 -0
  262. package/dist/utils/paths.js.map +1 -0
  263. package/dist/utils/version.js +44 -0
  264. package/dist/utils/version.js.map +1 -0
  265. package/package.json +91 -0
  266. package/web/dist/_app/env.js +1 -0
  267. package/web/dist/_app/immutable/assets/0.DxN3RsuG.css +1 -0
  268. package/web/dist/_app/immutable/assets/2.BNuGfmZP.css +1 -0
  269. package/web/dist/_app/immutable/assets/3.CTDprntu.css +1 -0
  270. package/web/dist/_app/immutable/chunks/BUpwHpMJ.js +1 -0
  271. package/web/dist/_app/immutable/chunks/BjY4dxcB.js +2 -0
  272. package/web/dist/_app/immutable/chunks/C2FuqD3s.js +1 -0
  273. package/web/dist/_app/immutable/chunks/CUHGq08X.js +2 -0
  274. package/web/dist/_app/immutable/chunks/CWj6FrbW.js +1 -0
  275. package/web/dist/_app/immutable/chunks/ClvoqtGj.js +1 -0
  276. package/web/dist/_app/immutable/chunks/DIEDQ_hk.js +1 -0
  277. package/web/dist/_app/immutable/entry/app.BoRpcVMj.js +2 -0
  278. package/web/dist/_app/immutable/entry/start.Cj-PQNTq.js +1 -0
  279. package/web/dist/_app/immutable/nodes/0.y9YEms5p.js +3 -0
  280. package/web/dist/_app/immutable/nodes/1.CPhhiRM2.js +1 -0
  281. package/web/dist/_app/immutable/nodes/2.B_c84Mc9.js +1 -0
  282. package/web/dist/_app/immutable/nodes/3.ClTd0J3D.js +64 -0
  283. package/web/dist/_app/version.json +1 -0
  284. package/web/dist/apple-touch-icon.png +0 -0
  285. package/web/dist/favicon.png +0 -0
  286. package/web/dist/favicon.svg +17 -0
  287. package/web/dist/icon-192.png +0 -0
  288. package/web/dist/icon-512-maskable.png +0 -0
  289. package/web/dist/icon-512.png +0 -0
  290. package/web/dist/index.html +73 -0
  291. package/web/dist/manifest.webmanifest +28 -0
  292. package/web/dist/service-worker.js +1 -0
@@ -0,0 +1,792 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Bot, InlineKeyboard } from 'grammy';
3
+ import { errorCodeOf, inlineKeyboard, btn } from './ui.js';
4
+ import { isEphemeralSession } from '../../opencode/submit.js';
5
+ import { TelegramSessionRenderer } from './renderer.js';
6
+ import { StreamingRenderer } from './streaming-render.js';
7
+ import { PermissionFlow } from './permission-flow.js';
8
+ import { QuestionFlow } from './question-flow.js';
9
+ import { renderSessionsMenu, renderAgentsMenu, renderModelsMenu, editMenu } from './menus.js';
10
+ import { InteractionManager } from './managers/interaction-manager.js';
11
+ import { PermissionManager } from './managers/permission-manager.js';
12
+ import { buildMainKeyboard, AGENT_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, CONTEXT_BUTTON_TEXT_PATTERN, } from './main-keyboard.js';
13
+ import { registerHandlers } from './handlers.js';
14
+ import { esc } from './esc.js';
15
+ import { createLogger } from '../../utils/logger.js';
16
+ const log = createLogger('telegram');
17
+ const CAPS = {
18
+ edit: true,
19
+ maxMessageLength: 4000,
20
+ buttons: true,
21
+ richText: true,
22
+ streaming: false,
23
+ };
24
+ export function createTelegramTransport(cfg, injected) {
25
+ // DI seam for tests: a recording bot object can be injected instead of a
26
+ // real Bot (whose Api has no spy-able prototype in grammY 1.46+).
27
+ const bot = injected?.bot ?? new Bot(cfg.token);
28
+ // Whitelist middleware — silently drop strangers. Replying "Unauthorized"
29
+ // would confirm to anyone that this bot exists and is access-controlled.
30
+ bot.use(async (ctx, next) => {
31
+ if (!cfg.allowedUserIds.includes(ctx.from?.id ?? -1)) {
32
+ if (ctx.from)
33
+ log.warn(`rejected from ${ctx.from.id}`);
34
+ return;
35
+ }
36
+ await next();
37
+ });
38
+ let messageHandler;
39
+ /** True while grammY polling is live — surfaced via status() for the channels panel. */
40
+ let pollingLive = false;
41
+ // Suggestion-chip tokens: callback ids → the chip text they send.
42
+ const sugTokens = new Map();
43
+ // "Generating" is derived from the relay's per-session abort registry, not a
44
+ // local flag. The relay returns immediately (the response arrives via the
45
+ // event hook), so a local flag would clear before generation finishes. The
46
+ // registry is set when a run starts and cleared on session.idle/error/abort.
47
+ // The gate is per-session: a busy session must not block input targeted at
48
+ // another (idle) session.
49
+ const fmtK = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n));
50
+ const targetSessionId = () => {
51
+ const raw = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
52
+ return raw ? cfg.state.normalizeSessionId(raw) : undefined;
53
+ };
54
+ const isGenerating = () => {
55
+ const sid = targetSessionId();
56
+ return sid ? cfg.state.hasActiveGeneration(sid) : false;
57
+ };
58
+ /** Abort the in-flight generation for the bot's target session (normalized pinned ?? last). Returns the resolved session id. */
59
+ function abortGeneration() {
60
+ const raw = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
61
+ if (!raw)
62
+ return undefined;
63
+ const sid = cfg.state.normalizeSessionId(raw);
64
+ cfg.state.getActiveAbort(sid)?.abort();
65
+ return sid;
66
+ }
67
+ // Wire text handler
68
+ /** Largest PhotoSize → getFile → download → base64 (TG photo intake). */
69
+ async function downloadLargestPhoto(api, sizes) {
70
+ const best = sizes.reduce((a, b) => ((b.width ?? 0) * (b.height ?? 0) > (a.width ?? 0) * (a.height ?? 0) ? b : a));
71
+ if (!best?.file_id)
72
+ throw new Error('photo has no file_id');
73
+ const file = await api.getFile(best.file_id);
74
+ if (!file.file_path)
75
+ throw new Error('getFile returned no file_path');
76
+ const res = await fetch(`https://api.telegram.org/file/bot${cfg.token}/${file.file_path}`);
77
+ if (!res.ok)
78
+ throw new Error(`download failed: ${res.status}`);
79
+ return { data: Buffer.from(await res.arrayBuffer()).toString('base64'), mimeType: 'image/jpeg' };
80
+ }
81
+ bot.use(async (ctx, next) => {
82
+ if (ctx.callbackQuery)
83
+ return next();
84
+ const m = ctx.message;
85
+ if (!m)
86
+ return next();
87
+ // grammY's Message union keeps `text` optional even after narrowing.
88
+ const text = 'text' in m ? m.text : undefined;
89
+ // P2b: photo intake — a photo (caption optional) becomes an image turn;
90
+ // anything else without text (stickers, voice, …) is not ours.
91
+ const photoSizes = !text && 'photo' in m && Array.isArray(m.photo) ? m.photo : undefined;
92
+ if (!text && !photoSizes)
93
+ return next();
94
+ if (text?.startsWith('/'))
95
+ return next();
96
+ let images;
97
+ if (photoSizes?.length) {
98
+ try {
99
+ images = [await downloadLargestPhoto(ctx.api, photoSizes)];
100
+ }
101
+ catch (err) {
102
+ log.warn('photo download failed', err.message);
103
+ void ctx.reply('❌ 图片接收失败,请稍后重试').catch(() => { });
104
+ return;
105
+ }
106
+ }
107
+ // M4 reply-keyboard presses arrive as plain text — route them HERE, before
108
+ // the relay gate (grinev message-router pattern). Otherwise the button text
109
+ // would be forwarded to the model as if the user had typed it.
110
+ // Photo turns (no text) skip button routing — a caption must never press a
111
+ // keyboard button by accident.
112
+ if (text) {
113
+ if (AGENT_BUTTON_TEXT_PATTERN.test(text)) {
114
+ await openAgentsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''));
115
+ return;
116
+ }
117
+ if (MODEL_BUTTON_TEXT_PATTERN.test(text)) {
118
+ await openModelsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''));
119
+ return;
120
+ }
121
+ if (text === '📋 会话') {
122
+ await openSessionsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''), 0);
123
+ return;
124
+ }
125
+ if (CONTEXT_BUTTON_TEXT_PATTERN.test(text)) {
126
+ try {
127
+ // Same data source as the Inspector's CONTEXT panel (backend.getContext):
128
+ // used = last turn's input+output+reasoning+cache; max = model context window.
129
+ const target = targetSessionId();
130
+ if (!target) {
131
+ await ctx.reply('📊 没有活动会话');
132
+ return;
133
+ }
134
+ const meta = await cfg.backend.getContext(target);
135
+ const tokens = (meta.tokens ?? {});
136
+ const used = tokens.used ?? 0;
137
+ const max = tokens.max ?? 0;
138
+ const pct = max > 0 ? Math.round((used / max) * 100) : 0;
139
+ let msg = `📊 上下文用量:${fmtK(used)} / ${fmtK(max)} tokens(${pct}%)`;
140
+ // Prompt-cache line, same semantics as the web Inspector: hit rate =
141
+ // cache.read / (input + cache.read) for the latest turn.
142
+ const cacheRead = tokens.cache?.read;
143
+ const cacheWrite = tokens.cache?.write;
144
+ const uncached = tokens.input;
145
+ if (typeof cacheRead === 'number' && typeof uncached === 'number' && uncached + cacheRead > 0) {
146
+ const hit = Math.round((cacheRead / (uncached + cacheRead)) * 100);
147
+ msg += `\n💾 缓存命中 ${hit}% · ${fmtK(cacheRead)} read / ${fmtK(cacheWrite ?? 0)} write`;
148
+ }
149
+ await ctx.reply(msg);
150
+ }
151
+ catch {
152
+ await ctx.reply('📊 上下文用量暂不可用');
153
+ }
154
+ return;
155
+ }
156
+ }
157
+ if (!messageHandler)
158
+ return next();
159
+ // Prompt-queue (grinev M4 leftover, minimal slice): the relay serializes
160
+ // turns per session, so a mid-generation prompt is queued, not dropped —
161
+ // ack it so the sender isn't left staring at silence.
162
+ if (isGenerating()) {
163
+ void ctx.reply('⏳ 已排队——当前回复生成中,完成后自动执行').catch((err) => {
164
+ log.warn('failed to send queue ack', err.message);
165
+ });
166
+ }
167
+ const msg = {
168
+ userId: String(ctx.from?.id ?? ''),
169
+ chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
170
+ text: text ?? m.caption ?? '',
171
+ messageId: String(m.message_id),
172
+ images,
173
+ origin: 'telegram',
174
+ };
175
+ void messageHandler(msg);
176
+ });
177
+ // ── P2b-M3: grinev interaction mutex + permission flow ──
178
+ const interactionManager = new InteractionManager();
179
+ const permissionManager = new PermissionManager(interactionManager);
180
+ let permissionFlow;
181
+ let questionFlow;
182
+ /** Latest reply-keyboard payload, refreshed by whoever has fresh data. */
183
+ let keyboardData = {
184
+ agentName: 'opencode',
185
+ modelLabel: 'default',
186
+ };
187
+ // Legacy approval state (fallback path + TTL sweep kept from M1).
188
+ const pendingApprovals = new Map();
189
+ // Short token → permissionId, so approve:* callback_data stays under
190
+ // Telegram's 64-byte limit (same sha1 pattern as the workspace tokens).
191
+ const approvalTokens = new Map();
192
+ const approvalToken = (permId) => {
193
+ const t = createHash('sha1').update(`approve:${permId}`).digest('base64url').slice(0, 16);
194
+ approvalTokens.set(t, permId);
195
+ return t;
196
+ };
197
+ // Entries are normally removed on click or on the permission.replied event;
198
+ // if that event is lost the entry would leak forever. Sweep expired ones.
199
+ const APPROVAL_TTL_MS = 30 * 60 * 1000;
200
+ const approvalSweep = setInterval(() => {
201
+ const now = Date.now();
202
+ for (const [permId, p] of pendingApprovals) {
203
+ if (now - p.createdAt > APPROVAL_TTL_MS) {
204
+ pendingApprovals.delete(permId);
205
+ log.warn(`pending approval ${permId} expired (TTL), dropping`);
206
+ }
207
+ }
208
+ // Tokens whose approval is gone (clicked, replied, expired) are dead weight.
209
+ for (const [t, permId] of approvalTokens) {
210
+ if (!pendingApprovals.has(permId))
211
+ approvalTokens.delete(t);
212
+ }
213
+ }, 60_000);
214
+ approvalSweep.unref?.();
215
+ // P2b-M3: grinev permission:* callbacks — the mutex flow owns these.
216
+ bot.callbackQuery(/^permission:(once|always|reject)$/, async (ctx) => {
217
+ const decision = ctx.match[1];
218
+ if (!permissionFlow) {
219
+ await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
220
+ return;
221
+ }
222
+ await permissionFlow.onDecision(ctx, decision).catch((err) => {
223
+ log.error('permission decision failed', err);
224
+ ctx.answerCallbackQuery('处理失败').catch(() => { });
225
+ });
226
+ });
227
+ // ── M10: question-tool callbacks (q:<tok>:o:<idx> / :ok / :rej) ──
228
+ bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):o:(\d+)$/, async (ctx) => {
229
+ const m = ctx.match;
230
+ if (!questionFlow) {
231
+ await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
232
+ return;
233
+ }
234
+ await questionFlow.onOption(ctx, m[1], parseInt(m[2], 10)).catch((err) => {
235
+ log.error('question option failed', err);
236
+ ctx.answerCallbackQuery('处理失败').catch(() => { });
237
+ });
238
+ });
239
+ bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):ok$/, async (ctx) => {
240
+ if (!questionFlow) {
241
+ await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
242
+ return;
243
+ }
244
+ await questionFlow.onSubmit(ctx, ctx.match[1]).catch((err) => {
245
+ log.error('question submit failed', err);
246
+ ctx.answerCallbackQuery('处理失败').catch(() => { });
247
+ });
248
+ });
249
+ bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):rej$/, async (ctx) => {
250
+ if (!questionFlow) {
251
+ await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
252
+ return;
253
+ }
254
+ await questionFlow.onReject(ctx, ctx.match[1]).catch((err) => {
255
+ log.error('question reject failed', err);
256
+ ctx.answerCallbackQuery('处理失败').catch(() => { });
257
+ });
258
+ });
259
+ // ── M4: inline menus (sessions paging / agents / models) ──
260
+ let sessionsCache = [];
261
+ let activeMenuMessageId;
262
+ bot.callbackQuery(/^menu:spage:(\d+)$/, async (ctx) => {
263
+ const p = Number(ctx.match[1]);
264
+ const { text, keyboard } = renderSessionsMenu(sessionsCache, p);
265
+ await editMenu(bot.api, ctx.chat?.id ?? chatIdOf(), ctx.callbackQuery.message?.message_id, text, keyboard);
266
+ await ctx.answerCallbackQuery();
267
+ });
268
+ bot.callbackQuery(/^menu:session:(.+)$/, async (ctx) => {
269
+ const sid = ctx.match[1];
270
+ cfg.state.setPinnedSessionId(sid);
271
+ await ctx.answerCallbackQuery(`已切换:…${sid.slice(-8)}`);
272
+ try {
273
+ await ctx.editMessageText(`📍 已切换会话 <code>…${sid.slice(-8)}</code>`, { parse_mode: 'HTML' });
274
+ }
275
+ catch { }
276
+ });
277
+ bot.callbackQuery(/^menu:agent:(.+)$/, async (ctx) => {
278
+ const name = ctx.match[1];
279
+ cfg.state.setNextAgent(name);
280
+ await ctx.answerCallbackQuery(`Agent → ${name}`);
281
+ try {
282
+ await ctx.editMessageText(`🤖 Agent 覆盖:<b>${name}</b>`, { parse_mode: 'HTML' });
283
+ }
284
+ catch { }
285
+ });
286
+ bot.callbackQuery(/^menu:model:([^:]+):(.+)$/, async (ctx) => {
287
+ const providerID = ctx.match[1];
288
+ const modelID = ctx.match[2];
289
+ cfg.state.setNextModel({ providerID, modelID });
290
+ await ctx.answerCallbackQuery(`模型 → ${providerID}/${modelID}`);
291
+ try {
292
+ await ctx.editMessageText(`🧠 模型覆盖:<b>${providerID}/${modelID}</b>`, { parse_mode: 'HTML' });
293
+ }
294
+ catch { }
295
+ });
296
+ bot.callbackQuery('menu:agentclear', async (ctx) => {
297
+ cfg.state.setNextAgent(undefined);
298
+ await ctx.answerCallbackQuery('已清除 agent 覆盖');
299
+ try {
300
+ await ctx.editMessageText('Agent 覆盖已清除', { parse_mode: 'HTML' });
301
+ }
302
+ catch { }
303
+ });
304
+ bot.callbackQuery('menu:noop', async (ctx) => { await ctx.answerCallbackQuery(); });
305
+ // Web C4 parity: two-step session delete from the sessions menu.
306
+ bot.callbackQuery(/^menu:sdel:(.+)$/, async (ctx) => {
307
+ const sid = ctx.match[1];
308
+ const kb = new InlineKeyboard()
309
+ .text('🗑 确认删除', `menu:sdelok:${sid}`)
310
+ .text('取消', 'menu:spage:0');
311
+ await ctx.answerCallbackQuery();
312
+ try {
313
+ await ctx.editMessageText(`⚠️ 删除会话 …${sid.slice(-8)}?其全部消息将不可恢复。`, { parse_mode: 'HTML', reply_markup: kb });
314
+ }
315
+ catch { }
316
+ });
317
+ bot.callbackQuery(/^menu:sdelok:(.+)$/, async (ctx) => {
318
+ const sid = ctx.match[1];
319
+ try {
320
+ await cfg.backend.deleteSession(sid);
321
+ if (cfg.state.getPinnedSessionId() === sid)
322
+ cfg.state.setPinnedSessionId(undefined);
323
+ await ctx.answerCallbackQuery('🗑 已删除');
324
+ try {
325
+ await ctx.editMessageText('🗑 会话已删除。');
326
+ }
327
+ catch { }
328
+ await openSessionsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''), 0);
329
+ }
330
+ catch (err) {
331
+ await ctx.answerCallbackQuery(`删除失败:${err.message.slice(0, 60)}`);
332
+ }
333
+ });
334
+ // ── Regenerate (web C3 parity): re-send the last user message of the session.
335
+ bot.callbackQuery(/^retry:(.+)$/, async (ctx) => {
336
+ const sid = ctx.match[1];
337
+ try {
338
+ const cards = await cfg.backend.getHistory(sid);
339
+ const lastUser = [...cards].reverse().find((c) => c.kind === 'user');
340
+ const text = lastUser?.text ?? '';
341
+ if (!text.trim()) {
342
+ await ctx.answerCallbackQuery('没有可重发的内容');
343
+ return;
344
+ }
345
+ await messageHandler?.({
346
+ userId: String(ctx.from?.id ?? ''),
347
+ chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
348
+ text,
349
+ messageId: `retry_${Date.now()}`,
350
+ origin: 'telegram',
351
+ });
352
+ await ctx.answerCallbackQuery('↻ 已重发上一条');
353
+ }
354
+ catch (err) {
355
+ await ctx.answerCallbackQuery(`重发失败:${err.message.slice(0, 80)}`);
356
+ }
357
+ });
358
+ // Suggestion chips (web C2 parity): tap = send directly (no draft box on TG).
359
+ bot.callbackQuery(/^sug:(\d+)$/, async (ctx) => {
360
+ const text = sugTokens.get(Number(ctx.match[1]));
361
+ if (!text) {
362
+ await ctx.answerCallbackQuery('该建议已过期');
363
+ return;
364
+ }
365
+ await messageHandler?.({
366
+ userId: String(ctx.from?.id ?? ''),
367
+ chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
368
+ text,
369
+ messageId: `sug_${Date.now()}`,
370
+ origin: 'telegram',
371
+ });
372
+ await ctx.answerCallbackQuery('已发送');
373
+ });
374
+ /** Open the agents menu as an editable message. */
375
+ async function openAgentsMenu(chatId) {
376
+ try {
377
+ const agents = await cfg.backend.getAgents(cfg.opencodeProject);
378
+ const current = cfg.state.getNextAgent();
379
+ const { text, keyboard } = renderAgentsMenu(agents.map(a => ({ name: a.name, model: a.model })), current);
380
+ const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
381
+ activeMenuMessageId = sent.message_id;
382
+ }
383
+ catch (err) {
384
+ log.warn('openAgentsMenu failed', err.message);
385
+ }
386
+ }
387
+ /** Open the models menu as an editable message. */
388
+ async function openModelsMenu(chatId) {
389
+ try {
390
+ const providers = await cfg.backend.getModels(cfg.opencodeProject);
391
+ const { text, keyboard } = renderModelsMenu(providers);
392
+ const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
393
+ activeMenuMessageId = sent.message_id;
394
+ }
395
+ catch (err) {
396
+ log.warn('openModelsMenu failed', err.message);
397
+ }
398
+ }
399
+ /** Open the paginated sessions menu. */
400
+ async function openSessionsMenu(chatId, page) {
401
+ try {
402
+ sessionsCache = (await cfg.backend.listSessionSummaries()).map(s => ({
403
+ id: s.id, title: s.title, directory: s.directory, lastActiveAt: s.lastActiveAt,
404
+ }));
405
+ const active = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
406
+ const { text, keyboard } = renderSessionsMenu(sessionsCache, page, active);
407
+ const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
408
+ activeMenuMessageId = sent.message_id;
409
+ }
410
+ catch (err) {
411
+ log.warn('openSessionsMenu failed', err.message);
412
+ }
413
+ }
414
+ const chatIdOf = () => String(cfg.allowedUserIds[0]);
415
+ // ── M4: reply keyboard + hears routers (grinev signature UX) ──
416
+ const sendMainKeyboard = async (ctx) => {
417
+ await ctx.reply('键盘已更新', { reply_markup: buildMainKeyboard(keyboardData) }).catch((err) => {
418
+ log.warn('send keyboard failed', err.message);
419
+ });
420
+ };
421
+ const refreshKeyboardData = async () => {
422
+ try {
423
+ const target = targetSessionId();
424
+ const meta = target ? await cfg.backend.getContext(target) : undefined;
425
+ const agents = cfg.backend.capabilities.catalog ? await cfg.backend.getAgents(cfg.opencodeProject).catch(() => []) : [];
426
+ keyboardData = {
427
+ agentName: meta?.agent ?? keyboardData.agentName,
428
+ modelLabel: meta?.model ?? keyboardData.modelLabel,
429
+ context: meta?.tokens?.used != null && meta?.tokens?.max
430
+ ? { used: (meta?.tokens).used, limit: (meta?.tokens).max }
431
+ : keyboardData.context,
432
+ };
433
+ void agents;
434
+ }
435
+ catch { /* keep previous */ }
436
+ };
437
+ // Button presses arrive as ordinary text messages — match by pattern.
438
+ // Register commands + callbacks
439
+ registerHandlers({
440
+ bot,
441
+ backend: cfg.backend,
442
+ baseUrl: cfg.baseUrl ?? '',
443
+ state: cfg.state,
444
+ isGenerating,
445
+ abortGeneration,
446
+ pendingApprovals,
447
+ approvalTokens,
448
+ opencodeProject: cfg.opencodeProject,
449
+ scheduler: cfg.scheduler,
450
+ channels: cfg.channels,
451
+ });
452
+ // Error catch-all — grammY wraps handler errors in BotError (err.error).
453
+ bot.catch((err) => {
454
+ const e = err.error ?? err;
455
+ log.error('grammY catch-all', e);
456
+ bot.api.sendMessage(String(cfg.allowedUserIds[0]), `Internal error: ${e.message}`).catch(() => { });
457
+ });
458
+ // Per-session renderers. The StreamingRenderer owns thinking/streaming/
459
+ // assistant/error cards (grinev pipeline: live-edited stream message with
460
+ // progressive throttling); the legacy renderer stays for user/info echoes.
461
+ const renderers = new Map();
462
+ let streamingRenderer;
463
+ function getRenderer(sessionId, chatId) {
464
+ let r = renderers.get(sessionId);
465
+ if (!r) {
466
+ r = new TelegramSessionRenderer({ chatId, sessionId, bot: bot.api, chunkSoftLimit: cfg.tgChunkSoftLimit });
467
+ renderers.set(sessionId, r);
468
+ }
469
+ return r;
470
+ }
471
+ function labelFor(response) {
472
+ switch (response) {
473
+ case 'once': return 'Allowed (once)';
474
+ case 'always': return 'Always Allowed';
475
+ case 'reject': return 'Rejected';
476
+ default: return response ?? 'Handled';
477
+ }
478
+ }
479
+ let cardBusRef;
480
+ return {
481
+ name: 'telegram',
482
+ capabilities: CAPS,
483
+ async start(deps) {
484
+ const { cardBus } = deps;
485
+ cardBusRef = cardBus;
486
+ const chatId = String(cfg.allowedUserIds[0]);
487
+ streamingRenderer = new StreamingRenderer({ api: bot.api, chatId, granularity: () => cfg.channels?.()?.replyGranularity ?? 'detailed' });
488
+ permissionFlow = new PermissionFlow({
489
+ interactionManager,
490
+ permissionManager,
491
+ resolve: async (requestIds, decision) => {
492
+ // All grouped requests share one signature → one session; take the
493
+ // session from the pressed message's request when available.
494
+ for (const requestId of requestIds) {
495
+ const sid = targetSessionId();
496
+ if (!sid)
497
+ continue;
498
+ await cfg.backend.resolvePermission(sid, requestId, decision).catch((err) => {
499
+ log.warn(`resolvePermission failed for ${requestId}`, err.message);
500
+ });
501
+ }
502
+ },
503
+ sessionIdOf: (r) => r.sessionID,
504
+ }, chatId);
505
+ questionFlow = new QuestionFlow({
506
+ interactionManager,
507
+ answer: async (sid, requestId, answers) => await cfg.backend.answerQuestion?.(sid, requestId, answers),
508
+ reject: async (sid, requestId) => await cfg.backend.rejectQuestion?.(sid, requestId),
509
+ }, chatId);
510
+ // Tier2 suggestion chips (web C2 parity): the relay generates them
511
+ // fire-and-forget AFTER finalize and parks them in state — poll twice,
512
+ // then send up to 3 as an inline keyboard. Tapping a chip SENDS it
513
+ // (Telegram has no draft box — the web "fill, don't send" semantics
514
+ // doesn't exist here; user-approved deviation).
515
+ const suggestionTimers = new Map();
516
+ let sugSeq = 0;
517
+ function scheduleSuggestions(sid) {
518
+ const prev = suggestionTimers.get(sid);
519
+ if (prev)
520
+ prev.forEach(clearTimeout);
521
+ const timers = [3_000, 9_000].map((ms) => setTimeout(async () => {
522
+ const items = cfg.state.getSessionSuggestions?.(sid) ?? [];
523
+ if (items.length === 0 || suggestionTimers.get(sid) !== timers)
524
+ return;
525
+ suggestionTimers.delete(sid);
526
+ const kb = new InlineKeyboard();
527
+ for (const item of items.slice(0, 3)) {
528
+ const tok = ++sugSeq;
529
+ sugTokens.set(tok, item);
530
+ kb.text(`💡 ${item.slice(0, 64)}`, `sug:${tok}`).row();
531
+ }
532
+ await bot.api
533
+ .sendMessage(chatId, '💡 建议下一步(点按直接发送)', { reply_markup: kb })
534
+ .catch((err) => log.warn('suggestions send failed', err.message));
535
+ }, ms));
536
+ suggestionTimers.set(sid, timers);
537
+ }
538
+ cardBus.subscribeAll((card) => {
539
+ if (!('sessionId' in card) || !card.sessionId)
540
+ return;
541
+ log.info(`[telegram] card received: kind=${card.kind} sessionId=${card.sessionId}`);
542
+ const sr = streamingRenderer;
543
+ if (sr && (card.kind === 'streaming' || card.kind === 'thinking' || card.kind === 'assistant' || card.kind === 'error')) {
544
+ // P2b-M2: grinev streaming pipeline owns the turn lifecycle.
545
+ void (async () => {
546
+ try {
547
+ if (card.kind === 'thinking')
548
+ return; // the stream message is the placeholder
549
+ if (card.kind === 'streaming') {
550
+ const text = card.blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
551
+ sr.onStreaming(card.sessionId, card.messageId, text);
552
+ return;
553
+ }
554
+ if (card.kind === 'assistant') {
555
+ // P2c parity: regenerate action bar on the final message…
556
+ const actions = isEphemeralSession(card.sessionId)
557
+ ? undefined
558
+ : { text: '⌨️ 操作', keyboard: new InlineKeyboard().text('↻ 重发上一条', `retry:${card.sessionId}`) };
559
+ await sr.onFinalize(card.sessionId, card.blocks, card.meta, actions);
560
+ // …and Tier2 suggestion chips as a follow-up message (they are
561
+ // generated fire-and-forget after finalize — poll briefly).
562
+ if (!isEphemeralSession(card.sessionId))
563
+ scheduleSuggestions(card.sessionId);
564
+ return;
565
+ }
566
+ if (card.kind === 'error') {
567
+ await sr.onError(card.sessionId, card.message ?? 'error');
568
+ return;
569
+ }
570
+ }
571
+ catch (err) {
572
+ log.error(`[telegram] streaming onCard failed for ${card.kind}`, err);
573
+ }
574
+ })();
575
+ if (card.kind === 'assistant' || card.kind === 'error')
576
+ renderers.delete(card.sessionId);
577
+ // grinev UX 对照表:后台会话完成通知 —— a turn finished in a session
578
+ // other than the TG-target one gets a heads-up with a switch button.
579
+ if (card.kind === 'assistant' && permissionFlow) {
580
+ const target = targetSessionId();
581
+ if (target && card.sessionId !== target && !isEphemeralSession(card.sessionId)) {
582
+ const bsid = card.sessionId;
583
+ const bk = new InlineKeyboard().text('📥 打开该会话', `menu:session:${bsid}`);
584
+ void bot.api
585
+ .sendMessage(chatId, `ℹ️ 后台会话已完成:…${bsid.slice(-8)}`, { reply_markup: bk })
586
+ .catch((err) => log.warn('background notice failed', err.message));
587
+ }
588
+ }
589
+ return;
590
+ }
591
+ const r = getRenderer(card.sessionId, chatId);
592
+ r.onCard(card).catch((err) => {
593
+ log.error(`[telegram] onCard failed for ${card.kind}`, err);
594
+ });
595
+ if (card.kind === 'assistant' || card.kind === 'error') {
596
+ renderers.delete(card.sessionId);
597
+ }
598
+ });
599
+ // Deliver the persistent reply keyboard once per boot.
600
+ await refreshKeyboardData().catch(() => { });
601
+ await bot.api
602
+ .sendMessage(chatId, 'ocrc 已就绪', { reply_markup: buildMainKeyboard(keyboardData) })
603
+ .catch((err) => log.warn('boot keyboard send failed', err.message));
604
+ const MAX_CONFLICT = 8;
605
+ const MAX_RETRIES = 10;
606
+ while (true) {
607
+ let attempt = 0;
608
+ let conflictCount = 0;
609
+ for (let retryCount = 0; retryCount < MAX_RETRIES; retryCount++) {
610
+ try {
611
+ await bot.start({ onStart: (me) => { pollingLive = true; log.info(`bot polling as @${me.username}`); } });
612
+ log.info('bot polling ended cleanly');
613
+ pollingLive = false;
614
+ return;
615
+ }
616
+ catch (err) {
617
+ const code = errorCodeOf(err);
618
+ if (code === 409) {
619
+ if (++conflictCount >= MAX_CONFLICT) {
620
+ log.error('FATAL: Telegram 409 conflict persisted — another instance is polling this bot token; transport stopped');
621
+ throw new Error('Telegram 409 persisted');
622
+ }
623
+ log.warn(`409 #${conflictCount}, releasing stale lock and retrying`);
624
+ try {
625
+ await bot.stop();
626
+ }
627
+ catch { /* bot may not have fully started */ }
628
+ try {
629
+ await bot.api.raw.getUpdates({ offset: -1, timeout: 0, limit: 1 });
630
+ }
631
+ catch { /* ignore — if this fails the next launch will tell us */ }
632
+ await new Promise((r) => setTimeout(r, 5000));
633
+ }
634
+ else if (code === 401) {
635
+ log.error('FATAL: bot token invalid or revoked (401) — Telegram transport stopped; fix the token and restart');
636
+ return;
637
+ }
638
+ else {
639
+ attempt += 1;
640
+ const delay = Math.min(1000 * 2 ** attempt, 30000);
641
+ log.error(`bot.start failed (attempt ${attempt}/${MAX_RETRIES})`, err?.message ?? err);
642
+ await new Promise((r) => setTimeout(r, delay));
643
+ }
644
+ }
645
+ }
646
+ log.warn(`bot.start: exhausted ${MAX_RETRIES} retries, restarting in 60s`);
647
+ await new Promise((r) => setTimeout(r, 60_000));
648
+ }
649
+ },
650
+ async stop() {
651
+ pollingLive = false;
652
+ await bot.stop();
653
+ clearInterval(approvalSweep);
654
+ },
655
+ status() {
656
+ return { connected: pollingLive, username: bot.botInfo?.username };
657
+ },
658
+ async send(_chatId, _card) {
659
+ throw new Error('Transport.send not implemented for Telegram in v0.5.0');
660
+ },
661
+ onMessage(h) { messageHandler = h; },
662
+ onCommand(_name, _h) { },
663
+ onButtonClick(_h) { },
664
+ /** Plugin mode: handle permission events from the opencode event hook. */
665
+ async handlePluginPermissionEvent(event) {
666
+ const props = event.properties ?? {};
667
+ if (event.type === 'permission.updated' || event.type === 'permission.asked') {
668
+ const permId = props.id;
669
+ const title = props.title ?? props.permission ?? 'Unknown operation';
670
+ const sessionId = props.sessionID;
671
+ if (!permId || !sessionId) {
672
+ log.warn(`Plugin permission: missing id or sessionID`, props);
673
+ return;
674
+ }
675
+ // P2b-M3: grinev mutex flow (merge + slot + generation) replaces the
676
+ // plain three-button card. The legacy pendingApprovals/token maps stay
677
+ // wired for the legacy approve:* callbacks (kept for fallback).
678
+ const fullSid = cfg.state.normalizeSessionId(sessionId);
679
+ const request = {
680
+ id: permId,
681
+ sessionID: fullSid,
682
+ permission: props.permission ?? title,
683
+ patterns: props.patterns ?? [],
684
+ metadata: props.args ?? {},
685
+ always: [],
686
+ };
687
+ if (permissionFlow) {
688
+ await permissionFlow.present(bot.api, request);
689
+ }
690
+ else {
691
+ const escaped = title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
692
+ const text = `⚠️ <b>Permission Required</b>\n\n<code>${escaped}</code>`;
693
+ const token = approvalToken(permId);
694
+ const keyboard = {
695
+ ...inlineKeyboard([
696
+ [
697
+ btn('✅ Once', `approve:once:${token}`),
698
+ btn('🔓 Always', `approve:always:${token}`),
699
+ btn('❌ Reject', `approve:reject:${token}`),
700
+ ],
701
+ ]),
702
+ parse_mode: 'HTML',
703
+ };
704
+ try {
705
+ const msg = await bot.api.sendMessage(String(cfg.allowedUserIds[0]), text, keyboard);
706
+ pendingApprovals.set(permId, {
707
+ sessionId,
708
+ permissionId: permId,
709
+ messageId: msg.message_id,
710
+ title,
711
+ createdAt: Date.now(),
712
+ });
713
+ log.info(`[plugin] approval card sent permId=${permId}`);
714
+ }
715
+ catch (err) {
716
+ log.error('[plugin] failed to send approval card', err);
717
+ }
718
+ }
719
+ // Publish to CardBus so Web UI can also show the approval modal
720
+ try {
721
+ cardBusRef?.publish({
722
+ kind: 'approval',
723
+ sessionId: cfg.state.normalizeSessionId(sessionId),
724
+ title,
725
+ args: props.args ?? props.permission ?? {},
726
+ requestId: permId,
727
+ });
728
+ }
729
+ catch (err) {
730
+ log.warn('[plugin] failed to publish approval card', err);
731
+ }
732
+ }
733
+ if (event.type === 'permission.replied') {
734
+ const permId = props.permissionID ?? props.requestID;
735
+ const response = props.response ?? props.reply;
736
+ if (!permId)
737
+ return;
738
+ // P2b-M3: 外部已答清理 via the mutex flow when it owns the prompt.
739
+ if (permissionFlow) {
740
+ await permissionFlow.onExternalReply(bot.api, permId, response).catch((err) => {
741
+ log.warn('external permission cleanup failed', err.message);
742
+ });
743
+ }
744
+ const p = pendingApprovals.get(permId);
745
+ if (!p)
746
+ return;
747
+ pendingApprovals.delete(permId);
748
+ try {
749
+ const display = labelFor(response);
750
+ await bot.api.editMessageText(String(cfg.allowedUserIds[0]), p.messageId, `${display} (from TUI)\n\n${esc(p.title)}`, { parse_mode: 'HTML' });
751
+ }
752
+ catch (err) {
753
+ log.warn(`[plugin] couldn't update card after TUI reply: ${err.message}`);
754
+ }
755
+ }
756
+ },
757
+ /** M10: handle question events from the opencode event hook. */
758
+ async handlePluginQuestionEvent(event) {
759
+ const props = event.properties ?? {};
760
+ // The web question card is published by the plugin entry (single source);
761
+ // this method only drives the Telegram side.
762
+ if (event.type === 'question.asked') {
763
+ const requestId = props.id;
764
+ const sessionId = props.sessionID;
765
+ if (!requestId || !sessionId || !questionFlow)
766
+ return;
767
+ const questions = (Array.isArray(props.questions) ? props.questions : []).map((q) => ({
768
+ question: String(q?.question ?? ''),
769
+ header: typeof q?.header === 'string' && q.header ? q.header : undefined,
770
+ multiple: q?.multiple === true,
771
+ custom: q?.custom === true,
772
+ options: (Array.isArray(q?.options) ? q.options : [])
773
+ .filter((o) => typeof o?.label === 'string' && o.label)
774
+ .map((o) => ({ label: String(o.label), description: typeof o?.description === 'string' ? o.description : undefined })),
775
+ }));
776
+ await questionFlow.present(bot.api, {
777
+ requestId,
778
+ sessionId: cfg.state.normalizeSessionId(sessionId),
779
+ questions,
780
+ }).catch((err) => log.error('question present failed', err));
781
+ return;
782
+ }
783
+ if (event.type === 'question.replied' || event.type === 'question.rejected') {
784
+ const requestId = props.requestID;
785
+ if (!requestId || !questionFlow)
786
+ return;
787
+ await questionFlow.onExternal(bot.api, requestId, event.type === 'question.replied' ? 'replied' : 'rejected').catch((err) => log.warn('external question cleanup failed', err.message));
788
+ }
789
+ },
790
+ };
791
+ }
792
+ //# sourceMappingURL=index.js.map