mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat: add providers CLI and multi-account onboarding
This commit is contained in:
@@ -260,6 +260,33 @@ Notes:
|
||||
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
|
||||
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
|
||||
|
||||
### `telegram.accounts` / `discord.accounts` / `slack.accounts` / `signal.accounts` / `imessage.accounts`
|
||||
|
||||
Run multiple accounts per provider (each account has its own `accountId` and optional `name`):
|
||||
|
||||
```json5
|
||||
{
|
||||
telegram: {
|
||||
accounts: {
|
||||
default: {
|
||||
name: "Primary bot",
|
||||
botToken: "123456:ABC..."
|
||||
},
|
||||
alerts: {
|
||||
name: "Alerts bot",
|
||||
botToken: "987654:XYZ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `default` is used when `accountId` is omitted (CLI + routing).
|
||||
- Env tokens only apply to the **default** account.
|
||||
- Base provider settings (group policy, mention gating, etc.) apply to all accounts unless overridden per account.
|
||||
- Use `routing.bindings[].match.accountId` to route each account to a different agent.
|
||||
|
||||
### `routing.groupChat`
|
||||
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
||||
@@ -560,6 +587,7 @@ Set `web.enabled: false` to keep it off by default.
|
||||
|
||||
Clawdbot starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`.
|
||||
Set `telegram.enabled: false` to disable automatic startup.
|
||||
Multi-account support lives under `telegram.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -609,6 +637,7 @@ Retry policy defaults and behavior are documented in [Retry policy](/concepts/re
|
||||
### `discord` (bot transport)
|
||||
|
||||
Configure the Discord bot by setting the bot token and optional gating:
|
||||
Multi-account support lives under `discord.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -728,6 +757,8 @@ Slack runs in Socket Mode and requires both a bot token and app token:
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support lives under `slack.accounts` (see the multi-account section above). Env tokens only apply to the default account.
|
||||
|
||||
Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.
|
||||
|
||||
Reaction notification modes:
|
||||
@@ -764,10 +795,19 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support lives under `imessage.accounts` (see the multi-account section above).
|
||||
|
||||
Notes:
|
||||
- Requires Full Disk Access to the Messages DB.
|
||||
- The first send will prompt for Messages automation permission.
|
||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||
- `imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts.
|
||||
|
||||
Example wrapper:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T mac-mini "imsg rpc"
|
||||
```
|
||||
|
||||
### `agent.workspace`
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ Or via config:
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support: use `discord.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
#### Allowlist + channel routing
|
||||
Example “single server, only allow me, only allow #help”:
|
||||
|
||||
|
||||
@@ -19,11 +19,28 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
|
||||
- macOS with Messages signed in.
|
||||
- Full Disk Access for Clawdbot + `imsg` (Messages DB access).
|
||||
- Automation permission when sending.
|
||||
- `imessage.cliPath` can point to a wrapper script (for example, an SSH hop to another Mac that runs `imsg rpc`).
|
||||
|
||||
## Setup (fast path)
|
||||
1) Ensure Messages is signed in on this Mac.
|
||||
2) Configure iMessage and start the gateway.
|
||||
|
||||
### Remote/SSH variant (optional)
|
||||
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that
|
||||
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
|
||||
stdio stream; `imsg` still runs on the remote macOS host.
|
||||
|
||||
Example wrapper (save somewhere in your PATH and `chmod +x`):
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T mac-mini "imsg rpc"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Remote Mac must have Messages signed in and `imsg` installed.
|
||||
- Full Disk Access + Automation prompts happen on the remote Mac.
|
||||
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
@@ -36,6 +53,8 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `imessage.dmPolicy = "pairing"`.
|
||||
|
||||
@@ -39,6 +39,8 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support: use `signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## Access control (DMs + groups)
|
||||
DMs:
|
||||
- Default: `signal.dmPolicy = "pairing"`.
|
||||
|
||||
@@ -22,6 +22,8 @@ read_when: "Setting up Slack or debugging Slack socket mode"
|
||||
|
||||
Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
Multi-account support: use `slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ Status: production-ready for bot DMs + groups via grammY. Long-polling by defaul
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support: use `telegram.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
3) Start the gateway. Telegram starts when a `telegram` config section exists and a token is resolved.
|
||||
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
|
||||
5) For groups: add the bot, disable privacy mode (or make it admin), then set `telegram.groups` to control mention gating + allowlists.
|
||||
|
||||
@@ -85,6 +85,20 @@ describe("resolveTextChunkLimit", () => {
|
||||
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234);
|
||||
});
|
||||
|
||||
it("prefers account overrides when provided", () => {
|
||||
const cfg = {
|
||||
telegram: {
|
||||
textChunkLimit: 2000,
|
||||
accounts: {
|
||||
default: { textChunkLimit: 1234 },
|
||||
primary: { textChunkLimit: 777 },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveTextChunkLimit(cfg, "telegram", "primary")).toBe(777);
|
||||
expect(resolveTextChunkLimit(cfg, "telegram", "default")).toBe(1234);
|
||||
});
|
||||
|
||||
it("uses the matching provider override", () => {
|
||||
const cfg = {
|
||||
discord: { textChunkLimit: 111 },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isSafeFenceBreak,
|
||||
parseFenceSpans,
|
||||
} from "../markdown/fences.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
|
||||
export type TextChunkProvider =
|
||||
| "whatsapp"
|
||||
@@ -31,15 +32,44 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
||||
export function resolveTextChunkLimit(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
provider?: TextChunkProvider,
|
||||
accountId?: string | null,
|
||||
): number {
|
||||
const providerOverride = (() => {
|
||||
if (!provider) return undefined;
|
||||
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
||||
if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
|
||||
if (provider === "discord") return cfg?.discord?.textChunkLimit;
|
||||
if (provider === "slack") return cfg?.slack?.textChunkLimit;
|
||||
if (provider === "signal") return cfg?.signal?.textChunkLimit;
|
||||
if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (provider === "whatsapp") {
|
||||
return cfg?.whatsapp?.textChunkLimit;
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
return (
|
||||
cfg?.telegram?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.telegram?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "discord") {
|
||||
return (
|
||||
cfg?.discord?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.discord?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return (
|
||||
cfg?.slack?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.slack?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "signal") {
|
||||
return (
|
||||
cfg?.signal?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.signal?.textChunkLimit
|
||||
);
|
||||
}
|
||||
if (provider === "imessage") {
|
||||
return (
|
||||
cfg?.imessage?.accounts?.[normalizedAccountId]?.textChunkLimit ??
|
||||
cfg?.imessage?.textChunkLimit
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function routeReply(
|
||||
mediaUrl,
|
||||
messageThreadId: threadId,
|
||||
replyToMessageId: resolvedReplyToMessageId,
|
||||
accountId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export async function routeReply(
|
||||
const result = await sendMessageSlack(to, text, {
|
||||
mediaUrl,
|
||||
threadTs: replyToId,
|
||||
accountId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
@@ -101,17 +103,24 @@ export async function routeReply(
|
||||
const result = await sendMessageDiscord(to, text, {
|
||||
mediaUrl,
|
||||
replyTo: replyToId,
|
||||
accountId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
|
||||
case "signal": {
|
||||
const result = await sendMessageSignal(to, text, { mediaUrl });
|
||||
const result = await sendMessageSignal(to, text, {
|
||||
mediaUrl,
|
||||
accountId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
|
||||
case "imessage": {
|
||||
const result = await sendMessageIMessage(to, text, { mediaUrl });
|
||||
const result = await sendMessageIMessage(to, text, {
|
||||
mediaUrl,
|
||||
accountId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { registerModelsCli } from "./models-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { registerPairingCli } from "./pairing-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerProvidersCli } from "./providers-cli.js";
|
||||
import { registerTelegramCli } from "./telegram-cli.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
|
||||
@@ -637,6 +638,7 @@ Examples:
|
||||
registerDocsCli(program);
|
||||
registerHooksCli(program);
|
||||
registerPairingCli(program);
|
||||
registerProvidersCli(program);
|
||||
registerTelegramCli(program);
|
||||
|
||||
program
|
||||
|
||||
130
src/cli/providers-cli.ts
Normal file
130
src/cli/providers-cli.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
providersAddCommand,
|
||||
providersListCommand,
|
||||
providersRemoveCommand,
|
||||
providersStatusCommand,
|
||||
} from "../commands/providers.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
const optionNamesAdd = [
|
||||
"provider",
|
||||
"account",
|
||||
"name",
|
||||
"token",
|
||||
"tokenFile",
|
||||
"botToken",
|
||||
"appToken",
|
||||
"signalNumber",
|
||||
"cliPath",
|
||||
"dbPath",
|
||||
"service",
|
||||
"region",
|
||||
"authDir",
|
||||
"httpUrl",
|
||||
"httpHost",
|
||||
"httpPort",
|
||||
"useEnv",
|
||||
] as const;
|
||||
|
||||
const optionNamesRemove = ["provider", "account", "delete"] as const;
|
||||
|
||||
function hasExplicitOptions(
|
||||
command: Command,
|
||||
names: readonly string[],
|
||||
): boolean {
|
||||
return names.some((name) => {
|
||||
if (typeof command.getOptionValueSource !== "function") {
|
||||
return false;
|
||||
}
|
||||
return command.getOptionValueSource(name) === "cli";
|
||||
});
|
||||
}
|
||||
|
||||
export function registerProvidersCli(program: Command) {
|
||||
const providers = program
|
||||
.command("providers")
|
||||
.alias("provider")
|
||||
.description("Manage chat provider accounts");
|
||||
|
||||
providers
|
||||
.command("list")
|
||||
.description("List configured providers + auth profiles")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await providersListCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
providers
|
||||
.command("status")
|
||||
.description("Show gateway provider status")
|
||||
.option("--probe", "Probe provider credentials", false)
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await providersStatusCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
providers
|
||||
.command("add")
|
||||
.description("Add or update a provider account")
|
||||
.option(
|
||||
"--provider <name>",
|
||||
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
)
|
||||
.option("--account <id>", "Account id (default when omitted)")
|
||||
.option("--name <name>", "Display name for this account")
|
||||
.option("--token <token>", "Bot token (Telegram/Discord)")
|
||||
.option("--token-file <path>", "Bot token file (Telegram)")
|
||||
.option("--bot-token <token>", "Slack bot token (xoxb-...)")
|
||||
.option("--app-token <token>", "Slack app token (xapp-...)")
|
||||
.option("--signal-number <e164>", "Signal account number (E.164)")
|
||||
.option("--cli-path <path>", "CLI path (signal-cli or imsg)")
|
||||
.option("--db-path <path>", "iMessage database path")
|
||||
.option("--service <service>", "iMessage service (imessage|sms|auto)")
|
||||
.option("--region <region>", "iMessage region (for SMS)")
|
||||
.option("--auth-dir <path>", "WhatsApp auth directory override")
|
||||
.option("--http-url <url>", "Signal HTTP daemon base URL")
|
||||
.option("--http-host <host>", "Signal HTTP host")
|
||||
.option("--http-port <port>", "Signal HTTP port")
|
||||
.option("--use-env", "Use env token (default account only)", false)
|
||||
.action(async (opts, command) => {
|
||||
try {
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
|
||||
await providersAddCommand(opts, defaultRuntime, { hasFlags });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
providers
|
||||
.command("remove")
|
||||
.description("Disable or delete a provider account")
|
||||
.option(
|
||||
"--provider <name>",
|
||||
"Provider (whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
)
|
||||
.option("--account <id>", "Account id (default when omitted)")
|
||||
.option("--delete", "Delete config entries (no prompt)", false)
|
||||
.action(async (opts, command) => {
|
||||
try {
|
||||
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
|
||||
await providersRemoveCommand(opts, defaultRuntime, { hasFlags });
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -266,7 +266,7 @@ describe("agentCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes telegram token when delivering", async () => {
|
||||
it("passes telegram account id when delivering", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const store = path.join(home, "sessions.json");
|
||||
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
|
||||
@@ -297,7 +297,7 @@ describe("agentCommand", () => {
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"ok",
|
||||
expect.objectContaining({ token: "t-1" }),
|
||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
if (prevTelegramToken === undefined) {
|
||||
|
||||
@@ -322,17 +322,18 @@ function buildProviderBindings(params: {
|
||||
agentId: string;
|
||||
selection: ProviderChoice[];
|
||||
config: ClawdbotConfig;
|
||||
whatsappAccountId?: string;
|
||||
accountIds?: Partial<Record<ProviderChoice, string>>;
|
||||
}): AgentBinding[] {
|
||||
const bindings: AgentBinding[] = [];
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
for (const provider of params.selection) {
|
||||
const match: AgentBinding["match"] = { provider };
|
||||
if (provider === "whatsapp") {
|
||||
const accountId =
|
||||
params.whatsappAccountId?.trim() ||
|
||||
resolveDefaultWhatsAppAccountId(params.config);
|
||||
match.accountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
const accountId = params.accountIds?.[provider]?.trim();
|
||||
if (accountId) {
|
||||
match.accountId = accountId;
|
||||
} else if (provider === "whatsapp") {
|
||||
const defaultId = resolveDefaultWhatsAppAccountId(params.config);
|
||||
match.accountId = defaultId || DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
bindings.push({ agentId, match });
|
||||
}
|
||||
@@ -493,15 +494,15 @@ export async function agentsAddCommand(
|
||||
});
|
||||
|
||||
let selection: ProviderChoice[] = [];
|
||||
let whatsappAccountId: string | undefined;
|
||||
const providerAccountIds: Partial<Record<ProviderChoice, string>> = {};
|
||||
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
onSelection: (value) => {
|
||||
selection = value;
|
||||
},
|
||||
promptWhatsAppAccountId: true,
|
||||
onWhatsAppAccountId: (value) => {
|
||||
whatsappAccountId = value;
|
||||
promptAccountIds: true,
|
||||
onAccountId: (provider, accountId) => {
|
||||
providerAccountIds[provider] = accountId;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -516,7 +517,7 @@ export async function agentsAddCommand(
|
||||
agentId,
|
||||
selection,
|
||||
config: nextConfig,
|
||||
whatsappAccountId,
|
||||
accountIds: providerAccountIds,
|
||||
});
|
||||
const result = applyAgentBindings(nextConfig, desiredBindings);
|
||||
nextConfig = result.config;
|
||||
|
||||
@@ -44,13 +44,17 @@ function normalizeDefaultWorkspacePath(
|
||||
return next === resolved ? value : next;
|
||||
}
|
||||
|
||||
export function replaceLegacyName(value: string | undefined): string | undefined {
|
||||
export function replaceLegacyName(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
|
||||
return replacedClawdis.replace(/clawd(?!bot)/g, "clawdbot");
|
||||
}
|
||||
|
||||
export function replaceModernName(value: string | undefined): string | undefined {
|
||||
export function replaceModernName(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
if (!value.includes("clawdbot")) return value;
|
||||
return value.replace(/clawdbot/g, "clawdis");
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
import { replaceModernName } from "./doctor-legacy-config.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
type SandboxScriptInfo = {
|
||||
scriptPath: string;
|
||||
|
||||
@@ -257,8 +257,10 @@ export async function noteStateIntegrity(
|
||||
const recent = entries
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
|
||||
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
|
||||
const aUpdated =
|
||||
typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
|
||||
const bUpdated =
|
||||
typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
|
||||
return bUpdated - aUpdated;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
@@ -13,28 +13,25 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
||||
import {
|
||||
maybeMigrateLegacyConfigFile,
|
||||
normalizeLegacyConfigValues,
|
||||
} from "./doctor-legacy-config.js";
|
||||
import {
|
||||
maybeMigrateLegacyGatewayService,
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "./doctor-gateway-services.js";
|
||||
import {
|
||||
createDoctorPrompter,
|
||||
type DoctorOptions,
|
||||
} from "./doctor-prompter.js";
|
||||
maybeMigrateLegacyConfigFile,
|
||||
normalizeLegacyConfigValues,
|
||||
} from "./doctor-legacy-config.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||
import { maybeRepairSandboxImages } from "./doctor-sandbox.js";
|
||||
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||
import {
|
||||
detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations,
|
||||
} from "./doctor-state-migrations.js";
|
||||
import {
|
||||
noteStateIntegrity,
|
||||
noteWorkspaceBackupTip,
|
||||
} from "./doctor-state-integrity.js";
|
||||
import {
|
||||
detectLegacyStateMigrations,
|
||||
runLegacyStateMigrations,
|
||||
} from "./doctor-state-migrations.js";
|
||||
import {
|
||||
MEMORY_SYSTEM_PROMPT,
|
||||
shouldSuggestMemorySystem,
|
||||
|
||||
@@ -2,13 +2,38 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { DmPolicy } from "../config/types.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "../discord/accounts.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
} from "../imessage/accounts.js";
|
||||
import { loginWeb } from "../provider-web.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "../signal/accounts.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
} from "../slack/accounts.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../telegram/accounts.js";
|
||||
import { formatTerminalLink, normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
@@ -19,6 +44,53 @@ import { detectBinary } from "./onboard-helpers.js";
|
||||
import type { ProviderChoice } from "./onboard-types.js";
|
||||
import { installSignalCli } from "./signal-install.js";
|
||||
|
||||
const DOCS_BASE = "https://docs.clawd.bot";
|
||||
|
||||
function docsLink(path: string, label?: string): string {
|
||||
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const url = `${DOCS_BASE}${cleanPath}`;
|
||||
return formatTerminalLink(label ?? url, url, { fallback: url });
|
||||
}
|
||||
|
||||
async function promptAccountId(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId?: string;
|
||||
listAccountIds: (cfg: ClawdbotConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
}): Promise<string> {
|
||||
const existingIds = params.listAccountIds(params.cfg);
|
||||
const initial =
|
||||
params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||
const choice = (await params.prompter.select({
|
||||
message: `${params.label} account`,
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
initialValue: initial,
|
||||
})) as string;
|
||||
|
||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||
|
||||
const entered = await params.prompter.text({
|
||||
message: `New ${params.label} account id`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await params.prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
`${params.label} account`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function addWildcardAllowFrom(
|
||||
allowFrom?: Array<string | number> | null,
|
||||
): Array<string | number> {
|
||||
@@ -51,13 +123,13 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
|
||||
"DM security: default is pairing; unknown DMs get a pairing code.",
|
||||
"Approve with: clawdbot pairing approve --provider <provider> <code>",
|
||||
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
|
||||
"Docs: https://docs.clawd.bot/start/pairing",
|
||||
`Docs: ${docsLink("/start/pairing", "start/pairing")}`,
|
||||
"",
|
||||
"Telegram: easiest start — register a bot with @BotFather, paste token, go.",
|
||||
"Telegram: simplest way to get started — register a bot with @BotFather and get going.",
|
||||
"WhatsApp: works with your own number; recommend a separate phone + eSIM.",
|
||||
"Discord: very well supported right now.",
|
||||
"Slack: supported (Socket Mode).",
|
||||
"Signal: signal-cli linked device; more setup (if you want easy, hop on Discord).",
|
||||
'Signal: signal-cli linked device; more setup (David Reagans: "Hop on Discord.").',
|
||||
"iMessage: this is still a work in progress.",
|
||||
].join("\n"),
|
||||
"How providers work",
|
||||
@@ -71,7 +143,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
"2) Run /newbot (or /mybots)",
|
||||
"3) Copy the token (looks like 123456:ABC...)",
|
||||
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
|
||||
"Docs: https://docs.clawd.bot/telegram",
|
||||
`Docs: ${docsLink("/telegram", "telegram")}`,
|
||||
].join("\n"),
|
||||
"Telegram bot token",
|
||||
);
|
||||
@@ -84,7 +156,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
"2) Bot → Add Bot → Reset Token → copy token",
|
||||
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
||||
"Tip: enable Message Content Intent if you need message text.",
|
||||
"Docs: https://docs.clawd.bot/discord",
|
||||
`Docs: ${docsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
);
|
||||
@@ -172,7 +244,7 @@ async function noteSlackTokenHelp(
|
||||
"4) Enable Event Subscriptions (socket) for message events",
|
||||
"5) App Home → enable the Messages tab for DMs",
|
||||
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
|
||||
"Docs: https://docs.clawd.bot/slack",
|
||||
`Docs: ${docsLink("/slack", "slack")}`,
|
||||
"",
|
||||
"Manifest (JSON):",
|
||||
manifest,
|
||||
@@ -345,7 +417,7 @@ async function maybeConfigureDmPolicies(params: {
|
||||
"Default: pairing (unknown DMs get a pairing code).",
|
||||
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
|
||||
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
|
||||
"Docs: https://docs.clawd.bot/start/pairing",
|
||||
`Docs: ${docsLink("/start/pairing", "start/pairing")}`,
|
||||
].join("\n"),
|
||||
`${params.label} DM access`,
|
||||
);
|
||||
@@ -432,7 +504,7 @@ async function promptWhatsAppAllowFrom(
|
||||
"- disabled: ignore WhatsApp DMs",
|
||||
"",
|
||||
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
||||
"Docs: https://docs.clawd.bot/whatsapp",
|
||||
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||
].join("\n"),
|
||||
"WhatsApp DM access",
|
||||
);
|
||||
@@ -567,6 +639,9 @@ type SetupProvidersOptions = {
|
||||
allowDisable?: boolean;
|
||||
allowSignalInstall?: boolean;
|
||||
onSelection?: (selection: ProviderChoice[]) => void;
|
||||
accountIds?: Partial<Record<ProviderChoice, string>>;
|
||||
onAccountId?: (provider: ProviderChoice, accountId: string) => void;
|
||||
promptAccountIds?: boolean;
|
||||
whatsappAccountId?: string;
|
||||
promptWhatsAppAccountId?: boolean;
|
||||
onWhatsAppAccountId?: (accountId: string) => void;
|
||||
@@ -585,22 +660,31 @@ export async function setupProviders(
|
||||
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
||||
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
|
||||
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
|
||||
const telegramConfigured = Boolean(
|
||||
telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile,
|
||||
const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) =>
|
||||
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
|
||||
);
|
||||
const discordConfigured = Boolean(discordEnv || cfg.discord?.token);
|
||||
const slackConfigured = Boolean(
|
||||
(slackBotEnv && slackAppEnv) ||
|
||||
(cfg.slack?.botToken && cfg.slack?.appToken),
|
||||
const discordConfigured = listDiscordAccountIds(cfg).some((accountId) =>
|
||||
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
|
||||
);
|
||||
const signalConfigured = Boolean(
|
||||
cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort,
|
||||
const slackConfigured = listSlackAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.appToken);
|
||||
});
|
||||
const signalConfigured = listSignalAccountIds(cfg).some(
|
||||
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
|
||||
);
|
||||
const signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
|
||||
const signalCliDetected = await detectBinary(signalCliPath);
|
||||
const imessageConfigured = Boolean(
|
||||
cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom,
|
||||
);
|
||||
const imessageConfigured = listIMessageAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveIMessageAccount({ cfg, accountId });
|
||||
return Boolean(
|
||||
account.config.cliPath ||
|
||||
account.config.dbPath ||
|
||||
account.config.allowFrom ||
|
||||
account.config.service ||
|
||||
account.config.region,
|
||||
);
|
||||
});
|
||||
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
|
||||
const imessageCliDetected = await detectBinary(imessageCliPath);
|
||||
|
||||
@@ -635,8 +719,8 @@ export async function setupProviders(
|
||||
value: "telegram",
|
||||
label: "Telegram (Bot API)",
|
||||
hint: telegramConfigured
|
||||
? "easy start · configured"
|
||||
: "easy start · needs token",
|
||||
? "recommended · configured"
|
||||
: "recommended · newcomer-friendly",
|
||||
},
|
||||
{
|
||||
value: "whatsapp",
|
||||
@@ -667,20 +751,26 @@ export async function setupProviders(
|
||||
})) as ProviderChoice[];
|
||||
|
||||
options?.onSelection?.(selection);
|
||||
const accountOverrides: Partial<Record<ProviderChoice, string>> = {
|
||||
...options?.accountIds,
|
||||
};
|
||||
if (options?.whatsappAccountId?.trim()) {
|
||||
accountOverrides.whatsapp = options.whatsappAccountId.trim();
|
||||
}
|
||||
const recordAccount = (provider: ProviderChoice, accountId: string) => {
|
||||
options?.onAccountId?.(provider, accountId);
|
||||
if (provider === "whatsapp") {
|
||||
options?.onWhatsAppAccountId?.(accountId);
|
||||
}
|
||||
};
|
||||
|
||||
const selectionNotes: Record<ProviderChoice, string> = {
|
||||
telegram:
|
||||
"Telegram — easiest start: register a bot with @BotFather and paste the token. Docs: https://docs.clawd.bot/telegram",
|
||||
whatsapp:
|
||||
"WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: https://docs.clawd.bot/whatsapp",
|
||||
discord:
|
||||
"Discord — very well supported right now. Docs: https://docs.clawd.bot/discord",
|
||||
slack:
|
||||
"Slack — supported (Socket Mode). Docs: https://docs.clawd.bot/slack",
|
||||
signal:
|
||||
"Signal — signal-cli linked device; more setup (if you want easy, hop on Discord). Docs: https://docs.clawd.bot/signal",
|
||||
imessage:
|
||||
"iMessage — this is still a work in progress. Docs: https://docs.clawd.bot/imessage",
|
||||
telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "telegram")}`,
|
||||
whatsapp: `WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||
discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`,
|
||||
slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`,
|
||||
signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`,
|
||||
imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`,
|
||||
};
|
||||
const selectedLines = selection
|
||||
.map((provider) => selectionNotes[provider])
|
||||
@@ -689,38 +779,23 @@ export async function setupProviders(
|
||||
await prompter.note(selectedLines.join("\n"), "Selected providers");
|
||||
}
|
||||
|
||||
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
||||
|
||||
let next = cfg;
|
||||
|
||||
if (selection.includes("whatsapp")) {
|
||||
if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) {
|
||||
const existingIds = listWhatsAppAccountIds(next);
|
||||
const choice = (await prompter.select({
|
||||
message: "WhatsApp account",
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
})) as string;
|
||||
|
||||
if (choice === "__new__") {
|
||||
const entered = await prompter.text({
|
||||
message: "New WhatsApp account id",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
"WhatsApp account",
|
||||
);
|
||||
}
|
||||
whatsappAccountId = normalized;
|
||||
} else {
|
||||
whatsappAccountId = choice;
|
||||
}
|
||||
const overrideId = accountOverrides.whatsapp?.trim();
|
||||
if (overrideId) {
|
||||
whatsappAccountId = normalizeAccountId(overrideId);
|
||||
} else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
|
||||
whatsappAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "WhatsApp",
|
||||
currentId: whatsappAccountId,
|
||||
listAccountIds: listWhatsAppAccountIds,
|
||||
defaultAccountId: resolveDefaultWhatsAppAccountId(next),
|
||||
});
|
||||
}
|
||||
|
||||
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
@@ -740,7 +815,7 @@ export async function setupProviders(
|
||||
};
|
||||
}
|
||||
|
||||
options?.onWhatsAppAccountId?.(whatsappAccountId);
|
||||
recordAccount("whatsapp", whatsappAccountId);
|
||||
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
|
||||
const { authDir } = resolveWhatsAppAuthDir({
|
||||
cfg: next,
|
||||
@@ -752,7 +827,7 @@ export async function setupProviders(
|
||||
[
|
||||
"Scan the QR with WhatsApp on your phone.",
|
||||
`Credentials are stored under ${authDir}/ for future runs.`,
|
||||
"Docs: https://docs.clawd.bot/whatsapp",
|
||||
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||
].join("\n"),
|
||||
"WhatsApp linking",
|
||||
);
|
||||
@@ -769,7 +844,7 @@ export async function setupProviders(
|
||||
} catch (err) {
|
||||
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
||||
await prompter.note(
|
||||
"Docs: https://docs.clawd.bot/whatsapp",
|
||||
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||
"WhatsApp help",
|
||||
);
|
||||
}
|
||||
@@ -784,11 +859,39 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (selection.includes("telegram")) {
|
||||
const telegramOverride = accountOverrides.telegram?.trim();
|
||||
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(next);
|
||||
let telegramAccountId = telegramOverride
|
||||
? normalizeAccountId(telegramOverride)
|
||||
: defaultTelegramAccountId;
|
||||
if (shouldPromptAccountIds && !telegramOverride) {
|
||||
telegramAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Telegram",
|
||||
currentId: telegramAccountId,
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
defaultAccountId: defaultTelegramAccountId,
|
||||
});
|
||||
}
|
||||
recordAccount("telegram", telegramAccountId);
|
||||
|
||||
const resolvedAccount = resolveTelegramAccount({
|
||||
cfg: next,
|
||||
accountId: telegramAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && telegramEnv;
|
||||
const hasConfigToken = Boolean(
|
||||
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!telegramConfigured) {
|
||||
if (!accountConfigured) {
|
||||
await noteTelegramTokenHelp(prompter);
|
||||
}
|
||||
if (telegramEnv && !cfg.telegram?.botToken) {
|
||||
if (canUseEnv && !resolvedAccount.config.botToken) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
@@ -809,7 +912,7 @@ export async function setupProviders(
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (cfg.telegram?.botToken) {
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Telegram token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
@@ -832,23 +935,68 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (token) {
|
||||
next = {
|
||||
...next,
|
||||
telegram: {
|
||||
...next.telegram,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
};
|
||||
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
telegram: {
|
||||
...next.telegram,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
telegram: {
|
||||
...next.telegram,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.telegram?.accounts,
|
||||
[telegramAccountId]: {
|
||||
...next.telegram?.accounts?.[telegramAccountId],
|
||||
enabled:
|
||||
next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.includes("discord")) {
|
||||
const discordOverride = accountOverrides.discord?.trim();
|
||||
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(next);
|
||||
let discordAccountId = discordOverride
|
||||
? normalizeAccountId(discordOverride)
|
||||
: defaultDiscordAccountId;
|
||||
if (shouldPromptAccountIds && !discordOverride) {
|
||||
discordAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Discord",
|
||||
currentId: discordAccountId,
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
defaultAccountId: defaultDiscordAccountId,
|
||||
});
|
||||
}
|
||||
recordAccount("discord", discordAccountId);
|
||||
|
||||
const resolvedAccount = resolveDiscordAccount({
|
||||
cfg: next,
|
||||
accountId: discordAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && discordEnv;
|
||||
const hasConfigToken = Boolean(resolvedAccount.config.token);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!discordConfigured) {
|
||||
if (!accountConfigured) {
|
||||
await noteDiscordTokenHelp(prompter);
|
||||
}
|
||||
if (discordEnv && !cfg.discord?.token) {
|
||||
if (canUseEnv && !resolvedAccount.config.token) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
@@ -869,7 +1017,7 @@ export async function setupProviders(
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (cfg.discord?.token) {
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Discord token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
@@ -892,18 +1040,67 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (token) {
|
||||
next = {
|
||||
...next,
|
||||
discord: {
|
||||
...next.discord,
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
};
|
||||
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
discord: {
|
||||
...next.discord,
|
||||
enabled: true,
|
||||
token,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
discord: {
|
||||
...next.discord,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.discord?.accounts,
|
||||
[discordAccountId]: {
|
||||
...next.discord?.accounts?.[discordAccountId],
|
||||
enabled:
|
||||
next.discord?.accounts?.[discordAccountId]?.enabled ?? true,
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.includes("slack")) {
|
||||
const slackOverride = accountOverrides.slack?.trim();
|
||||
const defaultSlackAccountId = resolveDefaultSlackAccountId(next);
|
||||
let slackAccountId = slackOverride
|
||||
? normalizeAccountId(slackOverride)
|
||||
: defaultSlackAccountId;
|
||||
if (shouldPromptAccountIds && !slackOverride) {
|
||||
slackAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Slack",
|
||||
currentId: slackAccountId,
|
||||
listAccountIds: listSlackAccountIds,
|
||||
defaultAccountId: defaultSlackAccountId,
|
||||
});
|
||||
}
|
||||
recordAccount("slack", slackAccountId);
|
||||
|
||||
const resolvedAccount = resolveSlackAccount({
|
||||
cfg: next,
|
||||
accountId: slackAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(
|
||||
resolvedAccount.botToken && resolvedAccount.appToken,
|
||||
);
|
||||
const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && slackBotEnv && slackAppEnv;
|
||||
const hasConfigTokens = Boolean(
|
||||
resolvedAccount.config.botToken && resolvedAccount.config.appToken,
|
||||
);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let appToken: string | null = null;
|
||||
const slackBotName = String(
|
||||
@@ -912,13 +1109,12 @@ export async function setupProviders(
|
||||
initialValue: "Clawdbot",
|
||||
}),
|
||||
).trim();
|
||||
if (!slackConfigured) {
|
||||
if (!accountConfigured) {
|
||||
await noteSlackTokenHelp(prompter, slackBotName);
|
||||
}
|
||||
if (
|
||||
slackBotEnv &&
|
||||
slackAppEnv &&
|
||||
(!cfg.slack?.botToken || !cfg.slack?.appToken)
|
||||
canUseEnv &&
|
||||
(!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
|
||||
) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
|
||||
@@ -946,7 +1142,7 @@ export async function setupProviders(
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (cfg.slack?.botToken && cfg.slack?.appToken) {
|
||||
} else if (hasConfigTokens) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Slack tokens already configured. Keep them?",
|
||||
initialValue: true,
|
||||
@@ -981,21 +1177,63 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (botToken && appToken) {
|
||||
next = {
|
||||
...next,
|
||||
slack: {
|
||||
...next.slack,
|
||||
enabled: true,
|
||||
botToken,
|
||||
appToken,
|
||||
},
|
||||
};
|
||||
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
slack: {
|
||||
...next.slack,
|
||||
enabled: true,
|
||||
botToken,
|
||||
appToken,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
slack: {
|
||||
...next.slack,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.slack?.accounts,
|
||||
[slackAccountId]: {
|
||||
...next.slack?.accounts?.[slackAccountId],
|
||||
enabled:
|
||||
next.slack?.accounts?.[slackAccountId]?.enabled ?? true,
|
||||
botToken,
|
||||
appToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.includes("signal")) {
|
||||
let resolvedCliPath = signalCliPath;
|
||||
let cliDetected = signalCliDetected;
|
||||
const signalOverride = accountOverrides.signal?.trim();
|
||||
const defaultSignalAccountId = resolveDefaultSignalAccountId(next);
|
||||
let signalAccountId = signalOverride
|
||||
? normalizeAccountId(signalOverride)
|
||||
: defaultSignalAccountId;
|
||||
if (shouldPromptAccountIds && !signalOverride) {
|
||||
signalAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Signal",
|
||||
currentId: signalAccountId,
|
||||
listAccountIds: listSignalAccountIds,
|
||||
defaultAccountId: defaultSignalAccountId,
|
||||
});
|
||||
}
|
||||
recordAccount("signal", signalAccountId);
|
||||
|
||||
const resolvedAccount = resolveSignalAccount({
|
||||
cfg: next,
|
||||
accountId: signalAccountId,
|
||||
});
|
||||
const accountConfig = resolvedAccount.config;
|
||||
let resolvedCliPath = accountConfig.cliPath ?? signalCliPath;
|
||||
let cliDetected = await detectBinary(resolvedCliPath);
|
||||
if (options?.allowSignalInstall) {
|
||||
const wantsInstall = await prompter.confirm({
|
||||
message: cliDetected
|
||||
@@ -1035,7 +1273,7 @@ export async function setupProviders(
|
||||
);
|
||||
}
|
||||
|
||||
let account = cfg.signal?.account ?? "";
|
||||
let account = accountConfig.account ?? "";
|
||||
if (account) {
|
||||
const keep = await prompter.confirm({
|
||||
message: `Signal account set (${account}). Keep it?`,
|
||||
@@ -1054,15 +1292,35 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (account) {
|
||||
next = {
|
||||
...next,
|
||||
signal: {
|
||||
...next.signal,
|
||||
enabled: true,
|
||||
account,
|
||||
cliPath: resolvedCliPath ?? "signal-cli",
|
||||
},
|
||||
};
|
||||
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
signal: {
|
||||
...next.signal,
|
||||
enabled: true,
|
||||
account,
|
||||
cliPath: resolvedCliPath ?? "signal-cli",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
signal: {
|
||||
...next.signal,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.signal?.accounts,
|
||||
[signalAccountId]: {
|
||||
...next.signal?.accounts?.[signalAccountId],
|
||||
enabled:
|
||||
next.signal?.accounts?.[signalAccountId]?.enabled ?? true,
|
||||
account,
|
||||
cliPath: resolvedCliPath ?? "signal-cli",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
@@ -1070,15 +1328,37 @@ export async function setupProviders(
|
||||
'Link device with: signal-cli link -n "Clawdbot"',
|
||||
"Scan QR in Signal → Linked Devices",
|
||||
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
|
||||
"Docs: https://docs.clawd.bot/signal",
|
||||
`Docs: ${docsLink("/signal", "signal")}`,
|
||||
].join("\n"),
|
||||
"Signal next steps",
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.includes("imessage")) {
|
||||
let resolvedCliPath = imessageCliPath;
|
||||
if (!imessageCliDetected) {
|
||||
const imessageOverride = accountOverrides.imessage?.trim();
|
||||
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(next);
|
||||
let imessageAccountId = imessageOverride
|
||||
? normalizeAccountId(imessageOverride)
|
||||
: defaultIMessageAccountId;
|
||||
if (shouldPromptAccountIds && !imessageOverride) {
|
||||
imessageAccountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "iMessage",
|
||||
currentId: imessageAccountId,
|
||||
listAccountIds: listIMessageAccountIds,
|
||||
defaultAccountId: defaultIMessageAccountId,
|
||||
});
|
||||
}
|
||||
recordAccount("imessage", imessageAccountId);
|
||||
|
||||
const resolvedAccount = resolveIMessageAccount({
|
||||
cfg: next,
|
||||
accountId: imessageAccountId,
|
||||
});
|
||||
let resolvedCliPath = resolvedAccount.config.cliPath ?? imessageCliPath;
|
||||
const cliDetected = await detectBinary(resolvedCliPath);
|
||||
if (!cliDetected) {
|
||||
const entered = await prompter.text({
|
||||
message: "imsg CLI path",
|
||||
initialValue: resolvedCliPath,
|
||||
@@ -1094,14 +1374,33 @@ export async function setupProviders(
|
||||
}
|
||||
|
||||
if (resolvedCliPath) {
|
||||
next = {
|
||||
...next,
|
||||
imessage: {
|
||||
...next.imessage,
|
||||
enabled: true,
|
||||
cliPath: resolvedCliPath,
|
||||
},
|
||||
};
|
||||
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
imessage: {
|
||||
...next.imessage,
|
||||
enabled: true,
|
||||
cliPath: resolvedCliPath,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
imessage: {
|
||||
...next.imessage,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.imessage?.accounts,
|
||||
[imessageAccountId]: {
|
||||
...next.imessage?.accounts?.[imessageAccountId],
|
||||
enabled:
|
||||
next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true,
|
||||
cliPath: resolvedCliPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
@@ -1110,7 +1409,7 @@ export async function setupProviders(
|
||||
"Ensure Clawdbot has Full Disk Access to Messages DB.",
|
||||
"Grant Automation permission for Messages when prompted.",
|
||||
"List chats with: imsg chats --limit 20",
|
||||
"Docs: https://docs.clawd.bot/imessage",
|
||||
`Docs: ${docsLink("/imessage", "imessage")}`,
|
||||
].join("\n"),
|
||||
"iMessage next steps",
|
||||
);
|
||||
|
||||
114
src/commands/providers.test.ts
Normal file
114
src/commands/providers.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
writeConfigFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: configMocks.readConfigFileSnapshot,
|
||||
writeConfigFile: configMocks.writeConfigFile,
|
||||
};
|
||||
});
|
||||
|
||||
import { providersAddCommand, providersRemoveCommand } from "./providers.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const baseSnapshot = {
|
||||
path: "/tmp/clawdbot.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
|
||||
describe("providers command", () => {
|
||||
beforeEach(() => {
|
||||
configMocks.readConfigFileSnapshot.mockReset();
|
||||
configMocks.writeConfigFile.mockClear();
|
||||
runtime.log.mockClear();
|
||||
runtime.error.mockClear();
|
||||
runtime.exit.mockClear();
|
||||
});
|
||||
|
||||
it("adds a non-default telegram account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
|
||||
await providersAddCommand(
|
||||
{ provider: "telegram", account: "alerts", token: "123:abc" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
telegram?: {
|
||||
enabled?: boolean;
|
||||
accounts?: Record<string, { botToken?: string }>;
|
||||
};
|
||||
};
|
||||
expect(next.telegram?.enabled).toBe(true);
|
||||
expect(next.telegram?.accounts?.alerts?.botToken).toBe("123:abc");
|
||||
});
|
||||
|
||||
it("adds a default slack account with tokens", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot });
|
||||
await providersAddCommand(
|
||||
{
|
||||
provider: "slack",
|
||||
account: "default",
|
||||
botToken: "xoxb-1",
|
||||
appToken: "xapp-1",
|
||||
},
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
slack?: { enabled?: boolean; botToken?: string; appToken?: string };
|
||||
};
|
||||
expect(next.slack?.enabled).toBe(true);
|
||||
expect(next.slack?.botToken).toBe("xoxb-1");
|
||||
expect(next.slack?.appToken).toBe("xapp-1");
|
||||
});
|
||||
|
||||
it("deletes a non-default discord account", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: { token: "d0" },
|
||||
work: { token: "d1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await providersRemoveCommand(
|
||||
{ provider: "discord", account: "work", delete: true },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const next = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
|
||||
discord?: { accounts?: Record<string, { token?: string }> };
|
||||
};
|
||||
expect(next.discord?.accounts?.work).toBeUndefined();
|
||||
expect(next.discord?.accounts?.default?.token).toBe("d0");
|
||||
});
|
||||
});
|
||||
1077
src/commands/providers.ts
Normal file
1077
src/commands/providers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -137,7 +137,7 @@ describe("sendCommand", () => {
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hi",
|
||||
expect.objectContaining({ token: "token-abc", verbose: false }),
|
||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||
);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -158,7 +158,7 @@ describe("sendCommand", () => {
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"hi",
|
||||
expect.objectContaining({ token: "cfg-token", verbose: false }),
|
||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -209,7 +209,11 @@ describe("sendCommand", () => {
|
||||
deps,
|
||||
runtime,
|
||||
);
|
||||
expect(deps.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi");
|
||||
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
|
||||
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
|
||||
@@ -18,10 +19,22 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
|
||||
function resolveProviderGroups(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: GroupPolicyProvider,
|
||||
accountId?: string | null,
|
||||
): ProviderGroups | undefined {
|
||||
if (provider === "whatsapp") return cfg.whatsapp?.groups;
|
||||
if (provider === "telegram") return cfg.telegram?.groups;
|
||||
if (provider === "imessage") return cfg.imessage?.groups;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (provider === "telegram") {
|
||||
return (
|
||||
cfg.telegram?.accounts?.[normalizedAccountId]?.groups ??
|
||||
cfg.telegram?.groups
|
||||
);
|
||||
}
|
||||
if (provider === "imessage") {
|
||||
return (
|
||||
cfg.imessage?.accounts?.[normalizedAccountId]?.groups ??
|
||||
cfg.imessage?.groups
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -29,9 +42,10 @@ export function resolveProviderGroupPolicy(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: GroupPolicyProvider;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): ProviderGroupPolicy {
|
||||
const { cfg, provider } = params;
|
||||
const groups = resolveProviderGroups(cfg, provider);
|
||||
const groups = resolveProviderGroups(cfg, provider, params.accountId);
|
||||
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
||||
const normalizedId = params.groupId?.trim();
|
||||
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
||||
@@ -56,6 +70,7 @@ export function resolveProviderGroupRequireMention(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: GroupPolicyProvider;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
requireMentionOverride?: boolean;
|
||||
overrideOrder?: "before-config" | "after-config";
|
||||
}): boolean {
|
||||
|
||||
@@ -129,6 +129,8 @@ export type WhatsAppConfig = {
|
||||
};
|
||||
|
||||
export type WhatsAppAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Override auth directory (Baileys multi-file auth state). */
|
||||
@@ -258,33 +260,9 @@ export type TelegramActionConfig = {
|
||||
sendMessage?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this topic. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for topic senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this topic. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration (key is message_thread_id as string) */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this group (and its topics). */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for group senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this group. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
export type TelegramAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/**
|
||||
* Controls how Telegram direct chats (DMs) are handled:
|
||||
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
||||
@@ -293,10 +271,10 @@ export type TelegramConfig = {
|
||||
* - "disabled": ignore all inbound DMs
|
||||
*/
|
||||
dmPolicy?: DmPolicy;
|
||||
/** If false, do not start the Telegram provider. Default: true. */
|
||||
/** If false, do not start this Telegram account. Default: true. */
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
/** Path to file containing bot token (for secret managers like agenix) */
|
||||
/** Path to file containing bot token (for secret managers like agenix). */
|
||||
tokenFile?: string;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
@@ -326,6 +304,37 @@ export type TelegramConfig = {
|
||||
actions?: TelegramActionConfig;
|
||||
};
|
||||
|
||||
export type TelegramTopicConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this topic. */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for topic senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this topic. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration (key is message_thread_id as string) */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this group (and its topics). */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist for group senders (ids or usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this group. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
/** Optional per-account Telegram configuration (multi-account). */
|
||||
accounts?: Record<string, TelegramAccountConfig>;
|
||||
} & TelegramAccountConfig;
|
||||
|
||||
export type DiscordDmConfig = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
@@ -387,8 +396,10 @@ export type DiscordActionConfig = {
|
||||
stickerUploads?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
export type DiscordAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Discord account. Default: true. */
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
/**
|
||||
@@ -413,6 +424,11 @@ export type DiscordConfig = {
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** Optional per-account Discord configuration (multi-account). */
|
||||
accounts?: Record<string, DiscordAccountConfig>;
|
||||
} & DiscordAccountConfig;
|
||||
|
||||
export type SlackDmConfig = {
|
||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||
enabled?: boolean;
|
||||
@@ -465,8 +481,10 @@ export type SlackSlashCommandConfig = {
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type SlackConfig = {
|
||||
/** If false, do not start the Slack provider. Default: true. */
|
||||
export type SlackAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Slack account. Default: true. */
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
@@ -491,8 +509,15 @@ export type SlackConfig = {
|
||||
channels?: Record<string, SlackChannelConfig>;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
/** If false, do not start the Signal provider. Default: true. */
|
||||
export type SlackConfig = {
|
||||
/** Optional per-account Slack configuration (multi-account). */
|
||||
accounts?: Record<string, SlackAccountConfig>;
|
||||
} & SlackAccountConfig;
|
||||
|
||||
export type SignalAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Signal account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional explicit E.164 account for signal-cli. */
|
||||
account?: string;
|
||||
@@ -527,8 +552,15 @@ export type SignalConfig = {
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
export type IMessageConfig = {
|
||||
/** If false, do not start the iMessage provider. Default: true. */
|
||||
export type SignalConfig = {
|
||||
/** Optional per-account Signal configuration (multi-account). */
|
||||
accounts?: Record<string, SignalAccountConfig>;
|
||||
} & SignalAccountConfig;
|
||||
|
||||
export type IMessageAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this iMessage account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** imsg CLI binary path (default: imsg). */
|
||||
cliPath?: string;
|
||||
@@ -565,6 +597,11 @@ export type IMessageConfig = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type IMessageConfig = {
|
||||
/** Optional per-account iMessage configuration (multi-account). */
|
||||
accounts?: Record<string, IMessageAccountConfig>;
|
||||
} & IMessageAccountConfig;
|
||||
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
|
||||
@@ -89,6 +89,26 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
||||
|
||||
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
||||
|
||||
const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
|
||||
(values ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||
|
||||
const requireOpenAllowFrom = (params: {
|
||||
policy?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
ctx: z.RefinementCtx;
|
||||
path: Array<string | number>;
|
||||
message: string;
|
||||
}) => {
|
||||
if (params.policy !== "open") return;
|
||||
const allow = normalizeAllowFrom(params.allowFrom);
|
||||
if (allow.includes("*")) return;
|
||||
params.ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: params.path,
|
||||
message: params.message,
|
||||
});
|
||||
};
|
||||
|
||||
const RetryConfigSchema = z
|
||||
.object({
|
||||
attempts: z.number().int().min(1).optional(),
|
||||
@@ -121,6 +141,316 @@ const HexColorSchema = z
|
||||
.string()
|
||||
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
||||
|
||||
const TelegramTopicSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
const TelegramGroupSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const TelegramAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
proxy: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
const DiscordDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.policy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
const DiscordGuildChannelSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
const DiscordGuildSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z
|
||||
.record(z.string(), DiscordGuildChannelSchema.optional())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const DiscordAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
retry: RetryConfigSchema,
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: DiscordDmSchema.optional(),
|
||||
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const DiscordConfigSchema = DiscordAccountSchema.extend({
|
||||
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const SlackDmSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.policy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
const SlackChannelSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
const SlackAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
emojiList: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
slashCommand: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
dm: SlackDmSchema.optional(),
|
||||
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const SlackConfigSchema = SlackAccountSchema.extend({
|
||||
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const SignalAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
httpUrl: z.string().optional(),
|
||||
httpHost: z.string().optional(),
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const SignalAccountSchema = SignalAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const SignalConfigSchema = SignalAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
const IMessageAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
service: z
|
||||
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
|
||||
.optional(),
|
||||
region: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine(
|
||||
(value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
@@ -777,6 +1107,7 @@ export const ClawdbotSchema = z.object({
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||
authDir: z.string().optional(),
|
||||
@@ -849,311 +1180,12 @@ export const ClawdbotSchema = z.object({
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
topics: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z
|
||||
.array(z.union([z.string(), z.number()]))
|
||||
.optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
streamMode: z
|
||||
.enum(["off", "partial", "block"])
|
||||
.optional()
|
||||
.default("partial"),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
proxy: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy !== "open") return;
|
||||
const allow = (value.allowFrom ?? [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (allow.includes("*")) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'telegram.dmPolicy="open" requires telegram.allowFrom to include "*"',
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
discord: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
token: z.string().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
retry: RetryConfigSchema,
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.policy !== "open") return;
|
||||
const allow = (value.allowFrom ?? [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (allow.includes("*")) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'discord.dm.policy="open" requires discord.dm.allowFrom to include "*"',
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
guilds: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
slug: z.string().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
reactionNotifications: z
|
||||
.enum(["off", "own", "all", "allowlist"])
|
||||
.optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
channels: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
users: z
|
||||
.array(z.union([z.string(), z.number()]))
|
||||
.optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
slack: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
appToken: z.string().optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
reactionNotifications: z
|
||||
.enum(["off", "own", "all", "allowlist"])
|
||||
.optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
emojiList: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
slashCommand: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
sessionPrefix: z.string().optional(),
|
||||
ephemeral: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
dm: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
policy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.policy !== "open") return;
|
||||
const allow = (value.allowFrom ?? [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (allow.includes("*")) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'slack.dm.policy="open" requires slack.dm.allowFrom to include "*"',
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
channels: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
signal: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
httpUrl: z.string().optional(),
|
||||
httpHost: z.string().optional(),
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
receiveMode: z
|
||||
.union([z.literal("on-start"), z.literal("manual")])
|
||||
.optional(),
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy !== "open") return;
|
||||
const allow = (value.allowFrom ?? [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (allow.includes("*")) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'signal.dmPolicy="open" requires signal.allowFrom to include "*"',
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
imessage: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
cliPath: z.string().optional(),
|
||||
dbPath: z.string().optional(),
|
||||
service: z
|
||||
.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")])
|
||||
.optional(),
|
||||
region: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||
includeAttachments: z.boolean().optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy !== "open") return;
|
||||
const allow = (value.allowFrom ?? [])
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (allow.includes("*")) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'imessage.dmPolicy="open" requires imessage.allowFrom to include "*"',
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
|
||||
telegram: TelegramConfigSchema.optional(),
|
||||
discord: DiscordConfigSchema.optional(),
|
||||
slack: SlackConfigSchema.optional(),
|
||||
signal: SignalConfigSchema.optional(),
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
bridge: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 () => {}),
|
||||
|
||||
@@ -1542,7 +1542,16 @@ export async function startGatewayServer(
|
||||
getRuntimeSnapshot,
|
||||
startWhatsAppProvider,
|
||||
stopWhatsAppProvider,
|
||||
startTelegramProvider,
|
||||
stopTelegramProvider,
|
||||
startDiscordProvider,
|
||||
stopDiscordProvider,
|
||||
startSlackProvider,
|
||||
stopSlackProvider,
|
||||
startSignalProvider,
|
||||
stopSignalProvider,
|
||||
startIMessageProvider,
|
||||
stopIMessageProvider,
|
||||
markWhatsAppLoggedOut,
|
||||
wizardRunner,
|
||||
broadcastVoiceWakeChanged,
|
||||
|
||||
74
src/imessage/accounts.ts
Normal file
74
src/imessage/accounts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { IMessageAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type ResolvedIMessageAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
config: IMessageAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.imessage?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listIMessageAccountIds(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 resolveDefaultIMessageAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listIMessageAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): IMessageAccountConfig | undefined {
|
||||
const accounts = cfg.imessage?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as IMessageAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeIMessageAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): IMessageAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.imessage ??
|
||||
{}) as IMessageAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveIMessageAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedIMessageAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.imessage?.enabled !== false;
|
||||
const merged = mergeIMessageAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
return {
|
||||
accountId,
|
||||
enabled: baseEnabled && accountEnabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledIMessageAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedIMessageAccount[] {
|
||||
return listIMessageAccountIds(cfg)
|
||||
.map((accountId) => resolveIMessageAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../auto-reply/reply/mentions.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveProviderGroupPolicy,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient } from "./client.js";
|
||||
import { sendMessageIMessage } from "./send.js";
|
||||
import {
|
||||
@@ -56,6 +58,8 @@ export type MonitorIMessageOpts = {
|
||||
abortSignal?: AbortSignal;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
includeAttachments?: boolean;
|
||||
@@ -75,32 +79,21 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveGroupAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.groupAllowFrom ??
|
||||
cfg.imessage?.groupAllowFrom ??
|
||||
(cfg.imessage?.allowFrom && cfg.imessage.allowFrom.length > 0
|
||||
? cfg.imessage.allowFrom
|
||||
: []);
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit } = params;
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId } =
|
||||
params;
|
||||
for (const payload of replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
@@ -108,7 +101,11 @@ async function deliverReplies(params: {
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, textLimit)) {
|
||||
await sendMessageIMessage(target, chunk, { maxBytes, client });
|
||||
await sendMessageIMessage(target, chunk, {
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
@@ -119,6 +116,7 @@ async function deliverReplies(params: {
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,17 +128,32 @@ export async function monitorIMessageProvider(
|
||||
opts: MonitorIMessageOpts = {},
|
||||
): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "imessage");
|
||||
const allowFrom = resolveAllowFrom(opts);
|
||||
const groupAllowFrom = resolveGroupAllowFrom(opts);
|
||||
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
|
||||
const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing";
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const imessageCfg = accountInfo.config;
|
||||
const textLimit = resolveTextChunkLimit(
|
||||
cfg,
|
||||
"imessage",
|
||||
accountInfo.accountId,
|
||||
);
|
||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
imessageCfg.groupAllowFrom ??
|
||||
(imessageCfg.allowFrom && imessageCfg.allowFrom.length > 0
|
||||
? imessageCfg.allowFrom
|
||||
: []),
|
||||
);
|
||||
const groupPolicy = imessageCfg.groupPolicy ?? "open";
|
||||
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const includeAttachments =
|
||||
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
|
||||
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.imessage?.mediaMaxMb ?? 16) * 1024 * 1024;
|
||||
(opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
|
||||
|
||||
const handleMessage = async (raw: unknown) => {
|
||||
const params = raw as { message?: IMessagePayload | null };
|
||||
@@ -202,6 +215,7 @@ export async function monitorIMessageProvider(
|
||||
const groupListPolicy = resolveProviderGroupPolicy({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
groupId,
|
||||
});
|
||||
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
||||
@@ -254,6 +268,7 @@ export async function monitorIMessageProvider(
|
||||
{
|
||||
client,
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
},
|
||||
);
|
||||
@@ -279,6 +294,7 @@ export async function monitorIMessageProvider(
|
||||
const requireMention = resolveProviderGroupRequireMention({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
groupId,
|
||||
requireMentionOverride: opts.requireMention,
|
||||
overrideOrder: "before-config",
|
||||
@@ -344,6 +360,7 @@ export async function monitorIMessageProvider(
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup
|
||||
@@ -410,6 +427,7 @@ export async function monitorIMessageProvider(
|
||||
replies: [payload],
|
||||
target: ctxPayload.To,
|
||||
client,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
@@ -431,8 +449,8 @@ export async function monitorIMessageProvider(
|
||||
};
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath: opts.cliPath ?? cfg.imessage?.cliPath,
|
||||
dbPath: opts.dbPath ?? cfg.imessage?.dbPath,
|
||||
cliPath: opts.cliPath ?? imessageCfg.cliPath,
|
||||
dbPath: opts.dbPath ?? imessageCfg.dbPath,
|
||||
runtime,
|
||||
onNotification: (msg) => {
|
||||
if (msg.method === "message") {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
@@ -14,6 +15,7 @@ export type IMessageSendOpts = {
|
||||
dbPath?: string;
|
||||
service?: IMessageService;
|
||||
region?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
@@ -25,28 +27,6 @@ export type IMessageSendResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
function resolveCliPath(explicit?: string): string {
|
||||
const cfg = loadConfig();
|
||||
return explicit?.trim() || cfg.imessage?.cliPath?.trim() || "imsg";
|
||||
}
|
||||
|
||||
function resolveDbPath(explicit?: string): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
return explicit?.trim() || cfg.imessage?.dbPath?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveService(explicit?: IMessageService): IMessageService {
|
||||
const cfg = loadConfig();
|
||||
return (
|
||||
explicit || (cfg.imessage?.service as IMessageService | undefined) || "auto"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRegion(explicit?: string): string {
|
||||
const cfg = loadConfig();
|
||||
return explicit?.trim() || cfg.imessage?.region?.trim() || "US";
|
||||
}
|
||||
|
||||
async function resolveAttachment(
|
||||
mediaUrl: string,
|
||||
maxBytes: number,
|
||||
@@ -66,15 +46,28 @@ export async function sendMessageIMessage(
|
||||
text: string,
|
||||
opts: IMessageSendOpts = {},
|
||||
): Promise<IMessageSendResult> {
|
||||
const cliPath = resolveCliPath(opts.cliPath);
|
||||
const dbPath = resolveDbPath(opts.dbPath);
|
||||
const cfg = loadConfig();
|
||||
const account = resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const cliPath =
|
||||
opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
||||
const target = parseIMessageTarget(
|
||||
opts.chatId ? formatIMessageChatTarget(opts.chatId) : to,
|
||||
);
|
||||
const service =
|
||||
opts.service ?? (target.kind === "handle" ? target.service : undefined);
|
||||
const region = resolveRegion(opts.region);
|
||||
const maxBytes = opts.maxBytes ?? 16 * 1024 * 1024;
|
||||
opts.service ??
|
||||
(target.kind === "handle" ? target.service : undefined) ??
|
||||
(account.config.service as IMessageService | undefined);
|
||||
const region = opts.region?.trim() || account.config.region?.trim() || "US";
|
||||
const maxBytes =
|
||||
typeof opts.maxBytes === "number"
|
||||
? opts.maxBytes
|
||||
: typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: 16 * 1024 * 1024;
|
||||
let message = text ?? "";
|
||||
let filePath: string | undefined;
|
||||
|
||||
@@ -94,7 +87,7 @@ export async function sendMessageIMessage(
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
service: resolveService(service),
|
||||
service: (service || "auto") as IMessageService,
|
||||
region,
|
||||
};
|
||||
if (filePath) params.file = filePath;
|
||||
|
||||
@@ -411,7 +411,7 @@ describe("runHeartbeatOnce", () => {
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"Hello from heartbeat",
|
||||
expect.objectContaining({ token: "test-bot-token-123" }),
|
||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "./deliver.js";
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
it("chunks telegram markdown and passes config token", async () => {
|
||||
it("chunks telegram markdown and passes account id", async () => {
|
||||
const sendTelegram = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||
@@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => {
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||
for (const call of sendTelegram.mock.calls) {
|
||||
expect(call[2]).toEqual(
|
||||
expect.objectContaining({ token: "tok-1", verbose: false }),
|
||||
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||
);
|
||||
}
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
@@ -7,10 +7,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { sendMessageDiscord } from "../../discord/send.js";
|
||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { sendMessageSignal } from "../../signal/send.js";
|
||||
import { sendMessageSlack } from "../../slack/send.js";
|
||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||
import { normalizeOutboundPayloads } from "./payloads.js";
|
||||
@@ -64,9 +64,15 @@ type ProviderHandler = {
|
||||
function resolveMediaMaxBytes(
|
||||
cfg: ClawdbotConfig,
|
||||
provider: "signal" | "imessage",
|
||||
accountId?: string | null,
|
||||
): number | undefined {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const providerLimit =
|
||||
provider === "signal" ? cfg.signal?.mediaMaxMb : cfg.imessage?.mediaMaxMb;
|
||||
provider === "signal"
|
||||
? (cfg.signal?.accounts?.[normalizedAccountId]?.mediaMaxMb ??
|
||||
cfg.signal?.mediaMaxMb)
|
||||
: (cfg.imessage?.accounts?.[normalizedAccountId]?.mediaMaxMb ??
|
||||
cfg.imessage?.mediaMaxMb);
|
||||
if (providerLimit) return providerLimit * MB;
|
||||
if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB;
|
||||
return undefined;
|
||||
@@ -76,20 +82,18 @@ function createProviderHandler(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: Exclude<OutboundProvider, "none">;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
deps: Required<OutboundSendDeps>;
|
||||
}): ProviderHandler {
|
||||
const { cfg, to, deps } = params;
|
||||
const telegramToken =
|
||||
params.provider === "telegram"
|
||||
? resolveTelegramToken(cfg).token || undefined
|
||||
: undefined;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const signalMaxBytes =
|
||||
params.provider === "signal"
|
||||
? resolveMediaMaxBytes(cfg, "signal")
|
||||
? resolveMediaMaxBytes(cfg, "signal", accountId)
|
||||
: undefined;
|
||||
const imessageMaxBytes =
|
||||
params.provider === "imessage"
|
||||
? resolveMediaMaxBytes(cfg, "imessage")
|
||||
? resolveMediaMaxBytes(cfg, "imessage", accountId)
|
||||
: undefined;
|
||||
|
||||
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
|
||||
@@ -97,13 +101,17 @@ function createProviderHandler(params: {
|
||||
chunker: providerCaps.whatsapp.chunker,
|
||||
sendText: async (text) => ({
|
||||
provider: "whatsapp",
|
||||
...(await deps.sendWhatsApp(to, text, { verbose: false })),
|
||||
...(await deps.sendWhatsApp(to, text, {
|
||||
verbose: false,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
provider: "whatsapp",
|
||||
...(await deps.sendWhatsApp(to, caption, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
@@ -113,7 +121,7 @@ function createProviderHandler(params: {
|
||||
provider: "telegram",
|
||||
...(await deps.sendTelegram(to, text, {
|
||||
verbose: false,
|
||||
token: telegramToken,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
@@ -121,7 +129,7 @@ function createProviderHandler(params: {
|
||||
...(await deps.sendTelegram(to, caption, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
token: telegramToken,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
@@ -129,13 +137,17 @@ function createProviderHandler(params: {
|
||||
chunker: providerCaps.discord.chunker,
|
||||
sendText: async (text) => ({
|
||||
provider: "discord",
|
||||
...(await deps.sendDiscord(to, text, { verbose: false })),
|
||||
...(await deps.sendDiscord(to, text, {
|
||||
verbose: false,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
provider: "discord",
|
||||
...(await deps.sendDiscord(to, caption, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
@@ -143,24 +155,33 @@ function createProviderHandler(params: {
|
||||
chunker: providerCaps.slack.chunker,
|
||||
sendText: async (text) => ({
|
||||
provider: "slack",
|
||||
...(await deps.sendSlack(to, text)),
|
||||
...(await deps.sendSlack(to, text, {
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
provider: "slack",
|
||||
...(await deps.sendSlack(to, caption, { mediaUrl })),
|
||||
...(await deps.sendSlack(to, caption, {
|
||||
mediaUrl,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
signal: {
|
||||
chunker: providerCaps.signal.chunker,
|
||||
sendText: async (text) => ({
|
||||
provider: "signal",
|
||||
...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })),
|
||||
...(await deps.sendSignal(to, text, {
|
||||
maxBytes: signalMaxBytes,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
provider: "signal",
|
||||
...(await deps.sendSignal(to, caption, {
|
||||
mediaUrl,
|
||||
maxBytes: signalMaxBytes,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
@@ -168,13 +189,17 @@ function createProviderHandler(params: {
|
||||
chunker: providerCaps.imessage.chunker,
|
||||
sendText: async (text) => ({
|
||||
provider: "imessage",
|
||||
...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })),
|
||||
...(await deps.sendIMessage(to, text, {
|
||||
maxBytes: imessageMaxBytes,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
sendMedia: async (caption, mediaUrl) => ({
|
||||
provider: "imessage",
|
||||
...(await deps.sendIMessage(to, caption, {
|
||||
mediaUrl,
|
||||
maxBytes: imessageMaxBytes,
|
||||
accountId,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
@@ -187,6 +212,7 @@ export async function deliverOutboundPayloads(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: Exclude<OutboundProvider, "none">;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
payloads: ReplyPayload[];
|
||||
deps?: OutboundSendDeps;
|
||||
bestEffort?: boolean;
|
||||
@@ -194,6 +220,7 @@ export async function deliverOutboundPayloads(params: {
|
||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||
}): Promise<OutboundDeliveryResult[]> {
|
||||
const { cfg, provider, to, payloads } = params;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const deps = {
|
||||
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
||||
@@ -208,9 +235,10 @@ export async function deliverOutboundPayloads(params: {
|
||||
provider,
|
||||
to,
|
||||
deps,
|
||||
accountId,
|
||||
});
|
||||
const textLimit = handler.chunker
|
||||
? resolveTextChunkLimit(cfg, provider)
|
||||
? resolveTextChunkLimit(cfg, provider, accountId)
|
||||
: undefined;
|
||||
|
||||
const sendTextChunks = async (text: string) => {
|
||||
|
||||
90
src/signal/accounts.ts
Normal file
90
src/signal/accounts.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SignalAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type ResolvedSignalAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
baseUrl: string;
|
||||
configured: boolean;
|
||||
config: SignalAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.signal?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listSignalAccountIds(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 resolveDefaultSignalAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listSignalAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig | undefined {
|
||||
const accounts = cfg.signal?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as SignalAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeSignalAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SignalAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.signal ??
|
||||
{}) as SignalAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveSignalAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedSignalAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.signal?.enabled !== false;
|
||||
const merged = mergeSignalAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const host = merged.httpHost?.trim() || "127.0.0.1";
|
||||
const port = merged.httpPort ?? 8080;
|
||||
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
|
||||
const configured = Boolean(
|
||||
merged.account?.trim() ||
|
||||
merged.httpUrl?.trim() ||
|
||||
merged.cliPath?.trim() ||
|
||||
merged.httpHost?.trim() ||
|
||||
typeof merged.httpPort === "number" ||
|
||||
typeof merged.autoStart === "boolean",
|
||||
);
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
baseUrl,
|
||||
configured,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledSignalAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedSignalAccount[] {
|
||||
return listSignalAccountIds(cfg)
|
||||
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { spawnSignalDaemon } from "./daemon.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
@@ -51,6 +53,8 @@ export type MonitorSignalOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
baseUrl?: string;
|
||||
autoStart?: boolean;
|
||||
cliPath?: string;
|
||||
@@ -83,36 +87,8 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBaseUrl(opts: MonitorSignalOpts): string {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
if (opts.baseUrl?.trim()) return opts.baseUrl.trim();
|
||||
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
|
||||
const host = opts.httpHost ?? signalCfg?.httpHost ?? "127.0.0.1";
|
||||
const port = opts.httpPort ?? signalCfg?.httpPort ?? 8080;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function resolveAccount(opts: MonitorSignalOpts): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
return opts.account?.trim() || cfg.signal?.account?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] {
|
||||
const cfg = loadConfig();
|
||||
const raw =
|
||||
opts.groupAllowFrom ??
|
||||
cfg.signal?.groupAllowFrom ??
|
||||
(cfg.signal?.allowFrom && cfg.signal.allowFrom.length > 0
|
||||
? cfg.signal.allowFrom
|
||||
: []);
|
||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||
@@ -207,12 +183,21 @@ async function deliverReplies(params: {
|
||||
target: string;
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } =
|
||||
params;
|
||||
const {
|
||||
replies,
|
||||
target,
|
||||
baseUrl,
|
||||
account,
|
||||
accountId,
|
||||
runtime,
|
||||
maxBytes,
|
||||
textLimit,
|
||||
} = params;
|
||||
for (const payload of replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
@@ -224,6 +209,7 @@ async function deliverReplies(params: {
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -236,6 +222,7 @@ async function deliverReplies(params: {
|
||||
account,
|
||||
mediaUrl: url,
|
||||
maxBytes,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -247,37 +234,53 @@ export async function monitorSignalProvider(
|
||||
opts: MonitorSignalOpts = {},
|
||||
): Promise<void> {
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal");
|
||||
const baseUrl = resolveBaseUrl(opts);
|
||||
const account = resolveAccount(opts);
|
||||
const dmPolicy = cfg.signal?.dmPolicy ?? "pairing";
|
||||
const allowFrom = resolveAllowFrom(opts);
|
||||
const groupAllowFrom = resolveGroupAllowFrom(opts);
|
||||
const groupPolicy = cfg.signal?.groupPolicy ?? "open";
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const dmPolicy = accountInfo.config.dmPolicy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(
|
||||
opts.allowFrom ?? accountInfo.config.allowFrom,
|
||||
);
|
||||
const groupAllowFrom = normalizeAllowList(
|
||||
opts.groupAllowFrom ??
|
||||
accountInfo.config.groupAllowFrom ??
|
||||
(accountInfo.config.allowFrom && accountInfo.config.allowFrom.length > 0
|
||||
? accountInfo.config.allowFrom
|
||||
: []),
|
||||
);
|
||||
const groupPolicy = accountInfo.config.groupPolicy ?? "open";
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments =
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments ?? false;
|
||||
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
|
||||
const autoStart =
|
||||
opts.autoStart ?? cfg.signal?.autoStart ?? !cfg.signal?.httpUrl;
|
||||
opts.autoStart ??
|
||||
accountInfo.config.autoStart ??
|
||||
!accountInfo.config.httpUrl;
|
||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||
|
||||
if (autoStart) {
|
||||
const cliPath = opts.cliPath ?? cfg.signal?.cliPath ?? "signal-cli";
|
||||
const httpHost = opts.httpHost ?? cfg.signal?.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? cfg.signal?.httpPort ?? 8080;
|
||||
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||
const httpHost =
|
||||
opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||
daemonHandle = spawnSignalDaemon({
|
||||
cliPath,
|
||||
account,
|
||||
httpHost,
|
||||
httpPort,
|
||||
receiveMode: opts.receiveMode ?? cfg.signal?.receiveMode,
|
||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||
ignoreAttachments:
|
||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? cfg.signal?.ignoreStories,
|
||||
sendReadReceipts: opts.sendReadReceipts ?? cfg.signal?.sendReadReceipts,
|
||||
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||
sendReadReceipts:
|
||||
opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
@@ -357,7 +360,12 @@ export async function monitorSignalProvider(
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider signal <code>",
|
||||
].join("\n"),
|
||||
{ baseUrl, account, maxBytes: mediaMaxBytes },
|
||||
{
|
||||
baseUrl,
|
||||
account,
|
||||
maxBytes: mediaMaxBytes,
|
||||
accountId: accountInfo.accountId,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
@@ -447,6 +455,7 @@ export async function monitorSignalProvider(
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "signal",
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
||||
@@ -505,6 +514,7 @@ export async function monitorSignalProvider(
|
||||
target: ctxPayload.To,
|
||||
baseUrl,
|
||||
account,
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
textLimit,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { loadConfig } from "../config/config.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { saveMediaBuffer } from "../media/store.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
|
||||
export type SignalSendOpts = {
|
||||
baseUrl?: string;
|
||||
account?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
@@ -22,23 +24,6 @@ type SignalTarget =
|
||||
| { type: "group"; groupId: string }
|
||||
| { type: "username"; username: string };
|
||||
|
||||
function resolveBaseUrl(explicit?: string): string {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
if (explicit?.trim()) return explicit.trim();
|
||||
if (signalCfg?.httpUrl?.trim()) return signalCfg.httpUrl.trim();
|
||||
const host = signalCfg?.httpHost?.trim() || "127.0.0.1";
|
||||
const port = signalCfg?.httpPort ?? 8080;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function resolveAccount(explicit?: string): string | undefined {
|
||||
const cfg = loadConfig();
|
||||
const signalCfg = cfg.signal;
|
||||
const account = explicit?.trim() || signalCfg?.account?.trim();
|
||||
return account || undefined;
|
||||
}
|
||||
|
||||
function parseTarget(raw: string): SignalTarget {
|
||||
let value = raw.trim();
|
||||
if (!value) throw new Error("Signal recipient is required");
|
||||
@@ -81,11 +66,25 @@ export async function sendMessageSignal(
|
||||
text: string,
|
||||
opts: SignalSendOpts = {},
|
||||
): Promise<SignalSendResult> {
|
||||
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
||||
const account = resolveAccount(opts.account);
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveSignalAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
||||
const target = parseTarget(to);
|
||||
let message = text ?? "";
|
||||
const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
|
||||
const maxBytes = (() => {
|
||||
if (typeof opts.maxBytes === "number") return opts.maxBytes;
|
||||
if (typeof accountInfo.config.mediaMaxMb === "number") {
|
||||
return accountInfo.config.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
if (typeof cfg.agent?.mediaMaxMb === "number") {
|
||||
return cfg.agent.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
return 8 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
let attachments: string[] | undefined;
|
||||
if (opts.mediaUrl?.trim()) {
|
||||
|
||||
113
src/slack/accounts.ts
Normal file
113
src/slack/accounts.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SlackAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
export type SlackTokenSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedSlackAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
botTokenSource: SlackTokenSource;
|
||||
appTokenSource: SlackTokenSource;
|
||||
config: SlackAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.slack?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listSlackAccountIds(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 resolveDefaultSlackAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listSlackAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SlackAccountConfig | undefined {
|
||||
const accounts = cfg.slack?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as SlackAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeSlackAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): SlackAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.slack ??
|
||||
{}) as SlackAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveSlackAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedSlackAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.slack?.enabled !== false;
|
||||
const merged = mergeSlackAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
const botToken = resolveSlackBotToken(
|
||||
merged.botToken ??
|
||||
(allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ??
|
||||
(allowEnv ? params.cfg.slack?.botToken : undefined),
|
||||
);
|
||||
const appToken = resolveSlackAppToken(
|
||||
merged.appToken ??
|
||||
(allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ??
|
||||
(allowEnv ? params.cfg.slack?.appToken : undefined),
|
||||
);
|
||||
const botTokenSource: SlackTokenSource = merged.botToken
|
||||
? "config"
|
||||
: allowEnv && process.env.SLACK_BOT_TOKEN
|
||||
? "env"
|
||||
: allowEnv && params.cfg.slack?.botToken
|
||||
? "config"
|
||||
: "none";
|
||||
const appTokenSource: SlackTokenSource = merged.appToken
|
||||
? "config"
|
||||
: allowEnv && process.env.SLACK_APP_TOKEN
|
||||
? "env"
|
||||
: allowEnv && params.cfg.slack?.appToken
|
||||
? "config"
|
||||
: "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
appToken,
|
||||
botTokenSource,
|
||||
appTokenSource,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledSlackAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedSlackAccount[] {
|
||||
return listSlackAccountIds(cfg)
|
||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
SlackReactionNotificationMode,
|
||||
SlackSlashCommandConfig,
|
||||
} from "../config/config.js";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { reactSlackMessage } from "./actions.js";
|
||||
import { sendMessageSlack } from "./send.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
@@ -56,6 +58,8 @@ import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
export type MonitorSlackOpts = {
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
mediaMaxMb?: number;
|
||||
@@ -436,7 +440,11 @@ async function resolveSlackThreadStarter(params: {
|
||||
}
|
||||
|
||||
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const cfg = loadConfig();
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const sessionCfg = cfg.session;
|
||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||
@@ -462,21 +470,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
mainKey,
|
||||
);
|
||||
};
|
||||
const botToken = resolveSlackBotToken(
|
||||
opts.botToken ??
|
||||
process.env.SLACK_BOT_TOKEN ??
|
||||
cfg.slack?.botToken ??
|
||||
undefined,
|
||||
);
|
||||
const appToken = resolveSlackAppToken(
|
||||
opts.appToken ??
|
||||
process.env.SLACK_APP_TOKEN ??
|
||||
cfg.slack?.appToken ??
|
||||
undefined,
|
||||
);
|
||||
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
||||
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
||||
if (!botToken || !appToken) {
|
||||
throw new Error(
|
||||
"SLACK_BOT_TOKEN and SLACK_APP_TOKEN (or slack.botToken/slack.appToken) are required for Slack socket mode",
|
||||
`Slack bot + app tokens missing for account "${account.accountId}" (set slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,26 +486,27 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
},
|
||||
};
|
||||
|
||||
const dmConfig = cfg.slack?.dm;
|
||||
const slackCfg = account.config;
|
||||
const dmConfig = slackCfg.dm;
|
||||
const dmPolicy = dmConfig?.policy ?? "pairing";
|
||||
const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
||||
const channelsConfig = cfg.slack?.channels;
|
||||
const channelsConfig = slackCfg.channels;
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
|
||||
const groupPolicy = slackCfg.groupPolicy ?? "open";
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
||||
const reactionMode = slackCfg.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
|
||||
const slashCommand = resolveSlackSlashCommandConfig(
|
||||
opts.slashCommand ?? cfg.slack?.slashCommand,
|
||||
opts.slashCommand ?? slackCfg.slashCommand,
|
||||
);
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
(opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
|
||||
const logger = getChildLogger({ module: "slack-auto-reply" });
|
||||
const channelCache = new Map<
|
||||
@@ -790,7 +789,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
"Ask the bot owner to approve with:",
|
||||
"clawdbot pairing approve --provider slack <code>",
|
||||
].join("\n"),
|
||||
{ token: botToken, client: app.client },
|
||||
{
|
||||
token: botToken,
|
||||
client: app.client,
|
||||
accountId: account.accountId,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
@@ -922,6 +925,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
@@ -1071,6 +1075,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
textLimit,
|
||||
threadTs: incomingThreadTs,
|
||||
@@ -1749,6 +1754,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "slack",
|
||||
accountId: account.accountId,
|
||||
teamId: teamId || undefined,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||
@@ -1875,6 +1881,7 @@ async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
threadTs?: string;
|
||||
@@ -1893,6 +1900,7 @@ async function deliverReplies(params: {
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs: params.threadTs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -1904,6 +1912,7 @@ async function deliverReplies(params: {
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs: params.threadTs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
const SLACK_TEXT_LIMIT = 4000;
|
||||
@@ -22,6 +23,7 @@ type SlackRecipient =
|
||||
|
||||
type SlackSendOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
client?: WebClient;
|
||||
threadTs?: string;
|
||||
@@ -32,17 +34,20 @@ export type SlackSendResult = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().slack?.botToken;
|
||||
const token = resolveSlackBotToken(
|
||||
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
|
||||
);
|
||||
if (!token) {
|
||||
function resolveToken(params: {
|
||||
explicit?: string;
|
||||
accountId: string;
|
||||
fallbackToken?: string;
|
||||
}) {
|
||||
const explicit = resolveSlackBotToken(params.explicit);
|
||||
if (explicit) return explicit;
|
||||
const fallback = resolveSlackBotToken(params.fallbackToken);
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
"SLACK_BOT_TOKEN or slack.botToken is required for Slack sends",
|
||||
`Slack bot token missing for account "${params.accountId}" (set slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseRecipient(raw: string): SlackRecipient {
|
||||
@@ -140,17 +145,25 @@ export async function sendMessageSlack(
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
throw new Error("Slack send requires text or media");
|
||||
}
|
||||
const token = resolveToken(opts.token);
|
||||
const cfg = loadConfig();
|
||||
const account = resolveSlackAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken({
|
||||
explicit: opts.token,
|
||||
accountId: account.accountId,
|
||||
fallbackToken: account.botToken,
|
||||
});
|
||||
const client = opts.client ?? new WebClient(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const mediaMaxBytes =
|
||||
typeof cfg.slack?.mediaMaxMb === "number"
|
||||
? cfg.slack.mediaMaxMb * 1024 * 1024
|
||||
typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: undefined;
|
||||
|
||||
let lastMessageId = "";
|
||||
|
||||
81
src/telegram/accounts.ts
Normal file
81
src/telegram/accounts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
export type ResolvedTelegramAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
||||
config: TelegramAccountConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.telegram?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(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 resolveDefaultTelegramAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listTelegramAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): TelegramAccountConfig | undefined {
|
||||
const accounts = cfg.telegram?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as TelegramAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeTelegramAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): TelegramAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.telegram ??
|
||||
{}) as TelegramAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveTelegramAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedTelegramAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.telegram?.enabled !== false;
|
||||
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveTelegramToken(params.cfg, { accountId });
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledTelegramAccounts(
|
||||
cfg: ClawdbotConfig,
|
||||
): ResolvedTelegramAccount[] {
|
||||
return listTelegramAccountIds(cfg)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
import {
|
||||
readTelegramAllowFromStore,
|
||||
@@ -105,6 +106,7 @@ type TelegramContext = {
|
||||
|
||||
export type TelegramBotOptions = {
|
||||
token: string;
|
||||
accountId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
requireMention?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
@@ -158,14 +160,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
|
||||
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "telegram");
|
||||
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const telegramCfg = account.config;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "telegram", account.accountId);
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||
const groupAllowFrom =
|
||||
opts.groupAllowFrom ??
|
||||
cfg.telegram?.groupAllowFrom ??
|
||||
(cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0
|
||||
? cfg.telegram.allowFrom
|
||||
telegramCfg.groupAllowFrom ??
|
||||
(telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0
|
||||
? telegramCfg.allowFrom
|
||||
: undefined) ??
|
||||
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
|
||||
const normalizeAllowFrom = (list?: Array<string | number>) => {
|
||||
@@ -205,15 +212,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
(entry) => entry === username || entry === `@${username}`,
|
||||
);
|
||||
};
|
||||
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "first";
|
||||
const streamMode = resolveTelegramStreamMode(cfg);
|
||||
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "first";
|
||||
const streamMode = resolveTelegramStreamMode(telegramCfg);
|
||||
const nativeEnabled = cfg.commands?.native === true;
|
||||
const nativeDisabledExplicit = cfg.commands?.native === false;
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const mediaMaxBytes =
|
||||
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||
(opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 5) * 1024 * 1024;
|
||||
const logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
let botHasTopicsEnabled: boolean | undefined;
|
||||
@@ -237,6 +244,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
resolveProviderGroupPolicy({
|
||||
cfg,
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
groupId: String(chatId),
|
||||
});
|
||||
const resolveGroupActivation = (params: {
|
||||
@@ -264,6 +272,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
resolveProviderGroupRequireMention({
|
||||
cfg,
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
groupId: String(chatId),
|
||||
requireMentionOverride: opts.requireMention,
|
||||
overrideOrder: "after-config",
|
||||
@@ -272,7 +281,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => {
|
||||
const groups = cfg.telegram?.groups;
|
||||
const groups = telegramCfg.groups;
|
||||
if (!groups) return { groupConfig: undefined, topicConfig: undefined };
|
||||
const groupKey = String(chatId);
|
||||
const groupConfig = groups[groupKey] ?? groups["*"];
|
||||
@@ -304,6 +313,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: peerId,
|
||||
@@ -814,7 +824,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
}
|
||||
|
||||
if (isGroup && useAccessGroups) {
|
||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
await bot.api.sendMessage(
|
||||
chatId,
|
||||
@@ -881,6 +891,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "telegram",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup
|
||||
@@ -1009,7 +1020,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
// - "open" (default): groups bypass allowFrom, only mention-gating applies
|
||||
// - "disabled": block all group messages entirely
|
||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
||||
const groupPolicy = telegramCfg.groupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
||||
return;
|
||||
@@ -1260,9 +1271,9 @@ function buildTelegramThreadParams(messageThreadId?: number) {
|
||||
}
|
||||
|
||||
function resolveTelegramStreamMode(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
telegramCfg: ClawdbotConfig["telegram"],
|
||||
): TelegramStreamMode {
|
||||
const raw = cfg.telegram?.streamMode?.trim().toLowerCase();
|
||||
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
||||
if (raw === "off" || raw === "partial" || raw === "block") return raw;
|
||||
return "partial";
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@ import { type RunOptions, run } from "@grammyjs/runner";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
import { createTelegramBot } from "./bot.js";
|
||||
import { makeProxyFetch } from "./proxy.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
import { startTelegramWebhook } from "./webhook.js";
|
||||
|
||||
export type MonitorTelegramOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
useWebhook?: boolean;
|
||||
@@ -36,20 +38,22 @@ export function createTelegramRunnerOptions(
|
||||
}
|
||||
|
||||
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
const cfg = loadConfig();
|
||||
const { token } = resolveTelegramToken(cfg, {
|
||||
envToken: opts.token,
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = opts.token?.trim() || account.token;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"TELEGRAM_BOT_TOKEN or telegram.botToken/tokenFile is required for Telegram gateway",
|
||||
`Telegram bot token missing for account "${account.accountId}" (set telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const proxyFetch =
|
||||
opts.proxyFetch ??
|
||||
(cfg.telegram?.proxy
|
||||
? makeProxyFetch(cfg.telegram?.proxy as string)
|
||||
(account.config.proxy
|
||||
? makeProxyFetch(account.config.proxy as string)
|
||||
: undefined);
|
||||
|
||||
const bot = createTelegramBot({
|
||||
@@ -57,6 +61,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||
runtime: opts.runtime,
|
||||
proxyFetch,
|
||||
config: cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
if (opts.useWebhook) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { RetryConfig } from "../infra/retry.js";
|
||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||
import { mediaKindFromMime } from "../media/constants.js";
|
||||
import { isGifMedia } from "../media/mime.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
import { resolveTelegramAccount } from "./accounts.js";
|
||||
|
||||
type TelegramSendOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
verbose?: boolean;
|
||||
mediaUrl?: string;
|
||||
maxBytes?: number;
|
||||
@@ -30,6 +30,7 @@ type TelegramSendResult = {
|
||||
|
||||
type TelegramReactionOpts = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
api?: Bot["api"];
|
||||
remove?: boolean;
|
||||
verbose?: boolean;
|
||||
@@ -39,15 +40,17 @@ type TelegramReactionOpts = {
|
||||
const PARSE_ERR_RE =
|
||||
/can't parse entities|parse entities|find end of the entity/i;
|
||||
|
||||
function resolveToken(explicit?: string, cfg?: ClawdbotConfig): string {
|
||||
function resolveToken(
|
||||
explicit: string | undefined,
|
||||
params: { accountId: string; token: string },
|
||||
) {
|
||||
if (explicit?.trim()) return explicit.trim();
|
||||
const { token } = resolveTelegramToken(cfg);
|
||||
if (!token) {
|
||||
if (!params.token) {
|
||||
throw new Error(
|
||||
"TELEGRAM_BOT_TOKEN (or telegram.botToken/tokenFile) is required for Telegram sends (Bot API)",
|
||||
`Telegram bot token missing for account "${params.accountId}" (set telegram.accounts.${params.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
return token.trim();
|
||||
return params.token.trim();
|
||||
}
|
||||
|
||||
function normalizeChatId(to: string): string {
|
||||
@@ -97,7 +100,11 @@ export async function sendMessageTelegram(
|
||||
opts: TelegramSendOpts = {},
|
||||
): Promise<TelegramSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const token = resolveToken(opts.token, cfg);
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(to);
|
||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||
// operator ensures api is always defined (Bot.api is always non-null).
|
||||
@@ -116,7 +123,7 @@ export async function sendMessageTelegram(
|
||||
const hasThreadParams = Object.keys(threadParams).length > 0;
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: cfg.telegram?.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
|
||||
@@ -236,13 +243,17 @@ export async function reactMessageTelegram(
|
||||
opts: TelegramReactionOpts = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const cfg = loadConfig();
|
||||
const token = resolveToken(opts.token, cfg);
|
||||
const account = resolveTelegramAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = resolveToken(opts.token, account);
|
||||
const chatId = normalizeChatId(String(chatIdInput));
|
||||
const messageId = normalizeMessageId(messageIdInput);
|
||||
const api = opts.api ?? new Bot(token).api;
|
||||
const request = createTelegramRetryRunner({
|
||||
retry: opts.retry,
|
||||
configRetry: cfg.telegram?.retry,
|
||||
configRetry: account.config.retry,
|
||||
verbose: opts.verbose,
|
||||
});
|
||||
const remove = opts.remove === true;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
||||
|
||||
@@ -11,6 +15,7 @@ export type TelegramTokenResolution = {
|
||||
|
||||
type ResolveTelegramTokenOpts = {
|
||||
envToken?: string | null;
|
||||
accountId?: string | null;
|
||||
logMissingFile?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -18,13 +23,48 @@ export function resolveTelegramToken(
|
||||
cfg?: ClawdbotConfig,
|
||||
opts: ResolveTelegramTokenOpts = {},
|
||||
): TelegramTokenResolution {
|
||||
const envToken = (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim();
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
const accountCfg =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? cfg?.telegram?.accounts?.[accountId]
|
||||
: cfg?.telegram?.accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
const accountTokenFile = accountCfg?.tokenFile?.trim();
|
||||
if (accountTokenFile) {
|
||||
if (!fs.existsSync(accountTokenFile)) {
|
||||
opts.logMissingFile?.(
|
||||
`telegram.accounts.${accountId}.tokenFile not found: ${accountTokenFile}`,
|
||||
);
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
try {
|
||||
const token = fs.readFileSync(accountTokenFile, "utf-8").trim();
|
||||
if (token) {
|
||||
return { token, source: "tokenFile" };
|
||||
}
|
||||
} catch (err) {
|
||||
opts.logMissingFile?.(
|
||||
`telegram.accounts.${accountId}.tokenFile read failed: ${String(err)}`,
|
||||
);
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const accountToken = accountCfg?.botToken?.trim();
|
||||
if (accountToken) {
|
||||
return { token: accountToken, source: "config" };
|
||||
}
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv
|
||||
? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim()
|
||||
: "";
|
||||
if (envToken) {
|
||||
return { token: envToken, source: "env" };
|
||||
}
|
||||
|
||||
const tokenFile = cfg?.telegram?.tokenFile?.trim();
|
||||
if (tokenFile) {
|
||||
if (tokenFile && allowEnv) {
|
||||
if (!fs.existsSync(tokenFile)) {
|
||||
opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`);
|
||||
return { token: "", source: "none" };
|
||||
@@ -38,11 +78,10 @@ export function resolveTelegramToken(
|
||||
opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`);
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const configToken = cfg?.telegram?.botToken?.trim();
|
||||
if (configToken) {
|
||||
if (configToken && allowEnv) {
|
||||
return { token: configToken, source: "config" };
|
||||
}
|
||||
|
||||
|
||||
20
src/utils.ts
20
src/utils.ts
@@ -142,5 +142,25 @@ export function shortenHomeInString(input: string): string {
|
||||
return input.split(home).join("~");
|
||||
}
|
||||
|
||||
export function formatTerminalLink(
|
||||
label: string,
|
||||
url: string,
|
||||
opts?: { fallback?: string; force?: boolean },
|
||||
): string {
|
||||
const esc = "\u001b";
|
||||
const safeLabel = label.replaceAll(esc, "");
|
||||
const safeUrl = url.replaceAll(esc, "");
|
||||
const allow =
|
||||
opts?.force === true
|
||||
? true
|
||||
: opts?.force === false
|
||||
? false
|
||||
: Boolean(process.stdout.isTTY);
|
||||
if (!allow) {
|
||||
return opts?.fallback ?? `${safeLabel} (${safeUrl})`;
|
||||
}
|
||||
return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`;
|
||||
}
|
||||
|
||||
// Configuration root; can be overridden via CLAWDBOT_STATE_DIR.
|
||||
export const CONFIG_DIR = resolveConfigDir();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export type ResolvedWhatsAppAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
authDir: string;
|
||||
isLegacyAuthDir: boolean;
|
||||
@@ -101,6 +102,7 @@ export function resolveWhatsAppAccount(params: {
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
name: accountCfg?.name?.trim() || undefined,
|
||||
enabled,
|
||||
authDir,
|
||||
isLegacyAuthDir: isLegacy,
|
||||
|
||||
Reference in New Issue
Block a user