opencode-discord-bot 0.0.2 → 0.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discord-bot",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Discord bot bridge for a self-hosted opencode instance",
5
5
  "type": "module",
6
6
  "main": "src/Main.ts",
@@ -68,6 +68,7 @@
68
68
  "@commitlint/cli": "^21.0.2",
69
69
  "@commitlint/config-conventional": "^21.0.2",
70
70
  "@effect/vitest": "4.0.0-beta.74",
71
+ "@opencode-ai/plugin": "1.16.2",
71
72
  "@types/bun": "1.3.13",
72
73
  "@typescript/native-preview": "^7.0.0-dev.20260606.1",
73
74
  "@vitest/coverage-v8": "^4.1.8",
package/src/Main.test.ts CHANGED
@@ -41,6 +41,12 @@ const mentionMessage = {
41
41
  channelType: "guild"
42
42
  } satisfies DiscordMessage
43
43
 
44
+ const makeTestEnv = (projectDir: string, bridgePort: number) => ({
45
+ DISCORD_TOKEN: "token",
46
+ OPENCODE_PROJECT_DIR: projectDir,
47
+ DISCORD_BRIDGE_PORT: bridgePort.toString()
48
+ })
49
+
44
50
  describe("makeProgram", () => {
45
51
  test("runs Bun preflight, loads config, and scaffolds generated Discord tools", async () => {
46
52
  const projectDir = await mkdtemp(join(tmpdir(), "ocdb-program-"))
@@ -54,11 +60,11 @@ describe("makeProgram", () => {
54
60
  }
55
61
 
56
62
  try {
57
- await Effect.runPromise(makeProgram(projectDir, { DISCORD_TOKEN: "token", OPENCODE_PROJECT_DIR: projectDir }, factories))
63
+ await Effect.runPromise(makeProgram(projectDir, makeTestEnv(projectDir, 18_787), factories))
58
64
  const tool = await readFile(join(projectDir, ".opencode", "tools", "discord-bridge.ts"), "utf8")
59
65
 
60
66
  expect(tool).toContain("Generated by opencode-discord-bot")
61
- expect(tool).toContain("http://127.0.0.1:8787/tool")
67
+ expect(tool).toContain("http://127.0.0.1:18787/tool")
62
68
  } finally {
63
69
  await rm(projectDir, { recursive: true, force: true })
64
70
  }
@@ -152,7 +158,7 @@ describe("makeProgram callbacks", () => {
152
158
  }
153
159
 
154
160
  try {
155
- await Effect.runPromise(makeProgram(projectDir, { DISCORD_TOKEN: "token", OPENCODE_PROJECT_DIR: projectDir }, factories))
161
+ await Effect.runPromise(makeProgram(projectDir, makeTestEnv(projectDir, 18_788), factories))
156
162
  if (gatewayOptions === undefined) throw new Error("gateway options were not captured")
157
163
 
158
164
  await Effect.runPromise(gatewayOptions.onMessage(mentionMessage, { userId: "self" }))
@@ -59,6 +59,47 @@ describe("opencode event mapping", () => {
59
59
  expect(decodeOpencodeEvent({ type: "message.part.delta", part: { type: "reasoning" }, delta: "hidden" })).toBeUndefined()
60
60
  })
61
61
 
62
+ test("preserves text part ids and maps hidden reasoning to typing events", async () => {
63
+ const events = await Effect.runPromise(
64
+ opencodeEventStream(
65
+ Stream.fromIterable([
66
+ { type: "session.next.reasoning.started", properties: { sessionID: "s1", reasoningID: "r1" } },
67
+ { type: "session.next.text.delta", properties: { sessionID: "s1", textID: "text-1", delta: "hello" } },
68
+ { type: "session.next.text.ended", properties: { sessionID: "s1", textID: "text-1", text: "hello" } },
69
+ {
70
+ type: "message.updated",
71
+ properties: { sessionID: "s1", info: { id: "assistant-message", role: "assistant" } }
72
+ },
73
+ {
74
+ type: "message.part.delta",
75
+ properties: {
76
+ sessionID: "s1",
77
+ messageID: "assistant-message",
78
+ partID: "text-2",
79
+ part: { sessionID: "s1", messageID: "assistant-message", type: "text" },
80
+ delta: " second"
81
+ }
82
+ },
83
+ {
84
+ type: "message.part.updated",
85
+ properties: {
86
+ sessionID: "s1",
87
+ part: { id: "text-3", sessionID: "s1", messageID: "assistant-message", type: "text", text: "third" }
88
+ }
89
+ }
90
+ ])
91
+ ).pipe(Stream.runCollect)
92
+ )
93
+
94
+ expect(events).toEqual([
95
+ { type: "reasoning-start" },
96
+ { type: "text-delta", id: "text-1", text: "hello" },
97
+ { type: "text-snapshot", id: "text-1", text: "hello" },
98
+ { type: "text-delta", id: "text-2", text: " second" },
99
+ { type: "text-snapshot", id: "text-3", text: "third" }
100
+ ])
101
+ })
102
+
62
103
  test("maps wrapped SDK events and delta-bearing part updates", () => {
63
104
  expect(
64
105
  decodeOpencodeEvent({
@@ -183,6 +224,6 @@ describe("opencode event mapping", () => {
183
224
  ).pipe(Stream.runCollect)
184
225
  )
185
226
 
186
- expect(events).toEqual([{ type: "text-snapshot", text: "Normal answer" }, { type: "idle" }])
227
+ expect(events).toEqual([{ type: "text-snapshot", id: "assistant-text", text: "Normal answer" }, { type: "idle" }])
187
228
  })
188
229
  })
@@ -38,6 +38,24 @@ const textPart = (value: unknown): Readonly<Record<string, unknown>> | undefined
38
38
  return stringField(value, "type") === "text" ? value : undefined
39
39
  }
40
40
 
41
+ const textId = (record: Readonly<Record<string, unknown>>): string | undefined =>
42
+ stringField(record, "textID") ?? stringField(record, "textId")
43
+
44
+ const partId = (record: Readonly<Record<string, unknown>>): string | undefined =>
45
+ stringField(record, "id") ?? stringField(record, "partID") ?? stringField(record, "partId")
46
+
47
+ const messagePartId = (
48
+ payload: Readonly<Record<string, unknown>>,
49
+ properties: Readonly<Record<string, unknown>>,
50
+ part: Readonly<Record<string, unknown>> | undefined
51
+ ): string | undefined =>
52
+ (part === undefined ? undefined : partId(part)) ??
53
+ textId(properties) ??
54
+ stringField(properties, "partID") ??
55
+ stringField(properties, "partId") ??
56
+ stringField(payload, "partID") ??
57
+ stringField(payload, "partId")
58
+
41
59
  const errorMessage = (value: unknown): string => {
42
60
  if (!isRecord(value)) return "Unknown opencode error"
43
61
  const properties = payloadProperties(value)
@@ -107,11 +125,15 @@ const decodeText = (properties: Readonly<Record<string, unknown>>, type: string
107
125
  switch (type) {
108
126
  case "session.next.text.delta": {
109
127
  const delta = stringField(properties, "delta")
110
- return delta === undefined ? undefined : { type: "text-delta", text: delta }
128
+ if (delta === undefined) return undefined
129
+ const id = textId(properties)
130
+ return id === undefined ? { type: "text-delta", text: delta } : { type: "text-delta", id, text: delta }
111
131
  }
112
132
  case "session.next.text.ended": {
113
133
  const text = stringField(properties, "text")
114
- return text === undefined ? undefined : { type: "text-snapshot", text }
134
+ if (text === undefined) return undefined
135
+ const id = textId(properties)
136
+ return id === undefined ? { type: "text-snapshot", text } : { type: "text-snapshot", id, text }
115
137
  }
116
138
  default:
117
139
  return undefined
@@ -120,10 +142,12 @@ const decodeText = (properties: Readonly<Record<string, unknown>>, type: string
120
142
 
121
143
  const textDelta = (
122
144
  payload: Readonly<Record<string, unknown>>,
123
- properties: Readonly<Record<string, unknown>>
145
+ properties: Readonly<Record<string, unknown>>,
146
+ id: string | undefined
124
147
  ): OpencodeEvent | undefined => {
125
148
  const delta = stringField(properties, "delta") ?? stringField(payload, "delta")
126
- return delta === undefined ? undefined : { type: "text-delta", text: delta }
149
+ if (delta === undefined) return undefined
150
+ return id === undefined ? { type: "text-delta", text: delta } : { type: "text-delta", id, text: delta }
127
151
  }
128
152
 
129
153
  const decodePartDelta = (
@@ -132,9 +156,13 @@ const decodePartDelta = (
132
156
  options: DecodeOptions
133
157
  ): OpencodeEvent | undefined => {
134
158
  const part = properties.part ?? payload.part
135
- if (part !== undefined) return textPart(part) === undefined ? undefined : textDelta(payload, properties)
159
+ if (part !== undefined) {
160
+ const text = textPart(part)
161
+ return text === undefined ? undefined : textDelta(payload, properties, messagePartId(payload, properties, text))
162
+ }
136
163
  if (options.includeGenericPartDeltas !== true) return undefined
137
- return stringField(properties, "field") === "text" ? textDelta(payload, properties) : undefined
164
+ const id = messagePartId(payload, properties, undefined)
165
+ return stringField(properties, "field") === "text" ? textDelta(payload, properties, id) : undefined
138
166
  }
139
167
 
140
168
  const decodePartUpdated = (
@@ -143,12 +171,17 @@ const decodePartUpdated = (
143
171
  ): OpencodeEvent | undefined => {
144
172
  const part = textPart(properties.part ?? payload.part)
145
173
  if (part === undefined) return undefined
146
- const delta = textDelta(payload, properties)
174
+ const id = messagePartId(payload, properties, part)
175
+ const delta = textDelta(payload, properties, id)
147
176
  if (delta !== undefined) return delta
148
177
  const text = stringField(part, "text")
149
- return text === undefined ? undefined : { type: "text-snapshot", text }
178
+ if (text === undefined) return undefined
179
+ return id === undefined ? { type: "text-snapshot", text } : { type: "text-snapshot", id, text }
150
180
  }
151
181
 
182
+ const decodeReasoning = (type: string | undefined): OpencodeEvent | undefined =>
183
+ type === "session.next.reasoning.started" ? { type: "reasoning-start" } : undefined
184
+
152
185
  const decodePart = (
153
186
  payload: Readonly<Record<string, unknown>>,
154
187
  properties: Readonly<Record<string, unknown>>,
@@ -213,6 +246,7 @@ export const decodeOpencodeEvent = (payload: unknown, options: DecodeOptions = {
213
246
  return (
214
247
  decodeLifecycle(event, type) ??
215
248
  decodeText(properties, type) ??
249
+ decodeReasoning(type) ??
216
250
  decodePart(event, properties, type, options) ??
217
251
  decodeTool(event, type) ??
218
252
  decodeStep(event, type)
@@ -17,6 +17,12 @@ type PostedChunk = {
17
17
  content: string
18
18
  }
19
19
 
20
+ type RenderSegment = {
21
+ readonly id: string | undefined
22
+ text: string
23
+ readonly posted: Array<PostedChunk>
24
+ }
25
+
20
26
  const discordRetrySchedule = Schedule.fromStepWithMetadata(
21
27
  Effect.succeed((metadata: Schedule.InputMetadata<DiscordError>) => {
22
28
  if (metadata.attempt > 2) return Cause.done(metadata.attempt)
@@ -34,16 +40,31 @@ export const renderOpencodeEvents = Effect.fn("renderOpencodeEvents")(function*
34
40
  config: RuntimeConfig,
35
41
  discord: DiscordService
36
42
  ) {
37
- let answer = ""
38
43
  let changed: string | undefined
39
- const posted: Array<PostedChunk> = []
44
+ const segments: Array<RenderSegment> = []
45
+ const segmentsById = new Map<string, RenderSegment>()
40
46
  const updateIntervalMs = Math.max(0, Duration.toMillis(config.streaming.updateInterval))
41
47
  let lastFlushAt = Number.NEGATIVE_INFINITY
42
48
  let typingFiber: Fiber.Fiber<void, never> | undefined
43
49
  let status: PostedChunk | undefined
44
50
  let finished = false
45
51
 
46
- const visibleContent = () => sanitizeDiscordContent(changed === undefined ? answer : `${answer}\n\n${changed}`.trim(), config.guards)
52
+ const lastSegment = (): RenderSegment | undefined => segments[segments.length - 1]
53
+ const createSegment = (id: string | undefined): RenderSegment => {
54
+ const segment: RenderSegment = { id, text: "", posted: [] }
55
+ segments.push(segment)
56
+ if (id !== undefined) segmentsById.set(id, segment)
57
+ return segment
58
+ }
59
+ const getSegment = (id: string | undefined): RenderSegment => {
60
+ if (id !== undefined) return segmentsById.get(id) ?? createSegment(id)
61
+ const last = lastSegment()
62
+ return last !== undefined && last.id === undefined ? last : createSegment(undefined)
63
+ }
64
+ const visibleContent = (segment: RenderSegment) => {
65
+ const text = changed === undefined || lastSegment() !== segment ? segment.text : `${segment.text}\n\n${changed}`.trim()
66
+ return sanitizeDiscordContent(text, config.guards)
67
+ }
47
68
  const startTyping = Effect.fn("startDiscordTyping")(function* () {
48
69
  if (typingFiber !== undefined) return
49
70
  yield* retryDiscord(discord.sendTyping(scope)).pipe(Effect.catch(() => Effect.void))
@@ -72,39 +93,55 @@ export const renderOpencodeEvents = Effect.fn("renderOpencodeEvents")(function*
72
93
  status.content = safe
73
94
  }
74
95
  })
75
- const writeChunk = Effect.fn("writeDiscordRenderChunk")(function* (index: number, chunk: string, forceEdit = false) {
76
- const existing = posted[index]
96
+ const writeChunk = Effect.fn("writeDiscordRenderChunk")(function* (
97
+ segment: RenderSegment,
98
+ index: number,
99
+ chunk: string,
100
+ forceEdit = false
101
+ ) {
102
+ const existing = segment.posted[index]
77
103
  if (existing === undefined) {
78
104
  const created = yield* retryDiscord(discord.postMessage(scope, chunk))
79
- posted.push({ id: created.id, content: chunk })
105
+ segment.posted.push({ id: created.id, content: chunk })
80
106
  } else if (forceEdit || existing.content !== chunk) {
81
107
  yield* retryDiscord(discord.editMessage(scope, existing.id, chunk))
82
108
  existing.content = chunk
83
109
  }
84
110
  })
85
- const flush = Effect.fn("flushDiscordRender")(function* (force = false, forceEdit = false) {
86
- const chunks = splitDiscordMarkdown(visibleContent())
111
+ const flushSegment = Effect.fn("flushDiscordRenderSegment")(function* (segment: RenderSegment, force = false, forceEdit = false) {
112
+ const chunks = splitDiscordMarkdown(visibleContent(segment))
87
113
  if (chunks.length === 0) return
88
114
  const now = yield* Clock.currentTimeMillis
89
- if (!force && posted.length > 0 && now - lastFlushAt < updateIntervalMs) return
115
+ if (!force && segment.posted.length > 0 && now - lastFlushAt < updateIntervalMs) return
90
116
  for (let index = 0; index < chunks.length; index += 1) {
91
117
  const chunk = chunks[index]
92
118
  if (chunk === undefined) continue
93
- yield* writeChunk(index, chunk, forceEdit)
119
+ yield* writeChunk(segment, index, chunk, forceEdit)
94
120
  }
95
- for (let index = posted.length - 1; index >= chunks.length; index -= 1) {
96
- const stale = posted[index]
121
+ for (let index = segment.posted.length - 1; index >= chunks.length; index -= 1) {
122
+ const stale = segment.posted[index]
97
123
  if (stale === undefined) continue
98
124
  yield* retryDiscord(discord.deleteMessage(scope, stale.id))
99
- posted.splice(index, 1)
125
+ segment.posted.splice(index, 1)
100
126
  }
101
127
  lastFlushAt = now
102
128
  })
129
+ const prepareSegment = Effect.fn("prepareDiscordTextSegment")(function* (id: string | undefined) {
130
+ const previousLast = lastSegment()
131
+ const segment = getSegment(id)
132
+ if (changed !== undefined && previousLast !== undefined && previousLast !== segment) yield* flushSegment(previousLast, true)
133
+ return segment
134
+ })
135
+ const flushLastSegment = Effect.fn("flushLastDiscordRenderSegment")(function* (force = false, forceEdit = false) {
136
+ const segment = lastSegment()
137
+ if (segment === undefined) return
138
+ yield* flushSegment(segment, force, forceEdit)
139
+ })
103
140
  const finish = Effect.fn("finishDiscordRender")(function* () {
104
141
  if (finished) return
105
142
  finished = true
106
143
  const wasTyping = yield* stopTyping()
107
- yield* flush(true, wasTyping)
144
+ yield* flushLastSegment(true, wasTyping)
108
145
  })
109
146
 
110
147
  yield* startTyping()
@@ -122,26 +159,32 @@ export const renderOpencodeEvents = Effect.fn("renderOpencodeEvents")(function*
122
159
  yield* renderStatus("Tool finished.")
123
160
  break
124
161
  }
162
+ case "reasoning-start": {
163
+ yield* startTyping()
164
+ break
165
+ }
125
166
  case "idle": {
126
167
  yield* finish()
127
168
  break
128
169
  }
129
170
  case "text-delta": {
130
171
  yield* stopTyping()
131
- answer += event.text
132
- yield* flush(posted.length === 0)
172
+ const segment = yield* prepareSegment(event.id)
173
+ segment.text += event.text
174
+ yield* flushSegment(segment, segment.posted.length === 0)
133
175
  break
134
176
  }
135
177
  case "text-snapshot": {
136
178
  yield* stopTyping()
137
- answer = event.text
138
- yield* flush(true)
179
+ const segment = yield* prepareSegment(event.id)
180
+ segment.text = event.text
181
+ yield* flushSegment(segment, true)
139
182
  break
140
183
  }
141
184
  case "changed-files": {
142
185
  if (config.streaming.changedFilesSummary && hasChangedFiles(event)) {
143
186
  changed = changedSummary(event)
144
- yield* flush(true)
187
+ yield* flushSegment(lastSegment() ?? createSegment(undefined), true)
145
188
  }
146
189
  break
147
190
  }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import { defaultConfig } from "../Config.ts"
4
+ import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
5
+ import { opencodeEventStream } from "../Opencode/EventMapping.ts"
6
+ import type { DiscordScope } from "../Schema.ts"
7
+ import { renderOpencodeEvents } from "./Renderer.ts"
8
+
9
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
10
+
11
+ describe("renderOpencodeEvents text segments", () => {
12
+ test("preserves separate text parts around hidden reasoning", async () => {
13
+ const discord = makeMemoryDiscord()
14
+
15
+ await Effect.runPromise(
16
+ renderOpencodeEvents(
17
+ opencodeEventStream(
18
+ Stream.fromIterable([
19
+ { type: "session.next.reasoning.started", properties: { sessionID: "s1", reasoningID: "r1" } },
20
+ { type: "session.next.text.delta", properties: { sessionID: "s1", textID: "text-1", delta: "First response" } },
21
+ { type: "session.next.text.ended", properties: { sessionID: "s1", textID: "text-1", text: "First response" } },
22
+ { type: "session.next.reasoning.started", properties: { sessionID: "s1", reasoningID: "r2" } },
23
+ { type: "session.next.text.delta", properties: { sessionID: "s1", textID: "text-2", delta: "Second response" } },
24
+ { type: "session.next.text.ended", properties: { sessionID: "s1", textID: "text-2", text: "Second response" } },
25
+ { type: "session.idle", properties: { sessionID: "s1" } }
26
+ ])
27
+ ),
28
+ scope,
29
+ defaultConfig,
30
+ discord
31
+ )
32
+ )
33
+
34
+ expect(discord.typingScopes).toEqual([scope, scope])
35
+ expect(discord.messages).toEqual([
36
+ { scope, content: "First response" },
37
+ { scope, content: "Second response" }
38
+ ])
39
+ expect(discord.edits).toEqual([])
40
+ expect(discord.deletes).toEqual([])
41
+ })
42
+
43
+ test("does not delete earlier text-part continuation messages when a later part is shorter", async () => {
44
+ const discord = makeMemoryDiscord()
45
+ const long = "a".repeat(2001)
46
+
47
+ await Effect.runPromise(
48
+ renderOpencodeEvents(
49
+ Stream.fromIterable([
50
+ { type: "text-delta", id: "text-1", text: long },
51
+ { type: "text-snapshot", id: "text-1", text: long },
52
+ { type: "text-delta", id: "text-2", text: "short" },
53
+ { type: "text-snapshot", id: "text-2", text: "short" }
54
+ ]),
55
+ scope,
56
+ defaultConfig,
57
+ discord
58
+ )
59
+ )
60
+
61
+ expect(discord.messages.map((item) => item.content.length)).toEqual([2000, 1, 5])
62
+ expect(discord.deletes).toEqual([])
63
+ })
64
+ })
package/src/Schema.ts CHANGED
@@ -66,8 +66,9 @@ export type ToolRequest = {
66
66
  export type ToolResponse = { readonly ok: true; readonly result: unknown } | { readonly ok: false; readonly error: string }
67
67
 
68
68
  export type OpencodeEvent =
69
- | { readonly type: "text-delta"; readonly text: string }
70
- | { readonly type: "text-snapshot"; readonly text: string }
69
+ | { readonly type: "text-delta"; readonly id?: string; readonly text: string }
70
+ | { readonly type: "text-snapshot"; readonly id?: string; readonly text: string }
71
+ | { readonly type: "reasoning-start" }
71
72
  | { readonly type: "tool-start"; readonly title: string }
72
73
  | { readonly type: "tool-end" }
73
74
  | { readonly type: "changed-files"; readonly files: number; readonly insertions: number; readonly deletions: number }
@@ -0,0 +1,44 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+
3
+ const loopbackToolUrl = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__/tool"
4
+
5
+ export default tool({
6
+ description: "Perform safe Discord bridge actions through the local opencode-discord-bot process.",
7
+ args: {
8
+ action: tool.schema
9
+ .enum([
10
+ "followUpMessage",
11
+ "addReaction",
12
+ "removeReaction",
13
+ "fetchHistory",
14
+ "attachFile",
15
+ "createThread",
16
+ "editOwnMessage",
17
+ "deleteOwnMessage",
18
+ "postOtherChannel",
19
+ "pin",
20
+ "unpin"
21
+ ])
22
+ .describe("Discord bridge action to perform."),
23
+ target: tool.schema
24
+ .object({
25
+ guildId: tool.schema.string().optional(),
26
+ channelId: tool.schema.string().optional(),
27
+ threadId: tool.schema.string().optional(),
28
+ messageId: tool.schema.string().optional()
29
+ })
30
+ .describe("Discord target for the action."),
31
+ args: tool.schema
32
+ .record(tool.schema.string(), tool.schema.unknown())
33
+ .describe("Action-specific arguments, such as content, emoji, limit, path, or name.")
34
+ },
35
+ async execute(request) {
36
+ const response = await fetch(loopbackToolUrl, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify(request)
40
+ })
41
+ const payload: unknown = await response.json()
42
+ return JSON.stringify(payload, null, 2) ?? String(payload)
43
+ }
44
+ })
@@ -5,15 +5,24 @@ import { join } from "node:path"
5
5
  import { ensureDiscordTools, renderDiscordToolFile } from "./Scaffolding.ts"
6
6
 
7
7
  describe("Discord tool scaffolding", () => {
8
- test("renders a generated tool file with the injected loopback URL", () => {
9
- const source = renderDiscordToolFile("http://127.0.0.1:8787")
8
+ test("renders a generated opencode tool file with the injected loopback URL", async () => {
9
+ const source = await renderDiscordToolFile("http://127.0.0.1:8787")
10
10
 
11
11
  expect(source).toContain("Generated by opencode-discord-bot")
12
12
  expect(source).toContain("http://127.0.0.1:8787/tool")
13
+ expect(source).toContain('import { tool } from "@opencode-ai/plugin"')
14
+ expect(source).toContain("export default tool({")
15
+ expect(source).toContain("args: {")
16
+ expect(source).toContain(".enum([")
17
+ expect(source).toContain('"fetchHistory"')
18
+ expect(source).toContain('"addReaction"')
19
+ expect(source).toContain(".record(tool.schema.string(), tool.schema.unknown())")
13
20
  expect(source).toContain("fetch")
21
+ expect(source).not.toContain("export const parameters")
22
+ expect(source).not.toContain("__OPENCODE_DISCORD_BOT_LOOPBACK_URL__")
14
23
  })
15
24
 
16
- test("creates and refreshes only generated files", async () => {
25
+ test("creates and regenerates the tool file on every run", async () => {
17
26
  const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tools-"))
18
27
 
19
28
  try {
@@ -28,16 +37,18 @@ describe("Discord tool scaffolding", () => {
28
37
  expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:9999/tool")
29
38
 
30
39
  await writeFile(toolPath, "// operator file\n")
31
- const skipped = await ensureDiscordTools({ projectDir, bridgePort: 7777, enabled: true, autoInstall: true })
40
+ const regenerated = await ensureDiscordTools({ projectDir, bridgePort: 7777, enabled: true, autoInstall: true })
32
41
 
33
- expect(skipped).toEqual([])
34
- expect(await readFile(toolPath, "utf8")).toBe("// operator file\n")
42
+ expect(regenerated).toEqual([toolPath])
43
+ expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:7777/tool")
44
+ expect(await readFile(toolPath, "utf8")).not.toContain("// operator file")
35
45
 
36
- await writeFile(toolPath, `${renderDiscordToolFile("http://127.0.0.1:9999")}\n// operator edit\n`)
37
- const edited = await ensureDiscordTools({ projectDir, bridgePort: 6666, enabled: true, autoInstall: true })
46
+ await writeFile(toolPath, "// Generated by opencode-discord-bot. DO NOT EDIT.\nexport const parameters = {}\n")
47
+ const migrated = await ensureDiscordTools({ projectDir, bridgePort: 6666, enabled: true, autoInstall: true })
38
48
 
39
- expect(edited).toEqual([])
40
- expect(await readFile(toolPath, "utf8")).toContain("// operator edit")
49
+ expect(migrated).toEqual([toolPath])
50
+ expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:6666/tool")
51
+ expect(await readFile(toolPath, "utf8")).not.toContain("export const parameters")
41
52
  } finally {
42
53
  await rm(projectDir, { recursive: true, force: true })
43
54
  }
@@ -2,7 +2,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
3
 
4
4
  const header = "// Generated by opencode-discord-bot. DO NOT EDIT."
5
- const loopbackUrlPattern = /const loopbackToolUrl = "(http:\/\/127\.0\.0\.1:\d+)\/tool"/
5
+ const loopbackUrlPlaceholder = "__OPENCODE_DISCORD_BOT_LOOPBACK_URL__"
6
+ const discordToolSourceUrl = new URL("./DiscordBridgeTool.ts", import.meta.url)
6
7
 
7
8
  export type ToolScaffoldOptions = {
8
9
  readonly projectDir: string
@@ -11,50 +12,18 @@ export type ToolScaffoldOptions = {
11
12
  readonly autoInstall: boolean
12
13
  }
13
14
 
14
- export const renderDiscordToolFile = (loopbackUrl: string): string => `${header}
15
- const loopbackToolUrl = "${loopbackUrl}/tool"
16
-
17
- export const description = "Perform safe Discord bridge actions through the local opencode-discord-bot process."
18
-
19
- export const parameters = {
20
- type: "object",
21
- properties: {
22
- action: { type: "string" },
23
- target: { type: "object" },
24
- args: { type: "object" }
25
- },
26
- required: ["action", "target", "args"]
27
- }
28
-
29
- export async function execute(input: { action: string; target: object; args: object }) {
30
- const response = await fetch(loopbackToolUrl, {
31
- method: "POST",
32
- headers: { "content-type": "application/json" },
33
- body: JSON.stringify(input)
34
- })
35
- return await response.json()
15
+ export const renderDiscordToolFile = async (loopbackUrl: string): Promise<string> => {
16
+ const source = await readFile(discordToolSourceUrl, "utf8")
17
+ return `${header}\n${source.replaceAll(loopbackUrlPlaceholder, loopbackUrl)}`
36
18
  }
37
- `
38
-
39
- const generatedLoopbackUrl = (source: string): string | undefined => loopbackUrlPattern.exec(source)?.[1]
40
19
 
41
20
  export const ensureDiscordTools = async (options: ToolScaffoldOptions): Promise<ReadonlyArray<string>> => {
42
21
  if (!options.enabled || !options.autoInstall) return []
43
22
  const toolsDir = join(options.projectDir, ".opencode", "tools")
44
23
  const toolPath = join(toolsDir, "discord-bridge.ts")
45
- const next = renderDiscordToolFile(`http://127.0.0.1:${options.bridgePort}`)
24
+ const next = await renderDiscordToolFile(`http://127.0.0.1:${options.bridgePort}`)
46
25
 
47
26
  await mkdir(toolsDir, { recursive: true })
48
- try {
49
- const current = await readFile(toolPath, "utf8")
50
- if (!current.startsWith(header)) return []
51
- if (current === next) return [toolPath]
52
- const currentLoopbackUrl = generatedLoopbackUrl(current)
53
- if (currentLoopbackUrl === undefined || current !== renderDiscordToolFile(currentLoopbackUrl)) return []
54
- } catch (error) {
55
- if (!(error instanceof Error) || !Reflect.has(error, "code")) throw error
56
- }
57
-
58
27
  await writeFile(toolPath, next)
59
28
  return [toolPath]
60
29
  }