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 +2 -1
- package/src/Main.test.ts +9 -3
- package/src/Opencode/EventMapping.test.ts +42 -1
- package/src/Opencode/EventMapping.ts +42 -8
- package/src/Render/Renderer.ts +62 -19
- package/src/Render/RendererSegments.test.ts +64 -0
- package/src/Schema.ts +3 -2
- package/src/Tools/DiscordBridgeTool.ts +44 -0
- package/src/Tools/Scaffolding.test.ts +21 -10
- package/src/Tools/Scaffolding.ts +6 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-discord-bot",
|
|
3
|
-
"version": "0.0.
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/Render/Renderer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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* (
|
|
76
|
-
|
|
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
|
|
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*
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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*
|
|
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
|
|
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
|
|
40
|
+
const regenerated = await ensureDiscordTools({ projectDir, bridgePort: 7777, enabled: true, autoInstall: true })
|
|
32
41
|
|
|
33
|
-
expect(
|
|
34
|
-
expect(await readFile(toolPath, "utf8")).
|
|
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,
|
|
37
|
-
const
|
|
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(
|
|
40
|
-
expect(await readFile(toolPath, "utf8")).toContain("
|
|
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
|
}
|
package/src/Tools/Scaffolding.ts
CHANGED
|
@@ -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
|
|
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 =>
|
|
15
|
-
const
|
|
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
|
}
|