mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-30 01:06:11 +00:00
refactor: move telegram onboarding to setup wizard
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
formatAllowFromLowercase,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
buildTokenChannelStatusSummary,
|
||||
clearAccountEntryFields,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
looksLikeTelegramTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
sendTelegramPayloadMessages,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@@ -45,6 +42,7 @@ import {
|
||||
resolveOutboundSendDep,
|
||||
} from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getTelegramRuntime } from "./runtime.js";
|
||||
import { telegramSetupAdapter, telegramSetupWizard } from "./setup-surface.js";
|
||||
|
||||
type TelegramSendFn = ReturnType<
|
||||
typeof getTelegramRuntime
|
||||
@@ -186,7 +184,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
onboarding: telegramOnboardingAdapter,
|
||||
setupWizard: telegramSetupWizard,
|
||||
pairing: {
|
||||
idLabel: "telegramUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""),
|
||||
@@ -297,81 +295,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params),
|
||||
},
|
||||
actions: telegramMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "telegram",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "TELEGRAM_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token && !input.tokenFile) {
|
||||
return "Telegram requires token or --token-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "telegram",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "telegram",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.telegram?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.telegram?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
setup: telegramSetupAdapter,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
|
||||
@@ -1,256 +1,6 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import {
|
||||
applySingleTokenPromptResult,
|
||||
patchChannelConfigForAccount,
|
||||
promptResolvedAllowFrom,
|
||||
promptSingleChannelSecretInput,
|
||||
resolveAccountIdForConfigure,
|
||||
resolveOnboardingAccountId,
|
||||
setChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
splitOnboardingEntries,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { inspectTelegramAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
import { fetchTelegramChatId } from "./api-fetch.js";
|
||||
|
||||
const channel = "telegram" as const;
|
||||
|
||||
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://openclaw.ai",
|
||||
].join("\n"),
|
||||
"Telegram bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
`1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`,
|
||||
"2) Or call https://api.telegram.org/bot<bot_token>/getUpdates and read message.from.id",
|
||||
"3) Third-party: DM @userinfobot or @getidsbot",
|
||||
`Docs: ${formatDocsLink("/telegram")}`,
|
||||
"Website: https://openclaw.ai",
|
||||
].join("\n"),
|
||||
"Telegram user id",
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTelegramAllowFromInput(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(telegram|tg):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseTelegramAllowFromId(raw: string): string | null {
|
||||
const stripped = normalizeTelegramAllowFromInput(raw);
|
||||
return /^\d+$/.test(stripped) ? stripped : null;
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
tokenOverride?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveTelegramAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
await noteTelegramUserIdHelp(prompter);
|
||||
|
||||
const token = params.tokenOverride?.trim() || resolved.token;
|
||||
if (!token) {
|
||||
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
|
||||
}
|
||||
const unique = await promptResolvedAllowFrom({
|
||||
prompter,
|
||||
existing: existingAllowFrom,
|
||||
token,
|
||||
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
|
||||
placeholder: "@username",
|
||||
label: "Telegram allowlist",
|
||||
parseInputs: splitOnboardingEntries,
|
||||
parseId: parseTelegramAllowFromId,
|
||||
invalidWithoutTokenNote:
|
||||
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
||||
resolveEntries: async ({ token: tokenValue, entries }) => {
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const numericId = parseTelegramAllowFromId(entry);
|
||||
if (numericId) {
|
||||
return { input: entry, resolved: true, id: numericId };
|
||||
}
|
||||
const stripped = normalizeTelegramAllowFromInput(entry);
|
||||
if (!stripped) {
|
||||
return { input: entry, resolved: false, id: null };
|
||||
}
|
||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||
const id = await fetchTelegramChatId({ token: tokenValue, chatId: username });
|
||||
return { input: entry, resolved: Boolean(id), id };
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
},
|
||||
});
|
||||
|
||||
return patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom: unique },
|
||||
});
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFromForAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
return promptTelegramAllowFrom({
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Telegram",
|
||||
channel,
|
||||
policyKey: "channels.telegram.dmPolicy",
|
||||
allowFromKey: "channels.telegram.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) =>
|
||||
setChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: promptTelegramAllowFromForAccount,
|
||||
};
|
||||
|
||||
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const account = inspectTelegramAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
options,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
|
||||
const telegramAccountId = await resolveAccountIdForConfigure({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Telegram",
|
||||
accountOverride: accountOverrides.telegram,
|
||||
shouldPromptAccountIds,
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
defaultAccountId: defaultTelegramAccountId,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveTelegramAccount({
|
||||
cfg: next,
|
||||
accountId: telegramAccountId,
|
||||
});
|
||||
const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken);
|
||||
const hasConfigToken =
|
||||
hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim());
|
||||
const accountConfigured = Boolean(resolvedAccount.token) || hasConfigToken;
|
||||
const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv && !hasConfigToken && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim());
|
||||
|
||||
if (!accountConfigured) {
|
||||
await noteTelegramTokenHelp(prompter);
|
||||
}
|
||||
|
||||
const tokenResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "telegram",
|
||||
credentialLabel: "Telegram bot token",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured,
|
||||
canUseEnv,
|
||||
hasConfigToken,
|
||||
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Telegram token already configured. Keep it?",
|
||||
inputPrompt: "Enter Telegram bot token",
|
||||
preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined,
|
||||
});
|
||||
|
||||
let resolvedTokenForAllowFrom: string | undefined;
|
||||
if (tokenResult.action === "use-env") {
|
||||
next = applySingleTokenPromptResult({
|
||||
cfg: next,
|
||||
channel: "telegram",
|
||||
accountId: telegramAccountId,
|
||||
tokenPatchKey: "botToken",
|
||||
tokenResult: { useEnv: true, token: null },
|
||||
});
|
||||
resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined;
|
||||
} else if (tokenResult.action === "set") {
|
||||
next = applySingleTokenPromptResult({
|
||||
cfg: next,
|
||||
channel: "telegram",
|
||||
accountId: telegramAccountId,
|
||||
tokenPatchKey: "botToken",
|
||||
tokenResult: { useEnv: false, token: tokenResult.value },
|
||||
});
|
||||
resolvedTokenForAllowFrom = tokenResult.resolvedValue;
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptTelegramAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: telegramAccountId,
|
||||
tokenOverride: resolvedTokenForAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: telegramAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
export {
|
||||
normalizeTelegramAllowFromInput,
|
||||
parseTelegramAllowFromId,
|
||||
telegramOnboardingAdapter,
|
||||
telegramSetupWizard,
|
||||
} from "./setup-surface.js";
|
||||
|
||||
312
extensions/telegram/src/setup-surface.ts
Normal file
312
extensions/telegram/src/setup-surface.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import {
|
||||
patchChannelConfigForAccount,
|
||||
promptResolvedAllowFrom,
|
||||
resolveOnboardingAccountId,
|
||||
setChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
splitOnboardingEntries,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import {
|
||||
buildChannelOnboardingAdapterFromSetupWizard,
|
||||
type ChannelSetupWizard,
|
||||
} from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
|
||||
import { getChatChannelMeta } from "../../../src/channels/registry.js";
|
||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import { inspectTelegramAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
} from "./accounts.js";
|
||||
import { fetchTelegramChatId } from "./api-fetch.js";
|
||||
|
||||
const channel = "telegram" as const;
|
||||
|
||||
const TELEGRAM_TOKEN_HELP_LINES = [
|
||||
"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://openclaw.ai",
|
||||
];
|
||||
|
||||
const TELEGRAM_USER_ID_HELP_LINES = [
|
||||
`1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`,
|
||||
"2) Or call https://api.telegram.org/bot<bot_token>/getUpdates and read message.from.id",
|
||||
"3) Third-party: DM @userinfobot or @getidsbot",
|
||||
`Docs: ${formatDocsLink("/telegram")}`,
|
||||
"Website: https://openclaw.ai",
|
||||
];
|
||||
|
||||
export function normalizeTelegramAllowFromInput(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(telegram|tg):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseTelegramAllowFromId(raw: string): string | null {
|
||||
const stripped = normalizeTelegramAllowFromInput(raw);
|
||||
return /^\d+$/.test(stripped) ? stripped : null;
|
||||
}
|
||||
|
||||
async function resolveTelegramAllowFromEntries(params: {
|
||||
entries: string[];
|
||||
credentialValue?: string;
|
||||
}) {
|
||||
return await Promise.all(
|
||||
params.entries.map(async (entry) => {
|
||||
const numericId = parseTelegramAllowFromId(entry);
|
||||
if (numericId) {
|
||||
return { input: entry, resolved: true, id: numericId };
|
||||
}
|
||||
const stripped = normalizeTelegramAllowFromInput(entry);
|
||||
if (!stripped || !params.credentialValue?.trim()) {
|
||||
return { input: entry, resolved: false, id: null };
|
||||
}
|
||||
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
|
||||
const id = await fetchTelegramChatId({
|
||||
token: params.credentialValue,
|
||||
chatId: username,
|
||||
});
|
||||
return { input: entry, resolved: Boolean(id), id };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function promptTelegramAllowFromForAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||
await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id");
|
||||
if (!resolved.token?.trim()) {
|
||||
await params.prompter.note(
|
||||
"Telegram token missing; username lookup is unavailable.",
|
||||
"Telegram",
|
||||
);
|
||||
}
|
||||
const unique = await promptResolvedAllowFrom({
|
||||
prompter: params.prompter,
|
||||
existing: resolved.config.allowFrom ?? [],
|
||||
token: resolved.token,
|
||||
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
|
||||
placeholder: "@username",
|
||||
label: "Telegram allowlist",
|
||||
parseInputs: splitOnboardingEntries,
|
||||
parseId: parseTelegramAllowFromId,
|
||||
invalidWithoutTokenNote:
|
||||
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
||||
resolveEntries: async ({ entries, token }) =>
|
||||
resolveTelegramAllowFromEntries({
|
||||
credentialValue: token,
|
||||
entries,
|
||||
}),
|
||||
});
|
||||
return patchChannelConfigForAccount({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { 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) =>
|
||||
setChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel,
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: promptTelegramAllowFromForAccount,
|
||||
};
|
||||
|
||||
export const telegramSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "TELEGRAM_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token && !input.tokenFile) {
|
||||
return "Telegram requires token or --token-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: channel,
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
telegram: {
|
||||
...next.channels?.telegram,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.telegram?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.telegram?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const telegramSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs token",
|
||||
configuredHint: "recommended · configured",
|
||||
unconfiguredHint: "recommended · newcomer-friendly",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 10,
|
||||
resolveConfigured: ({ cfg }) =>
|
||||
listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const account = inspectTelegramAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
}),
|
||||
},
|
||||
credential: {
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: "Telegram bot token",
|
||||
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
|
||||
helpTitle: "Telegram bot token",
|
||||
helpLines: TELEGRAM_TOKEN_HELP_LINES,
|
||||
envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Telegram token already configured. Keep it?",
|
||||
inputPrompt: "Enter Telegram bot token",
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolved = resolveTelegramAccount({ cfg, accountId });
|
||||
const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken);
|
||||
const hasConfiguredValue =
|
||||
hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim());
|
||||
return {
|
||||
accountConfigured: Boolean(resolved.token) || hasConfiguredValue,
|
||||
hasConfiguredValue,
|
||||
resolvedValue: resolved.token?.trim() || undefined,
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
allowFrom: {
|
||||
helpTitle: "Telegram user id",
|
||||
helpLines: TELEGRAM_USER_ID_HELP_LINES,
|
||||
message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
|
||||
placeholder: "@username",
|
||||
invalidWithoutCredentialNote:
|
||||
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
|
||||
parseInputs: splitOnboardingEntries,
|
||||
parseId: parseTelegramAllowFromId,
|
||||
resolveEntries: async ({ credentialValue, entries }) =>
|
||||
resolveTelegramAllowFromEntries({
|
||||
credentialValue,
|
||||
entries,
|
||||
}),
|
||||
apply: async ({ cfg, accountId, allowFrom }) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom },
|
||||
}),
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
|
||||
const telegramSetupPlugin = {
|
||||
id: channel,
|
||||
meta: {
|
||||
...getChatChannelMeta(channel),
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveTelegramAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
|
||||
resolveTelegramAccount({ cfg, accountId }).config.allowFrom,
|
||||
},
|
||||
setup: telegramSetupAdapter,
|
||||
} as const;
|
||||
|
||||
export const telegramOnboardingAdapter: ChannelOnboardingAdapter =
|
||||
buildChannelOnboardingAdapterFromSetupWizard({
|
||||
plugin: telegramSetupPlugin,
|
||||
wizard: telegramSetupWizard,
|
||||
});
|
||||
281
src/channels/plugins/setup-wizard.ts
Normal file
281
src/channels/plugins/setup-wizard.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveChannelDefaultAccountId } from "./helpers.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ChannelOnboardingStatus,
|
||||
ChannelOnboardingStatusContext,
|
||||
} from "./onboarding-types.js";
|
||||
import {
|
||||
promptResolvedAllowFrom,
|
||||
resolveAccountIdForConfigure,
|
||||
runSingleChannelSecretStep,
|
||||
splitOnboardingEntries,
|
||||
} from "./onboarding/helpers.js";
|
||||
import type { ChannelSetupInput } from "./types.core.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
|
||||
export type ChannelSetupWizardStatus = {
|
||||
configuredLabel: string;
|
||||
unconfiguredLabel: string;
|
||||
configuredHint?: string;
|
||||
unconfiguredHint?: string;
|
||||
configuredScore?: number;
|
||||
unconfiguredScore?: number;
|
||||
resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
export type ChannelSetupWizardCredentialState = {
|
||||
accountConfigured: boolean;
|
||||
hasConfiguredValue: boolean;
|
||||
resolvedValue?: string;
|
||||
envValue?: string;
|
||||
};
|
||||
|
||||
export type ChannelSetupWizardCredential = {
|
||||
inputKey: keyof ChannelSetupInput;
|
||||
providerHint: string;
|
||||
credentialLabel: string;
|
||||
preferredEnvVar?: string;
|
||||
helpTitle?: string;
|
||||
helpLines?: string[];
|
||||
envPrompt: string;
|
||||
keepPrompt: string;
|
||||
inputPrompt: string;
|
||||
allowEnv?: (params: { cfg: OpenClawConfig; accountId: string }) => boolean;
|
||||
inspect: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}) => ChannelSetupWizardCredentialState;
|
||||
};
|
||||
|
||||
export type ChannelSetupWizardAllowFromEntry = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
export type ChannelSetupWizardAllowFrom = {
|
||||
helpTitle?: string;
|
||||
helpLines?: string[];
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
invalidWithoutCredentialNote?: string;
|
||||
parseInputs?: (raw: string) => string[];
|
||||
parseId: (raw: string) => string | null;
|
||||
resolveEntries?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
credentialValue?: string;
|
||||
entries: string[];
|
||||
}) => Promise<ChannelSetupWizardAllowFromEntry[]>;
|
||||
apply: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
allowFrom: string[];
|
||||
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
||||
};
|
||||
|
||||
export type ChannelSetupWizard = {
|
||||
channel: string;
|
||||
status: ChannelSetupWizardStatus;
|
||||
credential: ChannelSetupWizardCredential;
|
||||
dmPolicy?: ChannelOnboardingDmPolicy;
|
||||
allowFrom?: ChannelSetupWizardAllowFrom;
|
||||
disable?: (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"];
|
||||
};
|
||||
|
||||
type ChannelSetupWizardPlugin = Pick<ChannelPlugin, "id" | "meta" | "config" | "setup">;
|
||||
|
||||
async function buildStatus(
|
||||
plugin: ChannelSetupWizardPlugin,
|
||||
wizard: ChannelSetupWizard,
|
||||
ctx: ChannelOnboardingStatusContext,
|
||||
): Promise<ChannelOnboardingStatus> {
|
||||
const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg });
|
||||
return {
|
||||
channel: plugin.id,
|
||||
configured,
|
||||
statusLines: [
|
||||
`${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`,
|
||||
],
|
||||
selectionHint: configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint,
|
||||
quickstartScore: configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore,
|
||||
};
|
||||
}
|
||||
|
||||
function applySetupInput(params: {
|
||||
plugin: ChannelSetupWizardPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
}) {
|
||||
const setup = params.plugin.setup;
|
||||
if (!setup?.applyAccountConfig) {
|
||||
throw new Error(`${params.plugin.id} does not support setup`);
|
||||
}
|
||||
const resolvedAccountId =
|
||||
setup.resolveAccountId?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
input: params.input,
|
||||
}) ?? params.accountId;
|
||||
const validationError = setup.validateInput?.({
|
||||
cfg: params.cfg,
|
||||
accountId: resolvedAccountId,
|
||||
input: params.input,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
let next = setup.applyAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: resolvedAccountId,
|
||||
input: params.input,
|
||||
});
|
||||
if (params.input.name?.trim() && setup.applyAccountName) {
|
||||
next = setup.applyAccountName({
|
||||
cfg: next,
|
||||
accountId: resolvedAccountId,
|
||||
name: params.input.name,
|
||||
});
|
||||
}
|
||||
return {
|
||||
cfg: next,
|
||||
accountId: resolvedAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelOnboardingAdapterFromSetupWizard(params: {
|
||||
plugin: ChannelSetupWizardPlugin;
|
||||
wizard: ChannelSetupWizard;
|
||||
}): ChannelOnboardingAdapter {
|
||||
const { plugin, wizard } = params;
|
||||
return {
|
||||
channel: plugin.id,
|
||||
getStatus: async (ctx) => buildStatus(plugin, wizard, ctx),
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
options,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
const accountId = await resolveAccountIdForConfigure({
|
||||
cfg,
|
||||
prompter,
|
||||
label: plugin.meta.label,
|
||||
accountOverride: accountOverrides[plugin.id],
|
||||
shouldPromptAccountIds,
|
||||
listAccountIds: plugin.config.listAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
let credentialState = wizard.credential.inspect({ cfg: next, accountId });
|
||||
let resolvedCredentialValue = credentialState.resolvedValue?.trim() || undefined;
|
||||
const allowEnv = wizard.credential.allowEnv?.({ cfg: next, accountId }) ?? false;
|
||||
|
||||
const credentialResult = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: wizard.credential.providerHint,
|
||||
credentialLabel: wizard.credential.credentialLabel,
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured: credentialState.accountConfigured,
|
||||
hasConfigToken: credentialState.hasConfiguredValue,
|
||||
allowEnv,
|
||||
envValue: credentialState.envValue,
|
||||
envPrompt: wizard.credential.envPrompt,
|
||||
keepPrompt: wizard.credential.keepPrompt,
|
||||
inputPrompt: wizard.credential.inputPrompt,
|
||||
preferredEnvVar: wizard.credential.preferredEnvVar,
|
||||
onMissingConfigured:
|
||||
wizard.credential.helpLines && wizard.credential.helpLines.length > 0
|
||||
? async () => {
|
||||
await prompter.note(
|
||||
wizard.credential.helpLines!.join("\n"),
|
||||
wizard.credential.helpTitle ?? wizard.credential.credentialLabel,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
applyUseEnv: async (currentCfg) =>
|
||||
applySetupInput({
|
||||
plugin,
|
||||
cfg: currentCfg,
|
||||
accountId,
|
||||
input: {
|
||||
[wizard.credential.inputKey]: undefined,
|
||||
useEnv: true,
|
||||
},
|
||||
}).cfg,
|
||||
applySet: async (currentCfg, value, resolvedValue) => {
|
||||
resolvedCredentialValue = resolvedValue;
|
||||
return applySetupInput({
|
||||
plugin,
|
||||
cfg: currentCfg,
|
||||
accountId,
|
||||
input: {
|
||||
[wizard.credential.inputKey]: value,
|
||||
useEnv: false,
|
||||
},
|
||||
}).cfg;
|
||||
},
|
||||
});
|
||||
|
||||
next = credentialResult.cfg;
|
||||
credentialState = wizard.credential.inspect({ cfg: next, accountId });
|
||||
resolvedCredentialValue =
|
||||
credentialResult.resolvedValue?.trim() ||
|
||||
credentialState.resolvedValue?.trim() ||
|
||||
undefined;
|
||||
|
||||
if (forceAllowFrom && wizard.allowFrom) {
|
||||
if (wizard.allowFrom.helpLines && wizard.allowFrom.helpLines.length > 0) {
|
||||
await prompter.note(
|
||||
wizard.allowFrom.helpLines.join("\n"),
|
||||
wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`,
|
||||
);
|
||||
}
|
||||
const existingAllowFrom =
|
||||
plugin.config.resolveAllowFrom?.({
|
||||
cfg: next,
|
||||
accountId,
|
||||
}) ?? [];
|
||||
const unique = await promptResolvedAllowFrom({
|
||||
prompter,
|
||||
existing: existingAllowFrom,
|
||||
token: resolvedCredentialValue,
|
||||
message: wizard.allowFrom.message,
|
||||
placeholder: wizard.allowFrom.placeholder,
|
||||
label: wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`,
|
||||
parseInputs: wizard.allowFrom.parseInputs ?? splitOnboardingEntries,
|
||||
parseId: wizard.allowFrom.parseId,
|
||||
invalidWithoutTokenNote: wizard.allowFrom.invalidWithoutCredentialNote,
|
||||
resolveEntries: wizard.allowFrom.resolveEntries
|
||||
? async ({ entries }) =>
|
||||
wizard.allowFrom!.resolveEntries!({
|
||||
cfg: next,
|
||||
accountId,
|
||||
credentialValue: resolvedCredentialValue,
|
||||
entries,
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
next = await wizard.allowFrom.apply({
|
||||
cfg: next,
|
||||
accountId,
|
||||
allowFrom: unique,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
dmPolicy: wizard.dmPolicy,
|
||||
disable: wizard.disable,
|
||||
onAccountRecorded: wizard.onAccountRecorded,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChannelOnboardingAdapter } from "./onboarding-types.js";
|
||||
import type { ChannelSetupWizard } from "./setup-wizard.js";
|
||||
import type {
|
||||
ChannelAuthAdapter,
|
||||
ChannelCommandAdapter,
|
||||
@@ -58,6 +59,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
||||
reload?: { configPrefixes: string[]; noopPrefixes?: string[] };
|
||||
// CLI onboarding wizard hooks for this channel.
|
||||
onboarding?: ChannelOnboardingAdapter;
|
||||
setupWizard?: ChannelSetupWizard;
|
||||
config: ChannelConfigAdapter<ResolvedAccount>;
|
||||
configSchema?: ChannelConfigSchema;
|
||||
setup?: ChannelSetupAdapter;
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js";
|
||||
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import type { ChannelOnboardingAdapter } from "./types.js";
|
||||
|
||||
const setupWizardAdapters = new WeakMap<object, ChannelOnboardingAdapter>();
|
||||
|
||||
function resolveChannelOnboardingAdapter(
|
||||
plugin: (typeof listChannelSetupPlugins)[number],
|
||||
): ChannelOnboardingAdapter | undefined {
|
||||
if (plugin.setupWizard) {
|
||||
const cached = setupWizardAdapters.get(plugin);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const adapter = buildChannelOnboardingAdapterFromSetupWizard({
|
||||
plugin,
|
||||
wizard: plugin.setupWizard,
|
||||
});
|
||||
setupWizardAdapters.set(plugin, adapter);
|
||||
return adapter;
|
||||
}
|
||||
if (plugin.onboarding) {
|
||||
return plugin.onboarding;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user