@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 ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@pedrohnas/opencode-telegram",
3
+ "version": "0.1.0",
4
+ "description": "Telegram bot for OpenCode — AI coding assistant via Telegram",
5
+ "type": "module",
6
+ "bin": {
7
+ "opencode-telegram": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src/**/*.ts",
11
+ "!src/**/*.test.ts"
12
+ ],
13
+ "license": "MIT",
14
+ "dependencies": {
15
+ "@opencode-ai/sdk": "^1.1.53",
16
+ "grammy": "^1.35.0"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.0.0"
20
+ }
21
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Grammy bot creation and handler logic.
3
+ *
4
+ * Exports:
5
+ * - createBot(config, deps?) — creates Grammy Bot with all handlers
6
+ * - handleMessage(params) — processes a text message (testable standalone)
7
+ * - handleNew(params) — processes /new command (testable standalone)
8
+ */
9
+
10
+ import { Bot } from "grammy"
11
+ import type { Config } from "./config"
12
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
13
+ import type { SessionManager } from "./session-manager"
14
+ import type { TurnManager, ActiveTurn } from "./turn-manager"
15
+ import type { PendingRequests } from "./pending-requests"
16
+ import { handleCancel } from "./handlers/cancel"
17
+ import { parsePermissionCallback } from "./handlers/permissions"
18
+ import {
19
+ parseQuestionCallback,
20
+ resolveQuestionAnswer,
21
+ } from "./handlers/questions"
22
+ import { startTypingLoop } from "./handlers/typing"
23
+ import { DraftStream } from "./send/draft-stream"
24
+
25
+ export const START_MESSAGE = [
26
+ "OpenCode Telegram Bot",
27
+ "",
28
+ "Send any message to start coding.",
29
+ "/new — New session",
30
+ "/cancel — Stop generation",
31
+ ].join("\n")
32
+
33
+ export type BotDeps = {
34
+ sdk: OpencodeClient
35
+ sessionManager: SessionManager
36
+ turnManager: TurnManager
37
+ pendingRequests: PendingRequests
38
+ }
39
+
40
+ export function createBot(config: Config, deps?: BotDeps) {
41
+ const bot = new Bot(config.botToken)
42
+
43
+ bot.command("start", async (ctx) => {
44
+ await ctx.reply(START_MESSAGE)
45
+ })
46
+
47
+ if (deps) {
48
+ const { sdk, sessionManager, turnManager, pendingRequests } = deps
49
+
50
+ bot.command("new", async (ctx) => {
51
+ const chatId = ctx.chat.id
52
+ await handleNew({ chatId, sdk, sessionManager })
53
+ await ctx.reply("New session started.")
54
+ })
55
+
56
+ bot.command("cancel", async (ctx) => {
57
+ const chatId = ctx.chat.id
58
+ const result = await handleCancel({
59
+ chatId,
60
+ sdk,
61
+ sessionManager,
62
+ turnManager,
63
+ })
64
+ await ctx.reply(result)
65
+ })
66
+
67
+ bot.on("callback_query:data", async (ctx) => {
68
+ const data = ctx.callbackQuery.data
69
+ await ctx.answerCallbackQuery()
70
+
71
+ if (data.startsWith("perm:")) {
72
+ const parsed = parsePermissionCallback(data)
73
+ if (!parsed) return
74
+
75
+ const pending = pendingRequests.get(parsed.requestID)
76
+ if (!pending) {
77
+ await ctx.editMessageText("This request has expired.")
78
+ return
79
+ }
80
+ pendingRequests.delete(parsed.requestID)
81
+
82
+ await sdk.permission.reply({
83
+ requestID: parsed.requestID,
84
+ reply: parsed.reply as "once" | "always" | "reject",
85
+ })
86
+
87
+ const label =
88
+ parsed.reply === "reject"
89
+ ? "Denied"
90
+ : `Granted (${parsed.reply})`
91
+ await ctx.editMessageText(`Permission ${label}`)
92
+ return
93
+ }
94
+
95
+ if (data.startsWith("q:")) {
96
+ const parsed = parseQuestionCallback(data)
97
+ if (!parsed) return
98
+
99
+ const pending = pendingRequests.get(parsed.requestID)
100
+ if (!pending) {
101
+ await ctx.editMessageText("This question has expired.")
102
+ return
103
+ }
104
+ pendingRequests.delete(parsed.requestID)
105
+
106
+ if (parsed.action === "skip") {
107
+ await sdk.question.reject({ requestID: parsed.requestID })
108
+ await ctx.editMessageText("Question skipped.")
109
+ } else {
110
+ const answer = resolveQuestionAnswer(parsed.optionIndex, pending)
111
+ await sdk.question.reply({
112
+ requestID: parsed.requestID,
113
+ answers: [answer],
114
+ })
115
+ await ctx.editMessageText(`Selected: ${answer.join(", ")}`)
116
+ }
117
+ return
118
+ }
119
+ })
120
+
121
+ bot.on("message:text", async (ctx) => {
122
+ const chatId = ctx.chat.id
123
+ const text = ctx.message.text.trim()
124
+ if (!text) return
125
+
126
+ const { turn } = await handleMessage({
127
+ chatId,
128
+ text,
129
+ sdk,
130
+ sessionManager,
131
+ turnManager,
132
+ draftDeps: {
133
+ sendMessage: (id, t, o) =>
134
+ bot.api.sendMessage(id, t, o as any),
135
+ editMessageText: (id, m, t, o) =>
136
+ bot.api.editMessageText(id, m, t, o as any),
137
+ },
138
+ })
139
+
140
+ startTypingLoop(
141
+ chatId,
142
+ (id, action) => bot.api.sendChatAction(id, action as any),
143
+ turn.abortController.signal,
144
+ )
145
+ })
146
+ }
147
+
148
+ bot.catch((err) => {
149
+ console.error("Bot error:", err.message)
150
+ })
151
+
152
+ return bot
153
+ }
154
+
155
+ // --- Testable handler functions ---
156
+
157
+ export async function handleMessage(params: {
158
+ chatId: number
159
+ text: string
160
+ sdk: OpencodeClient
161
+ sessionManager: SessionManager
162
+ turnManager: TurnManager
163
+ draftDeps?: import("./send/draft-stream").DraftStreamDeps
164
+ }): Promise<{ turn: ActiveTurn }> {
165
+ const { chatId, text, sdk, sessionManager, turnManager, draftDeps } = params
166
+ const chatKey = String(chatId)
167
+
168
+ const entry = await sessionManager.getOrCreate(chatKey, sdk)
169
+ const turn = turnManager.start(entry.sessionId, chatId)
170
+
171
+ // Attach draft stream BEFORE firing the prompt, so SSE events
172
+ // can update the draft immediately as they arrive.
173
+ if (draftDeps) {
174
+ turn.draft = new DraftStream(draftDeps, chatId, turn.abortController.signal)
175
+ }
176
+
177
+ // Fire-and-forget: don't block the Grammy handler.
178
+ // The response comes via SSE events → DraftStream → finalizeResponse.
179
+ sdk.session.prompt({
180
+ sessionID: entry.sessionId,
181
+ parts: [{ type: "text", text }],
182
+ }).catch((err: unknown) => {
183
+ console.error("Prompt error:", err)
184
+ })
185
+
186
+ return { turn }
187
+ }
188
+
189
+ export async function handleNew(params: {
190
+ chatId: number
191
+ sdk: OpencodeClient
192
+ sessionManager: SessionManager
193
+ }) {
194
+ const { chatId, sdk, sessionManager } = params
195
+ const chatKey = String(chatId)
196
+
197
+ // Remove existing session mapping (session persists on server)
198
+ sessionManager.remove(chatKey)
199
+
200
+ // Create fresh session
201
+ const entry = await sessionManager.getOrCreate(chatKey, sdk)
202
+ return entry
203
+ }
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type Config = {
2
+ botToken: string
3
+ opencodeUrl: string
4
+ projectDirectory: string
5
+ testEnv: boolean
6
+ e2e: {
7
+ apiId: number
8
+ apiHash: string
9
+ session: string
10
+ botUsername: string
11
+ }
12
+ }
13
+
14
+ export function loadConfig(): Config {
15
+ const botToken = process.env.TELEGRAM_BOT_TOKEN
16
+ if (!botToken) {
17
+ throw new Error("TELEGRAM_BOT_TOKEN is required")
18
+ }
19
+
20
+ return {
21
+ botToken,
22
+ opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4096",
23
+ projectDirectory: process.env.OPENCODE_DIRECTORY ?? process.cwd(),
24
+ testEnv: process.env.TELEGRAM_TEST_ENV === "1",
25
+ e2e: {
26
+ apiId: Number(process.env.TELEGRAM_API_ID ?? 0),
27
+ apiHash: process.env.TELEGRAM_API_HASH ?? "",
28
+ session: process.env.TELEGRAM_SESSION ?? "",
29
+ botUsername: process.env.TELEGRAM_BOT_USERNAME ?? "",
30
+ },
31
+ }
32
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Single SSE connection to the OpenCode server.
3
+ *
4
+ * Routes incoming events to the correct Telegram chat by looking up
5
+ * the sessionId → chatKey mapping via SessionManager.
6
+ *
7
+ * Anti-leak design:
8
+ * - One connection for ALL sessions (not one per session)
9
+ * - AbortController for clean shutdown
10
+ * - Auto-reconnect with backoff on stream end
11
+ */
12
+
13
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
14
+ import type { SessionManager } from "./session-manager"
15
+
16
+ export type EventHandler = (
17
+ sessionId: string,
18
+ chatKey: string,
19
+ event: any,
20
+ ) => void
21
+
22
+ export type EventBusOptions = {
23
+ sdk: OpencodeClient
24
+ sessionManager: SessionManager
25
+ onEvent: EventHandler
26
+ }
27
+
28
+ export class EventBus {
29
+ private readonly sdk: OpencodeClient
30
+ private readonly sessionManager: SessionManager
31
+ private readonly onEvent: EventHandler
32
+ private stopped = false
33
+
34
+ constructor(opts: EventBusOptions) {
35
+ this.sdk = opts.sdk
36
+ this.sessionManager = opts.sessionManager
37
+ this.onEvent = opts.onEvent
38
+ }
39
+
40
+ async start(): Promise<void> {
41
+ this.stopped = false
42
+ this.listen().catch((err) => {
43
+ if (!this.stopped) {
44
+ console.error("EventBus stream error:", err)
45
+ }
46
+ })
47
+ }
48
+
49
+ stop(): void {
50
+ this.stopped = true
51
+ }
52
+
53
+ private async listen(): Promise<void> {
54
+ const result = await this.sdk.event.subscribe()
55
+ for await (const event of result.stream) {
56
+ if (this.stopped) break
57
+
58
+ const sessionId = extractSessionId(event)
59
+ if (!sessionId) continue
60
+
61
+ const lookup = this.sessionManager.getBySessionId(sessionId)
62
+ if (!lookup) continue
63
+
64
+ try {
65
+ this.onEvent(sessionId, lookup.chatKey, event)
66
+ } catch (err) {
67
+ console.error("EventBus handler error:", err)
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extract sessionID from various event shapes.
75
+ * Events may have it at:
76
+ * - event.properties.part.sessionID (message.part.updated)
77
+ * - event.properties.info.sessionID (message.updated)
78
+ * - event.properties.sessionID (session.idle, session.error, etc.)
79
+ */
80
+ function extractSessionId(event: any): string | undefined {
81
+ const props = event?.properties
82
+ if (!props) return undefined
83
+
84
+ return (
85
+ props.part?.sessionID ??
86
+ props.info?.sessionID ??
87
+ props.sessionID ??
88
+ undefined
89
+ )
90
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * /cancel command handler — aborts the active generation.
3
+ *
4
+ * Calls sdk.session.abort() and cleans up the TurnManager.
5
+ */
6
+
7
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
8
+ import type { SessionManager } from "../session-manager"
9
+ import type { TurnManager } from "../turn-manager"
10
+
11
+ export async function handleCancel(params: {
12
+ chatId: number
13
+ sdk: OpencodeClient
14
+ sessionManager: SessionManager
15
+ turnManager: TurnManager
16
+ }): Promise<string> {
17
+ const { chatId, sdk, sessionManager, turnManager } = params
18
+ const chatKey = String(chatId)
19
+
20
+ const entry = sessionManager.get(chatKey)
21
+ if (!entry) return "No active session."
22
+
23
+ const turn = turnManager.get(entry.sessionId)
24
+ if (!turn) return "Nothing running."
25
+
26
+ await sdk.session.abort({ sessionID: entry.sessionId })
27
+ turnManager.end(entry.sessionId)
28
+
29
+ return "Generation cancelled."
30
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Permission event → Telegram inline keyboard message.
3
+ *
4
+ * Pure functions:
5
+ * - formatPermissionMessage(perm) → { text, reply_markup } for sendMessage
6
+ * - parsePermissionCallback(data) → { requestID, reply } parsed from callback_data
7
+ */
8
+
9
+ export type PermissionEvent = {
10
+ id: string
11
+ sessionID: string
12
+ permission: string
13
+ patterns: string[]
14
+ metadata: Record<string, unknown>
15
+ always: string[]
16
+ tool?: { messageID: string; callID: string }
17
+ }
18
+
19
+ type InlineKeyboardButton = {
20
+ text: string
21
+ callback_data: string
22
+ }
23
+
24
+ type InlineKeyboardMarkup = {
25
+ inline_keyboard: InlineKeyboardButton[][]
26
+ }
27
+
28
+ export function formatPermissionMessage(perm: PermissionEvent): {
29
+ text: string
30
+ reply_markup: InlineKeyboardMarkup
31
+ } {
32
+ const description = perm.patterns.length > 0
33
+ ? perm.patterns.join(", ")
34
+ : perm.permission
35
+
36
+ const text = `Permission needed: <b>${escapeHtml(perm.permission)}</b>\n<code>${escapeHtml(description)}</code>`
37
+
38
+ return {
39
+ text,
40
+ reply_markup: {
41
+ inline_keyboard: [
42
+ [
43
+ { text: "✓ Allow", callback_data: `perm:once:${perm.id}` },
44
+ { text: "✓ Always", callback_data: `perm:always:${perm.id}` },
45
+ { text: "✗ Deny", callback_data: `perm:deny:${perm.id}` },
46
+ ],
47
+ ],
48
+ },
49
+ }
50
+ }
51
+
52
+ const VALID_ACTIONS = new Set(["once", "always", "deny"])
53
+
54
+ const ACTION_TO_REPLY: Record<string, "once" | "always" | "reject"> = {
55
+ once: "once",
56
+ always: "always",
57
+ deny: "reject",
58
+ }
59
+
60
+ export function parsePermissionCallback(
61
+ data: string,
62
+ ): { requestID: string; reply: "once" | "always" | "reject" } | null {
63
+ if (!data.startsWith("perm:")) return null
64
+
65
+ const firstColon = data.indexOf(":")
66
+ const secondColon = data.indexOf(":", firstColon + 1)
67
+ if (secondColon === -1) return null
68
+
69
+ const action = data.slice(firstColon + 1, secondColon)
70
+ const requestID = data.slice(secondColon + 1)
71
+
72
+ if (!VALID_ACTIONS.has(action) || !requestID) return null
73
+
74
+ return { requestID, reply: ACTION_TO_REPLY[action] }
75
+ }
76
+
77
+ function escapeHtml(text: string): string {
78
+ return text
79
+ .replace(/&/g, "&amp;")
80
+ .replace(/</g, "&lt;")
81
+ .replace(/>/g, "&gt;")
82
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Question event → Telegram inline keyboard message.
3
+ *
4
+ * Pure functions:
5
+ * - formatQuestionMessage(event) → { text, reply_markup }
6
+ * - parseQuestionCallback(data) → parsed callback action
7
+ * - resolveQuestionAnswer(index, pending) → answer labels array
8
+ *
9
+ * Phase 2 MVP: handles first question only (single-select).
10
+ */
11
+
12
+ import type { PendingEntry } from "../pending-requests"
13
+
14
+ export type QuestionOption = {
15
+ label: string
16
+ description: string
17
+ }
18
+
19
+ export type QuestionInfo = {
20
+ question: string
21
+ header: string
22
+ options: QuestionOption[]
23
+ multiple?: boolean
24
+ custom?: boolean
25
+ }
26
+
27
+ export type QuestionEvent = {
28
+ id: string
29
+ sessionID: string
30
+ questions: QuestionInfo[]
31
+ tool?: { messageID: string; callID: string }
32
+ }
33
+
34
+ type InlineKeyboardButton = {
35
+ text: string
36
+ callback_data: string
37
+ }
38
+
39
+ type InlineKeyboardMarkup = {
40
+ inline_keyboard: InlineKeyboardButton[][]
41
+ }
42
+
43
+ export function formatQuestionMessage(event: QuestionEvent): {
44
+ text: string
45
+ reply_markup: InlineKeyboardMarkup
46
+ } {
47
+ // Phase 2 MVP: handle first question only
48
+ const q = event.questions[0]
49
+ const text = q.question
50
+
51
+ const optionRows: InlineKeyboardButton[][] = q.options.map((opt, i) => [
52
+ { text: opt.label, callback_data: `q:${event.id}:${i}` },
53
+ ])
54
+
55
+ // Skip button as last row
56
+ optionRows.push([
57
+ { text: "✗ Skip", callback_data: `q:${event.id}:skip` },
58
+ ])
59
+
60
+ return {
61
+ text,
62
+ reply_markup: { inline_keyboard: optionRows },
63
+ }
64
+ }
65
+
66
+ export type ParsedQuestionCallback =
67
+ | { requestID: string; action: "select"; optionIndex: number }
68
+ | { requestID: string; action: "skip" }
69
+
70
+ export function parseQuestionCallback(
71
+ data: string,
72
+ ): ParsedQuestionCallback | null {
73
+ if (!data.startsWith("q:")) return null
74
+
75
+ // Format: q:{requestID}:{index|skip}
76
+ const firstColon = data.indexOf(":")
77
+ const lastColon = data.lastIndexOf(":")
78
+ if (firstColon === lastColon) return null
79
+
80
+ const requestID = data.slice(firstColon + 1, lastColon)
81
+ const suffix = data.slice(lastColon + 1)
82
+
83
+ if (!requestID) return null
84
+
85
+ if (suffix === "skip") {
86
+ return { requestID, action: "skip" }
87
+ }
88
+
89
+ const optionIndex = parseInt(suffix, 10)
90
+ if (isNaN(optionIndex)) return null
91
+
92
+ return { requestID, action: "select", optionIndex }
93
+ }
94
+
95
+ export function resolveQuestionAnswer(
96
+ optionIndex: number,
97
+ pending: PendingEntry,
98
+ ): string[] {
99
+ const questions = pending.questions ?? []
100
+ const firstQuestion = questions[0]
101
+ if (!firstQuestion) return []
102
+
103
+ const option = firstQuestion.options[optionIndex]
104
+ if (!option) return []
105
+
106
+ return [option.label]
107
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Typing indicator loop — sends "typing" chat action every 4 seconds.
3
+ *
4
+ * Telegram's typing indicator expires after ~5s, so we re-send it
5
+ * at 4s intervals. Tied to an AbortSignal for automatic cleanup
6
+ * when the turn ends.
7
+ */
8
+
9
+ export function startTypingLoop(
10
+ chatId: number,
11
+ sendAction: (chatId: number, action: string) => Promise<unknown>,
12
+ signal: AbortSignal,
13
+ ): void {
14
+ const send = () => {
15
+ sendAction(chatId, "typing").catch(() => {})
16
+ }
17
+
18
+ const schedule = () => {
19
+ if (signal.aborted) return
20
+ send()
21
+ const timer = setTimeout(schedule, 4000)
22
+ const onAbort = () => clearTimeout(timer)
23
+ signal.addEventListener("abort", onAbort, { once: true })
24
+ }
25
+
26
+ schedule()
27
+ }