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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +78 -0
- package/src/Bridge/LoopbackServer.test.ts +94 -0
- package/src/Bridge/LoopbackServer.ts +77 -0
- package/src/Bridge/ToolControl.test.ts +245 -0
- package/src/Bridge/ToolControl.ts +260 -0
- package/src/Bridge/ToolControlEdges.test.ts +49 -0
- package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
- package/src/Config.test.ts +142 -0
- package/src/Config.ts +295 -0
- package/src/ConfigSchema.ts +46 -0
- package/src/ConfigTypes.ts +11 -0
- package/src/Discord/ChatSdkDiscord.test.ts +257 -0
- package/src/Discord/ChatSdkDiscord.ts +206 -0
- package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
- package/src/Discord/DiscordGateway.test.ts +215 -0
- package/src/Discord/DiscordGateway.ts +140 -0
- package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
- package/src/Discord/DiscordJsDiscord.test.ts +208 -0
- package/src/Discord/DiscordJsDiscord.ts +267 -0
- package/src/Discord/DiscordPort.ts +30 -0
- package/src/Discord/MemoryDiscord.test.ts +44 -0
- package/src/Discord/MemoryDiscord.ts +85 -0
- package/src/Discord/Safety.ts +11 -0
- package/src/Main.test.ts +273 -0
- package/src/Main.ts +192 -0
- package/src/MainQueue.test.ts +124 -0
- package/src/Opencode/EventMapping.test.ts +188 -0
- package/src/Opencode/EventMapping.ts +232 -0
- package/src/Opencode/EventMappingState.ts +97 -0
- package/src/Opencode/MemoryOpencode.test.ts +18 -0
- package/src/Opencode/MemoryOpencode.ts +29 -0
- package/src/Opencode/OpencodePort.ts +30 -0
- package/src/Opencode/PromptParts.ts +47 -0
- package/src/Opencode/SdkOpencode.test.ts +280 -0
- package/src/Opencode/SdkOpencode.ts +270 -0
- package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
- package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
- package/src/Orchestrator/ContextAssembly.test.ts +115 -0
- package/src/Orchestrator/ContextAssembly.ts +120 -0
- package/src/Orchestrator/Orchestrator.ts +67 -0
- package/src/Orchestrator/StopCommand.test.ts +20 -0
- package/src/Orchestrator/StopCommand.ts +14 -0
- package/src/Orchestrator/Triggering.test.ts +56 -0
- package/src/Orchestrator/Triggering.ts +26 -0
- package/src/Orchestrator/TurnManager.test.ts +180 -0
- package/src/Orchestrator/TurnManager.ts +179 -0
- package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
- package/src/PublicContracts.test.ts +43 -0
- package/src/Render/Renderer.test.ts +249 -0
- package/src/Render/Renderer.ts +159 -0
- package/src/Render/Splitting.test.ts +30 -0
- package/src/Render/Splitting.ts +68 -0
- package/src/Schema.ts +93 -0
- package/src/Tools/Scaffolding.test.ts +56 -0
- 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)))
|