@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
@@ -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 &lt; b &gt; c &amp; d &quot;e&quot;')).toBe('a < b > c & d "e"');
11
+ });
12
+
13
+ // u110-4: decoding &amp; LAST avoids double-decoding a literal escaped entity.
14
+ it('does not double-decode a literal escaped entity (&amp;lt;)', () => {
15
+ expect(stripHtml('a &amp;lt; b')).toBe('a &lt; 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&#39;s a &#x27;quote&#x27;')).toBe("it's a 'quote'");
21
+ });
22
+
23
+ it('combines tags, named, and numeric entities', () => {
24
+ expect(stripHtml('<i>x</i> &amp; &#39;y&#39; &lt;z&gt;')).toBe("x & 'y' <z>");
25
+ });
26
+
27
+ it('leaves an out-of-range numeric entity as empty rather than throwing', () => {
28
+ expect(() => stripHtml('&#999999999;')).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 `&amp;` LAST: decoding it first
33
+ * would double-decode (`&amp;lt;` → `&lt;` → `<`). Numeric entities
34
+ * (`&#39;`, `&#x27;`) 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(/&lt;/g, '<')
42
+ .replace(/&gt;/g, '>')
43
+ .replace(/&quot;/g, '"')
44
+ .replace(/&amp;/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
+ }