@pedrohnas/opencode-telegram 0.1.0 → 1.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.
Files changed (43) hide show
  1. package/.env.example +17 -0
  2. package/bunfig.toml +2 -0
  3. package/docs/PROGRESS.md +327 -0
  4. package/docs/mapping.md +326 -0
  5. package/docs/plans/phase-1.md +176 -0
  6. package/docs/plans/phase-2.md +235 -0
  7. package/docs/plans/phase-3.md +485 -0
  8. package/docs/plans/phase-4.md +566 -0
  9. package/docs/spec.md +2055 -0
  10. package/e2e/client.ts +24 -0
  11. package/e2e/helpers.ts +119 -0
  12. package/e2e/phase-0.test.ts +30 -0
  13. package/e2e/phase-1.test.ts +48 -0
  14. package/e2e/phase-2.test.ts +54 -0
  15. package/e2e/phase-3.test.ts +142 -0
  16. package/e2e/phase-4.test.ts +96 -0
  17. package/e2e/runner.ts +145 -0
  18. package/package.json +14 -12
  19. package/scripts/gen-session.ts +49 -0
  20. package/src/bot.test.ts +301 -0
  21. package/src/bot.ts +91 -0
  22. package/src/config.test.ts +130 -0
  23. package/src/config.ts +15 -0
  24. package/src/event-bus.test.ts +175 -0
  25. package/src/handlers/allowlist.test.ts +60 -0
  26. package/src/handlers/allowlist.ts +33 -0
  27. package/src/handlers/cancel.test.ts +105 -0
  28. package/src/handlers/permissions.test.ts +72 -0
  29. package/src/handlers/questions.test.ts +107 -0
  30. package/src/handlers/sessions.test.ts +479 -0
  31. package/src/handlers/sessions.ts +202 -0
  32. package/src/handlers/typing.test.ts +60 -0
  33. package/src/index.ts +26 -0
  34. package/src/pending-requests.test.ts +64 -0
  35. package/src/send/chunker.test.ts +74 -0
  36. package/src/send/draft-stream.test.ts +229 -0
  37. package/src/send/format.test.ts +143 -0
  38. package/src/send/tool-progress.test.ts +70 -0
  39. package/src/session-manager.test.ts +198 -0
  40. package/src/session-manager.ts +23 -0
  41. package/src/turn-manager.test.ts +155 -0
  42. package/src/turn-manager.ts +5 -0
  43. package/tsconfig.json +9 -0
