@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,175 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from "bun:test"
|
|
2
|
+
import { EventBus, type EventHandler } from "./event-bus"
|
|
3
|
+
import { SessionManager } from "./session-manager"
|
|
4
|
+
|
|
5
|
+
// Helper: create a mock SSE stream from an array of events
|
|
6
|
+
function createMockStream(events: any[]) {
|
|
7
|
+
async function* generate() {
|
|
8
|
+
for (const event of events) {
|
|
9
|
+
yield event
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return { stream: generate() }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Helper: create a mock SDK with controllable event stream
|
|
16
|
+
function createMockSdk(events: any[]) {
|
|
17
|
+
return {
|
|
18
|
+
event: {
|
|
19
|
+
subscribe: mock(async () => createMockStream(events)),
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract sessionId from event (same logic as EventBus)
|
|
25
|
+
function makePartEvent(sessionId: string, text: string) {
|
|
26
|
+
return {
|
|
27
|
+
type: "message.part.updated",
|
|
28
|
+
properties: {
|
|
29
|
+
part: {
|
|
30
|
+
id: "p1",
|
|
31
|
+
sessionID: sessionId,
|
|
32
|
+
messageID: "m1",
|
|
33
|
+
type: "text",
|
|
34
|
+
text,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeIdleEvent(sessionId: string) {
|
|
41
|
+
return {
|
|
42
|
+
type: "session.idle",
|
|
43
|
+
properties: { sessionID: sessionId },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeErrorEvent(sessionId: string) {
|
|
48
|
+
return {
|
|
49
|
+
type: "session.error",
|
|
50
|
+
properties: { sessionID: sessionId, error: "test error" },
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("EventBus", () => {
|
|
55
|
+
let sm: SessionManager
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
sm = new SessionManager({ maxEntries: 100, ttlMs: 60000 })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("routes event to correct chatKey via SessionManager", async () => {
|
|
62
|
+
// Setup: map chat:1 → session s1
|
|
63
|
+
sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
|
|
64
|
+
|
|
65
|
+
const received: any[] = []
|
|
66
|
+
const handler: EventHandler = (sessionId, chatKey, event) => {
|
|
67
|
+
received.push({ sessionId, chatKey, type: event.type })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sdk = createMockSdk([makePartEvent("s1", "hello")])
|
|
71
|
+
const bus = new EventBus({ sdk: sdk as any, sessionManager: sm, onEvent: handler })
|
|
72
|
+
await bus.start()
|
|
73
|
+
|
|
74
|
+
// Wait for stream to be consumed
|
|
75
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
76
|
+
|
|
77
|
+
expect(received.length).toBe(1)
|
|
78
|
+
expect(received[0].sessionId).toBe("s1")
|
|
79
|
+
expect(received[0].chatKey).toBe("chat:1")
|
|
80
|
+
expect(received[0].type).toBe("message.part.updated")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("ignores events for unknown sessionIds", async () => {
|
|
84
|
+
const received: any[] = []
|
|
85
|
+
const handler: EventHandler = (sessionId, chatKey, event) => {
|
|
86
|
+
received.push({ sessionId, chatKey })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sdk = createMockSdk([makePartEvent("unknown-session", "hello")])
|
|
90
|
+
const bus = new EventBus({ sdk: sdk as any, sessionManager: sm, onEvent: handler })
|
|
91
|
+
await bus.start()
|
|
92
|
+
|
|
93
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
94
|
+
|
|
95
|
+
expect(received.length).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("calls onEvent with sessionId, chatKey, and event", async () => {
|
|
99
|
+
sm.set("chat:42", { sessionId: "s42", directory: "/tmp" })
|
|
100
|
+
|
|
101
|
+
const onEvent = mock((_sid: string, _ck: string, _ev: any) => {})
|
|
102
|
+
const sdk = createMockSdk([makeIdleEvent("s42")])
|
|
103
|
+
const bus = new EventBus({ sdk: sdk as any, sessionManager: sm, onEvent })
|
|
104
|
+
await bus.start()
|
|
105
|
+
|
|
106
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
107
|
+
|
|
108
|
+
expect(onEvent).toHaveBeenCalledTimes(1)
|
|
109
|
+
const [sid, ck, ev] = onEvent.mock.calls[0]
|
|
110
|
+
expect(sid).toBe("s42")
|
|
111
|
+
expect(ck).toBe("chat:42")
|
|
112
|
+
expect(ev.type).toBe("session.idle")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("handles multiple events for different sessions", async () => {
|
|
116
|
+
sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
|
|
117
|
+
sm.set("chat:2", { sessionId: "s2", directory: "/tmp" })
|
|
118
|
+
|
|
119
|
+
const received: string[] = []
|
|
120
|
+
const handler: EventHandler = (sessionId, chatKey, event) => {
|
|
121
|
+
received.push(`${chatKey}:${event.type}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sdk = createMockSdk([
|
|
125
|
+
makePartEvent("s1", "hello"),
|
|
126
|
+
makeIdleEvent("s2"),
|
|
127
|
+
makeErrorEvent("s1"),
|
|
128
|
+
])
|
|
129
|
+
const bus = new EventBus({ sdk: sdk as any, sessionManager: sm, onEvent: handler })
|
|
130
|
+
await bus.start()
|
|
131
|
+
|
|
132
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
133
|
+
|
|
134
|
+
expect(received).toEqual([
|
|
135
|
+
"chat:1:message.part.updated",
|
|
136
|
+
"chat:2:session.idle",
|
|
137
|
+
"chat:1:session.error",
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("stop() prevents processing of further events", async () => {
|
|
142
|
+
sm.set("chat:1", { sessionId: "s1", directory: "/tmp" })
|
|
143
|
+
|
|
144
|
+
const received: any[] = []
|
|
145
|
+
const handler: EventHandler = (sid, ck, ev) => {
|
|
146
|
+
received.push(ev.type)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create a stream that yields events slowly
|
|
150
|
+
async function* slowStream() {
|
|
151
|
+
yield makePartEvent("s1", "first")
|
|
152
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
153
|
+
yield makePartEvent("s1", "second")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sdk = {
|
|
157
|
+
event: {
|
|
158
|
+
subscribe: mock(async () => ({ stream: slowStream() })),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const bus = new EventBus({ sdk: sdk as any, sessionManager: sm, onEvent: handler })
|
|
163
|
+
await bus.start()
|
|
164
|
+
|
|
165
|
+
// Let first event process
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
167
|
+
bus.stop()
|
|
168
|
+
|
|
169
|
+
// Wait for slow stream
|
|
170
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
171
|
+
|
|
172
|
+
// Should have only the first event
|
|
173
|
+
expect(received.length).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test"
|
|
2
|
+
import { createAllowlistMiddleware } from "./allowlist"
|
|
3
|
+
|
|
4
|
+
describe("createAllowlistMiddleware", () => {
|
|
5
|
+
const createCtx = (userId?: number) => ({
|
|
6
|
+
from: userId !== undefined ? { id: userId } : undefined,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("empty allowedUsers list blocks all (safe default)", async () => {
|
|
10
|
+
const mw = createAllowlistMiddleware([])
|
|
11
|
+
const next = mock(() => Promise.resolve())
|
|
12
|
+
await mw(createCtx(999) as any, next)
|
|
13
|
+
expect(next).not.toHaveBeenCalled()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("allowAll=true allows everyone", async () => {
|
|
17
|
+
const mw = createAllowlistMiddleware([], true)
|
|
18
|
+
const next = mock(() => Promise.resolve())
|
|
19
|
+
await mw(createCtx(999) as any, next)
|
|
20
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("user ID in list calls next", async () => {
|
|
24
|
+
const mw = createAllowlistMiddleware([111, 222])
|
|
25
|
+
const next = mock(() => Promise.resolve())
|
|
26
|
+
await mw(createCtx(111) as any, next)
|
|
27
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("user ID NOT in list does not call next", async () => {
|
|
31
|
+
const mw = createAllowlistMiddleware([111, 222])
|
|
32
|
+
const next = mock(() => Promise.resolve())
|
|
33
|
+
await mw(createCtx(999) as any, next)
|
|
34
|
+
expect(next).not.toHaveBeenCalled()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("no from on context does not call next", async () => {
|
|
38
|
+
const mw = createAllowlistMiddleware([111])
|
|
39
|
+
const next = mock(() => Promise.resolve())
|
|
40
|
+
await mw({ from: undefined } as any, next)
|
|
41
|
+
expect(next).not.toHaveBeenCalled()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("multiple users in list all allowed", async () => {
|
|
45
|
+
const mw = createAllowlistMiddleware([111, 222, 333])
|
|
46
|
+
for (const id of [111, 222, 333]) {
|
|
47
|
+
const next = mock(() => Promise.resolve())
|
|
48
|
+
await mw(createCtx(id) as any, next)
|
|
49
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("callback queries also filtered by from.id", async () => {
|
|
54
|
+
const mw = createAllowlistMiddleware([111])
|
|
55
|
+
const next = mock(() => Promise.resolve())
|
|
56
|
+
// Callback query context still has from
|
|
57
|
+
await mw({ from: { id: 999 }, callbackQuery: { data: "test" } } as any, next)
|
|
58
|
+
expect(next).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist middleware — restricts bot access to specific Telegram user IDs.
|
|
3
|
+
*
|
|
4
|
+
* Safe default: empty list blocks everyone. The bot owner must explicitly
|
|
5
|
+
* configure TELEGRAM_ALLOWED_USERS with their Telegram ID(s).
|
|
6
|
+
*
|
|
7
|
+
* - allowAll=true (TELEGRAM_ALLOWED_USERS="*") → allow everyone (explicit opt-in)
|
|
8
|
+
* - allowedUsers=[123,456] → only those IDs
|
|
9
|
+
* - allowedUsers=[] + allowAll=false → block everyone (safe default)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MiddlewareFn, Context } from "grammy"
|
|
13
|
+
|
|
14
|
+
export function createAllowlistMiddleware(
|
|
15
|
+
allowedUsers: number[],
|
|
16
|
+
allowAll = false,
|
|
17
|
+
): MiddlewareFn<Context> {
|
|
18
|
+
if (allowAll) {
|
|
19
|
+
return (_ctx, next) => next()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (allowedUsers.length === 0) {
|
|
23
|
+
return () => {} // block everyone — no users configured
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const allowed = new Set(allowedUsers)
|
|
27
|
+
|
|
28
|
+
return (ctx, next) => {
|
|
29
|
+
const userId = ctx.from?.id
|
|
30
|
+
if (!userId || !allowed.has(userId)) return
|
|
31
|
+
return next()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test"
|
|
2
|
+
import { handleCancel } from "./cancel"
|
|
3
|
+
import { SessionManager } from "../session-manager"
|
|
4
|
+
import { TurnManager } from "../turn-manager"
|
|
5
|
+
|
|
6
|
+
function createMockSdk() {
|
|
7
|
+
return {
|
|
8
|
+
session: {
|
|
9
|
+
abort: mock(async () => ({ data: true })),
|
|
10
|
+
create: mock(async () => ({ data: { id: "s1", directory: "/tmp" } })),
|
|
11
|
+
},
|
|
12
|
+
} as any
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("handleCancel", () => {
|
|
16
|
+
test("returns 'No active session.' if no session found", async () => {
|
|
17
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
18
|
+
const tm = new TurnManager()
|
|
19
|
+
const sdk = createMockSdk()
|
|
20
|
+
|
|
21
|
+
const result = await handleCancel({
|
|
22
|
+
chatId: 123,
|
|
23
|
+
sdk,
|
|
24
|
+
sessionManager: sm,
|
|
25
|
+
turnManager: tm,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(result).toBe("No active session.")
|
|
29
|
+
expect(sdk.session.abort).not.toHaveBeenCalled()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("returns 'Nothing running.' if no active turn", async () => {
|
|
33
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
34
|
+
const tm = new TurnManager()
|
|
35
|
+
const sdk = createMockSdk()
|
|
36
|
+
|
|
37
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
38
|
+
|
|
39
|
+
const result = await handleCancel({
|
|
40
|
+
chatId: 123,
|
|
41
|
+
sdk,
|
|
42
|
+
sessionManager: sm,
|
|
43
|
+
turnManager: tm,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(result).toBe("Nothing running.")
|
|
47
|
+
expect(sdk.session.abort).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("calls sdk.session.abort with sessionID", async () => {
|
|
51
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
52
|
+
const tm = new TurnManager()
|
|
53
|
+
const sdk = createMockSdk()
|
|
54
|
+
|
|
55
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
56
|
+
tm.start("s1", 123)
|
|
57
|
+
|
|
58
|
+
await handleCancel({
|
|
59
|
+
chatId: 123,
|
|
60
|
+
sdk,
|
|
61
|
+
sessionManager: sm,
|
|
62
|
+
turnManager: tm,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(sdk.session.abort).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(sdk.session.abort).toHaveBeenCalledWith({ sessionID: "s1" })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("calls turnManager.end after abort", async () => {
|
|
70
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
71
|
+
const tm = new TurnManager()
|
|
72
|
+
const sdk = createMockSdk()
|
|
73
|
+
|
|
74
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
75
|
+
tm.start("s1", 123)
|
|
76
|
+
|
|
77
|
+
await handleCancel({
|
|
78
|
+
chatId: 123,
|
|
79
|
+
sdk,
|
|
80
|
+
sessionManager: sm,
|
|
81
|
+
turnManager: tm,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Turn should be cleaned up
|
|
85
|
+
expect(tm.get("s1")).toBeUndefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("returns 'Generation cancelled.' on success", async () => {
|
|
89
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
90
|
+
const tm = new TurnManager()
|
|
91
|
+
const sdk = createMockSdk()
|
|
92
|
+
|
|
93
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
94
|
+
tm.start("s1", 123)
|
|
95
|
+
|
|
96
|
+
const result = await handleCancel({
|
|
97
|
+
chatId: 123,
|
|
98
|
+
sdk,
|
|
99
|
+
sessionManager: sm,
|
|
100
|
+
turnManager: tm,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(result).toBe("Generation cancelled.")
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
formatPermissionMessage,
|
|
4
|
+
parsePermissionCallback,
|
|
5
|
+
} from "./permissions"
|
|
6
|
+
|
|
7
|
+
const samplePermission = {
|
|
8
|
+
id: "per_abc123def456ghi789jk",
|
|
9
|
+
sessionID: "ses_xyz",
|
|
10
|
+
permission: "bash",
|
|
11
|
+
patterns: ["rm -rf /tmp/test"],
|
|
12
|
+
metadata: {},
|
|
13
|
+
always: ["bash:*"],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("formatPermissionMessage", () => {
|
|
17
|
+
test("text contains permission name", () => {
|
|
18
|
+
const { text } = formatPermissionMessage(samplePermission)
|
|
19
|
+
expect(text).toContain("bash")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("text contains patterns", () => {
|
|
23
|
+
const { text } = formatPermissionMessage(samplePermission)
|
|
24
|
+
expect(text).toContain("rm -rf /tmp/test")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("returns keyboard with 3 buttons", () => {
|
|
28
|
+
const { reply_markup } = formatPermissionMessage(samplePermission)
|
|
29
|
+
const buttons = reply_markup.inline_keyboard[0]
|
|
30
|
+
expect(buttons).toHaveLength(3)
|
|
31
|
+
expect(buttons[0].text).toContain("Allow")
|
|
32
|
+
expect(buttons[1].text).toContain("Always")
|
|
33
|
+
expect(buttons[2].text).toContain("Deny")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("callback data follows perm:{action}:{requestID} pattern", () => {
|
|
37
|
+
const { reply_markup } = formatPermissionMessage(samplePermission)
|
|
38
|
+
const buttons = reply_markup.inline_keyboard[0]
|
|
39
|
+
expect(buttons[0].callback_data).toBe(
|
|
40
|
+
`perm:once:${samplePermission.id}`,
|
|
41
|
+
)
|
|
42
|
+
expect(buttons[1].callback_data).toBe(
|
|
43
|
+
`perm:always:${samplePermission.id}`,
|
|
44
|
+
)
|
|
45
|
+
expect(buttons[2].callback_data).toBe(
|
|
46
|
+
`perm:deny:${samplePermission.id}`,
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("parsePermissionCallback", () => {
|
|
52
|
+
test("parses perm:once correctly", () => {
|
|
53
|
+
const result = parsePermissionCallback("perm:once:per_abc123")
|
|
54
|
+
expect(result).toEqual({ requestID: "per_abc123", reply: "once" })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("parses perm:always correctly", () => {
|
|
58
|
+
const result = parsePermissionCallback("perm:always:per_abc123")
|
|
59
|
+
expect(result).toEqual({ requestID: "per_abc123", reply: "always" })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("parses perm:deny as reject", () => {
|
|
63
|
+
const result = parsePermissionCallback("perm:deny:per_abc123")
|
|
64
|
+
expect(result).toEqual({ requestID: "per_abc123", reply: "reject" })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("returns null for invalid data", () => {
|
|
68
|
+
expect(parsePermissionCallback("invalid:data")).toBeNull()
|
|
69
|
+
expect(parsePermissionCallback("q:req1:0")).toBeNull()
|
|
70
|
+
expect(parsePermissionCallback("perm:unknown:req1")).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
formatQuestionMessage,
|
|
4
|
+
parseQuestionCallback,
|
|
5
|
+
resolveQuestionAnswer,
|
|
6
|
+
} from "./questions"
|
|
7
|
+
|
|
8
|
+
const sampleQuestion = {
|
|
9
|
+
id: "que_abc123def456ghi789jk",
|
|
10
|
+
sessionID: "ses_xyz",
|
|
11
|
+
questions: [
|
|
12
|
+
{
|
|
13
|
+
question: "Which approach should we use?",
|
|
14
|
+
header: "Approach",
|
|
15
|
+
options: [
|
|
16
|
+
{ label: "Option A", description: "First approach" },
|
|
17
|
+
{ label: "Option B", description: "Second approach" },
|
|
18
|
+
{ label: "Option C", description: "Third approach" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("formatQuestionMessage", () => {
|
|
25
|
+
test("returns question text in message", () => {
|
|
26
|
+
const { text } = formatQuestionMessage(sampleQuestion)
|
|
27
|
+
expect(text).toContain("Which approach should we use?")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("renders options as 1-per-row buttons", () => {
|
|
31
|
+
const { reply_markup } = formatQuestionMessage(sampleQuestion)
|
|
32
|
+
const keyboard = reply_markup.inline_keyboard
|
|
33
|
+
// 3 option rows + 1 skip row
|
|
34
|
+
expect(keyboard.length).toBe(4)
|
|
35
|
+
expect(keyboard[0][0].text).toBe("Option A")
|
|
36
|
+
expect(keyboard[1][0].text).toBe("Option B")
|
|
37
|
+
expect(keyboard[2][0].text).toBe("Option C")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("adds Skip button as last row", () => {
|
|
41
|
+
const { reply_markup } = formatQuestionMessage(sampleQuestion)
|
|
42
|
+
const keyboard = reply_markup.inline_keyboard
|
|
43
|
+
const lastRow = keyboard[keyboard.length - 1]
|
|
44
|
+
expect(lastRow[0].text).toContain("Skip")
|
|
45
|
+
expect(lastRow[0].callback_data).toBe(
|
|
46
|
+
`q:${sampleQuestion.id}:skip`,
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("callback data uses q:{requestID}:{index} format", () => {
|
|
51
|
+
const { reply_markup } = formatQuestionMessage(sampleQuestion)
|
|
52
|
+
const keyboard = reply_markup.inline_keyboard
|
|
53
|
+
expect(keyboard[0][0].callback_data).toBe(
|
|
54
|
+
`q:${sampleQuestion.id}:0`,
|
|
55
|
+
)
|
|
56
|
+
expect(keyboard[1][0].callback_data).toBe(
|
|
57
|
+
`q:${sampleQuestion.id}:1`,
|
|
58
|
+
)
|
|
59
|
+
expect(keyboard[2][0].callback_data).toBe(
|
|
60
|
+
`q:${sampleQuestion.id}:2`,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("parseQuestionCallback", () => {
|
|
66
|
+
test("parses selection correctly", () => {
|
|
67
|
+
const result = parseQuestionCallback("q:que_abc:0")
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
requestID: "que_abc",
|
|
70
|
+
action: "select",
|
|
71
|
+
optionIndex: 0,
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("parses skip correctly", () => {
|
|
76
|
+
const result = parseQuestionCallback("q:que_abc:skip")
|
|
77
|
+
expect(result).toEqual({ requestID: "que_abc", action: "skip" })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("returns null for invalid data", () => {
|
|
81
|
+
expect(parseQuestionCallback("invalid")).toBeNull()
|
|
82
|
+
expect(parseQuestionCallback("perm:once:req1")).toBeNull()
|
|
83
|
+
expect(parseQuestionCallback("q:")).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("resolveQuestionAnswer", () => {
|
|
88
|
+
test("maps index to option label from stored questions", () => {
|
|
89
|
+
const pending = {
|
|
90
|
+
type: "question" as const,
|
|
91
|
+
createdAt: Date.now(),
|
|
92
|
+
questions: [
|
|
93
|
+
{
|
|
94
|
+
options: [
|
|
95
|
+
{ label: "Option A" },
|
|
96
|
+
{ label: "Option B" },
|
|
97
|
+
{ label: "Option C" },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(resolveQuestionAnswer(0, pending)).toEqual(["Option A"])
|
|
104
|
+
expect(resolveQuestionAnswer(1, pending)).toEqual(["Option B"])
|
|
105
|
+
expect(resolveQuestionAnswer(2, pending)).toEqual(["Option C"])
|
|
106
|
+
})
|
|
107
|
+
})
|