Files
clawdbot/ui/src/ui/controllers/config.ts
2026-01-15 04:06:11 +00:00

498 lines
17 KiB
TypeScript

import type { GatewayBrowserClient } from "../gateway";
import type {
ConfigSchemaResponse,
ConfigSnapshot,
ConfigUiHints,
} from "../types";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SlackChannelForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import {
cloneConfigObject,
removePathValue,
serializeConfigForm,
setPathValue,
} from "./config/form-utils";
export type ConfigState = {
client: GatewayBrowserClient | null;
connected: boolean;
applySessionKey: string;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
configApplying: boolean;
updateRunning: boolean;
configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaVersion: string | null;
configSchemaLoading: boolean;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
lastError: string | null;
telegramForm: TelegramForm;
discordForm: DiscordForm;
slackForm: SlackForm;
signalForm: SignalForm;
imessageForm: IMessageForm;
telegramConfigStatus: string | null;
discordConfigStatus: string | null;
slackConfigStatus: string | null;
signalConfigStatus: string | null;
imessageConfigStatus: string | null;
};
export async function loadConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configLoading = true;
state.lastError = null;
try {
const res = (await state.client.request("config.get", {})) as ConfigSnapshot;
applyConfigSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.configLoading = false;
}
}
export async function loadConfigSchema(state: ConfigState) {
if (!state.client || !state.connected) return;
if (state.configSchemaLoading) return;
state.configSchemaLoading = true;
try {
const res = (await state.client.request(
"config.schema",
{},
)) as ConfigSchemaResponse;
applyConfigSchema(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.configSchemaLoading = false;
}
}
export function applyConfigSchema(
state: ConfigState,
res: ConfigSchemaResponse,
) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
}
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
state.configSnapshot = snapshot;
const rawFromSnapshot =
typeof snapshot.raw === "string"
? snapshot.raw
: snapshot.config && typeof snapshot.config === "object"
? serializeConfigForm(snapshot.config as Record<string, unknown>)
: state.configRaw;
if (!state.configFormDirty || state.configFormMode === "raw") {
state.configRaw = rawFromSnapshot;
} else if (state.configForm) {
state.configRaw = serializeConfigForm(state.configForm);
} else {
state.configRaw = rawFromSnapshot;
}
state.configValid = typeof snapshot.valid === "boolean" ? snapshot.valid : null;
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
const config = snapshot.config ?? {};
const channels = (config.channels ?? {}) as Record<string, unknown>;
const telegram = (channels.telegram ?? config.telegram ?? {}) as Record<string, unknown>;
const discord = (channels.discord ?? config.discord ?? {}) as Record<string, unknown>;
const slack = (channels.slack ?? config.slack ?? {}) as Record<string, unknown>;
const signal = (channels.signal ?? config.signal ?? {}) as Record<string, unknown>;
const imessage = (channels.imessage ?? config.imessage ?? {}) as Record<string, unknown>;
const toList = (value: unknown) =>
Array.isArray(value)
? value
.map((v) => String(v ?? "").trim())
.filter((v) => v.length > 0)
.join(", ")
: "";
const telegramGroups =
telegram.groups && typeof telegram.groups === "object"
? (telegram.groups as Record<string, unknown>)
: {};
const telegramDefaultGroup =
telegramGroups["*"] && typeof telegramGroups["*"] === "object"
? (telegramGroups["*"] as Record<string, unknown>)
: {};
const telegramHasWildcard = Boolean(telegramGroups["*"]);
const allowFrom = Array.isArray(telegram.allowFrom)
? toList(telegram.allowFrom)
: typeof telegram.allowFrom === "string"
? telegram.allowFrom
: "";
state.telegramForm = {
token: typeof telegram.botToken === "string" ? telegram.botToken : "",
requireMention:
typeof telegramDefaultGroup.requireMention === "boolean"
? telegramDefaultGroup.requireMention
: true,
groupsWildcardEnabled: telegramHasWildcard,
allowFrom,
proxy: typeof telegram.proxy === "string" ? telegram.proxy : "",
webhookUrl: typeof telegram.webhookUrl === "string" ? telegram.webhookUrl : "",
webhookSecret:
typeof telegram.webhookSecret === "string" ? telegram.webhookSecret : "",
webhookPath: typeof telegram.webhookPath === "string" ? telegram.webhookPath : "",
};
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
const discordGuilds = discord.guilds;
const readAction = (key: keyof DiscordActionForm) =>
typeof discordActions[key] === "boolean"
? (discordActions[key] as boolean)
: defaultDiscordActions[key];
state.discordForm = {
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
token: typeof discord.token === "string" ? discord.token : "",
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
allowFrom: toList(discordDm.allowFrom),
groupEnabled:
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
groupChannels: toList(discordDm.groupChannels),
mediaMaxMb:
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
historyLimit:
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
textChunkLimit:
typeof discord.textChunkLimit === "number"
? String(discord.textChunkLimit)
: "",
replyToMode:
discord.replyToMode === "first" || discord.replyToMode === "all"
? discord.replyToMode
: "off",
guilds: Array.isArray(discordGuilds)
? []
: typeof discordGuilds === "object" && discordGuilds
? Object.entries(discordGuilds as Record<string, unknown>).map(
([key, value]): DiscordGuildForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const channelsRaw =
entry.channels && typeof entry.channels === "object"
? (entry.channels as Record<string, unknown>)
: {};
const channels = Object.entries(channelsRaw).map(
([channelKey, channelValue]): DiscordGuildChannelForm => {
const channel =
channelValue && typeof channelValue === "object"
? (channelValue as Record<string, unknown>)
: {};
return {
key: channelKey,
allow:
typeof channel.allow === "boolean" ? channel.allow : true,
requireMention:
typeof channel.requireMention === "boolean"
? channel.requireMention
: false,
};
},
);
return {
key,
slug: typeof entry.slug === "string" ? entry.slug : "",
requireMention:
typeof entry.requireMention === "boolean"
? entry.requireMention
: false,
reactionNotifications:
entry.reactionNotifications === "off" ||
entry.reactionNotifications === "all" ||
entry.reactionNotifications === "own" ||
entry.reactionNotifications === "allowlist"
? entry.reactionNotifications
: "own",
users: toList(entry.users),
channels,
};
},
)
: [],
actions: {
reactions: readAction("reactions"),
stickers: readAction("stickers"),
polls: readAction("polls"),
permissions: readAction("permissions"),
messages: readAction("messages"),
threads: readAction("threads"),
pins: readAction("pins"),
search: readAction("search"),
memberInfo: readAction("memberInfo"),
roleInfo: readAction("roleInfo"),
channelInfo: readAction("channelInfo"),
voiceStatus: readAction("voiceStatus"),
events: readAction("events"),
roles: readAction("roles"),
moderation: readAction("moderation"),
},
slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false,
slashName: typeof slash.name === "string" ? slash.name : "",
slashSessionPrefix:
typeof slash.sessionPrefix === "string" ? slash.sessionPrefix : "",
slashEphemeral:
typeof slash.ephemeral === "boolean" ? slash.ephemeral : true,
};
const slackDm = (slack.dm ?? {}) as Record<string, unknown>;
const slackChannels = slack.channels;
const slackSlash = (slack.slashCommand ?? {}) as Record<string, unknown>;
const slackActions =
(slack.actions ?? {}) as Partial<Record<keyof typeof defaultSlackActions, unknown>>;
state.slackForm = {
enabled: typeof slack.enabled === "boolean" ? slack.enabled : true,
botToken: typeof slack.botToken === "string" ? slack.botToken : "",
appToken: typeof slack.appToken === "string" ? slack.appToken : "",
dmEnabled: typeof slackDm.enabled === "boolean" ? slackDm.enabled : true,
allowFrom: toList(slackDm.allowFrom),
groupEnabled:
typeof slackDm.groupEnabled === "boolean" ? slackDm.groupEnabled : false,
groupChannels: toList(slackDm.groupChannels),
mediaMaxMb:
typeof slack.mediaMaxMb === "number" ? String(slack.mediaMaxMb) : "",
textChunkLimit:
typeof slack.textChunkLimit === "number"
? String(slack.textChunkLimit)
: "",
reactionNotifications:
slack.reactionNotifications === "off" ||
slack.reactionNotifications === "all" ||
slack.reactionNotifications === "allowlist"
? slack.reactionNotifications
: "own",
reactionAllowlist: toList(slack.reactionAllowlist),
slashEnabled:
typeof slackSlash.enabled === "boolean" ? slackSlash.enabled : false,
slashName: typeof slackSlash.name === "string" ? slackSlash.name : "",
slashSessionPrefix:
typeof slackSlash.sessionPrefix === "string"
? slackSlash.sessionPrefix
: "",
slashEphemeral:
typeof slackSlash.ephemeral === "boolean" ? slackSlash.ephemeral : true,
actions: {
...defaultSlackActions,
reactions:
typeof slackActions.reactions === "boolean"
? slackActions.reactions
: defaultSlackActions.reactions,
messages:
typeof slackActions.messages === "boolean"
? slackActions.messages
: defaultSlackActions.messages,
pins:
typeof slackActions.pins === "boolean"
? slackActions.pins
: defaultSlackActions.pins,
memberInfo:
typeof slackActions.memberInfo === "boolean"
? slackActions.memberInfo
: defaultSlackActions.memberInfo,
emojiList:
typeof slackActions.emojiList === "boolean"
? slackActions.emojiList
: defaultSlackActions.emojiList,
},
channels: Array.isArray(slackChannels)
? []
: typeof slackChannels === "object" && slackChannels
? Object.entries(slackChannels as Record<string, unknown>).map(
([key, value]): SlackChannelForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return {
key,
allow:
typeof entry.allow === "boolean" ? entry.allow : true,
requireMention:
typeof entry.requireMention === "boolean"
? entry.requireMention
: false,
};
},
)
: [],
};
state.signalForm = {
enabled: typeof signal.enabled === "boolean" ? signal.enabled : true,
account: typeof signal.account === "string" ? signal.account : "",
httpUrl: typeof signal.httpUrl === "string" ? signal.httpUrl : "",
httpHost: typeof signal.httpHost === "string" ? signal.httpHost : "",
httpPort: typeof signal.httpPort === "number" ? String(signal.httpPort) : "",
cliPath: typeof signal.cliPath === "string" ? signal.cliPath : "",
autoStart: typeof signal.autoStart === "boolean" ? signal.autoStart : true,
receiveMode:
signal.receiveMode === "on-start" || signal.receiveMode === "manual"
? signal.receiveMode
: "",
ignoreAttachments:
typeof signal.ignoreAttachments === "boolean" ? signal.ignoreAttachments : false,
ignoreStories:
typeof signal.ignoreStories === "boolean" ? signal.ignoreStories : false,
sendReadReceipts:
typeof signal.sendReadReceipts === "boolean" ? signal.sendReadReceipts : false,
allowFrom: toList(signal.allowFrom),
mediaMaxMb:
typeof signal.mediaMaxMb === "number" ? String(signal.mediaMaxMb) : "",
};
state.imessageForm = {
enabled: typeof imessage.enabled === "boolean" ? imessage.enabled : true,
cliPath: typeof imessage.cliPath === "string" ? imessage.cliPath : "",
dbPath: typeof imessage.dbPath === "string" ? imessage.dbPath : "",
service:
imessage.service === "imessage" ||
imessage.service === "sms" ||
imessage.service === "auto"
? imessage.service
: "auto",
region: typeof imessage.region === "string" ? imessage.region : "",
allowFrom: toList(imessage.allowFrom),
includeAttachments:
typeof imessage.includeAttachments === "boolean"
? imessage.includeAttachments
: false,
mediaMaxMb:
typeof imessage.mediaMaxMb === "number" ? String(imessage.mediaMaxMb) : "",
};
const configInvalid = snapshot.valid === false ? "Config invalid." : null;
state.telegramConfigStatus = configInvalid;
state.discordConfigStatus = configInvalid;
state.slackConfigStatus = configInvalid;
state.signalConfigStatus = configInvalid;
state.imessageConfigStatus = configInvalid;
if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {});
}
}
export async function saveConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configSaving = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return;
}
await state.client.request("config.set", { raw, baseHash });
state.configFormDirty = false;
await loadConfig(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.configSaving = false;
}
}
export async function applyConfig(state: ConfigState) {
if (!state.client || !state.connected) return;
state.configApplying = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return;
}
await state.client.request("config.apply", {
raw,
baseHash,
sessionKey: state.applySessionKey,
});
state.configFormDirty = false;
await loadConfig(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.configApplying = false;
}
}
export async function runUpdate(state: ConfigState) {
if (!state.client || !state.connected) return;
state.updateRunning = true;
state.lastError = null;
try {
await state.client.request("update.run", {
sessionKey: state.applySessionKey,
});
} catch (err) {
state.lastError = String(err);
} finally {
state.updateRunning = false;
}
}
export function updateConfigFormValue(
state: ConfigState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}
export function removeConfigFormValue(
state: ConfigState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;
if (state.configFormMode === "form") {
state.configRaw = serializeConfigForm(base);
}
}