refactor(ui): split config and connections views
parent
a44f1912b3
commit
acfa762617
|
|
@ -19,8 +19,5 @@
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "4.0.17"
|
"vitest": "4.0.17"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"minimumReleaseAge": 2880
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ import {
|
||||||
type SignalForm,
|
type SignalForm,
|
||||||
type TelegramForm,
|
type TelegramForm,
|
||||||
} from "../ui-types";
|
} from "../ui-types";
|
||||||
|
import {
|
||||||
|
cloneConfigObject,
|
||||||
|
removePathValue,
|
||||||
|
serializeConfigForm,
|
||||||
|
setPathValue,
|
||||||
|
} from "./config/form-utils";
|
||||||
|
|
||||||
export type ConfigState = {
|
export type ConfigState = {
|
||||||
client: GatewayBrowserClient | null;
|
client: GatewayBrowserClient | null;
|
||||||
|
|
@ -477,84 +483,3 @@ export function removeConfigFormValue(
|
||||||
state.configRaw = serializeConfigForm(base);
|
state.configRaw = serializeConfigForm(base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneConfigObject<T>(value: T): T {
|
|
||||||
if (typeof structuredClone === "function") {
|
|
||||||
return structuredClone(value);
|
|
||||||
}
|
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeConfigForm(form: Record<string, unknown>): string {
|
|
||||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 removePathValue(
|
|
||||||
obj: Record<string, unknown> | unknown[],
|
|
||||||
path: Array<string | number>,
|
|
||||||
) {
|
|
||||||
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];
|
|
||||||
if (typeof key === "number") {
|
|
||||||
if (!Array.isArray(current)) return;
|
|
||||||
current = current[key] as Record<string, unknown> | unknown[];
|
|
||||||
} else {
|
|
||||||
if (typeof current !== "object" || current == null) return;
|
|
||||||
current = (current as Record<string, unknown>)[key] as
|
|
||||||
| Record<string, unknown>
|
|
||||||
| unknown[];
|
|
||||||
}
|
|
||||||
if (current == null) return;
|
|
||||||
}
|
|
||||||
const lastKey = path[path.length - 1];
|
|
||||||
if (typeof lastKey === "number") {
|
|
||||||
if (Array.isArray(current)) {
|
|
||||||
current.splice(lastKey, 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof current === "object" && current != null) {
|
|
||||||
delete (current as Record<string, unknown>)[lastKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
export function cloneConfigObject<T>(value: T): T {
|
||||||
|
if (typeof structuredClone === "function") {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeConfigForm(form: Record<string, unknown>): string {
|
||||||
|
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePathValue(
|
||||||
|
obj: Record<string, unknown> | unknown[],
|
||||||
|
path: Array<string | number>,
|
||||||
|
) {
|
||||||
|
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];
|
||||||
|
if (typeof key === "number") {
|
||||||
|
if (!Array.isArray(current)) return;
|
||||||
|
current = current[key] as Record<string, unknown> | unknown[];
|
||||||
|
} else {
|
||||||
|
if (typeof current !== "object" || current == null) return;
|
||||||
|
current = (current as Record<string, unknown>)[key] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| unknown[];
|
||||||
|
}
|
||||||
|
if (current == null) return;
|
||||||
|
}
|
||||||
|
const lastKey = path[path.length - 1];
|
||||||
|
if (typeof lastKey === "number") {
|
||||||
|
if (Array.isArray(current)) current.splice(lastKey, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof current === "object" && current != null) {
|
||||||
|
delete (current as Record<string, unknown>)[lastKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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 }>;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -12,85 +12,21 @@ import type {
|
||||||
WhatsAppStatus,
|
WhatsAppStatus,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type {
|
import type {
|
||||||
DiscordActionForm,
|
|
||||||
DiscordForm,
|
DiscordForm,
|
||||||
IMessageForm,
|
IMessageForm,
|
||||||
SlackActionForm,
|
|
||||||
SlackForm,
|
SlackForm,
|
||||||
SignalForm,
|
SignalForm,
|
||||||
TelegramForm,
|
TelegramForm,
|
||||||
} from "../ui-types";
|
} from "../ui-types";
|
||||||
|
import type {
|
||||||
const discordActionOptions = [
|
ChannelKey,
|
||||||
{ key: "reactions", label: "Reactions" },
|
ConnectionsChannelData,
|
||||||
{ key: "stickers", label: "Stickers" },
|
ConnectionsProps,
|
||||||
{ key: "polls", label: "Polls" },
|
} from "./connections.types";
|
||||||
{ key: "permissions", label: "Permissions" },
|
import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared";
|
||||||
{ key: "messages", label: "Messages" },
|
import { discordActionOptions, slackActionOptions } from "./connections.action-options";
|
||||||
{ key: "threads", label: "Threads" },
|
import { renderTelegramCard } from "./connections.telegram";
|
||||||
{ key: "pins", label: "Pins" },
|
import { renderWhatsAppCard } from "./connections.whatsapp";
|
||||||
{ 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 }>;
|
|
||||||
|
|
||||||
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 }>;
|
|
||||||
|
|
||||||
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 function renderConnections(props: ConnectionsProps) {
|
export function renderConnections(props: ConnectionsProps) {
|
||||||
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
||||||
|
|
@ -158,426 +94,29 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChannelKey =
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage";
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChannelAccountCount(
|
|
||||||
key: ChannelKey,
|
|
||||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
|
|
||||||
): number {
|
|
||||||
return channelAccounts?.[key]?.length ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChannel(
|
function renderChannel(
|
||||||
key: ChannelKey,
|
key: ChannelKey,
|
||||||
props: ConnectionsProps,
|
props: ConnectionsProps,
|
||||||
data: {
|
data: ConnectionsChannelData,
|
||||||
whatsapp?: WhatsAppStatus;
|
|
||||||
telegram?: TelegramStatus;
|
|
||||||
discord?: DiscordStatus | null;
|
|
||||||
slack?: SlackStatus | null;
|
|
||||||
signal?: SignalStatus | null;
|
|
||||||
imessage?: IMessageStatus | null;
|
|
||||||
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const accountCountLabel = renderChannelAccountCount(
|
const accountCountLabel = renderChannelAccountCount(
|
||||||
key,
|
key,
|
||||||
data.channelAccounts,
|
data.channelAccounts,
|
||||||
);
|
);
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "whatsapp": {
|
case "whatsapp":
|
||||||
const whatsapp = data.whatsapp;
|
return renderWhatsAppCard({
|
||||||
return html`
|
props,
|
||||||
<div class="card">
|
whatsapp: data.whatsapp,
|
||||||
<div class="card-title">WhatsApp</div>
|
accountCountLabel,
|
||||||
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
});
|
||||||
${accountCountLabel}
|
case "telegram":
|
||||||
|
return renderTelegramCard({
|
||||||
<div class="status-list" style="margin-top: 16px;">
|
props,
|
||||||
<div>
|
telegram: data.telegram,
|
||||||
<span class="label">Configured</span>
|
telegramAccounts: data.channelAccounts?.telegram ?? [],
|
||||||
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
accountCountLabel,
|
||||||
</div>
|
});
|
||||||
<div>
|
|
||||||
<span class="label">Linked</span>
|
|
||||||
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Running</span>
|
|
||||||
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Connected</span>
|
|
||||||
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Last connect</span>
|
|
||||||
<span>
|
|
||||||
${whatsapp?.lastConnectedAt
|
|
||||||
? formatAgo(whatsapp.lastConnectedAt)
|
|
||||||
: "n/a"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Last message</span>
|
|
||||||
<span>
|
|
||||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="label">Auth age</span>
|
|
||||||
<span>
|
|
||||||
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${whatsapp?.lastError
|
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
|
||||||
${whatsapp.lastError}
|
|
||||||
</div>`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
${props.whatsappMessage
|
|
||||||
? html`<div class="callout" style="margin-top: 12px;">
|
|
||||||
${props.whatsappMessage}
|
|
||||||
</div>`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
${props.whatsappQrDataUrl
|
|
||||||
? html`<div class="qr-wrap">
|
|
||||||
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
|
||||||
</div>`
|
|
||||||
: nothing}
|
|
||||||
|
|
||||||
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
|
||||||
<button
|
|
||||||
class="btn primary"
|
|
||||||
?disabled=${props.whatsappBusy}
|
|
||||||
@click=${() => props.onWhatsAppStart(false)}
|
|
||||||
>
|
|
||||||
${props.whatsappBusy ? "Working…" : "Show QR"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
?disabled=${props.whatsappBusy}
|
|
||||||
@click=${() => props.onWhatsAppStart(true)}
|
|
||||||
>
|
|
||||||
Relink
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn"
|
|
||||||
?disabled=${props.whatsappBusy}
|
|
||||||
@click=${() => props.onWhatsAppWait()}
|
|
||||||
>
|
|
||||||
Wait for scan
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn danger"
|
|
||||||
?disabled=${props.whatsappBusy}
|
|
||||||
@click=${() => props.onWhatsAppLogout()}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
<button class="btn" @click=${() => props.onRefresh(true)}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
case "telegram": {
|
|
||||||
const telegram = data.telegram;
|
|
||||||
const telegramAccounts = data.channelAccounts?.telegram ?? [];
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
case "discord": {
|
case "discord": {
|
||||||
const discord = data.discord;
|
const discord = data.discord;
|
||||||
const botName = discord?.probe?.bot?.username;
|
const botName = discord?.probe?.bot?.username;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function renderWhatsAppCard(params: {
|
||||||
|
props: ConnectionsProps;
|
||||||
|
whatsapp?: WhatsAppStatus;
|
||||||
|
accountCountLabel: unknown;
|
||||||
|
}) {
|
||||||
|
const { props, whatsapp, accountCountLabel } = params;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">WhatsApp</div>
|
||||||
|
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
|
||||||
|
${accountCountLabel}
|
||||||
|
|
||||||
|
<div class="status-list" style="margin-top: 16px;">
|
||||||
|
<div>
|
||||||
|
<span class="label">Configured</span>
|
||||||
|
<span>${whatsapp?.configured ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Linked</span>
|
||||||
|
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Running</span>
|
||||||
|
<span>${whatsapp?.running ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Connected</span>
|
||||||
|
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Last connect</span>
|
||||||
|
<span>
|
||||||
|
${whatsapp?.lastConnectedAt
|
||||||
|
? formatAgo(whatsapp.lastConnectedAt)
|
||||||
|
: "n/a"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Last message</span>
|
||||||
|
<span>
|
||||||
|
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Auth age</span>
|
||||||
|
<span>
|
||||||
|
${whatsapp?.authAgeMs != null
|
||||||
|
? formatDuration(whatsapp.authAgeMs)
|
||||||
|
: "n/a"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${whatsapp?.lastError
|
||||||
|
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||||
|
${whatsapp.lastError}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${props.whatsappMessage
|
||||||
|
? html`<div class="callout" style="margin-top: 12px;">
|
||||||
|
${props.whatsappMessage}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
${props.whatsappQrDataUrl
|
||||||
|
? html`<div class="qr-wrap">
|
||||||
|
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
?disabled=${props.whatsappBusy}
|
||||||
|
@click=${() => props.onWhatsAppStart(false)}
|
||||||
|
>
|
||||||
|
${props.whatsappBusy ? "Working…" : "Show QR"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
?disabled=${props.whatsappBusy}
|
||||||
|
@click=${() => props.onWhatsAppStart(true)}
|
||||||
|
>
|
||||||
|
Relink
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
?disabled=${props.whatsappBusy}
|
||||||
|
@click=${() => props.onWhatsAppWait()}
|
||||||
|
>
|
||||||
|
Wait for scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn danger"
|
||||||
|
?disabled=${props.whatsappBusy}
|
||||||
|
@click=${() => props.onWhatsAppLogout()}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue