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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-bot",
3
- "version": "0.0.11",
3
+ "version": "0.2.0",
4
4
  "description": "Discord bot bridge for a self-hosted opencode instance",
5
5
  "type": "module",
6
6
  "main": "src/Main.ts",
@@ -104,7 +104,7 @@ describe("handleToolRequest", () => {
104
104
  })
105
105
 
106
106
  describe("handleToolRequest action dispatch", () => {
107
- test("dispatches reactions, history, and safe attachments", async () => {
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 history = await Effect.runPromise(
140
+ const search = await Effect.runPromise(
141
141
  handleToolRequest(
142
- { action: "fetchHistory", target: { guildId: "g1", channelId: "c1" }, args: { limit: 1 } },
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(history.ok).toBe(true)
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 "fetchHistory":
19
- return "fetchHistory"
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 || channelId === undefined) return "Discord target must include guildId and channelId"
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 fetchHistory = Effect.fn("toolFetchHistory")(function* (
73
- request: ToolRequest,
74
- scope: DiscordScope,
75
- config: RuntimeConfig,
76
- discord: DiscordService
77
- ) {
78
- const limit = typeof request.args.limit === "number" ? request.args.limit : config.context.messages
79
- const result = yield* discord.fetchHistory(scope, limit)
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 scope = scopeFromRequest(request)
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 "fetchHistory":
127
- return yield* fetchHistory(request, scope, config, discord)
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 fetchHistory: boolean
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
- fetchHistory: true,
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
- fetchHistory: readBoolean(tools, "fetchHistory", defaultConfig.tools.fetchHistory),
257
+ searchMessages: readBoolean(tools, "searchMessages", defaultConfig.tools.searchMessages),
258
258
  createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread)
259
259
  },
260
260
  streaming: {
@@ -7,7 +7,7 @@ const RawToolsSchema = Schema.Struct({
7
7
  autoInstall: OptionalBoolean,
8
8
  reactions: OptionalBoolean,
9
9
  attachFiles: OptionalBoolean,
10
- fetchHistory: OptionalBoolean,
10
+ searchMessages: OptionalBoolean,
11
11
  createThread: OptionalBoolean
12
12
  })
13
13
 
@@ -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
- fetchHistory: fetchMessages,
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
- fetchHistory: (scope, limit) => makeDiscordJsDiscord(client).fetchContext(scope, limit),
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 fetchHistory: (scope: DiscordScope, limit: number) => Effect.Effect<ReadonlyArray<DiscordMessage>, DiscordError>
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>