@pedrohnas/opencode-telegram 0.1.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/package.json +21 -0
- package/src/bot.ts +203 -0
- package/src/config.ts +32 -0
- package/src/event-bus.ts +90 -0
- package/src/handlers/cancel.ts +30 -0
- package/src/handlers/permissions.ts +82 -0
- package/src/handlers/questions.ts +107 -0
- package/src/handlers/typing.ts +27 -0
- package/src/index.ts +255 -0
- package/src/pending-requests.ts +73 -0
- package/src/sdk.ts +113 -0
- package/src/send/chunker.ts +59 -0
- package/src/send/draft-stream.ts +161 -0
- package/src/send/format.ts +93 -0
- package/src/send/tool-progress.ts +22 -0
- package/src/session-manager.ts +143 -0
- package/src/turn-manager.ts +87 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point for the OpenCode Telegram Bot.
|
|
3
|
+
*
|
|
4
|
+
* 1. Initializes SDK (spawns server or connects to existing)
|
|
5
|
+
* 2. Creates Grammy bot with all handlers
|
|
6
|
+
* 3. Starts EventBus (single SSE connection)
|
|
7
|
+
* 4. Routes events → formatted Telegram responses
|
|
8
|
+
* 5. Graceful shutdown on SIGINT/SIGTERM
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { loadConfig } from "./config"
|
|
12
|
+
import { initSdk } from "./sdk"
|
|
13
|
+
import { createBot } from "./bot"
|
|
14
|
+
import { SessionManager } from "./session-manager"
|
|
15
|
+
import { TurnManager } from "./turn-manager"
|
|
16
|
+
import { EventBus } from "./event-bus"
|
|
17
|
+
import { PendingRequests } from "./pending-requests"
|
|
18
|
+
import { markdownToTelegramHtml } from "./send/format"
|
|
19
|
+
import { chunkMessage } from "./send/chunker"
|
|
20
|
+
import { formatPermissionMessage } from "./handlers/permissions"
|
|
21
|
+
import { formatQuestionMessage } from "./handlers/questions"
|
|
22
|
+
import { formatToolStatus } from "./send/tool-progress"
|
|
23
|
+
import type { ActiveTurn } from "./turn-manager"
|
|
24
|
+
|
|
25
|
+
const config = loadConfig()
|
|
26
|
+
|
|
27
|
+
// --- Initialize SDK ---
|
|
28
|
+
// If OPENCODE_URL is set, connect to it; otherwise spawn a local server
|
|
29
|
+
const externalUrl = process.env.OPENCODE_URL
|
|
30
|
+
if (externalUrl) {
|
|
31
|
+
console.log(`Connecting to OpenCode server at ${externalUrl}...`)
|
|
32
|
+
} else {
|
|
33
|
+
console.log(`Starting OpenCode server for ${config.projectDirectory}...`)
|
|
34
|
+
}
|
|
35
|
+
const sdkHandle = await initSdk({
|
|
36
|
+
opencodeUrl: externalUrl,
|
|
37
|
+
projectDirectory: config.projectDirectory,
|
|
38
|
+
})
|
|
39
|
+
const sdk = sdkHandle.client
|
|
40
|
+
console.log(`OpenCode server at ${sdkHandle.url}`)
|
|
41
|
+
|
|
42
|
+
// --- Create managers ---
|
|
43
|
+
const sessionManager = new SessionManager({
|
|
44
|
+
maxEntries: 500,
|
|
45
|
+
ttlMs: 30 * 60 * 1000, // 30 minutes
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const turnManager = new TurnManager()
|
|
49
|
+
|
|
50
|
+
const pendingRequests = new PendingRequests({
|
|
51
|
+
maxEntries: 200,
|
|
52
|
+
ttlMs: 10 * 60 * 1000, // 10 minutes
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// --- Create bot with deps ---
|
|
56
|
+
const bot = createBot(config, { sdk, sessionManager, turnManager, pendingRequests })
|
|
57
|
+
|
|
58
|
+
// --- Response sender (format + chunk + send) ---
|
|
59
|
+
async function sendFormattedResponse(chatId: number, markdown: string) {
|
|
60
|
+
const html = markdownToTelegramHtml(markdown)
|
|
61
|
+
const chunks = chunkMessage(html)
|
|
62
|
+
for (const chunk of chunks) {
|
|
63
|
+
try {
|
|
64
|
+
await bot.api.sendMessage(chatId, chunk, { parse_mode: "HTML" })
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// HTML parse error → retry as plain text
|
|
67
|
+
if (/can't parse entities/i.test(String(err))) {
|
|
68
|
+
const plainChunks = chunkMessage(markdown)
|
|
69
|
+
for (const plain of plainChunks) {
|
|
70
|
+
await bot.api.sendMessage(chatId, plain)
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Finalize response (draft stream → final formatted message) ---
|
|
80
|
+
async function finalizeResponse(chatId: number, turn: ActiveTurn) {
|
|
81
|
+
turn.draft?.stop()
|
|
82
|
+
const text = turn.accumulatedText
|
|
83
|
+
if (!text) return
|
|
84
|
+
|
|
85
|
+
const draftMsgId = turn.draft?.getMessageId() ?? null
|
|
86
|
+
const html = markdownToTelegramHtml(text)
|
|
87
|
+
const chunks = chunkMessage(html)
|
|
88
|
+
|
|
89
|
+
if (chunks.length === 1 && draftMsgId) {
|
|
90
|
+
// Single chunk — edit draft to final version
|
|
91
|
+
try {
|
|
92
|
+
await bot.api.editMessageText(chatId, draftMsgId, html, {
|
|
93
|
+
parse_mode: "HTML",
|
|
94
|
+
})
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const msg = String(err)
|
|
97
|
+
if (/message is not modified/i.test(msg)) return
|
|
98
|
+
if (/can't parse entities/i.test(msg)) {
|
|
99
|
+
try {
|
|
100
|
+
await bot.api.editMessageText(
|
|
101
|
+
chatId,
|
|
102
|
+
draftMsgId,
|
|
103
|
+
text.slice(0, 4096),
|
|
104
|
+
)
|
|
105
|
+
} catch {
|
|
106
|
+
// Give up editing — send as new message
|
|
107
|
+
await sendFormattedResponse(chatId, text)
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
/message to edit not found/i.test(msg) ||
|
|
113
|
+
/MESSAGE_ID_INVALID/i.test(msg)
|
|
114
|
+
) {
|
|
115
|
+
await sendFormattedResponse(chatId, text)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
throw err
|
|
119
|
+
}
|
|
120
|
+
} else if (draftMsgId) {
|
|
121
|
+
// Multiple chunks — delete draft and send all
|
|
122
|
+
await bot.api.deleteMessage(chatId, draftMsgId).catch(() => {})
|
|
123
|
+
await sendFormattedResponse(chatId, text)
|
|
124
|
+
} else {
|
|
125
|
+
// No draft was ever sent — send normally
|
|
126
|
+
await sendFormattedResponse(chatId, text)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- EventBus: route SSE events → Telegram ---
|
|
131
|
+
const eventBus = new EventBus({
|
|
132
|
+
sdk,
|
|
133
|
+
sessionManager,
|
|
134
|
+
onEvent: (sessionId, chatKey, event) => {
|
|
135
|
+
const chatId = Number(chatKey.split(":")[0])
|
|
136
|
+
|
|
137
|
+
switch (event.type) {
|
|
138
|
+
case "message.part.updated": {
|
|
139
|
+
const part = event.properties.part
|
|
140
|
+
const turn = turnManager.get(sessionId)
|
|
141
|
+
if (!turn) break
|
|
142
|
+
|
|
143
|
+
if (part.type === "text") {
|
|
144
|
+
// Replace accumulated text (SDK sends full text, not deltas)
|
|
145
|
+
turn.accumulatedText = part.text
|
|
146
|
+
turn.toolSuffix = ""
|
|
147
|
+
turn.draft?.update(part.text).catch((err: unknown) =>
|
|
148
|
+
console.error("Draft update error:", err),
|
|
149
|
+
)
|
|
150
|
+
} else if (part.type === "tool") {
|
|
151
|
+
const suffix = formatToolStatus(part)
|
|
152
|
+
if (suffix) {
|
|
153
|
+
turn.toolSuffix = suffix
|
|
154
|
+
if (turn.accumulatedText) {
|
|
155
|
+
turn.draft
|
|
156
|
+
?.update(turn.accumulatedText + suffix)
|
|
157
|
+
.catch((err: unknown) =>
|
|
158
|
+
console.error("Draft tool update error:", err),
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "session.idle": {
|
|
167
|
+
const turn = turnManager.get(sessionId)
|
|
168
|
+
if (turn) {
|
|
169
|
+
finalizeResponse(chatId, turn).catch((err) => {
|
|
170
|
+
console.error("Error finalizing response:", err)
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
turnManager.end(sessionId)
|
|
174
|
+
break
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case "session.error": {
|
|
178
|
+
const error = event.properties.error
|
|
179
|
+
const msg =
|
|
180
|
+
typeof error === "string"
|
|
181
|
+
? error
|
|
182
|
+
: error?.data?.message ?? "Unknown error"
|
|
183
|
+
bot.api
|
|
184
|
+
.sendMessage(chatId, `Error: ${msg}`)
|
|
185
|
+
.catch((err) => console.error("Error sending error message:", err))
|
|
186
|
+
turnManager.end(sessionId)
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "permission.asked": {
|
|
191
|
+
const perm = event.properties
|
|
192
|
+
const { text, reply_markup } = formatPermissionMessage(perm)
|
|
193
|
+
pendingRequests.set(perm.id, {
|
|
194
|
+
type: "permission",
|
|
195
|
+
createdAt: Date.now(),
|
|
196
|
+
})
|
|
197
|
+
bot.api
|
|
198
|
+
.sendMessage(chatId, text, { parse_mode: "HTML", reply_markup })
|
|
199
|
+
.catch((err) =>
|
|
200
|
+
console.error("Error sending permission message:", err),
|
|
201
|
+
)
|
|
202
|
+
break
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case "question.asked": {
|
|
206
|
+
const q = event.properties
|
|
207
|
+
if (q.questions && q.questions.length > 0) {
|
|
208
|
+
pendingRequests.set(q.id, {
|
|
209
|
+
type: "question",
|
|
210
|
+
createdAt: Date.now(),
|
|
211
|
+
questions: q.questions.map((qq: any) => ({
|
|
212
|
+
options: qq.options,
|
|
213
|
+
})),
|
|
214
|
+
})
|
|
215
|
+
const { text, reply_markup } = formatQuestionMessage(q)
|
|
216
|
+
bot.api
|
|
217
|
+
.sendMessage(chatId, text, { reply_markup })
|
|
218
|
+
.catch((err) =>
|
|
219
|
+
console.error("Error sending question message:", err),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// --- Start everything ---
|
|
229
|
+
await eventBus.start()
|
|
230
|
+
console.log("EventBus listening for SSE events")
|
|
231
|
+
|
|
232
|
+
// Periodic TTL cleanup
|
|
233
|
+
const cleanupInterval = setInterval(() => {
|
|
234
|
+
sessionManager.cleanup()
|
|
235
|
+
pendingRequests.cleanup()
|
|
236
|
+
}, 5 * 60 * 1000)
|
|
237
|
+
|
|
238
|
+
// --- Graceful shutdown ---
|
|
239
|
+
const shutdown = async () => {
|
|
240
|
+
console.log("Shutting down...")
|
|
241
|
+
clearInterval(cleanupInterval)
|
|
242
|
+
eventBus.stop()
|
|
243
|
+
turnManager.abortAll()
|
|
244
|
+
await bot.stop()
|
|
245
|
+
sdkHandle.cleanup()
|
|
246
|
+
process.exit(0)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
process.on("SIGINT", shutdown)
|
|
250
|
+
process.on("SIGTERM", shutdown)
|
|
251
|
+
|
|
252
|
+
// --- Start polling ---
|
|
253
|
+
await bot.start({
|
|
254
|
+
onStart: () => console.log("Bot started"),
|
|
255
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded Map<requestID, PendingEntry> with TTL.
|
|
3
|
+
*
|
|
4
|
+
* Stores pending permission and question requests so that:
|
|
5
|
+
* - Questions can resolve option index → label when callback is clicked
|
|
6
|
+
* - Double-click protection (delete on first use)
|
|
7
|
+
* - TTL expiry guard (stale buttons get "expired" message)
|
|
8
|
+
*
|
|
9
|
+
* Anti-leak: bounded by maxEntries + TTL cleanup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type PendingEntry = {
|
|
13
|
+
type: "permission" | "question"
|
|
14
|
+
createdAt: number
|
|
15
|
+
questions?: Array<{ options: Array<{ label: string }> }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type PendingRequestsOptions = {
|
|
19
|
+
maxEntries: number
|
|
20
|
+
ttlMs: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PendingRequests {
|
|
24
|
+
private map = new Map<string, PendingEntry>()
|
|
25
|
+
private readonly maxEntries: number
|
|
26
|
+
private readonly ttlMs: number
|
|
27
|
+
|
|
28
|
+
constructor(opts: PendingRequestsOptions) {
|
|
29
|
+
this.maxEntries = opts.maxEntries
|
|
30
|
+
this.ttlMs = opts.ttlMs
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(requestID: string, entry: PendingEntry): void {
|
|
34
|
+
// Evict oldest if at capacity
|
|
35
|
+
while (this.map.size >= this.maxEntries) {
|
|
36
|
+
const oldest = this.map.keys().next().value
|
|
37
|
+
if (oldest !== undefined) {
|
|
38
|
+
this.map.delete(oldest)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.map.set(requestID, entry)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get(requestID: string): PendingEntry | undefined {
|
|
45
|
+
const entry = this.map.get(requestID)
|
|
46
|
+
if (!entry) return undefined
|
|
47
|
+
|
|
48
|
+
// Check TTL
|
|
49
|
+
if (Date.now() - entry.createdAt > this.ttlMs) {
|
|
50
|
+
this.map.delete(requestID)
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entry
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
delete(requestID: string): boolean {
|
|
58
|
+
return this.map.delete(requestID)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cleanup(): void {
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
for (const [id, entry] of this.map) {
|
|
64
|
+
if (now - entry.createdAt > this.ttlMs) {
|
|
65
|
+
this.map.delete(id)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get size(): number {
|
|
71
|
+
return this.map.size
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK client factory.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. OPENCODE_URL set → connect to existing server (production/manual)
|
|
6
|
+
* 2. OPENCODE_URL not set → spawn local server from monorepo source (dev)
|
|
7
|
+
*
|
|
8
|
+
* Returns { client, cleanup } where cleanup stops the spawned server (if any).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
12
|
+
import { spawn as nodeSpawn } from "node:child_process"
|
|
13
|
+
import { resolve } from "node:path"
|
|
14
|
+
|
|
15
|
+
export type SdkHandle = {
|
|
16
|
+
client: OpencodeClient
|
|
17
|
+
url: string
|
|
18
|
+
cleanup: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function initSdk(opts: {
|
|
22
|
+
opencodeUrl?: string
|
|
23
|
+
projectDirectory: string
|
|
24
|
+
}): Promise<SdkHandle> {
|
|
25
|
+
if (opts.opencodeUrl) {
|
|
26
|
+
// Connect to existing server
|
|
27
|
+
const client = createOpencodeClient({
|
|
28
|
+
baseUrl: opts.opencodeUrl,
|
|
29
|
+
directory: opts.projectDirectory,
|
|
30
|
+
})
|
|
31
|
+
return { client, url: opts.opencodeUrl, cleanup: () => {} }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Spawn local server from monorepo source
|
|
35
|
+
const { url, proc } = await spawnOpencodeServer(opts.projectDirectory)
|
|
36
|
+
const client = createOpencodeClient({
|
|
37
|
+
baseUrl: url,
|
|
38
|
+
directory: opts.projectDirectory,
|
|
39
|
+
})
|
|
40
|
+
return {
|
|
41
|
+
client,
|
|
42
|
+
url,
|
|
43
|
+
cleanup: () => proc.kill(),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function spawnOpencodeServer(projectDirectory: string): Promise<{
|
|
48
|
+
url: string
|
|
49
|
+
proc: ReturnType<typeof nodeSpawn>
|
|
50
|
+
}> {
|
|
51
|
+
// CWD must be the opencode package (for module resolution)
|
|
52
|
+
// The project directory is passed via the SDK client's x-opencode-directory header
|
|
53
|
+
const monorepoRoot = resolve(import.meta.dir, "../../..")
|
|
54
|
+
const opencodeDir = resolve(monorepoRoot, "packages/opencode")
|
|
55
|
+
|
|
56
|
+
const proc = nodeSpawn(
|
|
57
|
+
process.execPath,
|
|
58
|
+
["run", "--conditions=browser", "./src/index.ts", "serve", "--port=0"],
|
|
59
|
+
{
|
|
60
|
+
cwd: opencodeDir,
|
|
61
|
+
env: { ...process.env },
|
|
62
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const url = await waitForServerUrl(proc, 30000)
|
|
67
|
+
return { url, proc }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function waitForServerUrl(
|
|
71
|
+
proc: ReturnType<typeof nodeSpawn>,
|
|
72
|
+
timeoutMs: number,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let output = ""
|
|
76
|
+
const timeout = setTimeout(() => {
|
|
77
|
+
reject(
|
|
78
|
+
new Error(
|
|
79
|
+
`OpenCode server did not start within ${timeoutMs}ms. Output: ${output}`,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
}, timeoutMs)
|
|
83
|
+
|
|
84
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
85
|
+
output += chunk.toString()
|
|
86
|
+
const match = output.match(
|
|
87
|
+
/opencode server listening on (https?:\/\/[^\s]+)/,
|
|
88
|
+
)
|
|
89
|
+
if (match) {
|
|
90
|
+
clearTimeout(timeout)
|
|
91
|
+
resolve(match[1])
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
proc.stderr?.on("data", (chunk: Buffer) => {
|
|
96
|
+
output += chunk.toString()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
proc.on("exit", (code) => {
|
|
100
|
+
clearTimeout(timeout)
|
|
101
|
+
reject(
|
|
102
|
+
new Error(
|
|
103
|
+
`OpenCode server exited with code ${code}. Output: ${output}`,
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
proc.on("error", (err) => {
|
|
109
|
+
clearTimeout(timeout)
|
|
110
|
+
reject(err)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split an HTML message into chunks that fit Telegram's 4096-char limit.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Try to split at the last newline before the limit
|
|
6
|
+
* 2. If no newline, split at the last space
|
|
7
|
+
* 3. Never split inside an HTML tag (< ... >)
|
|
8
|
+
* 4. As a last resort, split at the limit
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LIMIT = 4096
|
|
12
|
+
|
|
13
|
+
export function chunkMessage(html: string, limit = DEFAULT_LIMIT): string[] {
|
|
14
|
+
if (!html) return []
|
|
15
|
+
if (html.length <= limit) return [html]
|
|
16
|
+
|
|
17
|
+
const chunks: string[] = []
|
|
18
|
+
let remaining = html
|
|
19
|
+
|
|
20
|
+
while (remaining.length > 0) {
|
|
21
|
+
if (remaining.length <= limit) {
|
|
22
|
+
chunks.push(remaining)
|
|
23
|
+
break
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let splitAt = findSplitPoint(remaining, limit)
|
|
27
|
+
chunks.push(remaining.slice(0, splitAt))
|
|
28
|
+
remaining = remaining.slice(splitAt)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return chunks
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findSplitPoint(text: string, limit: number): number {
|
|
35
|
+
// Try newline first (best visual break)
|
|
36
|
+
const lastNewline = text.lastIndexOf("\n", limit)
|
|
37
|
+
if (lastNewline > limit * 0.5) {
|
|
38
|
+
return lastNewline + 1 // include the newline in the current chunk
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Try space
|
|
42
|
+
const lastSpace = text.lastIndexOf(" ", limit)
|
|
43
|
+
if (lastSpace > limit * 0.5) {
|
|
44
|
+
return lastSpace + 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Ensure we don't split inside an HTML tag
|
|
48
|
+
let splitAt = limit
|
|
49
|
+
const tagStart = text.lastIndexOf("<", splitAt)
|
|
50
|
+
if (tagStart !== -1) {
|
|
51
|
+
const tagEnd = text.indexOf(">", tagStart)
|
|
52
|
+
if (tagEnd === -1 || tagEnd >= splitAt) {
|
|
53
|
+
// We'd be splitting inside a tag — move split before the tag
|
|
54
|
+
splitAt = tagStart
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return splitAt
|
|
59
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DraftStream — streams AI response text via Telegram message edits.
|
|
3
|
+
*
|
|
4
|
+
* On first text update: sends a new message (the "draft").
|
|
5
|
+
* On subsequent updates: edits the draft (throttled at ~400ms).
|
|
6
|
+
* On stop/abort: clears timers, no more edits.
|
|
7
|
+
*
|
|
8
|
+
* Anti-leak:
|
|
9
|
+
* - AbortSignal integration (auto-stop on turn end/cancel)
|
|
10
|
+
* - No Grammy context stored — only chatId + deps
|
|
11
|
+
* - Timer tracked and cleared on stop
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { markdownToTelegramHtml } from "./format"
|
|
15
|
+
|
|
16
|
+
export type DraftStreamDeps = {
|
|
17
|
+
sendMessage: (
|
|
18
|
+
chatId: number,
|
|
19
|
+
text: string,
|
|
20
|
+
opts?: { parse_mode?: string },
|
|
21
|
+
) => Promise<{ message_id: number }>
|
|
22
|
+
editMessageText: (
|
|
23
|
+
chatId: number,
|
|
24
|
+
messageId: number,
|
|
25
|
+
text: string,
|
|
26
|
+
opts?: { parse_mode?: string },
|
|
27
|
+
) => Promise<unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class DraftStream {
|
|
31
|
+
private messageId: number | null = null
|
|
32
|
+
private lastText = ""
|
|
33
|
+
private lastSentAt = 0
|
|
34
|
+
private pending = ""
|
|
35
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
private stopped = false
|
|
37
|
+
private flushing = false
|
|
38
|
+
private _htmlFailed = false
|
|
39
|
+
readonly throttleMs: number
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly deps: DraftStreamDeps,
|
|
43
|
+
private readonly chatId: number,
|
|
44
|
+
signal: AbortSignal,
|
|
45
|
+
throttleMs = 400,
|
|
46
|
+
) {
|
|
47
|
+
this.throttleMs = throttleMs
|
|
48
|
+
|
|
49
|
+
if (signal.aborted) {
|
|
50
|
+
this.stopped = true
|
|
51
|
+
} else {
|
|
52
|
+
signal.addEventListener("abort", () => this.stop(), { once: true })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async update(text: string): Promise<void> {
|
|
57
|
+
if (this.stopped || !text.trim()) return
|
|
58
|
+
this.pending = text
|
|
59
|
+
|
|
60
|
+
if (this.messageId === null) {
|
|
61
|
+
// First update — send initial message
|
|
62
|
+
const truncated = text.slice(0, 4096)
|
|
63
|
+
try {
|
|
64
|
+
const html = markdownToTelegramHtml(truncated)
|
|
65
|
+
const msg = await this.deps.sendMessage(this.chatId, html, {
|
|
66
|
+
parse_mode: "HTML",
|
|
67
|
+
})
|
|
68
|
+
this.messageId = msg.message_id
|
|
69
|
+
this.lastText = truncated
|
|
70
|
+
this.lastSentAt = Date.now()
|
|
71
|
+
} catch {
|
|
72
|
+
// sendMessage failed — messageId stays null
|
|
73
|
+
}
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.scheduleFlush()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private scheduleFlush(): void {
|
|
81
|
+
if (this.timer !== null) return
|
|
82
|
+
const elapsed = Date.now() - this.lastSentAt
|
|
83
|
+
const delay = Math.max(0, this.throttleMs - elapsed)
|
|
84
|
+
this.timer = setTimeout(() => this.flush(), delay)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async flush(): Promise<void> {
|
|
88
|
+
this.timer = null
|
|
89
|
+
if (this.stopped || this.messageId === null) return
|
|
90
|
+
|
|
91
|
+
this.flushing = true
|
|
92
|
+
const text = this.pending.slice(0, 4096)
|
|
93
|
+
|
|
94
|
+
if (text === this.lastText) {
|
|
95
|
+
this.flushing = false
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
if (this._htmlFailed) {
|
|
101
|
+
await this.deps.editMessageText(this.chatId, this.messageId, text)
|
|
102
|
+
} else {
|
|
103
|
+
const html = markdownToTelegramHtml(text)
|
|
104
|
+
await this.deps.editMessageText(this.chatId, this.messageId, html, {
|
|
105
|
+
parse_mode: "HTML",
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const msg = String(err)
|
|
110
|
+
|
|
111
|
+
if (/message is not modified/i.test(msg)) {
|
|
112
|
+
// Ignore — text didn't actually change
|
|
113
|
+
} else if (/can't parse entities/i.test(msg)) {
|
|
114
|
+
this._htmlFailed = true
|
|
115
|
+
// Retry as plain text
|
|
116
|
+
try {
|
|
117
|
+
await this.deps.editMessageText(this.chatId, this.messageId!, text)
|
|
118
|
+
} catch {
|
|
119
|
+
// Give up on this edit
|
|
120
|
+
}
|
|
121
|
+
} else if (
|
|
122
|
+
/message to edit not found/i.test(msg) ||
|
|
123
|
+
/MESSAGE_ID_INVALID/i.test(msg)
|
|
124
|
+
) {
|
|
125
|
+
this.stopped = true
|
|
126
|
+
this.flushing = false
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
// Other errors: log and continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.lastText = text
|
|
133
|
+
this.lastSentAt = Date.now()
|
|
134
|
+
this.flushing = false
|
|
135
|
+
|
|
136
|
+
// If pending changed during flush, schedule another
|
|
137
|
+
if (this.pending.slice(0, 4096) !== text) {
|
|
138
|
+
this.scheduleFlush()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
stop(): void {
|
|
143
|
+
this.stopped = true
|
|
144
|
+
if (this.timer !== null) {
|
|
145
|
+
clearTimeout(this.timer)
|
|
146
|
+
this.timer = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getMessageId(): number | null {
|
|
151
|
+
return this.messageId
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
isStopped(): boolean {
|
|
155
|
+
return this.stopped
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
hasHtmlFailed(): boolean {
|
|
159
|
+
return this._htmlFailed
|
|
160
|
+
}
|
|
161
|
+
}
|