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
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
+
})
|