feat: add providers CLI and multi-account onboarding

This commit is contained in:
Peter Steinberger
2026-01-08 01:18:37 +01:00
parent 6b3ed40d0f
commit 05b8679c8b
54 changed files with 4399 additions and 1448 deletions

View File

@@ -42,7 +42,7 @@ import {
} from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ReplyToMode } from "../config/config.js";
import type { ClawdbotConfig, ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
@@ -62,12 +62,15 @@ import {
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscordApplicationId } from "./probe.js";
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = {
token?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
@@ -244,16 +247,15 @@ function summarizeGuilds(entries?: Record<string, DiscordGuildEntryResolved>) {
}
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig();
const token = normalizeDiscordToken(
opts.token ??
process.env.DISCORD_BOT_TOKEN ??
cfg.discord?.token ??
undefined,
);
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
if (!token) {
throw new Error(
"DISCORD_BOT_TOKEN or discord.token is required for Discord gateway",
`Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
}
@@ -265,18 +267,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
};
const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds;
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
const discordCfg = account.config;
const dmConfig = discordCfg.dm;
const guildEntries = discordCfg.guilds;
const groupPolicy = discordCfg.groupPolicy ?? "open";
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord");
(opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId);
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
opts.historyLimit ?? discordCfg.historyLimit ?? 20,
);
const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off";
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = dmConfig?.policy ?? "pairing";
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
@@ -303,6 +306,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
createDiscordNativeCommand({
command: spec,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
ephemeralDefault,
}),
@@ -359,6 +364,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const messageHandler = createDiscordMessageHandler({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
token,
runtime,
botUserId,
@@ -377,6 +384,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
client.listeners.push(new DiscordMessageListener(messageHandler, logger));
client.listeners.push(
new DiscordReactionListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
@@ -385,6 +394,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
);
client.listeners.push(
new DiscordReactionRemoveListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
@@ -431,6 +442,8 @@ async function clearDiscordNativeCommands(params: {
export function createDiscordMessageHandler(params: {
cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
token: string;
runtime: RuntimeEnv;
botUserId?: string;
@@ -447,6 +460,8 @@ export function createDiscordMessageHandler(params: {
}): DiscordMessageHandler {
const {
cfg,
discordConfig,
accountId,
token,
runtime,
botUserId,
@@ -465,7 +480,7 @@ export function createDiscordMessageHandler(params: {
const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
return async (data, client) => {
try {
@@ -490,7 +505,7 @@ export function createDiscordMessageHandler(params: {
return;
}
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing";
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
@@ -539,7 +554,7 @@ export function createDiscordMessageHandler(params: {
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
{ token, rest: client.rest },
{ token, rest: client.rest, accountId },
);
} catch (err) {
logVerbose(
@@ -633,6 +648,7 @@ export function createDiscordMessageHandler(params: {
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
@@ -988,6 +1004,7 @@ export function createDiscordMessageHandler(params: {
replies: [payload],
target: replyTarget,
token,
accountId,
rest: client.rest,
runtime,
replyToMode,
@@ -1068,6 +1085,8 @@ class DiscordMessageListener extends MessageCreateListener {
class DiscordReactionListener extends MessageReactionAddListener {
constructor(
private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
@@ -1084,6 +1103,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
data,
client,
action: "added",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries,
logger: this.params.logger,
@@ -1102,6 +1123,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
constructor(
private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
@@ -1118,6 +1141,8 @@ class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
data,
client,
action: "removed",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries,
logger: this.params.logger,
@@ -1137,6 +1162,8 @@ async function handleDiscordReactionEvent(params: {
data: DiscordReactionEvent;
client: Client;
action: "added" | "removed";
cfg: ReturnType<typeof loadConfig>;
accountId: string;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: ReturnType<typeof getChildLogger>;
@@ -1202,10 +1229,10 @@ async function handleDiscordReactionEvent(params: {
: undefined;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
const cfg = loadConfig();
const route = resolveAgentRoute({
cfg,
cfg: params.cfg,
provider: "discord",
accountId: params.accountId,
guildId: data.guild_id ?? undefined,
peer: { kind: "channel", id: data.channel_id },
});
@@ -1227,10 +1254,19 @@ function createDiscordNativeCommand(params: {
acceptsArgs: boolean;
};
cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
}) {
const { command, cfg, sessionPrefix, ephemeralDefault } = params;
const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
} = params;
return new (class extends Command {
name = command.name;
description = command.description;
@@ -1266,7 +1302,7 @@ function createDiscordNativeCommand(params: {
);
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildEntries: cfg.discord?.guilds,
guildEntries: discordConfig?.guilds,
});
const channelConfig = interaction.guild
? resolveDiscordChannelConfig({
@@ -1294,7 +1330,7 @@ function createDiscordNativeCommand(params: {
Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: cfg.discord?.groupPolicy ?? "open",
groupPolicy: discordConfig?.groupPolicy ?? "open",
channelAllowlistConfigured,
channelAllowed,
});
@@ -1305,8 +1341,8 @@ function createDiscordNativeCommand(params: {
return;
}
}
const dmEnabled = cfg.discord?.dm?.enabled ?? true;
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing";
const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
@@ -1318,7 +1354,7 @@ function createDiscordNativeCommand(params: {
"discord",
).catch(() => []);
const effectiveAllowFrom = [
...(cfg.discord?.dm?.allowFrom ?? []),
...(discordConfig?.dm?.allowFrom ?? []),
...storeAllowFrom,
];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
@@ -1384,7 +1420,7 @@ function createDiscordNativeCommand(params: {
}
}
}
if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) {
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
await interaction.reply({ content: "Discord group DMs are disabled." });
return;
}
@@ -1395,6 +1431,7 @@ function createDiscordNativeCommand(params: {
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
@@ -1544,6 +1581,7 @@ async function deliverDiscordReply(params: {
replies: ReplyPayload[];
target: string;
token: string;
accountId?: string;
rest?: RequestClient;
runtime: RuntimeEnv;
textLimit: number;
@@ -1563,6 +1601,7 @@ async function deliverDiscordReply(params: {
await sendMessageDiscord(params.target, trimmed, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
});
}
continue;
@@ -1574,12 +1613,14 @@ async function deliverDiscordReply(params: {
token: params.token,
rest: params.rest,
mediaUrl: firstMedia,
accountId: params.accountId,
});
for (const extra of mediaList.slice(1)) {
await sendMessageDiscord(params.target, "", {
token: params.token,
rest: params.rest,
mediaUrl: extra,
accountId: params.accountId,
});
}
}