@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.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- 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 === '&' ? '&' : c === '<' ? '<' : '>'));
|
|
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
|
+
}
|