* Add multi-message sending via split marker
* Add marker and length split integration tests
Tests that SplitByMarker and SplitMessage work together correctly, and
that code block boundaries are preserved during marker splitting.
* Simplify message chunking logic in channel worker
Extract splitByLength helper function and remove goto-based control
flow.
The logic now flows more naturally - try marker splitting first, then
fall
back to length-based splitting.
* Update multi-message output instructions in agent context
* Add split_on_marker to config defaults
* Add split_on_marker config option
* Rename 'Multi-Message Sending' setting to 'Chatty Mode'
* Add SplitOnMarker config option
* 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>
* feat(pico): add pico_client outbound WebSocket channel
Add a client-mode counterpart to the existing pico server channel.
pico_client connects to a remote Pico Protocol WebSocket server,
enabling picoclaw to bridge messages with external Pico-compatible
services.
Includes config, factory registration, manager wiring, 8 unit tests,
and a minimal echo-server example for interactive testing.
* fix(pico): address PR #1198 review — goroutine leak, race, auth
- Add per-connection context cancel to picoConn to prevent pingLoop
goroutine leak on disconnect
- Re-acquire mutex in StartTyping stop closure to avoid stale conn race
- Remove query-param token auth from echo server (header-only)
- Move ListenAndServe to main goroutine where log.Fatal is safe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: replace ConsumeInbound with InboundChan select in client test
MessageBus does not expose a ConsumeInbound method. Use a select on
InboundChan() with context cancellation, matching the pattern used in
the bus package tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(wecom): add WebSocket long-connection support for WeCom AI Bot
- Introduced WeComAIBotWSChannel to handle WebSocket connections.
- Updated NewWeComAIBotChannel to prioritize WebSocket mode when BotID and Secret are provided.
- Enhanced WeComAIBotConfig to include BotID and Secret for WebSocket mode.
- Implemented message handling for text, image, voice, and mixed messages in WebSocket mode.
- Added tests for WebSocket mode functionality and ensured backward compatibility with webhook mode.
- Refactored existing code to improve clarity and maintainability.
* feat(wecom): implement periodic processing hints and enforce WeCom stream deadline
* feat(wecom): update WeCom AI Bot setup instructions and configuration parameters
* feat(wecom): enhance WeCom AI Bot with image handling and media support
* feat(wecom): refactor WeCom AI Bot task management to use req_id for concurrent message handling
* feat(wecom): refactor WeCom AI Bot to manage request states and late replies
* feat(wecom): add response timeout handling and improve WebSocket command acknowledgment
* fix(wecom): improve error handling for late reply proactive push delivery
* refactor(wecom): reorganize WeCom AI Bot configuration fields for improved readability
* fix(wecom): update error message for websocket delivery failure in late reply proactive push
* feat(wecom): implement shared HTTP clients for WeCom image handling and response URL posting
* refactor(wecom): simplify image download and storage process in storeWSImage
* fix(wecom): improve error logging for WebSocket message handling and proactive push delivery
* fix(wecom): enhance WebSocket connection stability and task cancellation handling
* fix(wecom): improve WS image message handling by ensuring proper error response and initializing mediaRefs
* feat(wecom): enhance WeCom AIBot WebSocket handling with message deduplication and support for file and video messages
* refactor(wecom): rename image handling functions to media handling and enhance media type support
* feat(wecom): implement byte-aware content splitting for WeCom AI Bot stream messages
* refactor(wecom): remove max message length constraint from WeCom AIBot WS channel
* fix: Fixed the bug where the bus was closed and consumers had unfinished messages.
* fix: remove unnecessary blank line in Close method
* fix: refactor message bus and channel handling for improved performance and reliability
* fix: improve message handling and bus closure logic for better reliability
* fix: reduce sleep duration in agent loop for improved responsiveness
* fix the test case
* feat(gateway): support hot reload and empty startup
- extract gateway runtime into pkg/gateway
- add gateway.hot_reload config with default and example values
- allow starting the gateway without a default model via --allow-empty
- stop treating missing enabled channels as a startup error
- update related tests
* feat: replace gateway SSE updates with polling-based state sync
- remove gateway SSE broadcasting and event endpoint
- add polling-based gateway status refresh with stopping state handling
- detect when gateway restart is required after default model changes
- resolve gateway health and websocket proxy targets from configured host
- update gateway UI labels and add backend/frontend test coverage
* feat(channels): enhance QQ channel with group support, typing, media, and URL sanitization
Add group message routing alongside existing C2C (direct) support using
chatType sync.Map to track whether a chatID is group or direct. Implement
passive reply with msg_id/msg_seq tracking for multi-part responses.
Add StartTyping (InputNotify msg_type=6 with periodic resend), SendMedia
(RichMediaMessage for HTTP/HTTPS URLs), and configurable Markdown message
support. Replace unbounded dedup map with TTL-based expiry and janitor
goroutine.
Sanitize URLs in group messages by replacing dots in domains with fullwidth
period to avoid QQ's URL blacklist rejection (error 40054010). Add rate
limit config (5 msg/s) and MaxMessageLength/SendMarkdown config fields.
* fix(channels): address review feedback on QQ channel implementation
- Fix goroutine leak: reinitialize done channel and sync.Once in Start()
to prevent multiple janitor goroutines on restart
- Fix double-close panic: guard close(done) with sync.Once in Stop()
- Fix StartTyping context: use c.ctx (channel lifecycle) instead of
caller's ctx (request lifecycle) for typing goroutine
- Refactor: extract getChatKind() helper to deduplicate chatType lookup
across Send(), StartTyping(), and SendMedia()
- Fix: use new(atomic.Uint64) instead of taking address of local var
- Fix: require explicit http(s):// scheme in URL regex to avoid false
positives on version strings like "1.2.3"
- Optimize: collect expired keys before deleting in dedupJanitor to
reduce lock hold time
- Fix: remove MaxMessageLength zero-value override in NewQQChannel
since defaults.go already sets 2000
* fix(channels): address second round of review feedback on QQ channel
- Fix SendMedia: bypass media store for direct http(s) URLs in part.Ref;
only fall back to store.Resolve for media:// refs; log clear warning
for local-only paths instead of silently skipping
- Fix chatType routing: default unknown chatIDs to "group" (safer for QQ
since outbound-only destinations like reasoning_channel_id are groups);
pre-register reasoning_channel_id as group at Start() time; add debug
log for untracked chatIDs
- Add dedup hard cap (10000 entries): evict oldest entry when map
exceeds capacity to prevent unbounded memory growth under high traffic
Add IRC as a new channel for picoclaw, supporting server connections,
channel joins, DMs, mention-based group triggers, and IRCv3 typing
indicators. Uses ergochat/irc-go for connection management with SASL,
NickServ, and automatic reconnection support.
Closes#1137
- Introduced WeCom AIBot channel configuration in config.go with relevant fields.
- Implemented WeCom AIBot channel factory registration in init.go.
- Created unit tests for WeCom AIBot channel functionalities including initialization, start/stop behavior, webhook path handling, message encryption/decryption, and signature generation.
- Set default values for WeCom AIBot configuration in defaults.go.
Define PlaceholderCapable, TypingCapable, and ReactionCapable interfaces
and have BaseChannel.HandleMessage auto-detect and trigger all three as
independent pipelines on inbound messages. This replaces the scattered
manual orchestration code in each channel's handleMessage with a single
unified dispatch in the framework layer.
Changes:
- Add PlaceholderCapable interface to interfaces.go
- Add ReactionCapable + RecordReactionUndo to interfaces.go
- BaseChannel.HandleMessage auto-triggers Typing → Reaction → Placeholder
- Manager gains reactionUndos sync.Map with TTL janitor cleanup
- Telegram: extract SendPlaceholder from manual code, add StartTyping
- Discord: add SendPlaceholder + StartTyping
- Pico: add SendPlaceholder (uses Pico Protocol message.create)
- Slack: extract ReactToMessage from manual code
- OneBot: extract ReactToMessage, remove leaked pendingEmojiMsg sync.Map
- LINE: move group-chat guard into StartTyping, remove manual orchestration
- Config: add Placeholder to PicoConfig; remove from Slack/LINE/OneBot
(no MessageEditor, so placeholder config was dead code)
Message splitting is exclusively a Manager responsibility. Moving it
into the channels package eliminates the cross-package dependency and
aligns with the refactoring plan.
Phase 10: Define TypingCapable, MessageEditor, PlaceholderRecorder interfaces.
Manager orchestrates outbound typing stop and placeholder editing via preSend.
Migrate Telegram, Discord, Slack, OneBot to register state with Manager instead
of handling locally in Send. Phase 7: Add native WebSocket Pico Protocol channel
as reference implementation of all optional capability interfaces.
Add outbound media sending capability so the agent can publish media
attachments (images, files, audio, video) through channels via the bus.
- Add MediaPart and OutboundMediaMessage types to bus
- Add PublishOutboundMedia/SubscribeOutboundMedia bus methods
- Add MediaSender interface discovered via type assertion by Manager
- Add media dispatch/worker in Manager with shared retry logic
- Extend ToolResult with Media field and MediaResult constructor
- Publish outbound media from agent loop on tool results
- Implement SendMedia for Telegram, Discord, Slack, LINE, OneBot, WeCom
Merge 3 independent channel HTTP servers (LINE :18791, WeCom Bot :18793,
WeCom App :18792) and the health server (:18790) into a single shared
HTTP server on the Gateway address. Channels implement WebhookHandler
and/or HealthChecker interfaces to register their handlers on the shared
mux. Also change Gateway default host from 0.0.0.0 to 127.0.0.1 for
security.
Define sentinel error types (ErrNotRunning, ErrRateLimit, ErrTemporary,
ErrSendFailed) so the Manager can classify Send failures and choose the
right retry strategy: permanent errors bail immediately, rate-limit
errors use a fixed 1s delay, and temporary/unknown errors use exponential
backoff (500ms→1s→2s, capped at 8s, up to 3 retries). A per-channel
token-bucket rate limiter (golang.org/x/time/rate) throttles outbound
sends before they hit the platform API.
Channels previously deleted downloaded media files via defer os.Remove,
racing with the async Agent consumer. Introduce MediaStore to decouple
file ownership: channels register files on download, Agent releases them
after processing via ReleaseAll(scope).
- New pkg/media with MediaStore interface + FileMediaStore implementation
- InboundMessage gains MediaScope field for lifecycle tracking
- BaseChannel gains SetMediaStore/GetMediaStore + BuildMediaScope helper
- Manager injects MediaStore into channels; AgentLoop releases on completion
- Telegram, Discord, Slack, OneBot, LINE channels migrated from defer
os.Remove to store.Store() with media:// refs
Move message splitting from individual channels (Discord) to the Manager
layer via per-channel worker goroutines. Each channel now declares its
max message length through BaseChannelOption/MessageLengthProvider, and
the Manager automatically splits oversized outbound messages before
dispatch. This prevents one slow channel from blocking all others.
- Add WithMaxMessageLength option and MessageLengthProvider interface
- Set platform-specific limits (Discord 2000, Telegram 4096, Slack 40000, etc.)
- Convert SplitMessage to rune-aware counting for correct Unicode handling
- Replace single dispatcher goroutine with per-channel buffered worker queues
- Remove Discord's internal SplitMessage call (now handled centrally)