feat(channels): 管理面板消息日志——chat_messages 加 adapter_id + 配置级/平台/总览三粒度端点
后端: - 迁移 0007:chat_messages 加 adapter_id 列 + 索引 - history.Message 加 AdapterID;persistToMain/persistIsolated 传适配器名(a.Name()) - channelbridge: 4 个持久化点 + registerSilenced 传 a;light.go registerSilenced 签名加 a - Store 新增 ListAdapterLog(?adapter=, 不排除主会话)/ListAllLog(adapter_id!='', 总览);ListPlatformLog 保留 - 新端点 GET /api/v1/admin/channels/messages:?adapter= 配置级 / ?platform= 平台 / 无参=总览,historyMessage 加 adapterId - 测试:ListAdapterLog/ListAllLog 过滤用例 + 端点粒度用例 + 桥接持久化断言 前端:渠道管理每配置(账号)「消息日志」按钮 + 页顶「适配器总览日志」入口 + ChannelLogPanel 弹窗(总览/配置级),Miuix 风格,build 绿。
This commit is contained in:
1 parent
c1bd740f7c
commit
2e044d1d19
15 files changed
+560
-41
No files matched your search
@@ -5,9 +5,11 @@ import {
|
|||||||
adminChannelOpen,
|
adminChannelOpen,
|
||||||
adminChannelClose,
|
adminChannelClose,
|
||||||
adminChannelTest,
|
adminChannelTest,
|
||||||
|
adminChannelMessages,
|
||||||
|
adminChannelLogOverview,
|
||||||
adminDeleteChannelAccount,
|
adminDeleteChannelAccount,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { AdminChannelRecord, ChannelTestResult } from "../../lib/types";
|
import type { AdminChannelMessage, AdminChannelRecord, ChannelTestResult } from "../../lib/types";
|
||||||
import { useAutoReload } from "../hooks";
|
import { useAutoReload } from "../hooks";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||||
@@ -20,6 +22,14 @@ function platformOf(name: string): string {
|
|||||||
return name.split(":")[0];
|
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. */
|
||||||
|
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. */
|
/** Open-config target: the platform + the prefill bundle for its form. */
|
||||||
interface CfgTarget {
|
interface CfgTarget {
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -34,6 +44,7 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
const [cfg, setCfg] = useState<CfgTarget | null>(null);
|
const [cfg, setCfg] = useState<CfgTarget | null>(null);
|
||||||
|
const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null);
|
||||||
|
|
||||||
const [ownerId, setOwnerId] = useState("");
|
const [ownerId, setOwnerId] = useState("");
|
||||||
const [platform, setPlatform] = useState("");
|
const [platform, setPlatform] = useState("");
|
||||||
@@ -157,6 +168,14 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
|
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
|
||||||
{tokenMasked ? "token 已配置" : "token 未填"}
|
{tokenMasked ? "token 已配置" : "token 未填"}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => setLogFor({ adapter: adapterNameOf(platformOf(c.name), a.id) })}
|
||||||
|
>
|
||||||
|
消息日志
|
||||||
|
</Button>
|
||||||
{a.id ? (
|
{a.id ? (
|
||||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||||
删除
|
删除
|
||||||
@@ -195,9 +214,14 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
title="渠道管理"
|
title="渠道管理"
|
||||||
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
|
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
|
<span className="flex items-center gap-2">
|
||||||
刷新
|
<Button variant="ghost" disabled={busy !== null} onClick={() => setLogFor({ adapter: null })}>
|
||||||
</Button>
|
适配器总览日志
|
||||||
|
</Button>
|
||||||
|
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -266,6 +290,10 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{logFor ? (
|
||||||
|
<ChannelLogPanel adapter={logFor.adapter} onClose={() => setLogFor(null)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
|
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
|
||||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||||
@@ -304,3 +332,67 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overlay that renders one adapter config's send/receive message log, or the
|
||||||
|
* adapter overview (adapter == null) across every adapter. */
|
||||||
|
function ChannelLogPanel({ adapter, onClose }: { adapter: string | null; onClose: () => void }): React.ReactNode {
|
||||||
|
const isOverview = adapter == null;
|
||||||
|
const platform = adapter ? platformOf(adapter) : "";
|
||||||
|
const { data, loading, error, reload } = useAsyncData(
|
||||||
|
() => (adapter ? adminChannelMessages(adapter, 200) : adminChannelLogOverview(200)),
|
||||||
|
[adapter],
|
||||||
|
);
|
||||||
|
// Backend returns newest-first; display oldest→newest for a natural read.
|
||||||
|
const msgs = data ? [...data].reverse() : data;
|
||||||
|
|
||||||
|
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 flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
|
||||||
|
{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>
|
||||||
|
<Button size="sm" variant="soft" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[70vh] space-y-2 overflow-y-auto px-5 py-4">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message line in the adapter log. */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAutomationRecord,
|
AdminAutomationRecord,
|
||||||
|
AdminChannelMessage,
|
||||||
AdminChannelRecord,
|
AdminChannelRecord,
|
||||||
AdminIoTDevice,
|
AdminIoTDevice,
|
||||||
AdminLlmConfig,
|
AdminLlmConfig,
|
||||||
@@ -546,6 +547,18 @@ export function adminChannelTest(req: {
|
|||||||
}): Promise<ChannelTestResult> {
|
}): Promise<ChannelTestResult> {
|
||||||
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
|
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
|
||||||
}
|
}
|
||||||
|
/** GET /api/v1/admin/channels/messages?adapter=<composite> — one adapter config's
|
||||||
|
* (platform:account) send/receive message log, newest first. */
|
||||||
|
export function adminChannelMessages(adapter: string, limit?: number): Promise<AdminChannelMessage[]> {
|
||||||
|
const q = new URLSearchParams({ adapter });
|
||||||
|
if (limit && limit > 0) q.set("limit", String(limit));
|
||||||
|
return request<AdminChannelMessage[]>(`/admin/channels/messages?${q.toString()}`);
|
||||||
|
}
|
||||||
|
/** GET /api/v1/admin/channels/messages — adapter overview log across all adapters. */
|
||||||
|
export function adminChannelLogOverview(limit?: number): Promise<AdminChannelMessage[]> {
|
||||||
|
const q = limit && limit > 0 ? `?limit=${limit}` : "";
|
||||||
|
return request<AdminChannelMessage[]>(`/admin/channels/messages${q}`);
|
||||||
|
}
|
||||||
/** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */
|
/** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */
|
||||||
export function adminSaveChannelConfig(
|
export function adminSaveChannelConfig(
|
||||||
platform: string,
|
platform: string,
|
||||||
|
|||||||
@@ -275,6 +275,23 @@ export interface AdminChannelAccount {
|
|||||||
botNickname?: string;
|
botNickname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One message in the platform (IM channel) send/receive log. Mirrors the
|
||||||
|
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
||||||
|
export interface AdminChannelMessage {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
role: string; // "user" | "assistant"
|
||||||
|
content: string;
|
||||||
|
mode?: string;
|
||||||
|
platform?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
/** Adapter (platform:account) that produced the message; blank for Web. */
|
||||||
|
adapterId?: string;
|
||||||
|
/** Human sender display name for group/stranger messages; blank = owner. */
|
||||||
|
senderName?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
||||||
export interface AdminIoTDevice {
|
export interface AdminIoTDevice {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 0007_chat_adapter down: drop the adapter_id column and its index.
|
||||||
|
-- SQLite DROP COLUMN (3.35+) cannot drop a column that is still referenced by an
|
||||||
|
-- index, so the adapter index is dropped first, then the column.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_chat_msgs_adapter_created;
|
||||||
|
ALTER TABLE chat_messages DROP COLUMN adapter_id;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 0007_chat_adapter: tag every chat message with the adapter (platform + account)
|
||||||
|
-- that received it, e.g. "kook:3bef..." for a multi-account channels.json registration
|
||||||
|
-- or a bare platform name (e.g. "kook") when the account comes from env. It lets the
|
||||||
|
-- admin panel query the message send/receive log per independent adapter config, and
|
||||||
|
-- the full adapter overview (adapter_id != '').
|
||||||
|
--
|
||||||
|
-- IM adapter-originated messages carry this tag; Web main-session messages stay empty,
|
||||||
|
-- so only IM adapter sources are surfaced. The 0006 chat_messages table already exists
|
||||||
|
-- at this point, so ALTER ADD COLUMN is the correct, non-destructive step. The
|
||||||
|
-- canonical schema (including this column) is also re-issued idempotently by
|
||||||
|
-- internal/runtime/history.NewStore so a fresh in-memory store used by tests stays
|
||||||
|
-- self-contained; the migration is the authoritative path for an existing database.
|
||||||
|
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN adapter_id TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chat_msgs_adapter_created ON chat_messages (adapter_id, created_at DESC);
|
||||||
@@ -94,6 +94,12 @@ type Message struct {
|
|||||||
// the main session (the owner is never labeled). It is display metadata only —
|
// the main session (the owner is never labeled). It is display metadata only —
|
||||||
// identity is never decided from this field.
|
// identity is never decided from this field.
|
||||||
SenderName string
|
SenderName string
|
||||||
|
// AdapterID is the adapter (platform + account) registration name of the IM
|
||||||
|
// source that produced the message, e.g. "kook:3bef..." for a multi-account
|
||||||
|
// channels.json registration or a bare platform name (e.g. "kook") when the
|
||||||
|
// account comes from env. IM adapter-originated messages carry it; the Web main
|
||||||
|
// session leaves it empty, so it is always '' for Web messages.
|
||||||
|
AdapterID string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +136,18 @@ type HistoryStore interface {
|
|||||||
// message stream (groups + stranger private chats) is surfaced without the
|
// message stream (groups + stranger private chats) is surfaced without the
|
||||||
// owner-private mirror. before is a millisecond cursor; 0 means newest page.
|
// owner-private mirror. before is a millisecond cursor; 0 means newest page.
|
||||||
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
|
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error)
|
||||||
|
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
|
||||||
|
// account registration name, e.g. "kook:3bef..."), newest first, for the
|
||||||
|
// per-adapter log page. Unlike ListPlatformLog it does NOT exclude the main web
|
||||||
|
// session: an owner-private IM chat lives in the main session but still carries
|
||||||
|
// the adapter_id, so a per-adapter log includes the owner mirror. before is a
|
||||||
|
// millisecond cursor; 0 means the newest page. An empty adapterID returns an
|
||||||
|
// empty slice.
|
||||||
|
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error)
|
||||||
|
// ListAllLog returns up to limit messages across every IM adapter source
|
||||||
|
// (adapter_id != ''), newest first, for the adapter overview page. before is a
|
||||||
|
// millisecond cursor; 0 means the newest page.
|
||||||
|
ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error)
|
||||||
// DeleteSession removes a sub session and all its messages. The main session is
|
// DeleteSession removes a sub session and all its messages. The main session is
|
||||||
// immutable and returns ErrImmutableMain.
|
// immutable and returns ErrImmutableMain.
|
||||||
DeleteSession(ctx context.Context, sessionID string) error
|
DeleteSession(ctx context.Context, sessionID string) error
|
||||||
@@ -212,10 +230,10 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
|||||||
msg.Platform = PlatformWeb
|
msg.Platform = PlatformWeb
|
||||||
}
|
}
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
||||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at)
|
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.CreatedAt)
|
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||||
}
|
}
|
||||||
@@ -231,7 +249,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
|||||||
// limit is clamped to [1, maxPageLimit].
|
// limit is clamped to [1, maxPageLimit].
|
||||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
|
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
|
||||||
limit = clampLimit(limit)
|
limit = clampLimit(limit)
|
||||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at
|
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||||
FROM chat_messages WHERE session_id = ?`
|
FROM chat_messages WHERE session_id = ?`
|
||||||
args := []any{sessionID}
|
args := []any{sessionID}
|
||||||
if before > 0 {
|
if before > 0 {
|
||||||
@@ -272,7 +290,7 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
|
|||||||
// only strictly older messages are returned. The returned slice is non-nil.
|
// only strictly older messages are returned. The returned slice is non-nil.
|
||||||
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
|
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
|
||||||
limit = clampLimit(limit)
|
limit = clampLimit(limit)
|
||||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at
|
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||||
FROM chat_messages WHERE platform = ? AND session_id != ?`
|
FROM chat_messages WHERE platform = ? AND session_id != ?`
|
||||||
args := []any{platform, MainSessionID}
|
args := []any{platform, MainSessionID}
|
||||||
if before > 0 {
|
if before > 0 {
|
||||||
@@ -302,13 +320,91 @@ func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int
|
|||||||
return msgs, nil
|
return msgs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAdapterLog returns up to limit messages for a single IM adapter (platform +
|
||||||
|
// account registration name, e.g. "kook:3bef..."), newest first, for the per-adapter
|
||||||
|
// log page. It does NOT exclude the main web session: an owner-private IM chat lives
|
||||||
|
// in the main session but still carries the adapter_id, so a per-adapter log includes
|
||||||
|
// the owner mirror. An empty adapterID returns an empty (non-nil) slice. before is a
|
||||||
|
// millisecond cursor; when nonzero only strictly older messages are returned. The
|
||||||
|
// returned slice is non-nil.
|
||||||
|
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
if adapterID == "" {
|
||||||
|
return make([]Message, 0), nil
|
||||||
|
}
|
||||||
|
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||||
|
FROM chat_messages WHERE adapter_id = ?`
|
||||||
|
args := []any{adapterID}
|
||||||
|
if before > 0 {
|
||||||
|
query += ` AND created_at < ?`
|
||||||
|
args = append(args, before)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("history: adapter log %s: %w", adapterID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
msgs := make([]Message, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := scanMessage(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("history: scan adapter log %s: %w", adapterID, err)
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("history: iterate adapter log %s: %w", adapterID, err)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllLog returns up to limit messages across every IM adapter source
|
||||||
|
// (adapter_id != ''), newest first, for the adapter overview page. before is a
|
||||||
|
// millisecond cursor; when nonzero only strictly older messages are returned. The
|
||||||
|
// returned slice is non-nil.
|
||||||
|
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||||
|
FROM chat_messages WHERE adapter_id != ''`
|
||||||
|
var args []any
|
||||||
|
if before > 0 {
|
||||||
|
query += ` AND created_at < ?`
|
||||||
|
args = append(args, before)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("history: all adapter log: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
msgs := make([]Message, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
m, err := scanMessage(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("history: scan all adapter log: %w", err)
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("history: iterate all adapter log: %w", err)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Recent returns the most recent n messages for sessionID in oldest-first order (the
|
// Recent returns the most recent n messages for sessionID in oldest-first order (the
|
||||||
// tail-most window), for WS replay / context tail. The returned slice is non-nil.
|
// tail-most window), for WS replay / context tail. The returned slice is non-nil.
|
||||||
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
|
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
|
||||||
if n < 1 {
|
if n < 1 {
|
||||||
n = defaultPageLimit
|
n = defaultPageLimit
|
||||||
}
|
}
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, created_at
|
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
||||||
@@ -422,11 +518,11 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
|||||||
// scanMessage reads one chat_messages row. message columns are scanned positionally.
|
// scanMessage reads one chat_messages row. message columns are scanned positionally.
|
||||||
func scanMessage(sc rowScanner) (Message, error) {
|
func scanMessage(sc rowScanner) (Message, error) {
|
||||||
var m Message
|
var m Message
|
||||||
var mode, mood, platform, deviceID, senderName string
|
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &m.CreatedAt); err != nil {
|
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.CreatedAt); err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName = mode, mood, platform, deviceID, senderName
|
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,6 +580,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
|||||||
platform TEXT NOT NULL DEFAULT 'web',
|
platform TEXT NOT NULL DEFAULT 'web',
|
||||||
device_id TEXT NOT NULL DEFAULT '',
|
device_id TEXT NOT NULL DEFAULT '',
|
||||||
sender_name TEXT NOT NULL DEFAULT '',
|
sender_name TEXT NOT NULL DEFAULT '',
|
||||||
|
adapter_id TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
)`
|
)`
|
||||||
|
|
||||||
|
|||||||
@@ -423,3 +423,116 @@ func TestSenderNameRoundTripOnListRecentAndPlatformLog(t *testing.T) {
|
|||||||
t.Fatalf("web platform log = %+v, err=%v; want empty", log, err)
|
t.Fatalf("web platform log = %+v, err=%v; want empty", log, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdapterLogFiltersByAdapter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seed across two adapters, an empty-adapter web message, and a main-session
|
||||||
|
// owner-private mirror that still carries the adapter (design/16 §4.4).
|
||||||
|
for _, m := range []Message{
|
||||||
|
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
|
||||||
|
{SessionID: "sess-g1", Role: RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
|
||||||
|
{SessionID: "sess-g2", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", SenderName: "李四", CreatedAt: 3000},
|
||||||
|
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 4000},
|
||||||
|
} {
|
||||||
|
if err := st.Append(ctx, m); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", m.Content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-adapter log includes BOTH the owner-private main mirror and the group
|
||||||
|
// message, newest first.
|
||||||
|
kook, err := st.ListAdapterLog(ctx, "kook:3bef", 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adapter log kook:3bef: %v", err)
|
||||||
|
}
|
||||||
|
if len(kook) != 2 {
|
||||||
|
t.Fatalf("kook adapter log = %d, want 2 (%+v)", len(kook), kook)
|
||||||
|
}
|
||||||
|
if kook[0].Content != "kook-group" || kook[0].SessionID != "sess-g1" {
|
||||||
|
t.Fatalf("kook[0] = %+v, want newest kook-group", kook[0])
|
||||||
|
}
|
||||||
|
if kook[1].Content != "kook-owner" || kook[1].SessionID != MainSessionID {
|
||||||
|
t.Fatalf("kook[1] = %+v, want owner mirror on main session", kook[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different adapter yields only its own lane.
|
||||||
|
tg, err := st.ListAdapterLog(ctx, "telegram:conf0", 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adapter log telegram:conf0: %v", err)
|
||||||
|
}
|
||||||
|
if len(tg) != 1 || tg[0].Content != "tg-group" {
|
||||||
|
t.Fatalf("telegram adapter log = %+v, want [tg-group]", tg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown / empty adapter yields a non-nil empty slice.
|
||||||
|
none, err := st.ListAdapterLog(ctx, "kook:unknown", 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adapter log unknown: %v", err)
|
||||||
|
}
|
||||||
|
if none == nil || len(none) != 0 {
|
||||||
|
t.Fatalf("unknown adapter log = %#v, want non-nil empty slice", none)
|
||||||
|
}
|
||||||
|
empty, err := st.ListAdapterLog(ctx, "", 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adapter log empty: %v", err)
|
||||||
|
}
|
||||||
|
if empty == nil || len(empty) != 0 {
|
||||||
|
t.Fatalf("empty adapter log = %#v, want non-nil empty slice", empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The before cursor pages backward through the adapter's stream.
|
||||||
|
older, err := st.ListAdapterLog(ctx, "kook:3bef", 2000, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("adapter log before: %v", err)
|
||||||
|
}
|
||||||
|
if len(older) != 1 || older[0].Content != "kook-owner" {
|
||||||
|
t.Fatalf("kook before page = %+v, want [kook-owner]", older)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllLogSurfacesOnlyAdapterMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, m := range []Message{
|
||||||
|
{SessionID: MainSessionID, Role: RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
|
||||||
|
{SessionID: MainSessionID, Role: RoleUser, Content: "web-msg", Platform: PlatformWeb, CreatedAt: 2000},
|
||||||
|
{SessionID: "sess-g1", Role: RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
|
||||||
|
} {
|
||||||
|
if err := st.Append(ctx, m); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", m.Content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The overview only surfaces IM adapter sources; the web message is excluded.
|
||||||
|
all, err := st.ListAllLog(ctx, 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("all log: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("all log = %d, want 2 (%+v)", len(all), all)
|
||||||
|
}
|
||||||
|
if all[0].Content != "tg-group" || all[1].Content != "kook-owner" {
|
||||||
|
t.Fatalf("all log order = %+v, want [tg-group kook-owner]", all)
|
||||||
|
}
|
||||||
|
// The web message never leaks into the adapter overview.
|
||||||
|
for _, m := range all {
|
||||||
|
if m.Content == "web-msg" {
|
||||||
|
t.Fatalf("web message leaked into adapter overview: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty store yields a non-nil empty slice.
|
||||||
|
st2 := newStore(t)
|
||||||
|
e, err := st2.ListAllLog(ctx, 0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty all log: %v", err)
|
||||||
|
}
|
||||||
|
if e == nil || len(e) != 0 {
|
||||||
|
t.Fatalf("empty all log = %#v, want non-nil empty slice", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,6 +356,18 @@ func basePlatform(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
|
||||||
|
// for the adapter that received a message, or "" when a is nil. The single-account
|
||||||
|
// convenience path (process) and a platform with no registered adapter can pass a nil
|
||||||
|
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
|
||||||
|
// dereferencing a nil adapter.
|
||||||
|
func adapterName(a adapter.PlatformAdapter) string {
|
||||||
|
if a == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.Name()
|
||||||
|
}
|
||||||
|
|
||||||
// readLoop runs an adapter's blocking read path until the bridge stops or the
|
// readLoop runs an adapter's blocking read path until the bridge stops or the
|
||||||
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
|
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
|
||||||
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
|
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
|
||||||
@@ -485,7 +497,7 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
|||||||
// owner-private chat and owner group @/high-value replies are unchanged.
|
// owner-private chat and owner group @/high-value replies are unchanged.
|
||||||
if !actor.IsOwner() {
|
if !actor.IsOwner() {
|
||||||
if ok, reason := b.shouldRespond(ctx, msg, actor); !ok {
|
if ok, reason := b.shouldRespond(ctx, msg, actor); !ok {
|
||||||
b.registerSilenced(ctx, msg, actor, sess)
|
b.registerSilenced(ctx, msg, actor, sess, a)
|
||||||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||||
"session", sess.SessionID, "reason", reason)
|
"session", sess.SessionID, "reason", reason)
|
||||||
@@ -519,9 +531,9 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
|||||||
// are broadcast to the Web client; restricted messages land in their isolated
|
// are broadcast to the Web client; restricted messages land in their isolated
|
||||||
// session (the adapter log page) and are NOT broadcast to the main session.
|
// session (the adapter log page) and are NOT broadcast to the main session.
|
||||||
if isOwnerPrivate {
|
if isOwnerPrivate {
|
||||||
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg))
|
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg), adapterName(a))
|
||||||
} else {
|
} else {
|
||||||
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName)
|
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
|
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
|
||||||
@@ -539,9 +551,9 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
|||||||
// Persist the assistant reply (owner-private → main session + broadcast;
|
// Persist the assistant reply (owner-private → main session + broadcast;
|
||||||
// restricted → isolated log) then deliver it back to the channel.
|
// restricted → isolated log) then deliver it back to the channel.
|
||||||
if isOwnerPrivate {
|
if isOwnerPrivate {
|
||||||
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg))
|
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg), adapterName(a))
|
||||||
} else {
|
} else {
|
||||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "")
|
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
|
||||||
}
|
}
|
||||||
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
||||||
if errors.Is(err, adapter.ErrUnsupported) {
|
if errors.Is(err, adapter.ErrUnsupported) {
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ func (c *captureHistory) Recent(context.Context, string, int) ([]history.Message
|
|||||||
func (c *captureHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
|
func (c *captureHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (c *captureHistory) ListAdapterLog(context.Context, string, int64, int) ([]history.Message, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (c *captureHistory) ListAllLog(context.Context, int64, int) ([]history.Message, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
|
func (c *captureHistory) DeleteSession(context.Context, string) error { return nil }
|
||||||
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
|
func (c *captureHistory) NewSubSession(context.Context, string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -284,6 +290,9 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
|
// Register the account adapter that pretends to receive the message, so the
|
||||||
|
// persisted stream carries the adapter registration name (e.g. "telegram").
|
||||||
|
b.Add(&fakeAdapter{name: "telegram"})
|
||||||
|
|
||||||
msg := directMessage() // Platform = "telegram"
|
msg := directMessage() // Platform = "telegram"
|
||||||
b.process(context.Background(), msg)
|
b.process(context.Background(), msg)
|
||||||
@@ -292,14 +301,14 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
|
|||||||
if len(recs) != 2 {
|
if len(recs) != 2 {
|
||||||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||||||
}
|
}
|
||||||
// Both land on the main web session and carry the channel platform.
|
// Both land on the main web session and carry the channel platform + adapter.
|
||||||
if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser ||
|
if recs[0].SessionID != history.MainSessionID || recs[0].Role != history.RoleUser ||
|
||||||
recs[0].Content != "hello" || recs[0].Platform != "telegram" {
|
recs[0].Content != "hello" || recs[0].Platform != "telegram" || recs[0].AdapterID != "telegram" {
|
||||||
t.Fatalf("user record = %+v, want web/telegram/hello", recs[0])
|
t.Fatalf("user record = %+v, want web/telegram/hello/telegram", recs[0])
|
||||||
}
|
}
|
||||||
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
|
if recs[1].SessionID != history.MainSessionID || recs[1].Role != history.RoleAssistant ||
|
||||||
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" {
|
recs[1].Content != "微信回复" || recs[1].Platform != "telegram" || recs[1].AdapterID != "telegram" {
|
||||||
t.Fatalf("assistant record = %+v, want web/telegram/微信回复", recs[1])
|
t.Fatalf("assistant record = %+v, want web/telegram/微信回复/telegram", recs[1])
|
||||||
}
|
}
|
||||||
// Every persisted message was handed to the broadcast hook.
|
// Every persisted message was handed to the broadcast hook.
|
||||||
if len(persisted) != 2 {
|
if len(persisted) != 2 {
|
||||||
|
|||||||
@@ -168,10 +168,11 @@ func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage,
|
|||||||
|
|
||||||
// registerSilenced persists a silenced non-owner inbound turn into its isolated
|
// registerSilenced persists a silenced non-owner inbound turn into its isolated
|
||||||
// session's platform log so the adapter log page still renders the non-owner
|
// session's platform log so the adapter log page still renders the non-owner
|
||||||
// stream (design/16 §4.6 §4), then stops — no loop, no reply. It is the 登记 side
|
// stream (design/16 §4.6 §4), then stops — no loop, no reply. a is the account
|
||||||
// of the minimal-disturbance path.
|
// adapter that received the message, so the log is tagged with the adapter config.
|
||||||
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session) {
|
// It is the 登记 side of the minimal-disturbance path.
|
||||||
b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor))
|
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) {
|
||||||
|
b.persistIsolated(ctx, history.RoleUser, msg.Content, deviceIDFor(msg), sess, msg.Platform, senderDisplayName(msg, actor), adapterName(a))
|
||||||
b.log.Debug("non-owner message silenced, registered only",
|
b.log.Debug("non-owner message silenced, registered only",
|
||||||
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
|
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,17 +50,19 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
|
|||||||
// platform and client deviceID, then notifies the transport hook so Web clients receive
|
// platform and client deviceID, then notifies the transport hook so Web clients receive
|
||||||
// a `message_persisted` event without a refresh (design/16 §4.4). deviceID is the
|
// a `message_persisted` event without a refresh (design/16 §4.4). deviceID is the
|
||||||
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
|
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
|
||||||
// for IM. It is a no-op when no history store is wired, so the bridge runs unchanged
|
// for IM. adapterID is the account adapter registration name that received the message
|
||||||
// without storage.
|
// (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream;
|
||||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID string) {
|
// it is empty for Web-originated messages. It is a no-op when no history store is wired,
|
||||||
|
// so the bridge runs unchanged without storage.
|
||||||
|
func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID, adapterID string) {
|
||||||
if b.history == nil {
|
if b.history == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
full, err := b.history.AppendFull(ctx, history.Message{
|
full, err := b.history.AppendFull(ctx, history.Message{
|
||||||
SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID,
|
SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID, AdapterID: adapterID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID)
|
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.notifyPersisted(full)
|
b.notifyPersisted(full)
|
||||||
@@ -69,21 +71,22 @@ func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, dev
|
|||||||
// persistIsolated writes one message into a restricted (group / stranger) session's
|
// persistIsolated writes one message into a restricted (group / stranger) session's
|
||||||
// own platform log (design/16 §4.6 §4): the message lands in the isolated sess-xxx
|
// own platform log (design/16 §4.6 §4): the message lands in the isolated sess-xxx
|
||||||
// session (NOT the main web session) and carries the channel platform, the client
|
// session (NOT the main web session) and carries the channel platform, the client
|
||||||
// deviceID and the sender's display name, so the IM adapter log page can render the
|
// deviceID, the sender's display name and the account adapter registration name, so
|
||||||
// non-owner message stream. It is deliberately NOT broadcast to the Web main session
|
// the IM adapter log page can render the non-owner message stream per adapter config.
|
||||||
// (only owner-private messages mirror to the Web). It is a no-op when no history
|
// It is deliberately NOT broadcast to the Web main session (only owner-private
|
||||||
// store is wired, so the bridge runs unchanged without storage.
|
// messages mirror to the Web). It is a no-op when no history store is wired, so the
|
||||||
func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID string, sess session.Session, platform, senderName string) {
|
// bridge runs unchanged without storage.
|
||||||
|
func (b *Bridge) persistIsolated(ctx context.Context, role, content, deviceID string, sess session.Session, platform, senderName, adapterID string) {
|
||||||
if b.history == nil {
|
if b.history == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
full, err := b.history.AppendFull(ctx, history.Message{
|
full, err := b.history.AppendFull(ctx, history.Message{
|
||||||
SessionID: sess.SessionID, Role: role, Content: content, Platform: platform,
|
SessionID: sess.SessionID, Role: role, Content: content, Platform: platform,
|
||||||
DeviceID: deviceID, SenderName: senderName,
|
DeviceID: deviceID, SenderName: senderName, AdapterID: adapterID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("persist isolated session", "err", err, "role", role,
|
b.log.Warn("persist isolated session", "err", err, "role", role,
|
||||||
"session", sess.SessionID, "platform", platform, "sender_name", senderName)
|
"session", sess.SessionID, "platform", platform, "sender_name", senderName, "adapter", adapterID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No broadcast: third-party messages must not reach the Web main session (design/16 §4.6 §3).
|
// No broadcast: third-party messages must not reach the Web main session (design/16 §4.6 §3).
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ type historyMessage struct {
|
|||||||
// message (group/stranger, design/16 §4.6). It is blank for the owner's
|
// message (group/stranger, design/16 §4.6). It is blank for the owner's
|
||||||
// private chat on the main session, so empty means "labeled as the owner".
|
// private chat on the main session, so empty means "labeled as the owner".
|
||||||
SenderName string `json:"senderName,omitempty"`
|
SenderName string `json:"senderName,omitempty"`
|
||||||
|
// AdapterID is the adapter (platform + account) registration name of the IM
|
||||||
|
// source that produced the message (e.g. "kook:3bef..."). It is blank for
|
||||||
|
// Web-originated messages.
|
||||||
|
AdapterID string `json:"adapterId,omitempty"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +179,62 @@ func (s *Server) handleChannelPlatformLog(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteSession deletes a sub session and all its messages. The main session is
|
// handleChannelMessages returns up to limit messages for the admin adapter log page,
|
||||||
// immutable (design/16 §6) and returns 403.
|
// newest first, across one of three granularities (design/16 §4.6 §4): a single
|
||||||
|
// adapter config (?adapter=kook:3bef...), a whole platform (?platform=kook), or the
|
||||||
|
// full IM adapter overview (neither, so adapter_id != ''). before/limit reuse the
|
||||||
|
// standard cursor pagination. The owner-private mirror on the main web session is
|
||||||
|
// NOT excluded here because it carries the adapter_id; each granularity filters on
|
||||||
|
// adapter_id / platform directly.
|
||||||
|
func (s *Server) handleChannelMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.history == nil {
|
||||||
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := historyDefaultLimit
|
||||||
|
if limitStr != "" {
|
||||||
|
v, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || v < 1 {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = v
|
||||||
|
}
|
||||||
|
var before int64
|
||||||
|
if b := r.URL.Query().Get("before"); b != "" {
|
||||||
|
v, err := strconv.ParseInt(b, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid before"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before = v
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := r.URL.Query().Get("adapter")
|
||||||
|
platform := r.URL.Query().Get("platform")
|
||||||
|
|
||||||
|
var msgs []history.Message
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case adapter != "":
|
||||||
|
msgs, err = s.history.ListAdapterLog(r.Context(), adapter, before, limit)
|
||||||
|
case platform != "":
|
||||||
|
msgs, err = s.history.ListPlatformLog(r.Context(), platform, before, limit)
|
||||||
|
default:
|
||||||
|
msgs, err = s.history.ListAllLog(r.Context(), before, limit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("history channel messages", "err", err, "adapter", adapter, "platform", platform)
|
||||||
|
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_messages_failed", "failed to list channel messages"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]historyMessage, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
out = append(out, toHistoryMessage(m))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.history == nil {
|
if s.history == nil {
|
||||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
||||||
@@ -213,6 +271,7 @@ func toHistoryMessage(m history.Message) historyMessage {
|
|||||||
Platform: m.Platform,
|
Platform: m.Platform,
|
||||||
DeviceID: m.DeviceID,
|
DeviceID: m.DeviceID,
|
||||||
SenderName: m.SenderName,
|
SenderName: m.SenderName,
|
||||||
|
AdapterID: m.AdapterID,
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,77 @@ func TestHistoryListMessagesPlatformFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChannelMessagesGranularity exercises the admin adapter log endpoint at each
|
||||||
|
// granularity: a single adapter config (?adapter=), a whole platform (?platform=),
|
||||||
|
// and the full IM adapter overview (neither). It asserts adapterId is carried on the
|
||||||
|
// DTO so the panel can label the source adapter config.
|
||||||
|
func TestHistoryChannelMessagesGranularity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, ts, jwt, st := newHistoryEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, m := range []history.Message{
|
||||||
|
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "kook-owner", Platform: "kook", AdapterID: "kook:3bef", CreatedAt: 1000},
|
||||||
|
{SessionID: "sess-g", Role: history.RoleUser, Content: "kook-group", Platform: "kook", AdapterID: "kook:3bef", SenderName: "张三", CreatedAt: 2000},
|
||||||
|
{SessionID: "sess-t", Role: history.RoleUser, Content: "tg-group", Platform: "telegram", AdapterID: "telegram:conf0", CreatedAt: 3000},
|
||||||
|
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "web-msg", Platform: history.PlatformWeb, CreatedAt: 4000},
|
||||||
|
} {
|
||||||
|
if err := st.Append(ctx, m); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", m.Content, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||||
|
|
||||||
|
// ?adapter=kook:3bef → only that adapter config, both the owner mirror and the
|
||||||
|
// group message, newest first, and the DTO carries adapterId.
|
||||||
|
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?adapter=kook:3bef", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("adapter status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var byAdapter []historyMessage
|
||||||
|
decodeDataSlice(t, body, &byAdapter)
|
||||||
|
if len(byAdapter) != 2 {
|
||||||
|
t.Fatalf("adapter page = %d, want 2 (%+v)", len(byAdapter), byAdapter)
|
||||||
|
}
|
||||||
|
if byAdapter[0].Content != "kook-group" || byAdapter[0].AdapterID != "kook:3bef" {
|
||||||
|
t.Fatalf("adapter[0] = %+v, want kook-group with adapterId", byAdapter[0])
|
||||||
|
}
|
||||||
|
if byAdapter[1].Content != "kook-owner" || byAdapter[1].SessionID != history.MainSessionID {
|
||||||
|
t.Fatalf("adapter[1] = %+v, want kook-owner on main session", byAdapter[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?platform=kook → the platform lane across isolated sessions (owner-private
|
||||||
|
// mirror excluded, matching handleChannelPlatformLog semantics).
|
||||||
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?platform=kook", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("platform status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var byPlatform []historyMessage
|
||||||
|
decodeDataSlice(t, body, &byPlatform)
|
||||||
|
if len(byPlatform) != 1 || byPlatform[0].Content != "kook-group" {
|
||||||
|
t.Fatalf("platform page = %+v, want [kook-group]", byPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filter → the full IM adapter overview (adapter_id != ''), web excluded.
|
||||||
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("all status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
var all []historyMessage
|
||||||
|
decodeDataSlice(t, body, &all)
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("all page = %d, want 3 (%+v)", len(all), all)
|
||||||
|
}
|
||||||
|
if all[0].Content != "tg-group" || all[1].Content != "kook-group" || all[2].Content != "kook-owner" {
|
||||||
|
t.Fatalf("all order = %+v, want [tg-group kook-group kook-owner]", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid before → 400.
|
||||||
|
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels/messages?before=abc", nil, tok)
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid before status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHistoryDeleteSubSession(t *testing.T) {
|
func TestHistoryDeleteSubSession(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, ts, jwt, st := newHistoryEnv(t)
|
_, ts, jwt, st := newHistoryEnv(t)
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ type HistoryStore interface {
|
|||||||
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
||||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||||
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
|
ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]history.Message, error)
|
||||||
|
ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]history.Message, error)
|
||||||
|
ListAllLog(ctx context.Context, before int64, limit int) ([]history.Message, error)
|
||||||
DeleteSession(ctx context.Context, sessionID string) error
|
DeleteSession(ctx context.Context, sessionID string) error
|
||||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||||
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
|
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
|
||||||
@@ -380,6 +382,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories)))
|
mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories)))
|
||||||
mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins)))
|
mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins)))
|
||||||
mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels)))
|
mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels)))
|
||||||
|
mux.Handle("GET /api/v1/admin/channels/messages", adminAuth(http.HandlerFunc(s.handleChannelMessages)))
|
||||||
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", adminAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", adminAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
||||||
mux.Handle("GET /api/v1/admin/iot", adminAuth(http.HandlerFunc(s.adminListIoT)))
|
mux.Handle("GET /api/v1/admin/iot", adminAuth(http.HandlerFunc(s.adminListIoT)))
|
||||||
mux.Handle("GET /api/v1/admin/llm", adminAuth(http.HandlerFunc(s.adminGetLlm)))
|
mux.Handle("GET /api/v1/admin/llm", adminAuth(http.HandlerFunc(s.adminGetLlm)))
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ func (f *fakeHistory) ListPlatformLog(context.Context, string, int64, int) ([]hi
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeHistory) ListAdapterLog(context.Context, string, int64, int) ([]history.Message, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHistory) ListAllLog(context.Context, int64, int) ([]history.Message, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeHistory) DeleteSession(context.Context, string) error {
|
func (f *fakeHistory) DeleteSession(context.Context, string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user