openclaw/ui/src/ui/views/channels.ts

330 lines
10 KiB
TypeScript

import { html, nothing } from "lit";
import { formatAgo } from "../format";
import type {
ChannelAccountSnapshot,
ChannelUiMetaEntry,
ChannelsStatusSnapshot,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
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 { renderGoogleChatCard } from "./channels.googlechat";
import { renderIMessageCard } from "./channels.imessage";
import { renderNostrCard } from "./channels.nostr";
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 googlechat = (channels?.googlechat ?? null) as GoogleChatStatus | 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 nostr = (channels?.nostr ?? null) as NostrStatus | 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,
googlechat,
slack,
signal,
imessage,
nostr,
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?.channelMeta?.length) {
return snapshot.channelMeta.map((entry) => entry.id) as ChannelKey[];
}
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return [
"whatsapp",
"telegram",
"discord",
"googlechat",
"slack",
"signal",
"imessage",
"nostr",
];
}
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 "googlechat":
return renderGoogleChatCard({
props,
googlechat: data.googlechat,
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,
});
case "nostr": {
const nostrAccounts = data.channelAccounts?.nostr ?? [];
const primaryAccount = nostrAccounts[0];
const accountId = primaryAccount?.accountId ?? "default";
const profile =
(primaryAccount as { profile?: NostrProfile | null } | undefined)?.profile ?? null;
const showForm =
props.nostrProfileAccountId === accountId ? props.nostrProfileFormState : null;
const profileFormCallbacks = showForm
? {
onFieldChange: props.onNostrProfileFieldChange,
onSave: props.onNostrProfileSave,
onImport: props.onNostrProfileImport,
onCancel: props.onNostrProfileCancel,
onToggleAdvanced: props.onNostrProfileToggleAdvanced,
}
: null;
return renderNostrCard({
props,
nostr: data.nostr,
nostrAccounts,
accountCountLabel,
profileFormState: showForm,
profileFormCallbacks,
onEditProfile: () => props.onNostrProfileEdit(accountId, profile),
});
}
default:
return renderGenericChannelCard(key, props, data.channelAccounts ?? {});
}
}
function renderGenericChannelCard(
key: ChannelKey,
props: ChannelsProps,
channelAccounts: Record<string, ChannelAccountSnapshot[]>,
) {
const label = resolveChannelLabel(props.snapshot, 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 resolveChannelMetaMap(
snapshot: ChannelsStatusSnapshot | null,
): Record<string, ChannelUiMetaEntry> {
if (!snapshot?.channelMeta?.length) return {};
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
}
function resolveChannelLabel(
snapshot: ChannelsStatusSnapshot | null,
key: string,
): string {
const meta = resolveChannelMetaMap(snapshot)[key];
return meta?.label ?? snapshot?.channelLabels?.[key] ?? key;
}
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
if (!account.lastInboundAt) return false;
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
}
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
if (account.running) return "Yes";
// If we have recent inbound activity, the channel is effectively running
if (hasRecentActivity(account)) return "Active";
return "No";
}
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
if (account.connected === true) return "Yes";
if (account.connected === false) return "No";
// If connected is null/undefined but we have recent activity, show as active
if (hasRecentActivity(account)) return "Active";
return "n/a";
}
function renderGenericAccount(account: ChannelAccountSnapshot) {
const runningStatus = deriveRunningStatus(account);
const connectedStatus = deriveConnectedStatus(account);
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>${runningStatus}</span>
</div>
<div>
<span class="label">Configured</span>
<span>${account.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connectedStatus}</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>
`;
}