@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test"
|
|
2
|
+
import { startTypingLoop } from "./typing"
|
|
3
|
+
|
|
4
|
+
describe("startTypingLoop", () => {
|
|
5
|
+
test("calls sendAction immediately on start", () => {
|
|
6
|
+
const sendAction = mock(async () => {})
|
|
7
|
+
const ac = new AbortController()
|
|
8
|
+
|
|
9
|
+
startTypingLoop(123, sendAction, ac.signal)
|
|
10
|
+
expect(sendAction).toHaveBeenCalledTimes(1)
|
|
11
|
+
expect(sendAction).toHaveBeenCalledWith(123, "typing")
|
|
12
|
+
|
|
13
|
+
ac.abort()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("calls sendAction again after ~4 seconds", async () => {
|
|
17
|
+
const sendAction = mock(async () => {})
|
|
18
|
+
const ac = new AbortController()
|
|
19
|
+
|
|
20
|
+
startTypingLoop(123, sendAction, ac.signal)
|
|
21
|
+
expect(sendAction).toHaveBeenCalledTimes(1)
|
|
22
|
+
|
|
23
|
+
// Wait a bit over 4 seconds for the next tick
|
|
24
|
+
await new Promise((r) => setTimeout(r, 4200))
|
|
25
|
+
|
|
26
|
+
expect(sendAction.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
27
|
+
ac.abort()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("stops calling when signal is aborted", async () => {
|
|
31
|
+
const sendAction = mock(async () => {})
|
|
32
|
+
const ac = new AbortController()
|
|
33
|
+
|
|
34
|
+
startTypingLoop(123, sendAction, ac.signal)
|
|
35
|
+
expect(sendAction).toHaveBeenCalledTimes(1)
|
|
36
|
+
|
|
37
|
+
ac.abort()
|
|
38
|
+
|
|
39
|
+
// Wait long enough for another tick to fire (if it weren't aborted)
|
|
40
|
+
await new Promise((r) => setTimeout(r, 4500))
|
|
41
|
+
|
|
42
|
+
// Should still be 1 (no more calls after abort)
|
|
43
|
+
expect(sendAction).toHaveBeenCalledTimes(1)
|
|
44
|
+
}, 10000)
|
|
45
|
+
|
|
46
|
+
test("does not throw if sendAction rejects", async () => {
|
|
47
|
+
const sendAction = mock(async () => {
|
|
48
|
+
throw new Error("network error")
|
|
49
|
+
})
|
|
50
|
+
const ac = new AbortController()
|
|
51
|
+
|
|
52
|
+
// Should not throw
|
|
53
|
+
startTypingLoop(123, sendAction, ac.signal)
|
|
54
|
+
expect(sendAction).toHaveBeenCalledTimes(1)
|
|
55
|
+
|
|
56
|
+
// Wait for next tick to confirm no unhandled rejection
|
|
57
|
+
await new Promise((r) => setTimeout(r, 4500))
|
|
58
|
+
ac.abort()
|
|
59
|
+
})
|
|
60
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,14 @@ const pendingRequests = new PendingRequests({
|
|
|
52
52
|
ttlMs: 10 * 60 * 1000, // 10 minutes
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
// --- Restore sessions from previous bot runs ---
|
|
56
|
+
try {
|
|
57
|
+
const restored = await sessionManager.restore(sdk)
|
|
58
|
+
if (restored > 0) console.log(`Restored ${restored} sessions`)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error("Session restore failed:", err)
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// --- Create bot with deps ---
|
|
56
64
|
const bot = createBot(config, { sdk, sessionManager, turnManager, pendingRequests })
|
|
57
65
|
|
|
@@ -249,6 +257,24 @@ const shutdown = async () => {
|
|
|
249
257
|
process.on("SIGINT", shutdown)
|
|
250
258
|
process.on("SIGTERM", shutdown)
|
|
251
259
|
|
|
260
|
+
// --- Register Telegram command menu ---
|
|
261
|
+
try {
|
|
262
|
+
await bot.api.setMyCommands([
|
|
263
|
+
{ command: "start", description: "Welcome message" },
|
|
264
|
+
{ command: "new", description: "New session" },
|
|
265
|
+
{ command: "cancel", description: "Stop generation" },
|
|
266
|
+
{ command: "list", description: "List sessions" },
|
|
267
|
+
{ command: "rename", description: "Rename session" },
|
|
268
|
+
{ command: "delete", description: "Delete session" },
|
|
269
|
+
{ command: "info", description: "Session info" },
|
|
270
|
+
{ command: "history", description: "Recent messages" },
|
|
271
|
+
{ command: "summarize", description: "Summarize session" },
|
|
272
|
+
])
|
|
273
|
+
console.log("Command menu registered")
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("Failed to register commands:", err)
|
|
276
|
+
}
|
|
277
|
+
|
|
252
278
|
// --- Start polling ---
|
|
253
279
|
await bot.start({
|
|
254
280
|
onStart: () => console.log("Bot started"),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test"
|
|
2
|
+
import { PendingRequests, type PendingEntry } from "./pending-requests"
|
|
3
|
+
|
|
4
|
+
describe("PendingRequests", () => {
|
|
5
|
+
let pr: PendingRequests
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
pr = new PendingRequests({ maxEntries: 5, ttlMs: 1000 })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("set() stores and get() retrieves", () => {
|
|
12
|
+
const entry: PendingEntry = { type: "permission", createdAt: Date.now() }
|
|
13
|
+
pr.set("req1", entry)
|
|
14
|
+
expect(pr.get("req1")).toEqual(entry)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("get() returns undefined for unknown requestID", () => {
|
|
18
|
+
expect(pr.get("unknown")).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("delete() removes entry and returns true", () => {
|
|
22
|
+
pr.set("req1", { type: "permission", createdAt: Date.now() })
|
|
23
|
+
expect(pr.delete("req1")).toBe(true)
|
|
24
|
+
expect(pr.get("req1")).toBeUndefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("delete() returns false for unknown requestID", () => {
|
|
28
|
+
expect(pr.delete("unknown")).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("evicts oldest when maxEntries exceeded", () => {
|
|
32
|
+
for (let i = 1; i <= 6; i++) {
|
|
33
|
+
pr.set(`req${i}`, { type: "permission", createdAt: Date.now() })
|
|
34
|
+
}
|
|
35
|
+
// req1 was evicted (oldest)
|
|
36
|
+
expect(pr.get("req1")).toBeUndefined()
|
|
37
|
+
// req6 still exists
|
|
38
|
+
expect(pr.get("req6")).toBeDefined()
|
|
39
|
+
expect(pr.size).toBe(5)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("get() returns undefined for expired entries", async () => {
|
|
43
|
+
pr.set("req1", { type: "permission", createdAt: Date.now() })
|
|
44
|
+
expect(pr.get("req1")).toBeDefined()
|
|
45
|
+
|
|
46
|
+
// Wait for TTL to expire
|
|
47
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
48
|
+
expect(pr.get("req1")).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("cleanup() removes all expired entries", async () => {
|
|
52
|
+
pr.set("req1", { type: "permission", createdAt: Date.now() })
|
|
53
|
+
pr.set("req2", { type: "question", createdAt: Date.now() })
|
|
54
|
+
|
|
55
|
+
await new Promise((r) => setTimeout(r, 1100))
|
|
56
|
+
|
|
57
|
+
// Add a fresh one
|
|
58
|
+
pr.set("req3", { type: "permission", createdAt: Date.now() })
|
|
59
|
+
|
|
60
|
+
pr.cleanup()
|
|
61
|
+
expect(pr.size).toBe(1)
|
|
62
|
+
expect(pr.get("req3")).toBeDefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { chunkMessage } from "./chunker"
|
|
3
|
+
|
|
4
|
+
describe("chunkMessage", () => {
|
|
5
|
+
test("returns empty array for empty string", () => {
|
|
6
|
+
expect(chunkMessage("")).toEqual([])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("returns single chunk for short message", () => {
|
|
10
|
+
const chunks = chunkMessage("hello world")
|
|
11
|
+
expect(chunks).toEqual(["hello world"])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("returns single chunk at exactly the limit", () => {
|
|
15
|
+
const text = "a".repeat(4096)
|
|
16
|
+
const chunks = chunkMessage(text)
|
|
17
|
+
expect(chunks).toHaveLength(1)
|
|
18
|
+
expect(chunks[0]).toBe(text)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("splits long message into multiple chunks", () => {
|
|
22
|
+
const text = "a".repeat(8192)
|
|
23
|
+
const chunks = chunkMessage(text)
|
|
24
|
+
expect(chunks.length).toBeGreaterThanOrEqual(2)
|
|
25
|
+
expect(chunks.join("")).toBe(text)
|
|
26
|
+
for (const chunk of chunks) {
|
|
27
|
+
expect(chunk.length).toBeLessThanOrEqual(4096)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("splits at newline boundary when possible", () => {
|
|
32
|
+
// Create text where a newline appears near the split point
|
|
33
|
+
const line = "x".repeat(100) + "\n"
|
|
34
|
+
const text = line.repeat(50) // 50 * 101 = 5050 chars
|
|
35
|
+
const chunks = chunkMessage(text)
|
|
36
|
+
expect(chunks.length).toBeGreaterThanOrEqual(2)
|
|
37
|
+
// Each chunk should end at a newline (except possibly the last)
|
|
38
|
+
for (let i = 0; i < chunks.length - 1; i++) {
|
|
39
|
+
expect(chunks[i].endsWith("\n")).toBe(true)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("never splits inside an HTML tag", () => {
|
|
44
|
+
// Create text with an HTML tag right at the split boundary
|
|
45
|
+
const before = "a".repeat(4090)
|
|
46
|
+
const tag = '<b>bold</b>'
|
|
47
|
+
const after = "b".repeat(100)
|
|
48
|
+
const text = before + tag + after
|
|
49
|
+
const chunks = chunkMessage(text)
|
|
50
|
+
// The tag should be entirely in one chunk
|
|
51
|
+
const fullText = chunks.join("")
|
|
52
|
+
expect(fullText).toBe(text)
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
// No chunk should contain a partial tag (< without matching >)
|
|
55
|
+
const opens = (chunk.match(/</g) || []).length
|
|
56
|
+
const closes = (chunk.match(/>/g) || []).length
|
|
57
|
+
expect(opens).toBe(closes)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("handles custom limit", () => {
|
|
62
|
+
const text = "a".repeat(200)
|
|
63
|
+
const chunks = chunkMessage(text, 100)
|
|
64
|
+
expect(chunks).toHaveLength(2)
|
|
65
|
+
expect(chunks[0].length).toBe(100)
|
|
66
|
+
expect(chunks[1].length).toBe(100)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("preserves all content when chunking", () => {
|
|
70
|
+
const text = "Hello <b>bold</b> and <code>code</code> text ".repeat(200)
|
|
71
|
+
const chunks = chunkMessage(text)
|
|
72
|
+
expect(chunks.join("")).toBe(text)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
import { DraftStream, type DraftStreamDeps } from "./draft-stream"
|
|
3
|
+
|
|
4
|
+
function createMockDeps(): DraftStreamDeps & {
|
|
5
|
+
sendMessage: ReturnType<typeof mock>
|
|
6
|
+
editMessageText: ReturnType<typeof mock>
|
|
7
|
+
} {
|
|
8
|
+
return {
|
|
9
|
+
sendMessage: mock(async (_chatId: number, _text: string, _opts?: any) => ({
|
|
10
|
+
message_id: 42,
|
|
11
|
+
})),
|
|
12
|
+
editMessageText: mock(async (_chatId: number, _msgId: number, _text: string, _opts?: any) => {}),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sleep(ms: number): Promise<void> {
|
|
17
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("DraftStream", () => {
|
|
21
|
+
let deps: ReturnType<typeof createMockDeps>
|
|
22
|
+
let ac: AbortController
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
deps = createMockDeps()
|
|
26
|
+
ac = new AbortController()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// --- Initial send ---
|
|
30
|
+
|
|
31
|
+
test("first update sends a new message via sendMessage", async () => {
|
|
32
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
33
|
+
await ds.update("hello")
|
|
34
|
+
expect(deps.sendMessage).toHaveBeenCalledTimes(1)
|
|
35
|
+
ac.abort()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("first update stores messageId from sendMessage response", async () => {
|
|
39
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
40
|
+
expect(ds.getMessageId()).toBeNull()
|
|
41
|
+
await ds.update("hello")
|
|
42
|
+
expect(ds.getMessageId()).toBe(42)
|
|
43
|
+
ac.abort()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("first update truncates text to 4096 chars", async () => {
|
|
47
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
48
|
+
const longText = "x".repeat(5000)
|
|
49
|
+
await ds.update(longText)
|
|
50
|
+
// The text sent should be truncated
|
|
51
|
+
const sentText = deps.sendMessage.mock.calls[0][1] as string
|
|
52
|
+
expect(sentText.length).toBeLessThanOrEqual(4096)
|
|
53
|
+
ac.abort()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("first update sends with HTML parse_mode", async () => {
|
|
57
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
58
|
+
await ds.update("hello **bold**")
|
|
59
|
+
const opts = deps.sendMessage.mock.calls[0][2]
|
|
60
|
+
expect(opts?.parse_mode).toBe("HTML")
|
|
61
|
+
ac.abort()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// --- Throttled edits ---
|
|
65
|
+
|
|
66
|
+
test("second update schedules an edit (not immediate)", async () => {
|
|
67
|
+
const ds = new DraftStream(deps, 123, ac.signal, 100)
|
|
68
|
+
await ds.update("hello")
|
|
69
|
+
await ds.update("hello world")
|
|
70
|
+
// Edit not called yet (still in throttle window)
|
|
71
|
+
expect(deps.editMessageText).toHaveBeenCalledTimes(0)
|
|
72
|
+
ac.abort()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("after throttle delay, editMessageText is called", async () => {
|
|
76
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
77
|
+
await ds.update("hello")
|
|
78
|
+
await ds.update("hello world")
|
|
79
|
+
await sleep(100)
|
|
80
|
+
expect(deps.editMessageText).toHaveBeenCalledTimes(1)
|
|
81
|
+
ac.abort()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("multiple rapid updates only result in one edit (latest text wins)", async () => {
|
|
85
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
86
|
+
await ds.update("v1")
|
|
87
|
+
await ds.update("v2")
|
|
88
|
+
await ds.update("v3")
|
|
89
|
+
await ds.update("v4")
|
|
90
|
+
await sleep(100)
|
|
91
|
+
expect(deps.editMessageText).toHaveBeenCalledTimes(1)
|
|
92
|
+
// The edit should contain the latest text (v4), converted to HTML
|
|
93
|
+
const editedText = deps.editMessageText.mock.calls[0][2] as string
|
|
94
|
+
expect(editedText).toContain("v4")
|
|
95
|
+
ac.abort()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("edit uses HTML parse_mode", async () => {
|
|
99
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
100
|
+
await ds.update("hello")
|
|
101
|
+
await ds.update("updated")
|
|
102
|
+
await sleep(100)
|
|
103
|
+
const opts = deps.editMessageText.mock.calls[0][3]
|
|
104
|
+
expect(opts?.parse_mode).toBe("HTML")
|
|
105
|
+
ac.abort()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// --- Error handling ---
|
|
109
|
+
|
|
110
|
+
test("message is not modified error is silently ignored", async () => {
|
|
111
|
+
deps.editMessageText = mock(async () => {
|
|
112
|
+
throw new Error("Bad Request: message is not modified")
|
|
113
|
+
})
|
|
114
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
115
|
+
await ds.update("hello")
|
|
116
|
+
await ds.update("hello") // same text but triggers edit
|
|
117
|
+
// Force different pending to trigger edit
|
|
118
|
+
await ds.update("different")
|
|
119
|
+
await sleep(100)
|
|
120
|
+
// Should not throw, stream still active
|
|
121
|
+
expect(ds.isStopped()).toBe(false)
|
|
122
|
+
ac.abort()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("can't parse entities falls back to plain text edit", async () => {
|
|
126
|
+
let callCount = 0
|
|
127
|
+
deps.editMessageText = mock(async (_cid: number, _mid: number, _text: string, opts?: any) => {
|
|
128
|
+
callCount++
|
|
129
|
+
if (callCount === 1 && opts?.parse_mode === "HTML") {
|
|
130
|
+
throw new Error("Bad Request: can't parse entities")
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
134
|
+
await ds.update("hello")
|
|
135
|
+
await ds.update("**broken html")
|
|
136
|
+
await sleep(100)
|
|
137
|
+
// Should have retried without parse_mode
|
|
138
|
+
expect(deps.editMessageText.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
139
|
+
expect(ds.hasHtmlFailed()).toBe(true)
|
|
140
|
+
ac.abort()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("after HTML failure, subsequent edits use plain text", async () => {
|
|
144
|
+
let callCount = 0
|
|
145
|
+
deps.editMessageText = mock(async (_cid: number, _mid: number, _text: string, opts?: any) => {
|
|
146
|
+
callCount++
|
|
147
|
+
if (callCount === 1 && opts?.parse_mode === "HTML") {
|
|
148
|
+
throw new Error("Bad Request: can't parse entities")
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
152
|
+
await ds.update("hello")
|
|
153
|
+
await ds.update("update1")
|
|
154
|
+
await sleep(100)
|
|
155
|
+
expect(ds.hasHtmlFailed()).toBe(true)
|
|
156
|
+
|
|
157
|
+
// Next edit should not use HTML
|
|
158
|
+
await ds.update("update2")
|
|
159
|
+
await sleep(100)
|
|
160
|
+
const lastCall = deps.editMessageText.mock.calls[deps.editMessageText.mock.calls.length - 1]
|
|
161
|
+
const lastOpts = lastCall[3]
|
|
162
|
+
expect(lastOpts?.parse_mode).toBeUndefined()
|
|
163
|
+
ac.abort()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("message to edit not found stops the stream", async () => {
|
|
167
|
+
deps.editMessageText = mock(async () => {
|
|
168
|
+
throw new Error("Bad Request: message to edit not found")
|
|
169
|
+
})
|
|
170
|
+
const ds = new DraftStream(deps, 123, ac.signal, 50)
|
|
171
|
+
await ds.update("hello")
|
|
172
|
+
await ds.update("updated")
|
|
173
|
+
await sleep(100)
|
|
174
|
+
expect(ds.isStopped()).toBe(true)
|
|
175
|
+
ac.abort()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("sendMessage failure on first update keeps messageId null", async () => {
|
|
179
|
+
deps.sendMessage = mock(async () => {
|
|
180
|
+
throw new Error("network error")
|
|
181
|
+
})
|
|
182
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
183
|
+
await ds.update("hello")
|
|
184
|
+
expect(ds.getMessageId()).toBeNull()
|
|
185
|
+
ac.abort()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// --- stop() ---
|
|
189
|
+
|
|
190
|
+
test("stop clears pending timer", async () => {
|
|
191
|
+
const ds = new DraftStream(deps, 123, ac.signal, 200)
|
|
192
|
+
await ds.update("hello")
|
|
193
|
+
await ds.update("updated")
|
|
194
|
+
ds.stop()
|
|
195
|
+
await sleep(250)
|
|
196
|
+
// Edit should not have fired
|
|
197
|
+
expect(deps.editMessageText).toHaveBeenCalledTimes(0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("after stop, update is a no-op", async () => {
|
|
201
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
202
|
+
ds.stop()
|
|
203
|
+
await ds.update("hello")
|
|
204
|
+
expect(deps.sendMessage).toHaveBeenCalledTimes(0)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("stop sets isStopped to true", () => {
|
|
208
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
209
|
+
expect(ds.isStopped()).toBe(false)
|
|
210
|
+
ds.stop()
|
|
211
|
+
expect(ds.isStopped()).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// --- AbortSignal ---
|
|
215
|
+
|
|
216
|
+
test("aborting signal calls stop automatically", async () => {
|
|
217
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
218
|
+
await ds.update("hello")
|
|
219
|
+
expect(ds.isStopped()).toBe(false)
|
|
220
|
+
ac.abort()
|
|
221
|
+
expect(ds.isStopped()).toBe(true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test("already-aborted signal stops immediately on construction", () => {
|
|
225
|
+
ac.abort()
|
|
226
|
+
const ds = new DraftStream(deps, 123, ac.signal)
|
|
227
|
+
expect(ds.isStopped()).toBe(true)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { markdownToTelegramHtml } from "./format"
|
|
3
|
+
|
|
4
|
+
describe("markdownToTelegramHtml", () => {
|
|
5
|
+
// --- HTML escaping (must happen FIRST, before any tag insertion) ---
|
|
6
|
+
|
|
7
|
+
test("escapes & < > in plain text", () => {
|
|
8
|
+
expect(markdownToTelegramHtml("A & B < C > D")).toBe(
|
|
9
|
+
"A & B < C > D",
|
|
10
|
+
)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("does not double-escape already-escaped HTML", () => {
|
|
14
|
+
// Input is markdown, not HTML — so "&" in markdown is literal text
|
|
15
|
+
// and should be escaped to "&amp;"
|
|
16
|
+
expect(markdownToTelegramHtml("&")).toBe("&amp;")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// --- Bold ---
|
|
20
|
+
|
|
21
|
+
test("converts **bold** to <b>", () => {
|
|
22
|
+
expect(markdownToTelegramHtml("**bold**")).toBe("<b>bold</b>")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("converts __bold__ to <b>", () => {
|
|
26
|
+
expect(markdownToTelegramHtml("__bold__")).toBe("<b>bold</b>")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// --- Italic ---
|
|
30
|
+
|
|
31
|
+
test("converts *italic* to <i>", () => {
|
|
32
|
+
expect(markdownToTelegramHtml("*italic*")).toBe("<i>italic</i>")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("converts _italic_ to <i>", () => {
|
|
36
|
+
expect(markdownToTelegramHtml("_italic_")).toBe("<i>italic</i>")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// --- Strikethrough ---
|
|
40
|
+
|
|
41
|
+
test("converts ~~strike~~ to <s>", () => {
|
|
42
|
+
expect(markdownToTelegramHtml("~~strike~~")).toBe("<s>strike</s>")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// --- Inline code ---
|
|
46
|
+
|
|
47
|
+
test("converts `code` to <code>", () => {
|
|
48
|
+
expect(markdownToTelegramHtml("`code`")).toBe("<code>code</code>")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("escapes HTML inside inline code", () => {
|
|
52
|
+
expect(markdownToTelegramHtml("`<div>&</div>`")).toBe(
|
|
53
|
+
"<code><div>&</div></code>",
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// --- Code blocks ---
|
|
58
|
+
|
|
59
|
+
test("converts fenced code block without language", () => {
|
|
60
|
+
expect(markdownToTelegramHtml("```\ncode\n```")).toBe(
|
|
61
|
+
"<pre><code>code</code></pre>",
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("converts fenced code block with language", () => {
|
|
66
|
+
expect(markdownToTelegramHtml("```ts\nconst x = 1\n```")).toBe(
|
|
67
|
+
'<pre><code class="language-ts">const x = 1</code></pre>',
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("escapes HTML inside code blocks", () => {
|
|
72
|
+
expect(markdownToTelegramHtml("```\n<div>&</div>\n```")).toBe(
|
|
73
|
+
"<pre><code><div>&</div></code></pre>",
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("preserves newlines inside code blocks", () => {
|
|
78
|
+
expect(markdownToTelegramHtml("```\nline1\nline2\n```")).toBe(
|
|
79
|
+
"<pre><code>line1\nline2</code></pre>",
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// --- Links ---
|
|
84
|
+
|
|
85
|
+
test("converts [text](url) to <a>", () => {
|
|
86
|
+
expect(markdownToTelegramHtml("[click](https://example.com)")).toBe(
|
|
87
|
+
'<a href="https://example.com">click</a>',
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("escapes HTML in link text", () => {
|
|
92
|
+
expect(markdownToTelegramHtml("[A & B](https://example.com)")).toBe(
|
|
93
|
+
'<a href="https://example.com">A & B</a>',
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// --- Nested formatting ---
|
|
98
|
+
|
|
99
|
+
test("handles bold + italic nested", () => {
|
|
100
|
+
const result = markdownToTelegramHtml("**bold *italic***")
|
|
101
|
+
expect(result).toContain("<b>")
|
|
102
|
+
expect(result).toContain("<i>")
|
|
103
|
+
expect(result).toContain("</b>")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// --- Edge cases ---
|
|
107
|
+
|
|
108
|
+
test("returns empty string for empty input", () => {
|
|
109
|
+
expect(markdownToTelegramHtml("")).toBe("")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("passes through plain text unchanged (after escaping)", () => {
|
|
113
|
+
expect(markdownToTelegramHtml("hello world")).toBe("hello world")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("handles multiple paragraphs", () => {
|
|
117
|
+
const result = markdownToTelegramHtml("paragraph 1\n\nparagraph 2")
|
|
118
|
+
expect(result).toContain("paragraph 1")
|
|
119
|
+
expect(result).toContain("paragraph 2")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// --- Headings (convert to bold since Telegram has no heading tags) ---
|
|
123
|
+
|
|
124
|
+
test("converts headings to bold text", () => {
|
|
125
|
+
const result = markdownToTelegramHtml("# Heading")
|
|
126
|
+
expect(result).toContain("<b>Heading</b>")
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// --- Lists ---
|
|
130
|
+
|
|
131
|
+
test("preserves bullet list formatting", () => {
|
|
132
|
+
const result = markdownToTelegramHtml("- item 1\n- item 2")
|
|
133
|
+
expect(result).toContain("item 1")
|
|
134
|
+
expect(result).toContain("item 2")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// --- Blockquotes ---
|
|
138
|
+
|
|
139
|
+
test("converts blockquotes", () => {
|
|
140
|
+
const result = markdownToTelegramHtml("> quoted text")
|
|
141
|
+
expect(result).toContain("quoted text")
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { formatToolStatus } from "./tool-progress"
|
|
3
|
+
|
|
4
|
+
describe("formatToolStatus", () => {
|
|
5
|
+
test("returns null for non-tool part", () => {
|
|
6
|
+
expect(formatToolStatus({ type: "text", text: "hello" })).toBeNull()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("returns running status for tool with status running and title", () => {
|
|
10
|
+
const result = formatToolStatus({
|
|
11
|
+
type: "tool",
|
|
12
|
+
tool: "bash",
|
|
13
|
+
state: { status: "running", title: "ls -la", time: { start: 1 } },
|
|
14
|
+
})
|
|
15
|
+
expect(result).not.toBeNull()
|
|
16
|
+
expect(result).toContain("Running")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("returns pending status for tool with status pending", () => {
|
|
20
|
+
const result = formatToolStatus({
|
|
21
|
+
type: "tool",
|
|
22
|
+
tool: "edit",
|
|
23
|
+
state: { status: "pending", input: {} },
|
|
24
|
+
})
|
|
25
|
+
expect(result).not.toBeNull()
|
|
26
|
+
expect(result).toContain("Preparing")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("returns null for completed tool", () => {
|
|
30
|
+
expect(
|
|
31
|
+
formatToolStatus({
|
|
32
|
+
type: "tool",
|
|
33
|
+
tool: "bash",
|
|
34
|
+
state: { status: "completed", input: {}, output: "", title: "done", time: { start: 1, end: 2 } },
|
|
35
|
+
}),
|
|
36
|
+
).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("returns null for error tool", () => {
|
|
40
|
+
expect(
|
|
41
|
+
formatToolStatus({
|
|
42
|
+
type: "tool",
|
|
43
|
+
tool: "bash",
|
|
44
|
+
state: { status: "error", input: {}, error: "fail", time: { start: 1, end: 2 } },
|
|
45
|
+
}),
|
|
46
|
+
).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("running status includes tool name", () => {
|
|
50
|
+
const result = formatToolStatus({
|
|
51
|
+
type: "tool",
|
|
52
|
+
tool: "bash",
|
|
53
|
+
state: { status: "running", title: "npm test", time: { start: 1 } },
|
|
54
|
+
})
|
|
55
|
+
expect(result).toContain("bash")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("running status includes title text", () => {
|
|
59
|
+
const result = formatToolStatus({
|
|
60
|
+
type: "tool",
|
|
61
|
+
tool: "bash",
|
|
62
|
+
state: { status: "running", title: "npm test", time: { start: 1 } },
|
|
63
|
+
})
|
|
64
|
+
expect(result).toContain("npm test")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("returns null when part.state is undefined", () => {
|
|
68
|
+
expect(formatToolStatus({ type: "tool", tool: "bash" })).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|