Merge branch 'feat/ownergate-owner-lane-gate-plus-conversat' into dev
This commit is contained in:
commit
20051f0168
14 files changed
+1049
-30
No files matched your search
@@ -108,6 +108,12 @@ func (a *app) wireChannels() error {
|
||||
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
|
||||
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
|
||||
docDataDir := resolveDataDir(a.cfg)
|
||||
// owner 车道结构化旋钮(design/18 §11「乙 = C」)的落盘面:主人在群里说「少说点」
|
||||
// 这类诉求被写成 $DATA_DIR/config/owner_lane.json(可读可改,面板后续照此写入)。
|
||||
// 它只承载「说不说 / 多频繁」,不承载任何身份/权限信息。
|
||||
ownerLane := runtimeconfig.NewOwnerLaneStore(
|
||||
runtimeconfig.DefaultOwnerLanePath(docDataDir),
|
||||
logger.WithSource(a.log, "services/channelbridge/ownerlane"))
|
||||
bridge, berr := channelbridge.New(channelbridge.Options{
|
||||
Log: logger.WithSource(a.log, "services/channelbridge"),
|
||||
Loop: a.loop,
|
||||
@@ -145,6 +151,23 @@ func (a *app) wireChannels() error {
|
||||
PersonaPrompt: a.defaultInstructions(),
|
||||
Engage: channelbridge.DefaultEngageConfig(),
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// OwnerLaneFor / OwnerLaneSet 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)
|
||||
// 的读取/落盘接缝:读接口按 (platform, groupID) 返回该群生效策略(缺省 = 不对她说
|
||||
// 就不开口),写接口把「少说点」这类结构化诉求落进 owner_lane.json,也是面板后续
|
||||
// 写入的那一份。它只影响说不说/多频繁,绝不触碰权限与 actor 元数据。
|
||||
OwnerLaneFor: func(platform, groupID string) *channelbridge.OwnerLanePolicy {
|
||||
p := ownerLane.PolicyFor(platform, groupID)
|
||||
return &channelbridge.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
}
|
||||
},
|
||||
OwnerLaneSet: func(platform, groupID string, p channelbridge.OwnerLanePolicy) error {
|
||||
return ownerLane.Set(platform, groupID, runtimeconfig.OwnerLanePolicy{
|
||||
ReplyToUnaddressed: p.ReplyToUnaddressed,
|
||||
MinReplyGapSec: p.MinReplyGapSec,
|
||||
})
|
||||
},
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// channels_ownerlane.go 承载 owner 车道结构化旋钮的**落盘面**(design/18 §11
|
||||
// 「乙 = C」,主人 2026-10-13 裁决):主人在群里说「少说点」这类诉求不再靠她自己记,
|
||||
// 而是被写成一份可读可改的每群配置(默认值 + 每群覆盖),面板后续照这份形状写入。
|
||||
//
|
||||
// 它落在 $DATA_DIR/config/owner_lane.json(与 backthink.json 同一目录口径、同一
|
||||
// 「.env 冻结 ⇒ 新配置走 $DATA_DIR/config/**」纪律,design/13 §2.1),**不进 .env**。
|
||||
// 本文件是纯存储层:它只负责读写与默认值归一,**不做任何行为判定** ——
|
||||
// 「说不说 / 多频繁」的判据在 channelbridge/ownerlane.go,身份权限仍只由 actor 元数据决定。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// ownerLaneConfigSubdir 与 backthink.json 共用 $DATA_DIR 下的 config 子目录口径。
|
||||
ownerLaneConfigSubdir = "config"
|
||||
// ownerLaneFileName 是 owner 车道旋钮的落盘文件名。
|
||||
ownerLaneFileName = "owner_lane.json"
|
||||
// ownerLaneLogSource 是本模块日志的来源标记,使「旋钮被谁改成什么」跨模块可定位
|
||||
// (项目规范 §五:禁止无来源裸日志)。
|
||||
ownerLaneLogSource = "config/runtimeconfig/ownerlane"
|
||||
)
|
||||
|
||||
// DefaultOwnerLanePath 返回 owner 车道旋钮文件在 dataDir 下的路径。
|
||||
func DefaultOwnerLanePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, ownerLaneConfigSubdir, ownerLaneFileName)
|
||||
}
|
||||
|
||||
// OwnerLanePolicy 是 owner 车道的一档参与策略(design/18 §11 的可调旋钮):
|
||||
// 它**只**影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。
|
||||
//
|
||||
// 零值 = 最保守的一档(只回指向她的消息),也就是裁决「甲」的默认口径。
|
||||
type OwnerLanePolicy struct {
|
||||
// ReplyToUnaddressed 是「说不说」开关:true 时主人**非指向她**的群消息也参与回复;
|
||||
// false(默认)时只回指向她的(@她 / @全体 / 未接线渠道的旧判据)。
|
||||
ReplyToUnaddressed bool `json:"replyToUnaddressed"`
|
||||
// MinReplyGapSec 是「多频繁」:owner 车道两条**非指向她**的回复之间的最小间隔(秒)。
|
||||
// 0 = 不额外限制。指向她的消息一律必回,**不受本字段约束**(硬保留)。
|
||||
MinReplyGapSec int `json:"minReplyGapSec"`
|
||||
}
|
||||
|
||||
// DefaultOwnerLanePolicy 返回裁决「甲」的默认口径:不对她说就不开口。
|
||||
func DefaultOwnerLanePolicy() OwnerLanePolicy {
|
||||
return OwnerLanePolicy{}
|
||||
}
|
||||
|
||||
// OwnerLaneFile 是 owner_lane.json 的形状:一个全局 default(可被面板改)
|
||||
// 加一份每群覆盖表。键见 OwnerLaneGroupKey。
|
||||
type OwnerLaneFile struct {
|
||||
Default OwnerLanePolicy `json:"default"`
|
||||
Groups map[string]OwnerLanePolicy `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// OwnerLaneGroupKey 是每群覆盖表的键:平台 + 群 id。群 id 为空表示**全局默认**那一档
|
||||
// (面板把「所有群」的旋钮写进 default 时用它)。
|
||||
func OwnerLaneGroupKey(platform, groupID string) string {
|
||||
return platform + "|" + groupID
|
||||
}
|
||||
|
||||
// OwnerLaneStore 是 owner 车道旋钮的读写面:内存缓存 + 落盘。它可安全并发使用。
|
||||
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但会留一条带来源的 WARN。
|
||||
type OwnerLaneStore struct {
|
||||
path string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cfg OwnerLaneFile
|
||||
}
|
||||
|
||||
// NewOwnerLaneStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
|
||||
// 不返回错误:配置坏了只影响旋钮取值,绝不该拦住渠道启动。
|
||||
func NewOwnerLaneStore(path string, log *slog.Logger) *OwnerLaneStore {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &OwnerLaneStore{
|
||||
path: path,
|
||||
log: logger.WithSource(log, ownerLaneLogSource),
|
||||
cfg: OwnerLaneFile{Default: DefaultOwnerLanePolicy(), Groups: map[string]OwnerLanePolicy{}},
|
||||
}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
|
||||
// 其它读/解析错误留一条带来源的 WARN 后仍走默认值。
|
||||
func (s *OwnerLaneStore) load() {
|
||||
if s.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.log.Warn("owner lane config unreadable, using defaults", "path", s.path, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
s.log.Warn("owner lane config invalid, using defaults", "path", s.path, "error", err)
|
||||
return
|
||||
}
|
||||
if f.Groups == nil {
|
||||
f.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cfg = f
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// PolicyFor 返回某个 (platform, groupID) 生效的策略:有每群覆盖就用它,否则用全局
|
||||
// default(default 缺省即「甲」)。它不返回「未设置」,因此调用方永远拿到一个可用的档位。
|
||||
func (s *OwnerLaneStore) PolicyFor(platform, groupID string) OwnerLanePolicy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if p, ok := s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)]; ok {
|
||||
return p
|
||||
}
|
||||
return s.cfg.Default
|
||||
}
|
||||
|
||||
// Snapshot 返回配置的一份拷贝,供面板/测试读取(改动它不影响 store)。
|
||||
func (s *OwnerLaneStore) Snapshot() OwnerLaneFile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))}
|
||||
for k, v := range s.cfg.Groups {
|
||||
out.Groups[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。
|
||||
//
|
||||
// 内存改动与**落盘都在同一把锁内**(review p1):并发 Set 因此完全串行 —— 既不会两个
|
||||
// 写者去写同一个临时文件把 JSON 写坏,也不会因为原子 rename 的完成先后与各快照的新旧
|
||||
// 不一致而丢掉「后发生的那次更新」。落盘量是一个小 JSON,持锁期间的 I/O 代价可忽略;
|
||||
// 读侧(PolicyFor)读的是内存、不碰文件,因此不会被这次落盘抖动。
|
||||
//
|
||||
// 内存先改、失败返回错误(调用方留可见 WARN),但内存里的新档位仍然生效 ——
|
||||
// 「改不动磁盘」不该让已经做出的行为决定回退。
|
||||
func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if groupID == "" {
|
||||
s.cfg.Default = p
|
||||
} else {
|
||||
if s.cfg.Groups == nil {
|
||||
s.cfg.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)] = p
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。
|
||||
//
|
||||
// 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go)
|
||||
// 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名,
|
||||
// 而半份文件永远不会以 s.path 的名字出现。
|
||||
func (s *OwnerLaneStore) saveLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// CreateTemp 以 0600 打开;显式再 chmod 一次,使落盘文件不因 umask 变化而更宽。
|
||||
// 任一步失败都删掉临时文件,绝不留垃圾。
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("write %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("close %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」):
|
||||
// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。
|
||||
func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
got := s.PolicyFor("qq-obv11", "672432767")
|
||||
if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 {
|
||||
t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的
|
||||
// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。
|
||||
func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120}
|
||||
if err := s.Set("qq-obv11", "672432767", want); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("owner_lane.json 未落盘:%v", err)
|
||||
}
|
||||
|
||||
reloaded := NewOwnerLaneStore(path, nil)
|
||||
if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want {
|
||||
t.Fatalf("重载后该群策略 = %+v, want %+v", got, want)
|
||||
}
|
||||
// 未被覆盖的群仍走全局 default(零值)。
|
||||
if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("未覆盖群策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档:
|
||||
// groupID 为空即写默认,所有未单独覆盖的群都跟着变。
|
||||
func TestOwnerLaneStoreGlobalDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil)
|
||||
global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60}
|
||||
if err := s.Set("", "", global); err != nil {
|
||||
t.Fatalf("Set default: %v", err)
|
||||
}
|
||||
if got := s.PolicyFor("telegram", "-20"); got != global {
|
||||
t.Fatalf("全局默认生效值 = %+v, want %+v", got, global)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」:
|
||||
// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。
|
||||
func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "owner_lane.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("坏配置下策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1):
|
||||
// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个
|
||||
// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示),
|
||||
// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。
|
||||
//
|
||||
// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言:
|
||||
// ① 每一轮结束后文件都是**合法 JSON**;
|
||||
// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。
|
||||
func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
const (
|
||||
rounds = 24
|
||||
writers = 24
|
||||
)
|
||||
for round := 0; round < rounds; round++ {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("g-%d-%d", round, i)
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
// 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错,
|
||||
// 但那正是本用例要靠**文件内容**抓住的失败。
|
||||
_ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err)
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw)
|
||||
}
|
||||
for i := 0; i < writers; i++ {
|
||||
key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i))
|
||||
if _, ok := f.Groups[key]; !ok {
|
||||
t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ type Options struct {
|
||||
// Now 是安静时段判定所用的墙上时钟源。为 nil 时用 time.Now,
|
||||
// 使依赖时间的行为在测试中可注入。
|
||||
Now func() time.Time
|
||||
// OwnerLaneFor 是 owner 车道结构化旋钮(design/18 §11「乙 = C」)的**读取**接缝:
|
||||
// 按 (platform, groupID) 返回该群生效的参与策略,返回 nil 表示没有覆盖
|
||||
// (桥用自己的默认档:不对她说就不开口)。为 nil 时全部走默认档 + 桥内内存覆盖。
|
||||
OwnerLaneFor func(platform, groupID string) *OwnerLanePolicy
|
||||
// OwnerLaneSet 是旋钮的**落盘**接缝:主人在群里说「少说点」这类诉求被结构化后,
|
||||
// 桥通过它把新策略写进外部配置(面板可读可改的那一份)。为 nil 时只更新桥内内存
|
||||
// 覆盖(进程重启即回到默认档)。
|
||||
OwnerLaneSet func(platform, groupID string, p OwnerLanePolicy) error
|
||||
// Factory 依据某平台生效的账号配置重建其适配器
|
||||
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
|
||||
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
|
||||
@@ -250,6 +258,19 @@ type Bridge struct {
|
||||
engageMu sync.Mutex
|
||||
engageGroups map[string]*engageGroupState
|
||||
|
||||
// ownerLane 是 owner 车道结构化旋钮(design/18 §11)的**内存覆盖表**,
|
||||
// 键 = platform|groupID。ownerLaneFor 从外部配置读取(组合根注入的 owner_lane.json),
|
||||
// ownerLaneSet 把改动回写落盘;ownerLaneLast 记录每条 owner 车道回复的时间戳,供
|
||||
// 「多频繁」闸使用。四者都由 ownerLaneMu 保护。
|
||||
ownerLane map[string]OwnerLanePolicy
|
||||
ownerLaneFor func(platform, groupID string) *OwnerLanePolicy
|
||||
ownerLaneSet func(platform, groupID string, p OwnerLanePolicy) error
|
||||
ownerLaneLast map[string]time.Time
|
||||
ownerLaneMu sync.Mutex
|
||||
// ownerLaneLog 是 owner 闸门/旋钮的子功能日志器(source=services/channelbridge/ownerlane),
|
||||
// 使「这条为什么没回」与「旋钮被改成什么」在日志里可追溯。
|
||||
ownerLaneLog *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
adapters map[string]adapter.PlatformAdapter
|
||||
connected map[string]bool
|
||||
@@ -356,6 +377,11 @@ func New(opts Options) (*Bridge, error) {
|
||||
engageRand: engageRand,
|
||||
bubbleGapFn: bubbleGap,
|
||||
engageGroups: make(map[string]*engageGroupState),
|
||||
ownerLane: make(map[string]OwnerLanePolicy),
|
||||
ownerLaneFor: opts.OwnerLaneFor,
|
||||
ownerLaneSet: opts.OwnerLaneSet,
|
||||
ownerLaneLast: make(map[string]time.Time),
|
||||
ownerLaneLog: logger.WithSource(log, ownerLaneLogSource),
|
||||
adapters: make(map[string]adapter.PlatformAdapter),
|
||||
connected: make(map[string]bool),
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
|
||||
@@ -566,9 +566,12 @@ func TestOwnerInGroupIsIsolatedButNotLabeledThirdParty(t *testing.T) {
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", SenderID: "owner_uid", SenderName: "主人",
|
||||
Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里",
|
||||
ContentType: adapter.ContentText,
|
||||
Sender: adapter.Actor{Role: adapter.RoleOwner}, Mentions: []adapter.Mention{{UserID: "owner_uid"}},
|
||||
Content: "我在群里", ContentType: adapter.ContentText,
|
||||
}
|
||||
// 这条主人群消息**指向她本人**(含提及):owner 车道默认「不对她说就不开口」
|
||||
// (design/18 §11),不带指向信号的主人群消息只进上下文、不驱动回合;
|
||||
// 带 @ 信号才会进循环,本用例因此仍在断言「进循环时主人不被标为第三方」。
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
// 受限上下文 → 仍会注入第三方边界系统消息。
|
||||
|
||||
@@ -349,9 +349,11 @@ func (b *Bridge) negativeAttitude(text string) bool {
|
||||
|
||||
// negativeAttitudeKeywords 是冷避规则词表(design/18 §5 词表兜底)。它只是
|
||||
// 兜底;同一信号也会被判定器中的跟进复审通过 avoidTopic 读取。
|
||||
// 「少说点」是主人 2026-10-13 裁决「乙 = C」里主人诉求的原话形态(design/18 §11 ②),
|
||||
// 与既有「少说两句」同义,因此并入同一张词表 —— owner 车道据此把诉求落成结构化降频。
|
||||
var negativeAttitudeKeywords = []string{
|
||||
"太吵", "吵死", "闭嘴", "别吵", "别刷屏", "别刷", "刷屏", "不想听",
|
||||
"安静点", "少说两句", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
|
||||
"安静点", "少说两句", "少说点", "别说了", "别bb", "别逼逼", "烦死了", "好烦",
|
||||
"打住", "住嘴", "shut up", "too loud", "stop spamming",
|
||||
}
|
||||
|
||||
|
||||
@@ -259,10 +259,16 @@ func kindTier(kind adapter.NoticeKind) events.Tier {
|
||||
}
|
||||
}
|
||||
|
||||
// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)、生命周期、
|
||||
// 她自己发出消息的回声。这三类的 self 命中是**恒定**的(帧帧都命中 self_id),所以
|
||||
// 按 §2.2 升档等于「每帧写一行 channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧
|
||||
// 写库是纯浪费」(计数表存在的理由),§3.1 给这三类的档位也正是 T1。
|
||||
// isFrameTelemetry 报告该类别是否属于**帧级链路遥测**:心跳(~15s 一帧)与生命周期。
|
||||
// 这两类的 self 命中是**恒定**的(帧帧都命中 self_id),所以按 §2.2 升档等于「每帧写一行
|
||||
// channel_events」—— 而 §4.3 明令「心跳 / 在线状态逐帧写库是纯浪费」(计数表存在的理由),
|
||||
// §3.1 给这两类的档位也正是 T1。
|
||||
//
|
||||
// 🔴 **`message_sent`(她自己发出消息的回声)不在此列**(切 M13;原 M12 登记的缺口):
|
||||
// 它**不是**帧级遥测 —— 一帧对应她真发出的一条消息,回声带的是那条出站消息的 id
|
||||
// (`RefMessage`)。此前把它一并豁免,会让 self 命中**不升档** ⇒ 整条回声只进计数,
|
||||
// `message_id` 被丢,「她自己最近发了哪些消息」在库里查不到(M12 的 A 层出站 id 因此
|
||||
// 断在这里)。摘出后它按 §2.2 正常 T1 → T2 逐条落行。
|
||||
//
|
||||
// ⚠️ **由此登记的债**(design/26 §2.6「她自己账号的在线 / 离线」= T2 高价值):
|
||||
// 那是**状态迁移**(在线→离线 / 离线→在线),不是每一帧。判定它需要迁移信号
|
||||
@@ -272,8 +278,7 @@ func kindTier(kind adapter.NoticeKind) events.Tier {
|
||||
// 迁移事件(T2)留待后续切片;已在切片报告「未做 / 存疑」中列明。
|
||||
func isFrameTelemetry(cat adapter.NoticeCategory) bool {
|
||||
switch cat {
|
||||
case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle,
|
||||
adapter.NoticeCategoryMessageSent:
|
||||
case adapter.NoticeCategoryHeartbeat, adapter.NoticeCategoryLifecycle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestEventTier_两轴映射_正常(t *testing.T) {
|
||||
}{
|
||||
{"心跳是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat}, events.TierT1},
|
||||
{"生命周期是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle}, events.TierT1},
|
||||
{"自身回声是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1},
|
||||
{"自身回声(无 self 信号)是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent}, events.TierT1},
|
||||
{"他人在线状态是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryClientStatus}, events.TierT1},
|
||||
{"未知类型是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryUnknown}, events.TierT1},
|
||||
{"解析失败是 T1", adapter.SystemNotice{Category: adapter.NoticeCategoryDecodeError}, events.TierT1},
|
||||
@@ -48,8 +48,8 @@ func TestEventTier_两轴映射_正常(t *testing.T) {
|
||||
adapter.SystemNotice{Category: adapter.NoticeCategoryHeartbeat, SelfRelated: true}, events.TierT1},
|
||||
{"生命周期命中自己 ⇒ 不升档(帧级遥测)",
|
||||
adapter.SystemNotice{Category: adapter.NoticeCategoryLifecycle, SelfRelated: true}, events.TierT1},
|
||||
{"自身回声命中自己 ⇒ 不升档(帧级遥测)",
|
||||
adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT1},
|
||||
{"自身回声命中自己 ⇒ 升档 T2(出站 id 回声须落行)",
|
||||
adapter.SystemNotice{Category: adapter.NoticeCategoryMessageSent, SelfRelated: true}, events.TierT2},
|
||||
{"类别缺失按 Kind 兜底:join ⇒ T2",
|
||||
adapter.SystemNotice{Kind: adapter.NoticeJoin}, events.TierT2},
|
||||
{"类别缺失按 Kind 兜底:request ⇒ T2",
|
||||
|
||||
@@ -205,6 +205,48 @@ func TestRecordEvent_unknown不进上下文但落行_正常(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordEvent_自身回声升T2落行带出站id_正常 钉住切 M13 修掉的缺口(原 M12 登记):
|
||||
// 她自己发出消息的回声(message_sent)**不是帧级遥测** —— self 命中必须把它从 T1 升到 T2
|
||||
// 逐条落行,`message_id`(出站消息 id)随之留在库里;此前它被帧级豁免挡回 T1,id 被丢。
|
||||
func TestRecordEvent_自身回声升T2落行带出站id_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestEventStore(t)
|
||||
|
||||
ad := eventAdapter("qq-obv11:conf0", adapter.SystemNotice{
|
||||
Kind: adapter.NoticeOther,
|
||||
Category: adapter.NoticeCategoryMessageSent,
|
||||
PlatformType: "message_sent",
|
||||
Ignorable: true,
|
||||
SelfRelated: true,
|
||||
RefMessage: "out-msg-42",
|
||||
})
|
||||
b, chat, hist, _ := eventBridgeFixture(t, ad, store)
|
||||
|
||||
b.processFrom(ctx, groupNoticeMessage(), ad)
|
||||
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("出站回声驱动了 loop:session=%q", chat.sessionID)
|
||||
}
|
||||
if recs := hist.records(); len(recs) != 0 {
|
||||
t.Fatalf("出站回声被登记进会话历史:%+v", recs)
|
||||
}
|
||||
rows, err := store.List(ctx, events.Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("出站回声落行数 = %d, want 1(self 命中 ⇒ T2 逐条落行)", len(rows))
|
||||
}
|
||||
ev := rows[0]
|
||||
if ev.Tier != events.TierT2 || ev.Category != events.Category(adapter.NoticeCategoryMessageSent) {
|
||||
t.Fatalf("落行档位/类别 = (%s,%s), want (t2,message_sent)", ev.Tier, ev.Category)
|
||||
}
|
||||
if ev.MessageID != "out-msg-42" {
|
||||
t.Fatalf("出站消息 id = %q, want out-msg-42(id 必须留在库里)", ev.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordEvent_未接线时入站照常_异常:`Options.EventStore == nil` 时降级为
|
||||
// 「内存计数 + 首次一条 DEBUG」,**绝不**中断入站 —— 高价值事件的升级真会话行为必须
|
||||
// 逐字不变(fail-visible 而非 fail-crash,§4.3)。
|
||||
|
||||
@@ -192,22 +192,23 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/12 §6 策略一/二:面向非主人消息的最小打扰闸门。
|
||||
// 被廉价轻量判定认为不值得回复的非主人**群聊**消息,只登记到
|
||||
// 其隔离会话日志并静默——绝不驱动完整 agent 循环;**陌生人私聊**则
|
||||
// 一律走完整回合(裁决①,见 shouldRespond),只受流控约束。
|
||||
// 主人消息绝不在此被拦,因此主人私聊与主人在群里的
|
||||
// @/高价值回复不受影响。
|
||||
// design/12 §6 策略一/二 + design/18 §11「乙 = C」(主人 2026-10-13 裁决):
|
||||
// owner 车道不再**无条件短路**这道闸 —— 主人群消息也按结构化判据决定说不说。
|
||||
// - 主人**私聊/非群**:恒回,不受影响;
|
||||
// - 主人**群聊**:只有指向她本人的(@她 / @全体 / 未接线渠道旧判据)才必回,
|
||||
// 其余**仍进上下文但不触发回复**(落进该群隔离会话,见 ownerlane.go);
|
||||
// - 非主人:走既有的最小打扰分级(shouldRespond)。
|
||||
// 被拦下的消息只登记到其隔离会话日志并静默——绝不驱动完整 agent 循环。
|
||||
//
|
||||
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
|
||||
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
|
||||
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
|
||||
if !actor.IsOwner() && !notice.Applicable {
|
||||
if !notice.Applicable {
|
||||
if ok, reason := b.shouldRespond(ctx, msg, actor, sess, a); !ok {
|
||||
b.registerSilenced(ctx, msg, actor, sess, a)
|
||||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||
b.log.Debug("inbound message silenced (minimal disturbance)",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "reason", reason)
|
||||
"session", sess.SessionID, "actor_role", actor.Role, "reason", reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,15 +160,23 @@ func isStrangerPrivate(sess session.Session) bool {
|
||||
return !sess.IsGroup && !sess.Owner
|
||||
}
|
||||
|
||||
// shouldRespond 实现非主人入站消息的最小打扰分级
|
||||
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5)。它返回该消息
|
||||
// shouldRespond 实现入站消息的最小打扰分级
|
||||
// (设计/12 §6 策略一/二 + design/18 §2/§4/§5/§11)。它返回该消息
|
||||
// 应驱动完整 agent 循环并回复,还是只登记并静默。
|
||||
// 主人消息绝不会到这里(processFrom 以 !actor.IsOwner 为门),
|
||||
// 因此主人私聊与主人在群里的 @/高价值回复不受影响。sess 是
|
||||
// **主人消息**由此处的 owner 车道分支处理(design/18 §11「乙 = C」):
|
||||
// 主人私聊恒回;主人群消息按「指向她本人」的结构化判据与每群旋钮决定说不说
|
||||
// (见 ownerlane.go);因此主人私聊与主人在群里的 @/@全体仍必回,
|
||||
// 而主人群里不对她说的消息改为**进上下文不回**。sess 是
|
||||
// 该消息解析出的隔离群聊/陌生人会话绑定,使参与度
|
||||
// 引擎探测时能读到群的近期上下文;a 是收到该消息的账号适配器,
|
||||
// 用于取该账号的流控闸门(陌生人私聊的强制限流)。
|
||||
func (b *Bridge) shouldRespond(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) (bool, string) {
|
||||
// owner 车道:结构化参与闸(design/18 §11)。身份由 actor 元数据判定,
|
||||
// 这里只决定「说不说 / 多频繁」,绝不动权限面。
|
||||
if actor.IsOwner() {
|
||||
return b.ownerGroupShouldRespond(msg, sess)
|
||||
}
|
||||
|
||||
// 策略一:平台/系统噪声 → 只登记,绝不进完整循环。
|
||||
if isLowValueEvent(actor) {
|
||||
b.log.Debug("non-owner low-value system event, registered only",
|
||||
@@ -251,11 +259,11 @@ func (b *Bridge) mentionsTriggerWord(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// registerSilenced 把一次被静默的非主人入站对话持久化进其隔离
|
||||
// registerSilenced 把一次被静默的入站对话持久化进其隔离
|
||||
// 会话的平台日志,使适配器日志页仍能渲染非主人
|
||||
// 消息流(design/16 §4.6 §4),随后就此结束——不进循环、不回复。a 是
|
||||
// 收到该消息的账号适配器,日志因此带上适配器配置标签。
|
||||
// 它是最小打扰路径中「登记」的那一侧。
|
||||
// 它是最小打扰路径(以及 owner 车道「进上下文不回」)中「登记」的那一侧。
|
||||
func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessage, actor adapter.Actor, sess session.Session, a adapter.PlatformAdapter) {
|
||||
// 被静默的消息不摄取附件(摄取在闸门之后),因此这两列恒为空 ——
|
||||
// 绝不写一条指向不存在文件的引用,也绝不写一张没进过上下文的图片。
|
||||
@@ -268,8 +276,9 @@ func (b *Bridge) registerSilenced(ctx context.Context, msg adapter.UnifiedMessag
|
||||
// senderDisplayName 会在无昵称时写出空串,把一条陌生人消息渲染成
|
||||
// 「来自主人(管理员账户)本人」(review p1:本次修掉的 bug 的镜像)。
|
||||
b.persistIsolated(ctx, history.RoleUser, msg.Content, "", "", deviceIDFor(msg), sess, msg.Platform, thirdPartySenderName(msg, actor), adapterName(a), "")
|
||||
b.log.Debug("non-owner message silenced, registered only",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID)
|
||||
b.log.Debug("inbound message silenced, registered only",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "session", sess.SessionID,
|
||||
"actor_role", actor.Role)
|
||||
}
|
||||
|
||||
// registerThrottled 是一次被强制流控(同一发送者已有回合在跑)拦下的
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
)
|
||||
|
||||
// 本文件承载 owner 车道(主人在**群聊**里的发言)的结构化参与闸与它的可调旋钮
|
||||
// (design/18 §11「乙 = C」,主人 2026-10-13 裁决「乙 C」):
|
||||
//
|
||||
// - 默认「不对她说就不开口」:只有**指向她本人**的主人群消息(@她 / @全体,
|
||||
// 或未接线渠道上「有提及」的旧判据)才触发回复;其余主人群消息**仍进上下文**
|
||||
// (经 registerSilenced 落进该群隔离会话,后续回合的转录照常读到),但**不驱动回合**。
|
||||
// - 可调旋钮:把主人在群里说的「少说点」这类诉求**落成结构化配置**(每群 / 主人的
|
||||
// 开关与频率,默认值 + 覆盖),可读可改 —— **不靠她自己记住**。
|
||||
//
|
||||
// 两条红线(design/18 §11 写死):判据只认**结构化字段 + 正文关键词**,绝不调 LLM 判身份;
|
||||
// 旋钮只影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。
|
||||
// 它同时**不碰** M10 的发言人归属车道(inbound_speaker.go):主人恒无标注这条不变量不变。
|
||||
|
||||
const (
|
||||
// ownerLaneLogSource 是本文件日志的来源标记,使「owner 闸门/旋钮怎么变的」跨模块
|
||||
// 可定位(项目规范 §五:禁止无来源裸日志)。
|
||||
ownerLaneLogSource = "services/channelbridge/ownerlane"
|
||||
// defaultOwnerLaneQuietGapSec 是「少说点」把旋钮降到保守档时的最小回复间隔(5 分钟):
|
||||
// 即便她仍回非指向她的主人消息,也不会连珠炮。
|
||||
defaultOwnerLaneQuietGapSec = 300
|
||||
)
|
||||
|
||||
// OwnerLanePolicy 是 owner 车道的一档参与策略(与 runtimeconfig.OwnerLanePolicy 同形,
|
||||
// 由组合根在接缝处转换,使 channelbridge 不依赖 config 包)。零值 = 最保守的一档。
|
||||
type OwnerLanePolicy struct {
|
||||
// ReplyToUnaddressed 是「说不说」开关。
|
||||
ReplyToUnaddressed bool
|
||||
// MinReplyGapSec 是「多频繁」:非指向她的回复之间的最小间隔(秒),0 = 不限制。
|
||||
MinReplyGapSec int
|
||||
}
|
||||
|
||||
// DefaultOwnerLanePolicy 返回默认档位:不对她说就不开口。
|
||||
func DefaultOwnerLanePolicy() OwnerLanePolicy {
|
||||
return OwnerLanePolicy{}
|
||||
}
|
||||
|
||||
// ownerLaneQuietPolicy 是「少说点」把旋钮降到的保守档:非指向她的一律不回,
|
||||
// 并保留一个频率闸(供别人把开关打开后仍受间隔约束)。
|
||||
func ownerLaneQuietPolicy() OwnerLanePolicy {
|
||||
return OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: defaultOwnerLaneQuietGapSec}
|
||||
}
|
||||
|
||||
// ownerAddressedToMe 是 owner 车道的「指向她本人」判据(M11 reviewer 给死的写法):
|
||||
//
|
||||
// msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)
|
||||
//
|
||||
// 后半句是**未接线渠道的旧判据**(现在只有 qq-obv11 填 SelfID/Mentions):对未接线渠道
|
||||
// AddressedToMe 恒 false,若只认前半句,kook / webhook 的群 @ 会不再必回。
|
||||
// ⇒ 「没有信号」绝不能被当成「没在对我说话」。判据全部来自结构化字段,绝不匹配正文。
|
||||
func ownerAddressedToMe(msg adapter.UnifiedMessage) bool {
|
||||
return msg.AddressedToMe || (msg.SelfID == "" && len(msg.Mentions) > 0)
|
||||
}
|
||||
|
||||
// ownerLaneKey 是旋钮内存覆盖表的键(平台 + 群)。
|
||||
func ownerLaneKey(platform, groupID string) string {
|
||||
return platform + "|" + groupID
|
||||
}
|
||||
|
||||
// OwnerLanePolicy 返回某个群当前生效的 owner 车道策略:外部配置(组合根注入的
|
||||
// OwnerLaneFor)优先,其次桥内内存覆盖,最后是默认档。它是**可读**面 —— 面板/运维
|
||||
// 据此显示该群当前的旋钮位置。
|
||||
func (b *Bridge) OwnerLanePolicy(platform, groupID string) OwnerLanePolicy {
|
||||
if b.ownerLaneFor != nil {
|
||||
if p := b.ownerLaneFor(platform, groupID); p != nil {
|
||||
return *p
|
||||
}
|
||||
}
|
||||
b.ownerLaneMu.Lock()
|
||||
defer b.ownerLaneMu.Unlock()
|
||||
if p, ok := b.ownerLane[ownerLaneKey(platform, groupID)]; ok {
|
||||
return p
|
||||
}
|
||||
return DefaultOwnerLanePolicy()
|
||||
}
|
||||
|
||||
// SetOwnerLanePolicy 写入某个群的 owner 车道策略(**可改**面:面板写入与「少说点」
|
||||
// 自动降频共用它)。内存先改(行为立即生效),再经注入的 OwnerLaneSet 尽力落盘;
|
||||
// 每次变化都留一条带 source 的 INFO 日志,使「旋钮怎么变的」可观测(design/18 §11)。
|
||||
func (b *Bridge) SetOwnerLanePolicy(platform, groupID string, p OwnerLanePolicy) error {
|
||||
b.ownerLaneMu.Lock()
|
||||
b.ownerLane[ownerLaneKey(platform, groupID)] = p
|
||||
b.ownerLaneMu.Unlock()
|
||||
|
||||
var persistErr error
|
||||
if b.ownerLaneSet != nil {
|
||||
if err := b.ownerLaneSet(platform, groupID, p); err != nil {
|
||||
persistErr = fmt.Errorf("channelbridge: persist owner lane policy: %w", err)
|
||||
}
|
||||
}
|
||||
b.ownerLaneLog.Info("owner lane policy set",
|
||||
"platform", platform, "group", groupID,
|
||||
"reply_to_unaddressed", p.ReplyToUnaddressed, "min_reply_gap_sec", p.MinReplyGapSec)
|
||||
return persistErr
|
||||
}
|
||||
|
||||
// ownerGroupShouldRespond 是 owner 车道的结构化参与闸(design/18 §11)。
|
||||
// 它只对**主人**消息调用(非主人走 shouldRespond 的既有分级),返回该消息是否应驱动
|
||||
// 完整 agent 循环并回复:
|
||||
//
|
||||
// - 主人**私聊/非群**:恒回(旋钮只管群,design/18 §11);
|
||||
// - 群聊里命中「少说点」这类关键词:先把它落成结构化配置(降频),再照常判定;
|
||||
// - 指向她本人(@她 / @全体 / 未接线旧判据):**必回**,不受旋钮约束(硬保留);
|
||||
// - 其余:按该群旋钮 —— 开关关(默认)⇒ 只进上下文不回;开关开⇒再受频率闸约束。
|
||||
func (b *Bridge) ownerGroupShouldRespond(msg adapter.UnifiedMessage, sess session.Session) (bool, string) {
|
||||
if !sess.IsGroup {
|
||||
return true, "owner private: always replies"
|
||||
}
|
||||
b.applyOwnerLaneRequest(msg, sess)
|
||||
if ownerAddressedToMe(msg) {
|
||||
return true, "owner group: addressed to her"
|
||||
}
|
||||
p := b.OwnerLanePolicy(msg.Platform, msg.GroupID)
|
||||
if !p.ReplyToUnaddressed {
|
||||
return false, "owner lane: not addressed to her"
|
||||
}
|
||||
if !b.ownerLaneGapPassed(msg.Platform, msg.GroupID, p.MinReplyGapSec) {
|
||||
return false, "owner lane: within reply gap"
|
||||
}
|
||||
return true, "owner lane: unaddressed reply allowed"
|
||||
}
|
||||
|
||||
// applyOwnerLaneRequest 把主人在群里表达的「少说点」这类诉求落成结构化配置
|
||||
// (design/18 §11 ②)。词表沿用 engage.go 的 negativeAttitudeKeywords(已有「少说两句」
|
||||
// 等),仍是廉价子串匹配 —— 绝不调用 LLM,也绝不读正文判身份。**日志不打印正文**
|
||||
// (design/18 §9.7)。落盘失败只留可见 WARN,内存里的降频仍已生效。
|
||||
func (b *Bridge) applyOwnerLaneRequest(msg adapter.UnifiedMessage, sess session.Session) {
|
||||
if !sess.IsGroup || !b.negativeAttitude(msg.Content) {
|
||||
return
|
||||
}
|
||||
if err := b.SetOwnerLanePolicy(msg.Platform, msg.GroupID, ownerLaneQuietPolicy()); err != nil {
|
||||
b.ownerLaneLog.Warn("owner lane quiet request could not be persisted",
|
||||
"platform", msg.Platform, "group", msg.GroupID, "error", err)
|
||||
return
|
||||
}
|
||||
b.ownerLaneLog.Info("owner lane quietened by owner request",
|
||||
"platform", msg.Platform, "group", msg.GroupID)
|
||||
}
|
||||
|
||||
// ownerLaneGapPassed 施加「多频繁」闸:间隔未到返回 false(该消息只进上下文不回);
|
||||
// 通过则记录本次时刻并返回 true。gapSec <= 0 表示不限制。只有**非指向她**的消息会走到
|
||||
// 这里,因此指向她的必回语义不受影响。调用方不持锁,这里取内存覆盖表同一把锁。
|
||||
func (b *Bridge) ownerLaneGapPassed(platform, groupID string, gapSec int) bool {
|
||||
if gapSec <= 0 {
|
||||
return true
|
||||
}
|
||||
b.ownerLaneMu.Lock()
|
||||
defer b.ownerLaneMu.Unlock()
|
||||
key := ownerLaneKey(platform, groupID)
|
||||
now := b.now()
|
||||
if last, ok := b.ownerLaneLast[key]; ok && now.Sub(last) < time.Duration(gapSec)*time.Second {
|
||||
return false
|
||||
}
|
||||
b.ownerLaneLast[key] = now
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件覆盖 design/18 §11「乙 = C」(主人 2026-10-13 裁决「乙 C」):
|
||||
// - owner 车道默认「不对她说就不开口」:主人群里不对她说的消息**仍进上下文**(落进该群
|
||||
// 隔离会话,转录读得到)但**不驱动回复**;
|
||||
// - 指向她本人(@她 / @全体 / 未接线渠道旧判据)**必回**,不被旋钮关掉;
|
||||
// - 结构化旋钮(每群开关 + 频率)可读可改、可观测,且只影响说不说/多频繁。
|
||||
|
||||
// ownerLaneGroupMsg 构造一条来自主人(由白名单结构化判主)的群消息。
|
||||
// Sender.Role 故意留 member:判主只看白名单(裁决 06.4.2)。
|
||||
func ownerLaneGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: "qq-obv11", ChannelID: "g1", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "g1", GroupName: "测试群", SenderID: "10001", SenderName: "主人",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
|
||||
Content: content, ContentType: adapter.ContentText,
|
||||
}
|
||||
}
|
||||
|
||||
// ownerGroupSession 是主人在群里的受限会话绑定(群 + 主人 + 记忆只读)。
|
||||
func ownerGroupSession() session.Session {
|
||||
return session.Session{SessionID: "sess-og", IsGroup: true, Owner: true, MemWritable: false}
|
||||
}
|
||||
|
||||
// newOwnerLaneBridge 构建一条接了 qq-obv11 适配器、主人白名单命中的测试桥接器。
|
||||
// now 为 nil 时用固定时钟。
|
||||
func newOwnerLaneBridge(t *testing.T, chat *stubChatter, sess *stubSessions, hist *captureHistory, now func() time.Time, log *slog.Logger) (*Bridge, *fakeAdapter) {
|
||||
t.Helper()
|
||||
if now == nil {
|
||||
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now = func() time.Time { return at }
|
||||
}
|
||||
ad := &fakeAdapter{name: "qq-obv11"}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: testOwnerProfile("qq-obv11", "10001"),
|
||||
History: hist, Log: log, Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
return b, ad
|
||||
}
|
||||
|
||||
// TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied 钉住默认口径「不对她说就不开口」:
|
||||
// 主人群里一句不对她说的普通消息**只进上下文**(落进该群隔离会话)、**不驱动回合**。
|
||||
// 变异自证:把 inbound.go 的 owner 短路恢复(`!actor.IsOwner() && !notice.Applicable`)⇒
|
||||
// 本用例立刻变红(循环会被驱动、chat.sessionID 非空)。
|
||||
func TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
hist := &captureHistory{}
|
||||
b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil)
|
||||
|
||||
b.process(context.Background(), ownerLaneGroupMsg("大家好啊,我今天在家"))
|
||||
|
||||
if chat.sessionID != "" || len(chat.messages) != 0 {
|
||||
t.Fatalf("不对她说的主人群消息驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
|
||||
}
|
||||
if len(ad.sends) != 0 {
|
||||
t.Fatalf("不对她说的主人群消息被回复了:%+v", ad.sends)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("records = %d, want 1(进上下文但不回)", len(recs))
|
||||
}
|
||||
if recs[0].SessionID != "sess-og" || recs[0].Role != history.RoleUser || recs[0].Content != "大家好啊,我今天在家" {
|
||||
t.Fatalf("登记记录 = %+v, want 该群隔离会话的 user 原样正文", recs[0])
|
||||
}
|
||||
if recs[0].SenderName != "" {
|
||||
t.Fatalf("主人被标了发送者名 %q(主人恒无标注,design/16 §4.6)", recs[0].SenderName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled 钉住硬保留:
|
||||
// 主人**明确 @ 她本人 / @全体** → 必回,**即使该群旋钮已降到最保守档(降频)**。
|
||||
func TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
msg func() adapter.UnifiedMessage
|
||||
}{
|
||||
{"direct mention", func() adapter.UnifiedMessage {
|
||||
m := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"})
|
||||
m.SelfID = "bot-10001"
|
||||
m.AddressedToMe = m.AddressedToSelf()
|
||||
return m
|
||||
}},
|
||||
{"mention all", func() adapter.UnifiedMessage {
|
||||
m := ownerLaneGroupMsg("都来看一眼")
|
||||
m.SelfID = "bot-10001"
|
||||
m.MentionAll = true
|
||||
m.AddressedToMe = m.AddressedToSelf()
|
||||
return m
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
|
||||
b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
|
||||
// 事先把该群压到最保守档 + 一小时频率闸。
|
||||
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil {
|
||||
t.Fatalf("SetOwnerLanePolicy: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), tc.msg())
|
||||
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("指向她的主人群消息被旋钮挡住了:session=%q", chat.sessionID)
|
||||
}
|
||||
if len(ad.sends) != 1 || ad.sends[0].Content != "在的" {
|
||||
t.Fatalf("指向她的主人群消息未回复:%+v", ad.sends)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion 钉住判据的后半句:
|
||||
// - 未接线渠道(SelfID 为空)上「有提及」按**旧判据**算指向她 —— 保证 kook / webhook
|
||||
// 的群 @ 仍然必回(不能把「没有信号」当成「没在对我说话」);
|
||||
// - 已接线渠道上「提及的不是她」→ 不指向她 → 不回。
|
||||
func TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("unwired channel with mention replies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
|
||||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
|
||||
|
||||
m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"})
|
||||
// SelfID 留空 = 未接线;AddressedToMe 恒 false(fail-closed)。
|
||||
b.process(context.Background(), m)
|
||||
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wired channel mentioning someone else is not addressed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
|
||||
|
||||
m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"})
|
||||
m.SelfID = "bot-10001"
|
||||
m.AddressedToMe = m.AddressedToSelf() // 提及的不是她 ⇒ false
|
||||
b.process(context.Background(), m)
|
||||
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("已接线渠道里 @ 别人被当成指向她:session=%q", chat.sessionID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOwnerLaneThrottleEffectiveAndRestorable 钉住结构化旋钮的**生效与可恢复**:
|
||||
// 打开该群开关 → 非指向消息回;主人说「少说点」→ 落结构化配置、降频生效(不回);
|
||||
// 写回打开档 → 又回。同时断言旋钮变化留下带 source 的可见日志。
|
||||
func TestOwnerLaneThrottleEffectiveAndRestorable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
|
||||
hist := &captureHistory{}
|
||||
var logBuf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, log)
|
||||
|
||||
// 1) 打开该群开关(= 主人/面板要求「多说点」):非指向消息也回。
|
||||
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil {
|
||||
t.Fatalf("SetOwnerLanePolicy: %v", err)
|
||||
}
|
||||
b.process(context.Background(), ownerLaneGroupMsg("大家今天都在忙什么"))
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("打开开关后非指向消息仍不回:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 2) 主人说「少说点」→ 落成结构化配置(降频),这一条自己也不再驱动回复。
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("降频后仍回:session=%q", chat.sessionID)
|
||||
}
|
||||
if p := b.OwnerLanePolicy("qq-obv11", "g1"); p.ReplyToUnaddressed {
|
||||
t.Fatalf("「少说点」未把旋钮落到保守档:%+v", p)
|
||||
}
|
||||
|
||||
// 3) 可恢复:写回打开档 → 又回。
|
||||
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil {
|
||||
t.Fatalf("SetOwnerLanePolicy restore: %v", err)
|
||||
}
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), ownerLaneGroupMsg("我再说一句普通的话"))
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("恢复旋钮后仍不回:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 4) 可观测:旋钮变化留下带 source 的日志。
|
||||
if logged := logBuf.String(); !strings.Contains(logged, "owner lane") ||
|
||||
!strings.Contains(logged, "source=services/channelbridge/ownerlane") {
|
||||
t.Fatalf("旋钮变化没有带 source 的可见日志:%q", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneReplyGapThrottlesUnaddressedReplies 钉住「多频繁」那一档:
|
||||
// 开关打开后,非指向消息之间还要过最小间隔闸;但指向她的消息**不受间隔约束**(必回)。
|
||||
func TestOwnerLaneReplyGapThrottlesUnaddressedReplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
clock := func() time.Time { return now }
|
||||
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
|
||||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, clock, nil)
|
||||
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 300}); err != nil {
|
||||
t.Fatalf("SetOwnerLanePolicy: %v", err)
|
||||
}
|
||||
|
||||
// 第一条非指向消息:过闸、回。
|
||||
b.process(context.Background(), ownerLaneGroupMsg("第一句普通的话"))
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("第一条非指向消息未回:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 间隔内第二条:被频率闸挡下,只进上下文。
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), ownerLaneGroupMsg("第二条普通的话"))
|
||||
if chat.sessionID != "" {
|
||||
t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 间隔内**指向她**的消息:仍必回(硬保留不受频率闸约束)。
|
||||
chat.sessionID = ""
|
||||
addressed := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"})
|
||||
addressed.SelfID = "bot-10001"
|
||||
addressed.AddressedToMe = addressed.AddressedToSelf()
|
||||
b.process(context.Background(), addressed)
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("间隔内指向她的消息被频率闸挡住:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 时钟推进过间隔:非指向消息恢复。
|
||||
now = now.Add(301 * time.Second)
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), ownerLaneGroupMsg("过了五分钟再说一句"))
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("过了间隔后非指向消息仍未回:session=%q", chat.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLanePolicySeamReadsAndPersists 钉住面板写入留好的形状(读写接缝):
|
||||
// OwnerLaneFor 决定该群生效档位,OwnerLaneSet 收到每次写入(组合根据此落盘)。
|
||||
func TestOwnerLanePolicySeamReadsAndPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var saved []OwnerLanePolicy
|
||||
var savedGroup string
|
||||
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: &stubSessions{sess: ownerGroupSession()},
|
||||
Owner: testOwnerProfile("qq-obv11", "10001"),
|
||||
History: &captureHistory{},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
|
||||
OwnerLaneFor: func(platform, groupID string) *OwnerLanePolicy {
|
||||
return &OwnerLanePolicy{ReplyToUnaddressed: true}
|
||||
},
|
||||
OwnerLaneSet: func(platform, groupID string, p OwnerLanePolicy) error {
|
||||
saved = append(saved, p)
|
||||
savedGroup = groupID
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// 读接缝:外部配置决定该群档位 ⇒ 非指向消息也回。
|
||||
b.process(context.Background(), ownerLaneGroupMsg("普通的一句话"))
|
||||
if chat.sessionID != "sess-og" {
|
||||
t.Fatalf("读接缝未生效:session=%q", chat.sessionID)
|
||||
}
|
||||
|
||||
// 写接缝:主人说「少说点」⇒ 写入被外部存储收到。
|
||||
chat.sessionID = ""
|
||||
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
|
||||
if len(saved) != 1 || savedGroup != "g1" || saved[0].ReplyToUnaddressed {
|
||||
t.Fatalf("写接缝未收到结构化降频:saved=%+v group=%q", saved, savedGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLanePrivateChatsUnaffected 钉住回归:旋钮只管**群**。
|
||||
// 主人私聊恒回;未进白名单的群消息走既有非主人分级(被 @ 仍回)。
|
||||
func TestOwnerLanePrivateChatsUnaffected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("owner private always replies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
if err := b.SetOwnerLanePolicy("telegram", "-20", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil {
|
||||
t.Fatalf("SetOwnerLanePolicy: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if chat.sessionID != history.MainSessionID || len(ad.sends) != 1 {
|
||||
t.Fatalf("主人私聊被 owner 车道旋钮影响:session=%q sends=%+v", chat.sessionID, ad.sends)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner group mention still replies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
|
||||
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("非主人群消息的既有分级被 owner 车道影响:session=%q", chat.sessionID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOwnerLaneSilenceKeepsSpeakerLabelInvariant 钉住 M10 的归属不变量在 owner 车道
|
||||
// 静默路径上仍闭合:**主人静默行恒无标注**(受限会话里空标注 = 主人本人),
|
||||
// **非主人静默行恒有标注**(第三方绝不被读成主人)。
|
||||
func TestOwnerLaneSilenceKeepsSpeakerLabelInvariant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("owner silenced row has no label", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &captureHistory{}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil)
|
||||
|
||||
b.process(context.Background(), ownerLaneGroupMsg("不对她说的主人群消息"))
|
||||
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 || recs[0].SenderName != "" {
|
||||
t.Fatalf("主人静默行 = %+v, want 无标注(主人本人)", recs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner silenced row keeps a label", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &captureHistory{}
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
|
||||
Owner: &user.OwnerProfile{}, History: hist,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
b.process(context.Background(), nonOwnerGroupMsg("群里随手一句普通的话"))
|
||||
|
||||
recs := hist.records()
|
||||
if len(recs) != 1 || recs[0].SenderName != "张三" {
|
||||
t.Fatalf("非主人静默行 = %+v, want 带发送者标注「张三」", recs)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func TestInboundChannelPersonaPrecedence(t *testing.T) {
|
||||
// TestInboundGroupForcesDefaultRole 是 design/22 §7-4:群聊——即使
|
||||
// 群里是主人自己的消息——即使账号绑定了角色也仍用默认
|
||||
// 角色,因此隔离的群会话绝不会被移入某个角色的作用域。
|
||||
// 这条主人群消息**指向她本人**(含提及):owner 车道默认「不对她说就不开口」
|
||||
// (design/18 §11),不带指向信号的主人群消息只进上下文、不驱动回合,
|
||||
// 因此这里刻意带上 @ 信号,让本用例仍只验证角色解析这件事。
|
||||
func TestInboundGroupForcesDefaultRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &envChatter{}
|
||||
@@ -143,7 +146,8 @@ func TestInboundGroupForcesDefaultRole(t *testing.T) {
|
||||
|
||||
b.process(context.Background(), adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20",
|
||||
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText,
|
||||
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Mentions: []adapter.Mention{{UserID: "100"}},
|
||||
Content: "我在群里", ContentType: adapter.ContentText,
|
||||
})
|
||||
|
||||
if !chat.got {
|
||||
|
||||
Reference in new issue
Block a user