Merge branch 'main' into main
This commit is contained in:
commit
aa1bc73536
19 changed files with 948 additions and 59 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 386 KiB |
|
|
@ -16,6 +16,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,28 @@
|
||||||
"max_steps": 10,
|
"max_steps": 10,
|
||||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "irc.libera.chat:6697",
|
||||||
|
"tls": true,
|
||||||
|
"nick": "mybot",
|
||||||
|
"user": "",
|
||||||
|
"real_name": "",
|
||||||
|
"password": "",
|
||||||
|
"nickserv_password": "",
|
||||||
|
"sasl_user": "",
|
||||||
|
"sasl_password": "",
|
||||||
|
"channels": ["#mychannel"],
|
||||||
|
"request_caps": ["server-time", "message-tags"],
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -14,7 +14,7 @@ require (
|
||||||
github.com/h2non/filetype v1.1.3
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0
|
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
|
@ -31,12 +31,13 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/ergochat/irc-go v0.5.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|
@ -48,6 +49,8 @@ require (
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.3 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
|
|
||||||
14
go.sum
14
go.sum
|
|
@ -1,6 +1,6 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
|
|
@ -48,6 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
|
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||||
|
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
|
@ -134,8 +136,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
|
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
|
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
|
@ -171,6 +173,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
|
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ func registerSharedTools(
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
if cfg.Tools.IsToolEnabled("spawn") {
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
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)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
|
|
@ -726,9 +726,8 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
opts processOptions,
|
opts processOptions,
|
||||||
) (string, error) {
|
) (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 != "" {
|
if opts.Channel != "" && opts.ChatID != "" {
|
||||||
// Don't record internal channels (cli, system, subagent)
|
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||||
|
|
@ -1144,15 +1143,47 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncExecutor
|
// Create async callback for tools that implement AsyncExecutor.
|
||||||
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
// 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 != "" {
|
if !result.Silent && result.ForUser != "" {
|
||||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
map[string]any{
|
defer outCancel()
|
||||||
"tool": tc.Name,
|
_ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
|
||||||
"content_len": len(result.ForUser),
|
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(
|
toolResult := agent.Tools.ExecuteWithContext(
|
||||||
|
|
|
||||||
154
pkg/channels/irc/handler.go
Normal file
154
pkg/channels/irc/handler.go
Normal 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
pkg/channels/irc/init.go
Normal file
16
pkg/channels/irc/init.go
Normal 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
pkg/channels/irc/irc.go
Normal file
194
pkg/channels/irc/irc.go
Normal 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
pkg/channels/irc/irc_test.go
Normal file
145
pkg/channels/irc/irc_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ var channelRateConfig = map[string]float64{
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
"qq": 5,
|
"qq": 5,
|
||||||
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelWorker struct {
|
type channelWorker struct {
|
||||||
|
|
@ -268,6 +269,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("pico", "Pico")
|
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{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ type ChannelsConfig struct {
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
|
IRC IRCConfig `json:"irc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -416,6 +417,25 @@ type PicoConfig struct {
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -190,14 +190,21 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
cs.mu.RUnlock()
|
cs.mu.RUnlock()
|
||||||
|
|
||||||
if callbackJob == nil {
|
if callbackJob == nil {
|
||||||
|
log.Printf("[cron] job %s not found, skipping", jobID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log job execution start
|
||||||
|
log.Printf("[cron] ▶ executing job '%s' (id: %s, schedule: %s, channel: %s)",
|
||||||
|
callbackJob.Name, jobID, callbackJob.Schedule.Kind, callbackJob.Payload.Channel)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if cs.onJob != nil {
|
if cs.onJob != nil {
|
||||||
_, err = cs.onJob(callbackJob)
|
_, err = cs.onJob(callbackJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
execDuration := time.Now().UnixMilli() - startTime
|
||||||
|
|
||||||
// Now acquire lock to update state
|
// Now acquire lock to update state
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -220,22 +227,35 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
job.State.LastStatus = "error"
|
job.State.LastStatus = "error"
|
||||||
job.State.LastError = err.Error()
|
job.State.LastError = err.Error()
|
||||||
|
log.Printf("[cron] ✗ job '%s' failed after %dms: %v", job.Name, execDuration, err)
|
||||||
} else {
|
} else {
|
||||||
job.State.LastStatus = "ok"
|
job.State.LastStatus = "ok"
|
||||||
job.State.LastError = ""
|
job.State.LastError = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute next run time
|
// Compute next run time
|
||||||
|
var nextRunStr string
|
||||||
if job.Schedule.Kind == "at" {
|
if job.Schedule.Kind == "at" {
|
||||||
if job.DeleteAfterRun {
|
if job.DeleteAfterRun {
|
||||||
cs.removeJobUnsafe(job.ID)
|
cs.removeJobUnsafe(job.ID)
|
||||||
|
nextRunStr = "(deleted)"
|
||||||
} else {
|
} else {
|
||||||
job.Enabled = false
|
job.Enabled = false
|
||||||
job.State.NextRunAtMS = nil
|
job.State.NextRunAtMS = nil
|
||||||
|
nextRunStr = "(disabled)"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
||||||
job.State.NextRunAtMS = nextRun
|
job.State.NextRunAtMS = nextRun
|
||||||
|
if nextRun != nil {
|
||||||
|
nextRunStr = time.UnixMilli(*nextRun).Format("2006-01-02 15:04:05")
|
||||||
|
} else {
|
||||||
|
nextRunStr = "(none)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[cron] ✓ job '%s' completed in %dms, next run: %s", job.Name, execDuration, nextRunStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cs.saveStoreUnsafe(); err != nil {
|
if err := cs.saveStoreUnsafe(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package openai_compat
|
package openai_compat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -183,19 +184,94 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
contentType := resp.Header.Get("Content-Type")
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Non-200: read a prefix to tell HTML error page apart from JSON error body.
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
||||||
|
}
|
||||||
|
if looksLikeHTML(body, contentType) {
|
||||||
|
return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"API request failed:\n Status: %d\n Body: %s",
|
||||||
|
resp.StatusCode,
|
||||||
|
responsePreview(body, 128),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseResponse(body)
|
// Peek without consuming so the full stream reaches the JSON decoder.
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
|
||||||
|
if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
|
||||||
|
return nil, fmt.Errorf("failed to inspect response: %w", err)
|
||||||
|
}
|
||||||
|
if looksLikeHTML(prefix, contentType) {
|
||||||
|
return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := parseResponse(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseResponse(body []byte) (*LLMResponse, error) {
|
func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
|
||||||
|
respPreview := responsePreview(body, 128)
|
||||||
|
return fmt.Errorf(
|
||||||
|
"API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
|
||||||
|
apiBase,
|
||||||
|
contentType,
|
||||||
|
statusCode,
|
||||||
|
respPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(body []byte, contentType string) bool {
|
||||||
|
contentType = strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
|
||||||
|
return bytes.HasPrefix(prefix, []byte("<!doctype html")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<html")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<head")) ||
|
||||||
|
bytes.HasPrefix(prefix, []byte("<body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func leadingTrimmedPrefix(body []byte, maxLen int) []byte {
|
||||||
|
i := 0
|
||||||
|
for i < len(body) {
|
||||||
|
switch body[i] {
|
||||||
|
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
end := i + maxLen
|
||||||
|
if end > len(body) {
|
||||||
|
end = len(body)
|
||||||
|
}
|
||||||
|
return body[i:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsePreview(body []byte, maxLen int) string {
|
||||||
|
trimmed := bytes.TrimSpace(body)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
return "<empty>"
|
||||||
|
}
|
||||||
|
if len(trimmed) <= maxLen {
|
||||||
|
return string(trimmed)
|
||||||
|
}
|
||||||
|
return string(trimmed[:maxLen]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseResponse(body io.Reader) (*LLMResponse, error) {
|
||||||
var apiResponse struct {
|
var apiResponse struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
|
|
@ -222,8 +298,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
Usage *UsageInfo `json:"usage"`
|
Usage *UsageInfo `json:"usage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(apiResponse.Choices) == 0 {
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package openai_compat
|
package openai_compat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -212,6 +215,132 @@ func TestProviderChat_HTTPError(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"bad request"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Status: 400") {
|
||||||
|
t.Fatalf("expected status code in error, got %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "returned HTML instead of JSON") {
|
||||||
|
t.Fatalf("expected non-HTML http error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentType string
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "html success response",
|
||||||
|
contentType: "text/html; charset=utf-8",
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
body: "<!DOCTYPE html><html><body>gateway login</body></html>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "html error response",
|
||||||
|
contentType: "text/html; charset=utf-8",
|
||||||
|
statusCode: http.StatusBadGateway,
|
||||||
|
body: "<!DOCTYPE html><html><body>bad gateway</body></html>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mislabeled html success response",
|
||||||
|
contentType: "application/json",
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
body: " \r\n\t<!DOCTYPE html><html><body>gateway login</body></html>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", tt.contentType)
|
||||||
|
w.WriteHeader(tt.statusCode)
|
||||||
|
_, _ = w.Write([]byte(tt.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) {
|
||||||
|
t.Fatalf("expected status code in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
|
||||||
|
t.Fatalf("expected helpful HTML error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
|
||||||
|
t.Fatalf("expected configuration hint, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) {
|
||||||
|
content := strings.Repeat("a", 1024)
|
||||||
|
body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}`
|
||||||
|
|
||||||
|
p := NewProvider("key", "https://example.com/v1", "")
|
||||||
|
p.httpClient = &http.Client{
|
||||||
|
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: &errAfterDataReadCloser{
|
||||||
|
data: []byte(body),
|
||||||
|
chunkSize: 64,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
if out.Content != content {
|
||||||
|
t.Fatalf("Content = %q, want %q", out.Content, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) {
|
||||||
|
body := append([]byte("<!DOCTYPE html><html><body>"), bytes.Repeat([]byte("A"), 2048)...)
|
||||||
|
body = append(body, []byte("</body></html>")...)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("key", server.URL, "")
|
||||||
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><body>") {
|
||||||
|
t.Fatalf("expected html preview in error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "...") {
|
||||||
|
t.Fatalf("expected truncated preview, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
|
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
|
@ -399,6 +528,40 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
return f(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errAfterDataReadCloser struct {
|
||||||
|
data []byte
|
||||||
|
chunkSize int
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *errAfterDataReadCloser) Read(p []byte) (int, error) {
|
||||||
|
if r.offset >= len(r.data) {
|
||||||
|
return 0, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := r.chunkSize
|
||||||
|
if n <= 0 || n > len(p) {
|
||||||
|
n = len(p)
|
||||||
|
}
|
||||||
|
remaining := len(r.data) - r.offset
|
||||||
|
if n > remaining {
|
||||||
|
n = remaining
|
||||||
|
}
|
||||||
|
copy(p, r.data[r.offset:r.offset+n])
|
||||||
|
r.offset += n
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *errAfterDataReadCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
|
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
|
||||||
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
|
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
|
||||||
if p.maxTokensField != "max_completion_tokens" {
|
if p.maxTokensField != "max_completion_tokens" {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
|
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSpawnTool(manager)
|
tool := NewSpawnTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -42,7 +42,7 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
|
||||||
|
|
||||||
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
|
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSpawnTool(manager)
|
tool := NewSpawnTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,7 +26,6 @@ type SubagentManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
provider providers.LLMProvider
|
provider providers.LLMProvider
|
||||||
defaultModel string
|
defaultModel string
|
||||||
bus *bus.MessageBus
|
|
||||||
workspace string
|
workspace string
|
||||||
tools *ToolRegistry
|
tools *ToolRegistry
|
||||||
maxIterations int
|
maxIterations int
|
||||||
|
|
@ -41,13 +39,11 @@ type SubagentManager struct {
|
||||||
func NewSubagentManager(
|
func NewSubagentManager(
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
defaultModel, workspace string,
|
defaultModel, workspace string,
|
||||||
bus *bus.MessageBus,
|
|
||||||
) *SubagentManager {
|
) *SubagentManager {
|
||||||
return &SubagentManager{
|
return &SubagentManager{
|
||||||
tasks: make(map[string]*SubagentTask),
|
tasks: make(map[string]*SubagentTask),
|
||||||
provider: provider,
|
provider: provider,
|
||||||
defaultModel: defaultModel,
|
defaultModel: defaultModel,
|
||||||
bus: bus,
|
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
tools: NewToolRegistry(),
|
tools: NewToolRegistry(),
|
||||||
maxIterations: 10,
|
maxIterations: 10,
|
||||||
|
|
@ -214,20 +210,6 @@ After completing the task, provide a clear summary of what was done.`
|
||||||
Async: false,
|
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) {
|
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,7 +46,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
|
||||||
|
|
||||||
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
|
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager.SetLLMOptions(2048, 0.6)
|
manager.SetLLMOptions(2048, 0.6)
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
|
|
@ -73,7 +72,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
|
||||||
// TestSubagentTool_Name verifies tool name
|
// TestSubagentTool_Name verifies tool name
|
||||||
func TestSubagentTool_Name(t *testing.T) {
|
func TestSubagentTool_Name(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
if tool.Name() != "subagent" {
|
if tool.Name() != "subagent" {
|
||||||
|
|
@ -84,7 +83,7 @@ func TestSubagentTool_Name(t *testing.T) {
|
||||||
// TestSubagentTool_Description verifies tool description
|
// TestSubagentTool_Description verifies tool description
|
||||||
func TestSubagentTool_Description(t *testing.T) {
|
func TestSubagentTool_Description(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
desc := tool.Description()
|
desc := tool.Description()
|
||||||
|
|
@ -99,7 +98,7 @@ func TestSubagentTool_Description(t *testing.T) {
|
||||||
// TestSubagentTool_Parameters verifies tool parameters schema
|
// TestSubagentTool_Parameters verifies tool parameters schema
|
||||||
func TestSubagentTool_Parameters(t *testing.T) {
|
func TestSubagentTool_Parameters(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
params := tool.Parameters()
|
params := tool.Parameters()
|
||||||
|
|
@ -149,8 +148,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_Success tests successful execution
|
// TestSubagentTool_Execute_Success tests successful execution
|
||||||
func TestSubagentTool_Execute_Success(t *testing.T) {
|
func TestSubagentTool_Execute_Success(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
msgBus := bus.NewMessageBus()
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
|
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
|
// TestSubagentTool_Execute_NoLabel tests execution without label
|
||||||
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
|
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
msgBus := bus.NewMessageBus()
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -228,7 +225,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_MissingTask tests error handling for missing task
|
// TestSubagentTool_Execute_MissingTask tests error handling for missing task
|
||||||
func TestSubagentTool_Execute_MissingTask(t *testing.T) {
|
func TestSubagentTool_Execute_MissingTask(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -278,8 +275,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
|
||||||
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
|
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
|
||||||
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
msgBus := bus.NewMessageBus()
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
channel := "test-channel"
|
channel := "test-channel"
|
||||||
|
|
@ -304,8 +300,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
|
||||||
func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
func TestSubagentTool_ForUserTruncation(t *testing.T) {
|
||||||
// Create a mock provider that returns very long content
|
// Create a mock provider that returns very long content
|
||||||
provider := &MockLLMProvider{}
|
provider := &MockLLMProvider{}
|
||||||
msgBus := bus.NewMessageBus()
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
|
|
||||||
tool := NewSubagentTool(manager)
|
tool := NewSubagentTool(manager)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
56
scripts/test-irc.sh
Executable file
56
scripts/test-irc.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Starts a local Ergo IRC server for testing the IRC channel.
|
||||||
|
#
|
||||||
|
# Requirements: docker
|
||||||
|
# Usage: ./scripts/test-irc.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER_NAME="picoclaw-test-ergo"
|
||||||
|
IRC_PORT=6667
|
||||||
|
|
||||||
|
# Clean up any previous instance
|
||||||
|
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "Starting Ergo IRC server on port $IRC_PORT..."
|
||||||
|
docker run -d \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-p "$IRC_PORT:6667" \
|
||||||
|
ghcr.io/ergochat/ergo:stable
|
||||||
|
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if nc -z localhost "$IRC_PORT" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 10 ]; then
|
||||||
|
echo "ERROR: Server did not start within 10s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "IRC server ready on localhost:$IRC_PORT"
|
||||||
|
echo ""
|
||||||
|
echo "Add this to your ~/.picoclaw/config.json under \"channels\":"
|
||||||
|
echo ""
|
||||||
|
echo ' "irc": {'
|
||||||
|
echo ' "enabled": true,'
|
||||||
|
echo ' "server": "localhost:6667",'
|
||||||
|
echo ' "tls": false,'
|
||||||
|
echo ' "nick": "picobot",'
|
||||||
|
echo ' "channels": ["#test"],'
|
||||||
|
echo ' "allow_from": [],'
|
||||||
|
echo ' "group_trigger": { "mention_only": true }'
|
||||||
|
echo ' }'
|
||||||
|
echo ""
|
||||||
|
echo "Then run picoclaw:"
|
||||||
|
echo " cd packages/picoclaw && go run ./cmd/picoclaw gateway"
|
||||||
|
echo ""
|
||||||
|
echo "Connect with an IRC client:"
|
||||||
|
echo " irssi: /connect localhost $IRC_PORT"
|
||||||
|
echo " weechat: /server add test localhost/$IRC_PORT && /connect test"
|
||||||
|
echo " Join #test, then: picobot: hello"
|
||||||
|
echo ""
|
||||||
|
echo "To stop the IRC server:"
|
||||||
|
echo " docker rm -f $CONTAINER_NAME"
|
||||||
Loading…
Add table
Reference in a new issue