添加注释
This commit is contained in:
parent
e1e3598a62
commit
4b978714f2
4 changed files with 1210 additions and 316 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,17 @@
|
||||||
|
// Package bus 是 PicoClaw 的消息总线,负责连接 Agent 循环(AgentLoop)与渠道管理器(Manager)。
|
||||||
|
//
|
||||||
|
// 消息总线维护三条带缓冲的 Go channel:
|
||||||
|
// - inbound:用户发送的消息,流向 Agent 循环进行处理。
|
||||||
|
// - outbound:Agent 生成的文本回复,流向渠道管理器再发送给用户。
|
||||||
|
// - outboundMedia:Agent 生成的媒体消息(图片、文件等),流向渠道管理器。
|
||||||
|
//
|
||||||
|
// 消息流向:渠道 → 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++
|
||||||
|
|
|
||||||
|
|
@ -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" → typingEntry(typing 停止函数)
|
||||||
reactionUndos sync.Map // "channel:chatID" → reactionEntry
|
reactionUndos sync.Map // "channel:chatID" → reactionEntry(reaction 撤销函数)
|
||||||
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
242
飞书渠道代码.md
Normal 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 配置 → 匹配前缀才回复(去掉前缀)
|
||||||
|
└── 无配置 → 全部回复 (宽松默认)
|
||||||
|
```
|
||||||
Loading…
Add table
Reference in a new issue