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 注释被转弯引号
247 lines
9.5 KiB
Go
247 lines
9.5 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
|
)
|
|
|
|
// proactiveTarget is the owner's IM address (platform + account id) resolved for
|
|
// a proactive push. The account id doubles as the DM peer on every adapter.
|
|
type proactiveTarget struct {
|
|
platform string
|
|
accountID string
|
|
}
|
|
|
|
// proactiveRecentCount is how many of the most recent main-session messages the
|
|
// proactive target selection scans to find "where the owner last talked", so a
|
|
// push lands on the channel she is actually using. Compile-time so no magic number
|
|
// appears in the run path.
|
|
const proactiveRecentCount = 20
|
|
|
|
// SendProactive pushes msg to the owner's IM channel via the registered adapter
|
|
// for that platform. It is the backthink IMAdapter seam (design/12 §3 fallback) and
|
|
// delivers to exactly ONE owner channel, chosen by the single-target selection in
|
|
// proactiveTarget: her explicit [渠道:...] choice first, then the platform she last
|
|
// used, then the whitelist default (rotating so it is not always the same channel).
|
|
// WeChat is a first-class candidate (it is whitelisted and, when connected, the
|
|
// recent-active/fallback selection may pick it).
|
|
//
|
|
// The push exits through the shared IM bubble dispatcher (sendIMBubbles), so a
|
|
// multi-paragraph message is split into bubbles, role-play/narration is stripped, and
|
|
// sends are spaced 0.5-2s apart — the same behaviour as a reply, and the reason
|
|
// WeChat/other IM proactive pushes no longer arrive as one un-split <p> blob.
|
|
//
|
|
// When the owner profile has no whitelisted account that is backed by a connected
|
|
// adapter it returns a clear error so the caller records the reason rather than
|
|
// silently dropping the push.
|
|
func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
target, ok := b.proactiveTarget(ctx, msg)
|
|
if !ok {
|
|
return fmt.Errorf("channelbridge: no connected owner IM channel for proactive push")
|
|
}
|
|
ad := b.adapterForPlatform(target.platform)
|
|
if ad == nil {
|
|
return fmt.Errorf("channelbridge: no adapter for owner platform %q", target.platform)
|
|
}
|
|
out := msg
|
|
out.Platform = target.platform
|
|
out.ChannelType = adapter.ChannelDirect
|
|
// The owner's account id is the DM peer on every adapter: telegram/qq read
|
|
// ChannelID, kook/weixin read SenderID (with ChannelID as the fallback). Setting
|
|
// both keeps a direct message targetable regardless of which field the adapter
|
|
// prefers, without duplicating the message.
|
|
out.ChannelID = target.accountID
|
|
out.SenderID = target.accountID
|
|
// The push exits through the shared IM bubble dispatcher, so a multi-paragraph
|
|
// proactive message is split into bubbles and role-play/narration is stripped, with
|
|
// the same 0.5-2s gap as a reply — WeChat proactive pushes are no longer sent as a
|
|
// single un-split message.
|
|
if err := b.sendIMBubbles(ctx, out, func(ctx context.Context, msg adapter.UnifiedMessage) error {
|
|
return ad.SendProactive(ctx, msg)
|
|
}, msg.Content); err != nil {
|
|
return fmt.Errorf("channelbridge: send proactive via %q: %w", target.platform, err)
|
|
}
|
|
// Mirror the proactive message into the main conversation context (design/16
|
|
// §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,
|
|
fmt.Sprintf("%s:%s:%s", target.platform, target.accountID, target.accountID),
|
|
target.platform)
|
|
return nil
|
|
}
|
|
|
|
// proactiveTarget resolves the single owner account a proactive push should reach.
|
|
// The range is every whitelisted owner channel that is backed by a connected
|
|
// adapter (WeChat included), and the choice follows the backthink priority:
|
|
//
|
|
// 1. A concrete msg.Platform (her [渠道:...] choice) that is whitelisted AND
|
|
// connected selects that channel.
|
|
// 2. The channel she most recently used in the main session (an owner/dialog
|
|
// message on a whitelisted, connected platform) is preferred.
|
|
// 3. Otherwise the platform that the whitelist default selects, rotating from a
|
|
// cursor so the fallback is not always the same channel (e.g. always kook).
|
|
//
|
|
// A nil owner or an empty whitelist yields !ok, as does a whitelist with no
|
|
// connected adapter.
|
|
func (b *Bridge) proactiveTarget(ctx context.Context, msg adapter.UnifiedMessage) (proactiveTarget, bool) {
|
|
accounts := b.ownerAccounts()
|
|
if len(accounts) == 0 {
|
|
return proactiveTarget{}, false
|
|
}
|
|
|
|
platform := strings.ToLower(strings.TrimSpace(msg.Platform))
|
|
if !isGenericProactivePlatform(platform) {
|
|
if acct, ok := matchOwnerAccount(accounts, platform); ok && b.hasConnectedAdapter(acct.Platform) {
|
|
return proactiveTarget{acct.Platform, acct.AccountID}, true
|
|
}
|
|
}
|
|
|
|
if acct, ok := b.recentActiveAccount(ctx, accounts); ok {
|
|
return proactiveTarget{acct.Platform, acct.AccountID}, true
|
|
}
|
|
|
|
if acct, ok := b.nextConnectedAccount(accounts); ok {
|
|
return proactiveTarget{acct.Platform, acct.AccountID}, true
|
|
}
|
|
|
|
return proactiveTarget{}, false
|
|
}
|
|
|
|
// ownerAccounts returns the owner's whitelisted accounts that carry an account id,
|
|
// in whitelist order. A nil owner or an empty whitelist yields nil.
|
|
func (b *Bridge) ownerAccounts() []user.OwnerAccount {
|
|
if b.owner == nil {
|
|
return nil
|
|
}
|
|
out := make([]user.OwnerAccount, 0, len(b.owner.Accounts))
|
|
for _, a := range b.owner.Accounts {
|
|
if a.AccountID != "" {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchOwnerAccount returns the owner account whose platform equals platform,
|
|
// case-insensitively.
|
|
func matchOwnerAccount(accounts []user.OwnerAccount, platform string) (user.OwnerAccount, bool) {
|
|
for _, a := range accounts {
|
|
if strings.EqualFold(a.Platform, platform) {
|
|
return a, true
|
|
}
|
|
}
|
|
return user.OwnerAccount{}, false
|
|
}
|
|
|
|
// recentActiveAccount returns the owner account for the most recent main-session
|
|
// message whose platform is a whitelisted, connected owner channel — the "she is
|
|
// active there" signal. A nil history store, a failed read or an empty tail yields
|
|
// ok=false so selection falls through to the default rather than guessing.
|
|
func (b *Bridge) recentActiveAccount(ctx context.Context, accounts []user.OwnerAccount) (user.OwnerAccount, bool) {
|
|
if b.history == nil {
|
|
return user.OwnerAccount{}, false
|
|
}
|
|
recent, err := b.history.Recent(ctx, history.MainSessionID, proactiveRecentCount)
|
|
if err != nil || len(recent) == 0 {
|
|
return user.OwnerAccount{}, false
|
|
}
|
|
// Recent is oldest-first; walk newest-first so the most recent channel wins.
|
|
for i := len(recent) - 1; i >= 0; i-- {
|
|
p := strings.ToLower(strings.TrimSpace(recent[i].Platform))
|
|
if p == "" || isGenericProactivePlatform(p) {
|
|
continue
|
|
}
|
|
if acct, ok := matchOwnerAccount(accounts, p); ok && b.hasConnectedAdapter(acct.Platform) {
|
|
return acct, true
|
|
}
|
|
}
|
|
return user.OwnerAccount{}, false
|
|
}
|
|
|
|
// nextConnectedAccount returns the next whitelisted owner account with a connected
|
|
// adapter, advancing a cursor so the no-signal fallback rotates instead of always
|
|
// landing on the first whitelisted channel (e.g. always kook). It holds the bridge
|
|
// lock to read the adapter map and update the cursor atomically.
|
|
func (b *Bridge) nextConnectedAccount(accounts []user.OwnerAccount) (user.OwnerAccount, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
n := len(accounts)
|
|
for i := 0; i < n; i++ {
|
|
idx := (b.proactiveCursor + i) % n
|
|
a := accounts[idx]
|
|
if b.hasConnectedAdapterLocked(a.Platform) {
|
|
b.proactiveCursor = (idx + 1) % n
|
|
return a, true
|
|
}
|
|
}
|
|
return user.OwnerAccount{}, false
|
|
}
|
|
|
|
// hasConnectedAdapter reports whether a registered adapter with this base platform
|
|
// is currently connected, so a channel is only ever selected when it can actually
|
|
// deliver a message.
|
|
func (b *Bridge) hasConnectedAdapter(platform string) bool {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return b.hasConnectedAdapterLocked(platform)
|
|
}
|
|
|
|
// hasConnectedAdapterLocked is hasConnectedAdapter without taking the lock; the
|
|
// caller must hold b.mu (read or write).
|
|
func (b *Bridge) hasConnectedAdapterLocked(platform string) bool {
|
|
for name, a := range b.adapters {
|
|
if strings.EqualFold(basePlatform(name), platform) && a.IsConnected() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isGenericProactivePlatform reports whether platform is a routing keyword rather
|
|
// than a concrete channel id. Only these fall through to the recent-active / default
|
|
// selection; a concrete id must resolve to a whitelisted, connected channel before
|
|
// it is honored, so an explicit channel choice is never silently replaced by a
|
|
// different channel.
|
|
func isGenericProactivePlatform(platform string) bool {
|
|
switch platform {
|
|
case "", "cyrene-backthink":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// adapterForPlatform returns a registered adapter whose base platform matches
|
|
// platform (multi-account names like "kook:3be..." resolve to their bare base),
|
|
// preferring a connected one so a dead account is only tried when it is the only
|
|
// option. It returns nil when none is registered.
|
|
func (b *Bridge) adapterForPlatform(platform string) adapter.PlatformAdapter {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
var first adapter.PlatformAdapter
|
|
for name, a := range b.adapters {
|
|
if !strings.EqualFold(basePlatform(name), platform) {
|
|
continue
|
|
}
|
|
if a.IsConnected() {
|
|
return a
|
|
}
|
|
if first == nil {
|
|
first = a
|
|
}
|
|
}
|
|
return first
|
|
}
|