@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/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
+ }