@tormentalabs/opencode-telegram-plugin 0.2.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.
@@ -0,0 +1,163 @@
1
+ import type { Context } from "grammy";
2
+ import { getActiveSessionId, attachSession } from "../state/mode.js";
3
+ import { getChatState } from "../state/store.js";
4
+ import { safeSend } from "../utils/safeSend.js";
5
+ import { escapeHtml } from "../utils/format.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Client interface — only the methods used in this file
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface SessionSummary {
12
+ id: string;
13
+ title: string;
14
+ createdAt: string;
15
+ }
16
+
17
+ interface OpenCodeClient {
18
+ session: {
19
+ list(): Promise<{ data: SessionSummary[] }>;
20
+ prompt(params: {
21
+ path: { id: string };
22
+ body: {
23
+ parts: [{ type: "text"; text: string }];
24
+ model?: { providerID: string; modelID: string };
25
+ effort?: string;
26
+ };
27
+ }): Promise<{ data: { info: unknown; parts: unknown[] } }>;
28
+ };
29
+ }
30
+
31
+ let _client: OpenCodeClient | null = null;
32
+
33
+ export function setClient(client: unknown): void {
34
+ _client = client as OpenCodeClient;
35
+ }
36
+
37
+ function getClient(): OpenCodeClient {
38
+ if (!_client) throw new Error("OpenCode client not initialized");
39
+ return _client;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Attempt to auto-attach to the most recently created session.
48
+ * Returns the resolved session ID on success, or null if unavailable.
49
+ */
50
+ async function tryAutoAttach(chatId: number): Promise<string | null> {
51
+ try {
52
+ const { data: sessions } = await getClient().session.list();
53
+ if (sessions.length === 0) return null;
54
+
55
+ const latest = [...sessions].sort(
56
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
57
+ )[0]!;
58
+
59
+ attachSession(chatId, latest.id);
60
+ return latest.id;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Handler
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Handles every plain-text message sent to the bot.
72
+ *
73
+ * Flow:
74
+ * 1. Resolve (or auto-attach to) an active session.
75
+ * 2. Fire the prompt against the OpenCode SDK — without awaiting the full
76
+ * response, because the streaming reply arrives through event hooks
77
+ * (message.updated) and is handled by a separate event listener.
78
+ * 3. Propagate any errors back to the user.
79
+ */
80
+ export async function handleTextMessage(ctx: Context): Promise<void> {
81
+ const chatId = ctx.chat?.id;
82
+ if (!chatId) return;
83
+
84
+ const text = ctx.message?.text;
85
+ if (!text) return;
86
+
87
+ // Ignore messages that look like unrecognized commands (e.g. /models, /foo)
88
+ // — these should not be forwarded as prompts to OpenCode
89
+ if (text.startsWith("/")) return;
90
+
91
+ // ------------------------------------------------------------------
92
+ // 1. Resolve active session
93
+ // ------------------------------------------------------------------
94
+ let sessionId = getActiveSessionId(chatId);
95
+
96
+ if (!sessionId) {
97
+ const autoAttach = process.env["TELEGRAM_AUTO_ATTACH"] !== "false";
98
+ if (autoAttach) {
99
+ sessionId = await tryAutoAttach(chatId);
100
+ }
101
+ }
102
+
103
+ if (!sessionId) {
104
+ await safeSend(() =>
105
+ ctx.reply(
106
+ "No active session.\n" +
107
+ "Use /attach to connect to an existing session or /new to create one.",
108
+ ),
109
+ );
110
+ return;
111
+ }
112
+
113
+ // ------------------------------------------------------------------
114
+ // 2. Show typing indicator (best-effort)
115
+ // ------------------------------------------------------------------
116
+ try {
117
+ await ctx.api.sendChatAction(chatId, "typing");
118
+ } catch {
119
+ // Non-fatal — the message will still be sent
120
+ }
121
+
122
+ // ------------------------------------------------------------------
123
+ // 3. Fire the prompt — response streams via event hooks, not here
124
+ // ------------------------------------------------------------------
125
+ const capturedSessionId = sessionId; // capture before any async gap
126
+
127
+ // Build prompt body with optional model/effort overrides
128
+ const chatState = getChatState(chatId);
129
+ const promptBody: {
130
+ parts: [{ type: "text"; text: string }];
131
+ model?: { providerID: string; modelID: string };
132
+ effort?: string;
133
+ } = { parts: [{ type: "text", text }] };
134
+
135
+ if (chatState.selectedModel) {
136
+ promptBody.model = {
137
+ providerID: chatState.selectedModel.providerID,
138
+ modelID: chatState.selectedModel.modelID,
139
+ };
140
+ }
141
+ if (chatState.effort !== "high") {
142
+ promptBody.effort = chatState.effort;
143
+ }
144
+
145
+ try {
146
+ void getClient()
147
+ .session.prompt({
148
+ path: { id: capturedSessionId },
149
+ body: promptBody,
150
+ })
151
+ .catch(async (err: unknown) => {
152
+ const msg = err instanceof Error ? err.message : String(err);
153
+ await safeSend(() =>
154
+ ctx.reply(`❌ Error sending prompt: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
155
+ );
156
+ });
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ await safeSend(() =>
160
+ ctx.reply(`❌ Error sending prompt: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
161
+ );
162
+ }
163
+ }
@@ -0,0 +1,448 @@
1
+ import type { Api, RawApi } from "grammy";
2
+ import {
3
+ getAllChatIds,
4
+ getChatState,
5
+ } from "../state/store.js";
6
+ import { getActiveSessionId } from "../state/mode.js";
7
+ import { markdownToTelegramHtml, stripHtml } from "../utils/format.js";
8
+ import { chunkMessage } from "../utils/chunk.js";
9
+ import { safeSend } from "../utils/safeSend.js";
10
+ import { startTyping } from "../utils/typing.js";
11
+
12
+ export interface HookContext {
13
+ api: Api<RawApi>;
14
+ editIntervalMs: number;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Event shapes (matching actual OpenCode SDK events)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface MessageUpdatedEvent {
22
+ type: "message.updated";
23
+ properties: {
24
+ info: {
25
+ id: string;
26
+ sessionID: string;
27
+ role: string;
28
+ [key: string]: unknown;
29
+ };
30
+ };
31
+ }
32
+
33
+ export interface PartUpdatedEvent {
34
+ type: "message.part.updated";
35
+ properties: {
36
+ part: {
37
+ id: string;
38
+ sessionID: string;
39
+ messageID: string;
40
+ type: string;
41
+ text?: string;
42
+ state?: string;
43
+ };
44
+ };
45
+ }
46
+
47
+ export interface PartDeltaEvent {
48
+ type: "message.part.delta";
49
+ properties: {
50
+ sessionID: string;
51
+ messageID: string;
52
+ partID: string;
53
+ field: string;
54
+ delta: string;
55
+ };
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Track assistant message IDs
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const assistantMessageIds = new Set<string>();
63
+
64
+ export function handleMessageInfo(event: MessageUpdatedEvent): void {
65
+ const { info } = event.properties;
66
+ if (info.role === "assistant") {
67
+ assistantMessageIds.add(info.id);
68
+ }
69
+ }
70
+
71
+ function isAssistantMessage(messageID: string): boolean {
72
+ return assistantMessageIds.has(messageID);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Per-part accumulated text (built from deltas)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const partTextAccumulator = new Map<string, string>();
80
+
81
+ function appendDelta(partID: string, delta: string): string {
82
+ const current = partTextAccumulator.get(partID) ?? "";
83
+ const updated = current + delta;
84
+ partTextAccumulator.set(partID, updated);
85
+ return updated;
86
+ }
87
+
88
+ function clearAccumulated(partID: string): void {
89
+ partTextAccumulator.delete(partID);
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Per-chat streaming context
94
+ // Simple model: store latest text, use setInterval to periodically edit.
95
+ // ---------------------------------------------------------------------------
96
+
97
+ interface ChatStreamCtx {
98
+ latestRawText: string;
99
+ latestHtml: string;
100
+ isFinal: boolean;
101
+ editTimer: ReturnType<typeof setInterval> | null;
102
+ api: Api<RawApi>;
103
+ editIntervalMs: number;
104
+ editing: boolean;
105
+ /** True while the initial sendMessage is in-flight */
106
+ sending: boolean;
107
+ }
108
+
109
+ const chatStreamCtx = new Map<number, ChatStreamCtx>();
110
+
111
+ // Check if a given sctx is still the active context for a chat
112
+ function isActive(chatId: number, sctx: ChatStreamCtx): boolean {
113
+ return chatStreamCtx.get(chatId) === sctx;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Handle message.part.delta — incremental streaming text
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export function handlePartDelta(
121
+ event: PartDeltaEvent,
122
+ ctx: HookContext,
123
+ ): void {
124
+ const { sessionID, messageID, partID, field, delta } = event.properties;
125
+ if (field !== "text") return;
126
+ if (!isAssistantMessage(messageID)) return;
127
+
128
+ const fullText = appendDelta(partID, delta);
129
+ if (!fullText.trim()) return;
130
+
131
+ const { api, editIntervalMs } = ctx;
132
+ const html = markdownToTelegramHtml(fullText);
133
+
134
+ for (const chatId of getAllChatIds()) {
135
+ if (getActiveSessionId(chatId) !== sessionID) continue;
136
+ updateChatStream(chatId, html, fullText, false, api, editIntervalMs);
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Handle message.part.updated — full part snapshot (may be final)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export function handlePartUpdated(
145
+ event: PartUpdatedEvent,
146
+ ctx: HookContext,
147
+ ): void {
148
+ const { part } = event.properties;
149
+ if (part.type !== "text") return;
150
+ if (!isAssistantMessage(part.messageID)) return;
151
+
152
+ const rawText = part.text ?? "";
153
+ if (!rawText.trim()) return;
154
+
155
+ const { sessionID, id: partID } = part;
156
+ const { api, editIntervalMs } = ctx;
157
+
158
+ partTextAccumulator.set(partID, rawText);
159
+
160
+ const isFinal = part.state === "complete";
161
+ const html = markdownToTelegramHtml(rawText);
162
+
163
+ for (const chatId of getAllChatIds()) {
164
+ if (getActiveSessionId(chatId) !== sessionID) continue;
165
+ updateChatStream(chatId, html, rawText, isFinal, api, editIntervalMs);
166
+ }
167
+
168
+ if (isFinal) {
169
+ clearAccumulated(partID);
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Update chat stream context — creates initial send or updates latest text
175
+ // ---------------------------------------------------------------------------
176
+
177
+ function updateChatStream(
178
+ chatId: number,
179
+ html: string,
180
+ rawText: string,
181
+ isFinal: boolean,
182
+ api: Api<RawApi>,
183
+ editIntervalMs: number,
184
+ ): void {
185
+ let sctx = chatStreamCtx.get(chatId);
186
+
187
+ if (!sctx) {
188
+ // First text — send initial message
189
+ sctx = {
190
+ latestRawText: rawText,
191
+ latestHtml: html,
192
+ isFinal,
193
+ editTimer: null,
194
+ api,
195
+ editIntervalMs,
196
+ editing: false,
197
+ sending: true,
198
+ };
199
+ chatStreamCtx.set(chatId, sctx);
200
+ void sendInitialMessage(chatId, sctx);
201
+ return;
202
+ }
203
+
204
+ // Update latest text
205
+ sctx.latestRawText = rawText;
206
+ sctx.latestHtml = html;
207
+ if (isFinal) {
208
+ sctx.isFinal = true;
209
+ void doFinalEdit(chatId, sctx);
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Send the initial message, then start the periodic edit timer
215
+ // ---------------------------------------------------------------------------
216
+
217
+ async function sendInitialMessage(chatId: number, sctx: ChatStreamCtx): Promise<void> {
218
+ const chatState = getChatState(chatId);
219
+
220
+ if (!chatState.typingStop) {
221
+ chatState.typingStop = startTyping(sctx.api, chatId);
222
+ }
223
+
224
+ const chunks = chunkMessage(sctx.latestHtml);
225
+ if (chunks.length === 0) {
226
+ sctx.sending = false;
227
+ return;
228
+ }
229
+
230
+ let sentMsg: Awaited<ReturnType<typeof sctx.api.sendMessage>> | null = null;
231
+ try {
232
+ sentMsg = await sctx.api.sendMessage(chatId, chunks[0], { parse_mode: "HTML" });
233
+ } catch {
234
+ // Fallback to plain text
235
+ try {
236
+ sentMsg = await sctx.api.sendMessage(chatId, stripHtml(sctx.latestHtml));
237
+ } catch {
238
+ sctx.sending = false;
239
+ cleanupStream(chatId);
240
+ return;
241
+ }
242
+ }
243
+
244
+ // After await: check if this context was cleaned up while we were sending
245
+ if (!isActive(chatId, sctx)) {
246
+ sctx.sending = false;
247
+ return;
248
+ }
249
+
250
+ chatState.stream.messageId = sentMsg.message_id;
251
+ chatState.stream.state = "SENT";
252
+ chatState.stream.lastSentText = sctx.latestRawText;
253
+ sctx.sending = false;
254
+
255
+ // Send overflow chunks
256
+ for (let i = 1; i < chunks.length; i++) {
257
+ if (!isActive(chatId, sctx)) return;
258
+ const r = await safeSend(() =>
259
+ sctx.api.sendMessage(chatId, chunks[i], { parse_mode: "HTML" }),
260
+ );
261
+ if (r.ok && r.messageId !== undefined) {
262
+ chatState.stream.chunks.push(r.messageId);
263
+ }
264
+ }
265
+
266
+ // After sending overflow: recheck
267
+ if (!isActive(chatId, sctx)) return;
268
+
269
+ // If already final, do one last edit and stop
270
+ if (sctx.isFinal) {
271
+ if (sctx.latestRawText !== chatState.stream.lastSentText) {
272
+ await doEdit(chatId, sctx);
273
+ }
274
+ cleanupStream(chatId);
275
+ return;
276
+ }
277
+
278
+ // Start periodic edit timer (only if still active)
279
+ if (!isActive(chatId, sctx)) return;
280
+ sctx.editTimer = setInterval(() => {
281
+ if (!isActive(chatId, sctx)) {
282
+ clearInterval(sctx.editTimer!);
283
+ sctx.editTimer = null;
284
+ return;
285
+ }
286
+ void doEdit(chatId, sctx);
287
+ }, sctx.editIntervalMs);
288
+ if (typeof sctx.editTimer === "object" && "unref" in sctx.editTimer) {
289
+ sctx.editTimer.unref();
290
+ }
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Periodic edit — reads latest text and edits the Telegram message
295
+ // ---------------------------------------------------------------------------
296
+
297
+ async function doEdit(chatId: number, sctx: ChatStreamCtx): Promise<void> {
298
+ if (sctx.editing || sctx.sending) return;
299
+ if (!isActive(chatId, sctx)) return;
300
+ sctx.editing = true;
301
+
302
+ try {
303
+ const chatState = getChatState(chatId);
304
+ const msgId = chatState.stream.messageId;
305
+ if (msgId === null) return;
306
+ if (sctx.latestRawText === chatState.stream.lastSentText) return;
307
+
308
+ const editChunks = chunkMessage(sctx.latestHtml);
309
+ if (editChunks.length === 0) return;
310
+
311
+ const editResult = await safeSend(() =>
312
+ sctx.api.editMessageText(chatId, msgId, editChunks[0], { parse_mode: "HTML" }),
313
+ );
314
+
315
+ if (!editResult.ok && editResult.reason === "parse error") {
316
+ await safeSend(() =>
317
+ sctx.api.editMessageText(chatId, msgId, stripHtml(editChunks[0])),
318
+ );
319
+ }
320
+
321
+ chatState.stream.lastSentText = sctx.latestRawText;
322
+
323
+ // Sync overflow chunks
324
+ for (let i = 1; i < editChunks.length; i++) {
325
+ const existingId = chatState.stream.chunks[i - 1];
326
+ if (existingId !== undefined) {
327
+ const r = await safeSend(() =>
328
+ sctx.api.editMessageText(chatId, existingId, editChunks[i], { parse_mode: "HTML" }),
329
+ );
330
+ if (!r.ok && r.reason === "parse error") {
331
+ await safeSend(() =>
332
+ sctx.api.editMessageText(chatId, existingId, stripHtml(editChunks[i])),
333
+ );
334
+ }
335
+ } else {
336
+ const r = await safeSend(() =>
337
+ sctx.api.sendMessage(chatId, editChunks[i], { parse_mode: "HTML" }),
338
+ );
339
+ if (r.ok && r.messageId !== undefined) {
340
+ chatState.stream.chunks.push(r.messageId);
341
+ }
342
+ }
343
+ }
344
+
345
+ // Delete stale overflow messages
346
+ const excessStart = editChunks.length - 1;
347
+ if (excessStart < chatState.stream.chunks.length) {
348
+ for (let j = chatState.stream.chunks.length - 1; j >= excessStart; j--) {
349
+ void safeSend(() => sctx.api.deleteMessage(chatId, chatState.stream.chunks[j]));
350
+ }
351
+ chatState.stream.chunks.length = Math.max(excessStart, 0);
352
+ }
353
+ } finally {
354
+ sctx.editing = false;
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Final edit — stop timer, do one last edit, cleanup
360
+ // ---------------------------------------------------------------------------
361
+
362
+ async function doFinalEdit(chatId: number, sctx: ChatStreamCtx): Promise<void> {
363
+ if (sctx.editTimer) {
364
+ clearInterval(sctx.editTimer);
365
+ sctx.editTimer = null;
366
+ }
367
+
368
+ // Wait for sending/editing to finish (bail if context was cleaned up externally)
369
+ let waitCount = 0;
370
+ while (sctx.editing || sctx.sending) {
371
+ if (!isActive(chatId, sctx) || ++waitCount > 200) return; // 200 × 50ms = 10s max
372
+ await new Promise((r) => setTimeout(r, 50));
373
+ }
374
+
375
+ if (!isActive(chatId, sctx)) return;
376
+
377
+ const chatState = getChatState(chatId);
378
+ if (sctx.latestRawText !== chatState.stream.lastSentText && chatState.stream.messageId !== null) {
379
+ await doEdit(chatId, sctx);
380
+ }
381
+
382
+ if (isActive(chatId, sctx)) {
383
+ cleanupStream(chatId);
384
+ }
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Cleanup
389
+ // ---------------------------------------------------------------------------
390
+
391
+ export function cleanupStream(chatId: number): void {
392
+ const sctx = chatStreamCtx.get(chatId);
393
+ if (sctx?.editTimer) {
394
+ clearInterval(sctx.editTimer);
395
+ }
396
+ chatStreamCtx.delete(chatId);
397
+
398
+ const chatState = getChatState(chatId);
399
+ chatState.typingStop?.();
400
+ chatState.typingStop = null;
401
+ chatState.stream.state = "FINAL";
402
+ }
403
+
404
+ /**
405
+ * Gracefully finalize any active stream for a chat: do one last edit with
406
+ * the latest accumulated text, then clean up. If no stream is active,
407
+ * just clean up immediately.
408
+ */
409
+ export async function gracefulFinalizeStream(chatId: number): Promise<void> {
410
+ const sctx = chatStreamCtx.get(chatId);
411
+ if (!sctx) {
412
+ // No active stream — just clean up store state
413
+ const chatState = getChatState(chatId);
414
+ chatState.typingStop?.();
415
+ chatState.typingStop = null;
416
+ return;
417
+ }
418
+
419
+ // Mark as final so no new timer ticks produce edits
420
+ sctx.isFinal = true;
421
+
422
+ // Stop timer
423
+ if (sctx.editTimer) {
424
+ clearInterval(sctx.editTimer);
425
+ sctx.editTimer = null;
426
+ }
427
+
428
+ // Wait for in-flight operations
429
+ let waitCount = 0;
430
+ while (sctx.editing || sctx.sending) {
431
+ if (!isActive(chatId, sctx) || ++waitCount > 200) break;
432
+ await new Promise((r) => setTimeout(r, 50));
433
+ }
434
+
435
+ // Do final edit with latest accumulated text
436
+ if (isActive(chatId, sctx)) {
437
+ const chatState = getChatState(chatId);
438
+ if (
439
+ chatState.stream.messageId !== null &&
440
+ sctx.latestRawText !== chatState.stream.lastSentText
441
+ ) {
442
+ await doEdit(chatId, sctx);
443
+ }
444
+ }
445
+
446
+ // Clean up
447
+ cleanupStream(chatId);
448
+ }
@@ -0,0 +1,81 @@
1
+ import type { Api, RawApi } from "grammy";
2
+ import { InlineKeyboard } from "grammy";
3
+ import {
4
+ getAllChatIds,
5
+ getChatState,
6
+ registerCallback,
7
+ type PendingPermission,
8
+ } from "../state/store.js";
9
+ import { getActiveSessionId } from "../state/mode.js";
10
+ import { escapeHtml } from "../utils/format.js";
11
+ import { safeSend } from "../utils/safeSend.js";
12
+
13
+ export interface HookContext {
14
+ api: Api<RawApi>;
15
+ editIntervalMs: number;
16
+ }
17
+
18
+ interface PermissionAskedEvent {
19
+ type: "permission.asked";
20
+ properties: {
21
+ sessionID: string;
22
+ id: string;
23
+ tool: string;
24
+ description: string;
25
+ };
26
+ }
27
+
28
+ export function handlePermissionAsked(
29
+ event: PermissionAskedEvent,
30
+ ctx: HookContext,
31
+ ): void {
32
+ const { sessionID, id: permissionId, tool, description } = event.properties;
33
+ const { api } = ctx;
34
+
35
+ for (const chatId of getAllChatIds()) {
36
+ if (getActiveSessionId(chatId) !== sessionID) continue;
37
+
38
+ const chatState = getChatState(chatId);
39
+
40
+ const approveKey = registerCallback("perm_approve", {
41
+ permissionId,
42
+ sessionId: sessionID,
43
+ });
44
+ const denyKey = registerCallback("perm_deny", {
45
+ permissionId,
46
+ sessionId: sessionID,
47
+ });
48
+
49
+ const keyboard = new InlineKeyboard()
50
+ .text("✅ Approve", approveKey)
51
+ .text("❌ Deny", denyKey);
52
+
53
+ const messageText =
54
+ `🔐 <b>Permission requested</b>\n\n` +
55
+ `<b>Tool:</b> <code>${escapeHtml(tool)}</code>\n` +
56
+ escapeHtml(description);
57
+
58
+ void (async () => {
59
+ const result = await safeSend(() =>
60
+ api.sendMessage(chatId, messageText, {
61
+ parse_mode: "HTML",
62
+ reply_markup: keyboard,
63
+ }),
64
+ );
65
+
66
+ const telegramMessageId =
67
+ result.ok && result.messageId !== undefined ? result.messageId : null;
68
+
69
+ const pending: PendingPermission = {
70
+ permissionId,
71
+ sessionId: sessionID,
72
+ tool,
73
+ description,
74
+ telegramMessageId,
75
+ timestamp: Date.now(),
76
+ };
77
+
78
+ chatState.pendingPermissions.set(permissionId, pending);
79
+ })();
80
+ }
81
+ }