添加注释

This commit is contained in:
ddshuai 2026-04-04 07:03:06 +08:00
parent e1e3598a62
commit 4b978714f2
4 changed files with 1210 additions and 316 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,17 @@
// Package bus 是 PicoClaw 的消息总线,负责连接 Agent 循环AgentLoop与渠道管理器Manager
//
// 消息总线维护三条带缓冲的 Go channel
// - inbound用户发送的消息流向 Agent 循环进行处理。
// - outboundAgent 生成的文本回复,流向渠道管理器再发送给用户。
// - outboundMediaAgent 生成的媒体消息(图片、文件等),流向渠道管理器。
//
// 消息流向:渠道 → inbound → AgentLoop → outbound/outboundMedia → Manager → 渠道
//
// 内部机制:
// - 所有 channel 使用 defaultBusBufferSize 大小的缓冲区,削峰填谷。
// - publish 使用 context 感知的三步安全检查,避免向已关闭 channel 发送数据。
// - Close 方法按照 安全关闭顺序close done → set closed → wait wg → close channels → drain
// 实现优雅关闭,确保不丢失已在缓冲区中的消息。
package bus package bus
import ( import (
@ -9,39 +23,54 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
// ErrBusClosed is returned when publishing to a closed MessageBus. // ErrBusClosed 当向已关闭的 MessageBus 发布消息时返回此错误。
var ErrBusClosed = errors.New("message bus closed") var ErrBusClosed = errors.New("message bus closed")
// defaultBusBufferSize 是每条消息通道的默认缓冲区大小,用于削峰填谷。
const defaultBusBufferSize = 64 const defaultBusBufferSize = 64
// StreamDelegate is implemented by the channel Manager to provide streaming // StreamDelegate 由渠道管理器Manager实现为 Agent 循环提供流式输出能力。
// capabilities to the agent loop without tight coupling. // 通过该接口将流式处理逻辑与 Agent 循环解耦,避免直接依赖具体渠道实现。
type StreamDelegate interface { type StreamDelegate interface {
// GetStreamer returns a Streamer for the given channel+chatID if the channel // GetStreamer 根据渠道名称和聊天 ID 返回对应的 Streamer 实例。
// supports streaming. Returns nil, false if streaming is unavailable. // 如果该渠道不支持流式输出,返回 nil, false。
GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool)
} }
// Streamer pushes incremental content to a streaming-capable channel. // Streamer 负责将增量内容推送到支持流式输出的渠道。
// Defined here so the agent loop can use it without importing pkg/channels. // 定义在此包中,使 Agent 循环可以使用流式输出而无需导入 pkg/channels。
type Streamer interface { type Streamer interface {
// Update 推送增量内容片段(流式中间结果)。
Update(ctx context.Context, content string) error Update(ctx context.Context, content string) error
// Finalize 推送最终完整内容,标识流式输出结束。
Finalize(ctx context.Context, content string) error Finalize(ctx context.Context, content string) error
// Cancel 取消当前流式输出会话。
Cancel(ctx context.Context) Cancel(ctx context.Context)
} }
// MessageBus 是消息总线的核心结构,管理三条消息通道及生命周期控制。
type MessageBus struct { type MessageBus struct {
// inbound 接收用户发来的消息由渠道侧写入Agent 循环侧读取。
inbound chan InboundMessage inbound chan InboundMessage
// outbound 承载 Agent 生成的文本回复,由 Agent 循环写入,渠道管理器读取。
outbound chan OutboundMessage outbound chan OutboundMessage
// outboundMedia 承载 Agent 生成的媒体消息(图片、文件等)。
outboundMedia chan OutboundMediaMessage outboundMedia chan OutboundMediaMessage
// closeOnce 确保 Close 操作只执行一次。
closeOnce sync.Once closeOnce sync.Once
// done 关闭后通知所有阻塞中的发布者退出。
done chan struct{} done chan struct{}
// closed 原子布尔标记,快速判断总线是否已关闭。
closed atomic.Bool closed atomic.Bool
// wg 跟踪正在进行中的 publish 调用,关闭时等待它们完成。
wg sync.WaitGroup wg sync.WaitGroup
streamDelegate atomic.Value // stores StreamDelegate // streamDelegate 以原子方式存储 StreamDelegate 实现(通常为渠道 Manager
streamDelegate atomic.Value
} }
// NewMessageBus 创建并返回一个新的 MessageBus 实例,
// 初始化三条带缓冲的消息通道和关闭信号通道。
func NewMessageBus() *MessageBus { func NewMessageBus() *MessageBus {
return &MessageBus{ return &MessageBus{
inbound: make(chan InboundMessage, defaultBusBufferSize), inbound: make(chan InboundMessage, defaultBusBufferSize),
@ -51,13 +80,18 @@ func NewMessageBus() *MessageBus {
} }
} }
// publish 是泛型消息发布函数,所有 PublishXxx 方法均委托给它。
// 执行三步安全检查以避免向已关闭 channel 发送数据:
// 1. 通过 atomic.Bool 快速检查 closed 标记,避免不必要的 wg.Add 和潜在死锁。
// 2. 通过 select 非阻塞检查 done 和 ctx.Done(),在真正发送前再次确认状态。
// 3. 在 wg 保护下执行实际的 channel 发送,确保 Close 会等待发送完成。
func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error { func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {
// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock // 第一步:快速检查总线是否已关闭,避免不必要的 wg.Add 及潜在死锁
if mb.closed.Load() { if mb.closed.Load() {
return ErrBusClosed return ErrBusClosed
} }
// check again,before sending message, to avoid sending to closed channel // 第二步:在发送消息前再次检查,避免向已关闭 channel 发送数据
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@ -66,6 +100,7 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
default: default:
} }
// 第三步:在 WaitGroup 保护下执行发送Close 方法会等待所有进行中的发送完成
mb.wg.Add(1) mb.wg.Add(1)
defer mb.wg.Done() defer mb.wg.Done()
@ -79,36 +114,44 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
} }
} }
// PublishInbound 将用户消息发布到 inbound 通道,供 Agent 循环消费。
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
return publish(ctx, mb, mb.inbound, msg) return publish(ctx, mb, mb.inbound, msg)
} }
// InboundChan 返回 inbound 通道的只读端,供 Agent 循环接收用户消息。
func (mb *MessageBus) InboundChan() <-chan InboundMessage { func (mb *MessageBus) InboundChan() <-chan InboundMessage {
return mb.inbound return mb.inbound
} }
// PublishOutbound 将 Agent 生成的文本回复发布到 outbound 通道,供渠道管理器消费。
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
return publish(ctx, mb, mb.outbound, msg) return publish(ctx, mb, mb.outbound, msg)
} }
// OutboundChan 返回 outbound 通道的只读端,供渠道管理器接收文本回复。
func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
return mb.outbound return mb.outbound
} }
// PublishOutboundMedia 将 Agent 生成的媒体消息发布到 outboundMedia 通道。
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
return publish(ctx, mb, mb.outboundMedia, msg) return publish(ctx, mb, mb.outboundMedia, msg)
} }
// OutboundMediaChan 返回 outboundMedia 通道的只读端,供渠道管理器接收媒体消息。
func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
return mb.outboundMedia return mb.outboundMedia
} }
// SetStreamDelegate registers a StreamDelegate (typically the channel Manager). // SetStreamDelegate 注册流式代理(通常为渠道管理器 Manager
// 使 Agent 循环可以通过消息总线获取 Streamer。
func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) {
mb.streamDelegate.Store(d) mb.streamDelegate.Store(d)
} }
// GetStreamer returns a Streamer for the given channel+chatID via the delegate. // GetStreamer 通过已注册的 StreamDelegate 获取指定渠道和聊天 ID 的 Streamer。
// 如果未注册代理或该渠道不支持流式输出,返回 nil, false。
func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) { func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) {
if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil { if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil {
return d.GetStreamer(ctx, channel, chatID) return d.GetStreamer(ctx, channel, chatID)
@ -116,24 +159,30 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (
return nil, false return nil, false
} }
// Close 优雅关闭消息总线,确保不丢失已缓冲的消息。
// 关闭顺序:
// 1. 关闭 done 通道 → 通知所有阻塞中的发布者退出。
// 2. 设置 closed 标记 → 阻止新的发布者进入。
// 3. 等待 wg → 确保所有进行中的 publish 调用完成。
// 4. 关闭三条消息通道 → 释放资源。
// 5. 排空drain通道中残留的消息 → 防止消息丢失。
func (mb *MessageBus) Close() { func (mb *MessageBus) Close() {
mb.closeOnce.Do(func() { mb.closeOnce.Do(func() {
// notify all blocked publishers to exit // 第一步:关闭 done 通道,通知所有阻塞在 select 上的发布者退出
close(mb.done) close(mb.done)
// because every publisher will check mb.closed before acquiring wg // 第二步:设置 closed 原子标记,确保新的发布者在 wg.Add 之前就能感知关闭状态
// so we can be sure that new publishers will not be added new messages after this point
mb.closed.Store(true) mb.closed.Store(true)
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited // 第三步:等待所有正在进行中的 publish 调用完成,确保消息已写入通道或已退出
mb.wg.Wait() mb.wg.Wait()
// close channels safely // 第四步:安全关闭三条消息通道
close(mb.inbound) close(mb.inbound)
close(mb.outbound) close(mb.outbound)
close(mb.outboundMedia) close(mb.outboundMedia)
// clean up any remaining messages in channels // 第五步:排空通道中残留的消息,防止消息丢失
drained := 0 drained := 0
for range mb.inbound { for range mb.inbound {
drained++ drained++

View file

@ -4,6 +4,34 @@
// //
// Copyright (c) 2026 PicoClaw contributors // Copyright (c) 2026 PicoClaw contributors
// Package channels 负责管理所有消息渠道Telegram/Discord/Slack/微信等)。
//
// # 核心架构
//
// Manager 是渠道管理的核心结构,负责渠道生命周期管理和消息分发。
//
// # 消息处理流水线
//
// 消息从生成到发送经过以下环节:
//
// bus.OutboundChan → dispatchOutbound → per-channel worker queue → runWorker → preSend → Send
//
// # 核心接口
//
// - Channel: 基础发送接口
// - MessageEditor: 编辑已发送的消息
// - PlaceholderCapable: 发送"思考中..."占位消息
// - StreamingCapable: 流式推送消息内容
// - WebhookHandler: 处理 Webhook 回调
//
// # 速率限制
//
// 每个渠道拥有独立的 rate.Limiter按渠道类型配置不同的速率限制如 telegram 20条/秒、discord 1条/秒)。
//
// # TTL 清理
//
// janitor 定时器定期清理过期的 typing/placeholder/reaction 条目,
// 防止在出站路径未能触发 preSend如 LLM 错误)时产生内存泄漏。
package channels package channels
import ( import (
@ -26,86 +54,90 @@ import (
) )
const ( const (
defaultChannelQueueSize = 16 defaultChannelQueueSize = 16 // 每个渠道 worker 的消息队列大小
defaultRateLimit = 10 // default 10 msg/s defaultRateLimit = 10 // 默认速率限制10条/秒)
maxRetries = 3 maxRetries = 3 // 发送失败最大重试次数
rateLimitDelay = 1 * time.Second rateLimitDelay = 1 * time.Second // 速率限制错误的固定延迟
baseBackoff = 500 * time.Millisecond baseBackoff = 500 * time.Millisecond // 指数退避的基础延迟
maxBackoff = 8 * time.Second maxBackoff = 8 * time.Second // 指数退避的最大延迟
janitorInterval = 10 * time.Second janitorInterval = 10 * time.Second // TTL 清理定时器的执行间隔
typingStopTTL = 5 * time.Minute typingStopTTL = 5 * time.Minute // typing 停止条目的过期时间
placeholderTTL = 10 * time.Minute placeholderTTL = 10 * time.Minute // 占位消息条目的过期时间
) )
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction. // typingEntry 封装 typing 停止函数及其创建时间,用于 TTL 过期清理。
type typingEntry struct { type typingEntry struct {
stop func() stop func()
createdAt time.Time createdAt time.Time
} }
// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction. // reactionEntry 封装 reaction 撤销函数及其创建时间,用于 TTL 过期清理。
type reactionEntry struct { type reactionEntry struct {
undo func() undo func()
createdAt time.Time createdAt time.Time
} }
// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction. // placeholderEntry 封装占位消息 ID 及其创建时间,用于 TTL 过期清理。
type placeholderEntry struct { type placeholderEntry struct {
id string id string
createdAt time.Time createdAt time.Time
} }
// channelRateConfig maps channel name to per-second rate limit. // channelRateConfig 各渠道的速率限制配置(每秒允许发送的消息数)。
// 未在此映射中配置的渠道使用 defaultRateLimit。
var channelRateConfig = map[string]float64{ var channelRateConfig = map[string]float64{
"telegram": 20, "telegram": 20, // Telegram: 20条/秒
"discord": 1, "discord": 1, // Discord: 1条/秒
"slack": 1, "slack": 1, // Slack: 1条/秒
"matrix": 2, "matrix": 2, // Matrix: 2条/秒
"line": 10, "line": 10, // LINE: 10条/秒
"qq": 5, "qq": 5, // QQ: 5条/秒
"irc": 2, "irc": 2, // IRC: 2条/秒
} }
// channelWorker 每个渠道对应一个 worker负责从消息队列中取出消息并发送。
// 包含独立的消息队列、媒体队列和速率限制器。
type channelWorker struct { type channelWorker struct {
ch Channel ch Channel // 渠道实例
queue chan bus.OutboundMessage queue chan bus.OutboundMessage // 文本消息队列
mediaQueue chan bus.OutboundMediaMessage mediaQueue chan bus.OutboundMediaMessage // 媒体消息队列
done chan struct{} done chan struct{} // 文本 worker 退出信号
mediaDone chan struct{} mediaDone chan struct{} // 媒体 worker 退出信号
limiter *rate.Limiter limiter *rate.Limiter // 速率限制器
} }
// Manager 是渠道管理的核心结构,管理所有渠道的生命周期、消息分发和状态跟踪。
type Manager struct { type Manager struct {
channels map[string]Channel channels map[string]Channel // 渠道名称 → Channel 实例
workers map[string]*channelWorker workers map[string]*channelWorker // 渠道名称 → worker 实例
bus *bus.MessageBus bus *bus.MessageBus // 消息总线,用于接收出站消息
config *config.Config config *config.Config // 全局配置
mediaStore media.MediaStore mediaStore media.MediaStore // 媒体存储
dispatchTask *asyncTask dispatchTask *asyncTask // 分发任务(持有 cancel 函数)
mux *dynamicServeMux mux *dynamicServeMux // 动态 HTTP 路由器
httpServer *http.Server httpServer *http.Server // 共享 HTTP 服务器
mu sync.RWMutex mu sync.RWMutex // 保护 channels/workers 等字段的读写锁
placeholders sync.Map // "channel:chatID" → placeholderID (string) placeholders sync.Map // "channel:chatID" → placeholderEntry占位消息 ID
typingStops sync.Map // "channel:chatID" → func() typingStops sync.Map // "channel:chatID" → typingEntrytyping 停止函数)
reactionUndos sync.Map // "channel:chatID" → reactionEntry reactionUndos sync.Map // "channel:chatID" → reactionEntryreaction 撤销函数)
streamActive sync.Map // "channel:chatID" → true (set when streamer.Finalize sent the message) streamActive sync.Map // "channel:chatID" → true流式推送完成标记
channelHashes map[string]string // channel name → config hash channelHashes map[string]string // 渠道名称 → 配置哈希(用于热重载时对比变更)
} }
type asyncTask struct { type asyncTask struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
// RecordPlaceholder registers a placeholder message for later editing. // RecordPlaceholder 记录占位消息 ID供后续 preSend 编辑或删除。
// Implements PlaceholderRecorder. // 实现 PlaceholderRecorder 接口。
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
key := channel + ":" + chatID key := channel + ":" + chatID
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
} }
// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID // SendPlaceholder 向指定渠道/聊天发送"思考中..."占位消息,并记录供后续编辑。
// and records it for later editing. Returns true if a placeholder was sent. // 如果渠道不支持 PlaceholderCapable 或发送失败,返回 false。
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
m.mu.RLock() m.mu.RLock()
ch, ok := m.channels[channel] ch, ok := m.channels[channel]
@ -125,8 +157,9 @@ func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) b
return true return true
} }
// RecordTypingStop registers a typing stop function for later invocation. // RecordTypingStop 记录 typing 停止函数,供后续 preSend 调用。
// Implements PlaceholderRecorder. // 如果已有旧的停止函数,会先调用它(确保前一个 typing 指示器被停止)。
// 实现 PlaceholderRecorder 接口。
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID key := channel + ":" + chatID
entry := typingEntry{stop: stop, createdAt: time.Now()} entry := typingEntry{stop: stop, createdAt: time.Now()}
@ -137,10 +170,10 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
} }
} }
// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID. // InvokeTypingStop 调用已注册的 typing 停止函数。
// It is safe to call even when no typing indicator is active (no-op). // 即使没有活跃的 typing 指示器也可安全调用(无操作)。
// Used by the agent loop to stop typing when processing completes (success, error, or panic), // 由 agent 循环在处理完成时调用(无论成功、错误或 panic
// regardless of whether an outbound message is published. // 确保无论是否发布出站消息都能停止 typing。
func (m *Manager) InvokeTypingStop(channel, chatID string) { func (m *Manager) InvokeTypingStop(channel, chatID string) {
key := channel + ":" + chatID key := channel + ":" + chatID
if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
@ -150,55 +183,60 @@ func (m *Manager) InvokeTypingStop(channel, chatID string) {
} }
} }
// RecordReactionUndo registers a reaction undo function for later invocation. // RecordReactionUndo 记录 reaction 撤销函数,供后续 preSend 调用。
// Implements PlaceholderRecorder. // 实现 PlaceholderRecorder 接口。
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
key := channel + ":" + chatID key := channel + ":" + chatID
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
} }
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // preSend 在发送消息前执行预处理:
// Returns true if the message was already delivered (skip Send). // 1. 停止 typing 指示器
// 2. 撤销 reaction
// 3. 检查流式推送是否已完成streamActive若已完成则删除占位消息并跳过 Send
// 4. 尝试编辑占位消息(将"思考中..."替换为实际内容),编辑成功则跳过 Send
//
// 返回 true 表示消息已通过编辑/流式方式投递,应跳过后续的 Send 调用。
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
key := name + ":" + msg.ChatID key := name + ":" + msg.ChatID
// 1. Stop typing // 1. 停止 typing 指示器
if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok { if entry, ok := v.(typingEntry); ok {
entry.stop() // idempotent, safe entry.stop() // 幂等操作,安全
} }
} }
// 2. Undo reaction // 2. 撤销 reaction
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
if entry, ok := v.(reactionEntry); ok { if entry, ok := v.(reactionEntry); ok {
entry.undo() // idempotent, safe entry.undo() // 幂等操作,安全
} }
} }
// 3. If a stream already finalized this message, delete the placeholder and skip send // 3. 如果流式推送已完成,删除占位消息并跳过 Send
if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if _, loaded := m.streamActive.LoadAndDelete(key); loaded {
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
// Prefer deleting the placeholder (cleaner UX than editing to same content) // 优先删除占位消息(比编辑为相同内容更干净的用户体验)
if deleter, ok := ch.(MessageDeleter); ok { if deleter, ok := ch.(MessageDeleter); ok {
deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // 尽力而为
} else if editor, ok := ch.(MessageEditor); ok { } else if editor, ok := ch.(MessageEditor); ok {
editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // 回退方案
} }
} }
} }
return true return true
} }
// 4. Try editing placeholder // 4. 尝试编辑占位消息(将"思考中..."替换为实际内容)
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
return true // edited successfully, skip Send return true // 编辑成功,跳过 Send
} }
// edit failed → fall through to normal Send // 编辑失败 → 继续走正常 Send 流程
} }
} }
} }
@ -206,40 +244,40 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
return false return false
} }
// preSendMedia handles typing stop, reaction undo, and placeholder cleanup // preSendMedia 在发送媒体附件前执行预处理(停止 typing、撤销 reaction、清理占位消息
// before sending media attachments. Unlike preSend for text messages, media // 与文本消息的 preSend 不同,媒体发送不会编辑占位消息(因为没有文本内容可替换),
// delivery never edits the placeholder because there is no text payload to // 仅在渠道支持 MessageDeleter 时尝试删除占位消息。
// replace it with; it only attempts to delete the placeholder when possible.
func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) { func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) {
key := name + ":" + msg.ChatID key := name + ":" + msg.ChatID
// 1. Stop typing // 1. 停止 typing 指示器
if v, loaded := m.typingStops.LoadAndDelete(key); loaded { if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok { if entry, ok := v.(typingEntry); ok {
entry.stop() // idempotent, safe entry.stop() // 幂等操作,安全
} }
} }
// 2. Undo reaction // 2. 撤销 reaction
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
if entry, ok := v.(reactionEntry); ok { if entry, ok := v.(reactionEntry); ok {
entry.undo() // idempotent, safe entry.undo() // 幂等操作,安全
} }
} }
// 3. Clear any finalized stream marker for this chat before media delivery. // 3. 清除此聊天的流式推送完成标记
m.streamActive.LoadAndDelete(key) m.streamActive.LoadAndDelete(key)
// 4. Delete placeholder if present. // 4. 如果存在占位消息则删除
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if deleter, ok := ch.(MessageDeleter); ok { if deleter, ok := ch.(MessageDeleter); ok {
deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // 尽力而为
} }
} }
} }
} }
// NewManager 创建 Manager 实例,根据配置初始化所有渠道,并将自身注册为流式推送代理。
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
m := &Manager{ m := &Manager{
channels: make(map[string]Channel), channels: make(map[string]Channel),
@ -250,21 +288,22 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi
channelHashes: make(map[string]string), channelHashes: make(map[string]string),
} }
// Register as streaming delegate so the agent loop can obtain streamers // 注册为流式推送代理,使 agent 循环可以获取流式推送器
messageBus.SetStreamDelegate(m) messageBus.SetStreamDelegate(m)
if err := m.initChannels(&cfg.Channels); err != nil { if err := m.initChannels(&cfg.Channels); err != nil {
return nil, err return nil, err
} }
// Store initial config hashes for all channels // 保存所有渠道的初始配置哈希(用于热重载时对比变更)
m.channelHashes = toChannelHashes(cfg) m.channelHashes = toChannelHashes(cfg)
return m, nil return m, nil
} }
// GetStreamer implements bus.StreamDelegate. // GetStreamer 实现 bus.StreamDelegate 接口。
// It checks if the named channel supports streaming and returns a Streamer. // 检查指定渠道是否支持流式推送,若支持则返回一个 Streamer。
// 返回的 Streamer 在 Finalize 时会标记 streamActive使 preSend 知道应清理占位消息。
func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) { func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) {
m.mu.RLock() m.mu.RLock()
ch, exists := m.channels[channelName] ch, exists := m.channels[channelName]
@ -288,7 +327,7 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (
return nil, false return nil, false
} }
// Mark streamActive on Finalize so preSend knows to clean up the placeholder // 在 Finalize 时标记 streamActive使 preSend 知道应清理占位消息
key := channelName + ":" + chatID key := channelName + ":" + chatID
return &finalizeHookStreamer{ return &finalizeHookStreamer{
Streamer: streamer, Streamer: streamer,
@ -296,7 +335,7 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (
}, true }, true
} }
// finalizeHookStreamer wraps a Streamer to run a hook on Finalize. // finalizeHookStreamer 包装 Streamer在 Finalize 时执行钩子函数(标记 streamActive
type finalizeHookStreamer struct { type finalizeHookStreamer struct {
Streamer Streamer
onFinalize func() onFinalize func()
@ -310,7 +349,8 @@ func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) err
return nil return nil
} }
// initChannel is a helper that looks up a factory by name and creates the channel. // initChannel 根据渠道名称查找工厂函数并创建渠道实例。
// 创建成功后会注入 MediaStore、PlaceholderRecorder 和 Owner 引用。
func (m *Manager) initChannel(name, displayName string) { func (m *Manager) initChannel(name, displayName string) {
f, ok := getFactory(name) f, ok := getFactory(name)
if !ok { if !ok {
@ -329,17 +369,17 @@ func (m *Manager) initChannel(name, displayName string) {
"error": err.Error(), "error": err.Error(),
}) })
} else { } else {
// Inject MediaStore if channel supports it // 注入 MediaStore如果渠道支持
if m.mediaStore != nil { if m.mediaStore != nil {
if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok { if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
setter.SetMediaStore(m.mediaStore) setter.SetMediaStore(m.mediaStore)
} }
} }
// Inject PlaceholderRecorder if channel supports it // 注入 PlaceholderRecorder如果渠道支持
if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok {
setter.SetPlaceholderRecorder(m) setter.SetPlaceholderRecorder(m)
} }
// Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction // 注入 Owner 引用,使 BaseChannel.HandleMessage 可以自动触发 typing/reaction
if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
setter.SetOwner(ch) setter.SetOwner(ch)
} }
@ -350,6 +390,7 @@ func (m *Manager) initChannel(name, displayName string) {
} }
} }
// initChannels 根据渠道配置逐个初始化所有已启用的渠道。
func (m *Manager) initChannels(channels *config.ChannelsConfig) error { func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager") logger.InfoC("channels", "Initializing channel manager")
@ -432,18 +473,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
return nil return nil
} }
// SetupHTTPServer creates a shared HTTP server with the given listen address. // SetupHTTPServer 创建共享 HTTP 服务器。
// It registers health endpoints from the health server and discovers channels // 注册健康检查端点,并自动发现实现了 WebhookHandler 和/或 HealthChecker 的渠道,
// that implement WebhookHandler and/or HealthChecker to register their handlers. // 为它们注册对应的 HTTP 处理器。
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
m.mux = newDynamicServeMux() m.mux = newDynamicServeMux()
// Register health endpoints // 注册健康检查端点
if healthServer != nil { if healthServer != nil {
healthServer.RegisterOnMux(m.mux) healthServer.RegisterOnMux(m.mux)
} }
// Discover and register webhook handlers and health checkers // 发现并注册 Webhook 处理器和健康检查处理器
m.registerHTTPHandlersLocked() m.registerHTTPHandlersLocked()
m.httpServer = &http.Server{ m.httpServer = &http.Server{
@ -454,17 +495,15 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
} }
} }
// registerHTTPHandlersLocked registers webhook and health-check handlers for // registerHTTPHandlersLocked 为 m.channels 中所有渠道注册 Webhook 和健康检查处理器。
// all channels currently in m.channels. Caller must hold m.mu (or ensure // 调用者必须持有 m.mu或确保独占访问
// exclusive access).
func (m *Manager) registerHTTPHandlersLocked() { func (m *Manager) registerHTTPHandlersLocked() {
for name, ch := range m.channels { for name, ch := range m.channels {
m.registerChannelHTTPHandler(name, ch) m.registerChannelHTTPHandler(name, ch)
} }
} }
// registerChannelHTTPHandler registers the webhook/health handlers for a // registerChannelHTTPHandler 为单个渠道注册 Webhook 和健康检查处理器到 m.mux。
// single channel onto m.mux.
func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) {
if wh, ok := ch.(WebhookHandler); ok { if wh, ok := ch.(WebhookHandler); ok {
m.mux.Handle(wh.WebhookPath(), wh) m.mux.Handle(wh.WebhookPath(), wh)
@ -482,8 +521,7 @@ func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) {
} }
} }
// unregisterChannelHTTPHandler removes the webhook/health handlers for a // unregisterChannelHTTPHandler 从 m.mux 中移除单个渠道的 Webhook 和健康检查处理器。
// single channel from m.mux.
func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) { func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) {
if wh, ok := ch.(WebhookHandler); ok { if wh, ok := ch.(WebhookHandler); ok {
m.mux.Unhandle(wh.WebhookPath()) m.mux.Unhandle(wh.WebhookPath())
@ -501,6 +539,8 @@ func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) {
} }
} }
// StartAll 启动所有已初始化的渠道。
// 为每个渠道创建 worker 并启动分发循环、TTL 清理定时器和共享 HTTP 服务器。
func (m *Manager) StartAll(ctx context.Context) error { func (m *Manager) StartAll(ctx context.Context) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -525,21 +565,21 @@ func (m *Manager) StartAll(ctx context.Context) error {
}) })
continue continue
} }
// Lazily create worker only after channel starts successfully // 渠道成功启动后才创建 worker延迟创建
w := newChannelWorker(name, channel) w := newChannelWorker(name, channel)
m.workers[name] = w m.workers[name] = w
go m.runWorker(dispatchCtx, name, w) go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w)
} }
// Start the dispatcher that reads from the bus and routes to workers // 启动分发器,从消息总线读取消息并路由到各渠道 worker
go m.dispatchOutbound(dispatchCtx) go m.dispatchOutbound(dispatchCtx)
go m.dispatchOutboundMedia(dispatchCtx) go m.dispatchOutboundMedia(dispatchCtx)
// Start the TTL janitor that cleans up stale typing/placeholder entries // 启动 TTL 清理定时器,定期清理过期的 typing/placeholder/reaction 条目
go m.runTTLJanitor(dispatchCtx) go m.runTTLJanitor(dispatchCtx)
// Start shared HTTP server if configured // 启动共享 HTTP 服务器(如果已配置)
if m.httpServer != nil { if m.httpServer != nil {
go func() { go func() {
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
@ -557,13 +597,19 @@ func (m *Manager) StartAll(ctx context.Context) error {
return nil return nil
} }
// StopAll 优雅停止所有渠道。关闭顺序:
// 1. 停止共享 HTTP 服务器
// 2. 取消分发任务
// 3. 关闭所有 worker 的消息队列并等待排空
// 4. 关闭所有媒体 worker 的队列并等待排空
// 5. 停止所有渠道
func (m *Manager) StopAll(ctx context.Context) error { func (m *Manager) StopAll(ctx context.Context) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
logger.InfoC("channels", "Stopping all channels") logger.InfoC("channels", "Stopping all channels")
// Shutdown shared HTTP server first // 1. 先关闭共享 HTTP 服务器
if m.httpServer != nil { if m.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
@ -575,13 +621,13 @@ func (m *Manager) StopAll(ctx context.Context) error {
m.httpServer = nil m.httpServer = nil
} }
// Cancel dispatcher // 2. 取消分发任务
if m.dispatchTask != nil { if m.dispatchTask != nil {
m.dispatchTask.cancel() m.dispatchTask.cancel()
m.dispatchTask = nil m.dispatchTask = nil
} }
// Close all worker queues and wait for them to drain // 3. 关闭所有文本 worker 的消息队列并等待排空
for _, w := range m.workers { for _, w := range m.workers {
if w != nil { if w != nil {
close(w.queue) close(w.queue)
@ -592,7 +638,7 @@ func (m *Manager) StopAll(ctx context.Context) error {
<-w.done <-w.done
} }
} }
// Close all media worker queues and wait for them to drain // 4. 关闭所有媒体 worker 的队列并等待排空
for _, w := range m.workers { for _, w := range m.workers {
if w != nil { if w != nil {
close(w.mediaQueue) close(w.mediaQueue)
@ -604,7 +650,7 @@ func (m *Manager) StopAll(ctx context.Context) error {
} }
} }
// Stop all channels // 5. 停止所有渠道
for name, channel := range m.channels { for name, channel := range m.channels {
logger.InfoCF("channels", "Stopping channel", map[string]any{ logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name, "channel": name,
@ -621,8 +667,8 @@ func (m *Manager) StopAll(ctx context.Context) error {
return nil return nil
} }
// newChannelWorker creates a channelWorker with a rate limiter configured // newChannelWorker 创建一个带速率限制的渠道 worker。
// for the given channel name. // 根据渠道名称从 channelRateConfig 查找速率限制配置,未找到则使用默认值。
func newChannelWorker(name string, ch Channel) *channelWorker { func newChannelWorker(name string, ch Channel) *channelWorker {
rateVal := float64(defaultRateLimit) rateVal := float64(defaultRateLimit)
if r, ok := channelRateConfig[name]; ok { if r, ok := channelRateConfig[name]; ok {
@ -640,10 +686,11 @@ func newChannelWorker(name string, ch Channel) *channelWorker {
} }
} }
// runWorker processes outbound messages for a single channel. // runWorker 处理单个渠道的出站消息。
// Message processing follows this order: // 消息处理流程:
// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting // 1. SplitByMarker如果配置启用—— 基于标记的语义分割
// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength) // 2. splitByLength —— 基于渠道最大消息长度的分割MaxMessageLength
// 3. 对每个分片调用 sendWithRetry 发送
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.done) defer close(w.done)
for { for {
@ -657,10 +704,10 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
maxLen = mlp.MaxMessageLength() maxLen = mlp.MaxMessageLength()
} }
// Collect all message chunks to send // 收集所有待发送的消息分片
var chunks []string var chunks []string
// Step 1: Try marker-based splitting if enabled // 步骤 1尝试基于标记的语义分割如果配置启用
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
for _, chunk := range markerChunks { for _, chunk := range markerChunks {
@ -669,12 +716,12 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
} }
} }
// Step 2: Fallback to length-based splitting if no chunks from marker // 步骤 2如果标记分割未产生分片回退到长度分割
if len(chunks) == 0 { if len(chunks) == 0 {
chunks = splitByLength(msg.Content, maxLen) chunks = splitByLength(msg.Content, maxLen)
} }
// Step 3: Send all chunks // 步骤 3逐个发送所有分片
for _, chunk := range chunks { for _, chunk := range chunks {
chunkMsg := msg chunkMsg := msg
chunkMsg.Content = chunk chunkMsg.Content = chunk
@ -686,7 +733,7 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
} }
} }
// splitByLength splits content by maxLen if needed, otherwise returns single chunk. // splitByLength 按最大长度分割消息内容。如果内容未超出限制则返回单个分片。
func splitByLength(content string, maxLen int) []string { func splitByLength(content string, maxLen int) []string {
if maxLen > 0 && len([]rune(content)) > maxLen { if maxLen > 0 && len([]rune(content)) > maxLen {
return SplitMessage(content, maxLen) return SplitMessage(content, maxLen)
@ -694,21 +741,21 @@ func splitByLength(content string, maxLen int) []string {
return []string{content} return []string{content}
} }
// sendWithRetry sends a message through the channel with rate limiting and // sendWithRetry 带速率限制和重试逻辑的消息发送。
// retry logic. It classifies errors to determine the retry strategy: // 错误分类与重试策略:
// - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrNotRunning / ErrSendFailed: 永久性错误,不重试
// - ErrRateLimit: fixed delay retry // - ErrRateLimit: 固定延迟重试
// - ErrTemporary / unknown: exponential backoff retry // - ErrTemporary / 未知错误: 指数退避重试
func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
// Rate limit: wait for token // 速率限制:等待令牌
if err := w.limiter.Wait(ctx); err != nil { if err := w.limiter.Wait(ctx); err != nil {
// ctx canceled, shutting down // ctx 已取消,正在关闭
return return
} }
// Pre-send: stop typing and try to edit placeholder // 发送前处理:停止 typing尝试编辑占位消息
if m.preSend(ctx, name, msg, w.ch) { if m.preSend(ctx, name, msg, w.ch) {
return // placeholder was edited successfully, skip Send return // 占位消息已编辑成功,跳过 Send
} }
var lastErr error var lastErr error
@ -718,17 +765,17 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
return return
} }
// Permanent failures — don't retry // 永久性错误 —— 不重试
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
break break
} }
// Last attempt exhausted — don't sleep // 已用尽最后一次重试 —— 不再等待
if attempt == maxRetries { if attempt == maxRetries {
break break
} }
// Rate limit error — fixed delay // 速率限制错误 —— 固定延迟重试
if errors.Is(lastErr, ErrRateLimit) { if errors.Is(lastErr, ErrRateLimit) {
select { select {
case <-time.After(rateLimitDelay): case <-time.After(rateLimitDelay):
@ -738,7 +785,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
} }
} }
// ErrTemporary or unknown error — exponential backoff // ErrTemporary 或未知错误 —— 指数退避重试
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
@ -747,7 +794,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
} }
} }
// All retries exhausted or permanent failure // 所有重试用尽或永久性错误
logger.ErrorCF("channels", "Send failed", map[string]any{ logger.ErrorCF("channels", "Send failed", map[string]any{
"channel": name, "channel": name,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
@ -756,6 +803,9 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
}) })
} }
// dispatchLoop 泛型消息分发循环。
// 从输入通道读取消息,根据 getChannel 函数获取目标渠道名称,
// 将消息入队到对应渠道的 worker。跳过内部渠道的消息。
func dispatchLoop[M any]( func dispatchLoop[M any](
ctx context.Context, ctx context.Context,
m *Manager, m *Manager,
@ -780,7 +830,7 @@ func dispatchLoop[M any](
channel := getChannel(msg) channel := getChannel(msg)
// Silently skip internal channels // 静默跳过内部渠道
if constants.IsInternalChannel(channel) { if constants.IsInternalChannel(channel) {
continue continue
} }
@ -806,6 +856,8 @@ func dispatchLoop[M any](
} }
} }
// dispatchOutbound 出站文本消息分发循环。
// 从 bus.OutboundChan 读取消息并路由到对应渠道的 worker 队列。
func (m *Manager) dispatchOutbound(ctx context.Context) { func (m *Manager) dispatchOutbound(ctx context.Context) {
dispatchLoop( dispatchLoop(
ctx, m, ctx, m,
@ -826,6 +878,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
) )
} }
// dispatchOutboundMedia 出站媒体消息分发循环。
// 从 bus.OutboundMediaChan 读取媒体消息并路由到对应渠道的 worker 媒体队列。
func (m *Manager) dispatchOutboundMedia(ctx context.Context) { func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
dispatchLoop( dispatchLoop(
ctx, m, ctx, m,
@ -846,7 +900,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
) )
} }
// runMediaWorker processes outbound media messages for a single channel. // runMediaWorker 处理单个渠道的出站媒体消息。
func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) { func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.mediaDone) defer close(w.mediaDone)
for { for {
@ -862,9 +916,8 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor
} }
} }
// sendMediaWithRetry sends a media message through the channel with rate limiting and // sendMediaWithRetry 带速率限制和重试逻辑的媒体消息发送。
// retry logic. It returns nil on success, or the last error after retries, // 成功返回 nil重试用尽后返回最后的错误包括渠道不支持 MediaSender 的情况)。
// including when the channel does not support MediaSender.
func (m *Manager) sendMediaWithRetry( func (m *Manager) sendMediaWithRetry(
ctx context.Context, ctx context.Context,
name string, name string,
@ -881,12 +934,12 @@ func (m *Manager) sendMediaWithRetry(
return err return err
} }
// Rate limit: wait for token // 速率限制:等待令牌
if err := w.limiter.Wait(ctx); err != nil { if err := w.limiter.Wait(ctx); err != nil {
return err return err
} }
// Pre-send: stop typing and clean up any placeholder before sending media. // 发送前处理:停止 typing 并清理占位消息
m.preSendMedia(ctx, name, msg, w.ch) m.preSendMedia(ctx, name, msg, w.ch)
var lastErr error var lastErr error
@ -896,17 +949,17 @@ func (m *Manager) sendMediaWithRetry(
return nil return nil
} }
// Permanent failures — don't retry // 永久性错误 —— 不重试
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
break break
} }
// Last attempt exhausted — don't sleep // 已用尽最后一次重试 —— 不再等待
if attempt == maxRetries { if attempt == maxRetries {
break break
} }
// Rate limit error — fixed delay // 速率限制错误 —— 固定延迟重试
if errors.Is(lastErr, ErrRateLimit) { if errors.Is(lastErr, ErrRateLimit) {
select { select {
case <-time.After(rateLimitDelay): case <-time.After(rateLimitDelay):
@ -916,7 +969,7 @@ func (m *Manager) sendMediaWithRetry(
} }
} }
// ErrTemporary or unknown error — exponential backoff // ErrTemporary 或未知错误 —— 指数退避重试
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
select { select {
case <-time.After(backoff): case <-time.After(backoff):
@ -925,7 +978,7 @@ func (m *Manager) sendMediaWithRetry(
} }
} }
// All retries exhausted or permanent failure // 所有重试用尽或永久性错误
logger.ErrorCF("channels", "SendMedia failed", map[string]any{ logger.ErrorCF("channels", "SendMedia failed", map[string]any{
"channel": name, "channel": name,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
@ -935,9 +988,8 @@ func (m *Manager) sendMediaWithRetry(
return lastErr return lastErr
} }
// runTTLJanitor periodically scans the typingStops and placeholders maps // runTTLJanitor TTL 清理定时器。定期扫描 typingStops、reactionUndos 和 placeholders
// and evicts entries that have exceeded their TTL. This prevents memory // 清除超过 TTL 的条目。防止出站路径未能触发 preSend如 LLM 错误)时产生内存泄漏。
// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
func (m *Manager) runTTLJanitor(ctx context.Context) { func (m *Manager) runTTLJanitor(ctx context.Context) {
ticker := time.NewTicker(janitorInterval) ticker := time.NewTicker(janitorInterval)
defer ticker.Stop() defer ticker.Stop()
@ -951,7 +1003,7 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
if entry, ok := value.(typingEntry); ok { if entry, ok := value.(typingEntry); ok {
if now.Sub(entry.createdAt) > typingStopTTL { if now.Sub(entry.createdAt) > typingStopTTL {
if _, loaded := m.typingStops.LoadAndDelete(key); loaded { if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
entry.stop() // idempotent, safe entry.stop() // 幂等操作,安全
} }
} }
} }
@ -961,7 +1013,7 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
if entry, ok := value.(reactionEntry); ok { if entry, ok := value.(reactionEntry); ok {
if now.Sub(entry.createdAt) > typingStopTTL { if now.Sub(entry.createdAt) > typingStopTTL {
if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded { if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
entry.undo() // idempotent, safe entry.undo() // 幂等操作,安全
} }
} }
} }
@ -979,6 +1031,7 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
} }
} }
// GetChannel 按名称获取渠道实例。
func (m *Manager) GetChannel(name string) (Channel, bool) { func (m *Manager) GetChannel(name string) (Channel, bool) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@ -986,6 +1039,7 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok return channel, ok
} }
// GetStatus 返回所有渠道的状态信息(是否启用、是否运行中)。
func (m *Manager) GetStatus() map[string]any { func (m *Manager) GetStatus() map[string]any {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@ -1000,6 +1054,7 @@ func (m *Manager) GetStatus() map[string]any {
return status return status
} }
// GetEnabledChannels 返回所有已启用渠道的名称列表。
func (m *Manager) GetEnabledChannels() []string { func (m *Manager) GetEnabledChannels() []string {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@ -1011,16 +1066,17 @@ func (m *Manager) GetEnabledChannels() []string {
return names return names
} }
// Reload updates the config reference without restarting channels. // Reload 热重载渠道配置。通过对比配置哈希确定新增和移除的渠道,
// This is used when channel config hasn't changed but other parts of the config have. // 停止旧渠道、初始化并启动新渠道。如果配置未变更则仅更新配置引用。
// 出错时回滚到旧配置。
func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
// Save old config so we can revert on error. // 保存旧配置以便出错时回滚
oldConfig := m.config oldConfig := m.config
// Update config early: initChannel uses m.config via factory(m.config, m.bus). // 提前更新配置initChannel 通过 factory(m.config, m.bus) 使用 m.config
m.config = cfg m.config = cfg
list := toChannelHashes(cfg) list := toChannelHashes(cfg)
@ -1028,7 +1084,7 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
deferFuncs := make([]func(), 0, len(removed)+len(added)) deferFuncs := make([]func(), 0, len(removed)+len(added))
for _, name := range removed { for _, name := range removed {
// Stop all channels // 停止所有需要移除的渠道
channel := m.channels[name] channel := m.channels[name]
logger.InfoCF("channels", "Stopping channel", map[string]any{ logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name, "channel": name,
@ -1071,7 +1127,7 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
}) })
continue continue
} }
// Lazily create worker only after channel starts successfully // 渠道成功启动后才创建 worker延迟创建
w := newChannelWorker(name, channel) w := newChannelWorker(name, channel)
m.workers[name] = w m.workers[name] = w
go m.runWorker(dispatchCtx, name, w) go m.runWorker(dispatchCtx, name, w)
@ -1081,7 +1137,7 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
}) })
} }
// Commit hashes only on full success. // 仅在全部成功时提交配置哈希
m.channelHashes = list m.channelHashes = list
go func() { go func() {
for _, f := range deferFuncs { for _, f := range deferFuncs {
@ -1091,6 +1147,7 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
return nil return nil
} }
// RegisterChannel 动态注册一个渠道,将其添加到 channels 映射并注册 HTTP 处理器。
func (m *Manager) RegisterChannel(name string, channel Channel) { func (m *Manager) RegisterChannel(name string, channel Channel) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -1100,6 +1157,7 @@ func (m *Manager) RegisterChannel(name string, channel Channel) {
} }
} }
// UnregisterChannel 动态注销一个渠道。移除 HTTP 处理器、等待 worker 排空、从映射中删除。
func (m *Manager) UnregisterChannel(name string) { func (m *Manager) UnregisterChannel(name string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -1116,10 +1174,8 @@ func (m *Manager) UnregisterChannel(name string) {
delete(m.channels, name) delete(m.channels, name)
} }
// SendMessage sends an outbound message synchronously through the channel // SendMessage 同步发送消息,阻塞直到发送完成(或重试用尽)。
// worker's rate limiter and retry logic. It blocks until the message is // 会按渠道最大消息长度自动分割消息。保证消息顺序,适用于后续操作依赖消息已发送的场景。
// delivered (or all retries are exhausted), which preserves ordering when
// a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[msg.Channel] _, exists := m.channels[msg.Channel]
@ -1149,10 +1205,8 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro
return nil return nil
} }
// SendMedia sends outbound media synchronously through the channel worker's // SendMedia 同步发送媒体消息,阻塞直到发送完成(或重试用尽)。
// rate limiter and retry logic. It blocks until the media is delivered (or all // 保证媒体发送顺序,适用于后续 agent 行为依赖媒体已发送的场景。
// retries are exhausted), which preserves ordering when later agent behavior
// depends on actual media delivery.
func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[msg.Channel] _, exists := m.channels[msg.Channel]
@ -1169,6 +1223,8 @@ func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) e
return m.sendMediaWithRetry(ctx, msg.Channel, w, msg) return m.sendMediaWithRetry(ctx, msg.Channel, w, msg)
} }
// SendToChannel 异步发送消息到指定渠道。将消息入队到 worker 的消息队列,
// 如果没有活跃的 worker 则直接调用渠道的 Send 方法(回退方案)。
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[channelName] _, exists := m.channels[channelName]
@ -1194,7 +1250,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
} }
} }
// Fallback: direct send (should not happen) // 回退方案:直接发送(正常情况下不应发生)
channel, _ := m.channels[channelName] channel, _ := m.channels[channelName]
return channel.Send(ctx, msg) return channel.Send(ctx, msg)
} }

