opencode-telegram-mirror 0.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/AGENTS.md +196 -0
- package/README.md +230 -0
- package/bun.lock +67 -0
- package/package.json +27 -0
- package/src/config.ts +99 -0
- package/src/database.ts +120 -0
- package/src/diff-service.ts +176 -0
- package/src/log.ts +23 -0
- package/src/main.ts +1182 -0
- package/src/message-formatting.ts +202 -0
- package/src/opencode.ts +306 -0
- package/src/permission-handler.ts +242 -0
- package/src/question-handler.ts +391 -0
- package/src/system-message.ts +73 -0
- package/src/telegram.ts +705 -0
- package/test/fixtures/commands-test.json +157 -0
- package/test/fixtures/sample-updates.json +9098 -0
- package/test/mock-server.ts +271 -0
- package/test/run-test.ts +160 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Mock server for testing the Telegram mirror bot.
|
|
4
|
+
*
|
|
5
|
+
* Serves two endpoints:
|
|
6
|
+
* 1. /updates - Mock updates endpoint (replaces Cloudflare DO)
|
|
7
|
+
* 2. /* - Mock Telegram Bot API (captures all sends)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* bun run test/mock-server.ts [fixture-file]
|
|
11
|
+
*
|
|
12
|
+
* Environment:
|
|
13
|
+
* MOCK_PORT - Port to listen on (default: 3456)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from "node:fs/promises"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
|
|
19
|
+
interface TelegramUpdate {
|
|
20
|
+
update_id: number
|
|
21
|
+
chat_id?: string
|
|
22
|
+
payload: {
|
|
23
|
+
update_id: number
|
|
24
|
+
message?: {
|
|
25
|
+
message_id: number
|
|
26
|
+
chat: { id: number }
|
|
27
|
+
text?: string
|
|
28
|
+
date?: number
|
|
29
|
+
from?: { id: number; username?: string }
|
|
30
|
+
message_thread_id?: number
|
|
31
|
+
}
|
|
32
|
+
callback_query?: {
|
|
33
|
+
id: string
|
|
34
|
+
data?: string
|
|
35
|
+
message?: { chat: { id: number }; message_id: number }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
received_at?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CapturedRequest {
|
|
42
|
+
timestamp: number
|
|
43
|
+
method: string
|
|
44
|
+
path: string
|
|
45
|
+
body: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PORT = Number(process.env.MOCK_PORT) || 3456
|
|
49
|
+
const fixtureFile = process.argv[2] || "test/fixtures/sample-updates.json"
|
|
50
|
+
|
|
51
|
+
let updates: TelegramUpdate[] = []
|
|
52
|
+
let updateIndex = 0
|
|
53
|
+
let lastServedUpdateId = 0
|
|
54
|
+
const capturedRequests: CapturedRequest[] = []
|
|
55
|
+
let messageIdCounter = 1000
|
|
56
|
+
|
|
57
|
+
async function loadFixtures() {
|
|
58
|
+
try {
|
|
59
|
+
const content = await readFile(fixtureFile, "utf-8")
|
|
60
|
+
updates = JSON.parse(content) as TelegramUpdate[]
|
|
61
|
+
updates.sort((a, b) => a.update_id - b.update_id)
|
|
62
|
+
console.log(`[mock] Loaded ${updates.length} updates from ${fixtureFile}`)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`[mock] Failed to load fixtures: ${error}`)
|
|
65
|
+
updates = []
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getNextUpdates(since: number, limit = 10): TelegramUpdate[] {
|
|
70
|
+
const filtered = updates.filter(u => u.update_id > since)
|
|
71
|
+
const batch = filtered.slice(0, limit)
|
|
72
|
+
|
|
73
|
+
if (batch.length > 0) {
|
|
74
|
+
lastServedUpdateId = batch[batch.length - 1].update_id
|
|
75
|
+
console.log(`[mock] Serving ${batch.length} updates (since=${since}, max=${lastServedUpdateId})`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return batch
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function handleUpdatesEndpoint(url: URL): Response {
|
|
82
|
+
const since = Number(url.searchParams.get("since") || "0")
|
|
83
|
+
const chatId = url.searchParams.get("chat_id")
|
|
84
|
+
|
|
85
|
+
let batch = getNextUpdates(since)
|
|
86
|
+
|
|
87
|
+
if (chatId) {
|
|
88
|
+
batch = batch.filter(u => u.chat_id === chatId || String(u.payload.message?.chat?.id) === chatId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Response.json({
|
|
92
|
+
updates: batch.map(u => ({ payload: u.payload })),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleTelegramApi(path: string, body: unknown): Response {
|
|
97
|
+
const method = path.split("/").pop() || ""
|
|
98
|
+
|
|
99
|
+
capturedRequests.push({
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
method,
|
|
102
|
+
path,
|
|
103
|
+
body,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
console.log(`[mock] Captured Telegram API: ${method}`, JSON.stringify(body).slice(0, 200))
|
|
107
|
+
|
|
108
|
+
switch (method) {
|
|
109
|
+
case "getMe":
|
|
110
|
+
return Response.json({
|
|
111
|
+
ok: true,
|
|
112
|
+
result: {
|
|
113
|
+
id: 123456789,
|
|
114
|
+
is_bot: true,
|
|
115
|
+
first_name: "TestBot",
|
|
116
|
+
username: "test_bot",
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
case "sendMessage":
|
|
121
|
+
return Response.json({
|
|
122
|
+
ok: true,
|
|
123
|
+
result: {
|
|
124
|
+
message_id: ++messageIdCounter,
|
|
125
|
+
chat: { id: (body as { chat_id?: string })?.chat_id || "-1" },
|
|
126
|
+
date: Math.floor(Date.now() / 1000),
|
|
127
|
+
text: (body as { text?: string })?.text || "",
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
case "editMessageText":
|
|
132
|
+
return Response.json({ ok: true, result: true })
|
|
133
|
+
|
|
134
|
+
case "editForumTopic":
|
|
135
|
+
return Response.json({ ok: true, result: true })
|
|
136
|
+
|
|
137
|
+
case "answerCallbackQuery":
|
|
138
|
+
return Response.json({ ok: true, result: true })
|
|
139
|
+
|
|
140
|
+
case "sendChatAction":
|
|
141
|
+
return Response.json({ ok: true, result: true })
|
|
142
|
+
|
|
143
|
+
case "setMyCommands":
|
|
144
|
+
console.log("[mock] Commands registered:", JSON.stringify((body as { commands?: unknown[] })?.commands))
|
|
145
|
+
return Response.json({ ok: true, result: true })
|
|
146
|
+
|
|
147
|
+
case "getFile":
|
|
148
|
+
return Response.json({
|
|
149
|
+
ok: true,
|
|
150
|
+
result: {
|
|
151
|
+
file_id: "test_file",
|
|
152
|
+
file_path: "photos/test.jpg",
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
default:
|
|
157
|
+
console.log(`[mock] Unknown Telegram method: ${method}`)
|
|
158
|
+
return Response.json({ ok: true, result: {} })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleControlEndpoint(url: URL): Response {
|
|
163
|
+
const action = url.searchParams.get("action")
|
|
164
|
+
|
|
165
|
+
switch (action) {
|
|
166
|
+
case "captured":
|
|
167
|
+
return Response.json({ requests: capturedRequests })
|
|
168
|
+
|
|
169
|
+
case "clear":
|
|
170
|
+
capturedRequests.length = 0
|
|
171
|
+
return Response.json({ ok: true })
|
|
172
|
+
|
|
173
|
+
case "reset":
|
|
174
|
+
updateIndex = 0
|
|
175
|
+
lastServedUpdateId = 0
|
|
176
|
+
capturedRequests.length = 0
|
|
177
|
+
return Response.json({ ok: true })
|
|
178
|
+
|
|
179
|
+
case "inject":
|
|
180
|
+
return new Response("Use POST to inject updates", { status: 400 })
|
|
181
|
+
|
|
182
|
+
case "status":
|
|
183
|
+
return Response.json({
|
|
184
|
+
totalUpdates: updates.length,
|
|
185
|
+
lastServedUpdateId,
|
|
186
|
+
capturedRequests: capturedRequests.length,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
default:
|
|
190
|
+
return Response.json({
|
|
191
|
+
endpoints: {
|
|
192
|
+
"/updates?since=N": "Get updates (mock DO endpoint)",
|
|
193
|
+
"/_control?action=captured": "Get captured Telegram API requests",
|
|
194
|
+
"/_control?action=clear": "Clear captured requests",
|
|
195
|
+
"/_control?action=reset": "Reset update pointer and captured requests",
|
|
196
|
+
"/_control?action=status": "Get server status",
|
|
197
|
+
"POST /_control?action=inject": "Inject a new update",
|
|
198
|
+
"/sendMessage, /editMessageText, etc": "Mock Telegram Bot API",
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function handleControlPost(url: URL, body: unknown): Promise<Response> {
|
|
205
|
+
const action = url.searchParams.get("action")
|
|
206
|
+
|
|
207
|
+
if (action === "inject") {
|
|
208
|
+
const update = body as TelegramUpdate
|
|
209
|
+
if (!update.update_id || !update.payload) {
|
|
210
|
+
return Response.json({ error: "Invalid update format" }, { status: 400 })
|
|
211
|
+
}
|
|
212
|
+
updates.push(update)
|
|
213
|
+
updates.sort((a, b) => a.update_id - b.update_id)
|
|
214
|
+
console.log(`[mock] Injected update ${update.update_id}`)
|
|
215
|
+
return Response.json({ ok: true, totalUpdates: updates.length })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Response.json({ error: "Unknown action" }, { status: 400 })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const server = Bun.serve({
|
|
222
|
+
port: PORT,
|
|
223
|
+
async fetch(req) {
|
|
224
|
+
const url = new URL(req.url)
|
|
225
|
+
const path = url.pathname
|
|
226
|
+
|
|
227
|
+
if (path === "/updates") {
|
|
228
|
+
return handleUpdatesEndpoint(url)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (path === "/_control") {
|
|
232
|
+
if (req.method === "POST") {
|
|
233
|
+
const body = await req.json()
|
|
234
|
+
return handleControlPost(url, body)
|
|
235
|
+
}
|
|
236
|
+
return handleControlEndpoint(url)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (req.method === "POST") {
|
|
240
|
+
try {
|
|
241
|
+
const body = await req.json()
|
|
242
|
+
return handleTelegramApi(path, body)
|
|
243
|
+
} catch {
|
|
244
|
+
return handleTelegramApi(path, {})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (req.method === "GET" && path.includes("/getMe")) {
|
|
249
|
+
return handleTelegramApi(path, {})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (req.method === "GET" && path.includes("/getFile")) {
|
|
253
|
+
return handleTelegramApi(path, {})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return new Response("Not found", { status: 404 })
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
loadFixtures().then(() => {
|
|
261
|
+
console.log(`[mock] Mock server running at http://localhost:${PORT}`)
|
|
262
|
+
console.log(`[mock] Updates endpoint: http://localhost:${PORT}/updates`)
|
|
263
|
+
console.log(`[mock] Control endpoint: http://localhost:${PORT}/_control`)
|
|
264
|
+
console.log(`[mock] Telegram API base: http://localhost:${PORT}`)
|
|
265
|
+
console.log("")
|
|
266
|
+
console.log("[mock] Environment variables for the bot:")
|
|
267
|
+
console.log(` TELEGRAM_UPDATES_URL=http://localhost:${PORT}/updates`)
|
|
268
|
+
console.log(` TELEGRAM_SEND_URL=http://localhost:${PORT}`)
|
|
269
|
+
console.log(" TELEGRAM_BOT_TOKEN=test:token")
|
|
270
|
+
console.log(" TELEGRAM_CHAT_ID=-1003546563617")
|
|
271
|
+
})
|
package/test/run-test.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Test runner that starts the mock server and the bot together.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run test/run-test.ts [fixture-file] [timeout-seconds]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, type Subprocess } from "bun"
|
|
10
|
+
import { setTimeout } from "node:timers/promises"
|
|
11
|
+
import { unlink } from "node:fs/promises"
|
|
12
|
+
|
|
13
|
+
const MOCK_PORT = 3456
|
|
14
|
+
const fixtureFile = process.argv[2] || "test/fixtures/sample-updates.json"
|
|
15
|
+
const timeoutSeconds = Number(process.argv[3]) || 30
|
|
16
|
+
|
|
17
|
+
let mockServer: Subprocess | null = null
|
|
18
|
+
let botProcess: Subprocess | null = null
|
|
19
|
+
|
|
20
|
+
async function waitForServer(url: string, maxAttempts = 20): Promise<boolean> {
|
|
21
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(url)
|
|
24
|
+
if (response.ok) return true
|
|
25
|
+
} catch {
|
|
26
|
+
await setTimeout(250)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startMockServer(): Promise<Subprocess> {
|
|
33
|
+
console.log("[test] Starting mock server...")
|
|
34
|
+
|
|
35
|
+
const proc = spawn({
|
|
36
|
+
cmd: ["bun", "run", "test/mock-server.ts", fixtureFile],
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
MOCK_PORT: String(MOCK_PORT),
|
|
40
|
+
},
|
|
41
|
+
stdout: "inherit",
|
|
42
|
+
stderr: "inherit",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const ready = await waitForServer(`http://localhost:${MOCK_PORT}/_control`)
|
|
46
|
+
if (!ready) {
|
|
47
|
+
throw new Error("Mock server failed to start")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log("[test] Mock server ready")
|
|
51
|
+
return proc
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function startBot(): Promise<Subprocess> {
|
|
55
|
+
console.log("[test] Starting bot...")
|
|
56
|
+
|
|
57
|
+
// Use a startup timestamp before fixture dates to ensure updates pass the filter
|
|
58
|
+
// Fixtures use dates around 1768590000 (Jan 2026)
|
|
59
|
+
const startupTimestamp = "1768589000"
|
|
60
|
+
const testDbPath = "/tmp/telegram-opencode-test.db"
|
|
61
|
+
|
|
62
|
+
const proc = spawn({
|
|
63
|
+
cmd: ["bun", "run", "src/main.ts", "."],
|
|
64
|
+
env: {
|
|
65
|
+
...process.env,
|
|
66
|
+
TELEGRAM_UPDATES_URL: `http://localhost:${MOCK_PORT}/updates`,
|
|
67
|
+
TELEGRAM_SEND_URL: `http://localhost:${MOCK_PORT}`,
|
|
68
|
+
TELEGRAM_BOT_TOKEN: "test:token",
|
|
69
|
+
TELEGRAM_CHAT_ID: "-1003546563617",
|
|
70
|
+
TELEGRAM_DB_PATH: testDbPath,
|
|
71
|
+
OPENCODE_URL: process.env.OPENCODE_URL || "",
|
|
72
|
+
STARTUP_TIMESTAMP: startupTimestamp,
|
|
73
|
+
},
|
|
74
|
+
stdout: "inherit",
|
|
75
|
+
stderr: "inherit",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
console.log("[test] Bot started")
|
|
79
|
+
return proc
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function getCapturedRequests(): Promise<unknown[]> {
|
|
83
|
+
const response = await fetch(`http://localhost:${MOCK_PORT}/_control?action=captured`)
|
|
84
|
+
const data = await response.json() as { requests: unknown[] }
|
|
85
|
+
return data.requests
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function getServerStatus(): Promise<unknown> {
|
|
89
|
+
const response = await fetch(`http://localhost:${MOCK_PORT}/_control?action=status`)
|
|
90
|
+
return response.json()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanup() {
|
|
94
|
+
console.log("\n[test] Cleaning up...")
|
|
95
|
+
|
|
96
|
+
if (botProcess) {
|
|
97
|
+
botProcess.kill()
|
|
98
|
+
botProcess = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (mockServer) {
|
|
102
|
+
mockServer.kill()
|
|
103
|
+
mockServer = null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.on("SIGINT", () => {
|
|
108
|
+
cleanup()
|
|
109
|
+
process.exit(0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
process.on("SIGTERM", () => {
|
|
113
|
+
cleanup()
|
|
114
|
+
process.exit(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
console.log("[test] === Test Runner Starting ===")
|
|
119
|
+
console.log(`[test] Fixture: ${fixtureFile}`)
|
|
120
|
+
console.log(`[test] Timeout: ${timeoutSeconds}s`)
|
|
121
|
+
console.log("")
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await unlink("/tmp/telegram-opencode-test.db").catch(() => {})
|
|
125
|
+
mockServer = await startMockServer()
|
|
126
|
+
|
|
127
|
+
await setTimeout(500)
|
|
128
|
+
|
|
129
|
+
botProcess = await startBot()
|
|
130
|
+
|
|
131
|
+
console.log(`[test] Running for ${timeoutSeconds} seconds...`)
|
|
132
|
+
console.log("[test] Press Ctrl+C to stop early")
|
|
133
|
+
console.log("")
|
|
134
|
+
|
|
135
|
+
await setTimeout(timeoutSeconds * 1000)
|
|
136
|
+
|
|
137
|
+
console.log("\n[test] === Test Complete ===")
|
|
138
|
+
|
|
139
|
+
const status = await getServerStatus()
|
|
140
|
+
console.log("[test] Server status:", JSON.stringify(status, null, 2))
|
|
141
|
+
|
|
142
|
+
const captured = await getCapturedRequests()
|
|
143
|
+
console.log(`[test] Captured ${captured.length} Telegram API requests`)
|
|
144
|
+
|
|
145
|
+
if (captured.length > 0) {
|
|
146
|
+
console.log("[test] Sample requests:")
|
|
147
|
+
for (const req of captured.slice(0, 5)) {
|
|
148
|
+
console.log(" -", JSON.stringify(req).slice(0, 150))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error("[test] Error:", error)
|
|
154
|
+
process.exitCode = 1
|
|
155
|
+
} finally {
|
|
156
|
+
cleanup()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main()
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"rootDir": "./src",
|
|
14
|
+
"types": [
|
|
15
|
+
"bun-types"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"dist",
|
|
24
|
+
"opensrc"
|
|
25
|
+
]
|
|
26
|
+
}
|