fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值; owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**); 而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate 对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话), 但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、 落盘 actor_role 永不更新)。 修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘 (updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正 (覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息); **身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。 QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id → sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人 可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event 已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。 测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时 陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单 热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」 「跨进程继承的旧行被就地纠正」)。 变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown / 还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
This commit is contained in:
1 parent
b16ab2c5e8
commit
d532eb3fcd
5 files changed
+494
-38
No files matched your search
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user