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