Merge remote-tracking branch 'origin/main' into feat/echo-voice-audio-transcription

# Conflicts:
#	pkg/channels/telegram/telegram.go
This commit is contained in:
afjcjsbx
2026-03-07 18:55:32 +01:00
18 changed files with 1132 additions and 53 deletions
+45 -11
View File
@@ -220,7 +220,7 @@ func registerSharedTools(
// Spawn tool with allowlist checker
if cfg.Tools.IsToolEnabled("spawn") {
if cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
@@ -759,9 +759,8 @@ func (al *AgentLoop) runAgentLoop(
agent *AgentInstance,
opts processOptions,
) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels)
// 0. Record last channel for heartbeat notifications (skip internal channels and cli)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
@@ -1098,9 +1097,12 @@ func (al *AgentLoop) runLLMIteration(
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Check if no tool calls - we're done
// Check if no tool calls - then check reasoning content if any
if len(response.ToolCalls) == 0 {
finalContent = response.Content
if finalContent == "" && response.ReasoningContent != "" {
finalContent = response.ReasoningContent
}
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{
"agent_id": agent.ID,
@@ -1186,15 +1188,47 @@ func (al *AgentLoop) runLLMIteration(
"iteration": iteration,
})
// Create async callback for tools that implement AsyncExecutor
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
// Create async callback for tools that implement AsyncExecutor.
// When the background work completes, this publishes the result
// as an inbound system message so processSystemMessage routes it
// back to the user via the normal agent loop.
asyncCallback := func(_ context.Context, result *tools.ToolResult) {
// Send ForUser content directly to the user (immediate feedback),
// mirroring the synchronous tool execution path.
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer outCancel()
_ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: result.ForUser,
})
}
// Determine content for the agent loop (ForLLM or error).
content := result.ForLLM
if content == "" && result.Err != nil {
content = result.Err.Error()
}
if content == "" {
return
}
logger.InfoCF("agent", "Async tool completed, publishing result",
map[string]any{
"tool": tc.Name,
"content_len": len(content),
"channel": opts.Channel,
})
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("async:%s", tc.Name),
ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
Content: content,
})
}
toolResult := agent.Tools.ExecuteWithContext(
+154
View File
@@ -0,0 +1,154 @@
package irc
import (
"fmt"
"strings"
"time"
"unicode"
"github.com/ergochat/irc-go/ircevent"
"github.com/ergochat/irc-go/ircmsg"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
// onConnect is called after a successful connection (and on reconnect).
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
// NickServ auth (only if SASL is not configured)
if c.config.NickServPassword != "" && c.config.SASLUser == "" {
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
}
// Join configured channels
for _, ch := range c.config.Channels {
conn.Join(ch)
logger.InfoCF("irc", "Joined IRC channel", map[string]any{
"channel": ch,
})
}
}
// onPrivmsg handles incoming PRIVMSG events.
func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
if len(e.Params) < 2 {
return
}
nick := e.Nick()
currentNick := conn.CurrentNick()
// Ignore own messages
if strings.EqualFold(nick, currentNick) {
return
}
target := e.Params[0] // channel name or bot's nick
content := e.Params[1] // message text
// Determine if this is a DM or channel message
isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&")
var chatID string
var peer bus.Peer
if isDM {
chatID = nick
peer = bus.Peer{Kind: "direct", ID: nick}
} else {
chatID = target
peer = bus.Peer{Kind: "group", ID: target}
}
sender := bus.SenderInfo{
Platform: "irc",
PlatformID: nick,
CanonicalID: identity.BuildCanonicalID("irc", nick),
Username: nick,
DisplayName: nick,
}
if !c.IsAllowedSender(sender) {
return
}
// For channel messages, check group trigger (mention detection)
if !isDM {
isMentioned := isBotMentioned(content, currentNick)
if isMentioned {
content = stripBotMention(content, currentNick)
}
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return
}
content = cleaned
}
if strings.TrimSpace(content) == "" {
return
}
messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano())
metadata := map[string]string{
"platform": "irc",
"server": c.config.Server,
}
if !isDM {
metadata["channel"] = target
}
c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender)
}
// nickMentionedAt returns the byte index where botNick is mentioned in content
// with word-boundary checks, or -1 if not found. Also checks for "nick:" /
// "nick," prefix convention.
func nickMentionedAt(content, botNick string) int {
lower := strings.ToLower(content)
lowerNick := strings.ToLower(botNick)
// "nick:" or "nick," at start (most common IRC convention)
if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") {
return 0
}
// Word-boundary match anywhere in the message
idx := strings.Index(lower, lowerNick)
if idx < 0 {
return -1
}
runes := []rune(lower)
nickRunes := []rune(lowerNick)
endIdx := idx + len(string(nickRunes))
before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1])
after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx]))
if before && after {
return idx
}
return -1
}
// isBotMentioned checks if the bot's nick appears in the message.
func isBotMentioned(content, botNick string) bool {
return nickMentionedAt(content, botNick) >= 0
}
// stripBotMention removes "nick: " or "nick, " prefix from content.
func stripBotMention(content, botNick string) string {
idx := nickMentionedAt(content, botNick)
if idx != 0 {
return content
}
lowerNick := strings.ToLower(botNick)
lower := strings.ToLower(content)
for _, sep := range []string{":", ","} {
prefix := lowerNick + sep
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(content[len(prefix):])
}
}
return content
}
+16
View File
@@ -0,0 +1,16 @@
package irc
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
if !cfg.Channels.IRC.Enabled {
return nil, nil
}
return NewIRCChannel(cfg.Channels.IRC, b)
})
}
+194
View File
@@ -0,0 +1,194 @@
package irc
import (
"context"
"crypto/tls"
"fmt"
"strings"
"github.com/ergochat/irc-go/ircevent"
"github.com/ergochat/irc-go/ircmsg"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// IRCChannel implements the Channel interface for IRC servers.
type IRCChannel struct {
*channels.BaseChannel
config config.IRCConfig
conn *ircevent.Connection
ctx context.Context
cancel context.CancelFunc
}
// NewIRCChannel creates a new IRC channel.
func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
if cfg.Server == "" {
return nil, fmt.Errorf("irc server is required")
}
if cfg.Nick == "" {
return nil, fmt.Errorf("irc nick is required")
}
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(400),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &IRCChannel{
BaseChannel: base,
config: cfg,
}, nil
}
// Start connects to the IRC server and begins listening.
func (c *IRCChannel) Start(ctx context.Context) error {
logger.InfoC("irc", "Starting IRC channel")
c.ctx, c.cancel = context.WithCancel(ctx)
user := c.config.User
if user == "" {
user = c.config.Nick
}
realName := c.config.RealName
if realName == "" {
realName = c.config.Nick
}
caps := []string(c.config.RequestCaps)
if len(caps) == 0 {
caps = []string{"server-time", "message-tags"}
}
conn := &ircevent.Connection{
Server: c.config.Server,
Nick: c.config.Nick,
User: user,
RealName: realName,
Password: c.config.Password,
UseTLS: c.config.TLS,
RequestCaps: caps,
QuitMessage: "Goodbye",
Debug: false,
Log: nil,
}
if c.config.TLS {
conn.TLSConfig = &tls.Config{
ServerName: extractHost(c.config.Server),
}
}
// SASL auth (takes priority over NickServ)
if c.config.SASLUser != "" && c.config.SASLPassword != "" {
conn.SASLLogin = c.config.SASLUser
conn.SASLPassword = c.config.SASLPassword
}
// Register event handlers
conn.AddConnectCallback(func(e ircmsg.Message) {
c.onConnect(conn)
})
conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
c.onPrivmsg(conn, e)
})
if err := conn.Connect(); err != nil {
return fmt.Errorf("irc connect failed: %w", err)
}
c.conn = conn
// ircevent.Connection.Loop() handles reconnection internally.
go conn.Loop()
c.SetRunning(true)
logger.InfoCF("irc", "IRC channel started", map[string]any{
"server": c.config.Server,
"nick": c.config.Nick,
})
return nil
}
// Stop disconnects from the IRC server.
func (c *IRCChannel) Stop(ctx context.Context) error {
logger.InfoC("irc", "Stopping IRC channel")
c.SetRunning(false)
if c.conn != nil {
c.conn.Quit()
}
if c.cancel != nil {
c.cancel()
}
logger.InfoC("irc", "IRC channel stopped")
return nil
}
// Send sends a message to an IRC channel or user.
func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
target := msg.ChatID
if target == "" {
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
}
if strings.TrimSpace(msg.Content) == "" {
return nil
}
// Send each line separately (IRC is line-oriented)
lines := strings.Split(msg.Content, "\n")
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
c.conn.Privmsg(target, line)
}
logger.DebugCF("irc", "Message sent", map[string]any{
"target": target,
"lines": len(lines),
})
return nil
}
// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
// Requires typing.enabled in config and server support for message-tags capability.
func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
noop := func() {}
if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
return noop, nil
}
// Check if server supports message-tags (required for TAGMSG)
if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
return noop, nil
}
c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
return func() {
if c.IsRunning() && c.conn != nil {
c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
}
}, nil
}
// extractHost returns the hostname portion of a host:port string.
func extractHost(server string) string {
host, _, found := strings.Cut(server, ":")
if found {
return host
}
return server
}
+145
View File
@@ -0,0 +1,145 @@
package irc
import (
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewIRCChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing server", func(t *testing.T) {
cfg := config.IRCConfig{Nick: "bot"}
_, err := NewIRCChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing server, got nil")
}
})
t.Run("missing nick", func(t *testing.T) {
cfg := config.IRCConfig{Server: "irc.example.com:6667"}
_, err := NewIRCChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing nick, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := config.IRCConfig{
Server: "irc.example.com:6667",
Nick: "testbot",
Channels: []string{"#test"},
}
ch, err := NewIRCChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Name() != "irc" {
t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
}
if ch.IsRunning() {
t.Error("new channel should not be running")
}
})
}
func TestExtractHost(t *testing.T) {
tests := []struct {
server string
want string
}{
{"irc.libera.chat:6697", "irc.libera.chat"},
{"localhost:6667", "localhost"},
{"irc.example.com", "irc.example.com"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.server, func(t *testing.T) {
got := extractHost(tt.server)
if got != tt.want {
t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
}
})
}
}
func TestNickMentionedAt(t *testing.T) {
tests := []struct {
name string
content string
nick string
want int
}{
{"colon prefix", "bot: hello", "bot", 0},
{"comma prefix", "bot, hello", "bot", 0},
{"case insensitive", "BOT: hello", "bot", 0},
{"word boundary mid", "hey bot what's up", "bot", 4},
{"no mention", "hello world", "bot", -1},
{"substring mismatch", "robotics are cool", "bot", -1},
{"nick at end", "hello bot", "bot", 6},
{"empty content", "", "bot", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := nickMentionedAt(tt.content, tt.nick)
if got != tt.want {
t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
}
})
}
}
func TestIsBotMentioned(t *testing.T) {
tests := []struct {
name string
content string
nick string
want bool
}{
{"colon prefix", "bot: hello", "bot", true},
{"comma prefix", "bot, hello", "bot", true},
{"case insensitive", "BOT: hello", "bot", true},
{"word boundary mid", "hey bot what's up", "bot", true},
{"no mention", "hello world", "bot", false},
{"substring mismatch", "robotics are cool", "bot", false},
{"nick at end", "hello bot", "bot", true},
{"empty content", "", "bot", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isBotMentioned(tt.content, tt.nick)
if got != tt.want {
t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
}
})
}
}
func TestStripBotMention(t *testing.T) {
tests := []struct {
name string
content string
nick string
want string
}{
{"colon prefix", "bot: hello there", "bot", "hello there"},
{"comma prefix", "bot, help me", "bot", "help me"},
{"case insensitive", "BOT: hello", "bot", "hello"},
{"no prefix match", "hello bot", "bot", "hello bot"},
{"only prefix", "bot:", "bot", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripBotMention(tt.content, tt.nick)
if got != tt.want {
t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
}
})
}
}
+5
View File
@@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{
"discord": 1,
"slack": 1,
"line": 10,
"irc": 2,
}
type channelWorker struct {
@@ -269,6 +270,10 @@ func (m *Manager) initChannels() error {
m.initChannel("pico", "Pico")
}
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
m.initChannel("irc", "IRC")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
+42 -8
View File
@@ -88,7 +88,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg,
bus,
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
@@ -173,30 +173,64 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(msg.Content)
if msg.Content == "" {
return nil
}
// Typing/placeholder handled by Manager.preSend — just send the message
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
// 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.
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
queue = queue[1:]
htmlContent := markdownToTelegramHTML(chunk)
if len([]rune(htmlContent)) > 4096 {
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
if smallerLen < 100 {
smallerLen = 100
}
// Push sub-chunks back to the front of the queue for
// re-validation instead of sending them blindly.
subChunks := channels.SplitMessage(chunk, smallerLen)
queue = append(subChunks, queue...)
continue
}
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk, msg.ReplyToMessageID); err != nil {
return err
}
}
return nil
}
// sendHTMLChunk sends a single HTML message, falling back to the original
// markdown as plain text on parse failure so users never see raw HTML tags.
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback, replyToID string) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
if msg.ReplyToMessageID != "" {
if mid, parseErr := strconv.Atoi(msg.ReplyToMessageID); parseErr == nil {
if replyToID != "" {
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
tgMsg.ReplyParameters = &telego.ReplyParameters{
MessageID: mid,
}
}
}
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
tgMsg.Text = mdFallback
tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
return nil
}
+273
View File
@@ -0,0 +1,273 @@
package telegram
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
)
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
// stubCaller implements ta.Caller for testing.
type stubCaller struct {
calls []stubCall
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
}
type stubCall struct {
URL string
Data *ta.RequestData
}
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
s.calls = append(s.calls, stubCall{URL: url, Data: data})
return s.callFn(ctx, url, data)
}
// stubConstructor implements ta.RequestConstructor for testing.
type stubConstructor struct{}
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
}
func (s *stubConstructor) MultipartRequest(
parameters map[string]string,
files map[string]ta.NamedReader,
) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
}
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
func successResponse(t *testing.T) *ta.Response {
t.Helper()
msg := &telego.Message{MessageID: 1}
b, err := json.Marshal(msg)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
}
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
t.Helper()
bot, err := telego.NewBot(testToken,
telego.WithAPICaller(caller),
telego.WithRequestConstructor(&stubConstructor{}),
telego.WithDiscardLogger(),
)
require.NoError(t, err)
base := channels.NewBaseChannel("telegram", nil, nil, nil,
channels.WithMaxMessageLength(4000),
)
base.SetRunning(true)
return &TelegramChannel{
BaseChannel: base,
bot: bot,
chatIDs: make(map[string]int64),
}
}
func TestSend_EmptyContent(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("SendMessage should not be called for empty content")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
assert.NoError(t, err)
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
}
func TestSend_ShortMessage_SingleCall(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
}
func TestSend_LongMessage_SingleCall(t *testing.T) {
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
// they reach Send(). A message at exactly 4000 chars should go through
// as a single SendMessage call (no re-split needed since HTML expansion
// won't exceed 4096 for plain text).
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("a", 4000)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
}
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
callCount := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
callCount++
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
if callCount%2 == 1 {
return nil, errors.New("Bad Request: can't parse entities")
}
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
assert.NoError(t, err)
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
}
func TestSend_HTMLFallback_BothFail(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
}
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
// With a long message that gets split into 2 chunks, if both HTML and
// plain text fail on the first chunk, Send should return early.
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("x", 4001)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.Error(t, err)
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
}
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
// yields HTML that exceeds Telegram's limit while markdown stays within it.
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
htmlExpanded := markdownToTelegramHTML(markdownContent)
assert.Greater(
t, len([]rune(htmlExpanded)), 4096,
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
assert.NoError(t, err)
assert.Greater(
t, len(caller.calls), 1,
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
)
}
func TestSend_NotRunning(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
ch.SetRunning(false)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.ErrorIs(t, err, channels.ErrNotRunning)
assert.Empty(t, caller.calls)
}
func TestSend_InvalidChatID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
assert.Empty(t, caller.calls)
}
+20
View File
@@ -232,6 +232,7 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
IRC IRCConfig `json:"irc"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -415,6 +416,25 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
+2 -2
View File
@@ -8,7 +8,7 @@ import (
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
ctx := context.Background()
@@ -42,7 +42,7 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
ctx := context.Background()
-18
View File
@@ -6,7 +6,6 @@ import (
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -27,7 +26,6 @@ type SubagentManager struct {
mu sync.RWMutex
provider providers.LLMProvider
defaultModel string
bus *bus.MessageBus
workspace string
tools *ToolRegistry
maxIterations int
@@ -41,13 +39,11 @@ type SubagentManager struct {
func NewSubagentManager(
provider providers.LLMProvider,
defaultModel, workspace string,
bus *bus.MessageBus,
) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
defaultModel: defaultModel,
bus: bus,
workspace: workspace,
tools: NewToolRegistry(),
maxIterations: 10,
@@ -214,20 +210,6 @@ After completing the task, provide a clear summary of what was done.`
Async: false,
}
}
// Send announce message back to main agent
if sm.bus != nil {
announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
sm.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
// Format: "original_channel:original_chat_id" for routing back
ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID),
Content: announceContent,
})
}
}
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
+9 -14
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -47,7 +46,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager)
@@ -73,7 +72,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
// TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
if tool.Name() != "subagent" {
@@ -84,7 +83,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
desc := tool.Description()
@@ -99,7 +98,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
params := tool.Parameters()
@@ -149,8 +148,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_Execute_Success tests successful execution
func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
@@ -204,8 +202,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
// TestSubagentTool_Execute_NoLabel tests execution without label
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()
@@ -228,7 +225,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()
@@ -278,8 +275,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
channel := "test-channel"
@@ -304,8 +300,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content
provider := &MockLLMProvider{}
msgBus := bus.NewMessageBus()
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()