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

@@ -4,16 +4,36 @@ import {
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../../signal/accounts.js";
import { probeSignal, type SignalProbe } from "../../signal/probe.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "../../slack/accounts.js";
import { probeSlack, type SlackProbe } from "../../slack/probe.js";
import {
resolveSlackAppToken,
resolveSlackBotToken,
} from "../../slack/token.js";
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
listEnabledWhatsAppAccounts,
resolveDefaultWhatsAppAccountId,
@@ -50,112 +70,193 @@ export const providersHandlers: GatewayRequestHandlers = {
const timeoutMs =
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
const cfg = loadConfig();
const telegramCfg = cfg.telegram;
const telegramEnabled =
Boolean(telegramCfg) && telegramCfg?.enabled !== false;
const { token: telegramToken, source: tokenSource } = telegramEnabled
? resolveTelegramToken(cfg)
: { token: "", source: "none" as const };
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && telegramToken && telegramEnabled) {
telegramProbe = await probeTelegram(
telegramToken,
timeoutMs,
telegramCfg?.proxy,
);
lastProbeAt = Date.now();
}
const runtime = context.getRuntimeSnapshot();
const discordCfg = cfg.discord;
const discordEnabled = Boolean(discordCfg) && discordCfg?.enabled !== false;
const discordEnvToken = discordEnabled
? process.env.DISCORD_BOT_TOKEN?.trim()
: "";
const discordConfigToken = discordEnabled ? discordCfg?.token?.trim() : "";
const discordToken = discordEnvToken || discordConfigToken || "";
const discordTokenSource = discordEnvToken
? "env"
: discordConfigToken
? "config"
: "none";
let discordProbe: DiscordProbe | undefined;
let discordLastProbeAt: number | null = null;
if (probe && discordToken && discordEnabled) {
discordProbe = await probeDiscord(discordToken, timeoutMs);
discordLastProbeAt = Date.now();
}
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
const slackCfg = cfg.slack;
const slackEnabled = slackCfg?.enabled !== false;
const slackBotEnvToken = slackEnabled
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
: undefined;
const slackBotConfigToken = slackEnabled
? resolveSlackBotToken(slackCfg?.botToken)
: undefined;
const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? "";
const slackBotTokenSource = slackBotEnvToken
? "env"
: slackBotConfigToken
? "config"
: "none";
const slackAppEnvToken = slackEnabled
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
: undefined;
const slackAppConfigToken = slackEnabled
? resolveSlackAppToken(slackCfg?.appToken)
: undefined;
const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? "";
const slackAppTokenSource = slackAppEnvToken
? "env"
: slackAppConfigToken
? "config"
: "none";
const slackConfigured =
slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken);
let slackProbe: SlackProbe | undefined;
let slackLastProbeAt: number | null = null;
if (probe && slackConfigured) {
slackProbe = await probeSlack(slackBotToken, timeoutMs);
slackLastProbeAt = Date.now();
}
const telegramAccounts = await Promise.all(
listTelegramAccountIds(cfg).map(async (accountId) => {
const account = resolveTelegramAccount({ cfg, accountId });
const rt =
runtime.telegramAccounts?.[account.accountId] ??
(account.accountId === defaultTelegramAccountId
? runtime.telegram
: undefined);
const configured = Boolean(account.token);
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && configured && account.enabled) {
telegramProbe = await probeTelegram(
account.token,
timeoutMs,
account.config.proxy,
);
lastProbeAt = Date.now();
}
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: rt?.running ?? false,
mode: rt?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"),
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
};
}),
);
const defaultTelegramAccount =
telegramAccounts.find(
(account) => account.accountId === defaultTelegramAccountId,
) ?? telegramAccounts[0];
const signalCfg = cfg.signal;
const signalEnabled = signalCfg?.enabled !== false;
const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1";
const signalPort = signalCfg?.httpPort ?? 8080;
const signalBaseUrl =
signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`;
const signalConfigured =
Boolean(signalCfg) &&
signalEnabled &&
Boolean(
signalCfg?.account?.trim() ||
signalCfg?.httpUrl?.trim() ||
signalCfg?.cliPath?.trim() ||
signalCfg?.httpHost?.trim() ||
typeof signalCfg?.httpPort === "number" ||
typeof signalCfg?.autoStart === "boolean",
);
let signalProbe: SignalProbe | undefined;
let signalLastProbeAt: number | null = null;
if (probe && signalConfigured) {
signalProbe = await probeSignal(signalBaseUrl, timeoutMs);
signalLastProbeAt = Date.now();
}
const discordAccounts = await Promise.all(
listDiscordAccountIds(cfg).map(async (accountId) => {
const account = resolveDiscordAccount({ cfg, accountId });
const rt =
runtime.discordAccounts?.[account.accountId] ??
(account.accountId === defaultDiscordAccountId
? runtime.discord
: undefined);
const configured = Boolean(account.token);
let discordProbe: DiscordProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && configured && account.enabled) {
discordProbe = await probeDiscord(account.token, timeoutMs);
lastProbeAt = Date.now();
}
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: discordProbe,
lastProbeAt,
};
}),
);
const defaultDiscordAccount =
discordAccounts.find(
(account) => account.accountId === defaultDiscordAccountId,
) ?? discordAccounts[0];
const imessageCfg = cfg.imessage;
const imessageEnabled = imessageCfg?.enabled !== false;
const imessageConfigured = Boolean(imessageCfg) && imessageEnabled;
const slackAccounts = await Promise.all(
listSlackAccountIds(cfg).map(async (accountId) => {
const account = resolveSlackAccount({ cfg, accountId });
const rt =
runtime.slackAccounts?.[account.accountId] ??
(account.accountId === defaultSlackAccountId
? runtime.slack
: undefined);
const configured = Boolean(account.botToken && account.appToken);
let slackProbe: SlackProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && configured && account.enabled && account.botToken) {
slackProbe = await probeSlack(account.botToken, timeoutMs);
lastProbeAt = Date.now();
}
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: slackProbe,
lastProbeAt,
};
}),
);
const defaultSlackAccount =
slackAccounts.find(
(account) => account.accountId === defaultSlackAccountId,
) ?? slackAccounts[0];
const signalAccounts = await Promise.all(
listSignalAccountIds(cfg).map(async (accountId) => {
const account = resolveSignalAccount({ cfg, accountId });
const rt =
runtime.signalAccounts?.[account.accountId] ??
(account.accountId === defaultSignalAccountId
? runtime.signal
: undefined);
const configured = account.configured;
let signalProbe: SignalProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && configured && account.enabled) {
signalProbe = await probeSignal(account.baseUrl, timeoutMs);
lastProbeAt = Date.now();
}
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
baseUrl: account.baseUrl,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: signalProbe,
lastProbeAt,
};
}),
);
const defaultSignalAccount =
signalAccounts.find(
(account) => account.accountId === defaultSignalAccountId,
) ?? signalAccounts[0];
const imessageBaseConfigured = Boolean(cfg.imessage);
let imessageProbe: IMessageProbe | undefined;
let imessageLastProbeAt: number | null = null;
if (probe && imessageConfigured) {
if (probe && imessageBaseConfigured) {
imessageProbe = await probeIMessage(timeoutMs);
imessageLastProbeAt = Date.now();
}
const runtime = context.getRuntimeSnapshot();
const imessageAccounts = listIMessageAccountIds(cfg).map((accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
const rt =
runtime.imessageAccounts?.[account.accountId] ??
(account.accountId === defaultIMessageAccountId
? runtime.imessage
: undefined);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: imessageBaseConfigured,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
cliPath: rt?.cliPath ?? account.config.cliPath ?? null,
dbPath: rt?.dbPath ?? account.config.dbPath ?? null,
probe: imessageProbe,
lastProbeAt: imessageLastProbeAt,
};
});
const defaultIMessageAccount =
imessageAccounts.find(
(account) => account.accountId === defaultIMessageAccountId,
) ?? imessageAccounts[0];
const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg);
const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg);
const defaultWhatsAppAccount =
@@ -226,58 +327,68 @@ export const providersHandlers: GatewayRequestHandlers = {
whatsappAccounts,
whatsappDefaultAccountId: defaultWhatsAppAccountId,
telegram: {
configured: telegramEnabled && Boolean(telegramToken),
tokenSource,
running: runtime.telegram.running,
mode: runtime.telegram.mode ?? null,
lastStartAt: runtime.telegram.lastStartAt ?? null,
lastStopAt: runtime.telegram.lastStopAt ?? null,
lastError: runtime.telegram.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
configured: defaultTelegramAccount?.configured ?? false,
tokenSource: defaultTelegramAccount?.tokenSource ?? "none",
running: defaultTelegramAccount?.running ?? false,
mode: defaultTelegramAccount?.mode ?? null,
lastStartAt: defaultTelegramAccount?.lastStartAt ?? null,
lastStopAt: defaultTelegramAccount?.lastStopAt ?? null,
lastError: defaultTelegramAccount?.lastError ?? null,
probe: defaultTelegramAccount?.probe,
lastProbeAt: defaultTelegramAccount?.lastProbeAt ?? null,
},
telegramAccounts,
telegramDefaultAccountId: defaultTelegramAccountId,
discord: {
configured: discordEnabled && Boolean(discordToken),
tokenSource: discordTokenSource,
running: runtime.discord.running,
lastStartAt: runtime.discord.lastStartAt ?? null,
lastStopAt: runtime.discord.lastStopAt ?? null,
lastError: runtime.discord.lastError ?? null,
probe: discordProbe,
lastProbeAt: discordLastProbeAt,
configured: defaultDiscordAccount?.configured ?? false,
tokenSource: defaultDiscordAccount?.tokenSource ?? "none",
running: defaultDiscordAccount?.running ?? false,
lastStartAt: defaultDiscordAccount?.lastStartAt ?? null,
lastStopAt: defaultDiscordAccount?.lastStopAt ?? null,
lastError: defaultDiscordAccount?.lastError ?? null,
probe: defaultDiscordAccount?.probe,
lastProbeAt: defaultDiscordAccount?.lastProbeAt ?? null,
},
discordAccounts,
discordDefaultAccountId: defaultDiscordAccountId,
slack: {
configured: slackConfigured,
botTokenSource: slackBotTokenSource,
appTokenSource: slackAppTokenSource,
running: runtime.slack.running,
lastStartAt: runtime.slack.lastStartAt ?? null,
lastStopAt: runtime.slack.lastStopAt ?? null,
lastError: runtime.slack.lastError ?? null,
probe: slackProbe,
lastProbeAt: slackLastProbeAt,
configured: defaultSlackAccount?.configured ?? false,
botTokenSource: defaultSlackAccount?.botTokenSource ?? "none",
appTokenSource: defaultSlackAccount?.appTokenSource ?? "none",
running: defaultSlackAccount?.running ?? false,
lastStartAt: defaultSlackAccount?.lastStartAt ?? null,
lastStopAt: defaultSlackAccount?.lastStopAt ?? null,
lastError: defaultSlackAccount?.lastError ?? null,
probe: defaultSlackAccount?.probe,
lastProbeAt: defaultSlackAccount?.lastProbeAt ?? null,
},
slackAccounts,
slackDefaultAccountId: defaultSlackAccountId,
signal: {
configured: signalConfigured,
baseUrl: signalBaseUrl,
running: runtime.signal.running,
lastStartAt: runtime.signal.lastStartAt ?? null,
lastStopAt: runtime.signal.lastStopAt ?? null,
lastError: runtime.signal.lastError ?? null,
probe: signalProbe,
lastProbeAt: signalLastProbeAt,
configured: defaultSignalAccount?.configured ?? false,
baseUrl: defaultSignalAccount?.baseUrl ?? null,
running: defaultSignalAccount?.running ?? false,
lastStartAt: defaultSignalAccount?.lastStartAt ?? null,
lastStopAt: defaultSignalAccount?.lastStopAt ?? null,
lastError: defaultSignalAccount?.lastError ?? null,
probe: defaultSignalAccount?.probe,
lastProbeAt: defaultSignalAccount?.lastProbeAt ?? null,
},
signalAccounts,
signalDefaultAccountId: defaultSignalAccountId,
imessage: {
configured: imessageConfigured,
running: runtime.imessage.running,
lastStartAt: runtime.imessage.lastStartAt ?? null,
lastStopAt: runtime.imessage.lastStopAt ?? null,
lastError: runtime.imessage.lastError ?? null,
cliPath: runtime.imessage.cliPath ?? null,
dbPath: runtime.imessage.dbPath ?? null,
probe: imessageProbe,
lastProbeAt: imessageLastProbeAt,
configured: defaultIMessageAccount?.configured ?? false,
running: defaultIMessageAccount?.running ?? false,
lastStartAt: defaultIMessageAccount?.lastStartAt ?? null,
lastStopAt: defaultIMessageAccount?.lastStopAt ?? null,
lastError: defaultIMessageAccount?.lastError ?? null,
cliPath: defaultIMessageAccount?.cliPath ?? null,
dbPath: defaultIMessageAccount?.dbPath ?? null,
probe: defaultIMessageAccount?.probe,
lastProbeAt: defaultIMessageAccount?.lastProbeAt ?? null,
},
imessageAccounts,
imessageDefaultAccountId: defaultIMessageAccountId,
},
undefined,
);

View File

@@ -5,7 +5,6 @@ import { sendMessageIMessage } from "../../imessage/index.js";
import { sendMessageSignal } from "../../signal/index.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import { normalizeMessageProvider } from "../../utils/message-provider.js";
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
@@ -53,14 +52,16 @@ export const sendHandlers: GatewayRequestHandlers = {
const to = request.to.trim();
const message = request.message.trim();
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
: undefined;
try {
if (provider === "telegram") {
const cfg = loadConfig();
const { token } = resolveTelegramToken(cfg);
const result = await sendMessageTelegram(to, message, {
mediaUrl: request.mediaUrl,
verbose: shouldLogVerbose(),
token: token || undefined,
accountId,
});
const payload = {
runId: idem,
@@ -77,7 +78,7 @@ export const sendHandlers: GatewayRequestHandlers = {
} else if (provider === "discord") {
const result = await sendMessageDiscord(to, message, {
mediaUrl: request.mediaUrl,
token: process.env.DISCORD_BOT_TOKEN,
accountId,
});
const payload = {
runId: idem,
@@ -94,6 +95,7 @@ export const sendHandlers: GatewayRequestHandlers = {
} else if (provider === "slack") {
const result = await sendMessageSlack(to, message, {
mediaUrl: request.mediaUrl,
accountId,
});
const payload = {
runId: idem,
@@ -108,14 +110,9 @@ export const sendHandlers: GatewayRequestHandlers = {
});
respond(true, payload, undefined, { provider });
} else if (provider === "signal") {
const cfg = loadConfig();
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
const port = cfg.signal?.httpPort ?? 8080;
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
const result = await sendMessageSignal(to, message, {
mediaUrl: request.mediaUrl,
baseUrl,
account: cfg.signal?.account,
accountId,
});
const payload = {
runId: idem,
@@ -129,14 +126,9 @@ export const sendHandlers: GatewayRequestHandlers = {
});
respond(true, payload, undefined, { provider });
} else if (provider === "imessage") {
const cfg = loadConfig();
const result = await sendMessageIMessage(to, message, {
mediaUrl: request.mediaUrl,
cliPath: cfg.imessage?.cliPath,
dbPath: cfg.imessage?.dbPath,
maxBytes: cfg.imessage?.mediaMaxMb
? cfg.imessage.mediaMaxMb * 1024 * 1024
: undefined,
accountId,
});
const payload = {
runId: idem,
@@ -151,16 +143,13 @@ export const sendHandlers: GatewayRequestHandlers = {
respond(true, payload, undefined, { provider });
} else {
const cfg = loadConfig();
const accountId =
typeof request.accountId === "string" &&
request.accountId.trim().length > 0
? request.accountId.trim()
: resolveDefaultWhatsAppAccountId(cfg);
const targetAccountId =
accountId ?? resolveDefaultWhatsAppAccountId(cfg);
const result = await sendMessageWhatsApp(to, message, {
mediaUrl: request.mediaUrl,
verbose: shouldLogVerbose(),
gifPlayback: request.gifPlayback,
accountId,
accountId: targetAccountId,
});
const payload = {
runId: idem,
@@ -238,9 +227,13 @@ export const sendHandlers: GatewayRequestHandlers = {
maxSelections: request.maxSelections,
durationHours: request.durationHours,
};
const accountId =
typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim()
: undefined;
try {
if (provider === "discord") {
const result = await sendPollDiscord(to, poll);
const result = await sendPollDiscord(to, poll, { accountId });
const payload = {
runId: idem,
messageId: result.messageId,

View File

@@ -71,7 +71,16 @@ export type GatewayRequestContext = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startWhatsAppProvider: (accountId?: string) => Promise<void>;
stopWhatsAppProvider: (accountId?: string) => Promise<void>;
stopTelegramProvider: () => Promise<void>;
startTelegramProvider: (accountId?: string) => Promise<void>;
stopTelegramProvider: (accountId?: string) => Promise<void>;
startDiscordProvider: (accountId?: string) => Promise<void>;
stopDiscordProvider: (accountId?: string) => Promise<void>;
startSlackProvider: (accountId?: string) => Promise<void>;
stopSlackProvider: (accountId?: string) => Promise<void>;
startSignalProvider: (accountId?: string) => Promise<void>;
stopSignalProvider: (accountId?: string) => Promise<void>;
startIMessageProvider: (accountId?: string) => Promise<void>;
stopIMessageProvider: (accountId?: string) => Promise<void>;
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
wizardRunner: (
opts: import("../../commands/onboard-types.js").OnboardOptions,

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,7 @@ const hoisted = vi.hoisted(() => {
lastEventAt: null,
lastError: null,
},
whatsappAccounts: {},
telegram: {
running: false,
lastStartAt: null,
@@ -49,18 +50,21 @@ const hoisted = vi.hoisted(() => {
lastError: null,
mode: null,
},
telegramAccounts: {},
discord: {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
discordAccounts: {},
slack: {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
slackAccounts: {},
signal: {
running: false,
lastStartAt: null,
@@ -68,6 +72,7 @@ const hoisted = vi.hoisted(() => {
lastError: null,
baseUrl: null,
},
signalAccounts: {},
imessage: {
running: false,
lastStartAt: null,
@@ -76,6 +81,7 @@ const hoisted = vi.hoisted(() => {
cliPath: null,
dbPath: null,
},
imessageAccounts: {},
})),
startProviders: vi.fn(async () => {}),
startWhatsAppProvider: vi.fn(async () => {}),

View File

@@ -1542,7 +1542,16 @@ export async function startGatewayServer(
getRuntimeSnapshot,
startWhatsAppProvider,
stopWhatsAppProvider,
startTelegramProvider,
stopTelegramProvider,
startDiscordProvider,
stopDiscordProvider,
startSlackProvider,
stopSlackProvider,
startSignalProvider,
stopSignalProvider,
startIMessageProvider,
stopIMessageProvider,
markWhatsAppLoggedOut,
wizardRunner,
broadcastVoiceWakeChanged,