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,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
|
+
}
|