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
package/src/Config.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { Data, Duration, Effect, Redacted, Schema } from "effect"
|
|
4
|
+
import { type ParseError, parse } from "jsonc-parser"
|
|
5
|
+
import { RawConfigSchema } from "./ConfigSchema.ts"
|
|
6
|
+
import type { ConfigSources, LoadConfigOptions } from "./ConfigTypes.ts"
|
|
7
|
+
|
|
8
|
+
export type { ConfigSources, LoadConfigOptions } from "./ConfigTypes.ts"
|
|
9
|
+
|
|
10
|
+
export class ConfigError extends Data.TaggedError("ConfigError")<{
|
|
11
|
+
readonly message: string
|
|
12
|
+
}> {}
|
|
13
|
+
|
|
14
|
+
export type ToolConfig = {
|
|
15
|
+
readonly enabled: boolean
|
|
16
|
+
readonly autoInstall: boolean
|
|
17
|
+
readonly reactions: boolean
|
|
18
|
+
readonly attachFiles: boolean
|
|
19
|
+
readonly fetchHistory: boolean
|
|
20
|
+
readonly followUpMessages: boolean
|
|
21
|
+
readonly createThread: boolean
|
|
22
|
+
readonly editDeleteOwn: boolean
|
|
23
|
+
readonly postOtherChannels: boolean
|
|
24
|
+
readonly pin: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RuntimeConfig = {
|
|
28
|
+
readonly discordToken: Redacted.Redacted<string>
|
|
29
|
+
readonly discord: {
|
|
30
|
+
readonly applicationId?: string
|
|
31
|
+
readonly publicKey?: string
|
|
32
|
+
readonly guildId?: string
|
|
33
|
+
}
|
|
34
|
+
readonly opencode: {
|
|
35
|
+
readonly port: number
|
|
36
|
+
readonly baseUrl: string
|
|
37
|
+
readonly projectDir: string
|
|
38
|
+
readonly model?: string
|
|
39
|
+
readonly agent?: string
|
|
40
|
+
}
|
|
41
|
+
readonly bridge: {
|
|
42
|
+
readonly host: "127.0.0.1"
|
|
43
|
+
readonly port: number
|
|
44
|
+
}
|
|
45
|
+
readonly context: {
|
|
46
|
+
readonly messages: number
|
|
47
|
+
readonly maxChars: number
|
|
48
|
+
readonly attachmentMaxBytes: number
|
|
49
|
+
}
|
|
50
|
+
readonly threads: {
|
|
51
|
+
readonly activeByRecentBotParticipation: boolean
|
|
52
|
+
}
|
|
53
|
+
readonly tools: ToolConfig
|
|
54
|
+
readonly streaming: {
|
|
55
|
+
readonly updateInterval: Duration.Duration
|
|
56
|
+
readonly placeholderText: string | null
|
|
57
|
+
readonly showToolStatus: boolean
|
|
58
|
+
readonly changedFilesSummary: boolean
|
|
59
|
+
}
|
|
60
|
+
readonly concurrency: {
|
|
61
|
+
readonly strategy: "queue" | "burst"
|
|
62
|
+
readonly lockScope: "discord-scope"
|
|
63
|
+
readonly globalMaxActiveTurns: number | null
|
|
64
|
+
}
|
|
65
|
+
readonly guards: {
|
|
66
|
+
readonly ignoreBots: boolean
|
|
67
|
+
readonly stripMassMentions: boolean
|
|
68
|
+
readonly redactSecretsInErrors: boolean
|
|
69
|
+
readonly maxTurn: Duration.Duration | null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const defaultConfig: RuntimeConfig = {
|
|
74
|
+
discordToken: Redacted.make(""),
|
|
75
|
+
discord: {},
|
|
76
|
+
opencode: {
|
|
77
|
+
port: 4096,
|
|
78
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
79
|
+
projectDir: process.cwd()
|
|
80
|
+
},
|
|
81
|
+
bridge: {
|
|
82
|
+
host: "127.0.0.1",
|
|
83
|
+
port: 8787
|
|
84
|
+
},
|
|
85
|
+
context: {
|
|
86
|
+
messages: 30,
|
|
87
|
+
maxChars: 60_000,
|
|
88
|
+
attachmentMaxBytes: 10 * 1024 * 1024
|
|
89
|
+
},
|
|
90
|
+
threads: {
|
|
91
|
+
activeByRecentBotParticipation: true
|
|
92
|
+
},
|
|
93
|
+
tools: {
|
|
94
|
+
enabled: true,
|
|
95
|
+
autoInstall: true,
|
|
96
|
+
reactions: true,
|
|
97
|
+
attachFiles: true,
|
|
98
|
+
fetchHistory: true,
|
|
99
|
+
followUpMessages: true,
|
|
100
|
+
createThread: false,
|
|
101
|
+
editDeleteOwn: false,
|
|
102
|
+
postOtherChannels: false,
|
|
103
|
+
pin: false
|
|
104
|
+
},
|
|
105
|
+
streaming: {
|
|
106
|
+
updateInterval: Duration.millis(500),
|
|
107
|
+
placeholderText: null,
|
|
108
|
+
showToolStatus: true,
|
|
109
|
+
changedFilesSummary: true
|
|
110
|
+
},
|
|
111
|
+
concurrency: {
|
|
112
|
+
strategy: "queue",
|
|
113
|
+
lockScope: "discord-scope",
|
|
114
|
+
globalMaxActiveTurns: null
|
|
115
|
+
},
|
|
116
|
+
guards: {
|
|
117
|
+
ignoreBots: true,
|
|
118
|
+
stripMassMentions: true,
|
|
119
|
+
redactSecretsInErrors: true,
|
|
120
|
+
maxTurn: null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
125
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
126
|
+
|
|
127
|
+
const decodeRawConfig = (value: unknown): Effect.Effect<Readonly<Record<string, unknown>>, ConfigError> =>
|
|
128
|
+
Schema.decodeUnknownEffect(RawConfigSchema)(value).pipe(
|
|
129
|
+
Effect.map((decoded) => decoded),
|
|
130
|
+
Effect.mapError(() => new ConfigError({ message: "Config file failed schema validation" }))
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const readRecord = (source: Readonly<Record<string, unknown>>, key: string): Readonly<Record<string, unknown>> => {
|
|
134
|
+
const value = source[key]
|
|
135
|
+
return isRecord(value) ? value : {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const readNumber = (source: Readonly<Record<string, unknown>>, key: string, fallback: number): number => {
|
|
139
|
+
const value = source[key]
|
|
140
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const readBoolean = (source: Readonly<Record<string, unknown>>, key: string, fallback: boolean): boolean => {
|
|
144
|
+
const value = source[key]
|
|
145
|
+
return typeof value === "boolean" ? value : fallback
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const readNullableString = (source: Readonly<Record<string, unknown>>, key: string, fallback: string | null): string | null => {
|
|
149
|
+
const value = source[key]
|
|
150
|
+
if (value === null) return null
|
|
151
|
+
return typeof value === "string" ? value : fallback
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const readNullableNumber = (source: Readonly<Record<string, unknown>>, key: string, fallback: number | null): number | null => {
|
|
155
|
+
const value = source[key]
|
|
156
|
+
if (value === null) return null
|
|
157
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const readConcurrencyStrategy = (source: Readonly<Record<string, unknown>>): "queue" | "burst" => {
|
|
161
|
+
const value = source.strategy
|
|
162
|
+
return value === "queue" || value === "burst" ? value : defaultConfig.concurrency.strategy
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const optionalEnv = (value: string | undefined): string | undefined => {
|
|
166
|
+
if (value === undefined) return undefined
|
|
167
|
+
const trimmed = value.trim()
|
|
168
|
+
return trimmed === "" ? undefined : trimmed
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const parsePort = (value: string | undefined, fallback: number, name: string): Effect.Effect<number, ConfigError> => {
|
|
172
|
+
if (value === undefined || value.trim() === "") return Effect.succeed(fallback)
|
|
173
|
+
const parsed = Number(value)
|
|
174
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) return Effect.succeed(parsed)
|
|
175
|
+
return Effect.fail(new ConfigError({ message: `${name} must be a TCP port` }))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const parseConfigText = (text: string | undefined): Effect.Effect<Readonly<Record<string, unknown>>, ConfigError> => {
|
|
179
|
+
if (text === undefined || text.trim() === "") return Effect.succeed({})
|
|
180
|
+
return Effect.try({
|
|
181
|
+
try: () => {
|
|
182
|
+
const errors: Array<ParseError> = []
|
|
183
|
+
const parsed: unknown = parse(text, errors)
|
|
184
|
+
if (errors.length > 0) throw new Error("Invalid JSONC")
|
|
185
|
+
if (!isRecord(parsed)) return {}
|
|
186
|
+
return parsed
|
|
187
|
+
},
|
|
188
|
+
catch: () => new ConfigError({ message: "Config file must be valid JSONC" })
|
|
189
|
+
}).pipe(Effect.flatMap(decodeRawConfig))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const isMissingFile = (cause: unknown): boolean => typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"
|
|
193
|
+
|
|
194
|
+
const readOptionalConfigFile = (path: string): Effect.Effect<string | undefined, ConfigError> =>
|
|
195
|
+
Effect.tryPromise({
|
|
196
|
+
try: async () => {
|
|
197
|
+
try {
|
|
198
|
+
return await readFile(path, "utf8")
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
if (isMissingFile(cause)) return undefined
|
|
201
|
+
throw cause
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
catch: () => new ConfigError({ message: "Unable to read config file" })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
export const loadConfigFromSources = Effect.fn("loadConfigFromSources")(function* (sources: ConfigSources) {
|
|
208
|
+
const file = yield* parseConfigText(sources.configText)
|
|
209
|
+
const token = sources.env.DISCORD_TOKEN
|
|
210
|
+
if (token === undefined || token.trim() === "") {
|
|
211
|
+
return yield* Effect.fail(new ConfigError({ message: "DISCORD_TOKEN is required" }))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const opencodePort = yield* parsePort(sources.env.OPENCODE_PORT, defaultConfig.opencode.port, "OPENCODE_PORT")
|
|
215
|
+
const bridgePort = yield* parsePort(sources.env.DISCORD_BRIDGE_PORT, defaultConfig.bridge.port, "DISCORD_BRIDGE_PORT")
|
|
216
|
+
const tools = readRecord(file, "tools")
|
|
217
|
+
const streaming = readRecord(file, "streaming")
|
|
218
|
+
const threads = readRecord(file, "threads")
|
|
219
|
+
const concurrency = readRecord(file, "concurrency")
|
|
220
|
+
const guards = readRecord(file, "guards")
|
|
221
|
+
|
|
222
|
+
const applicationId = optionalEnv(sources.env.DISCORD_APPLICATION_ID)
|
|
223
|
+
const publicKey = optionalEnv(sources.env.DISCORD_PUBLIC_KEY)
|
|
224
|
+
const guildId = optionalEnv(sources.env.DISCORD_GUILD_ID)
|
|
225
|
+
const model = optionalEnv(sources.env.OPENCODE_MODEL)
|
|
226
|
+
const agent = optionalEnv(sources.env.OPENCODE_AGENT)
|
|
227
|
+
const projectDir = optionalEnv(sources.env.OPENCODE_PROJECT_DIR) ?? sources.cwd
|
|
228
|
+
const maxTurnMs = readNullableNumber(guards, "maxTurnMs", null)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
discordToken: Redacted.make(token),
|
|
232
|
+
discord: {
|
|
233
|
+
...(applicationId === undefined ? {} : { applicationId }),
|
|
234
|
+
...(publicKey === undefined ? {} : { publicKey }),
|
|
235
|
+
...(guildId === undefined ? {} : { guildId })
|
|
236
|
+
},
|
|
237
|
+
opencode: {
|
|
238
|
+
port: opencodePort,
|
|
239
|
+
baseUrl: `http://127.0.0.1:${opencodePort}`,
|
|
240
|
+
projectDir,
|
|
241
|
+
...(model === undefined ? {} : { model }),
|
|
242
|
+
...(agent === undefined ? {} : { agent })
|
|
243
|
+
},
|
|
244
|
+
bridge: {
|
|
245
|
+
host: defaultConfig.bridge.host,
|
|
246
|
+
port: bridgePort
|
|
247
|
+
},
|
|
248
|
+
context: {
|
|
249
|
+
messages: readNumber(file, "contextMessages", defaultConfig.context.messages),
|
|
250
|
+
maxChars: readNumber(file, "contextMaxChars", defaultConfig.context.maxChars),
|
|
251
|
+
attachmentMaxBytes: readNumber(file, "attachmentMaxBytes", defaultConfig.context.attachmentMaxBytes)
|
|
252
|
+
},
|
|
253
|
+
threads: {
|
|
254
|
+
activeByRecentBotParticipation: readBoolean(
|
|
255
|
+
threads,
|
|
256
|
+
"activeByRecentBotParticipation",
|
|
257
|
+
defaultConfig.threads.activeByRecentBotParticipation
|
|
258
|
+
)
|
|
259
|
+
},
|
|
260
|
+
tools: {
|
|
261
|
+
enabled: readBoolean(tools, "enabled", defaultConfig.tools.enabled),
|
|
262
|
+
autoInstall: readBoolean(tools, "autoInstall", defaultConfig.tools.autoInstall),
|
|
263
|
+
reactions: readBoolean(tools, "reactions", defaultConfig.tools.reactions),
|
|
264
|
+
attachFiles: readBoolean(tools, "attachFiles", defaultConfig.tools.attachFiles),
|
|
265
|
+
fetchHistory: readBoolean(tools, "fetchHistory", defaultConfig.tools.fetchHistory),
|
|
266
|
+
followUpMessages: readBoolean(tools, "followUpMessages", defaultConfig.tools.followUpMessages),
|
|
267
|
+
createThread: readBoolean(tools, "createThread", defaultConfig.tools.createThread),
|
|
268
|
+
editDeleteOwn: readBoolean(tools, "editDeleteOwn", defaultConfig.tools.editDeleteOwn),
|
|
269
|
+
postOtherChannels: readBoolean(tools, "postOtherChannels", defaultConfig.tools.postOtherChannels),
|
|
270
|
+
pin: readBoolean(tools, "pin", defaultConfig.tools.pin)
|
|
271
|
+
},
|
|
272
|
+
streaming: {
|
|
273
|
+
updateInterval: Duration.millis(readNumber(streaming, "updateIntervalMs", 500)),
|
|
274
|
+
placeholderText: readNullableString(streaming, "placeholderText", defaultConfig.streaming.placeholderText),
|
|
275
|
+
showToolStatus: readBoolean(streaming, "showToolStatus", defaultConfig.streaming.showToolStatus),
|
|
276
|
+
changedFilesSummary: readBoolean(streaming, "changedFilesSummary", defaultConfig.streaming.changedFilesSummary)
|
|
277
|
+
},
|
|
278
|
+
concurrency: {
|
|
279
|
+
strategy: readConcurrencyStrategy(concurrency),
|
|
280
|
+
lockScope: defaultConfig.concurrency.lockScope,
|
|
281
|
+
globalMaxActiveTurns: readNullableNumber(concurrency, "globalMaxActiveTurns", defaultConfig.concurrency.globalMaxActiveTurns)
|
|
282
|
+
},
|
|
283
|
+
guards: {
|
|
284
|
+
ignoreBots: readBoolean(guards, "ignoreBots", defaultConfig.guards.ignoreBots),
|
|
285
|
+
stripMassMentions: readBoolean(guards, "stripMassMentions", defaultConfig.guards.stripMassMentions),
|
|
286
|
+
redactSecretsInErrors: readBoolean(guards, "redactSecretsInErrors", defaultConfig.guards.redactSecretsInErrors),
|
|
287
|
+
maxTurn: maxTurnMs === null ? null : Duration.millis(maxTurnMs)
|
|
288
|
+
}
|
|
289
|
+
} satisfies RuntimeConfig
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
export const loadConfig = Effect.fn("loadConfig")(function* (options: LoadConfigOptions) {
|
|
293
|
+
const configText = yield* readOptionalConfigFile(options.configPath ?? join(options.cwd, ".opencode-discord.jsonc"))
|
|
294
|
+
return yield* loadConfigFromSources({ cwd: options.cwd, env: options.env, configText })
|
|
295
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
const PositiveInt = Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))
|
|
4
|
+
const OptionalBoolean = Schema.optional(Schema.Boolean)
|
|
5
|
+
const RawToolsSchema = Schema.Struct({
|
|
6
|
+
enabled: OptionalBoolean,
|
|
7
|
+
autoInstall: OptionalBoolean,
|
|
8
|
+
reactions: OptionalBoolean,
|
|
9
|
+
attachFiles: OptionalBoolean,
|
|
10
|
+
fetchHistory: OptionalBoolean,
|
|
11
|
+
followUpMessages: OptionalBoolean,
|
|
12
|
+
createThread: OptionalBoolean,
|
|
13
|
+
editDeleteOwn: OptionalBoolean,
|
|
14
|
+
postOtherChannels: OptionalBoolean,
|
|
15
|
+
pin: OptionalBoolean
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const RawConfigSchema = Schema.Struct({
|
|
19
|
+
contextMessages: Schema.optional(PositiveInt),
|
|
20
|
+
contextMaxChars: Schema.optional(PositiveInt),
|
|
21
|
+
attachmentMaxBytes: Schema.optional(PositiveInt),
|
|
22
|
+
threads: Schema.optional(Schema.Struct({ activeByRecentBotParticipation: OptionalBoolean })),
|
|
23
|
+
tools: Schema.optional(RawToolsSchema),
|
|
24
|
+
streaming: Schema.optional(
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
updateIntervalMs: Schema.optional(PositiveInt),
|
|
27
|
+
placeholderText: Schema.optional(Schema.NullOr(Schema.String)),
|
|
28
|
+
showToolStatus: OptionalBoolean,
|
|
29
|
+
changedFilesSummary: OptionalBoolean
|
|
30
|
+
})
|
|
31
|
+
),
|
|
32
|
+
concurrency: Schema.optional(
|
|
33
|
+
Schema.Struct({
|
|
34
|
+
strategy: Schema.optional(Schema.Union([Schema.Literal("queue"), Schema.Literal("burst")])),
|
|
35
|
+
globalMaxActiveTurns: Schema.optional(Schema.NullOr(PositiveInt))
|
|
36
|
+
})
|
|
37
|
+
),
|
|
38
|
+
guards: Schema.optional(
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
ignoreBots: OptionalBoolean,
|
|
41
|
+
stripMassMentions: OptionalBoolean,
|
|
42
|
+
redactSecretsInErrors: OptionalBoolean,
|
|
43
|
+
maxTurnMs: Schema.optional(Schema.NullOr(PositiveInt))
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ConfigSources = {
|
|
2
|
+
readonly cwd: string
|
|
3
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
4
|
+
readonly configText?: string | undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type LoadConfigOptions = {
|
|
8
|
+
readonly cwd: string
|
|
9
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
10
|
+
readonly configPath?: string
|
|
11
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import type { DiscordThreadId } from "@chat-adapter/discord"
|
|
6
|
+
import type { AdapterPostableMessage, ChannelInfo, FetchResult, RawMessage } from "chat"
|
|
7
|
+
import { Message, parseMarkdown } from "chat"
|
|
8
|
+
import { Duration, Effect } from "effect"
|
|
9
|
+
import type { DiscordScope } from "../Schema.ts"
|
|
10
|
+
import { makeChatSdkDiscord } from "./ChatSdkDiscord.ts"
|
|
11
|
+
import { DiscordError } from "./DiscordPort.ts"
|
|
12
|
+
|
|
13
|
+
const scope: DiscordScope = { guildId: "g1", channelId: "c1", threadId: "t1" }
|
|
14
|
+
|
|
15
|
+
class FakeDiscordAdapter {
|
|
16
|
+
readonly calls: Array<readonly [string, unknown]> = []
|
|
17
|
+
|
|
18
|
+
encodeThreadId(input: DiscordThreadId): string {
|
|
19
|
+
this.calls.push(["encodeThreadId", input])
|
|
20
|
+
return input.threadId === undefined
|
|
21
|
+
? `discord:${input.guildId}:${input.channelId}`
|
|
22
|
+
: `discord:${input.guildId}:${input.channelId}:${input.threadId}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
postMessage(threadId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
26
|
+
this.calls.push(["postMessage", { threadId, message }])
|
|
27
|
+
return Promise.resolve({ id: "posted-1", threadId, raw: {} })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
editMessage(threadId: string, messageId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
31
|
+
this.calls.push(["editMessage", { threadId, messageId, message }])
|
|
32
|
+
return Promise.resolve({ id: messageId, threadId, raw: {} })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
deleteMessage(threadId: string, messageId: string): Promise<void> {
|
|
36
|
+
this.calls.push(["deleteMessage", { threadId, messageId }])
|
|
37
|
+
return Promise.resolve()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
postChannelMessage(channelId: string, message: AdapterPostableMessage): Promise<RawMessage<unknown>> {
|
|
41
|
+
this.calls.push(["postChannelMessage", { channelId, message }])
|
|
42
|
+
return Promise.resolve({ id: "posted-channel-1", threadId: channelId, raw: {} })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fetchChannelInfo(channelId: string): Promise<ChannelInfo> {
|
|
46
|
+
this.calls.push(["fetchChannelInfo", { channelId }])
|
|
47
|
+
return Promise.resolve({ id: channelId, isDM: false, metadata: { raw: { guild_id: "g1" } } })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
startTyping(threadId: string): Promise<void> {
|
|
51
|
+
this.calls.push(["startTyping", { threadId }])
|
|
52
|
+
return Promise.resolve()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
addReaction(threadId: string, messageId: string, emoji: string): Promise<void> {
|
|
56
|
+
this.calls.push(["addReaction", { threadId, messageId, emoji }])
|
|
57
|
+
return Promise.resolve()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
removeReaction(threadId: string, messageId: string, emoji: string): Promise<void> {
|
|
61
|
+
this.calls.push(["removeReaction", { threadId, messageId, emoji }])
|
|
62
|
+
return Promise.resolve()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fetchMessages(threadId: string): Promise<FetchResult<unknown>> {
|
|
66
|
+
this.calls.push(["fetchMessages", { threadId }])
|
|
67
|
+
return Promise.resolve({
|
|
68
|
+
messages: [
|
|
69
|
+
new Message({
|
|
70
|
+
id: "m1",
|
|
71
|
+
threadId,
|
|
72
|
+
text: "hello <@999>",
|
|
73
|
+
formatted: parseMarkdown("hello <@999>"),
|
|
74
|
+
raw: {},
|
|
75
|
+
author: { userId: "u1", userName: "alice", fullName: "Alice", isBot: false, isMe: false },
|
|
76
|
+
metadata: { dateSent: new Date("2026-06-05T14:03:00.000Z"), edited: false },
|
|
77
|
+
attachments: [{ type: "file", name: "notes.txt", mimeType: "text/plain", size: 42, url: "https://example.test/notes.txt" }]
|
|
78
|
+
})
|
|
79
|
+
]
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("makeChatSdkDiscord", () => {
|
|
85
|
+
test("routes Discord port operations through chat-sdk adapter primitives", async () => {
|
|
86
|
+
const adapter = new FakeDiscordAdapter()
|
|
87
|
+
const discord = makeChatSdkDiscord(adapter)
|
|
88
|
+
|
|
89
|
+
const context = await Effect.runPromise(discord.fetchContext(scope, 30))
|
|
90
|
+
const history = await Effect.runPromise(discord.fetchHistory(scope, 2))
|
|
91
|
+
const posted = await Effect.runPromise(discord.postMessage(scope, "reply"))
|
|
92
|
+
await Effect.runPromise(discord.editMessage(scope, posted.id, "edited"))
|
|
93
|
+
await Effect.runPromise(discord.sendTyping(scope))
|
|
94
|
+
await Effect.runPromise(discord.addReaction(scope, "m1", "rocket"))
|
|
95
|
+
await Effect.runPromise(discord.removeReaction(scope, "m1", "rocket"))
|
|
96
|
+
const directory = await mkdtemp(join(tmpdir(), "ocdb-chat-"))
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const path = join(directory, "upload.txt")
|
|
100
|
+
await writeFile(path, "uploaded")
|
|
101
|
+
expect(await Effect.runPromise(discord.attachFile(scope, path))).toEqual({ path: "posted-1" })
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(directory, { recursive: true, force: true })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(context).toEqual([
|
|
107
|
+
{
|
|
108
|
+
id: "m1",
|
|
109
|
+
guildId: "g1",
|
|
110
|
+
channelId: "c1",
|
|
111
|
+
threadId: "t1",
|
|
112
|
+
author: { id: "u1", displayName: "Alice", nickname: "alice", isBot: false },
|
|
113
|
+
content: "hello <@999>",
|
|
114
|
+
timestamp: "2026-06-05T14:03:00.000Z",
|
|
115
|
+
mentions: ["999"],
|
|
116
|
+
roleMentions: [],
|
|
117
|
+
everyoneMention: false,
|
|
118
|
+
hereMention: false,
|
|
119
|
+
attachments: [{ id: "m1-0", filename: "notes.txt", contentType: "text/plain", size: 42, url: "https://example.test/notes.txt" }],
|
|
120
|
+
reactions: [],
|
|
121
|
+
channelType: "guild"
|
|
122
|
+
}
|
|
123
|
+
])
|
|
124
|
+
expect(history).toEqual(context)
|
|
125
|
+
expect(adapter.calls.map((item) => item[0])).toEqual([
|
|
126
|
+
"encodeThreadId",
|
|
127
|
+
"fetchMessages",
|
|
128
|
+
"encodeThreadId",
|
|
129
|
+
"fetchMessages",
|
|
130
|
+
"encodeThreadId",
|
|
131
|
+
"postMessage",
|
|
132
|
+
"encodeThreadId",
|
|
133
|
+
"editMessage",
|
|
134
|
+
"encodeThreadId",
|
|
135
|
+
"startTyping",
|
|
136
|
+
"encodeThreadId",
|
|
137
|
+
"addReaction",
|
|
138
|
+
"encodeThreadId",
|
|
139
|
+
"removeReaction",
|
|
140
|
+
"encodeThreadId",
|
|
141
|
+
"postMessage"
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("routes channel posts, deletes, and raw REST adapter gaps", async () => {
|
|
146
|
+
const adapter = new FakeDiscordAdapter()
|
|
147
|
+
const requests: Array<readonly [string, RequestInit]> = []
|
|
148
|
+
const originalFetch = globalThis.fetch
|
|
149
|
+
const fakeFetch: typeof fetch = Object.assign(
|
|
150
|
+
(input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
|
151
|
+
requests.push([String(input), init ?? {}])
|
|
152
|
+
return Promise.resolve(
|
|
153
|
+
new Response(input.toString().includes("/threads") ? JSON.stringify({ id: "thread-1" }) : undefined, {
|
|
154
|
+
status: input.toString().includes("/threads") ? 200 : 204,
|
|
155
|
+
headers: { "content-type": "application/json" }
|
|
156
|
+
})
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
{ preconnect: originalFetch.preconnect }
|
|
160
|
+
)
|
|
161
|
+
globalThis.fetch = fakeFetch
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const discord = makeChatSdkDiscord(adapter, { botToken: "token", apiUrl: "https://discord.test/api" })
|
|
165
|
+
|
|
166
|
+
await Effect.runPromise(discord.deleteMessage(scope, "m1"))
|
|
167
|
+
expect(await Effect.runPromise(discord.postChannelMessage("g1", "c2", "hello"))).toEqual({ id: "posted-channel-1" })
|
|
168
|
+
expect(await Effect.runPromise(discord.createThread(scope, "work"))).toEqual({ id: "thread-1" })
|
|
169
|
+
await Effect.runPromise(discord.pinMessage(scope, "m1"))
|
|
170
|
+
await Effect.runPromise(discord.unpinMessage(scope, "m1"))
|
|
171
|
+
} finally {
|
|
172
|
+
globalThis.fetch = originalFetch
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(adapter.calls.map((item) => item[0])).toEqual([
|
|
176
|
+
"encodeThreadId",
|
|
177
|
+
"deleteMessage",
|
|
178
|
+
"encodeThreadId",
|
|
179
|
+
"fetchChannelInfo",
|
|
180
|
+
"postChannelMessage"
|
|
181
|
+
])
|
|
182
|
+
expect(requests.map((request) => [request[0], request[1].method])).toEqual([
|
|
183
|
+
["https://discord.test/api/channels/c1/threads", "POST"],
|
|
184
|
+
["https://discord.test/api/channels/t1/pins/m1", "PUT"],
|
|
185
|
+
["https://discord.test/api/channels/t1/pins/m1", "DELETE"]
|
|
186
|
+
])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("fails raw REST operations when no raw Discord client is configured", async () => {
|
|
190
|
+
const discord = makeChatSdkDiscord(new FakeDiscordAdapter())
|
|
191
|
+
|
|
192
|
+
await expect(Effect.runPromise(discord.createThread(scope, "work"))).rejects.toMatchObject({
|
|
193
|
+
_tag: "DiscordError",
|
|
194
|
+
message: "Discord adapter does not expose this operation"
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("preserves chat-sdk adapter retry metadata", async () => {
|
|
199
|
+
const adapter = new FakeDiscordAdapter()
|
|
200
|
+
adapter.postMessage = () => Promise.reject(Object.assign(new Error("limited"), { retryAfterMs: 123 }))
|
|
201
|
+
const discord = makeChatSdkDiscord(adapter)
|
|
202
|
+
let error: unknown
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await Effect.runPromise(discord.postMessage(scope, "hello"))
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
error = cause
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!(error instanceof DiscordError)) throw new Error("expected DiscordError")
|
|
211
|
+
if (error.retryAfter === undefined) throw new Error("expected retryAfter")
|
|
212
|
+
expect(error.message).toBe("limited")
|
|
213
|
+
expect(Duration.toMillis(error.retryAfter)).toBe(123)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test("rejects DM channel info before cross-channel posting", async () => {
|
|
217
|
+
const adapter = new FakeDiscordAdapter()
|
|
218
|
+
adapter.fetchChannelInfo = (channelId: string) => {
|
|
219
|
+
adapter.calls.push(["fetchChannelInfo", { channelId }])
|
|
220
|
+
return Promise.resolve({ id: channelId, isDM: true, metadata: {} })
|
|
221
|
+
}
|
|
222
|
+
const discord = makeChatSdkDiscord(adapter)
|
|
223
|
+
|
|
224
|
+
await expect(Effect.runPromise(discord.postChannelMessage("g1", "dm1", "hello"))).rejects.toMatchObject({
|
|
225
|
+
_tag: "DiscordError",
|
|
226
|
+
message: "Discord DMs are not supported"
|
|
227
|
+
})
|
|
228
|
+
expect(adapter.calls.map((item) => item[0])).toEqual(["encodeThreadId", "fetchChannelInfo"])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test("preserves raw Discord REST retry-after metadata", async () => {
|
|
232
|
+
const originalFetch = globalThis.fetch
|
|
233
|
+
const fakeFetch: typeof fetch = Object.assign(
|
|
234
|
+
() => Promise.resolve(new Response("limited", { status: 429, headers: { "retry-after": "2" } })),
|
|
235
|
+
{ preconnect: originalFetch.preconnect }
|
|
236
|
+
)
|
|
237
|
+
globalThis.fetch = fakeFetch
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const discord = makeChatSdkDiscord(new FakeDiscordAdapter(), { botToken: "token", apiUrl: "https://discord.test/api" })
|
|
241
|
+
let error: unknown
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
await Effect.runPromise(discord.createThread(scope, "work"))
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
error = cause
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!(error instanceof DiscordError)) throw new Error("expected DiscordError")
|
|
250
|
+
if (error.retryAfter === undefined) throw new Error("expected retryAfter")
|
|
251
|
+
expect(error.message).toBe("Discord REST 429: limited")
|
|
252
|
+
expect(Duration.toMillis(error.retryAfter)).toBe(2000)
|
|
253
|
+
} finally {
|
|
254
|
+
globalThis.fetch = originalFetch
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|