opencode-discord-bot 0.0.1
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/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +78 -0
- package/src/Bridge/LoopbackServer.test.ts +94 -0
- package/src/Bridge/LoopbackServer.ts +77 -0
- package/src/Bridge/ToolControl.test.ts +245 -0
- package/src/Bridge/ToolControl.ts +260 -0
- package/src/Bridge/ToolControlEdges.test.ts +49 -0
- package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
- package/src/Config.test.ts +142 -0
- package/src/Config.ts +295 -0
- package/src/ConfigSchema.ts +46 -0
- package/src/ConfigTypes.ts +11 -0
- package/src/Discord/ChatSdkDiscord.test.ts +257 -0
- package/src/Discord/ChatSdkDiscord.ts +206 -0
- package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
- package/src/Discord/DiscordGateway.test.ts +215 -0
- package/src/Discord/DiscordGateway.ts +140 -0
- package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
- package/src/Discord/DiscordJsDiscord.test.ts +208 -0
- package/src/Discord/DiscordJsDiscord.ts +267 -0
- package/src/Discord/DiscordPort.ts +30 -0
- package/src/Discord/MemoryDiscord.test.ts +44 -0
- package/src/Discord/MemoryDiscord.ts +85 -0
- package/src/Discord/Safety.ts +11 -0
- package/src/Main.test.ts +273 -0
- package/src/Main.ts +192 -0
- package/src/MainQueue.test.ts +124 -0
- package/src/Opencode/EventMapping.test.ts +188 -0
- package/src/Opencode/EventMapping.ts +232 -0
- package/src/Opencode/EventMappingState.ts +97 -0
- package/src/Opencode/MemoryOpencode.test.ts +18 -0
- package/src/Opencode/MemoryOpencode.ts +29 -0
- package/src/Opencode/OpencodePort.ts +30 -0
- package/src/Opencode/PromptParts.ts +47 -0
- package/src/Opencode/SdkOpencode.test.ts +280 -0
- package/src/Opencode/SdkOpencode.ts +270 -0
- package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
- package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
- package/src/Orchestrator/ContextAssembly.test.ts +115 -0
- package/src/Orchestrator/ContextAssembly.ts +120 -0
- package/src/Orchestrator/Orchestrator.ts +67 -0
- package/src/Orchestrator/StopCommand.test.ts +20 -0
- package/src/Orchestrator/StopCommand.ts +14 -0
- package/src/Orchestrator/Triggering.test.ts +56 -0
- package/src/Orchestrator/Triggering.ts +26 -0
- package/src/Orchestrator/TurnManager.test.ts +180 -0
- package/src/Orchestrator/TurnManager.ts +179 -0
- package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
- package/src/PublicContracts.test.ts +43 -0
- package/src/Render/Renderer.test.ts +249 -0
- package/src/Render/Renderer.ts +159 -0
- package/src/Render/Splitting.test.ts +30 -0
- package/src/Render/Splitting.ts +68 -0
- package/src/Schema.ts +93 -0
- package/src/Tools/Scaffolding.test.ts +56 -0
- package/src/Tools/Scaffolding.ts +60 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Duration, Effect, Stream } from "effect"
|
|
3
|
+
import { defaultConfig } from "../Config.ts"
|
|
4
|
+
import { DiscordError, type DiscordService } from "../Discord/DiscordPort.ts"
|
|
5
|
+
import { makeMemoryDiscord } from "../Discord/MemoryDiscord.ts"
|
|
6
|
+
import { opencodeEventStream } from "../Opencode/EventMapping.ts"
|
|
7
|
+
import { OpencodeError } from "../Opencode/OpencodePort.ts"
|
|
8
|
+
import type { DiscordScope } from "../Schema.ts"
|
|
9
|
+
import { renderOpencodeEvents } from "./Renderer.ts"
|
|
10
|
+
|
|
11
|
+
const scope: DiscordScope = { guildId: "g1", channelId: "c1" }
|
|
12
|
+
|
|
13
|
+
describe("renderOpencodeEvents", () => {
|
|
14
|
+
test("renders snapshots, tool typing, and changed-file summaries", async () => {
|
|
15
|
+
const discord = makeMemoryDiscord()
|
|
16
|
+
|
|
17
|
+
await Effect.runPromise(
|
|
18
|
+
renderOpencodeEvents(
|
|
19
|
+
Stream.fromIterable([
|
|
20
|
+
{ type: "tool-start", title: "Running tests" },
|
|
21
|
+
{ type: "text-snapshot", text: "Done" },
|
|
22
|
+
{ type: "changed-files", files: 3, insertions: 42, deletions: 7 },
|
|
23
|
+
{ type: "tool-end" },
|
|
24
|
+
{ type: "idle" }
|
|
25
|
+
]),
|
|
26
|
+
scope,
|
|
27
|
+
defaultConfig,
|
|
28
|
+
discord
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(discord.typingScopes).toEqual([scope])
|
|
33
|
+
expect(discord.messages).toEqual([
|
|
34
|
+
{ scope, content: "Running tests..." },
|
|
35
|
+
{ scope, content: "Done" }
|
|
36
|
+
])
|
|
37
|
+
expect(discord.edits).toEqual([
|
|
38
|
+
{ scope, messageId: "posted-2", content: "Done\n\nChanged: 3 files (+42/-7)" },
|
|
39
|
+
{ scope, messageId: "posted-1", content: "Tool finished." }
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("streams text by posting once and editing as deltas arrive", async () => {
|
|
44
|
+
const discord = makeMemoryDiscord()
|
|
45
|
+
|
|
46
|
+
await Effect.runPromise(
|
|
47
|
+
renderOpencodeEvents(
|
|
48
|
+
Stream.fromIterable([{ type: "text-delta", text: "Hel" }, { type: "text-delta", text: "lo" }, { type: "idle" }]),
|
|
49
|
+
scope,
|
|
50
|
+
defaultConfig,
|
|
51
|
+
discord
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(discord.typingScopes).toEqual([scope])
|
|
56
|
+
expect(discord.messages).toEqual([{ scope, content: "Hel" }])
|
|
57
|
+
expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "Hello" }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("refreshes the final answer when completion stops an active typing phase", async () => {
|
|
61
|
+
const discord = makeMemoryDiscord()
|
|
62
|
+
const config = { ...defaultConfig, streaming: { ...defaultConfig.streaming, showToolStatus: false } }
|
|
63
|
+
|
|
64
|
+
await Effect.runPromise(
|
|
65
|
+
renderOpencodeEvents(
|
|
66
|
+
Stream.fromIterable([{ type: "text-delta", text: "Done" }, { type: "tool-start", title: "Checking" }, { type: "idle" }]),
|
|
67
|
+
scope,
|
|
68
|
+
config,
|
|
69
|
+
discord
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
expect(discord.typingScopes).toEqual([scope, scope])
|
|
74
|
+
expect(discord.messages).toEqual([{ scope, content: "Done" }])
|
|
75
|
+
expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "Done" }])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("starts typing before the first stream event arrives", async () => {
|
|
79
|
+
const discord = makeMemoryDiscord()
|
|
80
|
+
let releaseStream: (() => void) | undefined
|
|
81
|
+
const waiting = new Promise<void>((resolve) => {
|
|
82
|
+
releaseStream = resolve
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const running = Effect.runPromise(
|
|
86
|
+
renderOpencodeEvents(
|
|
87
|
+
Stream.fromAsyncIterable(
|
|
88
|
+
(async function* () {
|
|
89
|
+
await waiting
|
|
90
|
+
yield { type: "text-delta" as const, text: "Hello" }
|
|
91
|
+
yield { type: "idle" as const }
|
|
92
|
+
})(),
|
|
93
|
+
() => new OpencodeError({ message: "stream failed" })
|
|
94
|
+
),
|
|
95
|
+
scope,
|
|
96
|
+
defaultConfig,
|
|
97
|
+
discord
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
102
|
+
expect(discord.typingScopes).toEqual([scope])
|
|
103
|
+
expect(discord.messages).toEqual([])
|
|
104
|
+
|
|
105
|
+
releaseStream?.()
|
|
106
|
+
await running
|
|
107
|
+
|
|
108
|
+
expect(discord.messages).toEqual([{ scope, content: "Hello" }])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("posts continuation messages when streamed content exceeds the Discord limit", async () => {
|
|
112
|
+
const discord = makeMemoryDiscord()
|
|
113
|
+
|
|
114
|
+
await Effect.runPromise(
|
|
115
|
+
renderOpencodeEvents(Stream.fromIterable([{ type: "text-delta", text: "a".repeat(2001) }]), scope, defaultConfig, discord)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
expect(discord.messages.map((item) => item.content.length)).toEqual([2000, 1])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("deletes stale continuation messages when a later snapshot is shorter", async () => {
|
|
122
|
+
const discord = makeMemoryDiscord()
|
|
123
|
+
|
|
124
|
+
await Effect.runPromise(
|
|
125
|
+
renderOpencodeEvents(
|
|
126
|
+
Stream.fromIterable([
|
|
127
|
+
{ type: "text-delta", text: "a".repeat(2001) },
|
|
128
|
+
{ type: "text-snapshot", text: "short" }
|
|
129
|
+
]),
|
|
130
|
+
scope,
|
|
131
|
+
defaultConfig,
|
|
132
|
+
discord
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
expect(discord.messages.map((item) => item.content.length)).toEqual([2000, 1])
|
|
137
|
+
expect(discord.edits).toEqual([{ scope, messageId: "posted-1", content: "short" }])
|
|
138
|
+
expect(discord.deletes).toEqual([{ scope, messageId: "posted-2" }])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("does not post the Discord context prompt from user message part events", async () => {
|
|
142
|
+
const discord = makeMemoryDiscord()
|
|
143
|
+
|
|
144
|
+
await Effect.runPromise(
|
|
145
|
+
renderOpencodeEvents(
|
|
146
|
+
opencodeEventStream(
|
|
147
|
+
Stream.fromIterable([
|
|
148
|
+
{ type: "message.updated", properties: { sessionID: "s1", info: { id: "user-message", role: "user" } } },
|
|
149
|
+
{
|
|
150
|
+
type: "message.part.updated",
|
|
151
|
+
properties: {
|
|
152
|
+
sessionID: "s1",
|
|
153
|
+
part: { sessionID: "s1", messageID: "user-message", type: "text", text: "Discord bridge context" }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{ type: "session.next.text.ended", properties: { sessionID: "s1", text: "Normal answer" } },
|
|
157
|
+
{ type: "session.idle", properties: { sessionID: "s1" } }
|
|
158
|
+
])
|
|
159
|
+
),
|
|
160
|
+
scope,
|
|
161
|
+
defaultConfig,
|
|
162
|
+
discord
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(discord.messages).toEqual([{ scope, content: "Normal answer" }])
|
|
167
|
+
expect(discord.edits).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe("renderOpencodeEvents guards", () => {
|
|
172
|
+
test("can suppress changed-file summaries", async () => {
|
|
173
|
+
const discord = makeMemoryDiscord()
|
|
174
|
+
const config = { ...defaultConfig, streaming: { ...defaultConfig.streaming, changedFilesSummary: false } }
|
|
175
|
+
|
|
176
|
+
await Effect.runPromise(
|
|
177
|
+
renderOpencodeEvents(
|
|
178
|
+
Stream.fromIterable([
|
|
179
|
+
{ type: "text-delta", text: "Done" },
|
|
180
|
+
{ type: "changed-files", files: 1, insertions: 1, deletions: 0 }
|
|
181
|
+
]),
|
|
182
|
+
scope,
|
|
183
|
+
config,
|
|
184
|
+
discord
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(discord.messages).toEqual([{ scope, content: "Done" }])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test("omits changed-file summaries when nothing changed", async () => {
|
|
192
|
+
const discord = makeMemoryDiscord()
|
|
193
|
+
|
|
194
|
+
await Effect.runPromise(
|
|
195
|
+
renderOpencodeEvents(
|
|
196
|
+
Stream.fromIterable([
|
|
197
|
+
{ type: "text-delta", text: "Done" },
|
|
198
|
+
{ type: "changed-files", files: 0, insertions: 0, deletions: 0 },
|
|
199
|
+
{ type: "idle" }
|
|
200
|
+
]),
|
|
201
|
+
scope,
|
|
202
|
+
defaultConfig,
|
|
203
|
+
discord
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expect(discord.messages).toEqual([{ scope, content: "Done" }])
|
|
208
|
+
expect(discord.edits).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("neutralizes mass mentions by default", async () => {
|
|
212
|
+
const discord = makeMemoryDiscord()
|
|
213
|
+
|
|
214
|
+
await Effect.runPromise(
|
|
215
|
+
renderOpencodeEvents(Stream.fromIterable([{ type: "text-delta", text: "@everyone @here <@&123>" }]), scope, defaultConfig, discord)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
expect(discord.messages).toEqual([{ scope, content: "@ everyone @ here <@& 123>" }])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("honors Discord retry-after metadata when retrying output", async () => {
|
|
222
|
+
const memory = makeMemoryDiscord()
|
|
223
|
+
let attempts = 0
|
|
224
|
+
const discord: DiscordService = {
|
|
225
|
+
...memory,
|
|
226
|
+
postMessage: (target, content) =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
attempts += 1
|
|
229
|
+
if (attempts === 1) return yield* Effect.fail(new DiscordError({ message: "rate limited", retryAfter: Duration.millis(0) }))
|
|
230
|
+
return yield* memory.postMessage(target, content)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await Effect.runPromise(
|
|
235
|
+
renderOpencodeEvents(Stream.fromIterable([{ type: "text-delta", text: "hello" }]), scope, defaultConfig, discord)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
expect(attempts).toBe(2)
|
|
239
|
+
expect(memory.messages).toEqual([{ scope, content: "hello" }])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("fails on opencode error events", async () => {
|
|
243
|
+
await expect(
|
|
244
|
+
renderOpencodeEvents(Stream.fromIterable([{ type: "error", message: "boom" }]), scope, defaultConfig, makeMemoryDiscord()).pipe(
|
|
245
|
+
Effect.runPromise
|
|
246
|
+
)
|
|
247
|
+
).rejects.toMatchObject({ _tag: "OpencodeError", message: "boom" })
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Cause, Clock, Duration, Effect, Fiber, Schedule, Stream } from "effect"
|
|
2
|
+
import type { RuntimeConfig } from "../Config.ts"
|
|
3
|
+
import type { DiscordError, DiscordService } from "../Discord/DiscordPort.ts"
|
|
4
|
+
import { sanitizeDiscordContent } from "../Discord/Safety.ts"
|
|
5
|
+
import { OpencodeError } from "../Opencode/OpencodePort.ts"
|
|
6
|
+
import type { DiscordScope, OpencodeEvent } from "../Schema.ts"
|
|
7
|
+
import { splitDiscordMarkdown } from "./Splitting.ts"
|
|
8
|
+
|
|
9
|
+
const changedSummary = (event: Extract<OpencodeEvent, { readonly type: "changed-files" }>) =>
|
|
10
|
+
`Changed: ${event.files} files (+${event.insertions}/-${event.deletions})`
|
|
11
|
+
|
|
12
|
+
const hasChangedFiles = (event: Extract<OpencodeEvent, { readonly type: "changed-files" }>): boolean =>
|
|
13
|
+
event.files > 0 || event.insertions > 0 || event.deletions > 0
|
|
14
|
+
|
|
15
|
+
type PostedChunk = {
|
|
16
|
+
readonly id: string
|
|
17
|
+
content: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const discordRetrySchedule = Schedule.fromStepWithMetadata(
|
|
21
|
+
Effect.succeed((metadata: Schedule.InputMetadata<DiscordError>) => {
|
|
22
|
+
if (metadata.attempt > 2) return Cause.done(metadata.attempt)
|
|
23
|
+
const fallback = Duration.millis(250 * 2 ** (metadata.attempt - 1))
|
|
24
|
+
return Effect.succeed([metadata.attempt, metadata.input.retryAfter ?? fallback] as [number, Duration.Duration])
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const retryDiscord = <A, R>(effect: Effect.Effect<A, DiscordError, R>): Effect.Effect<A, DiscordError, R> =>
|
|
29
|
+
effect.pipe(Effect.retry(discordRetrySchedule))
|
|
30
|
+
|
|
31
|
+
export const renderOpencodeEvents = Effect.fn("renderOpencodeEvents")(function* (
|
|
32
|
+
events: Stream.Stream<OpencodeEvent, OpencodeError>,
|
|
33
|
+
scope: DiscordScope,
|
|
34
|
+
config: RuntimeConfig,
|
|
35
|
+
discord: DiscordService
|
|
36
|
+
) {
|
|
37
|
+
let answer = ""
|
|
38
|
+
let changed: string | undefined
|
|
39
|
+
const posted: Array<PostedChunk> = []
|
|
40
|
+
const updateIntervalMs = Math.max(0, Duration.toMillis(config.streaming.updateInterval))
|
|
41
|
+
let lastFlushAt = Number.NEGATIVE_INFINITY
|
|
42
|
+
let typingFiber: Fiber.Fiber<void, never> | undefined
|
|
43
|
+
let status: PostedChunk | undefined
|
|
44
|
+
let finished = false
|
|
45
|
+
|
|
46
|
+
const visibleContent = () => sanitizeDiscordContent(changed === undefined ? answer : `${answer}\n\n${changed}`.trim(), config.guards)
|
|
47
|
+
const startTyping = Effect.fn("startDiscordTyping")(function* () {
|
|
48
|
+
if (typingFiber !== undefined) return
|
|
49
|
+
yield* retryDiscord(discord.sendTyping(scope)).pipe(Effect.catch(() => Effect.void))
|
|
50
|
+
typingFiber = yield* Effect.forever(
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
yield* Effect.sleep(Duration.seconds(8))
|
|
53
|
+
yield* retryDiscord(discord.sendTyping(scope)).pipe(Effect.catch(() => Effect.void))
|
|
54
|
+
})
|
|
55
|
+
).pipe(Effect.forkChild({ startImmediately: true }))
|
|
56
|
+
})
|
|
57
|
+
const stopTyping = Effect.fn("stopDiscordTyping")(function* () {
|
|
58
|
+
const fiber = typingFiber
|
|
59
|
+
typingFiber = undefined
|
|
60
|
+
if (fiber === undefined) return false
|
|
61
|
+
yield* Fiber.interrupt(fiber).pipe(Effect.catch(() => Effect.void))
|
|
62
|
+
return true
|
|
63
|
+
})
|
|
64
|
+
const renderStatus = Effect.fn("renderDiscordToolStatus")(function* (content: string) {
|
|
65
|
+
if (!config.streaming.showToolStatus) return
|
|
66
|
+
const safe = sanitizeDiscordContent(content, config.guards)
|
|
67
|
+
if (status === undefined) {
|
|
68
|
+
const created = yield* retryDiscord(discord.postMessage(scope, safe))
|
|
69
|
+
status = { id: created.id, content: safe }
|
|
70
|
+
} else if (status.content !== safe) {
|
|
71
|
+
yield* retryDiscord(discord.editMessage(scope, status.id, safe))
|
|
72
|
+
status.content = safe
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
const writeChunk = Effect.fn("writeDiscordRenderChunk")(function* (index: number, chunk: string, forceEdit = false) {
|
|
76
|
+
const existing = posted[index]
|
|
77
|
+
if (existing === undefined) {
|
|
78
|
+
const created = yield* retryDiscord(discord.postMessage(scope, chunk))
|
|
79
|
+
posted.push({ id: created.id, content: chunk })
|
|
80
|
+
} else if (forceEdit || existing.content !== chunk) {
|
|
81
|
+
yield* retryDiscord(discord.editMessage(scope, existing.id, chunk))
|
|
82
|
+
existing.content = chunk
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
const flush = Effect.fn("flushDiscordRender")(function* (force = false, forceEdit = false) {
|
|
86
|
+
const chunks = splitDiscordMarkdown(visibleContent())
|
|
87
|
+
if (chunks.length === 0) return
|
|
88
|
+
const now = yield* Clock.currentTimeMillis
|
|
89
|
+
if (!force && posted.length > 0 && now - lastFlushAt < updateIntervalMs) return
|
|
90
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
91
|
+
const chunk = chunks[index]
|
|
92
|
+
if (chunk === undefined) continue
|
|
93
|
+
yield* writeChunk(index, chunk, forceEdit)
|
|
94
|
+
}
|
|
95
|
+
for (let index = posted.length - 1; index >= chunks.length; index -= 1) {
|
|
96
|
+
const stale = posted[index]
|
|
97
|
+
if (stale === undefined) continue
|
|
98
|
+
yield* retryDiscord(discord.deleteMessage(scope, stale.id))
|
|
99
|
+
posted.splice(index, 1)
|
|
100
|
+
}
|
|
101
|
+
lastFlushAt = now
|
|
102
|
+
})
|
|
103
|
+
const finish = Effect.fn("finishDiscordRender")(function* () {
|
|
104
|
+
if (finished) return
|
|
105
|
+
finished = true
|
|
106
|
+
const wasTyping = yield* stopTyping()
|
|
107
|
+
yield* flush(true, wasTyping)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
yield* startTyping()
|
|
111
|
+
yield* events
|
|
112
|
+
.pipe(
|
|
113
|
+
Stream.runForEach((event) =>
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
switch (event.type) {
|
|
116
|
+
case "tool-start": {
|
|
117
|
+
yield* startTyping()
|
|
118
|
+
yield* renderStatus(`${event.title}...`)
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
case "tool-end": {
|
|
122
|
+
yield* renderStatus("Tool finished.")
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
case "idle": {
|
|
126
|
+
yield* finish()
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case "text-delta": {
|
|
130
|
+
yield* stopTyping()
|
|
131
|
+
answer += event.text
|
|
132
|
+
yield* flush(posted.length === 0)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case "text-snapshot": {
|
|
136
|
+
yield* stopTyping()
|
|
137
|
+
answer = event.text
|
|
138
|
+
yield* flush(true)
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
case "changed-files": {
|
|
142
|
+
if (config.streaming.changedFilesSummary && hasChangedFiles(event)) {
|
|
143
|
+
changed = changedSummary(event)
|
|
144
|
+
yield* flush(true)
|
|
145
|
+
}
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
case "error": {
|
|
149
|
+
yield* stopTyping()
|
|
150
|
+
return yield* Effect.fail(new OpencodeError({ message: event.message }))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
.pipe(Effect.ensuring(stopTyping()))
|
|
157
|
+
|
|
158
|
+
yield* finish()
|
|
159
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { splitDiscordMarkdown } from "./Splitting.ts"
|
|
3
|
+
|
|
4
|
+
describe("splitDiscordMarkdown", () => {
|
|
5
|
+
test("returns no chunks for empty output", () => {
|
|
6
|
+
expect(splitDiscordMarkdown("")).toEqual([])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test("hard-splits a single oversized line", () => {
|
|
10
|
+
expect(splitDiscordMarkdown("x".repeat(25), 10)).toEqual(["x".repeat(10), "x".repeat(10), "x".repeat(5)])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("keeps every chunk within Discord's 2000 character limit", () => {
|
|
14
|
+
const chunks = splitDiscordMarkdown(`${"a".repeat(1990)}\n${"b".repeat(1990)}`)
|
|
15
|
+
|
|
16
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
17
|
+
expect(chunks.every((chunk) => chunk.length <= 2000)).toBe(true)
|
|
18
|
+
expect(chunks.join("\n").replaceAll("\n", "")).toContain("a".repeat(100))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("preserves code fences across continuation messages", () => {
|
|
22
|
+
const source = `before\n\`\`\`ts\n${"const value = 1\n".repeat(180)}\`\`\`\nafter`
|
|
23
|
+
const chunks = splitDiscordMarkdown(source)
|
|
24
|
+
|
|
25
|
+
expect(chunks.length).toBeGreaterThan(1)
|
|
26
|
+
expect(chunks.every((chunk) => chunk.length <= 2000)).toBe(true)
|
|
27
|
+
expect(chunks.at(0)?.endsWith("\n```")).toBe(true)
|
|
28
|
+
expect(chunks.at(1)?.startsWith("```ts\n")).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fenceStart = (line: string): string | undefined => {
|
|
2
|
+
const match = /^```([^`]*)\s*$/.exec(line.trim())
|
|
3
|
+
return match === null ? undefined : match[1]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const hardSplit = (text: string, limit: number): ReadonlyArray<string> => {
|
|
7
|
+
const chunks: Array<string> = []
|
|
8
|
+
for (let index = 0; index < text.length; index += limit) {
|
|
9
|
+
chunks.push(text.slice(index, index + limit))
|
|
10
|
+
}
|
|
11
|
+
return chunks
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type SplitState = {
|
|
15
|
+
readonly chunks: Array<string>
|
|
16
|
+
current: string
|
|
17
|
+
openFence: string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const closeChunk = (state: SplitState) => {
|
|
21
|
+
if (state.current.length === 0) return
|
|
22
|
+
const suffix = state.openFence === undefined ? "" : "\n```"
|
|
23
|
+
state.chunks.push(`${state.current}${suffix}`)
|
|
24
|
+
state.current = state.openFence === undefined ? "" : `\`\`\`${state.openFence}\n`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const appendSegment = (state: SplitState, segment: string, limit: number) => {
|
|
28
|
+
const suffixLength = state.openFence === undefined ? 0 : 4
|
|
29
|
+
if (segment.length + suffixLength > limit) {
|
|
30
|
+
closeChunk(state)
|
|
31
|
+
appendOversizedSegment(state, segment, limit - suffixLength, suffixLength)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (state.current.length + segment.length + suffixLength > limit) {
|
|
35
|
+
closeChunk(state)
|
|
36
|
+
state.current += segment.startsWith("\n") && state.current.length === 0 ? segment.slice(1) : segment
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
state.current += segment
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const appendOversizedSegment = (state: SplitState, segment: string, limit: number, suffixLength: number) => {
|
|
43
|
+
for (const piece of hardSplit(segment, limit)) {
|
|
44
|
+
if (state.current.length + piece.length + suffixLength > limit) closeChunk(state)
|
|
45
|
+
state.current += piece
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const updateFence = (state: SplitState, line: string) => {
|
|
50
|
+
const marker = fenceStart(line)
|
|
51
|
+
if (marker !== undefined) state.openFence = state.openFence === undefined ? marker : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const splitDiscordMarkdown = (text: string, limit = 2000): ReadonlyArray<string> => {
|
|
55
|
+
if (text.length <= limit) return text.length === 0 ? [] : [text]
|
|
56
|
+
|
|
57
|
+
const lines = text.split("\n")
|
|
58
|
+
const state: SplitState = { chunks: [], current: "", openFence: undefined }
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
61
|
+
const segment = `${index === 0 ? "" : "\n"}${lines[index]}`
|
|
62
|
+
appendSegment(state, segment, limit)
|
|
63
|
+
updateFence(state, lines[index] ?? "")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (state.current.length > 0) state.chunks.push(state.current)
|
|
67
|
+
return state.chunks
|
|
68
|
+
}
|
package/src/Schema.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
export type Snowflake = string
|
|
4
|
+
|
|
5
|
+
export type DiscordScope = {
|
|
6
|
+
readonly guildId: Snowflake
|
|
7
|
+
readonly channelId: Snowflake
|
|
8
|
+
readonly threadId?: Snowflake
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type BotIdentity = {
|
|
12
|
+
readonly userId: Snowflake
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DiscordAuthor = {
|
|
16
|
+
readonly id: Snowflake
|
|
17
|
+
readonly displayName: string
|
|
18
|
+
readonly nickname?: string
|
|
19
|
+
readonly isBot: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type DiscordAttachment = {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly filename: string
|
|
25
|
+
readonly contentType?: string
|
|
26
|
+
readonly size: number
|
|
27
|
+
readonly url: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DiscordReaction = {
|
|
31
|
+
readonly emoji: string
|
|
32
|
+
readonly count: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DiscordMessage = {
|
|
36
|
+
readonly id: Snowflake
|
|
37
|
+
readonly guildId: Snowflake
|
|
38
|
+
readonly channelId: Snowflake
|
|
39
|
+
readonly threadId?: Snowflake
|
|
40
|
+
readonly author: DiscordAuthor
|
|
41
|
+
readonly content: string
|
|
42
|
+
readonly timestamp: string
|
|
43
|
+
readonly mentions: ReadonlyArray<Snowflake>
|
|
44
|
+
readonly roleMentions: ReadonlyArray<Snowflake>
|
|
45
|
+
readonly everyoneMention: boolean
|
|
46
|
+
readonly hereMention: boolean
|
|
47
|
+
readonly attachments: ReadonlyArray<DiscordAttachment>
|
|
48
|
+
readonly reactions: ReadonlyArray<DiscordReaction>
|
|
49
|
+
readonly channelType: "guild" | "dm"
|
|
50
|
+
readonly isSystem?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ToolTarget = {
|
|
54
|
+
readonly guildId?: string | undefined
|
|
55
|
+
readonly channelId?: string | undefined
|
|
56
|
+
readonly threadId?: string | undefined
|
|
57
|
+
readonly messageId?: string | undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ToolRequest = {
|
|
61
|
+
readonly action: string
|
|
62
|
+
readonly target: ToolTarget
|
|
63
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ToolResponse = { readonly ok: true; readonly result: unknown } | { readonly ok: false; readonly error: string }
|
|
67
|
+
|
|
68
|
+
export type OpencodeEvent =
|
|
69
|
+
| { readonly type: "text-delta"; readonly text: string }
|
|
70
|
+
| { readonly type: "text-snapshot"; readonly text: string }
|
|
71
|
+
| { readonly type: "tool-start"; readonly title: string }
|
|
72
|
+
| { readonly type: "tool-end" }
|
|
73
|
+
| { readonly type: "changed-files"; readonly files: number; readonly insertions: number; readonly deletions: number }
|
|
74
|
+
| { readonly type: "idle" }
|
|
75
|
+
| { readonly type: "error"; readonly message: string }
|
|
76
|
+
|
|
77
|
+
export const ToolTargetSchema = Schema.Struct({
|
|
78
|
+
guildId: Schema.optional(Schema.String),
|
|
79
|
+
channelId: Schema.optional(Schema.String),
|
|
80
|
+
threadId: Schema.optional(Schema.String),
|
|
81
|
+
messageId: Schema.optional(Schema.String)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const ToolRequestSchema = Schema.Struct({
|
|
85
|
+
action: Schema.String,
|
|
86
|
+
target: ToolTargetSchema,
|
|
87
|
+
args: Schema.Record(Schema.String, Schema.Unknown)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export const ToolResponseSchema = Schema.Union([
|
|
91
|
+
Schema.Struct({ ok: Schema.Literal(true), result: Schema.Unknown }),
|
|
92
|
+
Schema.Struct({ ok: Schema.Literal(false), error: Schema.String })
|
|
93
|
+
])
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { ensureDiscordTools, renderDiscordToolFile } from "./Scaffolding.ts"
|
|
6
|
+
|
|
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")
|
|
10
|
+
|
|
11
|
+
expect(source).toContain("Generated by opencode-discord-bot")
|
|
12
|
+
expect(source).toContain("http://127.0.0.1:8787/tool")
|
|
13
|
+
expect(source).toContain("fetch")
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("creates and refreshes only generated files", async () => {
|
|
17
|
+
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tools-"))
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const created = await ensureDiscordTools({ projectDir, bridgePort: 8787, enabled: true, autoInstall: true })
|
|
21
|
+
const toolPath = join(projectDir, ".opencode", "tools", "discord-bridge.ts")
|
|
22
|
+
const first = await readFile(toolPath, "utf8")
|
|
23
|
+
|
|
24
|
+
expect(created).toEqual([toolPath])
|
|
25
|
+
expect(first).toContain("http://127.0.0.1:8787/tool")
|
|
26
|
+
|
|
27
|
+
await ensureDiscordTools({ projectDir, bridgePort: 9999, enabled: true, autoInstall: true })
|
|
28
|
+
expect(await readFile(toolPath, "utf8")).toContain("http://127.0.0.1:9999/tool")
|
|
29
|
+
|
|
30
|
+
await writeFile(toolPath, "// operator file\n")
|
|
31
|
+
const skipped = await ensureDiscordTools({ projectDir, bridgePort: 7777, enabled: true, autoInstall: true })
|
|
32
|
+
|
|
33
|
+
expect(skipped).toEqual([])
|
|
34
|
+
expect(await readFile(toolPath, "utf8")).toBe("// operator file\n")
|
|
35
|
+
|
|
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 })
|
|
38
|
+
|
|
39
|
+
expect(edited).toEqual([])
|
|
40
|
+
expect(await readFile(toolPath, "utf8")).toContain("// operator edit")
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(projectDir, { recursive: true, force: true })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("does nothing when auto install is disabled", async () => {
|
|
47
|
+
const projectDir = await mkdtemp(join(tmpdir(), "ocdb-tools-disabled-"))
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(join(projectDir, ".opencode"), { recursive: true })
|
|
51
|
+
expect(await ensureDiscordTools({ projectDir, bridgePort: 8787, enabled: true, autoInstall: false })).toEqual([])
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(projectDir, { recursive: true, force: true })
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
})
|