2026-02-20 15:25:44 +00:00
|
|
|
package telegram
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
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
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/binary"
|
2026-02-20 15:25:44 +00:00
|
|
|
"fmt"
|
2026-03-18 02:46:35 +00:00
|
|
|
"io"
|
2026-02-20 15:25:44 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"regexp"
|
2026-02-22 20:55:15 +00:00
|
|
|
"strconv"
|
2026-02-20 15:25:44 +00:00
|
|
|
"strings"
|
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
|
|
|
"sync"
|
2026-02-20 15:25:44 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/mymmrac/telego"
|
2026-02-21 08:35:56 +00:00
|
|
|
th "github.com/mymmrac/telego/telegohandler"
|
2026-02-20 15:25:44 +00:00
|
|
|
tu "github.com/mymmrac/telego/telegoutil"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/commands"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-26 15:24:35 +00:00
|
|
|
var (
|
2026-03-18 13:29:21 +00:00
|
|
|
reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
|
2026-02-26 15:24:35 +00:00
|
|
|
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
|
|
|
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
|
|
|
|
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
|
|
|
|
|
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
|
|
|
|
|
reItalic = regexp.MustCompile(`_([^_]+)_`)
|
|
|
|
|
reStrike = regexp.MustCompile(`~~(.+?)~~`)
|
|
|
|
|
reListItem = regexp.MustCompile(`^[-*]\s+`)
|
|
|
|
|
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
|
|
|
|
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
type TelegramChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
bot *telego.Bot
|
|
|
|
|
bh *th.BotHandler
|
|
|
|
|
config *config.Config
|
|
|
|
|
chatIDs map[string]int64
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
|
|
|
|
|
registerFunc func(context.Context, []commands.Definition) error
|
|
|
|
|
commandRegCancel context.CancelFunc
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
|
|
|
|
var opts []telego.BotOption
|
|
|
|
|
telegramCfg := cfg.Channels.Telegram
|
|
|
|
|
|
|
|
|
|
if telegramCfg.Proxy != "" {
|
|
|
|
|
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
|
|
|
|
|
if parseErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
|
|
|
|
|
}
|
|
|
|
|
opts = append(opts, telego.WithHTTPClient(&http.Client{
|
|
|
|
|
Transport: &http.Transport{
|
|
|
|
|
Proxy: http.ProxyURL(proxyURL),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
|
|
|
|
|
// Use environment proxy if configured
|
|
|
|
|
opts = append(opts, telego.WithHTTPClient(&http.Client{
|
|
|
|
|
Transport: &http.Transport{
|
|
|
|
|
Proxy: http.ProxyFromEnvironment,
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 09:27:57 +00:00
|
|
|
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
|
|
|
|
|
opts = append(opts, telego.WithAPIServer(baseURL))
|
|
|
|
|
}
|
2026-03-11 18:35:37 +00:00
|
|
|
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
|
2026-03-03 09:27:57 +00:00
|
|
|
|
2026-03-21 17:55:00 +00:00
|
|
|
bot, err := telego.NewBot(telegramCfg.Token(), opts...)
|
2026-02-20 15:25:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
base := channels.NewBaseChannel(
|
|
|
|
|
"telegram",
|
|
|
|
|
telegramCfg,
|
|
|
|
|
bus,
|
|
|
|
|
telegramCfg.AllowFrom,
|
2026-03-04 13:46:43 +00:00
|
|
|
channels.WithMaxMessageLength(4000),
|
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
|
|
|
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
|
2026-02-26 05:24:51 +00:00
|
|
|
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
|
2026-02-22 14:46:29 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return &TelegramChannel{
|
2026-02-22 20:55:15 +00:00
|
|
|
BaseChannel: base,
|
|
|
|
|
bot: bot,
|
|
|
|
|
config: cfg,
|
|
|
|
|
chatIDs: make(map[string]int64),
|
2026-02-20 15:25:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *TelegramChannel) Start(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
|
|
|
|
|
|
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
2026-02-20 15:25:44 +00:00
|
|
|
Timeout: 30,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
c.cancel()
|
2026-02-20 15:25:44 +00:00
|
|
|
return fmt.Errorf("failed to start long polling: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:32:15 +00:00
|
|
|
bh, err := th.NewBotHandler(c.bot, updates)
|
2026-02-20 15:25:44 +00:00
|
|
|
if err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
c.cancel()
|
2026-02-20 15:25:44 +00:00
|
|
|
return fmt.Errorf("failed to create bot handler: %w", err)
|
|
|
|
|
}
|
2026-02-22 14:25:07 +00:00
|
|
|
c.bh = bh
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
|
|
|
return c.handleMessage(ctx, &message)
|
|
|
|
|
}, th.AnyMessage())
|
|
|
|
|
|
|
|
|
|
c.SetRunning(true)
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"username": c.bot.Username(),
|
|
|
|
|
})
|
|
|
|
|
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
|
|
|
|
|
|
2026-02-28 21:32:15 +00:00
|
|
|
go func() {
|
|
|
|
|
if err = bh.Start(); err != nil {
|
|
|
|
|
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}()
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-21 08:35:56 +00:00
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("telegram", "Stopping Telegram bot...")
|
|
|
|
|
c.SetRunning(false)
|
2026-02-22 14:25:07 +00:00
|
|
|
|
|
|
|
|
// Stop the bot handler
|
|
|
|
|
if c.bh != nil {
|
2026-02-28 21:32:15 +00:00
|
|
|
_ = c.bh.StopWithContext(ctx)
|
2026-02-22 14:25:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel our context (stops long polling)
|
|
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
if c.commandRegCancel != nil {
|
|
|
|
|
c.commandRegCancel()
|
2026-02-28 21:32:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
|
|
|
if !c.IsRunning() {
|
2026-02-22 17:45:48 +00:00
|
|
|
return channels.ErrNotRunning
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
|
|
|
|
|
|
2026-03-10 02:54:10 +00:00
|
|
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
2026-02-20 15:25:44 +00:00
|
|
|
if err != nil {
|
2026-02-22 17:45:48 +00:00
|
|
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-01 02:40:09 +00:00
|
|
|
if msg.Content == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-03-07 15:01:04 +00:00
|
|
|
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
|
|
|
|
// so msg.Content is guaranteed to be within that limit. We still need to
|
|
|
|
|
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
2026-03-08 16:57:43 +00:00
|
|
|
replyToID := msg.ReplyToMessageID
|
2026-03-07 15:01:04 +00:00
|
|
|
queue := []string{msg.Content}
|
2026-03-04 15:19:04 +00:00
|
|
|
for len(queue) > 0 {
|
|
|
|
|
chunk := queue[0]
|
|
|
|
|
queue = queue[1:]
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
content := parseContent(chunk, useMarkdownV2)
|
2026-03-01 01:56:12 +00:00
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
if len([]rune(content)) > 4096 {
|
2026-03-18 08:44:30 +00:00
|
|
|
runeChunk := []rune(chunk)
|
2026-03-18 13:29:21 +00:00
|
|
|
ratio := float64(len(runeChunk)) / float64(len([]rune(content)))
|
2026-03-02 15:35:41 +00:00
|
|
|
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
|
2026-03-18 08:44:30 +00:00
|
|
|
|
|
|
|
|
// Guarantee progress: if estimated length is >= chunk length, force it smaller
|
|
|
|
|
if smallerLen >= len(runeChunk) {
|
|
|
|
|
smallerLen = len(runeChunk) - 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if smallerLen <= 0 {
|
2026-03-18 13:29:21 +00:00
|
|
|
if err := c.sendChunk(ctx, sendChunkParams{
|
|
|
|
|
chatID: chatID,
|
|
|
|
|
threadID: threadID,
|
|
|
|
|
content: content,
|
|
|
|
|
replyToID: replyToID,
|
|
|
|
|
mdFallback: chunk,
|
|
|
|
|
useMarkdownV2: useMarkdownV2,
|
|
|
|
|
}); err != nil {
|
2026-03-18 08:44:30 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
replyToID = ""
|
|
|
|
|
continue
|
2026-03-02 15:35:41 +00:00
|
|
|
}
|
2026-03-18 08:44:30 +00:00
|
|
|
|
|
|
|
|
// Use the estimated smaller length as a guide for SplitMessage.
|
|
|
|
|
// SplitMessage will find natural break points (newlines/spaces) and respect code blocks.
|
2026-03-02 15:35:41 +00:00
|
|
|
subChunks := channels.SplitMessage(chunk, smallerLen)
|
2026-03-18 08:44:30 +00:00
|
|
|
|
|
|
|
|
// Safety fallback: If SplitMessage failed to shorten the chunk, force a manual hard split.
|
|
|
|
|
if len(subChunks) == 1 && subChunks[0] == chunk {
|
|
|
|
|
part1 := string(runeChunk[:smallerLen])
|
|
|
|
|
part2 := string(runeChunk[smallerLen:])
|
|
|
|
|
subChunks = []string{part1, part2}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter out empty chunks to avoid sending empty messages to Telegram.
|
|
|
|
|
nonEmpty := make([]string, 0, len(subChunks))
|
|
|
|
|
for _, s := range subChunks {
|
|
|
|
|
if s != "" {
|
|
|
|
|
nonEmpty = append(nonEmpty, s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Push sub-chunks back to the front of the queue
|
|
|
|
|
queue = append(nonEmpty, queue...)
|
2026-03-02 15:35:41 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
if err := c.sendChunk(ctx, sendChunkParams{
|
|
|
|
|
chatID: chatID,
|
|
|
|
|
threadID: threadID,
|
|
|
|
|
content: content,
|
|
|
|
|
replyToID: replyToID,
|
|
|
|
|
mdFallback: chunk,
|
|
|
|
|
useMarkdownV2: useMarkdownV2,
|
|
|
|
|
}); err != nil {
|
2026-03-02 15:35:41 +00:00
|
|
|
return err
|
2026-02-22 17:45:48 +00:00
|
|
|
}
|
2026-03-08 16:57:43 +00:00
|
|
|
// Only the first chunk should be a reply; subsequent chunks are normal messages.
|
|
|
|
|
replyToID = ""
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
type sendChunkParams struct {
|
|
|
|
|
chatID int64
|
|
|
|
|
threadID int
|
|
|
|
|
content string
|
|
|
|
|
replyToID string
|
|
|
|
|
mdFallback string
|
|
|
|
|
useMarkdownV2 bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sendChunk sends a single HTML/MarkdownV2 message, falling back to the original
|
|
|
|
|
// markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags.
|
|
|
|
|
func (c *TelegramChannel) sendChunk(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
params sendChunkParams,
|
2026-03-07 17:56:38 +00:00
|
|
|
) error {
|
2026-03-18 13:29:21 +00:00
|
|
|
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
|
|
|
|
|
tgMsg.MessageThreadID = params.threadID
|
|
|
|
|
if params.useMarkdownV2 {
|
|
|
|
|
tgMsg.WithParseMode(telego.ModeMarkdownV2)
|
|
|
|
|
} else {
|
|
|
|
|
tgMsg.WithParseMode(telego.ModeHTML)
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
if params.replyToID != "" {
|
|
|
|
|
if mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil {
|
2026-03-07 14:49:33 +00:00
|
|
|
tgMsg.ReplyParameters = &telego.ReplyParameters{
|
|
|
|
|
MessageID: mid,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 15:35:41 +00:00
|
|
|
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
2026-03-18 13:29:21 +00:00
|
|
|
logParseFailed(err, params.useMarkdownV2)
|
|
|
|
|
|
|
|
|
|
tgMsg.Text = params.mdFallback
|
2026-02-20 15:25:44 +00:00
|
|
|
tgMsg.ParseMode = ""
|
2026-02-22 17:45:48 +00:00
|
|
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
|
|
|
|
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-03-18 13:29:21 +00:00
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 00:22:04 +00:00
|
|
|
// maxTypingDuration limits how long the typing indicator can run.
|
|
|
|
|
// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.
|
|
|
|
|
// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.
|
|
|
|
|
const maxTypingDuration = 5 * time.Minute
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// 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.
|
2026-03-12 00:22:04 +00:00
|
|
|
// The goroutine also exits automatically after maxTypingDuration if cancel is
|
|
|
|
|
// never called (e.g. when the LLM fails or times out without publishing).
|
2026-02-26 19:02:40 +00:00
|
|
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
2026-03-10 02:54:10 +00:00
|
|
|
cid, threadID, err := parseTelegramChatID(chatID)
|
2026-02-26 19:02:40 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return func() {}, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:54:10 +00:00
|
|
|
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
|
|
|
|
action.MessageThreadID = threadID
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// Send the first typing action immediately
|
2026-03-10 02:54:10 +00:00
|
|
|
_ = c.bot.SendChatAction(ctx, action)
|
2026-02-26 19:02:40 +00:00
|
|
|
|
|
|
|
|
typingCtx, cancel := context.WithCancel(ctx)
|
2026-03-12 00:22:04 +00:00
|
|
|
// Cap lifetime so the goroutine cannot run indefinitely if cancel is never called
|
|
|
|
|
maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration)
|
2026-02-26 19:02:40 +00:00
|
|
|
go func() {
|
2026-03-12 00:22:04 +00:00
|
|
|
defer maxCancel()
|
2026-02-26 19:02:40 +00:00
|
|
|
ticker := time.NewTicker(4 * time.Second)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
2026-03-12 00:22:04 +00:00
|
|
|
case <-maxCtx.Done():
|
2026-02-26 19:02:40 +00:00
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
2026-03-10 02:54:10 +00:00
|
|
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
|
|
|
|
a.MessageThreadID = threadID
|
|
|
|
|
_ = c.bot.SendChatAction(typingCtx, a)
|
2026-02-26 19:02:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
return cancel, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// EditMessage implements channels.MessageEditor.
|
|
|
|
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
2026-03-18 13:29:21 +00:00
|
|
|
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
|
2026-03-10 02:54:10 +00:00
|
|
|
cid, _, err := parseTelegramChatID(chatID)
|
2026-02-22 20:55:15 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
mid, err := strconv.Atoi(messageID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-18 13:29:21 +00:00
|
|
|
parsedContent := parseContent(content, useMarkdownV2)
|
|
|
|
|
editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent)
|
|
|
|
|
if useMarkdownV2 {
|
|
|
|
|
editMsg.WithParseMode(telego.ModeMarkdownV2)
|
|
|
|
|
} else {
|
|
|
|
|
editMsg.WithParseMode(telego.ModeHTML)
|
|
|
|
|
}
|
2026-02-22 20:55:15 +00:00
|
|
|
_, err = c.bot.EditMessageText(ctx, editMsg)
|
2026-03-18 13:29:21 +00:00
|
|
|
if err != nil {
|
|
|
|
|
logParseFailed(err, useMarkdownV2)
|
|
|
|
|
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// DeleteMessage implements channels.MessageDeleter.
|
|
|
|
|
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
|
|
|
|
cid, _, err := parseTelegramChatID(chatID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
mid, err := strconv.Atoi(messageID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
|
|
|
|
|
ChatID: tu.ID(cid),
|
|
|
|
|
MessageID: mid,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// 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... 💭"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:54:10 +00:00
|
|
|
cid, threadID, err := parseTelegramChatID(chatID)
|
2026-02-26 19:02:40 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:54:10 +00:00
|
|
|
phMsg := tu.Message(tu.ID(cid), text)
|
|
|
|
|
phMsg.MessageThreadID = threadID
|
|
|
|
|
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
2026-02-26 19:02:40 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("%d", pMsg.MessageID), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
// SendMedia implements the channels.MediaSender interface.
|
|
|
|
|
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
|
|
|
|
if !c.IsRunning() {
|
|
|
|
|
return channels.ErrNotRunning
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:54:10 +00:00
|
|
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
2026-02-22 19:10:57 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
|
|
|
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, part := range msg.Parts {
|
|
|
|
|
localPath, err := store.Resolve(part.Ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, err := os.Open(localPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
|
|
|
|
|
"path": localPath,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch part.Type {
|
|
|
|
|
case "image":
|
|
|
|
|
params := &telego.SendPhotoParams{
|
2026-03-10 02:54:10 +00:00
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Photo: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendPhoto(ctx, params)
|
2026-03-18 02:46:35 +00:00
|
|
|
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
|
|
|
|
|
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
|
|
|
|
|
file.Close()
|
|
|
|
|
return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
docParams := &telego.SendDocumentParams{
|
|
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Document: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
|
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendDocument(ctx, docParams)
|
|
|
|
|
}
|
2026-02-22 19:10:57 +00:00
|
|
|
case "audio":
|
2026-03-23 21:11:10 +00:00
|
|
|
// Send OGG files with "voice" in the filename as Telegram voice
|
|
|
|
|
// bubbles (SendVoice) instead of audio attachments (SendAudio).
|
|
|
|
|
fn := strings.ToLower(part.Filename)
|
|
|
|
|
if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) {
|
|
|
|
|
vparams := &telego.SendVoiceParams{
|
|
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Voice: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
|
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendVoice(ctx, vparams)
|
|
|
|
|
} else {
|
|
|
|
|
params := &telego.SendAudioParams{
|
|
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Audio: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
|
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendAudio(ctx, params)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
case "video":
|
|
|
|
|
params := &telego.SendVideoParams{
|
2026-03-10 02:54:10 +00:00
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Video: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendVideo(ctx, params)
|
|
|
|
|
default: // "file" or unknown types
|
|
|
|
|
params := &telego.SendDocumentParams{
|
2026-03-10 02:54:10 +00:00
|
|
|
ChatID: tu.ID(chatID),
|
|
|
|
|
MessageThreadID: threadID,
|
|
|
|
|
Document: telego.InputFile{File: file},
|
|
|
|
|
Caption: part.Caption,
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
_, err = c.bot.SendDocument(ctx, params)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file.Close()
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
|
|
|
|
|
"type": part.Type,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
|
|
|
|
if message == nil {
|
|
|
|
|
return fmt.Errorf("message is nil")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
user := message.From
|
|
|
|
|
if user == nil {
|
|
|
|
|
return fmt.Errorf("message sender (user) is nil")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
platformID := fmt.Sprintf("%d", user.ID)
|
|
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "telegram",
|
|
|
|
|
PlatformID: platformID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
DisplayName: user.FirstName,
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-24 04:17:11 +00:00
|
|
|
// check allowlist to avoid downloading attachments for rejected users
|
2026-02-22 22:56:48 +00:00
|
|
|
if !c.IsAllowedSender(sender) {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
2026-02-22 22:56:48 +00:00
|
|
|
"user_id": platformID,
|
2026-02-20 15:25:44 +00:00
|
|
|
})
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatID := message.Chat.ID
|
2026-02-22 22:56:48 +00:00
|
|
|
c.chatIDs[platformID] = chatID
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
content := ""
|
|
|
|
|
mediaPaths := []string{}
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
chatIDStr := fmt.Sprintf("%d", chatID)
|
|
|
|
|
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
|
|
|
|
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
|
|
|
|
|
|
|
|
|
// Helper to register a local file with the media store
|
|
|
|
|
storeMedia := func(localPath, filename string) string {
|
|
|
|
|
if store := c.GetMediaStore(); store != nil {
|
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
2026-03-23 04:13:59 +00:00
|
|
|
Filename: filename,
|
|
|
|
|
Source: "telegram",
|
|
|
|
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
2026-02-22 15:27:55 +00:00
|
|
|
}, scope)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ref
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
return localPath // fallback: use raw path
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
if message.Text != "" {
|
|
|
|
|
content += message.Text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if message.Caption != "" {
|
|
|
|
|
if content != "" {
|
|
|
|
|
content += "\n"
|
|
|
|
|
}
|
|
|
|
|
content += message.Caption
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(message.Photo) > 0 {
|
|
|
|
|
photo := message.Photo[len(message.Photo)-1]
|
|
|
|
|
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
|
|
|
|
if photoPath != "" {
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
|
2026-02-20 15:25:44 +00:00
|
|
|
if content != "" {
|
|
|
|
|
content += "\n"
|
|
|
|
|
}
|
|
|
|
|
content += "[image: photo]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if message.Voice != nil {
|
|
|
|
|
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
|
|
|
|
if voicePath != "" {
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
if content != "" {
|
|
|
|
|
content += "\n"
|
|
|
|
|
}
|
2026-02-22 19:47:12 +00:00
|
|
|
content += "[voice]"
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if message.Audio != nil {
|
|
|
|
|
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
|
|
|
|
if audioPath != "" {
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
|
2026-02-20 15:25:44 +00:00
|
|
|
if content != "" {
|
|
|
|
|
content += "\n"
|
|
|
|
|
}
|
|
|
|
|
content += "[audio]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if message.Document != nil {
|
|
|
|
|
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
|
|
|
|
if docPath != "" {
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
|
2026-02-20 15:25:44 +00:00
|
|
|
if content != "" {
|
|
|
|
|
content += "\n"
|
|
|
|
|
}
|
|
|
|
|
content += "[file]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
content = "[empty message]"
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// In group chats, apply unified group trigger filtering
|
|
|
|
|
if message.Chat.Type != "private" {
|
|
|
|
|
isMentioned := c.isBotMentioned(message)
|
|
|
|
|
if isMentioned {
|
|
|
|
|
content = c.stripBotMention(content)
|
|
|
|
|
}
|
|
|
|
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
|
|
|
if !respond {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
content = cleaned
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:25:14 +00:00
|
|
|
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
|
|
|
|
// route to the correct topic and each topic gets its own session.
|
|
|
|
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
|
|
|
|
// must share one session per group.
|
2026-03-10 02:54:10 +00:00
|
|
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
|
|
|
|
threadID := message.MessageThreadID
|
2026-03-10 13:25:14 +00:00
|
|
|
if message.Chat.IsForum && threadID != 0 {
|
2026-03-10 02:54:10 +00:00
|
|
|
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
2026-02-22 22:56:48 +00:00
|
|
|
"sender_id": sender.CanonicalID,
|
2026-03-10 02:54:10 +00:00
|
|
|
"chat_id": compositeChatID,
|
|
|
|
|
"thread_id": threadID,
|
2026-02-20 15:25:44 +00:00
|
|
|
"preview": utils.Truncate(content, 50),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
peerKind := "direct"
|
|
|
|
|
peerID := fmt.Sprintf("%d", user.ID)
|
|
|
|
|
if message.Chat.Type != "private" {
|
|
|
|
|
peerKind = "group"
|
2026-03-10 02:54:10 +00:00
|
|
|
peerID = compositeChatID
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:57:12 +00:00
|
|
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
|
|
|
|
messageID := fmt.Sprintf("%d", message.MessageID)
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
metadata := map[string]string{
|
|
|
|
|
"user_id": fmt.Sprintf("%d", user.ID),
|
|
|
|
|
"username": user.Username,
|
|
|
|
|
"first_name": user.FirstName,
|
|
|
|
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:25:14 +00:00
|
|
|
// Set parent_peer metadata for per-topic agent binding.
|
|
|
|
|
if message.Chat.IsForum && threadID != 0 {
|
2026-03-10 02:54:10 +00:00
|
|
|
metadata["parent_peer_kind"] = "topic"
|
|
|
|
|
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:03:23 +00:00
|
|
|
c.HandleMessage(c.ctx,
|
2026-02-22 13:57:12 +00:00
|
|
|
peer,
|
|
|
|
|
messageID,
|
2026-02-22 22:56:48 +00:00
|
|
|
platformID,
|
2026-03-10 02:54:10 +00:00
|
|
|
compositeChatID,
|
2026-02-22 13:57:12 +00:00
|
|
|
content,
|
|
|
|
|
mediaPaths,
|
|
|
|
|
metadata,
|
2026-02-22 22:56:48 +00:00
|
|
|
sender,
|
2026-02-22 13:57:12 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
|
|
|
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
|
|
|
|
if err != nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.downloadFileWithInfo(file, ".jpg")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
|
|
|
|
|
if file.FilePath == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := c.bot.FileDownloadURL(file.FilePath)
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
// Use FilePath as filename for better identification
|
|
|
|
|
filename := file.FilePath + ext
|
|
|
|
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
|
|
|
LoggerPrefix: "telegram",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
|
|
|
|
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
|
|
|
|
if err != nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.downloadFileWithInfo(file, ext)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
func parseContent(text string, useMarkdownV2 bool) string {
|
|
|
|
|
if useMarkdownV2 {
|
|
|
|
|
return markdownToTelegramMarkdownV2(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return markdownToTelegramHTML(text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 13:25:14 +00:00
|
|
|
// parseTelegramChatID splits "chatID/threadID" into its components.
|
|
|
|
|
// Returns threadID=0 when no "/" is present (non-forum messages).
|
2026-03-10 02:54:10 +00:00
|
|
|
func parseTelegramChatID(chatID string) (int64, int, error) {
|
|
|
|
|
idx := strings.Index(chatID, "/")
|
|
|
|
|
if idx == -1 {
|
|
|
|
|
cid, err := strconv.ParseInt(chatID, 10, 64)
|
|
|
|
|
return cid, 0, err
|
|
|
|
|
}
|
|
|
|
|
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0, err
|
|
|
|
|
}
|
|
|
|
|
tid, err := strconv.Atoi(chatID[idx+1:])
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
|
|
|
|
}
|
|
|
|
|
return cid, tid, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
func logParseFailed(err error, useMarkdownV2 bool) {
|
|
|
|
|
parsingName := "HTML"
|
|
|
|
|
if useMarkdownV2 {
|
|
|
|
|
parsingName = "MarkdownV2"
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 13:29:21 +00:00
|
|
|
logger.ErrorCF("telegram",
|
|
|
|
|
fmt.Sprintf("%s parse failed, falling back to plain text", parsingName),
|
|
|
|
|
map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-02-20 15:25:44 +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
|
|
|
|
|
|
|
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
|
|
|
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
text, entities := telegramEntityTextAndList(message)
|
|
|
|
|
if text == "" || len(entities) == 0 {
|
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
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
botUsername := ""
|
|
|
|
|
if c.bot != nil {
|
|
|
|
|
botUsername = c.bot.Username()
|
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
|
|
|
}
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
runes := []rune(text)
|
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
|
|
|
|
|
|
|
|
for _, entity := range entities {
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
entityText, ok := telegramEntityText(runes, entity)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch entity.Type {
|
|
|
|
|
case telego.EntityTypeMention:
|
|
|
|
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
|
|
|
|
return true
|
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
|
|
|
}
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
case telego.EntityTypeTextMention:
|
|
|
|
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
|
|
|
|
return true
|
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
|
|
|
}
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
case telego.EntityTypeBotCommand:
|
|
|
|
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
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
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
feat(commands): centralized command registry with sub-command routing (#959)
* feat(commands): Session management [Phase 1/2] command centralization and registration
* docs: add design for command registry post-review fixes
Documents the architecture decisions for fixing 5 Important issues
from code review: SubCommand pattern, Deps struct, command-group files,
Executor caching, and Telegram registration dedup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add SubCommand type and EffectiveUsage method
Introduce SubCommand struct for declaring sub-commands structurally
within a parent command Definition. The EffectiveUsage() method
auto-generates usage strings from sub-command names and args,
preventing drift between help text and actual handler behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add Deps struct and secondToken helper, remove dead contains()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating
all definitions. Definitions with SubCommands are routed to matching
sub-command handlers. Missing or unknown sub-commands reply with
auto-generated usage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): split into command-group files with Deps injection
Extract show/list/start/help into individual cmd_*.go files.
Replace config.Config parameter with Deps struct for runtime data.
Restore /show agents and /list agents sub-commands.
Use EffectiveUsage for auto-generated help text.
Bridge external callers (agent/loop.go, telegram.go) with Deps wrapper
until Task 5 fully wires the Deps fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf(commands): cache Executor in AgentLoop, wire Deps with runtime callbacks
Create Executor once in NewAgentLoop instead of per-message. Deps
closures capture AgentLoop pointer for late-bound access to
channelManager and runtime agent model.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(telegram): remove duplicate initBotCommands, keep async startCommandRegistration only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(commands): restore Outcome comments and annotate Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): consolidate /switch into commands package, fix ! prefix
Move /switch model and /switch channel handling from inline loop.go
logic into cmd_switch.go using the SubCommand + Deps pattern. This
removes the OutcomePassthrough branch in handleCommand entirely.
Also replace the hardcoded "/" prefix check with commands.HasCommandPrefix
so that "!" prefixed commands are correctly routed to the Executor.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: add docs/plans to .gitignore and untrack existing files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings
- Remove dead ExecuteResult.Reply field and unused branch in loop.go
- Extract shared agentsHandler for /show agents and /list agents
- Remove redundant firstToken/secondToken (use nthToken instead)
- Simplify Telegram startup: pass BuiltinDefinitions directly
- Centralize req.Reply nil guard in executeDefinition
- Extract unavailableMsg constant (was duplicated 5 times)
- Remove unused MessageID from Request
- Remove stale "reserved for Phase 2" comment on Deps.Config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): replace Deps with per-request Runtime
Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.
- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix gci import grouping and godoc formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(onboard): skip legacy AGENT.md when copying embedded workspace templates
The workspace/ directory contains both AGENT.md (legacy) and AGENTS.md
(current). copyEmbeddedToTarget was copying both, causing the test
TestCopyEmbeddedToTargetUsesAgentsMarkdown to fail. Skip AGENT.md
during the walk to match the expected behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(agent): address self-review comments on loop.go
- Move cmdRegistry init into struct literal (review comment #11)
- Rename buildRuntime → buildCommandsRuntime for clarity (review comment #12)
- Add comment to default switch case explaining passthrough (review comment #13)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): address code review findings on naming and correctness
- Rename dispatcher.go → request.go (no Dispatcher type remains)
- Rename cmd_agents.go → handler_agents.go (shared handler, not a top-level command)
- Add modelMu to protect AgentInstance.Model writes in SwitchModel
- Add ListDefinitions to Runtime so /help uses registry instead of BuiltinDefinitions()
- Fix SwitchChannel message: validation-only callback should not say "Switched"
- Propagate Reply errors in executor instead of discarding with _ =
- Add HasCommandPrefix unit test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(onboard): extract legacy filename to constant
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(agent): handle commands before route error check
Move handleCommand() before the routeErr gate so global commands
(/help, /show, /switch) remain available even when routing fails.
Context-dependent commands that need a routed agent will report
"unavailable" through their nil-Runtime guards.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* revert: remove unnecessary AGENT.md skip in onboard
Reverts 02d0c04 and 74deae1. The test failure was caused by a local
leftover workspace/AGENT.md file (gitignored but embedded by go:embed).
Deleting the local file fixes the root cause; the code-level skip was
never needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: executeDefinition Unknown option
* fix(agent): use routed agent for model commands, restore Telegram command diff
- Remove modelMu: message processing is serial, no concurrent writes
- Pass routed agent to handleCommand/buildCommandsRuntime instead of
always using default agent
- GetModelInfo/SwitchModel are nil when agent is nil (route failed),
handlers reply "unavailable"
- Restore GetMyCommands + slices.Equal check before SetMyCommands to
avoid unnecessary Telegram API calls on restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(commands): remove unintended config mutation in SwitchModel
SwitchModel should only update the routed agent's runtime Model field.
Writing to cfg.Agents.Defaults.ModelName was a behavioral change that
corrupts the default agent config when switching a non-default agent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(commands): move /switch channel to /check channel
/switch channel only validates availability, not actually switching.
Rename to /check channel to match actual behavior. /switch channel
now shows a redirect message pointing users to the new command.
Addresses review feedback from yinwm on PR #959.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:31:40 +00:00
|
|
|
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
|
|
|
|
if message.Text != "" {
|
|
|
|
|
return message.Text, message.Entities
|
|
|
|
|
}
|
|
|
|
|
return message.Caption, message.CaptionEntities
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
|
|
|
|
if entity.Offset < 0 || entity.Length <= 0 {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
end := entity.Offset + entity.Length
|
|
|
|
|
if entity.Offset >= len(runes) || end > len(runes) {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return string(runes[entity.Offset:end]), true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
|
|
|
|
if !strings.HasPrefix(entityText, "/") {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
command := strings.TrimPrefix(entityText, "/")
|
|
|
|
|
if command == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
at := strings.IndexRune(command, '@')
|
|
|
|
|
if at == -1 {
|
|
|
|
|
// A bare /command delivered to this bot is intended for this bot.
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mentionUsername := command[at+1:]
|
|
|
|
|
if mentionUsername == "" || botUsername == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(mentionUsername, botUsername)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// stripBotMention removes the @bot mention from the content.
|
|
|
|
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
|
|
|
|
botUsername := c.bot.Username()
|
|
|
|
|
if botUsername == "" {
|
|
|
|
|
return content
|
|
|
|
|
}
|
|
|
|
|
// Case-insensitive replacement
|
|
|
|
|
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
|
|
|
|
|
content = re.ReplaceAllString(content, "")
|
|
|
|
|
return strings.TrimSpace(content)
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
// BeginStream implements channels.StreamingCapable.
|
|
|
|
|
func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
|
|
|
|
|
if !c.config.Channels.Telegram.Streaming.Enabled {
|
|
|
|
|
return nil, fmt.Errorf("streaming disabled in config")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cid, _, err := parseTelegramChatID(chatID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
streamCfg := c.config.Channels.Telegram.Streaming
|
|
|
|
|
return &telegramStreamer{
|
|
|
|
|
bot: c.bot,
|
|
|
|
|
chatID: cid,
|
|
|
|
|
draftID: cryptoRandInt(),
|
|
|
|
|
throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
|
|
|
|
|
minGrowth: streamCfg.MinGrowthChars,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// telegramStreamer streams partial LLM output via Telegram's sendMessageDraft API.
|
|
|
|
|
// On first API error (e.g. bot lacks forum mode), it silently degrades: Update
|
|
|
|
|
// becomes a no-op, while Finalize still delivers the final message.
|
|
|
|
|
type telegramStreamer struct {
|
|
|
|
|
bot *telego.Bot
|
|
|
|
|
chatID int64
|
|
|
|
|
draftID int
|
|
|
|
|
throttleInterval time.Duration
|
|
|
|
|
minGrowth int
|
|
|
|
|
lastLen int
|
|
|
|
|
lastAt time.Time
|
|
|
|
|
failed bool
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *telegramStreamer) Update(ctx context.Context, content string) error {
|
|
|
|
|
s.mu.Lock()
|
|
|
|
|
defer s.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if s.failed {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Throttle: skip if not enough time or content has passed
|
|
|
|
|
now := time.Now()
|
|
|
|
|
growth := len(content) - s.lastLen
|
|
|
|
|
if s.lastLen > 0 && now.Sub(s.lastAt) < s.throttleInterval && growth < s.minGrowth {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
htmlContent := markdownToTelegramHTML(content)
|
|
|
|
|
|
|
|
|
|
err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{
|
|
|
|
|
ChatID: s.chatID,
|
|
|
|
|
DraftID: s.draftID,
|
|
|
|
|
Text: htmlContent,
|
|
|
|
|
ParseMode: telego.ModeHTML,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
// First error → degrade silently (e.g. no forum mode)
|
|
|
|
|
logger.WarnCF("telegram", "sendMessageDraft failed, disabling streaming", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
s.failed = true
|
|
|
|
|
return nil // don't propagate — Finalize will still deliver
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.lastLen = len(content)
|
|
|
|
|
s.lastAt = now
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *telegramStreamer) Finalize(ctx context.Context, content string) error {
|
|
|
|
|
htmlContent := markdownToTelegramHTML(content)
|
|
|
|
|
tgMsg := tu.Message(tu.ID(s.chatID), htmlContent)
|
|
|
|
|
tgMsg.ParseMode = telego.ModeHTML
|
|
|
|
|
|
|
|
|
|
if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil {
|
|
|
|
|
// Fallback to plain text
|
|
|
|
|
tgMsg.ParseMode = ""
|
|
|
|
|
if _, err = s.bot.SendMessage(ctx, tgMsg); err != nil {
|
|
|
|
|
logger.ErrorCF("telegram", "Finalize failed after HTML and plain-text attempts", map[string]any{
|
|
|
|
|
"chat_id": s.chatID,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
"len": len(content),
|
|
|
|
|
})
|
|
|
|
|
return fmt.Errorf("telegram finalize: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *telegramStreamer) Cancel(ctx context.Context) {
|
|
|
|
|
// Draft auto-expires on Telegram's side; nothing to clean up.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cryptoRandInt returns a non-zero random int using crypto/rand.
|
|
|
|
|
func cryptoRandInt() int {
|
|
|
|
|
var b [4]byte
|
|
|
|
|
_, _ = rand.Read(b[:])
|
|
|
|
|
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
|
|
|
|
|
}
|