Config: schema-driven channels and settings

This commit is contained in:
Shadow
2026-01-16 14:13:30 -06:00
committed by Peter Steinberger
parent bcfc9bead5
commit 1ad26d6fea
79 changed files with 2290 additions and 6326 deletions

34
ui/src/ui/app-channels.ts Normal file
View File

@@ -0,0 +1,34 @@
import {
loadChannels,
logoutWhatsApp,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/channels";
import { loadConfig, saveConfig } from "./controllers/config";
import type { ClawdbotApp } from "./app";
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
await loadChannels(host, true);
}
export async function handleWhatsAppWait(host: ClawdbotApp) {
await waitWhatsAppLogin(host);
await loadChannels(host, true);
}
export async function handleWhatsAppLogout(host: ClawdbotApp) {
await logoutWhatsApp(host);
await loadChannels(host, true);
}
export async function handleChannelConfigSave(host: ClawdbotApp) {
await saveConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleChannelConfigReload(host: ClawdbotApp) {
await loadConfig(host);
await loadChannels(host, true);
}

View File

@@ -1,58 +0,0 @@
import {
loadChannels,
logoutWhatsApp,
saveDiscordConfig,
saveIMessageConfig,
saveSlackConfig,
saveSignalConfig,
saveTelegramConfig,
startWhatsAppLogin,
waitWhatsAppLogin,
} from "./controllers/connections";
import { loadConfig } from "./controllers/config";
import type { ClawdbotApp } from "./app";
export async function handleWhatsAppStart(host: ClawdbotApp, force: boolean) {
await startWhatsAppLogin(host, force);
await loadChannels(host, true);
}
export async function handleWhatsAppWait(host: ClawdbotApp) {
await waitWhatsAppLogin(host);
await loadChannels(host, true);
}
export async function handleWhatsAppLogout(host: ClawdbotApp) {
await logoutWhatsApp(host);
await loadChannels(host, true);
}
export async function handleTelegramSave(host: ClawdbotApp) {
await saveTelegramConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleDiscordSave(host: ClawdbotApp) {
await saveDiscordConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleSlackSave(host: ClawdbotApp) {
await saveSlackConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleSignalSave(host: ClawdbotApp) {
await saveSignalConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}
export async function handleIMessageSave(host: ClawdbotApp) {
await saveIMessageConfig(host);
await loadConfig(host);
await loadChannels(host, true);
}

View File

@@ -27,18 +27,10 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
import type {
ChatQueueItem,
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
import { renderConnections } from "./views/connections";
import { renderChannels } from "./views/channels";
import { renderCron } from "./views/cron";
import { renderDebug } from "./views/debug";
import { renderInstances } from "./views/instances";
@@ -48,14 +40,7 @@ import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderSkills } from "./views/skills";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
import {
loadChannels,
updateDiscordForm,
updateIMessageForm,
updateSlackForm,
updateSignalForm,
updateTelegramForm,
} from "./controllers/connections";
import { loadChannels } from "./controllers/channels";
import { loadPresence } from "./controllers/presence";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions";
import {
@@ -205,8 +190,8 @@ export function renderApp(state: AppViewState) {
})
: nothing}
${state.tab === "connections"
? renderConnections({
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
@@ -216,39 +201,19 @@ export function renderApp(state: AppViewState) {
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
telegramForm: state.telegramForm,
telegramTokenLocked: state.telegramTokenLocked,
telegramSaving: state.telegramSaving,
telegramStatus: state.telegramConfigStatus,
discordForm: state.discordForm,
discordTokenLocked: state.discordTokenLocked,
discordSaving: state.discordSaving,
discordStatus: state.discordConfigStatus,
slackForm: state.slackForm,
slackTokenLocked: state.slackTokenLocked,
slackAppTokenLocked: state.slackAppTokenLocked,
slackSaving: state.slackSaving,
slackStatus: state.slackConfigStatus,
signalForm: state.signalForm,
signalSaving: state.signalSaving,
signalStatus: state.signalConfigStatus,
imessageForm: state.imessageForm,
imessageSaving: state.imessageSaving,
imessageStatus: state.imessageConfigStatus,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onTelegramChange: (patch) => updateTelegramForm(state, patch),
onTelegramSave: () => state.handleTelegramSave(),
onDiscordChange: (patch) => updateDiscordForm(state, patch),
onDiscordSave: () => state.handleDiscordSave(),
onSlackChange: (patch) => updateSlackForm(state, patch),
onSlackSave: () => state.handleSlackSave(),
onSignalChange: (patch) => updateSignalForm(state, patch),
onSignalSave: () => state.handleSignalSave(),
onIMessageChange: (patch) => updateIMessageForm(state, patch),
onIMessageSave: () => state.handleIMessageSave(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
})
: nothing}

View File

@@ -1,6 +1,6 @@
import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/connections";
import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
@@ -125,7 +125,7 @@ export function setTheme(
export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "overview") await loadOverview(host);
if (host.tab === "connections") await loadConnections(host);
if (host.tab === "channels") await loadChannelsTab(host);
if (host.tab === "instances") await loadPresence(host as unknown as ClawdbotApp);
if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp);
if (host.tab === "cron") await loadCron(host);
@@ -256,9 +256,10 @@ export async function loadOverview(host: SettingsHost) {
]);
}
export async function loadConnections(host: SettingsHost) {
export async function loadChannelsTab(host: SettingsHost) {
await Promise.all([
loadChannels(host as unknown as ClawdbotApp, true),
loadConfigSchema(host as unknown as ClawdbotApp),
loadConfig(host as unknown as ClawdbotApp),
]);
}

View File

@@ -17,15 +17,7 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
import type {
ChatQueueItem,
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
@@ -73,25 +65,7 @@ export type AppViewState = {
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
configFormDirty: boolean;
presenceLoading: boolean;
presenceEntries: PresenceEntry[];
presenceError: string | null;
@@ -145,11 +119,8 @@ export type AppViewState = {
handleWhatsAppStart: (force: boolean) => Promise<void>;
handleWhatsAppWait: () => Promise<void>;
handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>;
handleDiscordSave: () => Promise<void>;
handleSlackSave: () => Promise<void>;
handleSignalSave: () => Promise<void>;
handleIMessageSave: () => Promise<void>;
handleChannelConfigSave: () => Promise<void>;
handleChannelConfigReload: () => Promise<void>;
handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>;
@@ -188,10 +159,5 @@ export type AppViewState = {
handleLogsLevelFilterToggle: (level: LogLevel) => void;
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
handleUpdateDiscordForm: (path: string, value: unknown) => void;
handleUpdateSlackForm: (path: string, value: unknown) => void;
handleUpdateSignalForm: (path: string, value: unknown) => void;
handleUpdateTelegramForm: (path: string, value: unknown) => void;
handleUpdateIMessageForm: (path: string, value: unknown) => void;
};

View File

@@ -21,17 +21,7 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
import {
defaultDiscordActions,
defaultSlackActions,
type ChatQueueItem,
type CronFormState,
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "./ui-types";
import { type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import {
@@ -66,15 +56,12 @@ import {
removeQueuedMessage as removeQueuedMessageInternal,
} from "./app-chat";
import {
handleDiscordSave as handleDiscordSaveInternal,
handleIMessageSave as handleIMessageSaveInternal,
handleSignalSave as handleSignalSaveInternal,
handleSlackSave as handleSlackSaveInternal,
handleTelegramSave as handleTelegramSaveInternal,
handleChannelConfigReload as handleChannelConfigReloadInternal,
handleChannelConfigSave as handleChannelConfigSaveInternal,
handleWhatsAppLogout as handleWhatsAppLogoutInternal,
handleWhatsAppStart as handleWhatsAppStartInternal,
handleWhatsAppWait as handleWhatsAppWaitInternal,
} from "./app-connections";
} from "./app-channels";
declare global {
interface Window {
@@ -143,91 +130,6 @@ export class ClawdbotApp extends LitElement {
@state() whatsappLoginQrDataUrl: string | null = null;
@state() whatsappLoginConnected: boolean | null = null;
@state() whatsappBusy = false;
@state() telegramForm: TelegramForm = {
token: "",
requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "",
proxy: "",
webhookUrl: "",
webhookSecret: "",
webhookPath: "",
};
@state() telegramSaving = false;
@state() telegramTokenLocked = false;
@state() telegramConfigStatus: string | null = null;
@state() discordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
};
@state() discordSaving = false;
@state() discordTokenLocked = false;
@state() discordConfigStatus: string | null = null;
@state() slackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
@state() slackSaving = false;
@state() slackTokenLocked = false;
@state() slackAppTokenLocked = false;
@state() slackConfigStatus: string | null = null;
@state() signalForm: SignalForm = {
enabled: true,
account: "",
httpUrl: "",
httpHost: "",
httpPort: "",
cliPath: "",
autoStart: true,
receiveMode: "",
ignoreAttachments: false,
ignoreStories: false,
sendReadReceipts: false,
allowFrom: "",
mediaMaxMb: "",
};
@state() signalSaving = false;
@state() signalConfigStatus: string | null = null;
@state() imessageForm: IMessageForm = {
enabled: true,
cliPath: "",
dbPath: "",
service: "auto",
region: "",
allowFrom: "",
includeAttachments: false,
mediaMaxMb: "",
};
@state() imessageSaving = false;
@state() imessageConfigStatus: string | null = null;
@state() presenceLoading = false;
@state() presenceEntries: PresenceEntry[] = [];
@@ -439,24 +341,12 @@ export class ClawdbotApp extends LitElement {
await handleWhatsAppLogoutInternal(this);
}
async handleTelegramSave() {
await handleTelegramSaveInternal(this);
async handleChannelConfigSave() {
await handleChannelConfigSaveInternal(this);
}
async handleDiscordSave() {
await handleDiscordSaveInternal(this);
}
async handleSlackSave() {
await handleSlackSaveInternal(this);
}
async handleSignalSave() {
await handleSignalSaveInternal(this);
}
async handleIMessageSave() {
await handleIMessageSaveInternal(this);
async handleChannelConfigReload() {
await handleChannelConfigReloadInternal(this);
}
// Sidebar handlers for tool output viewing

View File

@@ -0,0 +1,76 @@
import type { ChannelsStatusSnapshot } from "../types";
import type { ChannelsState } from "./channels.types";
export type { ChannelsState };
export async function loadChannels(state: ChannelsState, probe: boolean) {
if (!state.client || !state.connected) return;
if (state.channelsLoading) return;
state.channelsLoading = true;
state.channelsError = null;
try {
const res = (await state.client.request("channels.status", {
probe,
timeoutMs: 8000,
})) as ChannelsStatusSnapshot;
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
state.channelsError = String(err);
} finally {
state.channelsLoading = false;
}
}
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.start", {
force,
timeoutMs: 30000,
})) as { message?: string; qrDataUrl?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function waitWhatsAppLogin(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.wait", {
timeoutMs: 120000,
})) as { connected?: boolean; message?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) state.whatsappLoginQrDataUrl = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function logoutWhatsApp(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
}
}

View File

@@ -0,0 +1,15 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot } from "../types";
export type ChannelsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
};

View File

@@ -7,92 +7,6 @@ import {
updateConfigFormValue,
type ConfigState,
} from "./config";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordForm,
type IMessageForm,
type SignalForm,
type SlackForm,
type TelegramForm,
} from "../ui-types";
const baseTelegramForm: TelegramForm = {
token: "",
requireMention: true,
groupsWildcardEnabled: false,
allowFrom: "",
proxy: "",
webhookUrl: "",
webhookSecret: "",
webhookPath: "",
};
const baseDiscordForm: DiscordForm = {
enabled: true,
token: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
replyToMode: "off",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
};
const baseSlackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
const baseSignalForm: SignalForm = {
enabled: true,
account: "",
httpUrl: "",
httpHost: "",
httpPort: "",
cliPath: "",
autoStart: true,
receiveMode: "",
ignoreAttachments: false,
ignoreStories: false,
sendReadReceipts: false,
allowFrom: "",
mediaMaxMb: "",
};
const baseIMessageForm: IMessageForm = {
enabled: true,
cliPath: "",
dbPath: "",
service: "auto",
region: "",
allowFrom: "",
includeAttachments: false,
mediaMaxMb: "",
};
function createState(): ConfigState {
return {
@@ -115,40 +29,10 @@ function createState(): ConfigState {
configFormDirty: false,
configFormMode: "form",
lastError: null,
telegramForm: { ...baseTelegramForm },
discordForm: { ...baseDiscordForm },
slackForm: { ...baseSlackForm },
signalForm: { ...baseSignalForm },
imessageForm: { ...baseIMessageForm },
telegramConfigStatus: null,
discordConfigStatus: null,
slackConfigStatus: null,
signalConfigStatus: null,
imessageConfigStatus: null,
};
}
describe("applyConfigSnapshot", () => {
it("handles missing slack config without throwing", () => {
const state = createState();
applyConfigSnapshot(state, {
config: {
channels: {
telegram: {},
discord: {},
signal: {},
imessage: {},
},
},
valid: true,
issues: [],
raw: "{}",
});
expect(state.slackForm.botToken).toBe("");
expect(state.slackForm.actions).toEqual(defaultSlackActions);
});
it("does not clobber form edits while dirty", () => {
const state = createState();
state.configFormMode = "form";
@@ -167,6 +51,18 @@ describe("applyConfigSnapshot", () => {
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
);
});
it("updates config form when clean", () => {
const state = createState();
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: "{}",
});
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
});
});
describe("updateConfigFormValue", () => {

View File

@@ -4,19 +4,6 @@ import type {
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,
@@ -44,16 +31,6 @@ export type ConfigState = {
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) {
@@ -114,285 +91,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
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 ?? {});
}

View File

@@ -1,173 +0,0 @@
import { parseList } from "../format";
import {
defaultDiscordActions,
type DiscordActionForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveDiscordConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.discordSaving) return;
state.discordSaving = true;
state.discordConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.discordConfigStatus = "Config hash missing; reload and retry.";
return;
}
const discord: Record<string, unknown> = {};
const form = state.discordForm;
if (form.enabled) {
discord.enabled = null;
} else {
discord.enabled = false;
}
if (!state.discordTokenLocked) {
const token = form.token.trim();
discord.token = token || null;
}
const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels);
const dm: Record<string, unknown> = {
enabled: form.dmEnabled ? null : false,
allowFrom: allowFrom.length > 0 ? allowFrom : null,
groupEnabled: form.groupEnabled ? true : null,
groupChannels: groupChannels.length > 0 ? groupChannels : null,
};
discord.dm = dm;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
discord.mediaMaxMb = mediaMaxMb;
} else {
discord.mediaMaxMb = null;
}
const historyLimitRaw = form.historyLimit.trim();
if (historyLimitRaw.length === 0) {
discord.historyLimit = null;
} else {
const historyLimit = Number(historyLimitRaw);
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
discord.historyLimit = historyLimit;
} else {
discord.historyLimit = null;
}
}
const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) {
discord.textChunkLimit = null;
} else {
const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit;
} else {
discord.textChunkLimit = null;
}
}
if (form.replyToMode === "off") {
discord.replyToMode = null;
} else {
discord.replyToMode = form.replyToMode;
}
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
const guilds: Record<string, unknown> = {};
guildsForm.forEach((guild: DiscordGuildForm) => {
const key = String(guild.key ?? "").trim();
if (!key) return;
const entry: Record<string, unknown> = {};
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true;
if (
guild.reactionNotifications === "off" ||
guild.reactionNotifications === "all" ||
guild.reactionNotifications === "own" ||
guild.reactionNotifications === "allowlist"
) {
entry.reactionNotifications = guild.reactionNotifications;
}
const users = parseList(guild.users);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
channelForms.forEach((channel: DiscordGuildChannelForm) => {
const channelKey = String(channel.key ?? "").trim();
if (!channelKey) return;
const channelEntry: Record<string, unknown> = {};
if (channel.allow === false) channelEntry.allow = false;
if (channel.requireMention) channelEntry.requireMention = true;
channels[channelKey] = channelEntry;
});
if (Object.keys(channels).length > 0) entry.channels = channels;
guilds[key] = entry;
});
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else discord.guilds = null;
const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => {
const value = form.actions[key];
if (value !== defaultDiscordActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("stickers");
applyAction("polls");
applyAction("permissions");
applyAction("messages");
applyAction("threads");
applyAction("pins");
applyAction("search");
applyAction("memberInfo");
applyAction("roleInfo");
applyAction("channelInfo");
applyAction("voiceStatus");
applyAction("events");
applyAction("roles");
applyAction("moderation");
if (Object.keys(actions).length > 0) {
discord.actions = actions;
} else {
discord.actions = null;
}
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) {
slash.enabled = true;
} else {
slash.enabled = null;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else slash.name = null;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else slash.sessionPrefix = null;
if (form.slashEphemeral) {
slash.ephemeral = null;
} else {
slash.ephemeral = false;
}
discord.slashCommand = Object.keys(slash).length > 0 ? slash : null;
const raw = `${JSON.stringify(
{ channels: { discord } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.discordConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.discordConfigStatus = String(err);
} finally {
state.discordSaving = false;
}
}

View File

@@ -1,63 +0,0 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveIMessageConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.imessageSaving) return;
state.imessageSaving = true;
state.imessageConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.imessageConfigStatus = "Config hash missing; reload and retry.";
return;
}
const imessage: Record<string, unknown> = {};
const form = state.imessageForm;
if (form.enabled) {
imessage.enabled = null;
} else {
imessage.enabled = false;
}
const cliPath = form.cliPath.trim();
imessage.cliPath = cliPath || null;
const dbPath = form.dbPath.trim();
imessage.dbPath = dbPath || null;
if (form.service === "auto") {
imessage.service = null;
} else {
imessage.service = form.service;
}
const region = form.region.trim();
imessage.region = region || null;
const allowFrom = parseList(form.allowFrom);
imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null;
imessage.includeAttachments = form.includeAttachments ? true : null;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
imessage.mediaMaxMb = mediaMaxMb;
} else {
imessage.mediaMaxMb = null;
}
const raw = `${JSON.stringify(
{ channels: { imessage } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.imessageConfigStatus = String(err);
} finally {
state.imessageSaving = false;
}
}

View File

@@ -1,81 +0,0 @@
import { parseList } from "../format";
import type { ConnectionsState } from "./connections.types";
export async function saveSignalConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.signalSaving) return;
state.signalSaving = true;
state.signalConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.signalConfigStatus = "Config hash missing; reload and retry.";
return;
}
const signal: Record<string, unknown> = {};
const form = state.signalForm;
if (form.enabled) {
signal.enabled = null;
} else {
signal.enabled = false;
}
const account = form.account.trim();
signal.account = account || null;
const httpUrl = form.httpUrl.trim();
signal.httpUrl = httpUrl || null;
const httpHost = form.httpHost.trim();
signal.httpHost = httpHost || null;
const httpPort = Number(form.httpPort);
if (Number.isFinite(httpPort) && httpPort > 0) {
signal.httpPort = httpPort;
} else {
signal.httpPort = null;
}
const cliPath = form.cliPath.trim();
signal.cliPath = cliPath || null;
if (form.autoStart) {
signal.autoStart = null;
} else {
signal.autoStart = false;
}
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
signal.receiveMode = form.receiveMode;
} else {
signal.receiveMode = null;
}
signal.ignoreAttachments = form.ignoreAttachments ? true : null;
signal.ignoreStories = form.ignoreStories ? true : null;
signal.sendReadReceipts = form.sendReadReceipts ? true : null;
const allowFrom = parseList(form.allowFrom);
signal.allowFrom = allowFrom.length > 0 ? allowFrom : null;
const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
signal.mediaMaxMb = mediaMaxMb;
} else {
signal.mediaMaxMb = null;
}
const raw = `${JSON.stringify(
{ channels: { signal } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.signalConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.signalConfigStatus = String(err);
} finally {
state.signalSaving = false;
}
}

View File

@@ -1,138 +0,0 @@
import { parseList } from "../format";
import { defaultSlackActions, type SlackActionForm } from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export async function saveSlackConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.slackSaving) return;
state.slackSaving = true;
state.slackConfigStatus = null;
try {
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.slackConfigStatus = "Config hash missing; reload and retry.";
return;
}
const slack: Record<string, unknown> = {};
const form = state.slackForm;
if (form.enabled) {
slack.enabled = null;
} else {
slack.enabled = false;
}
if (!state.slackTokenLocked) {
const token = form.botToken.trim();
slack.botToken = token || null;
}
if (!state.slackAppTokenLocked) {
const token = form.appToken.trim();
slack.appToken = token || null;
}
const dm: Record<string, unknown> = {};
dm.enabled = form.dmEnabled;
const allowFrom = parseList(form.allowFrom);
dm.allowFrom = allowFrom.length > 0 ? allowFrom : null;
if (form.groupEnabled) {
dm.groupEnabled = true;
} else {
dm.groupEnabled = null;
}
const groupChannels = parseList(form.groupChannels);
dm.groupChannels = groupChannels.length > 0 ? groupChannels : null;
slack.dm = dm;
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
slack.mediaMaxMb = mediaMaxMb;
} else {
slack.mediaMaxMb = null;
}
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
slack.textChunkLimit = textChunkLimit;
} else {
slack.textChunkLimit = null;
}
if (form.reactionNotifications === "own") {
slack.reactionNotifications = null;
} else {
slack.reactionNotifications = form.reactionNotifications;
}
const reactionAllowlist = parseList(form.reactionAllowlist);
if (reactionAllowlist.length > 0) {
slack.reactionAllowlist = reactionAllowlist;
} else {
slack.reactionAllowlist = null;
}
const slash: Record<string, unknown> = {};
if (form.slashEnabled) {
slash.enabled = true;
} else {
slash.enabled = null;
}
if (form.slashName.trim()) slash.name = form.slashName.trim();
else slash.name = null;
if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim();
else slash.sessionPrefix = null;
if (form.slashEphemeral) {
slash.ephemeral = null;
} else {
slash.ephemeral = false;
}
slack.slashCommand = slash;
const actions: Partial<SlackActionForm> = {};
const applyAction = (key: keyof SlackActionForm) => {
const value = form.actions[key];
if (value !== defaultSlackActions[key]) actions[key] = value;
};
applyAction("reactions");
applyAction("messages");
applyAction("pins");
applyAction("memberInfo");
applyAction("emojiList");
if (Object.keys(actions).length > 0) {
slack.actions = actions;
} else {
slack.actions = null;
}
const channels = form.channels
.map((entry): [string, Record<string, unknown>] | null => {
const key = entry.key.trim();
if (!key) return null;
const record: Record<string, unknown> = {
allow: entry.allow,
requireMention: entry.requireMention,
};
return [key, record];
})
.filter((value): value is [string, Record<string, unknown>] =>
Boolean(value),
);
if (channels.length > 0) {
slack.channels = Object.fromEntries(channels);
} else {
slack.channels = null;
}
const raw = `${JSON.stringify(
{ channels: { slack } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.slackConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.slackConfigStatus = String(err);
} finally {
state.slackSaving = false;
}
}

View File

@@ -1,221 +0,0 @@
import { parseList } from "../format";
import type { ChannelsStatusSnapshot } from "../types";
import {
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
import type { ConnectionsState } from "./connections.types";
export { saveDiscordConfig } from "./connections.save-discord";
export { saveIMessageConfig } from "./connections.save-imessage";
export { saveSlackConfig } from "./connections.save-slack";
export { saveSignalConfig } from "./connections.save-signal";
export type { ConnectionsState };
export async function loadChannels(state: ConnectionsState, probe: boolean) {
if (!state.client || !state.connected) return;
if (state.channelsLoading) return;
state.channelsLoading = true;
state.channelsError = null;
try {
const res = (await state.client.request("channels.status", {
probe,
timeoutMs: 8000,
})) as ChannelsStatusSnapshot;
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
const channels = res.channels as Record<string, unknown>;
const telegram = channels.telegram as { tokenSource?: string | null };
const discord = channels.discord as { tokenSource?: string | null } | null;
const slack = channels.slack as
| { botTokenSource?: string | null; appTokenSource?: string | null }
| null;
state.telegramTokenLocked = telegram?.tokenSource === "env";
state.discordTokenLocked = discord?.tokenSource === "env";
state.slackTokenLocked = slack?.botTokenSource === "env";
state.slackAppTokenLocked = slack?.appTokenSource === "env";
} catch (err) {
state.channelsError = String(err);
} finally {
state.channelsLoading = false;
}
}
export async function startWhatsAppLogin(state: ConnectionsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.start", {
force,
timeoutMs: 30000,
})) as { message?: string; qrDataUrl?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function waitWhatsAppLogin(state: ConnectionsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
const res = (await state.client.request("web.login.wait", {
timeoutMs: 120000,
})) as { connected?: boolean; message?: string };
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) state.whatsappLoginQrDataUrl = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null;
} finally {
state.whatsappBusy = false;
}
}
export async function logoutWhatsApp(state: ConnectionsState) {
if (!state.client || !state.connected || state.whatsappBusy) return;
state.whatsappBusy = true;
try {
await state.client.request("channels.logout", { channel: "whatsapp" });
state.whatsappLoginMessage = "Logged out.";
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
} catch (err) {
state.whatsappLoginMessage = String(err);
} finally {
state.whatsappBusy = false;
}
}
export function updateTelegramForm(
state: ConnectionsState,
patch: Partial<TelegramForm>,
) {
state.telegramForm = { ...state.telegramForm, ...patch };
}
export function updateDiscordForm(
state: ConnectionsState,
patch: Partial<DiscordForm>,
) {
if (patch.actions) {
state.discordForm = {
...state.discordForm,
...patch,
actions: { ...state.discordForm.actions, ...patch.actions },
};
return;
}
state.discordForm = { ...state.discordForm, ...patch };
}
export function updateSlackForm(
state: ConnectionsState,
patch: Partial<SlackForm>,
) {
if (patch.actions) {
state.slackForm = {
...state.slackForm,
...patch,
actions: { ...state.slackForm.actions, ...patch.actions },
};
return;
}
state.slackForm = { ...state.slackForm, ...patch };
}
export function updateSignalForm(
state: ConnectionsState,
patch: Partial<SignalForm>,
) {
state.signalForm = { ...state.signalForm, ...patch };
}
export function updateIMessageForm(
state: ConnectionsState,
patch: Partial<IMessageForm>,
) {
state.imessageForm = { ...state.imessageForm, ...patch };
}
export async function saveTelegramConfig(state: ConnectionsState) {
if (!state.client || !state.connected) return;
if (state.telegramSaving) return;
state.telegramSaving = true;
state.telegramConfigStatus = null;
try {
if (state.telegramForm.groupsWildcardEnabled) {
const confirmed = window.confirm(
'Telegram groups wildcard "*" allows all groups. Continue?',
);
if (!confirmed) {
state.telegramConfigStatus = "Save cancelled.";
return;
}
}
const base = state.configSnapshot?.config ?? {};
const channels = (base.channels ?? {}) as Record<string, unknown>;
const telegram = {
...(channels.telegram ?? base.telegram ?? {}),
} as Record<string, unknown>;
if (!state.telegramTokenLocked) {
const token = state.telegramForm.token.trim();
telegram.botToken = token || null;
}
const groupsPatch: Record<string, unknown> = {};
if (state.telegramForm.groupsWildcardEnabled) {
const existingGroups = telegram.groups as Record<string, unknown> | undefined;
const defaultGroup =
existingGroups?.["*"] && typeof existingGroups["*"] === "object"
? ({ ...(existingGroups["*"] as Record<string, unknown>) } as Record<
string,
unknown
>)
: {};
defaultGroup.requireMention = state.telegramForm.requireMention;
groupsPatch["*"] = defaultGroup;
} else {
groupsPatch["*"] = null;
}
telegram.groups = groupsPatch;
telegram.requireMention = null;
const allowFrom = parseList(state.telegramForm.allowFrom);
telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null;
const proxy = state.telegramForm.proxy.trim();
telegram.proxy = proxy || null;
const webhookUrl = state.telegramForm.webhookUrl.trim();
telegram.webhookUrl = webhookUrl || null;
const webhookSecret = state.telegramForm.webhookSecret.trim();
telegram.webhookSecret = webhookSecret || null;
const webhookPath = state.telegramForm.webhookPath.trim();
telegram.webhookPath = webhookPath || null;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.telegramConfigStatus = "Config hash missing; reload and retry.";
return;
}
const raw = `${JSON.stringify(
{ channels: { telegram } },
null,
2,
).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
state.telegramConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) {
state.telegramConfigStatus = String(err);
} finally {
state.telegramSaving = false;
}
}

View File

@@ -1,43 +0,0 @@
import type { GatewayBrowserClient } from "../gateway";
import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types";
import type {
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "../ui-types";
export type ConnectionsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramSaving: boolean;
telegramTokenLocked: boolean;
telegramConfigStatus: string | null;
discordForm: DiscordForm;
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageConfigStatus: string | null;
configSnapshot: ConfigSnapshot | null;
};

View File

@@ -45,14 +45,14 @@ describe("chat focus mode", () => {
await app.updateComplete;
expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/connections"]');
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
await app.updateComplete;
expect(app.tab).toBe("connections");
expect(app.tab).toBe("channels");
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');

View File

@@ -76,7 +76,7 @@ describe("control UI routing", () => {
await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>(
'a.nav-item[href="/connections"]',
'a.nav-item[href="/channels"]',
);
expect(link).not.toBeNull();
link?.dispatchEvent(
@@ -84,8 +84,8 @@ describe("control UI routing", () => {
);
await app.updateComplete;
expect(app.tab).toBe("connections");
expect(window.location.pathname).toBe("/connections");
expect(app.tab).toBe("channels");
expect(window.location.pathname).toBe("/channels");
});
it("keeps chat and nav usable on narrow viewports", async () => {

View File

@@ -29,7 +29,7 @@ describe("iconForTab", () => {
it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬");
expect(iconForTab("overview")).toBe("📊");
expect(iconForTab("connections")).toBe("🔗");
expect(iconForTab("channels")).toBe("🔗");
expect(iconForTab("instances")).toBe("📡");
expect(iconForTab("sessions")).toBe("📄");
expect(iconForTab("cron")).toBe("⏰");

View File

@@ -2,7 +2,7 @@ export const TAB_GROUPS = [
{ label: "Chat", tabs: ["chat"] },
{
label: "Control",
tabs: ["overview", "connections", "instances", "sessions", "cron"],
tabs: ["overview", "channels", "instances", "sessions", "cron"],
},
{ label: "Agent", tabs: ["skills", "nodes"] },
{ label: "Settings", tabs: ["config", "debug", "logs"] },
@@ -10,7 +10,7 @@ export const TAB_GROUPS = [
export type Tab =
| "overview"
| "connections"
| "channels"
| "instances"
| "sessions"
| "cron"
@@ -23,7 +23,7 @@ export type Tab =
const TAB_PATHS: Record<Tab, string> = {
overview: "/overview",
connections: "/connections",
channels: "/channels",
instances: "/instances",
sessions: "/sessions",
cron: "/cron",
@@ -104,7 +104,7 @@ export function iconForTab(tab: Tab): string {
return "💬";
case "overview":
return "📊";
case "connections":
case "channels":
return "🔗";
case "instances":
return "📡";
@@ -131,8 +131,8 @@ export function titleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Overview";
case "connections":
return "Connections";
case "channels":
return "Channels";
case "instances":
return "Instances";
case "sessions":
@@ -160,8 +160,8 @@ export function subtitleForTab(tab: Tab) {
switch (tab) {
case "overview":
return "Gateway status, entry points, and a fast health read.";
case "connections":
return "Link channels and keep transport settings in sync.";
case "channels":
return "Manage channels and settings.";
case "instances":
return "Presence beacons from connected clients and nodes.";
case "sessions":

View File

@@ -1,159 +1,9 @@
export type TelegramForm = {
token: string;
requireMention: boolean;
groupsWildcardEnabled: boolean;
allowFrom: string;
proxy: string;
webhookUrl: string;
webhookSecret: string;
webhookPath: string;
};
export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
};
export type DiscordForm = {
enabled: boolean;
token: string;
dmEnabled: boolean;
allowFrom: string;
groupEnabled: boolean;
groupChannels: string;
mediaMaxMb: string;
historyLimit: string;
textChunkLimit: string;
replyToMode: "off" | "first" | "all";
guilds: DiscordGuildForm[];
actions: DiscordActionForm;
slashEnabled: boolean;
slashName: string;
slashSessionPrefix: string;
slashEphemeral: boolean;
};
export type DiscordGuildForm = {
key: string;
slug: string;
requireMention: boolean;
reactionNotifications: "off" | "own" | "all" | "allowlist";
users: string;
channels: DiscordGuildChannelForm[];
};
export type DiscordGuildChannelForm = {
key: string;
allow: boolean;
requireMention: boolean;
};
export type DiscordActionForm = {
reactions: boolean;
stickers: boolean;
polls: boolean;
permissions: boolean;
messages: boolean;
threads: boolean;
pins: boolean;
search: boolean;
memberInfo: boolean;
roleInfo: boolean;
channelInfo: boolean;
voiceStatus: boolean;
events: boolean;
roles: boolean;
moderation: boolean;
};
export type SlackChannelForm = {
key: string;
allow: boolean;
requireMention: boolean;
};
export type SlackActionForm = {
reactions: boolean;
messages: boolean;
pins: boolean;
memberInfo: boolean;
emojiList: boolean;
};
export type SlackForm = {
enabled: boolean;
botToken: string;
appToken: string;
dmEnabled: boolean;
allowFrom: string;
groupEnabled: boolean;
groupChannels: string;
mediaMaxMb: string;
textChunkLimit: string;
reactionNotifications: "off" | "own" | "all" | "allowlist";
reactionAllowlist: string;
slashEnabled: boolean;
slashName: string;
slashSessionPrefix: string;
slashEphemeral: boolean;
actions: SlackActionForm;
channels: SlackChannelForm[];
};
export const defaultDiscordActions: DiscordActionForm = {
reactions: true,
stickers: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
channelInfo: true,
voiceStatus: true,
events: true,
roles: false,
moderation: false,
};
export const defaultSlackActions: SlackActionForm = {
reactions: true,
messages: true,
pins: true,
memberInfo: true,
emojiList: true,
};
export type SignalForm = {
enabled: boolean;
account: string;
httpUrl: string;
httpHost: string;
httpPort: string;
cliPath: string;
autoStart: boolean;
receiveMode: "on-start" | "manual" | "";
ignoreAttachments: boolean;
ignoreStories: boolean;
sendReadReceipts: boolean;
allowFrom: string;
mediaMaxMb: string;
};
export type IMessageForm = {
enabled: boolean;
cliPath: string;
dbPath: string;
service: "auto" | "imessage" | "sms";
region: string;
allowFrom: string;
includeAttachments: boolean;
mediaMaxMb: string;
};
export type CronFormState = {
name: string;
description: string;

View File

@@ -0,0 +1,134 @@
import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
analyzeConfigSchema,
renderNode,
schemaType,
type JsonSchema,
} from "./config-form";
type ChannelConfigFormProps = {
channelId: string;
configValue: Record<string, unknown> | null;
schema: unknown | null;
uiHints: ConfigUiHints;
disabled: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
function resolveSchemaNode(
schema: JsonSchema | null,
path: Array<string | number>,
): JsonSchema | null {
let current = schema;
for (const key of path) {
if (!current) return null;
const type = schemaType(current);
if (type === "object") {
const properties = current.properties ?? {};
if (typeof key === "string" && properties[key]) {
current = properties[key];
continue;
}
const additional = current.additionalProperties;
if (typeof key === "string" && additional && typeof additional === "object") {
current = additional as JsonSchema;
continue;
}
return null;
}
if (type === "array") {
if (typeof key !== "number") return null;
const items = Array.isArray(current.items) ? current.items[0] : current.items;
current = items ?? null;
continue;
}
return null;
}
return current;
}
function resolveChannelValue(
config: Record<string, unknown>,
channelId: string,
): Record<string, unknown> {
const channels = (config.channels ?? {}) as Record<string, unknown>;
const fromChannels = channels[channelId];
const fallback = config[channelId];
const resolved =
(fromChannels && typeof fromChannels === "object"
? (fromChannels as Record<string, unknown>)
: null) ??
(fallback && typeof fallback === "object"
? (fallback as Record<string, unknown>)
: null);
return resolved ?? {};
}
export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`<div class="callout danger">Schema unavailable. Use Raw.</div>`;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`<div class="callout danger">Channel config schema unavailable.</div>`;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
return html`
<div class="config-form">
${renderNode({
schema: node,
value,
path: ["channels", props.channelId],
hints: props.uiHints,
unsupported: new Set(analysis.unsupportedPaths),
disabled: props.disabled,
showLabel: false,
onPatch: props.onPatch,
})}
</div>
`;
}
export function renderChannelConfigSection(params: {
channelId: string;
props: ChannelsProps;
}) {
const { channelId, props } = params;
const disabled = props.configSaving || props.configSchemaLoading;
return html`
<div style="margin-top: 16px;">
${props.configSchemaLoading
? html`<div class="muted">Loading config schema…</div>`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"
?disabled=${disabled || !props.configFormDirty}
@click=${() => props.onConfigSave()}
>
${props.configSaving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
Reload
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderDiscordCard(params: {
props: ChannelsProps;
discord?: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderIMessageCard(params: {
props: ChannelsProps;
imessage?: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,46 @@
import { html, nothing } from "lit";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) return false;
const channelStatus = channels[key] as Record<string, unknown> | undefined;
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected;
const accounts = snapshot.channelAccounts?.[key] ?? [];
const accountActive = accounts.some(
(account) => account.configured || account.running || account.connected,
);
return configured || running || connected || accountActive;
}
export function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
export function renderChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@@ -0,0 +1,66 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSignalCard(params: {
props: ChannelsProps;
signal?: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,62 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderSlackCard(params: {
props: ChannelsProps;
slack?: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -0,0 +1,113 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
export function renderTelegramCard(params: {
props: ChannelsProps;
telegram?: TelegramStatus;
telegramAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username;
const label = account.name || account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">
${botUsername ? `@${botUsername}` : label}
</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

234
ui/src/ui/views/channels.ts Normal file
View File

@@ -0,0 +1,234 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ChannelsChannelData,
ChannelsProps,
} from "./channels.types";
import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
import { renderTelegramCard } from "./channels.telegram";
import { renderWhatsAppCard } from "./channels.whatsapp";
export function renderChannels(props: ChannelsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const channelOrder = resolveChannelOrder(props.snapshot);
const orderedChannels = channelOrder
.map((key, index) => ({
key,
enabled: channelEnabled(key, props),
order: index,
}))
.sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
return a.order - b.order;
});
return html`
<section class="grid grid-cols-2">
${orderedChannels.map((channel) =>
renderChannel(channel.key, props, {
whatsapp,
telegram,
discord,
slack,
signal,
imessage,
channelAccounts: props.snapshot?.channelAccounts ?? null,
}),
)}
</section>
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</section>
`;
}
function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKey[] {
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"];
}
function renderChannel(
key: ChannelKey,
props: ChannelsProps,
data: ChannelsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
props,
whatsapp: data.whatsapp,
accountCountLabel,
});
case "telegram":
return renderTelegramCard({
props,
telegram: data.telegram,
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCountLabel,
});
case "discord":
return renderDiscordCard({
props,
discord: data.discord,
accountCountLabel,
});
case "slack":
return renderSlackCard({
props,
slack: data.slack,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,
signal: data.signal,
accountCountLabel,
});
case "imessage":
return renderIMessageCard({
props,
imessage: data.imessage,
accountCountLabel,
});
default:
return renderGenericChannelCard(key, props, data.channelAccounts ?? {});
}
}
function renderGenericChannelCard(
key: ChannelKey,
props: ChannelsProps,
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
) {
const label = props.snapshot?.channelLabels?.[key] ?? key;
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | undefined;
const configured = typeof status?.configured === "boolean" ? status.configured : undefined;
const running = typeof status?.running === "boolean" ? status.running : undefined;
const connected = typeof status?.connected === "boolean" ? status.connected : undefined;
const lastError = typeof status?.lastError === "string" ? status.lastError : undefined;
const accounts = channelAccounts[key] ?? [];
const accountCountLabel = renderChannelAccountCount(key, channelAccounts);
return html`
<div class="card">
<div class="card-title">${label}</div>
<div class="card-sub">Channel status and configuration.</div>
${accountCountLabel}
${accounts.length > 0
? html`
<div class="account-card-list">
${accounts.map((account) => renderGenericAccount(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
</div>
</div>
`}
${lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${lastError}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: key, props })}
</div>
`;
}
function renderGenericAccount(account: ChannelAccountSnapshot) {
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">${account.name || account.accountId}</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${account.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
}

View File

@@ -0,0 +1,48 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
export type ChannelKey = string;
export type ChannelsProps = {
connected: boolean;
loading: boolean;
snapshot: ChannelsStatusSnapshot | null;
lastError: string | null;
lastSuccessAt: number | null;
whatsappMessage: string | null;
whatsappQrDataUrl: string | null;
whatsappConnected: boolean | null;
whatsappBusy: boolean;
configSchema: unknown | null;
configSchemaLoading: boolean;
configForm: Record<string, unknown> | null;
configUiHints: ConfigUiHints;
configSaving: boolean;
configFormDirty: boolean;
onRefresh: (probe: boolean) => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;
onConfigPatch: (path: Array<string | number>, value: unknown) => void;
onConfigSave: () => void;
onConfigReload: () => void;
};
export type ChannelsChannelData = {
whatsapp?: WhatsAppStatus;
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
};

View File

@@ -2,11 +2,12 @@ import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { WhatsAppStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { formatDuration } from "./connections.shared";
import type { ChannelsProps } from "./channels.types";
import { renderChannelConfigSection } from "./channels.config";
import { formatDuration } from "./channels.shared";
export function renderWhatsAppCard(params: {
props: ConnectionsProps;
props: ChannelsProps;
whatsapp?: WhatsAppStatus;
accountCountLabel: unknown;
}) {
@@ -110,6 +111,8 @@ export function renderWhatsAppCard(params: {
Refresh
</button>
</div>
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div>
`;
}

View File

@@ -3,5 +3,6 @@ export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
} from "./config-form.analyze";
export type { JsonSchema } from "./config-form.shared";
export { renderNode } from "./config-form.node";
export { schemaType, type JsonSchema } from "./config-form.shared";

View File

@@ -1,20 +1,7 @@
import { html, nothing } from "lit";
import {
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
MOONSHOT_KIMI_K2_COST,
MOONSHOT_KIMI_K2_DEFAULT_ID,
MOONSHOT_KIMI_K2_INPUT,
MOONSHOT_KIMI_K2_MAX_TOKENS,
MOONSHOT_KIMI_K2_MODELS,
} from "../data/moonshot-kimi-k2";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
type ConfigPatch = {
path: Array<string | number>;
value: unknown;
};
export type ConfigProps = {
raw: string;
valid: boolean | null;
@@ -38,287 +25,6 @@ export type ConfigProps = {
onUpdate: () => void;
};
function cloneConfigObject<T>(value: T): T {
if (typeof structuredClone === "function") return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function tryParseJsonObject(raw: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
function setPathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
value: unknown,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const nextKey = path[i + 1];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) current[lastKey] = value;
return;
}
if (typeof current === "object" && current != null) {
(current as Record<string, unknown>)[lastKey] = value;
}
}
function getPathValue(
obj: unknown,
path: Array<string | number>,
): unknown | undefined {
let current: unknown = obj;
for (const key of path) {
if (typeof key === "number") {
if (!Array.isArray(current)) return undefined;
current = current[key];
} else {
if (!current || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
}
return current;
}
function buildModelPresetPatches(base: Record<string, unknown>): Array<{
id: "minimax" | "zai" | "moonshot";
title: string;
description: string;
patches: ConfigPatch[];
}> {
const setPrimary = (modelRef: string) => ({
path: ["agents", "defaults", "model", "primary"],
value: modelRef,
});
const safeAlias = (modelRef: string, alias: string): ConfigPatch | null => {
const existingAlias = getPathValue(base, [
"agents",
"defaults",
"models",
modelRef,
"alias",
]);
if (typeof existingAlias === "string" && existingAlias.trim().length > 0) {
return null;
}
return {
path: ["agents", "defaults", "models", modelRef, "alias"],
value: alias,
};
};
const minimaxModelsPath = ["models", "providers", "minimax", "models"] satisfies Array<
string | number
>;
const moonshotModelsPath = [
"models",
"providers",
"moonshot",
"models",
] satisfies Array<string | number>;
const hasNonEmptyString = (value: unknown) =>
typeof value === "string" && value.trim().length > 0;
const envMinimax = getPathValue(base, ["env", "MINIMAX_API_KEY"]);
const envZai = getPathValue(base, ["env", "ZAI_API_KEY"]);
const envMoonshot = getPathValue(base, ["env", "MOONSHOT_API_KEY"]);
const minimaxHasModels = Array.isArray(getPathValue(base, minimaxModelsPath));
const moonshotHasModels = Array.isArray(getPathValue(base, moonshotModelsPath));
const minimaxProviderBaseUrl = getPathValue(base, [
"models",
"providers",
"minimax",
"baseUrl",
]);
const minimaxProviderApiKey = getPathValue(base, [
"models",
"providers",
"minimax",
"apiKey",
]);
const minimaxProviderApi = getPathValue(base, [
"models",
"providers",
"minimax",
"api",
]);
const moonshotProviderBaseUrl = getPathValue(base, [
"models",
"providers",
"moonshot",
"baseUrl",
]);
const moonshotProviderApiKey = getPathValue(base, [
"models",
"providers",
"moonshot",
"apiKey",
]);
const moonshotProviderApi = getPathValue(base, [
"models",
"providers",
"moonshot",
"api",
]);
const modelsMode = getPathValue(base, ["models", "mode"]);
const minimax: ConfigPatch[] = [];
if (!hasNonEmptyString(envMinimax)) {
minimax.push({ path: ["env", "MINIMAX_API_KEY"], value: "sk-..." });
}
if (modelsMode == null) {
minimax.push({ path: ["models", "mode"], value: "merge" });
}
// Intentional: enforce the preferred MiniMax endpoint/mode.
if (minimaxProviderBaseUrl !== "https://api.minimax.io/anthropic") {
minimax.push({
path: ["models", "providers", "minimax", "baseUrl"],
value: "https://api.minimax.io/anthropic",
});
}
if (!hasNonEmptyString(minimaxProviderApiKey)) {
minimax.push({
path: ["models", "providers", "minimax", "apiKey"],
value: "${MINIMAX_API_KEY}",
});
}
if (minimaxProviderApi !== "anthropic-messages") {
minimax.push({
path: ["models", "providers", "minimax", "api"],
value: "anthropic-messages",
});
}
if (!minimaxHasModels) {
minimax.push({
path: minimaxModelsPath as Array<string | number>,
value: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192,
},
],
});
}
minimax.push(setPrimary("minimax/MiniMax-M2.1"));
const minimaxAlias = safeAlias("minimax/MiniMax-M2.1", "Minimax");
if (minimaxAlias) minimax.push(minimaxAlias);
const zai: ConfigPatch[] = [];
if (!hasNonEmptyString(envZai)) {
zai.push({ path: ["env", "ZAI_API_KEY"], value: "sk-..." });
}
zai.push(setPrimary("zai/glm-4.7"));
const zaiAlias = safeAlias("zai/glm-4.7", "GLM 4.7");
if (zaiAlias) zai.push(zaiAlias);
const moonshot: ConfigPatch[] = [];
if (!hasNonEmptyString(envMoonshot)) {
moonshot.push({ path: ["env", "MOONSHOT_API_KEY"], value: "sk-..." });
}
if (modelsMode == null) {
moonshot.push({ path: ["models", "mode"], value: "merge" });
}
if (!hasNonEmptyString(moonshotProviderBaseUrl)) {
moonshot.push({
path: ["models", "providers", "moonshot", "baseUrl"],
value: "https://api.moonshot.ai/v1",
});
}
if (!hasNonEmptyString(moonshotProviderApiKey)) {
moonshot.push({
path: ["models", "providers", "moonshot", "apiKey"],
value: "${MOONSHOT_API_KEY}",
});
}
if (!hasNonEmptyString(moonshotProviderApi)) {
moonshot.push({
path: ["models", "providers", "moonshot", "api"],
value: "openai-completions",
});
}
const moonshotModelDefinitions = MOONSHOT_KIMI_K2_MODELS.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: [...MOONSHOT_KIMI_K2_INPUT],
cost: { ...MOONSHOT_KIMI_K2_COST },
contextWindow: MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
maxTokens: MOONSHOT_KIMI_K2_MAX_TOKENS,
}));
if (!moonshotHasModels) {
moonshot.push({
path: moonshotModelsPath as Array<string | number>,
value: moonshotModelDefinitions,
});
}
moonshot.push(setPrimary(`moonshot/${MOONSHOT_KIMI_K2_DEFAULT_ID}`));
for (const model of MOONSHOT_KIMI_K2_MODELS) {
const moonshotAlias = safeAlias(`moonshot/${model.id}`, model.alias);
if (moonshotAlias) moonshot.push(moonshotAlias);
}
return [
{
id: "minimax",
title: "MiniMax M2.1 (Anthropic)",
description:
"Adds provider config for MiniMaxs /anthropic endpoint and sets it as the default model.",
patches: minimax,
},
{
id: "zai",
title: "GLM 4.7 (Z.AI)",
description: "Adds ZAI_API_KEY placeholder + sets default model to zai/glm-4.7.",
patches: zai,
},
{
id: "moonshot",
title: "Kimi (Moonshot)",
description:
"Adds Moonshot provider config + sets default model to kimi-k2-0905-preview (includes Kimi K2 turbo/thinking variants).",
patches: moonshot,
},
];
}
export function renderConfig(props: ConfigProps) {
const validity =
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
@@ -339,25 +45,6 @@ export function renderConfig(props: ConfigProps) {
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const applyPreset = (patches: ConfigPatch[]) => {
const base =
props.formValue ??
tryParseJsonObject(props.raw) ??
({} as Record<string, unknown>);
const next = cloneConfigObject(base);
for (const patch of patches) {
setPathValue(next, patch.path, patch.value);
}
props.onRawChange(`${JSON.stringify(next, null, 2).trimEnd()}\n`);
for (const patch of patches) props.onFormPatch(patch.path, patch.value);
};
const presetBase =
props.formValue ??
tryParseJsonObject(props.raw) ??
({} as Record<string, unknown>);
const modelPresets = buildModelPresetPatches(presetBase);
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -414,31 +101,6 @@ export function renderConfig(props: ConfigProps) {
comes back.
</div>
<div class="callout" style="margin-top: 12px;">
<div style="font-weight: 600;">Model presets</div>
<div class="muted" style="margin-top: 6px;">
One-click inserts for MiniMax, GLM 4.7 (Z.AI), and Kimi (Moonshot). Keeps
existing API keys and per-model params when present.
</div>
<div class="row" style="margin-top: 10px; flex-wrap: wrap;">
${modelPresets.map(
(preset) => html`
<button
class="btn"
?disabled=${props.loading || props.saving || !props.connected}
title=${preset.description}
@click=${() => applyPreset(preset.patches)}
>
${preset.title}
</button>
`,
)}
</div>
<div class="muted" style="margin-top: 8px;">
Tip: use <span class="mono">/model</span> to switch models without editing
config.
</div>
</div>
${props.formMode === "form"
? html`<div style="margin-top: 12px;">

View File

@@ -1,28 +0,0 @@
import type { DiscordActionForm, SlackActionForm } from "../ui-types";
export const discordActionOptions = [
{ key: "reactions", label: "Reactions" },
{ key: "stickers", label: "Stickers" },
{ key: "polls", label: "Polls" },
{ key: "permissions", label: "Permissions" },
{ key: "messages", label: "Messages" },
{ key: "threads", label: "Threads" },
{ key: "pins", label: "Pins" },
{ key: "search", label: "Search" },
{ key: "memberInfo", label: "Member info" },
{ key: "roleInfo", label: "Role info" },
{ key: "channelInfo", label: "Channel info" },
{ key: "voiceStatus", label: "Voice status" },
{ key: "events", label: "Events" },
{ key: "roles", label: "Role changes" },
{ key: "moderation", label: "Moderation" },
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
export const slackActionOptions = [
{ key: "reactions", label: "Reactions" },
{ key: "messages", label: "Messages" },
{ key: "pins", label: "Pins" },
{ key: "memberInfo", label: "Member info" },
{ key: "emojiList", label: "Emoji list" },
] satisfies Array<{ key: keyof SlackActionForm; label: string }>;

View File

@@ -1,31 +0,0 @@
import { html } from "lit";
import type { ConnectionsProps } from "./connections.types";
import { discordActionOptions } from "./connections.action-options";
export function renderDiscordActionsSection(props: ConnectionsProps) {
return html`
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${discordActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
actions: {
...props.discordForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
`;
}

View File

@@ -1,262 +0,0 @@
import { html, nothing } from "lit";
import type { ConnectionsProps } from "./connections.types";
export function renderDiscordGuildsEditor(props: ConnectionsProps) {
return html`
<div class="field full">
<span>Guilds</span>
<div class="card-sub">
Add each guild (id or slug) and optional channel rules. Empty channel
entries still allow that channel.
</div>
<div class="list">
${props.discordForm.guilds.map(
(guild, guildIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Guild id / slug</span>
<input
.value=${guild.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
key: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Slug</span>
<input
.value=${guild.slug}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
slug: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${guild.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${guild.reactionNotifications}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Users allowlist</span>
<input
.value=${guild.users}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
users: (e.target as HTMLInputElement).value,
};
props.onDiscordChange({ guilds: next });
}}
placeholder="123456789, username#1234"
/>
</label>
</div>
${guild.channels.length
? html`
<div class="form-grid" style="margin-top: 8px;">
${guild.channels.map(
(channel, channelIndex) => html`
<label class="field">
<span>Channel id / slug</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
key: (e.target as HTMLInputElement).value,
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
allow:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels[channelIndex] = {
...channels[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value ===
"yes",
};
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
];
channels.splice(channelIndex, 1);
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Remove
</button>
</label>
`,
)}
</div>
`
: nothing}
</div>
<div class="list-meta">
<span>Channels</span>
<button
class="btn"
@click=${() => {
const next = [...props.discordForm.guilds];
const channels = [
...(next[guildIndex].channels ?? []),
{ key: "", allow: true, requireMention: false },
];
next[guildIndex] = {
...next[guildIndex],
channels,
};
props.onDiscordChange({ guilds: next });
}}
>
Add channel
</button>
<button
class="btn danger"
@click=${() => {
const next = [...props.discordForm.guilds];
next.splice(guildIndex, 1);
props.onDiscordChange({ guilds: next });
}}
>
Remove guild
</button>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onDiscordChange({
guilds: [
...props.discordForm.guilds,
{
key: "",
slug: "",
requireMention: false,
reactionNotifications: "own",
users: "",
channels: [],
},
],
})}
>
Add guild
</button>
</div>
`;
}

View File

@@ -1,261 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { DiscordStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { renderDiscordActionsSection } from "./connections.discord.actions";
import { renderDiscordGuildsEditor } from "./connections.discord.guilds";
export function renderDiscordCard(params: {
props: ConnectionsProps;
discord: DiscordStatus | null;
accountCountLabel: unknown;
}) {
const { props, discord, accountCountLabel } = params;
const botName = discord?.probe?.bot?.username;
return html`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot connection and probe status.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${discord?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? `@${botName}` : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.discordForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.discordForm.token}
?disabled=${props.discordTokenLocked}
@input=${(e: Event) =>
props.onDiscordChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.discordForm.allowFrom}
@input=${(e: Event) =>
props.onDiscordChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, username#1234"
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DMs</span>
<select
.value=${props.discordForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group channels</span>
<input
.value=${props.discordForm.groupChannels}
@input=${(e: Event) =>
props.onDiscordChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="channelId1, channelId2"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.discordForm.mediaMaxMb}
@input=${(e: Event) =>
props.onDiscordChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
<label class="field">
<span>History limit</span>
<input
.value=${props.discordForm.historyLimit}
@input=${(e: Event) =>
props.onDiscordChange({
historyLimit: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.discordForm.textChunkLimit}
@input=${(e: Event) =>
props.onDiscordChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="2000"
/>
</label>
<label class="field">
<span>Reply to mode</span>
<select
.value=${props.discordForm.replyToMode}
@change=${(e: Event) =>
props.onDiscordChange({
replyToMode: (e.target as HTMLSelectElement).value as
| "off"
| "first"
| "all",
})}
>
<option value="off">Off</option>
<option value="first">First</option>
<option value="all">All</option>
</select>
</label>
${renderDiscordGuildsEditor(props)}
<label class="field">
<span>Slash command</span>
<select
.value=${props.discordForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.discordForm.slashName}
@input=${(e: Event) =>
props.onDiscordChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.discordForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onDiscordChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="discord:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.discordForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onDiscordChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
${renderDiscordActionsSection(props)}
${props.discordTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
DISCORD_BOT_TOKEN is set in the environment. Config edits will not
override it.
</div>`
: nothing}
${props.discordStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.discordStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.discordSaving}
@click=${() => props.onDiscordSave()}
>
${props.discordSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -1,184 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { IMessageStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderIMessageCard(params: {
props: ConnectionsProps;
imessage: IMessageStatus | null;
accountCountLabel: unknown;
}) {
const { props, imessage, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">imsg CLI and database availability.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${imessage?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">CLI</span>
<span>${imessage?.cliPath ?? "n/a"}</span>
</div>
<div>
<span class="label">DB</span>
<span>${imessage?.dbPath ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>
${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last probe</span>
<span>
${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}
</span>
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
${imessage?.probe && !imessage.probe.ok
? html`<div class="callout" style="margin-top: 12px;">
Probe failed · ${imessage.probe.error ?? "unknown error"}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.imessageForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.imessageForm.cliPath}
@input=${(e: Event) =>
props.onIMessageChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="imsg"
/>
</label>
<label class="field">
<span>DB path</span>
<input
.value=${props.imessageForm.dbPath}
@input=${(e: Event) =>
props.onIMessageChange({
dbPath: (e.target as HTMLInputElement).value,
})}
placeholder="~/Library/Messages/chat.db"
/>
</label>
<label class="field">
<span>Service</span>
<select
.value=${props.imessageForm.service}
@change=${(e: Event) =>
props.onIMessageChange({
service: (e.target as HTMLSelectElement).value as
| "auto"
| "imessage"
| "sms",
})}
>
<option value="auto">Auto</option>
<option value="imessage">iMessage</option>
<option value="sms">SMS</option>
</select>
</label>
<label class="field">
<span>Region</span>
<input
.value=${props.imessageForm.region}
@input=${(e: Event) =>
props.onIMessageChange({
region: (e.target as HTMLInputElement).value,
})}
placeholder="US"
/>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.imessageForm.allowFrom}
@input=${(e: Event) =>
props.onIMessageChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="chat_id:101, +1555"
/>
</label>
<label class="field">
<span>Include attachments</span>
<select
.value=${props.imessageForm.includeAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onIMessageChange({
includeAttachments:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.imessageForm.mediaMaxMb}
@input=${(e: Event) =>
props.onIMessageChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="16"
/>
</label>
</div>
${props.imessageStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.imessageStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.imessageSaving}
@click=${() => props.onIMessageSave()}
>
${props.imessageSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -1,71 +0,0 @@
import { html, nothing } from "lit";
import type {
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ConnectionsProps } from "./connections.types";
export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) return "n/a";
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.round(min / 60);
return `${hr}h`;
}
export function channelEnabled(key: ChannelKey, props: ConnectionsProps) {
const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) return false;
const whatsapp = channels.whatsapp as WhatsAppStatus | undefined;
const telegram = channels.telegram as TelegramStatus | undefined;
const discord = (channels.discord ?? null) as DiscordStatus | null;
const slack = (channels.slack ?? null) as SlackStatus | null;
const signal = (channels.signal ?? null) as SignalStatus | null;
const imessage = (channels.imessage ?? null) as IMessageStatus | null;
switch (key) {
case "whatsapp":
return (
Boolean(whatsapp?.configured) ||
Boolean(whatsapp?.linked) ||
Boolean(whatsapp?.running)
);
case "telegram":
return Boolean(telegram?.configured) || Boolean(telegram?.running);
case "discord":
return Boolean(discord?.configured || discord?.running);
case "slack":
return Boolean(slack?.configured || slack?.running);
case "signal":
return Boolean(signal?.configured || signal?.running);
case "imessage":
return Boolean(imessage?.configured || imessage?.running);
default:
return false;
}
}
export function getChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
): number {
return channelAccounts?.[key]?.length ?? 0;
}
export function renderChannelAccountCount(
key: ChannelKey,
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@@ -1,237 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SignalStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderSignalCard(params: {
props: ConnectionsProps;
signal: SignalStatus | null;
accountCountLabel: unknown;
}) {
const { props, signal, accountCountLabel } = params;
return html`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">REST daemon status and probe details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${signal?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.signalForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Account</span>
<input
.value=${props.signalForm.account}
@input=${(e: Event) =>
props.onSignalChange({
account: (e.target as HTMLInputElement).value,
})}
placeholder="+15551234567"
/>
</label>
<label class="field">
<span>HTTP URL</span>
<input
.value=${props.signalForm.httpUrl}
@input=${(e: Event) =>
props.onSignalChange({
httpUrl: (e.target as HTMLInputElement).value,
})}
placeholder="http://127.0.0.1:8080"
/>
</label>
<label class="field">
<span>HTTP host</span>
<input
.value=${props.signalForm.httpHost}
@input=${(e: Event) =>
props.onSignalChange({
httpHost: (e.target as HTMLInputElement).value,
})}
placeholder="127.0.0.1"
/>
</label>
<label class="field">
<span>HTTP port</span>
<input
.value=${props.signalForm.httpPort}
@input=${(e: Event) =>
props.onSignalChange({
httpPort: (e.target as HTMLInputElement).value,
})}
placeholder="8080"
/>
</label>
<label class="field">
<span>CLI path</span>
<input
.value=${props.signalForm.cliPath}
@input=${(e: Event) =>
props.onSignalChange({
cliPath: (e.target as HTMLInputElement).value,
})}
placeholder="signal-cli"
/>
</label>
<label class="field">
<span>Auto start</span>
<select
.value=${props.signalForm.autoStart ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
autoStart: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Receive mode</span>
<select
.value=${props.signalForm.receiveMode}
@change=${(e: Event) =>
props.onSignalChange({
receiveMode: (e.target as HTMLSelectElement).value as
| "on-start"
| "manual"
| "",
})}
>
<option value="">Default</option>
<option value="on-start">on-start</option>
<option value="manual">manual</option>
</select>
</label>
<label class="field">
<span>Ignore attachments</span>
<select
.value=${props.signalForm.ignoreAttachments ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreAttachments: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Ignore stories</span>
<select
.value=${props.signalForm.ignoreStories ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
ignoreStories: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Send read receipts</span>
<select
.value=${props.signalForm.sendReadReceipts ? "yes" : "no"}
@change=${(e: Event) =>
props.onSignalChange({
sendReadReceipts: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.signalForm.allowFrom}
@input=${(e: Event) =>
props.onSignalChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="12345, +1555"
/>
</label>
<label class="field">
<span>Media max MB</span>
<input
.value=${props.signalForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSignalChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="8"
/>
</label>
</div>
${props.signalStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.signalStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.signalSaving}
@click=${() => props.onSignalSave()}
>
${props.signalSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -1,391 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { SlackStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
import { slackActionOptions } from "./connections.action-options";
export function renderSlackCard(params: {
props: ConnectionsProps;
slack: SlackStatus | null;
accountCountLabel: unknown;
}) {
const { props, slack, accountCountLabel } = params;
const botName = slack?.probe?.bot?.name;
const teamName = slack?.probe?.team?.name;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and bot details.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? botName : "n/a"}</span>
</div>
<div>
<span class="label">Team</span>
<span>${teamName ? teamName : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
${slack.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.slackForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.slackForm.botToken}
?disabled=${props.slackTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
botToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>App token</span>
<input
type="password"
.value=${props.slackForm.appToken}
?disabled=${props.slackAppTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
appToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.slackForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.slackForm.allowFrom}
@input=${(e: Event) =>
props.onSlackChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456, *"
/>
</label>
<label class="field">
<span>Group DMs enabled</span>
<select
.value=${props.slackForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DM channels</span>
<input
.value=${props.slackForm.groupChannels}
@input=${(e: Event) =>
props.onSlackChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="G123, #team"
/>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${props.slackForm.reactionNotifications}
@change=${(e: Event) =>
props.onSlackChange({
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
})}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Reaction allowlist</span>
<input
.value=${props.slackForm.reactionAllowlist}
@input=${(e: Event) =>
props.onSlackChange({
reactionAllowlist: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.slackForm.textChunkLimit}
@input=${(e: Event) =>
props.onSlackChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="4000"
/>
</label>
<label class="field">
<span>Media max (MB)</span>
<input
.value=${props.slackForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSlackChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Slash command</div>
<div class="form-grid" style="margin-top: 8px;">
<label class="field">
<span>Slash enabled</span>
<select
.value=${props.slackForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.slackForm.slashName}
@input=${(e: Event) =>
props.onSlackChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.slackForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onSlackChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="slack:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.slackForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Channels</div>
<div class="card-sub">Add channel ids or #names and optionally require mentions.</div>
<div class="list">
${props.slackForm.channels.map(
(channel, channelIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Channel id / name</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
key: (e.target as HTMLInputElement).value,
};
props.onSlackChange({ channels: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
allow: (e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.slackForm.channels];
next.splice(channelIndex, 1);
props.onSlackChange({ channels: next });
}}
>
Remove
</button>
</label>
</div>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onSlackChange({
channels: [
...props.slackForm.channels,
{ key: "", allow: true, requireMention: false },
],
})}
>
Add channel
</button>
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${slackActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.slackForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
actions: {
...props.slackForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
${props.slackTokenLocked || props.slackAppTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the
environment. Config edits will not override it.
</div>`
: nothing}
${props.slackStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.slackStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.slackSaving}
@click=${() => props.onSlackSave()}
>
${props.slackSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
}

View File

@@ -1,248 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type { ChannelAccountSnapshot, TelegramStatus } from "../types";
import type { ConnectionsProps } from "./connections.types";
export function renderTelegramCard(params: {
props: ConnectionsProps;
telegram?: TelegramStatus;
telegramAccounts: ChannelAccountSnapshot[];
accountCountLabel: unknown;
}) {
const { props, telegram, telegramAccounts, accountCountLabel } = params;
const hasMultipleAccounts = telegramAccounts.length > 1;
const renderAccountCard = (account: ChannelAccountSnapshot) => {
const probe = account.probe as { bot?: { username?: string } } | undefined;
const botUsername = probe?.bot?.username;
const label = account.name || account.accountId;
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">
${botUsername ? `@${botUsername}` : label}
</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
<div>
<span class="label">Running</span>
<span>${account.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
</div>
</div>
`;
};
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot token and delivery options.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${telegram?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.telegramForm.token}
?disabled=${props.telegramTokenLocked}
@input=${(e: Event) =>
props.onTelegramChange({
token: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>Apply default group rules</span>
<select
.value=${props.telegramForm.groupsWildcardEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onTelegramChange({
groupsWildcardEnabled:
(e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="no">No</option>
<option value="yes">Yes (allow all groups)</option>
</select>
</label>
<label class="field">
<span>Require mention in groups</span>
<select
.value=${props.telegramForm.requireMention ? "yes" : "no"}
?disabled=${!props.telegramForm.groupsWildcardEnabled}
@change=${(e: Event) =>
props.onTelegramChange({
requireMention: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Allow from</span>
<input
.value=${props.telegramForm.allowFrom}
@input=${(e: Event) =>
props.onTelegramChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="123456789, @team, tg:123"
/>
</label>
<label class="field">
<span>Proxy</span>
<input
.value=${props.telegramForm.proxy}
@input=${(e: Event) =>
props.onTelegramChange({
proxy: (e.target as HTMLInputElement).value,
})}
placeholder="socks5://localhost:9050"
/>
</label>
<label class="field">
<span>Webhook URL</span>
<input
.value=${props.telegramForm.webhookUrl}
@input=${(e: Event) =>
props.onTelegramChange({
webhookUrl: (e.target as HTMLInputElement).value,
})}
placeholder="https://example.com/telegram-webhook"
/>
</label>
<label class="field">
<span>Webhook secret</span>
<input
.value=${props.telegramForm.webhookSecret}
@input=${(e: Event) =>
props.onTelegramChange({
webhookSecret: (e.target as HTMLInputElement).value,
})}
placeholder="secret"
/>
</label>
<label class="field">
<span>Webhook path</span>
<input
.value=${props.telegramForm.webhookPath}
@input=${(e: Event) =>
props.onTelegramChange({
webhookPath: (e.target as HTMLInputElement).value,
})}
placeholder="/telegram-webhook"
/>
</label>
</div>
<div class="callout" style="margin-top: 12px;">
Allow from supports numeric user IDs (recommended) or @usernames. DM the bot
to get your ID, or run /whoami.
</div>
${props.telegramTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it.
</div>`
: nothing}
${props.telegramForm.groupsWildcardEnabled
? html`<div class="callout danger" style="margin-top: 12px;">
This writes telegram.groups["*"] and allows all groups. Remove it
if you only want specific groups.
<div class="row" style="margin-top: 8px;">
<button
class="btn"
@click=${() => props.onTelegramChange({ groupsWildcardEnabled: false })}
>
Remove wildcard
</button>
</div>
</div>`
: nothing}
${props.telegramStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.telegramStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.telegramSaving}
@click=${() => props.onTelegramSave()}
>
${props.telegramSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -1,141 +0,0 @@
import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ConnectionsChannelData,
ConnectionsProps,
} from "./connections.types";
import { channelEnabled, renderChannelAccountCount } from "./connections.shared";
import { renderDiscordCard } from "./connections.discord";
import { renderIMessageCard } from "./connections.imessage";
import { renderSignalCard } from "./connections.signal";
import { renderSlackCard } from "./connections.slack";
import { renderTelegramCard } from "./connections.telegram";
import { renderWhatsAppCard } from "./connections.whatsapp";
export function renderConnections(props: ConnectionsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const channelOrder: ChannelKey[] = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
];
const orderedChannels = channelOrder
.map((key, index) => ({
key,
enabled: channelEnabled(key, props),
order: index,
}))
.sort((a, b) => {
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1;
return a.order - b.order;
});
return html`
<section class="grid grid-cols-2">
${orderedChannels.map((channel) =>
renderChannel(channel.key, props, {
whatsapp,
telegram,
discord,
slack,
signal,
imessage,
channelAccounts: props.snapshot?.channelAccounts ?? null,
}),
)}
</section>
<section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Connection health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</section>
`;
}
function renderChannel(
key: ChannelKey,
props: ConnectionsProps,
data: ConnectionsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
props,
whatsapp: data.whatsapp,
accountCountLabel,
});
case "telegram":
return renderTelegramCard({
props,
telegram: data.telegram,
telegramAccounts: data.channelAccounts?.telegram ?? [],
accountCountLabel,
});
case "discord":
return renderDiscordCard({
props,
discord: data.discord,
accountCountLabel,
});
case "slack":
return renderSlackCard({
props,
slack: data.slack,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,
signal: data.signal,
accountCountLabel,
});
case "imessage":
return renderIMessageCard({
props,
imessage: data.imessage,
accountCountLabel,
});
default:
return nothing;
}
}

View File

@@ -1,81 +0,0 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
DiscordForm,
IMessageForm,
SignalForm,
SlackForm,
TelegramForm,
} from "../ui-types";
export type ChannelKey =
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
export type ConnectionsProps = {
connected: boolean;
loading: boolean;
snapshot: ChannelsStatusSnapshot | null;
lastError: string | null;
lastSuccessAt: number | null;
whatsappMessage: string | null;
whatsappQrDataUrl: string | null;
whatsappConnected: boolean | null;
whatsappBusy: boolean;
telegramForm: TelegramForm;
telegramTokenLocked: boolean;
telegramSaving: boolean;
telegramStatus: string | null;
discordForm: DiscordForm;
discordTokenLocked: boolean;
discordSaving: boolean;
discordStatus: string | null;
slackForm: SlackForm;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackSaving: boolean;
slackStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalStatus: string | null;
imessageForm: IMessageForm;
imessageSaving: boolean;
imessageStatus: string | null;
onRefresh: (probe: boolean) => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;
onTelegramChange: (patch: Partial<TelegramForm>) => void;
onTelegramSave: () => void;
onDiscordChange: (patch: Partial<DiscordForm>) => void;
onDiscordSave: () => void;
onSlackChange: (patch: Partial<SlackForm>) => void;
onSlackSave: () => void;
onSignalChange: (patch: Partial<SignalForm>) => void;
onSignalSave: () => void;
onIMessageChange: (patch: Partial<IMessageForm>) => void;
onIMessageSave: () => void;
};
export type ConnectionsChannelData = {
whatsapp?: WhatsAppStatus;
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
};

View File

@@ -12,7 +12,7 @@ export function renderNodes(props: NodesProps) {
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live connections.</div>
<div class="card-sub">Paired devices and live links.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}

View File

@@ -169,7 +169,7 @@ export function renderOverview(props: OverviewProps) {
${authHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`}
</div>
</section>