242
飞书渠道代码.md Normal file
View file

@ -0,0 +1,242 @@
# 飞书消息收发完整流程分析
## 涉及的文件
| 文件 | 作用 |
|------|------|
| `pkg/channels/feishu/init.go` | 注册飞书 channel 工厂 |
| `pkg/channels/feishu/feishu_64.go` | 64 位系统下的完整实现(核心) |
| `pkg/channels/feishu/common.go` | 工具函数JSON 解析、卡片构建、@提及清理等 |
| `pkg/channels/feishu/token_cache.go` | 自定义 token 缓存(修复 SDK 不清理过期 token 的问题) |
| `pkg/channels/feishu/feishu_32.go` | 32 位系统的 stub飞书 SDK 不支持 32 位) |
| `pkg/channels/base.go` | `BaseChannel` 通用 channel 基础设施 |
| `pkg/channels/manager.go` | `Manager` — 管理 channel 生命周期、出站消息分发 |
| `pkg/bus/types.go` | 消息类型定义(`InboundMessage`, `OutboundMessage` |
| `pkg/bus/bus.go` | `MessageBus` — 带 buffer 的 inbound/outbound channel |
| `pkg/agent/loop.go` | `AgentLoop` — 消费 inbound、调用 LLM、产出 outbound |
---
## 完整流转过程
```
┌──────────────┐
│ Feishu SDK │ WebSocket 长连接 (larkws.Client)
│ 推送事件 │
└──────┬───────┘
│ P2MessageReceiveV1 event
┌──────────────────────────────────────────────────────────────┐
│ ① FeishuChannel.handleMessageReceive() │
│ pkg/channels/feishu/feishu_64.go:382 │
│ │
│ 1. 解析 event → chatID, senderID, messageType, │
│ messageID, rawContent │
│ 2. 提前检查 allowlist (IsAllowedSender) │
│ 避免 unauthorized 用户触发媒体下载 │
│ 3. extractContent() — 按 messageType 提取文本: │
│ - text → JSON.text │
│ - post/interactive → 原始 JSON (给 LLM 更丰富信息) │
│ - image → "" │
│ - file/audio/media → file_name │
│ 4. downloadInboundMedia() — 下载媒体到 MediaStore: │
│ - image → 提取 image_key → API 下载 → store │
│ - interactive → 递归提取 img_key/icon_key │
│ - file/audio/media → 提取 file_key → API 下载 │
│ 5. appendMediaTags() — 拼接 [image: photo] 等标记 │
│ 6. 群聊过滤: │
│ - isBotMentioned() 检测 @机器人
│ - stripMentionPlaceholders() 清除 @_user_N 占位符 │
│ - ShouldRespondInGroup() 统一群触发逻辑 │
│ (@→总是回复 / prefix→匹配才回复 / 否则→全部回复) │
│ 7. 组装 metadata (message_id, message_type, chat_type, │
│ tenant_key) │
│ 8. 确定 peer: p2p→{direct, senderID} │
│ group→{group, chatID} │
└──────────┬───────────────────────────────────────────────────┘
│ 调用 BaseChannel.HandleMessage()
┌──────────────────────────────────────────────────────────────┐
│ ② BaseChannel.HandleMessage() │
│ pkg/channels/base.go:232 │
│ │
│ 1. 二次 allowlist 检查 (SenderInfo / senderID) │
│ 2. 构建 bus.InboundMessage{ │
│ Channel: "feishu", │
│ SenderID: canonicalID, │
│ ChatID, Content, Media, Peer, MessageID, Metadata │
│ } │
│ 3. 自动触发 UI 反馈 (如果 channel 支持该能力): │
│ - TypingCapable → StartTyping() 输入中指示器 │
│ - ReactionCapable → ReactToMessage() 添加 emoji 反应 │
│ - PlaceholderCapable → SendPlaceholder() 发送"思考中" │
│ 4. bus.PublishInbound() → 写入 bus.inbound channel │
└──────────┬───────────────────────────────────────────────────┘
│ Go channel (buffer=64)
┌──────────────────────────────────────────────────────────────┐
│ ③ AgentLoop.Run() │
│ pkg/agent/loop.go:380 │
│ │
│ 主循环从 bus.InboundChan() 消费消息: │
│ 1. transcribeAudioInMessage() — 如果有音频,转写后替换 │
│ 2. processMessage(): │
│ - resolveMessageRoute() → 路由到正确的 Agent │
│ - handleCommand() → 检查是否是 /命令 │
│ - runAgentLoop() → runTurn() → 核心 LLM 循环: │
│ a. BuildMessages() — 构建 system prompt + history + │
│ user message │
│ b. 调用 LLM Provider.Chat() │
│ c. 如果有 tool_calls → 执行工具 → 结果追加到 messages │
│ → 重新调用 LLM (循环直到无 tool_calls) │
│ d. 得到 finalContent (LLM 最终文本回复) │
│ - 保存 assistant message 到 session history │
│ 3. publishResponseIfNeeded() → bus.PublishOutbound() │
│ 写入 bus.outbound channel │
└──────────┬───────────────────────────────────────────────────┘
│ Go channel (buffer=64)
┌──────────────────────────────────────────────────────────────┐
│ ④ Manager.dispatchOutbound() │
│ pkg/channels/manager.go:809 │
│ │
│ 从 bus.OutboundChan() 读取, 按 msg.Channel 路由到 │
│ 对应 channelWorker.queue │
└──────────┬───────────────────────────────────────────────────┘
│ Go channel (buffer=16)
┌──────────────────────────────────────────────────────────────┐
│ ⑤ Manager.runWorker() → sendWithRetry() │
│ pkg/channels/manager.go:647 │
│ │
│ 1. Rate limiting (rate.Limiter, feishu 无特殊配置=10 msg/s) │
│ 2. preSend(): │
│ - 停止 typing 指示器 │
│ - 撤销 reaction emoji │
│ - 尝试 EditMessage() 编辑 placeholder → 成功则跳过 Send │
│ 3. 消息分割 (SplitOnMarker / MaxMessageLength) │
│ 4. 调用 FeishuChannel.Send() (带 retry) │
└──────────┬───────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ ⑥ FeishuChannel.Send() │
│ pkg/channels/feishu/feishu_64.go:134 │
│ │
│ 1. buildMarkdownCard() — 构建 JSON 2.0 交互卡片: │
│ {"schema":"2.0","body":{"elements":[{"tag":"markdown", │
│ "content":"..."}]}} │
│ 2. sendCard() — 调用飞书 API: │
│ Im.V1.Message.Create(ctx, ReceiveIdType=ChatId, │
│ MsgType=Interactive, Content=cardJSON) │
│ 3. 如果卡片发送失败且错误码 11310 (表格限制): │
│ → 回退到 sendText() 发送纯文本消息 │
│ │
│ 结果: 飞书用户收到 markdown 渲染的回复消息 │
└──────────────────────────────────────────────────────────────┘
```
---
## 关键设计要点
### 1. WebSocket 模式
飞书使用 `larkws.Client` 长连接接收事件,无需配置 HTTP webhook。
### 2. 双次 allowlist 检查
- `handleMessageReceive` 中提前检查一次(避免浪费带宽下载 unauthorized 用户的媒体)
- `HandleMessage` 中再检查一次(兜底)
### 3. Placeholder 编辑优化
收到消息时先发 "思考中" 占位卡片,回复时直接 `EditMessage` 更新内容,用户看到的是原地更新而非两条消息。
### 4. 卡片→纯文本降级
发送优先用 Interactive Card支持完整 markdown失败时降级为纯文本。
### 5. Token 失效自动处理
`tokenCache` 绕过 SDK 的 bug在遇到 `99991663` 错误码时主动清除缓存强制重新获取 `tenant_access_token`
### 6. 32 位架构 stub
飞书 SDK 不支持 32 位系统,通过 build tag 提供空实现并返回明确错误。
---
## 核心数据结构流转
```
飞书事件 (larkim.P2MessageReceiveV1)
handleMessageReceive() 解析
bus.InboundMessage {
Channel: "feishu"
SenderID: canonicalID ("feishu:open_id_xxx")
ChatID: "oc_xxx"
Content: "用户消息文本"
Media: []string{"media://ref1", ...}
Peer: {Kind:"direct|group", ID:"..."}
MessageID: "om_xxx"
Metadata: {message_id, message_type, chat_type, tenant_key}
}
▼ bus.PublishInbound() → inbound channel
AgentLoop 消费 → LLM 处理 → 得到回复文本
bus.OutboundMessage {
Channel: "feishu"
ChatID: "oc_xxx"
Content: "LLM 回复内容 (markdown)"
}
▼ bus.PublishOutbound() → outbound channel
Manager 分发 → channelWorker → rate limit → retry
FeishuChannel.Send()
飞书 Interactive Card JSON 2.0
{"schema":"2.0","body":{"elements":[{"tag":"markdown","content":"..."}]}}
飞书 API: Im.V1.Message.Create → 用户收到渲染后的消息
```
---
## 媒体处理
### 入站媒体(接收)
- **图片**: 从 rawContent 提取 `image_key``Im.V1.MessageResource.Get` 下载 → 存入 MediaStore → content 追加 `[image: photo]`
- **文件/音频/视频**: 提取 `file_key` → 同样 API 下载 → content 追加 `[file]` / `[audio]` / `[video]`
- **交互式卡片**: 递归遍历 JSON 结构提取所有 `img_key` / `icon_key` / 外部 URL
### 出站媒体(发送)
- `SendMedia()``sendMediaPart()``sendImage()` / `sendFile()`
- 先上传获取 `image_key` / `file_key`,再用该 key 发送消息
---
## 群聊消息过滤逻辑
```
收到群消息
isBotMentioned() — 检查 message.Mentions 是否包含 bot open_id
stripMentionPlaceholders() — 清除 @_user_N 占位符
ShouldRespondInGroup(isMentioned, content):
├── 被 @ → 总是回复
├── mention_only=true 且未被 @ → 忽略
├── 有 prefix 配置 → 匹配前缀才回复(去掉前缀)
└── 无配置 → 全部回复 (宽松默认)
```