From 29ed65010769ac901eaf077dee3f680d80e14aa6 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 03:02:40 +0800 Subject: [PATCH] feat(channels): auto-orchestrate Placeholder/Typing/Reaction via capability interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define PlaceholderCapable, TypingCapable, and ReactionCapable interfaces and have BaseChannel.HandleMessage auto-detect and trigger all three as independent pipelines on inbound messages. This replaces the scattered manual orchestration code in each channel's handleMessage with a single unified dispatch in the framework layer. Changes: - Add PlaceholderCapable interface to interfaces.go - Add ReactionCapable + RecordReactionUndo to interfaces.go - BaseChannel.HandleMessage auto-triggers Typing → Reaction → Placeholder - Manager gains reactionUndos sync.Map with TTL janitor cleanup - Telegram: extract SendPlaceholder from manual code, add StartTyping - Discord: add SendPlaceholder + StartTyping - Pico: add SendPlaceholder (uses Pico Protocol message.create) - Slack: extract ReactToMessage from manual code - OneBot: extract ReactToMessage, remove leaked pendingEmojiMsg sync.Map - LINE: move group-chat guard into StartTyping, remove manual orchestration - Config: add Placeholder to PicoConfig; remove from Slack/LINE/OneBot (no MessageEditor, so placeholder config was dead code) --- pkg/channels/base.go | 30 +++++++++++ pkg/channels/discord/discord.go | 35 ++++++++++--- pkg/channels/interfaces.go | 17 +++++++ pkg/channels/line/line.go | 34 +++---------- pkg/channels/manager.go | 61 ++++++++++++++++++----- pkg/channels/onebot/onebot.go | 57 +++++++++++---------- pkg/channels/pico/pico.go | 34 ++++++++++--- pkg/channels/slack/slack.go | 56 +++++++++------------ pkg/channels/telegram/telegram.go | 82 ++++++++++++++++++++++--------- pkg/config/config.go | 4 +- 10 files changed, 268 insertions(+), 142 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 6bf4a0d56..2ba450291 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -82,6 +82,7 @@ type BaseChannel struct { groupTrigger config.GroupTriggerConfig mediaStore media.MediaStore placeholderRecorder PlaceholderRecorder + owner Channel // the concrete channel that embeds this BaseChannel } func NewBaseChannel( @@ -257,6 +258,29 @@ func (c *BaseChannel) HandleMessage( Metadata: metadata, } + // Auto-trigger typing indicator, message reaction, and placeholder before publishing. + // Each capability is independent — all three may fire for the same message. + if c.owner != nil && c.placeholderRecorder != nil { + // Typing — independent pipeline + if tc, ok := c.owner.(TypingCapable); ok { + if stop, err := tc.StartTyping(ctx, chatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + } + } + // Reaction — independent pipeline + if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { + if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + } + } + // Placeholder — independent pipeline + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + } + } + } + if err := c.bus.PublishInbound(ctx, msg); err != nil { logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ "channel": c.name, @@ -286,6 +310,12 @@ func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { return c.placeholderRecorder } +// 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 +} + // BuildMediaScope constructs a scope key for media lifecycle tracking. func BuildMediaScope(channel, chatID, messageID string) string { id := messageID diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index dc49e7413..46fbaecfb 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -224,6 +224,27 @@ func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, message return err } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message that will later be edited to the actual +// response via EditMessage (channels.MessageEditor). +func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + msg, err := c.session.ChannelMessageSend(chatID, text) + if err != nil { + return "", err + } + + return msg.ID, nil +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) @@ -360,13 +381,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = "[media only]" } - // Start typing after all early returns — guaranteed to have a matching Send() - c.startTyping(m.ChannelID) - // Register typing stop with Manager for outbound orchestration - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordTypingStop("discord", m.ChannelID, func() { c.stopTyping(m.ChannelID) }) - } - logger.DebugCF("discord", "Received message", map[string]any{ "sender_name": sender.DisplayName, "sender_id": senderID, @@ -440,6 +454,13 @@ func (c *DiscordChannel) stopTyping(chatID string) { } } +// StartTyping implements channels.TypingCapable. +// It starts a continuous typing indicator and returns an idempotent stop function. +func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + c.startTyping(chatID) + return func() { c.stopTyping(chatID) }, nil +} + func (c *DiscordChannel) downloadAttachment(url, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "discord", diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 32bfe95f8..74caeeac5 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -15,10 +15,27 @@ type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } +// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. +// ReactToMessage adds a reaction and returns an undo function to remove it. +// The undo function MUST be idempotent and safe to call multiple times. +type ReactionCapable interface { + ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) +} + +// PlaceholderCapable — channels that can send a placeholder message +// (e.g. "Thinking... 💭") that will later be edited to the actual response. +// The channel MUST also implement MessageEditor for the placeholder to be useful. +// SendPlaceholder returns the platform message ID of the placeholder so that +// Manager.preSend can later edit it via MessageEditor.EditMessage. +type PlaceholderCapable interface { + SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) +} + // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. type PlaceholderRecorder interface { RecordPlaceholder(channel, chatID, placeholderID string) RecordTypingStop(channel, chatID string, stop func()) + RecordReactionUndo(channel, chatID string, undo func()) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 1a5aa8ac8..25c29c217 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -378,28 +378,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - // Thinking indicator (LINE loading animation is 1:1 only). - // For group/room chats, LINE provides no equivalent API. - // Only start if PlaceholderRecorder is available to avoid wasted API calls. - if !isGroup { - if rec := c.GetPlaceholderRecorder(); rec != nil { - typingCtx, typingCancel := context.WithTimeout(c.ctx, 5*time.Minute) - stop, err := c.StartTyping(typingCtx, chatID) - if err == nil { - var stopOnce sync.Once - stopFn := func() { - stopOnce.Do(func() { - stop() - typingCancel() - }) - } - rec.RecordTypingStop("line", chatID, stopFn) - } else { - typingCancel() - } - } - } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } @@ -598,15 +576,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri // StartTyping implements channels.TypingCapable using LINE's loading animation. // -// NOTE: The LINE loading animation API only works for 1:1 chats. Callers must ensure -// the provided chatID is a user chat ID (not a group/room ID). -// There is no explicit "stop" API; we periodically re-send start requests to keep -// the indicator alive, and stop by canceling the context. +// NOTE: The LINE loading animation API only works for 1:1 chats. +// Group/room chat IDs (starting with "C" or "R") are detected automatically; +// for these, a no-op stop function is returned without calling the API. func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { if chatID == "" { return func() {}, nil } + // Group/room chats: LINE loading animation is 1:1 only. + if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") { + return func() {}, nil + } + typingCtx, cancel := context.WithCancel(ctx) var once sync.Once stop := func() { once.Do(cancel) } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 23a38c699..38b408f5e 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -44,6 +44,12 @@ type typingEntry struct { createdAt time.Time } +// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction. +type reactionEntry struct { + undo func() + createdAt time.Time +} + // placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction. type placeholderEntry struct { id string @@ -68,17 +74,18 @@ type channelWorker struct { } type Manager struct { - channels map[string]Channel - workers map[string]*channelWorker - bus *bus.MessageBus - config *config.Config - mediaStore media.MediaStore - dispatchTask *asyncTask - mux *http.ServeMux - httpServer *http.Server - mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderID (string) - typingStops sync.Map // "channel:chatID" → func() + channels map[string]Channel + workers map[string]*channelWorker + bus *bus.MessageBus + config *config.Config + mediaStore media.MediaStore + dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server + mu sync.RWMutex + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() + reactionUndos sync.Map // "channel:chatID" → reactionEntry } type asyncTask struct { @@ -99,7 +106,14 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) } -// preSend handles typing stop and placeholder editing before sending a message. +// 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. // Returns true if the message was edited into a placeholder (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { key := name + ":" + msg.ChatID @@ -111,7 +125,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 2. Try editing placeholder + // 2. Undo reaction + if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { + if entry, ok := v.(reactionEntry); ok { + entry.undo() // idempotent, safe + } + } + + // 3. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { @@ -171,6 +192,10 @@ func (m *Manager) initChannel(name, displayName string) { if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { setter.SetPlaceholderRecorder(m) } + // Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction + if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { + setter.SetOwner(ch) + } m.channels[name] = ch logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, @@ -690,6 +715,16 @@ func (m *Manager) runTTLJanitor(ctx context.Context) { } return true }) + 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 + }) m.placeholders.Range(func(key, value any) bool { if entry, ok := value.(placeholderEntry); ok { if now.Sub(entry.createdAt) > placeholderTTL { diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index f8042b248..7666c039f 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -23,21 +23,20 @@ import ( type OneBotChannel struct { *channels.BaseChannel - config config.OneBotConfig - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - dedup map[string]struct{} - dedupRing []string - dedupIdx int - mu sync.Mutex - writeMu sync.Mutex - echoCounter int64 - selfID int64 - pending map[string]chan json.RawMessage - pendingMu sync.Mutex - lastMessageID sync.Map - pendingEmojiMsg sync.Map + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 + selfID int64 + pending map[string]chan json.RawMessage + pendingMu sync.Mutex + lastMessageID sync.Map } type oneBotRawEvent struct { @@ -129,6 +128,22 @@ func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) }() } +// ReactToMessage implements channels.ReactionCapable. +// It adds an emoji reaction (ID 289) to group messages and returns an undo function. +// Private messages return a no-op since reactions are only meaningful in groups. +func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + // Only react in group chats + if !strings.HasPrefix(chatID, "group:") { + return func() {}, nil + } + + c.setMsgEmojiLike(messageID, 289, true) + + return func() { + c.setMsgEmojiLike(messageID, 289, false) + }, nil +} + func (c *OneBotChannel) Start(ctx context.Context) error { if c.config.WSUrl == "" { return fmt.Errorf("OneBot ws_url not configured") @@ -1044,18 +1059,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { c.lastMessageID.Store(chatID, messageID) - if raw.MessageType == "group" && messageID != "" && messageID != "0" { - c.setMsgEmojiLike(messageID, 289, true) - c.pendingEmojiMsg.Store(chatID, messageID) - // Register emoji stop with Manager for outbound orchestration - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedMsgID := messageID - rec.RecordTypingStop("onebot", chatID, func() { - c.setMsgEmojiLike(capturedMsgID, 289, false) - }) - } - } - senderInfo := bus.SenderInfo{ Platform: "onebot", PlatformID: senderID, diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index c646a3b0b..2ae82d8da 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -171,6 +171,32 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e }, nil } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message via the Pico Protocol that will later be +// edited to the actual response via EditMessage (channels.MessageEditor). +func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + msgID := uuid.New().String() + outMsg := newMessage(TypeMessageCreate, map[string]any{ + "content": text, + "message_id": msgID, + }) + + if err := c.broadcastToSession(chatID, outMsg); err != nil { + return "", err + } + + return msgID, nil +} + // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" @@ -413,14 +439,6 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { "preview": truncate(content, 50), }) - // Register typing with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - stop, err := c.StartTyping(c.ctx, chatID) - if err == nil { - rec.RecordTypingStop("pico", chatID, stop) - } - } - sender := bus.SenderInfo{ Platform: "pico", PlatformID: senderID, diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 1733ccee1..488eb1296 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -200,6 +200,28 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa return nil } +// ReactToMessage implements channels.ReactionCapable. +// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function +// that removes the reaction. +func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + channelID, _ := parseSlackChatID(chatID) + if channelID == "" { + return func() {}, nil + } + + c.api.AddReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageID, + }) + + return func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageID, + }) + }, nil +} + func (c *SlackChannel) eventLoop() { for { select { @@ -275,23 +297,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { chatID = channelID + "/" + threadTS } - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - // Register typing stop (remove "eyes" reaction) with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedChannelID := channelID - capturedMessageTS := messageTS - rec.RecordTypingStop("slack", chatID, func() { - c.api.RemoveReaction("eyes", slack.ItemRef{ - Channel: capturedChannelID, - Timestamp: capturedMessageTS, - }) - }) - } - c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, @@ -402,23 +407,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { chatID = channelID + "/" + messageTS } - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - // Register typing stop (remove "eyes" reaction) with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedChannelID := channelID - capturedMessageTS := messageTS - rec.RecordTypingStop("slack", chatID, func() { - c.api.RemoveReaction("eyes", slack.ItemRef{ - Channel: capturedChannelID, - Timestamp: capturedMessageTS, - }) - }) - } - c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 3e4e4d398..4834c7d19 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -191,6 +191,36 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +// StartTyping implements channels.TypingCapable. +// It sends ChatAction(typing) immediately and then repeats every 4 seconds +// (Telegram's typing indicator expires after ~5s) in a background goroutine. +// The returned stop function is idempotent and cancels the goroutine. +func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + cid, err := parseChatID(chatID) + if err != nil { + return func() {}, err + } + + // Send the first typing action immediately + _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + + typingCtx, cancel := context.WithCancel(ctx) + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + } + } + }() + + return cancel, nil +} + // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { cid, err := parseChatID(chatID) @@ -208,6 +238,33 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag return err } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message (e.g. "Thinking... 💭") that will later be +// edited to the actual response via EditMessage (channels.MessageEditor). +func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + phCfg := c.config.Channels.Telegram.Placeholder + if !phCfg.Enabled { + return "", nil + } + + text := phCfg.Text + if text == "" { + text = "Thinking... 💭" + } + + cid, err := parseChatID(chatID) + if err != nil { + return "", err + } + + pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + if err != nil { + return "", err + } + + return fmt.Sprintf("%d", pMsg.MessageID), nil +} + // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -419,30 +476,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ - "error": err.Error(), - }) - } - - // Create cancel function for thinking state and register with Manager - _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordTypingStop("telegram", chatIDStr, thinkCancel) - } else { - // No recorder — cancel immediately to avoid context leak - thinkCancel() - } - - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) - if err == nil { - pID := pMsg.MessageID - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordPlaceholder("telegram", chatIDStr, fmt.Sprintf("%d", pID)) - } - } + // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) diff --git a/pkg/config/config.go b/pkg/config/config.go index 64cdf6eac..bdd4d8823 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -288,7 +288,6 @@ type SlackConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type LINEConfig struct { @@ -301,7 +300,6 @@ type LINEConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type OneBotConfig struct { @@ -313,7 +311,6 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type WeComConfig struct { @@ -354,6 +351,7 @@ type PicoConfig struct { WriteTimeout int `json:"write_timeout,omitempty"` MaxConnections int `json:"max_connections,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type HeartbeatConfig struct {