feat: add providers CLI and multi-account onboarding
This commit is contained in:
81
src/discord/accounts.ts
Normal file
81
src/discord/accounts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DiscordAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
|
||||
export type ResolvedDiscordAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "config" | "none";
|
||||
config: DiscordAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.discord?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listDiscordAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultDiscordAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listDiscordAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): DiscordAccountConfig | undefined {
|
||||
const accounts = cfg.discord?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as DiscordAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeDiscordAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): DiscordAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.discord ??
|
||||
{}) as DiscordAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveDiscordAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedDiscordAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.discord?.enabled !== false;
|
||||
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledDiscordAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedDiscordAccount[] {
|
||||
return listDiscordAccountIds(cfg)
|
||||
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ describe("discord tool result dispatch", () => {
|
||||
const runtimeError = vi.fn();
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
@@ -115,6 +117,8 @@ describe("discord tool result dispatch", () => {
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
@@ -197,6 +201,8 @@ describe("discord tool result dispatch", () => {
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
@@ -306,6 +312,8 @@ describe("discord tool result dispatch", () => {
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type PollInput,
|
||||
} from "../polls.js";
|
||||
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
@@ -74,6 +75,7 @@ type DiscordRecipient =
|
||||
|
||||
type DiscordSendOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
verbose?: boolean;
|
||||
rest?: RequestClient;
|
||||
@@ -88,6 +90,7 @@ export type DiscordSendResult = {
|
||||
|
||||
export type DiscordReactOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
verbose?: boolean;
|
||||
retry?: RetryConfig;
|
||||
@@ -179,17 +182,20 @@ export type DiscordStickerUpload = {
|
||||
mediaUrl: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().discord?.token;
|
||||
const token = normalizeDiscordToken(
|
||||
explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined,
|
||||
);
|
||||
if (!token) {
|
||||
function resolveToken(params: {
|
||||
explicit?: string;
|
||||
accountId: string;
|
||||
fallbackToken?: string;
|
||||
}) {
|
||||
const explicit = normalizeDiscordToken(params.explicit);
|
||||
if (explicit) return explicit;
|
||||
const fallback = normalizeDiscordToken(params.fallbackToken);
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
"DISCORD_BOT_TOKEN or discord.token is required for Discord sends",
|
||||
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveRest(token: string, rest?: RequestClient) {
|
||||
@@ -198,22 +204,32 @@ function resolveRest(token: string, rest?: RequestClient) {
|
||||
|
||||
type DiscordClientOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
retry?: RetryConfig;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
|
||||
const token = resolveToken(opts.token);
|
||||
const account = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
accountId: account.accountId,
|
||||
fallbackToken: account.token,
|
||||
});
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const request = createDiscordRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: cfg.discord?.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
return { token, rest, request };
|
||||
}
|
||||
|
||||
function resolveDiscordRest(opts: DiscordClientOpts) {
|
||||
return createDiscordClient(opts).rest;
|
||||
}
|
||||
|
||||
function normalizeReactionEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -635,8 +651,7 @@ export async function removeReactionDiscord(
|
||||
emoji: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const encoded = normalizeReactionEmoji(emoji);
|
||||
await rest.delete(
|
||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||
@@ -649,8 +664,7 @@ export async function removeOwnReactionsDiscord(
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<{ ok: true; removed: string[] }> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
@@ -683,8 +697,7 @@ export async function fetchReactionsDiscord(
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts & { limit?: number } = {},
|
||||
): Promise<DiscordReactionSummary[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
@@ -733,8 +746,7 @@ export async function fetchChannelPermissionsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<DiscordPermissionsSummary> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
const channelType = "type" in channel ? channel.type : undefined;
|
||||
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||
@@ -808,8 +820,7 @@ export async function readMessagesDiscord(
|
||||
query: DiscordMessageQuery = {},
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const limit =
|
||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||
@@ -831,8 +842,7 @@ export async function editMessageDiscord(
|
||||
payload: DiscordMessageEdit,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: { content: payload.content },
|
||||
})) as APIMessage;
|
||||
@@ -843,8 +853,7 @@ export async function deleteMessageDiscord(
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -854,8 +863,7 @@ export async function pinMessageDiscord(
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.put(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -865,8 +873,7 @@ export async function unpinMessageDiscord(
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -875,8 +882,7 @@ export async function listPinsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||
}
|
||||
|
||||
@@ -885,8 +891,7 @@ export async function createThreadDiscord(
|
||||
payload: DiscordThreadCreate,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.autoArchiveMinutes) {
|
||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||
@@ -899,8 +904,7 @@ export async function listThreadsDiscord(
|
||||
payload: DiscordThreadList,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
if (payload.includeArchived) {
|
||||
if (!payload.channelId) {
|
||||
throw new Error("channelId required to list archived threads");
|
||||
@@ -920,8 +924,7 @@ export async function searchMessagesDiscord(
|
||||
query: DiscordSearchQuery,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const params = new URLSearchParams();
|
||||
params.set("content", query.content);
|
||||
if (query.channelIds?.length) {
|
||||
@@ -947,8 +950,7 @@ export async function listGuildEmojisDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return await rest.get(Routes.guildEmojis(guildId));
|
||||
}
|
||||
|
||||
@@ -956,8 +958,7 @@ export async function uploadEmojiDiscord(
|
||||
payload: DiscordEmojiUpload,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const media = await loadWebMediaRaw(
|
||||
payload.mediaUrl,
|
||||
DISCORD_MAX_EMOJI_BYTES,
|
||||
@@ -986,8 +987,7 @@ export async function uploadStickerDiscord(
|
||||
payload: DiscordStickerUpload,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const media = await loadWebMediaRaw(
|
||||
payload.mediaUrl,
|
||||
DISCORD_MAX_STICKER_BYTES,
|
||||
@@ -1025,8 +1025,7 @@ export async function fetchMemberInfoDiscord(
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildMember(guildId, userId),
|
||||
)) as APIGuildMember;
|
||||
@@ -1036,8 +1035,7 @@ export async function fetchRoleInfoDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIRole[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||
}
|
||||
|
||||
@@ -1045,8 +1043,7 @@ export async function addRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.put(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
@@ -1057,8 +1054,7 @@ export async function removeRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
@@ -1069,8 +1065,7 @@ export async function fetchChannelInfoDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
}
|
||||
|
||||
@@ -1078,8 +1073,7 @@ export async function listGuildChannelsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||
}
|
||||
|
||||
@@ -1088,8 +1082,7 @@ export async function fetchVoiceStatusDiscord(
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIVoiceState> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildVoiceState(guildId, userId),
|
||||
)) as APIVoiceState;
|
||||
@@ -1099,8 +1092,7 @@ export async function listScheduledEventsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.get(
|
||||
Routes.guildScheduledEvents(guildId),
|
||||
)) as APIGuildScheduledEvent[];
|
||||
@@ -1111,8 +1103,7 @@ export async function createScheduledEventDiscord(
|
||||
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||
body: payload,
|
||||
})) as APIGuildScheduledEvent;
|
||||
@@ -1122,8 +1113,7 @@ export async function timeoutMemberDiscord(
|
||||
payload: DiscordTimeoutTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
@@ -1144,8 +1134,7 @@ export async function kickMemberDiscord(
|
||||
payload: DiscordModerationTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
||||
headers: payload.reason
|
||||
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||
@@ -1158,8 +1147,7 @@ export async function banMemberDiscord(
|
||||
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = resolveRest(token, opts.rest);
|
||||
const rest = resolveDiscordRest(opts);
|
||||
const deleteMessageDays =
|
||||
typeof payload.deleteMessageDays === "number" &&
|
||||
Number.isFinite(payload.deleteMessageDays)
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type DiscordTokenSource = "env" | "config" | "none";
|
||||
|
||||
export type DiscordTokenResolution = {
|
||||
token: string;
|
||||
source: DiscordTokenSource;
|
||||
};
|
||||
|
||||
export function normalizeDiscordToken(raw?: string | null): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^Bot\s+/i, "");
|
||||
}
|
||||
|
||||
export function resolveDiscordToken(
|
||||
cfg?: ClawdbotConfig,
|
||||
opts: { accountId?: string | null; envToken?: string | null } = {},
|
||||
): DiscordTokenResolution {
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
const accountCfg =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? cfg?.discord?.accounts?.[accountId]
|
||||
: cfg?.discord?.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
const accountToken = normalizeDiscordToken(accountCfg?.token ?? undefined);
|
||||
if (accountToken) return { token: accountToken, source: "config" };
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv
|
||||
? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN)
|
||||
: undefined;
|
||||
if (envToken) return { token: envToken, source: "env" };
|
||||
|
||||
const configToken = allowEnv
|
||||
? normalizeDiscordToken(cfg?.discord?.token ?? undefined)
|
||||
: undefined;
|
||||
if (configToken) return { token: configToken, source: "config" };
|
||||
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user