2026-02-04 11:06:13 +00:00
|
|
|
package channels
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-02-24 14:30:22 +00:00
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"encoding/hex"
|
2026-03-08 17:22:15 +00:00
|
|
|
"regexp"
|
2026-02-24 14:30:22 +00:00
|
|
|
"strconv"
|
2026-02-12 05:45:45 +00:00
|
|
|
"strings"
|
2026-02-20 16:00:29 +00:00
|
|
|
"sync/atomic"
|
2026-02-24 14:30:22 +00:00
|
|
|
"time"
|
2026-02-22 15:27:55 +00:00
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-22 22:03:23 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-24 14:30:22 +00:00
|
|
|
var (
|
2026-02-25 03:51:21 +00:00
|
|
|
uniqueIDCounter uint64
|
|
|
|
|
uniqueIDPrefix string
|
2026-02-24 14:30:22 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
// One-time read from crypto/rand for a unique prefix (single syscall).
|
|
|
|
|
var b [8]byte
|
|
|
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
|
|
|
// fallback to time-based prefix
|
|
|
|
|
binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano()))
|
|
|
|
|
}
|
2026-02-25 03:51:21 +00:00
|
|
|
uniqueIDPrefix = hex.EncodeToString(b[:])
|
2026-02-24 14:30:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 17:22:15 +00:00
|
|
|
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
|
|
|
|
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
|
|
|
|
|
2026-02-25 03:51:21 +00:00
|
|
|
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
|
|
|
|
|
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
|
|
|
|
|
// cryptographically secure — it must not be used in contexts where unpredictability matters.
|
|
|
|
|
func uniqueID() string {
|
|
|
|
|
n := atomic.AddUint64(&uniqueIDCounter, 1)
|
|
|
|
|
return uniqueIDPrefix + strconv.FormatUint(n, 16)
|
2026-02-24 14:30:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type Channel interface {
|
|
|
|
|
Name() string
|
|
|
|
|
Start(ctx context.Context) error
|
|
|
|
|
Stop(ctx context.Context) 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
|
|
|
Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error)
|
2026-02-04 11:06:13 +00:00
|
|
|
IsRunning() bool
|
|
|
|
|
IsAllowed(senderID string) bool
|
2026-02-22 22:56:48 +00:00
|
|
|
IsAllowedSender(sender bus.SenderInfo) bool
|
2026-02-26 05:24:51 +00:00
|
|
|
ReasoningChannelID() string
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
// BaseChannelOption is a functional option for configuring a BaseChannel.
|
|
|
|
|
type BaseChannelOption func(*BaseChannel)
|
|
|
|
|
|
|
|
|
|
// WithMaxMessageLength sets the maximum message length (in runes) for a channel.
|
|
|
|
|
// Messages exceeding this limit will be automatically split by the Manager.
|
|
|
|
|
// A value of 0 means no limit.
|
|
|
|
|
func WithMaxMessageLength(n int) BaseChannelOption {
|
|
|
|
|
return func(c *BaseChannel) { c.maxMessageLength = n }
|
|
|
|
|
}
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
// WithGroupTrigger sets the group trigger configuration for a channel.
|
|
|
|
|
func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
|
|
|
|
|
return func(c *BaseChannel) { c.groupTrigger = gt }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 05:24:51 +00:00
|
|
|
// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
|
|
|
|
|
func WithReasoningChannelID(id string) BaseChannelOption {
|
|
|
|
|
return func(c *BaseChannel) { c.reasoningChannelID = id }
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
// MessageLengthProvider is an opt-in interface that channels implement
|
|
|
|
|
// to advertise their maximum message length. The Manager uses this via
|
|
|
|
|
// type assertion to decide whether to split outbound messages.
|
|
|
|
|
type MessageLengthProvider interface {
|
|
|
|
|
MaxMessageLength() int
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type BaseChannel struct {
|
2026-02-22 20:55:15 +00:00
|
|
|
config any
|
|
|
|
|
bus *bus.MessageBus
|
|
|
|
|
running atomic.Bool
|
|
|
|
|
name string
|
|
|
|
|
allowList []string
|
|
|
|
|
maxMessageLength int
|
|
|
|
|
groupTrigger config.GroupTriggerConfig
|
|
|
|
|
mediaStore media.MediaStore
|
|
|
|
|
placeholderRecorder PlaceholderRecorder
|
2026-02-26 19:02:40 +00:00
|
|
|
owner Channel // the concrete channel that embeds this BaseChannel
|
2026-02-26 05:24:51 +00:00
|
|
|
reasoningChannelID string
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
func NewBaseChannel(
|
|
|
|
|
name string,
|
|
|
|
|
config any,
|
|
|
|
|
bus *bus.MessageBus,
|
|
|
|
|
allowList []string,
|
|
|
|
|
opts ...BaseChannelOption,
|
|
|
|
|
) *BaseChannel {
|
2026-04-13 15:34:44 +00:00
|
|
|
isEmpty := true
|
|
|
|
|
for _, s := range allowList {
|
|
|
|
|
if s != "" {
|
|
|
|
|
isEmpty = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if isEmpty {
|
|
|
|
|
allowList = []string{}
|
|
|
|
|
}
|
2026-02-22 14:46:29 +00:00
|
|
|
bc := &BaseChannel{
|
2026-02-04 11:06:13 +00:00
|
|
|
config: config,
|
|
|
|
|
bus: bus,
|
|
|
|
|
name: name,
|
|
|
|
|
allowList: allowList,
|
|
|
|
|
}
|
2026-02-22 14:46:29 +00:00
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(bc)
|
|
|
|
|
}
|
2026-03-27 13:04:21 +00:00
|
|
|
|
|
|
|
|
// Security Audit: Check for open-by-default (unsecured) channels.
|
|
|
|
|
// PicoClaw aims to be secure-by-default. If allow_from is empty, the bot
|
|
|
|
|
// currently defaults to accepting messages from ANYONE. To explicitly
|
|
|
|
|
// acknowledge and permit this (e.g. for a public bot), use ["*"].
|
|
|
|
|
if len(bc.allowList) == 0 {
|
|
|
|
|
logger.WarnCF("channels", "SECURITY: Channel allows EVERYONE (allow_from is empty)", map[string]any{
|
|
|
|
|
"channel": bc.name,
|
|
|
|
|
"hint": "Set allow_from to your ID, or use '*' to explicitly acknowledge open access.",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
return bc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MaxMessageLength returns the maximum message length (in runes) for this channel.
|
|
|
|
|
// A value of 0 means no limit.
|
|
|
|
|
func (c *BaseChannel) MaxMessageLength() int {
|
|
|
|
|
return c.maxMessageLength
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
// ShouldRespondInGroup determines whether the bot should respond in a group chat.
|
|
|
|
|
// Each channel is responsible for:
|
|
|
|
|
// 1. Detecting isMentioned (platform-specific)
|
|
|
|
|
// 2. Stripping bot mention from content (platform-specific)
|
|
|
|
|
// 3. Calling this method to get the group response decision
|
|
|
|
|
//
|
|
|
|
|
// Logic:
|
|
|
|
|
// - If isMentioned → always respond
|
|
|
|
|
// - If mention_only configured and not mentioned → ignore
|
|
|
|
|
// - If prefixes configured → respond if content starts with any prefix (strip it)
|
|
|
|
|
// - If prefixes configured but no match and not mentioned → ignore
|
|
|
|
|
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
|
|
|
|
|
func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
|
|
|
|
|
gt := c.groupTrigger
|
|
|
|
|
|
|
|
|
|
// Mentioned → always respond
|
|
|
|
|
if isMentioned {
|
|
|
|
|
return true, strings.TrimSpace(content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mention_only → require mention
|
|
|
|
|
if gt.MentionOnly {
|
|
|
|
|
return false, content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prefix matching
|
|
|
|
|
if len(gt.Prefixes) > 0 {
|
|
|
|
|
for _, prefix := range gt.Prefixes {
|
|
|
|
|
if prefix != "" && strings.HasPrefix(content, prefix) {
|
|
|
|
|
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Prefixes configured but none matched and not mentioned → ignore
|
|
|
|
|
return false, content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No group_trigger configured → permissive (respond to all)
|
|
|
|
|
return true, strings.TrimSpace(content)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
func (c *BaseChannel) Name() string {
|
|
|
|
|
return c.name
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
// SetName updates the channel name. Used by the manager after channel creation
|
|
|
|
|
// to ensure the name matches the config key (which may differ from the type).
|
|
|
|
|
func (c *BaseChannel) SetName(name string) {
|
|
|
|
|
c.name = name
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 05:24:51 +00:00
|
|
|
func (c *BaseChannel) ReasoningChannelID() string {
|
|
|
|
|
return c.reasoningChannelID
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
func (c *BaseChannel) IsRunning() bool {
|
2026-02-20 16:00:29 +00:00
|
|
|
return c.running.Load()
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *BaseChannel) IsAllowed(senderID string) bool {
|
|
|
|
|
if len(c.allowList) == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 05:45:45 +00:00
|
|
|
// Extract parts from compound senderID like "123456|username"
|
|
|
|
|
idPart := senderID
|
|
|
|
|
userPart := ""
|
|
|
|
|
if idx := strings.Index(senderID, "|"); idx > 0 {
|
|
|
|
|
idPart = senderID[:idx]
|
|
|
|
|
userPart = senderID[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
for _, allowed := range c.allowList {
|
2026-03-27 13:04:21 +00:00
|
|
|
if allowed == "*" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-02-12 05:45:45 +00:00
|
|
|
// Strip leading "@" from allowed value for username matching
|
|
|
|
|
trimmed := strings.TrimPrefix(allowed, "@")
|
2026-02-12 18:09:59 +00:00
|
|
|
allowedID := trimmed
|
|
|
|
|
allowedUser := ""
|
|
|
|
|
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
|
|
|
|
allowedID = trimmed[:idx]
|
|
|
|
|
allowedUser = trimmed[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Support either side using "id|username" compound form.
|
|
|
|
|
// This keeps backward compatibility with legacy Telegram allowlist entries.
|
|
|
|
|
if senderID == allowed ||
|
|
|
|
|
idPart == allowed ||
|
|
|
|
|
senderID == trimmed ||
|
|
|
|
|
idPart == trimmed ||
|
|
|
|
|
idPart == allowedID ||
|
|
|
|
|
(allowedUser != "" && senderID == allowedUser) ||
|
|
|
|
|
(userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
|
2026-02-04 11:06:13 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.
|
|
|
|
|
// It delegates to identity.MatchAllowed for each entry, providing unified matching
|
|
|
|
|
// across all legacy formats and the new canonical "platform:id" format.
|
|
|
|
|
func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
|
|
|
|
|
if len(c.allowList) == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, allowed := range c.allowList {
|
2026-03-27 13:04:21 +00:00
|
|
|
if allowed == "*" || identity.MatchAllowed(sender, allowed) {
|
2026-02-22 22:56:48 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 05:50:24 +00:00
|
|
|
func (c *BaseChannel) HandleMessageWithContext(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
deliveryChatID, content string,
|
|
|
|
|
media []string,
|
|
|
|
|
inboundCtx bus.InboundContext,
|
|
|
|
|
senderOpts ...bus.SenderInfo,
|
2026-06-08 10:10:42 +00:00
|
|
|
) error {
|
2026-02-22 22:56:48 +00:00
|
|
|
// Use SenderInfo-based allow check when available, else fall back to string
|
|
|
|
|
var sender bus.SenderInfo
|
|
|
|
|
if len(senderOpts) > 0 {
|
|
|
|
|
sender = senderOpts[0]
|
|
|
|
|
}
|
2026-04-01 05:50:24 +00:00
|
|
|
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
2026-02-22 22:56:48 +00:00
|
|
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
2026-06-08 10:10:42 +00:00
|
|
|
return nil
|
2026-02-22 22:56:48 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if !c.IsAllowed(senderID) {
|
2026-06-08 10:10:42 +00:00
|
|
|
return nil
|
2026-02-22 22:56:48 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set SenderID to canonical if available, otherwise keep the raw senderID
|
|
|
|
|
resolvedSenderID := senderID
|
|
|
|
|
if sender.CanonicalID != "" {
|
|
|
|
|
resolvedSenderID = sender.CanonicalID
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 05:50:24 +00:00
|
|
|
if resolvedSenderID == "" {
|
|
|
|
|
resolvedSenderID = senderID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inboundCtx.Channel = c.name
|
|
|
|
|
if inboundCtx.ChatID == "" {
|
|
|
|
|
inboundCtx.ChatID = deliveryChatID
|
|
|
|
|
}
|
|
|
|
|
if inboundCtx.SenderID == "" {
|
|
|
|
|
inboundCtx.SenderID = resolvedSenderID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID)
|
2026-02-22 15:27:55 +00:00
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
msg := bus.InboundMessage{
|
2026-04-01 05:50:24 +00:00
|
|
|
Context: inboundCtx,
|
2026-04-01 12:56:48 +00:00
|
|
|
Sender: sender,
|
2026-02-22 15:27:55 +00:00
|
|
|
Content: content,
|
|
|
|
|
Media: media,
|
|
|
|
|
MediaScope: scope,
|
2026-02-11 10:43:21 +00:00
|
|
|
}
|
2026-04-01 05:50:24 +00:00
|
|
|
msg = bus.NormalizeInboundMessage(msg)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
|
|
|
|
|
// Each capability is independent — all three may fire for the same message.
|
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
|
|
|
// Note: even when streaming is available, we still show typing + placeholder on inbound.
|
|
|
|
|
// If streaming actually activates, preSend will skip the placeholder edit (streamActive map)
|
|
|
|
|
// and the typing stop will still be called. This avoids the problem of compile-time interface
|
|
|
|
|
// checks incorrectly skipping indicators when streaming may not work at runtime.
|
2026-02-26 19:02:40 +00:00
|
|
|
if c.owner != nil && c.placeholderRecorder != nil {
|
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
|
|
|
// Typing
|
2026-02-26 19:02:40 +00:00
|
|
|
if tc, ok := c.owner.(TypingCapable); ok {
|
2026-04-01 05:50:24 +00:00
|
|
|
if stop, err := tc.StartTyping(ctx, deliveryChatID); err == nil {
|
|
|
|
|
c.placeholderRecorder.RecordTypingStop(c.name, deliveryChatID, stop)
|
2026-02-26 19:02:40 +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
|
|
|
// Reaction
|
2026-04-01 05:50:24 +00:00
|
|
|
if rc, ok := c.owner.(ReactionCapable); ok && msg.MessageID != "" {
|
|
|
|
|
if undo, err := rc.ReactToMessage(ctx, deliveryChatID, msg.MessageID); err == nil {
|
|
|
|
|
c.placeholderRecorder.RecordReactionUndo(c.name, deliveryChatID, undo)
|
2026-02-26 19:02:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-08 17:22:15 +00:00
|
|
|
// Placeholder — independent pipeline.
|
|
|
|
|
// Skip when the message contains audio: the agent will send the
|
|
|
|
|
// placeholder after transcription completes, so the user sees
|
|
|
|
|
// "Thinking…" only once the voice has been processed.
|
|
|
|
|
if !audioAnnotationRe.MatchString(content) {
|
|
|
|
|
if pc, ok := c.owner.(PlaceholderCapable); ok {
|
2026-04-01 05:50:24 +00:00
|
|
|
if phID, err := pc.SendPlaceholder(ctx, deliveryChatID); err == nil && phID != "" {
|
|
|
|
|
c.placeholderRecorder.RecordPlaceholder(c.name, deliveryChatID, phID)
|
2026-03-08 17:22:15 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 19:02:40 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:03:23 +00:00
|
|
|
if err := c.bus.PublishInbound(ctx, msg); err != nil {
|
|
|
|
|
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
|
|
|
|
|
"channel": c.name,
|
2026-04-01 05:50:24 +00:00
|
|
|
"chat_id": deliveryChatID,
|
2026-02-22 22:03:23 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-06-08 10:10:42 +00:00
|
|
|
return err
|
2026-02-22 22:03:23 +00:00
|
|
|
}
|
2026-06-08 10:10:42 +00:00
|
|
|
return nil
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
// HandleInboundContext publishes a normalized inbound message using only the
|
|
|
|
|
// structured context.
|
|
|
|
|
func (c *BaseChannel) HandleInboundContext(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
deliveryChatID, content string,
|
|
|
|
|
media []string,
|
|
|
|
|
inboundCtx bus.InboundContext,
|
|
|
|
|
senderOpts ...bus.SenderInfo,
|
2026-06-08 10:10:42 +00:00
|
|
|
) error {
|
|
|
|
|
return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
2026-04-01 12:56:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:18:46 +00:00
|
|
|
func (c *BaseChannel) SetRunning(running bool) {
|
2026-02-20 16:00:29 +00:00
|
|
|
c.running.Store(running)
|
2026-02-20 15:18:46 +00:00
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
// SetMediaStore injects a MediaStore into the channel.
|
|
|
|
|
func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
|
|
|
|
|
|
|
|
|
|
// GetMediaStore returns the injected MediaStore (may be nil).
|
|
|
|
|
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
|
|
|
|
|
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
|
|
|
|
|
c.placeholderRecorder = r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil).
|
|
|
|
|
func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder {
|
|
|
|
|
return c.placeholderRecorder
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// SetOwner injects the concrete channel that embeds this BaseChannel.
|
|
|
|
|
// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable.
|
|
|
|
|
func (c *BaseChannel) SetOwner(ch Channel) {
|
|
|
|
|
c.owner = ch
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
// BuildMediaScope constructs a scope key for media lifecycle tracking.
|
|
|
|
|
func BuildMediaScope(channel, chatID, messageID string) string {
|
|
|
|
|
id := messageID
|
|
|
|
|
if id == "" {
|
2026-02-25 03:51:21 +00:00
|
|
|
id = uniqueID()
|
2026-02-22 15:27:55 +00:00
|
|
|
}
|
|
|
|
|
return channel + ":" + chatID + ":" + id
|
|
|
|
|
}
|