feat(emotion): A1 情绪系统接线 + refactor: 拆分 channelbridge.go
A1 情绪接线: - loop 回合边界钩子(TurnEnd/OnTurnEnd/settleTurn, Result.Mood); 每轮一次非每步 - persona/emotion: React(valence五档) + Machine.Move(BFS最短合法路径一步) + Observer(observe/decay/好感度,仅变化才emit); 好感度步长读角色配置(cyrene delta=10, 0..700) - 落库 Mood(web session_persist.res.Mood + IM channelbridge/persist; user消息不带mood); WS mood事件(BroadcastMood,仅变化); per-persona隔离; 群聊/陌生人只读不写不发 - cmd/cyrene/emotion.go 生产接线 + wire.go 注入 loop.Options.OnTurnEnd - 22 测试(状态机被loop调用/Mood非空落库/mood帧shape/只变化才发/群聊只读) + 真二进制冒烟 - Docs: api-client v0.32 §3.7 情绪事件 + 开源对照清单(companion 应用对照) - 诚实: 本机无LLM凭据→未活体验证'模型返回后完整轮次'一环 拆分 channelbridge.go 816→257(纯搬移零逻辑): connect/inbound/outbound/blacklist/helpers.go; A1 的 IM mood 调用点落在新 inbound.go, 故与本提交同批 回滚 6 个 gofmt 顺手格式化文件(capacity_loop_test/flow/source_test/findchannel_test/admin_channels_platform_test/history) 缩 diff, 含 history.go 注释被转弯引号
This commit is contained in:
1 parent
610b330025
commit
1a85de555d
27 files changed
+1946
-594
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package emotion
|
||||
|
||||
import "sync"
|
||||
|
||||
// Update reports the effect of one observed turn on a persona's emotion and
|
||||
// affection. It is handed to the Observer's emit sink so a caller can surface
|
||||
// the change (the `mood` WS event) and log it.
|
||||
type Update struct {
|
||||
// PersonaID is the persona the turn was applied to.
|
||||
PersonaID string
|
||||
// Before is the emotion state before the turn.
|
||||
Before EmotionState
|
||||
// After is the emotion state after the turn.
|
||||
After EmotionState
|
||||
// Affection is the affection value after the turn.
|
||||
Affection int
|
||||
// Signal reports whether the turn carried an emotional cue.
|
||||
Signal bool
|
||||
}
|
||||
|
||||
// Moved reports whether the emotion state actually changed this turn. It is the
|
||||
// gate for broadcasting: a turn that leaves the mood where it was is not news.
|
||||
func (u Update) Moved() bool { return u.Before != u.After }
|
||||
|
||||
// StepFunc returns the affection magnitude configured for a persona (its 好感度
|
||||
// 曲线 delta, design/01 §2). Returning 0 leaves affection untouched.
|
||||
type StepFunc func(personaID string) int
|
||||
|
||||
// Observer applies settled conversation turns to the per-persona emotion machine
|
||||
// and affection. It owns no state of its own beyond the write lock: the machines
|
||||
// and affection trackers live in the Manager, which is what keeps emotion keyed
|
||||
// by persona (design/01 §2: emotion/affection follow the persona/character, never
|
||||
// a behavior mode). All methods are safe for concurrent use.
|
||||
type Observer struct {
|
||||
mu sync.Mutex
|
||||
mgr *Manager
|
||||
step StepFunc
|
||||
emit func(Update)
|
||||
}
|
||||
|
||||
// NewObserver builds an Observer over mgr. step resolves the per-persona
|
||||
// affection magnitude (a nil step means affection never moves); emit, when
|
||||
// non-nil, is called once per mood change so a caller can broadcast it.
|
||||
func NewObserver(mgr *Manager, step StepFunc, emit func(Update)) *Observer {
|
||||
if step == nil {
|
||||
step = func(string) int { return 0 }
|
||||
}
|
||||
return &Observer{mgr: mgr, step: step, emit: emit}
|
||||
}
|
||||
|
||||
// Observe applies one settled turn's user text to personaID's emotion machine
|
||||
// and affection, then returns the persona's mood after the turn ("" when the
|
||||
// persona is unknown or the conversation is not writable).
|
||||
//
|
||||
// writable is the conversation's write gate: a restricted (group/stranger)
|
||||
// conversation is read-only for emotion exactly as it is for memory, so a third
|
||||
// party can never move the persona's mood or affection (design/02 §3, design/11
|
||||
// §2 — memory/emotion isolation; identity stays structural, never prompt-based).
|
||||
//
|
||||
// A turn with an emotional cue moves the machine one legal edge toward the state
|
||||
// the cue aims at (Machine.Move walks the persona's own transition table, and the
|
||||
// step size is one edge per turn — the ladder is the machine's, not a new
|
||||
// intensity scale) and steps the affection curve; a turn with no cue instead lets
|
||||
// the emotion decay one step toward calm (Machine.Decay, gated by the persona's
|
||||
// decayRate). One Run = one turn = one application, so nothing is applied twice.
|
||||
func (o *Observer) Observe(personaID, userText string, writable bool) EmotionState {
|
||||
if !writable || personaID == "" {
|
||||
return ""
|
||||
}
|
||||
machine, affection, ok := o.mgr.Get(personaID)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
before := machine.Current()
|
||||
reaction := React(userText)
|
||||
if reaction.Signal {
|
||||
machine.Move(reaction.State)
|
||||
if affection != nil && reaction.AffectionDelta != 0 {
|
||||
affection.Adjust(reaction.AffectionDelta * o.step(personaID))
|
||||
}
|
||||
} else {
|
||||
machine.Decay()
|
||||
}
|
||||
after := machine.Current()
|
||||
value := 0
|
||||
if affection != nil {
|
||||
value = affection.Value()
|
||||
}
|
||||
|
||||
update := Update{PersonaID: personaID, Before: before, After: after, Affection: value, Signal: reaction.Signal}
|
||||
if o.emit != nil && update.Moved() {
|
||||
o.emit(update)
|
||||
}
|
||||
return after
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package emotion
|
||||
|
||||
import "testing"
|
||||
|
||||
// newTestObserver builds an observer over one persona whose affection step is 10,
|
||||
// capturing every emitted update.
|
||||
func newTestObserver(t *testing.T, id string, decay float64, initial EmotionState) (*Observer, *Machine, *Affection, *[]Update) {
|
||||
t.Helper()
|
||||
machine, err := NewMachine(Params{InitialState: initial, DecayRate: decay})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
affection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
mgr := NewManager()
|
||||
mgr.Set(id, machine, affection)
|
||||
|
||||
emitted := &[]Update{}
|
||||
obs := NewObserver(mgr, func(string) int { return 10 }, func(u Update) {
|
||||
*emitted = append(*emitted, u)
|
||||
})
|
||||
return obs, machine, affection, emitted
|
||||
}
|
||||
|
||||
// TestObserveMovesEmotionAndAffection locks the turn rule: a warm turn raises the
|
||||
// mood toward happy/excited and steps affection up by the persona's configured
|
||||
// magnitude, a cold turn lowers both, and the change is reported for broadcast.
|
||||
func TestObserveMovesEmotionAndAffection(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("cyrene", "今天谢谢你呀", true); got != Happy {
|
||||
t.Fatalf("warm turn mood = %s, want happy", got)
|
||||
}
|
||||
if affection.Value() != 60 {
|
||||
t.Fatalf("affection after warm turn = %d, want 60 (50+10)", affection.Value())
|
||||
}
|
||||
if got := obs.Observe("cyrene", "我今天好累,有点难过", true); got != Sad {
|
||||
t.Fatalf("down turn mood = %s, want sad", got)
|
||||
}
|
||||
if affection.Value() != 50 {
|
||||
t.Fatalf("affection after down turn = %d, want 50 (60-10)", affection.Value())
|
||||
}
|
||||
// A cue-free turn carries no signal: the emotion decays toward calm instead of
|
||||
// jumping, so sad → calm.
|
||||
if got := obs.Observe("cyrene", "帮我看下这个报错", true); got != Calm {
|
||||
t.Fatalf("neutral turn mood = %s, want decayed calm", got)
|
||||
}
|
||||
if len(*emitted) != 3 {
|
||||
t.Fatalf("emitted = %d, want 3 (one per real mood change)", len(*emitted))
|
||||
}
|
||||
if machine.Current() != Calm {
|
||||
t.Fatalf("machine current = %s, want calm", machine.Current())
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveEmitsOnlyOnChange locks the noise guard: a turn that leaves the mood
|
||||
// where it already was emits nothing, while a turn that moves it emits once.
|
||||
func TestObserveEmitsOnlyOnChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, _, _, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
// calm + neutral → calm: no change, no event.
|
||||
if got := obs.Observe("cyrene", "把这段代码格式化一下", true); got != Calm {
|
||||
t.Fatalf("neutral turn mood = %s, want calm", got)
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0 for an unchanged mood", len(*emitted))
|
||||
}
|
||||
// calm + warm → happy: one event.
|
||||
obs.Observe("cyrene", "谢谢你", true)
|
||||
// happy + warm → happy: unchanged, no second event.
|
||||
obs.Observe("cyrene", "辛苦啦", true)
|
||||
if len(*emitted) != 1 {
|
||||
t.Fatalf("emitted = %d, want 1 (only the real change)", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveDecayDisabledByRate locks the persona's decayRate knob: with decay
|
||||
// off, a cue-free turn leaves the mood untouched.
|
||||
func TestObserveDecayDisabledByRate(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, _, emitted := newTestObserver(t, "cyrene", 0, Happy)
|
||||
|
||||
if got := obs.Observe("cyrene", "帮我把日志改成 debug", true); got != Happy {
|
||||
t.Fatalf("mood with decay disabled = %s, want happy unchanged", got)
|
||||
}
|
||||
if machine.Current() != Happy {
|
||||
t.Fatalf("machine current = %s, want happy", machine.Current())
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveRestrictedConversationIsReadOnly locks the isolation boundary: a
|
||||
// group-chat/stranger turn (writable=false) never moves the persona's mood or
|
||||
// affection, mirroring the read-only memory lane (design/02 §3, design/11 §2).
|
||||
func TestObserveRestrictedConversationIsReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, affection, emitted := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("cyrene", "我很喜欢你", false); got != "" {
|
||||
t.Fatalf("restricted turn mood = %q, want empty", got)
|
||||
}
|
||||
if machine.Current() != Calm || affection.Value() != 50 {
|
||||
t.Fatalf("restricted turn changed state to %s/%d, want calm/50", machine.Current(), affection.Value())
|
||||
}
|
||||
if len(*emitted) != 0 {
|
||||
t.Fatalf("emitted = %d, want 0 for a restricted turn", len(*emitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveUnknownPersonaIsNoop locks the persona-scoping guard: an unregistered
|
||||
// persona id yields no mood and no panic.
|
||||
func TestObserveUnknownPersonaIsNoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
obs, machine, _, _ := newTestObserver(t, "cyrene", 0.5, Calm)
|
||||
|
||||
if got := obs.Observe("someone-else", "我爱你", true); got != "" {
|
||||
t.Fatalf("unknown persona mood = %q, want empty", got)
|
||||
}
|
||||
if machine.Current() != Calm {
|
||||
t.Fatalf("unknown persona id moved the registered machine to %s", machine.Current())
|
||||
}
|
||||
if got := obs.Observe("", "我爱你", true); got != "" {
|
||||
t.Fatalf("empty persona mood = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserveKeepsPersonasSeparate locks the per-persona isolation the whole
|
||||
// mechanism rests on (design/01 §2: emotion follows the persona): a turn applied
|
||||
// to one persona leaves every other persona untouched.
|
||||
func TestObserveKeepsPersonasSeparate(t *testing.T) {
|
||||
t.Parallel()
|
||||
aMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
aAffection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
bMachine, err := NewMachine(Params{InitialState: Calm, DecayRate: 0.5})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
bAffection, err := NewAffection(50, 0, 700)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAffection: %v", err)
|
||||
}
|
||||
mgr := NewManager()
|
||||
mgr.Set("a", aMachine, aAffection)
|
||||
mgr.Set("b", bMachine, bAffection)
|
||||
obs := NewObserver(mgr, func(string) int { return 10 }, nil)
|
||||
|
||||
if got := obs.Observe("a", "我爱你", true); got != Happy {
|
||||
t.Fatalf("persona a mood = %s, want happy", got)
|
||||
}
|
||||
if bMachine.Current() != Calm || bAffection.Value() != 50 {
|
||||
t.Fatalf("persona b = %s/%d, want calm/50 untouched", bMachine.Current(), bAffection.Value())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package emotion
|
||||
|
||||
import "strings"
|
||||
|
||||
// Reaction is one turn's emotional reading of the user's message (design/01 §2:
|
||||
// the persona config carries the emotion state-machine parameters and the
|
||||
// affection curve; the machine itself only knows states and legal moves).
|
||||
//
|
||||
// design/01 fixes the model (states + transitions + decay + affection curve) but
|
||||
// does not enumerate the trigger vocabulary, so the minimal reading implemented
|
||||
// here is: a clearly angry turn, a clearly down turn, a strongly positive turn
|
||||
// and a mildly positive turn, each moving the matching state and stepping the
|
||||
// affection curve once. A turn with no cue carries no signal, which leaves the
|
||||
// emotion to decay toward calm instead of jumping (see Observer.Observe).
|
||||
type Reaction struct {
|
||||
// State is the emotion state this turn moves the machine toward. It is a
|
||||
// target, not a forced value: the machine walks its configured transition
|
||||
// table toward it (Machine.Move), so a persona's own table always wins.
|
||||
State EmotionState
|
||||
// AffectionDelta is the signed number of affection *steps* for this turn
|
||||
// (+1 warm, -1 cold, 0 neutral). The concrete magnitude is the persona's
|
||||
// configured affection curve step, applied by the caller.
|
||||
AffectionDelta int
|
||||
// Signal reports whether the turn carried any emotional cue at all.
|
||||
Signal bool
|
||||
}
|
||||
|
||||
// React classifies one user turn into the persona's emotional response. Anger
|
||||
// and negative cues win over positive ones, so a mixed message ("不开心" also
|
||||
// contains "开心") never reads as positive.
|
||||
func React(text string) Reaction {
|
||||
t := strings.ToLower(text)
|
||||
switch {
|
||||
case t == "":
|
||||
return Reaction{}
|
||||
case containsCue(t, angerCues()):
|
||||
return Reaction{State: Angry, AffectionDelta: affectionDown, Signal: true}
|
||||
case containsCue(t, negativeCues()):
|
||||
return Reaction{State: Sad, AffectionDelta: affectionDown, Signal: true}
|
||||
case containsCue(t, strongPositiveCues()):
|
||||
return Reaction{State: Excited, AffectionDelta: affectionUp, Signal: true}
|
||||
case containsCue(t, positiveCues()):
|
||||
return Reaction{State: Happy, AffectionDelta: affectionUp, Signal: true}
|
||||
default:
|
||||
return Reaction{}
|
||||
}
|
||||
}
|
||||
|
||||
// Affection step signs. The magnitude comes from the persona's own curve.
|
||||
const (
|
||||
affectionUp = 1
|
||||
affectionDown = -1
|
||||
)
|
||||
|
||||
// containsCue reports whether text contains any of the cues.
|
||||
func containsCue(text string, cues []string) bool {
|
||||
for _, c := range cues {
|
||||
if strings.Contains(text, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// angerCues are the protest/hostility cues that move the persona to angry. They
|
||||
// are checked first so an angry turn is never read as merely sad or happy.
|
||||
func angerCues() []string {
|
||||
return []string{
|
||||
"讨厌你", "烦你", "恨你", "别烦", "好烦", "烦死", "烦人", "生气", "气死", "气愤",
|
||||
"滚开", "走开", "闭嘴", "不理你", "分手", "凶我", "骂你",
|
||||
"angry", "annoying", "annoyed", "shut up", "hate you", "mad at",
|
||||
}
|
||||
}
|
||||
|
||||
// negativeCues are the down/low cues that move the persona to sad — including
|
||||
// tiredness, which in the five built-in states reads as the gentle/caring lane.
|
||||
func negativeCues() []string {
|
||||
return []string{
|
||||
"不开心", "很难过", "难过", "伤心", "委屈", "失望", "沮丧", "郁闷", "崩溃", "心累",
|
||||
"累了", "好累", "疲惫", "困了", "想哭", "呜呜", "555", "糟糕", "焦虑", "孤独",
|
||||
"孤单", "寂寞", "失眠", "睡不着", "被冷落",
|
||||
"sad", "tired", "upset", "exhausted", "lonely", "depressed", "feel bad",
|
||||
}
|
||||
}
|
||||
|
||||
// strongPositiveCues are the high-arousal warm cues that move the persona to
|
||||
// excited (a stronger state than happy).
|
||||
func strongPositiveCues() []string {
|
||||
return []string{
|
||||
"太开心", "好开心", "最喜欢", "太喜欢", "爱死", "太棒", "好耶", "好激动",
|
||||
"兴奋", "幸福死了", "amazing", "awesome", "so happy", "love you",
|
||||
}
|
||||
}
|
||||
|
||||
// positiveCues are the everyday warm cues that move the persona to happy.
|
||||
func positiveCues() []string {
|
||||
return []string{
|
||||
"开心", "高兴", "喜欢", "爱你", "想你", "抱抱", "么么", "亲亲", "摸摸", "谢谢",
|
||||
"辛苦", "哈哈", "嘿嘿", "嘻嘻", "真好", "厉害", "好棒", "期待", "满足", "幸福",
|
||||
"好呀", "好哒", "早安", "早上好", "中午好", "下午好", "晚上好", "晚安",
|
||||
"nice", "great", "thanks", "thank you", "haha", "hug", "miss you",
|
||||
"good morning", "good night", "good evening",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package emotion
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestReactClassifiesTurns locks the turn reading the observer applies: anger and
|
||||
// negative cues outrank positive ones, strong warmth outranks mild warmth, and a
|
||||
// cue-free turn carries no signal (so the machine is left to decay, not jump).
|
||||
func TestReactClassifiesTurns(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
state EmotionState
|
||||
delta int
|
||||
sig bool
|
||||
}{
|
||||
{name: "empty", text: "", sig: false},
|
||||
{name: "plain task", text: "帮我把这个文件重命名一下", sig: false},
|
||||
{name: "mild warm", text: "今天谢谢你呀", state: Happy, delta: affectionUp, sig: true},
|
||||
{name: "greeting", text: "早上好", state: Happy, delta: affectionUp, sig: true},
|
||||
{name: "strong warm", text: "我太开心了,最喜欢你了", state: Excited, delta: affectionUp, sig: true},
|
||||
{name: "down", text: "今天有点难过", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "tired", text: "我好累", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "angry", text: "别烦我了", state: Angry, delta: affectionDown, sig: true},
|
||||
// "不开心" contains "开心": the negative reading must win, otherwise a
|
||||
// complaint would raise the mood.
|
||||
{name: "negated positive", text: "我不开心", state: Sad, delta: affectionDown, sig: true},
|
||||
{name: "ascii warm", text: "thanks, miss you", state: Happy, delta: affectionUp, sig: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := React(tc.text)
|
||||
if got.Signal != tc.sig {
|
||||
t.Fatalf("React(%q).Signal = %v, want %v", tc.text, got.Signal, tc.sig)
|
||||
}
|
||||
if !tc.sig {
|
||||
return
|
||||
}
|
||||
if got.State != tc.state || got.AffectionDelta != tc.delta {
|
||||
t.Fatalf("React(%q) = {%s, %d}, want {%s, %d}", tc.text, got.State, got.AffectionDelta, tc.state, tc.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMachineMoveWalksTransitions verifies Move honors the installed transition
|
||||
// table instead of forcing a state: an installed edge is taken directly, and an
|
||||
// emotion two edges away is reached through the legal intermediate (the default
|
||||
// table has no calm→angry or calm→excited edge).
|
||||
func TestMachineMoveWalksTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := NewMachine(Params{InitialState: Calm})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
|
||||
if got := m.Move(Happy); got != Happy {
|
||||
t.Fatalf("calm → happy = %s, want direct happy", got)
|
||||
}
|
||||
m.Set(Calm)
|
||||
if got := m.Move(Angry); got != Sad {
|
||||
t.Fatalf("calm → angry = %s, want the legal intermediate sad", got)
|
||||
}
|
||||
if got := m.Move(Angry); got != Angry {
|
||||
t.Fatalf("sad → angry = %s, want direct angry", got)
|
||||
}
|
||||
m.Set(Calm)
|
||||
if got := m.Move(Excited); got != Happy {
|
||||
t.Fatalf("calm → excited = %s, want the legal intermediate happy", got)
|
||||
}
|
||||
// A target with no legal path leaves the machine where it was.
|
||||
p, err := NewMachine(Params{
|
||||
InitialState: Calm,
|
||||
Transitions: map[EmotionState][]EmotionState{Calm: {Happy}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
if got := p.Move(Sad); got != Calm {
|
||||
t.Fatalf("unreachable move = %s, want calm unchanged", got)
|
||||
}
|
||||
if got := m.Move(""); got != m.Current() {
|
||||
t.Fatalf("empty target moved to %s, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMachineMoveHonorsCustomTable verifies a persona's own transition table wins:
|
||||
// a custom edge that skips the built-in intermediate is taken directly.
|
||||
func TestMachineMoveHonorsCustomTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := NewMachine(Params{
|
||||
InitialState: Calm,
|
||||
Transitions: map[EmotionState][]EmotionState{
|
||||
Calm: {Excited},
|
||||
Excited: {Calm},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMachine: %v", err)
|
||||
}
|
||||
if got := m.Move(Excited); got != Excited {
|
||||
t.Fatalf("custom calm → excited = %s, want direct excited", got)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,63 @@ func (m *Machine) ensure(from EmotionState) map[EmotionState]struct{} {
|
||||
return m.transitions[from]
|
||||
}
|
||||
|
||||
// Move aims the machine at target, walking the persona's installed transition
|
||||
// table. A direct edge is taken when present; otherwise the first hop of a
|
||||
// shortest legal path is taken, so a caller can aim at an emotion without
|
||||
// knowing the intermediate states. A target that is unreachable from the current
|
||||
// state, an empty target, or the current state itself leaves the machine
|
||||
// unchanged rather than forcing an illegal move. The resulting state is
|
||||
// returned.
|
||||
func (m *Machine) Move(target EmotionState) EmotionState {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if target == "" || target == m.current {
|
||||
return m.current
|
||||
}
|
||||
if m.allowed(m.current, target) {
|
||||
m.current = target
|
||||
return m.current
|
||||
}
|
||||
if hop, ok := m.firstHopTo(target); ok {
|
||||
m.current = hop
|
||||
}
|
||||
return m.current
|
||||
}
|
||||
|
||||
// firstHopTo returns the first state on a shortest transition path from the
|
||||
// current state to target, and whether such a path exists. Ties between equally
|
||||
// short paths are broken arbitrarily, which is invisible to callers: any first
|
||||
// hop on a shortest legal path moves the machine in the wanted direction.
|
||||
func (m *Machine) firstHopTo(target EmotionState) (EmotionState, bool) {
|
||||
type node struct {
|
||||
state EmotionState
|
||||
first EmotionState
|
||||
}
|
||||
queue := make([]node, 0, len(m.transitions[m.current]))
|
||||
for next := range m.transitions[m.current] {
|
||||
if next == target {
|
||||
return next, true
|
||||
}
|
||||
queue = append(queue, node{state: next, first: next})
|
||||
}
|
||||
seen := map[EmotionState]struct{}{m.current: {}}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
if _, ok := seen[cur.state]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cur.state] = struct{}{}
|
||||
for next := range m.transitions[cur.state] {
|
||||
if next == target {
|
||||
return cur.first, true
|
||||
}
|
||||
queue = append(queue, node{state: next, first: cur.first})
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Decay drifts the emotion one position toward calm whenever DecayRate is
|
||||
// positive; otherwise it leaves the current state unchanged. The new state is
|
||||
// returned.
|
||||
|
||||
@@ -132,3 +132,14 @@ func (p *Persona) NewAffection() (*emotion.Affection, error) {
|
||||
}
|
||||
return emotion.NewAffection(params.Initial, params.Min, params.Max)
|
||||
}
|
||||
|
||||
// AffectionStep returns the per-turn affection magnitude of this persona's
|
||||
// 好感度曲线 (design/01 §2) — the configured `affection.delta`, or the loader
|
||||
// default when the persona declares no curve. It is the magnitude applied once
|
||||
// per emotional turn; the sign comes from the turn's signal.
|
||||
func (p *Persona) AffectionStep() int {
|
||||
if p.Affection.Delta != 0 {
|
||||
return p.Affection.Delta
|
||||
}
|
||||
return defaultAffectionDelta
|
||||
}
|
||||
@@ -234,6 +234,16 @@ func (r *Registry) Emotions() *emotion.Manager {
|
||||
return r.emotions
|
||||
}
|
||||
|
||||
// AffectionStep returns the per-turn affection magnitude configured for the
|
||||
// persona under personaID (its 好感度曲线 delta, design/01 §2). It returns 0 for
|
||||
// an unknown persona, so a caller skips the adjustment instead of inventing one.
|
||||
func (r *Registry) AffectionStep(personaID string) int {
|
||||
if p, ok := r.Persona(personaID); ok {
|
||||
return p.AffectionStep()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CountPersonas returns the number of registered personas.
|
||||
func (r *Registry) CountPersonas() int {
|
||||
r.mu.RLock()
|
||||
|
||||
@@ -73,6 +73,10 @@ type Result struct {
|
||||
// ToolCallsExecuted is the number of tool calls the loop invoked, including
|
||||
// those that failed.
|
||||
ToolCallsExecuted int
|
||||
// Mood is the persona's mood label for this turn, as returned by the
|
||||
// OnTurnEnd hook ("" when no hook is wired). The persistence layer carries it
|
||||
// onto the assistant message (design/16 §3.3).
|
||||
Mood string
|
||||
}
|
||||
|
||||
// Loop is the default agent execution engine. It is configured once via New and
|
||||
@@ -80,32 +84,33 @@ type Result struct {
|
||||
// dispatcher, stores, recorder, tracer) are themselves concurrency-safe, which
|
||||
// they are by contract. Run keeps all loop state on the stack.
|
||||
type Loop struct {
|
||||
agent *agent.Agent
|
||||
tools *tool.Dispatcher
|
||||
capacity *capacity.Decider
|
||||
onCapacity func(capacity.Event)
|
||||
memory *memory.Store
|
||||
retriever *memory.Retriever
|
||||
working *memory.WorkingMemory
|
||||
extractor memory.Extractor
|
||||
checkpoint checkpointStore
|
||||
tracer *trace.Tracer
|
||||
flow *FlowPublisher
|
||||
guards *guardrail.Pipeline
|
||||
personaID string
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
maxRounds int
|
||||
toolExec string
|
||||
toolParallelism int
|
||||
hooks ToolLoopHooks
|
||||
onStream func(model.StreamEvent)
|
||||
onAudit func(AuditEvent)
|
||||
maxContextTokens int
|
||||
compactor Compactor
|
||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||
imageResolver ImageResolver
|
||||
log *slog.Logger
|
||||
agent *agent.Agent
|
||||
tools *tool.Dispatcher
|
||||
capacity *capacity.Decider
|
||||
onCapacity func(capacity.Event)
|
||||
memory *memory.Store
|
||||
retriever *memory.Retriever
|
||||
working *memory.WorkingMemory
|
||||
extractor memory.Extractor
|
||||
checkpoint checkpointStore
|
||||
tracer *trace.Tracer
|
||||
flow *FlowPublisher
|
||||
guards *guardrail.Pipeline
|
||||
personaID string
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
maxRounds int
|
||||
toolExec string
|
||||
toolParallelism int
|
||||
hooks ToolLoopHooks
|
||||
onStream func(model.StreamEvent)
|
||||
onAudit func(AuditEvent)
|
||||
onTurnEnd TurnEndHook
|
||||
maxContextTokens int
|
||||
compactor Compactor
|
||||
transformContext func(context.Context, []model.Message, int) []model.Message
|
||||
imageResolver ImageResolver
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Loop from opts, applying defaults. A non-positive MaxRounds
|
||||
@@ -153,6 +158,7 @@ func New(opts Options) *Loop {
|
||||
hooks: opts.Hooks,
|
||||
onStream: opts.OnStream,
|
||||
onAudit: opts.OnAudit,
|
||||
onTurnEnd: opts.OnTurnEnd,
|
||||
maxContextTokens: opts.MaxContextTokens,
|
||||
compactor: compactor,
|
||||
transformContext: opts.TransformContext,
|
||||
|
||||
@@ -43,6 +43,13 @@ type Options struct {
|
||||
// small "xx 工具在群聊被拒" note. Nil = the rejection is still fed back to the
|
||||
// model as a soft not_allowed result, just not recorded to an external sink.
|
||||
OnAudit func(AuditEvent)
|
||||
// OnTurnEnd, when non-nil, is called once per settled turn (a Run that
|
||||
// returned a Result) with the turn's user/assistant text and the persona the
|
||||
// loop is bound to, so a caller can advance state that follows the persona —
|
||||
// the emotion machine and affection curve of design/01 §2. Its return value
|
||||
// is recorded on Result.Mood and carried to the persisted assistant message.
|
||||
// Nil = the loop reports nothing and Result.Mood stays empty.
|
||||
OnTurnEnd TurnEndHook
|
||||
// Memory is the long-term memory store used to persist extracted items.
|
||||
Memory *memory.Store
|
||||
// Retriever injects relevant memories into the context, re-evaluated every
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
// invocation), and after each step the memory Extractor runs with errors logged
|
||||
// rather than fatal.
|
||||
//
|
||||
// When a turn settles with a Result, the OnTurnEnd hook is called once for that
|
||||
// turn (never per step) and its mood label is recorded on Result.Mood, so the
|
||||
// layers around the loop can advance persona state and tag the persisted reply.
|
||||
//
|
||||
// It returns the final answer, or a partial Result with StopReasonMaxRounds when
|
||||
// the budget is exhausted without a final answer. Step errors are wrapped with %w
|
||||
// so callers can classify them with errors.Is.
|
||||
@@ -81,6 +85,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
res.StopReason = outcome.stopReason
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, outcome.stopReason)
|
||||
l.extractMemory(ctx, msgs, step)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -100,6 +105,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
res.StopReason = StopReasonToolLoopTerminated
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, StopReasonToolLoopTerminated)
|
||||
l.extractMemory(ctx, msgs, step)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
@@ -109,5 +115,6 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
res.StopReason = StopReasonMaxRounds
|
||||
l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds)
|
||||
l.extractMemory(ctx, msgs, l.maxRounds)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the loop's turn boundary: the point where one user turn has
|
||||
// settled (a Run produced a Result) and the state that follows the persona —
|
||||
// emotion and affection, design/01 §2 — is advanced outside the loop. The loop
|
||||
// itself stays persona- and emotion-agnostic (design/16 §3.3: the engine
|
||||
// generates text; state and persistence belong to the layers around it), so it
|
||||
// only reports the turn and records the mood label the hook hands back.
|
||||
|
||||
// TurnEnd describes one settled turn to the loop's OnTurnEnd hook. It carries
|
||||
// everything an observer needs without the loop knowing what the observer does.
|
||||
type TurnEnd struct {
|
||||
// SessionID is the conversation the turn belonged to.
|
||||
SessionID string
|
||||
// PersonaID is the persona the loop is bound to (Options.PersonaID), so the
|
||||
// observer stays persona-scoped (design/01 §2).
|
||||
PersonaID string
|
||||
// UserText is the latest user message of the turn ("" when the turn carried
|
||||
// no user text, e.g. a resumed tool round).
|
||||
UserText string
|
||||
// AssistantText is the turn's final assistant text.
|
||||
AssistantText string
|
||||
// StopReason classifies how the turn ended.
|
||||
StopReason string
|
||||
// Steps is the number of model steps the turn took.
|
||||
Steps int
|
||||
// Env is the conversation's resolved isolation environment (owner / group /
|
||||
// stranger), so a hook can keep a restricted conversation read-only.
|
||||
Env SessionEnv
|
||||
}
|
||||
|
||||
// TurnEndHook observes a settled turn and returns the persona's mood label for
|
||||
// it ("" when the caller tracks no emotion). It runs once per settled turn — not
|
||||
// once per model step — so a multi-step tool turn applies its effect exactly
|
||||
// once. It must not block: it runs on the Run path after the answer is complete,
|
||||
// and its return value is carried on Result.Mood to the persistence layer.
|
||||
type TurnEndHook func(TurnEnd) string
|
||||
|
||||
// settleTurn reports a settled turn to the hook (when wired) and records the
|
||||
// returned mood label on the result, so the persisted assistant message carries
|
||||
// the turn's mood (design/16 §3.3). The conversation environment travels on ctx,
|
||||
// so a group/stranger turn reaches the hook as restricted.
|
||||
func (l *Loop) settleTurn(ctx context.Context, sessionID string, msgs []model.Message, res *Result) {
|
||||
if l.onTurnEnd == nil {
|
||||
return
|
||||
}
|
||||
res.Mood = l.onTurnEnd(TurnEnd{
|
||||
SessionID: sessionID,
|
||||
PersonaID: l.personaID,
|
||||
UserText: lastUserText(msgs),
|
||||
AssistantText: res.Text,
|
||||
StopReason: res.StopReason,
|
||||
Steps: res.Steps,
|
||||
Env: sessionEnvFrom(ctx),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// TestRunCallsOnTurnEndOnce locks the turn boundary: a settled turn is reported
|
||||
// exactly once — with the turn's own user text, the persona the loop is bound to
|
||||
// and the answer — and the label the hook returns is recorded on Result.Mood so
|
||||
// the persistence layer can tag the assistant message.
|
||||
func TestRunCallsOnTurnEndOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在的呀♪"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
var ends []TurnEnd
|
||||
l := New(Options{
|
||||
Agent: a,
|
||||
PersonaID: "cyrene",
|
||||
OnTurnEnd: func(end TurnEnd) string {
|
||||
ends = append(ends, end)
|
||||
return "happy"
|
||||
},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "今天谢谢你")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(ends) != 1 {
|
||||
t.Fatalf("OnTurnEnd calls = %d, want 1", len(ends))
|
||||
}
|
||||
got := ends[0]
|
||||
if got.SessionID != "web" || got.PersonaID != "cyrene" {
|
||||
t.Fatalf("TurnEnd = %+v, want session web / persona cyrene", got)
|
||||
}
|
||||
if got.UserText != "今天谢谢你" {
|
||||
t.Fatalf("TurnEnd.UserText = %q, want the user turn text", got.UserText)
|
||||
}
|
||||
if got.AssistantText != "在的呀♪" {
|
||||
t.Fatalf("TurnEnd.AssistantText = %q, want the final answer", got.AssistantText)
|
||||
}
|
||||
if got.StopReason != agent.StopReasonStop || got.Steps != 1 {
|
||||
t.Fatalf("TurnEnd stop/steps = %q/%d, want stop/1", got.StopReason, got.Steps)
|
||||
}
|
||||
if !got.Env.MemWritable {
|
||||
t.Fatalf("TurnEnd.Env = %+v, want the owner lane by default", got.Env)
|
||||
}
|
||||
if res.Mood != "happy" {
|
||||
t.Fatalf("Result.Mood = %q, want the hook's label", res.Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunPassesRestrictedEnvToTurnEnd locks that the conversation's isolation
|
||||
// environment reaches the hook, so a group/stranger turn can be kept read-only.
|
||||
func TestRunPassesRestrictedEnvToTurnEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
var got TurnEnd
|
||||
l := New(Options{
|
||||
Agent: a,
|
||||
OnTurnEnd: func(end TurnEnd) string {
|
||||
got = end
|
||||
return ""
|
||||
},
|
||||
})
|
||||
env := SessionEnv{ActorEnv: "group_member", MemWritable: false}
|
||||
ctx := WithSessionEnv(context.Background(), env)
|
||||
if _, err := l.Run(ctx, "sess-group", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !got.Env.Restricted() || got.Env != env {
|
||||
t.Fatalf("TurnEnd.Env = %+v, want the restricted env", got.Env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTurnEndOnceForMultiStepTurn locks "once per turn, not per step": a
|
||||
// tool-loop turn (model → tool call → final answer) reports its boundary once,
|
||||
// and the reported user text stays the turn's user message rather than a
|
||||
// fed-back tool result.
|
||||
func TestRunTurnEndOnceForMultiStepTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "Echoed!"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
||||
a := newTestAgent(t, ad, td)
|
||||
|
||||
var ends []TurnEnd
|
||||
l := New(Options{
|
||||
Agent: a,
|
||||
PersonaID: "cyrene",
|
||||
OnTurnEnd: func(end TurnEnd) string {
|
||||
ends = append(ends, end)
|
||||
return "calm"
|
||||
},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(ends) != 1 {
|
||||
t.Fatalf("OnTurnEnd calls = %d, want 1 for a 2-step turn", len(ends))
|
||||
}
|
||||
if ends[0].UserText != "echo this" {
|
||||
t.Fatalf("TurnEnd.UserText = %q, want the turn's user text", ends[0].UserText)
|
||||
}
|
||||
if ends[0].AssistantText != "Echoed!" || ends[0].Steps != 2 {
|
||||
t.Fatalf("TurnEnd = %+v, want the final answer at 2 steps", ends[0])
|
||||
}
|
||||
if res.Mood != "calm" {
|
||||
t.Fatalf("Result.Mood = %q, want calm", res.Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNoTurnEndOnError locks that a failed turn is not reported: nothing
|
||||
// settled, so persona state must not advance.
|
||||
func TestRunNoTurnEndOnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{err: errors.New("model down")}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
|
||||
calls := 0
|
||||
l := New(Options{
|
||||
Agent: a,
|
||||
OnTurnEnd: func(TurnEnd) string { calls++; return "happy" },
|
||||
})
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "我爱你")}); err == nil {
|
||||
t.Fatal("Run: want an error")
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("OnTurnEnd calls = %d, want 0 on a failed turn", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWithoutTurnEndHook locks the nil-tolerance: an unwired hook leaves the
|
||||
// mood empty and Run behaves exactly as before.
|
||||
func TestRunWithoutTurnEndHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "hi"}}}}
|
||||
l := New(Options{Agent: newTestAgent(t, ad, nil)})
|
||||
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Mood != "" {
|
||||
t.Fatalf("Result.Mood = %q, want empty without a hook", res.Mood)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// This file holds the per-account allow/deny black-or-white lists (design/16 §4.6
|
||||
// §6) enforced at the head of the inbound pipeline, plus the config readers the
|
||||
// list matching depends on.
|
||||
|
||||
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
|
||||
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
|
||||
// against allowGroups/denyGroups; direct messages are matched by the sender's user
|
||||
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
|
||||
// deny list applies); a non-empty allow list becomes a white list. The owner is
|
||||
// never blacklisted (the caller skips this for owner messages).
|
||||
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
|
||||
if a == nil {
|
||||
return false, ""
|
||||
}
|
||||
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
|
||||
if msg.IsGroup() {
|
||||
if sliceContains(denyGroups, msg.GroupID) {
|
||||
return true, "group_denied"
|
||||
}
|
||||
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
|
||||
return true, "group_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
|
||||
if sliceContains(denyUsers, uid) {
|
||||
return true, "user_denied"
|
||||
}
|
||||
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
|
||||
return true, "user_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
|
||||
// The lists are stored as plain []string values (set by the multi-account wiring);
|
||||
// an absent key yields an empty list.
|
||||
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
|
||||
if cfg == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
|
||||
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
|
||||
}
|
||||
|
||||
// cfgStrings reads a []string field from an adapter config map, returning nil when
|
||||
// absent or of a different type.
|
||||
func cfgStrings(m map[string]any, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,9 @@ package channelbridge
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -257,560 +255,3 @@ func New(opts Options) (*Bridge, error) {
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Add registers an adapter by its Name. It is safe to call before Start; adding
|
||||
// after Start has no effect on already-running adapters.
|
||||
func (b *Bridge) Add(a adapter.PlatformAdapter) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.adapters[a.Name()] = a
|
||||
}
|
||||
|
||||
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
|
||||
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
|
||||
// path from the adapter's config. The gateway mounts these to receive external
|
||||
// webhook callbacks into the runtime. A nil map is returned when no adapter
|
||||
// provides an inbound handler.
|
||||
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make(map[string]http.Handler)
|
||||
for _, a := range b.adapters {
|
||||
wh, ok := a.(webhookAdapter)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := wh.WebhookPath()
|
||||
if path == "" {
|
||||
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
|
||||
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
|
||||
continue
|
||||
}
|
||||
h := wh.InboundHandler()
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
out[path] = h
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start connects every configured adapter, installs the inbound handler, and
|
||||
// launches the read loops. Adapters without credentials (or whose Connect fails)
|
||||
// are logged and left disconnected; they never fail startup. Start blocks only
|
||||
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
|
||||
// is idempotent: a second call is a no-op.
|
||||
func (b *Bridge) Start() {
|
||||
b.mu.Lock()
|
||||
if b.closed || b.started {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.ctx, b.cancel = context.WithCancel(context.Background())
|
||||
b.started = true
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, a := range b.snapshot() {
|
||||
b.connectAndRun(a)
|
||||
}
|
||||
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
|
||||
"registered", len(b.snapshot()))
|
||||
}
|
||||
|
||||
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
|
||||
// message processing to finish. It is idempotent.
|
||||
func (b *Bridge) Stop(ctx context.Context) {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.closed = true
|
||||
cancel := b.cancel
|
||||
b.mu.Unlock()
|
||||
|
||||
cancel()
|
||||
for name, a := range b.snapshot() {
|
||||
if err := a.Disconnect(ctx); err != nil {
|
||||
b.log.Warn("channel disconnect", "channel", name, "error", err)
|
||||
}
|
||||
}
|
||||
b.wg.Wait()
|
||||
b.log.Info("channel bridge stopped")
|
||||
}
|
||||
|
||||
// IsConnected reports whether the named adapter is currently connected.
|
||||
func (b *Bridge) IsConnected(name string) bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.connected[name]
|
||||
}
|
||||
|
||||
// ConnectedCount reports how many adapters are currently connected.
|
||||
func (b *Bridge) ConnectedCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
n := 0
|
||||
for _, ok := range b.connected {
|
||||
if ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the registered adapters for a lock-free iteration.
|
||||
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
|
||||
for _, a := range b.adapters {
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// configured reports whether the adapter carries enough configuration to attempt
|
||||
// a Connect. It inspects the adapter's own Config() snapshot for the credential
|
||||
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
|
||||
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
|
||||
// when no bot token is present.
|
||||
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||
cfg := a.Config()
|
||||
switch basePlatform(a.Name()) {
|
||||
case "telegram", "weixin-ilink", "kook":
|
||||
return cfgStr(cfg, "bot_token") != ""
|
||||
case "qq-obv11":
|
||||
if cfgStr(cfg, "mode") == "client" {
|
||||
return cfgStr(cfg, "remoteURL") != ""
|
||||
}
|
||||
return cfgStr(cfg, "listenAddr") != ""
|
||||
case "webhook":
|
||||
// Inbound-only: no external credential is required to hold the session.
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// basePlatform returns the bare platform name of a (possibly multi-account)
|
||||
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
|
||||
// reply-routing fallback treat every account of a platform identically.
|
||||
func basePlatform(name string) string {
|
||||
if base, _, ok := strings.Cut(name, ":"); ok {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
|
||||
// for the adapter that received a message, or "" when a is nil. The single-account
|
||||
// convenience path (process) and a platform with no registered adapter can pass a nil
|
||||
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
|
||||
// dereferencing a nil adapter.
|
||||
func adapterName(a adapter.PlatformAdapter) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.Name()
|
||||
}
|
||||
|
||||
// readLoop runs an adapter's blocking read path until the bridge stops or the
|
||||
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
|
||||
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
|
||||
// deliver through the same pipeline as push-style adapters. Message processing
|
||||
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
|
||||
// messages alone.
|
||||
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
|
||||
defer b.wg.Done()
|
||||
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
|
||||
b.onMessage(b.ctx, msg, a)
|
||||
})
|
||||
}
|
||||
|
||||
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
|
||||
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
|
||||
// The adapter is carried explicitly so the pipeline knows which account received
|
||||
// the message (multi-account reply routing + per-account allow/deny, design/16
|
||||
// §4.6 §5).
|
||||
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if err := msg.Validate(); err != nil {
|
||||
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
|
||||
// messages bypass it entirely (the owner is never throttled or pruned); every
|
||||
// other message passes a sliding-window rate limit (策略三) and the bounded
|
||||
// pending set whose saturation triggers the review model to prune noise (策略四).
|
||||
// Dropped messages are audited through the gate's OnPruned hook (wired to
|
||||
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if !actor.IsOwner() {
|
||||
verdict := b.gateFor(a).Admit(ctx, msg)
|
||||
if !verdict.Proceed {
|
||||
b.log.Warn("inbound message dropped by flow gate",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Add the worker under the same lock that Stop uses to set closed, so a
|
||||
// concurrent Stop either sees our Add before it waits, or we observe closed
|
||||
// and bail without leaking a goroutine.
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
<-b.sem
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
b.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer b.wg.Done()
|
||||
defer func() { <-b.sem }()
|
||||
b.processFrom(ctx, msg, a)
|
||||
}()
|
||||
}
|
||||
|
||||
// process runs the inbound pipeline using the adapter whose platform matches the
|
||||
// message (single-account convenience path; the adapter may be a bare
|
||||
// platform-named registration).
|
||||
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.processFrom(ctx, msg, b.adapter(msg.Platform))
|
||||
}
|
||||
|
||||
// processFrom runs the full inbound pipeline against the named account adapter:
|
||||
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
|
||||
// agent loop with the chat text, and send the loop's reply back through the same
|
||||
// adapter that received the message. Identity is resolved structurally by the
|
||||
// session layer; the bridge never reads message content to decide who the sender
|
||||
// is (a project red line).
|
||||
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
b.log.Debug("inbound message has no chat text, skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID)
|
||||
return
|
||||
}
|
||||
|
||||
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
|
||||
// the pipeline, before a session is resolved or the loop is driven, so a
|
||||
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
|
||||
// owner is always allowed (structural, via the resolved actor).
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if actor.IsOwner() {
|
||||
// fall through: owner messages are never blacklisted
|
||||
} else if denied, reason := b.blacklisted(msg, a); denied {
|
||||
b.log.Warn("inbound message blacklisted, dropped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
|
||||
if b.onRejected != nil {
|
||||
b.onRejected(msg, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
|
||||
if err != nil {
|
||||
b.log.Warn("session resolve failed", "platform", msg.Platform,
|
||||
"channel", msg.ChannelID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.log.Debug("inbound → session",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
|
||||
// non-owner group/stranger message that the cheap light judgement deems not
|
||||
// worth a response is registered to its isolated session log and silenced — it
|
||||
// never drives the full agent loop. Owner messages are never gated here, so
|
||||
// owner-private chat and owner group @/high-value replies are unchanged.
|
||||
if !actor.IsOwner() {
|
||||
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
|
||||
b.registerSilenced(ctx, msg, actor, sess, a)
|
||||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "reason", reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
|
||||
// main web session (shared across platforms) and persisted to chat_messages with
|
||||
// the channel platform, so the Web client mirrors it; group chats and strangers
|
||||
// keep their isolated sess-xxx context and their own platform log.
|
||||
sessID := sess.SessionID
|
||||
isOwnerPrivate := !sess.IsGroup && sess.Owner
|
||||
platform := ""
|
||||
if isOwnerPrivate {
|
||||
sessID = history.MainSessionID
|
||||
platform = msg.Platform
|
||||
}
|
||||
|
||||
// The third-party boundary only applies in a restricted (group/stranger)
|
||||
// context: memory is read-only (MemWritable=false), and the sender name is
|
||||
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
|
||||
// resolved actor decides who is the owner vs a third party structurally — never
|
||||
// from prompt text.
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||||
|
||||
// Persist the inbound user message before the turn so it is durable even if the
|
||||
// channel reply fails. Owner-private messages land in the main web session and
|
||||
// are broadcast to the Web client; restricted messages land in their isolated
|
||||
// session (the adapter log page) and are broadcast as a `channel_persisted` event
|
||||
// (never into the main/`web` session or the web chat).
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleUser, text, platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
|
||||
}
|
||||
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
|
||||
if err != nil {
|
||||
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||
"session", sessID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
reply := strings.TrimSpace(res.Text)
|
||||
if reply == "" {
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
// Persist the assistant reply (owner-private → main session + message_persisted
|
||||
// broadcast; restricted → isolated log + channel_persisted broadcast) then
|
||||
// deliver it back to the channel.
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
|
||||
}
|
||||
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
||||
if errors.Is(err, adapter.ErrUnsupported) {
|
||||
b.log.Debug("channel does not support outbound reply, skipped",
|
||||
"platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildLoopMessages builds the model-visible message list for one inbound turn. It
|
||||
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
|
||||
// the model can see which platform and conversation the message came from (design/16
|
||||
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
|
||||
// a restricted (group/stranger) context it additionally prepends a single system
|
||||
// message that states the third-party identity boundary — "every IM non-admin
|
||||
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
|
||||
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
|
||||
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
|
||||
// untouched (a copy is built with the prefix), so persisted content stays consistent
|
||||
// with what the source platform delivered.
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
|
||||
msg := model.TextMessage(model.RoleUser, text)
|
||||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||||
if thirdParty {
|
||||
// The speaker is carried as structured metadata, NOT woven into the user text
|
||||
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
|
||||
msg.SenderName = senderName
|
||||
}
|
||||
|
||||
// origin system line: conversation origin + identity-trust rule, one per turn.
|
||||
origin := originSystemText(src, actor)
|
||||
|
||||
if !env.Restricted() {
|
||||
if origin != "" {
|
||||
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
|
||||
}
|
||||
return []model.Message{msg}
|
||||
}
|
||||
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
|
||||
var sys string
|
||||
switch {
|
||||
case origin != "" && thirdPartyBoundaryPrompt != "":
|
||||
sys = origin + "\n" + thirdPartyBoundaryPrompt
|
||||
case origin != "":
|
||||
sys = origin
|
||||
default:
|
||||
sys = thirdPartyBoundaryPrompt
|
||||
}
|
||||
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
|
||||
}
|
||||
|
||||
// sendReply delivers the loop's text back to the channel's source conversation
|
||||
// through the account adapter that received the message (multi-account safe,
|
||||
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
|
||||
// the adapter resolves the same peer the message came from, and it exits through the
|
||||
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
|
||||
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
|
||||
if a == nil {
|
||||
a = b.adapter(in.Platform)
|
||||
}
|
||||
if a == nil {
|
||||
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||
}
|
||||
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
|
||||
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
|
||||
}, text)
|
||||
}
|
||||
|
||||
// adapter returns the registered adapter for a platform name, or nil.
|
||||
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.adapters[platform]
|
||||
}
|
||||
|
||||
// cfgStr reads a string field from an adapter config map, returning "" when
|
||||
// absent.
|
||||
func cfgStr(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := m[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deviceIDFor builds the client deviceID tag for an inbound IM message
|
||||
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
|
||||
// explicit deviceId field yet, so the local config id and the user platform uid are
|
||||
// taken from the normalized channel binding: ChannelID stands in for the local
|
||||
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
|
||||
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
|
||||
//
|
||||
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
|
||||
// bridge gains a local config id), replace the ChannelID stand-in here and return
|
||||
// that value directly.
|
||||
func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
if msg.SenderID != "" {
|
||||
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
|
||||
}
|
||||
return msg.Platform + ":" + msg.ChannelID
|
||||
}
|
||||
|
||||
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
|
||||
// (group-chat / stranger) context so the model knows the identity boundary. It is
|
||||
// only injected when the session is structurally restricted (design/16 §4.6 §1);
|
||||
// the LLM never decides who is a third party — that was already resolved from the
|
||||
// actor metadata before this message exists.
|
||||
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
|
||||
|
||||
// senderDisplayName returns the human-readable sender name used to label a
|
||||
// third-party message. It prefers the resolved actor's display name (which the
|
||||
// channel layer derived from channel metadata + the raw SenderName), falling back
|
||||
// to the message's own SenderName, so an empty SenderName always means "the
|
||||
// owner/unlabeled" rather than a nickname that resolved to nothing.
|
||||
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
|
||||
if actor.DisplayName != "" {
|
||||
return actor.DisplayName
|
||||
}
|
||||
return msg.SenderName
|
||||
}
|
||||
|
||||
// blacklisted applies the per-account allow/deny black-or-white lists (design/16
|
||||
// §4.6 §6) to a non-owner inbound message. Group messages are matched by GroupID
|
||||
// against allowGroups/denyGroups; direct messages are matched by the sender's user
|
||||
// id against allowUsers/denyUsers. An empty allow list means "allow all" (only the
|
||||
// deny list applies); a non-empty allow list becomes a white list. The owner is
|
||||
// never blacklisted (the caller skips this for owner messages).
|
||||
func (b *Bridge) blacklisted(msg adapter.UnifiedMessage, a adapter.PlatformAdapter) (bool, string) {
|
||||
if a == nil {
|
||||
return false, ""
|
||||
}
|
||||
allowGroups, denyGroups, allowUsers, denyUsers := channelLists(a.Config())
|
||||
if msg.IsGroup() {
|
||||
if sliceContains(denyGroups, msg.GroupID) {
|
||||
return true, "group_denied"
|
||||
}
|
||||
if len(allowGroups) > 0 && !sliceContains(allowGroups, msg.GroupID) {
|
||||
return true, "group_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
uid := firstNonEmptyStr(msg.SenderID, msg.Sender.UserID)
|
||||
if sliceContains(denyUsers, uid) {
|
||||
return true, "user_denied"
|
||||
}
|
||||
if len(allowUsers) > 0 && !sliceContains(allowUsers, uid) {
|
||||
return true, "user_not_allowed"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// channelLists reads the allow/deny string lists off an adapter's Config snapshot.
|
||||
// The lists are stored as plain []string values (set by the multi-account wiring);
|
||||
// an absent key yields an empty list.
|
||||
func channelLists(cfg map[string]any) (allowGroups, denyGroups, allowUsers, denyUsers []string) {
|
||||
if cfg == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
return cfgStrings(cfg, "allowGroups"), cfgStrings(cfg, "denyGroups"),
|
||||
cfgStrings(cfg, "allowUsers"), cfgStrings(cfg, "denyUsers")
|
||||
}
|
||||
|
||||
// cfgStrings reads a []string field from an adapter config map, returning nil when
|
||||
// absent or of a different type.
|
||||
func cfgStrings(m map[string]any, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sliceContains reports whether s is present in list.
|
||||
func sliceContains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstNonEmptyStr returns a when non-empty, otherwise b.
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// This file holds the adapter registry and the connect lifecycle of the channel
|
||||
// bridge (design/08 §0): adapters are registered through Add, dialed by Start
|
||||
// (each within connectTimeout), torn down by Stop, and skipped by the credential
|
||||
// gate when they carry no usable configuration.
|
||||
|
||||
// Add registers an adapter by its Name. It is safe to call before Start; adding
|
||||
// after Start has no effect on already-running adapters.
|
||||
func (b *Bridge) Add(a adapter.PlatformAdapter) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.adapters[a.Name()] = a
|
||||
}
|
||||
|
||||
// WebhookHandlers returns the inbound HTTP handlers of every registered adapter
|
||||
// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint
|
||||
// path from the adapter's config. The gateway mounts these to receive external
|
||||
// webhook callbacks into the runtime. A nil map is returned when no adapter
|
||||
// provides an inbound handler.
|
||||
func (b *Bridge) WebhookHandlers() map[string]http.Handler {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make(map[string]http.Handler)
|
||||
for _, a := range b.adapters {
|
||||
wh, ok := a.(webhookAdapter)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := wh.WebhookPath()
|
||||
if path == "" {
|
||||
// Adapter is not currently exposing a webhook (e.g. a websocket-mode
|
||||
// KOOK); skipping avoids mounting a handler at a stale or colliding route.
|
||||
continue
|
||||
}
|
||||
h := wh.InboundHandler()
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
out[path] = h
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start connects every configured adapter, installs the inbound handler, and
|
||||
// launches the read loops. Adapters without credentials (or whose Connect fails)
|
||||
// are logged and left disconnected; they never fail startup. Start blocks only
|
||||
// long enough to connect (bounded by connectTimeout per adapter) and returns. It
|
||||
// is idempotent: a second call is a no-op.
|
||||
func (b *Bridge) Start() {
|
||||
b.mu.Lock()
|
||||
if b.closed || b.started {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.ctx, b.cancel = context.WithCancel(context.Background())
|
||||
b.started = true
|
||||
b.mu.Unlock()
|
||||
|
||||
for _, a := range b.snapshot() {
|
||||
b.connectAndRun(a)
|
||||
}
|
||||
b.log.Info("channel bridge started", "connected", b.ConnectedCount(),
|
||||
"registered", len(b.snapshot()))
|
||||
}
|
||||
|
||||
// Stop cancels the read loops, disconnects every adapter and waits for in-flight
|
||||
// message processing to finish. It is idempotent.
|
||||
func (b *Bridge) Stop(ctx context.Context) {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.closed = true
|
||||
cancel := b.cancel
|
||||
b.mu.Unlock()
|
||||
|
||||
cancel()
|
||||
for name, a := range b.snapshot() {
|
||||
if err := a.Disconnect(ctx); err != nil {
|
||||
b.log.Warn("channel disconnect", "channel", name, "error", err)
|
||||
}
|
||||
}
|
||||
b.wg.Wait()
|
||||
b.log.Info("channel bridge stopped")
|
||||
}
|
||||
|
||||
// IsConnected reports whether the named adapter is currently connected.
|
||||
func (b *Bridge) IsConnected(name string) bool {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.connected[name]
|
||||
}
|
||||
|
||||
// ConnectedCount reports how many adapters are currently connected.
|
||||
func (b *Bridge) ConnectedCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
n := 0
|
||||
for _, ok := range b.connected {
|
||||
if ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// snapshot returns a copy of the registered adapters for a lock-free iteration.
|
||||
func (b *Bridge) snapshot() []adapter.PlatformAdapter {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
out := make([]adapter.PlatformAdapter, 0, len(b.adapters))
|
||||
for _, a := range b.adapters {
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// configured reports whether the adapter carries enough configuration to attempt
|
||||
// a Connect. It inspects the adapter's own Config() snapshot for the credential
|
||||
// each platform needs, so a bare (empty) adapter is skipped rather than dialed.
|
||||
// This is what keeps weixin-ilink from falling into its blocking QR-login flow
|
||||
// when no bot token is present.
|
||||
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||
cfg := a.Config()
|
||||
switch basePlatform(a.Name()) {
|
||||
case "telegram", "weixin-ilink", "kook":
|
||||
return cfgStr(cfg, "bot_token") != ""
|
||||
case "qq-obv11":
|
||||
if cfgStr(cfg, "mode") == "client" {
|
||||
return cfgStr(cfg, "remoteURL") != ""
|
||||
}
|
||||
return cfgStr(cfg, "listenAddr") != ""
|
||||
case "webhook":
|
||||
// Inbound-only: no external credential is required to hold the session.
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// cfgStr reads a string field from an adapter config map, returning "" when
|
||||
// absent.
|
||||
func cfgStr(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := m[key].(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// adapter returns the registered adapter for a platform name, or nil.
|
||||
func (b *Bridge) adapter(platform string) adapter.PlatformAdapter {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.adapters[platform]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// This file holds the small package-level helpers shared across the bridge files:
|
||||
// platform/account naming, the sender label, and generic slice readers.
|
||||
|
||||
// basePlatform returns the bare platform name of a (possibly multi-account)
|
||||
// adapter name like "telegram:conf0" → "telegram", so the credential gate and the
|
||||
// reply-routing fallback treat every account of a platform identically.
|
||||
func basePlatform(name string) string {
|
||||
if base, _, ok := strings.Cut(name, ":"); ok {
|
||||
return base
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// adapterName returns the account-adapter registration name (e.g. "kook:3bef...")
|
||||
// for the adapter that received a message, or "" when a is nil. The single-account
|
||||
// convenience path (process) and a platform with no registered adapter can pass a nil
|
||||
// adapter, so the persist hooks tolerate it and store an empty adapter_id rather than
|
||||
// dereferencing a nil adapter.
|
||||
func adapterName(a adapter.PlatformAdapter) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.Name()
|
||||
}
|
||||
|
||||
// senderDisplayName returns the human-readable sender name used to label a
|
||||
// third-party message. It prefers the resolved actor's display name (which the
|
||||
// channel layer derived from channel metadata + the raw SenderName), falling back
|
||||
// to the message's own SenderName, so an empty SenderName always means "the
|
||||
// owner/unlabeled" rather than a nickname that resolved to nothing.
|
||||
func senderDisplayName(msg adapter.UnifiedMessage, actor adapter.Actor) string {
|
||||
if actor.DisplayName != "" {
|
||||
return actor.DisplayName
|
||||
}
|
||||
return msg.SenderName
|
||||
}
|
||||
|
||||
// sliceContains reports whether s is present in list.
|
||||
func sliceContains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstNonEmptyStr returns a when non-empty, otherwise b.
|
||||
func firstNonEmptyStr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"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/runtime/model"
|
||||
)
|
||||
|
||||
// This file holds the inbound path of the channel bridge (design/08 §1-§2): the
|
||||
// per-adapter read loop, the bounded inbound callback, and the full
|
||||
// session→actor→loop→reply pipeline including the structural identity boundary.
|
||||
|
||||
// readLoop runs an adapter's blocking read path until the bridge stops or the
|
||||
// adapter's own context is cancelled (a hot reload). The emit sink is wired to
|
||||
// onMessage so poll-style adapters (Telegram getUpdates, weixin long poll)
|
||||
// deliver through the same pipeline as push-style adapters. Message processing
|
||||
// uses the bridge-lifetime context (not readCtx) so a reload leaves in-flight
|
||||
// messages alone.
|
||||
func (b *Bridge) readLoop(a adapter.PlatformAdapter, readCtx context.Context) {
|
||||
defer b.wg.Done()
|
||||
a.ReadMessages(readCtx, func(msg adapter.UnifiedMessage) {
|
||||
b.onMessage(b.ctx, msg, a)
|
||||
})
|
||||
}
|
||||
|
||||
// onMessage is the adapter-facing inbound callback. It runs the pipeline in a
|
||||
// bounded goroutine so the adapter's read loop never blocks on a slow agent turn.
|
||||
// The adapter is carried explicitly so the pipeline knows which account received
|
||||
// the message (multi-account reply routing + per-account allow/deny, design/16
|
||||
// §4.6 §5).
|
||||
func (b *Bridge) onMessage(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if err := msg.Validate(); err != nil {
|
||||
b.log.Warn("inbound message invalid, dropped", "platform", msg.Platform, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// design/12 §6 策略三/四: per-adapter minimal-disturbance gate. Owner-private
|
||||
// messages bypass it entirely (the owner is never throttled or pruned); every
|
||||
// other message passes a sliding-window rate limit (策略三) and the bounded
|
||||
// pending set whose saturation triggers the review model to prune noise (策略四).
|
||||
// Dropped messages are audited through the gate's OnPruned hook (wired to
|
||||
// OnRejected) and never reach the pipeline, so a flood cannot saturate the loop.
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if !actor.IsOwner() {
|
||||
verdict := b.gateFor(a).Admit(ctx, msg)
|
||||
if !verdict.Proceed {
|
||||
b.log.Warn("inbound message dropped by flow gate",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"reason", verdict.Reason, "dropped", len(verdict.Pruned))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
// Add the worker under the same lock that Stop uses to set closed, so a
|
||||
// concurrent Stop either sees our Add before it waits, or we observe closed
|
||||
// and bail without leaking a goroutine.
|
||||
b.mu.Lock()
|
||||
if b.closed {
|
||||
b.mu.Unlock()
|
||||
<-b.sem
|
||||
return
|
||||
}
|
||||
b.wg.Add(1)
|
||||
b.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer b.wg.Done()
|
||||
defer func() { <-b.sem }()
|
||||
b.processFrom(ctx, msg, a)
|
||||
}()
|
||||
}
|
||||
|
||||
// process runs the inbound pipeline using the adapter whose platform matches the
|
||||
// message (single-account convenience path; the adapter may be a bare
|
||||
// platform-named registration).
|
||||
func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
b.processFrom(ctx, msg, b.adapter(msg.Platform))
|
||||
}
|
||||
|
||||
// processFrom runs the full inbound pipeline against the named account adapter:
|
||||
// resolve the session/actor, apply the allow/deny black-or-white lists, drive the
|
||||
// agent loop with the chat text, and send the loop's reply back through the same
|
||||
// adapter that received the message. Identity is resolved structurally by the
|
||||
// session layer; the bridge never reads message content to decide who the sender
|
||||
// is (a project red line).
|
||||
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
b.log.Debug("inbound message has no chat text, skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID)
|
||||
return
|
||||
}
|
||||
|
||||
// design/16 §4.6 §6: the black-or-white lists are enforced at the very head of
|
||||
// the pipeline, before a session is resolved or the loop is driven, so a
|
||||
// blacklisted/non-whitelisted group or user message never reaches the LLM. The
|
||||
// owner is always allowed (structural, via the resolved actor).
|
||||
actor := session.ResolveActor(msg, b.owner)
|
||||
if actor.IsOwner() {
|
||||
// fall through: owner messages are never blacklisted
|
||||
} else if denied, reason := b.blacklisted(msg, a); denied {
|
||||
b.log.Warn("inbound message blacklisted, dropped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "reason", reason)
|
||||
if b.onRejected != nil {
|
||||
b.onRejected(msg, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
|
||||
if err != nil {
|
||||
b.log.Warn("session resolve failed", "platform", msg.Platform,
|
||||
"channel", msg.ChannelID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
b.log.Debug("inbound → session",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "actor_role", sess.ActorRole,
|
||||
"env", sess.Env(), "mem_writable", sess.MemWritable)
|
||||
|
||||
// design/12 §6 策略一/二/五: minimal-disturbance gate for non-owner messages. A
|
||||
// non-owner group/stranger message that the cheap light judgement deems not
|
||||
// worth a response is registered to its isolated session log and silenced — it
|
||||
// never drives the full agent loop. Owner messages are never gated here, so
|
||||
// owner-private chat and owner group @/high-value replies are unchanged.
|
||||
if !actor.IsOwner() {
|
||||
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
|
||||
b.registerSilenced(ctx, msg, actor, sess, a)
|
||||
b.log.Debug("non-owner message silenced (minimal disturbance)",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"session", sess.SessionID, "reason", reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// design/16 §4.4 + design/02 §3: an owner private chat is routed INTO the single
|
||||
// main web session (shared across platforms) and persisted to chat_messages with
|
||||
// the channel platform, so the Web client mirrors it; group chats and strangers
|
||||
// keep their isolated sess-xxx context and their own platform log.
|
||||
sessID := sess.SessionID
|
||||
isOwnerPrivate := !sess.IsGroup && sess.Owner
|
||||
platform := ""
|
||||
if isOwnerPrivate {
|
||||
sessID = history.MainSessionID
|
||||
platform = msg.Platform
|
||||
}
|
||||
|
||||
// The third-party boundary only applies in a restricted (group/stranger)
|
||||
// context: memory is read-only (MemWritable=false), and the sender name is
|
||||
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
|
||||
// resolved actor decides who is the owner vs a third party structurally — never
|
||||
// from prompt text.
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||||
|
||||
// Persist the inbound user message before the turn so it is durable even if the
|
||||
// channel reply fails. Owner-private messages land in the main web session and
|
||||
// are broadcast to the Web client; restricted messages land in their isolated
|
||||
// session (the adapter log page) and are broadcast as a `channel_persisted` event
|
||||
// (never into the main/`web` session or the web chat).
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleUser, text, "", platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleUser, text, deviceIDFor(msg), sess, msg.Platform, senderName, adapterName(a))
|
||||
}
|
||||
|
||||
res, err := b.run.Run(loop.WithSessionEnv(ctx, runEnv), sessID, loopMsgs)
|
||||
if err != nil {
|
||||
b.log.Warn("agent run failed", "platform", msg.Platform,
|
||||
"session", sessID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
reply := strings.TrimSpace(res.Text)
|
||||
if reply == "" {
|
||||
b.log.Debug("no reply produced", "platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
// Persist the assistant reply (owner-private → main session + message_persisted
|
||||
// broadcast; restricted → isolated log + channel_persisted broadcast) then
|
||||
// deliver it back to the channel.
|
||||
if isOwnerPrivate {
|
||||
b.persistToMain(ctx, history.RoleAssistant, reply, res.Mood, platform, deviceIDFor(msg), adapterName(a))
|
||||
} else {
|
||||
b.persistIsolated(ctx, history.RoleAssistant, reply, "", sess, msg.Platform, "", adapterName(a))
|
||||
}
|
||||
if err := b.sendReply(ctx, msg, a, reply); err != nil {
|
||||
if errors.Is(err, adapter.ErrUnsupported) {
|
||||
b.log.Debug("channel does not support outbound reply, skipped",
|
||||
"platform", msg.Platform, "session", sessID)
|
||||
return
|
||||
}
|
||||
b.log.Warn("send reply failed", "platform", msg.Platform, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceIDFor builds the client deviceID tag for an inbound IM message
|
||||
// (design/16 §4.5): <im平台名>:<本地配置唯一id>:<用户平台唯一uid>. UnifiedMessage has no
|
||||
// explicit deviceId field yet, so the local config id and the user platform uid are
|
||||
// taken from the normalized channel binding: ChannelID stands in for the local
|
||||
// adapter binding and SenderID is the owner's platform uid. When SenderID is absent
|
||||
// the tag degrades to <platform>:<channelID> so it stays non-empty and readable.
|
||||
//
|
||||
// Extension point: when an adapter/UnifiedMessage grows a concrete deviceId (or the
|
||||
// bridge gains a local config id), replace the ChannelID stand-in here and return
|
||||
// that value directly.
|
||||
func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
if msg.SenderID != "" {
|
||||
return msg.Platform + ":" + msg.ChannelID + ":" + msg.SenderID
|
||||
}
|
||||
return msg.Platform + ":" + msg.ChannelID
|
||||
}
|
||||
|
||||
// buildLoopMessages builds the model-visible message list for one inbound turn. It
|
||||
// decorates the user message with a "[来源: <platform> · <conversation>]" prefix so
|
||||
// the model can see which platform and conversation the message came from (design/16
|
||||
// §4.6.1); a pure Web main-session message (platform ""/"web") is left untouched. In
|
||||
// a restricted (group/stranger) context it additionally prepends a single system
|
||||
// message that states the third-party identity boundary — "every IM non-admin
|
||||
// account's message is a third party, unrelated to the owner" (design/16 §4.6 §1).
|
||||
// The boundary and the "[第三方 · 昵称]" marker are decided structurally from the
|
||||
// resolved SessionEnv/actor, never inferred by the LLM. The raw message text is left
|
||||
// untouched (a copy is built with the prefix), so persisted content stays consistent
|
||||
// with what the source platform delivered.
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
|
||||
msg := model.TextMessage(model.RoleUser, text)
|
||||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||||
if thirdParty {
|
||||
// The speaker is carried as structured metadata, NOT woven into the user text
|
||||
// (design/16 §4.6.1): a text tag is both ambiguous and a prompt-injection surface.
|
||||
msg.SenderName = senderName
|
||||
}
|
||||
|
||||
// origin system line: conversation origin + identity-trust rule, one per turn.
|
||||
origin := originSystemText(src, actor)
|
||||
|
||||
if !env.Restricted() {
|
||||
if origin != "" {
|
||||
return []model.Message{model.TextMessage(model.RoleSystem, origin), msg}
|
||||
}
|
||||
return []model.Message{msg}
|
||||
}
|
||||
// Restricted (group/stranger): origin (if IM) + the third-party identity boundary.
|
||||
var sys string
|
||||
switch {
|
||||
case origin != "" && thirdPartyBoundaryPrompt != "":
|
||||
sys = origin + "\n" + thirdPartyBoundaryPrompt
|
||||
case origin != "":
|
||||
sys = origin
|
||||
default:
|
||||
sys = thirdPartyBoundaryPrompt
|
||||
}
|
||||
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
|
||||
}
|
||||
|
||||
// thirdPartyBoundaryPrompt is the system message prepended to a restricted
|
||||
// (group-chat / stranger) context so the model knows the identity boundary. It is
|
||||
// only injected when the session is structurally restricted (design/16 §4.6 §1);
|
||||
// the LLM never decides who is a third party — that was already resolved from the
|
||||
// actor metadata before this message exists.
|
||||
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。只有未标注昵称的消息才是管理员(主人)发言。"
|
||||
@@ -0,0 +1,29 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// This file holds the outbound reply path (design/16 §4.6 §5): the loop's text is
|
||||
// delivered back to the source conversation through the same account adapter that
|
||||
// received the message.
|
||||
|
||||
// sendReply delivers the loop's text back to the channel's source conversation
|
||||
// through the account adapter that received the message (multi-account safe,
|
||||
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
|
||||
// the adapter resolves the same peer the message came from, and it exits through the
|
||||
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
|
||||
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
|
||||
if a == nil {
|
||||
a = b.adapter(in.Platform)
|
||||
}
|
||||
if a == nil {
|
||||
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
|
||||
}
|
||||
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
|
||||
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
|
||||
}, text)
|
||||
}
|
||||
@@ -57,14 +57,17 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
|
||||
// concrete client that sent the message (design/16 §4.5) — e.g. "weixin-ilink:conf0:wx_uid"
|
||||
// for IM. adapterID is the account adapter registration name that received the message
|
||||
// (e.g. "kook:3bef..."), so the admin log can query a per-adapter send/receive stream;
|
||||
// it is empty for Web-originated messages. It is a no-op when no history store is wired,
|
||||
// so the bridge runs unchanged without storage.
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, platform, deviceID, adapterID string) {
|
||||
// it is empty for Web-originated messages. mood is the persona's mood for that turn
|
||||
// (design/01 §2 情绪状态机), carried on the assistant reply only (design/16 §3.3: the
|
||||
// assistant message carries mode/mood); a user message and an untracked mood pass "".
|
||||
// It is a no-op when no history store is wired, so the bridge runs unchanged without
|
||||
// storage.
|
||||
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, platform, deviceID, adapterID string) {
|
||||
if b.history == nil {
|
||||
return
|
||||
}
|
||||
full, err := b.history.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID, Role: role, Content: content, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
|
||||
SessionID: history.MainSessionID, Role: role, Content: content, Mood: mood, Platform: platform, DeviceID: deviceID, AdapterID: adapterID, Mode: modeChat,
|
||||
})
|
||||
if err != nil {
|
||||
b.log.Warn("persist main session", "err", err, "role", role, "platform", platform, "device_id", deviceID, "adapter", adapterID)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
|
||||
// §4.4 + §4.6.1). Without this the model would not see what it proactively said, so
|
||||
// a follow-up owner reply would feel incoherent (the message "loses" its history).
|
||||
// It is the same mirror path as owner-private IM replies, scoped to MainSessionID.
|
||||
b.persistToMain(ctx, history.RoleAssistant, msg.Content, target.platform,
|
||||
b.persistToMain(ctx, history.RoleAssistant, msg.Content, "", target.platform,
|
||||
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
|
||||
target.platform)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"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/runtime/model"
|
||||
)
|
||||
|
||||
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
|
||||
// test can assert the mood the loop reported is what gets persisted.
|
||||
type moodRunner struct {
|
||||
mood string
|
||||
}
|
||||
|
||||
func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
||||
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
|
||||
// path: the mood the loop's turn-boundary hook reported lands on the persisted
|
||||
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
|
||||
// `message_persisted` and history replay carry real data instead of an always
|
||||
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
|
||||
// mood to the assistant reply).
|
||||
func TestOrchestratorPersistsTurnMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: moodRunner{mood: "happy"}.Run,
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
History: hist,
|
||||
})
|
||||
|
||||
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("谢谢你")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || res == nil {
|
||||
t.Fatalf("submit = %+v res=%v, want accepted with a result", sr, res)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
|
||||
}
|
||||
if recs[1].Role != history.RoleAssistant || recs[1].Mood != "happy" {
|
||||
t.Fatalf("assistant record = %+v, want Mood happy", recs[1])
|
||||
}
|
||||
wire := toHistoryMessage(recs[1])
|
||||
if wire.Mood != "happy" {
|
||||
t.Fatalf("wire mood = %q, want happy (message_persisted payload)", wire.Mood)
|
||||
}
|
||||
if recs[0].Mood != "" {
|
||||
t.Fatalf("user message mood = %q, want empty", recs[0].Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
|
||||
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
|
||||
// empty rather than being fabricated.
|
||||
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: moodRunner{mood: ""}.Run,
|
||||
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
History: hist,
|
||||
})
|
||||
if _, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv()); res == nil {
|
||||
t.Fatal("sync result is nil")
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("appends = %d, want 2", len(recs))
|
||||
}
|
||||
if recs[1].Mood != "" {
|
||||
t.Fatalf("assistant mood = %q, want empty", recs[1].Mood)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
|
||||
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
|
||||
// affection ride along as extra detail.
|
||||
func TestServerBroadcastMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
c := newFakeConn(env.srv.hub, 32)
|
||||
env.srv.hub.register(c)
|
||||
|
||||
env.srv.BroadcastMood("cyrene", "happy", 60)
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "mood" {
|
||||
t.Fatalf("frame type = %q, want mood (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok || data["mood"] != "happy" || data["personaId"] != "cyrene" {
|
||||
t.Fatalf("frame data = %+v, want {mood:happy,personaId:cyrene}", ev.Data)
|
||||
}
|
||||
if got, ok := data["affection"].(float64); !ok || got != 60 {
|
||||
t.Fatalf("frame affection = %v, want 60", data["affection"])
|
||||
}
|
||||
}
|
||||
@@ -681,6 +681,30 @@ func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool str
|
||||
})
|
||||
}
|
||||
|
||||
// wsMoodEvent is the WS event type carrying the persona's mood after a turn
|
||||
// (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing
|
||||
// `mood` handler (ws.ts) reads data.mood and shows the 心情 badge.
|
||||
const wsMoodEvent = "mood"
|
||||
|
||||
// BroadcastMood fans one emotion change to every Web client as a `mood` frame
|
||||
// (design/07 §3). It is called only when the mood actually changed — the
|
||||
// machinery in the composition root is the gate — so the badge does not flicker
|
||||
// with an unchanged value on every turn. data matches what the frontend `mood`
|
||||
// handler reads ({mood}); affection/personaId ride along as the honest extra
|
||||
// detail (affection is the persona's current 好感度, personaId the persona whose
|
||||
// mood moved) and are ignored by clients that do not render them. The mood is a
|
||||
// persona-level value, not a per-session one, so no sessionId is attached.
|
||||
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsMoodEvent,
|
||||
Data: map[string]any{
|
||||
"mood": mood,
|
||||
"personaId": personaID,
|
||||
"affection": affection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
|
||||
@@ -78,14 +78,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
|
||||
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
|
||||
// the reply with the client that sent the turn (design/16 §4.5) and mode is the
|
||||
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when
|
||||
// empty. It is a no-op when no history store is wired or the turn produced no text
|
||||
// (interrupted/errored turns leave res nil). The write runs under a fresh
|
||||
// empty. Mood is the persona's mood for that turn (design/01 §2 情绪状态机), taken
|
||||
// off the loop result, so the stored/DTO/`message_persisted` mood is real data
|
||||
// rather than a permanently empty column; a turn with no mood tracking stores an
|
||||
// empty mood. It is a no-op when no history store is wired or the turn produced no
|
||||
// text (interrupted/errored turns leave res nil). The write runs under a fresh
|
||||
// background context so a canceled turn context never aborts it.
|
||||
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) {
|
||||
if o.history == nil || res == nil || res.Text == "" {
|
||||
return
|
||||
}
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, DeviceID: deviceID, Mode: modeOrDefault(mode)})
|
||||
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode)})
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user