opencode-discord-bot 0.1.0 → 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 +3 -36
- 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,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
|
+
})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { DiscordSearchQuery } from "../Schema.ts"
|
|
2
|
+
import { type DiscordSearchDateKey, discordSearchDateFilter, timestampMsToDiscordSnowflake } from "./SearchQueryDates.ts"
|
|
3
|
+
import { splitKeyValue, splitValues, tokenizeDiscordSearch, unique } from "./SearchQueryTokens.ts"
|
|
4
|
+
|
|
5
|
+
export { hasDiscordSearchCriteria } from "./SearchQueryCriteria.ts"
|
|
6
|
+
export { timestampMsToDiscordSnowflake }
|
|
7
|
+
|
|
8
|
+
export type DiscordSearchParseResult =
|
|
9
|
+
| { readonly ok: true; readonly query: DiscordSearchQuery }
|
|
10
|
+
| { readonly ok: false; readonly error: string }
|
|
11
|
+
|
|
12
|
+
type MutableSearchQueryFields = { -readonly [K in keyof DiscordSearchQuery]?: DiscordSearchQuery[K] }
|
|
13
|
+
|
|
14
|
+
type SearchState = {
|
|
15
|
+
readonly content: Array<string>
|
|
16
|
+
readonly authors: Array<string>
|
|
17
|
+
readonly authorNames: Array<string>
|
|
18
|
+
readonly authorTypes: Array<string>
|
|
19
|
+
readonly channels: Array<string>
|
|
20
|
+
readonly channelNames: Array<string>
|
|
21
|
+
readonly mentions: Array<string>
|
|
22
|
+
readonly mentionNames: Array<string>
|
|
23
|
+
readonly roleMentions: Array<string>
|
|
24
|
+
readonly repliedToUsers: Array<string>
|
|
25
|
+
readonly repliedToUserNames: Array<string>
|
|
26
|
+
readonly repliedToMessages: Array<string>
|
|
27
|
+
readonly has: Array<string>
|
|
28
|
+
readonly embedTypes: Array<string>
|
|
29
|
+
readonly embedProviders: Array<string>
|
|
30
|
+
readonly linkHostnames: Array<string>
|
|
31
|
+
readonly attachmentFilenames: Array<string>
|
|
32
|
+
readonly attachmentExtensions: Array<string>
|
|
33
|
+
readonly next: MutableSearchQueryFields
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SearchHandler = (state: SearchState, value: string, key: string) => DiscordSearchParseResult | undefined
|
|
37
|
+
|
|
38
|
+
const snowflakePattern = /^\d{5,32}$/
|
|
39
|
+
|
|
40
|
+
export const emptyDiscordSearchQuery = (): DiscordSearchQuery => ({
|
|
41
|
+
authors: [],
|
|
42
|
+
authorNames: [],
|
|
43
|
+
authorTypes: [],
|
|
44
|
+
channels: [],
|
|
45
|
+
channelNames: [],
|
|
46
|
+
mentions: [],
|
|
47
|
+
mentionNames: [],
|
|
48
|
+
roleMentions: [],
|
|
49
|
+
repliedToUsers: [],
|
|
50
|
+
repliedToUserNames: [],
|
|
51
|
+
repliedToMessages: [],
|
|
52
|
+
has: [],
|
|
53
|
+
embedTypes: [],
|
|
54
|
+
embedProviders: [],
|
|
55
|
+
linkHostnames: [],
|
|
56
|
+
attachmentFilenames: [],
|
|
57
|
+
attachmentExtensions: []
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const createState = (): SearchState => ({
|
|
61
|
+
content: [],
|
|
62
|
+
authors: [],
|
|
63
|
+
authorNames: [],
|
|
64
|
+
authorTypes: [],
|
|
65
|
+
channels: [],
|
|
66
|
+
channelNames: [],
|
|
67
|
+
mentions: [],
|
|
68
|
+
mentionNames: [],
|
|
69
|
+
roleMentions: [],
|
|
70
|
+
repliedToUsers: [],
|
|
71
|
+
repliedToUserNames: [],
|
|
72
|
+
repliedToMessages: [],
|
|
73
|
+
has: [],
|
|
74
|
+
embedTypes: [],
|
|
75
|
+
embedProviders: [],
|
|
76
|
+
linkHostnames: [],
|
|
77
|
+
attachmentFilenames: [],
|
|
78
|
+
attachmentExtensions: [],
|
|
79
|
+
next: {}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const extractId = (input: string, pattern: RegExp): string | undefined => pattern.exec(input.trim())?.[1]
|
|
83
|
+
const isSnowflake = (input: string): boolean => snowflakePattern.test(input.trim())
|
|
84
|
+
const lower = (items: ReadonlyArray<string>): ReadonlyArray<string> => items.map((item) => item.toLowerCase())
|
|
85
|
+
|
|
86
|
+
const pushUserReference = (input: string, ids: Array<string>, names: Array<string>): void => {
|
|
87
|
+
const id = extractId(input, /^<@!?(\d+)>$/)
|
|
88
|
+
if (id !== undefined || isSnowflake(input)) ids.push(id ?? input.trim())
|
|
89
|
+
else names.push(input.trim().replace(/^@/, ""))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pushChannelReference = (input: string, ids: Array<string>, names: Array<string>): void => {
|
|
93
|
+
const id = extractId(input, /^<#(\d+)>$/)
|
|
94
|
+
if (id !== undefined || isSnowflake(input)) ids.push(id ?? input.trim())
|
|
95
|
+
else names.push(input.trim().replace(/^#/, ""))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pushRoleReference = (input: string, ids: Array<string>): DiscordSearchParseResult | undefined => {
|
|
99
|
+
const id = extractId(input, /^<@&(\d+)>$/)
|
|
100
|
+
if (id !== undefined || isSnowflake(input)) {
|
|
101
|
+
ids.push(id ?? input.trim())
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
return { ok: false, error: `Role search requires a role ID or role mention: ${input}` }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parseBoolean = (input: string): boolean | undefined => {
|
|
108
|
+
switch (input.trim().toLowerCase()) {
|
|
109
|
+
case "true":
|
|
110
|
+
case "yes":
|
|
111
|
+
case "1":
|
|
112
|
+
return true
|
|
113
|
+
case "false":
|
|
114
|
+
case "no":
|
|
115
|
+
case "0":
|
|
116
|
+
return false
|
|
117
|
+
default:
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const contentHandler: SearchHandler = (state, value) => {
|
|
123
|
+
state.content.push(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const authorHandler: SearchHandler = (state, value) => {
|
|
127
|
+
for (const item of splitValues(value)) pushUserReference(item, state.authors, state.authorNames)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const channelHandler: SearchHandler = (state, value) => {
|
|
131
|
+
for (const item of splitValues(value)) pushChannelReference(item, state.channels, state.channelNames)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const mentionHandler: SearchHandler = (state, value) => {
|
|
135
|
+
for (const item of splitValues(value)) pushUserReference(item, state.mentions, state.mentionNames)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const roleHandler: SearchHandler = (state, value) => {
|
|
139
|
+
for (const item of splitValues(value)) {
|
|
140
|
+
const failed = pushRoleReference(item, state.roleMentions)
|
|
141
|
+
if (failed !== undefined) return failed
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const repliedUserHandler: SearchHandler = (state, value) => {
|
|
146
|
+
for (const item of splitValues(value)) pushUserReference(item, state.repliedToUsers, state.repliedToUserNames)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dateHandler: SearchHandler = (state, value, key) => {
|
|
150
|
+
const result = discordSearchDateFilter(key as DiscordSearchDateKey, value)
|
|
151
|
+
if (!result.ok) return result
|
|
152
|
+
if (result.minId !== undefined) state.next.minId = result.minId
|
|
153
|
+
if (result.maxId !== undefined) state.next.maxId = result.maxId
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const slopHandler: SearchHandler = (state, value) => {
|
|
157
|
+
const slop = Number(value)
|
|
158
|
+
if (!Number.isInteger(slop) || slop < 0 || slop > 100) return { ok: false, error: `Invalid slop: ${value}` }
|
|
159
|
+
state.next.slop = slop
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const booleanHandler =
|
|
163
|
+
(field: "pinned" | "mentionEveryone" | "includeNsfw", label: string): SearchHandler =>
|
|
164
|
+
(state, value) => {
|
|
165
|
+
const parsed = parseBoolean(value)
|
|
166
|
+
if (parsed === undefined) return { ok: false, error: `Invalid ${label}: value ${value}` }
|
|
167
|
+
state.next[field] = parsed
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sortHandler: SearchHandler = (state, value) => {
|
|
171
|
+
const sortBy = value.toLowerCase()
|
|
172
|
+
if (sortBy !== "timestamp" && sortBy !== "relevance") return { ok: false, error: `Invalid sort: value ${value}` }
|
|
173
|
+
state.next.sortBy = sortBy
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const orderHandler: SearchHandler = (state, value) => {
|
|
177
|
+
const sortOrder = value.toLowerCase()
|
|
178
|
+
if (sortOrder !== "asc" && sortOrder !== "desc") return { ok: false, error: `Invalid order: value ${value}` }
|
|
179
|
+
state.next.sortOrder = sortOrder
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handlerByKey: Record<string, SearchHandler> = {}
|
|
183
|
+
const register = (aliases: ReadonlyArray<string>, handler: SearchHandler): void => {
|
|
184
|
+
for (const alias of aliases) handlerByKey[alias] = handler
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
register(["content"], contentHandler)
|
|
188
|
+
register(["from", "author", "author_id"], authorHandler)
|
|
189
|
+
register(["author_type"], (state, value) => {
|
|
190
|
+
state.authorTypes.push(...lower(splitValues(value)))
|
|
191
|
+
})
|
|
192
|
+
register(["in", "channel", "channel_id"], channelHandler)
|
|
193
|
+
register(["mentions", "mention", "mentions_user_id"], mentionHandler)
|
|
194
|
+
register(["mentions_role", "mentions_role_id", "mention_role", "role"], roleHandler)
|
|
195
|
+
register(["replied_to_user", "replied_to_user_id", "reply_to_user", "reply_to"], repliedUserHandler)
|
|
196
|
+
register(["replied_to_message", "replied_to_message_id", "reply_to_message"], (state, value) => {
|
|
197
|
+
state.repliedToMessages.push(...splitValues(value))
|
|
198
|
+
})
|
|
199
|
+
register(["has"], (state, value) => {
|
|
200
|
+
state.has.push(...lower(splitValues(value)))
|
|
201
|
+
})
|
|
202
|
+
register(["embed", "embed_type"], (state, value) => {
|
|
203
|
+
state.embedTypes.push(...lower(splitValues(value)))
|
|
204
|
+
})
|
|
205
|
+
register(["embed_provider"], (state, value) => {
|
|
206
|
+
state.embedProviders.push(...splitValues(value))
|
|
207
|
+
})
|
|
208
|
+
register(["link_hostname", "hostname", "domain"], (state, value) => {
|
|
209
|
+
state.linkHostnames.push(...lower(splitValues(value)))
|
|
210
|
+
})
|
|
211
|
+
register(["attachment_filename", "filename"], (state, value) => {
|
|
212
|
+
state.attachmentFilenames.push(...splitValues(value))
|
|
213
|
+
})
|
|
214
|
+
register(["attachment_extension", "extension", "ext"], (state, value) => {
|
|
215
|
+
state.attachmentExtensions.push(...lower(splitValues(value).map((item) => item.replace(/^\./, ""))))
|
|
216
|
+
})
|
|
217
|
+
register(["before", "after", "during"], dateHandler)
|
|
218
|
+
register(["max_id"], (state, value) => {
|
|
219
|
+
state.next.maxId = value
|
|
220
|
+
})
|
|
221
|
+
register(["min_id"], (state, value) => {
|
|
222
|
+
state.next.minId = value
|
|
223
|
+
})
|
|
224
|
+
register(["slop"], slopHandler)
|
|
225
|
+
register(["pinned"], booleanHandler("pinned", "pinned"))
|
|
226
|
+
register(["mention_everyone", "everyone"], booleanHandler("mentionEveryone", "mention_everyone"))
|
|
227
|
+
register(["include_nsfw", "nsfw"], booleanHandler("includeNsfw", "include_nsfw"))
|
|
228
|
+
register(["sort", "sort_by"], sortHandler)
|
|
229
|
+
register(["order", "sort_order"], orderHandler)
|
|
230
|
+
|
|
231
|
+
const buildQuery = (state: SearchState): DiscordSearchQuery => {
|
|
232
|
+
const content = state.content.join(" ").trim()
|
|
233
|
+
return {
|
|
234
|
+
...(content.length === 0 ? {} : { content }),
|
|
235
|
+
authors: unique(state.authors),
|
|
236
|
+
authorNames: unique(state.authorNames),
|
|
237
|
+
authorTypes: unique(state.authorTypes),
|
|
238
|
+
channels: unique(state.channels),
|
|
239
|
+
channelNames: unique(state.channelNames),
|
|
240
|
+
mentions: unique(state.mentions),
|
|
241
|
+
mentionNames: unique(state.mentionNames),
|
|
242
|
+
roleMentions: unique(state.roleMentions),
|
|
243
|
+
repliedToUsers: unique(state.repliedToUsers),
|
|
244
|
+
repliedToUserNames: unique(state.repliedToUserNames),
|
|
245
|
+
repliedToMessages: unique(state.repliedToMessages),
|
|
246
|
+
has: unique(state.has),
|
|
247
|
+
embedTypes: unique(state.embedTypes),
|
|
248
|
+
embedProviders: unique(state.embedProviders),
|
|
249
|
+
linkHostnames: unique(state.linkHostnames),
|
|
250
|
+
attachmentFilenames: unique(state.attachmentFilenames),
|
|
251
|
+
attachmentExtensions: unique(state.attachmentExtensions),
|
|
252
|
+
...state.next
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const parseDiscordSearchQuery = (input: string): DiscordSearchParseResult => {
|
|
257
|
+
const state = createState()
|
|
258
|
+
for (const token of tokenizeDiscordSearch(input)) {
|
|
259
|
+
const pair = splitKeyValue(token)
|
|
260
|
+
const handler = pair === undefined ? undefined : handlerByKey[pair[0]]
|
|
261
|
+
if (pair === undefined || handler === undefined) state.content.push(token)
|
|
262
|
+
else {
|
|
263
|
+
const failed = handler(state, pair[1], pair[0])
|
|
264
|
+
if (failed !== undefined) return failed
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, query: buildQuery(state) }
|
|
268
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DiscordSearchQuery } from "../Schema.ts"
|
|
2
|
+
|
|
3
|
+
export const hasDiscordSearchCriteria = (query: DiscordSearchQuery): boolean => {
|
|
4
|
+
const arrays = [
|
|
5
|
+
query.authors,
|
|
6
|
+
query.authorNames,
|
|
7
|
+
query.authorTypes,
|
|
8
|
+
query.channels,
|
|
9
|
+
query.channelNames,
|
|
10
|
+
query.mentions,
|
|
11
|
+
query.mentionNames,
|
|
12
|
+
query.roleMentions,
|
|
13
|
+
query.repliedToUsers,
|
|
14
|
+
query.repliedToUserNames,
|
|
15
|
+
query.repliedToMessages,
|
|
16
|
+
query.has,
|
|
17
|
+
query.embedTypes,
|
|
18
|
+
query.embedProviders,
|
|
19
|
+
query.linkHostnames,
|
|
20
|
+
query.attachmentFilenames,
|
|
21
|
+
query.attachmentExtensions
|
|
22
|
+
]
|
|
23
|
+
const scalars = [
|
|
24
|
+
query.content,
|
|
25
|
+
query.maxId,
|
|
26
|
+
query.minId,
|
|
27
|
+
query.slop,
|
|
28
|
+
query.pinned,
|
|
29
|
+
query.mentionEveryone,
|
|
30
|
+
query.includeNsfw,
|
|
31
|
+
query.sortBy,
|
|
32
|
+
query.sortOrder
|
|
33
|
+
]
|
|
34
|
+
return arrays.some((items) => items.length > 0) || scalars.some((value) => value !== undefined)
|
|
35
|
+
}
|