opencode-discord-bot 0.0.11 → 0.2.0
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/package.json +1 -1
- package/src/Bridge/ToolControl.test.ts +31 -4
- package/src/Bridge/ToolControl.ts +33 -17
- package/src/Config.ts +3 -3
- package/src/ConfigSchema.ts +1 -1
- package/src/Discord/ChatSdkDiscord.test.ts +0 -6
- package/src/Discord/ChatSdkDiscord.ts +22 -38
- package/src/Discord/ChatSdkDiscordReactions.test.ts +77 -0
- package/src/Discord/ChatSdkDiscordSearch.test.ts +122 -0
- package/src/Discord/DiscordJsDiscord.test.ts +0 -2
- package/src/Discord/DiscordJsDiscord.ts +1 -1
- package/src/Discord/DiscordPort.ts +6 -2
- package/src/Discord/DiscordRest.ts +78 -0
- package/src/Discord/DiscordSearchRest.ts +232 -0
- package/src/Discord/MemoryDiscord.test.ts +6 -2
- package/src/Discord/MemoryDiscord.ts +39 -2
- package/src/Discord/SearchQuery.test.ts +53 -0
- package/src/Discord/SearchQuery.ts +268 -0
- package/src/Discord/SearchQueryCriteria.ts +35 -0
- package/src/Discord/SearchQueryDates.ts +43 -0
- package/src/Discord/SearchQueryTokens.ts +25 -0
- package/src/Main.test.ts +1 -1
- package/src/Schema.ts +36 -0
- package/src/Tools/DiscordSearchMessagesTool.ts +35 -0
- package/src/Tools/Scaffolding.test.ts +7 -3
- package/src/Tools/Scaffolding.ts +10 -8
- package/src/Tools/DiscordFetchHistoryTool.ts +0 -28
package/package.json
CHANGED
|
@@ -104,7 +104,7 @@ describe("handleToolRequest", () => {
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
describe("handleToolRequest action dispatch", () => {
|
|
107
|
-
test("dispatches reactions,
|
|
107
|
+
test("dispatches reactions, search, and safe attachments", async () => {
|
|
108
108
|
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tool-"))
|
|
109
109
|
await mkdir(join(projectDir, "out"), { recursive: true })
|
|
110
110
|
await writeFile(join(projectDir, "out", "report.txt"), "report")
|
|
@@ -137,9 +137,9 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
137
137
|
discord
|
|
138
138
|
)
|
|
139
139
|
)
|
|
140
|
-
const
|
|
140
|
+
const search = await Effect.runPromise(
|
|
141
141
|
handleToolRequest(
|
|
142
|
-
{ action: "
|
|
142
|
+
{ action: "searchMessages", target: { guildId: "g1" }, args: { query: "hi", limit: 1 } },
|
|
143
143
|
defaultConfig,
|
|
144
144
|
projectDir,
|
|
145
145
|
discord
|
|
@@ -156,7 +156,15 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
156
156
|
const attachmentRealpath = await realpath(join(projectDir, "out", "report.txt"))
|
|
157
157
|
|
|
158
158
|
expect(add).toEqual({ ok: true, result: { reacted: true } })
|
|
159
|
-
expect(
|
|
159
|
+
expect(search).toEqual({
|
|
160
|
+
ok: true,
|
|
161
|
+
result: {
|
|
162
|
+
totalResults: 1,
|
|
163
|
+
offset: 0,
|
|
164
|
+
hasMore: false,
|
|
165
|
+
messages: discord.context
|
|
166
|
+
}
|
|
167
|
+
})
|
|
160
168
|
expect(attach).toEqual({ ok: true, result: { path: attachmentRealpath } })
|
|
161
169
|
expect(discord.reactions.map((item) => item.op)).toEqual(["add"])
|
|
162
170
|
expect(discord.attachments).toEqual([{ scope: { guildId: "g1", channelId: "c1" }, path: attachmentRealpath }])
|
|
@@ -199,6 +207,21 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
199
207
|
expect(response).toEqual({ ok: false, error: "Discord target is outside the active turn scope" })
|
|
200
208
|
})
|
|
201
209
|
|
|
210
|
+
test("allows search across the active turn guild", async () => {
|
|
211
|
+
const discord = makeMemoryDiscord({ context: [] })
|
|
212
|
+
const response = await Effect.runPromise(
|
|
213
|
+
handleToolRequest(
|
|
214
|
+
{ action: "searchMessages", target: { guildId: "g1" }, args: { query: "from:<@u1>" } },
|
|
215
|
+
defaultConfig,
|
|
216
|
+
"/repo",
|
|
217
|
+
discord,
|
|
218
|
+
{ allowedScopes: [{ guildId: "g1", channelId: "c1" }] }
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(response).toEqual({ ok: true, result: { totalResults: 0, offset: 0, hasMore: false, messages: [] } })
|
|
223
|
+
})
|
|
224
|
+
|
|
202
225
|
test("returns validation errors for malformed or unsupported requests", async () => {
|
|
203
226
|
const disabled = await Effect.runPromise(
|
|
204
227
|
handleToolRequest(
|
|
@@ -232,10 +255,14 @@ describe("handleToolRequest action dispatch", () => {
|
|
|
232
255
|
makeMemoryDiscord()
|
|
233
256
|
)
|
|
234
257
|
)
|
|
258
|
+
const missingQuery = await Effect.runPromise(
|
|
259
|
+
handleToolRequest({ action: "searchMessages", target: { guildId: "g1" }, args: {} }, defaultConfig, "/repo", makeMemoryDiscord())
|
|
260
|
+
)
|
|
235
261
|
|
|
236
262
|
expect(disabled).toEqual({ ok: false, error: "Discord bridge tools are disabled" })
|
|
237
263
|
expect(unknown).toEqual({ ok: false, error: "Unknown action unknown" })
|
|
238
264
|
expect(missingReactionFields).toEqual({ ok: false, error: "messageId and emoji are required" })
|
|
239
265
|
expect(missingPath).toEqual({ ok: false, error: "path is required" })
|
|
266
|
+
expect(missingQuery).toEqual({ ok: false, error: "query is required" })
|
|
240
267
|
})
|
|
241
268
|
})
|
|
@@ -3,6 +3,7 @@ import { isAbsolute, resolve, sep } from "node:path"
|
|
|
3
3
|
import { Effect } from "effect"
|
|
4
4
|
import type { RuntimeConfig, ToolConfig } from "../Config.ts"
|
|
5
5
|
import type { DiscordService } from "../Discord/DiscordPort.ts"
|
|
6
|
+
import { hasDiscordSearchCriteria, parseDiscordSearchQuery } from "../Discord/SearchQuery.ts"
|
|
6
7
|
import type { DiscordScope, ToolRequest, ToolResponse } from "../Schema.ts"
|
|
7
8
|
|
|
8
9
|
type ToolRequestOptions = {
|
|
@@ -15,8 +16,8 @@ const actionFlag = (action: string): keyof ToolConfig | undefined => {
|
|
|
15
16
|
return "reactions"
|
|
16
17
|
case "attachFile":
|
|
17
18
|
return "attachFiles"
|
|
18
|
-
case "
|
|
19
|
-
return "
|
|
19
|
+
case "searchMessages":
|
|
20
|
+
return "searchMessages"
|
|
20
21
|
case "createThread":
|
|
21
22
|
return "createThread"
|
|
22
23
|
default:
|
|
@@ -24,14 +25,15 @@ const actionFlag = (action: string): keyof ToolConfig | undefined => {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const scopeFromRequest = (request: ToolRequest): DiscordScope | string => {
|
|
28
|
+
const scopeFromRequest = (request: ToolRequest, requireChannel: boolean): DiscordScope | string => {
|
|
28
29
|
const { guildId, channelId, threadId } = request.target
|
|
29
|
-
if (guildId === undefined
|
|
30
|
+
if (guildId === undefined) return "Discord target must include guildId"
|
|
31
|
+
if (requireChannel && channelId === undefined) return "Discord target must include guildId and channelId"
|
|
30
32
|
const values = [guildId, channelId, threadId]
|
|
31
33
|
if (values.some((value) => value?.toLowerCase() === "@me" || value?.toLowerCase() === "dm")) {
|
|
32
34
|
return "Discord DMs are not supported"
|
|
33
35
|
}
|
|
34
|
-
return { guildId, channelId, ...(threadId === undefined ? {} : { threadId }) }
|
|
36
|
+
return { guildId, channelId: channelId ?? "", ...(threadId === undefined ? {} : { threadId }) }
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const scopeKey = (scope: DiscordScope): string => `${scope.guildId}:${scope.channelId}:${scope.threadId ?? ""}`
|
|
@@ -39,11 +41,21 @@ const scopeKey = (scope: DiscordScope): string => `${scope.guildId}:${scope.chan
|
|
|
39
41
|
const isAllowedScope = (scope: DiscordScope, allowedScopes: ReadonlyArray<DiscordScope> | undefined): boolean =>
|
|
40
42
|
allowedScopes === undefined || allowedScopes.some((allowed) => scopeKey(allowed) === scopeKey(scope))
|
|
41
43
|
|
|
44
|
+
const isAllowedGuild = (scope: DiscordScope, allowedScopes: ReadonlyArray<DiscordScope> | undefined): boolean =>
|
|
45
|
+
allowedScopes === undefined || allowedScopes.some((allowed) => allowed.guildId === scope.guildId)
|
|
46
|
+
|
|
42
47
|
const stringArg = (request: ToolRequest, key: string): string | undefined => {
|
|
43
48
|
const value = request.args[key]
|
|
44
49
|
return typeof value === "string" ? value : undefined
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
const integerArg = (request: ToolRequest, key: string): number | undefined => {
|
|
53
|
+
const value = request.args[key]
|
|
54
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value))
|
|
58
|
+
|
|
47
59
|
const attachmentPath = Effect.fn("attachmentPath")(function* (projectDir: string, input: string, maxBytes: number) {
|
|
48
60
|
if (isAbsolute(input) || input.includes(".."))
|
|
49
61
|
return { ok: false, error: "Attachment path must stay inside the project directory" } satisfies ToolResponse
|
|
@@ -69,14 +81,17 @@ const addReaction = Effect.fn("toolAddReaction")(function* (request: ToolRequest
|
|
|
69
81
|
return { ok: true, result: { reacted: true } } satisfies ToolResponse
|
|
70
82
|
})
|
|
71
83
|
|
|
72
|
-
const
|
|
73
|
-
request
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
) {
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
const searchMessages = Effect.fn("toolSearchMessages")(function* (request: ToolRequest, scope: DiscordScope, discord: DiscordService) {
|
|
85
|
+
const queryText = stringArg(request, "query")
|
|
86
|
+
if (queryText === undefined || queryText.trim() === "") return { ok: false, error: "query is required" } satisfies ToolResponse
|
|
87
|
+
const parsed = parseDiscordSearchQuery(queryText)
|
|
88
|
+
if (!parsed.ok) return { ok: false, error: parsed.error } satisfies ToolResponse
|
|
89
|
+
if (!hasDiscordSearchCriteria(parsed.query)) {
|
|
90
|
+
return { ok: false, error: "query must contain at least one Discord search criterion" } satisfies ToolResponse
|
|
91
|
+
}
|
|
92
|
+
const limit = clamp(integerArg(request, "limit") ?? 25, 1, 25)
|
|
93
|
+
const offset = clamp(integerArg(request, "offset") ?? 0, 0, 9975)
|
|
94
|
+
const result = yield* discord.searchMessages(scope, parsed.query, { limit, offset })
|
|
80
95
|
return { ok: true, result } satisfies ToolResponse
|
|
81
96
|
})
|
|
82
97
|
|
|
@@ -114,17 +129,18 @@ export const handleToolRequest = Effect.fn("handleToolRequest")(function* (
|
|
|
114
129
|
if (flag === undefined) return { ok: false, error: `Unknown action ${request.action}` } satisfies ToolResponse
|
|
115
130
|
if (!config.tools[flag]) return disabled(request.action)
|
|
116
131
|
|
|
117
|
-
const
|
|
132
|
+
const isSearch = request.action === "searchMessages"
|
|
133
|
+
const scope = scopeFromRequest(request, !isSearch)
|
|
118
134
|
if (typeof scope === "string") return { ok: false, error: scope } satisfies ToolResponse
|
|
119
|
-
if (!isAllowedScope(scope, options.allowedScopes)) {
|
|
135
|
+
if (isSearch ? !isAllowedGuild(scope, options.allowedScopes) : !isAllowedScope(scope, options.allowedScopes)) {
|
|
120
136
|
return { ok: false, error: "Discord target is outside the active turn scope" } satisfies ToolResponse
|
|
121
137
|
}
|
|
122
138
|
|
|
123
139
|
switch (request.action) {
|
|
124
140
|
case "addReaction":
|
|
125
141
|
return yield* addReaction(request, scope, discord)
|
|
126
|
-
case "
|
|
127
|
-
return yield*
|
|
142
|
+
case "searchMessages":
|
|
143
|
+
return yield* searchMessages(request, scope, discord)
|
|
128
144
|
case "attachFile":
|
|
129
145
|
return yield* attachFile(request, scope, config, projectDir, discord)
|
|
130
146
|
case "createThread":
|
package/src/Config.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type ToolConfig = {
|
|
|
16
16
|
readonly autoInstall: boolean
|
|
17
17
|
readonly reactions: boolean
|
|
18
18
|
readonly attachFiles: boolean
|
|
19
|
-
readonly
|
|
19
|
+
readonly searchMessages: boolean
|
|
20
20
|
readonly createThread: boolean
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -91,7 +91,7 @@ export const defaultConfig: RuntimeConfig = {
|
|
|
91
91
|
autoInstall: true,
|
|
92
92
|
reactions: true,
|
|
93
93
|
attachFiles: true,
|
|
94
|
-
|
|
94
|
+
searchMessages: true,
|
|
95
95
|
createThread: false
|
|
96
96
|
},
|
|
97
97
|
streaming: {
|
|
@@ -254,7 +254,7 @@ export const loadConfigFromSources = Effect.fn("loadConfigFromSources")(function
|
|
|
254
254
|
autoInstall: readBoolean(tools, "autoInstall", defaultConfig.tools.autoInstall),
|
|
255
255
|
reactions: readBoolean(tools, "reactions", defaultConfig.tools.reactions),
|
|
256
256
|
attachFiles: readBoolean(tools, "attachFiles", defaultConfig.tools.attachFiles),
|
|
257
|
-
|
|
257
|
+
searchMessages: readBoolean(tools, "searchMessages", defaultConfig.tools.searchMessages),
|
|
258
258
|
createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread)
|
|
259
259
|
},
|
|
260
260
|
streaming: {
|
package/src/ConfigSchema.ts
CHANGED
|
@@ -72,7 +72,6 @@ describe("makeChatSdkDiscord", () => {
|
|
|
72
72
|
const discord = makeChatSdkDiscord(adapter)
|
|
73
73
|
|
|
74
74
|
const context = await Effect.runPromise(discord.fetchContext(scope, 30))
|
|
75
|
-
const history = await Effect.runPromise(discord.fetchHistory(scope, 2))
|
|
76
75
|
const posted = await Effect.runPromise(discord.postMessage(scope, "reply"))
|
|
77
76
|
await Effect.runPromise(discord.editMessage(scope, posted.id, "edited"))
|
|
78
77
|
await Effect.runPromise(discord.sendTyping(scope))
|
|
@@ -105,10 +104,7 @@ describe("makeChatSdkDiscord", () => {
|
|
|
105
104
|
channelType: "guild"
|
|
106
105
|
}
|
|
107
106
|
])
|
|
108
|
-
expect(history).toEqual(context)
|
|
109
107
|
expect(adapter.calls.map((item) => item[0])).toEqual([
|
|
110
|
-
"encodeThreadId",
|
|
111
|
-
"fetchMessages",
|
|
112
108
|
"encodeThreadId",
|
|
113
109
|
"fetchMessages",
|
|
114
110
|
"encodeThreadId",
|
|
@@ -161,10 +157,8 @@ describe("makeChatSdkDiscord REST operations", () => {
|
|
|
161
157
|
const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
|
|
162
158
|
|
|
163
159
|
const context = await Effect.runPromise(discord.fetchContext(scope, 30))
|
|
164
|
-
const history = await Effect.runPromise(discord.fetchHistory(scope, 30))
|
|
165
160
|
|
|
166
161
|
expect(context[0]?.author).toEqual({ id: "u1", displayName: "Alice", nickname: "Ali the Great", isBot: false })
|
|
167
|
-
expect(history[0]?.author).toEqual({ id: "u1", displayName: "Alice", nickname: "Ali the Great", isBot: false })
|
|
168
162
|
} finally {
|
|
169
163
|
globalThis.fetch = originalFetch
|
|
170
164
|
}
|
|
@@ -3,8 +3,10 @@ import { basename } from "node:path"
|
|
|
3
3
|
import { createDiscordAdapter, type DiscordThreadId } from "@chat-adapter/discord"
|
|
4
4
|
import type { AdapterPostableMessage, FetchOptions, FetchResult, Message, PostableRaw, RawMessage } from "chat"
|
|
5
5
|
import { Duration, Effect } from "effect"
|
|
6
|
-
import type { DiscordAttachment, DiscordMessage, DiscordScope } from "../Schema.ts"
|
|
6
|
+
import type { DiscordAttachment, DiscordMessage, DiscordReaction, DiscordScope } from "../Schema.ts"
|
|
7
7
|
import { DiscordError, type DiscordService } from "./DiscordPort.ts"
|
|
8
|
+
import { type RawDiscordOptions, rawDiscord, rawDiscordRequest } from "./DiscordRest.ts"
|
|
9
|
+
import { searchDiscordMessages } from "./DiscordSearchRest.ts"
|
|
8
10
|
|
|
9
11
|
type ChatDiscordAdapter = {
|
|
10
12
|
readonly encodeThreadId: (input: DiscordThreadId) => string
|
|
@@ -52,6 +54,23 @@ const attachments = (message: Message<unknown>): ReadonlyArray<DiscordAttachment
|
|
|
52
54
|
url: item.url ?? ""
|
|
53
55
|
}))
|
|
54
56
|
|
|
57
|
+
const reactionEmoji = (value: unknown): string => {
|
|
58
|
+
if (!isRecord(value)) return "unknown"
|
|
59
|
+
const name = typeof value.name === "string" && value.name.length > 0 ? value.name : undefined
|
|
60
|
+
const id = typeof value.id === "string" && value.id.length > 0 ? value.id : undefined
|
|
61
|
+
if (id !== undefined && name !== undefined) return `${value.animated === true ? "a:" : ""}${name}:${id}`
|
|
62
|
+
return name ?? id ?? "unknown"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const reactions = (message: Message<unknown>): ReadonlyArray<DiscordReaction> => {
|
|
66
|
+
if (!isRecord(message.raw)) return []
|
|
67
|
+
const rawReactions: ReadonlyArray<unknown> = Array.isArray(message.raw.reactions) ? message.raw.reactions : []
|
|
68
|
+
return rawReactions.flatMap((item) => {
|
|
69
|
+
if (!isRecord(item) || typeof item.count !== "number" || !Number.isFinite(item.count) || item.count < 0) return []
|
|
70
|
+
return [{ emoji: reactionEmoji(item.emoji), count: item.count }]
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
const fromChatMessage = (scope: DiscordScope, message: Message<unknown>, nickname?: string | undefined): DiscordMessage => ({
|
|
56
75
|
id: message.id,
|
|
57
76
|
guildId: scope.guildId,
|
|
@@ -70,7 +89,7 @@ const fromChatMessage = (scope: DiscordScope, message: Message<unknown>, nicknam
|
|
|
70
89
|
everyoneMention: message.text.includes("@everyone"),
|
|
71
90
|
hereMention: message.text.includes("@here"),
|
|
72
91
|
attachments: attachments(message),
|
|
73
|
-
reactions:
|
|
92
|
+
reactions: reactions(message),
|
|
74
93
|
channelType: "guild"
|
|
75
94
|
})
|
|
76
95
|
|
|
@@ -100,41 +119,6 @@ const tryAdapter = <A>(operation: () => Promise<A>): Effect.Effect<A, DiscordErr
|
|
|
100
119
|
})
|
|
101
120
|
})
|
|
102
121
|
|
|
103
|
-
const retryAfterHeader = (response: Response) => {
|
|
104
|
-
const value = response.headers.get("retry-after")
|
|
105
|
-
if (value === null) return undefined
|
|
106
|
-
const seconds = Number(value)
|
|
107
|
-
return Number.isFinite(seconds) && seconds >= 0 ? Duration.millis(seconds * 1000) : undefined
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
type RawDiscordOptions = {
|
|
111
|
-
readonly botToken: string
|
|
112
|
-
readonly apiUrl?: string | undefined
|
|
113
|
-
readonly nicknameCacheTtlMs?: number | undefined
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const rawDiscordRequest = async (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Promise<unknown> => {
|
|
117
|
-
if (options === undefined) throw new Error("Discord adapter does not expose this operation")
|
|
118
|
-
const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
|
|
119
|
-
...init,
|
|
120
|
-
headers: {
|
|
121
|
-
authorization: `Bot ${options.botToken}`,
|
|
122
|
-
"content-type": "application/json",
|
|
123
|
-
...init.headers
|
|
124
|
-
}
|
|
125
|
-
})
|
|
126
|
-
if (!response.ok)
|
|
127
|
-
throw new DiscordError({
|
|
128
|
-
message: `Discord REST ${response.status}: ${await response.text()}`,
|
|
129
|
-
retryAfter: retryAfterHeader(response)
|
|
130
|
-
})
|
|
131
|
-
if (response.status === 204) return {}
|
|
132
|
-
return await response.json()
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Effect.Effect<unknown, DiscordError> =>
|
|
136
|
-
tryAdapter(() => rawDiscordRequest(options, path, init))
|
|
137
|
-
|
|
138
122
|
const memberNickname = (data: unknown): string | undefined => {
|
|
139
123
|
if (!isRecord(data)) return undefined
|
|
140
124
|
const nick = data.nick
|
|
@@ -207,7 +191,7 @@ export const makeChatSdkDiscord = (adapter: ChatDiscordAdapter, raw: RawDiscordO
|
|
|
207
191
|
|
|
208
192
|
return {
|
|
209
193
|
fetchContext: fetchMessages,
|
|
210
|
-
|
|
194
|
+
searchMessages: (scope, query, paging) => tryAdapter(() => searchDiscordMessages(raw, scope, query, paging, resolveNickname)),
|
|
211
195
|
sendTyping: (scope) => tryAdapter(() => adapter.startTyping(threadIdFromScope(adapter, scope))).pipe(Effect.asVoid),
|
|
212
196
|
postMessage: (scope, content) =>
|
|
213
197
|
tryAdapter(async () => {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { DiscordThreadId } from "@chat-adapter/discord"
|
|
3
|
+
import type { AdapterPostableMessage, FetchOptions, FetchResult, RawMessage } from "chat"
|
|
4
|
+
import { Message, parseMarkdown } from "chat"
|
|
5
|
+
import { Effect } from "effect"
|
|
6
|
+
import type { DiscordScope } from "../Schema.ts"
|
|
7
|
+
import { makeChatSdkDiscord } from "./ChatSdkDiscord.ts"
|
|
8
|
+
|
|
9
|
+
const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
|
|
10
|
+
|
|
11
|
+
const makeMessage = (threadId: string, raw: unknown): Message<unknown> =>
|
|
12
|
+
new Message({
|
|
13
|
+
id: "m1",
|
|
14
|
+
threadId,
|
|
15
|
+
text: "hello",
|
|
16
|
+
formatted: parseMarkdown("hello"),
|
|
17
|
+
raw,
|
|
18
|
+
author: { userId: "u1", userName: "alice", fullName: "Alice", isBot: false, isMe: false },
|
|
19
|
+
metadata: { dateSent: new Date("2026-06-05T14:03:00.000Z"), edited: false },
|
|
20
|
+
attachments: []
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
class ReactionAdapter {
|
|
24
|
+
encodeThreadId(input: DiscordThreadId): string {
|
|
25
|
+
return `discord:${input.guildId}:${input.channelId}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fetchMessages(threadId: string, _options?: FetchOptions): Promise<FetchResult<unknown>> {
|
|
29
|
+
return Promise.resolve({
|
|
30
|
+
messages: [
|
|
31
|
+
makeMessage(threadId, {
|
|
32
|
+
reactions: [
|
|
33
|
+
{ count: 3, emoji: { id: null, name: "\u{1F680}" } },
|
|
34
|
+
{ count: 2, emoji: { id: "custom1", name: "party_blob", animated: false } },
|
|
35
|
+
{ count: 1, emoji: { id: "anim1", name: "dance", animated: true } },
|
|
36
|
+
{ count: "bad", emoji: { id: null, name: "ignored" } },
|
|
37
|
+
{ count: -1, emoji: { id: null, name: "ignored" } }
|
|
38
|
+
]
|
|
39
|
+
})
|
|
40
|
+
]
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
postMessage(threadId: string, _message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
45
|
+
return Promise.resolve({ id: "posted", raw: {}, threadId })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
editMessage(threadId: string, messageId: string, _message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
49
|
+
return Promise.resolve({ id: messageId, raw: {}, threadId })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
deleteMessage(_threadId: string, _messageId: string): Promise<void> {
|
|
53
|
+
return Promise.resolve()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
startTyping(_threadId: string, _status?: string): Promise<void> {
|
|
57
|
+
return Promise.resolve()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addReaction(_threadId: string, _messageId: string, _emoji: string): Promise<void> {
|
|
61
|
+
return Promise.resolve()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("makeChatSdkDiscord reactions", () => {
|
|
66
|
+
test("maps aggregate reactions from raw Discord messages", async () => {
|
|
67
|
+
const discord = makeChatSdkDiscord(new ReactionAdapter())
|
|
68
|
+
|
|
69
|
+
const context = await Effect.runPromise(discord.fetchContext(scope, 30))
|
|
70
|
+
|
|
71
|
+
expect(context[0]?.reactions).toEqual([
|
|
72
|
+
{ emoji: "\u{1F680}", count: 3 },
|
|
73
|
+
{ emoji: "party_blob:custom1", count: 2 },
|
|
74
|
+
{ emoji: "a:dance:anim1", count: 1 }
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { DiscordThreadId } from "@chat-adapter/discord"
|
|
3
|
+
import type { AdapterPostableMessage, FetchResult, RawMessage } from "chat"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import type { DiscordScope } from "../Schema.ts"
|
|
6
|
+
import { makeChatSdkDiscord } from "./ChatSdkDiscord.ts"
|
|
7
|
+
import { parseDiscordSearchQuery } from "./SearchQuery.ts"
|
|
8
|
+
|
|
9
|
+
const scope: DiscordScope = { guildId: "g1", channelId: "c1", threadId: "t1" }
|
|
10
|
+
|
|
11
|
+
class FakeDiscordAdapter {
|
|
12
|
+
encodeThreadId(input: DiscordThreadId): string {
|
|
13
|
+
return input.threadId === undefined
|
|
14
|
+
? `discord:${input.guildId}:${input.channelId}`
|
|
15
|
+
: `discord:${input.guildId}:${input.channelId}:${input.threadId}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
postMessage(threadId: string, _message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
19
|
+
return Promise.resolve({ id: "posted-1", threadId, raw: {} })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
editMessage(threadId: string, messageId: string, _message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
23
|
+
return Promise.resolve({ id: messageId, threadId, raw: {} })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
deleteMessage(): Promise<void> {
|
|
27
|
+
return Promise.resolve()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
startTyping(): Promise<void> {
|
|
31
|
+
return Promise.resolve()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
addReaction(): Promise<void> {
|
|
35
|
+
return Promise.resolve()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fetchMessages(): Promise<FetchResult<unknown>> {
|
|
39
|
+
return Promise.resolve({ messages: [] })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("makeChatSdkDiscord search", () => {
|
|
44
|
+
test("searches guild messages through Discord REST search", async () => {
|
|
45
|
+
const requests: Array<string> = []
|
|
46
|
+
const originalFetch = globalThis.fetch
|
|
47
|
+
const fakeFetch: typeof fetch = Object.assign(
|
|
48
|
+
(input: URL | RequestInfo) => {
|
|
49
|
+
const url = String(input)
|
|
50
|
+
requests.push(url)
|
|
51
|
+
return Promise.resolve(url.includes("/messages/search") ? searchResponse() : memberResponse())
|
|
52
|
+
},
|
|
53
|
+
{ preconnect: originalFetch.preconnect }
|
|
54
|
+
)
|
|
55
|
+
globalThis.fetch = fakeFetch
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const parsed = parseDiscordSearchQuery("hello from:<@123> in:<#789> has:link before:2026-06-06")
|
|
59
|
+
if (!parsed.ok) throw new Error(parsed.error)
|
|
60
|
+
const discord = makeChatSdkDiscord(new FakeDiscordAdapter(), { botToken: "token", apiUrl: "https://discord.test/api" })
|
|
61
|
+
const result = await Effect.runPromise(discord.searchMessages(scope, parsed.query, { limit: 25, offset: 0 }))
|
|
62
|
+
|
|
63
|
+
expect(result.totalResults).toBe(3)
|
|
64
|
+
expect(result.hasMore).toBe(true)
|
|
65
|
+
expect(result.messages).toEqual([
|
|
66
|
+
{
|
|
67
|
+
id: "m-search",
|
|
68
|
+
guildId: "g1",
|
|
69
|
+
channelId: "c1",
|
|
70
|
+
threadId: "789",
|
|
71
|
+
author: { id: "123", displayName: "Alice", nickname: "Ali the Great", isBot: false },
|
|
72
|
+
content: "hello link https://example.test",
|
|
73
|
+
timestamp: "2026-06-05T14:03:00.000Z",
|
|
74
|
+
mentions: ["456"],
|
|
75
|
+
roleMentions: ["111"],
|
|
76
|
+
everyoneMention: false,
|
|
77
|
+
hereMention: false,
|
|
78
|
+
attachments: [{ id: "a1", filename: "notes.txt", contentType: "text/plain", size: 42, url: "https://example.test/notes.txt" }],
|
|
79
|
+
reactions: [],
|
|
80
|
+
channelType: "guild"
|
|
81
|
+
}
|
|
82
|
+
])
|
|
83
|
+
} finally {
|
|
84
|
+
globalThis.fetch = originalFetch
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(requests[0]).toContain("/guilds/g1/messages/search?")
|
|
88
|
+
expect(requests[0]).toContain("content=hello")
|
|
89
|
+
expect(requests[0]).toContain("author_id=123")
|
|
90
|
+
expect(requests[0]).toContain("channel_id=789")
|
|
91
|
+
expect(requests[0]).toContain("has=link")
|
|
92
|
+
expect(requests[1]).toBe("https://discord.test/api/guilds/g1/members/123")
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const memberResponse = (): Response =>
|
|
97
|
+
new Response(JSON.stringify({ nick: "Ali the Great" }), { status: 200, headers: { "content-type": "application/json" } })
|
|
98
|
+
|
|
99
|
+
const searchResponse = (): Response =>
|
|
100
|
+
new Response(
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
total_results: 3,
|
|
103
|
+
messages: [
|
|
104
|
+
[
|
|
105
|
+
{
|
|
106
|
+
id: "m-search",
|
|
107
|
+
channel_id: "789",
|
|
108
|
+
author: { id: "123", username: "alice", global_name: "Alice", bot: false },
|
|
109
|
+
content: "hello link https://example.test",
|
|
110
|
+
timestamp: "2026-06-05T14:03:00.000Z",
|
|
111
|
+
mentions: [{ id: "456" }],
|
|
112
|
+
mention_roles: ["111"],
|
|
113
|
+
mention_everyone: false,
|
|
114
|
+
attachments: [{ id: "a1", filename: "notes.txt", content_type: "text/plain", size: 42, url: "https://example.test/notes.txt" }],
|
|
115
|
+
type: 0
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
],
|
|
119
|
+
threads: [{ id: "789", parent_id: "c1" }]
|
|
120
|
+
}),
|
|
121
|
+
{ status: 200, headers: { "content-type": "application/json" } }
|
|
122
|
+
)
|
|
@@ -112,7 +112,6 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
112
112
|
const file = join(directory, "upload.txt")
|
|
113
113
|
await writeFile(file, "upload")
|
|
114
114
|
const context = await Effect.runPromise(discord.fetchContext(scope, 1))
|
|
115
|
-
const history = await Effect.runPromise(discord.fetchHistory(scope, 1))
|
|
116
115
|
await Effect.runPromise(discord.sendTyping(scope))
|
|
117
116
|
const posted = await Effect.runPromise(discord.postMessage(scope, "hello"))
|
|
118
117
|
await Effect.runPromise(discord.editMessage(scope, "m1", "edited"))
|
|
@@ -122,7 +121,6 @@ describe("makeDiscordJsDiscord", () => {
|
|
|
122
121
|
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
123
122
|
|
|
124
123
|
expect(context).toHaveLength(1)
|
|
125
|
-
expect(history).toHaveLength(1)
|
|
126
124
|
expect(posted).toEqual({ id: "posted-1" })
|
|
127
125
|
expect(attached).toEqual({ path: "posted-1" })
|
|
128
126
|
expect(calls.map((call) => call[0])).toContain("delete")
|
|
@@ -194,7 +194,7 @@ export const makeDiscordJsDiscord = (client: DiscordJsClientLike): DiscordServic
|
|
|
194
194
|
return mapped === undefined ? [] : [mapped]
|
|
195
195
|
})
|
|
196
196
|
}),
|
|
197
|
-
|
|
197
|
+
searchMessages: () => Effect.fail(new DiscordError({ message: "Discord search is only available through the live REST adapter" })),
|
|
198
198
|
sendTyping: (scope) =>
|
|
199
199
|
Effect.gen(function* () {
|
|
200
200
|
const channel = yield* fetchTextChannel(client, scope)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context, Data, type Duration, type Effect } from "effect"
|
|
2
|
-
import type { DiscordMessage, DiscordScope } from "../Schema.ts"
|
|
2
|
+
import type { DiscordMessage, DiscordScope, DiscordSearchQuery, DiscordSearchResult } from "../Schema.ts"
|
|
3
3
|
|
|
4
4
|
export class DiscordError extends Data.TaggedError("DiscordError")<{
|
|
5
5
|
readonly message: string
|
|
@@ -17,7 +17,11 @@ export type DiscordService = {
|
|
|
17
17
|
readonly postMessage: (scope: DiscordScope, content: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
18
18
|
readonly editMessage: (scope: DiscordScope, messageId: string, content: string) => Effect.Effect<void, DiscordError>
|
|
19
19
|
readonly addReaction: (scope: DiscordScope, messageId: string, emoji: string) => Effect.Effect<void, DiscordError>
|
|
20
|
-
readonly
|
|
20
|
+
readonly searchMessages: (
|
|
21
|
+
scope: DiscordScope,
|
|
22
|
+
query: DiscordSearchQuery,
|
|
23
|
+
paging: { readonly limit: number; readonly offset: number }
|
|
24
|
+
) => Effect.Effect<DiscordSearchResult, DiscordError>
|
|
21
25
|
readonly attachFile: (scope: DiscordScope, path: string) => Effect.Effect<{ readonly path: string }, DiscordError>
|
|
22
26
|
readonly createThread: (scope: DiscordScope, name: string) => Effect.Effect<{ readonly id: string }, DiscordError>
|
|
23
27
|
readonly deleteMessage: (scope: DiscordScope, messageId: string) => Effect.Effect<void, DiscordError>
|