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,206 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { basename } from "node:path"
|
|
3
|
+
import { createDiscordAdapter, type DiscordThreadId } from "@chat-adapter/discord"
|
|
4
|
+
import type { AdapterPostableMessage, ChannelInfo, FetchOptions, FetchResult, Message, PostableRaw, RawMessage } from "chat"
|
|
5
|
+
import { Duration, Effect } from "effect"
|
|
6
|
+
import type { DiscordAttachment, DiscordMessage, DiscordScope } from "../Schema.ts"
|
|
7
|
+
import { DiscordError, type DiscordService } from "./DiscordPort.ts"
|
|
8
|
+
|
|
9
|
+
type ChatDiscordAdapter = {
|
|
10
|
+
readonly encodeThreadId: (input: DiscordThreadId) => string
|
|
11
|
+
readonly postMessage: (threadId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
12
|
+
readonly postChannelMessage: (channelId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
13
|
+
readonly editMessage: (threadId: string, messageId: string, message: AdapterPostableMessage) => Promise<RawMessage<unknown>>
|
|
14
|
+
readonly deleteMessage: (threadId: string, messageId: string) => Promise<void>
|
|
15
|
+
readonly startTyping: (threadId: string, status?: string) => Promise<void>
|
|
16
|
+
readonly addReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
17
|
+
readonly removeReaction: (threadId: string, messageId: string, emoji: string) => Promise<void>
|
|
18
|
+
readonly fetchMessages: (threadId: string, options?: FetchOptions) => Promise<FetchResult<unknown>>
|
|
19
|
+
readonly fetchChannelInfo?: ((channelId: string) => Promise<ChannelInfo>) | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type LiveDiscordOptions = {
|
|
23
|
+
readonly botToken: string
|
|
24
|
+
readonly applicationId?: string
|
|
25
|
+
readonly publicKey?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const threadIdFromScope = (adapter: ChatDiscordAdapter, scope: DiscordScope): string =>
|
|
29
|
+
adapter.encodeThreadId({
|
|
30
|
+
guildId: scope.guildId,
|
|
31
|
+
channelId: scope.channelId,
|
|
32
|
+
...(scope.threadId === undefined ? {} : { threadId: scope.threadId })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const mentions = (content: string): ReadonlyArray<string> => [...content.matchAll(/<@(\d+)>/g)].map((match) => match[1] ?? "")
|
|
36
|
+
|
|
37
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
38
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
39
|
+
|
|
40
|
+
const stringField = (record: Readonly<Record<string, unknown>>, key: string): string | undefined => {
|
|
41
|
+
const value = record[key]
|
|
42
|
+
return typeof value === "string" ? value : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const attachments = (message: Message<unknown>): ReadonlyArray<DiscordAttachment> =>
|
|
46
|
+
message.attachments.map((item, index) => ({
|
|
47
|
+
id: `${message.id}-${index}`,
|
|
48
|
+
filename: item.name ?? `attachment-${index + 1}`,
|
|
49
|
+
...(item.mimeType === undefined ? {} : { contentType: item.mimeType }),
|
|
50
|
+
size: item.size ?? 0,
|
|
51
|
+
url: item.url ?? ""
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const fromChatMessage = (scope: DiscordScope, message: Message<unknown>): DiscordMessage => ({
|
|
55
|
+
id: message.id,
|
|
56
|
+
guildId: scope.guildId,
|
|
57
|
+
channelId: scope.channelId,
|
|
58
|
+
...(scope.threadId === undefined ? {} : { threadId: scope.threadId }),
|
|
59
|
+
author: {
|
|
60
|
+
id: message.author.userId,
|
|
61
|
+
displayName: message.author.fullName,
|
|
62
|
+
nickname: message.author.userName,
|
|
63
|
+
isBot: message.author.isBot === true
|
|
64
|
+
},
|
|
65
|
+
content: message.text,
|
|
66
|
+
timestamp: message.metadata.dateSent.toISOString(),
|
|
67
|
+
mentions: mentions(message.text),
|
|
68
|
+
roleMentions: [...message.text.matchAll(/<@&(\d+)>/g)].map((match) => match[1] ?? ""),
|
|
69
|
+
everyoneMention: message.text.includes("@everyone"),
|
|
70
|
+
hereMention: message.text.includes("@here"),
|
|
71
|
+
attachments: attachments(message),
|
|
72
|
+
reactions: [],
|
|
73
|
+
channelType: "guild"
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const retryAfterFromCause = (cause: unknown) => {
|
|
77
|
+
if (!isRecord(cause)) return undefined
|
|
78
|
+
const retryAfter = cause.retryAfter
|
|
79
|
+
if (Duration.isDuration(retryAfter)) return retryAfter
|
|
80
|
+
if (typeof cause.retryAfterMs === "number" && Number.isFinite(cause.retryAfterMs) && cause.retryAfterMs >= 0) {
|
|
81
|
+
return Duration.millis(cause.retryAfterMs)
|
|
82
|
+
}
|
|
83
|
+
if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) return Duration.millis(retryAfter * 1000)
|
|
84
|
+
if (typeof cause.retry_after === "number" && Number.isFinite(cause.retry_after) && cause.retry_after >= 0) {
|
|
85
|
+
return Duration.millis(cause.retry_after * 1000)
|
|
86
|
+
}
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const validateGuildChannel = async (adapter: ChatDiscordAdapter, guildId: string, channelThreadId: string): Promise<void> => {
|
|
91
|
+
if (adapter.fetchChannelInfo === undefined) return
|
|
92
|
+
const info = await adapter.fetchChannelInfo(channelThreadId)
|
|
93
|
+
if (info.isDM === true) throw new DiscordError({ message: "Discord DMs are not supported" })
|
|
94
|
+
const raw = info.metadata.raw
|
|
95
|
+
const actualGuildId = isRecord(raw) ? stringField(raw, "guild_id") : undefined
|
|
96
|
+
if (actualGuildId !== undefined && actualGuildId !== guildId) {
|
|
97
|
+
throw new DiscordError({ message: "Discord channel does not belong to the requested guild" })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tryAdapter = <A>(operation: () => Promise<A>): Effect.Effect<A, DiscordError> =>
|
|
102
|
+
Effect.tryPromise({
|
|
103
|
+
try: operation,
|
|
104
|
+
catch: (cause) =>
|
|
105
|
+
cause instanceof DiscordError
|
|
106
|
+
? cause
|
|
107
|
+
: new DiscordError({
|
|
108
|
+
message: cause instanceof Error ? cause.message : "chat-sdk Discord operation failed",
|
|
109
|
+
retryAfter: retryAfterFromCause(cause)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const retryAfterHeader = (response: Response) => {
|
|
114
|
+
const value = response.headers.get("retry-after")
|
|
115
|
+
if (value === null) return undefined
|
|
116
|
+
const seconds = Number(value)
|
|
117
|
+
return Number.isFinite(seconds) && seconds >= 0 ? Duration.millis(seconds * 1000) : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type RawDiscordOptions = {
|
|
121
|
+
readonly botToken: string
|
|
122
|
+
readonly apiUrl?: string | undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Effect.Effect<unknown, DiscordError> =>
|
|
126
|
+
tryAdapter(async () => {
|
|
127
|
+
if (options === undefined) throw new Error("Discord adapter does not expose this operation")
|
|
128
|
+
const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
|
|
129
|
+
...init,
|
|
130
|
+
headers: {
|
|
131
|
+
authorization: `Bot ${options.botToken}`,
|
|
132
|
+
"content-type": "application/json",
|
|
133
|
+
...init.headers
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
throw new DiscordError({
|
|
138
|
+
message: `Discord REST ${response.status}: ${await response.text()}`,
|
|
139
|
+
retryAfter: retryAfterHeader(response)
|
|
140
|
+
})
|
|
141
|
+
if (response.status === 204) return {}
|
|
142
|
+
return await response.json()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordOptions | undefined = undefined): DiscordService => ({
|
|
146
|
+
fetchContext: (scope, limit) =>
|
|
147
|
+
tryAdapter(async () => {
|
|
148
|
+
const threadId = threadIdFromScope(adapter, scope)
|
|
149
|
+
const result = await adapter.fetchMessages(threadId, { limit })
|
|
150
|
+
return result.messages.map((message) => fromChatMessage(scope, message))
|
|
151
|
+
}),
|
|
152
|
+
fetchHistory: (scope, limit) =>
|
|
153
|
+
tryAdapter(async () => {
|
|
154
|
+
const threadId = threadIdFromScope(adapter, scope)
|
|
155
|
+
const result = await adapter.fetchMessages(threadId, { limit })
|
|
156
|
+
return result.messages.map((message) => fromChatMessage(scope, message))
|
|
157
|
+
}),
|
|
158
|
+
sendTyping: (scope) => tryAdapter(() => adapter.startTyping(threadIdFromScope(adapter, scope))).pipe(Effect.asVoid),
|
|
159
|
+
postMessage: (scope, content) =>
|
|
160
|
+
tryAdapter(async () => {
|
|
161
|
+
const result = await adapter.postMessage(threadIdFromScope(adapter, scope), content)
|
|
162
|
+
return { id: result.id }
|
|
163
|
+
}),
|
|
164
|
+
editMessage: (scope, messageId, content) =>
|
|
165
|
+
tryAdapter(() => adapter.editMessage(threadIdFromScope(adapter, scope), messageId, content)).pipe(Effect.asVoid),
|
|
166
|
+
deleteMessage: (scope, messageId) =>
|
|
167
|
+
tryAdapter(() => adapter.deleteMessage(threadIdFromScope(adapter, scope), messageId)).pipe(Effect.asVoid),
|
|
168
|
+
addReaction: (scope, messageId, emoji) =>
|
|
169
|
+
tryAdapter(() => adapter.addReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
|
|
170
|
+
removeReaction: (scope, messageId, emoji) =>
|
|
171
|
+
tryAdapter(() => adapter.removeReaction(threadIdFromScope(adapter, scope), messageId, emoji)).pipe(Effect.asVoid),
|
|
172
|
+
attachFile: (scope, path) =>
|
|
173
|
+
tryAdapter(async () => {
|
|
174
|
+
const file: PostableRaw = { raw: "", files: [{ filename: basename(path), data: await readFile(path) }] }
|
|
175
|
+
const result = await adapter.postMessage(threadIdFromScope(adapter, scope), file)
|
|
176
|
+
return { path: result.id }
|
|
177
|
+
}),
|
|
178
|
+
createThread: (scope, name) =>
|
|
179
|
+
rawDiscord(raw, `/channels/${scope.channelId}/threads`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
body: JSON.stringify({ name, type: 11 })
|
|
182
|
+
}).pipe(Effect.map((data) => ({ id: isRecord(data) && typeof data.id === "string" ? data.id : "" }))),
|
|
183
|
+
postChannelMessage: (guildId, channelId, content) =>
|
|
184
|
+
tryAdapter(async () => {
|
|
185
|
+
const encodedChannelId = adapter.encodeThreadId({ guildId, channelId })
|
|
186
|
+
await validateGuildChannel(adapter, guildId, encodedChannelId)
|
|
187
|
+
const result = await adapter.postChannelMessage(encodedChannelId, sanitizeGuildContent(guildId, content))
|
|
188
|
+
return { id: result.id }
|
|
189
|
+
}),
|
|
190
|
+
pinMessage: (scope, messageId) =>
|
|
191
|
+
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "PUT" }).pipe(Effect.asVoid),
|
|
192
|
+
unpinMessage: (scope, messageId) =>
|
|
193
|
+
rawDiscord(raw, `/channels/${scope.threadId ?? scope.channelId}/pins/${messageId}`, { method: "DELETE" }).pipe(Effect.asVoid)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const sanitizeGuildContent = (_guildId: string, content: string): string => content
|
|
197
|
+
|
|
198
|
+
export const makeLiveChatSdkDiscord = (options: LiveDiscordOptions): DiscordService =>
|
|
199
|
+
makeChatSdkDiscord(
|
|
200
|
+
createDiscordAdapter({
|
|
201
|
+
botToken: options.botToken,
|
|
202
|
+
...(options.applicationId === undefined ? {} : { applicationId: options.applicationId }),
|
|
203
|
+
publicKey: options.publicKey ?? "0".repeat(64)
|
|
204
|
+
}),
|
|
205
|
+
{ botToken: options.botToken }
|
|
206
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { Message, parseMarkdown } from "chat"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import type { DiscordMessage } from "../Schema.ts"
|
|
5
|
+
import { collectDiscordMessages, makeChatGatewayIntake, makeGatewayAdapter, makeTransientChatState } from "./ChatSdkGatewayIntake.ts"
|
|
6
|
+
|
|
7
|
+
const message = {
|
|
8
|
+
id: "m1",
|
|
9
|
+
guildId: "g1",
|
|
10
|
+
channelId: "c1",
|
|
11
|
+
author: { id: "u1", displayName: "Alice", isBot: false },
|
|
12
|
+
content: "hello <@self>",
|
|
13
|
+
timestamp: "2026-06-05T14:03:00.000Z",
|
|
14
|
+
mentions: ["self"],
|
|
15
|
+
roleMentions: [],
|
|
16
|
+
everyoneMention: false,
|
|
17
|
+
hereMention: false,
|
|
18
|
+
attachments: [],
|
|
19
|
+
reactions: [],
|
|
20
|
+
channelType: "guild"
|
|
21
|
+
} satisfies DiscordMessage
|
|
22
|
+
|
|
23
|
+
test("processes gateway messages through chat-sdk intake and dedupes retries", async () => {
|
|
24
|
+
const seen: Array<readonly [string, number]> = []
|
|
25
|
+
const intake = makeChatGatewayIntake({
|
|
26
|
+
bot: { userId: "self" },
|
|
27
|
+
onMessage: (next, skipped) => Effect.sync(() => seen.push([next.id, skipped.length]))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
await Effect.runPromise(intake.processMessage(message))
|
|
31
|
+
await Effect.runPromise(intake.processMessage(message))
|
|
32
|
+
|
|
33
|
+
expect(seen).toEqual([["m1", 0]])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("maps Discord messages through the chat-sdk adapter facade", async () => {
|
|
37
|
+
const adapter = makeGatewayAdapter({ userId: "self" })
|
|
38
|
+
const withAttachment = {
|
|
39
|
+
...message,
|
|
40
|
+
attachments: [{ id: "a1", filename: "screen.png", contentType: "image/png", size: 10, url: "https://cdn/screen.png" }]
|
|
41
|
+
} satisfies DiscordMessage
|
|
42
|
+
const parsed = adapter.parseMessage(withAttachment)
|
|
43
|
+
|
|
44
|
+
expect(adapter.encodeThreadId({ guildId: "g1", channelId: "c1", threadId: "t1" })).toBe("discord:g1:c1:t1")
|
|
45
|
+
expect(adapter.decodeThreadId("discord:g1:c1:t1")).toEqual({ guildId: "g1", channelId: "c1", threadId: "t1" })
|
|
46
|
+
expect(adapter.channelIdFromThreadId("discord:g1:c1:t1")).toBe("c1")
|
|
47
|
+
expect(parsed.threadId).toBe("discord:g1:c1")
|
|
48
|
+
expect(parsed.attachments).toEqual([
|
|
49
|
+
{ type: "image", name: "screen.png", mimeType: "image/png", size: 10, url: "https://cdn/screen.png" }
|
|
50
|
+
])
|
|
51
|
+
expect(adapter.renderFormatted(parsed.formatted)).toContain("hello")
|
|
52
|
+
expect(await adapter.fetchMessages("discord:g1:c1")).toEqual({ messages: [] })
|
|
53
|
+
expect(await adapter.fetchThread("discord:g1:c1:t1")).toEqual({ id: "discord:g1:c1:t1", channelId: "c1", isDM: false, metadata: {} })
|
|
54
|
+
expect(adapter.isDM?.("discord:g1:c1")).toBe(false)
|
|
55
|
+
expect(await adapter.handleWebhook(new Request("http://localhost"))).toHaveProperty("status", 404)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("rejects output operations on the gateway intake adapter facade", async () => {
|
|
59
|
+
const adapter = makeGatewayAdapter({ userId: "self" })
|
|
60
|
+
|
|
61
|
+
await expect(adapter.postMessage("discord:g1:c1", "hello")).rejects.toThrow("cannot post messages")
|
|
62
|
+
if (adapter.postChannelMessage === undefined) throw new Error("missing postChannelMessage")
|
|
63
|
+
await expect(adapter.postChannelMessage("discord:g1:c1", "hello")).rejects.toThrow("cannot post channel messages")
|
|
64
|
+
await expect(adapter.editMessage("discord:g1:c1", "m1", "hello")).rejects.toThrow("cannot edit messages")
|
|
65
|
+
await expect(adapter.deleteMessage("discord:g1:c1", "m1")).rejects.toThrow("cannot delete messages")
|
|
66
|
+
await expect(adapter.addReaction("discord:g1:c1", "m1", "rocket")).rejects.toThrow("cannot add reactions")
|
|
67
|
+
await expect(adapter.removeReaction("discord:g1:c1", "m1", "rocket")).rejects.toThrow("cannot remove reactions")
|
|
68
|
+
await expect(adapter.startTyping("discord:g1:c1")).rejects.toThrow("cannot start typing")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("provides transient in-memory chat-sdk state", async () => {
|
|
72
|
+
const state = makeTransientChatState()
|
|
73
|
+
const adapter = makeGatewayAdapter({ userId: "self" })
|
|
74
|
+
const parsed = adapter.parseMessage(message)
|
|
75
|
+
|
|
76
|
+
await state.connect()
|
|
77
|
+
expect(await state.setIfNotExists("dedupe", true, 1000)).toBe(true)
|
|
78
|
+
expect(await state.setIfNotExists("dedupe", true, 1000)).toBe(false)
|
|
79
|
+
await state.set("expired", true, -1)
|
|
80
|
+
expect(await state.setIfNotExists("expired", true, 1000)).toBe(true)
|
|
81
|
+
await state.delete("dedupe")
|
|
82
|
+
await state.appendToList("list", "a", { maxLength: 1 })
|
|
83
|
+
expect(await state.getList("list")).toEqual([])
|
|
84
|
+
|
|
85
|
+
const lock = await state.acquireLock("thread", 1000)
|
|
86
|
+
if (lock === null) throw new Error("expected lock")
|
|
87
|
+
expect(await state.acquireLock("thread", 1000)).toBeNull()
|
|
88
|
+
expect(await state.extendLock(lock, 1000)).toBe(true)
|
|
89
|
+
await state.releaseLock(lock)
|
|
90
|
+
expect(await state.extendLock(lock, 1000)).toBe(false)
|
|
91
|
+
await state.forceReleaseLock("thread")
|
|
92
|
+
|
|
93
|
+
expect(await state.enqueue("thread", { message: parsed, enqueuedAt: 0, expiresAt: Date.now() - 1 }, 10)).toBe(1)
|
|
94
|
+
expect(await state.enqueue("thread", { message: parsed, enqueuedAt: 0, expiresAt: Date.now() + 1000 }, 1)).toBe(1)
|
|
95
|
+
expect(await state.queueDepth("thread")).toBe(1)
|
|
96
|
+
expect(await state.dequeue("thread")).toMatchObject({ message: parsed })
|
|
97
|
+
expect(await state.dequeue("thread")).toBeNull()
|
|
98
|
+
|
|
99
|
+
await state.subscribe("thread")
|
|
100
|
+
expect(await state.isSubscribed("thread")).toBe(true)
|
|
101
|
+
await state.unsubscribe("thread")
|
|
102
|
+
expect(await state.isSubscribed("thread")).toBe(false)
|
|
103
|
+
await state.disconnect()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("filters non-Discord raw messages from skipped chat context", () => {
|
|
107
|
+
const adapter = makeGatewayAdapter({ userId: "self" })
|
|
108
|
+
const valid = adapter.parseMessage(message)
|
|
109
|
+
const invalid = new Message({
|
|
110
|
+
id: "invalid",
|
|
111
|
+
threadId: "discord:g1:c1",
|
|
112
|
+
text: "invalid",
|
|
113
|
+
formatted: parseMarkdown("invalid"),
|
|
114
|
+
raw: {},
|
|
115
|
+
author: { userId: "u1", userName: "alice", fullName: "Alice", isBot: false, isMe: false },
|
|
116
|
+
metadata: { dateSent: new Date("2026-06-05T14:03:00.000Z"), edited: false },
|
|
117
|
+
attachments: []
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(collectDiscordMessages([invalid, valid])).toEqual([message])
|
|
121
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Adapter,
|
|
3
|
+
type Attachment,
|
|
4
|
+
Chat,
|
|
5
|
+
type FetchOptions,
|
|
6
|
+
type FetchResult,
|
|
7
|
+
type Lock,
|
|
8
|
+
Message,
|
|
9
|
+
type MessageContext,
|
|
10
|
+
parseMarkdown,
|
|
11
|
+
type QueueEntry,
|
|
12
|
+
type StateAdapter,
|
|
13
|
+
stringifyMarkdown
|
|
14
|
+
} from "chat"
|
|
15
|
+
import { Effect } from "effect"
|
|
16
|
+
import type { BotIdentity, DiscordAttachment, DiscordMessage, DiscordScope } from "../Schema.ts"
|
|
17
|
+
|
|
18
|
+
export type ChatGatewayIntake = {
|
|
19
|
+
readonly processMessage: (message: DiscordMessage) => Effect.Effect<void, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ChatGatewayIntakeOptions = {
|
|
23
|
+
readonly bot: BotIdentity
|
|
24
|
+
readonly onMessage: (message: DiscordMessage, skippedMessages: ReadonlyArray<DiscordMessage>) => Effect.Effect<void, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type StoredValue = {
|
|
28
|
+
readonly value: unknown
|
|
29
|
+
readonly expiresAt?: number | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const threadIdFromMessage = (message: DiscordMessage): string =>
|
|
33
|
+
message.threadId === undefined
|
|
34
|
+
? `discord:${message.guildId}:${message.channelId}`
|
|
35
|
+
: `discord:${message.guildId}:${message.channelId}:${message.threadId}`
|
|
36
|
+
|
|
37
|
+
const scopeFromThreadId = (threadId: string): DiscordScope => {
|
|
38
|
+
const [, guildId = "", channelId = "", threadIdPart = channelId] = threadId.split(":")
|
|
39
|
+
return threadIdPart === channelId ? { guildId, channelId } : { guildId, channelId, threadId: threadIdPart }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chatAttachment = (attachment: DiscordAttachment): Attachment => ({
|
|
43
|
+
type: attachment.contentType?.startsWith("image/") ? "image" : "file",
|
|
44
|
+
name: attachment.filename,
|
|
45
|
+
size: attachment.size,
|
|
46
|
+
url: attachment.url,
|
|
47
|
+
...(attachment.contentType === undefined ? {} : { mimeType: attachment.contentType })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const isDiscordMessage = (value: unknown): value is DiscordMessage =>
|
|
51
|
+
typeof value === "object" &&
|
|
52
|
+
value !== null &&
|
|
53
|
+
"id" in value &&
|
|
54
|
+
typeof value.id === "string" &&
|
|
55
|
+
"guildId" in value &&
|
|
56
|
+
typeof value.guildId === "string" &&
|
|
57
|
+
"channelId" in value &&
|
|
58
|
+
typeof value.channelId === "string"
|
|
59
|
+
|
|
60
|
+
const toChatMessage = (message: DiscordMessage, bot: BotIdentity): Message<DiscordMessage> =>
|
|
61
|
+
new Message({
|
|
62
|
+
id: message.id,
|
|
63
|
+
threadId: threadIdFromMessage(message),
|
|
64
|
+
text: message.content,
|
|
65
|
+
formatted: parseMarkdown(message.content),
|
|
66
|
+
raw: message,
|
|
67
|
+
author: {
|
|
68
|
+
userId: message.author.id,
|
|
69
|
+
userName: message.author.nickname ?? message.author.displayName,
|
|
70
|
+
fullName: message.author.displayName,
|
|
71
|
+
isBot: message.author.isBot,
|
|
72
|
+
isMe: message.author.id === bot.userId
|
|
73
|
+
},
|
|
74
|
+
metadata: { dateSent: new Date(message.timestamp), edited: false },
|
|
75
|
+
attachments: message.attachments.map(chatAttachment),
|
|
76
|
+
isMention: message.mentions.includes(bot.userId)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const fromChatMessage = (message: Message<unknown>): DiscordMessage | undefined => (isDiscordMessage(message.raw) ? message.raw : undefined)
|
|
80
|
+
|
|
81
|
+
export const collectDiscordMessages = (messages: Iterable<Message<unknown>>): ReadonlyArray<DiscordMessage> => {
|
|
82
|
+
const collected: Array<DiscordMessage> = []
|
|
83
|
+
for (const message of messages) {
|
|
84
|
+
const discordMessage = fromChatMessage(message)
|
|
85
|
+
if (discordMessage !== undefined) collected.push(discordMessage)
|
|
86
|
+
}
|
|
87
|
+
return collected
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const liveValue = (stored: StoredValue | undefined): unknown | undefined => {
|
|
91
|
+
if (stored === undefined) return undefined
|
|
92
|
+
if (stored.expiresAt !== undefined && stored.expiresAt <= Date.now()) return undefined
|
|
93
|
+
return stored.value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const makeTransientChatState = (): StateAdapter => {
|
|
97
|
+
const values = new Map<string, StoredValue>()
|
|
98
|
+
const lists = new Map<string, Array<unknown>>()
|
|
99
|
+
const queues = new Map<string, Array<QueueEntry>>()
|
|
100
|
+
const locks = new Map<string, Lock>()
|
|
101
|
+
const subscribed = new Set<string>()
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
connect: () => Promise.resolve(),
|
|
105
|
+
disconnect: () => Promise.resolve(),
|
|
106
|
+
get: () => Promise.resolve(null),
|
|
107
|
+
set: (key, value, ttlMs) => {
|
|
108
|
+
values.set(key, { value, ...(ttlMs === undefined ? {} : { expiresAt: Date.now() + ttlMs }) })
|
|
109
|
+
return Promise.resolve()
|
|
110
|
+
},
|
|
111
|
+
setIfNotExists: (key, value, ttlMs) => {
|
|
112
|
+
if (liveValue(values.get(key)) !== undefined) return Promise.resolve(false)
|
|
113
|
+
values.set(key, { value, ...(ttlMs === undefined ? {} : { expiresAt: Date.now() + ttlMs }) })
|
|
114
|
+
return Promise.resolve(true)
|
|
115
|
+
},
|
|
116
|
+
delete: (key) => {
|
|
117
|
+
values.delete(key)
|
|
118
|
+
return Promise.resolve()
|
|
119
|
+
},
|
|
120
|
+
appendToList: (key, value, options) => {
|
|
121
|
+
const next = [...(lists.get(key) ?? []), value].slice(-(options?.maxLength ?? Number.POSITIVE_INFINITY))
|
|
122
|
+
lists.set(key, next)
|
|
123
|
+
return Promise.resolve()
|
|
124
|
+
},
|
|
125
|
+
getList: () => Promise.resolve([]),
|
|
126
|
+
acquireLock: (threadId, ttlMs) => {
|
|
127
|
+
const existing = locks.get(threadId)
|
|
128
|
+
if (existing !== undefined && existing.expiresAt > Date.now()) return Promise.resolve(null)
|
|
129
|
+
const lock = { threadId, token: crypto.randomUUID(), expiresAt: Date.now() + ttlMs }
|
|
130
|
+
locks.set(threadId, lock)
|
|
131
|
+
return Promise.resolve(lock)
|
|
132
|
+
},
|
|
133
|
+
extendLock: (lock, ttlMs) => {
|
|
134
|
+
if (locks.get(lock.threadId)?.token !== lock.token) return Promise.resolve(false)
|
|
135
|
+
locks.set(lock.threadId, { ...lock, expiresAt: Date.now() + ttlMs })
|
|
136
|
+
return Promise.resolve(true)
|
|
137
|
+
},
|
|
138
|
+
releaseLock: (lock) => {
|
|
139
|
+
if (locks.get(lock.threadId)?.token === lock.token) locks.delete(lock.threadId)
|
|
140
|
+
return Promise.resolve()
|
|
141
|
+
},
|
|
142
|
+
forceReleaseLock: (threadId) => {
|
|
143
|
+
locks.delete(threadId)
|
|
144
|
+
return Promise.resolve()
|
|
145
|
+
},
|
|
146
|
+
enqueue: (threadId, entry, maxSize) => {
|
|
147
|
+
const next = [...(queues.get(threadId) ?? []), entry].slice(-maxSize)
|
|
148
|
+
queues.set(threadId, next)
|
|
149
|
+
return Promise.resolve(next.length)
|
|
150
|
+
},
|
|
151
|
+
dequeue: (threadId) => {
|
|
152
|
+
const queue = queues.get(threadId) ?? []
|
|
153
|
+
const now = Date.now()
|
|
154
|
+
while (queue.length > 0) {
|
|
155
|
+
const entry = queue.shift()
|
|
156
|
+
if (entry !== undefined && entry.expiresAt > now) return Promise.resolve(entry)
|
|
157
|
+
}
|
|
158
|
+
queues.delete(threadId)
|
|
159
|
+
return Promise.resolve(null)
|
|
160
|
+
},
|
|
161
|
+
queueDepth: (threadId) => Promise.resolve(queues.get(threadId)?.length ?? 0),
|
|
162
|
+
subscribe: (threadId) => {
|
|
163
|
+
subscribed.add(threadId)
|
|
164
|
+
return Promise.resolve()
|
|
165
|
+
},
|
|
166
|
+
unsubscribe: (threadId) => {
|
|
167
|
+
subscribed.delete(threadId)
|
|
168
|
+
return Promise.resolve()
|
|
169
|
+
},
|
|
170
|
+
isSubscribed: (threadId) => Promise.resolve(subscribed.has(threadId))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const unsupported = (operation: string): Promise<never> => Promise.reject(new Error(`chat-sdk gateway intake cannot ${operation}`))
|
|
175
|
+
|
|
176
|
+
export const makeGatewayAdapter = (bot: BotIdentity): Adapter<DiscordScope, DiscordMessage> => ({
|
|
177
|
+
name: "discord",
|
|
178
|
+
userName: `<@${bot.userId}>`,
|
|
179
|
+
botUserId: bot.userId,
|
|
180
|
+
lockScope: "thread",
|
|
181
|
+
initialize: () => Promise.resolve(),
|
|
182
|
+
handleWebhook: () => Promise.resolve(new Response(null, { status: 404 })),
|
|
183
|
+
encodeThreadId: (scope) =>
|
|
184
|
+
scope.threadId === undefined
|
|
185
|
+
? `discord:${scope.guildId}:${scope.channelId}`
|
|
186
|
+
: `discord:${scope.guildId}:${scope.channelId}:${scope.threadId}`,
|
|
187
|
+
decodeThreadId: scopeFromThreadId,
|
|
188
|
+
channelIdFromThreadId: (threadId) => scopeFromThreadId(threadId).channelId,
|
|
189
|
+
parseMessage: (raw) => toChatMessage(raw, bot),
|
|
190
|
+
renderFormatted: (content) => stringifyMarkdown(content),
|
|
191
|
+
fetchMessages: (_threadId, _options?: FetchOptions): Promise<FetchResult<DiscordMessage>> => Promise.resolve({ messages: [] }),
|
|
192
|
+
fetchThread: (threadId) => {
|
|
193
|
+
const scope = scopeFromThreadId(threadId)
|
|
194
|
+
return Promise.resolve({ id: threadId, channelId: scope.channelId, isDM: false, metadata: {} })
|
|
195
|
+
},
|
|
196
|
+
isDM: () => false,
|
|
197
|
+
postMessage: () => unsupported("post messages"),
|
|
198
|
+
postChannelMessage: () => unsupported("post channel messages"),
|
|
199
|
+
editMessage: () => unsupported("edit messages"),
|
|
200
|
+
deleteMessage: () => unsupported("delete messages"),
|
|
201
|
+
addReaction: () => unsupported("add reactions"),
|
|
202
|
+
removeReaction: () => unsupported("remove reactions"),
|
|
203
|
+
startTyping: () => unsupported("start typing")
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
export const makeChatGatewayIntake = (options: ChatGatewayIntakeOptions): ChatGatewayIntake => {
|
|
207
|
+
const adapter = makeGatewayAdapter(options.bot)
|
|
208
|
+
const chat = new Chat({
|
|
209
|
+
adapters: { discord: adapter },
|
|
210
|
+
state: makeTransientChatState(),
|
|
211
|
+
userName: `<@${options.bot.userId}>`,
|
|
212
|
+
concurrency: "concurrent",
|
|
213
|
+
dedupeTtlMs: 5 * 60 * 1000
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const handle = (message: Message<unknown>, context?: MessageContext) => {
|
|
217
|
+
const current = fromChatMessage(message)
|
|
218
|
+
if (current === undefined) return Promise.resolve()
|
|
219
|
+
const skipped = collectDiscordMessages(context?.skipped ?? [])
|
|
220
|
+
return Effect.runPromise(options.onMessage(current, skipped))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const dispatch = (_thread: unknown, message: Message<unknown>, context?: MessageContext) => handle(message, context)
|
|
224
|
+
chat.onNewMention(dispatch)
|
|
225
|
+
chat.onSubscribedMessage(dispatch)
|
|
226
|
+
chat.onNewMessage(/[\s\S]*/, dispatch)
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
processMessage: (message) =>
|
|
230
|
+
Effect.tryPromise({
|
|
231
|
+
try: () => chat.processMessage(adapter, threadIdFromMessage(message), toChatMessage(message, options.bot)),
|
|
232
|
+
catch: (cause) => cause
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|