@pedrohnas/opencode-telegram 1.2.1 → 1.3.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/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
- package/docs/AUDIT.md +193 -0
- package/docs/PROGRESS.md +188 -0
- package/docs/plans/phase-5.md +410 -0
- package/docs/plans/phase-6.5.md +426 -0
- package/docs/plans/phase-6.md +349 -0
- package/e2e/helpers.ts +34 -0
- package/e2e/phase-5.test.ts +295 -0
- package/e2e/phase-6.5.test.ts +239 -0
- package/e2e/phase-6.test.ts +302 -0
- package/package.json +5 -2
- package/src/api-server.test.ts +309 -0
- package/src/api-server.ts +201 -0
- package/src/bot.test.ts +354 -0
- package/src/bot.ts +200 -2
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/event-bus.test.ts +337 -1
- package/src/event-bus.ts +83 -3
- package/src/handlers/agents.test.ts +122 -0
- package/src/handlers/agents.ts +93 -0
- package/src/handlers/media.test.ts +264 -0
- package/src/handlers/media.ts +168 -0
- package/src/handlers/models.test.ts +319 -0
- package/src/handlers/models.ts +191 -0
- package/src/index.ts +15 -0
- package/src/send/draft-stream.test.ts +76 -0
- package/src/send/draft-stream.ts +13 -1
- package/src/session-manager.test.ts +46 -0
- package/src/session-manager.ts +10 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
|
2
|
+
import { setup, teardown, getClient, getBotUsername } from "./runner"
|
|
3
|
+
import {
|
|
4
|
+
sendAndWait,
|
|
5
|
+
assertHasButtons,
|
|
6
|
+
clickAndWaitEdit,
|
|
7
|
+
} from "./helpers"
|
|
8
|
+
import { Api } from "telegram/tl"
|
|
9
|
+
|
|
10
|
+
const API_BASE = "http://127.0.0.1:4097"
|
|
11
|
+
|
|
12
|
+
describe("Phase 6.5 — Production Hardening + Bot Control API", () => {
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await setup()
|
|
15
|
+
}, 90000)
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await teardown()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// --- /model filtering ---
|
|
22
|
+
|
|
23
|
+
test(
|
|
24
|
+
"/model shows filtered providers (small count, not 86)",
|
|
25
|
+
async () => {
|
|
26
|
+
const client = getClient()
|
|
27
|
+
const bot = getBotUsername()
|
|
28
|
+
const reply = await sendAndWait(client, bot, "/model", 15000)
|
|
29
|
+
assertHasButtons(reply)
|
|
30
|
+
|
|
31
|
+
const markup = reply.replyMarkup as Api.ReplyInlineMarkup
|
|
32
|
+
const providerCount = markup.rows.length
|
|
33
|
+
// Should show only connected providers (typically 4-5, never 86)
|
|
34
|
+
expect(providerCount).toBeGreaterThan(0)
|
|
35
|
+
expect(providerCount).toBeLessThan(10)
|
|
36
|
+
|
|
37
|
+
// Each button should show model count in parentheses
|
|
38
|
+
const firstText = markup.rows[0].buttons[0].text
|
|
39
|
+
expect(firstText).toMatch(/\(\d+\)/)
|
|
40
|
+
},
|
|
41
|
+
30000,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
test(
|
|
45
|
+
"clicking provider shows filtered models (small count per provider)",
|
|
46
|
+
async () => {
|
|
47
|
+
const client = getClient()
|
|
48
|
+
const bot = getBotUsername()
|
|
49
|
+
|
|
50
|
+
const providerMsg = await sendAndWait(client, bot, "/model", 15000)
|
|
51
|
+
const markup = providerMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
52
|
+
const firstButton = markup.rows[0].buttons[0]
|
|
53
|
+
|
|
54
|
+
const modelMsg = await clickAndWaitEdit(
|
|
55
|
+
client,
|
|
56
|
+
bot,
|
|
57
|
+
providerMsg.id,
|
|
58
|
+
firstButton.text,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const modelMarkup = modelMsg.replyMarkup as Api.ReplyInlineMarkup
|
|
62
|
+
// Filtered models: should be small number (e.g. 3-5 per provider, not 22+)
|
|
63
|
+
// Subtract 1 for Back button
|
|
64
|
+
const modelCount = modelMarkup.rows.length - 1
|
|
65
|
+
expect(modelCount).toBeGreaterThan(0)
|
|
66
|
+
expect(modelCount).toBeLessThan(15)
|
|
67
|
+
},
|
|
68
|
+
30000,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// --- Bot Control API ---
|
|
72
|
+
|
|
73
|
+
test(
|
|
74
|
+
"GET /api/sessions returns at least 1 session after sending a message",
|
|
75
|
+
async () => {
|
|
76
|
+
const client = getClient()
|
|
77
|
+
const bot = getBotUsername()
|
|
78
|
+
|
|
79
|
+
// Ensure we have a session by sending a message
|
|
80
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
81
|
+
await sendAndWait(
|
|
82
|
+
client,
|
|
83
|
+
bot,
|
|
84
|
+
"Respond with exactly the single word: hi",
|
|
85
|
+
60000,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Now call the Bot Control API
|
|
89
|
+
const res = await fetch(`${API_BASE}/api/sessions`)
|
|
90
|
+
expect(res.status).toBe(200)
|
|
91
|
+
const sessions = await res.json() as any[]
|
|
92
|
+
expect(sessions.length).toBeGreaterThanOrEqual(1)
|
|
93
|
+
|
|
94
|
+
// Each session should have expected fields
|
|
95
|
+
const session = sessions[0]
|
|
96
|
+
expect(session.chatId).toBeDefined()
|
|
97
|
+
expect(session.sessionId).toBeDefined()
|
|
98
|
+
},
|
|
99
|
+
120000,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
test(
|
|
103
|
+
"POST /api/session/:id/model changes the model override",
|
|
104
|
+
async () => {
|
|
105
|
+
// Get a sessionId from the API
|
|
106
|
+
const sessRes = await fetch(`${API_BASE}/api/sessions`)
|
|
107
|
+
const sessions = await sessRes.json() as any[]
|
|
108
|
+
expect(sessions.length).toBeGreaterThan(0)
|
|
109
|
+
const sessionId = sessions[0].sessionId
|
|
110
|
+
|
|
111
|
+
// Get available models to find a valid providerID/modelID
|
|
112
|
+
const modelsRes = await fetch(`${API_BASE}/api/models`)
|
|
113
|
+
const providers = await modelsRes.json() as any[]
|
|
114
|
+
expect(providers.length).toBeGreaterThan(0)
|
|
115
|
+
const provider = providers[0]
|
|
116
|
+
expect(provider.models.length).toBeGreaterThan(0)
|
|
117
|
+
const model = provider.models[0]
|
|
118
|
+
|
|
119
|
+
// Set model override
|
|
120
|
+
const setRes = await fetch(`${API_BASE}/api/session/${sessionId}/model`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify({ providerID: provider.id, modelID: model.id }),
|
|
124
|
+
})
|
|
125
|
+
expect(setRes.status).toBe(200)
|
|
126
|
+
|
|
127
|
+
// Verify via GET
|
|
128
|
+
const getRes = await fetch(`${API_BASE}/api/session/${sessionId}`)
|
|
129
|
+
const data = await getRes.json() as any
|
|
130
|
+
expect(data.model).toEqual({ providerID: provider.id, modelID: model.id })
|
|
131
|
+
},
|
|
132
|
+
30000,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
test(
|
|
136
|
+
"POST /api/session/:id/agent changes the agent override",
|
|
137
|
+
async () => {
|
|
138
|
+
const sessRes = await fetch(`${API_BASE}/api/sessions`)
|
|
139
|
+
const sessions = await sessRes.json() as any[]
|
|
140
|
+
const sessionId = sessions[0].sessionId
|
|
141
|
+
|
|
142
|
+
// Get available agents
|
|
143
|
+
const agentsRes = await fetch(`${API_BASE}/api/agents`)
|
|
144
|
+
const agents = await agentsRes.json() as any[]
|
|
145
|
+
expect(agents.length).toBeGreaterThan(0)
|
|
146
|
+
const agentName = agents[0].name
|
|
147
|
+
|
|
148
|
+
// Set agent override
|
|
149
|
+
const setRes = await fetch(`${API_BASE}/api/session/${sessionId}/agent`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "Content-Type": "application/json" },
|
|
152
|
+
body: JSON.stringify({ agent: agentName }),
|
|
153
|
+
})
|
|
154
|
+
expect(setRes.status).toBe(200)
|
|
155
|
+
|
|
156
|
+
// Verify via GET
|
|
157
|
+
const getRes = await fetch(`${API_BASE}/api/session/${sessionId}`)
|
|
158
|
+
const data = await getRes.json() as any
|
|
159
|
+
expect(data.agent).toBe(agentName)
|
|
160
|
+
},
|
|
161
|
+
30000,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
test(
|
|
165
|
+
"POST /api/session/:id/new creates a new session",
|
|
166
|
+
async () => {
|
|
167
|
+
const sessRes = await fetch(`${API_BASE}/api/sessions`)
|
|
168
|
+
const sessions = await sessRes.json() as any[]
|
|
169
|
+
const oldSessionId = sessions[0].sessionId
|
|
170
|
+
|
|
171
|
+
// Create new session
|
|
172
|
+
const newRes = await fetch(`${API_BASE}/api/session/${oldSessionId}/new`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
})
|
|
175
|
+
expect(newRes.status).toBe(200)
|
|
176
|
+
const data = await newRes.json() as any
|
|
177
|
+
expect(data.sessionId).toBeDefined()
|
|
178
|
+
expect(data.oldSessionId).toBe(oldSessionId)
|
|
179
|
+
expect(data.sessionId).not.toBe(oldSessionId)
|
|
180
|
+
},
|
|
181
|
+
30000,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
test(
|
|
185
|
+
"GET /api/models returns providers with filtered models",
|
|
186
|
+
async () => {
|
|
187
|
+
const res = await fetch(`${API_BASE}/api/models`)
|
|
188
|
+
expect(res.status).toBe(200)
|
|
189
|
+
const providers = await res.json() as any[]
|
|
190
|
+
expect(providers.length).toBeGreaterThan(0)
|
|
191
|
+
|
|
192
|
+
// Each provider should have models
|
|
193
|
+
for (const p of providers) {
|
|
194
|
+
expect(p.id).toBeDefined()
|
|
195
|
+
expect(p.name).toBeDefined()
|
|
196
|
+
expect(p.models.length).toBeGreaterThan(0)
|
|
197
|
+
// Each model should have expected fields
|
|
198
|
+
for (const m of p.models) {
|
|
199
|
+
expect(m.id).toBeDefined()
|
|
200
|
+
expect(m.name).toBeDefined()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
15000,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
test(
|
|
208
|
+
"GET /api/agents returns agents",
|
|
209
|
+
async () => {
|
|
210
|
+
const res = await fetch(`${API_BASE}/api/agents`)
|
|
211
|
+
expect(res.status).toBe(200)
|
|
212
|
+
const agents = await res.json() as any[]
|
|
213
|
+
expect(agents.length).toBeGreaterThan(0)
|
|
214
|
+
expect(agents[0].name).toBeDefined()
|
|
215
|
+
},
|
|
216
|
+
15000,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
// --- regression ---
|
|
220
|
+
|
|
221
|
+
test(
|
|
222
|
+
"regression: text message works after API model/agent changes",
|
|
223
|
+
async () => {
|
|
224
|
+
const client = getClient()
|
|
225
|
+
const bot = getBotUsername()
|
|
226
|
+
|
|
227
|
+
const reply = await sendAndWait(
|
|
228
|
+
client,
|
|
229
|
+
bot,
|
|
230
|
+
"Respond with exactly the single word: pong",
|
|
231
|
+
60000,
|
|
232
|
+
)
|
|
233
|
+
expect(reply).toBeDefined()
|
|
234
|
+
const text = reply.text ?? reply.message ?? ""
|
|
235
|
+
expect(text.length).toBeGreaterThan(0)
|
|
236
|
+
},
|
|
237
|
+
120000,
|
|
238
|
+
)
|
|
239
|
+
})
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
|
2
|
+
import { setup, teardown, getClient, getBotUsername } from "./runner"
|
|
3
|
+
import { sendAndWait } from "./helpers"
|
|
4
|
+
import { CustomFile } from "telegram/client/uploads"
|
|
5
|
+
import { deflateSync } from "node:zlib"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper: send a file to the bot and wait for a reply.
|
|
9
|
+
* Uses gramjs client.sendFile() to upload and then polls for bot response.
|
|
10
|
+
*/
|
|
11
|
+
async function sendFileAndWait(
|
|
12
|
+
client: ReturnType<typeof getClient>,
|
|
13
|
+
botUsername: string,
|
|
14
|
+
params: {
|
|
15
|
+
file: Buffer
|
|
16
|
+
filename: string
|
|
17
|
+
caption?: string
|
|
18
|
+
forceDocument?: boolean
|
|
19
|
+
},
|
|
20
|
+
timeoutMs = 60000,
|
|
21
|
+
) {
|
|
22
|
+
// Get the latest message ID before sending
|
|
23
|
+
const messagesBefore = await client.getMessages(botUsername, { limit: 1 })
|
|
24
|
+
const lastIdBefore = messagesBefore[0]?.id ?? 0
|
|
25
|
+
|
|
26
|
+
await client.sendFile(botUsername, {
|
|
27
|
+
file: new CustomFile(params.filename, params.file.length, "", params.file),
|
|
28
|
+
caption: params.caption,
|
|
29
|
+
forceDocument: params.forceDocument,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Poll for bot reply
|
|
33
|
+
const deadline = Date.now() + timeoutMs
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
36
|
+
const messages = await client.getMessages(botUsername, { limit: 5 })
|
|
37
|
+
for (const msg of messages) {
|
|
38
|
+
if (!msg.out && msg.id > lastIdBefore) {
|
|
39
|
+
return msg
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Bot did not reply within ${timeoutMs}ms after sending file`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("Phase 6 — Media & Files", () => {
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
await setup()
|
|
49
|
+
// Start fresh session
|
|
50
|
+
const client = getClient()
|
|
51
|
+
const bot = getBotUsername()
|
|
52
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
53
|
+
}, 90000)
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await teardown()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test(
|
|
60
|
+
"sending photo gets AI response about the image",
|
|
61
|
+
async () => {
|
|
62
|
+
const client = getClient()
|
|
63
|
+
const bot = getBotUsername()
|
|
64
|
+
|
|
65
|
+
// Create a valid 10x10 red PNG using zlib.deflateSync
|
|
66
|
+
const pngBuffer = createValidPng()
|
|
67
|
+
|
|
68
|
+
const reply = await sendFileAndWait(client, bot, {
|
|
69
|
+
file: pngBuffer,
|
|
70
|
+
filename: "test-image.png",
|
|
71
|
+
caption: "What do you see in this image? Respond briefly.",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(reply).toBeDefined()
|
|
75
|
+
const text = reply.text ?? reply.message ?? ""
|
|
76
|
+
expect(text.length).toBeGreaterThan(0)
|
|
77
|
+
},
|
|
78
|
+
120000,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
test(
|
|
82
|
+
"sending document gets AI response",
|
|
83
|
+
async () => {
|
|
84
|
+
const client = getClient()
|
|
85
|
+
const bot = getBotUsername()
|
|
86
|
+
|
|
87
|
+
// Send a small text file as document
|
|
88
|
+
const docBuffer = Buffer.from("Hello, this is a test document.\nIt has two lines.")
|
|
89
|
+
|
|
90
|
+
const reply = await sendFileAndWait(client, bot, {
|
|
91
|
+
file: docBuffer,
|
|
92
|
+
filename: "test-doc.txt",
|
|
93
|
+
caption: "What does this file contain? Respond briefly.",
|
|
94
|
+
forceDocument: true,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(reply).toBeDefined()
|
|
98
|
+
const text = reply.text ?? reply.message ?? ""
|
|
99
|
+
expect(text.length).toBeGreaterThan(0)
|
|
100
|
+
},
|
|
101
|
+
120000,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
test(
|
|
105
|
+
"photo with caption includes caption in response context",
|
|
106
|
+
async () => {
|
|
107
|
+
const client = getClient()
|
|
108
|
+
const bot = getBotUsername()
|
|
109
|
+
|
|
110
|
+
const pngBuffer = createValidPng()
|
|
111
|
+
|
|
112
|
+
const reply = await sendFileAndWait(client, bot, {
|
|
113
|
+
file: pngBuffer,
|
|
114
|
+
filename: "captioned.png",
|
|
115
|
+
caption: "Describe the color of this image in one word.",
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(reply).toBeDefined()
|
|
119
|
+
const text = reply.text ?? reply.message ?? ""
|
|
120
|
+
expect(text.length).toBeGreaterThan(0)
|
|
121
|
+
},
|
|
122
|
+
120000,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
test(
|
|
126
|
+
"regression: text message still works after media",
|
|
127
|
+
async () => {
|
|
128
|
+
const client = getClient()
|
|
129
|
+
const bot = getBotUsername()
|
|
130
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
131
|
+
|
|
132
|
+
const reply = await sendAndWait(
|
|
133
|
+
client,
|
|
134
|
+
bot,
|
|
135
|
+
"Respond with exactly the single word: pong",
|
|
136
|
+
60000,
|
|
137
|
+
)
|
|
138
|
+
expect(reply).toBeDefined()
|
|
139
|
+
const text = reply.text ?? reply.message ?? ""
|
|
140
|
+
expect(text.length).toBeGreaterThan(0)
|
|
141
|
+
},
|
|
142
|
+
120000,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
test(
|
|
146
|
+
"regression: no message fragmentation after media then text",
|
|
147
|
+
async () => {
|
|
148
|
+
const client = getClient()
|
|
149
|
+
const bot = getBotUsername()
|
|
150
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
151
|
+
|
|
152
|
+
// 1. Send a photo and wait for response
|
|
153
|
+
const pngBuffer = createValidPng()
|
|
154
|
+
const mediaReply = await sendFileAndWait(client, bot, {
|
|
155
|
+
file: pngBuffer,
|
|
156
|
+
filename: "test.png",
|
|
157
|
+
caption: "Describe this briefly in one sentence.",
|
|
158
|
+
})
|
|
159
|
+
expect(mediaReply).toBeDefined()
|
|
160
|
+
const mediaReplyId = mediaReply.id
|
|
161
|
+
|
|
162
|
+
// 2. Wait a moment for streaming to fully finish
|
|
163
|
+
await new Promise((r) => setTimeout(r, 3000))
|
|
164
|
+
|
|
165
|
+
// 3. Send text message and wait for response
|
|
166
|
+
const textReply = await sendAndWait(
|
|
167
|
+
client,
|
|
168
|
+
bot,
|
|
169
|
+
"Respond with exactly the single word: pong",
|
|
170
|
+
90000,
|
|
171
|
+
)
|
|
172
|
+
expect(textReply).toBeDefined()
|
|
173
|
+
const textReplyId = textReply.id
|
|
174
|
+
|
|
175
|
+
// 4. Count bot messages between media reply and text reply
|
|
176
|
+
// If streaming is fragmented, there will be many messages in between
|
|
177
|
+
const messages = await client.getMessages(bot, { limit: 20 })
|
|
178
|
+
const botMessages = messages.filter(
|
|
179
|
+
(m) => !m.out && m.id > mediaReplyId && m.id <= textReplyId,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
// Should be exactly 1 message (the text reply).
|
|
183
|
+
// If fragmented, there would be many intermediate messages.
|
|
184
|
+
expect(botMessages.length).toBeLessThanOrEqual(2) // Allow 1 for text reply + maybe 1 for tool status
|
|
185
|
+
},
|
|
186
|
+
180000,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
test(
|
|
190
|
+
"regression: sending text while AI responds to media doesn't fragment",
|
|
191
|
+
async () => {
|
|
192
|
+
const client = getClient()
|
|
193
|
+
const bot = getBotUsername()
|
|
194
|
+
await sendAndWait(client, bot, "/new", 30000)
|
|
195
|
+
|
|
196
|
+
// 1. Send a photo (triggers AI response)
|
|
197
|
+
const pngBuffer = createValidPng()
|
|
198
|
+
const messagesBefore = await client.getMessages(bot, { limit: 1 })
|
|
199
|
+
const lastIdBefore = messagesBefore[0]?.id ?? 0
|
|
200
|
+
|
|
201
|
+
await client.sendFile(bot, {
|
|
202
|
+
file: new CustomFile("interrupt.png", pngBuffer.length, "", pngBuffer),
|
|
203
|
+
caption: "Write a 3 paragraph essay about colors.",
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// 2. Wait briefly for streaming to start, then interrupt with text
|
|
207
|
+
await new Promise((r) => setTimeout(r, 4000))
|
|
208
|
+
|
|
209
|
+
const reply = await sendAndWait(
|
|
210
|
+
client,
|
|
211
|
+
bot,
|
|
212
|
+
"Respond with exactly the single word: pong",
|
|
213
|
+
90000,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
expect(reply).toBeDefined()
|
|
217
|
+
const text = reply.text ?? reply.message ?? ""
|
|
218
|
+
expect(text.length).toBeGreaterThan(0)
|
|
219
|
+
|
|
220
|
+
// 3. Count ALL bot messages after our initial send
|
|
221
|
+
await new Promise((r) => setTimeout(r, 3000))
|
|
222
|
+
const allMessages = await client.getMessages(bot, { limit: 30 })
|
|
223
|
+
const botMessagesAfter = allMessages.filter(
|
|
224
|
+
(m) => !m.out && m.id > lastIdBefore,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
// Should have at most 3 bot messages:
|
|
228
|
+
// 1. Draft from photo response (may be edited/finalized)
|
|
229
|
+
// 2. Possibly aborted/partial photo response
|
|
230
|
+
// 3. The "pong" text reply
|
|
231
|
+
// Fragmentation would cause 5+ messages
|
|
232
|
+
expect(botMessagesAfter.length).toBeLessThanOrEqual(4)
|
|
233
|
+
},
|
|
234
|
+
180000,
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Create a valid 10x10 red PNG image using Node's zlib for correct deflate.
|
|
240
|
+
* Telegram requires properly encoded images — hand-crafted zlib is fragile.
|
|
241
|
+
*/
|
|
242
|
+
function createValidPng(): Buffer {
|
|
243
|
+
const width = 10
|
|
244
|
+
const height = 10
|
|
245
|
+
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
246
|
+
|
|
247
|
+
// IHDR: 10x10, 8-bit RGB
|
|
248
|
+
const ihdrData = Buffer.alloc(13)
|
|
249
|
+
ihdrData.writeUInt32BE(width, 0)
|
|
250
|
+
ihdrData.writeUInt32BE(height, 4)
|
|
251
|
+
ihdrData[8] = 8 // bit depth
|
|
252
|
+
ihdrData[9] = 2 // color type (RGB)
|
|
253
|
+
ihdrData[10] = 0 // compression
|
|
254
|
+
ihdrData[11] = 0 // filter
|
|
255
|
+
ihdrData[12] = 0 // interlace
|
|
256
|
+
const ihdr = createPngChunk("IHDR", ihdrData)
|
|
257
|
+
|
|
258
|
+
// Raw image data: each row starts with filter byte 0, then RGB pixels
|
|
259
|
+
const rawRows: Buffer[] = []
|
|
260
|
+
for (let y = 0; y < height; y++) {
|
|
261
|
+
const row = Buffer.alloc(1 + width * 3) // filter byte + RGB per pixel
|
|
262
|
+
row[0] = 0 // filter: None
|
|
263
|
+
for (let x = 0; x < width; x++) {
|
|
264
|
+
row[1 + x * 3] = 0xff // R
|
|
265
|
+
row[1 + x * 3 + 1] = 0x00 // G
|
|
266
|
+
row[1 + x * 3 + 2] = 0x00 // B
|
|
267
|
+
}
|
|
268
|
+
rawRows.push(row)
|
|
269
|
+
}
|
|
270
|
+
const rawImageData = Buffer.concat(rawRows)
|
|
271
|
+
const compressedData = deflateSync(rawImageData)
|
|
272
|
+
const idat = createPngChunk("IDAT", compressedData)
|
|
273
|
+
|
|
274
|
+
// IEND
|
|
275
|
+
const iend = createPngChunk("IEND", Buffer.alloc(0))
|
|
276
|
+
|
|
277
|
+
return Buffer.concat([signature, ihdr, idat, iend])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createPngChunk(type: string, data: Buffer): Buffer {
|
|
281
|
+
const typeBytes = Buffer.from(type, "ascii")
|
|
282
|
+
const length = Buffer.alloc(4)
|
|
283
|
+
length.writeUInt32BE(data.length, 0)
|
|
284
|
+
const combined = Buffer.concat([typeBytes, data])
|
|
285
|
+
|
|
286
|
+
const crc = crc32(combined)
|
|
287
|
+
const crcBuf = Buffer.alloc(4)
|
|
288
|
+
crcBuf.writeUInt32BE(crc >>> 0, 0)
|
|
289
|
+
|
|
290
|
+
return Buffer.concat([length, combined, crcBuf])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function crc32(data: Buffer): number {
|
|
294
|
+
let crc = ~0
|
|
295
|
+
for (let i = 0; i < data.length; i++) {
|
|
296
|
+
crc ^= data[i]!
|
|
297
|
+
for (let j = 0; j < 8; j++) {
|
|
298
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return ~crc
|
|
302
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pedrohnas/opencode-telegram",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"scripts": {
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
"typecheck": "tsgo --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
+
"@grammyjs/runner": "2.0.3",
|
|
14
|
+
"@grammyjs/transformer-throttler": "1.2.1",
|
|
13
15
|
"@opencode-ai/sdk": "^1.1.53",
|
|
14
|
-
"grammy": "^1.35.0"
|
|
16
|
+
"grammy": "^1.35.0",
|
|
17
|
+
"hono": "4.11.9"
|
|
15
18
|
},
|
|
16
19
|
"devDependencies": {
|
|
17
20
|
"@types/bun": "^1.2.0",
|