@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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { Bot, Context } from 'grammy';
|
|
2
|
+
import { setCategoryDefault, setProviderModel } from '@moxxy/config';
|
|
3
|
+
import { isSelectableMode } from '@moxxy/sdk';
|
|
4
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
5
|
+
import type { PermissionDecision } from '@moxxy/sdk';
|
|
6
|
+
import type { TelegramPermissionResolver } from '../permission.js';
|
|
7
|
+
import type { TelegramApprovalResolver } from '../approval.js';
|
|
8
|
+
|
|
9
|
+
export interface AwaitingApprovalText {
|
|
10
|
+
approvalId: string;
|
|
11
|
+
optionId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CallbackState {
|
|
15
|
+
readonly bot: Bot | null;
|
|
16
|
+
readonly session: Session | null;
|
|
17
|
+
readonly chatId: number | null;
|
|
18
|
+
readonly permissionResolver: TelegramPermissionResolver;
|
|
19
|
+
readonly approvalResolver: TelegramApprovalResolver;
|
|
20
|
+
/** Pairing gate — the same authorization check the text/voice handlers enforce. */
|
|
21
|
+
readonly pairing: { isAuthorized(chatId: number): boolean };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CallbackCallbacks {
|
|
25
|
+
/** Latch an in-flight approval awaiting a text follow-up. */
|
|
26
|
+
setAwaitingApprovalText(state: AwaitingApprovalText | null): void;
|
|
27
|
+
setActiveModelOverride(modelId: string): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inline-keyboard callback router. Dispatches by prefix:
|
|
31
|
+
* - `perm:` → permission resolver
|
|
32
|
+
* - `appr:` → approval resolver
|
|
33
|
+
* - `model:` → provider+model switch (with credential resolve)
|
|
34
|
+
* - `mode:` → mode switch
|
|
35
|
+
*/
|
|
36
|
+
export async function handleCallback(
|
|
37
|
+
ctx: Context,
|
|
38
|
+
state: CallbackState,
|
|
39
|
+
cb: CallbackCallbacks,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const data = ctx.callbackQuery?.data;
|
|
42
|
+
if (!data) return;
|
|
43
|
+
|
|
44
|
+
// Authorization gate — mirror the text/voice handlers. Button clicks can
|
|
45
|
+
// resolve permission prompts, approve plans, and switch provider/model/mode,
|
|
46
|
+
// so an unpaired chat's callbacks must be refused just like its messages
|
|
47
|
+
// (inline-keyboard messages can be forwarded to arbitrary chats).
|
|
48
|
+
const chatId = ctx.chat?.id ?? ctx.callbackQuery?.message?.chat?.id;
|
|
49
|
+
if (chatId === undefined || !state.pairing.isAuthorized(chatId)) {
|
|
50
|
+
try {
|
|
51
|
+
await ctx.answerCallbackQuery({ text: 'This bot is paired with a different chat.' });
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (data.startsWith('perm:')) {
|
|
59
|
+
await handlePerm(ctx, data, state.permissionResolver);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (data.startsWith('appr:')) {
|
|
63
|
+
await handleAppr(ctx, data, state, cb);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (data.startsWith('model:')) {
|
|
67
|
+
await handleModel(ctx, data, state.session, cb);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (data.startsWith('mode:')) {
|
|
71
|
+
await handleMode(ctx, data, state.session);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handlePerm(
|
|
77
|
+
ctx: Context,
|
|
78
|
+
data: string,
|
|
79
|
+
resolver: TelegramPermissionResolver,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const parts = data.split(':');
|
|
82
|
+
if (parts.length !== 3) return;
|
|
83
|
+
const [, callId, choice] = parts;
|
|
84
|
+
if (!callId || !choice) return;
|
|
85
|
+
const decision = mapChoice(choice);
|
|
86
|
+
const handled = resolver.resolvePending(callId, decision);
|
|
87
|
+
await ctx.answerCallbackQuery({ text: handled ? choice : 'no pending permission' });
|
|
88
|
+
if (handled && ctx.callbackQuery?.message) {
|
|
89
|
+
try {
|
|
90
|
+
await ctx.editMessageReplyMarkup({});
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleAppr(
|
|
98
|
+
ctx: Context,
|
|
99
|
+
data: string,
|
|
100
|
+
state: CallbackState,
|
|
101
|
+
cb: CallbackCallbacks,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
// Format: appr:<approvalId>:<optionId>
|
|
104
|
+
const idx = data.indexOf(':', 5);
|
|
105
|
+
if (idx < 0) return;
|
|
106
|
+
const approvalId = data.slice(5, idx);
|
|
107
|
+
const optionId = data.slice(idx + 1);
|
|
108
|
+
const pending = state.approvalResolver.getPending(approvalId);
|
|
109
|
+
if (!pending) {
|
|
110
|
+
await ctx.answerCallbackQuery({ text: 'no pending approval' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const option = pending.request.options.find((o) => o.id === optionId);
|
|
114
|
+
if (!option) {
|
|
115
|
+
await ctx.answerCallbackQuery({ text: 'unknown option' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Clear the inline keyboard so the user can't double-click.
|
|
119
|
+
if (ctx.callbackQuery?.message) {
|
|
120
|
+
try {
|
|
121
|
+
await ctx.editMessageReplyMarkup({});
|
|
122
|
+
} catch {
|
|
123
|
+
/* ignore */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (option.requestsText) {
|
|
127
|
+
// Don't resolve yet — capture the user's next message as the
|
|
128
|
+
// follow-up text. Mirrors the TUI dialog's text-entry sub-mode.
|
|
129
|
+
cb.setAwaitingApprovalText({ approvalId, optionId });
|
|
130
|
+
await ctx.answerCallbackQuery({ text: option.label });
|
|
131
|
+
const prompt =
|
|
132
|
+
option.textPrompt ??
|
|
133
|
+
`Send your message — the next text you type becomes the ${optionId} input.`;
|
|
134
|
+
if (state.chatId && state.bot) {
|
|
135
|
+
try {
|
|
136
|
+
await state.bot.api.sendMessage(state.chatId, `✏️ ${prompt}`);
|
|
137
|
+
} catch {
|
|
138
|
+
/* ignore */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
state.approvalResolver.resolvePending(approvalId, optionId);
|
|
144
|
+
await ctx.answerCallbackQuery({ text: option.label });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handleModel(
|
|
148
|
+
ctx: Context,
|
|
149
|
+
data: string,
|
|
150
|
+
session: Session | null,
|
|
151
|
+
cb: CallbackCallbacks,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
// Format: model:<providerName>::<modelId>
|
|
154
|
+
const payload = data.slice(6);
|
|
155
|
+
const [providerId, modelId] = payload.split('::');
|
|
156
|
+
if (!providerId || !modelId || !session) {
|
|
157
|
+
await ctx.answerCallbackQuery({ text: 'invalid model selection' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Intercept switches to unconfigured providers — otherwise OAuth-
|
|
161
|
+
// backed providers (openai-codex) would surface a credential
|
|
162
|
+
// error on the next turn. Match the TUI's wording so the user
|
|
163
|
+
// sees the same setup command in both channels.
|
|
164
|
+
const ready = session.readyProviders ?? new Set<string>();
|
|
165
|
+
if (!ready.has(providerId)) {
|
|
166
|
+
const cmd =
|
|
167
|
+
providerId === 'openai-codex'
|
|
168
|
+
? 'moxxy login openai-codex'
|
|
169
|
+
: `moxxy init # (will prompt for ${providerId.toUpperCase()}_API_KEY)`;
|
|
170
|
+
await ctx.answerCallbackQuery({ text: `${providerId} not connected` });
|
|
171
|
+
if (ctx.callbackQuery?.message) {
|
|
172
|
+
try {
|
|
173
|
+
await ctx.editMessageText(
|
|
174
|
+
`${providerId} isn't connected.\n\nRun \`${cmd}\` then restart moxxy.`,
|
|
175
|
+
{ parse_mode: 'Markdown' },
|
|
176
|
+
);
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
if (session.providers.getActiveName() !== providerId) {
|
|
185
|
+
// Resolve credentials and drop the cached instance, same as
|
|
186
|
+
// the TUI. Without this the new provider gets createClient({})
|
|
187
|
+
// and openai-codex throws "no OAuth credentials" on next turn.
|
|
188
|
+
const resolver = session.credentialResolver;
|
|
189
|
+
const cfg = resolver ? await resolver(providerId) : {};
|
|
190
|
+
const def = session.providers.list().find((p) => p.name === providerId);
|
|
191
|
+
if (def) session.providers.replace(def);
|
|
192
|
+
session.providers.setActive(providerId, cfg);
|
|
193
|
+
}
|
|
194
|
+
cb.setActiveModelOverride(modelId);
|
|
195
|
+
// Persist to the unified manifest for the next CLI run — same config the TUI
|
|
196
|
+
// writes. Await so a write failure (disk/permission/lock) is caught below and
|
|
197
|
+
// reported, instead of telling the user "✓ switched" while it never landed.
|
|
198
|
+
await setCategoryDefault('provider', providerId);
|
|
199
|
+
await setProviderModel(providerId, modelId);
|
|
200
|
+
await ctx.answerCallbackQuery({ text: `→ ${providerId}:${modelId}` });
|
|
201
|
+
if (ctx.callbackQuery?.message) {
|
|
202
|
+
try {
|
|
203
|
+
await ctx.editMessageText(`✓ switched to ${providerId}:${modelId}`);
|
|
204
|
+
} catch {
|
|
205
|
+
/* ignore */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
await ctx.answerCallbackQuery({
|
|
210
|
+
text: `failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleMode(
|
|
216
|
+
ctx: Context,
|
|
217
|
+
data: string,
|
|
218
|
+
session: Session | null,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
const modeName = data.slice(5);
|
|
221
|
+
if (!modeName || !session) {
|
|
222
|
+
await ctx.answerCallbackQuery({ text: 'invalid mode' });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Refuse a special mode reached by name (e.g. a stale callback) — special
|
|
226
|
+
// modes are entered only via their own command. Mirrors the TUI guard.
|
|
227
|
+
const target = session.modes.list().find((m) => m.name === modeName);
|
|
228
|
+
if (target && !isSelectableMode(target)) {
|
|
229
|
+
await ctx.answerCallbackQuery({ text: `"${modeName}" is a special mode` });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
session.modes.setActive(modeName);
|
|
234
|
+
// Await so a persistence failure is caught below (and reported) rather than
|
|
235
|
+
// claiming success while it silently never landed.
|
|
236
|
+
await setCategoryDefault('mode', modeName);
|
|
237
|
+
await ctx.answerCallbackQuery({ text: `mode → ${modeName}` });
|
|
238
|
+
if (ctx.callbackQuery?.message) {
|
|
239
|
+
try {
|
|
240
|
+
await ctx.editMessageText(`✓ mode → ${modeName}`);
|
|
241
|
+
} catch {
|
|
242
|
+
/* ignore */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
await ctx.answerCallbackQuery({
|
|
247
|
+
text: `failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function mapChoice(choice: string): PermissionDecision {
|
|
253
|
+
if (choice === 'allow') return { mode: 'allow' };
|
|
254
|
+
if (choice === 'allow_session') return { mode: 'allow_session' };
|
|
255
|
+
return { mode: 'deny', reason: 'denied by user' };
|
|
256
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Bot } from 'grammy';
|
|
2
|
+
import { GrammyError } from 'grammy';
|
|
3
|
+
import { FramePump as StreamPump } from '@moxxy/channel-kit';
|
|
4
|
+
import { TurnRenderer, splitForTelegram } from '../render.js';
|
|
5
|
+
import { composeFrame, stripHtml } from './html.js';
|
|
6
|
+
|
|
7
|
+
export interface FramePumpLogger {
|
|
8
|
+
warn(msg: string, meta?: Record<string, unknown>): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FramePumpOptions {
|
|
12
|
+
readonly editFrameMs: number;
|
|
13
|
+
readonly logger?: FramePumpLogger;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Drives the throttled "compose snapshot → send/edit one message" loop
|
|
18
|
+
* for a turn. Owns the renderer and the Telegram-specific delivery —
|
|
19
|
+
* HTML parse-mode fallback + 4096-char message splitting — while the
|
|
20
|
+
* throttle/send-once-then-edit mechanics live in `@moxxy/channel-kit`'s
|
|
21
|
+
* {@link StreamPump} (one instance per turn).
|
|
22
|
+
*
|
|
23
|
+
* Lifecycle per turn:
|
|
24
|
+
* 1. `beginTurn(chatId)` resets state.
|
|
25
|
+
* 2. Renderer updates schedule edits via `scheduleEdit()`.
|
|
26
|
+
* 3. `flush(final)` drains the latest snapshot to Telegram.
|
|
27
|
+
* 4. `endTurn()` clears timers + chat binding.
|
|
28
|
+
*/
|
|
29
|
+
export class FramePump {
|
|
30
|
+
private readonly editFrameMs: number;
|
|
31
|
+
private readonly logger?: FramePumpLogger;
|
|
32
|
+
private bot: Bot | null = null;
|
|
33
|
+
private renderer: TurnRenderer = new TurnRenderer();
|
|
34
|
+
private pump: StreamPump<number> | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(opts: FramePumpOptions) {
|
|
37
|
+
this.editFrameMs = opts.editFrameMs;
|
|
38
|
+
if (opts.logger) this.logger = opts.logger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
attachBot(bot: Bot | null): void {
|
|
42
|
+
this.bot = bot;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Renderer the channel feeds events into. Owned here so reset/snapshot
|
|
46
|
+
* cycles stay coordinated with the message-id state. */
|
|
47
|
+
get renderState(): TurnRenderer {
|
|
48
|
+
return this.renderer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
resetRenderer(): void {
|
|
52
|
+
this.renderer.reset();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beginTurn(chatId: number): void {
|
|
56
|
+
this.renderer.reset();
|
|
57
|
+
this.pump = new StreamPump<number>({
|
|
58
|
+
editFrameMs: this.editFrameMs,
|
|
59
|
+
// Only the FINAL frame collapses the activity trace into its expandable
|
|
60
|
+
// box — mid-stream frames keep it open so the user watches work land live.
|
|
61
|
+
frame: (final) => composeFrame(this.renderer.snapshot({ collapse: final })),
|
|
62
|
+
// The final flush must produce at least one message so the user isn't
|
|
63
|
+
// left with the typing indicator dangling.
|
|
64
|
+
emptyFinalText: '<i>(no output)</i>',
|
|
65
|
+
// The sink does final-only work (split-overflow tails below), so the
|
|
66
|
+
// final frame must reach it even when the text didn't change since the
|
|
67
|
+
// last streamed edit.
|
|
68
|
+
alwaysFlushFinal: true,
|
|
69
|
+
sink: {
|
|
70
|
+
send: (text, final) => this.sendFrame(chatId, text, final),
|
|
71
|
+
edit: (messageId, text, final) => this.editFrame(chatId, messageId, text, final),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
endTurn(): void {
|
|
77
|
+
this.pump?.dispose();
|
|
78
|
+
this.pump = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
scheduleEdit(): void {
|
|
82
|
+
this.pump?.scheduleEdit();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async flush(final: boolean): Promise<void> {
|
|
86
|
+
if (!this.bot || !this.pump) return;
|
|
87
|
+
await this.pump.flush(final);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** First real content of this turn — send (don't edit a placeholder). On the
|
|
91
|
+
* final frame, overflow beyond Telegram's message limit goes out as
|
|
92
|
+
* follow-up messages. */
|
|
93
|
+
private async sendFrame(chatId: number, text: string, final: boolean): Promise<number | null> {
|
|
94
|
+
if (!this.bot) return null;
|
|
95
|
+
const parts = splitForTelegram(text);
|
|
96
|
+
const sent = await this.safeSend(chatId, parts[0]!);
|
|
97
|
+
if (final && parts.length > 1) {
|
|
98
|
+
for (const tail of parts.slice(1)) {
|
|
99
|
+
await this.safeSend(chatId, tail);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return sent;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async editFrame(
|
|
106
|
+
chatId: number,
|
|
107
|
+
messageId: number,
|
|
108
|
+
text: string,
|
|
109
|
+
final: boolean,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
if (!this.bot) return;
|
|
112
|
+
const parts = splitForTelegram(text);
|
|
113
|
+
await this.safeEdit(chatId, messageId, parts[0]!);
|
|
114
|
+
if (final && parts.length > 1) {
|
|
115
|
+
for (const tail of parts.slice(1)) {
|
|
116
|
+
await this.safeSend(chatId, tail);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `text` is already Telegram-flavored HTML (produced by
|
|
123
|
+
* `composeFrame`). Try HTML; on parse-entity errors, strip tags and
|
|
124
|
+
* send plain text so the message still lands instead of looping on
|
|
125
|
+
* the same edit forever.
|
|
126
|
+
*/
|
|
127
|
+
async safeEdit(chatId: number, messageId: number, text: string): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await this.bot!.api.editMessageText(chatId, messageId, text, {
|
|
130
|
+
parse_mode: 'HTML',
|
|
131
|
+
link_preview_options: { is_disabled: true },
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err instanceof GrammyError && err.description?.includes('not modified')) return;
|
|
136
|
+
if (err instanceof GrammyError && /can't parse entities|Bad Request: can't parse/i.test(err.description ?? '')) {
|
|
137
|
+
try {
|
|
138
|
+
await this.bot!.api.editMessageText(chatId, messageId, stripHtml(text));
|
|
139
|
+
return;
|
|
140
|
+
} catch (plainErr) {
|
|
141
|
+
if (plainErr instanceof GrammyError && plainErr.description?.includes('not modified')) return;
|
|
142
|
+
this.logger?.warn('editMessageText plain-fallback failed', { err: String(plainErr) });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.logger?.warn('editMessageText failed', { err: String(err) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Send a new message (first frame of a turn or split-tail).
|
|
152
|
+
* Returns the new message_id on success so callers can set
|
|
153
|
+
* messageId for future edits.
|
|
154
|
+
*/
|
|
155
|
+
async safeSend(chatId: number, text: string): Promise<number | null> {
|
|
156
|
+
try {
|
|
157
|
+
const sent = await this.bot!.api.sendMessage(chatId, text, {
|
|
158
|
+
parse_mode: 'HTML',
|
|
159
|
+
link_preview_options: { is_disabled: true },
|
|
160
|
+
});
|
|
161
|
+
return sent.message_id;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof GrammyError && /can't parse entities|Bad Request: can't parse/i.test(err.description ?? '')) {
|
|
164
|
+
try {
|
|
165
|
+
const sent = await this.bot!.api.sendMessage(chatId, stripHtml(text));
|
|
166
|
+
return sent.message_id;
|
|
167
|
+
} catch (plainErr) {
|
|
168
|
+
this.logger?.warn('sendMessage plain-fallback failed', { err: String(plainErr) });
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.logger?.warn('sendMessage failed', { err: String(err) });
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { stripHtml, truncate } from './html.js';
|
|
3
|
+
|
|
4
|
+
describe('stripHtml', () => {
|
|
5
|
+
it('strips tags and keeps text content', () => {
|
|
6
|
+
expect(stripHtml('<b>bold</b> and <code>code</code>')).toBe('bold and code');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('decodes the four named entities', () => {
|
|
10
|
+
expect(stripHtml('a < b > c & d "e"')).toBe('a < b > c & d "e"');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// u110-4: decoding & LAST avoids double-decoding a literal escaped entity.
|
|
14
|
+
it('does not double-decode a literal escaped entity (&lt;)', () => {
|
|
15
|
+
expect(stripHtml('a &lt; b')).toBe('a < b');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// u110-4: numeric/hex entities must be decoded, not leaked literally.
|
|
19
|
+
it('decodes numeric and hex entities (e.g. apostrophes)', () => {
|
|
20
|
+
expect(stripHtml('it's a 'quote'')).toBe("it's a 'quote'");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('combines tags, named, and numeric entities', () => {
|
|
24
|
+
expect(stripHtml('<i>x</i> & 'y' <z>')).toBe("x & 'y' <z>");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves an out-of-range numeric entity as empty rather than throwing', () => {
|
|
28
|
+
expect(() => stripHtml('�')).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('truncate', () => {
|
|
33
|
+
it('returns the string unchanged when within the limit', () => {
|
|
34
|
+
expect(truncate('hello', 10)).toBe('hello');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('truncates and appends an ellipsis past the limit', () => {
|
|
38
|
+
expect(truncate('hello world', 5)).toBe('hello…');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { markdownToTelegramHtml } from '../format.js';
|
|
2
|
+
import type { RenderedFrame } from '../render.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose one Telegram message string from the renderer's structured
|
|
6
|
+
* snapshot. The activity block is pre-formatted HTML (the renderer
|
|
7
|
+
* emits `<blockquote>`, `<code>`, `<b>`, `<i>` directly so it can
|
|
8
|
+
* style tool calls without going through the markdown converter). The
|
|
9
|
+
* assistant body is plain Markdown from the model — convert it through
|
|
10
|
+
* `markdownToTelegramHtml` so `**bold**`, `` `code` ``, ```code blocks```,
|
|
11
|
+
* `[links](…)`, and list bullets render natively in Telegram.
|
|
12
|
+
*
|
|
13
|
+
* The error line is also pre-formatted HTML (small, controlled).
|
|
14
|
+
*/
|
|
15
|
+
export function composeFrame(snap: RenderedFrame): string {
|
|
16
|
+
const parts: string[] = [];
|
|
17
|
+
if (snap.activityHtml) parts.push(snap.activityHtml);
|
|
18
|
+
if (snap.body) parts.push(markdownToTelegramHtml(snap.body));
|
|
19
|
+
// File-diff blocks are already Telegram HTML (summary line + a
|
|
20
|
+
// <pre><code class="language-diff"> fence), so they skip the markdown
|
|
21
|
+
// converter. Append after the body; the frame pump's `splitForTelegram`
|
|
22
|
+
// handles the 4000-char cap, sending overflow as follow-up messages.
|
|
23
|
+
if (snap.diffHtml) parts.push(snap.diffHtml);
|
|
24
|
+
if (snap.errorHtml) parts.push(snap.errorHtml);
|
|
25
|
+
return parts.join('\n\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip every HTML tag for plain-text fallback when Telegram rejects
|
|
29
|
+
* our parse_mode=HTML payload (rare — usually a malformed entity in
|
|
30
|
+
* user-supplied content). Keeps text content intact.
|
|
31
|
+
*
|
|
32
|
+
* Entities are decoded in a single pass with `&` LAST: decoding it first
|
|
33
|
+
* would double-decode (`&lt;` → `<` → `<`). Numeric entities
|
|
34
|
+
* (`'`, `'`) are handled too, since escaped user/code content can
|
|
35
|
+
* carry them and they'd otherwise leak literally into the fallback message. */
|
|
36
|
+
export function stripHtml(html: string): string {
|
|
37
|
+
return html
|
|
38
|
+
.replace(/<\/?[a-z][^>]*>/gi, '')
|
|
39
|
+
.replace(/&#(\d+);/g, (_, dec: string) => safeFromCodePoint(parseInt(dec, 10)))
|
|
40
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => safeFromCodePoint(parseInt(hex, 16)))
|
|
41
|
+
.replace(/</g, '<')
|
|
42
|
+
.replace(/>/g, '>')
|
|
43
|
+
.replace(/"/g, '"')
|
|
44
|
+
.replace(/&/g, '&');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Decode a numeric entity's code point, leaving an out-of-range/invalid value
|
|
48
|
+
* untouched (return empty) rather than throwing. */
|
|
49
|
+
function safeFromCodePoint(cp: number): string {
|
|
50
|
+
if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff) return '';
|
|
51
|
+
try {
|
|
52
|
+
return String.fromCodePoint(cp);
|
|
53
|
+
} catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function truncate(s: string, n: number): string {
|
|
59
|
+
return s.length <= n ? s : s.slice(0, n) + '…';
|
|
60
|
+
}
|