package/e2e/client.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { TelegramClient } from "telegram"
2
+ import { StringSession } from "telegram/sessions"
3
+
4
+ export type TestClientConfig = {
5
+ apiId: number
6
+ apiHash: string
7
+ session: string
8
+ }
9
+
10
+ export async function createTestClient(
11
+ config: TestClientConfig,
12
+ ): Promise<TelegramClient> {
13
+ const client = new TelegramClient(
14
+ new StringSession(config.session),
15
+ config.apiId,
16
+ config.apiHash,
17
+ {
18
+ connectionRetries: 3,
19
+ retryDelay: 1000,
20
+ },
21
+ )
22
+ await client.connect()
23
+ return client
24
+ }
package/e2e/helpers.ts ADDED
@@ -0,0 +1,119 @@
1
+ import type { TelegramClient } from "telegram"
2
+ import { Api } from "telegram/tl"
3
+
4
+ function sleep(ms: number): Promise<void> {
5
+ return new Promise((r) => setTimeout(r, ms))
6
+ }
7
+
8
+ /**
9
+ * Send a message to the bot and wait for a reply.
10
+ * Polls for new messages from the bot until one arrives after our send time.
11
+ */
12
+ export async function sendAndWait(
13
+ client: TelegramClient,
14
+ botUsername: string,
15
+ text: string,
16
+ timeoutMs = 15000,
17
+ ): Promise<Api.Message> {
18
+ // Get the latest message ID before sending, so we only look for newer messages
19
+ const messagesBefore = await client.getMessages(botUsername, { limit: 1 })
20
+ const lastIdBefore = messagesBefore[0]?.id ?? 0
21
+
22
+ await client.sendMessage(botUsername, { message: text })
23
+
24
+ const deadline = Date.now() + timeoutMs
25
+ while (Date.now() < deadline) {
26
+ await sleep(1500)
27
+ const messages = await client.getMessages(botUsername, { limit: 5 })
28
+ for (const msg of messages) {
29
+ // Bot messages have .out === false (not sent by us)
30
+ // Only consider messages with ID greater than what existed before we sent
31
+ if (!msg.out && msg.id > lastIdBefore) {
32
+ return msg
33
+ }
34
+ }
35
+ }
36
+ throw new Error(`Bot did not reply within ${timeoutMs}ms after sending: "${text}"`)
37
+ }
38
+
39
+ /**
40
+ * Wait for the next bot reply (without sending anything).
41
+ */
42
+ export async function waitForBotReply(
43
+ client: TelegramClient,
44
+ botUsername: string,
45
+ timeoutMs = 15000,
46
+ ): Promise<Api.Message> {
47
+ const before = Math.floor(Date.now() / 1000)
48
+ const deadline = Date.now() + timeoutMs
49
+ while (Date.now() < deadline) {
50
+ await sleep(500)
51
+ const messages = await client.getMessages(botUsername, { limit: 5 })
52
+ for (const msg of messages) {
53
+ if (!msg.out && msg.date >= before) {
54
+ return msg
55
+ }
56
+ }
57
+ }
58
+ throw new Error(`Bot did not reply within ${timeoutMs}ms`)
59
+ }
60
+
61
+ /**
62
+ * Click an inline keyboard button by matching its text.
63
+ */
64
+ export async function clickInlineButton(
65
+ client: TelegramClient,
66
+ botUsername: string,
67
+ msgId: number,
68
+ buttonText: string,
69
+ ): Promise<void> {
70
+ const messages = await client.getMessages(botUsername, { ids: [msgId] })
71
+ const msg = messages[0]
72
+ if (!msg?.replyMarkup || !(msg.replyMarkup instanceof Api.ReplyInlineMarkup)) {
73
+ throw new Error("Message has no inline keyboard")
74
+ }
75
+
76
+ for (const row of msg.replyMarkup.rows) {
77
+ for (const btn of row.buttons) {
78
+ if (btn.text.includes(buttonText) && btn instanceof Api.KeyboardButtonCallback) {
79
+ await client.invoke(
80
+ new Api.messages.GetBotCallbackAnswer({
81
+ peer: botUsername,
82
+ msgId,
83
+ data: btn.data,
84
+ }),
85
+ )
86
+ return
87
+ }
88
+ }
89
+ }
90
+ throw new Error(`Button "${buttonText}" not found in message ${msgId}`)
91
+ }
92
+
93
+ /**
94
+ * Assert that a message's text contains a string or matches a regex.
95
+ */
96
+ export function assertContains(msg: Api.Message, pattern: string | RegExp): void {
97
+ const text = msg.text ?? msg.message ?? ""
98
+ if (typeof pattern === "string") {
99
+ if (!text.includes(pattern)) {
100
+ throw new Error(`Expected "${pattern}" in message, got: "${text.slice(0, 200)}"`)
101
+ }
102
+ } else {
103
+ if (!pattern.test(text)) {
104
+ throw new Error(`Expected ${pattern} to match message, got: "${text.slice(0, 200)}"`)
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Assert that a message has inline keyboard buttons.
111
+ */
112
+ export function assertHasButtons(msg: Api.Message): void {
113
+ if (!msg.replyMarkup || !(msg.replyMarkup instanceof Api.ReplyInlineMarkup)) {
114
+ throw new Error("Expected inline buttons, got none")
115
+ }
116
+ if (msg.replyMarkup.rows.length === 0) {
117
+ throw new Error("Expected inline buttons, got empty keyboard")
118
+ }
119
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import { sendAndWait, assertContains } from "./helpers"
4
+
5
+ describe("Phase 0 — Bot Skeleton", () => {
6
+ beforeAll(async () => {
7
+ await setup()
8
+ }, 90000) // 90s: OpenCode server + bot + gramjs startup
9
+
10
+ afterAll(async () => {
11
+ await teardown()
12
+ })
13
+
14
+ test("bot responds to /start", async () => {
15
+ const client = getClient()
16
+ const reply = await sendAndWait(client, getBotUsername(), "/start")
17
+ assertContains(reply, "OpenCode Telegram Bot")
18
+ assertContains(reply, "/new")
19
+ assertContains(reply, "/cancel")
20
+ }, 20000)
21
+
22
+ test("bot does not crash on unknown command", async () => {
23
+ const client = getClient()
24
+ // Send unknown command — bot should not crash
25
+ // (It may respond via AI or ignore it — either is fine)
26
+ const reply = await sendAndWait(client, getBotUsername(), "/nonexistent_command_xyz", 30000)
27
+ // Bot responded without crashing — that's the test
28
+ expect(reply).toBeDefined()
29
+ }, 45000)
30
+ })
@@ -0,0 +1,48 @@
1
+ import { describe, test, beforeAll, afterAll } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import { sendAndWait, assertContains } from "./helpers"
4
+
5
+ describe("Phase 1 — Core Loop", () => {
6
+ beforeAll(async () => {
7
+ await setup()
8
+ }, 90000)
9
+
10
+ afterAll(async () => {
11
+ await teardown()
12
+ })
13
+
14
+ test(
15
+ "/start still works (regression)",
16
+ async () => {
17
+ const client = getClient()
18
+ const reply = await sendAndWait(client, getBotUsername(), "/start")
19
+ assertContains(reply, "OpenCode Telegram Bot")
20
+ },
21
+ 20000,
22
+ )
23
+
24
+ test(
25
+ "/new creates fresh session",
26
+ async () => {
27
+ const client = getClient()
28
+ const reply = await sendAndWait(client, getBotUsername(), "/new", 30000)
29
+ assertContains(reply, /session/i)
30
+ },
31
+ 45000,
32
+ )
33
+
34
+ test(
35
+ "text message gets AI response",
36
+ async () => {
37
+ const client = getClient()
38
+ const reply = await sendAndWait(
39
+ client,
40
+ getBotUsername(),
41
+ "Say exactly the word hello and nothing else",
42
+ 60000,
43
+ )
44
+ assertContains(reply, /hello/i)
45
+ },
46
+ 90000,
47
+ )
48
+ })
@@ -0,0 +1,54 @@
1
+ import { describe, test, beforeAll, afterAll, expect } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import { sendAndWait, assertContains } from "./helpers"
4
+
5
+ describe("Phase 2 — Interactive Controls", () => {
6
+ beforeAll(async () => {
7
+ await setup()
8
+ }, 90000)
9
+
10
+ afterAll(async () => {
11
+ await teardown()
12
+ })
13
+
14
+ test(
15
+ "/start still works (regression)",
16
+ async () => {
17
+ const client = getClient()
18
+ const reply = await sendAndWait(client, getBotUsername(), "/start")
19
+ assertContains(reply, "OpenCode Telegram Bot")
20
+ },
21
+ 20000,
22
+ )
23
+
24
+ test(
25
+ "/cancel with no active turn returns 'Nothing running.'",
26
+ async () => {
27
+ const client = getClient()
28
+ const bot = getBotUsername()
29
+
30
+ // Fresh session
31
+ await sendAndWait(client, bot, "/new", 30000)
32
+
33
+ // Cancel with nothing running
34
+ const reply = await sendAndWait(client, bot, "/cancel", 15000)
35
+ assertContains(reply, /nothing running/i)
36
+ },
37
+ 60000,
38
+ )
39
+
40
+ test(
41
+ "text message still gets AI response (Phase 2 wiring regression)",
42
+ async () => {
43
+ const client = getClient()
44
+ const reply = await sendAndWait(
45
+ client,
46
+ getBotUsername(),
47
+ "Say exactly the word hello and nothing else",
48
+ 60000,
49
+ )
50
+ assertContains(reply, /hello/i)
51
+ },
52
+ 90000,
53
+ )
54
+ })
@@ -0,0 +1,142 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import { sendAndWait, assertContains } from "./helpers"
4
+
5
+ function sleep(ms: number): Promise<void> {
6
+ return new Promise((r) => setTimeout(r, ms))
7
+ }
8
+
9
+ describe("Phase 3 — Streaming + UX", () => {
10
+ beforeAll(async () => {
11
+ await setup()
12
+ }, 90000)
13
+
14
+ afterAll(async () => {
15
+ await teardown()
16
+ })
17
+
18
+ test(
19
+ "response appears before AI finishes (draft streaming)",
20
+ async () => {
21
+ const client = getClient()
22
+ const bot = getBotUsername()
23
+
24
+ // Get baseline message ID before sending
25
+ const before = await client.getMessages(bot, { limit: 1 })
26
+ const lastIdBefore = before[0]?.id ?? 0
27
+
28
+ // Send a prompt that requires a lengthy response (forces streaming)
29
+ await client.sendMessage(bot, {
30
+ message:
31
+ "Write a detailed explanation of how TCP/IP works, covering at least the 4 layers. Be thorough.",
32
+ })
33
+
34
+ // Poll for the draft message to appear (should arrive within seconds, NOT after full response)
35
+ let draftId: number | null = null
36
+ let draftText = ""
37
+ const draftDeadline = Date.now() + 15000
38
+ while (Date.now() < draftDeadline) {
39
+ await sleep(1500)
40
+ const msgs = await client.getMessages(bot, { limit: 5 })
41
+ const botMsg = msgs.find((m) => !m.out && m.id > lastIdBefore)
42
+ if (botMsg) {
43
+ draftId = botMsg.id
44
+ draftText = botMsg.text ?? botMsg.message ?? ""
45
+ break
46
+ }
47
+ }
48
+
49
+ expect(draftId).not.toBeNull()
50
+ expect(draftText.length).toBeGreaterThan(0)
51
+
52
+ // Wait for response to finalize
53
+ await sleep(8000)
54
+
55
+ // Re-fetch the SAME message by ID — it should have been edited (streaming via edit)
56
+ const updated = await client.getMessages(bot, { ids: [draftId!] })
57
+ const finalMsg = updated[0]
58
+ const finalText = finalMsg?.text ?? finalMsg?.message ?? ""
59
+
60
+ // Core assertion: same message ID was reused (streaming via edit, not new message)
61
+ // Note: final text may be shorter than draft (tool suffix gets stripped on finalization)
62
+ expect(finalMsg?.id).toBe(draftId)
63
+ expect(finalText.length).toBeGreaterThan(0)
64
+ },
65
+ 90000,
66
+ )
67
+
68
+ test(
69
+ "tool call shows progress indicator then completes",
70
+ async () => {
71
+ const client = getClient()
72
+ const bot = getBotUsername()
73
+
74
+ // Start fresh session to avoid context carryover
75
+ await sendAndWait(client, bot, "/new", 30000)
76
+
77
+ const before = await client.getMessages(bot, { limit: 1 })
78
+ const lastIdBefore = before[0]?.id ?? 0
79
+
80
+ // Prompt that will trigger a tool call (bash/read)
81
+ await client.sendMessage(bot, {
82
+ message: "Run the command: echo 'e2e-tool-test-ok'",
83
+ })
84
+
85
+ // Poll for bot messages — look for the tool indicator or final response
86
+ let sawToolIndicator = false
87
+ const deadline = Date.now() + 30000
88
+ while (Date.now() < deadline) {
89
+ await sleep(1500)
90
+ const msgs = await client.getMessages(bot, { limit: 5 })
91
+ for (const msg of msgs) {
92
+ if (msg.out || msg.id <= lastIdBefore) continue
93
+ const text = msg.text ?? msg.message ?? ""
94
+ // Check for tool progress indicator (⚙) in any snapshot
95
+ if (text.includes("⚙") || text.includes("Running")) {
96
+ sawToolIndicator = true
97
+ }
98
+ // Check for final result
99
+ if (text.includes("e2e-tool-test-ok")) {
100
+ // Tool completed — the response contains the output
101
+ expect(text).toContain("e2e-tool-test-ok")
102
+ return
103
+ }
104
+ }
105
+ }
106
+
107
+ // If we get here, at least verify the bot responded
108
+ const finalMsgs = await client.getMessages(bot, { limit: 5 })
109
+ const botReply = finalMsgs.find((m) => !m.out && m.id > lastIdBefore)
110
+ expect(botReply).toBeDefined()
111
+ },
112
+ 60000,
113
+ )
114
+
115
+ test(
116
+ "regression: /start still works",
117
+ async () => {
118
+ const client = getClient()
119
+ const reply = await sendAndWait(client, getBotUsername(), "/start")
120
+ assertContains(reply, "OpenCode Telegram Bot")
121
+ },
122
+ 20000,
123
+ )
124
+
125
+ test(
126
+ "regression: text message gets AI response",
127
+ async () => {
128
+ const client = getClient()
129
+ const reply = await sendAndWait(
130
+ client,
131
+ getBotUsername(),
132
+ "Respond with exactly the single word: hello",
133
+ 60000,
134
+ )
135
+ // Bot responded — content varies by model config (may be "hello", "Feito", etc.)
136
+ expect(reply).toBeDefined()
137
+ const text = reply.text ?? reply.message ?? ""
138
+ expect(text.length).toBeGreaterThan(0)
139
+ },
140
+ 90000,
141
+ )
142
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test"
2
+ import { setup, teardown, getClient, getBotUsername } from "./runner"
3
+ import { sendAndWait, assertContains, assertHasButtons } from "./helpers"
4
+
5
+ describe("Phase 4 — Session Management + Hardening", () => {
6
+ beforeAll(async () => {
7
+ await setup()
8
+ }, 90000)
9
+
10
+ afterAll(async () => {
11
+ await teardown()
12
+ })
13
+
14
+ test(
15
+ "/list shows sessions (with inline keyboard after creating one)",
16
+ async () => {
17
+ const client = getClient()
18
+ const bot = getBotUsername()
19
+
20
+ // Ensure at least one session exists by sending a message
21
+ await sendAndWait(client, bot, "Say hello", 60000)
22
+
23
+ // Now list sessions
24
+ const reply = await sendAndWait(client, bot, "/list", 15000)
25
+ const text = reply.text ?? reply.message ?? ""
26
+ // Should show session selection or a list
27
+ expect(text.length).toBeGreaterThan(0)
28
+ // Should have inline keyboard buttons
29
+ assertHasButtons(reply)
30
+ },
31
+ 120000,
32
+ )
33
+
34
+ test(
35
+ "/info shows session details",
36
+ async () => {
37
+ const client = getClient()
38
+ const reply = await sendAndWait(client, getBotUsername(), "/info", 15000)
39
+ const text = reply.text ?? reply.message ?? ""
40
+ // Should contain session info (title, dates, directory)
41
+ assertContains(reply, /session|directory|created/i)
42
+ },
43
+ 30000,
44
+ )
45
+
46
+ test(
47
+ "/rename changes session title",
48
+ async () => {
49
+ const client = getClient()
50
+ const reply = await sendAndWait(
51
+ client,
52
+ getBotUsername(),
53
+ "/rename E2E Test Session",
54
+ 15000,
55
+ )
56
+ assertContains(reply, /renamed/i)
57
+ },
58
+ 30000,
59
+ )
60
+
61
+ test(
62
+ "regression: text message gets AI response",
63
+ async () => {
64
+ const client = getClient()
65
+ // Start fresh to avoid context from previous tests
66
+ await sendAndWait(client, getBotUsername(), "/new", 30000)
67
+
68
+ const reply = await sendAndWait(
69
+ client,
70
+ getBotUsername(),
71
+ "Respond with exactly the single word: hello",
72
+ 60000,
73
+ )
74
+ // Bot responded — content varies by model config
75
+ expect(reply).toBeDefined()
76
+ const text = reply.text ?? reply.message ?? ""
77
+ expect(text.length).toBeGreaterThan(0)
78
+ },
79
+ 120000,
80
+ )
81
+
82
+ test(
83
+ "regression: /new creates fresh session",
84
+ async () => {
85
+ const client = getClient()
86
+ const reply = await sendAndWait(
87
+ client,
88
+ getBotUsername(),
89
+ "/new",
90
+ 15000,
91
+ )
92
+ assertContains(reply, /session/i)
93
+ },
94
+ 30000,
95
+ )
96
+ })
package/e2e/runner.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { spawn, type Subprocess } from "bun"
2
+ import type { TelegramClient } from "telegram"
3
+ import { createTestClient } from "./client"
4
+ import { loadConfig } from "../src/config"
5
+ import { resolve } from "node:path"
6
+
7
+ let serverProcess: Subprocess | null = null
8
+ let botProcess: Subprocess | null = null
9
+ let testClient: TelegramClient | null = null
10
+ let botUsername = ""
11
+
12
+ /**
13
+ * Start OpenCode server + bot as subprocesses and connect the gramjs test client.
14
+ * Must be called in beforeAll().
15
+ */
16
+ export async function setup(): Promise<void> {
17
+ const config = loadConfig()
18
+
19
+ if (!config.e2e.apiId || !config.e2e.apiHash || !config.e2e.session) {
20
+ throw new Error(
21
+ "E2E tests require TELEGRAM_API_ID, TELEGRAM_API_HASH, and TELEGRAM_SESSION env vars. " +
22
+ "See .env.example for details.",
23
+ )
24
+ }
25
+
26
+ if (!config.e2e.botUsername) {
27
+ throw new Error("E2E tests require TELEGRAM_BOT_USERNAME env var.")
28
+ }
29
+
30
+ botUsername = config.e2e.botUsername
31
+ const packageDir = import.meta.dir.replace("/e2e", "")
32
+ const opencodeDir = resolve(packageDir, "../opencode")
33
+
34
+ // 1. Start OpenCode server
35
+ serverProcess = spawn(
36
+ [process.execPath, "run", "--conditions=browser", "./src/index.ts", "serve", "--port=0"],
37
+ {
38
+ cwd: opencodeDir,
39
+ env: { ...process.env },
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ },
43
+ )
44
+
45
+ const serverUrl = await waitForPattern(
46
+ serverProcess,
47
+ /opencode server listening on (https?:\/\/[^\s]+)/,
48
+ 30000,
49
+ "OpenCode server",
50
+ )
51
+
52
+ console.log(`E2E: OpenCode server at ${serverUrl}`)
53
+
54
+ // 2. Start bot with OPENCODE_URL pointing to our server
55
+ // Use /home/pedro/dev as project dir (has opencode.json with Opus configured)
56
+ const projectDir = process.env.OPENCODE_DIRECTORY ?? resolve(packageDir, "../../..")
57
+ botProcess = spawn([process.execPath, "run", "src/index.ts"], {
58
+ cwd: packageDir,
59
+ env: { ...process.env, OPENCODE_URL: serverUrl, OPENCODE_DIRECTORY: projectDir, TELEGRAM_ALLOWED_USERS: "*" },
60
+ stdout: "pipe",
61
+ stderr: "pipe",
62
+ })
63
+
64
+ await waitForPattern(botProcess, /Bot started/, 30000, "Bot")
65
+ console.log("E2E: Bot started")
66
+
67
+ // 3. Connect gramjs test client
68
+ testClient = await createTestClient({
69
+ apiId: config.e2e.apiId,
70
+ apiHash: config.e2e.apiHash,
71
+ session: config.e2e.session,
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Stop everything and disconnect the test client.
77
+ * Must be called in afterAll().
78
+ */
79
+ export async function teardown(): Promise<void> {
80
+ if (testClient) {
81
+ await testClient.disconnect().catch(() => {})
82
+ testClient = null
83
+ }
84
+ if (botProcess) {
85
+ botProcess.kill()
86
+ botProcess = null
87
+ }
88
+ if (serverProcess) {
89
+ serverProcess.kill()
90
+ serverProcess = null
91
+ }
92
+ }
93
+
94
+ export function getClient(): TelegramClient {
95
+ if (!testClient) throw new Error("Test client not initialized. Call setup() first.")
96
+ return testClient
97
+ }
98
+
99
+ export function getBotUsername(): string {
100
+ if (!botUsername) throw new Error("Bot username not set. Call setup() first.")
101
+ return botUsername
102
+ }
103
+
104
+ /**
105
+ * Read stdout of a process until a regex pattern matches.
106
+ * Uses `for await` on the ReadableStream — reliable with Bun.spawn.
107
+ * Returns the first capture group if present, otherwise the full match.
108
+ */
109
+ async function waitForPattern(
110
+ proc: Subprocess,
111
+ pattern: RegExp,
112
+ timeoutMs: number,
113
+ label: string,
114
+ ): Promise<string> {
115
+ if (!proc.stdout) throw new Error(`${label} process has no stdout`)
116
+
117
+ let output = ""
118
+ const decoder = new TextDecoder()
119
+
120
+ // Race the stream against a timeout
121
+ const result = await Promise.race([
122
+ (async () => {
123
+ for await (const chunk of proc.stdout!) {
124
+ output += decoder.decode(chunk)
125
+ const match = output.match(pattern)
126
+ if (match) {
127
+ return match[1] ?? match[0]
128
+ }
129
+ }
130
+ // Stream ended without match
131
+ return null
132
+ })(),
133
+ new Promise<null>((resolve) =>
134
+ setTimeout(() => resolve(null), timeoutMs),
135
+ ),
136
+ ])
137
+
138
+ if (result) return result
139
+
140
+ if (proc.exitCode !== null) {
141
+ throw new Error(`${label} exited with code ${proc.exitCode}. Output: ${output}`)
142
+ }
143
+
144
+ throw new Error(`${label} did not match "${pattern}" within ${timeoutMs}ms. Output: ${output}`)
145
+ }
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@pedrohnas/opencode-telegram",
3
- "version": "0.1.0",
4
- "description": "Telegram bot for OpenCode — AI coding assistant via Telegram",
3
+ "version": "1.2.0",
5
4
  "type": "module",
6
- "bin": {
7
- "opencode-telegram": "./src/index.ts"
8
- },
9
- "files": [
10
- "src/**/*.ts",
11
- "!src/**/*.test.ts"
12
- ],
13
5
  "license": "MIT",
6
+ "scripts": {
7
+ "dev": "bun run src/index.ts",
8
+ "test": "bun test src/",
9
+ "test:e2e": "bun test e2e/",
10
+ "typecheck": "tsgo --noEmit"
11
+ },
14
12
  "dependencies": {
15
- "@opencode-ai/sdk": "^1.1.53",
13
+ "@opencode-ai/sdk": "workspace:*",
16
14
  "grammy": "^1.35.0"
17
15
  },
18
- "engines": {
19
- "bun": ">=1.0.0"
16
+ "devDependencies": {
17
+ "@types/bun": "^1.2.0",
18
+ "@types/node": "catalog:",
19
+ "typescript": "catalog:",
20
+ "@typescript/native-preview": "catalog:",
21
+ "telegram": "^2.26.16"
20
22
  }
21
23
  }