opencode-discord-bot 0.0.1

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +78 -0
  4. package/src/Bridge/LoopbackServer.test.ts +94 -0
  5. package/src/Bridge/LoopbackServer.ts +77 -0
  6. package/src/Bridge/ToolControl.test.ts +245 -0
  7. package/src/Bridge/ToolControl.ts +260 -0
  8. package/src/Bridge/ToolControlEdges.test.ts +49 -0
  9. package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
  10. package/src/Config.test.ts +142 -0
  11. package/src/Config.ts +295 -0
  12. package/src/ConfigSchema.ts +46 -0
  13. package/src/ConfigTypes.ts +11 -0
  14. package/src/Discord/ChatSdkDiscord.test.ts +257 -0
  15. package/src/Discord/ChatSdkDiscord.ts +206 -0
  16. package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
  17. package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
  18. package/src/Discord/DiscordGateway.test.ts +215 -0
  19. package/src/Discord/DiscordGateway.ts +140 -0
  20. package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
  21. package/src/Discord/DiscordJsDiscord.test.ts +208 -0
  22. package/src/Discord/DiscordJsDiscord.ts +267 -0
  23. package/src/Discord/DiscordPort.ts +30 -0
  24. package/src/Discord/MemoryDiscord.test.ts +44 -0
  25. package/src/Discord/MemoryDiscord.ts +85 -0
  26. package/src/Discord/Safety.ts +11 -0
  27. package/src/Main.test.ts +273 -0
  28. package/src/Main.ts +192 -0
  29. package/src/MainQueue.test.ts +124 -0
  30. package/src/Opencode/EventMapping.test.ts +188 -0
  31. package/src/Opencode/EventMapping.ts +232 -0
  32. package/src/Opencode/EventMappingState.ts +97 -0
  33. package/src/Opencode/MemoryOpencode.test.ts +18 -0
  34. package/src/Opencode/MemoryOpencode.ts +29 -0
  35. package/src/Opencode/OpencodePort.ts +30 -0
  36. package/src/Opencode/PromptParts.ts +47 -0
  37. package/src/Opencode/SdkOpencode.test.ts +280 -0
  38. package/src/Opencode/SdkOpencode.ts +270 -0
  39. package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
  40. package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
  41. package/src/Orchestrator/ContextAssembly.test.ts +115 -0
  42. package/src/Orchestrator/ContextAssembly.ts +120 -0
  43. package/src/Orchestrator/Orchestrator.ts +67 -0
  44. package/src/Orchestrator/StopCommand.test.ts +20 -0
  45. package/src/Orchestrator/StopCommand.ts +14 -0
  46. package/src/Orchestrator/Triggering.test.ts +56 -0
  47. package/src/Orchestrator/Triggering.ts +26 -0
  48. package/src/Orchestrator/TurnManager.test.ts +180 -0
  49. package/src/Orchestrator/TurnManager.ts +179 -0
  50. package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
  51. package/src/PublicContracts.test.ts +43 -0
  52. package/src/Render/Renderer.test.ts +249 -0
  53. package/src/Render/Renderer.ts +159 -0
  54. package/src/Render/Splitting.test.ts +30 -0
  55. package/src/Render/Splitting.ts +68 -0
  56. package/src/Schema.ts +93 -0
  57. package/src/Tools/Scaffolding.test.ts +56 -0
  58. package/src/Tools/Scaffolding.ts +60 -0
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import type { DiscordScope } from "../Schema.ts"
4
+ import { makeSdkOpencode } from "./SdkOpencode.ts"
5
+
6
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
7
+ const options = { baseUrl: "http://127.0.0.1:4096", projectDir: "/repo" }
8
+
9
+ describe("makeSdkOpencode failure paths", () => {
10
+ test("maps prompt attachment preparation failures", async () => {
11
+ const originalFetch = globalThis.fetch
12
+ const failingFetch: typeof fetch = Object.assign(() => Promise.reject(new Error("network down")), {
13
+ preconnect: () => {}
14
+ })
15
+ globalThis.fetch = failingFetch
16
+ const client = {
17
+ session: {
18
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
19
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
20
+ abort: () => Promise.resolve({ data: {}, error: undefined })
21
+ },
22
+ event: {
23
+ subscribe: () => Promise.resolve({ stream: (async function* () {})() })
24
+ },
25
+ global: {
26
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
27
+ }
28
+ }
29
+
30
+ const opencode = makeSdkOpencode(client, options)
31
+
32
+ try {
33
+ await expect(
34
+ opencode
35
+ .runPrompt({
36
+ prompt: "hello",
37
+ projectDir: "/repo",
38
+ scope,
39
+ parts: [{ type: "file", mime: "image/png", filename: "shot.png", url: "https://cdn/shot.png" }]
40
+ })
41
+ .pipe(Stream.runCollect, Effect.runPromise)
42
+ ).rejects.toMatchObject({ _tag: "OpencodeError", message: "failed to fetch image attachment shot.png: network down" })
43
+ } finally {
44
+ globalThis.fetch = originalFetch
45
+ }
46
+ })
47
+
48
+ test("propagates SSE stream failures", async () => {
49
+ const client = {
50
+ session: {
51
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
52
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
53
+ abort: () => Promise.resolve({ data: {}, error: undefined })
54
+ },
55
+ event: {
56
+ subscribe: () =>
57
+ Promise.resolve({
58
+ stream: (async function* () {
59
+ yield { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "ok" } }
60
+ throw new Error("stream blew up")
61
+ })()
62
+ })
63
+ },
64
+ global: {
65
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
66
+ }
67
+ }
68
+
69
+ const opencode = makeSdkOpencode(client, options)
70
+
71
+ await expect(
72
+ opencode.runPrompt({ prompt: "hello", projectDir: "/repo", scope }).pipe(Stream.runCollect, Effect.runPromise)
73
+ ).rejects.toMatchObject({ _tag: "OpencodeError", message: "stream blew up" })
74
+ })
75
+
76
+ test("maps abort failures on an active session", async () => {
77
+ let releaseStream: (() => void) | undefined
78
+ const waiting = new Promise<void>((resolve) => {
79
+ releaseStream = resolve
80
+ })
81
+ const client = {
82
+ session: {
83
+ create: () => Promise.resolve({ data: { id: "session-1" }, error: undefined }),
84
+ promptAsync: () => Promise.resolve({ data: {}, error: undefined }),
85
+ abort: () => Promise.resolve({ data: undefined, error: "abort failed" })
86
+ },
87
+ event: {
88
+ subscribe: () =>
89
+ Promise.resolve({
90
+ stream: (async function* () {
91
+ yield { type: "session.next.text.delta", properties: { sessionID: "session-1", delta: "ok" } }
92
+ await waiting
93
+ })()
94
+ })
95
+ },
96
+ global: {
97
+ health: () => Promise.resolve({ data: { ok: true }, error: undefined })
98
+ }
99
+ }
100
+
101
+ const opencode = makeSdkOpencode(client, options)
102
+ const running = Effect.runPromise(opencode.runPrompt({ prompt: "hello", projectDir: "/repo", scope }).pipe(Stream.runDrain))
103
+ await new Promise((resolve) => setTimeout(resolve, 0))
104
+
105
+ await expect(opencode.abort(scope).pipe(Stream.runDrain, Effect.runPromise)).rejects.toMatchObject({
106
+ _tag: "OpencodeError",
107
+ message: "abort failed"
108
+ })
109
+
110
+ releaseStream?.()
111
+ await running.catch(() => {})
112
+ })
113
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { DiscordMessage } from "../Schema.ts"
3
+ import { assembleContextPrompt, formatDiscordMessage } from "./ContextAssembly.ts"
4
+
5
+ const makeMessage = (id: string, content: string, extra: Partial<DiscordMessage> = {}): DiscordMessage => ({
6
+ id,
7
+ guildId: "g1",
8
+ channelId: "c1",
9
+ author: { id: `u-${id}`, displayName: `User ${id}`, nickname: `Nick ${id}`, isBot: false },
10
+ content,
11
+ timestamp: "2026-06-05T14:03:00.000Z",
12
+ mentions: [],
13
+ roleMentions: [],
14
+ everyoneMention: false,
15
+ hereMention: false,
16
+ attachments: [],
17
+ reactions: [],
18
+ channelType: "guild",
19
+ ...extra
20
+ })
21
+
22
+ describe("context assembly", () => {
23
+ test("renders the structured Discord envelope", () => {
24
+ const rendered = formatDiscordMessage(
25
+ makeMessage("1", "Can you refactor this?", {
26
+ attachments: [{ id: "a1", filename: "screenshot.png", contentType: "image/png", size: 12, url: "https://cdn/a1" }],
27
+ reactions: [
28
+ { emoji: "thumbs_up", count: 2 },
29
+ { emoji: "tada", count: 1 }
30
+ ]
31
+ })
32
+ )
33
+
34
+ expect(rendered).toContain("[Nick 1 | <@u-1> | 2026-06-05 14:03 UTC]")
35
+ expect(rendered).toContain("Can you refactor this?")
36
+ expect(rendered).toContain("(discord target: guildId=g1 channelId=c1 messageId=1)")
37
+ expect(rendered).toContain("(attachments: screenshot.png [image/png; 12 bytes; https://cdn/a1])")
38
+ expect(rendered).toContain("(reactions: thumbs_up x2, tada x1)")
39
+ })
40
+
41
+ test("dedupes context and includes the triggering message exactly once at the end", () => {
42
+ const trigger = makeMessage("3", "latest <@999>", { mentions: ["999"] })
43
+ const prompt = assembleContextPrompt({
44
+ botUserId: "999",
45
+ contextMessages: [makeMessage("1", "older"), trigger, makeMessage("2", "middle"), trigger],
46
+ triggerMessage: trigger,
47
+ maxMessages: 30,
48
+ maxChars: 10_000,
49
+ maxAttachmentBytes: 10_000
50
+ })
51
+
52
+ expect(prompt.messages.map((item) => item.id)).toEqual(["1", "2", "3"])
53
+ expect(prompt.text.match(/latest <@999>/g)).toHaveLength(1)
54
+ expect(prompt.text).toContain("<@id> pings that user in Discord")
55
+ expect(prompt.text).toContain("Use discord target metadata when calling bridge tools")
56
+ expect(prompt.text).toContain("Do not emit @everyone, @here, or role pings")
57
+ })
58
+
59
+ test("applies top-N and character budgets without dropping the trigger", () => {
60
+ const trigger = makeMessage("5", "trigger")
61
+ const prompt = assembleContextPrompt({
62
+ botUserId: "999",
63
+ contextMessages: [makeMessage("1", "one"), makeMessage("2", "two"), makeMessage("3", "three"), makeMessage("4", "four")],
64
+ triggerMessage: trigger,
65
+ maxMessages: 3,
66
+ maxChars: 250,
67
+ maxAttachmentBytes: 10_000
68
+ })
69
+
70
+ expect(prompt.messages.at(-1)?.id).toBe("5")
71
+ expect(prompt.messages.length).toBeLessThanOrEqual(3)
72
+ expect(prompt.text.length).toBeLessThanOrEqual(250)
73
+ })
74
+
75
+ test("surfaces queued intermediate messages before the latest trigger", () => {
76
+ const skipped = makeMessage("2", "intermediate <@999>", { mentions: ["999"] })
77
+ const trigger = makeMessage("3", "latest <@999>", { mentions: ["999"] })
78
+ const prompt = assembleContextPrompt({
79
+ botUserId: "999",
80
+ contextMessages: [makeMessage("1", "older"), skipped, trigger],
81
+ skippedMessages: [skipped],
82
+ triggerMessage: trigger,
83
+ maxMessages: 3,
84
+ maxChars: 10_000,
85
+ maxAttachmentBytes: 10_000
86
+ })
87
+
88
+ expect(prompt.messages.map((item) => item.id)).toEqual(["1", "2", "3"])
89
+ expect(prompt.text).toContain("(queued intermediate message)")
90
+ expect(prompt.text.indexOf("intermediate <@999>")).toBeLessThan(prompt.text.indexOf("latest <@999>"))
91
+ })
92
+
93
+ test("creates opencode file parts for supported attachments under the configured cap", () => {
94
+ const trigger = makeMessage("1", "see files", {
95
+ attachments: [
96
+ { id: "a1", filename: "screenshot.png", contentType: "image/png", size: 12, url: "https://cdn/screenshot.png" },
97
+ { id: "a2", filename: "movie.mp4", contentType: "video/mp4", size: 12, url: "https://cdn/movie.mp4" },
98
+ { id: "a3", filename: "large.pdf", contentType: "application/pdf", size: 100, url: "https://cdn/large.pdf" }
99
+ ]
100
+ })
101
+
102
+ const prompt = assembleContextPrompt({
103
+ botUserId: "999",
104
+ contextMessages: [trigger],
105
+ triggerMessage: trigger,
106
+ maxMessages: 30,
107
+ maxChars: 10_000,
108
+ maxAttachmentBytes: 50
109
+ })
110
+
111
+ expect(prompt.parts).toEqual([{ type: "file", mime: "image/png", filename: "screenshot.png", url: "https://cdn/screenshot.png" }])
112
+ expect(prompt.text).toContain("movie.mp4 [video/mp4; 12 bytes; https://cdn/movie.mp4]")
113
+ expect(prompt.text).toContain("large.pdf [application/pdf; 100 bytes; https://cdn/large.pdf]")
114
+ })
115
+ })
@@ -0,0 +1,120 @@
1
+ import type { OpencodePromptFilePart } from "../Opencode/OpencodePort.ts"
2
+ import type { DiscordMessage } from "../Schema.ts"
3
+
4
+ export type ContextPrompt = {
5
+ readonly text: string
6
+ readonly messages: ReadonlyArray<DiscordMessage>
7
+ readonly parts: ReadonlyArray<OpencodePromptFilePart>
8
+ }
9
+
10
+ type ContextInput = {
11
+ readonly botUserId: string
12
+ readonly contextMessages: ReadonlyArray<DiscordMessage>
13
+ readonly triggerMessage: DiscordMessage
14
+ readonly skippedMessages?: ReadonlyArray<DiscordMessage> | undefined
15
+ readonly maxMessages: number
16
+ readonly maxChars: number
17
+ readonly maxAttachmentBytes: number
18
+ }
19
+
20
+ const preamble = (botUserId: string) =>
21
+ `Discord bridge context for <@${botUserId}>. <@id> pings that user in Discord. Use discord target metadata when calling bridge tools. Do not emit @everyone, @here, or role pings unless explicitly allowed.`
22
+
23
+ const timestamp = (value: string): string => {
24
+ const date = new Date(value)
25
+ const year = date.getUTCFullYear()
26
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0")
27
+ const day = String(date.getUTCDate()).padStart(2, "0")
28
+ const hour = String(date.getUTCHours()).padStart(2, "0")
29
+ const minute = String(date.getUTCMinutes()).padStart(2, "0")
30
+ return `${year}-${month}-${day} ${hour}:${minute} UTC`
31
+ }
32
+
33
+ const attachmentSummary = (message: DiscordMessage): string | undefined => {
34
+ if (message.attachments.length === 0) return undefined
35
+ return `(attachments: ${message.attachments
36
+ .map((item) => `${item.filename} [${item.contentType ?? "unknown"}; ${item.size} bytes; ${item.url}]`)
37
+ .join(", ")})`
38
+ }
39
+
40
+ const reactionSummary = (message: DiscordMessage): string | undefined => {
41
+ if (message.reactions.length === 0) return undefined
42
+ return `(reactions: ${message.reactions.map((item) => `${item.emoji} x${item.count}`).join(", ")})`
43
+ }
44
+
45
+ const targetSummary = (message: DiscordMessage): string => {
46
+ const thread = message.threadId === undefined ? "" : ` threadId=${message.threadId}`
47
+ return `(discord target: guildId=${message.guildId} channelId=${message.channelId}${thread} messageId=${message.id})`
48
+ }
49
+
50
+ const isForwardableMime = (mime: string): boolean =>
51
+ mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("audio/") || mime.startsWith("text/")
52
+
53
+ const attachmentParts = (messages: ReadonlyArray<DiscordMessage>, maxBytes: number): ReadonlyArray<OpencodePromptFilePart> =>
54
+ messages.flatMap((message) =>
55
+ message.attachments.flatMap((attachment) => {
56
+ const mime = attachment.contentType
57
+ if (mime === undefined || attachment.size > maxBytes || !isForwardableMime(mime)) return []
58
+ return [{ type: "file", mime, filename: attachment.filename, url: attachment.url } satisfies OpencodePromptFilePart]
59
+ })
60
+ )
61
+
62
+ export const formatDiscordMessage = (message: DiscordMessage): string => {
63
+ const label = message.author.nickname ?? message.author.displayName
64
+ const lines = [`[${label} | <@${message.author.id}> | ${timestamp(message.timestamp)}]`, message.content]
65
+ lines.push(targetSummary(message))
66
+ const attachments = attachmentSummary(message)
67
+ const reactions = reactionSummary(message)
68
+ if (attachments !== undefined) lines.push(attachments)
69
+ if (reactions !== undefined) lines.push(reactions)
70
+ return lines.join("\n")
71
+ }
72
+
73
+ const dedupeContext = (input: ContextInput): ReadonlyArray<DiscordMessage> => {
74
+ const seen = new Set<string>()
75
+ const context: Array<DiscordMessage> = []
76
+ const maxMessages = Math.max(1, input.maxMessages)
77
+ const skipped = (input.skippedMessages ?? [])
78
+ .filter((message) => message.id !== input.triggerMessage.id)
79
+ .filter((message) => {
80
+ if (seen.has(message.id)) return false
81
+ seen.add(message.id)
82
+ return true
83
+ })
84
+ .slice(-Math.max(0, maxMessages - 1))
85
+ const skippedIds = new Set(skipped.map((message) => message.id))
86
+ for (const message of input.contextMessages) {
87
+ if (message.id === input.triggerMessage.id || skippedIds.has(message.id) || seen.has(message.id)) continue
88
+ seen.add(message.id)
89
+ context.push(message)
90
+ }
91
+ const retainedLimit = Math.max(0, maxMessages - skipped.length - 1)
92
+ const retained = context.slice(Math.max(0, context.length - retainedLimit))
93
+ return [...retained, ...skipped, input.triggerMessage]
94
+ }
95
+
96
+ const renderPrompt = (
97
+ botUserId: string,
98
+ messages: ReadonlyArray<DiscordMessage>,
99
+ skippedMessages: ReadonlyArray<DiscordMessage>
100
+ ): string => {
101
+ const skippedIds = new Set(skippedMessages.map((message) => message.id))
102
+ return [
103
+ preamble(botUserId),
104
+ ...messages.map((message) => `${skippedIds.has(message.id) ? "(queued intermediate message)\n" : ""}${formatDiscordMessage(message)}`)
105
+ ].join("\n\n")
106
+ }
107
+
108
+ export const assembleContextPrompt = (input: ContextInput): ContextPrompt => {
109
+ const skippedMessages = input.skippedMessages ?? []
110
+ const messages = [...dedupeContext(input)]
111
+ while (messages.length > 1 && renderPrompt(input.botUserId, messages, skippedMessages).length > input.maxChars) {
112
+ messages.shift()
113
+ }
114
+ const text = renderPrompt(input.botUserId, messages, skippedMessages)
115
+ return {
116
+ messages,
117
+ parts: attachmentParts(messages, input.maxAttachmentBytes),
118
+ text: text.length <= input.maxChars ? text : text.slice(0, input.maxChars)
119
+ }
120
+ }
@@ -0,0 +1,67 @@
1
+ import { Effect } from "effect"
2
+ import type { RuntimeConfig } from "../Config.ts"
3
+ import type { DiscordService } from "../Discord/DiscordPort.ts"
4
+ import type { OpencodeService } from "../Opencode/OpencodePort.ts"
5
+ import { renderOpencodeEvents } from "../Render/Renderer.ts"
6
+ import type { BotIdentity, DiscordMessage } from "../Schema.ts"
7
+ import { assembleContextPrompt } from "./ContextAssembly.ts"
8
+ import { isThreadActiveFromContext, shouldTriggerTurn, toDiscordScope } from "./Triggering.ts"
9
+
10
+ type HandleOptions = {
11
+ readonly bot: BotIdentity
12
+ readonly config: RuntimeConfig
13
+ readonly discord: DiscordService
14
+ readonly opencode: OpencodeService
15
+ }
16
+
17
+ type HandleResult = {
18
+ readonly handled: boolean
19
+ }
20
+
21
+ const redactError = (message: string): string => message.replace(/secret[-_a-z0-9]*/gi, "[redacted]")
22
+
23
+ export const handleDiscordMessage = Effect.fn("handleDiscordMessage")(function* (
24
+ message: DiscordMessage,
25
+ options: HandleOptions,
26
+ skippedMessages: ReadonlyArray<DiscordMessage> = []
27
+ ) {
28
+ const scope = toDiscordScope(message)
29
+ const directTrigger = shouldTriggerTurn(message, options.bot, false)
30
+ let context: ReadonlyArray<DiscordMessage> | undefined
31
+ let activeThread = false
32
+
33
+ if (!directTrigger && message.threadId !== undefined && options.config.threads.activeByRecentBotParticipation) {
34
+ context = yield* options.discord.fetchContext(scope, options.config.context.messages)
35
+ activeThread = isThreadActiveFromContext(context, options.bot)
36
+ }
37
+
38
+ if (!shouldTriggerTurn(message, options.bot, activeThread)) return { handled: false } satisfies HandleResult
39
+
40
+ const contextMessages = context ?? (yield* options.discord.fetchContext(scope, options.config.context.messages))
41
+ const prompt = assembleContextPrompt({
42
+ botUserId: options.bot.userId,
43
+ contextMessages,
44
+ triggerMessage: message,
45
+ skippedMessages,
46
+ maxMessages: options.config.context.messages,
47
+ maxChars: options.config.context.maxChars,
48
+ maxAttachmentBytes: options.config.context.attachmentMaxBytes
49
+ })
50
+
51
+ const events = options.opencode.runPrompt({
52
+ prompt: prompt.text,
53
+ parts: prompt.parts,
54
+ projectDir: options.config.opencode.projectDir,
55
+ scope,
56
+ ...(options.config.opencode.model === undefined ? {} : { model: options.config.opencode.model }),
57
+ ...(options.config.opencode.agent === undefined ? {} : { agent: options.config.opencode.agent })
58
+ })
59
+
60
+ yield* renderOpencodeEvents(events, scope, options.config, options.discord).pipe(
61
+ Effect.catchTag("OpencodeError", (error) =>
62
+ options.discord.postMessage(scope, `opencode is unavailable or returned an error: ${redactError(error.message)}`)
63
+ )
64
+ )
65
+
66
+ return { handled: true } satisfies HandleResult
67
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
4
+ import { makeMemoryOpencode } from "../Opencode/MemoryOpencode.ts"
5
+ import type { DiscordScope } from "../Schema.ts"
6
+ import { handleStopCommand } from "./StopCommand.ts"
7
+ import { createTurnManager } from "./TurnManager.ts"
8
+
9
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
10
+
11
+ describe("handleStopCommand", () => {
12
+ test("posts a no-active-turn response when there is no transient handle", async () => {
13
+ const discord = makeMemoryDiscord()
14
+ const manager = createTurnManager(makeMemoryOpencode([]), discord)
15
+
16
+ await Effect.runPromise(handleStopCommand(scope, manager, discord))
17
+
18
+ expect(discord.messages).toEqual([{ scope, content: "There is no known active turn in this process." }])
19
+ })
20
+ })
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect"
2
+ import type { DiscordService } from "../Discord/DiscordPort.ts"
3
+ import type { DiscordScope } from "../Schema.ts"
4
+ import type { TurnManager } from "./TurnManager.ts"
5
+
6
+ export const handleStopCommand = Effect.fn("handleStopCommand")(function* (
7
+ scope: DiscordScope,
8
+ turns: TurnManager,
9
+ discord: DiscordService
10
+ ) {
11
+ const result = yield* turns.stop(scope)
12
+ const content = result.stopped ? "Stopped the active turn." : "There is no known active turn in this process."
13
+ yield* discord.postMessage(scope, content)
14
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { BotIdentity, DiscordMessage } from "../Schema.ts"
3
+ import { isThreadActiveFromContext, shouldTriggerTurn, toDiscordScope } from "./Triggering.ts"
4
+
5
+ const bot: BotIdentity = { userId: "999" }
6
+
7
+ const message = (overrides: Partial<DiscordMessage> = {}): DiscordMessage => ({
8
+ id: "m1",
9
+ guildId: "g1",
10
+ channelId: "c1",
11
+ author: { id: "123", displayName: "Alice", isBot: false },
12
+ content: "hello <@999>",
13
+ timestamp: "2026-06-05T14:03:00.000Z",
14
+ mentions: ["999"],
15
+ roleMentions: [],
16
+ everyoneMention: false,
17
+ hereMention: false,
18
+ attachments: [],
19
+ reactions: [],
20
+ channelType: "guild",
21
+ ...overrides
22
+ })
23
+
24
+ describe("shouldTriggerTurn", () => {
25
+ test("direct guild mention triggers in the same channel scope", () => {
26
+ const input = message()
27
+
28
+ expect(shouldTriggerTurn(input, bot, false)).toBe(true)
29
+ expect(toDiscordScope(input)).toEqual({ guildId: "g1", channelId: "c1" })
30
+ })
31
+
32
+ test("thread mention triggers in the thread scope", () => {
33
+ const input = message({ threadId: "t1" })
34
+
35
+ expect(shouldTriggerTurn(input, bot, false)).toBe(true)
36
+ expect(toDiscordScope(input)).toEqual({ guildId: "g1", channelId: "c1", threadId: "t1" })
37
+ })
38
+
39
+ test("ignores DMs, bots, self, system messages, and mass mentions", () => {
40
+ expect(shouldTriggerTurn(message({ channelType: "dm" }), bot, false)).toBe(false)
41
+ expect(shouldTriggerTurn(message({ author: { id: "321", displayName: "Bot", isBot: true } }), bot, false)).toBe(false)
42
+ expect(shouldTriggerTurn(message({ author: { id: "999", displayName: "Self", isBot: true } }), bot, false)).toBe(false)
43
+ expect(shouldTriggerTurn(message({ isSystem: true }), bot, false)).toBe(false)
44
+ expect(
45
+ shouldTriggerTurn(message({ content: "@everyone", mentions: [], everyoneMention: true, roleMentions: ["role1"] }), bot, false)
46
+ ).toBe(false)
47
+ })
48
+
49
+ test("active thread follow-ups can trigger without a repeated mention", () => {
50
+ const followUp = message({ content: "continue", mentions: [], threadId: "t1" })
51
+ const context = [message({ id: "m0", author: { id: "999", displayName: "Bridge", isBot: true }, threadId: "t1" })]
52
+
53
+ expect(isThreadActiveFromContext(context, bot)).toBe(true)
54
+ expect(shouldTriggerTurn(followUp, bot, true)).toBe(true)
55
+ })
56
+ })
@@ -0,0 +1,26 @@
1
+ import type { BotIdentity, DiscordMessage, DiscordScope } from "../Schema.ts"
2
+
3
+ export const toDiscordScope = (message: DiscordMessage): DiscordScope => ({
4
+ guildId: message.guildId,
5
+ channelId: message.channelId,
6
+ ...(message.threadId === undefined ? {} : { threadId: message.threadId })
7
+ })
8
+
9
+ const isDirectMention = (message: DiscordMessage, bot: BotIdentity): boolean => message.mentions.includes(bot.userId)
10
+
11
+ const canConsiderMessage = (message: DiscordMessage, bot: BotIdentity): boolean => {
12
+ if (message.channelType === "dm") return false
13
+ if (message.isSystem === true) return false
14
+ if (message.author.id === bot.userId) return false
15
+ if (message.author.isBot) return false
16
+ return true
17
+ }
18
+
19
+ export const shouldTriggerTurn = (message: DiscordMessage, bot: BotIdentity, activeThread: boolean): boolean => {
20
+ if (!canConsiderMessage(message, bot)) return false
21
+ if (isDirectMention(message, bot)) return true
22
+ return message.threadId !== undefined && activeThread
23
+ }
24
+
25
+ export const isThreadActiveFromContext = (messages: ReadonlyArray<DiscordMessage>, bot: BotIdentity): boolean =>
26
+ messages.some((message) => message.threadId !== undefined && (message.author.id === bot.userId || isDirectMention(message, bot)))