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,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
|
-
|
|
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-
|
|
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("./
|
|
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: "
|
|
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
|
}
|
package/src/Tools/Scaffolding.ts
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
})
|