fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)

- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
AskaEth committed 2026-10-04 12:07:40 +08:00
1 parent 7e7f53bb41
commit af8591b78a
30 files changed
+1289 -144

No files matched your search

+3 -1
View File
@@ -8,7 +8,9 @@ package adapter
type Role string
const (
// RoleOwner is the bound owner/primary account.
// RoleOwner is the bound owner/primary account. 只有渠道层的白名单判主可以赋这个角色
// (owner.yaml 账号白名单 + 每渠道 adminIds[],裁决 06.4.2):适配器**不得**从平台字段
// (如 OneBot 的 sender.role=owner)直接上报它——群主/群管理员都上报 RoleMember。
RoleOwner Role = "owner"
// RoleMember is a non-owner participant (for example a stranger in a group).
RoleMember Role = "member"
+123 -40
View File
@@ -31,19 +31,26 @@ type GateVerdict struct {
Pruned []UnifiedMessage
}
// FlowGateOptions configures a per-adapter ingress gate. Every limit is
// FlowGateOptions configures a per-account ingress gate. Every limit is
// independent and a zero value disables that limit, so an account that does not
// declare the new fields keeps its previous unlocked behavior (backward
// compatible, design/12 §6 实现要点).
//
// **计数键 = 会话级**(裁决 06.4.3):GroupMsgPerMin 与 MaxMessages 都按
// ConversationKey(`platform+channelID+groupID/peerID`)每个群聊/私聊独立计数,不再在
// 适配器顶层汇总——两个群并发互不挤占。MsgPerMin 是防刷屏的**账号级总量护栏**,按设计
// 口径仍留在适配器顶层,不随会话拆分。
type FlowGateOptions struct {
// GroupMsgPerMin caps group-chat inbound messages per minute (design/12 §6
// 策略三). Zero disables the group limit.
// GroupMsgPerMin caps group-chat inbound messages per minute **per
// conversation** (design/12 §6 策略三 / 裁决 06.4.3). Zero disables the limit.
GroupMsgPerMin int
// MsgPerMin caps total inbound non-owner messages per minute. Zero disables it.
// MsgPerMin caps total inbound non-owner messages per minute across the whole
// account — the anti-flood total guard. Zero disables it.
MsgPerMin int
// MaxMessages is the total pending-message cap. When the pending set nears it
// the Reviewer is invoked to prune noise; with no Reviewer the oldest messages
// are evicted FIFO. Zero disables the cap (design/12 §6 策略四).
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
// is invoked to prune noise; with no Reviewer the oldest messages are evicted
// FIFO. Zero disables the cap.
MaxMessages int
// Window is the sliding window over which the per-minute limits are counted.
// Zero uses defaultRateWindow.
@@ -72,9 +79,45 @@ const (
// FlowCapDefault is the protective default total-cap of 50 pending messages
// (design/12 §6 策略四).
FlowCapDefault = 50
// sessionGCInterval is how many admissions pass between idle-conversation sweeps,
// so a long-lived gate that has seen many conversations does not grow unbounded.
sessionGCInterval = 256
)
// FlowGate is a concurrency-safe, per-adapter ingress gate implementing the two
// ConversationKey 返回一条消息所属会话的节流键(裁决 06.4.3):
// `platform+channelID+groupID/peerID`——群聊按 (平台, 群) 独立计数,私聊按 (平台, 对端)
// 独立计数。它同时被入站滑窗(本文件的 FlowGate)与出处参与引擎
// (channelbridge.engageKey) 使用,使「每会话独立计数」只有一处口径。
// 多账号(platform:confId)之间的隔离由每个账号各自的 FlowGate/适配器实例承担。
func ConversationKey(msg UnifiedMessage) string {
conv := msg.GroupID
if msg.IsGroup() {
if conv == "" {
// 群聊缺 group id 时退回 channel id(qq-obv11 里 channelID 就是群号),
// 避免同一个群被拆成多个桶而让限流失效。
conv = msg.ChannelID
}
} else {
conv = msg.SenderID
if conv == "" {
conv = msg.Sender.UserID
}
if conv == "" {
conv = msg.ChannelID
}
}
return msg.Platform + "|" + msg.ChannelID + "|" + conv
}
// flowSession 是一个会话(群聊/私聊)独立的节流状态。FlowGate 按 ConversationKey 为
// 每个会话保存一份,因此两个群并发互不挤占(裁决 06.4.3)。
type flowSession struct {
groupHits []time.Time
buffer []queuedMsg
lastReview time.Time
}
// FlowGate is a concurrency-safe, per-account ingress gate implementing the two
// measures from design/12 §6 策略三/四:
//
// - 策略三: a sliding-window rate limit on group chat (and, optionally, all
@@ -84,6 +127,8 @@ const (
// invoked to prune low-value messages; with no Reviewer the oldest messages are
// evicted FIFO. Either way every dropped message is audited.
//
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
//
// Owner-private messages are not routed through the gate (the caller bypasses it),
// so the owner is never throttled or pruned. Identity is never examined here.
type FlowGate struct {
@@ -96,12 +141,11 @@ type FlowGate struct {
onPruned func(UnifiedMessage, string)
now func() time.Time
mu sync.Mutex
groupHits []time.Time
totalHits []time.Time
buffer []queuedMsg
lastReview time.Time
seq uint64
mu sync.Mutex
totalHits []time.Time
sessions map[string]*flowSession
admitted uint64
seq uint64
}
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
@@ -123,6 +167,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
review: opts.Review,
onPruned: opts.OnPruned,
now: opts.Now,
sessions: make(map[string]*flowSession),
}
if g.window <= 0 {
g.window = defaultRateWindow
@@ -139,23 +184,30 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
return g
}
// Admit applies the rate limit then the total-cap/review gate to one inbound
// message. It returns a verdict telling the caller whether to proceed; messages
// that the gate drops are returned (and audited) so nothing is lost silently.
// Admit applies the rate limits then the per-conversation cap/review gate to one
// inbound message. It returns a verdict telling the caller whether to proceed;
// messages that the gate drops are returned (and audited) so nothing is lost
// silently.
//
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
now := g.now()
key := ConversationKey(msg)
g.mu.Lock()
defer g.mu.Unlock()
g.maybeGCLocked(now)
// 策略三: sliding-window rate limits. Group chat has its own cap; MsgPerMin is
// an optional total cap across all non-owner messages. Over-limit messages are
// dropped at the door (downgraded) instead of blocking.
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
// (downgraded) instead of blocking.
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
g.groupHits = g.trimHits(g.groupHits, now)
if len(g.groupHits) >= g.groupPerMin {
st := g.sessionLocked(key)
st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) >= g.groupPerMin {
return g.drop("rate_limited", msg)
}
g.groupHits = append(g.groupHits, now)
st.groupHits = append(st.groupHits, now)
}
if g.msgPerMin > 0 {
g.totalHits = g.trimHits(g.totalHits, now)
@@ -169,29 +221,31 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
return GateVerdict{Proceed: true}
}
// 策略四: add to the pending set, then prune when it nears the cap.
// 策略四(会话级): add to this conversation's pending set, then prune when it
// nears the cap.
st := g.sessionLocked(key)
g.seq++
seq := g.seq
g.buffer = append(g.buffer, queuedMsg{seq: seq, msg: msg})
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
var dropped []queuedMsg
if g.review != nil && len(g.buffer) >= g.reviewAt && !g.reviewRecently(now) {
keep, err := g.review(ctx, queueMessages(g.buffer))
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
keep, err := g.review(ctx, queueMessages(st.buffer))
if err == nil {
g.buffer, dropped = g.applyReview(g.buffer, keep)
g.lastReview = now
st.buffer, dropped = g.applyReview(st.buffer, keep)
st.lastReview = now
g.auditDropped(dropped, "review_pruned")
}
}
if len(g.buffer) > g.maxMessages {
n := len(g.buffer) - g.maxMessages
if len(st.buffer) > g.maxMessages {
n := len(st.buffer) - g.maxMessages
var evicted []queuedMsg
g.buffer, evicted = g.dropFront(g.buffer, n)
st.buffer, evicted = g.dropFront(st.buffer, n)
dropped = append(dropped, evicted...)
g.auditDropped(evicted, "capped")
}
if !g.containsSeq(seq) {
if !bufferHasSeq(st.buffer, seq) {
reason := "capped"
if containsSeq(dropped, seq) {
reason = "review_pruned"
@@ -213,13 +267,41 @@ func (g *FlowGate) drop(reason string, msg UnifiedMessage) GateVerdict {
return v
}
// sessionLocked 返回 key 对应的会话节流状态,首次使用时惰性创建。调用方须持有 g.mu。
func (g *FlowGate) sessionLocked(key string) *flowSession {
if g.sessions == nil {
g.sessions = make(map[string]*flowSession)
}
st := g.sessions[key]
if st == nil {
st = &flowSession{}
g.sessions[key] = st
}
return st
}
// maybeGCLocked 每 sessionGCInterval 次放行清理一次空闲会话,避免会话状态表随历史会话
// 无界增长:滑窗时间戳全部过期且待处理队列为空的会话直接删除。调用方须持有 g.mu。
func (g *FlowGate) maybeGCLocked(now time.Time) {
g.admitted++
if g.admitted%sessionGCInterval != 0 {
return
}
for key, st := range g.sessions {
st.groupHits = g.trimHits(st.groupHits, now)
if len(st.groupHits) == 0 && len(st.buffer) == 0 {
delete(g.sessions, key)
}
}
}
// reviewRecently throttles review so it does not run on every message: it fires at
// most once per window, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(now time.Time) bool {
if g.lastReview.IsZero() {
// most once per window per conversation, which is the cost guard for the review model.
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
if st == nil || st.lastReview.IsZero() {
return false
}
return now.Sub(g.lastReview) < g.window
return now.Sub(st.lastReview) < g.window
}
// trimHits drops event timestamps older than the sliding window.
@@ -265,9 +347,10 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
}
// containsSeq reports whether an entry with the given sequence is still buffered.
func (g *FlowGate) containsSeq(seq uint64) bool {
for _, e := range g.buffer {
// bufferHasSeq reports whether an entry with the given sequence is still buffered
// in the conversation's pending set.
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
for _, e := range buffer {
if e.seq == seq {
return true
}
@@ -0,0 +1,132 @@
package adapter
// 本文件覆盖裁决 06.4.3:节流(群聊滑窗与待处理上限)的计数键是**会话级**
// (ConversationKey = platform+channelID+groupID/peerID),不在适配器顶层汇总——
// 两个群并发互不挤占。
import (
"context"
"testing"
"time"
)
// groupMsgIn 构造指定群的群聊消息(platform kook),用于验证会话级计数。
func groupMsgIn(groupID string, i int) UnifiedMessage {
return UnifiedMessage{
Platform: "kook",
ChannelID: groupID,
ChannelType: ChannelGroup,
GroupID: groupID,
SenderID: "u" + itoa(i),
Content: "msg " + itoa(i),
ContentType: ContentText,
}
}
// directMsgTo 构造指定对端的私聊消息,用于验证私聊额度独立。
func directMsgTo(peer string, i int) UnifiedMessage {
return UnifiedMessage{
Platform: "telegram",
ChannelID: peer,
ChannelType: ChannelDirect,
SenderID: peer,
Content: "dm " + itoa(i),
ContentType: ContentText,
}
}
// TestConversationKeySessionScoped 固定会话键口径(裁决 06.4.3):群聊按 (平台, 群)
// 独立、私聊按 (平台, 对端) 独立,不同会话的键互不相等。
func TestConversationKeySessionScoped(t *testing.T) {
t.Parallel()
if ConversationKey(groupMsgIn("-100", 0)) != ConversationKey(groupMsgIn("-100", 1)) {
t.Error("同一个群的两条消息应落在同一个会话键")
}
if ConversationKey(groupMsgIn("-100", 0)) == ConversationKey(groupMsgIn("-200", 0)) {
t.Error("两个群共用了同一个会话键(会话级计数失效)")
}
if ConversationKey(directMsgTo("peer-1", 0)) == ConversationKey(directMsgTo("peer-2", 0)) {
t.Error("两个私聊对端共用了同一个会话键")
}
if ConversationKey(directMsgTo("peer-1", 0)) == ConversationKey(groupMsgIn("-100", 0)) {
t.Error("私聊与群聊共用了同一个会话键")
}
// 群聊缺 group id 时退回 channel id:仍按群独立,不得退化成按发送者分桶
// (否则限流会被发言者数量放大而失效)。
bare := UnifiedMessage{Platform: "kook", ChannelType: ChannelGroup, ChannelID: "-100"}
withSender := UnifiedMessage{
Platform: "kook", ChannelType: ChannelGroup, ChannelID: "-100", SenderID: "u9",
}
if ConversationKey(bare) != ConversationKey(withSender) {
t.Error("群聊缺 group id 时退化成按发送者分桶")
}
}
// TestFlowGate_GroupRateLimitIsPerConversation 是裁决 06.4.3 的核心断言:群聊滑窗按
// 每个群独立计数——A 群用尽额度不挤占 B 群(两个群并发互不挤占)。
func TestFlowGate_GroupRateLimitIsPerConversation(t *testing.T) {
t.Parallel()
now, clock := fixedClock(time.Unix(1000, 0))
g := NewFlowGate(FlowGateOptions{GroupMsgPerMin: 2, Window: time.Minute, Now: now})
ctx := context.Background()
for i := 0; i < 2; i++ {
if v := g.Admit(ctx, groupMsgIn("-100", i)); !v.Proceed {
t.Fatalf("A 群第 %d 条被拒,want admitted: %+v", i+1, v)
}
}
if v := g.Admit(ctx, groupMsgIn("-100", 2)); v.Proceed {
t.Fatal("A 群超出自身额度仍被放行")
}
for i := 0; i < 2; i++ {
if v := g.Admit(ctx, groupMsgIn("-200", i)); !v.Proceed {
t.Fatalf("B 群第 %d 条被 A 群额度挤占: %+v", i+1, v)
}
}
if v := g.Admit(ctx, groupMsgIn("-200", 9)); v.Proceed {
t.Fatal("B 群超出自身额度仍被放行")
}
// 私聊有自己的额度,不参与群聊滑窗。
if v := g.Admit(ctx, directMsgTo("peer-1", 0)); !v.Proceed {
t.Fatalf("私聊被群聊额度挤占: %+v", v)
}
// 窗口滚过之后 A 群额度恢复。
*clock = clock.Add(61 * time.Second)
if v := g.Admit(ctx, groupMsgIn("-100", 3)); !v.Proceed {
t.Fatalf("窗口滚过后 A 群应恢复放行: %+v", v)
}
}
// TestFlowGate_CapIsPerConversation 覆盖策略四同样按会话独立:一个群堆满待处理消息
// 触发的是本群的 FIFO 淘汰,不会把另一个群的待处理消息挤掉。
func TestFlowGate_CapIsPerConversation(t *testing.T) {
t.Parallel()
g := NewFlowGate(FlowGateOptions{MaxMessages: 2})
ctx := context.Background()
for i := 0; i < 2; i++ {
if v := g.Admit(ctx, groupMsgIn("-100", i)); !v.Proceed {
t.Fatalf("A 群第 %d 条被拒: %+v", i+1, v)
}
}
v := g.Admit(ctx, groupMsgIn("-100", 2))
if !v.Proceed {
t.Fatalf("A 群新消息在 FIFO 淘汰后应放行: %+v", v)
}
if len(v.Pruned) != 1 || v.Pruned[0].ChannelID != "-100" {
t.Fatalf("A 群淘汰的应是本群消息, got %+v", v.Pruned)
}
for i := 0; i < 2; i++ {
if v := g.Admit(ctx, groupMsgIn("-200", i)); !v.Proceed || len(v.Pruned) != 0 {
t.Fatalf("B 群被 A 群的待处理队列挤占: %+v", v)
}
}
}
+32 -19
View File
@@ -6,13 +6,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// ResolveActor turns a normalized inbound message into the strongly typed actor
// metadata used by every downstream permission decision. It is the structural
// owner-recognition rule from design/11 §0: the owner is decided by matching
// channel-level identity (platform + account id) against the user.OwnerProfile
// whitelist (or by honoring a channel-level owner tag), and never by reading the
// message text. A message whose body says "I am the owner" is not the owner
// unless it also matches the whitelist.
// ResolveActor 把一条归一化入站消息解析成下游权限决策所用的强类型 actor 元数据。
// 这是 design/11 §0 的结构化主人识别规则(裁决 06.4.2):**owner 车道只认白名单**,
// 判主依据只有结构化来源——主人档案的账号白名单(owner.yaml,internal/user)与每渠道
// 账号配置的 adminIds[](channels.json,design/08 §1.1)。
//
// **适配器上报的角色标记一律不得单独判主**:平台消息里的 role 字段(OneBot 的群主
// owner / 群管理员 admin,或私聊帧里伪造的 role=owner)只能当作非主人侧提示,命中
// owner 也会被降级为 RoleMember。判主永远不看消息正文——正文声称「我是主人」同样无效。
func ResolveActor(msg adapter.UnifiedMessage, owner *user.OwnerProfile) adapter.Actor {
actor := adapter.Actor{
Role: msg.Sender.Role,
@@ -23,19 +24,30 @@ func ResolveActor(msg adapter.UnifiedMessage, owner *user.OwnerProfile) adapter.
GroupName: firstNonEmpty(msg.Sender.GroupName, msg.GroupName),
}
// Structural owner resolution: the whitelist is authoritative, and a
// channel-level owner tag (from authenticated login binding) is also
// structural. Neither depends on msg.Content.
if (owner != nil && owner.IsOwner(msg.Platform, msg.SenderID)) || actor.Role == adapter.RoleOwner {
// 白名单命中即主人;未命中则不管适配器上报了什么角色,都留在非主人车道
// (owner.IsOwner 对 nil 档案安全返回 false)。
if owner.IsOwner(msg.Platform, msg.SenderID) {
actor.Role = adapter.RoleOwner
} else if !actor.Role.Valid() {
// No channel-tagged role and not the owner: default to a member rather
// than leaving the role ambiguous, which is the conservative choice.
actor.Role = adapter.RoleMember
return actor.Resolve()
}
actor.Role = nonOwnerRole(actor.Role)
return actor.Resolve()
}
// nonOwnerRole 把适配器上报的角色收口到非主人车道:owner(群主 / 群管理员 / 伪造
// 字段)一律降级为 RoleMember;member / system / unknown 原样保留;非法值保守回落
// RoleMember,避免留下含糊角色。
func nonOwnerRole(role adapter.Role) adapter.Role {
switch role {
case adapter.RoleOwner:
return adapter.RoleMember
case adapter.RoleMember, adapter.RoleSystem, adapter.RoleUnknown:
return role
default:
return adapter.RoleMember
}
}
// EnvFor maps a resolved actor to the restrict.ActorEnv used to gate tools.
// Group chats always map to group_member (isolated, restricted) even when the
// owner is the speaker (design/02 §3: owner group speech stays in the isolated
@@ -51,15 +63,16 @@ func EnvFor(actor adapter.Actor) restrict.ActorEnv {
return restrict.ActorStranger
}
// IdentifyContext returns the restrict.ActorEnv for a message from the channel
// metadata it carries (msg.Sender), inferring the group flag from the channel
// type when the sender is not already tagged. This is the environment the policy
// engine is bound to before any tool call.
// IdentifyContext 仅凭消息自带的渠道元数据推断 restrict.ActorEnv:群聊一律
// group_member,其余一律 stranger。它拿不到白名单,因此**不能授予 owner 车道**——
// 适配器上报的 role=owner 在此被降级为 member(裁决 06.4.2,owner 只认白名单)。
// 真正的判主走 ResolveActor(msg, owner) + EnvFor。
func IdentifyContext(msg adapter.UnifiedMessage) restrict.ActorEnv {
actor := msg.Sender.Resolve()
if msg.IsGroup() {
actor.IsGroup = true
}
actor.Role = nonOwnerRole(actor.Role)
return EnvFor(actor)
}
+17 -9
View File
@@ -122,17 +122,23 @@ func TestResolveActorStranger(t *testing.T) {
}
}
func TestResolveActorPreservesChannelOwnerTag(t *testing.T) {
func TestResolveActorIgnoresAdapterOwnerTag(t *testing.T) {
t.Parallel()
// A channel-level owner tag (from authenticated login binding) is structural
// metadata and must be honored even without a whitelist.
msg := directMsg("login-owner")
// 反向用例(裁决 06.4.2):适配器上报的 owner 标记**不得单独判主**。没有白名单时
// 它只能拿到非主人车道,因此拿不到 MemWritable、也不可能镜像进主会话。
msg := directMsg("forged-owner")
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
actor := ResolveActor(msg, nil)
if !actor.IsOwner() {
t.Fatal("channel-tagged owner lost during resolution")
if actor.IsOwner() {
t.Fatal("适配器上报的 role=owner 被判成了主人(应只认白名单)")
}
if actor.Role != adapter.RoleMember {
t.Errorf("Role = %q, want member (owner 标记降级)", actor.Role)
}
if got := EnvFor(actor); got != restrict.ActorStranger {
t.Errorf("EnvFor() = %q, want stranger", got)
}
}
@@ -176,8 +182,10 @@ func TestEnvFor(t *testing.T) {
func TestIdentifyContext(t *testing.T) {
t.Parallel()
ownerMsg := directMsg("o-100")
ownerMsg.Sender = adapter.Actor{Role: adapter.RoleOwner}
// 适配器上报 role=owner 但没有白名单可查:IdentifyContext 不得授予 owner 车道
// (裁决 06.4.2),因此它只能得到 stranger。
ownerTaggedMsg := directMsg("o-100")
ownerTaggedMsg.Sender = adapter.Actor{Role: adapter.RoleOwner}
groupMsgWithSender := groupMsg("g-1")
groupMsgWithSender.Sender = adapter.Actor{Role: adapter.RoleMember}
@@ -189,7 +197,7 @@ func TestIdentifyContext(t *testing.T) {
msg adapter.UnifiedMessage
want restrict.ActorEnv
}{
{name: "owner", msg: ownerMsg, want: restrict.ActorOwner},
{name: "适配器 owner 标记(无白名单可查)", msg: ownerTaggedMsg, want: restrict.ActorStranger},
{name: "group", msg: groupMsgWithSender, want: restrict.ActorGroupMember},
{name: "stranger", msg: strangerMsg, want: restrict.ActorStranger},
}
+117
View File
@@ -0,0 +1,117 @@
package session
// 本文件覆盖裁决 06.4.2 在会话层的安全反向用例,以及第二处结构化判主来源
// (每渠道账号配置的 adminIds[],channels.json / design/08 §1.1)。
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// TestResolveActorOwnerByChannelAdminIDs 覆盖每渠道管理员 id 列表:SetPlatformAdmins
// 写入后命中即进 owner 车道(可多账户),不在列表里的账号——哪怕适配器上报 role=owner
// ——仍是非主人。
func TestResolveActorOwnerByChannelAdminIDs(t *testing.T) {
t.Parallel()
owner := &user.OwnerProfile{ID: "own-1", DisplayName: "Owner"}
owner.SetPlatformAdmins("qq", []string{"admin-1", " admin-2 ", "admin-1", ""})
ids := owner.PlatformAdmins("qq")
if len(ids) != 2 {
t.Fatalf("PlatformAdmins = %v, want 去重并去空白后的两个 id", ids)
}
for _, id := range []string{"admin-1", "admin-2"} {
if actor := ResolveActor(directMsg(id), owner); !actor.IsOwner() {
t.Errorf("adminIds 命中的 %q 未进 owner 车道", id)
}
}
forged := directMsg("group-owner")
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
if actor := ResolveActor(forged, owner); actor.IsOwner() {
t.Error("不在 adminIds 里的账号被判成了主人")
}
// 整体替换语义:重新声明后旧 id 立刻失效,channels.json 热重载不会留下悬挂管理员。
owner.SetPlatformAdmins("qq", []string{"admin-2"})
if owner.IsOwner("qq", "admin-1") {
t.Error("SetPlatformAdmins 未整体替换,旧管理员 id 仍有效")
}
owner.SetPlatformAdmins("qq", nil)
if owner.IsOwner("qq", "admin-2") || owner.PlatformAdmins("qq") != nil {
t.Error("空列表应清空该平台的管理员层")
}
}
// TestResolveActorNilOwnerIsFailClosed 覆盖没有主人档案的路径:任何入站帧都是非主人
// (fail-closed),适配器上报的 owner 标记也不例外。
func TestResolveActorNilOwnerIsFailClosed(t *testing.T) {
t.Parallel()
msg := directMsg("whoever")
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
actor := ResolveActor(msg, nil)
if actor.IsOwner() {
t.Fatal("nil 主人档案下仍被判成主人")
}
if actor.Role != adapter.RoleMember {
t.Errorf("Role = %q, want member", actor.Role)
}
}
// TestOwnerFrameMemWritable 走真实 SessionManager 链路断言 MemWritable:伪造 owner 的
// 私聊帧 Owner/MemWritable 全 false(不入主会话上下文、记忆只读);白名单命中的主人私聊
// 为 true;群主(群聊)永远是隔离环境、MemWritable=false。
func TestOwnerFrameMemWritable(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
owner := &user.OwnerProfile{
ID: "own-1",
DisplayName: "Owner",
Accounts: []user.OwnerAccount{{Platform: "qq", AccountID: "o-100"}},
}
forged := directMsg("forged-owner")
forged.Sender = adapter.Actor{Role: adapter.RoleOwner}
if actor := ResolveActor(forged, owner); actor.Role != adapter.RoleMember {
t.Fatalf("伪造 owner 的入站帧 role = %q, want member", actor.Role)
}
forgedSess, err := m.GetOrCreate(ctx, forged, owner)
if err != nil {
t.Fatalf("GetOrCreate(伪造 owner): %v", err)
}
if forgedSess.Owner || forgedSess.MemWritable {
t.Errorf("伪造 owner 帧 Owner=%v MemWritable=%v, want false/false",
forgedSess.Owner, forgedSess.MemWritable)
}
whitelisted := directMsg("o-100")
ownerSess, err := m.GetOrCreate(ctx, whitelisted, owner)
if err != nil {
t.Fatalf("GetOrCreate(白名单): %v", err)
}
if !ownerSess.Owner || !ownerSess.MemWritable {
t.Errorf("白名单主人帧 Owner=%v MemWritable=%v, want true/true",
ownerSess.Owner, ownerSess.MemWritable)
}
groupOwner := groupMsg("g-owner")
groupOwner.Sender = adapter.Actor{Role: adapter.RoleOwner, GroupID: "g1", IsGroup: true}
if actor := ResolveActor(groupOwner, owner); actor.Role != adapter.RoleMember || actor.IsOwner() {
t.Errorf("群主 role=%q owner=%v, want member/false", actor.Role, actor.IsOwner())
}
groupSess, err := m.GetOrCreate(ctx, groupOwner, owner)
if err != nil {
t.Fatalf("GetOrCreate(群主): %v", err)
}
if groupSess.Owner || groupSess.MemWritable || !groupSess.IsGroup {
t.Errorf("群主会话 Owner=%v MemWritable=%v IsGroup=%v, want false/false/true",
groupSess.Owner, groupSess.MemWritable, groupSess.IsGroup)
}
}
+15 -7
View File
@@ -10,9 +10,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Proactive push per-platform defaults (design/08 §3): weixin-ilink allows push
// only within 24h of the last inbound message; OneBot allows push but must be
// rate-limited to avoid wind-control flags; telegram/webhook push freely.
// 主动推送的每平台默认值(design/08 §3):weixin-ilink 只允许在最后一条入站消息的
// 24 小时内推送(超 24h 推不过去);OneBot 可推送但要限频以避开风控;telegram/webhook
// 推送强、不设限;**能力未声明的未知平台保守默认**(裁决 06.4.4)——严格限频,绝不给
// 「无限制」。真正的限幅落在适配器实现,策略层只声明口径(design/08 §3)。
const (
// iLinkWindow is the weixin-ilink proactive push window.
iLinkWindow = 24 * time.Hour
@@ -20,6 +21,10 @@ const (
oneBotBurstLimit = 3
// oneBotBurstWindow is the OneBot rate-limit window.
oneBotBurstWindow = 10 * time.Second
// unknownBurstLimit / unknownBurstWindow 是未知平台的保守默认:每分钟最多 1 条主动
// 推送(比 OneBot 的 3 条/10s 更紧),保证「未知 ≠ 无限制」。
unknownBurstLimit = 1
unknownBurstWindow = time.Minute
)
// ProactiveOptions configures a ProactivePolicy.
@@ -57,17 +62,20 @@ func NewProactivePolicy(opts ProactiveOptions) *ProactivePolicy {
return p
}
// ProactivePolicyForPlatform returns the default proactive policy for a known
// platform identifier, following design/08 §3. Unknown platforms get an
// unrestricted policy, which is the conservative-outcome of "strong" channels.
// ProactivePolicyForPlatform 返回某平台标识对应的默认主动推送策略(design/08 §3)。
// 已声明的平台按其规格:weixin-ilink 走 24h 窗口、OneBot 限频、telegram/webhook 推送强
// 不设限。**未知(能力未声明)平台保守默认**(裁决 06.4.4):给最严格的限频
// (1 条/分钟),不再返回「无限制」——限幅落在适配器实现,策略层只声明口径。
func ProactivePolicyForPlatform(platform string) *ProactivePolicy {
switch platform {
case "weixin-ilink":
return NewProactivePolicy(ProactiveOptions{Window: iLinkWindow})
case "qq-obv11", "qq-onebot", "onebot":
return NewProactivePolicy(ProactiveOptions{RateLimit: oneBotBurstLimit, RateWindow: oneBotBurstWindow})
default:
case "telegram", "webhook":
return NewProactivePolicy(ProactiveOptions{})
default:
return NewProactivePolicy(ProactiveOptions{RateLimit: unknownBurstLimit, RateWindow: unknownBurstWindow})
}
}
@@ -115,6 +115,32 @@ func TestProactivePolicyForPlatform(t *testing.T) {
}
}
// TestProactivePolicyForUnknownPlatformIsConservative 是裁决 06.4.4 的断言:未知平台
// (能力未声明、不在平台清单里)**不得**拿到「无限制」——默认给最严格的限频。
func TestProactivePolicyForUnknownPlatformIsConservative(t *testing.T) {
t.Parallel()
ctx := context.Background()
now := time.Now()
for _, platform := range []string{"mystery-im", "discord", ""} {
p := ProactivePolicyForPlatform(platform)
if p == nil {
t.Fatalf("ProactivePolicyForPlatform(%q) = nil", platform)
}
if p.limiter == nil {
t.Fatalf("未知平台 %q 拿到了无限制策略,违反裁决 06.4.4", platform)
}
for i := 0; i < unknownBurstLimit; i++ {
if !p.Allow(ctx, capsProactive(), now) {
t.Fatalf("未知平台 %q 第 %d 条被拒,want allowed(限频内)", platform, i+1)
}
}
if p.Allow(ctx, capsProactive(), now) {
t.Errorf("未知平台 %q 超出保守限频仍被放行", platform)
}
}
}
func TestProactivePolicyNilPolicyAndCanceledCtx(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -12,7 +12,8 @@
// matched against the user.OwnerProfile account whitelist. Nothing here reads
// message text to infer identity — that is a project red line.
// - Proactive push policy: per-platform rules from design/08 §3 (weixin-ilink
// 24h window, OneBot burst rate limit, telegram/webhook unrestricted).
// 24h window, OneBot burst rate limit, telegram/webhook unrestricted), with a
// conservative strict limit as the default for unknown platforms (裁决 06.4.4).
//
// The package is concurrency-safe: the manager guards its cache with an
// RWMutex and the rate limiter uses its own mutex, so multiple adapters may
+6
View File
@@ -296,6 +296,12 @@ type ChannelAccount struct {
// DenyUsers is a black list of user uids: a direct message from a user in this
// list is dropped at ingress.
DenyUsers []string `json:"denyUsers,omitempty"`
// AdminIDs 是该账号声明的管理员 id 列表(裁决 06.4.2;design/08 §1.1、design/11 §0):
// 命中的 uid 才可能进 owner 车道,可声明多个账户。**适配器上报的角色字段不参与判主**
// ——群主/群管理员一律是 RoleMember,伪造 role=owner 无效。只有需要的渠道才声明;
// weixin-ilink 只允许与绑定微信号私聊,不需要该项。这些 id 会与 owner.yaml 的账号
// 白名单取并集,共同构成唯一的判主依据(见 runtimeconfig.AdminIDs)。
AdminIDs []string `json:"adminIds,omitempty"`
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
// value disables the group rate limit; a positive value pins it.
@@ -0,0 +1,81 @@
package runtimeconfig
// 本文件覆盖裁决 06.4.2 的配置侧形状:channels.json / CHANNELS_JSON 的账号级
// `adminIds[]`(现有 allowUsers 之外)能被解析,并被 AdminIDs 汇总成
// platform → 管理员 id 列表。
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestAdminIDsAggregatesPerPlatform 覆盖多账号、重复与空白条目的合并去重。
func TestAdminIDsAggregatesPerPlatform(t *testing.T) {
t.Parallel()
accounts := map[string][]config.ChannelAccount{
"qq-obv11": {
{ID: "conf0", AdminIDs: []string{" 10001 ", "10002"}},
{ID: "conf1", AdminIDs: []string{"10002", "", "10003"}},
},
"telegram": {{ID: "conf0"}},
}
got := AdminIDs(accounts)
ids := got["qq-obv11"]
if len(ids) != 3 {
t.Fatalf("qq-obv11 admin ids = %v, want 3 个去重后的 id", ids)
}
for i, want := range []string{"10001", "10002", "10003"} {
if ids[i] != want {
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want)
}
}
// 未声明 adminIds 的平台仍出现在结果里(空切片),这样调用方的一次循环就是整体替换:
// 该平台上一轮的管理员层会被 SetPlatformAdmins(p, nil) 清掉,热重载不留悬挂管理员。
tg, ok := got["telegram"]
if !ok {
t.Error("未声明 adminIds 的平台也应出现在结果里(空切片),否则热重载无法清掉旧管理员层")
}
if len(tg) != 0 {
t.Errorf("telegram admin ids = %v, want 空", tg)
}
if len(AdminIDs(nil)) != 0 {
t.Error("nil accounts 应返回空映射")
}
}
// TestLoadChannelsParsesAdminIDs 覆盖 channels.json 的账号级 adminIds[] 形状:文件优先
// 加载后能被 AdminIDs 读到(证明 schema 真的落到了配置结构上,而不是被静默丢弃)。
func TestLoadChannelsParsesAdminIDs(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
raw := map[string][]config.ChannelAccount{
"qq-obv11": {{
ID: "conf0",
Config: map[string]any{"remote_url": "ws://127.0.0.1:3001"},
AdminIDs: []string{"10001", "10002"},
}},
}
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
accounts, warn := LoadChannels(path, nil)
if warn != nil {
t.Fatalf("LoadChannels warn = %v, want nil", warn)
}
ids := AdminIDs(accounts)["qq-obv11"]
if len(ids) != 2 || ids[0] != "10001" || ids[1] != "10002" {
t.Fatalf("channels.json 的 adminIds[] 未落到配置结构: %v", ids)
}
}
+29
View File
@@ -269,3 +269,32 @@ func accountBotToken(a config.ChannelAccount) string {
}
return ""
}
// AdminIDs 汇总 channels.json 里各账号声明的管理员 id(裁决 06.4.2;design/08 §1.1、
// design/11 §0),返回 platform → 去重后的管理员 id 列表(同一平台的多账号声明会合并去重、
// 空串被丢弃)。**结果覆盖 accounts 里的每个平台**:未声明 adminIds 的平台对应空切片,
// 这样 `for p, ids := range AdminIDs(accounts) { profile.SetPlatformAdmins(p, ids) }`
// 就是一次整体替换——热重载后删掉的管理员 id 会立即失效(不会有悬挂管理员)。返回的切片是
// 新建的,调用方改动它不影响 accounts。
func AdminIDs(accounts map[string][]config.ChannelAccount) map[string][]string {
out := make(map[string][]string, len(accounts))
for platform, list := range accounts {
seen := make(map[string]struct{})
var ids []string
for _, acct := range list {
for _, id := range acct.AdminIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, dup := seen[trimmed]; dup {
continue
}
seen[trimmed] = struct{}{}
ids = append(ids, trimmed)
}
}
out[platform] = ids
}
return out
}
@@ -89,10 +89,11 @@ func (s *stubSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage
return s.sess, nil
}
// directMessage is an owner-private direct message: the sender is tagged as the
// owner (structural, not from text), so it exercises the owner-private path. The
// single-account stub session in newTestBridge reports Owner=true to match, since
// the production SessionManager derives Owner from the resolved actor.
// directMessage 是一条主人私聊消息:platform telegram / 发送者 100。它是否是主人由**白名单**
// 决定——夹具的主人档案(testOwnerProfile("telegram", "100"))把这个账号结构性地写进去
// (裁决 06.4.2:owner 车道只认白名单)。这里仍带上适配器上报的 role=owner 标记,用来断言
// 「白名单命中时适配器标记不影响判主」;反过来,未进白名单的账号即使带该标记也不是主人。
// newTestBridge 的单账号 stub session 据此报 Owner=true,与生产 SessionManager 的派生一致。
func directMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
@@ -111,7 +112,9 @@ func newTestBridge(t *testing.T) (*Bridge, *stubChatter, *stubSessions, *fakeAda
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}})
// 主人私聊要进主会话,夹具必须把发送者结构性地写进白名单(owner 车道只认白名单,
// 裁决 06.4.2);适配器上报的 RoleOwner 标记本身不再判主。
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100")})
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -311,7 +314,7 @@ func TestOwnerPrivatePersistsToMainSessionWithPlatform(t *testing.T) {
hist := &captureHistory{}
var persisted []history.Message
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
History: hist,
OnPersisted: func(m history.Message) { persisted = append(persisted, m) },
})
@@ -358,7 +361,7 @@ func TestOwnerPrivatePersistsToMainWithDeviceID(t *testing.T) {
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
History: hist,
})
if err != nil {
@@ -581,7 +584,7 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
// but the owner is never labeled as a third party (design/16 §4.6 §1-2).
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}}
hist := &captureHistory{}
b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist})
b, err := New(Options{Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "owner_uid"), History: hist})
if err != nil {
t.Fatalf("New: %v", err)
}
+7 -10
View File
@@ -96,18 +96,15 @@ func (b *Bridge) engageConfigured() bool {
return len(b.triggerWords) > 0 || b.engage.Enabled || b.engage.Probability > 0
}
// engageKey returns the per-conversation state key (design/18 §1: 群聊会话 platform:groupID).
// A stranger direct chat (no group) falls back to platform:senderID so the engine still
// applies to one-to-one strangers.
// engageKey 返回该会话在参与度引擎里的状态键(design/18 §1/§6,裁决 06.4.3)。键口径与
// 入站节流**完全一致**:adapter.ConversationKey(platform+channelID+groupID/peerID),
// 所以同一会话在「滑窗计数」和「参与度状态机」里落在同一个桶——两个群并发互不挤占。
// 群聊缺 group id 时用 actor 上报的群 id 补齐(适配器可能只填了 Sender.GroupID)。
func engageKey(msg adapter.UnifiedMessage, actor adapter.Actor) string {
if msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "" {
g := msg.GroupID
if g == "" {
g = actor.GroupID
}
return msg.Platform + ":" + g
if msg.GroupID == "" && (msg.IsGroup() || actor.IsGroupChat() || actor.GroupID != "") {
msg.GroupID = actor.GroupID
}
return msg.Platform + ":" + firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
return adapter.ConversationKey(msg)
}
// engageState returns the state for a conversation key, creating an IDLE instance on
@@ -0,0 +1,81 @@
package channelbridge
// 本文件覆盖裁决 06.4.3 在参与度引擎侧的断言:会话状态键与入站节流键是同一个口径,
// 且每个会话的参与度状态互相独立——一个群冷场/参与中不影响另一个群。
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// TestEngageKeyMatchesThrottleKey 固定「成对契约」:参与度引擎的状态键与入站节流键
// (adapter.ConversationKey)必须同口径,否则同一会话会在「计数」与「状态机」里分裂成
// 两个桶(裁决 06.4.3)。
func TestEngageKeyMatchesThrottleKey(t *testing.T) {
t.Parallel()
msg := nonOwnerGroupMsg("hi")
msg.ChannelID, msg.GroupID = "-100", "-100"
actor := adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-100"}
if got, want := engageKey(msg, actor), adapter.ConversationKey(msg); got != want {
t.Errorf("engageKey = %q, 节流键 = %q, 两者必须同口径", got, want)
}
// 群聊缺 group id 时用 actor 上报的群 id 补齐后仍与节流键一致。
bare := nonOwnerGroupMsg("hi")
bare.ChannelID, bare.GroupID = "", ""
patchActor := adapter.Actor{Role: adapter.RoleMember, IsGroup: true, GroupID: "-20"}
patched := bare
patched.GroupID = "-20"
if got, want := engageKey(bare, patchActor), adapter.ConversationKey(patched); got != want {
t.Errorf("缺 group id 时 engageKey = %q, 期望 %q", got, want)
}
// 两个群必须是两个键。
other := nonOwnerGroupMsg("hi")
other.ChannelID, other.GroupID = "-200", "-200"
if engageKey(msg, actor) == engageKey(other, actor) {
t.Error("两个群共用同一个参与度状态键(会话未隔离)")
}
}
// TestEngageStateIsPerConversation 断言两个群并发互不挤占:A 群被要求安静进入 COOLDOWN
// 后,B 群仍能正常触发判定并回复(状态与计数都按会话独立,裁决 06.4.3)。
func TestEngageStateIsPerConversation(t *testing.T) {
t.Parallel()
judge := &engageScriptJudge{responses: []LightDecision{
{WorthReply: true, Interesting: true, Reason: "想聊"},
}}
b, chat, _ := engageTestBridge(t, judge, DefaultEngageConfig(), dayNow(), nil)
ctx := context.Background()
// A 群冷场:一句「太吵了」把 A 群打进 COOLDOWN,且不消耗判定。
cold := nonOwnerGroupMsg("太吵了,闭嘴")
cold.ChannelID, cold.GroupID = "-100", "-100"
b.process(ctx, cold)
if judge.calls != 0 {
t.Fatalf("冷场消息触发了轻判调用 = %d, want 0", judge.calls)
}
// A 群的后续触发消息处于 COOLDOWN:必须不调判定、不回复。
groupA := nonOwnerGroupMsg("昔涟 你来说说")
groupA.ChannelID, groupA.GroupID = "-100", "-100"
b.process(ctx, groupA)
if judge.calls != 0 {
t.Fatalf("A 群 COOLDOWN 期间仍调用了判定 = %d, want 0", judge.calls)
}
// B 群不受 A 群影响:照常 PROBING → 判定 → 回复。
groupB := nonOwnerGroupMsg("昔涟 你来说说")
groupB.ChannelID, groupB.GroupID = "-200", "-200"
b.process(ctx, groupB)
if judge.calls != 1 {
t.Fatalf("B 群判定被 A 群 COOLDOWN 挤占,judge.calls = %d, want 1", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("B 群判定为想聊后应驱动 loop,session = %q", chat.sessionID)
}
}
+4 -2
View File
@@ -14,8 +14,10 @@ import (
// gateFor returns the per-account flow gate for an adapter, building it lazily
// from the adapter's Config snapshot on first use. Gates are keyed by the adapter
// name so every account (platform:confId) gets an independent rate limit and
// pending-cap (design/12 §6 策略三/四), and they survive adapter reconnects.
// name so every account (platform:confId) keeps its own gate that survives adapter
// reconnects; **the throttling counters inside a gate are per conversation**
// (adapter.ConversationKey: platform+channelID+groupID/peerID), so two groups never
// squeeze each other (design/12 §6 策略三/四, 裁决 06.4.3).
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
name := a.Name()
b.gateMu.Lock()
@@ -83,7 +83,7 @@ func TestFlowGate_OwnerPrivateBypassesGate(t *testing.T) {
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
var rejected []string
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Loop: chat, Sessions: sess, Owner: testOwnerProfile("kook", "u0", "u1", "u2"),
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
})
if err != nil {
@@ -183,7 +183,7 @@ func TestOwnerUnaffectedInQuietHours(t *testing.T) {
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
@@ -0,0 +1,18 @@
package channelbridge
// 自裁决 06.4.2 起 owner 车道**只认白名单**:测试夹具不能再靠适配器上报的
// `adapter.Actor{Role: adapter.RoleOwner}` 标记把某条消息变成主人消息——那正是本次修掉的
// 漏洞(群主/伪造 role=owner 冒充主人)。需要主人私聊/主人群聊路径的用例,改为把该账号
// 结构性地写进主人档案白名单,与生产路径(owner.yaml + 渠道 adminIds[])同源。
import "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
// testOwnerProfile 返回一个把给定账号写进白名单的主人档案。DisplayName 故意留空:
// ruleLightJudge 会用 DisplayName 做「被点名」判定,空值保持这些夹具原有的语义。
func testOwnerProfile(platform string, accountIDs ...string) *user.OwnerProfile {
p := &user.OwnerProfile{ID: "owner"}
for _, id := range accountIDs {
p.Accounts = append(p.Accounts, user.OwnerAccount{Platform: platform, AccountID: id})
}
return p
}
@@ -50,7 +50,7 @@ func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Br
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
Owner: testOwnerProfile("telegram", "100"),
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: mainSessionPersona}},
ChannelPersona: func(platform, accountID string) string { return channelPersona },
})
@@ -125,7 +125,7 @@ func TestInboundGroupForcesDefaultRole(t *testing.T) {
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}},
Owner: &user.OwnerProfile{},
Owner: testOwnerProfile("telegram", "100"),
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
+96
View File
@@ -0,0 +1,96 @@
package user
// 本文件覆盖裁决 06.4.2 的「每渠道账号配置的管理员 id 列表」白名单层:它是 channels.json
// 的 adminIds[](design/08 §1.1)在内存里的落点,与 owner.yaml 的账号白名单取并集后共同
// 决定 owner 车道,且**不写回 owner.yaml**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestOwnerProfileSetPlatformAdmins(t *testing.T) {
t.Parallel()
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
p.SetPlatformAdmins("qq-obv11", []string{"10001", " 10002 ", "10001", " "})
ids := p.PlatformAdmins("qq-obv11")
if len(ids) != 2 || ids[0] != "10001" || ids[1] != "10002" {
t.Fatalf("PlatformAdmins = %v, want [10001 10002](去空白 + 去重)", ids)
}
for _, id := range ids {
if !p.IsOwner("qq-obv11", id) {
t.Errorf("adminIds 命中的 %q 未判为主人", id)
}
}
if p.IsOwner("qq-obv11", "10003") {
t.Error("未声明的账号被判为主人")
}
if p.IsOwner("telegram", "10001") {
t.Error("管理员 id 层跨平台串台了(应按平台隔离)")
}
if p.IsOwner("qq-obv11", "") {
t.Error("空 accountId 不得命中白名单(fail-closed)")
}
// 整体替换:重新声明后旧 id 立即失效,channels.json 热重载不会留下悬挂管理员。
p.SetPlatformAdmins("qq-obv11", []string{"10002"})
if p.IsOwner("qq-obv11", "10001") {
t.Error("SetPlatformAdmins 未整体替换")
}
// 空列表(含只有空白的列表)清空该平台的这一层。
p.SetPlatformAdmins("qq-obv11", []string{" "})
if p.IsOwner("qq-obv11", "10002") || p.PlatformAdmins("qq-obv11") != nil {
t.Error("空列表应清空该平台的管理员层")
}
}
func TestOwnerProfileAdminLayerConcurrentWithOwnerYaml(t *testing.T) {
t.Parallel()
// 两层来源共存:owner.yaml 的账号白名单与 channels.json 的 adminIds 都能判主。
p := &OwnerProfile{
ID: "owner",
DisplayName: "主人",
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}},
}
p.SetPlatformAdmins("kook", []string{"555"})
if !p.IsOwner("kook", "329284282") || !p.IsOwner("kook", "555") {
t.Fatalf("两层白名单未取并集: yaml=%v adminIds=%v",
p.IsOwner("kook", "329284282"), p.IsOwner("kook", "555"))
}
}
func TestOwnerProfileAdminLayerNotPersisted(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "owner.yaml")
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
p.RegisterAccount("kook", "329284282")
p.SetPlatformAdmins("qq-obv11", []string{"10001"})
if err := SaveOwnerProfile(path, p); err != nil {
t.Fatalf("SaveOwnerProfile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if strings.Contains(string(data), "10001") {
t.Error("adminIds 层被写回了 owner.yaml(应为只读来源 channels.json)")
}
reloaded, err := LoadOwnerProfile(path)
if err != nil {
t.Fatalf("LoadOwnerProfile: %v", err)
}
if !reloaded.IsOwner("kook", "329284282") {
t.Error("owner.yaml 的账号白名单应在重载后保留")
}
if reloaded.IsOwner("qq-obv11", "10001") {
t.Error("adminIds 层不应来自 owner.yaml")
}
}
+74 -2
View File
@@ -4,7 +4,10 @@
// this whitelist — and is never driven by prompt text or an LLM judgment.
package user
import "sync"
import (
"strings"
"sync"
)
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
// policy engine matches channel-layer actor metadata (platform + account id)
@@ -34,14 +37,23 @@ type OwnerProfile struct {
// (persona knowledge, NOT an access-control authority).
IdentityFile string `yaml:"identityFile,omitempty"`
Notes string `yaml:"notes,omitempty"`
// adminIDs 是「每渠道账号配置的管理员 id 列表」层(裁决 06.4.2):channels.json
// 里每个渠道账号可声明 adminIds[](design/08 §1.1、design/11 §0),加载/热重载时
// 由 SetPlatformAdmins 写入。它与 Accounts 白名单(owner.yaml)取并集后共同决定
// owner 车道,因此是内存态来源、不落盘——yaml:"-" 保证它不会被写回 owner.yaml。
adminIDs map[string][]string `yaml:"-"`
}
// IsOwner reports whether the given (platform, accountID) pair belongs to the
// owner according to the whitelist. It is safe for concurrent use with
// RegisterAccount (the bridge reads this on every inbound message while a
// scan-login registration may extend the whitelist).
//
// 判主只看两处结构化来源(裁决 06.4.2):owner.yaml 的 Accounts 白名单与每渠道配置的
// adminIds 层;空的 accountID 一律不算命中,避免白名单里的空占位把空发送者 id 判成主人。
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
if p == nil {
if p == nil || accountID == "" {
return false
}
p.mu.RLock()
@@ -51,6 +63,11 @@ func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
return true
}
}
for _, id := range p.adminIDs[platform] {
if id == accountID {
return true
}
}
return false
}
@@ -73,6 +90,61 @@ func (p *OwnerProfile) RegisterAccount(platform, accountID string) {
p.Accounts = append(p.Accounts, OwnerAccount{Platform: platform, AccountID: accountID})
}
// SetPlatformAdmins 用 ids 整体替换 platform 的每渠道管理员层(裁决 06.4.2)。整体
// 替换(而不是追加)语义保证 channels.json 的一次热重载不会留下已被移除的管理员 id;
// 空列表删除该平台条目。它只影响这一层,owner.yaml 的 Accounts 白名单不受影响。
// 与 IsOwner/RegisterAccount 并发安全。
func (p *OwnerProfile) SetPlatformAdmins(platform string, ids []string) {
if p == nil || platform == "" {
return
}
clean := normalizeAccountIDs(ids)
p.mu.Lock()
defer p.mu.Unlock()
if len(clean) == 0 {
delete(p.adminIDs, platform)
return
}
if p.adminIDs == nil {
p.adminIDs = make(map[string][]string, 1)
}
p.adminIDs[platform] = clean
}
// PlatformAdmins 返回 platform 当前声明的管理员 id 层副本(诊断/展示用),未声明时返回
// nil。与 SetPlatformAdmins 并发安全。
func (p *OwnerProfile) PlatformAdmins(platform string) []string {
if p == nil || platform == "" {
return nil
}
p.mu.RLock()
defer p.mu.RUnlock()
ids := p.adminIDs[platform]
if len(ids) == 0 {
return nil
}
return append([]string(nil), ids...)
}
// normalizeAccountIDs 清理配置里声明的管理员 id:去空白、丢弃空串、按首次出现顺序
// 去重。它保证 channels.json 里的重复/空白条目不会污染白名单。
func normalizeAccountIDs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
// AccountIdentifiers returns the distinct channel account ids owned by the
// owner, for diagnostics / listing. Safe for concurrent use with RegisterAccount.
func (p *OwnerProfile) AccountIdentifiers() []string {