From 5a0c1e2fd1a2f09222cb31f651361b06156cb8c2 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 17:50:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(channelbridge):=20IM=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=20action/narration=20+=20

=E6=8B=86?= =?UTF-8?q?=E5=A4=9A=E6=B0=94=E6=B3=A1=20+=200.5-2s=20=E9=97=B4=E9=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把回复转发给 IM 适配器(sendReply)时剥掉 / 动作/内心独白(Web 端 才渲染灰斜体,IM 只能收纯文本),并按

段落拆成多条气泡分别发送;多气泡发送间隔 随机 0.5~2s(仅 IM,Web 前端不延迟)。新增 sanitize.go stripNarration/splitBubbles/ bubbleGap + 测试。 --- .../admin/modules/channels/AdaptersNav.tsx | 108 ++++++++++++++++ .../admin/modules/channels/OverviewPage.tsx | 120 ++++++++++++++++++ .../modules/channels/channelIdentityTest.tsx | 80 ++++++++++++ .../admin/modules/channels/channelLogList.tsx | 77 +++++++++++ .../modules/channels/channelLogPanel.tsx | 27 ++++ .../admin/modules/channels/channelUtils.ts | 64 ++++++++++ .../admin/modules/channels/useAdapterNav.ts | 53 ++++++++ .../modules/channels/useChannelActions.ts | 58 +++++++++ .../services/channelbridge/channelbridge.go | 43 +++++-- internal/services/channelbridge/sanitize.go | 81 ++++++++++++ .../services/channelbridge/sanitize_test.go | 91 +++++++++++++ 11 files changed, 790 insertions(+), 12 deletions(-) create mode 100644 frontend/web/src/admin/modules/channels/AdaptersNav.tsx create mode 100644 frontend/web/src/admin/modules/channels/OverviewPage.tsx create mode 100644 frontend/web/src/admin/modules/channels/channelIdentityTest.tsx create mode 100644 frontend/web/src/admin/modules/channels/channelLogList.tsx create mode 100644 frontend/web/src/admin/modules/channels/channelLogPanel.tsx create mode 100644 frontend/web/src/admin/modules/channels/channelUtils.ts create mode 100644 frontend/web/src/admin/modules/channels/useAdapterNav.ts create mode 100644 frontend/web/src/admin/modules/channels/useChannelActions.ts create mode 100644 internal/services/channelbridge/sanitize.go create mode 100644 internal/services/channelbridge/sanitize_test.go diff --git a/frontend/web/src/admin/modules/channels/AdaptersNav.tsx b/frontend/web/src/admin/modules/channels/AdaptersNav.tsx new file mode 100644 index 0000000..e6e06a6 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/AdaptersNav.tsx @@ -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 = [ + { 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 ( + <> +

适配器
+ {ADAPTER_ITEMS.map((it) => ( + + ))} + {nav.kind === "detail" ? ( +
+
{PLATFORM_LABEL[platformOf(nav.adapter)] ?? platformOf(nav.adapter)}
+
{nav.adapter}
+
+ ) : 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 ( + + ); +} + +/** 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 ( +
+
+ +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/OverviewPage.tsx b/frontend/web/src/admin/modules/channels/OverviewPage.tsx new file mode 100644 index 0000000..059f89a --- /dev/null +++ b/frontend/web/src/admin/modules/channels/OverviewPage.tsx @@ -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 ( +
+ + + + } label="适配器类型" value={PLATFORM_TYPES.length} sub="可用平台类型" tone="accent" /> + } label="已连接平台" value={`${connected}/${configured.length}`} sub="已配置平台中连接数" tone="ok" /> + } label="配置账号" value={accountCount} sub="当前全部账号" tone="neutral" /> + } label="最近消息" value={logs.data?.length ?? "—"} sub="最近事件数" tone="warn" /> + + + {chans.error && !chans.data ? {chans.error} : null} + + +
+

最近事件(下钻到平台 / 配置)

+ + + + +
+ {logs.loading ? ( + 加载中… + ) : logs.error ? ( + {logs.error} + ) : !logs.data || logs.data.length === 0 ? ( + 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : ( +
+ {logs.data.map((m) => ( + navigate({ kind: "detail", adapter })} /> + ))} +
+ )} +
+ + {configured.length ? ( + +
+

已配置平台

+ + + +
+
+ {configured.map((c) => { + const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id); + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} + +/** 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 ( + + ); +} diff --git a/frontend/web/src/admin/modules/channels/channelIdentityTest.tsx b/frontend/web/src/admin/modules/channels/channelIdentityTest.tsx new file mode 100644 index 0000000..71a287b --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelIdentityTest.tsx @@ -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(null); + const [testing, setTesting] = useState(false); + const [err, setErr] = useState(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 ( + +

身份识别测试(结构性判定 · 绝非提示词)

+

+ 输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。 +

+
+ 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)]" + /> + 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)]" + /> + + +
+ {err ? {err} : null} + {verdict ? ( +
+
角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}
+
判定:{verdict.verdict}
+
+ ) : null} +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/channelLogList.tsx b/frontend/web/src/admin/modules/channels/channelLogList.tsx new file mode 100644 index 0000000..530c42c --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelLogList.tsx @@ -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 ( +
+
+ {isAssistant ? "她 / 昔涟" : "用户"} + {m.senderName ? {m.senderName} : null} + {showAdapter && m.adapterId ? {m.adapterId} : null} + {m.deviceId ? {m.deviceId} : null} + {new Date(m.createdAt).toLocaleString()} +
+
{m.content}
+
+ ); +} + +/** 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 ( +
+
+ {platform ? {PLATFORM_LABEL[platform] ?? platform} : 总览} + {adapter ? {adapter} : null} +

{isOverview ? "适配器总览日志" : "消息日志"}

+ GET /api/v1/admin/channels/messages + +
+
+ {loading ? ( + 加载中… + ) : error ? ( + {error} + ) : !msgs || msgs.length === 0 ? ( + 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : ( + msgs.map((m) => ) + )} +
+
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/channelLogPanel.tsx b/frontend/web/src/admin/modules/channels/channelLogPanel.tsx new file mode 100644 index 0000000..9836989 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelLogPanel.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+ 消息日志 + 弹出视图 + +
+
+ +
+
+
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/channelUtils.ts b/frontend/web/src/admin/modules/channels/channelUtils.ts new file mode 100644 index 0000000..597d1fa --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelUtils.ts @@ -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 = {}; + 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); +} diff --git a/frontend/web/src/admin/modules/channels/useAdapterNav.ts b/frontend/web/src/admin/modules/channels/useAdapterNav.ts new file mode 100644 index 0000000..830d76d --- /dev/null +++ b/frontend/web/src/admin/modules/channels/useAdapterNav.ts @@ -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/
`, so a + * deeper `overview|create|list|log|detail/` segment is channel-private + * and never collides. */ +export function useAdapterNav(): { nav: AdapterNav; navigate: (next: AdapterNav) => void } { + const [nav, setNav] = useState(() => 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 }; +} diff --git a/frontend/web/src/admin/modules/channels/useChannelActions.ts b/frontend/web/src/admin/modules/channels/useChannelActions.ts new file mode 100644 index 0000000..0940d01 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/useChannelActions.ts @@ -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(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(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 }; +} diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index cded587..4fd72e6 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -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

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. diff --git a/internal/services/channelbridge/sanitize.go b/internal/services/channelbridge/sanitize.go new file mode 100644 index 0000000..351fae8 --- /dev/null +++ b/internal/services/channelbridge/sanitize.go @@ -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 / as muted italic; IM channels must receive plain chat +// text only, with the action/monologue markup removed). + +var ( + // pairedRoleplayRe matches a complete ... / ... + // 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 . + pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?`) + // strayRoleplayRe matches any remaining single opening/closing role-play tag (for a + // malformed or unpaired emission). + strayRoleplayRe = regexp.MustCompile(`(?is)]*?>`) + // paragraphRe matches

...

/

paragraph markers, keeping the inner text. + paragraphRe = regexp.MustCompile(`(?is)]*?>`) + // 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

...

block, capturing its inner text +// (design: each

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)]*?>(.*?)`) + +// splitBubbles turns a reply into the list of chat bubbles it carries, in order. +// Each

...

block is one bubble, with role-play/action/narration stripped from +// its text; empty bubbles are dropped. A reply with no

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), " 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 +} diff --git a/internal/services/channelbridge/sanitize_test.go b/internal/services/channelbridge/sanitize_test.go new file mode 100644 index 0000000..3742f28 --- /dev/null +++ b/internal/services/channelbridge/sanitize_test.go @@ -0,0 +1,91 @@ +package channelbridge + +import ( + "strings" + "testing" +) + +func TestStripNarration_RemovesActionBlock(t *testing.T) { + t.Parallel() + in := "弯起眼睛笑了\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 := "

你好呀

\n心里有点甜\n

今天天气不错

" + 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 := "拍拍你" + if got := stripNarration(in); got != "" { + t.Errorf("stripNarration = %q, want empty", got) + } +} + +func TestStripNarration_CaseAndStrayTags(t *testing.T) { + t.Parallel() + in := "扑上来 你好 世界" + 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 := "

晚上好呀,亲爱的♪

怎么突然这么问,是想我了吗?

" + 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 := "

弯起眼睛笑了当然看得到呀。

❤

" + 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("拍拍你"); len(got) != 0 { + t.Errorf("splitBubbles = %#v, want empty", got) + } +}