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.
@@ -0,0 +1,78 @@
1
+ import { Duration, Effect } from "effect"
2
+ import { DiscordError } from "./DiscordPort.ts"
3
+
4
+ export type RawDiscordOptions = {
5
+ readonly botToken: string
6
+ readonly apiUrl?: string | undefined
7
+ readonly nicknameCacheTtlMs?: number | undefined
8
+ }
9
+
10
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
11
+ typeof value === "object" && value !== null && !Array.isArray(value)
12
+
13
+ const retryAfterHeader = (response: Response) => {
14
+ const value = response.headers.get("retry-after")
15
+ if (value === null) return undefined
16
+ const seconds = Number(value)
17
+ return Number.isFinite(seconds) && seconds >= 0 ? Duration.millis(seconds * 1000) : undefined
18
+ }
19
+
20
+ const retryAfterBody = (body: unknown) => {
21
+ if (!isRecord(body)) return undefined
22
+ const retryAfter = body.retry_after
23
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? Duration.millis(retryAfter * 1000) : undefined
24
+ }
25
+
26
+ export const rawDiscordRequest = async (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Promise<unknown> => {
27
+ if (options === undefined) throw new Error("Discord adapter does not expose this operation")
28
+ const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
29
+ ...init,
30
+ headers: {
31
+ authorization: `Bot ${options.botToken}`,
32
+ "content-type": "application/json",
33
+ ...init.headers
34
+ }
35
+ })
36
+ if (!response.ok) {
37
+ throw new DiscordError({
38
+ message: `Discord REST ${response.status}: ${await response.text()}`,
39
+ retryAfter: retryAfterHeader(response)
40
+ })
41
+ }
42
+ if (response.status === 204) return {}
43
+ return await response.json()
44
+ }
45
+
46
+ export const rawDiscordSearchRequest = async (options: RawDiscordOptions | undefined, path: string): Promise<unknown> => {
47
+ if (options === undefined) throw new Error("Discord adapter does not expose this operation")
48
+ const response = await fetch(`${options.apiUrl ?? "https://discord.com/api/v10"}${path}`, {
49
+ method: "GET",
50
+ headers: {
51
+ authorization: `Bot ${options.botToken}`,
52
+ "content-type": "application/json"
53
+ }
54
+ })
55
+ if (response.status === 202) {
56
+ const body = await response.json().catch(() => ({}))
57
+ throw new DiscordError({
58
+ message: "Discord search index is not ready yet; retry the search later",
59
+ retryAfter: retryAfterBody(body) ?? retryAfterHeader(response)
60
+ })
61
+ }
62
+ if (!response.ok) {
63
+ throw new DiscordError({
64
+ message: `Discord REST ${response.status}: ${await response.text()}`,
65
+ retryAfter: retryAfterHeader(response)
66
+ })
67
+ }
68
+ return await response.json()
69
+ }
70
+
71
+ export const rawDiscord = (options: RawDiscordOptions | undefined, path: string, init: RequestInit): Effect.Effect<unknown, DiscordError> =>
72
+ Effect.tryPromise({
73
+ try: () => rawDiscordRequest(options, path, init),
74
+ catch: (cause) =>
75
+ cause instanceof DiscordError
76
+ ? cause
77
+ : new DiscordError({ message: cause instanceof Error ? cause.message : "Discord REST operation failed" })
78
+ })
@@ -0,0 +1,232 @@
1
+ import type { DiscordAttachment, DiscordMessage, DiscordScope, DiscordSearchQuery, DiscordSearchResult } from "../Schema.ts"
2
+ import { DiscordError } from "./DiscordPort.ts"
3
+ import { type RawDiscordOptions, rawDiscordRequest, rawDiscordSearchRequest } from "./DiscordRest.ts"
4
+
5
+ type NicknameResolver = (scope: DiscordScope, userId: string) => Promise<string | undefined>
6
+
7
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value)
9
+
10
+ const stringField = (value: unknown, key: string): string | undefined => {
11
+ if (!isRecord(value)) return undefined
12
+ const field = value[key]
13
+ return typeof field === "string" && field.length > 0 ? field : undefined
14
+ }
15
+
16
+ const userIdFromMemberSearch = (data: unknown): string | undefined => {
17
+ if (!Array.isArray(data)) return undefined
18
+ const first = data[0]
19
+ if (!isRecord(first)) return undefined
20
+ return stringField(first.user, "id")
21
+ }
22
+
23
+ const channelIdFromGuildChannels = (data: unknown, name: string): string | undefined => {
24
+ if (!Array.isArray(data)) return undefined
25
+ const normalized = name.toLowerCase().replace(/^#/, "")
26
+ for (const channel of data) {
27
+ if (!isRecord(channel)) continue
28
+ const channelName = stringField(channel, "name")
29
+ if (channelName?.toLowerCase() === normalized) return stringField(channel, "id")
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ const resolveUserName = async (raw: RawDiscordOptions | undefined, scope: DiscordScope, name: string): Promise<string> => {
35
+ const params = new URLSearchParams({ query: name, limit: "1" })
36
+ const result = await rawDiscordRequest(raw, `/guilds/${scope.guildId}/members/search?${params}`, { method: "GET" })
37
+ const id = userIdFromMemberSearch(result)
38
+ if (id === undefined) throw new DiscordError({ message: `Unable to resolve Discord user ${name}; use an ID or mention` })
39
+ return id
40
+ }
41
+
42
+ const resolveChannelName = async (raw: RawDiscordOptions | undefined, scope: DiscordScope, name: string): Promise<string> => {
43
+ const result = await rawDiscordRequest(raw, `/guilds/${scope.guildId}/channels`, { method: "GET" })
44
+ const id = channelIdFromGuildChannels(result, name)
45
+ if (id === undefined) throw new DiscordError({ message: `Unable to resolve Discord channel ${name}; use an ID or channel mention` })
46
+ return id
47
+ }
48
+
49
+ const resolveSearchQuery = async (
50
+ raw: RawDiscordOptions | undefined,
51
+ scope: DiscordScope,
52
+ query: DiscordSearchQuery
53
+ ): Promise<DiscordSearchQuery> => {
54
+ const [authors, channels, mentions, repliedToUsers] = await Promise.all([
55
+ Promise.all(query.authorNames.map((name) => resolveUserName(raw, scope, name))),
56
+ Promise.all(query.channelNames.map((name) => resolveChannelName(raw, scope, name))),
57
+ Promise.all(query.mentionNames.map((name) => resolveUserName(raw, scope, name))),
58
+ Promise.all(query.repliedToUserNames.map((name) => resolveUserName(raw, scope, name)))
59
+ ])
60
+ return {
61
+ ...query,
62
+ authors: [...query.authors, ...authors],
63
+ authorNames: [],
64
+ channels: [...query.channels, ...channels],
65
+ channelNames: [],
66
+ mentions: [...query.mentions, ...mentions],
67
+ mentionNames: [],
68
+ repliedToUsers: [...query.repliedToUsers, ...repliedToUsers],
69
+ repliedToUserNames: []
70
+ }
71
+ }
72
+
73
+ const rawAttachment = (item: unknown): DiscordAttachment | undefined => {
74
+ if (!isRecord(item)) return undefined
75
+ const id = stringField(item, "id")
76
+ const filename = stringField(item, "filename")
77
+ const url = stringField(item, "url") ?? ""
78
+ const size = typeof item.size === "number" && Number.isFinite(item.size) && item.size >= 0 ? item.size : 0
79
+ const contentType = stringField(item, "content_type")
80
+ if (id === undefined || filename === undefined) return undefined
81
+ return { id, filename, ...(contentType === undefined ? {} : { contentType }), size, url }
82
+ }
83
+
84
+ const rawUserIds = (items: unknown): ReadonlyArray<string> =>
85
+ Array.isArray(items)
86
+ ? items.flatMap((item) => {
87
+ const id = stringField(item, "id")
88
+ return id === undefined ? [] : [id]
89
+ })
90
+ : []
91
+
92
+ const rawStringArray = (items: unknown): ReadonlyArray<string> =>
93
+ Array.isArray(items) ? items.flatMap((item) => (typeof item === "string" ? [item] : [])) : []
94
+
95
+ const threadParents = (items: unknown): ReadonlyMap<string, string> => {
96
+ const result = new Map<string, string>()
97
+ if (!Array.isArray(items)) return result
98
+ for (const thread of items) {
99
+ const id = stringField(thread, "id")
100
+ const parentId = stringField(thread, "parent_id")
101
+ if (id !== undefined && parentId !== undefined) result.set(id, parentId)
102
+ }
103
+ return result
104
+ }
105
+
106
+ const fromRawDiscordMessage = (
107
+ scope: DiscordScope,
108
+ data: unknown,
109
+ threadParentById: ReadonlyMap<string, string>
110
+ ): DiscordMessage | undefined => {
111
+ if (!isRecord(data)) return undefined
112
+ const id = stringField(data, "id")
113
+ const channelId = stringField(data, "channel_id")
114
+ const timestamp = stringField(data, "timestamp")
115
+ const author = data.author
116
+ const authorId = stringField(author, "id")
117
+ if (id === undefined || channelId === undefined || timestamp === undefined || authorId === undefined) return undefined
118
+ const parentId = threadParentById.get(channelId)
119
+ const content = typeof data.content === "string" ? data.content : ""
120
+ const attachments = Array.isArray(data.attachments) ? data.attachments : []
121
+ return {
122
+ id,
123
+ guildId: scope.guildId,
124
+ channelId: parentId ?? channelId,
125
+ ...(parentId === undefined ? {} : { threadId: channelId }),
126
+ author: {
127
+ id: authorId,
128
+ displayName: stringField(author, "global_name") ?? stringField(author, "username") ?? authorId,
129
+ isBot: isRecord(author) && author.bot === true
130
+ },
131
+ content,
132
+ timestamp,
133
+ mentions: rawUserIds(data.mentions),
134
+ roleMentions: rawStringArray(data.mention_roles),
135
+ everyoneMention: data.mention_everyone === true,
136
+ hereMention: content.includes("@here"),
137
+ attachments: attachments.flatMap((item) => {
138
+ const attachment = rawAttachment(item)
139
+ return attachment === undefined ? [] : [attachment]
140
+ }),
141
+ reactions: [],
142
+ channelType: "guild",
143
+ ...(typeof data.type === "number" && data.type !== 0 ? { isSystem: true } : {})
144
+ }
145
+ }
146
+
147
+ const flatSearchMessages = (data: unknown): ReadonlyArray<unknown> => {
148
+ if (!isRecord(data) || !Array.isArray(data.messages)) return []
149
+ return data.messages.flatMap((group) => (Array.isArray(group) ? group : []))
150
+ }
151
+
152
+ const searchTotalResults = (data: unknown): number =>
153
+ isRecord(data) && typeof data.total_results === "number" && Number.isFinite(data.total_results) && data.total_results >= 0
154
+ ? data.total_results
155
+ : 0
156
+
157
+ const appendAll = (params: URLSearchParams, key: string, values: ReadonlyArray<string>): void => {
158
+ for (const value of values) params.append(key, value)
159
+ }
160
+
161
+ const searchParams = (query: DiscordSearchQuery, limit: number, offset: number): URLSearchParams => {
162
+ const params = new URLSearchParams()
163
+ params.set("limit", String(Math.max(1, Math.min(25, limit))))
164
+ params.set("offset", String(Math.max(0, Math.min(9975, offset))))
165
+ if (query.content !== undefined) params.set("content", query.content)
166
+ if (query.maxId !== undefined) params.set("max_id", query.maxId)
167
+ if (query.minId !== undefined) params.set("min_id", query.minId)
168
+ if (query.slop !== undefined) params.set("slop", String(query.slop))
169
+ if (query.pinned !== undefined) params.set("pinned", String(query.pinned))
170
+ if (query.mentionEveryone !== undefined) params.set("mention_everyone", String(query.mentionEveryone))
171
+ if (query.sortBy !== undefined) params.set("sort_by", query.sortBy)
172
+ if (query.sortOrder !== undefined) params.set("sort_order", query.sortOrder)
173
+ if (query.includeNsfw !== undefined) params.set("include_nsfw", String(query.includeNsfw))
174
+ appendAll(params, "channel_id", query.channels)
175
+ appendAll(params, "author_id", query.authors)
176
+ appendAll(params, "author_type", query.authorTypes)
177
+ appendAll(params, "mentions", query.mentions)
178
+ appendAll(params, "mentions_role_id", query.roleMentions)
179
+ appendAll(params, "replied_to_user_id", query.repliedToUsers)
180
+ appendAll(params, "replied_to_message_id", query.repliedToMessages)
181
+ appendAll(params, "has", query.has)
182
+ appendAll(params, "embed_type", query.embedTypes)
183
+ appendAll(params, "embed_provider", query.embedProviders)
184
+ appendAll(params, "link_hostname", query.linkHostnames)
185
+ appendAll(params, "attachment_filename", query.attachmentFilenames)
186
+ appendAll(params, "attachment_extension", query.attachmentExtensions)
187
+ return params
188
+ }
189
+
190
+ const enrichNicknames = async (
191
+ scope: DiscordScope,
192
+ messages: ReadonlyArray<DiscordMessage>,
193
+ resolveNickname: NicknameResolver
194
+ ): Promise<ReadonlyArray<DiscordMessage>> => {
195
+ const nicknames = new Map<string, string | undefined>()
196
+ for (const userId of [...new Set(messages.map((message) => message.author.id))]) {
197
+ nicknames.set(userId, await resolveNickname(scope, userId))
198
+ }
199
+ return messages.map((message) => {
200
+ const nickname = nicknames.get(message.author.id)
201
+ return nickname === undefined ? message : { ...message, author: { ...message.author, nickname } }
202
+ })
203
+ }
204
+
205
+ const collectMessages = (scope: DiscordScope, data: unknown): ReadonlyArray<DiscordMessage> => {
206
+ const parents = isRecord(data) ? threadParents(data.threads) : new Map<string, string>()
207
+ const seen = new Set<string>()
208
+ const messages: Array<DiscordMessage> = []
209
+ for (const rawMessage of flatSearchMessages(data)) {
210
+ const message = fromRawDiscordMessage(scope, rawMessage, parents)
211
+ if (message === undefined || seen.has(message.id)) continue
212
+ seen.add(message.id)
213
+ messages.push(message)
214
+ }
215
+ return messages
216
+ }
217
+
218
+ export const searchDiscordMessages = async (
219
+ raw: RawDiscordOptions | undefined,
220
+ scope: DiscordScope,
221
+ query: DiscordSearchQuery,
222
+ paging: { readonly limit: number; readonly offset: number },
223
+ resolveNickname: NicknameResolver
224
+ ): Promise<DiscordSearchResult> => {
225
+ const resolvedQuery = await resolveSearchQuery(raw, scope, query)
226
+ const params = searchParams(resolvedQuery, paging.limit, paging.offset)
227
+ const data = await rawDiscordSearchRequest(raw, `/guilds/${scope.guildId}/messages/search?${params}`)
228
+ const messages = await enrichNicknames(scope, collectMessages(scope, data), resolveNickname)
229
+ const totalResults = searchTotalResults(data)
230
+ const offset = Math.max(0, Math.min(9975, paging.offset))
231
+ return { totalResults, offset, hasMore: offset + messages.length < totalResults, messages }
232
+ }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import type { DiscordMessage, DiscordScope } from "../Schema.ts"
4
4
  import { makeMemoryDiscord } from "./MemoryDiscord.ts"
5
+ import { parseDiscordSearchQuery } from "./SearchQuery.ts"
5
6
 
6
7
  const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
7
8
 
@@ -26,14 +27,17 @@ describe("makeMemoryDiscord", () => {
26
27
  const discord = makeMemoryDiscord({ context: [message("1"), message("2")] })
27
28
 
28
29
  const context = await Effect.runPromise(discord.fetchContext(scope, 1))
29
- const history = await Effect.runPromise(discord.fetchHistory(scope, 2))
30
+ const query = parseDiscordSearchQuery("content:2 from:Alice")
31
+ if (!query.ok) throw new Error(query.error)
32
+ const search = await Effect.runPromise(discord.searchMessages(scope, query.query, { limit: 2, offset: 0 }))
30
33
  const posted = await Effect.runPromise(discord.postMessage(scope, "hello"))
31
34
  await Effect.runPromise(discord.editMessage(scope, posted.id, "updated"))
32
35
  await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
33
36
  const attached = await Effect.runPromise(discord.attachFile(scope, "/repo/out.txt"))
34
37
 
35
38
  expect(context.map((item) => item.id)).toEqual(["2"])
36
- expect(history.map((item) => item.id)).toEqual(["1", "2"])
39
+ expect(search.messages.map((item) => item.id)).toEqual(["2"])
40
+ expect(search.totalResults).toBe(1)
37
41
  expect(posted).toEqual({ id: "posted-1" })
38
42
  expect(discord.messages).toEqual([{ scope, content: "hello" }])
39
43
  expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "updated" }])
@@ -1,11 +1,48 @@
1
1
  import { Effect } from "effect"
2
- import type { DiscordMessage, DiscordScope } from "../Schema.ts"
2
+ import type { DiscordMessage, DiscordScope, DiscordSearchQuery, DiscordSearchResult } from "../Schema.ts"
3
3
  import type { DiscordPostedMessage, DiscordService } from "./DiscordPort.ts"
4
4
 
5
5
  type MemoryOptions = {
6
6
  readonly context?: ReadonlyArray<DiscordMessage>
7
7
  }
8
8
 
9
+ const lowerIncludes = (value: string, expected: string): boolean => value.toLowerCase().includes(expected.toLowerCase())
10
+
11
+ const matchesContent = (message: DiscordMessage, query: DiscordSearchQuery): boolean =>
12
+ query.content === undefined || lowerIncludes(message.content, query.content)
13
+
14
+ const matchesAuthor = (message: DiscordMessage, query: DiscordSearchQuery): boolean =>
15
+ (query.authors.length === 0 || query.authors.includes(message.author.id)) &&
16
+ (query.authorNames.length === 0 || query.authorNames.some((name) => lowerIncludes(message.author.displayName, name)))
17
+
18
+ const matchesChannel = (message: DiscordMessage, query: DiscordSearchQuery): boolean =>
19
+ query.channels.length === 0 || query.channels.some((id) => id === message.channelId || id === message.threadId)
20
+
21
+ const matchesMentions = (message: DiscordMessage, query: DiscordSearchQuery): boolean =>
22
+ (query.mentions.length === 0 || query.mentions.every((id) => message.mentions.includes(id))) &&
23
+ (query.roleMentions.length === 0 || query.roleMentions.every((id) => message.roleMentions.includes(id))) &&
24
+ (query.mentionEveryone === undefined || message.everyoneMention === query.mentionEveryone)
25
+
26
+ const matchesHas = (message: DiscordMessage, query: DiscordSearchQuery): boolean =>
27
+ (!query.has.includes("file") || message.attachments.length > 0) && (!query.has.includes("link") || /https?:\/\//i.test(message.content))
28
+
29
+ const matchesSearch = (message: DiscordMessage, scope: DiscordScope, query: DiscordSearchQuery): boolean => {
30
+ if (message.guildId !== scope.guildId) return false
31
+ return [matchesContent, matchesAuthor, matchesChannel, matchesMentions, matchesHas].every((matches) => matches(message, query))
32
+ }
33
+
34
+ const memorySearch = (
35
+ context: ReadonlyArray<DiscordMessage>,
36
+ scope: DiscordScope,
37
+ query: DiscordSearchQuery,
38
+ limit: number,
39
+ offset: number
40
+ ): DiscordSearchResult => {
41
+ const matching = context.filter((message) => matchesSearch(message, scope, query))
42
+ const messages = matching.slice(offset, offset + limit)
43
+ return { totalResults: matching.length, offset, hasMore: offset + messages.length < matching.length, messages }
44
+ }
45
+
9
46
  export type MemoryDiscord = DiscordService & {
10
47
  readonly context: Array<DiscordMessage>
11
48
  readonly typingScopes: Array<DiscordScope>
@@ -52,7 +89,7 @@ export const makeMemoryDiscord = (options: MemoryOptions = {}): MemoryDiscord =>
52
89
  }),
53
90
  editMessage: (scope, messageId, content) => Effect.sync(() => edits.push({ scope, messageId, content })).pipe(Effect.asVoid),
54
91
  addReaction: (scope, messageId, emoji) => Effect.sync(() => reactions.push({ scope, messageId, emoji, op: "add" })).pipe(Effect.asVoid),
55
- fetchHistory: (_scope, limit) => Effect.succeed(context.slice(Math.max(0, context.length - limit))),
92
+ searchMessages: (scope, query, paging) => Effect.succeed(memorySearch(context, scope, query, paging.limit, paging.offset)),
56
93
  attachFile: (scope, path) =>
57
94
  Effect.sync(() => {
58
95
  attachments.push({ scope, path })
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseDiscordSearchQuery, timestampMsToDiscordSnowflake } from "./SearchQuery.ts"
3
+
4
+ describe("parseDiscordSearchQuery", () => {
5
+ test("parses Discord-style search operators", () => {
6
+ const result = parseDiscordSearchQuery(
7
+ '"release notes" from:<@123> mentions:<@!456> in:<#789> has:link pinned:true sort:relevance order:asc'
8
+ )
9
+ if (!result.ok) throw new Error(result.error)
10
+
11
+ expect(result.query).toMatchObject({
12
+ content: "release notes",
13
+ authors: ["123"],
14
+ mentions: ["456"],
15
+ channels: ["789"],
16
+ has: ["link"],
17
+ pinned: true,
18
+ sortBy: "relevance",
19
+ sortOrder: "asc"
20
+ })
21
+ })
22
+
23
+ test("supports name references and raw API filter aliases", () => {
24
+ const result = parseDiscordSearchQuery(
25
+ "from:Alice in:#general author_type:bot attachment_extension:ts link_hostname:example.com embed_type:image mention_everyone:false"
26
+ )
27
+ if (!result.ok) throw new Error(result.error)
28
+
29
+ expect(result.query.authorNames).toEqual(["Alice"])
30
+ expect(result.query.channelNames).toEqual(["general"])
31
+ expect(result.query.authorTypes).toEqual(["bot"])
32
+ expect(result.query.attachmentExtensions).toEqual(["ts"])
33
+ expect(result.query.linkHostnames).toEqual(["example.com"])
34
+ expect(result.query.embedTypes).toEqual(["image"])
35
+ expect(result.query.mentionEveryone).toBe(false)
36
+ })
37
+
38
+ test("converts date filters to Discord snowflake IDs", () => {
39
+ const result = parseDiscordSearchQuery("after:2026-06-05 during:2026-06-06")
40
+ if (!result.ok) throw new Error(result.error)
41
+
42
+ expect(result.query.minId).toBe(timestampMsToDiscordSnowflake(Date.UTC(2026, 5, 6)))
43
+ expect(result.query.maxId).toBe(timestampMsToDiscordSnowflake(Date.UTC(2026, 5, 7)))
44
+ })
45
+
46
+ test("rejects invalid operators", () => {
47
+ expect(parseDiscordSearchQuery("pinned:maybe")).toEqual({ ok: false, error: "Invalid pinned: value maybe" })
48
+ expect(parseDiscordSearchQuery("role:moderators")).toEqual({
49
+ ok: false,
50
+ error: "Role search requires a role ID or role mention: moderators"
51
+ })
52
+ })
53
+ })