feat: add plugin HTTP hooks + Zalo plugin
This commit is contained in:
15
extensions/zalo/src/shared/account-ids.ts
Normal file
15
extensions/zalo/src/shared/account-ids.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export function normalizeAccountId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || DEFAULT_ACCOUNT_ID
|
||||
);
|
||||
}
|
||||
112
extensions/zalo/src/shared/channel-config.ts
Normal file
112
extensions/zalo/src/shared/channel-config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "./account-ids.js";
|
||||
|
||||
type ChannelSection = {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type ConfigWithChannels = {
|
||||
channels?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function setAccountEnabledInConfigSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
allowTopLevel?: boolean;
|
||||
}): T {
|
||||
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
|
||||
const channels = params.cfg.channels;
|
||||
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
|
||||
const hasAccounts = Boolean(base?.accounts);
|
||||
if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
enabled: params.enabled,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
const baseAccounts = (base?.accounts ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const existing = baseAccounts[accountKey] ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
accounts: {
|
||||
...baseAccounts,
|
||||
[accountKey]: {
|
||||
...existing,
|
||||
enabled: params.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function deleteAccountFromConfigSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
clearBaseFields?: string[];
|
||||
}): T {
|
||||
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
|
||||
if (!base) return params.cfg;
|
||||
|
||||
const baseAccounts =
|
||||
base.accounts && typeof base.accounts === "object" ? { ...base.accounts } : undefined;
|
||||
|
||||
if (accountKey !== DEFAULT_ACCOUNT_ID) {
|
||||
const accounts = baseAccounts ? { ...baseAccounts } : {};
|
||||
delete accounts[accountKey];
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...base,
|
||||
accounts: Object.keys(accounts).length ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
if (baseAccounts && Object.keys(baseAccounts).length > 0) {
|
||||
delete baseAccounts[accountKey];
|
||||
const baseRecord = { ...(base as Record<string, unknown>) };
|
||||
for (const field of params.clearBaseFields ?? []) {
|
||||
if (field in baseRecord) baseRecord[field] = undefined;
|
||||
}
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.sectionKey]: {
|
||||
...baseRecord,
|
||||
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
const nextChannels = { ...channels } as Record<string, unknown>;
|
||||
delete nextChannels[params.sectionKey];
|
||||
const nextCfg = { ...params.cfg } as T;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
nextCfg.channels = nextChannels as T["channels"];
|
||||
} else {
|
||||
delete nextCfg.channels;
|
||||
}
|
||||
return nextCfg;
|
||||
}
|
||||
114
extensions/zalo/src/shared/channel-setup.ts
Normal file
114
extensions/zalo/src/shared/channel-setup.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
|
||||
|
||||
type ConfigWithChannels = {
|
||||
channels?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ChannelSectionBase = {
|
||||
name?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function channelHasAccounts(cfg: ConfigWithChannels, channelKey: string): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
|
||||
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
|
||||
}
|
||||
|
||||
function shouldStoreNameInAccounts(params: {
|
||||
cfg: ConfigWithChannels;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): boolean {
|
||||
if (params.alwaysUseAccounts) return true;
|
||||
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
|
||||
return channelHasAccounts(params.cfg, params.channelKey);
|
||||
}
|
||||
|
||||
export function applyAccountNameToChannelSection<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): T {
|
||||
const trimmed = params.name?.trim();
|
||||
if (!trimmed) return params.cfg;
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const baseConfig = channels?.[params.channelKey];
|
||||
const base =
|
||||
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined;
|
||||
const useAccounts = shouldStoreNameInAccounts({
|
||||
cfg: params.cfg,
|
||||
channelKey: params.channelKey,
|
||||
accountId,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
});
|
||||
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const safeBase = base ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...safeBase,
|
||||
name: trimmed,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
|
||||
const existingAccount = baseAccounts[accountId] ?? {};
|
||||
const baseWithoutName =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
|
||||
: (base ?? {});
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...baseWithoutName,
|
||||
accounts: {
|
||||
...baseAccounts,
|
||||
[accountId]: {
|
||||
...existingAccount,
|
||||
name: trimmed,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function migrateBaseNameToDefaultAccount<T extends ConfigWithChannels>(params: {
|
||||
cfg: T;
|
||||
channelKey: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
}): T {
|
||||
if (params.alwaysUseAccounts) return params.cfg;
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
|
||||
const baseName = base?.name?.trim();
|
||||
if (!baseName) return params.cfg;
|
||||
const accounts: Record<string, Record<string, unknown>> = {
|
||||
...base?.accounts,
|
||||
};
|
||||
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
|
||||
if (!defaultAccount.name) {
|
||||
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
|
||||
}
|
||||
const { name: _ignored, ...rest } = base ?? {};
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
...channels,
|
||||
[params.channelKey]: {
|
||||
...rest,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
53
extensions/zalo/src/shared/onboarding.ts
Normal file
53
extensions/zalo/src/shared/onboarding.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
|
||||
|
||||
export type PromptAccountIdParams<TConfig> = {
|
||||
cfg: TConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId?: string;
|
||||
listAccountIds: (cfg: TConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
};
|
||||
|
||||
export async function promptAccountId<TConfig>(
|
||||
params: PromptAccountIdParams<TConfig>,
|
||||
): Promise<string> {
|
||||
const existingIds = params.listAccountIds(params.cfg);
|
||||
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||
const choice = (await params.prompter.select({
|
||||
message: `${params.label} account`,
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
initialValue: initial,
|
||||
})) as string;
|
||||
|
||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||
|
||||
const entered = await params.prompter.text({
|
||||
message: `New ${params.label} account id`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await params.prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
`${params.label} account`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
6
extensions/zalo/src/shared/pairing.ts
Normal file
6
extensions/zalo/src/shared/pairing.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const PAIRING_APPROVED_MESSAGE =
|
||||
"\u2705 Clawdbot access approved. Send a message to start chatting.";
|
||||
|
||||
export function formatPairingApproveHint(channelId: string): string {
|
||||
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
|
||||
}
|
||||
Reference in New Issue
Block a user