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,43 @@
1
+ export type DiscordSearchDateKey = "before" | "after" | "during"
2
+
3
+ export type DiscordSearchDateResult =
4
+ | { readonly ok: true; readonly minId?: string; readonly maxId?: string }
5
+ | { readonly ok: false; readonly error: string }
6
+
7
+ const discordEpochMs = 1_420_070_400_000n
8
+ const dayMs = 24 * 60 * 60 * 1000
9
+ const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/
10
+
11
+ const parseTimestampMs = (input: string): number | undefined => {
12
+ const dateOnly = dateOnlyPattern.exec(input.trim())
13
+ if (dateOnly !== null) {
14
+ const year = Number(dateOnly[1])
15
+ const month = Number(dateOnly[2])
16
+ const day = Number(dateOnly[3])
17
+ const value = Date.UTC(year, month - 1, day)
18
+ return Number.isFinite(value) ? value : undefined
19
+ }
20
+ const value = Date.parse(input)
21
+ return Number.isFinite(value) ? value : undefined
22
+ }
23
+
24
+ const startOfUtcDay = (ms: number): number => {
25
+ const date = new Date(ms)
26
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
27
+ }
28
+
29
+ export const timestampMsToDiscordSnowflake = (ms: number): string => {
30
+ const timestamp = BigInt(Math.max(0, Math.trunc(ms)))
31
+ const value = (timestamp - discordEpochMs) << 22n
32
+ return (value > 0n ? value : 0n).toString()
33
+ }
34
+
35
+ export const discordSearchDateFilter = (key: DiscordSearchDateKey, value: string): DiscordSearchDateResult => {
36
+ const ms = parseTimestampMs(value)
37
+ if (ms === undefined) return { ok: false, error: `Invalid ${key}: date ${value}` }
38
+ if (key === "during") {
39
+ const start = startOfUtcDay(ms)
40
+ return { ok: true, minId: timestampMsToDiscordSnowflake(start), maxId: timestampMsToDiscordSnowflake(start + dayMs) }
41
+ }
42
+ return key === "before" ? { ok: true, maxId: timestampMsToDiscordSnowflake(ms) } : { ok: true, minId: timestampMsToDiscordSnowflake(ms) }
43
+ }
@@ -0,0 +1,25 @@
1
+ export const tokenizeDiscordSearch = (input: string): ReadonlyArray<string> => {
2
+ const matches = input.match(/(?:[^\s"'\\]+|\\.|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g)
3
+ if (matches === null) return []
4
+ return matches.map((token) =>
5
+ token
6
+ .replace(/(["'])((?:\\.|(?!\1).)*)\1/g, (_match, _quote: string, body: string) => body.replace(/\\(.)/g, "$1"))
7
+ .replace(/\\(.)/g, "$1")
8
+ )
9
+ }
10
+
11
+ export const splitValues = (input: string): ReadonlyArray<string> =>
12
+ input
13
+ .split(",")
14
+ .map((item) => item.trim())
15
+ .filter(Boolean)
16
+
17
+ export const splitKeyValue = (token: string): readonly [string, string] | undefined => {
18
+ const index = token.indexOf(":")
19
+ if (index <= 0) return undefined
20
+ const key = token.slice(0, index).trim().toLowerCase().replaceAll("-", "_")
21
+ const value = token.slice(index + 1).trim()
22
+ return key.length === 0 || value.length === 0 ? undefined : [key, value]
23
+ }
24
+
25
+ export const unique = <A>(items: ReadonlyArray<A>): ReadonlyArray<A> => [...new Set(items)]
package/src/Main.test.ts CHANGED
@@ -115,7 +115,7 @@ describe("makeApplication startup", () => {
115
115
  test("swallows failed message turns from gateway dispatch", async () => {
116
116
  const failingDiscord: DiscordService = {
117
117
  fetchContext: () => Effect.fail(new DiscordError({ message: "context failed" })),
118
- fetchHistory: () => Effect.succeed([]),
118
+ searchMessages: () => Effect.succeed({ totalResults: 0, offset: 0, hasMore: false, messages: [] }),
119
119
  sendTyping: () => Effect.void,
120
120
  postMessage: () => Effect.succeed({ id: "posted" }),
121
121
  editMessage: () => Effect.void,
package/src/Schema.ts CHANGED
@@ -50,6 +50,42 @@ export type DiscordMessage = {
50
50
  readonly isSystem?: boolean
51
51
  }
52
52
 
53
+ export type DiscordSearchQuery = {
54
+ readonly content?: string
55
+ readonly authors: ReadonlyArray<string>
56
+ readonly authorNames: ReadonlyArray<string>
57
+ readonly authorTypes: ReadonlyArray<string>
58
+ readonly channels: ReadonlyArray<string>
59
+ readonly channelNames: ReadonlyArray<string>
60
+ readonly mentions: ReadonlyArray<string>
61
+ readonly mentionNames: ReadonlyArray<string>
62
+ readonly roleMentions: ReadonlyArray<string>
63
+ readonly repliedToUsers: ReadonlyArray<string>
64
+ readonly repliedToUserNames: ReadonlyArray<string>
65
+ readonly repliedToMessages: ReadonlyArray<string>
66
+ readonly has: ReadonlyArray<string>
67
+ readonly embedTypes: ReadonlyArray<string>
68
+ readonly embedProviders: ReadonlyArray<string>
69
+ readonly linkHostnames: ReadonlyArray<string>
70
+ readonly attachmentFilenames: ReadonlyArray<string>
71
+ readonly attachmentExtensions: ReadonlyArray<string>
72
+ readonly maxId?: string
73
+ readonly minId?: string
74
+ readonly slop?: number
75
+ readonly pinned?: boolean
76
+ readonly mentionEveryone?: boolean
77
+ readonly sortBy?: "timestamp" | "relevance"
78
+ readonly sortOrder?: "asc" | "desc"
79
+ readonly includeNsfw?: boolean
80
+ }
81
+
82
+ export type DiscordSearchResult = {
83
+ readonly totalResults: number
84
+ readonly offset: number
85
+ readonly hasMore: boolean
86
+ readonly messages: ReadonlyArray<DiscordMessage>
87
+ }
88
+
53
89
  export type ToolTarget = {
54
90
  readonly guildId?: string | undefined
55
91
  readonly channelId?: string | undefined
@@ -0,0 +1,35 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+
3
+ const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
4
+
5
+ const targetSchema = tool.schema
6
+ .object({
7
+ guildId: tool.schema.string().describe("Discord guild/server ID to search within."),
8
+ channelId: tool.schema.string().optional().describe("Optional active channel ID. Search is guild-wide unless the query uses in:."),
9
+ threadId: tool.schema.string().optional().describe("Optional active thread ID. Search is guild-wide unless the query uses in:.")
10
+ })
11
+ .describe("Discord guild target for message search.")
12
+
13
+ export default tool({
14
+ description:
15
+ "Search Discord message history using Discord-style search syntax. Use free text plus operators like from:<@user>, mentions:<@user>, in:<#channel>, has:link, has:file, before:YYYY-MM-DD, after:YYYY-MM-DD, during:YYYY-MM-DD, pinned:true, sort:relevance, order:desc. Returns matching messages and total count. The active turn already includes recent messages by default; use this tool for older or specific messages.",
16
+ args: {
17
+ target: targetSchema,
18
+ query: tool.schema.string().describe("Discord search query string, using the same operators a Discord user would type."),
19
+ limit: tool.schema.number().int().positive().optional().describe("Results per page, 1-25. Default 25."),
20
+ offset: tool.schema.number().int().optional().describe("Pagination offset. Default 0; Discord supports offsets up to 9975.")
21
+ },
22
+ async execute(request) {
23
+ const response = await fetch(loopbackToolUrl, {
24
+ method: "POST",
25
+ headers: { "content-type": "application/json" },
26
+ body: JSON.stringify({
27
+ action: "searchMessages",
28
+ target: request.target,
29
+ args: { query: request.query, limit: request.limit, offset: request.offset }
30
+ })
31
+ })
32
+ const payload = await response.json()
33
+ return JSON.stringify(payload, null, 2) ?? String(payload)
34
+ }
35
+ })
@@ -4,11 +4,11 @@ import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
5
  import { ensureDiscordTools, renderDiscordToolFile } from "./Scaffolding.ts"
6
6
 
7
- const toolFiles = ["discord-add-reaction.ts", "discord-fetch-history.ts", "discord-attach-file.ts", "discord-create-thread.ts"] as const
7
+ const toolFiles = ["discord-add-reaction.ts", "discord-search-messages.ts", "discord-attach-file.ts", "discord-create-thread.ts"] as const
8
8
 
9
9
  const sourceUrls = [
10
10
  new URL("./DiscordAddReactionTool.ts", import.meta.url),
11
- new URL("./DiscordFetchHistoryTool.ts", import.meta.url),
11
+ new URL("./DiscordSearchMessagesTool.ts", import.meta.url),
12
12
  new URL("./DiscordAttachFileTool.ts", import.meta.url),
13
13
  new URL("./DiscordCreateThreadTool.ts", import.meta.url)
14
14
  ] as const
@@ -38,10 +38,11 @@ describe("Discord tool scaffolding", () => {
38
38
  }
39
39
 
40
40
  expect(combined).toContain('action: "addReaction"')
41
- expect(combined).toContain('action: "fetchHistory"')
41
+ expect(combined).toContain('action: "searchMessages"')
42
42
  expect(combined).toContain('action: "attachFile"')
43
43
  expect(combined).toContain('action: "createThread"')
44
44
  expect(combined).toContain("emoji:")
45
+ expect(combined).toContain("query:")
45
46
  expect(combined).toContain("limit:")
46
47
  expect(combined).toContain("path:")
47
48
  expect(combined).toContain("name:")
@@ -74,12 +75,15 @@ describe("Discord tool scaffolding", () => {
74
75
  expect(await readFile(toolPaths[0] ?? "", "utf8")).not.toContain("// operator file")
75
76
 
76
77
  const deprecatedToolPath = join(projectDir, ".opencode", "tools", "discord-bridge.ts")
78
+ const deprecatedHistoryToolPath = join(projectDir, ".opencode", "tools", "discord-fetch-history.ts")
77
79
  await writeFile(deprecatedToolPath, "// Generated by opencode-discord-bot. DO NOT EDIT.\nexport const parameters = {}\n")
80
+ await writeFile(deprecatedHistoryToolPath, "// Generated by opencode-discord-bot. DO NOT EDIT.\nexport default {}\n")
78
81
  const migrated = await ensureDiscordTools({ projectDir, bridgePort: 6666, enabled: true, autoInstall: true })
79
82
 
80
83
  expect(migrated).toEqual(toolPaths)
81
84
  for (const toolPath of toolPaths) expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:6666/tool")
82
85
  expect(await exists(deprecatedToolPath)).toBe(false)
86
+ expect(await exists(deprecatedHistoryToolPath)).toBe(false)
83
87
  } finally {
84
88
  await rm(projectDir, { recursive: true, force: true })
85
89
  }
@@ -3,11 +3,11 @@ import { join } from "node:path"
3
3
 
4
4
  const header = "// Generated by opencode-discord-bot. DO NOT EDIT."
5
5
  const loopbackUrlPlaceholder = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__"
6
- const deprecatedDiscordBridgeFile = "discord-bridge.ts"
6
+ const deprecatedDiscordBridgeFiles = ["discord-bridge.ts", "discord-fetch-history.ts"] as const
7
7
 
8
8
  const discordToolSources = [
9
9
  { fileName: "discord-add-reaction.ts", sourceUrl: new URL("./DiscordAddReactionTool.ts", import.meta.url) },
10
- { fileName: "discord-fetch-history.ts", sourceUrl: new URL("./DiscordFetchHistoryTool.ts", import.meta.url) },
10
+ { fileName: "discord-search-messages.ts", sourceUrl: new URL("./DiscordSearchMessagesTool.ts", import.meta.url) },
11
11
  { fileName: "discord-attach-file.ts", sourceUrl: new URL("./DiscordAttachFileTool.ts", import.meta.url) },
12
12
  { fileName: "discord-create-thread.ts", sourceUrl: new URL("./DiscordCreateThreadTool.ts", import.meta.url) }
13
13
  ] as const
@@ -22,12 +22,14 @@ export type ToolScaffoldOptions = {
22
22
  const isMissingFile = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"
23
23
 
24
24
  const removeDeprecatedGeneratedTool = async (toolsDir: string): Promise<void> => {
25
- const toolPath = join(toolsDir, deprecatedDiscordBridgeFile)
26
- try {
27
- const current = await readFile(toolPath, "utf8")
28
- if (current.startsWith(header)) await unlink(toolPath)
29
- } catch (cause) {
30
- if (!isMissingFile(cause)) throw cause
25
+ for (const fileName of deprecatedDiscordBridgeFiles) {
26
+ const toolPath = join(toolsDir, fileName)
27
+ try {
28
+ const current = await readFile(toolPath, "utf8")
29
+ if (current.startsWith(header)) await unlink(toolPath)
30
+ } catch (cause) {
31
+ if (!isMissingFile(cause)) throw cause
32
+ }
31
33
  }
32
34
  }
33
35
 
@@ -1,28 +0,0 @@
1
- import { tool } from "@opencode-ai/plugin"
2
-
3
- const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
4
-
5
- const targetSchema = tool.schema
6
- .object({
7
- guildId: tool.schema.string().describe("Discord guild ID."),
8
- channelId: tool.schema.string().describe("Discord channel ID."),
9
- threadId: tool.schema.string().optional().describe("Discord thread ID, when targeting a thread.")
10
- })
11
- .describe("Discord channel or thread target for history fetching.")
12
-
13
- export default tool({
14
- description: "Fetch recent Discord message history through the local opencode-discord-bot process.",
15
- args: {
16
- target: targetSchema,
17
- limit: tool.schema.number().int().positive().optional().describe("Maximum number of recent messages to fetch.")
18
- },
19
- async execute(request) {
20
- const response = await fetch(loopbackToolUrl, {
21
- method: "POST",
22
- headers: { "content-type": "application/json" },
23
- body: JSON.stringify({ action: "fetchHistory", target: request.target, args: { limit: request.limit } })
24
- })
25
- const payload = await response.json()
26
- return JSON.stringify(payload, null, 2) ?? String(payload)
27
- }
28
- })