@moxxy/plugin-telegram 0.27.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +37 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +75 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/channel/approval-prompt.d.ts +20 -0
  7. package/dist/channel/approval-prompt.d.ts.map +1 -0
  8. package/dist/channel/approval-prompt.js +34 -0
  9. package/dist/channel/approval-prompt.js.map +1 -0
  10. package/dist/channel/callback-handler.d.ts +32 -0
  11. package/dist/channel/callback-handler.d.ts.map +1 -0
  12. package/dist/channel/callback-handler.js +212 -0
  13. package/dist/channel/callback-handler.js.map +1 -0
  14. package/dist/channel/frame-pump.d.ts +58 -0
  15. package/dist/channel/frame-pump.d.ts.map +1 -0
  16. package/dist/channel/frame-pump.js +159 -0
  17. package/dist/channel/frame-pump.js.map +1 -0
  18. package/dist/channel/html.d.ts +24 -0
  19. package/dist/channel/html.d.ts.map +1 -0
  20. package/dist/channel/html.js +62 -0
  21. package/dist/channel/html.js.map +1 -0
  22. package/dist/channel/pairing-handler.d.ts +66 -0
  23. package/dist/channel/pairing-handler.d.ts.map +1 -0
  24. package/dist/channel/pairing-handler.js +147 -0
  25. package/dist/channel/pairing-handler.js.map +1 -0
  26. package/dist/channel/permission-prompt.d.ts +21 -0
  27. package/dist/channel/permission-prompt.d.ts.map +1 -0
  28. package/dist/channel/permission-prompt.js +36 -0
  29. package/dist/channel/permission-prompt.js.map +1 -0
  30. package/dist/channel/slash-handler.d.ts +47 -0
  31. package/dist/channel/slash-handler.d.ts.map +1 -0
  32. package/dist/channel/slash-handler.js +214 -0
  33. package/dist/channel/slash-handler.js.map +1 -0
  34. package/dist/channel/text-handler.d.ts +43 -0
  35. package/dist/channel/text-handler.d.ts.map +1 -0
  36. package/dist/channel/text-handler.js +116 -0
  37. package/dist/channel/text-handler.js.map +1 -0
  38. package/dist/channel/turn-runner.d.ts +40 -0
  39. package/dist/channel/turn-runner.d.ts.map +1 -0
  40. package/dist/channel/turn-runner.js +67 -0
  41. package/dist/channel/turn-runner.js.map +1 -0
  42. package/dist/channel/typing-indicator.d.ts +14 -0
  43. package/dist/channel/typing-indicator.d.ts.map +1 -0
  44. package/dist/channel/typing-indicator.js +29 -0
  45. package/dist/channel/typing-indicator.js.map +1 -0
  46. package/dist/channel/voice-handler.d.ts +42 -0
  47. package/dist/channel/voice-handler.d.ts.map +1 -0
  48. package/dist/channel/voice-handler.js +138 -0
  49. package/dist/channel/voice-handler.js.map +1 -0
  50. package/dist/channel.d.ts +118 -0
  51. package/dist/channel.d.ts.map +1 -0
  52. package/dist/channel.js +478 -0
  53. package/dist/channel.js.map +1 -0
  54. package/dist/channel.test-d.d.ts +2 -0
  55. package/dist/channel.test-d.d.ts.map +1 -0
  56. package/dist/channel.test-d.js +5 -0
  57. package/dist/channel.test-d.js.map +1 -0
  58. package/dist/format.d.ts +38 -0
  59. package/dist/format.d.ts.map +1 -0
  60. package/dist/format.js +237 -0
  61. package/dist/format.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +304 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +37 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +42 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +18 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +119 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +90 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +97 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +25 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +49 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +68 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +439 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/setup-wizard.d.ts +21 -0
  87. package/dist/setup-wizard.d.ts.map +1 -0
  88. package/dist/setup-wizard.js +140 -0
  89. package/dist/setup-wizard.js.map +1 -0
  90. package/package.json +91 -0
  91. package/src/approval.test.ts +103 -0
  92. package/src/approval.ts +86 -0
  93. package/src/channel/approval-prompt.ts +46 -0
  94. package/src/channel/callback-handler.test.ts +188 -0
  95. package/src/channel/callback-handler.ts +256 -0
  96. package/src/channel/frame-pump.ts +176 -0
  97. package/src/channel/html.test.ts +40 -0
  98. package/src/channel/html.ts +60 -0
  99. package/src/channel/pairing-handler.ts +181 -0
  100. package/src/channel/permission-prompt.ts +51 -0
  101. package/src/channel/slash-handler.ts +259 -0
  102. package/src/channel/text-handler.test.ts +222 -0
  103. package/src/channel/text-handler.ts +174 -0
  104. package/src/channel/turn-runner.ts +102 -0
  105. package/src/channel/typing-indicator.ts +31 -0
  106. package/src/channel/voice-handler.test.ts +328 -0
  107. package/src/channel/voice-handler.ts +198 -0
  108. package/src/channel/voice-reply.test.ts +223 -0
  109. package/src/channel.test-d.ts +7 -0
  110. package/src/channel.ts +564 -0
  111. package/src/format.test.ts +159 -0
  112. package/src/format.ts +257 -0
  113. package/src/index.ts +347 -0
  114. package/src/keys.ts +52 -0
  115. package/src/pair-flow.ts +131 -0
  116. package/src/pairing.test.ts +113 -0
  117. package/src/pairing.ts +184 -0
  118. package/src/permission.test.ts +56 -0
  119. package/src/permission.ts +61 -0
  120. package/src/render.test.ts +350 -0
  121. package/src/render.ts +522 -0
  122. package/src/setup-wizard.ts +178 -0
  123. package/src/subcommands.test.ts +153 -0
