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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +78 -0
  4. package/src/Bridge/LoopbackServer.test.ts +94 -0
  5. package/src/Bridge/LoopbackServer.ts +77 -0
  6. package/src/Bridge/ToolControl.test.ts +245 -0
  7. package/src/Bridge/ToolControl.ts +260 -0
  8. package/src/Bridge/ToolControlEdges.test.ts +49 -0
  9. package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
  10. package/src/Config.test.ts +142 -0
  11. package/src/Config.ts +295 -0
  12. package/src/ConfigSchema.ts +46 -0
  13. package/src/ConfigTypes.ts +11 -0
  14. package/src/Discord/ChatSdkDiscord.test.ts +257 -0
  15. package/src/Discord/ChatSdkDiscord.ts +206 -0
  16. package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
  17. package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
  18. package/src/Discord/DiscordGateway.test.ts +215 -0
  19. package/src/Discord/DiscordGateway.ts +140 -0
  20. package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
  21. package/src/Discord/DiscordJsDiscord.test.ts +208 -0
  22. package/src/Discord/DiscordJsDiscord.ts +267 -0
  23. package/src/Discord/DiscordPort.ts +30 -0
  24. package/src/Discord/MemoryDiscord.test.ts +44 -0
  25. package/src/Discord/MemoryDiscord.ts +85 -0
  26. package/src/Discord/Safety.ts +11 -0
  27. package/src/Main.test.ts +273 -0
  28. package/src/Main.ts +192 -0
  29. package/src/MainQueue.test.ts +124 -0
  30. package/src/Opencode/EventMapping.test.ts +188 -0
  31. package/src/Opencode/EventMapping.ts +232 -0
  32. package/src/Opencode/EventMappingState.ts +97 -0
  33. package/src/Opencode/MemoryOpencode.test.ts +18 -0
  34. package/src/Opencode/MemoryOpencode.ts +29 -0
  35. package/src/Opencode/OpencodePort.ts +30 -0
  36. package/src/Opencode/PromptParts.ts +47 -0
  37. package/src/Opencode/SdkOpencode.test.ts +280 -0
  38. package/src/Opencode/SdkOpencode.ts +270 -0
  39. package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
  40. package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
  41. package/src/Orchestrator/ContextAssembly.test.ts +115 -0
  42. package/src/Orchestrator/ContextAssembly.ts +120 -0
  43. package/src/Orchestrator/Orchestrator.ts +67 -0
  44. package/src/Orchestrator/StopCommand.test.ts +20 -0
  45. package/src/Orchestrator/StopCommand.ts +14 -0
  46. package/src/Orchestrator/Triggering.test.ts +56 -0
  47. package/src/Orchestrator/Triggering.ts +26 -0
  48. package/src/Orchestrator/TurnManager.test.ts +180 -0
  49. package/src/Orchestrator/TurnManager.ts +179 -0
  50. package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
  51. package/src/PublicContracts.test.ts +43 -0
  52. package/src/Render/Renderer.test.ts +249 -0
  53. package/src/Render/Renderer.ts +159 -0
  54. package/src/Render/Splitting.test.ts +30 -0
  55. package/src/Render/Splitting.ts +68 -0
  56. package/src/Schema.ts +93 -0
  57. package/src/Tools/Scaffolding.test.ts +56 -0
  58. 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
+ })