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