package/src/render.ts ADDED
@@ -0,0 +1,522 @@
1
+ import type { MoxxyEvent } from '@moxxy/sdk';
2
+ import {
3
+ fileDiffSummary,
4
+ fileDiffVerb,
5
+ isFileDiffDisplay,
6
+ type FileDiffDisplay,
7
+ } from '@moxxy/sdk';
8
+
9
+ const TELEGRAM_MESSAGE_LIMIT = 4000; // 4096 minus a safety margin
10
+
11
+ /**
12
+ * Minimum activity-block line count before the final frame folds it into an
13
+ * expandable box. Short traces (a call or two) stay inline; busy turns collapse
14
+ * so the answer isn't buried under the tool log.
15
+ */
16
+ const COLLAPSE_ACTIVITY_LINES = 4;
17
+
18
+ export interface RenderedFrame {
19
+ /** Pre-formatted HTML activity block (blockquote of tool calls). */
20
+ readonly activityHtml: string;
21
+ /** Assistant body in original markdown — caller converts to Telegram HTML. */
22
+ readonly body: string;
23
+ /** Pre-formatted HTML error line. */
24
+ readonly errorHtml: string;
25
+ /**
26
+ * Pre-formatted HTML diff block(s) for any file-diff tool results in
27
+ * this turn. Each is a summary line plus a `<pre><code
28
+ * class="language-diff">…</code></pre>` fence; Telegram highlights the
29
+ * `+`/`-` lines for the `diff` language. Empty string when no file was
30
+ * edited. Composed after the body — see `composeFrame`.
31
+ */
32
+ readonly diffHtml: string;
33
+ /** True if any of the above changed since the previous frame. */
34
+ readonly hasUpdate: boolean;
35
+ }
36
+
37
+ /**
38
+ * Accumulates events from a turn into a single string ready for
39
+ * Telegram. Output goes through `markdownToTelegramHtml` in the
40
+ * channel before being sent, so anything we emit here that LOOKS like
41
+ * markdown (`**bold**`, `` `code` ``, list bullets) ends up as proper
42
+ * Telegram-rendered formatting in the chat.
43
+ *
44
+ * Visual structure of a frame:
45
+ *
46
+ * ┌─ tool / skill activity (header, dim) ─┐
47
+ * │ • tool_name (args) ✓ 0.4s │
48
+ * │ • another_tool (args) running… │
49
+ * └───────────────────────────────────────┘
50
+ *
51
+ * <assistant body>
52
+ *
53
+ * <error if any>
54
+ *
55
+ * Bot rate limits favor edits over many separate sends, so the whole
56
+ * turn collapses into one editable message until it overflows.
57
+ */
58
+ type ToolStatus =
59
+ | { kind: 'pending'; startedAt: number }
60
+ | { kind: 'ok'; ms: number }
61
+ | { kind: 'err'; ms: number; message: string }
62
+ | { kind: 'denied'; reason: string };
63
+
64
+ interface ToolEntry {
65
+ readonly id: string;
66
+ readonly name: string;
67
+ readonly inputPreview: string;
68
+ status: ToolStatus;
69
+ }
70
+
71
+ interface SkillEntry {
72
+ readonly name: string;
73
+ readonly toolCount: number;
74
+ }
75
+
76
+ export class TurnRenderer {
77
+ private chunks: string[] = [];
78
+ /** In-flight + completed tool calls keyed by callId, in arrival order. */
79
+ private tools = new Map<string, ToolEntry>();
80
+ private skillBanner: SkillEntry | null = null;
81
+ private notices: string[] = [];
82
+ private finalAssistant: string | null = null;
83
+ private errorLine: string | null = null;
84
+ /** Pre-formatted HTML diff blocks, one per file-diff tool result, in arrival order. */
85
+ private diffBlocks: string[] = [];
86
+ private lastFrame = '';
87
+
88
+ accept(event: MoxxyEvent): RenderedFrame {
89
+ switch (event.type) {
90
+ case 'assistant_chunk':
91
+ this.chunks.push(event.delta);
92
+ break;
93
+ case 'assistant_message':
94
+ this.finalAssistant = event.content;
95
+ this.chunks = [];
96
+ break;
97
+ case 'tool_call_requested': {
98
+ const callId = String(event.callId);
99
+ this.tools.set(callId, {
100
+ id: callId,
101
+ name: event.name,
102
+ inputPreview: previewArgs(event.input),
103
+ status: { kind: 'pending', startedAt: Date.now() },
104
+ });
105
+ break;
106
+ }
107
+ case 'tool_call_denied': {
108
+ const entry = this.tools.get(String(event.callId));
109
+ if (entry) entry.status = { kind: 'denied', reason: event.reason };
110
+ break;
111
+ }
112
+ case 'tool_result': {
113
+ const entry = this.tools.get(String(event.callId));
114
+ if (!entry) break;
115
+ const started =
116
+ entry.status.kind === 'pending' ? entry.status.startedAt : Date.now();
117
+ const ms = Date.now() - started;
118
+ if (event.ok) {
119
+ entry.status = { kind: 'ok', ms };
120
+ // Write/Edit tools return { forModel, display: FileDiffDisplay }.
121
+ // Render the structured diff as a fenced ```diff block so
122
+ // Telegram highlights +/- lines, plus a summary line.
123
+ const display = (event.output as { display?: unknown } | undefined)?.display;
124
+ if (isFileDiffDisplay(display)) {
125
+ this.diffBlocks.push(renderFileDiff(display));
126
+ }
127
+ } else {
128
+ entry.status = {
129
+ kind: 'err',
130
+ ms,
131
+ message: `${event.error?.kind ?? 'error'}: ${event.error?.message ?? ''}`,
132
+ };
133
+ }
134
+ break;
135
+ }
136
+ case 'skill_invoked':
137
+ // skill_invoked fires when a skill scope opens; its tool calls
138
+ // arrive immediately after as tool_call_requested. Show a
139
+ // banner so the user knows the bot is now in a skill scope.
140
+ this.skillBanner = { name: event.name, toolCount: 0 };
141
+ break;
142
+ case 'skill_created':
143
+ this.notices.push(`💡 created skill: <b>${escape(event.name)}</b>`);
144
+ break;
145
+ case 'plugin_event':
146
+ if (event.pluginId === '@moxxy/subagents' && event.subtype === 'subagent_started') {
147
+ const payload = (event.payload ?? {}) as Record<string, unknown>;
148
+ const label = typeof payload.label === 'string' ? payload.label : 'agent';
149
+ this.notices.push(`🤖 spawned agent <b>${escape(label)}</b>`);
150
+ }
151
+ break;
152
+ case 'error':
153
+ this.errorLine = `❗ <b>${escape(event.kind)}</b>: ${escape(event.message)}`;
154
+ break;
155
+ default:
156
+ break;
157
+ }
158
+ const frame = this.snapshot();
159
+ const key = `${frame.activityHtml}\u0000${frame.body}\u0000${frame.diffHtml}\u0000${frame.errorHtml}`;
160
+ const hasUpdate = key !== this.lastFrame;
161
+ this.lastFrame = key;
162
+ return { ...frame, hasUpdate };
163
+ }
164
+
165
+ /**
166
+ * Compose the current frame. `collapse` (set on the final flush) folds a
167
+ * long tool-activity trace into an expandable box so the finished message
168
+ * leads with the answer; during streaming it's left false so the activity
169
+ * stays fully visible while work is in flight.
170
+ */
171
+ snapshot(opts: { collapse?: boolean } = {}): RenderedFrame {
172
+ const activityHtml = this.renderActivity(opts.collapse ?? false) ?? '';
173
+ const body = this.finalAssistant ?? this.chunks.join('');
174
+ const diffHtml = this.diffBlocks.join('\n\n');
175
+ const errorHtml = this.errorLine ?? '';
176
+ return { activityHtml, body, diffHtml, errorHtml, hasUpdate: false };
177
+ }
178
+
179
+ /**
180
+ * Format the tool/skill activity block. Returns null when there's
181
+ * nothing to show — keeps frames lean for chat-only turns.
182
+ *
183
+ * The block is rendered with HTML tags (`<b>`, `<code>`, `<i>`) so
184
+ * it survives `markdownToTelegramHtml` intact (the function escapes
185
+ * `<` `>` `&` in text segments but leaves existing tags alone via
186
+ * the fence/inline placeholder dance). Use `__inert__` markers so
187
+ * already-formed HTML doesn't get re-escaped.
188
+ *
189
+ * When `collapse` is set (final frame) and the trace ran long, fold it
190
+ * into a `<blockquote expandable>` topped by a one-line summary — the
191
+ * tool noise becomes a tap-to-open box and the assistant's answer leads.
192
+ */
193
+ private renderActivity(collapse: boolean): string | null {
194
+ const lines: string[] = [];
195
+ if (this.skillBanner) {
196
+ lines.push(`💡 <b>${escape(this.skillBanner.name)}</b>`);
197
+ }
198
+ // Show last N tool entries to keep the message compact when a turn
199
+ // makes many calls. Most-recent at the bottom (matches chronology).
200
+ const tools = [...this.tools.values()];
201
+ const visible = tools.slice(-10);
202
+ const hidden = tools.length - visible.length;
203
+ if (hidden > 0) {
204
+ lines.push(`<i>… ${hidden} earlier tool call${hidden === 1 ? '' : 's'} hidden</i>`);
205
+ }
206
+ for (const t of visible) {
207
+ lines.push(`• <code>${escape(t.name)}</code>(${escape(t.inputPreview)}) ${statusBadge(t.status)}`);
208
+ }
209
+ for (const n of this.notices.slice(-5)) lines.push(n);
210
+ if (lines.length === 0) return null;
211
+ if (collapse && lines.length >= COLLAPSE_ACTIVITY_LINES) {
212
+ const summary = activitySummary(tools.length);
213
+ const inner = summary ? `${summary}\n${lines.join('\n')}` : lines.join('\n');
214
+ return `<blockquote expandable>${inner}</blockquote>`;
215
+ }
216
+ return `<blockquote>${lines.join('\n')}</blockquote>`;
217
+ }
218
+
219
+ reset(): void {
220
+ this.chunks = [];
221
+ this.tools.clear();
222
+ this.skillBanner = null;
223
+ this.notices = [];
224
+ this.finalAssistant = null;
225
+ this.errorLine = null;
226
+ this.diffBlocks = [];
227
+ this.lastFrame = '';
228
+ }
229
+ }
230
+
231
+ /**
232
+ * One-line summary that heads a collapsed activity box, so the box reads as
233
+ * "🔧 8 steps ▸" before it's opened. Empty when the turn made no tool calls
234
+ * (a skill-only / notice-only trace already names itself in its first line).
235
+ */
236
+ function activitySummary(toolCount: number): string {
237
+ if (toolCount <= 0) return '';
238
+ return `🔧 <b>${toolCount} step${toolCount === 1 ? '' : 's'}</b>`;
239
+ }
240
+
241
+ function statusBadge(status: ToolStatus): string {
242
+ // Pair the glyph with a text word ('done'/'error') so the pass/fail
243
+ // distinction survives screen-reader announcement / glyph-stripping —
244
+ // the ✓/✗ alone are read inconsistently (or as verbose emoji names).
245
+ switch (status.kind) {
246
+ case 'pending':
247
+ return '<i>running…</i>';
248
+ case 'ok':
249
+ return `✓ done <i>${fmtElapsed(status.ms)}</i>`;
250
+ case 'err':
251
+ return `✗ error: <i>${escape(status.message).slice(0, 80)}</i>`;
252
+ case 'denied':
253
+ return `✗ <i>denied: ${escape(status.reason).slice(0, 80)}</i>`;
254
+ }
255
+ }
256
+
257
+ function fmtElapsed(ms: number): string {
258
+ if (ms < 1000) return `${ms}ms`;
259
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
260
+ return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
261
+ }
262
+
263
+ function escape(s: string): string {
264
+ return s.replace(/[&<>]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;'));
265
+ }
266
+
267
+ /**
268
+ * Render one file-diff tool result as a Telegram HTML block: a bold
269
+ * summary line followed by a fenced `diff`-language code block. Telegram
270
+ * highlights `+`/`-` prefixed lines for the `diff` language, which is the
271
+ * closest it can get to red/green backgrounds.
272
+ *
273
+ * The fence body is HTML-escaped exactly like `format.ts` does for
274
+ * ```fenced blocks (only `< > &` matter inside `<pre><code>`), so the
275
+ * diff text survives parse_mode=HTML intact. When `hunks` is empty (a
276
+ * huge diff that got truncated to just counts), we emit only the summary
277
+ * line — no empty code block.
278
+ */
279
+ function renderFileDiff(d: FileDiffDisplay): string {
280
+ const header = `<b>${escape(fileDiffVerb(d))} ${escape(d.path)}</b> — ${escape(fileDiffSummary(d))}`;
281
+ const diffText = unifiedDiffText(d);
282
+ if (!diffText) return header;
283
+ return (
284
+ header +
285
+ '\n' +
286
+ `<pre><code class="language-diff">${escape(diffText)}</code></pre>`
287
+ );
288
+ }
289
+
290
+ /**
291
+ * Build a classic unified-diff body from a file-diff display: a
292
+ * `@@ -oldStart,oldLines +newStart,newLines @@` header per hunk, then each
293
+ * line prefixed with ` `/`+`/`-`. Hunks are separated by a blank line.
294
+ * Returns '' when there are no hunks.
295
+ */
296
+ function unifiedDiffText(d: FileDiffDisplay): string {
297
+ const blocks: string[] = [];
298
+ for (const hunk of d.hunks) {
299
+ const lines: string[] = [
300
+ `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
301
+ ];
302
+ for (const line of hunk.lines) {
303
+ const marker = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' ';
304
+ lines.push(`${marker}${line.text}`);
305
+ }
306
+ blocks.push(lines.join('\n'));
307
+ }
308
+ return blocks.join('\n\n');
309
+ }
310
+
311
+ function truncJson(value: unknown, max = 60): string {
312
+ const s = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
313
+ if (s.length <= max) return s;
314
+ return s.slice(0, max - 1) + '…';
315
+ }
316
+
317
+ function previewArgs(input: unknown): string {
318
+ if (input == null || (typeof input === 'object' && Object.keys(input as object).length === 0)) {
319
+ return '';
320
+ }
321
+ if (typeof input === 'string') return truncJson(input);
322
+ try {
323
+ const obj = input as Record<string, unknown>;
324
+ const keys = Object.keys(obj).slice(0, 3);
325
+ const pairs = keys.map((k) => {
326
+ const v = obj[k];
327
+ return `${k}=${truncJson(v, 20)}`;
328
+ });
329
+ const tail = Object.keys(obj).length > keys.length ? ', …' : '';
330
+ return pairs.join(', ') + tail;
331
+ } catch {
332
+ return truncJson(input);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Telegram block-level tags that can legally wrap a cut: if one is open at the
338
+ * chosen split boundary we close it in the head part and reopen it in the tail
339
+ * so each emitted message is independently valid `parse_mode=HTML`. Inline
340
+ * marks (`<b>`/`<i>`/bare `<code>`/`<a>`) aren't reopenable cleanly here — we
341
+ * simply never cut while any tag is open unless we have to, so they stay intact.
342
+ */
343
+ interface OpenTag {
344
+ /** Lowercased tag name, e.g. `pre` / `code` / `blockquote`. */
345
+ readonly name: string;
346
+ /** The exact opening tag text (incl. attributes) to reopen with in the tail. */
347
+ readonly open: string;
348
+ }
349
+
350
+ interface BoundaryInfo {
351
+ stack: OpenTag[];
352
+ /** True when `idx` falls inside a `<...>` tag or `&...;` entity (unsafe to cut). */
353
+ insideTag: boolean;
354
+ }
355
+
356
+ /**
357
+ * Precomputed cut-boundary index for one `html` part. Replaces the old
358
+ * `tagStackAt(html, idx)` which re-scanned from offset 0 on every probe — that
359
+ * made `splitForTelegram` O(n²) per part because it calls it inside a
360
+ * walk-back/walk-forward loop. This scans the part exactly once, recording for
361
+ * every index whether it is inside a `<...>` tag or `&...;` entity and the
362
+ * open-tag stack as of the last completed tag, so `at(idx)` is O(stackDepth).
363
+ */
364
+ class TagBoundaryIndex {
365
+ // unsafe[i] === 1 when index i falls inside a tag or entity (unsafe to cut).
366
+ private readonly unsafe: Uint8Array;
367
+ // Checkpoints of the open-tag stack, one per completed tag, ordered by `pos`
368
+ // (the offset just AFTER the tag's `>`). stackAt(idx) = the latest checkpoint
369
+ // whose pos <= idx. The stack only changes at a tag close-bracket, so this is
370
+ // exact between checkpoints.
371
+ private readonly checkPos: number[] = [0];
372
+ private readonly checkStack: OpenTag[][];
373
+
374
+ constructor(html: string, seed: OpenTag[]) {
375
+ const n = html.length;
376
+ this.unsafe = new Uint8Array(n);
377
+ const stack: OpenTag[] = seed.slice();
378
+ this.checkStack = [stack.slice()];
379
+ let i = 0;
380
+ while (i < n) {
381
+ const ch = html[i];
382
+ if (ch === '<') {
383
+ const end = html.indexOf('>', i);
384
+ if (end === -1) {
385
+ // Unterminated tag straddling the end — every offset AFTER the `<` is
386
+ // inside it (cutting before the `<`, at i, is still safe). Mark + stop.
387
+ for (let k = i + 1; k < n; k++) this.unsafe[k] = 1;
388
+ break;
389
+ }
390
+ // Offsets strictly inside `<...>` (i.e. i+1..end) are unsafe; the `<`
391
+ // itself at i is a safe boundary (cutting before the tag is fine).
392
+ for (let k = i + 1; k <= end; k++) this.unsafe[k] = 1;
393
+ const raw = html.slice(i, end + 1);
394
+ // Allow a hyphen in the tag name so Telegram's own elements
395
+ // (`<tg-spoiler>`, `<tg-emoji>`) are recognised — otherwise the name
396
+ // would truncate at `tg` and the reopened/closed tag across a split
397
+ // would be the bogus `</tg>`.
398
+ const m = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw);
399
+ if (m) {
400
+ const closing = m[1] === '/';
401
+ const name = m[2]!.toLowerCase();
402
+ if (closing) {
403
+ for (let s = stack.length - 1; s >= 0; s--) {
404
+ if (stack[s]!.name === name) {
405
+ stack.splice(s, 1);
406
+ break;
407
+ }
408
+ }
409
+ } else if (!raw.endsWith('/>')) {
410
+ stack.push({ name, open: raw });
411
+ }
412
+ }
413
+ // Checkpoint the new stack at the offset just after the tag.
414
+ this.checkPos.push(end + 1);
415
+ this.checkStack.push(stack.slice());
416
+ i = end + 1;
417
+ continue;
418
+ }
419
+ if (ch === '&') {
420
+ const semi = html.indexOf(';', i);
421
+ if (semi !== -1 && semi - i <= 12 && /^&[a-zA-Z0-9#]+;$/.test(html.slice(i, semi + 1))) {
422
+ // Offsets strictly inside `&...;` (i+1..semi) are unsafe; cutting
423
+ // before the `&` is fine.
424
+ for (let k = i + 1; k <= semi; k++) this.unsafe[k] = 1;
425
+ i = semi + 1;
426
+ continue;
427
+ }
428
+ }
429
+ i += 1;
430
+ }
431
+ }
432
+
433
+ /** Boundary info at `idx`: the open-tag stack there + whether it's unsafe. */
434
+ at(idx: number): BoundaryInfo {
435
+ const inside = idx > 0 && idx < this.unsafe.length && this.unsafe[idx] === 1;
436
+ // The stack only changes at completed tags; find the latest checkpoint at or
437
+ // before idx via binary search over the (sorted) checkpoint positions.
438
+ let lo = 0;
439
+ let hi = this.checkPos.length - 1;
440
+ while (lo < hi) {
441
+ const mid = (lo + hi + 1) >> 1;
442
+ if (this.checkPos[mid]! <= idx) lo = mid;
443
+ else hi = mid - 1;
444
+ }
445
+ // Returns the checkpoint stack by reference — it is an immutable snapshot;
446
+ // the only consumer that re-roots it (`new TagBoundaryIndex(.., carry)`)
447
+ // copies via `seed.slice()` before mutating, so no probe can corrupt it.
448
+ return {
449
+ stack: this.checkStack[lo]!,
450
+ insideTag: inside,
451
+ };
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Split composed Telegram HTML into <=`limit`-char parts that are each
457
+ * independently valid `parse_mode=HTML`. Cuts are chosen so they never land
458
+ * inside a `<...>` tag or an `&...;` entity; if a block-level tag (e.g. a
459
+ * `<pre><code class="language-diff">` fence or a `<blockquote>`) is open at the
460
+ * boundary it is closed in the head part and reopened in the tail. For plain
461
+ * text with no open tags this reduces to the old newline-preferring behaviour
462
+ * and `parts.join('')` reconstructs the input exactly.
463
+ */
464
+ export function splitForTelegram(text: string, limit: number = TELEGRAM_MESSAGE_LIMIT): string[] {
465
+ if (text.length <= limit) return text ? [text] : [];
466
+ const out: string[] = [];
467
+ // Tags carried over from the previous part's tail (reopened at its head).
468
+ let carry: OpenTag[] = [];
469
+ let remaining = text;
470
+ while (remaining.length > limit) {
471
+ // The reopened tags from the previous cut eat into this part's budget.
472
+ const carryLen = carry.reduce((n, t) => n + t.open.length, 0);
473
+ const budget = limit - carryLen;
474
+ // Index this part's tag/entity boundaries ONCE; every probe below is then an
475
+ // O(stackDepth) lookup instead of an O(idx) re-scan from offset 0.
476
+ const boundaries = new TagBoundaryIndex(remaining, carry);
477
+ // Closing tags appended to the head also consume budget; reserve a margin
478
+ // and search downward for a boundary that's safe (outside tag/entity).
479
+ let idx = Math.min(budget, remaining.length);
480
+ // Prefer a newline at/under the budget so we cut on a natural boundary.
481
+ const nl = remaining.lastIndexOf('\n', idx);
482
+ if (nl > 0) idx = nl;
483
+ let info = boundaries.at(idx);
484
+ // If the chosen boundary is inside a tag or entity, walk back to a safe one.
485
+ while (info.insideTag && idx > 1) {
486
+ idx -= 1;
487
+ info = boundaries.at(idx);
488
+ }
489
+ // No safe boundary at/below budget (e.g. budget smaller than a single tag):
490
+ // never cut mid-tag — scan FORWARD to the next safe point even if it pushes
491
+ // this part over the soft cap. Validity beats the size margin.
492
+ if (info.insideTag) {
493
+ idx = Math.min(budget, remaining.length);
494
+ while (idx < remaining.length) {
495
+ const probe = boundaries.at(idx);
496
+ if (!probe.insideTag) {
497
+ info = probe;
498
+ break;
499
+ }
500
+ idx += 1;
501
+ }
502
+ if (idx >= remaining.length) info = boundaries.at(remaining.length);
503
+ }
504
+ // Guard against a zero-width cut (would loop forever): fall back to the
505
+ // remaining length so the loop terminates.
506
+ if (idx <= 0) {
507
+ idx = remaining.length;
508
+ info = boundaries.at(idx);
509
+ }
510
+ const closeTags = info.stack
511
+ .slice()
512
+ .reverse()
513
+ .map((t) => `</${t.name}>`)
514
+ .join('');
515
+ const head = carry.map((t) => t.open).join('') + remaining.slice(0, idx) + closeTags;
516
+ out.push(head);
517
+ carry = info.stack;
518
+ remaining = remaining.slice(idx);
519
+ }
520
+ if (remaining) out.push(carry.map((t) => t.open).join('') + remaining);
521
+ return out;
522
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ cancel,
3
+ intro,
4
+ isCancel,
5
+ log,
6
+ note,
7
+ outro,
8
+ password,
9
+ select,
10
+ } from '@clack/prompts';
11
+ import type { ChannelSubcommandContext } from '@moxxy/sdk';
12
+ import type { VaultStore } from '@moxxy/plugin-vault';
13
+ import {
14
+ TELEGRAM_AUTHORIZED_CHAT_KEY,
15
+ TELEGRAM_TOKEN_KEY,
16
+ TELEGRAM_TOKEN_RE,
17
+ parseChatId,
18
+ } from './keys.js';
19
+ import { runPairFlow } from './pair-flow.js';
20
+
21
+ // Tiny zero-dep ANSI helpers (bold + dim) so this wizard stays inside the
22
+ // plugin without depending on the CLI's colors module.
23
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
24
+ const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
25
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
26
+
27
+ interface State {
28
+ readonly hasToken: boolean;
29
+ /** "<prefix>...<suffix>" of the bot id for display. null when none. */
30
+ readonly tokenPreview: string | null;
31
+ readonly authorizedChatId: number | null;
32
+ }
33
+
34
+ type Action = 'set-token' | 'pair' | 'unpair' | 'start' | 'quit';
35
+
36
+ /**
37
+ * Interactive Telegram setup menu.
38
+ *
39
+ * Invoked as the channel's `interactiveCommand` when the user runs
40
+ * `moxxy telegram` (or `moxxy channels telegram`) with no subcommand in a TTY.
41
+ * Headless invocations (or when a runner is already up) bypass it and start the
42
+ * bot directly, preserving the cron / systemd usage path.
43
+ *
44
+ * Menu offers actions appropriate to the current state:
45
+ * - no token -> "Set bot token" + "Quit"
46
+ * - token, not paired -> "Pair this terminal" + "Change token" + "Quit"
47
+ * - token + paired -> "Start bot" + "Unpair" + "Change token" + "Quit"
48
+ *
49
+ * Pairing is driven by the wizard end-to-end via the host-issued QR flow: the
50
+ * wizard opens a pair window, prints a `t.me/<bot>?start=<code>` QR, and the user
51
+ * scans it (or opens the link) and taps START to pair — the same mechanism the
52
+ * desktop Channels panel uses.
53
+ */
54
+ export async function runTelegramWizard(ctx: ChannelSubcommandContext): Promise<number> {
55
+ const vault = ctx.deps.vault as VaultStore;
56
+
57
+ intro(bold('moxxy telegram setup'));
58
+
59
+
60
+ while (true) {
61
+ const state = await readState(vault);
62
+ printStatus(state);
63
+ const action = await pickAction(state);
64
+ if (action === null) {
65
+ cancel('cancelled.');
66
+ return 0;
67
+ }
68
+ if (action === 'quit') {
69
+ outro(dim('done.'));
70
+ return 0;
71
+ }
72
+ if (action === 'set-token') {
73
+ await actionSetToken(vault);
74
+ continue;
75
+ }
76
+ if (action === 'pair') {
77
+ return await runPairFlow(ctx);
78
+ }
79
+ if (action === 'unpair') {
80
+ await vault.delete(TELEGRAM_AUTHORIZED_CHAT_KEY);
81
+ log.success('Unpaired. The next /start from any chat will begin a fresh pairing window.');
82
+ continue;
83
+ }
84
+ if (action === 'start') {
85
+ log.info('Starting the bot. Press Ctrl+C to stop.');
86
+ outro(dim('handing off to bot...'));
87
+ return ctx.startChannel();
88
+ }
89
+ }
90
+ }
91
+
92
+ async function readState(vault: VaultStore): Promise<State> {
93
+ // env beats vault for token (matches the channel's own isAvailable
94
+ // precedence) so the wizard reflects what the bot would actually see
95
+ // at start time.
96
+ const envToken = process.env.MOXXY_TELEGRAM_TOKEN;
97
+ const vaultToken = envToken ?? (await vault.get(TELEGRAM_TOKEN_KEY));
98
+ const authorized = await vault.get(TELEGRAM_AUTHORIZED_CHAT_KEY);
99
+ return {
100
+ hasToken: !!vaultToken,
101
+ tokenPreview: vaultToken ? maskToken(vaultToken) : null,
102
+ authorizedChatId: parseChatId(authorized),
103
+ };
104
+ }
105
+
106
+ function maskToken(token: string): string {
107
+ const id = token.split(':')[0] ?? '';
108
+ return id.length > 4 ? `${id.slice(0, 3)}...${id.slice(-3)}` : id;
109
+ }
110
+
111
+ function printStatus(state: State): void {
112
+ const lines: string[] = [];
113
+ lines.push(
114
+ `Token ${state.hasToken ? bold(state.tokenPreview ?? 'set') : dim('not set')}`,
115
+ );
116
+ lines.push(
117
+ `Paired chat ${state.authorizedChatId != null ? bold(String(state.authorizedChatId)) : dim('none')}`,
118
+ );
119
+ note(lines.join('\n'), 'status');
120
+ }
121
+
122
+ async function pickAction(state: State): Promise<Action | null> {
123
+ const options: Array<{ value: Action; label: string; hint?: string }> = [];
124
+ if (state.hasToken && state.authorizedChatId != null) {
125
+ options.push({
126
+ value: 'start',
127
+ label: 'Start the bot',
128
+ hint: 'runs forever - Ctrl+C to stop',
129
+ });
130
+ options.push({
131
+ value: 'unpair',
132
+ label: 'Unpair this chat',
133
+ hint: 'next /start begins a fresh pairing window',
134
+ });
135
+ } else if (state.hasToken) {
136
+ options.push({
137
+ value: 'pair',
138
+ label: 'Pair a Telegram chat',
139
+ hint: 'scan a QR (or open the link) and tap START in Telegram',
140
+ });
141
+ }
142
+ options.push({
143
+ value: 'set-token',
144
+ label: state.hasToken ? 'Change the bot token' : 'Set the bot token',
145
+ hint: state.hasToken ? undefined : 'get one from @BotFather on Telegram',
146
+ });
147
+ options.push({ value: 'quit', label: 'Quit' });
148
+
149
+ const choice = await select<Action>({ message: 'What do you want to do?', options });
150
+ if (isCancel(choice)) return null;
151
+ return choice as Action;
152
+ }
153
+
154
+ async function actionSetToken(vault: VaultStore): Promise<boolean> {
155
+ note(
156
+ 'Open https://t.me/BotFather, run /newbot (or /token for an existing bot), copy the\n' +
157
+ 'token it returns (looks like 1234567890:ABCdef...), and paste it below. It goes\n' +
158
+ "straight into the moxxy vault under '" +
159
+ TELEGRAM_TOKEN_KEY +
160
+ "' - no env var needed.",
161
+ 'get a bot token',
162
+ );
163
+ const token = await password({
164
+ message: 'Paste the Telegram bot token',
165
+ mask: '•',
166
+ validate: (v) => {
167
+ if (!v || v.trim().length === 0) return 'required';
168
+ if (!TELEGRAM_TOKEN_RE.test(v.trim())) {
169
+ return 'doesn\'t look like a Telegram token - expected "<digits>:<22+ url-safe chars>"';
170
+ }
171
+ return undefined;
172
+ },
173
+ });
174
+ if (isCancel(token)) return false;
175
+ await vault.set(TELEGRAM_TOKEN_KEY, String(token).trim(), ['telegram']);
176
+ log.success('Token stored in vault.');
177
+ return true;
178
+ }