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.
@@ -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
+ })
@@ -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
+ }