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
package/src/Config.ts ADDED
@@ -0,0 +1,295 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import { Data, Duration, Effect, Redacted, Schema } from "effect"
4
+ import { type ParseError, parse } from "jsonc-parser"
5
+ import { RawConfigSchema } from "./ConfigSchema.ts"
6
+ import type { ConfigSources, LoadConfigOptions } from "./ConfigTypes.ts"
7
+
8
+ export type { ConfigSources, LoadConfigOptions } from "./ConfigTypes.ts"
9
+
10
+ export class ConfigError extends Data.TaggedError("ConfigError")<{
11
+ readonly message: string
12
+ }> {}
13
+
14
+ export type ToolConfig = {
15
+ readonly enabled: boolean
16
+ readonly autoInstall: boolean
17
+ readonly reactions: boolean
18
+ readonly attachFiles: boolean
19
+ readonly fetchHistory: boolean
20
+ readonly followUpMessages: boolean
21
+ readonly createThread: boolean
22
+ readonly editDeleteOwn: boolean
23
+ readonly postOtherChannels: boolean
24
+ readonly pin: boolean
25
+ }
26
+
27
+ export type RuntimeConfig = {
28
+ readonly discordToken: Redacted.Redacted<string>
29
+ readonly discord: {
30
+ readonly applicationId?: string
31
+ readonly publicKey?: string
32
+ readonly guildId?: string
33
+ }
34
+ readonly opencode: {
35
+ readonly port: number
36
+ readonly baseUrl: string
37
+ readonly projectDir: string
38
+ readonly model?: string
39
+ readonly agent?: string
40
+ }
41
+ readonly bridge: {
42
+ readonly host: "127.0.0.1"
43
+ readonly port: number
44
+ }
45
+ readonly context: {
46
+ readonly messages: number
47
+ readonly maxChars: number
48
+ readonly attachmentMaxBytes: number
49
+ }
50
+ readonly threads: {
51
+ readonly activeByRecentBotParticipation: boolean
52
+ }
53
+ readonly tools: ToolConfig
54
+ readonly streaming: {
55
+ readonly updateInterval: Duration.Duration
56
+ readonly placeholderText: string | null
57
+ readonly showToolStatus: boolean
58
+ readonly changedFilesSummary: boolean
59
+ }
60
+ readonly concurrency: {
61
+ readonly strategy: "queue" | "burst"
62
+ readonly lockScope: "discord-scope"
63
+ readonly globalMaxActiveTurns: number | null
64
+ }
65
+ readonly guards: {
66
+ readonly ignoreBots: boolean
67
+ readonly stripMassMentions: boolean
68
+ readonly redactSecretsInErrors: boolean
69
+ readonly maxTurn: Duration.Duration | null
70
+ }
71
+ }
72
+
73
+ export const defaultConfig: RuntimeConfig = {
74
+ discordToken: Redacted.make(""),
75
+ discord: {},
76
+ opencode: {
77
+ port: 4096,
78
+ baseUrl: "http://127.0.0.1:4096",
79
+ projectDir: process.cwd()
80
+ },
81
+ bridge: {
82
+ host: "127.0.0.1",
83
+ port: 8787
84
+ },
85
+ context: {
86
+ messages: 30,
87
+ maxChars: 60_000,
88
+ attachmentMaxBytes: 10 * 1024 * 1024
89
+ },
90
+ threads: {
91
+ activeByRecentBotParticipation: true
92
+ },
93
+ tools: {
94
+ enabled: true,
95
+ autoInstall: true,
96
+ reactions: true,
97
+ attachFiles: true,
98
+ fetchHistory: true,
99
+ followUpMessages: true,
100
+ createThread: false,
101
+ editDeleteOwn: false,
102
+ postOtherChannels: false,
103
+ pin: false
104
+ },
105
+ streaming: {
106
+ updateInterval: Duration.millis(500),
107
+ placeholderText: null,
108
+ showToolStatus: true,
109
+ changedFilesSummary: true
110
+ },
111
+ concurrency: {
112
+ strategy: "queue",
113
+ lockScope: "discord-scope",
114
+ globalMaxActiveTurns: null
115
+ },
116
+ guards: {
117
+ ignoreBots: true,
118
+ stripMassMentions: true,
119
+ redactSecretsInErrors: true,
120
+ maxTurn: null
121
+ }
122
+ }
123
+
124
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
125
+ typeof value === "object" && value !== null && !Array.isArray(value)
126
+
127
+ const decodeRawConfig = (value: unknown): Effect.Effect<Readonly<Record<string, unknown>>, ConfigError> =>
128
+ Schema.decodeUnknownEffect(RawConfigSchema)(value).pipe(
129
+ Effect.map((decoded) => decoded),
130
+ Effect.mapError(() => new ConfigError({ message: "Config file failed schema validation" }))
131
+ )
132
+
133
+ const readRecord = (source: Readonly<Record<string, unknown>>, key: string): Readonly<Record<string, unknown>> => {
134
+ const value = source[key]
135
+ return isRecord(value) ? value : {}
136
+ }
137
+
138
+ const readNumber = (source: Readonly<Record<string, unknown>>, key: string, fallback: number): number => {
139
+ const value = source[key]
140
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
141
+ }
142
+
143
+ const readBoolean = (source: Readonly<Record<string, unknown>>, key: string, fallback: boolean): boolean => {
144
+ const value = source[key]
145
+ return typeof value === "boolean" ? value : fallback
146
+ }
147
+
148
+ const readNullableString = (source: Readonly<Record<string, unknown>>, key: string, fallback: string | null): string | null => {
149
+ const value = source[key]
150
+ if (value === null) return null
151
+ return typeof value === "string" ? value : fallback
152
+ }
153
+
154
+ const readNullableNumber = (source: Readonly<Record<string, unknown>>, key: string, fallback: number | null): number | null => {
155
+ const value = source[key]
156
+ if (value === null) return null
157
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
158
+ }
159
+
160
+ const readConcurrencyStrategy = (source: Readonly<Record<string, unknown>>): "queue" | "burst" => {
161
+ const value = source.strategy
162
+ return value === "queue" || value === "burst" ? value : defaultConfig.concurrency.strategy
163
+ }
164
+
165
+ const optionalEnv = (value: string | undefined): string | undefined => {
166
+ if (value === undefined) return undefined
167
+ const trimmed = value.trim()
168
+ return trimmed === "" ? undefined : trimmed
169
+ }
170
+
171
+ const parsePort = (value: string | undefined, fallback: number, name: string): Effect.Effect<number, ConfigError> => {
172
+ if (value === undefined || value.trim() === "") return Effect.succeed(fallback)
173
+ const parsed = Number(value)
174
+ if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) return Effect.succeed(parsed)
175
+ return Effect.fail(new ConfigError({ message: `${name} must be a TCP port` }))
176
+ }
177
+
178
+ const parseConfigText = (text: string | undefined): Effect.Effect<Readonly<Record<string, unknown>>, ConfigError> => {
179
+ if (text === undefined || text.trim() === "") return Effect.succeed({})
180
+ return Effect.try({
181
+ try: () => {
182
+ const errors: Array<ParseError> = []
183
+ const parsed: unknown = parse(text, errors)
184
+ if (errors.length > 0) throw new Error("Invalid JSONC")
185
+ if (!isRecord(parsed)) return {}
186
+ return parsed
187
+ },
188
+ catch: () => new ConfigError({ message: "Config file must be valid JSONC" })
189
+ }).pipe(Effect.flatMap(decodeRawConfig))
190
+ }
191
+
192
+ const isMissingFile = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"
193
+
194
+ const readOptionalConfigFile = (path: string): Effect.Effect<string | undefined, ConfigError> =>
195
+ Effect.tryPromise({
196
+ try: async () => {
197
+ try {
198
+ return await readFile(path, "utf8")
199
+ } catch (cause) {
200
+ if (isMissingFile(cause)) return undefined
201
+ throw cause
202
+ }
203
+ },
204
+ catch: () => new ConfigError({ message: "Unable to read config file" })
205
+ })
206
+
207
+ export const loadConfigFromSources = Effect.fn("loadConfigFromSources")(function* (sources: ConfigSources) {
208
+ const file = yield* parseConfigText(sources.configText)
209
+ const token = sources.env.DISCORD_TOKEN
210
+ if (token === undefined || token.trim() === "") {
211
+ return yield* Effect.fail(new ConfigError({ message: "DISCORD_TOKEN is required" }))
212
+ }
213
+
214
+ const opencodePort = yield* parsePort(sources.env.OPENCODE_PORT, defaultConfig.opencode.port, "OPENCODE_PORT")
215
+ const bridgePort = yield* parsePort(sources.env.DISCORD_BRIDGE_PORT, defaultConfig.bridge.port, "DISCORD_BRIDGE_PORT")
216
+ const tools = readRecord(file, "tools")
217
+ const streaming = readRecord(file, "streaming")
218
+ const threads = readRecord(file, "threads")
219
+ const concurrency = readRecord(file, "concurrency")
220
+ const guards = readRecord(file, "guards")
221
+
222
+ const applicationId = optionalEnv(sources.env.DISCORD_APPLICATION_ID)
223
+ const publicKey = optionalEnv(sources.env.DISCORD_PUBLIC_KEY)
224
+ const guildId = optionalEnv(sources.env.DISCORD_GUILD_ID)
225
+ const model = optionalEnv(sources.env.OPENCODE_MODEL)
226
+ const agent = optionalEnv(sources.env.OPENCODE_AGENT)
227
+ const projectDir = optionalEnv(sources.env.OPENCODE_PROJECT_DIR) ?? sources.cwd
228
+ const maxTurnMs = readNullableNumber(guards, "maxTurnMs", null)
229
+
230
+ return {
231
+ discordToken: Redacted.make(token),
232
+ discord: {
233
+ ...(applicationId === undefined ? {} : { applicationId }),
234
+ ...(publicKey === undefined ? {} : { publicKey }),
235
+ ...(guildId === undefined ? {} : { guildId })
236
+ },
237
+ opencode: {
238
+ port: opencodePort,
239
+ baseUrl: `http://127.0.0.1:${opencodePort}`,
240
+ projectDir,
241
+ ...(model === undefined ? {} : { model }),
242
+ ...(agent === undefined ? {} : { agent })
243
+ },
244
+ bridge: {
245
+ host: defaultConfig.bridge.host,
246
+ port: bridgePort
247
+ },
248
+ context: {
249
+ messages: readNumber(file, "contextMessages", defaultConfig.context.messages),
250
+ maxChars: readNumber(file, "contextMaxChars", defaultConfig.context.maxChars),
251
+ attachmentMaxBytes: readNumber(file, "attachmentMaxBytes", defaultConfig.context.attachmentMaxBytes)
252
+ },
253
+ threads: {
254
+ activeByRecentBotParticipation: readBoolean(
255
+ threads,
256
+ "activeByRecentBotParticipation",
257
+ defaultConfig.threads.activeByRecentBotParticipation
258
+ )
259
+ },
260
+ tools: {
261
+ enabled: readBoolean(tools, "enabled", defaultConfig.tools.enabled),
262
+ autoInstall: readBoolean(tools, "autoInstall", defaultConfig.tools.autoInstall),
263
+ reactions: readBoolean(tools, "reactions", defaultConfig.tools.reactions),
264
+ attachFiles: readBoolean(tools, "attachFiles", defaultConfig.tools.attachFiles),
265
+ fetchHistory: readBoolean(tools, "fetchHistory", defaultConfig.tools.fetchHistory),
266
+ followUpMessages: readBoolean(tools, "followUpMessages", defaultConfig.tools.followUpMessages),
267
+ createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread),
268
+ editDeleteOwn: readBoolean(tools, "editDeleteOwn", defaultConfig.tools.editDeleteOwn),
269
+ postOtherChannels: readBoolean(tools, "postOtherChannels", defaultConfig.tools.postOtherChannels),
270
+ pin: readBoolean(tools, "pin", defaultConfig.tools.pin)
271
+ },
272
+ streaming: {
273
+ updateInterval: Duration.millis(readNumber(streaming, "updateIntervalMs", 500)),
274
+ placeholderText: readNullableString(streaming, "placeholderText", defaultConfig.streaming.placeholderText),
275
+ showToolStatus: readBoolean(streaming, "showToolStatus", defaultConfig.streaming.showToolStatus),
276
+ changedFilesSummary: readBoolean(streaming, "changedFilesSummary", defaultConfig.streaming.changedFilesSummary)
277
+ },
278
+ concurrency: {
279
+ strategy: readConcurrencyStrategy(concurrency),
280
+ lockScope: defaultConfig.concurrency.lockScope,
281
+ globalMaxActiveTurns: readNullableNumber(concurrency, "globalMaxActiveTurns", defaultConfig.concurrency.globalMaxActiveTurns)
282
+ },
283
+ guards: {
284
+ ignoreBots: readBoolean(guards, "ignoreBots", defaultConfig.guards.ignoreBots),
285
+ stripMassMentions: readBoolean(guards, "stripMassMentions", defaultConfig.guards.stripMassMentions),
286
+ redactSecretsInErrors: readBoolean(guards, "redactSecretsInErrors", defaultConfig.guards.redactSecretsInErrors),
287
+ maxTurn: maxTurnMs === null ? null : Duration.millis(maxTurnMs)
288
+ }
289
+ } satisfies RuntimeConfig
290
+ })
291
+
292
+ export const loadConfig = Effect.fn("loadConfig")(function* (options: LoadConfigOptions) {
293
+ const configText = yield* readOptionalConfigFile(options.configPath ?? join(options.cwd, ".opencode-discord.jsonc"))
294
+ return yield* loadConfigFromSources({ cwd: options.cwd, env: options.env, configText })
295
+ })
@@ -0,0 +1,46 @@
1
+ import { Schema } from "effect"
2
+
3
+ const PositiveInt = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))
4
+ const OptionalBoolean = Schema.optional(Schema.Boolean)
5
+ const RawToolsSchema = Schema.Struct({
6
+ enabled: OptionalBoolean,
7
+ autoInstall: OptionalBoolean,
8
+ reactions: OptionalBoolean,
9
+ attachFiles: OptionalBoolean,
10
+ fetchHistory: OptionalBoolean,
11
+ followUpMessages: OptionalBoolean,
12
+ createThread: OptionalBoolean,
13
+ editDeleteOwn: OptionalBoolean,
14
+ postOtherChannels: OptionalBoolean,
15
+ pin: OptionalBoolean
16
+ })
17
+
18
+ export const RawConfigSchema = Schema.Struct({
19
+ contextMessages: Schema.optional(PositiveInt),
20
+ contextMaxChars: Schema.optional(PositiveInt),
21
+ attachmentMaxBytes: Schema.optional(PositiveInt),
22
+ threads: Schema.optional(Schema.Struct({ activeByRecentBotParticipation: OptionalBoolean })),
23
+ tools: Schema.optional(RawToolsSchema),
24
+ streaming: Schema.optional(
25
+ Schema.Struct({
26
+ updateIntervalMs: Schema.optional(PositiveInt),
27
+ placeholderText: Schema.optional(Schema.NullOr(Schema.String)),
28
+ showToolStatus: OptionalBoolean,
29
+ changedFilesSummary: OptionalBoolean
30
+ })
31
+ ),
32
+ concurrency: Schema.optional(
33
+ Schema.Struct({
34
+ strategy: Schema.optional(Schema.Union([Schema.Literal("queue"), Schema.Literal("burst")])),
35
+ globalMaxActiveTurns: Schema.optional(Schema.NullOr(PositiveInt))
36
+ })
37
+ ),
38
+ guards: Schema.optional(
39
+ Schema.Struct({
40
+ ignoreBots: OptionalBoolean,
41
+ stripMassMentions: OptionalBoolean,
42
+ redactSecretsInErrors: OptionalBoolean,
43
+ maxTurnMs: Schema.optional(Schema.NullOr(PositiveInt))
44
+ })
45
+ )
46
+ })
@@ -0,0 +1,11 @@
1
+ export type ConfigSources = {
2
+ readonly cwd: string
3
+ readonly env: Readonly<Record<string, string | undefined>>
4
+ readonly configText?: string | undefined
5
+ }
6
+
7
+ export type LoadConfigOptions = {
8
+ readonly cwd: string
9
+ readonly env: Readonly<Record<string, string | undefined>>
10
+ readonly configPath?: string
11
+ }
@@ -0,0 +1,257 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import type { DiscordThreadId } from "@chat-adapter/discord"
6
+ import type { AdapterPostableMessage, ChannelInfo, FetchResult, RawMessage } from "chat"
7
+ import { Message, parseMarkdown } from "chat"
8
+ import { Duration, Effect } from "effect"
9
+ import type { DiscordScope } from "../Schema.ts"
10
+ import { makeChatSdkDiscord } from "./ChatSdkDiscord.ts"
11
+ import { DiscordError } from "./DiscordPort.ts"
12
+
13
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1", threadId: "t1" }
14
+
15
+ class FakeDiscordAdapter {
16
+ readonly calls: Array<readonly [string, unknown]> = []
17
+
18
+ encodeThreadId(input: DiscordThreadId): string {
19
+ this.calls.push(["encodeThreadId", input])
20
+ return input.threadId === undefined
21
+ ? `discord:${input.guildId}:${input.channelId}`
22
+ : `discord:${input.guildId}:${input.channelId}:${input.threadId}`
23
+ }
24
+
25
+ postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
26
+ this.calls.push(["postMessage", { threadId, message }])
27
+ return Promise.resolve({ id: "posted-1", threadId, raw: {} })
28
+ }
29
+
30
+ editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
31
+ this.calls.push(["editMessage", { threadId, messageId, message }])
32
+ return Promise.resolve({ id: messageId, threadId, raw: {} })
33
+ }
34
+
35
+ deleteMessage(threadId: string, messageId: string): Promise<void> {
36
+ this.calls.push(["deleteMessage", { threadId, messageId }])
37
+ return Promise.resolve()
38
+ }
39
+
40
+ postChannelMessage(channelId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
41
+ this.calls.push(["postChannelMessage", { channelId, message }])
42
+ return Promise.resolve({ id: "posted-channel-1", threadId: channelId, raw: {} })
43
+ }
44
+
45
+ fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
46
+ this.calls.push(["fetchChannelInfo", { channelId }])
47
+ return Promise.resolve({ id: channelId, isDM: false, metadata: { raw: { guild_id: "g1" } } })
48
+ }
49
+
50
+ startTyping(threadId: string): Promise<void> {
51
+ this.calls.push(["startTyping", { threadId }])
52
+ return Promise.resolve()
53
+ }
54
+
55
+ addReaction(threadId: string, messageId: string, emoji: string): Promise<void> {
56
+ this.calls.push(["addReaction", { threadId, messageId, emoji }])
57
+ return Promise.resolve()
58
+ }
59
+
60
+ removeReaction(threadId: string, messageId: string, emoji: string): Promise<void> {
61
+ this.calls.push(["removeReaction", { threadId, messageId, emoji }])
62
+ return Promise.resolve()
63
+ }
64
+
65
+ fetchMessages(threadId: string): Promise<FetchResult<unknown>> {
66
+ this.calls.push(["fetchMessages", { threadId }])
67
+ return Promise.resolve({
68
+ messages: [
69
+ new Message({
70
+ id: "m1",
71
+ threadId,
72
+ text: "hello <@999>",
73
+ formatted: parseMarkdown("hello <@999>"),
74
+ raw: {},
75
+ author: { userId: "u1", userName: "alice", fullName: "Alice", isBot: false, isMe: false },
76
+ metadata: { dateSent: new Date("2026-06-05T14:03:00.000Z"), edited: false },
77
+ attachments: [{ type: "file", name: "notes.txt", mimeType: "text/plain", size: 42, url: "https://example.test/notes.txt" }]
78
+ })
79
+ ]
80
+ })
81
+ }
82
+ }
83
+
84
+ describe("makeChatSdkDiscord", () => {
85
+ test("routes Discord port operations through chat-sdk adapter primitives", async () => {
86
+ const adapter = new FakeDiscordAdapter()
87
+ const discord = makeChatSdkDiscord(adapter)
88
+
89
+ const context = await Effect.runPromise(discord.fetchContext(scope, 30))
90
+ const history = await Effect.runPromise(discord.fetchHistory(scope, 2))
91
+ const posted = await Effect.runPromise(discord.postMessage(scope, "reply"))
92
+ await Effect.runPromise(discord.editMessage(scope, posted.id, "edited"))
93
+ await Effect.runPromise(discord.sendTyping(scope))
94
+ await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
95
+ await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
96
+ const directory = await mkdtemp(join(tmpdir(), "ocdb-chat-"))
97
+
98
+ try {
99
+ const path = join(directory, "upload.txt")
100
+ await writeFile(path, "uploaded")
101
+ expect(await Effect.runPromise(discord.attachFile(scope, path))).toEqual({ path: "posted-1" })
102
+ } finally {
103
+ await rm(directory, { recursive: true, force: true })
104
+ }
105
+
106
+ expect(context).toEqual([
107
+ {
108
+ id: "m1",
109
+ guildId: "g1",
110
+ channelId: "c1",
111
+ threadId: "t1",
112
+ author: { id: "u1", displayName: "Alice", nickname: "alice", isBot: false },
113
+ content: "hello <@999>",
114
+ timestamp: "2026-06-05T14:03:00.000Z",
115
+ mentions: ["999"],
116
+ roleMentions: [],
117
+ everyoneMention: false,
118
+ hereMention: false,
119
+ attachments: [{ id: "m1-0", filename: "notes.txt", contentType: "text/plain", size: 42, url: "https://example.test/notes.txt" }],
120
+ reactions: [],
121
+ channelType: "guild"
122
+ }
123
+ ])
124
+ expect(history).toEqual(context)
125
+ expect(adapter.calls.map((item) => item[0])).toEqual([
126
+ "encodeThreadId",
127
+ "fetchMessages",
128
+ "encodeThreadId",
129
+ "fetchMessages",
130
+ "encodeThreadId",
131
+ "postMessage",
132
+ "encodeThreadId",
133
+ "editMessage",
134
+ "encodeThreadId",
135
+ "startTyping",
136
+ "encodeThreadId",
137
+ "addReaction",
138
+ "encodeThreadId",
139
+ "removeReaction",
140
+ "encodeThreadId",
141
+ "postMessage"
142
+ ])
143
+ })
144
+
145
+ test("routes channel posts, deletes, and raw REST adapter gaps", async () => {
146
+ const adapter = new FakeDiscordAdapter()
147
+ const requests: Array<readonly [string, RequestInit]> = []
148
+ const originalFetch = globalThis.fetch
149
+ const fakeFetch: typeof fetch = Object.assign(
150
+ (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
151
+ requests.push([String(input), init ?? {}])
152
+ return Promise.resolve(
153
+ new Response(input.toString().includes("/threads") ? JSON.stringify({ id: "thread-1" }) : undefined, {
154
+ status: input.toString().includes("/threads") ? 200 : 204,
155
+ headers: { "content-type": "application/json" }
156
+ })
157
+ )
158
+ },
159
+ { preconnect: originalFetch.preconnect }
160
+ )
161
+ globalThis.fetch = fakeFetch
162
+
163
+ try {
164
+ const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
165
+
166
+ await Effect.runPromise(discord.deleteMessage(scope, "m1"))
167
+ expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-channel-1" })
168
+ expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
169
+ await Effect.runPromise(discord.pinMessage(scope, "m1"))
170
+ await Effect.runPromise(discord.unpinMessage(scope, "m1"))
171
+ } finally {
172
+ globalThis.fetch = originalFetch
173
+ }
174
+
175
+ expect(adapter.calls.map((item) => item[0])).toEqual([
176
+ "encodeThreadId",
177
+ "deleteMessage",
178
+ "encodeThreadId",
179
+ "fetchChannelInfo",
180
+ "postChannelMessage"
181
+ ])
182
+ expect(requests.map((request) => [request[0], request[1].method])).toEqual([
183
+ ["https://discord.test/api/channels/c1/threads", "POST"],
184
+ ["https://discord.test/api/channels/t1/pins/m1", "PUT"],
185
+ ["https://discord.test/api/channels/t1/pins/m1", "DELETE"]
186
+ ])
187
+ })
188
+
189
+ test("fails raw REST operations when no raw Discord client is configured", async () => {
190
+ const discord = makeChatSdkDiscord(new FakeDiscordAdapter())
191
+
192
+ await expect(Effect.runPromise(discord.createThread(scope, "work"))).rejects.toMatchObject({
193
+ _tag: "DiscordError",
194
+ message: "Discord adapter does not expose this operation"
195
+ })
196
+ })
197
+
198
+ test("preserves chat-sdk adapter retry metadata", async () => {
199
+ const adapter = new FakeDiscordAdapter()
200
+ adapter.postMessage = () => Promise.reject(Object.assign(new Error("limited"), { retryAfterMs: 123 }))
201
+ const discord = makeChatSdkDiscord(adapter)
202
+ let error: unknown
203
+
204
+ try {
205
+ await Effect.runPromise(discord.postMessage(scope, "hello"))
206
+ } catch (cause) {
207
+ error = cause
208
+ }
209
+
210
+ if (!(error instanceof DiscordError)) throw new Error("expected DiscordError")
211
+ if (error.retryAfter === undefined) throw new Error("expected retryAfter")
212
+ expect(error.message).toBe("limited")
213
+ expect(Duration.toMillis(error.retryAfter)).toBe(123)
214
+ })
215
+
216
+ test("rejects DM channel info before cross-channel posting", async () => {
217
+ const adapter = new FakeDiscordAdapter()
218
+ adapter.fetchChannelInfo = (channelId: string) => {
219
+ adapter.calls.push(["fetchChannelInfo", { channelId }])
220
+ return Promise.resolve({ id: channelId, isDM: true, metadata: {} })
221
+ }
222
+ const discord = makeChatSdkDiscord(adapter)
223
+
224
+ await expect(Effect.runPromise(discord.postChannelMessage("g1", "dm1", "hello"))).rejects.toMatchObject({
225
+ _tag: "DiscordError",
226
+ message: "Discord DMs are not supported"
227
+ })
228
+ expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "fetchChannelInfo"])
229
+ })
230
+
231
+ test("preserves raw Discord REST retry-after metadata", async () => {
232
+ const originalFetch = globalThis.fetch
233
+ const fakeFetch: typeof fetch = Object.assign(
234
+ () => Promise.resolve(new Response("limited", { status: 429, headers: { "retry-after": "2" } })),
235
+ { preconnect: originalFetch.preconnect }
236
+ )
237
+ globalThis.fetch = fakeFetch
238
+
239
+ try {
240
+ const discord = makeChatSdkDiscord(new FakeDiscordAdapter(), { botToken: "token", apiUrl: "https://discord.test/api" })
241
+ let error: unknown
242
+
243
+ try {
244
+ await Effect.runPromise(discord.createThread(scope, "work"))
245
+ } catch (cause) {
246
+ error = cause
247
+ }
248
+
249
+ if (!(error instanceof DiscordError)) throw new Error("expected DiscordError")
250
+ if (error.retryAfter === undefined) throw new Error("expected retryAfter")
251
+ expect(error.message).toBe("Discord REST 429: limited")
252
+ expect(Duration.toMillis(error.retryAfter)).toBe(2000)
253
+ } finally {
254
+ globalThis.fetch = originalFetch
255
+ }
256
+ })
257
+ })