fix(channelbridge): IM 发送过滤 action/narration + <p>拆多气泡 + 0.5-2s 间隔
把回复转发给 IM 适配器(sendReply)时剥掉 <action>/<narration> 动作/内心独白(Web 端 才渲染灰斜体,IM 只能收纯文本),并按 <p> 段落拆成多条气泡分别发送;多气泡发送间隔 随机 0.5~2s(仅 IM,Web 前端不延迟)。新增 sanitize.go stripNarration/splitBubbles/ bubbleGap + 测试。
This commit is contained in:
1 parent
dcb0b33954
commit
5a0c1e2fd1
11 files changed
+790
-12
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { Icon, type IconName } from "../../../components/Icon";
|
||||
import { PLATFORM_LABEL } from "../channelFields";
|
||||
import { platformOf } from "./channelUtils";
|
||||
import type { AdapterNav, AdapterSubPage } from "./useAdapterNav";
|
||||
|
||||
interface AdapterNavItem {
|
||||
id: AdapterSubPage;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
const ADAPTER_ITEMS: ReadonlyArray<AdapterNavItem> = [
|
||||
{ id: "overview", label: "活动总览", icon: "chart" },
|
||||
{ id: "create", label: "新建适配器", icon: "plugin" },
|
||||
{ id: "list", label: "活动适配器", icon: "channel" },
|
||||
{ id: "log", label: "模块总日志", icon: "terminal" },
|
||||
];
|
||||
|
||||
/** The sub-nav item that is highlighted: a page id, or "list" while inside an
|
||||
* adapter detail page (entered from the list). */
|
||||
function adapterNavActive(nav: AdapterNav): string {
|
||||
return nav.kind === "page" ? nav.page : "list";
|
||||
}
|
||||
|
||||
/** Content shared by the desktop rail and the mobile drawer so the two never
|
||||
* diverge. When viewing an adapter detail page the platform label is echoed
|
||||
* below the items. */
|
||||
function AdaptersNavContent({ nav, onNavigate }: { nav: AdapterNav; onNavigate: (n: AdapterNav) => void }): React.ReactNode {
|
||||
const active = adapterNavActive(nav);
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">适配器</div>
|
||||
{ADAPTER_ITEMS.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
onClick={() => onNavigate({ kind: "page", page: it.id })}
|
||||
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
|
||||
active === it.id
|
||||
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||||
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
|
||||
}`}
|
||||
>
|
||||
<Icon name={it.icon} size={18} />
|
||||
<span>{it.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{nav.kind === "detail" ? (
|
||||
<div className="mt-2 border-t border-[var(--mu-glass-border)] px-2 pt-2 text-[11px] text-[var(--mu-text-lo)]">
|
||||
<div className="mb-0.5 text-[var(--mu-text-mid)]">{PLATFORM_LABEL[platformOf(nav.adapter)] ?? platformOf(nav.adapter)}</div>
|
||||
<div className="break-all font-mono">{nav.adapter}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Desktop sub-sidebar — fixed, `hidden md:flex`. */
|
||||
export function AdaptersRail({
|
||||
nav,
|
||||
onNavigate,
|
||||
className = "",
|
||||
}: {
|
||||
nav: AdapterNav;
|
||||
onNavigate: (n: AdapterNav) => void;
|
||||
className?: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<nav className={`glass flex flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3 ${className}`}>
|
||||
<AdaptersNavContent nav={nav} onNavigate={onNavigate} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile drawer — overlay + scrim + spring slide-in (reuses mu-drawer-in). */
|
||||
export function AdaptersDrawer({
|
||||
open,
|
||||
nav,
|
||||
onNavigate,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
nav: AdapterNav;
|
||||
onNavigate: (n: AdapterNav) => void;
|
||||
onClose: () => void;
|
||||
}): React.ReactNode {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px]" onClick={onClose} aria-label="关闭适配器导航" />
|
||||
<nav
|
||||
className="glass absolute inset-y-0 left-0 flex w-[var(--mu-sidebar-w)] max-w-[80vw] flex-col gap-1 overflow-y-auto border-r border-[var(--mu-glass-border)] p-3"
|
||||
style={{ animation: "mu-drawer-in 0.3s var(--mu-spring) both" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
<span className="text-xs font-medium text-[var(--mu-text-lo)]">适配器</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 hover:bg-[var(--mu-surface-4)] active:scale-95"
|
||||
>
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<AdaptersNavContent nav={nav} onNavigate={onNavigate} />
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { adminListChannels, adminChannelLogOverview } from "../../../lib/api";
|
||||
import type { AdminChannelMessage } from "../../../lib/types";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import { useAutoReload } from "../../hooks";
|
||||
import { CardGrid, Notice, PageHeader, StatCard } from "../../components";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
|
||||
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
|
||||
import type { AdapterNav } from "./useAdapterNav";
|
||||
|
||||
/** Page ① — aggregate all adapters: connection state, message volume, health.
|
||||
* Recent-event cards drill into a platform or a single adapter detail page. */
|
||||
export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }): React.ReactNode {
|
||||
const chans = useAsyncData(adminListChannels, []);
|
||||
useAutoReload(chans.reload, 10000);
|
||||
const logs = useAsyncData(() => adminChannelLogOverview(20), []);
|
||||
useAutoReload(logs.reload, 10000);
|
||||
|
||||
const records = chans.data ?? [];
|
||||
const configured = configuredPlatforms(records);
|
||||
const accountCount = configuredAccountCount(records);
|
||||
const connected = configured.filter((c) => c.connected).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader title="适配器活动总览" subtitle="聚合所有渠道适配器 · 连接状态 / 收发消息量 / 健康度 / 最近事件" />
|
||||
|
||||
<CardGrid>
|
||||
<StatCard icon={<Icon name="plugin" size={18} />} label="适配器类型" value={PLATFORM_TYPES.length} sub="可用平台类型" tone="accent" />
|
||||
<StatCard icon={<Icon name="channel" size={18} />} label="已连接平台" value={`${connected}/${configured.length}`} sub="已配置平台中连接数" tone="ok" />
|
||||
<StatCard icon={<Icon name="device" size={18} />} label="配置账号" value={accountCount} sub="当前全部账号" tone="neutral" />
|
||||
<StatCard icon={<Icon name="chat" size={18} />} label="最近消息" value={logs.data?.length ?? "—"} sub="最近事件数" tone="warn" />
|
||||
</CardGrid>
|
||||
|
||||
{chans.error && !chans.data ? <Notice tone="err">{chans.error}</Notice> : null}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">最近事件(下钻到平台 / 配置)</h3>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate({ kind: "page", page: "log" })}>
|
||||
全部日志
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={logs.reload}>
|
||||
刷新
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{logs.loading ? (
|
||||
<Notice>加载中…</Notice>
|
||||
) : logs.error ? (
|
||||
<Notice tone="err">{logs.error}</Notice>
|
||||
) : !logs.data || logs.data.length === 0 ? (
|
||||
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs.data.map((m) => (
|
||||
<RecentEvent key={m.id} m={m} onDrill={(adapter) => navigate({ kind: "detail", adapter })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
|
||||
{configured.length ? (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">已配置平台</h3>
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="soft" onClick={() => navigate({ kind: "page", page: "list" })}>
|
||||
活动适配器列表
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{configured.map((c) => {
|
||||
const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id);
|
||||
return (
|
||||
<button
|
||||
key={c.name}
|
||||
onClick={() => navigate({ kind: "detail", adapter: first })}
|
||||
className="flex flex-col gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] p-3 text-left transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98]"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</Tag>
|
||||
<Tag tone={c.connected ? "ok" : "err"}>{c.connected ? "已连接" : "未连接"}</Tag>
|
||||
</span>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
{c.accounts?.length ?? 0} 个账号 · 点击进入该平台/配置专属页
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One recent event card; clicking a message with an adapter drills into it. */
|
||||
function RecentEvent({ m, onDrill }: { m: AdminChannelMessage; onDrill: (adapter: string) => void }): React.ReactNode {
|
||||
const isAssistant = m.role === "assistant";
|
||||
return (
|
||||
<button
|
||||
onClick={() => (m.adapterId ? onDrill(m.adapterId) : undefined)}
|
||||
className={`w-full rounded-[var(--mu-radius)] px-3 py-2 text-left transition-colors ${
|
||||
m.adapterId ? "bg-[var(--mu-surface-3)]/70 hover:bg-[var(--mu-surface-3)]" : "cursor-default bg-[var(--mu-surface-2)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
|
||||
{m.adapterId ? <Tag tone="accent">{m.adapterId}</Tag> : null}
|
||||
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
|
||||
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { adminChannelTest } from "../../../lib/api";
|
||||
import type { ChannelTestResult } from "../../../lib/types";
|
||||
import { useAuditStore } from "../../audit";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
|
||||
/** Structural identity test (design/08: actor metadata × owner whitelist — never a
|
||||
* prompt/LLM judgment). Self-contained so the list page and any adapter detail
|
||||
* page can render it; `defaultPlatform` prefills when known. */
|
||||
export function ChannelIdentityTest({
|
||||
defaultPlatform = "",
|
||||
disabled = false,
|
||||
}: {
|
||||
defaultPlatform?: string;
|
||||
disabled?: boolean;
|
||||
}): React.ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [platform, setPlatform] = useState(defaultPlatform);
|
||||
const [ownerId, setOwnerId] = useState("");
|
||||
const [group, setGroup] = useState(false);
|
||||
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const test = () => {
|
||||
if (!ownerId.trim()) {
|
||||
setErr("邮箱/账号 id 不能为空");
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
setErr(null);
|
||||
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
|
||||
.then((v) => {
|
||||
log("channel.identity-test", "ok");
|
||||
setVerdict(v);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("channel.identity-test", "error");
|
||||
setErr(e instanceof Error ? e.message : "身份测试失败");
|
||||
})
|
||||
.finally(() => setTesting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface gap="sm" className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
placeholder="platform(如 kook,可留空)"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<input
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwnerId(e.target.value)}
|
||||
placeholder="渠道账号 id(发送者 id)"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
|
||||
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
|
||||
群聊
|
||||
</label>
|
||||
<Button variant="primary" disabled={disabled || testing} onClick={test}>
|
||||
测试
|
||||
</Button>
|
||||
</div>
|
||||
{err ? <Tag tone="err">{err}</Tag> : null}
|
||||
{verdict ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||||
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
|
||||
<div>判定:{verdict.verdict}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { adminChannelMessages, adminChannelLogOverview } from "../../../lib/api";
|
||||
import type { AdminChannelMessage } from "../../../lib/types";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import { Button, Tag } from "../../../components/primitives";
|
||||
import { Notice } from "../../components";
|
||||
import { useAutoReload } from "../../hooks";
|
||||
import { PLATFORM_LABEL } from "../channelFields";
|
||||
import { platformOf } from "./channelUtils";
|
||||
|
||||
/** One message line in the adapter log. */
|
||||
export function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
|
||||
const isAssistant = m.role === "assistant";
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[var(--mu-radius)] px-3 py-2 ${
|
||||
isAssistant ? "bg-[var(--mu-surface-2)]" : "bg-[var(--mu-surface-3)]/70"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
|
||||
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
|
||||
{showAdapter && m.adapterId ? <Tag tone="neutral">{m.adapterId}</Tag> : null}
|
||||
{m.deviceId ? <span className="font-mono text-[var(--mu-text-lo)]">{m.deviceId}</span> : null}
|
||||
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline send/receive message stream for one adapter (adapter != null) or the
|
||||
* aggregate overview (adapter == null). Backend returns newest-first; display
|
||||
* oldest→newest for a natural read. Owns its toolbar so every surface (list
|
||||
* detail, module log, overview drill) reuses the same fetch + reload. */
|
||||
export function ChannelLogList({
|
||||
adapter,
|
||||
limit = 200,
|
||||
className = "",
|
||||
}: {
|
||||
adapter: string | null;
|
||||
limit?: number;
|
||||
className?: string;
|
||||
}): React.ReactNode {
|
||||
const isOverview = adapter == null;
|
||||
const platform = adapter ? platformOf(adapter) : "";
|
||||
const { data, loading, error, reload } = useAsyncData(
|
||||
() => (adapter ? adminChannelMessages(adapter, limit) : adminChannelLogOverview(limit)),
|
||||
[adapter, limit],
|
||||
);
|
||||
useAutoReload(reload, 10000);
|
||||
const msgs = data ? [...data].reverse() : data;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-1 pb-2">
|
||||
{platform ? <Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag> : <Tag tone="accent">总览</Tag>}
|
||||
{adapter ? <Tag tone="neutral">{adapter}</Tag> : null}
|
||||
<h3 className="text-sm font-semibold">{isOverview ? "适配器总览日志" : "消息日志"}</h3>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/channels/messages</span>
|
||||
<Button size="sm" variant="ghost" onClick={reload}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 pt-3">
|
||||
{loading ? (
|
||||
<Notice>加载中…</Notice>
|
||||
) : error ? (
|
||||
<Notice tone="err">{error}</Notice>
|
||||
) : !msgs || msgs.length === 0 ? (
|
||||
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
|
||||
) : (
|
||||
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from "../../../components/primitives";
|
||||
import { ChannelLogList } from "./channelLogList";
|
||||
|
||||
/** Overlay that renders one adapter config's send/receive message log, or the
|
||||
* adapter overview (adapter == null). Reused by the list page's per-account
|
||||
* "消息日志" button. */
|
||||
export function ChannelLogPanel({ adapter, onClose }: { adapter: string | null; onClose: () => void }): React.ReactNode {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-3xl rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-1)] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
|
||||
<span className="text-sm font-semibold">消息日志</span>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">弹出视图</span>
|
||||
<Button size="sm" variant="soft" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||
<ChannelLogList adapter={adapter} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AdminChannelRecord } from "../../../lib/types";
|
||||
import { PLATFORM_FIELDS } from "../channelFields";
|
||||
import type { ChannelConfigInitial } from "../channelConfigForm";
|
||||
|
||||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||||
export function platformOf(name: string): string {
|
||||
return name.split(":")[0];
|
||||
}
|
||||
|
||||
/** Adapter (platform:account) name for one account config. The bridge registers a
|
||||
* channels.json account as "kook:conf0" (env single-account = bare platform), so a
|
||||
* config id of "default"/"" maps to the bare platform name. */
|
||||
export function adapterNameOf(platform: string, accountId?: string): string {
|
||||
const id = accountId?.trim();
|
||||
return id && id !== "default" ? `${platform}:${id}` : platform;
|
||||
}
|
||||
|
||||
/** Open-config target: the platform + the prefill bundle for its form. */
|
||||
export interface CfgTarget {
|
||||
platform: string;
|
||||
initial: ChannelConfigInitial;
|
||||
}
|
||||
|
||||
/** Build the config-form prefill from a platform name and the channel records.
|
||||
* Do NOT put the masked placeholder into `token`: it would be written back as
|
||||
* the real bot_token (overwriting the stored secret with a demo string). Keep
|
||||
* token empty (save omits it → keeps the existing secret) and show a hint. */
|
||||
export function buildCfgTarget(name: string, records: AdminChannelRecord[]): CfgTarget {
|
||||
const plat = platformOf(name);
|
||||
const acct = records.find((c) => platformOf(c.name) === plat)?.accounts?.[0];
|
||||
const acctCfg = acct?.config ?? {};
|
||||
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !acctCfg.bot_token.includes("(已配置");
|
||||
const platVals: Record<string, string> = {};
|
||||
for (const f of PLATFORM_FIELDS[plat] ?? []) {
|
||||
const v = acctCfg[f.key];
|
||||
platVals[f.key] = v == null ? "" : String(v);
|
||||
}
|
||||
return {
|
||||
platform: plat,
|
||||
initial: {
|
||||
accountId: acct?.id ?? "",
|
||||
tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "",
|
||||
baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "",
|
||||
platVals,
|
||||
allowGroups: (acct?.allowGroups ?? []).join(","),
|
||||
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||||
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||||
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||||
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
||||
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
||||
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The set of platforms that have at least one configured account. */
|
||||
export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] {
|
||||
return records.filter((c) => (c.accounts?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
/** Total configured accounts across all platforms. */
|
||||
export function configuredAccountCount(records: AdminChannelRecord[]): number {
|
||||
return configuredPlatforms(records).reduce((n, c) => n + (c.accounts?.length ?? 0), 0);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/** The four flat pages of the adapter module. */
|
||||
export type AdapterSubPage = "overview" | "create" | "list" | "log";
|
||||
|
||||
/** Module-internal navigation state: a flat page, or one configured account
|
||||
* (adapter_id, e.g. "kook:3bef…") detail page. */
|
||||
export type AdapterNav =
|
||||
| { kind: "page"; page: AdapterSubPage }
|
||||
| { kind: "detail"; adapter: string };
|
||||
|
||||
const BASE = "#/admin/channels";
|
||||
|
||||
/** Parse a hash like `#/admin/channels/list` or `#/admin/channels/detail/kook:x`
|
||||
* into AdapterNav. Unknown/absent sub-route falls back to the overview page. */
|
||||
function parseAdapterHash(hash: string): AdapterNav {
|
||||
const raw = hash.replace(/^#/, "").replace(/^\/+/, "").split("/");
|
||||
if (raw[0] !== "admin" || raw[1] !== "channels") return { kind: "page", page: "overview" };
|
||||
const sub = raw[2];
|
||||
if (sub === "create" || sub === "list" || sub === "log") return { kind: "page", page: sub };
|
||||
if (sub === "detail" && raw[3]) return { kind: "detail", adapter: decodeURIComponent(raw[3]) };
|
||||
return { kind: "page", page: "overview" };
|
||||
}
|
||||
|
||||
/** Build the hash for a nav state so refresh / browser-back keep the sub-page.
|
||||
* Overview maps to the plain section hash, so the outer admin router still
|
||||
* resolves `#/admin/channels` unchanged. */
|
||||
function buildAdapterHash(nav: AdapterNav): string {
|
||||
if (nav.kind === "detail") return `${BASE}/detail/${encodeURIComponent(nav.adapter)}`;
|
||||
return nav.page === "overview" ? BASE : `${BASE}/${nav.page}`;
|
||||
}
|
||||
|
||||
/** Routes the adapter module by writing the sub-page into the location hash.
|
||||
* The outer admin hash router (router.ts) only reads `#/admin/<section>`, so a
|
||||
* deeper `overview|create|list|log|detail/<adapter>` segment is channel-private
|
||||
* and never collides. */
|
||||
export function useAdapterNav(): { nav: AdapterNav; navigate: (next: AdapterNav) => void } {
|
||||
const [nav, setNav] = useState<AdapterNav>(() => parseAdapterHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setNav(parseAdapterHash(window.location.hash));
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((next: AdapterNav): void => {
|
||||
const hash = buildAdapterHash(next);
|
||||
setNav(next);
|
||||
if (window.location.hash !== hash) window.location.hash = hash;
|
||||
}, []);
|
||||
|
||||
return { nav, navigate };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
adminChannelOpen,
|
||||
adminChannelClose,
|
||||
adminDeleteChannelAccount,
|
||||
} from "../../../lib/api";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
/** Shared connect/disconnect/delete actions + busy/error/notice state, reused by
|
||||
* the list page and each adapter detail page. `reload` re-fetches the channel
|
||||
* list after a mutation. */
|
||||
export function useChannelActions(reload: () => void) {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const toggle = useCallback(
|
||||
(name: string, connect: boolean) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
|
||||
call
|
||||
.then(() => {
|
||||
log(connect ? "channel.open" : "channel.close", "ok");
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log(connect ? "channel.open" : "channel.close", "error");
|
||||
setError(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
},
|
||||
[reload, log],
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
(platform: string, id: string) => {
|
||||
setBusy("__del__" + id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
adminDeleteChannelAccount(platform, id)
|
||||
.then(() => {
|
||||
log("channel.delete", "ok");
|
||||
setNotice(`已删除账号 ${id} 并热更新 ${platform}`);
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("channel.delete", "error");
|
||||
setError(e instanceof Error ? e.message : "删除渠道账号失败");
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
},
|
||||
[reload, log],
|
||||
);
|
||||
|
||||
return { busy, setBusy, error, setError, notice, setNotice, toggle, del };
|
||||
}
|
||||
@@ -597,19 +597,38 @@ func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a ada
|
||||
if a == nil {
|
||||
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||
}
|
||||
out := adapter.UnifiedMessage{
|
||||
Platform: in.Platform,
|
||||
ChannelType: in.ChannelType,
|
||||
ChannelID: in.ChannelID,
|
||||
GroupID: in.GroupID,
|
||||
GroupName: in.GroupName,
|
||||
SenderID: in.SenderID,
|
||||
Sender: in.Sender,
|
||||
Content: text,
|
||||
ContentType: adapter.ContentText,
|
||||
Timestamp: time.Now().UTC(),
|
||||
// IM channels receive the reply as one or more bubbles: role-play/action/narration
|
||||
// markup (rendered as muted italic on the Web) is stripped, and each <p> paragraph
|
||||
// becomes its own bubble/message (design/17 §2). A reply that was only an action is
|
||||
// dropped rather than sent empty. A multi-bubble reply spaces its sends 0.5-2s apart
|
||||
// so a burst doesn't flood the channel; the Web frontend has no such delay.
|
||||
bubbles := splitBubbles(text)
|
||||
if len(bubbles) == 0 {
|
||||
b.log.Debug("reply has no plain text, skipped", "platform", in.Platform, "channel", in.ChannelID)
|
||||
return nil
|
||||
}
|
||||
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}})
|
||||
for i, content := range bubbles {
|
||||
if i > 0 {
|
||||
time.Sleep(bubbleGap())
|
||||
}
|
||||
out := adapter.UnifiedMessage{
|
||||
Platform: in.Platform,
|
||||
ChannelType: in.ChannelType,
|
||||
ChannelID: in.ChannelID,
|
||||
GroupID: in.GroupID,
|
||||
GroupName: in.GroupName,
|
||||
SenderID: in.SenderID,
|
||||
Sender: in.Sender,
|
||||
Content: content,
|
||||
ContentType: adapter.ContentText,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
// Each bubble is its own send so the gap lands between messages.
|
||||
if err := a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}}); err != nil {
|
||||
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adapter returns the registered adapter for a platform name, or nil.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the chat-reply sanitizer used before a reply is delivered to an
|
||||
// IM channel adapter (design/17 §2 + the role-play convention: the Web client
|
||||
// renders <action>/<narration> as muted italic; IM channels must receive plain chat
|
||||
// text only, with the action/monologue markup removed).
|
||||
|
||||
var (
|
||||
// pairedRoleplayRe matches a complete <action>...</action> / <narration>...</narration>
|
||||
// block including its inner text, so an action/monologue is dropped wholesale before
|
||||
// IM delivery. Non-greedy and case-insensitive to tolerate a model that emits <Action>.
|
||||
pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?</(action|narration)\s*>`)
|
||||
// strayRoleplayRe matches any remaining single opening/closing role-play tag (for a
|
||||
// malformed or unpaired emission).
|
||||
strayRoleplayRe = regexp.MustCompile(`(?is)</?(action|narration)\b[^>]*?>`)
|
||||
// paragraphRe matches <p>...</p> / <p/> paragraph markers, keeping the inner text.
|
||||
paragraphRe = regexp.MustCompile(`(?is)</?p\b[^>]*?>`)
|
||||
// blankLinesRe collapses runs of blank lines left behind after stripping.
|
||||
blankLinesRe = regexp.MustCompile(`\n\s*\n+`)
|
||||
)
|
||||
|
||||
// stripNarration returns the plain chat text of a reply with role-play/action/
|
||||
// narration markup removed, paragraph markers flattened and blank-line runs
|
||||
// collapsed. It never raises or panics on malformed input; a reply that was only an
|
||||
// action/monologue becomes an empty string (caller may then skip the send).
|
||||
func stripNarration(text string) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return ""
|
||||
}
|
||||
s := pairedRoleplayRe.ReplaceAllString(text, "")
|
||||
s = strayRoleplayRe.ReplaceAllString(s, "")
|
||||
s = paragraphRe.ReplaceAllString(s, "")
|
||||
s = blankLinesRe.ReplaceAllString(s, "\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// paragraphBlockRe matches an entire <p>...</p> block, capturing its inner text
|
||||
// (design: each <p> is one chat bubble; the Web client renders each as its own
|
||||
// bubble and the IM adapters deliver each as its own message).
|
||||
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
|
||||
|
||||
// splitBubbles turns a reply into the list of chat bubbles it carries, in order.
|
||||
// Each <p>...</p> block is one bubble, with role-play/action/narration stripped from
|
||||
// its text; empty bubbles are dropped. A reply with no <p> markers is treated as a
|
||||
// single bubble (newlines preserved). A pure action/monologue yields an empty slice.
|
||||
func splitBubbles(text string) []string {
|
||||
if !strings.Contains(strings.ToLower(text), "<p") {
|
||||
if c := stripNarration(text); c != "" {
|
||||
return []string{c}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0)
|
||||
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
|
||||
if c := stripNarration(m[1]); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// <p> markers present but no well-formed block — fall back to whole text.
|
||||
if c := stripNarration(text); c != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bubbleGap returns a randomized 0.5s..2s delay used between IM outbound bubbles so a
|
||||
// burst of paragraphs doesn't flood the channel (design/17 §2). The Web frontend is
|
||||
// not affected — it renders all bubbles from the persisted stream without any delay.
|
||||
// It is only consulted when a reply carries more than one bubble.
|
||||
func bubbleGap() time.Duration {
|
||||
return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripNarration_RemovesActionBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<action>弯起眼睛笑了</action>\n当然看得到呀,亲爱的。\n怎么突然这么问,是想我了吗?"
|
||||
got := stripNarration(in)
|
||||
want := "当然看得到呀,亲爱的。\n怎么突然这么问,是想我了吗?"
|
||||
if got != want {
|
||||
t.Errorf("stripNarration = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_RemovesNarrationAndParagraphs(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<p>你好呀</p>\n<narration>心里有点甜</narration>\n<p>今天天气不错</p>"
|
||||
got := stripNarration(in)
|
||||
// Paragraph markers flattened to newlines; narration block dropped entirely.
|
||||
if got != "你好呀\n今天天气不错" {
|
||||
t.Errorf("stripNarration = %q, want %q", got, "你好呀\n今天天气不错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_PureChatKept(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "就是普通一句话,没有动作。\n第二行。"
|
||||
if got := stripNarration(in); got != in {
|
||||
t.Errorf("stripNarration changed plain text: %q -> %q", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_OnlyActionBecomesEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<action>拍拍你</action>"
|
||||
if got := stripNarration(in); got != "" {
|
||||
t.Errorf("stripNarration = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripNarration_CaseAndStrayTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<Action>扑上来</Action> 你好 <narration/> 世界"
|
||||
got := stripNarration(in)
|
||||
if strings.Contains(got, "action") || strings.Contains(got, "narration") || strings.Contains(got, "扑上来") {
|
||||
t.Errorf("stripNarration left role-play markup: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "你好") || !strings.Contains(got, "世界") {
|
||||
t.Errorf("stripNarration dropped chat text: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitBubbles_Paragraphs(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<p>晚上好呀,亲爱的♪</p><p>怎么突然这么问,是想我了吗?</p>"
|
||||
got := splitBubbles(in)
|
||||
if len(got) != 2 || got[0] != "晚上好呀,亲爱的♪" || got[1] != "怎么突然这么问,是想我了吗?" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitBubbles_StripsRoleplayAndDropsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "<p><action>弯起眼睛笑了</action>当然看得到呀。</p><p>❤</p>"
|
||||
got := splitBubbles(in)
|
||||
if len(got) != 2 || got[0] != "当然看得到呀。" {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
if got[1] != "❤" {
|
||||
t.Errorf("splitBubbles[1] = %q, want ❤", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitBubbles_NoParagraphIsSingleBubble(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "就是普通一句话\n带个换行。"
|
||||
got := splitBubbles(in)
|
||||
if len(got) != 1 || got[0] != in {
|
||||
t.Errorf("splitBubbles = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitBubbles_OnlyActionIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := splitBubbles("<action>拍拍你</action>"); len(got) != 0 {
|
||||
t.Errorf("splitBubbles = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user