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,208 @@
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 { Effect } from "effect"
6
+ import type { DiscordScope } from "../Schema.ts"
7
+ import { type DiscordJsChannelLike, fromDiscordJsMessage, makeDiscordJsDiscord } from "./DiscordJsDiscord.ts"
8
+
9
+ const collection = <A>(items: ReadonlyArray<A>) => ({ values: () => items[Symbol.iterator]() })
10
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
11
+
12
+ const baseMessage = (
13
+ overrides: { readonly guildId?: string | null; readonly channelId?: string; readonly channel?: DiscordJsChannelLike } = {}
14
+ ) => ({
15
+ id: "m1",
16
+ guildId: overrides.guildId ?? "g1",
17
+ channelId: overrides.channelId ?? "c1",
18
+ channel: overrides.channel ?? { id: "c1", isDMBased: () => false, isThread: () => false },
19
+ author: { id: "u1", username: "alice", globalName: null, displayName: "Alice", bot: false },
20
+ member: { nickname: null },
21
+ content: "hello @here",
22
+ createdAt: new Date("2026-06-05T14:03:00.000Z"),
23
+ mentions: { users: collection([{ id: "u2" }]), roles: collection([{ id: "r1" }]), everyone: true },
24
+ attachments: collection([{ id: "a1", name: "a.txt", contentType: null, size: 4, url: "https://example.test/a.txt" }]),
25
+ reactions: { cache: collection([{ emoji: { name: null }, count: 1 }]) },
26
+ system: false,
27
+ inGuild: () => overrides.guildId !== null
28
+ })
29
+
30
+ describe("fromDiscordJsMessage", () => {
31
+ test("maps guild and thread messages and ignores non-guild messages", () => {
32
+ expect(fromDiscordJsMessage(baseMessage())).toEqual({
33
+ id: "m1",
34
+ guildId: "g1",
35
+ channelId: "c1",
36
+ author: { id: "u1", displayName: "Alice", isBot: false },
37
+ content: "hello @here",
38
+ timestamp: "2026-06-05T14:03:00.000Z",
39
+ mentions: ["u2"],
40
+ roleMentions: ["r1"],
41
+ everyoneMention: true,
42
+ hereMention: true,
43
+ attachments: [{ id: "a1", filename: "a.txt", size: 4, url: "https://example.test/a.txt" }],
44
+ reactions: [{ emoji: "unknown", count: 1 }],
45
+ channelType: "guild",
46
+ isSystem: false
47
+ })
48
+ expect(
49
+ fromDiscordJsMessage(baseMessage({ channelId: "t1", channel: { id: "t1", parentId: "c1", isThread: () => true } }))
50
+ ).toMatchObject({
51
+ channelId: "c1",
52
+ threadId: "t1"
53
+ })
54
+ expect(fromDiscordJsMessage(baseMessage({ guildId: null, channel: { id: "dm1", isDMBased: () => true } }))).toBeUndefined()
55
+ })
56
+ })
57
+
58
+ describe("makeDiscordJsDiscord", () => {
59
+ test("routes port operations through a discord.js-like client", async () => {
60
+ const calls: Array<readonly [string, unknown]> = []
61
+ const fetchedMessage = {
62
+ ...baseMessage(),
63
+ edit: (content: string) => {
64
+ calls.push(["edit", content])
65
+ return Promise.resolve({})
66
+ },
67
+ delete: () => {
68
+ calls.push(["delete", {}])
69
+ return Promise.resolve({})
70
+ },
71
+ pin: () => {
72
+ calls.push(["pin", {}])
73
+ return Promise.resolve({})
74
+ },
75
+ unpin: () => {
76
+ calls.push(["unpin", {}])
77
+ return Promise.resolve({})
78
+ },
79
+ react: (emoji: string) => {
80
+ calls.push(["react", emoji])
81
+ return Promise.resolve({})
82
+ },
83
+ reactions: {
84
+ cache: collection([{ emoji: { name: "rocket" }, count: 2 }]),
85
+ resolve: (emoji: string) => ({
86
+ users: { remove: (userId: string) => Promise.resolve(calls.push(["removeReaction", { emoji, userId }])) }
87
+ })
88
+ }
89
+ }
90
+ const channel = {
91
+ send: (content: unknown) => {
92
+ calls.push(["send", content])
93
+ return Promise.resolve({ id: "posted-1" })
94
+ },
95
+ sendTyping: () => {
96
+ calls.push(["typing", {}])
97
+ return Promise.resolve()
98
+ },
99
+ messages: {
100
+ fetch: (query: string | { readonly limit: number }) => {
101
+ calls.push(["fetchMessages", query])
102
+ return Promise.resolve(typeof query === "string" ? fetchedMessage : collection([baseMessage()]))
103
+ }
104
+ },
105
+ threads: {
106
+ create: (options: { readonly name: string }) => {
107
+ calls.push(["createThread", options])
108
+ return Promise.resolve({ id: "thread-1" })
109
+ }
110
+ }
111
+ }
112
+ const client = {
113
+ user: { id: "bot-1" },
114
+ channels: {
115
+ fetch: (id: string) => {
116
+ calls.push(["fetchChannel", id])
117
+ return Promise.resolve(channel)
118
+ }
119
+ }
120
+ }
121
+ const discord = makeDiscordJsDiscord(client)
122
+ const directory = await mkdtemp(join(tmpdir(), "ocdb-discordjs-"))
123
+
124
+ try {
125
+ const file = join(directory, "upload.txt")
126
+ await writeFile(file, "upload")
127
+ const context = await Effect.runPromise(discord.fetchContext(scope, 1))
128
+ const history = await Effect.runPromise(discord.fetchHistory(scope, 1))
129
+ await Effect.runPromise(discord.sendTyping(scope))
130
+ const posted = await Effect.runPromise(discord.postMessage(scope, "hello"))
131
+ await Effect.runPromise(discord.editMessage(scope, "m1", "edited"))
132
+ await Effect.runPromise(discord.deleteMessage(scope, "m1"))
133
+ await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
134
+ await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
135
+ const attached = await Effect.runPromise(discord.attachFile(scope, file))
136
+ expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
137
+ expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-1" })
138
+ await Effect.runPromise(discord.pinMessage(scope, "m1"))
139
+ await Effect.runPromise(discord.unpinMessage(scope, "m1"))
140
+
141
+ expect(context).toHaveLength(1)
142
+ expect(history).toHaveLength(1)
143
+ expect(posted).toEqual({ id: "posted-1" })
144
+ expect(attached).toEqual({ path: "posted-1" })
145
+ expect(calls.map((call) => call[0])).toContain("removeReaction")
146
+ expect(calls.map((call) => call[0])).toContain("delete")
147
+ expect(calls.map((call) => call[0])).toContain("createThread")
148
+ expect(calls.map((call) => call[0])).toContain("pin")
149
+ expect(calls.map((call) => call[0])).toContain("unpin")
150
+ } finally {
151
+ await rm(directory, { recursive: true, force: true })
152
+ }
153
+ })
154
+
155
+ test("fails when a fetched channel is not text-capable", async () => {
156
+ const discord = makeDiscordJsDiscord({ user: { id: "bot-1" }, channels: { fetch: () => Promise.resolve({}) } })
157
+
158
+ await expect(Effect.runPromise(discord.postMessage(scope, "hello"))).rejects.toMatchObject({
159
+ _tag: "DiscordError",
160
+ message: "Discord channel is not text-capable"
161
+ })
162
+ })
163
+
164
+ test("rejects DM-based output targets", async () => {
165
+ const channel = {
166
+ isDMBased: () => true,
167
+ send: () => Promise.resolve({ id: "posted-1" }),
168
+ messages: { fetch: () => Promise.resolve(collection([])) }
169
+ }
170
+ const discord = makeDiscordJsDiscord({ user: { id: "bot-1" }, channels: { fetch: () => Promise.resolve(channel) } })
171
+
172
+ await expect(Effect.runPromise(discord.postMessage(scope, "hello"))).rejects.toMatchObject({
173
+ _tag: "DiscordError",
174
+ message: "Discord DMs are not supported"
175
+ })
176
+ })
177
+
178
+ test("fails high-risk operations on unsupported channels and messages", async () => {
179
+ const fetchedMessage = {
180
+ ...baseMessage(),
181
+ edit: () => Promise.resolve({}),
182
+ react: () => Promise.resolve({}),
183
+ reactions: { resolve: () => null }
184
+ }
185
+ const channel = {
186
+ send: () => Promise.resolve({ id: "posted-1" }),
187
+ messages: { fetch: () => Promise.resolve(fetchedMessage) }
188
+ }
189
+ const discord = makeDiscordJsDiscord({ user: { id: "bot-1" }, channels: { fetch: () => Promise.resolve(channel) } })
190
+
191
+ await expect(Effect.runPromise(discord.createThread(scope, "work"))).rejects.toMatchObject({
192
+ _tag: "DiscordError",
193
+ message: "Discord channel cannot create threads"
194
+ })
195
+ await expect(Effect.runPromise(discord.pinMessage(scope, "m1"))).rejects.toMatchObject({
196
+ _tag: "DiscordError",
197
+ message: "Discord message is not pinnable"
198
+ })
199
+ await expect(Effect.runPromise(discord.unpinMessage(scope, "m1"))).rejects.toMatchObject({
200
+ _tag: "DiscordError",
201
+ message: "Discord message is not unpinnable"
202
+ })
203
+ await expect(Effect.runPromise(discord.deleteMessage(scope, "m1"))).rejects.toMatchObject({
204
+ _tag: "DiscordError",
205
+ message: "Discord message is not deletable"
206
+ })
207
+ })
208
+ })
@@ -0,0 +1,267 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { basename } from "node:path"
3
+ import { Effect } from "effect"
4
+ import type { DiscordAttachment, DiscordMessage, DiscordReaction, DiscordScope } from "../Schema.ts"
5
+ import { DiscordError, type DiscordService } from "./DiscordPort.ts"
6
+
7
+ type CollectionLike<A> = {
8
+ readonly values: () => IterableIterator<A>
9
+ }
10
+
11
+ type AttachmentLike = {
12
+ readonly id: string
13
+ readonly name: string
14
+ readonly contentType: string | null
15
+ readonly size: number
16
+ readonly url: string
17
+ }
18
+
19
+ type ReactionLike = {
20
+ readonly emoji: { readonly name: string | null; readonly identifier?: string }
21
+ readonly count: number
22
+ }
23
+
24
+ type AuthorLike = {
25
+ readonly id: string
26
+ readonly displayName?: string
27
+ readonly globalName?: string | null
28
+ readonly username: string
29
+ readonly bot: boolean
30
+ }
31
+
32
+ export type DiscordJsMessageLike = {
33
+ readonly id: string
34
+ readonly guildId: string | null
35
+ readonly channelId: string
36
+ readonly channel: DiscordJsChannelLike
37
+ readonly author: AuthorLike
38
+ readonly member?: { readonly nickname: string | null } | null
39
+ readonly content: string
40
+ readonly createdAt: Date
41
+ readonly mentions: {
42
+ readonly users: CollectionLike<{ readonly id: string }>
43
+ readonly roles: CollectionLike<{ readonly id: string }>
44
+ readonly everyone: boolean
45
+ }
46
+ readonly attachments: CollectionLike<AttachmentLike>
47
+ readonly reactions: { readonly cache: CollectionLike<ReactionLike> }
48
+ readonly system: boolean
49
+ readonly inGuild?: () => boolean
50
+ }
51
+
52
+ type DiscordPostedLike = {
53
+ readonly id: string
54
+ }
55
+
56
+ type DiscordFetchedMessageLike = DiscordJsMessageLike & {
57
+ readonly edit: (content: string) => Promise<unknown>
58
+ readonly react: (emoji: string) => Promise<unknown>
59
+ readonly delete?: () => Promise<unknown>
60
+ readonly pin?: () => Promise<unknown>
61
+ readonly unpin?: () => Promise<unknown>
62
+ readonly reactions: DiscordJsMessageLike["reactions"] & {
63
+ readonly resolve: (emoji: string) => { readonly users: { readonly remove: (userId: string) => Promise<unknown> } } | null
64
+ }
65
+ }
66
+
67
+ export type DiscordJsChannelLike = {
68
+ readonly id: string
69
+ readonly parentId?: string | null
70
+ readonly isDMBased?: () => boolean
71
+ readonly isThread?: () => boolean
72
+ readonly send?: (
73
+ content: string | { readonly files: ReadonlyArray<{ readonly attachment: Uint8Array; readonly name: string }> }
74
+ ) => Promise<DiscordPostedLike>
75
+ readonly sendTyping?: () => Promise<void>
76
+ readonly threads?: {
77
+ readonly create: (input: { readonly name: string }) => Promise<{ readonly id: string }>
78
+ }
79
+ readonly messages?: {
80
+ readonly fetch: (
81
+ query: string | { readonly limit: number }
82
+ ) => Promise<DiscordFetchedMessageLike | CollectionLike<DiscordJsMessageLike>>
83
+ }
84
+ }
85
+
86
+ export type DiscordJsClientLike = {
87
+ readonly user: { readonly id: string } | null
88
+ readonly channels: {
89
+ readonly fetch: (id: string) => Promise<unknown>
90
+ }
91
+ }
92
+
93
+ const isObject = (value: unknown): value is object => typeof value === "object" && value !== null
94
+
95
+ const hasMethod = (value: object, key: string): boolean => typeof Reflect.get(value, key) === "function"
96
+
97
+ const isTextChannel = (value: unknown): value is Required<Pick<DiscordJsChannelLike, "send" | "messages">> & DiscordJsChannelLike =>
98
+ isObject(value) &&
99
+ hasMethod(value, "send") &&
100
+ isObject(Reflect.get(value, "messages")) &&
101
+ hasMethod(Reflect.get(value, "messages"), "fetch")
102
+
103
+ const isCollectionLike = <A>(value: unknown): value is CollectionLike<A> => isObject(value) && hasMethod(value, "values")
104
+
105
+ const isFetchedMessage = (value: unknown): value is DiscordFetchedMessageLike =>
106
+ isObject(value) && hasMethod(value, "edit") && hasMethod(value, "react")
107
+
108
+ const fromCollection = <A>(collection: CollectionLike<A>): ReadonlyArray<A> => [...collection.values()]
109
+
110
+ const channelTargetId = (scope: DiscordScope): string => scope.threadId ?? scope.channelId
111
+
112
+ const channelScope = (message: DiscordJsMessageLike): DiscordScope | undefined => {
113
+ if (message.guildId === null) return undefined
114
+ const channel = message.channel
115
+ if (channel.isDMBased?.() === true) return undefined
116
+ if (channel.isThread?.() === true) {
117
+ return { guildId: message.guildId, channelId: channel.parentId ?? message.channelId, threadId: message.channelId }
118
+ }
119
+ return { guildId: message.guildId, channelId: message.channelId }
120
+ }
121
+
122
+ const attachment = (item: AttachmentLike): DiscordAttachment => ({
123
+ id: item.id,
124
+ filename: item.name,
125
+ ...(item.contentType === null ? {} : { contentType: item.contentType }),
126
+ size: item.size,
127
+ url: item.url
128
+ })
129
+
130
+ const reaction = (item: ReactionLike): DiscordReaction => ({
131
+ emoji: item.emoji.identifier ?? item.emoji.name ?? "unknown",
132
+ count: item.count
133
+ })
134
+
135
+ export const fromDiscordJsMessage = (message: DiscordJsMessageLike): DiscordMessage | undefined => {
136
+ if (message.inGuild?.() === false) return undefined
137
+ const scope = channelScope(message)
138
+ if (scope === undefined) return undefined
139
+
140
+ return {
141
+ id: message.id,
142
+ ...scope,
143
+ author: {
144
+ id: message.author.id,
145
+ displayName: message.author.displayName ?? message.author.globalName ?? message.author.username,
146
+ ...(message.member?.nickname === undefined || message.member.nickname === null ? {} : { nickname: message.member.nickname }),
147
+ isBot: message.author.bot
148
+ },
149
+ content: message.content,
150
+ timestamp: message.createdAt.toISOString(),
151
+ mentions: fromCollection(message.mentions.users).map((user) => user.id),
152
+ roleMentions: fromCollection(message.mentions.roles).map((role) => role.id),
153
+ everyoneMention: message.mentions.everyone,
154
+ hereMention: message.content.includes("@here"),
155
+ attachments: fromCollection(message.attachments).map(attachment),
156
+ reactions: fromCollection(message.reactions.cache).map(reaction),
157
+ channelType: "guild",
158
+ isSystem: message.system
159
+ }
160
+ }
161
+
162
+ const tryDiscord = <A>(operation: () => Promise<A>): Effect.Effect<A, DiscordError> =>
163
+ Effect.tryPromise({
164
+ try: operation,
165
+ catch: (cause) => new DiscordError({ message: cause instanceof Error ? cause.message : "Discord operation failed" })
166
+ })
167
+
168
+ const fetchTextChannel = (
169
+ client: DiscordJsClientLike,
170
+ scope: DiscordScope
171
+ ): Effect.Effect<Required<Pick<DiscordJsChannelLike, "send" | "messages">> & DiscordJsChannelLike, DiscordError> =>
172
+ tryDiscord(async () => {
173
+ const channel = await client.channels.fetch(channelTargetId(scope))
174
+ if (!isTextChannel(channel)) throw new Error("Discord channel is not text-capable")
175
+ if (channel.isDMBased?.() === true) throw new Error("Discord DMs are not supported")
176
+ return channel
177
+ })
178
+
179
+ const fetchMessage = (
180
+ client: DiscordJsClientLike,
181
+ scope: DiscordScope,
182
+ messageId: string
183
+ ): Effect.Effect<DiscordFetchedMessageLike, DiscordError> =>
184
+ Effect.gen(function* () {
185
+ const channel = yield* fetchTextChannel(client, scope)
186
+ const message = yield* tryDiscord(() => channel.messages.fetch(messageId))
187
+ if (!isFetchedMessage(message)) return yield* Effect.fail(new DiscordError({ message: "Discord message is not editable/reactable" }))
188
+ return message
189
+ })
190
+
191
+ export const makeDiscordJsDiscord = (client: DiscordJsClientLike): DiscordService => ({
192
+ fetchContext: (scope, limit) =>
193
+ Effect.gen(function* () {
194
+ const channel = yield* fetchTextChannel(client, scope)
195
+ const result = yield* tryDiscord(() => channel.messages.fetch({ limit }))
196
+ if (!isCollectionLike<DiscordJsMessageLike>(result)) return []
197
+ return fromCollection(result).flatMap((message) => {
198
+ const mapped = fromDiscordJsMessage(message)
199
+ return mapped === undefined ? [] : [mapped]
200
+ })
201
+ }),
202
+ fetchHistory: (scope, limit) => makeDiscordJsDiscord(client).fetchContext(scope, limit),
203
+ sendTyping: (scope) =>
204
+ Effect.gen(function* () {
205
+ const channel = yield* fetchTextChannel(client, scope)
206
+ if (channel.sendTyping !== undefined) yield* tryDiscord(() => channel.sendTyping?.() ?? Promise.resolve())
207
+ }),
208
+ postMessage: (scope, content) =>
209
+ Effect.gen(function* () {
210
+ const channel = yield* fetchTextChannel(client, scope)
211
+ const result = yield* tryDiscord(() => channel.send(content))
212
+ return { id: result.id }
213
+ }),
214
+ editMessage: (scope, messageId, content) =>
215
+ Effect.gen(function* () {
216
+ const message = yield* fetchMessage(client, scope, messageId)
217
+ yield* tryDiscord(() => message.edit(content))
218
+ }),
219
+ deleteMessage: (scope, messageId) =>
220
+ Effect.gen(function* () {
221
+ const message = yield* fetchMessage(client, scope, messageId)
222
+ if (message.delete === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord message is not deletable" }))
223
+ yield* tryDiscord(() => message.delete?.() ?? Promise.resolve())
224
+ }),
225
+ addReaction: (scope, messageId, emoji) =>
226
+ Effect.gen(function* () {
227
+ const message = yield* fetchMessage(client, scope, messageId)
228
+ yield* tryDiscord(() => message.react(emoji))
229
+ }),
230
+ removeReaction: (scope, messageId, emoji) =>
231
+ Effect.gen(function* () {
232
+ const message = yield* fetchMessage(client, scope, messageId)
233
+ const reaction = message.reactions.resolve(emoji)
234
+ if (reaction !== null && client.user !== null) yield* tryDiscord(() => reaction.users.remove(client.user?.id ?? ""))
235
+ }),
236
+ attachFile: (scope, path) =>
237
+ Effect.gen(function* () {
238
+ const channel = yield* fetchTextChannel(client, scope)
239
+ const data = yield* tryDiscord(() => readFile(path))
240
+ const result = yield* tryDiscord(() => channel.send({ files: [{ attachment: data, name: basename(path) }] }))
241
+ return { path: result.id }
242
+ }),
243
+ createThread: (scope, name) =>
244
+ Effect.gen(function* () {
245
+ const channel = yield* fetchTextChannel(client, scope)
246
+ if (channel.threads === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord channel cannot create threads" }))
247
+ return yield* tryDiscord(() => channel.threads?.create({ name }) ?? Promise.resolve({ id: "" }))
248
+ }),
249
+ postChannelMessage: (_guildId, channelId, content) =>
250
+ Effect.gen(function* () {
251
+ const channel = yield* fetchTextChannel(client, { guildId: _guildId, channelId })
252
+ const result = yield* tryDiscord(() => channel.send(content))
253
+ return { id: result.id }
254
+ }),
255
+ pinMessage: (scope, messageId) =>
256
+ Effect.gen(function* () {
257
+ const message = yield* fetchMessage(client, scope, messageId)
258
+ if (message.pin === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord message is not pinnable" }))
259
+ yield* tryDiscord(() => message.pin?.() ?? Promise.resolve())
260
+ }),
261
+ unpinMessage: (scope, messageId) =>
262
+ Effect.gen(function* () {
263
+ const message = yield* fetchMessage(client, scope, messageId)
264
+ if (message.unpin === undefined) return yield* Effect.fail(new DiscordError({ message: "Discord message is not unpinnable" }))
265
+ yield* tryDiscord(() => message.unpin?.() ?? Promise.resolve())
266
+ })
267
+ })
@@ -0,0 +1,30 @@
1
+ import { Context, Data, type Duration, type Effect } from "effect"
2
+ import type { DiscordMessage, DiscordScope } from "../Schema.ts"
3
+
4
+ export class DiscordError extends Data.TaggedError("DiscordError")<{
5
+ readonly message: string
6
+ readonly retryAfter?: Duration.Duration | undefined
7
+ }> {}
8
+
9
+ export type DiscordPostedMessage = {
10
+ readonly scope: DiscordScope
11
+ readonly content: string
12
+ }
13
+
14
+ export type DiscordService = {
15
+ readonly fetchContext: (scope: DiscordScope, limit: number) => Effect.Effect<ReadonlyArray<DiscordMessage>, DiscordError>
16
+ readonly sendTyping: (scope: DiscordScope) => Effect.Effect<void, DiscordError>
17
+ readonly postMessage: (scope: DiscordScope, content: string) => Effect.Effect<{ readonly id: string }, DiscordError>
18
+ readonly editMessage: (scope: DiscordScope, messageId: string, content: string) => Effect.Effect<void, DiscordError>
19
+ readonly addReaction: (scope: DiscordScope, messageId: string, emoji: string) => Effect.Effect<void, DiscordError>
20
+ readonly removeReaction: (scope: DiscordScope, messageId: string, emoji: string) => Effect.Effect<void, DiscordError>
21
+ readonly fetchHistory: (scope: DiscordScope, limit: number) => Effect.Effect<ReadonlyArray<DiscordMessage>, DiscordError>
22
+ readonly attachFile: (scope: DiscordScope, path: string) => Effect.Effect<{ readonly path: string }, DiscordError>
23
+ readonly createThread: (scope: DiscordScope, name: string) => Effect.Effect<{ readonly id: string }, DiscordError>
24
+ readonly deleteMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
25
+ readonly postChannelMessage: (guildId: string, channelId: string, content: string) => Effect.Effect<{ readonly id: string }, DiscordError>
26
+ readonly pinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
27
+ readonly unpinMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
28
+ }
29
+
30
+ export const Discord = Context.Service<DiscordService>("opencode-discord-bot/Discord")
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import type { DiscordMessage, DiscordScope } from "../Schema.ts"
4
+ import { makeMemoryDiscord } from "./MemoryDiscord.ts"
5
+
6
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
7
+
8
+ const message = (id: string): DiscordMessage => ({
9
+ id,
10
+ guildId: "g1",
11
+ channelId: "c1",
12
+ author: { id: "u1", displayName: "Alice", isBot: false },
13
+ content: id,
14
+ timestamp: "2026-06-05T14:03:00.000Z",
15
+ mentions: [],
16
+ roleMentions: [],
17
+ everyoneMention: false,
18
+ hereMention: false,
19
+ attachments: [],
20
+ reactions: [],
21
+ channelType: "guild"
22
+ })
23
+
24
+ describe("makeMemoryDiscord", () => {
25
+ test("records every Discord port operation", async () => {
26
+ const discord = makeMemoryDiscord({ context: [message("1"), message("2")] })
27
+
28
+ const context = await Effect.runPromise(discord.fetchContext(scope, 1))
29
+ const history = await Effect.runPromise(discord.fetchHistory(scope, 2))
30
+ const posted = await Effect.runPromise(discord.postMessage(scope, "hello"))
31
+ await Effect.runPromise(discord.editMessage(scope, posted.id, "updated"))
32
+ await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
33
+ await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
34
+ const attached = await Effect.runPromise(discord.attachFile(scope, "/repo/out.txt"))
35
+
36
+ expect(context.map((item) => item.id)).toEqual(["2"])
37
+ expect(history.map((item) => item.id)).toEqual(["1", "2"])
38
+ expect(posted).toEqual({ id: "posted-1" })
39
+ expect(discord.messages).toEqual([{ scope, content: "hello" }])
40
+ expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "updated" }])
41
+ expect(discord.reactions.map((item) => item.op)).toEqual(["add", "remove"])
42
+ expect(attached).toEqual({ path: "/repo/out.txt" })
43
+ })
44
+ })
@@ -0,0 +1,85 @@
1
+ import { Effect } from "effect"
2
+ import type { DiscordMessage, DiscordScope } from "../Schema.ts"
3
+ import type { DiscordPostedMessage, DiscordService } from "./DiscordPort.ts"
4
+
5
+ type MemoryOptions = {
6
+ readonly context?: ReadonlyArray<DiscordMessage>
7
+ }
8
+
9
+ export type MemoryDiscord = DiscordService & {
10
+ readonly context: Array<DiscordMessage>
11
+ readonly typingScopes: Array<DiscordScope>
12
+ readonly messages: Array<DiscordPostedMessage>
13
+ readonly edits: Array<{ readonly scope: DiscordScope; readonly messageId: string; readonly content: string }>
14
+ readonly reactions: Array<{
15
+ readonly scope: DiscordScope
16
+ readonly messageId: string
17
+ readonly emoji: string
18
+ readonly op: "add" | "remove"
19
+ }>
20
+ readonly attachments: Array<{ readonly scope: DiscordScope; readonly path: string }>
21
+ readonly threads: Array<{ readonly scope: DiscordScope; readonly name: string }>
22
+ readonly deletes: Array<{ readonly scope: DiscordScope; readonly messageId: string }>
23
+ readonly channelMessages: Array<{ readonly guildId: string; readonly channelId: string; readonly content: string }>
24
+ readonly pins: Array<{ readonly scope: DiscordScope; readonly messageId: string; readonly op: "pin" | "unpin" }>
25
+ }
26
+
27
+ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord => {
28
+ let nextId = 0
29
+ const context = [...(options.context ?? [])]
30
+ const typingScopes: Array<DiscordScope> = []
31
+ const messages: Array<DiscordPostedMessage> = []
32
+ const edits: MemoryDiscord["edits"] = []
33
+ const reactions: MemoryDiscord["reactions"] = []
34
+ const attachments: MemoryDiscord["attachments"] = []
35
+ const threads: MemoryDiscord["threads"] = []
36
+ const deletes: MemoryDiscord["deletes"] = []
37
+ const channelMessages: MemoryDiscord["channelMessages"] = []
38
+ const pins: MemoryDiscord["pins"] = []
39
+
40
+ return {
41
+ context,
42
+ typingScopes,
43
+ messages,
44
+ edits,
45
+ reactions,
46
+ attachments,
47
+ threads,
48
+ deletes,
49
+ channelMessages,
50
+ pins,
51
+ fetchContext: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
52
+ sendTyping: (scope) => Effect.sync(() => typingScopes.push(scope)).pipe(Effect.asVoid),
53
+ postMessage: (scope, content) =>
54
+ Effect.sync(() => {
55
+ nextId += 1
56
+ messages.push({ scope, content })
57
+ return { id: `posted-${nextId}` }
58
+ }),
59
+ editMessage: (scope, messageId, content) => Effect.sync(() => edits.push({ scope, messageId, content })).pipe(Effect.asVoid),
60
+ addReaction: (scope, messageId, emoji) => Effect.sync(() => reactions.push({ scope, messageId, emoji, op: "add" })).pipe(Effect.asVoid),
61
+ removeReaction: (scope, messageId, emoji) =>
62
+ Effect.sync(() => reactions.push({ scope, messageId, emoji, op: "remove" })).pipe(Effect.asVoid),
63
+ fetchHistory: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
64
+ attachFile: (scope, path) =>
65
+ Effect.sync(() => {
66
+ attachments.push({ scope, path })
67
+ return { path }
68
+ }),
69
+ createThread: (scope, name) =>
70
+ Effect.sync(() => {
71
+ nextId += 1
72
+ threads.push({ scope, name })
73
+ return { id: `thread-${nextId}` }
74
+ }),
75
+ deleteMessage: (scope, messageId) => Effect.sync(() => deletes.push({ scope, messageId })).pipe(Effect.asVoid),
76
+ postChannelMessage: (guildId, channelId, content) =>
77
+ Effect.sync(() => {
78
+ nextId += 1
79
+ channelMessages.push({ guildId, channelId, content })
80
+ return { id: `posted-${nextId}` }
81
+ }),
82
+ pinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "pin" })).pipe(Effect.asVoid),
83
+ unpinMessage: (scope, messageId) => Effect.sync(() => pins.push({ scope, messageId, op: "unpin" })).pipe(Effect.asVoid)
84
+ }
85
+ }
@@ -0,0 +1,11 @@
1
+ export type DiscordOutputGuards = {
2
+ readonly stripMassMentions: boolean
3
+ }
4
+
5
+ export const sanitizeDiscordContent = (content: string, guards: DiscordOutputGuards): string => {
6
+ if (!guards.stripMassMentions) return content
7
+ return content
8
+ .replaceAll("@everyone", "@ everyone")
9
+ .replaceAll("@here", "@ here")
10
+ .replace(/<@&(\d+)>/g, "<@& $1>")
11
+ }