2026-02-04 11:06:13 +00:00
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package channels
import (
"context"
2026-02-22 15:51:55 +00:00
"errors"
2026-02-04 11:06:13 +00:00
"fmt"
2026-02-22 15:51:55 +00:00
"math"
2026-02-22 18:39:09 +00:00
"net/http"
2026-02-22 20:29:27 +00:00
"path/filepath"
2026-02-04 11:06:13 +00:00
"sync"
2026-02-22 15:51:55 +00:00
"time"
"golang.org/x/time/rate"
2026-02-04 11:06:13 +00:00
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
2026-02-13 07:05:16 +00:00
"github.com/sipeed/picoclaw/pkg/constants"
2026-02-22 18:39:09 +00:00
"github.com/sipeed/picoclaw/pkg/health"
2026-02-04 11:06:13 +00:00
"github.com/sipeed/picoclaw/pkg/logger"
2026-02-22 15:27:55 +00:00
"github.com/sipeed/picoclaw/pkg/media"
2026-02-04 11:06:13 +00:00
)
2026-02-22 15:51:55 +00:00
const (
2026-02-24 14:30:22 +00:00
defaultChannelQueueSize = 16
2026-02-22 15:51:55 +00:00
defaultRateLimit = 10 // default 10 msg/s
maxRetries = 3
rateLimitDelay = 1 * time . Second
baseBackoff = 500 * time . Millisecond
maxBackoff = 8 * time . Second
2026-02-24 14:30:22 +00:00
janitorInterval = 10 * time . Second
typingStopTTL = 5 * time . Minute
placeholderTTL = 10 * time . Minute
2026-02-22 15:51:55 +00:00
)
2026-02-24 14:30:22 +00:00
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
type typingEntry struct {
stop func ( )
createdAt time . Time
}
2026-02-26 19:02:40 +00:00
// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction.
type reactionEntry struct {
undo func ( )
createdAt time . Time
}
2026-02-24 14:30:22 +00:00
// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction.
type placeholderEntry struct {
id string
createdAt time . Time
}
2026-02-22 15:51:55 +00:00
// channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map [ string ] float64 {
"telegram" : 20 ,
"discord" : 1 ,
"slack" : 1 ,
"line" : 10 ,
}
2026-02-22 14:46:29 +00:00
type channelWorker struct {
2026-02-22 19:10:57 +00:00
ch Channel
queue chan bus . OutboundMessage
mediaQueue chan bus . OutboundMediaMessage
done chan struct { }
mediaDone chan struct { }
limiter * rate . Limiter
2026-02-22 14:46:29 +00:00
}
2026-02-04 11:06:13 +00:00
type Manager struct {
2026-02-26 19:02:40 +00:00
channels map [ string ] Channel
workers map [ string ] * channelWorker
bus * bus . MessageBus
config * config . Config
mediaStore media . MediaStore
dispatchTask * asyncTask
mux * http . ServeMux
httpServer * http . Server
mu sync . RWMutex
placeholders sync . Map // "channel:chatID" → placeholderID (string)
typingStops sync . Map // "channel:chatID" → func()
reactionUndos sync . Map // "channel:chatID" → reactionEntry
2026-02-04 11:06:13 +00:00
}
type asyncTask struct {
cancel context . CancelFunc
}
2026-02-22 20:55:15 +00:00
// RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder.
func ( m * Manager ) RecordPlaceholder ( channel , chatID , placeholderID string ) {
key := channel + ":" + chatID
2026-02-24 14:30:22 +00:00
m . placeholders . Store ( key , placeholderEntry { id : placeholderID , createdAt : time . Now ( ) } )
2026-02-22 20:55:15 +00:00
}
// RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder.
func ( m * Manager ) RecordTypingStop ( channel , chatID string , stop func ( ) ) {
key := channel + ":" + chatID
2026-02-24 14:30:22 +00:00
m . typingStops . Store ( key , typingEntry { stop : stop , createdAt : time . Now ( ) } )
2026-02-22 20:55:15 +00:00
}
2026-02-26 19:02:40 +00:00
// RecordReactionUndo registers a reaction undo function for later invocation.
// Implements PlaceholderRecorder.
func ( m * Manager ) RecordReactionUndo ( channel , chatID string , undo func ( ) ) {
key := channel + ":" + chatID
m . reactionUndos . Store ( key , reactionEntry { undo : undo , createdAt : time . Now ( ) } )
}
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
2026-02-22 20:55:15 +00:00
// Returns true if the message was edited into a placeholder (skip Send).
func ( m * Manager ) preSend ( ctx context . Context , name string , msg bus . OutboundMessage , ch Channel ) bool {
key := name + ":" + msg . ChatID
// 1. Stop typing
if v , loaded := m . typingStops . LoadAndDelete ( key ) ; loaded {
2026-02-24 14:30:22 +00:00
if entry , ok := v . ( typingEntry ) ; ok {
entry . stop ( ) // idempotent, safe
2026-02-22 20:55:15 +00:00
}
}
2026-02-26 19:02:40 +00:00
// 2. Undo reaction
if v , loaded := m . reactionUndos . LoadAndDelete ( key ) ; loaded {
if entry , ok := v . ( reactionEntry ) ; ok {
entry . undo ( ) // idempotent, safe
}
}
// 3. Try editing placeholder
2026-02-22 20:55:15 +00:00
if v , loaded := m . placeholders . LoadAndDelete ( key ) ; loaded {
2026-02-24 14:30:22 +00:00
if entry , ok := v . ( placeholderEntry ) ; ok && entry . id != "" {
2026-02-22 20:55:15 +00:00
if editor , ok := ch . ( MessageEditor ) ; ok {
2026-02-24 14:30:22 +00:00
if err := editor . EditMessage ( ctx , msg . ChatID , entry . id , msg . Content ) ; err == nil {
2026-02-22 20:55:15 +00:00
return true // edited successfully, skip Send
}
// edit failed → fall through to normal Send
}
}
}
return false
}
2026-02-22 15:27:55 +00:00
func NewManager ( cfg * config . Config , messageBus * bus . MessageBus , store media . MediaStore ) ( * Manager , error ) {
2026-02-04 11:06:13 +00:00
m := & Manager {
2026-02-22 15:27:55 +00:00
channels : make ( map [ string ] Channel ) ,
workers : make ( map [ string ] * channelWorker ) ,
bus : messageBus ,
config : cfg ,
mediaStore : store ,
2026-02-04 11:06:13 +00:00
}
if err := m . initChannels ( ) ; err != nil {
return nil , err
}
return m , nil
}
2026-02-20 15:19:40 +00:00
// initChannel is a helper that looks up a factory by name and creates the channel.
func ( m * Manager ) initChannel ( name , displayName string ) {
f , ok := getFactory ( name )
if ! ok {
2026-02-21 08:35:56 +00:00
logger . WarnCF ( "channels" , "Factory not registered" , map [ string ] any {
2026-02-20 15:19:40 +00:00
"channel" : displayName ,
} )
return
}
2026-02-21 08:35:56 +00:00
logger . DebugCF ( "channels" , "Attempting to initialize channel" , map [ string ] any {
2026-02-20 15:19:40 +00:00
"channel" : displayName ,
} )
ch , err := f ( m . config , m . bus )
if err != nil {
2026-02-21 08:35:56 +00:00
logger . ErrorCF ( "channels" , "Failed to initialize channel" , map [ string ] any {
2026-02-20 15:19:40 +00:00
"channel" : displayName ,
"error" : err . Error ( ) ,
} )
} else {
2026-02-22 15:27:55 +00:00
// Inject MediaStore if channel supports it
if m . mediaStore != nil {
if setter , ok := ch . ( interface { SetMediaStore ( s media . MediaStore ) } ) ; ok {
setter . SetMediaStore ( m . mediaStore )
}
}
2026-02-22 20:55:15 +00:00
// Inject PlaceholderRecorder if channel supports it
2026-02-22 21:22:18 +00:00
if setter , ok := ch . ( interface { SetPlaceholderRecorder ( r PlaceholderRecorder ) } ) ; ok {
2026-02-22 20:55:15 +00:00
setter . SetPlaceholderRecorder ( m )
}
2026-02-26 19:02:40 +00:00
// Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction
if setter , ok := ch . ( interface { SetOwner ( ch Channel ) } ) ; ok {
setter . SetOwner ( ch )
}
2026-02-20 15:19:40 +00:00
m . channels [ name ] = ch
2026-02-21 08:35:56 +00:00
logger . InfoCF ( "channels" , "Channel enabled successfully" , map [ string ] any {
2026-02-20 15:19:40 +00:00
"channel" : displayName ,
} )
}
}
2026-02-04 11:06:13 +00:00
func ( m * Manager ) initChannels ( ) error {
logger . InfoC ( "channels" , "Initializing channel manager" )
if m . config . Channels . Telegram . Enabled && m . config . Channels . Telegram . Token != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "telegram" , "Telegram" )
2026-02-04 11:06:13 +00:00
}
2026-02-22 20:29:27 +00:00
if m . config . Channels . WhatsApp . Enabled {
waCfg := m . config . Channels . WhatsApp
useNative := waCfg . UseNative
if useNative {
logger . DebugC ( "channels" , "Attempting to initialize WhatsApp native channel (whatsmeow)" )
storePath := waCfg . SessionStorePath
if storePath == "" {
storePath = filepath . Join ( m . config . WorkspacePath ( ) , "whatsapp" )
}
2026-02-27 06:35:20 +00:00
newNative := getWhatsAppNativeFactory ( )
if newNative == nil {
logger . ErrorCF ( "channels" , "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native" , nil )
2026-02-22 20:29:27 +00:00
} else {
2026-02-27 06:35:20 +00:00
ch , err := newNative ( waCfg , m . bus , storePath )
if err != nil {
logger . ErrorCF ( "channels" , "Failed to initialize WhatsApp native channel" , map [ string ] any {
"error" : err . Error ( ) ,
} )
} else {
m . channels [ "whatsapp" ] = ch
logger . InfoC ( "channels" , "WhatsApp native channel enabled successfully" )
}
2026-02-22 20:29:27 +00:00
}
} else if waCfg . BridgeURL != "" {
m . initChannel ( "whatsapp" , "WhatsApp" )
}
2026-02-04 11:06:13 +00:00
}
2026-02-10 07:20:00 +00:00
if m . config . Channels . Feishu . Enabled {
2026-02-20 15:19:40 +00:00
m . initChannel ( "feishu" , "Feishu" )
2026-02-10 07:20:00 +00:00
}
2026-02-04 11:06:13 +00:00
if m . config . Channels . Discord . Enabled && m . config . Channels . Discord . Token != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "discord" , "Discord" )
2026-02-04 11:06:13 +00:00
}
if m . config . Channels . MaixCam . Enabled {
2026-02-20 15:19:40 +00:00
m . initChannel ( "maixcam" , "MaixCam" )
2026-02-04 11:06:13 +00:00
}
2026-02-10 01:46:07 +00:00
if m . config . Channels . QQ . Enabled {
2026-02-20 15:19:40 +00:00
m . initChannel ( "qq" , "QQ" )
2026-02-10 01:46:07 +00:00
}
2026-02-10 13:33:55 +00:00
if m . config . Channels . DingTalk . Enabled && m . config . Channels . DingTalk . ClientID != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "dingtalk" , "DingTalk" )
2026-02-10 13:33:55 +00:00
}
2026-02-11 18:48:32 +00:00
if m . config . Channels . Slack . Enabled && m . config . Channels . Slack . BotToken != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "slack" , "Slack" )
2026-02-11 18:48:32 +00:00
}
2026-02-14 01:01:20 +00:00
if m . config . Channels . LINE . Enabled && m . config . Channels . LINE . ChannelAccessToken != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "line" , "LINE" )
2026-02-14 01:01:20 +00:00
}
2026-02-14 08:50:21 +00:00
if m . config . Channels . OneBot . Enabled && m . config . Channels . OneBot . WSUrl != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "onebot" , "OneBot" )
2026-02-14 08:50:21 +00:00
}
2026-02-20 07:33:24 +00:00
if m . config . Channels . WeCom . Enabled && m . config . Channels . WeCom . Token != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "wecom" , "WeCom" )
2026-02-20 07:33:24 +00:00
}
if m . config . Channels . WeComApp . Enabled && m . config . Channels . WeComApp . CorpID != "" {
2026-02-20 15:19:40 +00:00
m . initChannel ( "wecom_app" , "WeCom App" )
2026-02-20 07:33:24 +00:00
}
2026-02-22 20:55:15 +00:00
if m . config . Channels . Pico . Enabled && m . config . Channels . Pico . Token != "" {
m . initChannel ( "pico" , "Pico" )
}
2026-02-21 08:35:56 +00:00
logger . InfoCF ( "channels" , "Channel initialization completed" , map [ string ] any {
2026-02-04 11:06:13 +00:00
"enabled_channels" : len ( m . channels ) ,
} )
return nil
}
2026-02-22 18:39:09 +00:00
// SetupHTTPServer creates a shared HTTP server with the given listen address.
// It registers health endpoints from the health server and discovers channels
// that implement WebhookHandler and/or HealthChecker to register their handlers.
func ( m * Manager ) SetupHTTPServer ( addr string , healthServer * health . Server ) {
m . mux = http . NewServeMux ( )
// Register health endpoints
if healthServer != nil {
healthServer . RegisterOnMux ( m . mux )
}
// Discover and register webhook handlers and health checkers
for name , ch := range m . channels {
if wh , ok := ch . ( WebhookHandler ) ; ok {
m . mux . Handle ( wh . WebhookPath ( ) , wh )
logger . InfoCF ( "channels" , "Webhook handler registered" , map [ string ] any {
"channel" : name ,
"path" : wh . WebhookPath ( ) ,
} )
}
if hc , ok := ch . ( HealthChecker ) ; ok {
m . mux . HandleFunc ( hc . HealthPath ( ) , hc . HealthHandler )
logger . InfoCF ( "channels" , "Health endpoint registered" , map [ string ] any {
"channel" : name ,
"path" : hc . HealthPath ( ) ,
} )
}
}
m . httpServer = & http . Server {
Addr : addr ,
Handler : m . mux ,
ReadTimeout : 30 * time . Second ,
WriteTimeout : 30 * time . Second ,
}
}
2026-02-04 11:06:13 +00:00
func ( m * Manager ) StartAll ( ctx context . Context ) error {
m . mu . Lock ( )
defer m . mu . Unlock ( )
if len ( m . channels ) == 0 {
logger . WarnC ( "channels" , "No channels enabled" )
return nil
}
logger . InfoC ( "channels" , "Starting all channels" )
dispatchCtx , cancel := context . WithCancel ( ctx )
m . dispatchTask = & asyncTask { cancel : cancel }
for name , channel := range m . channels {
2026-02-21 08:35:56 +00:00
logger . InfoCF ( "channels" , "Starting channel" , map [ string ] any {
2026-02-04 11:06:13 +00:00
"channel" : name ,
} )
if err := channel . Start ( ctx ) ; err != nil {
2026-02-21 08:35:56 +00:00
logger . ErrorCF ( "channels" , "Failed to start channel" , map [ string ] any {
2026-02-04 11:06:13 +00:00
"channel" : name ,
"error" : err . Error ( ) ,
} )
2026-02-24 14:30:22 +00:00
continue
2026-02-04 11:06:13 +00:00
}
2026-02-24 14:30:22 +00:00
// Lazily create worker only after channel starts successfully
w := newChannelWorker ( name , channel )
m . workers [ name ] = w
2026-02-22 14:46:29 +00:00
go m . runWorker ( dispatchCtx , name , w )
2026-02-22 19:10:57 +00:00
go m . runMediaWorker ( dispatchCtx , name , w )
2026-02-22 14:46:29 +00:00
}
// Start the dispatcher that reads from the bus and routes to workers
go m . dispatchOutbound ( dispatchCtx )
2026-02-22 19:10:57 +00:00
go m . dispatchOutboundMedia ( dispatchCtx )
2026-02-22 14:46:29 +00:00
2026-02-24 14:30:22 +00:00
// Start the TTL janitor that cleans up stale typing/placeholder entries
go m . runTTLJanitor ( dispatchCtx )
2026-02-22 18:39:09 +00:00
// Start shared HTTP server if configured
if m . httpServer != nil {
go func ( ) {
logger . InfoCF ( "channels" , "Shared HTTP server listening" , map [ string ] any {
"addr" : m . httpServer . Addr ,
} )
if err := m . httpServer . ListenAndServe ( ) ; err != nil && err != http . ErrServerClosed {
logger . ErrorCF ( "channels" , "Shared HTTP server error" , map [ string ] any {
"error" : err . Error ( ) ,
} )
}
} ( )
}
2026-02-04 11:06:13 +00:00
logger . InfoC ( "channels" , "All channels started" )
return nil
}
func ( m * Manager ) StopAll ( ctx context . Context ) error {
m . mu . Lock ( )
defer m . mu . Unlock ( )
logger . InfoC ( "channels" , "Stopping all channels" )
2026-02-22 18:39:09 +00:00
// Shutdown shared HTTP server first
if m . httpServer != nil {
shutdownCtx , cancel := context . WithTimeout ( ctx , 5 * time . Second )
defer cancel ( )
if err := m . httpServer . Shutdown ( shutdownCtx ) ; err != nil {
logger . ErrorCF ( "channels" , "Shared HTTP server shutdown error" , map [ string ] any {
"error" : err . Error ( ) ,
} )
}
m . httpServer = nil
}
// Cancel dispatcher
2026-02-04 11:06:13 +00:00
if m . dispatchTask != nil {
m . dispatchTask . cancel ( )
m . dispatchTask = nil
}
2026-02-22 14:46:29 +00:00
// Close all worker queues and wait for them to drain
for _ , w := range m . workers {
2026-02-24 14:30:22 +00:00
if w != nil {
close ( w . queue )
}
2026-02-22 14:46:29 +00:00
}
for _ , w := range m . workers {
2026-02-24 14:30:22 +00:00
if w != nil {
<- w . done
}
2026-02-22 14:46:29 +00:00
}
2026-02-22 19:10:57 +00:00
// Close all media worker queues and wait for them to drain
for _ , w := range m . workers {
2026-02-24 14:30:22 +00:00
if w != nil {
close ( w . mediaQueue )
}
2026-02-22 19:10:57 +00:00
}
for _ , w := range m . workers {
2026-02-24 14:30:22 +00:00
if w != nil {
<- w . mediaDone
}
2026-02-22 19:10:57 +00:00
}
2026-02-22 14:46:29 +00:00
// Stop all channels
2026-02-04 11:06:13 +00:00
for name , channel := range m . channels {
2026-02-21 08:35:56 +00:00
logger . InfoCF ( "channels" , "Stopping channel" , map [ string ] any {
2026-02-04 11:06:13 +00:00
"channel" : name ,
} )
if err := channel . Stop ( ctx ) ; err != nil {
2026-02-21 08:35:56 +00:00
logger . ErrorCF ( "channels" , "Error stopping channel" , map [ string ] any {
2026-02-04 11:06:13 +00:00
"channel" : name ,
"error" : err . Error ( ) ,
} )
}
}
logger . InfoC ( "channels" , "All channels stopped" )
return nil
}
2026-02-22 15:51:55 +00:00
// newChannelWorker creates a channelWorker with a rate limiter configured
// for the given channel name.
func newChannelWorker ( name string , ch Channel ) * channelWorker {
rateVal := float64 ( defaultRateLimit )
if r , ok := channelRateConfig [ name ] ; ok {
rateVal = r
}
burst := int ( math . Max ( 1 , math . Ceil ( rateVal / 2 ) ) )
return & channelWorker {
2026-02-22 19:10:57 +00:00
ch : ch ,
queue : make ( chan bus . OutboundMessage , defaultChannelQueueSize ) ,
mediaQueue : make ( chan bus . OutboundMediaMessage , defaultChannelQueueSize ) ,
done : make ( chan struct { } ) ,
mediaDone : make ( chan struct { } ) ,
limiter : rate . NewLimiter ( rate . Limit ( rateVal ) , burst ) ,
2026-02-22 15:51:55 +00:00
}
}
2026-02-22 14:46:29 +00:00
// runWorker processes outbound messages for a single channel, splitting
// messages that exceed the channel's maximum message length.
func ( m * Manager ) runWorker ( ctx context . Context , name string , w * channelWorker ) {
defer close ( w . done )
for {
select {
case msg , ok := <- w . queue :
if ! ok {
return
}
maxLen := 0
if mlp , ok := w . ch . ( MessageLengthProvider ) ; ok {
maxLen = mlp . MaxMessageLength ( )
}
if maxLen > 0 && len ( [ ] rune ( msg . Content ) ) > maxLen {
2026-02-22 21:46:34 +00:00
chunks := SplitMessage ( msg . Content , maxLen )
2026-02-22 14:46:29 +00:00
for _ , chunk := range chunks {
chunkMsg := msg
chunkMsg . Content = chunk
2026-02-22 15:51:55 +00:00
m . sendWithRetry ( ctx , name , w , chunkMsg )
2026-02-22 14:46:29 +00:00
}
} else {
2026-02-22 15:51:55 +00:00
m . sendWithRetry ( ctx , name , w , msg )
}
case <- ctx . Done ( ) :
return
}
}
}
// sendWithRetry sends a message through the channel with rate limiting and
// retry logic. It classifies errors to determine the retry strategy:
// - ErrNotRunning / ErrSendFailed: permanent, no retry
// - ErrRateLimit: fixed delay retry
// - ErrTemporary / unknown: exponential backoff retry
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 {
2026-02-26 15:36:06 +00:00
// ctx canceled, shutting down
2026-02-22 15:51:55 +00:00
return
}
2026-02-22 20:55:15 +00:00
// Pre-send: stop typing and try to edit placeholder
if m . preSend ( ctx , name , msg , w . ch ) {
return // placeholder was edited successfully, skip Send
}
2026-02-22 15:51:55 +00:00
var lastErr error
for attempt := 0 ; attempt <= maxRetries ; attempt ++ {
lastErr = w . ch . Send ( ctx , msg )
if lastErr == nil {
return
}
// Permanent failures — don't retry
if errors . Is ( lastErr , ErrNotRunning ) || errors . Is ( lastErr , ErrSendFailed ) {
break
}
// Last attempt exhausted — don't sleep
if attempt == maxRetries {
break
}
// Rate limit error — fixed delay
if errors . Is ( lastErr , ErrRateLimit ) {
select {
case <- time . After ( rateLimitDelay ) :
continue
case <- ctx . Done ( ) :
return
2026-02-22 14:46:29 +00:00
}
2026-02-22 15:51:55 +00:00
}
// ErrTemporary or unknown error — exponential backoff
backoff := min ( time . Duration ( float64 ( baseBackoff ) * math . Pow ( 2 , float64 ( attempt ) ) ) , maxBackoff )
select {
case <- time . After ( backoff ) :
2026-02-22 14:46:29 +00:00
case <- ctx . Done ( ) :
return
}
}
2026-02-22 15:51:55 +00:00
// All retries exhausted or permanent failure
logger . ErrorCF ( "channels" , "Send failed" , map [ string ] any {
"channel" : name ,
"chat_id" : msg . ChatID ,
"error" : lastErr . Error ( ) ,
"retries" : maxRetries ,
} )
2026-02-22 14:46:29 +00:00
}
2026-02-04 11:06:13 +00:00
func ( m * Manager ) dispatchOutbound ( ctx context . Context ) {
logger . InfoC ( "channels" , "Outbound dispatcher started" )
for {
2026-02-24 14:30:22 +00:00
msg , ok := m . bus . SubscribeOutbound ( ctx )
if ! ok {
2026-02-04 11:06:13 +00:00
logger . InfoC ( "channels" , "Outbound dispatcher stopped" )
return
2026-02-24 14:30:22 +00:00
}
2026-02-04 11:06:13 +00:00
2026-02-24 14:30:22 +00:00
// Silently skip internal channels
if constants . IsInternalChannel ( msg . Channel ) {
continue
}
2026-02-13 03:13:32 +00:00
2026-02-24 14:30:22 +00:00
m . mu . RLock ( )
_ , exists := m . channels [ msg . Channel ]
w , wExists := m . workers [ msg . Channel ]
m . mu . RUnlock ( )
2026-02-04 11:06:13 +00:00
2026-02-24 14:30:22 +00:00
if ! exists {
logger . WarnCF ( "channels" , "Unknown channel for outbound message" , map [ string ] any {
"channel" : msg . Channel ,
} )
continue
}
2026-02-04 11:06:13 +00:00
2026-02-24 14:30:22 +00:00
if wExists && w != nil {
select {
case w . queue <- msg :
case <- ctx . Done ( ) :
return
2026-02-04 11:06:13 +00:00
}
2026-02-24 14:30:22 +00:00
} else if exists {
logger . WarnCF ( "channels" , "Channel has no active worker, skipping message" , map [ string ] any {
"channel" : msg . Channel ,
} )
2026-02-04 11:06:13 +00:00
}
}
}
2026-02-22 19:10:57 +00:00
func ( m * Manager ) dispatchOutboundMedia ( ctx context . Context ) {
logger . InfoC ( "channels" , "Outbound media dispatcher started" )
for {
2026-02-24 14:30:22 +00:00
msg , ok := m . bus . SubscribeOutboundMedia ( ctx )
if ! ok {
2026-02-22 19:10:57 +00:00
logger . InfoC ( "channels" , "Outbound media dispatcher stopped" )
return
2026-02-24 14:30:22 +00:00
}
2026-02-22 19:10:57 +00:00
2026-02-24 14:30:22 +00:00
// Silently skip internal channels
if constants . IsInternalChannel ( msg . Channel ) {
continue
}
2026-02-22 19:10:57 +00:00
2026-02-24 14:30:22 +00:00
m . mu . RLock ( )
_ , exists := m . channels [ msg . Channel ]
w , wExists := m . workers [ msg . Channel ]
m . mu . RUnlock ( )
2026-02-22 19:10:57 +00:00
2026-02-24 14:30:22 +00:00
if ! exists {
logger . WarnCF ( "channels" , "Unknown channel for outbound media message" , map [ string ] any {
"channel" : msg . Channel ,
} )
continue
}
2026-02-22 19:10:57 +00:00
2026-02-24 14:30:22 +00:00
if wExists && w != nil {
select {
case w . mediaQueue <- msg :
case <- ctx . Done ( ) :
return
2026-02-22 19:10:57 +00:00
}
2026-02-24 14:30:22 +00:00
} else if exists {
logger . WarnCF ( "channels" , "Channel has no active worker, skipping media message" , map [ string ] any {
"channel" : msg . Channel ,
} )
2026-02-22 19:10:57 +00:00
}
}
}
// runMediaWorker processes outbound media messages for a single channel.
func ( m * Manager ) runMediaWorker ( ctx context . Context , name string , w * channelWorker ) {
defer close ( w . mediaDone )
for {
select {
case msg , ok := <- w . mediaQueue :
if ! ok {
return
}
m . sendMediaWithRetry ( ctx , name , w , msg )
case <- ctx . Done ( ) :
return
}
}
}
// sendMediaWithRetry sends a media message through the channel with rate limiting and
// retry logic. If the channel does not implement MediaSender, it silently skips.
func ( m * Manager ) sendMediaWithRetry ( ctx context . Context , name string , w * channelWorker , msg bus . OutboundMediaMessage ) {
ms , ok := w . ch . ( MediaSender )
if ! ok {
logger . DebugCF ( "channels" , "Channel does not support MediaSender, skipping media" , map [ string ] any {
"channel" : name ,
} )
return
}
// Rate limit: wait for token
if err := w . limiter . Wait ( ctx ) ; err != nil {
return
}
var lastErr error
for attempt := 0 ; attempt <= maxRetries ; attempt ++ {
lastErr = ms . SendMedia ( ctx , msg )
if lastErr == nil {
return
}
// Permanent failures — don't retry
if errors . Is ( lastErr , ErrNotRunning ) || errors . Is ( lastErr , ErrSendFailed ) {
break
}
// Last attempt exhausted — don't sleep
if attempt == maxRetries {
break
}
// Rate limit error — fixed delay
if errors . Is ( lastErr , ErrRateLimit ) {
select {
case <- time . After ( rateLimitDelay ) :
continue
case <- ctx . Done ( ) :
return
}
}
// ErrTemporary or unknown error — exponential backoff
backoff := min ( time . Duration ( float64 ( baseBackoff ) * math . Pow ( 2 , float64 ( attempt ) ) ) , maxBackoff )
select {
case <- time . After ( backoff ) :
case <- ctx . Done ( ) :
return
}
}
// All retries exhausted or permanent failure
logger . ErrorCF ( "channels" , "SendMedia failed" , map [ string ] any {
"channel" : name ,
"chat_id" : msg . ChatID ,
"error" : lastErr . Error ( ) ,
"retries" : maxRetries ,
} )
}
2026-02-24 14:30:22 +00:00
// runTTLJanitor periodically scans the typingStops and placeholders maps
// and evicts entries that have exceeded their TTL. This prevents memory
// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
func ( m * Manager ) runTTLJanitor ( ctx context . Context ) {
ticker := time . NewTicker ( janitorInterval )
defer ticker . Stop ( )
for {
select {
case <- ctx . Done ( ) :
return
case now := <- ticker . C :
m . typingStops . Range ( func ( key , value any ) bool {
if entry , ok := value . ( typingEntry ) ; ok {
if now . Sub ( entry . createdAt ) > typingStopTTL {
if _ , loaded := m . typingStops . LoadAndDelete ( key ) ; loaded {
entry . stop ( ) // idempotent, safe
}
}
}
return true
} )
2026-02-26 19:02:40 +00:00
m . reactionUndos . Range ( func ( key , value any ) bool {
if entry , ok := value . ( reactionEntry ) ; ok {
if now . Sub ( entry . createdAt ) > typingStopTTL {
if _ , loaded := m . reactionUndos . LoadAndDelete ( key ) ; loaded {
entry . undo ( ) // idempotent, safe
}
}
}
return true
} )
2026-02-24 14:30:22 +00:00
m . placeholders . Range ( func ( key , value any ) bool {
if entry , ok := value . ( placeholderEntry ) ; ok {
if now . Sub ( entry . createdAt ) > placeholderTTL {
m . placeholders . Delete ( key )
}
}
return true
} )
}
}
}
2026-02-04 11:06:13 +00:00
func ( m * Manager ) GetChannel ( name string ) ( Channel , bool ) {
m . mu . RLock ( )
defer m . mu . RUnlock ( )
channel , ok := m . channels [ name ]
return channel , ok
}
2026-02-21 08:35:56 +00:00
func ( m * Manager ) GetStatus ( ) map [ string ] any {
2026-02-04 11:06:13 +00:00
m . mu . RLock ( )
defer m . mu . RUnlock ( )
2026-02-21 08:35:56 +00:00
status := make ( map [ string ] any )
2026-02-04 11:06:13 +00:00
for name , channel := range m . channels {
2026-02-21 08:35:56 +00:00
status [ name ] = map [ string ] any {
2026-02-04 11:06:13 +00:00
"enabled" : true ,
"running" : channel . IsRunning ( ) ,
}
}
return status
}
func ( m * Manager ) GetEnabledChannels ( ) [ ] string {
m . mu . RLock ( )
defer m . mu . RUnlock ( )
names := make ( [ ] string , 0 , len ( m . channels ) )
for name := range m . channels {
names = append ( names , name )
}
return names
}
func ( m * Manager ) RegisterChannel ( name string , channel Channel ) {
m . mu . Lock ( )
defer m . mu . Unlock ( )
m . channels [ name ] = channel
}
func ( m * Manager ) UnregisterChannel ( name string ) {
m . mu . Lock ( )
defer m . mu . Unlock ( )
2026-02-24 14:30:22 +00:00
if w , ok := m . workers [ name ] ; ok && w != nil {
2026-02-22 14:46:29 +00:00
close ( w . queue )
<- w . done
2026-02-22 19:10:57 +00:00
close ( w . mediaQueue )
<- w . mediaDone
2026-02-22 14:46:29 +00:00
}
delete ( m . workers , name )
2026-02-04 11:06:13 +00:00
delete ( m . channels , name )
}
func ( m * Manager ) SendToChannel ( ctx context . Context , channelName , chatID , content string ) error {
m . mu . RLock ( )
2026-02-22 14:46:29 +00:00
_ , exists := m . channels [ channelName ]
w , wExists := m . workers [ channelName ]
2026-02-04 11:06:13 +00:00
m . mu . RUnlock ( )
if ! exists {
return fmt . Errorf ( "channel %s not found" , channelName )
}
msg := bus . OutboundMessage {
Channel : channelName ,
ChatID : chatID ,
Content : content ,
}
2026-02-24 14:30:22 +00:00
if wExists && w != nil {
2026-02-22 14:46:29 +00:00
select {
case w . queue <- msg :
return nil
case <- ctx . Done ( ) :
return ctx . Err ( )
}
}
// Fallback: direct send (should not happen)
channel , _ := m . channels [ channelName ]
2026-02-04 11:06:13 +00:00
return channel . Send ( ctx , msg )
}