Initial codex-flows monorepo
This commit is contained in:
commit
3c446b11a4
642 changed files with 19676 additions and 0 deletions
27
apps/discord-bridge/package.json
Normal file
27
apps/discord-bridge/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "codex-discord-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Long-lived Discord sidecar for bridging Discord threads to Codex app-server threads.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex-discord-bridge": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"check:types": "tsc --noEmit",
|
||||
"pretty-log": "bun ./src/pretty-log.ts",
|
||||
"start:debug:commentary": "bun ./src/index.ts --local-app-server --debug --progress-mode commentary",
|
||||
"test": "bun test test/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@peezy-tech/codex-flows": "workspace:*",
|
||||
"discord.js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
1051
apps/discord-bridge/src/bridge.ts
Normal file
1051
apps/discord-bridge/src/bridge.ts
Normal file
File diff suppressed because it is too large
Load diff
368
apps/discord-bridge/src/config.ts
Normal file
368
apps/discord-bridge/src/config.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
v2,
|
||||
} from "@peezy-tech/codex-flows/generated";
|
||||
|
||||
import type {
|
||||
DiscordBridgeConfig,
|
||||
DiscordConsoleOutputMode,
|
||||
DiscordProgressMode,
|
||||
} from "./types.ts";
|
||||
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
|
||||
|
||||
export type ParsedConfig =
|
||||
| {
|
||||
type: "run";
|
||||
discordToken: string;
|
||||
appServerUrl?: string;
|
||||
localAppServer?: boolean;
|
||||
config: DiscordBridgeConfig;
|
||||
}
|
||||
| { type: "help"; text: string };
|
||||
|
||||
const effortValues = new Set<ReasoningEffort>([
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
const summaryValues = new Set<ReasoningSummary>([
|
||||
"auto",
|
||||
"concise",
|
||||
"detailed",
|
||||
"none",
|
||||
]);
|
||||
const progressModeValues = new Set<DiscordProgressMode>([
|
||||
"summary",
|
||||
"commentary",
|
||||
"none",
|
||||
]);
|
||||
const consoleOutputValues = new Set<DiscordConsoleOutputMode>([
|
||||
"messages",
|
||||
"none",
|
||||
]);
|
||||
const logLevelValues = new Set<DiscordBridgeLogLevelSetting>([
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"silent",
|
||||
]);
|
||||
const approvalPolicyValues = new Set<string>([
|
||||
"untrusted",
|
||||
"on-failure",
|
||||
"on-request",
|
||||
"never",
|
||||
]);
|
||||
const sandboxValues = new Set<v2.SandboxMode>([
|
||||
"read-only",
|
||||
"workspace-write",
|
||||
"danger-full-access",
|
||||
]);
|
||||
|
||||
export function parseConfig(argv: string[], env: NodeJS.ProcessEnv): ParsedConfig {
|
||||
const args = parseFlags(argv);
|
||||
if (args.has("help") || args.has("h")) {
|
||||
return { type: "help", text: helpText() };
|
||||
}
|
||||
const discordToken = stringFlag(args, "token") ?? env.CODEX_DISCORD_BOT_TOKEN;
|
||||
if (!discordToken) {
|
||||
throw new Error("Missing Discord bot token. Set CODEX_DISCORD_BOT_TOKEN or pass --token.");
|
||||
}
|
||||
const allowedUserIds = csvSet(
|
||||
stringFlag(args, "allowed-user-ids") ?? env.CODEX_DISCORD_ALLOWED_USER_IDS,
|
||||
);
|
||||
if (allowedUserIds.size === 0) {
|
||||
throw new Error(
|
||||
"Missing allowed Discord users. Set CODEX_DISCORD_ALLOWED_USER_IDS or pass --allowed-user-ids.",
|
||||
);
|
||||
}
|
||||
const explicitAppServerUrl =
|
||||
stringFlag(args, "app-server-url") ??
|
||||
stringFlag(args, "url");
|
||||
const localAppServer = booleanFlag(args, "local-app-server");
|
||||
if (localAppServer && explicitAppServerUrl) {
|
||||
throw new Error("Cannot set both --local-app-server and --app-server-url.");
|
||||
}
|
||||
const appServerUrl = localAppServer
|
||||
? undefined
|
||||
: explicitAppServerUrl ?? env.CODEX_WORKSPACE_APP_SERVER_WS_URL;
|
||||
const statePath =
|
||||
stringFlag(args, "state-path") ??
|
||||
env.CODEX_DISCORD_STATE_PATH ??
|
||||
path.join(os.homedir(), ".codex", "discord-bridge", "state.json");
|
||||
const permissionsProfile = stringFlag(args, "permissions-profile") ??
|
||||
env.CODEX_DISCORD_PERMISSIONS_PROFILE;
|
||||
const approvalPolicy = optionalApprovalPolicy(
|
||||
stringFlag(args, "approval-policy") ?? env.CODEX_DISCORD_APPROVAL_POLICY,
|
||||
);
|
||||
const sandbox = optionalSandbox(
|
||||
stringFlag(args, "sandbox") ?? env.CODEX_DISCORD_SANDBOX,
|
||||
);
|
||||
if (sandbox && permissionsProfile) {
|
||||
throw new Error("Cannot set both --sandbox and --permissions-profile.");
|
||||
}
|
||||
const debug = booleanFlag(args, "debug") || envFlag(env.CODEX_DISCORD_DEBUG);
|
||||
const logLevel = optionalLogLevel(
|
||||
stringFlag(args, "log-level") ?? env.CODEX_DISCORD_LOG_LEVEL,
|
||||
) ?? (debug ? "debug" : undefined);
|
||||
|
||||
return {
|
||||
type: "run",
|
||||
discordToken,
|
||||
appServerUrl,
|
||||
localAppServer,
|
||||
config: {
|
||||
allowedUserIds,
|
||||
allowedChannelIds: csvSet(
|
||||
stringFlag(args, "allowed-channel-ids") ??
|
||||
env.CODEX_DISCORD_ALLOWED_CHANNEL_IDS,
|
||||
),
|
||||
statePath,
|
||||
cwd: resolveHomeDir(
|
||||
stringFlag(args, "dir") ??
|
||||
stringFlag(args, "positional-dir") ??
|
||||
env.CODEX_DISCORD_DIR ??
|
||||
stringFlag(args, "cwd") ??
|
||||
env.CODEX_DISCORD_CWD,
|
||||
),
|
||||
model: stringFlag(args, "model") ?? env.CODEX_DISCORD_MODEL,
|
||||
modelProvider:
|
||||
stringFlag(args, "model-provider") ??
|
||||
env.CODEX_DISCORD_MODEL_PROVIDER,
|
||||
serviceTier:
|
||||
stringFlag(args, "service-tier") ?? env.CODEX_DISCORD_SERVICE_TIER,
|
||||
effort: optionalEffort(
|
||||
stringFlag(args, "effort") ?? env.CODEX_DISCORD_EFFORT,
|
||||
),
|
||||
summary: optionalSummary(
|
||||
stringFlag(args, "summary") ??
|
||||
env.CODEX_DISCORD_REASONING_SUMMARY ??
|
||||
"auto",
|
||||
),
|
||||
progressMode: optionalProgressMode(
|
||||
stringFlag(args, "progress-mode") ??
|
||||
env.CODEX_DISCORD_PROGRESS_MODE ??
|
||||
"summary",
|
||||
),
|
||||
consoleOutput: optionalConsoleOutput(
|
||||
stringFlag(args, "console-output") ??
|
||||
env.CODEX_DISCORD_CONSOLE_OUTPUT,
|
||||
),
|
||||
logLevel,
|
||||
approvalPolicy,
|
||||
sandbox,
|
||||
permissions: permissionsProfile
|
||||
? { type: "profile", id: permissionsProfile }
|
||||
: undefined,
|
||||
debug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseFlags(argv: string[]): Map<string, string | boolean> {
|
||||
const flags = new Map<string, string | boolean>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg?.startsWith("--")) {
|
||||
if (flags.has("positional-dir")) {
|
||||
throw new Error(`Unexpected argument: ${arg ?? ""}`);
|
||||
}
|
||||
flags.set("positional-dir", arg ?? "");
|
||||
continue;
|
||||
}
|
||||
const [rawName, inlineValue] = arg.slice(2).split("=", 2);
|
||||
if (!rawName) {
|
||||
throw new Error(`Invalid flag: ${arg}`);
|
||||
}
|
||||
if (inlineValue !== undefined) {
|
||||
flags.set(rawName, inlineValue);
|
||||
continue;
|
||||
}
|
||||
if (booleanFlagNames.has(rawName)) {
|
||||
flags.set(rawName, true);
|
||||
continue;
|
||||
}
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
flags.set(rawName, true);
|
||||
continue;
|
||||
}
|
||||
flags.set(rawName, next);
|
||||
index += 1;
|
||||
}
|
||||
if (
|
||||
flags.has("positional-dir") &&
|
||||
(flags.has("dir") || flags.has("cwd"))
|
||||
) {
|
||||
throw new Error("Cannot set both positional directory and --dir/--cwd.");
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
const booleanFlagNames = new Set(["debug", "help", "h", "local-app-server"]);
|
||||
|
||||
function stringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = flags.get(name);
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function csvSet(value: string | undefined): Set<string> {
|
||||
return new Set(
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function booleanFlag(flags: Map<string, string | boolean>, name: string): boolean {
|
||||
const value = flags.get(name);
|
||||
if (value === true) {
|
||||
return true;
|
||||
}
|
||||
return envFlag(typeof value === "string" ? value : undefined);
|
||||
}
|
||||
|
||||
function envFlag(value: string | undefined): boolean {
|
||||
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
function optionalEffort(value: string | undefined): ReasoningEffort | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!effortValues.has(value as ReasoningEffort)) {
|
||||
throw new Error("Invalid effort. Expected none, minimal, low, medium, high, or xhigh.");
|
||||
}
|
||||
return value as ReasoningEffort;
|
||||
}
|
||||
|
||||
function optionalSummary(value: string | undefined): ReasoningSummary | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!summaryValues.has(value as ReasoningSummary)) {
|
||||
throw new Error("Invalid summary. Expected auto, concise, detailed, or none.");
|
||||
}
|
||||
return value as ReasoningSummary;
|
||||
}
|
||||
|
||||
function optionalProgressMode(value: string | undefined): DiscordProgressMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!progressModeValues.has(value as DiscordProgressMode)) {
|
||||
throw new Error("Invalid progress mode. Expected summary, commentary, or none.");
|
||||
}
|
||||
return value as DiscordProgressMode;
|
||||
}
|
||||
|
||||
function optionalConsoleOutput(
|
||||
value: string | undefined,
|
||||
): DiscordConsoleOutputMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!consoleOutputValues.has(value as DiscordConsoleOutputMode)) {
|
||||
throw new Error("Invalid console output. Expected messages or none.");
|
||||
}
|
||||
return value as DiscordConsoleOutputMode;
|
||||
}
|
||||
|
||||
function optionalLogLevel(
|
||||
value: string | undefined,
|
||||
): DiscordBridgeLogLevelSetting | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!logLevelValues.has(value as DiscordBridgeLogLevelSetting)) {
|
||||
throw new Error("Invalid log level. Expected debug, info, warn, error, or silent.");
|
||||
}
|
||||
return value as DiscordBridgeLogLevelSetting;
|
||||
}
|
||||
|
||||
function optionalApprovalPolicy(
|
||||
value: string | undefined,
|
||||
): v2.AskForApproval | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!approvalPolicyValues.has(value)) {
|
||||
throw new Error(
|
||||
"Invalid approval policy. Expected untrusted, on-failure, on-request, or never.",
|
||||
);
|
||||
}
|
||||
return value as v2.AskForApproval;
|
||||
}
|
||||
|
||||
function optionalSandbox(value: string | undefined): v2.SandboxMode | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (!sandboxValues.has(value as v2.SandboxMode)) {
|
||||
throw new Error(
|
||||
"Invalid sandbox. Expected read-only, workspace-write, or danger-full-access.",
|
||||
);
|
||||
}
|
||||
return value as v2.SandboxMode;
|
||||
}
|
||||
|
||||
function helpText(): string {
|
||||
return `codex-discord-bridge connects Discord threads to Codex app-server threads.
|
||||
|
||||
Usage:
|
||||
codex-discord-bridge [options] [dir]
|
||||
|
||||
Required:
|
||||
--token <token> Discord bot token, or CODEX_DISCORD_BOT_TOKEN
|
||||
--allowed-user-ids <ids> Comma-separated Discord user ids, or CODEX_DISCORD_ALLOWED_USER_IDS
|
||||
|
||||
Options:
|
||||
--app-server-url <url> Existing app-server WebSocket URL
|
||||
--local-app-server Start a local app-server over stdio
|
||||
--state-path <path> Persistent bridge state file
|
||||
--allowed-channel-ids <ids> Comma-separated parent channel ids
|
||||
[dir] Optional Codex thread directory, resolved from home
|
||||
--dir <path> Codex thread directory, resolved from home
|
||||
--cwd <path> Alias for --dir
|
||||
--model <model> Codex model override
|
||||
--model-provider <provider> Codex model provider override
|
||||
--service-tier <tier> Codex service tier override
|
||||
--effort <effort> none|minimal|low|medium|high|xhigh
|
||||
--summary <summary> auto|concise|detailed|none
|
||||
--progress-mode <mode> summary|commentary|none
|
||||
--console-output <mode> messages|none
|
||||
--log-level <level> debug|info|warn|error|silent
|
||||
--approval-policy <policy> untrusted|on-failure|on-request|never
|
||||
--sandbox <mode> read-only|workspace-write|danger-full-access
|
||||
--permissions-profile <id> Named Codex permissions profile
|
||||
--debug Emit verbose bridge diagnostics to stderr
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveHomeDir(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
if (path.isAbsolute(value)) {
|
||||
return value;
|
||||
}
|
||||
return path.join(os.homedir(), value);
|
||||
}
|
||||
99
apps/discord-bridge/src/console-output.ts
Normal file
99
apps/discord-bridge/src/console-output.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export type DiscordConsoleMessageKind =
|
||||
| "summary"
|
||||
| "commentary"
|
||||
| "final"
|
||||
| "error";
|
||||
|
||||
export type DiscordConsoleMessage = {
|
||||
kind: DiscordConsoleMessageKind;
|
||||
text: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
turnId?: string;
|
||||
title?: string;
|
||||
at?: Date;
|
||||
};
|
||||
|
||||
export type DiscordConsoleOutput = {
|
||||
message(message: DiscordConsoleMessage): void;
|
||||
};
|
||||
|
||||
export type ConsoleMessageOutputOptions = {
|
||||
color?: boolean;
|
||||
now?: () => Date;
|
||||
stream?: Pick<NodeJS.WriteStream, "write">;
|
||||
};
|
||||
|
||||
export type ConsoleMessageFormatOptions = {
|
||||
color?: boolean;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
const resetColor = "\x1b[0m";
|
||||
const kindColors: Record<DiscordConsoleMessageKind, string> = {
|
||||
summary: "\x1b[90m",
|
||||
commentary: "\x1b[36m",
|
||||
final: "\x1b[32m",
|
||||
error: "\x1b[31m",
|
||||
};
|
||||
|
||||
export function createDiscordConsoleOutput(
|
||||
options: ConsoleMessageOutputOptions = {},
|
||||
): DiscordConsoleOutput {
|
||||
const stream = options.stream ?? process.stdout;
|
||||
const color = options.color ??
|
||||
Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
|
||||
const now = options.now ?? (() => new Date());
|
||||
return {
|
||||
message(message) {
|
||||
stream.write(`${formatConsoleMessage(message, { color, now })}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatConsoleMessage(
|
||||
message: DiscordConsoleMessage,
|
||||
options: ConsoleMessageFormatOptions = {},
|
||||
): string {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const time = formatTime(message.at ?? now());
|
||||
const kind = message.kind.toUpperCase().padEnd(10);
|
||||
const coloredKind = colorize(kind, kindColors[message.kind], options.color ?? false);
|
||||
const title = (message.title?.trim() || compactId(message.codexThreadId)).replace(
|
||||
/\s+/g,
|
||||
" ",
|
||||
);
|
||||
const metadata = [
|
||||
`thread=${compactId(message.codexThreadId)}`,
|
||||
message.turnId ? `turn=${compactId(message.turnId)}` : undefined,
|
||||
].filter(Boolean).join(" ");
|
||||
const header = `[${time}] ${coloredKind} ${title} ${metadata}`;
|
||||
const body = formatBody(message.text);
|
||||
return body ? `${header}\n${body}` : header;
|
||||
}
|
||||
|
||||
function formatBody(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function compactId(id: string): string {
|
||||
if (id.length <= 12) {
|
||||
return id;
|
||||
}
|
||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function colorize(text: string, color: string, enabled: boolean): string {
|
||||
return enabled ? `${color}${text}${resetColor}` : text;
|
||||
}
|
||||
380
apps/discord-bridge/src/discord-transport.ts
Normal file
380
apps/discord-bridge/src/discord-transport.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import {
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
type Interaction,
|
||||
type Message,
|
||||
} from "discord.js";
|
||||
|
||||
import { splitDiscordMessage } from "./bridge.ts";
|
||||
import {
|
||||
createDiscordBridgeLogger,
|
||||
type DiscordBridgeLogger,
|
||||
} from "./logger.ts";
|
||||
import type {
|
||||
DiscordBridgeTransport,
|
||||
DiscordBridgeTransportHandlers,
|
||||
} from "./types.ts";
|
||||
|
||||
export type DiscordJsBridgeTransportOptions = {
|
||||
token: string;
|
||||
logger?: DiscordBridgeLogger;
|
||||
};
|
||||
|
||||
export class DiscordJsBridgeTransport implements DiscordBridgeTransport {
|
||||
#token: string;
|
||||
#logger: DiscordBridgeLogger;
|
||||
#client: Client | undefined;
|
||||
#handlers: DiscordBridgeTransportHandlers | undefined;
|
||||
|
||||
constructor(options: DiscordJsBridgeTransportOptions) {
|
||||
this.#token = options.token;
|
||||
this.#logger = options.logger ?? createDiscordBridgeLogger();
|
||||
}
|
||||
|
||||
async start(handlers: DiscordBridgeTransportHandlers): Promise<void> {
|
||||
this.#handlers = handlers;
|
||||
if (this.#client) {
|
||||
return;
|
||||
}
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
});
|
||||
this.#client = client;
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
this.#logger.info("discord.connected", {
|
||||
userId: readyClient.user.id,
|
||||
tag: readyClient.user.tag,
|
||||
});
|
||||
});
|
||||
client.on(Events.MessageCreate, (message) => this.#handleMessage(message));
|
||||
client.on(Events.InteractionCreate, (interaction) =>
|
||||
void this.#handleInteraction(interaction).catch((error) => {
|
||||
this.#logger.error("discord.interaction.failed", {
|
||||
error: errorMessage(error),
|
||||
});
|
||||
})
|
||||
);
|
||||
await client.login(this.#token);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.#client?.destroy();
|
||||
this.#client = undefined;
|
||||
}
|
||||
|
||||
async registerCommands(): Promise<void> {
|
||||
const application = this.#client?.application;
|
||||
if (!application) {
|
||||
return;
|
||||
}
|
||||
await application.commands.set([
|
||||
{
|
||||
name: "clear",
|
||||
description: "Delete inactive Codex bridge threads",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async createThread(
|
||||
channelId: string,
|
||||
name: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
if (sourceMessageId) {
|
||||
const messages = getMessagesManager(channel);
|
||||
if (messages) {
|
||||
const sourceMessage = await messages.fetch(sourceMessageId);
|
||||
if (sourceMessage.startThread) {
|
||||
const thread = await sourceMessage.startThread({
|
||||
name,
|
||||
autoArchiveDuration: 1440,
|
||||
reason: "Codex Discord bridge thread",
|
||||
});
|
||||
if (thread.id) {
|
||||
return thread.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const threads = getThreadsManager(channel);
|
||||
if (!threads) {
|
||||
throw new Error(`Discord channel cannot create threads: ${channelId}`);
|
||||
}
|
||||
const thread = await threads.create({
|
||||
name,
|
||||
autoArchiveDuration: 1440,
|
||||
reason: "Codex Discord bridge thread",
|
||||
});
|
||||
if (!thread.id) {
|
||||
throw new Error("Discord did not return a thread id");
|
||||
}
|
||||
return thread.id;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, text: string): Promise<string[]> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messageIds: string[] = [];
|
||||
for (const chunk of splitDiscordMessage(text)) {
|
||||
const sent = await channel.send({
|
||||
content: chunk,
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
if (typeof sent.id === "string") {
|
||||
messageIds.push(sent.id);
|
||||
}
|
||||
}
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
async updateMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
await message.edit({
|
||||
content: splitDiscordMessage(text)[0] ?? "",
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
await message.delete();
|
||||
}
|
||||
|
||||
async deleteThread(channelId: string): Promise<void> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || !("delete" in channel) || typeof channel.delete !== "function") {
|
||||
throw new Error(`Discord channel cannot be deleted: ${channelId}`);
|
||||
}
|
||||
await channel.delete("Codex Discord bridge clear command");
|
||||
}
|
||||
|
||||
async addThreadMembers(channelId: string, userIds: string[]): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const members = getThreadMembersManager(channel);
|
||||
if (!members) {
|
||||
throw new Error(`Discord channel cannot add thread members: ${channelId}`);
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
await members.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async pinMessage(channelId: string, messageId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
const messages = getMessagesManager(channel);
|
||||
if (!messages) {
|
||||
throw new Error(`Discord channel cannot fetch messages: ${channelId}`);
|
||||
}
|
||||
const message = await messages.fetch(messageId);
|
||||
if (!message.pin) {
|
||||
throw new Error(`Discord message cannot be pinned: ${messageId}`);
|
||||
}
|
||||
if (message.pinned) {
|
||||
return;
|
||||
}
|
||||
await message.pin();
|
||||
}
|
||||
|
||||
async sendTyping(channelId: string): Promise<void> {
|
||||
const channel = await this.#sendableChannel(channelId);
|
||||
await channel.sendTyping?.();
|
||||
}
|
||||
|
||||
#handleMessage(message: Message): void {
|
||||
const botUserId = this.#client?.user?.id;
|
||||
if (
|
||||
botUserId &&
|
||||
!isThreadChannel(message.channel) &&
|
||||
message.mentions.users.has(botUserId)
|
||||
) {
|
||||
const mentionedUserIds = message.mentions.users
|
||||
.filter((user) => user.id !== botUserId && !user.bot)
|
||||
.map((user) => user.id);
|
||||
const prompt = stripUserMentions(message.content ?? "", [
|
||||
botUserId,
|
||||
...mentionedUserIds,
|
||||
]);
|
||||
this.#handlers?.onInbound({
|
||||
kind: "threadStart",
|
||||
sourceMessageId: message.id,
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
name: message.member?.displayName ||
|
||||
message.author.globalName ||
|
||||
message.author.username,
|
||||
isBot: message.author.bot,
|
||||
},
|
||||
prompt,
|
||||
mentionedUserIds,
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#handlers?.onInbound({
|
||||
kind: "message",
|
||||
channelId: message.channelId,
|
||||
guildId: message.guildId ?? undefined,
|
||||
messageId: message.id,
|
||||
author: {
|
||||
id: message.author.id,
|
||||
name: message.member?.displayName ||
|
||||
message.author.globalName ||
|
||||
message.author.username,
|
||||
isBot: message.author.bot,
|
||||
},
|
||||
content: message.content ?? "",
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async #handleInteraction(interaction: Interaction): Promise<void> {
|
||||
if (!interaction.isChatInputCommand() || interaction.commandName !== "clear") {
|
||||
return;
|
||||
}
|
||||
const channelId = interaction.channelId;
|
||||
this.#handlers?.onInbound({
|
||||
kind: "clear",
|
||||
channelId,
|
||||
guildId: interaction.guildId ?? undefined,
|
||||
author: {
|
||||
id: interaction.user.id,
|
||||
name: interaction.member && "displayName" in interaction.member
|
||||
? String(interaction.member.displayName)
|
||||
: interaction.user.globalName || interaction.user.username,
|
||||
isBot: interaction.user.bot,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
reply: async (text) => {
|
||||
await interaction.reply({
|
||||
content: text,
|
||||
ephemeral: true,
|
||||
allowedMentions: {
|
||||
parse: [],
|
||||
users: [],
|
||||
roles: [],
|
||||
repliedUser: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #sendableChannel(channelId: string): Promise<SendableChannel> {
|
||||
const client = this.#client;
|
||||
if (!client) {
|
||||
throw new Error("Discord bridge is not connected");
|
||||
}
|
||||
const channel = await client.channels.fetch(channelId);
|
||||
if (!channel || !("send" in channel)) {
|
||||
throw new Error(`Discord channel is not text-sendable: ${channelId}`);
|
||||
}
|
||||
return channel as unknown as SendableChannel;
|
||||
}
|
||||
}
|
||||
|
||||
type ThreadCreateOptions = {
|
||||
name: string;
|
||||
autoArchiveDuration?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SendableChannel = {
|
||||
id: string;
|
||||
send(options: Record<string, unknown>): Promise<{ id?: string }>;
|
||||
sendTyping?: () => Promise<void>;
|
||||
threads?: {
|
||||
create(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
};
|
||||
members?: {
|
||||
add(userId: string): Promise<unknown>;
|
||||
};
|
||||
messages?: {
|
||||
fetch(messageId: string): Promise<{
|
||||
delete(): Promise<unknown>;
|
||||
edit(options: Record<string, unknown>): Promise<unknown>;
|
||||
pinned?: boolean;
|
||||
pin?(): Promise<unknown>;
|
||||
startThread?(options: ThreadCreateOptions): Promise<{ id?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function getThreadsManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["threads"] | undefined {
|
||||
return channel.threads;
|
||||
}
|
||||
|
||||
function getMessagesManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["messages"] | undefined {
|
||||
return channel.messages;
|
||||
}
|
||||
|
||||
function getThreadMembersManager(
|
||||
channel: SendableChannel,
|
||||
): SendableChannel["members"] | undefined {
|
||||
return channel.members;
|
||||
}
|
||||
|
||||
function isThreadChannel(channel: unknown): boolean {
|
||||
return Boolean(
|
||||
channel &&
|
||||
typeof channel === "object" &&
|
||||
"isThread" in channel &&
|
||||
typeof channel.isThread === "function" &&
|
||||
channel.isThread(),
|
||||
);
|
||||
}
|
||||
|
||||
function stripUserMentions(content: string, userIds: string[]): string {
|
||||
let stripped = content;
|
||||
for (const userId of userIds) {
|
||||
stripped = stripped.replace(new RegExp(`<@!?${escapeRegExp(userId)}>`, "g"), "");
|
||||
}
|
||||
return stripped.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
96
apps/discord-bridge/src/index.ts
Normal file
96
apps/discord-bridge/src/index.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env bun
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
CodexStdioTransport,
|
||||
} from "@peezy-tech/codex-flows";
|
||||
|
||||
import { DiscordCodexBridge } from "./bridge.ts";
|
||||
import { createDiscordConsoleOutput } from "./console-output.ts";
|
||||
import { parseConfig } from "./config.ts";
|
||||
import { DiscordJsBridgeTransport } from "./discord-transport.ts";
|
||||
import { createDiscordBridgeLogger } from "./logger.ts";
|
||||
import { JsonFileStateStore } from "./state.ts";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let logger = createDiscordBridgeLogger();
|
||||
try {
|
||||
const parsed = parseConfig(Bun.argv.slice(2), process.env);
|
||||
if (parsed.type === "help") {
|
||||
process.stdout.write(parsed.text);
|
||||
return;
|
||||
}
|
||||
logger = createDiscordBridgeLogger({
|
||||
debug: parsed.config.debug,
|
||||
logLevel: parsed.config.logLevel,
|
||||
});
|
||||
const consoleOutput = parsed.config.consoleOutput === "messages"
|
||||
? createDiscordConsoleOutput()
|
||||
: undefined;
|
||||
const client = new CodexAppServerClient({
|
||||
transport: parsed.localAppServer
|
||||
? new CodexStdioTransport({
|
||||
args: localAppServerArgs(),
|
||||
requestTimeoutMs: 90_000,
|
||||
})
|
||||
: undefined,
|
||||
webSocketTransportOptions: parsed.appServerUrl
|
||||
? { url: parsed.appServerUrl, requestTimeoutMs: 90_000 }
|
||||
: undefined,
|
||||
clientName: "codex-discord-bridge",
|
||||
clientTitle: "Codex Discord Bridge",
|
||||
clientVersion: "0.1.0",
|
||||
});
|
||||
const bridge = new DiscordCodexBridge({
|
||||
client,
|
||||
transport: new DiscordJsBridgeTransport({
|
||||
token: parsed.discordToken,
|
||||
logger,
|
||||
}),
|
||||
store: new JsonFileStateStore(parsed.config.statePath),
|
||||
config: parsed.config,
|
||||
logger,
|
||||
consoleOutput,
|
||||
});
|
||||
await bridge.start();
|
||||
logger.info("bridge.started", {
|
||||
appServerUrl: parsed.appServerUrl ?? "local",
|
||||
localAppServer: Boolean(parsed.localAppServer),
|
||||
progressMode: parsed.config.progressMode ?? "summary",
|
||||
statePath: parsed.config.statePath,
|
||||
});
|
||||
await waitForShutdown(bridge);
|
||||
} catch (error) {
|
||||
logger.error("bridge.fatal", { error: errorMessage(error) });
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function localAppServerArgs(): string[] {
|
||||
return [
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
"--enable",
|
||||
"apps",
|
||||
"--enable",
|
||||
"hooks",
|
||||
];
|
||||
}
|
||||
|
||||
function waitForShutdown(bridge: DiscordCodexBridge): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const shutdown = () => {
|
||||
process.off("SIGINT", shutdown);
|
||||
process.off("SIGTERM", shutdown);
|
||||
void bridge.stop().finally(resolve);
|
||||
};
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
await main();
|
||||
71
apps/discord-bridge/src/logger.ts
Normal file
71
apps/discord-bridge/src/logger.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
export type DiscordBridgeLogLevel = "debug" | "info" | "warn" | "error";
|
||||
export type DiscordBridgeLogLevelSetting = DiscordBridgeLogLevel | "silent";
|
||||
|
||||
export type DiscordBridgeLogFields = Record<string, unknown>;
|
||||
|
||||
export type DiscordBridgeLogger = {
|
||||
debug(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
info(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
warn(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
error(event: string, fields?: DiscordBridgeLogFields): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeLoggerOptions = {
|
||||
component?: string;
|
||||
debug?: boolean;
|
||||
logLevel?: DiscordBridgeLogLevelSetting;
|
||||
now?: () => Date;
|
||||
stream?: Pick<NodeJS.WriteStream, "write">;
|
||||
};
|
||||
|
||||
const logLevelRanks: Record<DiscordBridgeLogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
export function createDiscordBridgeLogger(
|
||||
options: DiscordBridgeLoggerOptions = {},
|
||||
): DiscordBridgeLogger {
|
||||
const component = options.component ?? "codex-discord-bridge";
|
||||
const now = options.now ?? (() => new Date());
|
||||
const stream = options.stream ?? process.stderr;
|
||||
const logLevel = options.logLevel ?? (options.debug ? "debug" : "info");
|
||||
|
||||
const write = (
|
||||
level: DiscordBridgeLogLevel,
|
||||
event: string,
|
||||
fields: DiscordBridgeLogFields = {},
|
||||
): void => {
|
||||
if (!shouldWrite(level, logLevel)) {
|
||||
return;
|
||||
}
|
||||
stream.write(
|
||||
`${JSON.stringify({
|
||||
time: now().toISOString(),
|
||||
component,
|
||||
level,
|
||||
event,
|
||||
...fields,
|
||||
})}\n`,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
debug: (event, fields) => write("debug", event, fields),
|
||||
info: (event, fields) => write("info", event, fields),
|
||||
warn: (event, fields) => write("warn", event, fields),
|
||||
error: (event, fields) => write("error", event, fields),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldWrite(
|
||||
level: DiscordBridgeLogLevel,
|
||||
configured: DiscordBridgeLogLevelSetting,
|
||||
): boolean {
|
||||
if (configured === "silent") {
|
||||
return false;
|
||||
}
|
||||
return logLevelRanks[level] >= logLevelRanks[configured];
|
||||
}
|
||||
225
apps/discord-bridge/src/pretty-log.ts
Normal file
225
apps/discord-bridge/src/pretty-log.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
#!/usr/bin/env bun
|
||||
import type { DiscordBridgeLogLevel } from "./logger.ts";
|
||||
|
||||
type PrettyLogOptions = {
|
||||
color?: boolean;
|
||||
name?: string;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
type PrettyLogRecord = Record<string, unknown> & {
|
||||
component?: unknown;
|
||||
event?: unknown;
|
||||
level?: unknown;
|
||||
message?: unknown;
|
||||
time?: unknown;
|
||||
};
|
||||
|
||||
const reservedFields = new Set(["time", "component", "level", "event"]);
|
||||
const resetColor = "\x1b[0m";
|
||||
const levelColors: Record<DiscordBridgeLogLevel, string> = {
|
||||
debug: "\x1b[90m",
|
||||
info: "\x1b[36m",
|
||||
warn: "\x1b[33m",
|
||||
error: "\x1b[31m",
|
||||
};
|
||||
|
||||
export function formatPrettyLogLine(
|
||||
line: string,
|
||||
options: PrettyLogOptions = {},
|
||||
): string {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const record = parseRecord(line);
|
||||
if (!record) {
|
||||
return formatParts({
|
||||
color: options.color ?? false,
|
||||
component: options.name ?? "process",
|
||||
fields: "",
|
||||
level: "info",
|
||||
message: line,
|
||||
time: formatTime(now()),
|
||||
});
|
||||
}
|
||||
|
||||
const level = normalizeLevel(record.level);
|
||||
const message = stringifyMainMessage(record);
|
||||
return formatParts({
|
||||
color: options.color ?? false,
|
||||
component: stringifyComponent(record.component, options.name),
|
||||
fields: stringifyFields(record),
|
||||
level,
|
||||
message,
|
||||
time: formatTime(record.time, now),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPrettyLogCli(
|
||||
args: string[],
|
||||
input: AsyncIterable<string | Uint8Array>,
|
||||
output: Pick<NodeJS.WriteStream, "write">,
|
||||
): Promise<void> {
|
||||
const options = parseCliArgs(args);
|
||||
let buffer = "";
|
||||
for await (const chunk of input) {
|
||||
buffer += typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8");
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = trimTrailingCarriageReturn(buffer.slice(0, newlineIndex));
|
||||
output.write(`${formatPrettyLogLine(line, options)}\n`);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
output.write(
|
||||
`${formatPrettyLogLine(trimTrailingCarriageReturn(buffer), options)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(args: string[]): PrettyLogOptions {
|
||||
const options: PrettyLogOptions = {
|
||||
color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--name") {
|
||||
const name = args[index + 1];
|
||||
if (!name) {
|
||||
throw new Error("Missing value for --name");
|
||||
}
|
||||
options.name = name;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--color") {
|
||||
options.color = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--no-color") {
|
||||
options.color = false;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unexpected argument: ${arg ?? ""}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseRecord(line: string): PrettyLogRecord | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
return value !== null && typeof value === "object"
|
||||
? value as PrettyLogRecord
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLevel(level: unknown): DiscordBridgeLogLevel {
|
||||
if (typeof level !== "string") {
|
||||
return "info";
|
||||
}
|
||||
const normalized = level.toLowerCase();
|
||||
if (
|
||||
normalized === "debug" || normalized === "info" || normalized === "warn" ||
|
||||
normalized === "error"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function stringifyComponent(component: unknown, fallback: string | undefined): string {
|
||||
return typeof component === "string" && component.length > 0
|
||||
? component
|
||||
: fallback ?? "process";
|
||||
}
|
||||
|
||||
function stringifyMainMessage(record: PrettyLogRecord): string {
|
||||
if (typeof record.event === "string" && record.event.length > 0) {
|
||||
return record.event;
|
||||
}
|
||||
if (typeof record.message === "string" && record.message.length > 0) {
|
||||
return record.message;
|
||||
}
|
||||
return "log";
|
||||
}
|
||||
|
||||
function stringifyFields(record: PrettyLogRecord): string {
|
||||
const fields: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (reservedFields.has(key) || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key === "message" && typeof record.event !== "string") {
|
||||
continue;
|
||||
}
|
||||
fields.push(`${key}=${stringifyFieldValue(value)}`);
|
||||
}
|
||||
return fields.join(" ");
|
||||
}
|
||||
|
||||
function stringifyFieldValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return /^[^\s=]+$/.test(value) ? value : JSON.stringify(value);
|
||||
}
|
||||
if (
|
||||
typeof value === "number" || typeof value === "boolean" || value === null
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
|
||||
function formatTime(time: unknown, now?: () => Date): string {
|
||||
const date = time instanceof Date
|
||||
? time
|
||||
: typeof time === "string" || typeof time === "number"
|
||||
? new Date(time)
|
||||
: now?.() ?? new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
const fallback = now?.() ?? new Date();
|
||||
return fallback.toISOString().slice(11, 23);
|
||||
}
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function formatParts(options: {
|
||||
color: boolean;
|
||||
component: string;
|
||||
fields: string;
|
||||
level: DiscordBridgeLogLevel;
|
||||
message: string;
|
||||
time: string;
|
||||
}): string {
|
||||
const level = options.level.toUpperCase().padEnd(5);
|
||||
const coloredLevel = colorize(level, levelColors[options.level], options.color);
|
||||
const message = options.fields.length > 0
|
||||
? `${options.message} ${options.fields}`
|
||||
: options.message;
|
||||
return `[${options.time}] ${coloredLevel} ${options.component} ${message}`;
|
||||
}
|
||||
|
||||
function colorize(text: string, color: string, enabled: boolean): string {
|
||||
return enabled ? `${color}${text}${resetColor}` : text;
|
||||
}
|
||||
|
||||
function trimTrailingCarriageReturn(line: string): string {
|
||||
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
await runPrettyLogCli(Bun.argv.slice(2), process.stdin, process.stdout);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`pretty-log failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
2208
apps/discord-bridge/src/runner.ts
Normal file
2208
apps/discord-bridge/src/runner.ts
Normal file
File diff suppressed because it is too large
Load diff
222
apps/discord-bridge/src/state.ts
Normal file
222
apps/discord-bridge/src/state.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type {
|
||||
DiscordBridgeActiveTurn,
|
||||
DiscordBridgeDelivery,
|
||||
DiscordBridgeQueueItem,
|
||||
DiscordBridgeSession,
|
||||
DiscordBridgeState,
|
||||
DiscordBridgeStateStore,
|
||||
} from "./types.ts";
|
||||
|
||||
const maxProcessedMessageIds = 1000;
|
||||
const maxDeliveries = 500;
|
||||
|
||||
export class JsonFileStateStore implements DiscordBridgeStateStore {
|
||||
readonly path: string;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.path = path.resolve(filePath);
|
||||
}
|
||||
|
||||
async load(): Promise<DiscordBridgeState> {
|
||||
const file = Bun.file(this.path);
|
||||
if (!(await file.exists())) {
|
||||
return emptyState();
|
||||
}
|
||||
const parsed = JSON.parse(await file.text()) as unknown;
|
||||
return parseState(parsed);
|
||||
}
|
||||
|
||||
async save(state: DiscordBridgeState): Promise<void> {
|
||||
trimState(state);
|
||||
await mkdir(path.dirname(this.path), { recursive: true });
|
||||
const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
await rename(tempPath, this.path);
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryStateStore implements DiscordBridgeStateStore {
|
||||
state: DiscordBridgeState;
|
||||
|
||||
constructor(state: DiscordBridgeState = emptyState()) {
|
||||
this.state = structuredClone(state);
|
||||
}
|
||||
|
||||
async load(): Promise<DiscordBridgeState> {
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
async save(state: DiscordBridgeState): Promise<void> {
|
||||
this.state = structuredClone(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyState(): DiscordBridgeState {
|
||||
return {
|
||||
version: 1,
|
||||
sessions: [],
|
||||
queue: [],
|
||||
activeTurns: [],
|
||||
processedMessageIds: [],
|
||||
deliveries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function trimState(state: DiscordBridgeState): void {
|
||||
state.processedMessageIds = state.processedMessageIds.slice(
|
||||
-maxProcessedMessageIds,
|
||||
);
|
||||
state.deliveries = state.deliveries.slice(-maxDeliveries);
|
||||
}
|
||||
|
||||
function parseState(value: unknown): DiscordBridgeState {
|
||||
if (!isRecord(value) || value.version !== 1) {
|
||||
throw new Error("Invalid Discord bridge state file");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sessions: Array.isArray(value.sessions)
|
||||
? value.sessions.map(parseSession)
|
||||
: [],
|
||||
queue: Array.isArray(value.queue) ? value.queue.map(parseQueueItem) : [],
|
||||
activeTurns: Array.isArray(value.activeTurns)
|
||||
? value.activeTurns.map(parseActiveTurn)
|
||||
: [],
|
||||
processedMessageIds: Array.isArray(value.processedMessageIds)
|
||||
? value.processedMessageIds.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string",
|
||||
)
|
||||
: [],
|
||||
deliveries: Array.isArray(value.deliveries)
|
||||
? value.deliveries.map(parseDelivery)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseActiveTurn(value: unknown): DiscordBridgeActiveTurn {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge active turn");
|
||||
}
|
||||
const origin = value.origin === "discord" || value.origin === "external"
|
||||
? value.origin
|
||||
: "external";
|
||||
return {
|
||||
turnId: requiredString(value.turnId, "activeTurns.turnId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "activeTurns.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "activeTurns.codexThreadId"),
|
||||
origin,
|
||||
queueItemId: optionalString(value.queueItemId),
|
||||
startedAt: optionalString(value.startedAt),
|
||||
observedAt: requiredString(value.observedAt, "activeTurns.observedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSession(value: unknown): DiscordBridgeSession {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge session");
|
||||
}
|
||||
return {
|
||||
discordThreadId: requiredString(value.discordThreadId, "session.discordThreadId"),
|
||||
parentChannelId: requiredString(value.parentChannelId, "session.parentChannelId"),
|
||||
guildId: optionalString(value.guildId),
|
||||
sourceMessageId: optionalString(value.sourceMessageId),
|
||||
codexThreadId: requiredString(value.codexThreadId, "session.codexThreadId"),
|
||||
title: requiredString(value.title, "session.title"),
|
||||
createdAt: requiredString(value.createdAt, "session.createdAt"),
|
||||
ownerUserId: optionalString(value.ownerUserId),
|
||||
participantUserIds: Array.isArray(value.participantUserIds)
|
||||
? uniqueStrings(value.participantUserIds)
|
||||
: undefined,
|
||||
cwd: optionalString(value.cwd),
|
||||
mode: parseSessionMode(value.mode),
|
||||
statusMessageId: optionalString(value.statusMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueueItem(value: unknown): DiscordBridgeQueueItem {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge queue item");
|
||||
}
|
||||
const status = value.status;
|
||||
if (status !== "pending" && status !== "processing" && status !== "failed") {
|
||||
throw new Error("Invalid Discord bridge queue item status");
|
||||
}
|
||||
return {
|
||||
id: requiredString(value.id, "queue.id"),
|
||||
status,
|
||||
discordMessageId: requiredString(value.discordMessageId, "queue.discordMessageId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "queue.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "queue.codexThreadId"),
|
||||
authorId: requiredString(value.authorId, "queue.authorId"),
|
||||
authorName: requiredString(value.authorName, "queue.authorName"),
|
||||
content: requiredString(value.content, "queue.content"),
|
||||
createdAt: requiredString(value.createdAt, "queue.createdAt"),
|
||||
receivedAt: requiredString(value.receivedAt, "queue.receivedAt"),
|
||||
attempts: optionalNumber(value.attempts) ?? 0,
|
||||
turnId: optionalString(value.turnId),
|
||||
lastError: optionalString(value.lastError),
|
||||
nextAttemptAt: optionalString(value.nextAttemptAt),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDelivery(value: unknown): DiscordBridgeDelivery {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Invalid Discord bridge delivery");
|
||||
}
|
||||
const kind = value.kind;
|
||||
if (
|
||||
kind !== "summary" &&
|
||||
kind !== "commentary" &&
|
||||
kind !== "final" &&
|
||||
kind !== "error"
|
||||
) {
|
||||
throw new Error("Invalid Discord bridge delivery kind");
|
||||
}
|
||||
return {
|
||||
discordMessageId: requiredString(value.discordMessageId, "delivery.discordMessageId"),
|
||||
discordThreadId: requiredString(value.discordThreadId, "delivery.discordThreadId"),
|
||||
codexThreadId: requiredString(value.codexThreadId, "delivery.codexThreadId"),
|
||||
turnId: optionalString(value.turnId),
|
||||
kind,
|
||||
outboundMessageIds: Array.isArray(value.outboundMessageIds)
|
||||
? value.outboundMessageIds.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string",
|
||||
)
|
||||
: [],
|
||||
deliveredAt: requiredString(value.deliveredAt, "delivery.deliveredAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, fieldName: string): string {
|
||||
const parsed = optionalString(value);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Discord bridge state ${fieldName}: expected string`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseSessionMode(value: unknown): DiscordBridgeSession["mode"] {
|
||||
return value === "new" || value === "resumed" ? value : undefined;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: unknown[]): string[] {
|
||||
return [...new Set(values.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
|
||||
))];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
177
apps/discord-bridge/src/types.ts
Normal file
177
apps/discord-bridge/src/types.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type {
|
||||
ReasoningEffort,
|
||||
ReasoningSummary,
|
||||
v2,
|
||||
} from "@peezy-tech/codex-flows/generated";
|
||||
import type { JsonRpcNotification, JsonRpcRequest } from "@peezy-tech/codex-flows/rpc";
|
||||
import type { DiscordBridgeLogLevelSetting } from "./logger.ts";
|
||||
|
||||
export type DiscordBridgeConfig = {
|
||||
allowedUserIds: Set<string>;
|
||||
allowedChannelIds: Set<string>;
|
||||
statePath: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
serviceTier?: string;
|
||||
effort?: ReasoningEffort;
|
||||
summary?: ReasoningSummary;
|
||||
approvalPolicy?: v2.AskForApproval;
|
||||
sandbox?: v2.SandboxMode;
|
||||
permissions?: v2.PermissionProfileSelectionParams;
|
||||
typingIntervalMs?: number;
|
||||
reconcileIntervalMs?: number;
|
||||
progressMode?: DiscordProgressMode;
|
||||
consoleOutput?: DiscordConsoleOutputMode;
|
||||
logLevel?: DiscordBridgeLogLevelSetting;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordProgressMode = "summary" | "commentary" | "none";
|
||||
export type DiscordConsoleOutputMode = "messages" | "none";
|
||||
|
||||
export type DiscordAuthor = {
|
||||
id: string;
|
||||
name: string;
|
||||
isBot: boolean;
|
||||
};
|
||||
|
||||
export type DiscordMessageInbound = {
|
||||
kind: "message";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
messageId: string;
|
||||
author: DiscordAuthor;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadStartInbound = {
|
||||
kind: "threadStart";
|
||||
sourceMessageId: string;
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
prompt?: string;
|
||||
mentionedUserIds?: string[];
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordClearInbound = {
|
||||
kind: "clear";
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
author: DiscordAuthor;
|
||||
createdAt: string;
|
||||
reply?: (text: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DiscordInbound =
|
||||
| DiscordMessageInbound
|
||||
| DiscordThreadStartInbound
|
||||
| DiscordClearInbound;
|
||||
|
||||
export type DiscordBridgeTransportHandlers = {
|
||||
onInbound(inbound: DiscordInbound): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeTransport = {
|
||||
start(handlers: DiscordBridgeTransportHandlers): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
registerCommands(): Promise<void>;
|
||||
createThread(
|
||||
channelId: string,
|
||||
name: string,
|
||||
sourceMessageId?: string,
|
||||
): Promise<string>;
|
||||
sendMessage(channelId: string, text: string): Promise<string[]>;
|
||||
updateMessage?(channelId: string, messageId: string, text: string): Promise<void>;
|
||||
deleteMessage(channelId: string, messageId: string): Promise<void>;
|
||||
deleteThread?(channelId: string): Promise<void>;
|
||||
addThreadMembers?(channelId: string, userIds: string[]): Promise<void>;
|
||||
pinMessage?(channelId: string, messageId: string): Promise<void>;
|
||||
sendTyping(channelId: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type CodexBridgeClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): void;
|
||||
on(event: "notification", listener: (message: JsonRpcNotification) => void): unknown;
|
||||
on(event: "request", listener: (message: JsonRpcRequest) => void): unknown;
|
||||
startThread(params: v2.ThreadStartParams): Promise<v2.ThreadStartResponse>;
|
||||
resumeThread(params: v2.ThreadResumeParams): Promise<v2.ThreadResumeResponse>;
|
||||
setThreadName(params: v2.ThreadSetNameParams): Promise<v2.ThreadSetNameResponse>;
|
||||
startTurn(params: v2.TurnStartParams): Promise<v2.TurnStartResponse>;
|
||||
steerTurn(params: v2.TurnSteerParams): Promise<v2.TurnSteerResponse>;
|
||||
readThread(params: v2.ThreadReadParams): Promise<v2.ThreadReadResponse>;
|
||||
getThreadGoal(params: v2.ThreadGoalGetParams): Promise<v2.ThreadGoalGetResponse>;
|
||||
respondError(id: string | number, code: number, message: string, data?: unknown): void;
|
||||
};
|
||||
|
||||
export type DiscordBridgeState = {
|
||||
version: 1;
|
||||
sessions: DiscordBridgeSession[];
|
||||
queue: DiscordBridgeQueueItem[];
|
||||
activeTurns: DiscordBridgeActiveTurn[];
|
||||
processedMessageIds: string[];
|
||||
deliveries: DiscordBridgeDelivery[];
|
||||
};
|
||||
|
||||
export type DiscordBridgeSession = {
|
||||
discordThreadId: string;
|
||||
parentChannelId: string;
|
||||
guildId?: string;
|
||||
sourceMessageId?: string;
|
||||
codexThreadId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
ownerUserId?: string;
|
||||
participantUserIds?: string[];
|
||||
cwd?: string;
|
||||
mode?: "new" | "resumed";
|
||||
statusMessageId?: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeQueueItem = {
|
||||
id: string;
|
||||
status: "pending" | "processing" | "failed";
|
||||
discordMessageId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
receivedAt: string;
|
||||
attempts: number;
|
||||
turnId?: string;
|
||||
lastError?: string;
|
||||
nextAttemptAt?: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeActiveTurn = {
|
||||
turnId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
origin: "discord" | "external";
|
||||
queueItemId?: string;
|
||||
startedAt?: string;
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeDelivery = {
|
||||
discordMessageId: string;
|
||||
discordThreadId: string;
|
||||
codexThreadId: string;
|
||||
turnId?: string;
|
||||
kind: "summary" | "commentary" | "final" | "error";
|
||||
outboundMessageIds: string[];
|
||||
deliveredAt: string;
|
||||
};
|
||||
|
||||
export type DiscordBridgeStateStore = {
|
||||
load(): Promise<DiscordBridgeState>;
|
||||
save(state: DiscordBridgeState): Promise<void>;
|
||||
};
|
||||
2340
apps/discord-bridge/test/bridge.test.ts
Normal file
2340
apps/discord-bridge/test/bridge.test.ts
Normal file
File diff suppressed because it is too large
Load diff
223
apps/discord-bridge/test/config.test.ts
Normal file
223
apps/discord-bridge/test/config.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { parseConfig } from "../src/config.ts";
|
||||
|
||||
describe("parseConfig", () => {
|
||||
test("resolves --dir relative to the home directory", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"projects/demo",
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
|
||||
}
|
||||
});
|
||||
|
||||
test("expands tilde dir paths from the home directory", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"~/projects/demo",
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "projects/demo"));
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts one positional directory for root script usage", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
"~/game-protocol-workspace",
|
||||
],
|
||||
{ CODEX_DISCORD_DIR: "env-dir" },
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.localAppServer).toBe(true);
|
||||
expect(parsed.config.cwd).toBe(
|
||||
path.join(os.homedir(), "game-protocol-workspace"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects multiple directory arguments", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"one",
|
||||
"two",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Unexpected argument: two");
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--dir",
|
||||
"one",
|
||||
"two",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Cannot set both positional directory and --dir/--cwd.");
|
||||
});
|
||||
|
||||
test("prefers CODEX_DISCORD_DIR over legacy cwd env", () => {
|
||||
const parsed = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{
|
||||
CODEX_DISCORD_DIR: "current",
|
||||
CODEX_DISCORD_CWD: "/legacy",
|
||||
},
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.config.cwd).toBe(path.join(os.homedir(), "current"));
|
||||
}
|
||||
});
|
||||
|
||||
test("enables debug logging from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1", "--debug"],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{ CODEX_DISCORD_DEBUG: "true" },
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.debug).toBe(true);
|
||||
expect(fromEnv.config.debug).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("parses progress mode from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--progress-mode",
|
||||
"commentary",
|
||||
],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{ CODEX_DISCORD_PROGRESS_MODE: "none" },
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.progressMode).toBe("commentary");
|
||||
expect(fromEnv.config.progressMode).toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses console output and log level from flag or environment", () => {
|
||||
const fromFlag = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--console-output",
|
||||
"messages",
|
||||
"--log-level",
|
||||
"warn",
|
||||
],
|
||||
{},
|
||||
);
|
||||
const fromEnv = parseConfig(
|
||||
["--token", "discord-token", "--allowed-user-ids", "user-1"],
|
||||
{
|
||||
CODEX_DISCORD_CONSOLE_OUTPUT: "none",
|
||||
CODEX_DISCORD_LOG_LEVEL: "silent",
|
||||
},
|
||||
);
|
||||
|
||||
expect(fromFlag.type).toBe("run");
|
||||
expect(fromEnv.type).toBe("run");
|
||||
if (fromFlag.type === "run" && fromEnv.type === "run") {
|
||||
expect(fromFlag.config.consoleOutput).toBe("messages");
|
||||
expect(fromFlag.config.logLevel).toBe("warn");
|
||||
expect(fromEnv.config.consoleOutput).toBe("none");
|
||||
expect(fromEnv.config.logLevel).toBe("silent");
|
||||
}
|
||||
});
|
||||
|
||||
test("can force a local app-server even when workspace URL env is set", () => {
|
||||
const parsed = parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
],
|
||||
{ CODEX_WORKSPACE_APP_SERVER_WS_URL: "ws://127.0.0.1:9999" },
|
||||
);
|
||||
|
||||
expect(parsed.type).toBe("run");
|
||||
if (parsed.type === "run") {
|
||||
expect(parsed.localAppServer).toBe(true);
|
||||
expect(parsed.appServerUrl).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects mixing local and explicit external app-server modes", () => {
|
||||
expect(() =>
|
||||
parseConfig(
|
||||
[
|
||||
"--token",
|
||||
"discord-token",
|
||||
"--allowed-user-ids",
|
||||
"user-1",
|
||||
"--local-app-server",
|
||||
"--app-server-url",
|
||||
"ws://127.0.0.1:9999",
|
||||
],
|
||||
{},
|
||||
)
|
||||
).toThrow("Cannot set both --local-app-server and --app-server-url.");
|
||||
});
|
||||
});
|
||||
79
apps/discord-bridge/test/console-output.test.ts
Normal file
79
apps/discord-bridge/test/console-output.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
createDiscordConsoleOutput,
|
||||
formatConsoleMessage,
|
||||
} from "../src/console-output.ts";
|
||||
|
||||
describe("discord bridge console output", () => {
|
||||
test("formats delivered assistant messages for terminal output", () => {
|
||||
expect(
|
||||
formatConsoleMessage(
|
||||
{
|
||||
kind: "final",
|
||||
text: "Repo scan complete.\nNo regressions found.",
|
||||
discordThreadId: "discord-thread-123456",
|
||||
codexThreadId: "codex-thread-abcdef",
|
||||
turnId: "turn-1234567890",
|
||||
title: "Scan repo",
|
||||
at: new Date("2026-05-12T04:22:00.123Z"),
|
||||
},
|
||||
{ color: false },
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"[04:22:00.123] FINAL Scan repo thread=codex-...cdef turn=turn-1...7890",
|
||||
" Repo scan complete.",
|
||||
" No regressions found.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("writes one formatted block per message", () => {
|
||||
const output = createMemoryOutput();
|
||||
const consoleOutput = createDiscordConsoleOutput({
|
||||
color: false,
|
||||
now: () => new Date("2026-05-12T04:22:01.456Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
consoleOutput.message({
|
||||
kind: "commentary",
|
||||
text: "I will inspect the bridge.",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
turnId: "turn-1",
|
||||
title: "Bridge status",
|
||||
});
|
||||
|
||||
expect(output.text).toBe(
|
||||
[
|
||||
"[04:22:01.456] COMMENTARY Bridge status thread=codex-...ad-1 turn=turn-1",
|
||||
" I will inspect the bridge.",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMemoryOutput(): {
|
||||
readonly stream: Pick<NodeJS.WriteStream, "write">;
|
||||
readonly text: string;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
stream: {
|
||||
write: ((chunk: string | Uint8Array) => {
|
||||
chunks.push(
|
||||
typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8"),
|
||||
);
|
||||
return true;
|
||||
}) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
get text() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
98
apps/discord-bridge/test/logger.test.ts
Normal file
98
apps/discord-bridge/test/logger.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createDiscordBridgeLogger } from "../src/logger.ts";
|
||||
import { formatPrettyLogLine } from "../src/pretty-log.ts";
|
||||
|
||||
describe("discord bridge logger", () => {
|
||||
test("writes info logs as structured json and gates debug logs", () => {
|
||||
const output = createMemoryOutput();
|
||||
const logger = createDiscordBridgeLogger({
|
||||
component: "test-bridge",
|
||||
now: () => new Date("2026-05-12T04:22:00.123Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
logger.debug("hidden.debug", { threadId: "thread-1" });
|
||||
logger.info("bridge.started", {
|
||||
appServerUrl: "local",
|
||||
statePath: "/tmp/discord-state.json",
|
||||
});
|
||||
|
||||
const lines = output.text.trim().split("\n");
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0] ?? "")).toEqual({
|
||||
time: "2026-05-12T04:22:00.123Z",
|
||||
component: "test-bridge",
|
||||
level: "info",
|
||||
event: "bridge.started",
|
||||
appServerUrl: "local",
|
||||
statePath: "/tmp/discord-state.json",
|
||||
});
|
||||
});
|
||||
|
||||
test("filters logs below the configured log level", () => {
|
||||
const output = createMemoryOutput();
|
||||
const logger = createDiscordBridgeLogger({
|
||||
component: "test-bridge",
|
||||
logLevel: "warn",
|
||||
now: () => new Date("2026-05-12T04:22:00.123Z"),
|
||||
stream: output.stream,
|
||||
});
|
||||
|
||||
logger.debug("hidden.debug");
|
||||
logger.info("hidden.info");
|
||||
logger.warn("visible.warn");
|
||||
logger.error("visible.error");
|
||||
|
||||
expect(output.text.trim().split("\n").map((line) => JSON.parse(line).event))
|
||||
.toEqual(["visible.warn", "visible.error"]);
|
||||
});
|
||||
|
||||
test("pretty prints structured json logs and plain process output", () => {
|
||||
const structured = formatPrettyLogLine(
|
||||
JSON.stringify({
|
||||
time: "2026-05-12T04:22:00.123Z",
|
||||
component: "codex-discord-bridge",
|
||||
level: "info",
|
||||
event: "bridge.started",
|
||||
appServerUrl: "local",
|
||||
localAppServer: true,
|
||||
}),
|
||||
{ color: false },
|
||||
);
|
||||
const plain = formatPrettyLogLine("listening on ws://127.0.0.1:3585", {
|
||||
color: false,
|
||||
name: "codex-remote-control",
|
||||
now: () => new Date("2026-05-12T04:22:01.456Z"),
|
||||
});
|
||||
|
||||
expect(structured).toBe(
|
||||
"[04:22:00.123] INFO codex-discord-bridge bridge.started appServerUrl=local localAppServer=true",
|
||||
);
|
||||
expect(plain).toBe(
|
||||
"[04:22:01.456] INFO codex-remote-control listening on ws://127.0.0.1:3585",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createMemoryOutput(): {
|
||||
readonly stream: Pick<NodeJS.WriteStream, "write">;
|
||||
readonly text: string;
|
||||
} {
|
||||
const chunks: string[] = [];
|
||||
return {
|
||||
stream: {
|
||||
write: ((chunk: string | Uint8Array) => {
|
||||
chunks.push(
|
||||
typeof chunk === "string"
|
||||
? chunk
|
||||
: Buffer.from(chunk).toString("utf8"),
|
||||
);
|
||||
return true;
|
||||
}) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
get text() {
|
||||
return chunks.join("");
|
||||
},
|
||||
};
|
||||
}
|
||||
103
apps/discord-bridge/test/state.test.ts
Normal file
103
apps/discord-bridge/test/state.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { JsonFileStateStore } from "../src/state.ts";
|
||||
|
||||
describe("JsonFileStateStore", () => {
|
||||
test("loads per-thread grant metadata and older sessions without grants", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "discord-bridge-state-"));
|
||||
try {
|
||||
const statePath = path.join(dir, "state.json");
|
||||
await writeFile(
|
||||
statePath,
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sessions: [
|
||||
{
|
||||
discordThreadId: "discord-thread-1",
|
||||
parentChannelId: "parent-channel",
|
||||
sourceMessageId: "message-start-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
title: "Granted thread",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
ownerUserId: "user-1",
|
||||
participantUserIds: ["user-2", "", "user-2", "user-3"],
|
||||
cwd: "/workspace/project",
|
||||
mode: "resumed",
|
||||
statusMessageId: "message-status-1",
|
||||
},
|
||||
{
|
||||
discordThreadId: "discord-thread-2",
|
||||
parentChannelId: "parent-channel",
|
||||
codexThreadId: "codex-thread-2",
|
||||
title: "Older thread",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
queue: [],
|
||||
activeTurns: [
|
||||
{
|
||||
turnId: "turn-active-1",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
origin: "external",
|
||||
startedAt: "2026-05-11T00:00:01.000Z",
|
||||
observedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
turnId: "turn-active-2",
|
||||
discordThreadId: "discord-thread-2",
|
||||
codexThreadId: "codex-thread-2",
|
||||
origin: "unknown",
|
||||
queueItemId: "queue-1",
|
||||
observedAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
],
|
||||
processedMessageIds: [],
|
||||
deliveries: [],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const state = await new JsonFileStateStore(statePath).load();
|
||||
|
||||
expect(state.sessions).toHaveLength(2);
|
||||
expect(state.sessions[0]?.ownerUserId).toBe("user-1");
|
||||
expect(state.sessions[0]?.sourceMessageId).toBe("message-start-1");
|
||||
expect(state.sessions[0]?.participantUserIds).toEqual([
|
||||
"user-2",
|
||||
"user-3",
|
||||
]);
|
||||
expect(state.sessions[0]?.cwd).toBe("/workspace/project");
|
||||
expect(state.sessions[0]?.mode).toBe("resumed");
|
||||
expect(state.sessions[0]?.statusMessageId).toBe("message-status-1");
|
||||
expect(state.sessions[1]?.ownerUserId).toBeUndefined();
|
||||
expect(state.sessions[1]?.sourceMessageId).toBeUndefined();
|
||||
expect(state.sessions[1]?.participantUserIds).toBeUndefined();
|
||||
expect(state.sessions[1]?.cwd).toBeUndefined();
|
||||
expect(state.sessions[1]?.mode).toBeUndefined();
|
||||
expect(state.sessions[1]?.statusMessageId).toBeUndefined();
|
||||
expect(state.activeTurns).toEqual([
|
||||
{
|
||||
turnId: "turn-active-1",
|
||||
discordThreadId: "discord-thread-1",
|
||||
codexThreadId: "codex-thread-1",
|
||||
origin: "external",
|
||||
startedAt: "2026-05-11T00:00:01.000Z",
|
||||
observedAt: "2026-05-11T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
turnId: "turn-active-2",
|
||||
discordThreadId: "discord-thread-2",
|
||||
codexThreadId: "codex-thread-2",
|
||||
origin: "external",
|
||||
queueItemId: "queue-1",
|
||||
observedAt: "2026-05-11T00:00:03.000Z",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
25
apps/discord-bridge/tsconfig.json
Normal file
25
apps/discord-bridge/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "bun"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@peezy-tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
|
||||
"@peezy-tech/codex-flows/*": ["../../packages/codex-client/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue