@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.
- package/.env.example +17 -0
- package/bunfig.toml +2 -0
- package/docs/PROGRESS.md +327 -0
- package/docs/mapping.md +326 -0
- package/docs/plans/phase-1.md +176 -0
- package/docs/plans/phase-2.md +235 -0
- package/docs/plans/phase-3.md +485 -0
- package/docs/plans/phase-4.md +566 -0
- package/docs/spec.md +2055 -0
- package/e2e/client.ts +24 -0
- package/e2e/helpers.ts +119 -0
- package/e2e/phase-0.test.ts +30 -0
- package/e2e/phase-1.test.ts +48 -0
- package/e2e/phase-2.test.ts +54 -0
- package/e2e/phase-3.test.ts +142 -0
- package/e2e/phase-4.test.ts +96 -0
- package/e2e/runner.ts +145 -0
- package/package.json +14 -12
- package/scripts/gen-session.ts +49 -0
- package/src/bot.test.ts +301 -0
- package/src/bot.ts +91 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +15 -0
- package/src/event-bus.test.ts +175 -0
- package/src/handlers/allowlist.test.ts +60 -0
- package/src/handlers/allowlist.ts +33 -0
- package/src/handlers/cancel.test.ts +105 -0
- package/src/handlers/permissions.test.ts +72 -0
- package/src/handlers/questions.test.ts +107 -0
- package/src/handlers/sessions.test.ts +479 -0
- package/src/handlers/sessions.ts +202 -0
- package/src/handlers/typing.test.ts +60 -0
- package/src/index.ts +26 -0
- package/src/pending-requests.test.ts +64 -0
- package/src/send/chunker.test.ts +74 -0
- package/src/send/draft-stream.test.ts +229 -0
- package/src/send/format.test.ts +143 -0
- package/src/send/tool-progress.test.ts +70 -0
- package/src/session-manager.test.ts +198 -0
- package/src/session-manager.ts +23 -0
- package/src/turn-manager.test.ts +155 -0
- package/src/turn-manager.ts +5 -0
- 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": "
|
|
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": "
|
|
13
|
+
"@opencode-ai/sdk": "workspace:*",
|
|
16
14
|
"grammy": "^1.35.0"
|
|
17
15
|
},
|
|
18
|
-
"
|
|
19
|
-
"bun": "
|
|
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
|
}
|