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-04-14 04:43:49 +00:00
|
|
|
"net"
|
2026-02-22 18:39:09 +00:00
|
|
|
"net/http"
|
2026-04-07 13:19:11 +00:00
|
|
|
"sort"
|
2026-04-23 02:35:50 +00:00
|
|
|
"strings"
|
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-04-26 08:05:10 +00:00
|
|
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
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-04-23 02:35:50 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
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-05-19 08:38:47 +00:00
|
|
|
|
|
|
|
|
streamAuxiliaryTombstoneTTL = 30 * time.Second
|
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,
|
2026-03-07 17:44:24 +00:00
|
|
|
"matrix": 2,
|
2026-02-22 15:51:55 +00:00
|
|
|
"line": 10,
|
2026-03-10 04:07:02 +00:00
|
|
|
"qq": 5,
|
2026-03-05 10:46:01 +00:00
|
|
|
"irc": 2,
|
2026-02-22 15:51:55 +00:00
|
|
|
}
|
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-05-19 08:38:47 +00:00
|
|
|
channels map[string]Channel
|
|
|
|
|
workers map[string]*channelWorker
|
|
|
|
|
bus *bus.MessageBus
|
|
|
|
|
runtimeEvents runtimeevents.Bus
|
|
|
|
|
config *config.Config
|
|
|
|
|
mediaStore media.MediaStore
|
|
|
|
|
dispatchTask *asyncTask
|
|
|
|
|
mux *dynamicServeMux
|
|
|
|
|
httpServer *http.Server
|
|
|
|
|
httpListeners []net.Listener
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
placeholders sync.Map // "channel:chatID" → placeholderID (string)
|
|
|
|
|
typingStops sync.Map // "channel:chatID" → func()
|
|
|
|
|
reactionUndos sync.Map // "channel:chatID" → reactionEntry
|
|
|
|
|
streamActive sync.Map // streamSuppressionKey → true (set when streamer.Finalize sent the message)
|
|
|
|
|
streamAuxiliaryTombstones sync.Map // streamSuppressionKey → time.Time (drops late auxiliary messages after stream final)
|
|
|
|
|
channelHashes map[string]string // channel name → config hash
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:50:58 +00:00
|
|
|
type mediaStoreSetter interface {
|
|
|
|
|
SetMediaStore(s media.MediaStore)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:05:10 +00:00
|
|
|
// ManagerOption configures a channel Manager.
|
|
|
|
|
type ManagerOption func(*Manager)
|
|
|
|
|
|
|
|
|
|
// WithRuntimeEvents injects the runtime event bus used for channel observations.
|
|
|
|
|
func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption {
|
|
|
|
|
return func(m *Manager) {
|
|
|
|
|
m.runtimeEvents = eventBus
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ChannelLifecyclePayload describes channel lifecycle runtime events.
|
|
|
|
|
type ChannelLifecyclePayload struct {
|
|
|
|
|
Type string `json:"type,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ChannelOutboundPayload describes channel outbound message runtime events.
|
|
|
|
|
type ChannelOutboundPayload struct {
|
|
|
|
|
Media bool `json:"media,omitempty"`
|
|
|
|
|
ContentLen int `json:"content_len,omitempty"`
|
|
|
|
|
MessageIDs []string `json:"message_ids,omitempty"`
|
|
|
|
|
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
Retries int `json:"retries,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
type toolFeedbackMessageTracker interface {
|
|
|
|
|
RecordToolFeedbackMessage(chatID, messageID, content string)
|
|
|
|
|
ClearToolFeedbackMessage(chatID string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type toolFeedbackMessageCleaner interface {
|
|
|
|
|
DismissToolFeedbackMessage(ctx context.Context, chatID string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type toolFeedbackMessageTargetResolver interface {
|
|
|
|
|
ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type toolFeedbackMessageContentPreparer interface {
|
|
|
|
|
PrepareToolFeedbackMessageContent(content string) string
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type asyncTask struct {
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
func outboundMessageChannel(msg bus.OutboundMessage) string {
|
|
|
|
|
return msg.Context.Channel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func outboundMessageChatID(msg bus.OutboundMessage) string {
|
2026-04-07 16:32:53 +00:00
|
|
|
return msg.ChatID
|
2026-04-01 12:56:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func outboundMessageHasAuxiliaryKind(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(msg.Context.Raw["message_kind"]) != ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func outboundMessageIsFinal(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["outbound_kind"]), "final")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 11:43:25 +00:00
|
|
|
func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
kind := strings.TrimSpace(msg.Context.Raw["message_kind"])
|
|
|
|
|
return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
func outboundMessageEditPayload(msg bus.OutboundMessage, content string) map[string]any {
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"content": content,
|
|
|
|
|
}
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
if modelName := strings.TrimSpace(msg.Context.Raw["model_name"]); modelName != "" {
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
}
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
func outboundMediaChannel(msg bus.OutboundMediaMessage) string {
|
|
|
|
|
return msg.Context.Channel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func outboundMediaChatID(msg bus.OutboundMediaMessage) string {
|
2026-04-07 16:32:53 +00:00
|
|
|
return msg.ChatID
|
2026-04-01 12:56:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func streamSuppressionKey(channel, chatID, sessionKey string) string {
|
|
|
|
|
key := channel + ":" + chatID
|
|
|
|
|
if strings.TrimSpace(sessionKey) == "" {
|
|
|
|
|
return key
|
|
|
|
|
}
|
|
|
|
|
return key + ":" + sessionKey
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bus.InboundContext) string {
|
|
|
|
|
if resolver, ok := ch.(toolFeedbackMessageTargetResolver); ok {
|
|
|
|
|
if resolved := strings.TrimSpace(resolver.ToolFeedbackMessageChatID(chatID, outboundCtx)); resolved != "" {
|
|
|
|
|
return resolved
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dismissTrackedToolFeedbackMessage(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
ch Channel,
|
|
|
|
|
chatID string,
|
|
|
|
|
outboundCtx *bus.InboundContext,
|
|
|
|
|
) {
|
|
|
|
|
trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
|
|
|
|
|
if trackedChatID == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok {
|
|
|
|
|
cleaner.DismissToolFeedbackMessage(ctx, trackedChatID)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
|
|
|
|
|
tracker.ClearToolFeedbackMessage(trackedChatID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 03:49:41 +00:00
|
|
|
func clearTrackedToolFeedbackMessage(
|
|
|
|
|
ch Channel,
|
|
|
|
|
chatID string,
|
|
|
|
|
outboundCtx *bus.InboundContext,
|
|
|
|
|
) {
|
|
|
|
|
trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
|
|
|
|
|
if trackedChatID == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
|
|
|
|
|
tracker.ClearToolFeedbackMessage(trackedChatID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 03:17:55 +00:00
|
|
|
// DismissToolFeedback clears any tracked tool feedback animation for the
|
|
|
|
|
// given channel/chat. This is called when a turn ends without a final
|
|
|
|
|
// response (e.g., ResponseHandled tools) to stop orphaned animation goroutines.
|
|
|
|
|
// outboundCtx carries topic/thread info for channels that use scoped tracker
|
|
|
|
|
// keys (e.g., Telegram forum topics); may be nil for non-topic channels.
|
|
|
|
|
func (m *Manager) DismissToolFeedback(
|
|
|
|
|
ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext,
|
|
|
|
|
) {
|
|
|
|
|
ch, ok := m.GetChannel(channelName)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
|
|
|
|
|
prepared := strings.TrimSpace(content)
|
|
|
|
|
if prepared == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if preparer, ok := ch.(toolFeedbackMessageContentPreparer); ok {
|
|
|
|
|
if candidate := strings.TrimSpace(preparer.PrepareToolFeedbackMessageContent(prepared)); candidate != "" {
|
|
|
|
|
return candidate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return prepared
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 03:49:41 +00:00
|
|
|
func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool {
|
|
|
|
|
if m == nil || m.config == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled()
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:22:15 +00:00
|
|
|
// SendPlaceholder sends a "Thinking…" placeholder for the given channel/chatID
|
|
|
|
|
// and records it for later editing. Returns true if a placeholder was sent.
|
|
|
|
|
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
|
|
|
|
|
m.mu.RLock()
|
|
|
|
|
ch, ok := m.channels[channel]
|
|
|
|
|
m.mu.RUnlock()
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
pc, ok := ch.(PlaceholderCapable)
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
phID, err := pc.SendPlaceholder(ctx, chatID)
|
|
|
|
|
if err != nil || phID == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
m.RecordPlaceholder(channel, chatID, phID)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
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-03-12 06:31:00 +00:00
|
|
|
entry := typingEntry{stop: stop, createdAt: time.Now()}
|
|
|
|
|
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
|
|
|
|
|
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
|
|
|
|
|
oldEntry.stop()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 00:22:04 +00:00
|
|
|
// InvokeTypingStop invokes the registered typing stop function for the given channel and chatID.
|
|
|
|
|
// It is safe to call even when no typing indicator is active (no-op).
|
|
|
|
|
// Used by the agent loop to stop typing when processing completes (success, error, or panic),
|
|
|
|
|
// regardless of whether an outbound message is published.
|
|
|
|
|
func (m *Manager) InvokeTypingStop(channel, chatID string) {
|
|
|
|
|
key := channel + ":" + chatID
|
|
|
|
|
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
|
|
|
|
if entry, ok := v.(typingEntry); ok {
|
|
|
|
|
entry.stop()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
// Returns the delivered message IDs and true when delivery completed before a normal Send.
|
|
|
|
|
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) {
|
2026-04-01 12:56:48 +00:00
|
|
|
chatID := outboundMessageChatID(msg)
|
|
|
|
|
key := name + ":" + chatID
|
2026-05-19 08:38:47 +00:00
|
|
|
streamKey := streamSuppressionKey(name, chatID, msg.SessionKey)
|
2026-02-22 20:55:15 +00:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
2026-05-19 08:38:47 +00:00
|
|
|
isAuxiliaryMessage := outboundMessageHasAuxiliaryKind(msg)
|
|
|
|
|
isFinalMessage := outboundMessageIsFinal(msg)
|
2026-04-24 03:49:41 +00:00
|
|
|
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
|
2026-04-23 02:35:50 +00:00
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// 3. If a stream already finalized this chat, stale auxiliary messages must
|
|
|
|
|
// be dropped without consuming the final-response marker. Streaming
|
|
|
|
|
// finalization bypasses the worker queue, so older queued feedback/thoughts
|
|
|
|
|
// can arrive before the normal final outbound message that cleans up the
|
|
|
|
|
// marker and placeholder.
|
|
|
|
|
if isAuxiliaryMessage {
|
|
|
|
|
if _, loaded := m.streamActive.Load(streamKey); loaded {
|
|
|
|
|
return nil, true
|
|
|
|
|
}
|
|
|
|
|
if m.streamAuxiliaryTombstoneActive(streamKey) {
|
2026-04-23 02:35:50 +00:00
|
|
|
return nil, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// 4. If a stream already finalized this turn, skip only the duplicate final
|
|
|
|
|
// outbound. Earlier queued visible messages must still be delivered.
|
|
|
|
|
if isFinalMessage {
|
|
|
|
|
if _, loaded := m.streamActive.LoadAndDelete(streamKey); loaded {
|
|
|
|
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
|
|
|
|
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 {
|
|
|
|
|
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
|
|
|
|
} else if editor, ok := ch.(MessageEditor); ok {
|
2026-05-20 05:42:21 +00:00
|
|
|
if payloadEditor, ok := ch.(MessageEditorWithPayload); ok {
|
|
|
|
|
_ = payloadEditor.EditMessageWithPayload(
|
|
|
|
|
ctx,
|
|
|
|
|
chatID,
|
|
|
|
|
entry.id,
|
|
|
|
|
outboundMessageEditPayload(msg, msg.Content),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
}
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
if !isToolFeedback {
|
|
|
|
|
if separateToolFeedbackMessages {
|
|
|
|
|
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
|
|
|
|
|
} else {
|
|
|
|
|
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context)
|
|
|
|
|
}
|
2026-04-24 03:49:41 +00:00
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
return nil, true
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, loaded := m.streamActive.Load(streamKey); loaded {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if m.streamActiveForChat(name, chatID) {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !isAuxiliaryMessage {
|
|
|
|
|
m.streamAuxiliaryTombstones.Delete(streamKey)
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 03:49:41 +00:00
|
|
|
if separateToolFeedbackMessages {
|
|
|
|
|
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
// 5. Try editing placeholder
|
2026-03-08 17:22:15 +00:00
|
|
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
|
|
|
|
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
2026-04-24 03:49:41 +00:00
|
|
|
if isToolFeedback && separateToolFeedbackMessages {
|
|
|
|
|
if deleter, ok := ch.(MessageDeleter); ok {
|
|
|
|
|
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
|
|
|
|
}
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-04-26 11:43:25 +00:00
|
|
|
if outboundMessageBypassesPlaceholderEdit(msg) {
|
|
|
|
|
if deleter, ok := ch.(MessageDeleter); ok {
|
|
|
|
|
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
|
|
|
|
}
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
2026-03-08 17:22:15 +00:00
|
|
|
if editor, ok := ch.(MessageEditor); ok {
|
2026-04-23 02:35:50 +00:00
|
|
|
content := msg.Content
|
|
|
|
|
trackedContent := msg.Content
|
|
|
|
|
if isToolFeedback {
|
|
|
|
|
trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content)
|
|
|
|
|
content = InitialAnimatedToolFeedbackContent(trackedContent)
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
err := func() error {
|
|
|
|
|
if payloadEditor, ok := ch.(MessageEditorWithPayload); ok {
|
|
|
|
|
return payloadEditor.EditMessageWithPayload(
|
|
|
|
|
ctx,
|
|
|
|
|
chatID,
|
|
|
|
|
entry.id,
|
|
|
|
|
outboundMessageEditPayload(msg, content),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return editor.EditMessage(ctx, chatID, entry.id, content)
|
|
|
|
|
}()
|
|
|
|
|
if err == nil {
|
2026-04-23 02:35:50 +00:00
|
|
|
trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context)
|
|
|
|
|
if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback {
|
|
|
|
|
tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent)
|
|
|
|
|
} else if !isToolFeedback {
|
|
|
|
|
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context)
|
|
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return []string{entry.id}, true
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
2026-03-08 17:22:15 +00:00
|
|
|
// edit failed → fall through to normal Send
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, false
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 12:47:23 +00:00
|
|
|
// preSendMedia handles typing stop, reaction undo, and placeholder cleanup
|
|
|
|
|
// before sending media attachments. Unlike preSend for text messages, media
|
|
|
|
|
// delivery never edits the placeholder because there is no text payload to
|
|
|
|
|
// 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) {
|
2026-04-01 12:56:48 +00:00
|
|
|
chatID := outboundMediaChatID(msg)
|
|
|
|
|
key := name + ":" + chatID
|
2026-05-19 08:38:47 +00:00
|
|
|
streamKey := streamSuppressionKey(name, chatID, msg.SessionKey)
|
2026-03-22 12:47:23 +00:00
|
|
|
|
|
|
|
|
// 1. Stop typing
|
|
|
|
|
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
|
|
|
|
|
if entry, ok := v.(typingEntry); ok {
|
|
|
|
|
entry.stop() // idempotent, safe
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Undo reaction
|
|
|
|
|
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
|
|
|
|
|
if entry, ok := v.(reactionEntry); ok {
|
|
|
|
|
entry.undo() // idempotent, safe
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// 3. Clear any finalized stream markers for this chat before media delivery.
|
|
|
|
|
m.streamActive.LoadAndDelete(streamKey)
|
|
|
|
|
m.streamAuxiliaryTombstones.Delete(streamKey)
|
2026-03-22 12:47:23 +00:00
|
|
|
|
2026-04-24 03:49:41 +00:00
|
|
|
if m.toolFeedbackSeparateMessagesEnabled() {
|
|
|
|
|
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 12:47:23 +00:00
|
|
|
// 4. Delete placeholder if present.
|
|
|
|
|
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
|
|
|
|
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
|
|
|
|
if deleter, ok := ch.(MessageDeleter); ok {
|
2026-04-01 12:56:48 +00:00
|
|
|
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
2026-03-22 12:47:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:05:10 +00:00
|
|
|
func NewManager(
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
messageBus *bus.MessageBus,
|
|
|
|
|
store media.MediaStore,
|
|
|
|
|
opts ...ManagerOption,
|
|
|
|
|
) (*Manager, error) {
|
2026-02-04 11:06:13 +00:00
|
|
|
m := &Manager{
|
2026-03-19 07:28:52 +00:00
|
|
|
channels: make(map[string]Channel),
|
|
|
|
|
workers: make(map[string]*channelWorker),
|
|
|
|
|
bus: messageBus,
|
|
|
|
|
config: cfg,
|
|
|
|
|
mediaStore: store,
|
|
|
|
|
channelHashes: make(map[string]string),
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-04-26 08:05:10 +00:00
|
|
|
for _, opt := range opts {
|
|
|
|
|
if opt != nil {
|
|
|
|
|
opt(m)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
// Register as streaming delegate so the agent loop can obtain streamers
|
|
|
|
|
messageBus.SetStreamDelegate(m)
|
|
|
|
|
|
2026-03-19 07:28:52 +00:00
|
|
|
if err := m.initChannels(&cfg.Channels); err != nil {
|
2026-02-04 11:06:13 +00:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 07:28:52 +00:00
|
|
|
// Store initial config hashes for all channels
|
|
|
|
|
m.channelHashes = toChannelHashes(cfg)
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 12:50:58 +00:00
|
|
|
// SetMediaStore updates the store used by the manager and every channel that
|
|
|
|
|
// accepts media store injection. Gateway reload creates a fresh store, so
|
|
|
|
|
// keeping existing channels on the same store as the agent is required for
|
|
|
|
|
// inbound media refs to remain resolvable after reload.
|
|
|
|
|
func (m *Manager) SetMediaStore(store media.MediaStore) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
m.mediaStore = store
|
|
|
|
|
for _, ch := range m.channels {
|
|
|
|
|
if setter, ok := ch.(mediaStoreSetter); ok {
|
|
|
|
|
setter.SetMediaStore(store)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
// GetStreamer implements bus.StreamDelegate.
|
|
|
|
|
// It checks if the named channel supports streaming and returns a Streamer.
|
2026-05-19 08:38:47 +00:00
|
|
|
func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID, sessionKey string) (bus.Streamer, bool) {
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
m.mu.RLock()
|
|
|
|
|
ch, exists := m.channels[channelName]
|
|
|
|
|
m.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if !exists {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sc, ok := ch.(StreamingCapable)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
streamer, err := sc.BeginStream(ctx, chatID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.DebugCF("channels", "Streaming unavailable, falling back to placeholder", map[string]any{
|
|
|
|
|
"channel": channelName,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark streamActive on Finalize so preSend knows to clean up the placeholder
|
2026-05-19 08:38:47 +00:00
|
|
|
// and late auxiliary messages cannot leak after streaming produced a final.
|
|
|
|
|
streamKey := streamSuppressionKey(channelName, chatID, sessionKey)
|
|
|
|
|
placeholderKey := channelName + ":" + chatID
|
|
|
|
|
clearMarker := func() {
|
|
|
|
|
m.streamActive.Delete(streamKey)
|
|
|
|
|
}
|
|
|
|
|
onFinalize := func(finalizeCtx context.Context, finalContent string) {
|
|
|
|
|
if m.toolFeedbackSeparateMessagesEnabled() {
|
|
|
|
|
clearTrackedToolFeedbackMessage(
|
|
|
|
|
ch,
|
|
|
|
|
chatID,
|
|
|
|
|
&bus.InboundContext{
|
|
|
|
|
Channel: channelName,
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
dismissTrackedToolFeedbackMessage(
|
|
|
|
|
finalizeCtx,
|
|
|
|
|
ch,
|
|
|
|
|
chatID,
|
|
|
|
|
&bus.InboundContext{
|
|
|
|
|
Channel: channelName,
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if v, loaded := m.placeholders.LoadAndDelete(placeholderKey); loaded {
|
|
|
|
|
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
|
|
|
|
if deleter, ok := ch.(MessageDeleter); ok {
|
|
|
|
|
deleter.DeleteMessage(finalizeCtx, chatID, entry.id) // best effort
|
|
|
|
|
} else if editor, ok := ch.(MessageEditor); ok {
|
|
|
|
|
editor.EditMessage(finalizeCtx, chatID, entry.id, finalContent) // best effort fallback
|
|
|
|
|
}
|
2026-04-24 03:49:41 +00:00
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
}
|
|
|
|
|
m.streamActive.Store(streamKey, true)
|
|
|
|
|
m.streamAuxiliaryTombstones.Store(streamKey, time.Now())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.config != nil && m.config.Agents.Defaults.SplitOnMarker {
|
|
|
|
|
return &splitMarkerStreamer{
|
|
|
|
|
current: streamer,
|
|
|
|
|
reasoning: reasoningStreamerFrom(streamer),
|
|
|
|
|
begin: func(beginCtx context.Context) (bus.Streamer, error) { return sc.BeginStream(beginCtx, chatID) },
|
|
|
|
|
onFinalize: onFinalize,
|
|
|
|
|
clearMarker: clearMarker,
|
|
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &finalizeHookStreamer{
|
|
|
|
|
Streamer: streamer,
|
|
|
|
|
clearMarker: clearMarker,
|
|
|
|
|
onFinalize: onFinalize,
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func reasoningStreamerFrom(streamer bus.Streamer) bus.ReasoningStreamer {
|
|
|
|
|
if reasoningStreamer, ok := streamer.(bus.ReasoningStreamer); ok {
|
|
|
|
|
return reasoningStreamer
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
type modelNameStreamer interface {
|
|
|
|
|
SetModelName(modelName string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setStreamerModelName(streamer any, modelName string) {
|
|
|
|
|
setter, ok := streamer.(modelNameStreamer)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setter.SetModelName(modelName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// splitMarkerStreamer turns accumulated streaming text containing
|
|
|
|
|
// MessageSplitMarker into separate channel stream messages.
|
|
|
|
|
type splitMarkerStreamer struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
current bus.Streamer
|
|
|
|
|
reasoning bus.ReasoningStreamer
|
|
|
|
|
begin func(context.Context) (bus.Streamer, error)
|
|
|
|
|
completedParts int
|
|
|
|
|
finalized bool
|
|
|
|
|
onFinalize func(context.Context, string)
|
|
|
|
|
clearMarker func()
|
2026-05-20 05:42:21 +00:00
|
|
|
modelName string
|
2026-05-19 08:38:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) Update(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
return s.updateLocked(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) Finalize(ctx context.Context, content string) error {
|
|
|
|
|
return s.FinalizeWithContext(ctx, content, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) FinalizeWithContext(ctx context.Context, content string, usage *bus.ContextUsage) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if err := s.finalizeLocked(ctx, content, usage); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.runFinalizeHook(ctx, content)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) UpdateReasoning(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.reasoning == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
setStreamerModelName(s.reasoning, s.modelName)
|
2026-05-19 08:38:47 +00:00
|
|
|
return s.reasoning.UpdateReasoning(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) FinalizeReasoning(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.reasoning == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-05-20 05:42:21 +00:00
|
|
|
setStreamerModelName(s.reasoning, s.modelName)
|
2026-05-19 08:38:47 +00:00
|
|
|
return s.reasoning.FinalizeReasoning(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
func (s *splitMarkerStreamer) SetModelName(modelName string) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
s.modelName = strings.TrimSpace(modelName)
|
|
|
|
|
setStreamerModelName(s.current, s.modelName)
|
|
|
|
|
setStreamerModelName(s.reasoning, s.modelName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func (s *splitMarkerStreamer) Cancel(ctx context.Context) {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
if s.current != nil {
|
|
|
|
|
s.current.Cancel(ctx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) ClearFinalizedStreamMarker() {
|
|
|
|
|
if s.clearMarker != nil {
|
|
|
|
|
s.clearMarker()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) updateLocked(ctx context.Context, content string) error {
|
|
|
|
|
parts := strings.Split(content, MessageSplitMarker)
|
|
|
|
|
completedLimit := len(parts) - 1
|
|
|
|
|
if err := s.finalizeCompletedPartsLocked(ctx, parts, completedLimit, nil); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
active := strings.TrimSpace(parts[len(parts)-1])
|
|
|
|
|
if active == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := s.ensureCurrentLocked(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return s.current.Update(ctx, active)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) finalizeLocked(ctx context.Context, content string, usage *bus.ContextUsage) error {
|
|
|
|
|
parts := strings.Split(content, MessageSplitMarker)
|
|
|
|
|
return s.finalizeCompletedPartsLocked(ctx, parts, len(parts), usage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) finalizeCompletedPartsLocked(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
parts []string,
|
|
|
|
|
limit int,
|
|
|
|
|
usage *bus.ContextUsage,
|
|
|
|
|
) error {
|
|
|
|
|
for s.completedParts < limit {
|
|
|
|
|
content := strings.TrimSpace(parts[s.completedParts])
|
|
|
|
|
isLast := s.completedParts == limit-1
|
|
|
|
|
if content != "" {
|
|
|
|
|
if err := s.ensureCurrentLocked(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if isLast && usage != nil {
|
|
|
|
|
if contextStreamer, ok := s.current.(bus.ContextUsageStreamer); ok {
|
|
|
|
|
if err := contextStreamer.FinalizeWithContext(ctx, content, usage); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else if err := s.current.Finalize(ctx, content); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else if err := s.current.Finalize(ctx, content); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.current = nil
|
|
|
|
|
}
|
|
|
|
|
s.completedParts++
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) ensureCurrentLocked(ctx context.Context) error {
|
|
|
|
|
if s.current != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if s.begin == nil {
|
|
|
|
|
return fmt.Errorf("streamer is not initialized")
|
|
|
|
|
}
|
|
|
|
|
streamer, err := s.begin(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.current = streamer
|
2026-05-20 05:42:21 +00:00
|
|
|
setStreamerModelName(s.current, s.modelName)
|
2026-05-19 08:38:47 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *splitMarkerStreamer) runFinalizeHook(ctx context.Context, content string) {
|
|
|
|
|
if s.finalized {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
s.finalized = true
|
|
|
|
|
if s.onFinalize != nil {
|
|
|
|
|
s.onFinalize(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) streamAuxiliaryTombstoneActive(key string) bool {
|
|
|
|
|
v, ok := m.streamAuxiliaryTombstones.Load(key)
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
createdAt, ok := v.(time.Time)
|
|
|
|
|
if !ok || time.Since(createdAt) > streamAuxiliaryTombstoneTTL {
|
|
|
|
|
m.streamAuxiliaryTombstones.Delete(key)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) streamActiveForChat(channel, chatID string) bool {
|
|
|
|
|
chatKey := streamSuppressionKey(channel, chatID, "")
|
|
|
|
|
found := false
|
|
|
|
|
m.streamActive.Range(func(key, _ any) bool {
|
|
|
|
|
keyString, ok := key.(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if keyString == chatKey || strings.HasPrefix(keyString, chatKey+":") {
|
|
|
|
|
found = true
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
return found
|
|
|
|
|
}
|
|
|
|
|
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
// finalizeHookStreamer wraps a Streamer to run a hook on Finalize.
|
|
|
|
|
type finalizeHookStreamer struct {
|
|
|
|
|
Streamer
|
2026-05-19 08:38:47 +00:00
|
|
|
onFinalize func(context.Context, string)
|
|
|
|
|
clearMarker func()
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error {
|
|
|
|
|
if err := s.Streamer.Finalize(ctx, content); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-05-19 08:38:47 +00:00
|
|
|
s.runFinalizeHook(ctx, content)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *finalizeHookStreamer) FinalizeWithContext(ctx context.Context, content string, usage *bus.ContextUsage) error {
|
|
|
|
|
if streamer, ok := s.Streamer.(bus.ContextUsageStreamer); ok {
|
|
|
|
|
if err := streamer.FinalizeWithContext(ctx, content, usage); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else if err := s.Streamer.Finalize(ctx, content); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
s.runFinalizeHook(ctx, content)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *finalizeHookStreamer) UpdateReasoning(ctx context.Context, content string) error {
|
|
|
|
|
if streamer, ok := s.Streamer.(bus.ReasoningStreamer); ok {
|
|
|
|
|
return streamer.UpdateReasoning(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *finalizeHookStreamer) FinalizeReasoning(ctx context.Context, content string) error {
|
|
|
|
|
if streamer, ok := s.Streamer.(bus.ReasoningStreamer); ok {
|
|
|
|
|
return streamer.FinalizeReasoning(ctx, content)
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
feat(telegram): stream LLM responses via sendMessageDraft (#1101)
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* feat(telegram): stream LLM responses in real-time via sendMessageDraft
Implements real-time token streaming to Telegram using the sendMessageDraft
API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder
until the full response arrives, users now see partial LLM output appear
in the chat as it's generated.
The streaming pipeline threads through all layers:
- StreamingProvider interface (providers/types.go): opt-in ChatStream()
method that receives an onChunk callback with accumulated text
- OpenAI-compatible SSE streaming (openai_compat/provider.go): parses
SSE events with stream:true, handles text deltas and tool call assembly
- Anthropic native streaming (anthropic/provider.go): uses SDK's
NewStreaming() for direct Anthropic API connections
- HTTPProvider delegation (http_provider.go): delegates ChatStream to
the underlying openai_compat provider
- StreamingCapable + Streamer interfaces (channels/interfaces.go):
opt-in channel capability like TypingCapable/PlaceholderCapable
- Telegram streamer (telegram/telegram.go): BeginStream returns a
telegramStreamer that throttles sendMessageDraft calls (3s/200 chars)
with graceful degradation on API errors
- StreamDelegate bridge (bus/bus.go): decouples agent loop from channel
manager without tight imports
- Manager integration (manager.go): implements StreamDelegate, tracks
streamActive state, coordinates with placeholder editing
- Agent loop (loop.go): uses ChatStream when both provider and channel
support streaming, cancels stream on tool calls, skips PublishOutbound
when Finalize already delivered the message
Graceful degradation:
- Bots without forum/topics mode: first sendMessageDraft error sets
failed=true, subsequent Updates become no-ops, Finalize still delivers
via SendMessage. User sees normal non-streaming behavior.
- Non-streaming providers: type assertion fails, falls back to Chat()
- Config opt-out: streaming.enabled (default true) in telegram config
Closes #1098
* fix(telegram): delete placeholder message when streaming delivers response
When streaming was active, the "Thinking..." placeholder message stayed
in the chat because preSend only deleted the tracking entry without
removing the actual Telegram message. Now preSend deletes the placeholder
via the new MessageDeleter interface when streamActive is set.
* refactor(streaming): remove dead code and simplify streaming wiring
- Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory
creates HTTPProvider for all OpenAI-compat providers including OpenRouter
- Simplify runLLMIteration from 4 to 3 return values (remove unused
streamed bool)
- Replace managerStreamer struct with finalizeHookStreamer using embedding
(Update/Cancel promoted, only Finalize overridden)
* fix(streaming): skip streamer acquisition when SendResponse is false
Heartbeat messages set SendResponse=false but the streaming path
was unconditionally acquiring a streamer, causing HEARTBEAT_OK to
leak to Telegram via streamer.Finalize().
* fix(streaming): guard streamer for non-sendable messages, add streaming config
Skip streamer acquisition for heartbeat (NoHistory=true), preventing
HEARTBEAT_OK from leaking to Telegram via streamer.Finalize().
Add streaming.enabled to Telegram defaults and example config.
* fix(picoclaw): add missing closing brace for StreamingProvider interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve golangci-lint formatting issues
Fix gci import ordering in telegram and anthropic provider, and break
long function signature in openai_compat provider to satisfy golines.
* fix: address code review feedback on streaming PR
- Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer
to prevent type drift across packages
- Increase SSE scanner buffer to 10MB max to handle large single-line
responses that exceed bufio.Scanner's 64KB default
- Switch draftID generation from math/rand to crypto/rand for
collision-resistant random IDs
- Add context cancellation check in SSE parsing loop so cancelled
streams stop processing immediately
- Log Finalize failures with chat_id and content length for debugging
silent message delivery failures
* feat: make streaming throttle interval and min growth configurable
Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200)
into StreamingConfig so they can be tuned per deployment via config
or environment variables.
* fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream
These two functions called undefined parseChatID. Use
parseTelegramChatID with _ for the unused threadID instead of adding
a wrapper function. Fixes all three CI checks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(streaming): set streamActive only after successful Finalize
Move onFinalize hook to run after Streamer.Finalize succeeds, so that
if Finalize fails the streamActive flag stays false and the regular
placeholder fallback path remains available.
Addresses review feedback from @alexhoshina.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 05:42:21 +00:00
|
|
|
func (s *finalizeHookStreamer) SetModelName(modelName string) {
|
|
|
|
|
setStreamerModelName(s.Streamer, strings.TrimSpace(modelName))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func (s *finalizeHookStreamer) runFinalizeHook(ctx context.Context, content string) {
|
|
|
|
|
if s.onFinalize != nil {
|
|
|
|
|
s.onFinalize(ctx, content)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *finalizeHookStreamer) ClearFinalizedStreamMarker() {
|
|
|
|
|
if s.clearMarker != nil {
|
|
|
|
|
s.clearMarker()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
// initChannel is a helper that looks up a factory by type name and creates the channel.
|
|
|
|
|
// typeName is the channel type used for factory lookup (e.g., "telegram").
|
|
|
|
|
// channelName is the config map key used as the channel's runtime name (e.g., "my_telegram").
|
|
|
|
|
func (m *Manager) initChannel(typeName, channelName string) {
|
|
|
|
|
f, ok := getFactory(typeName)
|
2026-02-20 15:19:40 +00:00
|
|
|
if !ok {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.WarnCF("channels", "Factory not registered", map[string]any{
|
2026-04-11 16:57:26 +00:00
|
|
|
"channel": channelName,
|
|
|
|
|
"type": typeName,
|
2026-02-20 15:19:40 +00:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{
|
2026-04-11 16:57:26 +00:00
|
|
|
"channel": channelName,
|
|
|
|
|
"type": typeName,
|
2026-02-20 15:19:40 +00:00
|
|
|
})
|
2026-04-11 16:57:26 +00:00
|
|
|
ch, err := f(channelName, typeName, m.config, m.bus)
|
2026-02-20 15:19:40 +00:00
|
|
|
if err != nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{
|
2026-04-11 16:57:26 +00:00
|
|
|
"channel": channelName,
|
|
|
|
|
"type": typeName,
|
2026-02-20 15:19:40 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
} else {
|
2026-02-22 15:27:55 +00:00
|
|
|
// Inject MediaStore if channel supports it
|
|
|
|
|
if m.mediaStore != nil {
|
2026-05-06 12:50:58 +00:00
|
|
|
if setter, ok := ch.(mediaStoreSetter); ok {
|
2026-02-22 15:27:55 +00:00
|
|
|
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-04-11 16:57:26 +00:00
|
|
|
m.channels[channelName] = ch
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleInitialized,
|
|
|
|
|
channelName,
|
|
|
|
|
runtimeevents.Scope{Channel: channelName},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: typeName},
|
|
|
|
|
)
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
|
2026-04-11 16:57:26 +00:00
|
|
|
"channel": channelName,
|
|
|
|
|
"type": typeName,
|
2026-02-20 15:19:40 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channel, bool) {
|
|
|
|
|
bc, ok := m.config.Channels[channelName]
|
|
|
|
|
if !ok || bc == nil {
|
|
|
|
|
return nil, false
|
2026-03-22 06:23:39 +00:00
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
if !bc.Enabled {
|
|
|
|
|
return bc, false
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
// Use Type to determine the config struct for validation.
|
|
|
|
|
// The map key (channelName) is the config key, which may differ from the type.
|
|
|
|
|
channelType := bc.Type
|
|
|
|
|
if channelType == "" {
|
|
|
|
|
channelType = channelName
|
2026-03-20 12:43:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
// Settings have already been decoded by InitChannelList, so we just need to
|
|
|
|
|
// type-assert and check the relevant fields.
|
|
|
|
|
decoded, err := bc.GetDecoded()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return bc, false
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
//nolint:revive
|
|
|
|
|
switch settings := decoded.(type) {
|
|
|
|
|
case *config.WhatsAppSettings:
|
|
|
|
|
if channelType == config.ChannelWhatsApp {
|
|
|
|
|
return bc, settings.BridgeURL != ""
|
|
|
|
|
}
|
|
|
|
|
return bc, channelType == config.ChannelWhatsAppNative && settings.UseNative
|
|
|
|
|
case *config.MatrixSettings:
|
|
|
|
|
return bc, settings.Homeserver != "" && settings.UserID != "" && settings.AccessToken.String() != ""
|
|
|
|
|
case *config.WeComSettings:
|
|
|
|
|
return bc, settings.BotID != "" && settings.Secret.String() != ""
|
|
|
|
|
case *config.PicoClientSettings:
|
|
|
|
|
return bc, settings.URL != ""
|
|
|
|
|
case *config.DingTalkSettings:
|
|
|
|
|
return bc, settings.ClientID != ""
|
|
|
|
|
case *config.SlackSettings:
|
|
|
|
|
return bc, settings.BotToken.String() != ""
|
|
|
|
|
case *config.WeixinSettings:
|
|
|
|
|
return bc, settings.Token.String() != ""
|
|
|
|
|
case *config.PicoSettings:
|
|
|
|
|
return bc, settings.Token.String() != ""
|
|
|
|
|
case *config.IRCSettings:
|
|
|
|
|
return bc, settings.Server != ""
|
|
|
|
|
case *config.LINESettings:
|
|
|
|
|
return bc, settings.ChannelAccessToken.String() != ""
|
|
|
|
|
case *config.OneBotSettings:
|
|
|
|
|
return bc, settings.WSUrl != ""
|
|
|
|
|
case *config.QQSettings:
|
|
|
|
|
return bc, settings.AppSecret.String() != ""
|
|
|
|
|
case *config.TelegramSettings:
|
|
|
|
|
return bc, settings.Token.String() != ""
|
|
|
|
|
case *config.FeishuSettings:
|
|
|
|
|
return bc, settings.AppSecret.String() != ""
|
|
|
|
|
case *config.MaixCamSettings:
|
|
|
|
|
return bc, true
|
|
|
|
|
case *config.TeamsWebhookSettings:
|
2026-05-11 07:16:18 +00:00
|
|
|
return bc, true
|
|
|
|
|
case *config.SlackWebhookSettings:
|
2026-04-11 16:57:26 +00:00
|
|
|
return bc, true
|
|
|
|
|
case *config.DiscordSettings:
|
|
|
|
|
return bc, settings.Token.String() != ""
|
|
|
|
|
case *config.VKSettings:
|
|
|
|
|
return bc, settings.GroupID != 0 && settings.Token.String() != ""
|
2026-04-29 03:18:16 +00:00
|
|
|
case *config.MQTTSettings:
|
|
|
|
|
return bc, settings.Broker != "" && settings.AgentID != ""
|
2026-04-11 16:57:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return bc, bc.Enabled
|
|
|
|
|
}
|
2026-03-05 10:46:01 +00:00
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
// initChannels initializes all enabled channels based on the configuration.
|
|
|
|
|
// It iterates config entries and uses bc.Type to look up the appropriate factory.
|
|
|
|
|
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
|
|
|
|
logger.InfoC("channels", "Initializing channel manager")
|
2026-04-03 02:56:26 +00:00
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
for name, bc := range *channels {
|
|
|
|
|
if !bc.Enabled {
|
|
|
|
|
continue
|
2026-04-07 11:24:27 +00:00
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
_, ready := m.getChannelConfigAndEnabled(name)
|
|
|
|
|
if !ready {
|
|
|
|
|
continue
|
2026-04-07 11:24:27 +00:00
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
typeName := bc.Type
|
|
|
|
|
if typeName == "" {
|
|
|
|
|
typeName = name
|
|
|
|
|
}
|
|
|
|
|
m.initChannel(typeName, name)
|
2026-04-07 11:24:27 +00:00
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-04-14 04:43:49 +00:00
|
|
|
m.SetupHTTPServerListeners(nil, addr, healthServer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetupHTTPServerListeners creates a shared HTTP server on pre-opened listeners.
|
|
|
|
|
// When listeners is empty it falls back to Addr-based ListenAndServe behavior.
|
|
|
|
|
func (m *Manager) SetupHTTPServerListeners(listeners []net.Listener, addr string, healthServer *health.Server) {
|
2026-03-28 03:30:31 +00:00
|
|
|
m.mux = newDynamicServeMux()
|
2026-02-22 18:39:09 +00:00
|
|
|
|
|
|
|
|
// Register health endpoints
|
|
|
|
|
if healthServer != nil {
|
|
|
|
|
healthServer.RegisterOnMux(m.mux)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Discover and register webhook handlers and health checkers
|
2026-03-28 03:30:31 +00:00
|
|
|
m.registerHTTPHandlersLocked()
|
2026-02-22 18:39:09 +00:00
|
|
|
|
|
|
|
|
m.httpServer = &http.Server{
|
|
|
|
|
Addr: addr,
|
|
|
|
|
Handler: m.mux,
|
|
|
|
|
ReadTimeout: 30 * time.Second,
|
|
|
|
|
WriteTimeout: 30 * time.Second,
|
|
|
|
|
}
|
2026-04-14 04:43:49 +00:00
|
|
|
m.httpListeners = append([]net.Listener(nil), listeners...)
|
2026-02-22 18:39:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 03:30:31 +00:00
|
|
|
// registerHTTPHandlersLocked registers webhook and health-check handlers for
|
|
|
|
|
// all channels currently in m.channels. Caller must hold m.mu (or ensure
|
|
|
|
|
// exclusive access).
|
|
|
|
|
func (m *Manager) registerHTTPHandlersLocked() {
|
|
|
|
|
for name, ch := range m.channels {
|
|
|
|
|
m.registerChannelHTTPHandler(name, ch)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerChannelHTTPHandler registers the webhook/health handlers for a
|
|
|
|
|
// single channel onto m.mux.
|
|
|
|
|
func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) {
|
|
|
|
|
if wh, ok := ch.(WebhookHandler); ok {
|
|
|
|
|
m.mux.Handle(wh.WebhookPath(), wh)
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelWebhookRegistered,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
|
|
|
|
|
)
|
2026-03-28 03:30:31 +00:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// unregisterChannelHTTPHandler removes the webhook/health handlers for a
|
|
|
|
|
// single channel from m.mux.
|
|
|
|
|
func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) {
|
|
|
|
|
if wh, ok := ch.(WebhookHandler); ok {
|
|
|
|
|
m.mux.Unhandle(wh.WebhookPath())
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelWebhookUnregistered,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
|
|
|
|
|
)
|
2026-03-28 03:30:31 +00:00
|
|
|
logger.InfoCF("channels", "Webhook handler unregistered", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
"path": wh.WebhookPath(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if hc, ok := ch.(HealthChecker); ok {
|
|
|
|
|
m.mux.Unhandle(hc.HealthPath())
|
|
|
|
|
logger.InfoCF("channels", "Health endpoint unregistered", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
"path": hc.HealthPath(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.InfoC("channels", "Starting all channels")
|
|
|
|
|
|
|
|
|
|
dispatchCtx, cancel := context.WithCancel(ctx)
|
|
|
|
|
m.dispatchTask = &asyncTask{cancel: cancel}
|
2026-04-07 13:19:11 +00:00
|
|
|
failedStarts := make([]error, 0, len(m.channels))
|
|
|
|
|
failedNames := make([]string, 0, len(m.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", "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-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleStartFailed,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityError,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()},
|
|
|
|
|
)
|
2026-04-07 13:19:11 +00:00
|
|
|
failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
|
|
|
|
|
failedNames = append(failedNames, name)
|
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
|
2026-04-11 16:57:26 +00:00
|
|
|
channelType := name
|
|
|
|
|
if m.config != nil {
|
|
|
|
|
if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" {
|
|
|
|
|
channelType = bc.Type
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w := newChannelWorker(name, channel, channelType)
|
2026-02-24 14:30:22 +00:00
|
|
|
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-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleStarted,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelType},
|
|
|
|
|
)
|
2026-02-22 14:46:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
if len(m.channels) > 0 && len(m.workers) == 0 {
|
|
|
|
|
if m.dispatchTask != nil {
|
|
|
|
|
m.dispatchTask.cancel()
|
|
|
|
|
m.dispatchTask = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sort.Strings(failedNames)
|
|
|
|
|
if len(failedStarts) == 0 {
|
|
|
|
|
return fmt.Errorf("failed to start any enabled channels")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{
|
|
|
|
|
"failed": len(failedNames),
|
|
|
|
|
"total": len(m.channels),
|
|
|
|
|
"failed_channels": failedNames,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(failedNames) > 0 {
|
|
|
|
|
sort.Strings(failedNames)
|
|
|
|
|
logger.WarnCF("channels", "Some channels failed to start", map[string]any{
|
|
|
|
|
"failed": len(failedNames),
|
|
|
|
|
"started": len(m.workers),
|
|
|
|
|
"total": len(m.channels),
|
|
|
|
|
"failed_channels": failedNames,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-04-14 04:43:49 +00:00
|
|
|
if len(m.httpListeners) > 0 {
|
|
|
|
|
for _, listener := range m.httpListeners {
|
|
|
|
|
ln := listener
|
|
|
|
|
go func() {
|
|
|
|
|
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
|
|
|
|
|
"addr": ln.Addr().String(),
|
|
|
|
|
})
|
|
|
|
|
if err := m.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
|
|
|
logger.FatalCF("channels", "Shared HTTP server error", map[string]any{
|
|
|
|
|
"addr": ln.Addr().String(),
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-02-22 18:39:09 +00:00
|
|
|
}
|
2026-04-14 04:43:49 +00:00
|
|
|
} else {
|
|
|
|
|
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.FatalCF("channels", "Shared HTTP server error", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
2026-02-22 18:39:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 13:19:11 +00:00
|
|
|
logger.InfoCF("channels", "Channel startup completed", map[string]any{
|
|
|
|
|
"started": len(m.workers),
|
|
|
|
|
"failed": len(failedNames),
|
|
|
|
|
"total": len(m.channels),
|
|
|
|
|
})
|
2026-02-04 11:06:13 +00:00
|
|
|
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
|
2026-04-14 04:43:49 +00:00
|
|
|
m.httpListeners = nil
|
2026-02-22 18:39:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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(),
|
|
|
|
|
})
|
2026-04-26 08:05:10 +00:00
|
|
|
continue
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleStopped,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
|
|
|
|
|
)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-11 16:57:26 +00:00
|
|
|
// for the given channel type. channelType is used for rate limit lookup.
|
|
|
|
|
func newChannelWorker(name string, ch Channel, channelType string) *channelWorker {
|
2026-02-22 15:51:55 +00:00
|
|
|
rateVal := float64(defaultRateLimit)
|
2026-04-11 16:57:26 +00:00
|
|
|
if r, ok := channelRateConfig[channelType]; ok {
|
2026-02-22 15:51:55 +00:00
|
|
|
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-03-25 17:33:49 +00:00
|
|
|
// runWorker processes outbound messages for a single channel.
|
|
|
|
|
// Message processing follows this order:
|
|
|
|
|
// 1. SplitByMarker (if enabled in config) - LLM semantic marker-based splitting
|
|
|
|
|
// 2. SplitMessage - channel-specific length-based splitting (MaxMessageLength)
|
2026-02-22 14:46:29 +00:00
|
|
|
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()
|
|
|
|
|
}
|
2026-03-25 17:33:49 +00:00
|
|
|
|
|
|
|
|
// Collect all message chunks to send
|
|
|
|
|
var chunks []string
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
// Step 1: Try marker-based splitting if enabled.
|
|
|
|
|
// Tool feedback must stay a single message, so it skips marker splitting.
|
2026-05-19 08:38:47 +00:00
|
|
|
// Stream-final duplicate responses must also stay intact so preSend can
|
|
|
|
|
// consume the whole final message before any marker chunk leaks.
|
|
|
|
|
if m.finalizedStreamActiveForMessage(name, msg) {
|
|
|
|
|
chunks = []string{msg.Content}
|
|
|
|
|
} else if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) {
|
2026-03-25 17:33:49 +00:00
|
|
|
if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 {
|
|
|
|
|
for _, chunk := range markerChunks {
|
2026-04-23 02:35:50 +00:00
|
|
|
chunkMsg := msg
|
|
|
|
|
chunkMsg.Content = chunk
|
|
|
|
|
chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...)
|
2026-03-25 17:33:49 +00:00
|
|
|
}
|
2026-02-22 14:46:29 +00:00
|
|
|
}
|
2026-03-25 17:33:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: Fallback to length-based splitting if no chunks from marker
|
|
|
|
|
if len(chunks) == 0 {
|
2026-04-23 02:35:50 +00:00
|
|
|
chunks = splitOutboundMessageContent(msg, maxLen)
|
2026-03-25 17:33:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 3: Send all chunks
|
|
|
|
|
for _, chunk := range chunks {
|
|
|
|
|
chunkMsg := msg
|
|
|
|
|
chunkMsg.Content = chunk
|
|
|
|
|
m.sendWithRetry(ctx, name, w, chunkMsg)
|
2026-02-22 15:51:55 +00:00
|
|
|
}
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
func (m *Manager) finalizedStreamActiveForMessage(channelName string, msg bus.OutboundMessage) bool {
|
|
|
|
|
if m == nil || !outboundMessageIsFinal(msg) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
chatID := outboundMessageChatID(msg)
|
|
|
|
|
if strings.TrimSpace(channelName) == "" || strings.TrimSpace(chatID) == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
_, active := m.streamActive.Load(streamSuppressionKey(channelName, chatID, msg.SessionKey))
|
|
|
|
|
return active
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
// splitOutboundMessageContent splits regular outbound content by maxLen, but
|
|
|
|
|
// keeps tool feedback in a single message by truncating the explanation body.
|
|
|
|
|
func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string {
|
|
|
|
|
if maxLen > 0 {
|
|
|
|
|
if outboundMessageIsToolFeedback(msg) {
|
|
|
|
|
animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength()
|
|
|
|
|
if animationSafeLen <= 0 {
|
|
|
|
|
animationSafeLen = maxLen
|
|
|
|
|
}
|
|
|
|
|
if len([]rune(msg.Content)) > animationSafeLen {
|
|
|
|
|
return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)}
|
|
|
|
|
}
|
|
|
|
|
return []string{msg.Content}
|
|
|
|
|
}
|
|
|
|
|
if len([]rune(msg.Content)) > maxLen {
|
|
|
|
|
return SplitMessage(msg.Content, maxLen)
|
|
|
|
|
}
|
2026-03-25 17:33:49 +00:00
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
return []string{msg.Content}
|
2026-03-25 17:33:49 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:51:55 +00:00
|
|
|
// 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
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (m *Manager) sendWithRetry(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
name string,
|
|
|
|
|
w *channelWorker,
|
|
|
|
|
msg bus.OutboundMessage,
|
|
|
|
|
) ([]string, bool) {
|
2026-02-22 15:51:55 +00:00
|
|
|
// 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-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelRateLimited,
|
|
|
|
|
name,
|
|
|
|
|
scopeFromOutboundContext(msg.Context),
|
|
|
|
|
runtimeevents.SeverityWarn,
|
|
|
|
|
ChannelOutboundPayload{
|
|
|
|
|
ContentLen: len([]rune(msg.Content)),
|
|
|
|
|
ReplyToMessageID: msg.ReplyToMessageID,
|
|
|
|
|
Error: err.Error(),
|
|
|
|
|
},
|
|
|
|
|
)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, false
|
2026-02-22 15:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// Pre-send: stop typing and try to edit placeholder
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled {
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundSent(name, msg, msgIDs)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return msgIDs, true
|
2026-02-22 20:55:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:51:55 +00:00
|
|
|
var lastErr error
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
var msgIDs []string
|
2026-02-22 15:51:55 +00:00
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
msgIDs, lastErr = w.ch.Send(ctx, msg)
|
2026-02-22 15:51:55 +00:00
|
|
|
if lastErr == nil {
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundSent(name, msg, msgIDs)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return msgIDs, true
|
2026-02-22 15:51:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, false
|
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():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, false
|
2026-02-22 14:46:29 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:51:55 +00:00
|
|
|
|
|
|
|
|
// All retries exhausted or permanent failure
|
|
|
|
|
logger.ErrorCF("channels", "Send failed", map[string]any{
|
|
|
|
|
"channel": name,
|
2026-04-01 12:56:48 +00:00
|
|
|
"chat_id": outboundMessageChatID(msg),
|
2026-02-22 15:51:55 +00:00
|
|
|
"error": lastErr.Error(),
|
|
|
|
|
"retries": maxRetries,
|
|
|
|
|
})
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundFailed(name, msg, lastErr, false)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
|
|
|
|
|
return nil, false
|
2026-02-22 14:46:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 07:17:32 +00:00
|
|
|
func dispatchLoop[M any](
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
m *Manager,
|
2026-03-17 16:12:12 +00:00
|
|
|
ch <-chan M,
|
2026-03-01 07:17:32 +00:00
|
|
|
getChannel func(M) string,
|
|
|
|
|
enqueue func(context.Context, *channelWorker, M) bool,
|
|
|
|
|
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
|
|
|
|
|
) {
|
|
|
|
|
logger.InfoC("channels", startMsg)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
for {
|
2026-03-17 16:12:12 +00:00
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
2026-03-01 07:17:32 +00:00
|
|
|
logger.InfoC("channels", stopMsg)
|
2026-02-04 11:06:13 +00:00
|
|
|
return
|
|
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
case msg, ok := <-ch:
|
|
|
|
|
if !ok {
|
|
|
|
|
logger.InfoC("channels", stopMsg)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-01 07:17:32 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
channel := getChannel(msg)
|
2026-02-13 03:13:32 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
// Silently skip internal channels
|
|
|
|
|
if constants.IsInternalChannel(channel) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
m.mu.RLock()
|
|
|
|
|
_, exists := m.channels[channel]
|
|
|
|
|
w, wExists := m.workers[channel]
|
|
|
|
|
m.mu.RUnlock()
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-03-17 16:12:12 +00:00
|
|
|
if !exists {
|
|
|
|
|
logger.WarnCF("channels", unknownMsg, map[string]any{"channel": channel})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wExists && w != nil {
|
|
|
|
|
if !enqueue(ctx, w, msg) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else if exists {
|
|
|
|
|
logger.WarnCF("channels", noWorkerMsg, map[string]any{"channel": channel})
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 07:17:32 +00:00
|
|
|
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|
|
|
|
dispatchLoop(
|
|
|
|
|
ctx, m,
|
2026-03-17 16:12:12 +00:00
|
|
|
m.bus.OutboundChan(),
|
2026-04-01 12:56:48 +00:00
|
|
|
func(msg bus.OutboundMessage) string { return outboundMessageChannel(msg) },
|
2026-03-01 07:17:32 +00:00
|
|
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
|
|
|
|
|
select {
|
|
|
|
|
case w.queue <- msg:
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundQueued(outboundMessageChannel(msg), msg)
|
2026-03-01 07:17:32 +00:00
|
|
|
return true
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"Outbound dispatcher started",
|
|
|
|
|
"Outbound dispatcher stopped",
|
|
|
|
|
"Unknown channel for outbound message",
|
|
|
|
|
"Channel has no active worker, skipping message",
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-22 19:10:57 +00:00
|
|
|
|
2026-03-01 07:17:32 +00:00
|
|
|
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
|
|
|
|
dispatchLoop(
|
|
|
|
|
ctx, m,
|
2026-03-17 16:12:12 +00:00
|
|
|
m.bus.OutboundMediaChan(),
|
2026-04-01 12:56:48 +00:00
|
|
|
func(msg bus.OutboundMediaMessage) string { return outboundMediaChannel(msg) },
|
2026-03-01 07:17:32 +00:00
|
|
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
|
2026-02-24 14:30:22 +00:00
|
|
|
select {
|
|
|
|
|
case w.mediaQueue <- msg:
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundMediaQueued(outboundMediaChannel(msg), msg)
|
2026-03-01 07:17:32 +00:00
|
|
|
return true
|
2026-02-24 14:30:22 +00:00
|
|
|
case <-ctx.Done():
|
2026-03-01 07:17:32 +00:00
|
|
|
return false
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
2026-03-01 07:17:32 +00:00
|
|
|
},
|
|
|
|
|
"Outbound media dispatcher started",
|
|
|
|
|
"Outbound media dispatcher stopped",
|
|
|
|
|
"Unknown channel for outbound media message",
|
|
|
|
|
"Channel has no active worker, skipping media message",
|
|
|
|
|
)
|
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
|
|
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
_, _ = m.sendMediaWithRetry(ctx, name, w, msg)
|
2026-02-22 19:10:57 +00:00
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sendMediaWithRetry sends a media message through the channel with rate limiting and
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
// retry logic. It returns the message IDs and nil on success, or nil and the last error
|
|
|
|
|
// after retries, including when the channel does not support MediaSender.
|
2026-03-22 11:05:28 +00:00
|
|
|
func (m *Manager) sendMediaWithRetry(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
name string,
|
|
|
|
|
w *channelWorker,
|
|
|
|
|
msg bus.OutboundMediaMessage,
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
) ([]string, error) {
|
2026-02-22 19:10:57 +00:00
|
|
|
ms, ok := w.ch.(MediaSender)
|
|
|
|
|
if !ok {
|
2026-03-22 12:47:23 +00:00
|
|
|
err := fmt.Errorf("channel %q does not support media sending", name)
|
|
|
|
|
logger.WarnCF("channels", "Channel does not support MediaSender", map[string]any{
|
2026-02-22 19:10:57 +00:00
|
|
|
"channel": name,
|
2026-03-22 12:47:23 +00:00
|
|
|
"error": err.Error(),
|
2026-02-22 19:10:57 +00:00
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, err
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rate limit: wait for token
|
|
|
|
|
if err := w.limiter.Wait(ctx); err != nil {
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelRateLimited,
|
|
|
|
|
name,
|
|
|
|
|
scopeFromOutboundContext(msg.Context),
|
|
|
|
|
runtimeevents.SeverityWarn,
|
|
|
|
|
ChannelOutboundPayload{
|
|
|
|
|
Media: true,
|
|
|
|
|
Error: err.Error(),
|
|
|
|
|
},
|
|
|
|
|
)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, err
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 12:47:23 +00:00
|
|
|
// Pre-send: stop typing and clean up any placeholder before sending media.
|
|
|
|
|
m.preSendMedia(ctx, name, msg, w.ch)
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
var lastErr error
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
var msgIDs []string
|
2026-02-22 19:10:57 +00:00
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
msgIDs, lastErr = ms.SendMedia(ctx, msg)
|
2026-02-22 19:10:57 +00:00
|
|
|
if lastErr == nil {
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundMediaSent(name, msg, msgIDs)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return msgIDs, nil
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, ctx.Err()
|
2026-02-22 19:10:57 +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):
|
|
|
|
|
case <-ctx.Done():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, ctx.Err()
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All retries exhausted or permanent failure
|
|
|
|
|
logger.ErrorCF("channels", "SendMedia failed", map[string]any{
|
|
|
|
|
"channel": name,
|
2026-04-01 12:56:48 +00:00
|
|
|
"chat_id": outboundMediaChatID(msg),
|
2026-02-22 19:10:57 +00:00
|
|
|
"error": lastErr.Error(),
|
|
|
|
|
"retries": maxRetries,
|
|
|
|
|
})
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundMediaFailed(name, msg, lastErr)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, lastErr
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 08:38:47 +00:00
|
|
|
// runTTLJanitor periodically scans the typingStops, placeholders, and stream
|
|
|
|
|
// tombstone maps and evicts entries that have exceeded their TTL. This prevents
|
|
|
|
|
// memory accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
|
2026-02-24 14:30:22 +00:00
|
|
|
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-05-19 08:38:47 +00:00
|
|
|
m.streamAuxiliaryTombstones.Range(func(key, value any) bool {
|
|
|
|
|
if createdAt, ok := value.(time.Time); !ok || now.Sub(createdAt) > streamAuxiliaryTombstoneTTL {
|
|
|
|
|
m.streamAuxiliaryTombstones.Delete(key)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
})
|
2026-02-24 14:30:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-03-19 07:28:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reload updates the config reference without restarting channels.
|
|
|
|
|
// 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 {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
2026-03-28 03:30:31 +00:00
|
|
|
|
|
|
|
|
// Save old config so we can revert on error.
|
|
|
|
|
oldConfig := m.config
|
|
|
|
|
|
|
|
|
|
// Update config early: initChannel uses m.config via factory(m.config, m.bus).
|
|
|
|
|
m.config = cfg
|
|
|
|
|
|
2026-03-19 07:28:52 +00:00
|
|
|
list := toChannelHashes(cfg)
|
|
|
|
|
added, removed := compareChannels(m.channelHashes, list)
|
2026-03-28 03:30:31 +00:00
|
|
|
|
|
|
|
|
deferFuncs := make([]func(), 0, len(removed)+len(added))
|
2026-03-19 07:28:52 +00:00
|
|
|
for _, name := range removed {
|
|
|
|
|
// Stop all channels
|
|
|
|
|
channel := m.channels[name]
|
|
|
|
|
logger.InfoCF("channels", "Stopping channel", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
})
|
|
|
|
|
if err := channel.Stop(ctx); err != nil {
|
|
|
|
|
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-03-28 03:30:31 +00:00
|
|
|
deferFuncs = append(deferFuncs, func() {
|
2026-03-19 07:28:52 +00:00
|
|
|
m.UnregisterChannel(name)
|
2026-03-28 03:30:31 +00:00
|
|
|
})
|
2026-03-19 07:28:52 +00:00
|
|
|
}
|
|
|
|
|
dispatchCtx, cancel := context.WithCancel(ctx)
|
|
|
|
|
m.dispatchTask = &asyncTask{cancel: cancel}
|
|
|
|
|
cc, err := toChannelConfig(cfg, added)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorC("channels", fmt.Sprintf("toChannelConfig error: %v", err))
|
2026-03-28 03:30:31 +00:00
|
|
|
m.config = oldConfig
|
|
|
|
|
cancel()
|
2026-03-19 07:28:52 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
err = m.initChannels(cc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorC("channels", fmt.Sprintf("initChannels error: %v", err))
|
2026-03-28 03:30:31 +00:00
|
|
|
m.config = oldConfig
|
|
|
|
|
cancel()
|
2026-03-19 07:28:52 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, name := range added {
|
|
|
|
|
channel := m.channels[name]
|
|
|
|
|
logger.InfoCF("channels", "Starting channel", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
})
|
|
|
|
|
if err := channel.Start(ctx); err != nil {
|
|
|
|
|
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
|
|
|
|
|
"channel": name,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleStartFailed,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityError,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()},
|
|
|
|
|
)
|
2026-03-19 07:28:52 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Lazily create worker only after channel starts successfully
|
2026-04-11 16:57:26 +00:00
|
|
|
channelType := name
|
|
|
|
|
if m.config != nil {
|
|
|
|
|
if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" {
|
|
|
|
|
channelType = bc.Type
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
w := newChannelWorker(name, channel, channelType)
|
2026-03-19 07:28:52 +00:00
|
|
|
m.workers[name] = w
|
|
|
|
|
go m.runWorker(dispatchCtx, name, w)
|
|
|
|
|
go m.runMediaWorker(dispatchCtx, name, w)
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishChannelEvent(
|
|
|
|
|
runtimeevents.KindChannelLifecycleStarted,
|
|
|
|
|
name,
|
|
|
|
|
runtimeevents.Scope{Channel: name},
|
|
|
|
|
runtimeevents.SeverityInfo,
|
|
|
|
|
ChannelLifecyclePayload{Type: channelType},
|
|
|
|
|
)
|
2026-03-28 03:30:31 +00:00
|
|
|
deferFuncs = append(deferFuncs, func() {
|
2026-03-19 07:28:52 +00:00
|
|
|
m.RegisterChannel(name, channel)
|
2026-03-28 03:30:31 +00:00
|
|
|
})
|
2026-03-19 07:28:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 03:30:31 +00:00
|
|
|
// Commit hashes only on full success.
|
|
|
|
|
m.channelHashes = list
|
|
|
|
|
go func() {
|
|
|
|
|
for _, f := range deferFuncs {
|
|
|
|
|
f()
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-03-19 07:28:52 +00:00
|
|
|
return nil
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) RegisterChannel(name string, channel Channel) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
m.channels[name] = channel
|
2026-03-28 03:30:31 +00:00
|
|
|
if m.mux != nil {
|
|
|
|
|
m.registerChannelHTTPHandler(name, channel)
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) UnregisterChannel(name string) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
2026-03-28 03:30:31 +00:00
|
|
|
if ch, ok := m.channels[name]; ok && m.mux != nil {
|
|
|
|
|
m.unregisterChannelHTTPHandler(name, ch)
|
|
|
|
|
}
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 10:38:23 +00:00
|
|
|
// SendMessage sends an outbound message synchronously through the channel
|
|
|
|
|
// 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 {
|
2026-04-01 07:23:36 +00:00
|
|
|
msg = bus.NormalizeOutboundMessage(msg)
|
2026-04-01 12:56:48 +00:00
|
|
|
channelName := outboundMessageChannel(msg)
|
2026-04-01 07:23:36 +00:00
|
|
|
|
2026-03-09 10:38:23 +00:00
|
|
|
m.mu.RLock()
|
2026-04-01 12:56:48 +00:00
|
|
|
_, exists := m.channels[channelName]
|
|
|
|
|
w, wExists := m.workers[channelName]
|
2026-03-09 10:38:23 +00:00
|
|
|
m.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if !exists {
|
2026-04-01 12:56:48 +00:00
|
|
|
return fmt.Errorf("channel %s not found", channelName)
|
2026-03-09 10:38:23 +00:00
|
|
|
}
|
|
|
|
|
if !wExists || w == nil {
|
2026-04-01 12:56:48 +00:00
|
|
|
return fmt.Errorf("channel %s has no active worker", channelName)
|
2026-03-09 10:38:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
maxLen := 0
|
|
|
|
|
if mlp, ok := w.ch.(MessageLengthProvider); ok {
|
|
|
|
|
maxLen = mlp.MaxMessageLength()
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 {
|
|
|
|
|
for _, chunk := range chunks {
|
2026-03-09 10:38:23 +00:00
|
|
|
chunkMsg := msg
|
|
|
|
|
chunkMsg.Content = chunk
|
2026-04-01 12:56:48 +00:00
|
|
|
m.sendWithRetry(ctx, channelName, w, chunkMsg)
|
2026-03-09 10:38:23 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-23 02:35:50 +00:00
|
|
|
if len(chunks) == 1 {
|
|
|
|
|
msg.Content = chunks[0]
|
|
|
|
|
}
|
2026-04-01 12:56:48 +00:00
|
|
|
m.sendWithRetry(ctx, channelName, w, msg)
|
2026-03-09 10:38:23 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 11:05:28 +00:00
|
|
|
// SendMedia sends outbound media synchronously through the channel worker's
|
|
|
|
|
// rate limiter and retry logic. It blocks until the media is delivered (or all
|
|
|
|
|
// 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 {
|
2026-04-01 07:23:36 +00:00
|
|
|
msg = bus.NormalizeOutboundMediaMessage(msg)
|
2026-04-01 12:56:48 +00:00
|
|
|
channelName := outboundMediaChannel(msg)
|
2026-04-01 07:23:36 +00:00
|
|
|
|
2026-03-22 11:05:28 +00:00
|
|
|
m.mu.RLock()
|
2026-04-01 12:56:48 +00:00
|
|
|
_, exists := m.channels[channelName]
|
|
|
|
|
w, wExists := m.workers[channelName]
|
2026-03-22 11:05:28 +00:00
|
|
|
m.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if !exists {
|
2026-04-01 12:56:48 +00:00
|
|
|
return fmt.Errorf("channel %s not found", channelName)
|
2026-03-22 11:05:28 +00:00
|
|
|
}
|
|
|
|
|
if !wExists || w == nil {
|
2026-04-01 12:56:48 +00:00
|
|
|
return fmt.Errorf("channel %s has no active worker", channelName)
|
2026-03-22 11:05:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
_, err := m.sendMediaWithRetry(ctx, channelName, w, msg)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return err
|
2026-03-22 11:05:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
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{
|
2026-04-01 12:56:48 +00:00
|
|
|
Context: bus.NewOutboundContext(channelName, chatID, ""),
|
2026-02-04 11:06:13 +00:00
|
|
|
Content: content,
|
|
|
|
|
}
|
2026-04-01 12:56:48 +00:00
|
|
|
msg = bus.NormalizeOutboundMessage(msg)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
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:
|
2026-04-26 08:05:10 +00:00
|
|
|
m.publishOutboundQueued(channelName, msg)
|
2026-02-22 14:46:29 +00:00
|
|
|
return nil
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: direct send (should not happen)
|
|
|
|
|
channel, _ := m.channels[channelName]
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
_, err := channel.Send(ctx, msg)
|
|
|
|
|
return err
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|