diff --git a/internal/channel/session/manager.go b/internal/channel/session/manager.go index 4a0084a..5668078 100644 --- a/internal/channel/session/manager.go +++ b/internal/channel/session/manager.go @@ -44,7 +44,13 @@ INSERT OR IGNORE INTO channel_sessions VALUES (?, ?, ?, ?, ?, ?)` const selectSessionSQL = ` -SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?` +SELECT session_id, actor_role FROM channel_sessions WHERE platform = ? AND channel_id = ?` + +// updateActorRoleSQL 只改身份车道列(不碰 session_id / created_at):渠道↔会话的 +// 绑定不因身份变化而改变,同一段对话继续落进同一个 session。 +const updateActorRoleSQL = ` +UPDATE channel_sessions SET actor_role = ?, updated_at = ? +WHERE platform = ? AND channel_id = ?` // listSessionsSQL selects every persisted channel mapping, newest first. Group // sessions key on a channel_id carrying groupChannelPrefix; direct sessions use @@ -94,6 +100,10 @@ func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*SessionManager, er // sight. The owner whitelist is applied here (ResolveActor), so the owner flag // is set structurally rather than from prompt text. Concurrent calls for the // same channel return the same session. +// +// 已绑定的渠道若解析出的身份车道与缓存里的不一致(典型:主人刚把该账号加进 +// 白名单并热重载,见 rebindLocked 的说明),本次调用会把车道刷新到会话上—— +// 否则这段对话会一直留在绑定时的旧车道上(陌生人:隔离会话 + 记忆只读)。 func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (Session, error) { actor := ResolveActor(msg, owner) cid := channelID(msg) @@ -102,14 +112,18 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes } key := msg.Platform + "\x00" + cid - if s, ok := m.lookupCached(key); ok { + // 快路径:已绑定且车道与本次解析一致——只读锁即可返回。 + if s, ok := m.lookupCached(key); ok && s.ActorRole == actor.Role { return s, nil } m.mu.Lock() defer m.mu.Unlock() if s, ok := m.cache[key]; ok { - return s, nil + if s.ActorRole == actor.Role { + return s, nil + } + return m.rebindLocked(ctx, key, s, actor), nil } sessionID, err := m.persist(ctx, msg.Platform, cid, actor) @@ -117,15 +131,7 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes return Session{}, err } - s := Session{ - Platform: msg.Platform, - ChannelID: cid, - SessionID: sessionID, - ActorRole: actor.Role, - Owner: actor.IsOwner(), - IsGroup: actor.IsGroupChat(), - MemWritable: actor.IsOwner() && !actor.IsGroupChat(), - } + s := sessionWithActor(Session{Platform: msg.Platform, ChannelID: cid, SessionID: sessionID}, actor) m.cache[key] = s return s, nil } @@ -185,15 +191,22 @@ func (m *SessionManager) lookupCached(key string) (Session, bool) { // canonical session_id. INSERT OR IGNORE makes it safe under concurrency: the // winning writer's session_id becomes the binding. It is called under the write // lock so the in-memory cache and the table stay in agreement. +// +// 读回的落盘车道与本次解析不一致时就地纠正:这条绑定可能是**上一个进程**留下的 +// (那时该账号还没进白名单,落盘的是陌生人车道)。不纠正的话,重启后第一条消息虽然 +// 按新车道跑,面板/诊断读到的落盘车道仍是旧值,直到这条渠道再次发生身份变化。 func (m *SessionManager) persist(ctx context.Context, platform, cid string, actor adapter.Actor) (string, error) { now := formatSessionTime(time.Now()) if _, err := m.db.ExecContext(ctx, insertOrIgnoreSessionSQL, platform, cid, newSessionID(), string(actor.Role), now, now); err != nil { return "", fmt.Errorf("session: persist %s/%s: %w", platform, cid, err) } - var sessionID string - if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID); err != nil { + var sessionID, storedRole string + if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID, &storedRole); err != nil { return "", fmt.Errorf("session: read back %s/%s: %w", platform, cid, err) } + if storedRole != string(actor.Role) { + m.syncActorRole(ctx, platform, cid, actor) + } if m.log != nil { m.log.Debug("channel session bound", "platform", platform, "channel_id", cid, "session_id", sessionID, "actor_role", actor.Role) @@ -217,3 +230,56 @@ func channelID(msg adapter.UnifiedMessage) string { } return msg.SenderID } + +// sessionWithActor 按本次结构化解析出的 actor 派生会话的身份字段。Platform / +// ChannelID / SessionID 原样保留——它们描述的是渠道绑定,不是身份:身份变了, +// 这段对话仍属于同一个 session(绝不因判主结果不同而搬家、换 session id)。 +func sessionWithActor(s Session, actor adapter.Actor) Session { + s.ActorRole = actor.Role + s.Owner = actor.IsOwner() + s.IsGroup = actor.IsGroupChat() + s.MemWritable = actor.IsOwner() && !actor.IsGroupChat() + return s +} + +// rebindLocked 用本次解析出的 actor 刷新一个已绑定会话的身份车道,并把新车道写回 +// channel_sessions。调用者必须持有写锁。 +// +// 为什么必须刷新(真机事故 2026-10-05,QQ 私聊被当陌生人):主人把 QQ 号经面板加进 +// 白名单、热重载成功后,ResolveActor 立刻命中 owner,但这条渠道**早已在它是陌生人 +// 时绑定过**。旧实现直接返回缓存里的旧车道,于是主人私聊仍按陌生人处理——不镜像进 +// Web 主会话(前端看不到)、MemWritable 恒 false(记忆只读的隔离上下文),要等进程 +// 重启才恢复。刷新后新车道立即对后续消息生效,且与 owner.yaml / channels.json 的 +// 热重载承诺(design/11 §9)一致。 +// +// 车道只由 ResolveActor 的结构化判定(白名单 + 每渠道 adminIds)给出:本函数不读 +// 消息正文,也不看适配器上报的 role,因此刷新不引入任何「靠提示词判主」的通道。 +func (m *SessionManager) rebindLocked(ctx context.Context, key string, s Session, actor adapter.Actor) Session { + from := s.ActorRole + s = sessionWithActor(s, actor) + m.cache[key] = s + m.syncActorRole(ctx, s.Platform, s.ChannelID, actor) + if m.log != nil { + m.log.Debug("channel session identity lane updated", + "platform", s.Platform, "channel_id", s.ChannelID, "session_id", s.SessionID, + "from", from, "to", actor.Role) + } + return s +} + +// syncActorRole 把身份车道写回绑定表。落盘只为让车道跨重启存活,失败不能把消息整条 +// 丢掉:内存里的车道已经是这次结构化判定的结论,如实记一条 WARN,下一条消息会再试。 +func (m *SessionManager) syncActorRole(ctx context.Context, platform, cid string, actor adapter.Actor) { + if err := m.updateActorRole(ctx, platform, cid, actor.Role); err != nil && m.log != nil { + m.log.Warn("channel session actor role persist failed", + "platform", platform, "channel_id", cid, "err", err) + } +} + +// updateActorRole 把刷新后的身份车道写回绑定表。 +func (m *SessionManager) updateActorRole(ctx context.Context, platform, cid string, role adapter.Role) error { + if _, err := m.db.ExecContext(ctx, updateActorRoleSQL, string(role), formatSessionTime(time.Now()), platform, cid); err != nil { + return fmt.Errorf("session: update actor role %s/%s: %w", platform, cid, err) + } + return nil +} diff --git a/internal/channel/session/ownerlane_test.go b/internal/channel/session/ownerlane_test.go index 9a9e40f..8b2b56e 100644 --- a/internal/channel/session/ownerlane_test.go +++ b/internal/channel/session/ownerlane_test.go @@ -5,9 +5,12 @@ package session import ( "context" + "database/sql" "testing" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) @@ -115,3 +118,179 @@ func TestOwnerFrameMemWritable(t *testing.T) { groupSess.Owner, groupSess.MemWritable, groupSess.IsGroup) } } + +// TestGetOrCreateCorrectsInheritedStaleRole 覆盖跨进程的同一件事:上一轮进程把这条 +// 渠道按陌生人落盘(那时账号还没进白名单),重启后新进程首次入站必须把落盘车道一并 +// 纠正——新进程的缓存是空的,但 channel_sessions 里的旧车道不会被 INSERT OR IGNORE +// 覆盖,不纠正的话面板/诊断会一直读到旧识别。 +func TestGetOrCreateCorrectsInheritedStaleRole(t *testing.T) { + t.Parallel() + + ctx := context.Background() + handle, err := db.Open(ctx, ":memory:", nil) + if err != nil { + t.Fatalf("db.Open = %v", err) + } + t.Cleanup(func() { _ = handle.Close() }) + + // 旧进程:账号还不在白名单里,落盘陌生人车道。 + stale, err := New(ctx, handle, nil) + if err != nil { + t.Fatalf("New#1 = %v", err) + } + direct := directMsg("o-100") + direct.Sender = adapter.Actor{Role: adapter.RoleUnknown} + old, err := stale.GetOrCreate(ctx, direct, &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}) + if err != nil { + t.Fatalf("GetOrCreate(旧进程) = %v", err) + } + if got := persistedRole(t, handle, old.Platform, old.ChannelID); got == string(adapter.RoleOwner) { + t.Fatalf("前置条件不成立:旧进程不该落盘 owner 车道(落盘 = %q)", got) + } + + // 新进程:同一张表、空缓存,按当前白名单重建这条渠道。 + fresh, err := New(ctx, handle, nil) + if err != nil { + t.Fatalf("New#2 = %v", err) + } + reborn, err := fresh.GetOrCreate(ctx, direct, testOwner()) + if err != nil { + t.Fatalf("GetOrCreate(新进程) = %v", err) + } + if !reborn.Owner || !reborn.MemWritable || reborn.ActorRole != adapter.RoleOwner { + t.Fatalf("新进程首帧车道 = %+v, want owner 车道", reborn) + } + if reborn.SessionID != old.SessionID { + t.Errorf("渠道↔会话绑定丢了: %q != %q", reborn.SessionID, old.SessionID) + } + if got := persistedRole(t, handle, reborn.Platform, reborn.ChannelID); got != string(adapter.RoleOwner) { + t.Errorf("落盘车道 = %q, want %q(旧进程留下的旧识别必须被纠正)", got, adapter.RoleOwner) + } +} + +// persistedRole 读取 channel_sessions 里落盘的身份车道。 +func persistedRole(t *testing.T, handle *sql.DB, platform, cid string) string { + t.Helper() + var role string + if err := handle.QueryRowContext(context.Background(), + `SELECT actor_role FROM channel_sessions WHERE platform = ? AND channel_id = ?`, + platform, cid).Scan(&role); err != nil { + t.Fatalf("读取 channel_sessions(%s/%s) = %v", platform, cid, err) + } + return role +} + +// TestGetOrCreateRefreshesLaneAfterWhitelistHotReload 覆盖真机事故(2026-10-05「QQ 私聊 +// 被当陌生人」):一段渠道在**还不是主人**时先绑定过(缓存与 channel_sessions 都是陌生人 +// 车道),之后主人把它加进白名单并热重载(design/11 §9)。此后该渠道的后续消息必须立刻 +// 走新车道——只刷新判主门槛是不够的:会话若继续用绑定时的旧车道,主人私聊仍不镜像进 +// Web 主会话(前端看不到)、MemWritable 恒 false(记忆只读的隔离上下文),要等进程重启。 +// +// 反向也必须在场:白名单里**移除**该账号后,同一条渠道必须立刻退回陌生人车道(fail-closed), +// 刷新因此不是单向提权。 +func TestGetOrCreateRefreshesLaneAfterWhitelistHotReload(t *testing.T) { + t.Parallel() + + ctx := context.Background() + m, handle := newTestManager(t) + direct := directMsg("o-100") + group := groupMsg("o-100") + // OneBot 私聊帧不带 sender.role,适配器因此上报 unknown(真机的形状);这里照抄, + // 使「陌生人车道的初始值」与真机一致。 + direct.Sender = adapter.Actor{Role: adapter.RoleUnknown} + + // 活档案:与 gateway 的 hotSwapOwnerProfile 走同一条路径(同一个指针 + Replace)。 + // 初始没有这个账号 —— 先让两条渠道以陌生人车道绑定(真机的初始状态)。 + live := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"} + firstDirect, firstGroup := bindStrangerLanes(t, ctx, m, live, direct, group) + + // 面板保存 owner.yaml → 白名单新增这个账号。 + live.Replace(testOwner()) + + secondDirect, err := m.GetOrCreate(ctx, direct, live) + if err != nil { + t.Fatalf("GetOrCreate(热重载后私聊) = %v", err) + } + if !secondDirect.Owner || !secondDirect.MemWritable || secondDirect.ActorRole != adapter.RoleOwner { + t.Fatalf("热重载后私聊车道 = %+v, want owner 车道(否则主人私聊仍被隔离、前端看不到)", secondDirect) + } + if secondDirect.SessionID != firstDirect.SessionID { + t.Errorf("身份变化把渠道搬了家: session %q != %q", secondDirect.SessionID, firstDirect.SessionID) + } + if got := persistedRole(t, handle, secondDirect.Platform, secondDirect.ChannelID); got != string(adapter.RoleOwner) { + t.Errorf("channel_sessions.actor_role = %q, want %q(否则重启后又变回陌生人)", got, adapter.RoleOwner) + } + if !listedLane(t, m, secondDirect) { + t.Error("面板列出的会话仍是旧车道(List 未反映刷新后的身份)") + } + + // 群聊:车道刷新为 owner,但**隔离不变**(群聊永远是 group_member、记忆只读)。 + secondGroup, err := m.GetOrCreate(ctx, group, live) + if err != nil { + t.Fatalf("GetOrCreate(热重载后群聊) = %v", err) + } + if !secondGroup.Owner || secondGroup.MemWritable || !secondGroup.IsGroup { + t.Errorf("热重载后群聊车道 Owner=%v MemWritable=%v IsGroup=%v, want true/false/true", + secondGroup.Owner, secondGroup.MemWritable, secondGroup.IsGroup) + } + if got := secondGroup.Env(); got != restrict.ActorGroupMember { + t.Errorf("群聊 Env() = %q, want group_member", got) + } + if secondGroup.SessionID != firstGroup.SessionID { + t.Errorf("群聊 session 变了: %q != %q", secondGroup.SessionID, firstGroup.SessionID) + } + + // 反向:白名单里移除该账号 ⇒ 同一条渠道立刻退回陌生人车道(不得单向提权)。 + live.Replace(&user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}) + revoked, err := m.GetOrCreate(ctx, direct, live) + if err != nil { + t.Fatalf("GetOrCreate(白名单移除后) = %v", err) + } + if revoked.Owner || revoked.MemWritable || revoked.ActorRole == adapter.RoleOwner { + t.Errorf("白名单移除后车道 = %+v, want 退回陌生人车道", revoked) + } + if revoked.SessionID != firstDirect.SessionID { + t.Errorf("撤销判主把渠道搬了家: %q != %q", revoked.SessionID, firstDirect.SessionID) + } + if got := persistedRole(t, handle, revoked.Platform, revoked.ChannelID); got == string(adapter.RoleOwner) { + t.Errorf("channel_sessions.actor_role 仍是 owner, want 非主人(撤销未落盘)") + } +} + +// bindStrangerLanes 在空白名单下把私聊与群聊两条渠道先绑定成陌生人车道并断言结果, +// 返回两个会话。这就是真机上的初始状态:账号还没进白名单,渠道却已经按陌生人落库并进缓存。 +func bindStrangerLanes(t *testing.T, ctx context.Context, m *SessionManager, + live *user.OwnerProfile, direct, group adapter.UnifiedMessage) (Session, Session) { + t.Helper() + firstDirect, err := m.GetOrCreate(ctx, direct, live) + if err != nil { + t.Fatalf("GetOrCreate(白名单外私聊) = %v", err) + } + if firstDirect.Owner || firstDirect.MemWritable || firstDirect.ActorRole != adapter.RoleUnknown { + t.Fatalf("白名单外私聊车道 = %+v, want 陌生人车道", firstDirect) + } + firstGroup, err := m.GetOrCreate(ctx, group, live) + if err != nil { + t.Fatalf("GetOrCreate(白名单外群聊) = %v", err) + } + if firstGroup.Owner || firstGroup.MemWritable || !firstGroup.IsGroup { + t.Fatalf("白名单外群聊车道 = %+v, want 非主人/只读/群聊", firstGroup) + } + return firstDirect, firstGroup +} + +// listedLane 报告 m.List()(面板/诊断读的那一份)里该渠道是否已经是 owner 车道。 +func listedLane(t *testing.T, m *SessionManager, want Session) bool { + t.Helper() + got, err := m.List(context.Background()) + if err != nil { + t.Fatalf("List() = %v", err) + } + for _, s := range got { + if s.Platform == want.Platform && s.ChannelID == want.ChannelID { + return s.Owner && s.ActorRole == adapter.RoleOwner + } + } + t.Fatalf("List() 里找不到 %s/%s", want.Platform, want.ChannelID) + return false +} diff --git a/plugins/channels/qq_obv11/ids.go b/plugins/channels/qq_obv11/ids.go new file mode 100644 index 0000000..1240ed9 --- /dev/null +++ b/plugins/channels/qq_obv11/ids.go @@ -0,0 +1,55 @@ +package qq_obv11 + +// 本文件承载 OneBot 事件里 id 的解析与几个轻量字符串工具,从 message.go 拆出 +// (AGENTS §10 文件 ≤500 行)。判主用的 SenderID 解析规则就在这里:见 senderIDOf。 + +import ( + "fmt" + "strconv" +) + +// senderIDOf 解析本条消息的发送者 id(判主用的那个字段)。OneBot v11 规定消息事件 +// 在**顶层**带 user_id(obEvent.UserID),但实现之间有出入:有的只在 sender.user_id +// 里给。过去的实现只读顶层,顶层缺失/为 0 时 SenderID 就成了空串,而 +// owner.IsOwner 对空 accountID 一律返回 false(fail-closed)——号码明明在白名单里 +// 也永远判不成主人。这里给出兜底链:顶层 user_id → sender.user_id,两者都缺才是空。 +// +// 群聊同样走这条链(发送者就是 user_id):群号**不是**发送者,绝不能作为它的兜底, +// 否则群里任何人都能顶着群号去撞白名单。见 toUnified 的 group/private 两个分支。 +func senderIDOf(ev *obEvent) string { + if ev == nil { + return "" + } + if id := formatID(ev.UserID); id != "" { + return id + } + if ev.Sender != nil { + return formatID(ev.Sender.UserID) + } + return "" +} + +// formatID 把 OneBot 的数字 id 归一成字符串;0(字段缺失)归一成空串,让 +// 「没给」与「给了 0」在下游是同一种情况。 +func formatID(id int64) string { + if id == 0 { + return "" + } + return strconv.FormatInt(id, 10) +} + +func parseInt64(s string) (int64, error) { + if s == "" { + return 0, fmt.Errorf("empty id") + } + return strconv.ParseInt(s, 10, 64) +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} diff --git a/plugins/channels/qq_obv11/message.go b/plugins/channels/qq_obv11/message.go index 3b85214..717ddbb 100644 --- a/plugins/channels/qq_obv11/message.go +++ b/plugins/channels/qq_obv11/message.go @@ -82,7 +82,7 @@ func parseEvent(payload []byte) (*obEvent, error) { func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage { text, media, mentions, replyID, replyText, hasText := a.parseMessage(ev) - senderID := formatID(ev.UserID) + senderID := senderIDOf(ev) senderName := "unknown" role := adapter.RoleUnknown if ev.Sender != nil { @@ -112,6 +112,11 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage { switch ev.MessageType { case "group": + // 群聊的渠道键是**群号**,发送者仍是 senderID(上一步解析):两者绝不可互换。 + // 用群号当 SenderID 会让群里任何人都顶着群号去撞白名单(冒充主人)。事件里 + // 没有第二个群号来源,group_id 缺失时 ChannelID 就如实留空——下游 + // session.channelID() 会以 ErrInvalidChannel 拒掉这条消息(fail-closed), + // 绝不退回用发送者号去编一个群会话键。 gid := formatID(ev.GroupID) msg.ChannelType = adapter.ChannelGroup msg.ChannelID = gid @@ -125,6 +130,9 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage { } case "private": msg.ChannelType = adapter.ChannelDirect + // 私聊的对端**就是**发送者,所以这里的 ChannelID 可以安全地回落到 SenderID + // (对端号 == 发送者号)。群聊绝不允许这样回落:群号不是发送者,用 ChannelID + // 充当 SenderID 会让**任何群成员**都顶着群号去撞白名单——见 group 分支。 msg.ChannelID = senderID } return msg @@ -443,26 +451,3 @@ func segStr(d map[string]any, key string) string { return "" } } - -func formatID(id int64) string { - if id == 0 { - return "" - } - return strconv.FormatInt(id, 10) -} - -func parseInt64(s string) (int64, error) { - if s == "" { - return 0, fmt.Errorf("empty id") - } - return strconv.ParseInt(s, 10, 64) -} - -func firstNonEmpty(ss ...string) string { - for _, s := range ss { - if s != "" { - return s - } - } - return "" -} diff --git a/plugins/channels/qq_obv11/ownerlane_test.go b/plugins/channels/qq_obv11/ownerlane_test.go index 302798a..f16b382 100644 --- a/plugins/channels/qq_obv11/ownerlane_test.go +++ b/plugins/channels/qq_obv11/ownerlane_test.go @@ -6,6 +6,7 @@ package qq_obv11 // 因而既绕不过限流/黑白名单,也拿不到 MemWritable、不会镜像进主会话。 import ( + "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" @@ -121,3 +122,173 @@ func TestOwnerLaneRejectsAnonymousWhitelistEntry(t *testing.T) { t.Fatal("空 accountId 的白名单把匿名发送者判成了主人") } } + +// TestSelfSentAndNonMessageFramesNotDispatched 覆盖第三种事件:自己发出的消息 +// (OneBot v11 的 post_type=message_sent)与任何非 message 上报都不得进流水线。 +// message_sent 的 user_id 是**对端**:一旦被当成入站,机器人会把自己刚发出的消息 +// 当对方的来信再回一遍(回声环),判主/会话键也会跟着乱。 +func TestSelfSentAndNonMessageFramesNotDispatched(t *testing.T) { + t.Parallel() + + frames := []struct { + name string + raw string + }{ + { + name: "自己发的私聊", + raw: `{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100000,"target_id":100000,"message":[{"type":"text","data":{"text":"我发出的回复"}}],"sender":{"user_id":100,"nickname":"bot"}}`, + }, + { + name: "自己发的群聊", + raw: `{"post_type":"message_sent","message_type":"group","time":1700000000,"self_id":100,"message_id":22,"group_id":321,"user_id":100000,"target_id":321,"message":[{"type":"text","data":{"text":"我发出的回复"}}],"sender":{"user_id":100,"nickname":"bot"}}`, + }, + { + name: "元事件(心跳)", + raw: `{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100,"status":{"online":true}}`, + }, + } + for _, f := range frames { + f := f + t.Run(f.name, func(t *testing.T) { + a := NewAdapter(Config{}) + var delivered []adapter.UnifiedMessage + // handlePayload → dispatch 是同步的,因此无需 sleep 即可断言「没投递」。 + a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { + delivered = append(delivered, m) + }) + a.handlePayload(context.Background(), []byte(f.raw)) + if len(delivered) != 0 { + t.Fatalf("%s 被投递进流水线: %+v", f.name, delivered) + } + }) + } +} + +// senderIDCase 是 TestSenderIDFallbackChain 的一帧样本与其期望值。 +type senderIDCase struct { + name string + raw string + // wantSenderID 是判主用的发送者号;wantChannel/wantGroupID 是会话键与群标识—— + // 三者在群聊里必须彼此独立,绝不互相顶替。 + wantSenderID string + wantChannel string + wantGroupID string + wantOwner bool + wantEnv restrict.ActorEnv + wantMem bool +} + +// senderIDFallbackCases 返回样本表。写成函数而不是包级变量(AGENTS §10 禁包级全局变量)。 +func senderIDFallbackCases(ownerQQ string) []senderIDCase { + return []senderIDCase{ + { + // 顶层没有 user_id(只在 sender.user_id 里给):兜底链的正面用例。 + name: "私聊:user_id 只在 sender 里", + raw: `{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":11,"message":[{"type":"text","data":{"text":"在吗"}}],"sender":{"user_id":100000,"nickname":"主人","role":""}}`, + wantSenderID: ownerQQ, wantChannel: ownerQQ, + wantOwner: true, wantEnv: restrict.ActorOwner, wantMem: true, + }, + { + name: "私聊:顶层带 user_id", + raw: `{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":12,"user_id":100000,"message":[{"type":"text","data":{"text":"在吗"}}],"sender":{"user_id":100000,"nickname":"主人"}}`, + wantSenderID: ownerQQ, wantChannel: ownerQQ, + wantOwner: true, wantEnv: restrict.ActorOwner, wantMem: true, + }, + { + // 群聊里主人发言:SenderID 是主人的号,ChannelID/GroupID 是群号;判主走 + // 白名单(是主人),但环境仍是群聊隔离的 group_member、记忆只读。 + name: "群聊:主人发言", + raw: `{"post_type":"message","message_type":"group","time":1700000000,"self_id":100,"message_id":13,"group_id":321,"group_name":"Team","message":[{"type":"text","data":{"text":"大家好"}}],"sender":{"user_id":100000,"nickname":"主人","role":"member"}}`, + wantSenderID: ownerQQ, wantChannel: "321", wantGroupID: "321", + wantOwner: true, wantEnv: restrict.ActorGroupMember, wantMem: false, + }, + { + // 群号恰好等于主人号:若实现拿 ChannelID(群号) 当 SenderID,陌生人 666 + // 就会被判成主人——本用例就是这条退化的哨兵。 + name: "群聊:群号等于主人号时陌生人不得借群号冒充", + raw: `{"post_type":"message","message_type":"group","time":1700000000,"self_id":100,"message_id":14,"group_id":100000,"group_name":"Team","message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":666,"nickname":"路人","role":"member"}}`, + wantSenderID: "666", wantChannel: ownerQQ, wantGroupID: ownerQQ, + wantOwner: false, wantEnv: restrict.ActorGroupMember, wantMem: false, + }, + { + // 群聊里发送者 id 完全缺失:SenderID 只能是空(fail-closed),**绝不**退回 + // 群号——否则群里任何人(或匿名发送)都能顶着群号去撞白名单。这里群号就 + // 是白名单里的主人号,任何「群号冒充发送者」的实现都会在本用例上判成主人。 + name: "群聊:发送者 id 缺失时不得用群号顶替", + raw: `{"post_type":"message","message_type":"group","time":1700000000,"self_id":100,"message_id":16,"group_id":100000,"group_name":"Team","message":[{"type":"text","data":{"text":"hi"}}]}`, + wantSenderID: "", wantChannel: ownerQQ, wantGroupID: ownerQQ, + wantOwner: false, wantEnv: restrict.ActorGroupMember, wantMem: false, + }, + { + // 不在白名单里的陌生号:仍然是非主人(不误判),且车道是陌生人。 + name: "私聊:未知号仍是陌生人", + raw: `{"post_type":"message","message_type":"private","time":1700000000,"self_id":100,"message_id":15,"message":[{"type":"text","data":{"text":"hi"}}],"sender":{"user_id":999,"nickname":"陌生人"}}`, + wantSenderID: "999", wantChannel: "999", + wantOwner: false, wantEnv: restrict.ActorStranger, wantMem: false, + }, + } +} + +// TestSenderIDFallbackChain 覆盖判主字段 SenderID 的解析链:OneBot 实现之间对 +// 「user_id 放在顶层还是 sender 里」有出入,顶层缺失时必须回落到 sender.user_id, +// 否则号码明明在白名单里也会因为空 SenderID 判不成主人(owner.IsOwner 对空 id +// fail-closed)。群聊一律不得拿群号当 SenderID。 +func TestSenderIDFallbackChain(t *testing.T) { + t.Parallel() + + const ownerQQ = "100000" + owner := &user.OwnerProfile{ + ID: "owner", + DisplayName: "主人", + Accounts: []user.OwnerAccount{{Platform: platformName, AccountID: ownerQQ}}, + } + + for _, tt := range senderIDFallbackCases(ownerQQ) { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + raw := []byte(tt.raw) + a := NewAdapter(Config{}) + ev, err := parseEvent(raw) + if err != nil { + t.Fatalf("parseEvent: %v", err) + } + msg := a.toUnified(ev, raw) + if err := msg.Validate(); err != nil { + t.Fatalf("归一化后的消息不合法(桥接器会整条丢掉): %v", err) + } + + if msg.SenderID != tt.wantSenderID { + t.Errorf("SenderID = %q, want %q(判主字段)", msg.SenderID, tt.wantSenderID) + } + if msg.Sender.UserID != tt.wantSenderID { + t.Errorf("Sender.UserID = %q, want %q", msg.Sender.UserID, tt.wantSenderID) + } + if msg.ChannelID != tt.wantChannel { + t.Errorf("ChannelID = %q, want %q", msg.ChannelID, tt.wantChannel) + } + if msg.GroupID != tt.wantGroupID { + t.Errorf("GroupID = %q, want %q", msg.GroupID, tt.wantGroupID) + } + wantIsGroup := tt.wantGroupID != "" + if msg.Sender.IsGroup != wantIsGroup || msg.IsGroup() != wantIsGroup { + t.Errorf("IsGroup = %v/%v, want %v", msg.Sender.IsGroup, msg.IsGroup(), wantIsGroup) + } + if msg.Sender.GroupID != tt.wantGroupID { + t.Errorf("Sender.GroupID = %q, want %q", msg.Sender.GroupID, tt.wantGroupID) + } + + actor := session.ResolveActor(msg, owner) + if actor.IsOwner() != tt.wantOwner { + t.Errorf("IsOwner() = %v, want %v", actor.IsOwner(), tt.wantOwner) + } + if got := session.EnvFor(actor); got != tt.wantEnv { + t.Errorf("EnvFor() = %q, want %q", got, tt.wantEnv) + } + if got := actor.IsOwner() && !actor.IsGroupChat(); got != tt.wantMem { + t.Errorf("MemWritable 派生 = %v, want %v", got, tt.wantMem) + } + }) + } +}