@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.
- package/LICENSE +674 -0
- package/README.md +222 -0
- package/package.json +34 -0
- package/src/bot.ts +143 -0
- package/src/config.ts +218 -0
- package/src/handlers/callbacks.ts +209 -0
- package/src/handlers/commands.ts +562 -0
- package/src/handlers/messages.ts +163 -0
- package/src/hooks/message.ts +448 -0
- package/src/hooks/permission.ts +81 -0
- package/src/hooks/session.ts +126 -0
- package/src/hooks/tool.ts +99 -0
- package/src/index.ts +395 -0
- package/src/state/mapping.ts +112 -0
- package/src/state/mode.ts +40 -0
- package/src/state/store.ts +167 -0
- package/src/utils/chunk.ts +186 -0
- package/src/utils/format.ts +120 -0
- package/src/utils/safeSend.ts +99 -0
- package/src/utils/throttle.ts +128 -0
- package/src/utils/typing.ts +30 -0
|
@@ -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
|
+
}
|