feat: add providers CLI and multi-account onboarding
This commit is contained in:
parent
6b3ed40d0f
commit
05b8679c8b
@ -260,6 +260,33 @@ Notes:
|
|||||||
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
|
- 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`.
|
- 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`
|
### `routing.groupChat`
|
||||||
|
|
||||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
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`.
|
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.
|
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
|
```json5
|
||||||
{
|
{
|
||||||
@ -609,6 +637,7 @@ Retry policy defaults and behavior are documented in [Retry policy](/concepts/re
|
|||||||
### `discord` (bot transport)
|
### `discord` (bot transport)
|
||||||
|
|
||||||
Configure the Discord bot by setting the bot token and optional gating:
|
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
|
```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.
|
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:
|
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:
|
Notes:
|
||||||
- Requires Full Disk Access to the Messages DB.
|
- Requires Full Disk Access to the Messages DB.
|
||||||
- The first send will prompt for Messages automation permission.
|
- The first send will prompt for Messages automation permission.
|
||||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
- 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`
|
### `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
|
#### Allowlist + channel routing
|
||||||
Example “single server, only allow me, only allow #help”:
|
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.
|
- macOS with Messages signed in.
|
||||||
- Full Disk Access for Clawdbot + `imsg` (Messages DB access).
|
- Full Disk Access for Clawdbot + `imsg` (Messages DB access).
|
||||||
- Automation permission when sending.
|
- 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)
|
## Setup (fast path)
|
||||||
1) Ensure Messages is signed in on this Mac.
|
1) Ensure Messages is signed in on this Mac.
|
||||||
2) Configure iMessage and start the gateway.
|
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:
|
Example:
|
||||||
```json5
|
```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)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `imessage.dmPolicy = "pairing"`.
|
- 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)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `signal.dmPolicy = "pairing"`.
|
- 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.
|
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)
|
## Manifest (optional)
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want).
|
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.
|
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.
|
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.
|
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);
|
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", () => {
|
it("uses the matching provider override", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
discord: { textChunkLimit: 111 },
|
discord: { textChunkLimit: 111 },
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
isSafeFenceBreak,
|
isSafeFenceBreak,
|
||||||
parseFenceSpans,
|
parseFenceSpans,
|
||||||
} from "../markdown/fences.js";
|
} from "../markdown/fences.js";
|
||||||
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
|
|
||||||
export type TextChunkProvider =
|
export type TextChunkProvider =
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
@ -31,15 +32,44 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
|
|||||||
export function resolveTextChunkLimit(
|
export function resolveTextChunkLimit(
|
||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
provider?: TextChunkProvider,
|
provider?: TextChunkProvider,
|
||||||
|
accountId?: string | null,
|
||||||
): number {
|
): number {
|
||||||
const providerOverride = (() => {
|
const providerOverride = (() => {
|
||||||
if (!provider) return undefined;
|
if (!provider) return undefined;
|
||||||
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
|
if (provider === "whatsapp") {
|
||||||
if (provider === "discord") return cfg?.discord?.textChunkLimit;
|
return cfg?.whatsapp?.textChunkLimit;
|
||||||
if (provider === "slack") return cfg?.slack?.textChunkLimit;
|
}
|
||||||
if (provider === "signal") return cfg?.signal?.textChunkLimit;
|
if (provider === "telegram") {
|
||||||
if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
|
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;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
if (typeof providerOverride === "number" && providerOverride > 0) {
|
if (typeof providerOverride === "number" && providerOverride > 0) {
|
||||||
|
|||||||
@ -85,6 +85,7 @@ export async function routeReply(
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
messageThreadId: threadId,
|
messageThreadId: threadId,
|
||||||
replyToMessageId: resolvedReplyToMessageId,
|
replyToMessageId: resolvedReplyToMessageId,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
@ -93,6 +94,7 @@ export async function routeReply(
|
|||||||
const result = await sendMessageSlack(to, text, {
|
const result = await sendMessageSlack(to, text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
threadTs: replyToId,
|
threadTs: replyToId,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
@ -101,17 +103,24 @@ export async function routeReply(
|
|||||||
const result = await sendMessageDiscord(to, text, {
|
const result = await sendMessageDiscord(to, text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo: replyToId,
|
replyTo: replyToId,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "signal": {
|
case "signal": {
|
||||||
const result = await sendMessageSignal(to, text, { mediaUrl });
|
const result = await sendMessageSignal(to, text, {
|
||||||
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "imessage": {
|
case "imessage": {
|
||||||
const result = await sendMessageIMessage(to, text, { mediaUrl });
|
const result = await sendMessageIMessage(to, text, {
|
||||||
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
return { ok: true, messageId: result.messageId };
|
return { ok: true, messageId: result.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import { registerModelsCli } from "./models-cli.js";
|
|||||||
import { registerNodesCli } from "./nodes-cli.js";
|
import { registerNodesCli } from "./nodes-cli.js";
|
||||||
import { registerPairingCli } from "./pairing-cli.js";
|
import { registerPairingCli } from "./pairing-cli.js";
|
||||||
import { forceFreePort } from "./ports.js";
|
import { forceFreePort } from "./ports.js";
|
||||||
|
import { registerProvidersCli } from "./providers-cli.js";
|
||||||
import { registerTelegramCli } from "./telegram-cli.js";
|
import { registerTelegramCli } from "./telegram-cli.js";
|
||||||
import { registerTuiCli } from "./tui-cli.js";
|
import { registerTuiCli } from "./tui-cli.js";
|
||||||
|
|
||||||
@ -637,6 +638,7 @@ Examples:
|
|||||||
registerDocsCli(program);
|
registerDocsCli(program);
|
||||||
registerHooksCli(program);
|
registerHooksCli(program);
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
|
registerProvidersCli(program);
|
||||||
registerTelegramCli(program);
|
registerTelegramCli(program);
|
||||||
|
|
||||||
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) => {
|
await withTempHome(async (home) => {
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
|
mockConfig(home, store, undefined, undefined, { botToken: "t-1" });
|
||||||
@ -297,7 +297,7 @@ describe("agentCommand", () => {
|
|||||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"ok",
|
"ok",
|
||||||
expect.objectContaining({ token: "t-1" }),
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (prevTelegramToken === undefined) {
|
if (prevTelegramToken === undefined) {
|
||||||
|
|||||||
@ -322,17 +322,18 @@ function buildProviderBindings(params: {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
selection: ProviderChoice[];
|
selection: ProviderChoice[];
|
||||||
config: ClawdbotConfig;
|
config: ClawdbotConfig;
|
||||||
whatsappAccountId?: string;
|
accountIds?: Partial<Record<ProviderChoice, string>>;
|
||||||
}): AgentBinding[] {
|
}): AgentBinding[] {
|
||||||
const bindings: AgentBinding[] = [];
|
const bindings: AgentBinding[] = [];
|
||||||
const agentId = normalizeAgentId(params.agentId);
|
const agentId = normalizeAgentId(params.agentId);
|
||||||
for (const provider of params.selection) {
|
for (const provider of params.selection) {
|
||||||
const match: AgentBinding["match"] = { provider };
|
const match: AgentBinding["match"] = { provider };
|
||||||
if (provider === "whatsapp") {
|
const accountId = params.accountIds?.[provider]?.trim();
|
||||||
const accountId =
|
if (accountId) {
|
||||||
params.whatsappAccountId?.trim() ||
|
match.accountId = accountId;
|
||||||
resolveDefaultWhatsAppAccountId(params.config);
|
} else if (provider === "whatsapp") {
|
||||||
match.accountId = accountId || DEFAULT_ACCOUNT_ID;
|
const defaultId = resolveDefaultWhatsAppAccountId(params.config);
|
||||||
|
match.accountId = defaultId || DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
bindings.push({ agentId, match });
|
bindings.push({ agentId, match });
|
||||||
}
|
}
|
||||||
@ -493,15 +494,15 @@ export async function agentsAddCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let selection: ProviderChoice[] = [];
|
let selection: ProviderChoice[] = [];
|
||||||
let whatsappAccountId: string | undefined;
|
const providerAccountIds: Partial<Record<ProviderChoice, string>> = {};
|
||||||
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
|
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
|
||||||
allowSignalInstall: true,
|
allowSignalInstall: true,
|
||||||
onSelection: (value) => {
|
onSelection: (value) => {
|
||||||
selection = value;
|
selection = value;
|
||||||
},
|
},
|
||||||
promptWhatsAppAccountId: true,
|
promptAccountIds: true,
|
||||||
onWhatsAppAccountId: (value) => {
|
onAccountId: (provider, accountId) => {
|
||||||
whatsappAccountId = value;
|
providerAccountIds[provider] = accountId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -516,7 +517,7 @@ export async function agentsAddCommand(
|
|||||||
agentId,
|
agentId,
|
||||||
selection,
|
selection,
|
||||||
config: nextConfig,
|
config: nextConfig,
|
||||||
whatsappAccountId,
|
accountIds: providerAccountIds,
|
||||||
});
|
});
|
||||||
const result = applyAgentBindings(nextConfig, desiredBindings);
|
const result = applyAgentBindings(nextConfig, desiredBindings);
|
||||||
nextConfig = result.config;
|
nextConfig = result.config;
|
||||||
|
|||||||
@ -44,13 +44,17 @@ function normalizeDefaultWorkspacePath(
|
|||||||
return next === resolved ? value : next;
|
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;
|
if (!value) return value;
|
||||||
const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
|
const replacedClawdis = value.replace(/clawdis/g, "clawdbot");
|
||||||
return replacedClawdis.replace(/clawd(?!bot)/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) return value;
|
||||||
if (!value.includes("clawdbot")) return value;
|
if (!value.includes("clawdbot")) return value;
|
||||||
return value.replace(/clawdbot/g, "clawdis");
|
return value.replace(/clawdbot/g, "clawdis");
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import {
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
|
||||||
import { replaceModernName } from "./doctor-legacy-config.js";
|
import { replaceModernName } from "./doctor-legacy-config.js";
|
||||||
|
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||||
|
|
||||||
type SandboxScriptInfo = {
|
type SandboxScriptInfo = {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
|
|||||||
@ -257,8 +257,10 @@ export async function noteStateIntegrity(
|
|||||||
const recent = entries
|
const recent = entries
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aUpdated = typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
|
const aUpdated =
|
||||||
const bUpdated = typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
|
typeof a[1].updatedAt === "number" ? a[1].updatedAt : 0;
|
||||||
|
const bUpdated =
|
||||||
|
typeof b[1].updatedAt === "number" ? b[1].updatedAt : 0;
|
||||||
return bUpdated - aUpdated;
|
return bUpdated - aUpdated;
|
||||||
})
|
})
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|||||||
@ -13,28 +13,25 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
||||||
import {
|
|
||||||
maybeMigrateLegacyConfigFile,
|
|
||||||
normalizeLegacyConfigValues,
|
|
||||||
} from "./doctor-legacy-config.js";
|
|
||||||
import {
|
import {
|
||||||
maybeMigrateLegacyGatewayService,
|
maybeMigrateLegacyGatewayService,
|
||||||
maybeScanExtraGatewayServices,
|
maybeScanExtraGatewayServices,
|
||||||
} from "./doctor-gateway-services.js";
|
} from "./doctor-gateway-services.js";
|
||||||
import {
|
import {
|
||||||
createDoctorPrompter,
|
maybeMigrateLegacyConfigFile,
|
||||||
type DoctorOptions,
|
normalizeLegacyConfigValues,
|
||||||
} from "./doctor-prompter.js";
|
} from "./doctor-legacy-config.js";
|
||||||
|
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||||
import { maybeRepairSandboxImages } from "./doctor-sandbox.js";
|
import { maybeRepairSandboxImages } from "./doctor-sandbox.js";
|
||||||
import { noteSecurityWarnings } from "./doctor-security.js";
|
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||||
import {
|
|
||||||
detectLegacyStateMigrations,
|
|
||||||
runLegacyStateMigrations,
|
|
||||||
} from "./doctor-state-migrations.js";
|
|
||||||
import {
|
import {
|
||||||
noteStateIntegrity,
|
noteStateIntegrity,
|
||||||
noteWorkspaceBackupTip,
|
noteWorkspaceBackupTip,
|
||||||
} from "./doctor-state-integrity.js";
|
} from "./doctor-state-integrity.js";
|
||||||
|
import {
|
||||||
|
detectLegacyStateMigrations,
|
||||||
|
runLegacyStateMigrations,
|
||||||
|
} from "./doctor-state-migrations.js";
|
||||||
import {
|
import {
|
||||||
MEMORY_SYSTEM_PROMPT,
|
MEMORY_SYSTEM_PROMPT,
|
||||||
shouldSuggestMemorySystem,
|
shouldSuggestMemorySystem,
|
||||||
|
|||||||
@ -2,13 +2,38 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { DmPolicy } from "../config/types.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 { loginWeb } from "../provider-web.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.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 {
|
import {
|
||||||
listWhatsAppAccountIds,
|
listWhatsAppAccountIds,
|
||||||
resolveDefaultWhatsAppAccountId,
|
resolveDefaultWhatsAppAccountId,
|
||||||
@ -19,6 +44,53 @@ import { detectBinary } from "./onboard-helpers.js";
|
|||||||
import type { ProviderChoice } from "./onboard-types.js";
|
import type { ProviderChoice } from "./onboard-types.js";
|
||||||
import { installSignalCli } from "./signal-install.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(
|
function addWildcardAllowFrom(
|
||||||
allowFrom?: Array<string | number> | null,
|
allowFrom?: Array<string | number> | null,
|
||||||
): Array<string | number> {
|
): 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.",
|
"DM security: default is pairing; unknown DMs get a pairing code.",
|
||||||
"Approve with: clawdbot pairing approve --provider <provider> <code>",
|
"Approve with: clawdbot pairing approve --provider <provider> <code>",
|
||||||
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
|
'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.",
|
"WhatsApp: works with your own number; recommend a separate phone + eSIM.",
|
||||||
"Discord: very well supported right now.",
|
"Discord: very well supported right now.",
|
||||||
"Slack: supported (Socket Mode).",
|
"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.",
|
"iMessage: this is still a work in progress.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"How providers work",
|
"How providers work",
|
||||||
@ -71,7 +143,7 @@ async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
|
|||||||
"2) Run /newbot (or /mybots)",
|
"2) Run /newbot (or /mybots)",
|
||||||
"3) Copy the token (looks like 123456:ABC...)",
|
"3) Copy the token (looks like 123456:ABC...)",
|
||||||
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
|
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
|
||||||
"Docs: https://docs.clawd.bot/telegram",
|
`Docs: ${docsLink("/telegram", "telegram")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Telegram bot token",
|
"Telegram bot token",
|
||||||
);
|
);
|
||||||
@ -84,7 +156,7 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
|||||||
"2) Bot → Add Bot → Reset Token → copy token",
|
"2) Bot → Add Bot → Reset Token → copy token",
|
||||||
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
||||||
"Tip: enable Message Content Intent if you need message text.",
|
"Tip: enable Message Content Intent if you need message text.",
|
||||||
"Docs: https://docs.clawd.bot/discord",
|
`Docs: ${docsLink("/discord", "discord")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Discord bot token",
|
"Discord bot token",
|
||||||
);
|
);
|
||||||
@ -172,7 +244,7 @@ async function noteSlackTokenHelp(
|
|||||||
"4) Enable Event Subscriptions (socket) for message events",
|
"4) Enable Event Subscriptions (socket) for message events",
|
||||||
"5) App Home → enable the Messages tab for DMs",
|
"5) App Home → enable the Messages tab for DMs",
|
||||||
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
|
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
|
||||||
"Docs: https://docs.clawd.bot/slack",
|
`Docs: ${docsLink("/slack", "slack")}`,
|
||||||
"",
|
"",
|
||||||
"Manifest (JSON):",
|
"Manifest (JSON):",
|
||||||
manifest,
|
manifest,
|
||||||
@ -345,7 +417,7 @@ async function maybeConfigureDmPolicies(params: {
|
|||||||
"Default: pairing (unknown DMs get a pairing code).",
|
"Default: pairing (unknown DMs get a pairing code).",
|
||||||
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
|
`Approve: clawdbot pairing approve --provider ${params.provider} <code>`,
|
||||||
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
|
`Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`,
|
||||||
"Docs: https://docs.clawd.bot/start/pairing",
|
`Docs: ${docsLink("/start/pairing", "start/pairing")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
`${params.label} DM access`,
|
`${params.label} DM access`,
|
||||||
);
|
);
|
||||||
@ -432,7 +504,7 @@ async function promptWhatsAppAllowFrom(
|
|||||||
"- disabled: ignore WhatsApp DMs",
|
"- disabled: ignore WhatsApp DMs",
|
||||||
"",
|
"",
|
||||||
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
||||||
"Docs: https://docs.clawd.bot/whatsapp",
|
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"WhatsApp DM access",
|
"WhatsApp DM access",
|
||||||
);
|
);
|
||||||
@ -567,6 +639,9 @@ type SetupProvidersOptions = {
|
|||||||
allowDisable?: boolean;
|
allowDisable?: boolean;
|
||||||
allowSignalInstall?: boolean;
|
allowSignalInstall?: boolean;
|
||||||
onSelection?: (selection: ProviderChoice[]) => void;
|
onSelection?: (selection: ProviderChoice[]) => void;
|
||||||
|
accountIds?: Partial<Record<ProviderChoice, string>>;
|
||||||
|
onAccountId?: (provider: ProviderChoice, accountId: string) => void;
|
||||||
|
promptAccountIds?: boolean;
|
||||||
whatsappAccountId?: string;
|
whatsappAccountId?: string;
|
||||||
promptWhatsAppAccountId?: boolean;
|
promptWhatsAppAccountId?: boolean;
|
||||||
onWhatsAppAccountId?: (accountId: string) => void;
|
onWhatsAppAccountId?: (accountId: string) => void;
|
||||||
@ -585,22 +660,31 @@ export async function setupProviders(
|
|||||||
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
||||||
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
|
const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim());
|
||||||
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
|
const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim());
|
||||||
const telegramConfigured = Boolean(
|
const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) =>
|
||||||
telegramEnv || cfg.telegram?.botToken || cfg.telegram?.tokenFile,
|
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
|
||||||
);
|
);
|
||||||
const discordConfigured = Boolean(discordEnv || cfg.discord?.token);
|
const discordConfigured = listDiscordAccountIds(cfg).some((accountId) =>
|
||||||
const slackConfigured = Boolean(
|
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
|
||||||
(slackBotEnv && slackAppEnv) ||
|
|
||||||
(cfg.slack?.botToken && cfg.slack?.appToken),
|
|
||||||
);
|
);
|
||||||
const signalConfigured = Boolean(
|
const slackConfigured = listSlackAccountIds(cfg).some((accountId) => {
|
||||||
cfg.signal?.account || cfg.signal?.httpUrl || cfg.signal?.httpPort,
|
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 signalCliPath = cfg.signal?.cliPath ?? "signal-cli";
|
||||||
const signalCliDetected = await detectBinary(signalCliPath);
|
const signalCliDetected = await detectBinary(signalCliPath);
|
||||||
const imessageConfigured = Boolean(
|
const imessageConfigured = listIMessageAccountIds(cfg).some((accountId) => {
|
||||||
cfg.imessage?.cliPath || cfg.imessage?.dbPath || cfg.imessage?.allowFrom,
|
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 imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
|
||||||
const imessageCliDetected = await detectBinary(imessageCliPath);
|
const imessageCliDetected = await detectBinary(imessageCliPath);
|
||||||
|
|
||||||
@ -635,8 +719,8 @@ export async function setupProviders(
|
|||||||
value: "telegram",
|
value: "telegram",
|
||||||
label: "Telegram (Bot API)",
|
label: "Telegram (Bot API)",
|
||||||
hint: telegramConfigured
|
hint: telegramConfigured
|
||||||
? "easy start · configured"
|
? "recommended · configured"
|
||||||
: "easy start · needs token",
|
: "recommended · newcomer-friendly",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "whatsapp",
|
value: "whatsapp",
|
||||||
@ -667,20 +751,26 @@ export async function setupProviders(
|
|||||||
})) as ProviderChoice[];
|
})) as ProviderChoice[];
|
||||||
|
|
||||||
options?.onSelection?.(selection);
|
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> = {
|
const selectionNotes: Record<ProviderChoice, string> = {
|
||||||
telegram:
|
telegram: `Telegram — simplest way to get started: register a bot with @BotFather and get going. Docs: ${docsLink("/telegram", "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: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||||
whatsapp:
|
discord: `Discord — very well supported right now. Docs: ${docsLink("/discord", "discord")}`,
|
||||||
"WhatsApp — works with your own number; recommend a separate phone + eSIM. Docs: https://docs.clawd.bot/whatsapp",
|
slack: `Slack — supported (Socket Mode). Docs: ${docsLink("/slack", "slack")}`,
|
||||||
discord:
|
signal: `Signal — signal-cli linked device; more setup (David Reagans: "Hop on Discord."). Docs: ${docsLink("/signal", "signal")}`,
|
||||||
"Discord — very well supported right now. Docs: https://docs.clawd.bot/discord",
|
imessage: `iMessage — this is still a work in progress. Docs: ${docsLink("/imessage", "imessage")}`,
|
||||||
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",
|
|
||||||
};
|
};
|
||||||
const selectedLines = selection
|
const selectedLines = selection
|
||||||
.map((provider) => selectionNotes[provider])
|
.map((provider) => selectionNotes[provider])
|
||||||
@ -689,38 +779,23 @@ export async function setupProviders(
|
|||||||
await prompter.note(selectedLines.join("\n"), "Selected providers");
|
await prompter.note(selectedLines.join("\n"), "Selected providers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
||||||
|
|
||||||
let next = cfg;
|
let next = cfg;
|
||||||
|
|
||||||
if (selection.includes("whatsapp")) {
|
if (selection.includes("whatsapp")) {
|
||||||
if (options?.promptWhatsAppAccountId && !options.whatsappAccountId) {
|
const overrideId = accountOverrides.whatsapp?.trim();
|
||||||
const existingIds = listWhatsAppAccountIds(next);
|
if (overrideId) {
|
||||||
const choice = (await prompter.select({
|
whatsappAccountId = normalizeAccountId(overrideId);
|
||||||
message: "WhatsApp account",
|
} else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
|
||||||
options: [
|
whatsappAccountId = await promptAccountId({
|
||||||
...existingIds.map((id) => ({
|
cfg: next,
|
||||||
value: id,
|
prompter,
|
||||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
label: "WhatsApp",
|
||||||
})),
|
currentId: whatsappAccountId,
|
||||||
{ value: "__new__", label: "Add a new account" },
|
listAccountIds: listWhatsAppAccountIds,
|
||||||
],
|
defaultAccountId: resolveDefaultWhatsAppAccountId(next),
|
||||||
})) 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
|
if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||||
@ -740,7 +815,7 @@ export async function setupProviders(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
options?.onWhatsAppAccountId?.(whatsappAccountId);
|
recordAccount("whatsapp", whatsappAccountId);
|
||||||
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
|
whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId);
|
||||||
const { authDir } = resolveWhatsAppAuthDir({
|
const { authDir } = resolveWhatsAppAuthDir({
|
||||||
cfg: next,
|
cfg: next,
|
||||||
@ -752,7 +827,7 @@ export async function setupProviders(
|
|||||||
[
|
[
|
||||||
"Scan the QR with WhatsApp on your phone.",
|
"Scan the QR with WhatsApp on your phone.",
|
||||||
`Credentials are stored under ${authDir}/ for future runs.`,
|
`Credentials are stored under ${authDir}/ for future runs.`,
|
||||||
"Docs: https://docs.clawd.bot/whatsapp",
|
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"WhatsApp linking",
|
"WhatsApp linking",
|
||||||
);
|
);
|
||||||
@ -769,7 +844,7 @@ export async function setupProviders(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
"Docs: https://docs.clawd.bot/whatsapp",
|
`Docs: ${docsLink("/whatsapp", "whatsapp")}`,
|
||||||
"WhatsApp help",
|
"WhatsApp help",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -784,11 +859,39 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selection.includes("telegram")) {
|
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;
|
let token: string | null = null;
|
||||||
if (!telegramConfigured) {
|
if (!accountConfigured) {
|
||||||
await noteTelegramTokenHelp(prompter);
|
await noteTelegramTokenHelp(prompter);
|
||||||
}
|
}
|
||||||
if (telegramEnv && !cfg.telegram?.botToken) {
|
if (canUseEnv && !resolvedAccount.config.botToken) {
|
||||||
const keepEnv = await prompter.confirm({
|
const keepEnv = await prompter.confirm({
|
||||||
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
@ -809,7 +912,7 @@ export async function setupProviders(
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
} else if (cfg.telegram?.botToken) {
|
} else if (hasConfigToken) {
|
||||||
const keep = await prompter.confirm({
|
const keep = await prompter.confirm({
|
||||||
message: "Telegram token already configured. Keep it?",
|
message: "Telegram token already configured. Keep it?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
@ -832,23 +935,68 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
next = {
|
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
...next,
|
next = {
|
||||||
telegram: {
|
...next,
|
||||||
...next.telegram,
|
telegram: {
|
||||||
enabled: true,
|
...next.telegram,
|
||||||
botToken: token,
|
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")) {
|
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;
|
let token: string | null = null;
|
||||||
if (!discordConfigured) {
|
if (!accountConfigured) {
|
||||||
await noteDiscordTokenHelp(prompter);
|
await noteDiscordTokenHelp(prompter);
|
||||||
}
|
}
|
||||||
if (discordEnv && !cfg.discord?.token) {
|
if (canUseEnv && !resolvedAccount.config.token) {
|
||||||
const keepEnv = await prompter.confirm({
|
const keepEnv = await prompter.confirm({
|
||||||
message: "DISCORD_BOT_TOKEN detected. Use env var?",
|
message: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
@ -869,7 +1017,7 @@ export async function setupProviders(
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
} else if (cfg.discord?.token) {
|
} else if (hasConfigToken) {
|
||||||
const keep = await prompter.confirm({
|
const keep = await prompter.confirm({
|
||||||
message: "Discord token already configured. Keep it?",
|
message: "Discord token already configured. Keep it?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
@ -892,18 +1040,67 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
next = {
|
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
...next,
|
next = {
|
||||||
discord: {
|
...next,
|
||||||
...next.discord,
|
discord: {
|
||||||
enabled: true,
|
...next.discord,
|
||||||
token,
|
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")) {
|
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 botToken: string | null = null;
|
||||||
let appToken: string | null = null;
|
let appToken: string | null = null;
|
||||||
const slackBotName = String(
|
const slackBotName = String(
|
||||||
@ -912,13 +1109,12 @@ export async function setupProviders(
|
|||||||
initialValue: "Clawdbot",
|
initialValue: "Clawdbot",
|
||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
if (!slackConfigured) {
|
if (!accountConfigured) {
|
||||||
await noteSlackTokenHelp(prompter, slackBotName);
|
await noteSlackTokenHelp(prompter, slackBotName);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
slackBotEnv &&
|
canUseEnv &&
|
||||||
slackAppEnv &&
|
(!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
|
||||||
(!cfg.slack?.botToken || !cfg.slack?.appToken)
|
|
||||||
) {
|
) {
|
||||||
const keepEnv = await prompter.confirm({
|
const keepEnv = await prompter.confirm({
|
||||||
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
|
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
|
||||||
@ -946,7 +1142,7 @@ export async function setupProviders(
|
|||||||
}),
|
}),
|
||||||
).trim();
|
).trim();
|
||||||
}
|
}
|
||||||
} else if (cfg.slack?.botToken && cfg.slack?.appToken) {
|
} else if (hasConfigTokens) {
|
||||||
const keep = await prompter.confirm({
|
const keep = await prompter.confirm({
|
||||||
message: "Slack tokens already configured. Keep them?",
|
message: "Slack tokens already configured. Keep them?",
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
@ -981,21 +1177,63 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (botToken && appToken) {
|
if (botToken && appToken) {
|
||||||
next = {
|
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
...next,
|
next = {
|
||||||
slack: {
|
...next,
|
||||||
...next.slack,
|
slack: {
|
||||||
enabled: true,
|
...next.slack,
|
||||||
botToken,
|
enabled: true,
|
||||||
appToken,
|
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")) {
|
if (selection.includes("signal")) {
|
||||||
let resolvedCliPath = signalCliPath;
|
const signalOverride = accountOverrides.signal?.trim();
|
||||||
let cliDetected = signalCliDetected;
|
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) {
|
if (options?.allowSignalInstall) {
|
||||||
const wantsInstall = await prompter.confirm({
|
const wantsInstall = await prompter.confirm({
|
||||||
message: cliDetected
|
message: cliDetected
|
||||||
@ -1035,7 +1273,7 @@ export async function setupProviders(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let account = cfg.signal?.account ?? "";
|
let account = accountConfig.account ?? "";
|
||||||
if (account) {
|
if (account) {
|
||||||
const keep = await prompter.confirm({
|
const keep = await prompter.confirm({
|
||||||
message: `Signal account set (${account}). Keep it?`,
|
message: `Signal account set (${account}). Keep it?`,
|
||||||
@ -1054,15 +1292,35 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
next = {
|
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
...next,
|
next = {
|
||||||
signal: {
|
...next,
|
||||||
...next.signal,
|
signal: {
|
||||||
enabled: true,
|
...next.signal,
|
||||||
account,
|
enabled: true,
|
||||||
cliPath: resolvedCliPath ?? "signal-cli",
|
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(
|
await prompter.note(
|
||||||
@ -1070,15 +1328,37 @@ export async function setupProviders(
|
|||||||
'Link device with: signal-cli link -n "Clawdbot"',
|
'Link device with: signal-cli link -n "Clawdbot"',
|
||||||
"Scan QR in Signal → Linked Devices",
|
"Scan QR in Signal → Linked Devices",
|
||||||
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
|
"Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'",
|
||||||
"Docs: https://docs.clawd.bot/signal",
|
`Docs: ${docsLink("/signal", "signal")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Signal next steps",
|
"Signal next steps",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.includes("imessage")) {
|
if (selection.includes("imessage")) {
|
||||||
let resolvedCliPath = imessageCliPath;
|
const imessageOverride = accountOverrides.imessage?.trim();
|
||||||
if (!imessageCliDetected) {
|
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({
|
const entered = await prompter.text({
|
||||||
message: "imsg CLI path",
|
message: "imsg CLI path",
|
||||||
initialValue: resolvedCliPath,
|
initialValue: resolvedCliPath,
|
||||||
@ -1094,14 +1374,33 @@ export async function setupProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedCliPath) {
|
if (resolvedCliPath) {
|
||||||
next = {
|
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
|
||||||
...next,
|
next = {
|
||||||
imessage: {
|
...next,
|
||||||
...next.imessage,
|
imessage: {
|
||||||
enabled: true,
|
...next.imessage,
|
||||||
cliPath: resolvedCliPath,
|
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(
|
await prompter.note(
|
||||||
@ -1110,7 +1409,7 @@ export async function setupProviders(
|
|||||||
"Ensure Clawdbot has Full Disk Access to Messages DB.",
|
"Ensure Clawdbot has Full Disk Access to Messages DB.",
|
||||||
"Grant Automation permission for Messages when prompted.",
|
"Grant Automation permission for Messages when prompted.",
|
||||||
"List chats with: imsg chats --limit 20",
|
"List chats with: imsg chats --limit 20",
|
||||||
"Docs: https://docs.clawd.bot/imessage",
|
`Docs: ${docsLink("/imessage", "imessage")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"iMessage next steps",
|
"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(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ token: "token-abc", verbose: false }),
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -158,7 +158,7 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||||
"123",
|
"123",
|
||||||
"hi",
|
"hi",
|
||||||
expect.objectContaining({ token: "cfg-token", verbose: false }),
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -209,7 +209,11 @@ describe("sendCommand", () => {
|
|||||||
deps,
|
deps,
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
expect(deps.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi");
|
expect(deps.sendMessageSlack).toHaveBeenCalledWith(
|
||||||
|
"channel:C123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({ accountId: "default" }),
|
||||||
|
);
|
||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeAccountId } from "../routing/session-key.js";
|
||||||
import type { ClawdbotConfig } from "./config.js";
|
import type { ClawdbotConfig } from "./config.js";
|
||||||
|
|
||||||
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
|
export type GroupPolicyProvider = "whatsapp" | "telegram" | "imessage";
|
||||||
@ -18,10 +19,22 @@ type ProviderGroups = Record<string, ProviderGroupConfig>;
|
|||||||
function resolveProviderGroups(
|
function resolveProviderGroups(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
provider: GroupPolicyProvider,
|
provider: GroupPolicyProvider,
|
||||||
|
accountId?: string | null,
|
||||||
): ProviderGroups | undefined {
|
): ProviderGroups | undefined {
|
||||||
if (provider === "whatsapp") return cfg.whatsapp?.groups;
|
if (provider === "whatsapp") return cfg.whatsapp?.groups;
|
||||||
if (provider === "telegram") return cfg.telegram?.groups;
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
if (provider === "imessage") return cfg.imessage?.groups;
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,9 +42,10 @@ export function resolveProviderGroupPolicy(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
provider: GroupPolicyProvider;
|
provider: GroupPolicyProvider;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
}): ProviderGroupPolicy {
|
}): ProviderGroupPolicy {
|
||||||
const { cfg, provider } = params;
|
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 allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
||||||
const normalizedId = params.groupId?.trim();
|
const normalizedId = params.groupId?.trim();
|
||||||
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined;
|
||||||
@ -56,6 +70,7 @@ export function resolveProviderGroupRequireMention(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
provider: GroupPolicyProvider;
|
provider: GroupPolicyProvider;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
requireMentionOverride?: boolean;
|
requireMentionOverride?: boolean;
|
||||||
overrideOrder?: "before-config" | "after-config";
|
overrideOrder?: "before-config" | "after-config";
|
||||||
}): boolean {
|
}): boolean {
|
||||||
|
|||||||
@ -129,6 +129,8 @@ export type WhatsAppConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WhatsAppAccountConfig = {
|
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. */
|
/** If false, do not start this WhatsApp account provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Override auth directory (Baileys multi-file auth state). */
|
/** Override auth directory (Baileys multi-file auth state). */
|
||||||
@ -258,33 +260,9 @@ export type TelegramActionConfig = {
|
|||||||
sendMessage?: boolean;
|
sendMessage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramTopicConfig = {
|
export type TelegramAccountConfig = {
|
||||||
requireMention?: boolean;
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
|
name?: string;
|
||||||
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 = {
|
|
||||||
/**
|
/**
|
||||||
* Controls how Telegram direct chats (DMs) are handled:
|
* Controls how Telegram direct chats (DMs) are handled:
|
||||||
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
* - "pairing" (default): unknown senders get a pairing code; owner must approve
|
||||||
@ -293,10 +271,10 @@ export type TelegramConfig = {
|
|||||||
* - "disabled": ignore all inbound DMs
|
* - "disabled": ignore all inbound DMs
|
||||||
*/
|
*/
|
||||||
dmPolicy?: DmPolicy;
|
dmPolicy?: DmPolicy;
|
||||||
/** If false, do not start the Telegram provider. Default: true. */
|
/** If false, do not start this Telegram account. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
botToken?: string;
|
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;
|
tokenFile?: string;
|
||||||
/** Control reply threading when reply tags are present (off|first|all). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
@ -326,6 +304,37 @@ export type TelegramConfig = {
|
|||||||
actions?: TelegramActionConfig;
|
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 = {
|
export type DiscordDmConfig = {
|
||||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -387,8 +396,10 @@ export type DiscordActionConfig = {
|
|||||||
stickerUploads?: boolean;
|
stickerUploads?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordAccountConfig = {
|
||||||
/** If false, do not start the Discord provider. Default: true. */
|
/** 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;
|
enabled?: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
/**
|
/**
|
||||||
@ -413,6 +424,11 @@ export type DiscordConfig = {
|
|||||||
guilds?: Record<string, DiscordGuildEntry>;
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordConfig = {
|
||||||
|
/** Optional per-account Discord configuration (multi-account). */
|
||||||
|
accounts?: Record<string, DiscordAccountConfig>;
|
||||||
|
} & DiscordAccountConfig;
|
||||||
|
|
||||||
export type SlackDmConfig = {
|
export type SlackDmConfig = {
|
||||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -465,8 +481,10 @@ export type SlackSlashCommandConfig = {
|
|||||||
ephemeral?: boolean;
|
ephemeral?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackConfig = {
|
export type SlackAccountConfig = {
|
||||||
/** If false, do not start the Slack provider. Default: true. */
|
/** 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;
|
enabled?: boolean;
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
appToken?: string;
|
appToken?: string;
|
||||||
@ -491,8 +509,15 @@ export type SlackConfig = {
|
|||||||
channels?: Record<string, SlackChannelConfig>;
|
channels?: Record<string, SlackChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalConfig = {
|
export type SlackConfig = {
|
||||||
/** If false, do not start the Signal provider. Default: true. */
|
/** 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;
|
enabled?: boolean;
|
||||||
/** Optional explicit E.164 account for signal-cli. */
|
/** Optional explicit E.164 account for signal-cli. */
|
||||||
account?: string;
|
account?: string;
|
||||||
@ -527,8 +552,15 @@ export type SignalConfig = {
|
|||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IMessageConfig = {
|
export type SignalConfig = {
|
||||||
/** If false, do not start the iMessage provider. Default: true. */
|
/** 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;
|
enabled?: boolean;
|
||||||
/** imsg CLI binary path (default: imsg). */
|
/** imsg CLI binary path (default: imsg). */
|
||||||
cliPath?: string;
|
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 =
|
export type QueueMode =
|
||||||
| "steer"
|
| "steer"
|
||||||
| "followup"
|
| "followup"
|
||||||
|
|||||||
@ -89,6 +89,26 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
|||||||
|
|
||||||
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
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
|
const RetryConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
attempts: z.number().int().min(1).optional(),
|
attempts: z.number().int().min(1).optional(),
|
||||||
@ -121,6 +141,316 @@ const HexColorSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
.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
|
const SessionSchema = z
|
||||||
.object({
|
.object({
|
||||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||||
@ -777,6 +1107,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
z.string(),
|
z.string(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
|
||||||
authDir: z.string().optional(),
|
authDir: z.string().optional(),
|
||||||
@ -849,311 +1180,12 @@ export const ClawdbotSchema = z.object({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
telegram: z
|
|
||||||
.object({
|
telegram: TelegramConfigSchema.optional(),
|
||||||
enabled: z.boolean().optional(),
|
discord: DiscordConfigSchema.optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
slack: SlackConfigSchema.optional(),
|
||||||
botToken: z.string().optional(),
|
signal: SignalConfigSchema.optional(),
|
||||||
tokenFile: z.string().optional(),
|
imessage: IMessageConfigSchema.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(),
|
|
||||||
bridge: z
|
bridge: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
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 runtimeError = vi.fn();
|
||||||
const handler = createDiscordMessageHandler({
|
const handler = createDiscordMessageHandler({
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
token: "token",
|
token: "token",
|
||||||
runtime: {
|
runtime: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@ -115,6 +117,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
|
|
||||||
const handler = createDiscordMessageHandler({
|
const handler = createDiscordMessageHandler({
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
token: "token",
|
token: "token",
|
||||||
runtime: {
|
runtime: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@ -197,6 +201,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
|
|
||||||
const handler = createDiscordMessageHandler({
|
const handler = createDiscordMessageHandler({
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
token: "token",
|
token: "token",
|
||||||
runtime: {
|
runtime: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
@ -306,6 +312,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
|
|
||||||
const handler = createDiscordMessageHandler({
|
const handler = createDiscordMessageHandler({
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: cfg.discord,
|
||||||
|
accountId: "default",
|
||||||
token: "token",
|
token: "token",
|
||||||
runtime: {
|
runtime: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
|
|||||||
@ -42,7 +42,7 @@ import {
|
|||||||
} from "../auto-reply/reply/reply-dispatcher.js";
|
} from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.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 { loadConfig } from "../config/config.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
@ -62,12 +62,15 @@ import {
|
|||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
import { fetchDiscordApplicationId } from "./probe.js";
|
import { fetchDiscordApplicationId } from "./probe.js";
|
||||||
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
import { reactMessageDiscord, sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
|
||||||
export type MonitorDiscordOpts = {
|
export type MonitorDiscordOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
@ -244,16 +247,15 @@ function summarizeGuilds(entries?: Record<string, DiscordGuildEntryResolved>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const token = normalizeDiscordToken(
|
const account = resolveDiscordAccount({
|
||||||
opts.token ??
|
cfg,
|
||||||
process.env.DISCORD_BOT_TOKEN ??
|
accountId: opts.accountId,
|
||||||
cfg.discord?.token ??
|
});
|
||||||
undefined,
|
const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
|
||||||
);
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
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 discordCfg = account.config;
|
||||||
const guildEntries = cfg.discord?.guilds;
|
const dmConfig = discordCfg.dm;
|
||||||
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
|
const guildEntries = discordCfg.guilds;
|
||||||
|
const groupPolicy = discordCfg.groupPolicy ?? "open";
|
||||||
const allowFrom = dmConfig?.allowFrom;
|
const allowFrom = dmConfig?.allowFrom;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId);
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
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 dmEnabled = dmConfig?.enabled ?? true;
|
||||||
const dmPolicy = dmConfig?.policy ?? "pairing";
|
const dmPolicy = dmConfig?.policy ?? "pairing";
|
||||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||||
@ -303,6 +306,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
createDiscordNativeCommand({
|
createDiscordNativeCommand({
|
||||||
command: spec,
|
command: spec,
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: discordCfg,
|
||||||
|
accountId: account.accountId,
|
||||||
sessionPrefix,
|
sessionPrefix,
|
||||||
ephemeralDefault,
|
ephemeralDefault,
|
||||||
}),
|
}),
|
||||||
@ -359,6 +364,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
const messageHandler = createDiscordMessageHandler({
|
const messageHandler = createDiscordMessageHandler({
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig: discordCfg,
|
||||||
|
accountId: account.accountId,
|
||||||
token,
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
@ -377,6 +384,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
client.listeners.push(new DiscordMessageListener(messageHandler, logger));
|
client.listeners.push(new DiscordMessageListener(messageHandler, logger));
|
||||||
client.listeners.push(
|
client.listeners.push(
|
||||||
new DiscordReactionListener({
|
new DiscordReactionListener({
|
||||||
|
cfg,
|
||||||
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
@ -385,6 +394,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
);
|
);
|
||||||
client.listeners.push(
|
client.listeners.push(
|
||||||
new DiscordReactionRemoveListener({
|
new DiscordReactionRemoveListener({
|
||||||
|
cfg,
|
||||||
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
@ -431,6 +442,8 @@ async function clearDiscordNativeCommands(params: {
|
|||||||
|
|
||||||
export function createDiscordMessageHandler(params: {
|
export function createDiscordMessageHandler(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
discordConfig: ClawdbotConfig["discord"];
|
||||||
|
accountId: string;
|
||||||
token: string;
|
token: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
@ -447,6 +460,8 @@ export function createDiscordMessageHandler(params: {
|
|||||||
}): DiscordMessageHandler {
|
}): DiscordMessageHandler {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
|
discordConfig,
|
||||||
|
accountId,
|
||||||
token,
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
@ -465,7 +480,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
const mentionRegexes = buildMentionRegexes(cfg);
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
|
const groupPolicy = discordConfig?.groupPolicy ?? "open";
|
||||||
|
|
||||||
return async (data, client) => {
|
return async (data, client) => {
|
||||||
try {
|
try {
|
||||||
@ -490,7 +505,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing";
|
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
|
||||||
let commandAuthorized = true;
|
let commandAuthorized = true;
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
@ -539,7 +554,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
"Ask the bot owner to approve with:",
|
"Ask the bot owner to approve with:",
|
||||||
"clawdbot pairing approve --provider discord <code>",
|
"clawdbot pairing approve --provider discord <code>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
{ token, rest: client.rest },
|
{ token, rest: client.rest, accountId },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@ -633,6 +648,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
|
accountId,
|
||||||
guildId: data.guild_id ?? undefined,
|
guildId: data.guild_id ?? undefined,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||||
@ -988,6 +1004,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: replyTarget,
|
target: replyTarget,
|
||||||
token,
|
token,
|
||||||
|
accountId,
|
||||||
rest: client.rest,
|
rest: client.rest,
|
||||||
runtime,
|
runtime,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
@ -1068,6 +1085,8 @@ class DiscordMessageListener extends MessageCreateListener {
|
|||||||
class DiscordReactionListener extends MessageReactionAddListener {
|
class DiscordReactionListener extends MessageReactionAddListener {
|
||||||
constructor(
|
constructor(
|
||||||
private params: {
|
private params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
accountId: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||||
@ -1084,6 +1103,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
data,
|
data,
|
||||||
client,
|
client,
|
||||||
action: "added",
|
action: "added",
|
||||||
|
cfg: this.params.cfg,
|
||||||
|
accountId: this.params.accountId,
|
||||||
botUserId: this.params.botUserId,
|
botUserId: this.params.botUserId,
|
||||||
guildEntries: this.params.guildEntries,
|
guildEntries: this.params.guildEntries,
|
||||||
logger: this.params.logger,
|
logger: this.params.logger,
|
||||||
@ -1102,6 +1123,8 @@ class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
|
class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
|
||||||
constructor(
|
constructor(
|
||||||
private params: {
|
private params: {
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
accountId: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||||
@ -1118,6 +1141,8 @@ class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
|
|||||||
data,
|
data,
|
||||||
client,
|
client,
|
||||||
action: "removed",
|
action: "removed",
|
||||||
|
cfg: this.params.cfg,
|
||||||
|
accountId: this.params.accountId,
|
||||||
botUserId: this.params.botUserId,
|
botUserId: this.params.botUserId,
|
||||||
guildEntries: this.params.guildEntries,
|
guildEntries: this.params.guildEntries,
|
||||||
logger: this.params.logger,
|
logger: this.params.logger,
|
||||||
@ -1137,6 +1162,8 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
data: DiscordReactionEvent;
|
data: DiscordReactionEvent;
|
||||||
client: Client;
|
client: Client;
|
||||||
action: "added" | "removed";
|
action: "added" | "removed";
|
||||||
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
accountId: string;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||||
logger: ReturnType<typeof getChildLogger>;
|
logger: ReturnType<typeof getChildLogger>;
|
||||||
@ -1202,10 +1229,10 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
|
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
|
||||||
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
||||||
const cfg = loadConfig();
|
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg: params.cfg,
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
|
accountId: params.accountId,
|
||||||
guildId: data.guild_id ?? undefined,
|
guildId: data.guild_id ?? undefined,
|
||||||
peer: { kind: "channel", id: data.channel_id },
|
peer: { kind: "channel", id: data.channel_id },
|
||||||
});
|
});
|
||||||
@ -1227,10 +1254,19 @@ function createDiscordNativeCommand(params: {
|
|||||||
acceptsArgs: boolean;
|
acceptsArgs: boolean;
|
||||||
};
|
};
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
|
discordConfig: ClawdbotConfig["discord"];
|
||||||
|
accountId: string;
|
||||||
sessionPrefix: string;
|
sessionPrefix: string;
|
||||||
ephemeralDefault: boolean;
|
ephemeralDefault: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { command, cfg, sessionPrefix, ephemeralDefault } = params;
|
const {
|
||||||
|
command,
|
||||||
|
cfg,
|
||||||
|
discordConfig,
|
||||||
|
accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
ephemeralDefault,
|
||||||
|
} = params;
|
||||||
return new (class extends Command {
|
return new (class extends Command {
|
||||||
name = command.name;
|
name = command.name;
|
||||||
description = command.description;
|
description = command.description;
|
||||||
@ -1266,7 +1302,7 @@ function createDiscordNativeCommand(params: {
|
|||||||
);
|
);
|
||||||
const guildInfo = resolveDiscordGuildEntry({
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
guild: interaction.guild ?? undefined,
|
guild: interaction.guild ?? undefined,
|
||||||
guildEntries: cfg.discord?.guilds,
|
guildEntries: discordConfig?.guilds,
|
||||||
});
|
});
|
||||||
const channelConfig = interaction.guild
|
const channelConfig = interaction.guild
|
||||||
? resolveDiscordChannelConfig({
|
? resolveDiscordChannelConfig({
|
||||||
@ -1294,7 +1330,7 @@ function createDiscordNativeCommand(params: {
|
|||||||
Object.keys(guildInfo?.channels ?? {}).length > 0;
|
Object.keys(guildInfo?.channels ?? {}).length > 0;
|
||||||
const channelAllowed = channelConfig?.allowed !== false;
|
const channelAllowed = channelConfig?.allowed !== false;
|
||||||
const allowByPolicy = isDiscordGroupAllowedByPolicy({
|
const allowByPolicy = isDiscordGroupAllowedByPolicy({
|
||||||
groupPolicy: cfg.discord?.groupPolicy ?? "open",
|
groupPolicy: discordConfig?.groupPolicy ?? "open",
|
||||||
channelAllowlistConfigured,
|
channelAllowlistConfigured,
|
||||||
channelAllowed,
|
channelAllowed,
|
||||||
});
|
});
|
||||||
@ -1305,8 +1341,8 @@ function createDiscordNativeCommand(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dmEnabled = cfg.discord?.dm?.enabled ?? true;
|
const dmEnabled = discordConfig?.dm?.enabled ?? true;
|
||||||
const dmPolicy = cfg.discord?.dm?.policy ?? "pairing";
|
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
|
||||||
let commandAuthorized = true;
|
let commandAuthorized = true;
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
if (!dmEnabled || dmPolicy === "disabled") {
|
if (!dmEnabled || dmPolicy === "disabled") {
|
||||||
@ -1318,7 +1354,7 @@ function createDiscordNativeCommand(params: {
|
|||||||
"discord",
|
"discord",
|
||||||
).catch(() => []);
|
).catch(() => []);
|
||||||
const effectiveAllowFrom = [
|
const effectiveAllowFrom = [
|
||||||
...(cfg.discord?.dm?.allowFrom ?? []),
|
...(discordConfig?.dm?.allowFrom ?? []),
|
||||||
...storeAllowFrom,
|
...storeAllowFrom,
|
||||||
];
|
];
|
||||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
|
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." });
|
await interaction.reply({ content: "Discord group DMs are disabled." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1395,6 +1431,7 @@ function createDiscordNativeCommand(params: {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
|
accountId,
|
||||||
guildId: interaction.guild?.id ?? undefined,
|
guildId: interaction.guild?.id ?? undefined,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||||
@ -1544,6 +1581,7 @@ async function deliverDiscordReply(params: {
|
|||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
accountId?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
@ -1563,6 +1601,7 @@ async function deliverDiscordReply(params: {
|
|||||||
await sendMessageDiscord(params.target, trimmed, {
|
await sendMessageDiscord(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@ -1574,12 +1613,14 @@ async function deliverDiscordReply(params: {
|
|||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
mediaUrl: firstMedia,
|
mediaUrl: firstMedia,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
for (const extra of mediaList.slice(1)) {
|
for (const extra of mediaList.slice(1)) {
|
||||||
await sendMessageDiscord(params.target, "", {
|
await sendMessageDiscord(params.target, "", {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
mediaUrl: extra,
|
mediaUrl: extra,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
type PollInput,
|
type PollInput,
|
||||||
} from "../polls.js";
|
} from "../polls.js";
|
||||||
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
import { loadWebMedia, loadWebMediaRaw } from "../web/media.js";
|
||||||
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
|
||||||
const DISCORD_TEXT_LIMIT = 2000;
|
const DISCORD_TEXT_LIMIT = 2000;
|
||||||
@ -74,6 +75,7 @@ type DiscordRecipient =
|
|||||||
|
|
||||||
type DiscordSendOpts = {
|
type DiscordSendOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
@ -88,6 +90,7 @@ export type DiscordSendResult = {
|
|||||||
|
|
||||||
export type DiscordReactOpts = {
|
export type DiscordReactOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
retry?: RetryConfig;
|
retry?: RetryConfig;
|
||||||
@ -179,17 +182,20 @@ export type DiscordStickerUpload = {
|
|||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveToken(explicit?: string) {
|
function resolveToken(params: {
|
||||||
const cfgToken = loadConfig().discord?.token;
|
explicit?: string;
|
||||||
const token = normalizeDiscordToken(
|
accountId: string;
|
||||||
explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined,
|
fallbackToken?: string;
|
||||||
);
|
}) {
|
||||||
if (!token) {
|
const explicit = normalizeDiscordToken(params.explicit);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const fallback = normalizeDiscordToken(params.fallbackToken);
|
||||||
|
if (!fallback) {
|
||||||
throw new Error(
|
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) {
|
function resolveRest(token: string, rest?: RequestClient) {
|
||||||
@ -198,22 +204,32 @@ function resolveRest(token: string, rest?: RequestClient) {
|
|||||||
|
|
||||||
type DiscordClientOpts = {
|
type DiscordClientOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
retry?: RetryConfig;
|
retry?: RetryConfig;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
|
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 rest = resolveRest(token, opts.rest);
|
||||||
const request = createDiscordRetryRunner({
|
const request = createDiscordRetryRunner({
|
||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: cfg.discord?.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
});
|
});
|
||||||
return { token, rest, request };
|
return { token, rest, request };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDiscordRest(opts: DiscordClientOpts) {
|
||||||
|
return createDiscordClient(opts).rest;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReactionEmoji(raw: string) {
|
function normalizeReactionEmoji(raw: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@ -635,8 +651,7 @@ export async function removeReactionDiscord(
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const encoded = normalizeReactionEmoji(emoji);
|
const encoded = normalizeReactionEmoji(emoji);
|
||||||
await rest.delete(
|
await rest.delete(
|
||||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||||
@ -649,8 +664,7 @@ export async function removeOwnReactionsDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<{ ok: true; removed: string[] }> {
|
): Promise<{ ok: true; removed: string[] }> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const message = (await rest.get(
|
const message = (await rest.get(
|
||||||
Routes.channelMessage(channelId, messageId),
|
Routes.channelMessage(channelId, messageId),
|
||||||
)) as {
|
)) as {
|
||||||
@ -683,8 +697,7 @@ export async function fetchReactionsDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts & { limit?: number } = {},
|
opts: DiscordReactOpts & { limit?: number } = {},
|
||||||
): Promise<DiscordReactionSummary[]> {
|
): Promise<DiscordReactionSummary[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const message = (await rest.get(
|
const message = (await rest.get(
|
||||||
Routes.channelMessage(channelId, messageId),
|
Routes.channelMessage(channelId, messageId),
|
||||||
)) as {
|
)) as {
|
||||||
@ -733,8 +746,7 @@ export async function fetchChannelPermissionsDiscord(
|
|||||||
channelId: string,
|
channelId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<DiscordPermissionsSummary> {
|
): Promise<DiscordPermissionsSummary> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||||
const channelType = "type" in channel ? channel.type : undefined;
|
const channelType = "type" in channel ? channel.type : undefined;
|
||||||
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
const guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||||
@ -808,8 +820,7 @@ export async function readMessagesDiscord(
|
|||||||
query: DiscordMessageQuery = {},
|
query: DiscordMessageQuery = {},
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage[]> {
|
): Promise<APIMessage[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const limit =
|
const limit =
|
||||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||||
@ -831,8 +842,7 @@ export async function editMessageDiscord(
|
|||||||
payload: DiscordMessageEdit,
|
payload: DiscordMessageEdit,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage> {
|
): Promise<APIMessage> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||||
body: { content: payload.content },
|
body: { content: payload.content },
|
||||||
})) as APIMessage;
|
})) as APIMessage;
|
||||||
@ -843,8 +853,7 @@ export async function deleteMessageDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@ -854,8 +863,7 @@ export async function pinMessageDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.put(Routes.channelPin(channelId, messageId));
|
await rest.put(Routes.channelPin(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@ -865,8 +873,7 @@ export async function unpinMessageDiscord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.delete(Routes.channelPin(channelId, messageId));
|
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@ -875,8 +882,7 @@ export async function listPinsDiscord(
|
|||||||
channelId: string,
|
channelId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIMessage[]> {
|
): Promise<APIMessage[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -885,8 +891,7 @@ export async function createThreadDiscord(
|
|||||||
payload: DiscordThreadCreate,
|
payload: DiscordThreadCreate,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const body: Record<string, unknown> = { name: payload.name };
|
const body: Record<string, unknown> = { name: payload.name };
|
||||||
if (payload.autoArchiveMinutes) {
|
if (payload.autoArchiveMinutes) {
|
||||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||||
@ -899,8 +904,7 @@ export async function listThreadsDiscord(
|
|||||||
payload: DiscordThreadList,
|
payload: DiscordThreadList,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
if (payload.includeArchived) {
|
if (payload.includeArchived) {
|
||||||
if (!payload.channelId) {
|
if (!payload.channelId) {
|
||||||
throw new Error("channelId required to list archived threads");
|
throw new Error("channelId required to list archived threads");
|
||||||
@ -920,8 +924,7 @@ export async function searchMessagesDiscord(
|
|||||||
query: DiscordSearchQuery,
|
query: DiscordSearchQuery,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("content", query.content);
|
params.set("content", query.content);
|
||||||
if (query.channelIds?.length) {
|
if (query.channelIds?.length) {
|
||||||
@ -947,8 +950,7 @@ export async function listGuildEmojisDiscord(
|
|||||||
guildId: string,
|
guildId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return await rest.get(Routes.guildEmojis(guildId));
|
return await rest.get(Routes.guildEmojis(guildId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -956,8 +958,7 @@ export async function uploadEmojiDiscord(
|
|||||||
payload: DiscordEmojiUpload,
|
payload: DiscordEmojiUpload,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const media = await loadWebMediaRaw(
|
const media = await loadWebMediaRaw(
|
||||||
payload.mediaUrl,
|
payload.mediaUrl,
|
||||||
DISCORD_MAX_EMOJI_BYTES,
|
DISCORD_MAX_EMOJI_BYTES,
|
||||||
@ -986,8 +987,7 @@ export async function uploadStickerDiscord(
|
|||||||
payload: DiscordStickerUpload,
|
payload: DiscordStickerUpload,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const media = await loadWebMediaRaw(
|
const media = await loadWebMediaRaw(
|
||||||
payload.mediaUrl,
|
payload.mediaUrl,
|
||||||
DISCORD_MAX_STICKER_BYTES,
|
DISCORD_MAX_STICKER_BYTES,
|
||||||
@ -1025,8 +1025,7 @@ export async function fetchMemberInfoDiscord(
|
|||||||
userId: string,
|
userId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildMember> {
|
): Promise<APIGuildMember> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(
|
return (await rest.get(
|
||||||
Routes.guildMember(guildId, userId),
|
Routes.guildMember(guildId, userId),
|
||||||
)) as APIGuildMember;
|
)) as APIGuildMember;
|
||||||
@ -1036,8 +1035,7 @@ export async function fetchRoleInfoDiscord(
|
|||||||
guildId: string,
|
guildId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIRole[]> {
|
): Promise<APIRole[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1045,8 +1043,7 @@ export async function addRoleDiscord(
|
|||||||
payload: DiscordRoleChange,
|
payload: DiscordRoleChange,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.put(
|
await rest.put(
|
||||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||||
);
|
);
|
||||||
@ -1057,8 +1054,7 @@ export async function removeRoleDiscord(
|
|||||||
payload: DiscordRoleChange,
|
payload: DiscordRoleChange,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.delete(
|
await rest.delete(
|
||||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||||
);
|
);
|
||||||
@ -1069,8 +1065,7 @@ export async function fetchChannelInfoDiscord(
|
|||||||
channelId: string,
|
channelId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIChannel> {
|
): Promise<APIChannel> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1078,8 +1073,7 @@ export async function listGuildChannelsDiscord(
|
|||||||
guildId: string,
|
guildId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIChannel[]> {
|
): Promise<APIChannel[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1088,8 +1082,7 @@ export async function fetchVoiceStatusDiscord(
|
|||||||
userId: string,
|
userId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIVoiceState> {
|
): Promise<APIVoiceState> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(
|
return (await rest.get(
|
||||||
Routes.guildVoiceState(guildId, userId),
|
Routes.guildVoiceState(guildId, userId),
|
||||||
)) as APIVoiceState;
|
)) as APIVoiceState;
|
||||||
@ -1099,8 +1092,7 @@ export async function listScheduledEventsDiscord(
|
|||||||
guildId: string,
|
guildId: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildScheduledEvent[]> {
|
): Promise<APIGuildScheduledEvent[]> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.get(
|
return (await rest.get(
|
||||||
Routes.guildScheduledEvents(guildId),
|
Routes.guildScheduledEvents(guildId),
|
||||||
)) as APIGuildScheduledEvent[];
|
)) as APIGuildScheduledEvent[];
|
||||||
@ -1111,8 +1103,7 @@ export async function createScheduledEventDiscord(
|
|||||||
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildScheduledEvent> {
|
): Promise<APIGuildScheduledEvent> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||||
body: payload,
|
body: payload,
|
||||||
})) as APIGuildScheduledEvent;
|
})) as APIGuildScheduledEvent;
|
||||||
@ -1122,8 +1113,7 @@ export async function timeoutMemberDiscord(
|
|||||||
payload: DiscordTimeoutTarget,
|
payload: DiscordTimeoutTarget,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
): Promise<APIGuildMember> {
|
): Promise<APIGuildMember> {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
let until = payload.until;
|
let until = payload.until;
|
||||||
if (!until && payload.durationMinutes) {
|
if (!until && payload.durationMinutes) {
|
||||||
const ms = payload.durationMinutes * 60 * 1000;
|
const ms = payload.durationMinutes * 60 * 1000;
|
||||||
@ -1144,8 +1134,7 @@ export async function kickMemberDiscord(
|
|||||||
payload: DiscordModerationTarget,
|
payload: DiscordModerationTarget,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
||||||
headers: payload.reason
|
headers: payload.reason
|
||||||
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
? { "X-Audit-Log-Reason": encodeURIComponent(payload.reason) }
|
||||||
@ -1158,8 +1147,7 @@ export async function banMemberDiscord(
|
|||||||
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const rest = resolveDiscordRest(opts);
|
||||||
const rest = resolveRest(token, opts.rest);
|
|
||||||
const deleteMessageDays =
|
const deleteMessageDays =
|
||||||
typeof payload.deleteMessageDays === "number" &&
|
typeof payload.deleteMessageDays === "number" &&
|
||||||
Number.isFinite(payload.deleteMessageDays)
|
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 {
|
export function normalizeDiscordToken(raw?: string | null): string | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
return trimmed.replace(/^Bot\s+/i, "");
|
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,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../../config/config.js";
|
} from "../../config/config.js";
|
||||||
|
import {
|
||||||
|
listDiscordAccountIds,
|
||||||
|
resolveDefaultDiscordAccountId,
|
||||||
|
resolveDiscordAccount,
|
||||||
|
} from "../../discord/accounts.js";
|
||||||
import { type DiscordProbe, probeDiscord } from "../../discord/probe.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 { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
|
||||||
|
import {
|
||||||
|
listSignalAccountIds,
|
||||||
|
resolveDefaultSignalAccountId,
|
||||||
|
resolveSignalAccount,
|
||||||
|
} from "../../signal/accounts.js";
|
||||||
import { probeSignal, type SignalProbe } from "../../signal/probe.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 { probeSlack, type SlackProbe } from "../../slack/probe.js";
|
||||||
import {
|
import {
|
||||||
resolveSlackAppToken,
|
listTelegramAccountIds,
|
||||||
resolveSlackBotToken,
|
resolveDefaultTelegramAccountId,
|
||||||
} from "../../slack/token.js";
|
resolveTelegramAccount,
|
||||||
|
} from "../../telegram/accounts.js";
|
||||||
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
|
||||||
import {
|
import {
|
||||||
listEnabledWhatsAppAccounts,
|
listEnabledWhatsAppAccounts,
|
||||||
resolveDefaultWhatsAppAccountId,
|
resolveDefaultWhatsAppAccountId,
|
||||||
@ -50,112 +70,193 @@ export const providersHandlers: GatewayRequestHandlers = {
|
|||||||
const timeoutMs =
|
const timeoutMs =
|
||||||
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
|
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const telegramCfg = cfg.telegram;
|
const runtime = context.getRuntimeSnapshot();
|
||||||
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 discordCfg = cfg.discord;
|
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
|
||||||
const discordEnabled = Boolean(discordCfg) && discordCfg?.enabled !== false;
|
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
|
||||||
const discordEnvToken = discordEnabled
|
const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
|
||||||
? process.env.DISCORD_BOT_TOKEN?.trim()
|
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
|
||||||
: "";
|
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
|
||||||
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 slackCfg = cfg.slack;
|
const telegramAccounts = await Promise.all(
|
||||||
const slackEnabled = slackCfg?.enabled !== false;
|
listTelegramAccountIds(cfg).map(async (accountId) => {
|
||||||
const slackBotEnvToken = slackEnabled
|
const account = resolveTelegramAccount({ cfg, accountId });
|
||||||
? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN)
|
const rt =
|
||||||
: undefined;
|
runtime.telegramAccounts?.[account.accountId] ??
|
||||||
const slackBotConfigToken = slackEnabled
|
(account.accountId === defaultTelegramAccountId
|
||||||
? resolveSlackBotToken(slackCfg?.botToken)
|
? runtime.telegram
|
||||||
: undefined;
|
: undefined);
|
||||||
const slackBotToken = slackBotEnvToken ?? slackBotConfigToken ?? "";
|
const configured = Boolean(account.token);
|
||||||
const slackBotTokenSource = slackBotEnvToken
|
let telegramProbe: TelegramProbe | undefined;
|
||||||
? "env"
|
let lastProbeAt: number | null = null;
|
||||||
: slackBotConfigToken
|
if (probe && configured && account.enabled) {
|
||||||
? "config"
|
telegramProbe = await probeTelegram(
|
||||||
: "none";
|
account.token,
|
||||||
const slackAppEnvToken = slackEnabled
|
timeoutMs,
|
||||||
? resolveSlackAppToken(process.env.SLACK_APP_TOKEN)
|
account.config.proxy,
|
||||||
: undefined;
|
);
|
||||||
const slackAppConfigToken = slackEnabled
|
lastProbeAt = Date.now();
|
||||||
? resolveSlackAppToken(slackCfg?.appToken)
|
}
|
||||||
: undefined;
|
return {
|
||||||
const slackAppToken = slackAppEnvToken ?? slackAppConfigToken ?? "";
|
accountId: account.accountId,
|
||||||
const slackAppTokenSource = slackAppEnvToken
|
name: account.name,
|
||||||
? "env"
|
enabled: account.enabled,
|
||||||
: slackAppConfigToken
|
configured,
|
||||||
? "config"
|
tokenSource: account.tokenSource,
|
||||||
: "none";
|
running: rt?.running ?? false,
|
||||||
const slackConfigured =
|
mode: rt?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"),
|
||||||
slackEnabled && Boolean(slackBotToken) && Boolean(slackAppToken);
|
lastStartAt: rt?.lastStartAt ?? null,
|
||||||
let slackProbe: SlackProbe | undefined;
|
lastStopAt: rt?.lastStopAt ?? null,
|
||||||
let slackLastProbeAt: number | null = null;
|
lastError: rt?.lastError ?? null,
|
||||||
if (probe && slackConfigured) {
|
probe: telegramProbe,
|
||||||
slackProbe = await probeSlack(slackBotToken, timeoutMs);
|
lastProbeAt,
|
||||||
slackLastProbeAt = Date.now();
|
};
|
||||||
}
|
}),
|
||||||
|
);
|
||||||
|
const defaultTelegramAccount =
|
||||||
|
telegramAccounts.find(
|
||||||
|
(account) => account.accountId === defaultTelegramAccountId,
|
||||||
|
) ?? telegramAccounts[0];
|
||||||
|
|
||||||
const signalCfg = cfg.signal;
|
const discordAccounts = await Promise.all(
|
||||||
const signalEnabled = signalCfg?.enabled !== false;
|
listDiscordAccountIds(cfg).map(async (accountId) => {
|
||||||
const signalHost = signalCfg?.httpHost?.trim() || "127.0.0.1";
|
const account = resolveDiscordAccount({ cfg, accountId });
|
||||||
const signalPort = signalCfg?.httpPort ?? 8080;
|
const rt =
|
||||||
const signalBaseUrl =
|
runtime.discordAccounts?.[account.accountId] ??
|
||||||
signalCfg?.httpUrl?.trim() || `http://${signalHost}:${signalPort}`;
|
(account.accountId === defaultDiscordAccountId
|
||||||
const signalConfigured =
|
? runtime.discord
|
||||||
Boolean(signalCfg) &&
|
: undefined);
|
||||||
signalEnabled &&
|
const configured = Boolean(account.token);
|
||||||
Boolean(
|
let discordProbe: DiscordProbe | undefined;
|
||||||
signalCfg?.account?.trim() ||
|
let lastProbeAt: number | null = null;
|
||||||
signalCfg?.httpUrl?.trim() ||
|
if (probe && configured && account.enabled) {
|
||||||
signalCfg?.cliPath?.trim() ||
|
discordProbe = await probeDiscord(account.token, timeoutMs);
|
||||||
signalCfg?.httpHost?.trim() ||
|
lastProbeAt = Date.now();
|
||||||
typeof signalCfg?.httpPort === "number" ||
|
}
|
||||||
typeof signalCfg?.autoStart === "boolean",
|
return {
|
||||||
);
|
accountId: account.accountId,
|
||||||
let signalProbe: SignalProbe | undefined;
|
name: account.name,
|
||||||
let signalLastProbeAt: number | null = null;
|
enabled: account.enabled,
|
||||||
if (probe && signalConfigured) {
|
configured,
|
||||||
signalProbe = await probeSignal(signalBaseUrl, timeoutMs);
|
tokenSource: account.tokenSource,
|
||||||
signalLastProbeAt = Date.now();
|
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 slackAccounts = await Promise.all(
|
||||||
const imessageEnabled = imessageCfg?.enabled !== false;
|
listSlackAccountIds(cfg).map(async (accountId) => {
|
||||||
const imessageConfigured = Boolean(imessageCfg) && imessageEnabled;
|
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 imessageProbe: IMessageProbe | undefined;
|
||||||
let imessageLastProbeAt: number | null = null;
|
let imessageLastProbeAt: number | null = null;
|
||||||
if (probe && imessageConfigured) {
|
if (probe && imessageBaseConfigured) {
|
||||||
imessageProbe = await probeIMessage(timeoutMs);
|
imessageProbe = await probeIMessage(timeoutMs);
|
||||||
imessageLastProbeAt = Date.now();
|
imessageLastProbeAt = Date.now();
|
||||||
}
|
}
|
||||||
|
const imessageAccounts = listIMessageAccountIds(cfg).map((accountId) => {
|
||||||
const runtime = context.getRuntimeSnapshot();
|
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 defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg);
|
||||||
const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg);
|
const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg);
|
||||||
const defaultWhatsAppAccount =
|
const defaultWhatsAppAccount =
|
||||||
@ -226,58 +327,68 @@ export const providersHandlers: GatewayRequestHandlers = {
|
|||||||
whatsappAccounts,
|
whatsappAccounts,
|
||||||
whatsappDefaultAccountId: defaultWhatsAppAccountId,
|
whatsappDefaultAccountId: defaultWhatsAppAccountId,
|
||||||
telegram: {
|
telegram: {
|
||||||
configured: telegramEnabled && Boolean(telegramToken),
|
configured: defaultTelegramAccount?.configured ?? false,
|
||||||
tokenSource,
|
tokenSource: defaultTelegramAccount?.tokenSource ?? "none",
|
||||||
running: runtime.telegram.running,
|
running: defaultTelegramAccount?.running ?? false,
|
||||||
mode: runtime.telegram.mode ?? null,
|
mode: defaultTelegramAccount?.mode ?? null,
|
||||||
lastStartAt: runtime.telegram.lastStartAt ?? null,
|
lastStartAt: defaultTelegramAccount?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime.telegram.lastStopAt ?? null,
|
lastStopAt: defaultTelegramAccount?.lastStopAt ?? null,
|
||||||
lastError: runtime.telegram.lastError ?? null,
|
lastError: defaultTelegramAccount?.lastError ?? null,
|
||||||
probe: telegramProbe,
|
probe: defaultTelegramAccount?.probe,
|
||||||
lastProbeAt,
|
lastProbeAt: defaultTelegramAccount?.lastProbeAt ?? null,
|
||||||
},
|
},
|
||||||
|
telegramAccounts,
|
||||||
|
telegramDefaultAccountId: defaultTelegramAccountId,
|
||||||
discord: {
|
discord: {
|
||||||
configured: discordEnabled && Boolean(discordToken),
|
configured: defaultDiscordAccount?.configured ?? false,
|
||||||
tokenSource: discordTokenSource,
|
tokenSource: defaultDiscordAccount?.tokenSource ?? "none",
|
||||||
running: runtime.discord.running,
|
running: defaultDiscordAccount?.running ?? false,
|
||||||
lastStartAt: runtime.discord.lastStartAt ?? null,
|
lastStartAt: defaultDiscordAccount?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime.discord.lastStopAt ?? null,
|
lastStopAt: defaultDiscordAccount?.lastStopAt ?? null,
|
||||||
lastError: runtime.discord.lastError ?? null,
|
lastError: defaultDiscordAccount?.lastError ?? null,
|
||||||
probe: discordProbe,
|
probe: defaultDiscordAccount?.probe,
|
||||||
lastProbeAt: discordLastProbeAt,
|
lastProbeAt: defaultDiscordAccount?.lastProbeAt ?? null,
|
||||||
},
|
},
|
||||||
|
discordAccounts,
|
||||||
|
discordDefaultAccountId: defaultDiscordAccountId,
|
||||||
slack: {
|
slack: {
|
||||||
configured: slackConfigured,
|
configured: defaultSlackAccount?.configured ?? false,
|
||||||
botTokenSource: slackBotTokenSource,
|
botTokenSource: defaultSlackAccount?.botTokenSource ?? "none",
|
||||||
appTokenSource: slackAppTokenSource,
|
appTokenSource: defaultSlackAccount?.appTokenSource ?? "none",
|
||||||
running: runtime.slack.running,
|
running: defaultSlackAccount?.running ?? false,
|
||||||
lastStartAt: runtime.slack.lastStartAt ?? null,
|
lastStartAt: defaultSlackAccount?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime.slack.lastStopAt ?? null,
|
lastStopAt: defaultSlackAccount?.lastStopAt ?? null,
|
||||||
lastError: runtime.slack.lastError ?? null,
|
lastError: defaultSlackAccount?.lastError ?? null,
|
||||||
probe: slackProbe,
|
probe: defaultSlackAccount?.probe,
|
||||||
lastProbeAt: slackLastProbeAt,
|
lastProbeAt: defaultSlackAccount?.lastProbeAt ?? null,
|
||||||
},
|
},
|
||||||
|
slackAccounts,
|
||||||
|
slackDefaultAccountId: defaultSlackAccountId,
|
||||||
signal: {
|
signal: {
|
||||||
configured: signalConfigured,
|
configured: defaultSignalAccount?.configured ?? false,
|
||||||
baseUrl: signalBaseUrl,
|
baseUrl: defaultSignalAccount?.baseUrl ?? null,
|
||||||
running: runtime.signal.running,
|
running: defaultSignalAccount?.running ?? false,
|
||||||
lastStartAt: runtime.signal.lastStartAt ?? null,
|
lastStartAt: defaultSignalAccount?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime.signal.lastStopAt ?? null,
|
lastStopAt: defaultSignalAccount?.lastStopAt ?? null,
|
||||||
lastError: runtime.signal.lastError ?? null,
|
lastError: defaultSignalAccount?.lastError ?? null,
|
||||||
probe: signalProbe,
|
probe: defaultSignalAccount?.probe,
|
||||||
lastProbeAt: signalLastProbeAt,
|
lastProbeAt: defaultSignalAccount?.lastProbeAt ?? null,
|
||||||
},
|
},
|
||||||
|
signalAccounts,
|
||||||
|
signalDefaultAccountId: defaultSignalAccountId,
|
||||||
imessage: {
|
imessage: {
|
||||||
configured: imessageConfigured,
|
configured: defaultIMessageAccount?.configured ?? false,
|
||||||
running: runtime.imessage.running,
|
running: defaultIMessageAccount?.running ?? false,
|
||||||
lastStartAt: runtime.imessage.lastStartAt ?? null,
|
lastStartAt: defaultIMessageAccount?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime.imessage.lastStopAt ?? null,
|
lastStopAt: defaultIMessageAccount?.lastStopAt ?? null,
|
||||||
lastError: runtime.imessage.lastError ?? null,
|
lastError: defaultIMessageAccount?.lastError ?? null,
|
||||||
cliPath: runtime.imessage.cliPath ?? null,
|
cliPath: defaultIMessageAccount?.cliPath ?? null,
|
||||||
dbPath: runtime.imessage.dbPath ?? null,
|
dbPath: defaultIMessageAccount?.dbPath ?? null,
|
||||||
probe: imessageProbe,
|
probe: defaultIMessageAccount?.probe,
|
||||||
lastProbeAt: imessageLastProbeAt,
|
lastProbeAt: defaultIMessageAccount?.lastProbeAt ?? null,
|
||||||
},
|
},
|
||||||
|
imessageAccounts,
|
||||||
|
imessageDefaultAccountId: defaultIMessageAccountId,
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { sendMessageIMessage } from "../../imessage/index.js";
|
|||||||
import { sendMessageSignal } from "../../signal/index.js";
|
import { sendMessageSignal } from "../../signal/index.js";
|
||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
|
||||||
import { normalizeMessageProvider } from "../../utils/message-provider.js";
|
import { normalizeMessageProvider } from "../../utils/message-provider.js";
|
||||||
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
|
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
|
||||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
|
||||||
@ -53,14 +52,16 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
const to = request.to.trim();
|
const to = request.to.trim();
|
||||||
const message = request.message.trim();
|
const message = request.message.trim();
|
||||||
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
|
const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
|
||||||
|
const accountId =
|
||||||
|
typeof request.accountId === "string" && request.accountId.trim().length
|
||||||
|
? request.accountId.trim()
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
if (provider === "telegram") {
|
if (provider === "telegram") {
|
||||||
const cfg = loadConfig();
|
|
||||||
const { token } = resolveTelegramToken(cfg);
|
|
||||||
const result = await sendMessageTelegram(to, message, {
|
const result = await sendMessageTelegram(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
verbose: shouldLogVerbose(),
|
verbose: shouldLogVerbose(),
|
||||||
token: token || undefined,
|
accountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -77,7 +78,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
} else if (provider === "discord") {
|
} else if (provider === "discord") {
|
||||||
const result = await sendMessageDiscord(to, message, {
|
const result = await sendMessageDiscord(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
token: process.env.DISCORD_BOT_TOKEN,
|
accountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -94,6 +95,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
} else if (provider === "slack") {
|
} else if (provider === "slack") {
|
||||||
const result = await sendMessageSlack(to, message, {
|
const result = await sendMessageSlack(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -108,14 +110,9 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
} else if (provider === "signal") {
|
} 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, {
|
const result = await sendMessageSignal(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
baseUrl,
|
accountId,
|
||||||
account: cfg.signal?.account,
|
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -129,14 +126,9 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
} else if (provider === "imessage") {
|
} else if (provider === "imessage") {
|
||||||
const cfg = loadConfig();
|
|
||||||
const result = await sendMessageIMessage(to, message, {
|
const result = await sendMessageIMessage(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
cliPath: cfg.imessage?.cliPath,
|
accountId,
|
||||||
dbPath: cfg.imessage?.dbPath,
|
|
||||||
maxBytes: cfg.imessage?.mediaMaxMb
|
|
||||||
? cfg.imessage.mediaMaxMb * 1024 * 1024
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -151,16 +143,13 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
respond(true, payload, undefined, { provider });
|
respond(true, payload, undefined, { provider });
|
||||||
} else {
|
} else {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const accountId =
|
const targetAccountId =
|
||||||
typeof request.accountId === "string" &&
|
accountId ?? resolveDefaultWhatsAppAccountId(cfg);
|
||||||
request.accountId.trim().length > 0
|
|
||||||
? request.accountId.trim()
|
|
||||||
: resolveDefaultWhatsAppAccountId(cfg);
|
|
||||||
const result = await sendMessageWhatsApp(to, message, {
|
const result = await sendMessageWhatsApp(to, message, {
|
||||||
mediaUrl: request.mediaUrl,
|
mediaUrl: request.mediaUrl,
|
||||||
verbose: shouldLogVerbose(),
|
verbose: shouldLogVerbose(),
|
||||||
gifPlayback: request.gifPlayback,
|
gifPlayback: request.gifPlayback,
|
||||||
accountId,
|
accountId: targetAccountId,
|
||||||
});
|
});
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
@ -238,9 +227,13 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
maxSelections: request.maxSelections,
|
maxSelections: request.maxSelections,
|
||||||
durationHours: request.durationHours,
|
durationHours: request.durationHours,
|
||||||
};
|
};
|
||||||
|
const accountId =
|
||||||
|
typeof request.accountId === "string" && request.accountId.trim().length
|
||||||
|
? request.accountId.trim()
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
if (provider === "discord") {
|
if (provider === "discord") {
|
||||||
const result = await sendPollDiscord(to, poll);
|
const result = await sendPollDiscord(to, poll, { accountId });
|
||||||
const payload = {
|
const payload = {
|
||||||
runId: idem,
|
runId: idem,
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
|
|||||||
@ -71,7 +71,16 @@ export type GatewayRequestContext = {
|
|||||||
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
|
||||||
startWhatsAppProvider: (accountId?: string) => Promise<void>;
|
startWhatsAppProvider: (accountId?: string) => Promise<void>;
|
||||||
stopWhatsAppProvider: (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;
|
markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void;
|
||||||
wizardRunner: (
|
wizardRunner: (
|
||||||
opts: import("../../commands/onboard-types.js").OnboardOptions,
|
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,
|
lastEventAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
|
whatsappAccounts: {},
|
||||||
telegram: {
|
telegram: {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
@ -49,18 +50,21 @@ const hoisted = vi.hoisted(() => {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
mode: null,
|
mode: null,
|
||||||
},
|
},
|
||||||
|
telegramAccounts: {},
|
||||||
discord: {
|
discord: {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
|
discordAccounts: {},
|
||||||
slack: {
|
slack: {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
|
slackAccounts: {},
|
||||||
signal: {
|
signal: {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
@ -68,6 +72,7 @@ const hoisted = vi.hoisted(() => {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
baseUrl: null,
|
baseUrl: null,
|
||||||
},
|
},
|
||||||
|
signalAccounts: {},
|
||||||
imessage: {
|
imessage: {
|
||||||
running: false,
|
running: false,
|
||||||
lastStartAt: null,
|
lastStartAt: null,
|
||||||
@ -76,6 +81,7 @@ const hoisted = vi.hoisted(() => {
|
|||||||
cliPath: null,
|
cliPath: null,
|
||||||
dbPath: null,
|
dbPath: null,
|
||||||
},
|
},
|
||||||
|
imessageAccounts: {},
|
||||||
})),
|
})),
|
||||||
startProviders: vi.fn(async () => {}),
|
startProviders: vi.fn(async () => {}),
|
||||||
startWhatsAppProvider: vi.fn(async () => {}),
|
startWhatsAppProvider: vi.fn(async () => {}),
|
||||||
|
|||||||
@ -1542,7 +1542,16 @@ export async function startGatewayServer(
|
|||||||
getRuntimeSnapshot,
|
getRuntimeSnapshot,
|
||||||
startWhatsAppProvider,
|
startWhatsAppProvider,
|
||||||
stopWhatsAppProvider,
|
stopWhatsAppProvider,
|
||||||
|
startTelegramProvider,
|
||||||
stopTelegramProvider,
|
stopTelegramProvider,
|
||||||
|
startDiscordProvider,
|
||||||
|
stopDiscordProvider,
|
||||||
|
startSlackProvider,
|
||||||
|
stopSlackProvider,
|
||||||
|
startSignalProvider,
|
||||||
|
stopSignalProvider,
|
||||||
|
startIMessageProvider,
|
||||||
|
stopIMessageProvider,
|
||||||
markWhatsAppLoggedOut,
|
markWhatsAppLoggedOut,
|
||||||
wizardRunner,
|
wizardRunner,
|
||||||
broadcastVoiceWakeChanged,
|
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";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveProviderGroupPolicy,
|
resolveProviderGroupPolicy,
|
||||||
@ -22,6 +23,7 @@ import {
|
|||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveIMessageAccount } from "./accounts.js";
|
||||||
import { createIMessageRpcClient } from "./client.js";
|
import { createIMessageRpcClient } from "./client.js";
|
||||||
import { sendMessageIMessage } from "./send.js";
|
import { sendMessageIMessage } from "./send.js";
|
||||||
import {
|
import {
|
||||||
@ -56,6 +58,8 @@ export type MonitorIMessageOpts = {
|
|||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
|
accountId?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
groupAllowFrom?: Array<string | number>;
|
groupAllowFrom?: Array<string | number>;
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
@ -75,32 +79,21 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
function normalizeAllowList(list?: Array<string | number>) {
|
||||||
const cfg = loadConfig();
|
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deliverReplies(params: {
|
async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
|
||||||
|
accountId?: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
textLimit: 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) {
|
for (const payload of replies) {
|
||||||
const mediaList =
|
const mediaList =
|
||||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
@ -108,7 +101,11 @@ async function deliverReplies(params: {
|
|||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkText(text, textLimit)) {
|
for (const chunk of chunkText(text, textLimit)) {
|
||||||
await sendMessageIMessage(target, chunk, { maxBytes, client });
|
await sendMessageIMessage(target, chunk, {
|
||||||
|
maxBytes,
|
||||||
|
client,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let first = true;
|
let first = true;
|
||||||
@ -119,6 +116,7 @@ async function deliverReplies(params: {
|
|||||||
mediaUrl: url,
|
mediaUrl: url,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
client,
|
client,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,17 +128,32 @@ export async function monitorIMessageProvider(
|
|||||||
opts: MonitorIMessageOpts = {},
|
opts: MonitorIMessageOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runtime = resolveRuntime(opts);
|
const runtime = resolveRuntime(opts);
|
||||||
const cfg = loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "imessage");
|
const accountInfo = resolveIMessageAccount({
|
||||||
const allowFrom = resolveAllowFrom(opts);
|
cfg,
|
||||||
const groupAllowFrom = resolveGroupAllowFrom(opts);
|
accountId: opts.accountId,
|
||||||
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
|
});
|
||||||
const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing";
|
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 mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const includeAttachments =
|
const includeAttachments =
|
||||||
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
|
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.imessage?.mediaMaxMb ?? 16) * 1024 * 1024;
|
(opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
|
||||||
|
|
||||||
const handleMessage = async (raw: unknown) => {
|
const handleMessage = async (raw: unknown) => {
|
||||||
const params = raw as { message?: IMessagePayload | null };
|
const params = raw as { message?: IMessagePayload | null };
|
||||||
@ -202,6 +215,7 @@ export async function monitorIMessageProvider(
|
|||||||
const groupListPolicy = resolveProviderGroupPolicy({
|
const groupListPolicy = resolveProviderGroupPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
||||||
@ -254,6 +268,7 @@ export async function monitorIMessageProvider(
|
|||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
...(chatId ? { chatId } : {}),
|
...(chatId ? { chatId } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -279,6 +294,7 @@ export async function monitorIMessageProvider(
|
|||||||
const requireMention = resolveProviderGroupRequireMention({
|
const requireMention = resolveProviderGroupRequireMention({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
groupId,
|
groupId,
|
||||||
requireMentionOverride: opts.requireMention,
|
requireMentionOverride: opts.requireMention,
|
||||||
overrideOrder: "before-config",
|
overrideOrder: "before-config",
|
||||||
@ -344,6 +360,7 @@ export async function monitorIMessageProvider(
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: isGroup
|
id: isGroup
|
||||||
@ -410,6 +427,7 @@ export async function monitorIMessageProvider(
|
|||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: ctxPayload.To,
|
target: ctxPayload.To,
|
||||||
client,
|
client,
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
textLimit,
|
textLimit,
|
||||||
@ -431,8 +449,8 @@ export async function monitorIMessageProvider(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const client = await createIMessageRpcClient({
|
const client = await createIMessageRpcClient({
|
||||||
cliPath: opts.cliPath ?? cfg.imessage?.cliPath,
|
cliPath: opts.cliPath ?? imessageCfg.cliPath,
|
||||||
dbPath: opts.dbPath ?? cfg.imessage?.dbPath,
|
dbPath: opts.dbPath ?? imessageCfg.dbPath,
|
||||||
runtime,
|
runtime,
|
||||||
onNotification: (msg) => {
|
onNotification: (msg) => {
|
||||||
if (msg.method === "message") {
|
if (msg.method === "message") {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveIMessageAccount } from "./accounts.js";
|
||||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||||
import {
|
import {
|
||||||
formatIMessageChatTarget,
|
formatIMessageChatTarget,
|
||||||
@ -14,6 +15,7 @@ export type IMessageSendOpts = {
|
|||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
service?: IMessageService;
|
service?: IMessageService;
|
||||||
region?: string;
|
region?: string;
|
||||||
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@ -25,28 +27,6 @@ export type IMessageSendResult = {
|
|||||||
messageId: string;
|
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(
|
async function resolveAttachment(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
@ -66,15 +46,28 @@ export async function sendMessageIMessage(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: IMessageSendOpts = {},
|
opts: IMessageSendOpts = {},
|
||||||
): Promise<IMessageSendResult> {
|
): Promise<IMessageSendResult> {
|
||||||
const cliPath = resolveCliPath(opts.cliPath);
|
const cfg = loadConfig();
|
||||||
const dbPath = resolveDbPath(opts.dbPath);
|
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(
|
const target = parseIMessageTarget(
|
||||||
opts.chatId ? formatIMessageChatTarget(opts.chatId) : to,
|
opts.chatId ? formatIMessageChatTarget(opts.chatId) : to,
|
||||||
);
|
);
|
||||||
const service =
|
const service =
|
||||||
opts.service ?? (target.kind === "handle" ? target.service : undefined);
|
opts.service ??
|
||||||
const region = resolveRegion(opts.region);
|
(target.kind === "handle" ? target.service : undefined) ??
|
||||||
const maxBytes = opts.maxBytes ?? 16 * 1024 * 1024;
|
(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 message = text ?? "";
|
||||||
let filePath: string | undefined;
|
let filePath: string | undefined;
|
||||||
|
|
||||||
@ -94,7 +87,7 @@ export async function sendMessageIMessage(
|
|||||||
|
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
text: message,
|
text: message,
|
||||||
service: resolveService(service),
|
service: (service || "auto") as IMessageService,
|
||||||
region,
|
region,
|
||||||
};
|
};
|
||||||
if (filePath) params.file = filePath;
|
if (filePath) params.file = filePath;
|
||||||
|
|||||||
@ -411,7 +411,7 @@ describe("runHeartbeatOnce", () => {
|
|||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
"123456",
|
"123456",
|
||||||
"Hello from heartbeat",
|
"Hello from heartbeat",
|
||||||
expect.objectContaining({ token: "test-bot-token-123" }),
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
replySpy.mockRestore();
|
replySpy.mockRestore();
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
} from "./deliver.js";
|
} from "./deliver.js";
|
||||||
|
|
||||||
describe("deliverOutboundPayloads", () => {
|
describe("deliverOutboundPayloads", () => {
|
||||||
it("chunks telegram markdown and passes config token", async () => {
|
it("chunks telegram markdown and passes account id", async () => {
|
||||||
const sendTelegram = vi
|
const sendTelegram = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
.mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
@ -28,7 +28,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
expect(sendTelegram).toHaveBeenCalledTimes(2);
|
||||||
for (const call of sendTelegram.mock.calls) {
|
for (const call of sendTelegram.mock.calls) {
|
||||||
expect(call[2]).toEqual(
|
expect(call[2]).toEqual(
|
||||||
expect.objectContaining({ token: "tok-1", verbose: false }),
|
expect.objectContaining({ accountId: "default", verbose: false }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expect(results).toHaveLength(2);
|
expect(results).toHaveLength(2);
|
||||||
|
|||||||
@ -7,10 +7,10 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { sendMessageDiscord } from "../../discord/send.js";
|
import { sendMessageDiscord } from "../../discord/send.js";
|
||||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||||
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import { sendMessageSlack } from "../../slack/send.js";
|
import { sendMessageSlack } from "../../slack/send.js";
|
||||||
import { sendMessageTelegram } from "../../telegram/send.js";
|
import { sendMessageTelegram } from "../../telegram/send.js";
|
||||||
import { resolveTelegramToken } from "../../telegram/token.js";
|
|
||||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||||
import type { NormalizedOutboundPayload } from "./payloads.js";
|
import type { NormalizedOutboundPayload } from "./payloads.js";
|
||||||
import { normalizeOutboundPayloads } from "./payloads.js";
|
import { normalizeOutboundPayloads } from "./payloads.js";
|
||||||
@ -64,9 +64,15 @@ type ProviderHandler = {
|
|||||||
function resolveMediaMaxBytes(
|
function resolveMediaMaxBytes(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
provider: "signal" | "imessage",
|
provider: "signal" | "imessage",
|
||||||
|
accountId?: string | null,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
const providerLimit =
|
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 (providerLimit) return providerLimit * MB;
|
||||||
if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB;
|
if (cfg.agent?.mediaMaxMb) return cfg.agent.mediaMaxMb * MB;
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -76,20 +82,18 @@ function createProviderHandler(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
provider: Exclude<OutboundProvider, "none">;
|
provider: Exclude<OutboundProvider, "none">;
|
||||||
to: string;
|
to: string;
|
||||||
|
accountId?: string;
|
||||||
deps: Required<OutboundSendDeps>;
|
deps: Required<OutboundSendDeps>;
|
||||||
}): ProviderHandler {
|
}): ProviderHandler {
|
||||||
const { cfg, to, deps } = params;
|
const { cfg, to, deps } = params;
|
||||||
const telegramToken =
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
params.provider === "telegram"
|
|
||||||
? resolveTelegramToken(cfg).token || undefined
|
|
||||||
: undefined;
|
|
||||||
const signalMaxBytes =
|
const signalMaxBytes =
|
||||||
params.provider === "signal"
|
params.provider === "signal"
|
||||||
? resolveMediaMaxBytes(cfg, "signal")
|
? resolveMediaMaxBytes(cfg, "signal", accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const imessageMaxBytes =
|
const imessageMaxBytes =
|
||||||
params.provider === "imessage"
|
params.provider === "imessage"
|
||||||
? resolveMediaMaxBytes(cfg, "imessage")
|
? resolveMediaMaxBytes(cfg, "imessage", accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
|
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
|
||||||
@ -97,13 +101,17 @@ function createProviderHandler(params: {
|
|||||||
chunker: providerCaps.whatsapp.chunker,
|
chunker: providerCaps.whatsapp.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
...(await deps.sendWhatsApp(to, text, { verbose: false })),
|
...(await deps.sendWhatsApp(to, text, {
|
||||||
|
verbose: false,
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
...(await deps.sendWhatsApp(to, caption, {
|
...(await deps.sendWhatsApp(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -113,7 +121,7 @@ function createProviderHandler(params: {
|
|||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
...(await deps.sendTelegram(to, text, {
|
...(await deps.sendTelegram(to, text, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
token: telegramToken,
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
@ -121,7 +129,7 @@ function createProviderHandler(params: {
|
|||||||
...(await deps.sendTelegram(to, caption, {
|
...(await deps.sendTelegram(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
token: telegramToken,
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -129,13 +137,17 @@ function createProviderHandler(params: {
|
|||||||
chunker: providerCaps.discord.chunker,
|
chunker: providerCaps.discord.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
...(await deps.sendDiscord(to, text, { verbose: false })),
|
...(await deps.sendDiscord(to, text, {
|
||||||
|
verbose: false,
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
...(await deps.sendDiscord(to, caption, {
|
...(await deps.sendDiscord(to, caption, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -143,24 +155,33 @@ function createProviderHandler(params: {
|
|||||||
chunker: providerCaps.slack.chunker,
|
chunker: providerCaps.slack.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
...(await deps.sendSlack(to, text)),
|
...(await deps.sendSlack(to, text, {
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
...(await deps.sendSlack(to, caption, { mediaUrl })),
|
...(await deps.sendSlack(to, caption, {
|
||||||
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
signal: {
|
signal: {
|
||||||
chunker: providerCaps.signal.chunker,
|
chunker: providerCaps.signal.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })),
|
...(await deps.sendSignal(to, text, {
|
||||||
|
maxBytes: signalMaxBytes,
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
...(await deps.sendSignal(to, caption, {
|
...(await deps.sendSignal(to, caption, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: signalMaxBytes,
|
maxBytes: signalMaxBytes,
|
||||||
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -168,13 +189,17 @@ function createProviderHandler(params: {
|
|||||||
chunker: providerCaps.imessage.chunker,
|
chunker: providerCaps.imessage.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })),
|
...(await deps.sendIMessage(to, text, {
|
||||||
|
maxBytes: imessageMaxBytes,
|
||||||
|
accountId,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
sendMedia: async (caption, mediaUrl) => ({
|
sendMedia: async (caption, mediaUrl) => ({
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
...(await deps.sendIMessage(to, caption, {
|
...(await deps.sendIMessage(to, caption, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
maxBytes: imessageMaxBytes,
|
maxBytes: imessageMaxBytes,
|
||||||
|
accountId,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -187,6 +212,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
provider: Exclude<OutboundProvider, "none">;
|
provider: Exclude<OutboundProvider, "none">;
|
||||||
to: string;
|
to: string;
|
||||||
|
accountId?: string;
|
||||||
payloads: ReplyPayload[];
|
payloads: ReplyPayload[];
|
||||||
deps?: OutboundSendDeps;
|
deps?: OutboundSendDeps;
|
||||||
bestEffort?: boolean;
|
bestEffort?: boolean;
|
||||||
@ -194,6 +220,7 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
onPayload?: (payload: NormalizedOutboundPayload) => void;
|
||||||
}): Promise<OutboundDeliveryResult[]> {
|
}): Promise<OutboundDeliveryResult[]> {
|
||||||
const { cfg, provider, to, payloads } = params;
|
const { cfg, provider, to, payloads } = params;
|
||||||
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const deps = {
|
const deps = {
|
||||||
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp,
|
||||||
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram,
|
||||||
@ -208,9 +235,10 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
provider,
|
provider,
|
||||||
to,
|
to,
|
||||||
deps,
|
deps,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
const textLimit = handler.chunker
|
const textLimit = handler.chunker
|
||||||
? resolveTextChunkLimit(cfg, provider)
|
? resolveTextChunkLimit(cfg, provider, accountId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const sendTextChunks = async (text: string) => {
|
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 { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
||||||
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
|
import { resolveSignalAccount } from "./accounts.js";
|
||||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||||
import { spawnSignalDaemon } from "./daemon.js";
|
import { spawnSignalDaemon } from "./daemon.js";
|
||||||
import { sendMessageSignal } from "./send.js";
|
import { sendMessageSignal } from "./send.js";
|
||||||
@ -51,6 +53,8 @@ export type MonitorSignalOpts = {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
@ -83,36 +87,8 @@ function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBaseUrl(opts: MonitorSignalOpts): string {
|
function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||||
const cfg = loadConfig();
|
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
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 isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||||
@ -207,12 +183,21 @@ async function deliverReplies(params: {
|
|||||||
target: string;
|
target: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
}) {
|
}) {
|
||||||
const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } =
|
const {
|
||||||
params;
|
replies,
|
||||||
|
target,
|
||||||
|
baseUrl,
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
runtime,
|
||||||
|
maxBytes,
|
||||||
|
textLimit,
|
||||||
|
} = params;
|
||||||
for (const payload of replies) {
|
for (const payload of replies) {
|
||||||
const mediaList =
|
const mediaList =
|
||||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
@ -224,6 +209,7 @@ async function deliverReplies(params: {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
account,
|
account,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -236,6 +222,7 @@ async function deliverReplies(params: {
|
|||||||
account,
|
account,
|
||||||
mediaUrl: url,
|
mediaUrl: url,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,37 +234,53 @@ export async function monitorSignalProvider(
|
|||||||
opts: MonitorSignalOpts = {},
|
opts: MonitorSignalOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const runtime = resolveRuntime(opts);
|
const runtime = resolveRuntime(opts);
|
||||||
const cfg = loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "signal");
|
const accountInfo = resolveSignalAccount({
|
||||||
const baseUrl = resolveBaseUrl(opts);
|
cfg,
|
||||||
const account = resolveAccount(opts);
|
accountId: opts.accountId,
|
||||||
const dmPolicy = cfg.signal?.dmPolicy ?? "pairing";
|
});
|
||||||
const allowFrom = resolveAllowFrom(opts);
|
const textLimit = resolveTextChunkLimit(cfg, "signal", accountInfo.accountId);
|
||||||
const groupAllowFrom = resolveGroupAllowFrom(opts);
|
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
||||||
const groupPolicy = cfg.signal?.groupPolicy ?? "open";
|
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 =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const ignoreAttachments =
|
const ignoreAttachments =
|
||||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments ?? false;
|
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||||
|
|
||||||
const autoStart =
|
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;
|
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||||
|
|
||||||
if (autoStart) {
|
if (autoStart) {
|
||||||
const cliPath = opts.cliPath ?? cfg.signal?.cliPath ?? "signal-cli";
|
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
|
||||||
const httpHost = opts.httpHost ?? cfg.signal?.httpHost ?? "127.0.0.1";
|
const httpHost =
|
||||||
const httpPort = opts.httpPort ?? cfg.signal?.httpPort ?? 8080;
|
opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
|
||||||
|
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
|
||||||
daemonHandle = spawnSignalDaemon({
|
daemonHandle = spawnSignalDaemon({
|
||||||
cliPath,
|
cliPath,
|
||||||
account,
|
account,
|
||||||
httpHost,
|
httpHost,
|
||||||
httpPort,
|
httpPort,
|
||||||
receiveMode: opts.receiveMode ?? cfg.signal?.receiveMode,
|
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||||
ignoreAttachments:
|
ignoreAttachments:
|
||||||
opts.ignoreAttachments ?? cfg.signal?.ignoreAttachments,
|
opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||||
ignoreStories: opts.ignoreStories ?? cfg.signal?.ignoreStories,
|
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||||
sendReadReceipts: opts.sendReadReceipts ?? cfg.signal?.sendReadReceipts,
|
sendReadReceipts:
|
||||||
|
opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -357,7 +360,12 @@ export async function monitorSignalProvider(
|
|||||||
"Ask the bot owner to approve with:",
|
"Ask the bot owner to approve with:",
|
||||||
"clawdbot pairing approve --provider signal <code>",
|
"clawdbot pairing approve --provider signal <code>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
{ baseUrl, account, maxBytes: mediaMaxBytes },
|
{
|
||||||
|
baseUrl,
|
||||||
|
account,
|
||||||
|
maxBytes: mediaMaxBytes,
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@ -447,6 +455,7 @@ export async function monitorSignalProvider(
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
||||||
@ -505,6 +514,7 @@ export async function monitorSignalProvider(
|
|||||||
target: ctxPayload.To,
|
target: ctxPayload.To,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
account,
|
account,
|
||||||
|
accountId: accountInfo.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
|||||||
@ -2,11 +2,13 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveSignalAccount } from "./accounts.js";
|
||||||
import { signalRpcRequest } from "./client.js";
|
import { signalRpcRequest } from "./client.js";
|
||||||
|
|
||||||
export type SignalSendOpts = {
|
export type SignalSendOpts = {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
@ -22,23 +24,6 @@ type SignalTarget =
|
|||||||
| { type: "group"; groupId: string }
|
| { type: "group"; groupId: string }
|
||||||
| { type: "username"; username: 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 {
|
function parseTarget(raw: string): SignalTarget {
|
||||||
let value = raw.trim();
|
let value = raw.trim();
|
||||||
if (!value) throw new Error("Signal recipient is required");
|
if (!value) throw new Error("Signal recipient is required");
|
||||||
@ -81,11 +66,25 @@ export async function sendMessageSignal(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: SignalSendOpts = {},
|
opts: SignalSendOpts = {},
|
||||||
): Promise<SignalSendResult> {
|
): Promise<SignalSendResult> {
|
||||||
const baseUrl = resolveBaseUrl(opts.baseUrl);
|
const cfg = loadConfig();
|
||||||
const account = resolveAccount(opts.account);
|
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);
|
const target = parseTarget(to);
|
||||||
let message = text ?? "";
|
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;
|
let attachments: string[] | undefined;
|
||||||
if (opts.mediaUrl?.trim()) {
|
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 { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type {
|
import type {
|
||||||
|
ClawdbotConfig,
|
||||||
SlackReactionNotificationMode,
|
SlackReactionNotificationMode,
|
||||||
SlackSlashCommandConfig,
|
SlackSlashCommandConfig,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
@ -49,6 +50,7 @@ import {
|
|||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveSlackAccount } from "./accounts.js";
|
||||||
import { reactSlackMessage } from "./actions.js";
|
import { reactSlackMessage } from "./actions.js";
|
||||||
import { sendMessageSlack } from "./send.js";
|
import { sendMessageSlack } from "./send.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||||
@ -56,6 +58,8 @@ import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
|||||||
export type MonitorSlackOpts = {
|
export type MonitorSlackOpts = {
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
appToken?: string;
|
appToken?: string;
|
||||||
|
accountId?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
@ -436,7 +440,11 @@ async function resolveSlackThreadStarter(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
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 sessionCfg = cfg.session;
|
||||||
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
||||||
@ -462,21 +470,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
mainKey,
|
mainKey,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const botToken = resolveSlackBotToken(
|
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
||||||
opts.botToken ??
|
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
||||||
process.env.SLACK_BOT_TOKEN ??
|
|
||||||
cfg.slack?.botToken ??
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
const appToken = resolveSlackAppToken(
|
|
||||||
opts.appToken ??
|
|
||||||
process.env.SLACK_APP_TOKEN ??
|
|
||||||
cfg.slack?.appToken ??
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
if (!botToken || !appToken) {
|
if (!botToken || !appToken) {
|
||||||
throw new Error(
|
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 dmPolicy = dmConfig?.policy ?? "pairing";
|
||||||
const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
|
const allowFrom = normalizeAllowList(dmConfig?.allowFrom);
|
||||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||||
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
||||||
const channelsConfig = cfg.slack?.channels;
|
const channelsConfig = slackCfg.channels;
|
||||||
const dmEnabled = dmConfig?.enabled ?? true;
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
|
const groupPolicy = slackCfg.groupPolicy ?? "open";
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
const reactionMode = slackCfg.reactionNotifications ?? "own";
|
||||||
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];
|
||||||
const slashCommand = resolveSlackSlashCommandConfig(
|
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 mentionRegexes = buildMentionRegexes(cfg);
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const mediaMaxBytes =
|
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 logger = getChildLogger({ module: "slack-auto-reply" });
|
||||||
const channelCache = new Map<
|
const channelCache = new Map<
|
||||||
@ -790,7 +789,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
"Ask the bot owner to approve with:",
|
"Ask the bot owner to approve with:",
|
||||||
"clawdbot pairing approve --provider slack <code>",
|
"clawdbot pairing approve --provider slack <code>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
{ token: botToken, client: app.client },
|
{
|
||||||
|
token: botToken,
|
||||||
|
client: app.client,
|
||||||
|
accountId: account.accountId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
@ -922,6 +925,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
|
accountId: account.accountId,
|
||||||
teamId: teamId || undefined,
|
teamId: teamId || undefined,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||||
@ -1071,6 +1075,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: replyTarget,
|
target: replyTarget,
|
||||||
token: botToken,
|
token: botToken,
|
||||||
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
textLimit,
|
textLimit,
|
||||||
threadTs: incomingThreadTs,
|
threadTs: incomingThreadTs,
|
||||||
@ -1749,6 +1754,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
|
accountId: account.accountId,
|
||||||
teamId: teamId || undefined,
|
teamId: teamId || undefined,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
|
||||||
@ -1875,6 +1881,7 @@ async function deliverReplies(params: {
|
|||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
accountId?: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
threadTs?: string;
|
threadTs?: string;
|
||||||
@ -1893,6 +1900,7 @@ async function deliverReplies(params: {
|
|||||||
await sendMessageSlack(params.target, trimmed, {
|
await sendMessageSlack(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
threadTs: params.threadTs,
|
threadTs: params.threadTs,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1904,6 +1912,7 @@ async function deliverReplies(params: {
|
|||||||
token: params.token,
|
token: params.token,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
threadTs: params.threadTs,
|
threadTs: params.threadTs,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
} from "../auto-reply/chunk.js";
|
} from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveSlackAccount } from "./accounts.js";
|
||||||
import { resolveSlackBotToken } from "./token.js";
|
import { resolveSlackBotToken } from "./token.js";
|
||||||
|
|
||||||
const SLACK_TEXT_LIMIT = 4000;
|
const SLACK_TEXT_LIMIT = 4000;
|
||||||
@ -22,6 +23,7 @@ type SlackRecipient =
|
|||||||
|
|
||||||
type SlackSendOpts = {
|
type SlackSendOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
client?: WebClient;
|
client?: WebClient;
|
||||||
threadTs?: string;
|
threadTs?: string;
|
||||||
@ -32,17 +34,20 @@ export type SlackSendResult = {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveToken(explicit?: string) {
|
function resolveToken(params: {
|
||||||
const cfgToken = loadConfig().slack?.botToken;
|
explicit?: string;
|
||||||
const token = resolveSlackBotToken(
|
accountId: string;
|
||||||
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
|
fallbackToken?: string;
|
||||||
);
|
}) {
|
||||||
if (!token) {
|
const explicit = resolveSlackBotToken(params.explicit);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const fallback = resolveSlackBotToken(params.fallbackToken);
|
||||||
|
if (!fallback) {
|
||||||
throw new Error(
|
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 {
|
function parseRecipient(raw: string): SlackRecipient {
|
||||||
@ -140,17 +145,25 @@ export async function sendMessageSlack(
|
|||||||
if (!trimmedMessage && !opts.mediaUrl) {
|
if (!trimmedMessage && !opts.mediaUrl) {
|
||||||
throw new Error("Slack send requires text or media");
|
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 client = opts.client ?? new WebClient(token);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(client, recipient);
|
const { channelId } = await resolveChannelId(client, recipient);
|
||||||
const cfg = loadConfig();
|
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
|
||||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
typeof cfg.slack?.mediaMaxMb === "number"
|
typeof account.config.mediaMaxMb === "number"
|
||||||
? cfg.slack.mediaMaxMb * 1024 * 1024
|
? account.config.mediaMaxMb * 1024 * 1024
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let lastMessageId = "";
|
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 { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
import {
|
import {
|
||||||
readTelegramAllowFromStore,
|
readTelegramAllowFromStore,
|
||||||
@ -105,6 +106,7 @@ type TelegramContext = {
|
|||||||
|
|
||||||
export type TelegramBotOptions = {
|
export type TelegramBotOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
|
accountId?: string;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
@ -158,14 +160,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
|
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
|
||||||
|
|
||||||
const cfg = opts.config ?? loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "telegram");
|
const account = resolveTelegramAccount({
|
||||||
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
|
cfg,
|
||||||
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
|
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 =
|
const groupAllowFrom =
|
||||||
opts.groupAllowFrom ??
|
opts.groupAllowFrom ??
|
||||||
cfg.telegram?.groupAllowFrom ??
|
telegramCfg.groupAllowFrom ??
|
||||||
(cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0
|
(telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0
|
||||||
? cfg.telegram.allowFrom
|
? telegramCfg.allowFrom
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
|
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
|
||||||
const normalizeAllowFrom = (list?: Array<string | number>) => {
|
const normalizeAllowFrom = (list?: Array<string | number>) => {
|
||||||
@ -205,15 +212,15 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
(entry) => entry === username || entry === `@${username}`,
|
(entry) => entry === username || entry === `@${username}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "first";
|
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "first";
|
||||||
const streamMode = resolveTelegramStreamMode(cfg);
|
const streamMode = resolveTelegramStreamMode(telegramCfg);
|
||||||
const nativeEnabled = cfg.commands?.native === true;
|
const nativeEnabled = cfg.commands?.native === true;
|
||||||
const nativeDisabledExplicit = cfg.commands?.native === false;
|
const nativeDisabledExplicit = cfg.commands?.native === false;
|
||||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||||
const mediaMaxBytes =
|
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 logger = getChildLogger({ module: "telegram-auto-reply" });
|
||||||
const mentionRegexes = buildMentionRegexes(cfg);
|
const mentionRegexes = buildMentionRegexes(cfg);
|
||||||
let botHasTopicsEnabled: boolean | undefined;
|
let botHasTopicsEnabled: boolean | undefined;
|
||||||
@ -237,6 +244,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
resolveProviderGroupPolicy({
|
resolveProviderGroupPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
|
accountId: account.accountId,
|
||||||
groupId: String(chatId),
|
groupId: String(chatId),
|
||||||
});
|
});
|
||||||
const resolveGroupActivation = (params: {
|
const resolveGroupActivation = (params: {
|
||||||
@ -264,6 +272,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
resolveProviderGroupRequireMention({
|
resolveProviderGroupRequireMention({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
|
accountId: account.accountId,
|
||||||
groupId: String(chatId),
|
groupId: String(chatId),
|
||||||
requireMentionOverride: opts.requireMention,
|
requireMentionOverride: opts.requireMention,
|
||||||
overrideOrder: "after-config",
|
overrideOrder: "after-config",
|
||||||
@ -272,7 +281,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
chatId: string | number,
|
chatId: string | number,
|
||||||
messageThreadId?: number,
|
messageThreadId?: number,
|
||||||
) => {
|
) => {
|
||||||
const groups = cfg.telegram?.groups;
|
const groups = telegramCfg.groups;
|
||||||
if (!groups) return { groupConfig: undefined, topicConfig: undefined };
|
if (!groups) return { groupConfig: undefined, topicConfig: undefined };
|
||||||
const groupKey = String(chatId);
|
const groupKey = String(chatId);
|
||||||
const groupConfig = groups[groupKey] ?? groups["*"];
|
const groupConfig = groups[groupKey] ?? groups["*"];
|
||||||
@ -304,6 +313,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
|
accountId: account.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
@ -814,7 +824,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGroup && useAccessGroups) {
|
if (isGroup && useAccessGroups) {
|
||||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
const groupPolicy = telegramCfg.groupPolicy ?? "open";
|
||||||
if (groupPolicy === "disabled") {
|
if (groupPolicy === "disabled") {
|
||||||
await bot.api.sendMessage(
|
await bot.api.sendMessage(
|
||||||
chatId,
|
chatId,
|
||||||
@ -881,6 +891,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
|
accountId: account.accountId,
|
||||||
peer: {
|
peer: {
|
||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: isGroup
|
id: isGroup
|
||||||
@ -1009,7 +1020,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
// - "open" (default): groups bypass allowFrom, only mention-gating applies
|
// - "open" (default): groups bypass allowFrom, only mention-gating applies
|
||||||
// - "disabled": block all group messages entirely
|
// - "disabled": block all group messages entirely
|
||||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
const groupPolicy = telegramCfg.groupPolicy ?? "open";
|
||||||
if (groupPolicy === "disabled") {
|
if (groupPolicy === "disabled") {
|
||||||
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
||||||
return;
|
return;
|
||||||
@ -1260,9 +1271,9 @@ function buildTelegramThreadParams(messageThreadId?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTelegramStreamMode(
|
function resolveTelegramStreamMode(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
telegramCfg: ClawdbotConfig["telegram"],
|
||||||
): TelegramStreamMode {
|
): TelegramStreamMode {
|
||||||
const raw = cfg.telegram?.streamMode?.trim().toLowerCase();
|
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
||||||
if (raw === "off" || raw === "partial" || raw === "block") return raw;
|
if (raw === "off" || raw === "partial" || raw === "block") return raw;
|
||||||
return "partial";
|
return "partial";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,15 @@ import { type RunOptions, run } from "@grammyjs/runner";
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
import { makeProxyFetch } from "./proxy.js";
|
import { makeProxyFetch } from "./proxy.js";
|
||||||
import { resolveTelegramToken } from "./token.js";
|
|
||||||
import { startTelegramWebhook } from "./webhook.js";
|
import { startTelegramWebhook } from "./webhook.js";
|
||||||
|
|
||||||
export type MonitorTelegramOpts = {
|
export type MonitorTelegramOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
config?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
useWebhook?: boolean;
|
useWebhook?: boolean;
|
||||||
@ -36,20 +38,22 @@ export function createTelegramRunnerOptions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
||||||
const cfg = loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
const { token } = resolveTelegramToken(cfg, {
|
const account = resolveTelegramAccount({
|
||||||
envToken: opts.token,
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
|
const token = opts.token?.trim() || account.token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
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 =
|
const proxyFetch =
|
||||||
opts.proxyFetch ??
|
opts.proxyFetch ??
|
||||||
(cfg.telegram?.proxy
|
(account.config.proxy
|
||||||
? makeProxyFetch(cfg.telegram?.proxy as string)
|
? makeProxyFetch(account.config.proxy as string)
|
||||||
: undefined);
|
: undefined);
|
||||||
|
|
||||||
const bot = createTelegramBot({
|
const bot = createTelegramBot({
|
||||||
@ -57,6 +61,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
|||||||
runtime: opts.runtime,
|
runtime: opts.runtime,
|
||||||
proxyFetch,
|
proxyFetch,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts.useWebhook) {
|
if (opts.useWebhook) {
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
|
||||||
import { Bot, InputFile } from "grammy";
|
import { Bot, InputFile } from "grammy";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { ClawdbotConfig } from "../config/types.js";
|
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import type { RetryConfig } from "../infra/retry.js";
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { isGifMedia } from "../media/mime.js";
|
import { isGifMedia } from "../media/mime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import { resolveTelegramToken } from "./token.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
|
|
||||||
type TelegramSendOpts = {
|
type TelegramSendOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
@ -30,6 +30,7 @@ type TelegramSendResult = {
|
|||||||
|
|
||||||
type TelegramReactionOpts = {
|
type TelegramReactionOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
api?: Bot["api"];
|
api?: Bot["api"];
|
||||||
remove?: boolean;
|
remove?: boolean;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@ -39,15 +40,17 @@ type TelegramReactionOpts = {
|
|||||||
const PARSE_ERR_RE =
|
const PARSE_ERR_RE =
|
||||||
/can't parse entities|parse entities|find end of the entity/i;
|
/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();
|
if (explicit?.trim()) return explicit.trim();
|
||||||
const { token } = resolveTelegramToken(cfg);
|
if (!params.token) {
|
||||||
if (!token) {
|
|
||||||
throw new Error(
|
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 {
|
function normalizeChatId(to: string): string {
|
||||||
@ -97,7 +100,11 @@ export async function sendMessageTelegram(
|
|||||||
opts: TelegramSendOpts = {},
|
opts: TelegramSendOpts = {},
|
||||||
): Promise<TelegramSendResult> {
|
): Promise<TelegramSendResult> {
|
||||||
const cfg = loadConfig();
|
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);
|
const chatId = normalizeChatId(to);
|
||||||
// Use provided api or create a new Bot instance. The nullish coalescing
|
// Use provided api or create a new Bot instance. The nullish coalescing
|
||||||
// operator ensures api is always defined (Bot.api is always non-null).
|
// 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 hasThreadParams = Object.keys(threadParams).length > 0;
|
||||||
const request = createTelegramRetryRunner({
|
const request = createTelegramRetryRunner({
|
||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: cfg.telegram?.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -236,13 +243,17 @@ export async function reactMessageTelegram(
|
|||||||
opts: TelegramReactionOpts = {},
|
opts: TelegramReactionOpts = {},
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const cfg = loadConfig();
|
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 chatId = normalizeChatId(String(chatIdInput));
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const api = opts.api ?? new Bot(token).api;
|
const api = opts.api ?? new Bot(token).api;
|
||||||
const request = createTelegramRetryRunner({
|
const request = createTelegramRetryRunner({
|
||||||
retry: opts.retry,
|
retry: opts.retry,
|
||||||
configRetry: cfg.telegram?.retry,
|
configRetry: account.config.retry,
|
||||||
verbose: opts.verbose,
|
verbose: opts.verbose,
|
||||||
});
|
});
|
||||||
const remove = opts.remove === true;
|
const remove = opts.remove === true;
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_ACCOUNT_ID,
|
||||||
|
normalizeAccountId,
|
||||||
|
} from "../routing/session-key.js";
|
||||||
|
|
||||||
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
||||||
|
|
||||||
@ -11,6 +15,7 @@ export type TelegramTokenResolution = {
|
|||||||
|
|
||||||
type ResolveTelegramTokenOpts = {
|
type ResolveTelegramTokenOpts = {
|
||||||
envToken?: string | null;
|
envToken?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
logMissingFile?: (message: string) => void;
|
logMissingFile?: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,13 +23,48 @@ export function resolveTelegramToken(
|
|||||||
cfg?: ClawdbotConfig,
|
cfg?: ClawdbotConfig,
|
||||||
opts: ResolveTelegramTokenOpts = {},
|
opts: ResolveTelegramTokenOpts = {},
|
||||||
): TelegramTokenResolution {
|
): 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) {
|
if (envToken) {
|
||||||
return { token: envToken, source: "env" };
|
return { token: envToken, source: "env" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenFile = cfg?.telegram?.tokenFile?.trim();
|
const tokenFile = cfg?.telegram?.tokenFile?.trim();
|
||||||
if (tokenFile) {
|
if (tokenFile && allowEnv) {
|
||||||
if (!fs.existsSync(tokenFile)) {
|
if (!fs.existsSync(tokenFile)) {
|
||||||
opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`);
|
opts.logMissingFile?.(`telegram.tokenFile not found: ${tokenFile}`);
|
||||||
return { token: "", source: "none" };
|
return { token: "", source: "none" };
|
||||||
@ -38,11 +78,10 @@ export function resolveTelegramToken(
|
|||||||
opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`);
|
opts.logMissingFile?.(`telegram.tokenFile read failed: ${String(err)}`);
|
||||||
return { token: "", source: "none" };
|
return { token: "", source: "none" };
|
||||||
}
|
}
|
||||||
return { token: "", source: "none" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const configToken = cfg?.telegram?.botToken?.trim();
|
const configToken = cfg?.telegram?.botToken?.trim();
|
||||||
if (configToken) {
|
if (configToken && allowEnv) {
|
||||||
return { token: configToken, source: "config" };
|
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("~");
|
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.
|
// Configuration root; can be overridden via CLAWDBOT_STATE_DIR.
|
||||||
export const CONFIG_DIR = resolveConfigDir();
|
export const CONFIG_DIR = resolveConfigDir();
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
|
|
||||||
export type ResolvedWhatsAppAccount = {
|
export type ResolvedWhatsAppAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
authDir: string;
|
authDir: string;
|
||||||
isLegacyAuthDir: boolean;
|
isLegacyAuthDir: boolean;
|
||||||
@ -101,6 +102,7 @@ export function resolveWhatsAppAccount(params: {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
name: accountCfg?.name?.trim() || undefined,
|
||||||
enabled,
|
enabled,
|
||||||
authDir,
|
authDir,
|
||||||
isLegacyAuthDir: isLegacy,
|
isLegacyAuthDir: isLegacy,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user