refactor!: rename chat providers to channels
This commit is contained in:
203
src/channels/plugins/onboarding/discord.ts
Normal file
203
src/channels/plugins/onboarding/discord.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "../../../discord/accounts.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "discord" as const;
|
||||
|
||||
function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.discord?.dm?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: {
|
||||
...cfg.channels?.discord,
|
||||
dm: {
|
||||
...cfg.channels?.discord?.dm,
|
||||
enabled: cfg.channels?.discord?.dm?.enabled ?? true,
|
||||
policy: dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Discord Developer Portal → Applications → New Application",
|
||||
"2) Bot → Add Bot → Reset Token → copy token",
|
||||
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
||||
"Tip: enable Message Content Intent if you need message text.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Discord",
|
||||
channel,
|
||||
policyKey: "channels.discord.dm.policy",
|
||||
allowFromKey: "channels.discord.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listDiscordAccountIds(cfg).some((accountId) =>
|
||||
Boolean(resolveDiscordAccount({ cfg, accountId }).token),
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Discord: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "configured" : "needs token",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
}) => {
|
||||
const discordOverride = accountOverrides.discord?.trim();
|
||||
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
|
||||
let discordAccountId = discordOverride
|
||||
? normalizeAccountId(discordOverride)
|
||||
: defaultDiscordAccountId;
|
||||
if (shouldPromptAccountIds && !discordOverride) {
|
||||
discordAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Discord",
|
||||
currentId: discordAccountId,
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
defaultAccountId: defaultDiscordAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveDiscordAccount({
|
||||
cfg: next,
|
||||
accountId: discordAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv && Boolean(process.env.DISCORD_BOT_TOKEN?.trim());
|
||||
const hasConfigToken = Boolean(resolvedAccount.config.token);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteDiscordTokenHelp(prompter);
|
||||
}
|
||||
if (canUseEnv && !resolvedAccount.config.token) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: { ...next.channels?.discord, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Discord bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Discord token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Discord bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Discord bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (discordAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: { ...next.channels?.discord, enabled: true, token },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: {
|
||||
...next.channels?.discord,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.discord?.accounts,
|
||||
[discordAccountId]: {
|
||||
...next.channels?.discord?.accounts?.[discordAccountId],
|
||||
enabled:
|
||||
next.channels?.discord?.accounts?.[discordAccountId]
|
||||
?.enabled ?? true,
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: discordAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: { ...cfg.channels?.discord, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
50
src/channels/plugins/onboarding/helpers.ts
Normal file
50
src/channels/plugins/onboarding/helpers.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import type {
|
||||
PromptAccountId,
|
||||
PromptAccountIdParams,
|
||||
} from "../onboarding-types.js";
|
||||
|
||||
export const promptAccountId: PromptAccountId = async (
|
||||
params: PromptAccountIdParams,
|
||||
) => {
|
||||
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;
|
||||
};
|
||||
|
||||
export function addWildcardAllowFrom(
|
||||
allowFrom?: Array<string | number> | null,
|
||||
): Array<string | number> {
|
||||
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
|
||||
if (!next.includes("*")) next.push("*");
|
||||
return next;
|
||||
}
|
||||
177
src/channels/plugins/onboarding/imessage.ts
Normal file
177
src/channels/plugins/onboarding/imessage.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { detectBinary } from "../../../commands/onboard-helpers.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
listIMessageAccountIds,
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
} from "../../../imessage/accounts.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "imessage" as const;
|
||||
|
||||
function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
imessage: {
|
||||
...cfg.channels?.imessage,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "iMessage",
|
||||
channel,
|
||||
policyKey: "channels.imessage.dmPolicy",
|
||||
allowFromKey: "channels.imessage.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listIMessageAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveIMessageAccount({ cfg, accountId });
|
||||
return Boolean(
|
||||
account.config.cliPath ||
|
||||
account.config.dbPath ||
|
||||
account.config.allowFrom ||
|
||||
account.config.service ||
|
||||
account.config.region,
|
||||
);
|
||||
});
|
||||
const imessageCliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
|
||||
const imessageCliDetected = await detectBinary(imessageCliPath);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`iMessage: ${configured ? "configured" : "needs setup"}`,
|
||||
`imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`,
|
||||
],
|
||||
selectionHint: imessageCliDetected ? "imsg found" : "imsg missing",
|
||||
quickstartScore: imessageCliDetected ? 1 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
}) => {
|
||||
const imessageOverride = accountOverrides.imessage?.trim();
|
||||
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
|
||||
let imessageAccountId = imessageOverride
|
||||
? normalizeAccountId(imessageOverride)
|
||||
: defaultIMessageAccountId;
|
||||
if (shouldPromptAccountIds && !imessageOverride) {
|
||||
imessageAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "iMessage",
|
||||
currentId: imessageAccountId,
|
||||
listAccountIds: listIMessageAccountIds,
|
||||
defaultAccountId: defaultIMessageAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveIMessageAccount({
|
||||
cfg: next,
|
||||
accountId: imessageAccountId,
|
||||
});
|
||||
let resolvedCliPath = resolvedAccount.config.cliPath ?? "imsg";
|
||||
const cliDetected = await detectBinary(resolvedCliPath);
|
||||
if (!cliDetected) {
|
||||
const entered = await prompter.text({
|
||||
message: "imsg CLI path",
|
||||
initialValue: resolvedCliPath,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
resolvedCliPath = String(entered).trim();
|
||||
if (!resolvedCliPath) {
|
||||
await prompter.note(
|
||||
"imsg CLI path required to enable iMessage.",
|
||||
"iMessage",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCliPath) {
|
||||
if (imessageAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
imessage: {
|
||||
...next.channels?.imessage,
|
||||
enabled: true,
|
||||
cliPath: resolvedCliPath,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
imessage: {
|
||||
...next.channels?.imessage,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.imessage?.accounts,
|
||||
[imessageAccountId]: {
|
||||
...next.channels?.imessage?.accounts?.[imessageAccountId],
|
||||
enabled:
|
||||
next.channels?.imessage?.accounts?.[imessageAccountId]
|
||||
?.enabled ?? true,
|
||||
cliPath: resolvedCliPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"This is still a work in progress.",
|
||||
"Ensure Clawdbot has Full Disk Access to Messages DB.",
|
||||
"Grant Automation permission for Messages when prompted.",
|
||||
"List chats with: imsg chats --limit 20",
|
||||
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
|
||||
].join("\n"),
|
||||
"iMessage next steps",
|
||||
);
|
||||
|
||||
return { cfg: next, accountId: imessageAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
imessage: { ...cfg.channels?.imessage, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
204
src/channels/plugins/onboarding/msteams.ts
Normal file
204
src/channels/plugins/onboarding/msteams.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import { resolveMSTeamsCredentials } from "../../../msteams/token.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom } from "./helpers.js";
|
||||
|
||||
const channel = "msteams" as const;
|
||||
|
||||
function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) =>
|
||||
String(entry),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteMSTeamsCredentialHelp(
|
||||
prompter: WizardPrompter,
|
||||
): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Azure Bot registration → get App ID + Tenant ID",
|
||||
"2) Add a client secret (App Password)",
|
||||
"3) Set webhook URL + messaging endpoint",
|
||||
"Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.",
|
||||
`Docs: ${formatDocsLink("/msteams", "msteams")}`,
|
||||
].join("\n"),
|
||||
"MS Teams credentials",
|
||||
);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "MS Teams",
|
||||
channel,
|
||||
policyKey: "channels.msteams.dmPolicy",
|
||||
allowFromKey: "channels.msteams.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = Boolean(
|
||||
resolveMSTeamsCredentials(cfg.channels?.msteams),
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`MS Teams: ${configured ? "configured" : "needs app credentials"}`,
|
||||
],
|
||||
selectionHint: configured ? "configured" : "needs app creds",
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);
|
||||
const hasConfigCreds = Boolean(
|
||||
cfg.channels?.msteams?.appId?.trim() &&
|
||||
cfg.channels?.msteams?.appPassword?.trim() &&
|
||||
cfg.channels?.msteams?.tenantId?.trim(),
|
||||
);
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds &&
|
||||
process.env.MSTEAMS_APP_ID?.trim() &&
|
||||
process.env.MSTEAMS_APP_PASSWORD?.trim() &&
|
||||
process.env.MSTEAMS_TENANT_ID?.trim(),
|
||||
);
|
||||
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appPassword: string | null = null;
|
||||
let tenantId: string | null = null;
|
||||
|
||||
if (!resolved) {
|
||||
await noteMSTeamsCredentialHelp(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message:
|
||||
"MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
msteams: { ...next.channels?.msteams, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "MS Teams credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appPassword = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams App Password",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
tenantId = String(
|
||||
await prompter.text({
|
||||
message: "Enter MS Teams Tenant ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (appId && appPassword && tenantId) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
msteams: {
|
||||
...next.channels?.msteams,
|
||||
enabled: true,
|
||||
appId,
|
||||
appPassword,
|
||||
tenantId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: { ...cfg.channels?.msteams, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
219
src/channels/plugins/onboarding/signal.ts
Normal file
219
src/channels/plugins/onboarding/signal.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { detectBinary } from "../../../commands/onboard-helpers.js";
|
||||
import { installSignalCli } from "../../../commands/signal-install.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import {
|
||||
listSignalAccountIds,
|
||||
resolveDefaultSignalAccountId,
|
||||
resolveSignalAccount,
|
||||
} from "../../../signal/accounts.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "signal" as const;
|
||||
|
||||
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: {
|
||||
...cfg.channels?.signal,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Signal",
|
||||
channel,
|
||||
policyKey: "channels.signal.dmPolicy",
|
||||
allowFromKey: "channels.signal.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listSignalAccountIds(cfg).some(
|
||||
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
|
||||
);
|
||||
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
|
||||
const signalCliDetected = await detectBinary(signalCliPath);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`Signal: ${configured ? "configured" : "needs setup"}`,
|
||||
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
|
||||
],
|
||||
selectionHint: signalCliDetected
|
||||
? "signal-cli found"
|
||||
: "signal-cli missing",
|
||||
quickstartScore: signalCliDetected ? 1 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
runtime,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
options,
|
||||
}) => {
|
||||
const signalOverride = accountOverrides.signal?.trim();
|
||||
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
|
||||
let signalAccountId = signalOverride
|
||||
? normalizeAccountId(signalOverride)
|
||||
: defaultSignalAccountId;
|
||||
if (shouldPromptAccountIds && !signalOverride) {
|
||||
signalAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Signal",
|
||||
currentId: signalAccountId,
|
||||
listAccountIds: listSignalAccountIds,
|
||||
defaultAccountId: defaultSignalAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveSignalAccount({
|
||||
cfg: next,
|
||||
accountId: signalAccountId,
|
||||
});
|
||||
const accountConfig = resolvedAccount.config;
|
||||
let resolvedCliPath = accountConfig.cliPath ?? "signal-cli";
|
||||
let cliDetected = await detectBinary(resolvedCliPath);
|
||||
if (options?.allowSignalInstall) {
|
||||
const wantsInstall = await prompter.confirm({
|
||||
message: cliDetected
|
||||
? "signal-cli detected. Reinstall/update now?"
|
||||
: "signal-cli not found. Install now?",
|
||||
initialValue: !cliDetected,
|
||||
});
|
||||
if (wantsInstall) {
|
||||
try {
|
||||
const result = await installSignalCli(runtime);
|
||||
if (result.ok && result.cliPath) {
|
||||
cliDetected = true;
|
||||
resolvedCliPath = result.cliPath;
|
||||
await prompter.note(
|
||||
`Installed signal-cli at ${result.cliPath}`,
|
||||
"Signal",
|
||||
);
|
||||
} else if (!result.ok) {
|
||||
await prompter.note(
|
||||
result.error ?? "signal-cli install failed.",
|
||||
"Signal",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
`signal-cli install failed: ${String(err)}`,
|
||||
"Signal",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cliDetected) {
|
||||
await prompter.note(
|
||||
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
|
||||
"Signal",
|
||||
);
|
||||
}
|
||||
|
||||
let account = accountConfig.account ?? "";
|
||||
if (account) {
|
||||
const keep = await prompter.confirm({
|
||||
message: `Signal account set (${account}). Keep it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) account = "";
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
account = String(
|
||||
await prompter.text({
|
||||
message: "Signal bot number (E.164)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (account) {
|
||||
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
signal: {
|
||||
...next.channels?.signal,
|
||||
enabled: true,
|
||||
account,
|
||||
cliPath: resolvedCliPath ?? "signal-cli",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
signal: {
|
||||
...next.channels?.signal,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.signal?.accounts,
|
||||
[signalAccountId]: {
|
||||
...next.channels?.signal?.accounts?.[signalAccountId],
|
||||
enabled:
|
||||
next.channels?.signal?.accounts?.[signalAccountId]
|
||||
?.enabled ?? true,
|
||||
account,
|
||||
cliPath: resolvedCliPath ?? "signal-cli",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
'Link device with: signal-cli link -n "Clawdbot"',
|
||||
"Scan QR in Signal → Linked Devices",
|
||||
"Then run: clawdbot gateway call channels.status --params '{\"probe\":true}'",
|
||||
`Docs: ${formatDocsLink("/signal", "signal")}`,
|
||||
].join("\n"),
|
||||
"Signal next steps",
|
||||
);
|
||||
|
||||
return { cfg: next, accountId: signalAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: { ...cfg.channels?.signal, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
322
src/channels/plugins/onboarding/slack.ts
Normal file
322
src/channels/plugins/onboarding/slack.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
} from "../../../slack/accounts.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "slack" as const;
|
||||
|
||||
function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.slack?.dm?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
slack: {
|
||||
...cfg.channels?.slack,
|
||||
dm: {
|
||||
...cfg.channels?.slack?.dm,
|
||||
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
|
||||
policy: dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSlackManifest(botName: string) {
|
||||
const safeName = botName.trim() || "Clawdbot";
|
||||
const manifest = {
|
||||
display_information: {
|
||||
name: safeName,
|
||||
description: `${safeName} connector for Clawdbot`,
|
||||
},
|
||||
features: {
|
||||
bot_user: {
|
||||
display_name: safeName,
|
||||
always_online: false,
|
||||
},
|
||||
app_home: {
|
||||
messages_tab_enabled: true,
|
||||
messages_tab_read_only_enabled: false,
|
||||
},
|
||||
slash_commands: [
|
||||
{
|
||||
command: "/clawd",
|
||||
description: "Send a message to Clawdbot",
|
||||
should_escape: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
oauth_config: {
|
||||
scopes: {
|
||||
bot: [
|
||||
"chat:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"mpim:history",
|
||||
"users:read",
|
||||
"app_mentions:read",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"pins:read",
|
||||
"pins:write",
|
||||
"emoji:read",
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write",
|
||||
],
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
socket_mode_enabled: true,
|
||||
event_subscriptions: {
|
||||
bot_events: [
|
||||
"app_mention",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
"message.mpim",
|
||||
"reaction_added",
|
||||
"reaction_removed",
|
||||
"member_joined_channel",
|
||||
"member_left_channel",
|
||||
"channel_rename",
|
||||
"pin_added",
|
||||
"pin_removed",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
async function noteSlackTokenHelp(
|
||||
prompter: WizardPrompter,
|
||||
botName: string,
|
||||
): Promise<void> {
|
||||
const manifest = buildSlackManifest(botName);
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Slack API → Create App → From scratch",
|
||||
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
|
||||
"3) OAuth & Permissions → install app to workspace (xoxb- bot token)",
|
||||
"4) Enable Event Subscriptions (socket) for message events",
|
||||
"5) App Home → enable the Messages tab for DMs",
|
||||
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
|
||||
`Docs: ${formatDocsLink("/slack", "slack")}`,
|
||||
"",
|
||||
"Manifest (JSON):",
|
||||
manifest,
|
||||
].join("\n"),
|
||||
"Slack socket mode tokens",
|
||||
);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Slack",
|
||||
channel,
|
||||
policyKey: "channels.slack.dm.policy",
|
||||
allowFromKey: "channels.slack.dm.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listSlackAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.appToken);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`],
|
||||
selectionHint: configured ? "configured" : "needs tokens",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
}) => {
|
||||
const slackOverride = accountOverrides.slack?.trim();
|
||||
const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
|
||||
let slackAccountId = slackOverride
|
||||
? normalizeAccountId(slackOverride)
|
||||
: defaultSlackAccountId;
|
||||
if (shouldPromptAccountIds && !slackOverride) {
|
||||
slackAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Slack",
|
||||
currentId: slackAccountId,
|
||||
listAccountIds: listSlackAccountIds,
|
||||
defaultAccountId: defaultSlackAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveSlackAccount({
|
||||
cfg: next,
|
||||
accountId: slackAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(
|
||||
resolvedAccount.botToken && resolvedAccount.appToken,
|
||||
);
|
||||
const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv &&
|
||||
Boolean(process.env.SLACK_BOT_TOKEN?.trim()) &&
|
||||
Boolean(process.env.SLACK_APP_TOKEN?.trim());
|
||||
const hasConfigTokens = Boolean(
|
||||
resolvedAccount.config.botToken && resolvedAccount.config.appToken,
|
||||
);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let appToken: string | null = null;
|
||||
const slackBotName = String(
|
||||
await prompter.text({
|
||||
message: "Slack bot display name (used for manifest)",
|
||||
initialValue: "Clawdbot",
|
||||
}),
|
||||
).trim();
|
||||
if (!accountConfigured) {
|
||||
await noteSlackTokenHelp(prompter, slackBotName);
|
||||
}
|
||||
if (
|
||||
canUseEnv &&
|
||||
(!resolvedAccount.config.botToken || !resolvedAccount.config.appToken)
|
||||
) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
slack: { ...next.channels?.slack, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack bot token (xoxb-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack app token (xapp-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigTokens) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Slack tokens already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack bot token (xoxb-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack app token (xapp-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack bot token (xoxb-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Slack app token (xapp-...)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (botToken && appToken) {
|
||||
if (slackAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
slack: {
|
||||
...next.channels?.slack,
|
||||
enabled: true,
|
||||
botToken,
|
||||
appToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
slack: {
|
||||
...next.channels?.slack,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.slack?.accounts,
|
||||
[slackAccountId]: {
|
||||
...next.channels?.slack?.accounts?.[slackAccountId],
|
||||
enabled:
|
||||
next.channels?.slack?.accounts?.[slackAccountId]?.enabled ??
|
||||
true,
|
||||
botToken,
|
||||
appToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: slackAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
slack: { ...cfg.channels?.slack, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
285
src/channels/plugins/onboarding/telegram.ts
Normal file
285
src/channels/plugins/onboarding/telegram.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "../../../telegram/accounts.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../onboarding-types.js";
|
||||
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "telegram" as const;
|
||||
|
||||
function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.telegram?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: {
|
||||
...cfg.channels?.telegram,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Open Telegram and chat with @BotFather",
|
||||
"2) Run /newbot (or /mybots)",
|
||||
"3) Copy the token (looks like 123456:ABC...)",
|
||||
"Tip: you can also set TELEGRAM_BOT_TOKEN in your env.",
|
||||
`Docs: ${formatDocsLink("/telegram")}`,
|
||||
"Website: https://clawd.bot",
|
||||
].join("\n"),
|
||||
"Telegram bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveTelegramAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Telegram allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0]
|
||||
? String(existingAllowFrom[0])
|
||||
: undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: {
|
||||
...cfg.channels?.telegram,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: {
|
||||
...cfg.channels?.telegram,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.telegram?.accounts,
|
||||
[accountId]: {
|
||||
...cfg.channels?.telegram?.accounts?.[accountId],
|
||||
enabled:
|
||||
cfg.channels?.telegram?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Telegram",
|
||||
channel,
|
||||
policyKey: "channels.telegram.dmPolicy",
|
||||
allowFromKey: "channels.telegram.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
|
||||
};
|
||||
|
||||
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listTelegramAccountIds(cfg).some((accountId) =>
|
||||
Boolean(resolveTelegramAccount({ cfg, accountId }).token),
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured
|
||||
? "recommended · configured"
|
||||
: "recommended · newcomer-friendly",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const telegramOverride = accountOverrides.telegram?.trim();
|
||||
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
|
||||
let telegramAccountId = telegramOverride
|
||||
? normalizeAccountId(telegramOverride)
|
||||
: defaultTelegramAccountId;
|
||||
if (shouldPromptAccountIds && !telegramOverride) {
|
||||
telegramAccountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Telegram",
|
||||
currentId: telegramAccountId,
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
defaultAccountId: defaultTelegramAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveTelegramAccount({
|
||||
cfg: next,
|
||||
accountId: telegramAccountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
|
||||
const hasConfigToken = Boolean(
|
||||
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteTelegramTokenHelp(prompter);
|
||||
}
|
||||
if (canUseEnv && !resolvedAccount.config.botToken) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Telegram bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Telegram token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Telegram bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Telegram bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (telegramAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.telegram?.accounts,
|
||||
[telegramAccountId]: {
|
||||
...next.channels?.telegram?.accounts?.[telegramAccountId],
|
||||
enabled:
|
||||
next.channels?.telegram?.accounts?.[telegramAccountId]
|
||||
?.enabled ?? true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptTelegramAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: telegramAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: telegramAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: { ...cfg.channels?.telegram, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
404
src/channels/plugins/onboarding/whatsapp.ts
Normal file
404
src/channels/plugins/onboarding/whatsapp.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { loginWeb } from "../../../channel-web.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
|
||||
import type { DmPolicy } from "../../../config/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import { formatDocsLink } from "../../../terminal/links.js";
|
||||
import { normalizeE164 } from "../../../utils.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAuthDir,
|
||||
} from "../../../web/accounts.js";
|
||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
||||
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
|
||||
import { promptAccountId } from "./helpers.js";
|
||||
|
||||
const channel = "whatsapp" as const;
|
||||
|
||||
function setWhatsAppDmPolicy(
|
||||
cfg: ClawdbotConfig,
|
||||
dmPolicy: DmPolicy,
|
||||
): ClawdbotConfig {
|
||||
return mergeWhatsAppConfig(cfg, { dmPolicy });
|
||||
}
|
||||
|
||||
function setWhatsAppAllowFrom(
|
||||
cfg: ClawdbotConfig,
|
||||
allowFrom?: string[],
|
||||
): ClawdbotConfig {
|
||||
return mergeWhatsAppConfig(
|
||||
cfg,
|
||||
{ allowFrom },
|
||||
{ unsetOnUndefined: ["allowFrom"] },
|
||||
);
|
||||
}
|
||||
|
||||
function setMessagesResponsePrefix(
|
||||
cfg: ClawdbotConfig,
|
||||
responsePrefix?: string,
|
||||
): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
messages: {
|
||||
...cfg.messages,
|
||||
responsePrefix,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setWhatsAppSelfChatMode(
|
||||
cfg: ClawdbotConfig,
|
||||
selfChatMode: boolean,
|
||||
): ClawdbotConfig {
|
||||
return mergeWhatsAppConfig(cfg, { selfChatMode });
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectWhatsAppLinked(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): Promise<boolean> {
|
||||
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
|
||||
const credsPath = path.join(authDir, "creds.json");
|
||||
return await pathExists(credsPath);
|
||||
}
|
||||
|
||||
async function promptWhatsAppAllowFrom(
|
||||
cfg: ClawdbotConfig,
|
||||
_runtime: RuntimeEnv,
|
||||
prompter: WizardPrompter,
|
||||
options?: { forceAllowlist?: boolean },
|
||||
): Promise<ClawdbotConfig> {
|
||||
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
|
||||
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
|
||||
const existingLabel =
|
||||
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
const existingResponsePrefix = cfg.messages?.responsePrefix;
|
||||
|
||||
if (options?.forceAllowlist) {
|
||||
await prompter.note(
|
||||
"We need the sender/owner number so Clawdbot can allowlist you.",
|
||||
"WhatsApp number",
|
||||
);
|
||||
const entry = await prompter.text({
|
||||
message:
|
||||
"Your personal WhatsApp number (the phone you will message from)",
|
||||
placeholder: "+15555550123",
|
||||
initialValue: existingAllowFrom[0],
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const normalized = normalizeE164(raw);
|
||||
if (!normalized) return `Invalid number: ${raw}`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = normalizeE164(String(entry).trim());
|
||||
const merged = [
|
||||
...existingAllowFrom
|
||||
.filter((item) => item !== "*")
|
||||
.map((item) => normalizeE164(item))
|
||||
.filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged.filter(Boolean))];
|
||||
let next = setWhatsAppSelfChatMode(cfg, true);
|
||||
next = setWhatsAppDmPolicy(next, "allowlist");
|
||||
next = setWhatsAppAllowFrom(next, unique);
|
||||
if (existingResponsePrefix === undefined) {
|
||||
next = setMessagesResponsePrefix(next, "[clawdbot]");
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
"Allowlist mode enabled.",
|
||||
`- allowFrom includes ${normalized}`,
|
||||
existingResponsePrefix === undefined
|
||||
? "- responsePrefix set to [clawdbot]"
|
||||
: "- responsePrefix left unchanged",
|
||||
].join("\n"),
|
||||
"WhatsApp allowlist",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"WhatsApp direct chats are gated by `channels.whatsapp.dmPolicy` + `channels.whatsapp.allowFrom`.",
|
||||
"- pairing (default): unknown senders get a pairing code; owner approves",
|
||||
"- allowlist: unknown senders are blocked",
|
||||
'- open: public inbound DMs (requires allowFrom to include "*")',
|
||||
"- disabled: ignore WhatsApp DMs",
|
||||
"",
|
||||
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
||||
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
|
||||
].join("\n"),
|
||||
"WhatsApp DM access",
|
||||
);
|
||||
|
||||
const phoneMode = (await prompter.select({
|
||||
message: "WhatsApp phone setup",
|
||||
options: [
|
||||
{ value: "personal", label: "This is my personal phone number" },
|
||||
{ value: "separate", label: "Separate phone just for Clawdbot" },
|
||||
],
|
||||
})) as "personal" | "separate";
|
||||
|
||||
if (phoneMode === "personal") {
|
||||
await prompter.note(
|
||||
"We need the sender/owner number so Clawdbot can allowlist you.",
|
||||
"WhatsApp number",
|
||||
);
|
||||
const entry = await prompter.text({
|
||||
message:
|
||||
"Your personal WhatsApp number (the phone you will message from)",
|
||||
placeholder: "+15555550123",
|
||||
initialValue: existingAllowFrom[0],
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const normalized = normalizeE164(raw);
|
||||
if (!normalized) return `Invalid number: ${raw}`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = normalizeE164(String(entry).trim());
|
||||
const merged = [
|
||||
...existingAllowFrom
|
||||
.filter((item) => item !== "*")
|
||||
.map((item) => normalizeE164(item))
|
||||
.filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged.filter(Boolean))];
|
||||
let next = setWhatsAppSelfChatMode(cfg, true);
|
||||
next = setWhatsAppDmPolicy(next, "allowlist");
|
||||
next = setWhatsAppAllowFrom(next, unique);
|
||||
if (existingResponsePrefix === undefined) {
|
||||
next = setMessagesResponsePrefix(next, "[clawdbot]");
|
||||
}
|
||||
await prompter.note(
|
||||
[
|
||||
"Personal phone mode enabled.",
|
||||
"- dmPolicy set to allowlist (pairing skipped)",
|
||||
`- allowFrom includes ${normalized}`,
|
||||
existingResponsePrefix === undefined
|
||||
? "- responsePrefix set to [clawdbot]"
|
||||
: "- responsePrefix left unchanged",
|
||||
].join("\n"),
|
||||
"WhatsApp personal phone",
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
const policy = (await prompter.select({
|
||||
message: "WhatsApp DM policy",
|
||||
options: [
|
||||
{ value: "pairing", label: "Pairing (recommended)" },
|
||||
{ value: "allowlist", label: "Allowlist only (block unknown senders)" },
|
||||
{ value: "open", label: "Open (public inbound DMs)" },
|
||||
{ value: "disabled", label: "Disabled (ignore WhatsApp DMs)" },
|
||||
],
|
||||
})) as DmPolicy;
|
||||
|
||||
let next = setWhatsAppSelfChatMode(cfg, false);
|
||||
next = setWhatsAppDmPolicy(next, policy);
|
||||
if (policy === "open") {
|
||||
next = setWhatsAppAllowFrom(next, ["*"]);
|
||||
}
|
||||
if (policy === "disabled") return next;
|
||||
|
||||
const allowOptions =
|
||||
existingAllowFrom.length > 0
|
||||
? ([
|
||||
{ value: "keep", label: "Keep current allowFrom" },
|
||||
{
|
||||
value: "unset",
|
||||
label: "Unset allowFrom (use pairing approvals only)",
|
||||
},
|
||||
{ value: "list", label: "Set allowFrom to specific numbers" },
|
||||
] as const)
|
||||
: ([
|
||||
{ value: "unset", label: "Unset allowFrom (default)" },
|
||||
{ value: "list", label: "Set allowFrom to specific numbers" },
|
||||
] as const);
|
||||
|
||||
const mode = (await prompter.select({
|
||||
message: "WhatsApp allowFrom (optional pre-allowlist)",
|
||||
options: allowOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
})) as (typeof allowOptions)[number]["value"];
|
||||
|
||||
if (mode === "keep") {
|
||||
// Keep allowFrom as-is.
|
||||
} else if (mode === "unset") {
|
||||
next = setWhatsAppAllowFrom(next, undefined);
|
||||
} else {
|
||||
const allowRaw = await prompter.text({
|
||||
message: "Allowed sender numbers (comma-separated, E.164)",
|
||||
placeholder: "+15555550123, +447700900123",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
const parts = raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return "Required";
|
||||
for (const part of parts) {
|
||||
if (part === "*") continue;
|
||||
const normalized = normalizeE164(part);
|
||||
if (!normalized) return `Invalid number: ${part}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const parts = String(allowRaw)
|
||||
.split(/[\n,;]+/g)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const normalized = parts.map((part) =>
|
||||
part === "*" ? "*" : normalizeE164(part),
|
||||
);
|
||||
const unique = [...new Set(normalized.filter(Boolean))];
|
||||
next = setWhatsAppAllowFrom(next, unique);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const overrideId = accountOverrides.whatsapp?.trim();
|
||||
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
|
||||
const accountId = overrideId
|
||||
? normalizeAccountId(overrideId)
|
||||
: defaultAccountId;
|
||||
const linked = await detectWhatsAppLinked(cfg, accountId);
|
||||
const accountLabel =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
|
||||
return {
|
||||
channel,
|
||||
configured: linked,
|
||||
statusLines: [
|
||||
`WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`,
|
||||
],
|
||||
selectionHint: linked ? "linked" : "not linked",
|
||||
quickstartScore: linked ? 5 : 4,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
runtime,
|
||||
prompter,
|
||||
options,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const overrideId = accountOverrides.whatsapp?.trim();
|
||||
let accountId = overrideId
|
||||
? normalizeAccountId(overrideId)
|
||||
: resolveDefaultWhatsAppAccountId(cfg);
|
||||
if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
|
||||
if (!overrideId) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "WhatsApp",
|
||||
currentId: accountId,
|
||||
listAccountIds: listWhatsAppAccountIds,
|
||||
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
whatsapp: {
|
||||
...next.channels?.whatsapp,
|
||||
accounts: {
|
||||
...next.channels?.whatsapp?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.whatsapp?.accounts?.[accountId],
|
||||
enabled:
|
||||
next.channels?.whatsapp?.accounts?.[accountId]?.enabled ??
|
||||
true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const linked = await detectWhatsAppLinked(next, accountId);
|
||||
const { authDir } = resolveWhatsAppAuthDir({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (!linked) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Scan the QR with WhatsApp on your phone.",
|
||||
`Credentials are stored under ${authDir}/ for future runs.`,
|
||||
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
|
||||
].join("\n"),
|
||||
"WhatsApp linking",
|
||||
);
|
||||
}
|
||||
const wantsLink = await prompter.confirm({
|
||||
message: linked
|
||||
? "WhatsApp already linked. Re-link now?"
|
||||
: "Link WhatsApp now (QR)?",
|
||||
initialValue: !linked,
|
||||
});
|
||||
if (wantsLink) {
|
||||
try {
|
||||
await loginWeb(false, undefined, runtime, accountId);
|
||||
} catch (err) {
|
||||
runtime.error(`WhatsApp login failed: ${String(err)}`);
|
||||
await prompter.note(
|
||||
`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`,
|
||||
"WhatsApp help",
|
||||
);
|
||||
}
|
||||
} else if (!linked) {
|
||||
await prompter.note(
|
||||
"Run `clawdbot channels login` later to link WhatsApp.",
|
||||
"WhatsApp",
|
||||
);
|
||||
}
|
||||
|
||||
next = await promptWhatsAppAllowFrom(next, runtime, prompter, {
|
||||
forceAllowlist: forceAllowFrom,
|
||||
});
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
onAccountRecorded: (accountId, options) => {
|
||||
options?.onWhatsAppAccountId?.(accountId);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user