diff --git a/README.zh.md b/README.zh.md index 6aeca2794..1b98fd30a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -47,6 +47,7 @@ > * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 > * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 > * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. +* **XMPP 风险提示:** 启用 XMPP 附件上传功能(XEP-0363)时,Agent 可能会尝试根据服务端返回的 URL 进行文件上传。如果服务端返回恶意内网 URL,可能存在 SSRF(服务端请求伪造)风险。建议仅连接可信的 XMPP 服务器。 ## 📢 新闻 (News) @@ -460,46 +461,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ``` -### 按渠道清理对话会话 (session_ttl) - -每个渠道的配置下都包含一个可选字段 `session_ttl`,用于控制该渠道的**对话会话保留时间**(存放在 `workspace/sessions/` 目录中的会话文件)。 - -示例(以 XMPP 为例): - -```json -{ - "channels": { - "xmpp": { - "enabled": true, - "jid": "bot@example.com", - "password": "YOUR_PASSWORD", - "server": "example.com:5222", - "upload_domain": "upload.example.com", - "allow_from": [], - "session_ttl": "1h" - } - } -} -``` - -行为说明: - -- `session_ttl`: 字符串形式的时间长度,仅对该渠道生效 - - `"false"` 或空字符串:禁用自动清理(默认值) - - `"30m"`:保留 30 分钟 - - `"1h"`:保留 1 小时 - - `"2h"`:保留 2 小时 - - `"1d"`:保留 1 天(等价于 `24h`) -- Agent 会定期检查所有会话: - - 如果某个会话最后更新时间早于当前时间减去 `session_ttl` - - 则删除该会话的历史记录(内存 + `sessions/*.json` 文件) -- 只影响会话历史,不会删除 `memory/` 目录中的长期记忆(MEMORY.md)或每日笔记。 - -这可以帮助在树莓派等内存/存储有限的设备上,按渠道控制对话保留时间,例如: - -- 对安全敏感的 XMPP 渠道设置较短 TTL(如 `1h`),降低明文历史泄露风险 -- 对相对不敏感或需要长期上下文的渠道保留 `"false"`,完全不自动清理 - 对于 XMPP 渠道,Agent 会默认: - 在处理用户消息时发送「正在输入 / active」状态(XEP‑0085),方便前端展示输入指示; @@ -507,6 +468,8 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work 这两个行为都是**默认开启且向后兼容**的:旧版 `config.json` 不需要增加任何字段即可使用,新版客户端若不支持相应 XEP 也会直接忽略这些附加元素。 +XEP‑0363 HTTP 上传会根据服务器下发的 URL 发起 HTTP 请求。若服务器恶意或被劫持,可能导致客户端对非预期地址发起请求(潜在 SSRF)。建议仅使用可信的 XMPP 服务器,并在部署环境中限制网络访问范围。 + ### 心跳 / 周期性任务 (Heartbeat) PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bcc7dcc61..08eede796 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -44,7 +44,6 @@ type AgentLoop struct { running atomic.Bool summarizing sync.Map // Tracks which sessions are currently being summarized channelManager *channels.Manager - channelTTLs map[string]time.Duration } // processOptions configures how a message is processed @@ -153,12 +152,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers contextBuilder: contextBuilder, tools: toolsRegistry, summarizing: sync.Map{}, - channelTTLs: make(map[string]time.Duration), } - al.initChannelTTLs(cfg) - go al.startSessionCleanup() - return al } @@ -992,108 +987,6 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { return totalChars * 2 / 5 } -func (al *AgentLoop) initChannelTTLs(cfg *config.Config) { - parseTTL := func(raw string) (time.Duration, bool) { - if raw == "" { - return 0, false - } - lower := strings.ToLower(strings.TrimSpace(raw)) - if lower == "false" { - return 0, false - } - if strings.HasSuffix(lower, "d") { - num := strings.TrimSuffix(lower, "d") - days, err := time.ParseDuration(num + "h") - if err != nil { - return 0, false - } - return days * 24, true - } - d, err := time.ParseDuration(lower) - if err != nil || d <= 0 { - return 0, false - } - return d, true - } - - if ttl, ok := parseTTL(cfg.Channels.WhatsApp.SessionTTL); ok { - al.channelTTLs["whatsapp"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.Telegram.SessionTTL); ok { - al.channelTTLs["telegram"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.Feishu.SessionTTL); ok { - al.channelTTLs["feishu"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.Discord.SessionTTL); ok { - al.channelTTLs["discord"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.MaixCam.SessionTTL); ok { - al.channelTTLs["maixcam"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.QQ.SessionTTL); ok { - al.channelTTLs["qq"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.DingTalk.SessionTTL); ok { - al.channelTTLs["dingtalk"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.Slack.SessionTTL); ok { - al.channelTTLs["slack"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.LINE.SessionTTL); ok { - al.channelTTLs["line"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.OneBot.SessionTTL); ok { - al.channelTTLs["onebot"] = ttl - } - if ttl, ok := parseTTL(cfg.Channels.XMPP.SessionTTL); ok { - al.channelTTLs["xmpp"] = ttl - } -} - -func (al *AgentLoop) startSessionCleanup() { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - - for { - <-ticker.C - al.cleanupExpiredSessions() - } -} - -func (al *AgentLoop) cleanupExpiredSessions() { - sessions := al.sessions.ListSessions() - - for _, info := range sessions { - colon := strings.Index(info.Key, ":") - if colon <= 0 { - continue - } - channel := info.Key[:colon] - - ttl, ok := al.channelTTLs[channel] - if !ok || ttl <= 0 { - continue - } - - cutoff := time.Now().Add(-ttl) - if !info.Updated.Before(cutoff) { - continue - } - - if err := al.sessions.DeleteSession(info.Key); err != nil { - logger.WarnCF("agent", "Failed to delete expired session", map[string]interface{}{ - "session_key": info.Key, - "error": err.Error(), - }) - } else { - logger.InfoCF("agent", "Deleted expired session", map[string]interface{}{ - "session_key": info.Key, - }) - } - } -} - func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { content := strings.TrimSpace(msg.Content) if !strings.HasPrefix(content, "/") { diff --git a/pkg/channels/xmpp.go b/pkg/channels/xmpp.go index 8ccdb5623..978f8d19e 100644 --- a/pkg/channels/xmpp.go +++ b/pkg/channels/xmpp.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -40,16 +41,20 @@ type XMPPChannel struct { uploadMu sync.Mutex uploadJID jid.JID + uploadMax int64 - lastMsgMu sync.Mutex - lastFromBare string - lastContent string - lastTime time.Time + lastMsgMu sync.Mutex + lastMsg map[string]xmppLastMessage } const chatStatesNS = "http://jabber.org/protocol/chatstates" const receiptsNS = "urn:xmpp:receipts" +type xmppLastMessage struct { + content string + at time.Time +} + func NewXMPPChannel(cfg config.XMPPConfig, messageBus *bus.MessageBus) (*XMPPChannel, error) { if cfg.JID == "" { return nil, fmt.Errorf("xmpp jid is required") @@ -64,6 +69,7 @@ func NewXMPPChannel(cfg config.XMPPConfig, messageBus *bus.MessageBus) (*XMPPCha BaseChannel: base, config: cfg, httpClient: newHTTPClient(), + lastMsg: make(map[string]xmppLastMessage), }, nil } @@ -343,15 +349,9 @@ func (c *XMPPChannel) handleIncomingMessage(msg stanza.Message, t xmlstream.Toke fromBare := msg.From.Bare().String() chatID := msg.From.String() - c.lastMsgMu.Lock() - if fromBare == c.lastFromBare && content == c.lastContent && time.Since(c.lastTime) < 2*time.Second { - c.lastMsgMu.Unlock() + if c.shouldDropDuplicate(fromBare, content, time.Now()) { return nil } - c.lastFromBare = fromBare - c.lastContent = content - c.lastTime = time.Now() - c.lastMsgMu.Unlock() logger.DebugCF("xmpp", "Received message", map[string]interface{}{ "from": chatID, @@ -367,6 +367,23 @@ func (c *XMPPChannel) handleIncomingMessage(msg stanza.Message, t xmlstream.Toke return nil } +func (c *XMPPChannel) shouldDropDuplicate(fromBare, content string, now time.Time) bool { + c.lastMsgMu.Lock() + defer c.lastMsgMu.Unlock() + + if last, ok := c.lastMsg[fromBare]; ok { + if last.content == content && now.Sub(last.at) < 2*time.Second { + return true + } + } + + c.lastMsg[fromBare] = xmppLastMessage{ + content: content, + at: now, + } + return false +} + func (c *XMPPChannel) sendChatState(to jid.JID, state string) error { if !c.IsRunning() || c.session == nil { return nil @@ -440,14 +457,6 @@ func (c *XMPPChannel) discoverUploadService(ctx context.Context) (jid.JID, error return c.uploadJID, nil } - if c.config.UploadDomain != "" { - j, err := jid.Parse(c.config.UploadDomain) - if err == nil { - c.uploadJID = j - return c.uploadJID, nil - } - } - if c.session == nil { return jid.JID{}, fmt.Errorf("xmpp session not initialized") } @@ -504,6 +513,50 @@ func (c *XMPPChannel) discoverUploadService(ctx context.Context) (jid.JID, error return c.uploadJID, nil } +func (c *XMPPChannel) getUploadMaxSize(ctx context.Context, uploadJID jid.JID) int64 { + c.uploadMu.Lock() + if c.uploadMax > 0 { + max := c.uploadMax + c.uploadMu.Unlock() + return max + } + c.uploadMu.Unlock() + + if c.session == nil { + return 0 + } + + info, err := disco.GetInfo(ctx, "", uploadJID, c.session) + if err != nil { + return 0 + } + + var maxSize int64 + for _, form := range info.Form { + raw, ok := form.GetString("max-file-size") + if !ok { + continue + } + parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || parsed <= 0 { + continue + } + maxSize = parsed + break + } + + if maxSize > 0 { + c.uploadMu.Lock() + if c.uploadMax == 0 { + c.uploadMax = maxSize + } + maxSize = c.uploadMax + c.uploadMu.Unlock() + } + + return maxSize +} + func (c *XMPPChannel) uploadFile(ctx context.Context, uploadJID jid.JID, path string) (string, error) { f, err := os.Open(path) if err != nil { @@ -521,6 +574,11 @@ func (c *XMPPChannel) uploadFile(ctx context.Context, uploadJID jid.JID, path st return "", fmt.Errorf("file is empty") } + maxSize := c.getUploadMaxSize(ctx, uploadJID) + if maxSize > 0 && size > maxSize { + return "", fmt.Errorf("file size %d exceeds server limit %d", size, maxSize) + } + buffer := make([]byte, 512) n, _ := f.Read(buffer) if _, err := f.Seek(0, 0); err != nil { @@ -575,9 +633,40 @@ func (c *XMPPChannel) uploadFile(ctx context.Context, uploadJID jid.JID, path st req.Header.Set("Content-Type", contentType) } - resp, err := c.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("http put: %w", err) + var resp *http.Response + var uploadErr error + + // Retry loop for DNS/connection fallback + // If upload.domain fails, try domain + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + // Check if we can fallback + host := req.URL.Host + if strings.HasPrefix(host, "upload.") { + newHost := strings.TrimPrefix(host, "upload.") + logger.WarnCF("xmpp", "Upload failed, retrying with fallback host", map[string]interface{}{ + "old_host": host, + "new_host": newHost, + "error": uploadErr.Error(), + }) + req.URL.Host = newHost + // Reset body + if _, err := f.Seek(0, 0); err != nil { + return "", fmt.Errorf("seek file for retry: %w", err) + } + } else { + break // No fallback possible + } + } + + resp, uploadErr = c.httpClient.Do(req) + if uploadErr == nil { + break + } + } + + if uploadErr != nil { + return "", fmt.Errorf("http put: %w", uploadErr) } defer resp.Body.Close() diff --git a/pkg/channels/xmpp_test.go b/pkg/channels/xmpp_test.go new file mode 100644 index 000000000..f5ee7b4dd --- /dev/null +++ b/pkg/channels/xmpp_test.go @@ -0,0 +1,69 @@ +package channels + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "mellium.im/xmpp/jid" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func newTestXMPPChannel(t *testing.T) *XMPPChannel { + t.Helper() + ch, err := NewXMPPChannel(config.XMPPConfig{ + JID: "bot@example.com", + Password: "pass", + }, bus.NewMessageBus()) + if err != nil { + t.Fatalf("NewXMPPChannel error: %v", err) + } + return ch +} + +func TestXMPPChannelDedupBySender(t *testing.T) { + ch := newTestXMPPChannel(t) + now := time.Now() + + if ch.shouldDropDuplicate("alice@example.com", "hi", now) { + t.Fatalf("expected first message to be accepted") + } + if !ch.shouldDropDuplicate("alice@example.com", "hi", now.Add(time.Second)) { + t.Fatalf("expected duplicate within window to be dropped") + } + if ch.shouldDropDuplicate("bob@example.com", "hi", now.Add(time.Second)) { + t.Fatalf("expected different sender to be accepted") + } + if ch.shouldDropDuplicate("alice@example.com", "hi", now.Add(3*time.Second)) { + t.Fatalf("expected message after window to be accepted") + } +} + +func TestXMPPChannelUploadRespectsServerMax(t *testing.T) { + ch := newTestXMPPChannel(t) + ch.uploadMax = 1 + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "a.txt") + if err := os.WriteFile(path, []byte("hi"), 0644); err != nil { + t.Fatalf("write temp file: %v", err) + } + + uploadJID, err := jid.Parse("upload.example.com") + if err != nil { + t.Fatalf("parse upload jid: %v", err) + } + + _, err = ch.uploadFile(context.Background(), uploadJID, path) + if err == nil { + t.Fatal("expected upload to fail due to size limit") + } + if !strings.Contains(err.Error(), "exceeds server limit") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ad4871512..f3767101f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -66,7 +66,6 @@ type AgentDefaults struct { MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_AGENTS_DEFAULTS_SESSION_TTL"` } type ChannelsConfig struct { @@ -84,18 +83,16 @@ type ChannelsConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_TELEGRAM_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { @@ -105,30 +102,26 @@ type FeishuConfig struct { EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_FEISHU_SESSION_TTL"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_DISCORD_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_MAIXCAM_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_QQ_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { @@ -136,15 +129,13 @@ type DingTalkConfig struct { ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_DINGTALK_SESSION_TTL"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_SLACK_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` } type LINEConfig struct { @@ -155,7 +146,6 @@ type LINEConfig struct { WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_LINE_SESSION_TTL"` } type OneBotConfig struct { @@ -165,17 +155,13 @@ type OneBotConfig struct { ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_ONEBOT_SESSION_TTL"` } type XMPPConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XMPP_ENABLED"` - JID string `json:"jid" env:"PICOCLAW_CHANNELS_XMPP_JID"` - Password string `json:"password" env:"PICOCLAW_CHANNELS_XMPP_PASSWORD"` - Server string `json:"server" env:"PICOCLAW_CHANNELS_XMPP_SERVER"` - UploadDomain string `json:"upload_domain" env:"PICOCLAW_CHANNELS_XMPP_UPLOAD_DOMAIN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_XMPP_ALLOW_FROM"` - SessionTTL string `json:"session_ttl" env:"PICOCLAW_CHANNELS_XMPP_SESSION_TTL"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XMPP_ENABLED"` + JID string `json:"jid" env:"PICOCLAW_CHANNELS_XMPP_JID"` + Password string `json:"password" env:"PICOCLAW_CHANNELS_XMPP_PASSWORD"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_XMPP_ALLOW_FROM"` } type HeartbeatConfig struct { @@ -260,21 +246,18 @@ func DefaultConfig() *Config { MaxTokens: 8192, Temperature: 0.7, MaxToolIterations: 20, - SessionTTL: "", }, }, Channels: ChannelsConfig{ WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + BridgeURL: "ws://localhost:3001", + AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, }, Feishu: FeishuConfig{ Enabled: false, @@ -283,41 +266,35 @@ func DefaultConfig() *Config { EncryptKey: "", VerificationToken: "", AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", }, Discord: DiscordConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, }, MaixCam: MaixCamConfig{ - Enabled: false, - Host: "0.0.0.0", - Port: 18790, - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + Host: "0.0.0.0", + Port: 18790, + AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + AppID: "", + AppSecret: "", + AllowFrom: FlexibleStringSlice{}, }, DingTalk: DingTalkConfig{ Enabled: false, ClientID: "", ClientSecret: "", AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", }, Slack: SlackConfig{ - Enabled: false, - BotToken: "", - AppToken: "", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + BotToken: "", + AppToken: "", + AllowFrom: FlexibleStringSlice{}, }, LINE: LINEConfig{ Enabled: false, @@ -327,7 +304,6 @@ func DefaultConfig() *Config { WebhookPort: 18791, WebhookPath: "/webhook/line", AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", }, OneBot: OneBotConfig{ Enabled: false, @@ -336,16 +312,12 @@ func DefaultConfig() *Config { ReconnectInterval: 5, GroupTriggerPrefix: []string{}, AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", }, XMPP: XMPPConfig{ - Enabled: false, - JID: "", - Password: "", - Server: "", - UploadDomain: "", - AllowFrom: FlexibleStringSlice{}, - SessionTTL: "false", + Enabled: false, + JID: "", + Password: "", + AllowFrom: FlexibleStringSlice{}, }, }, Providers: ProvidersConfig{ diff --git a/tream b/tream deleted file mode 100644 index 901a11c2f..000000000 --- a/tream +++ /dev/null @@ -1,298 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^S _n Search for match in _n-th parenthesized subpattern. - ^W WRAP search if no match found. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< * Go to first line in file (or line _N). - G > ESC-> * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-m_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. - :n * Examine the (_N-th) next file from the command line. - :p * Examine the (_N-th) previous file from the command line. - :x * Examine the first (or _N-th) file from the command line. - :d Delete the current file from the command line list. - = ^G :f Print current file name. - --------------------------------------------------------------------------- - - MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS - - -_<_f_l_a_g_> Toggle a command line option [see OPTIONS below]. - --_<_n_a_m_e_> Toggle a command line option, by name. - __<_f_l_a_g_> Display the setting of a command line option. - ___<_n_a_m_e_> Display the setting of an option, by name. - +_c_m_d Execute the less cmd each time a new file is examined. - - !_c_o_m_m_a_n_d Execute the shell command with $SHELL. - #_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt. - |XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command. - s _f_i_l_e Save input to a file. - v Edit the current file with $VISUAL or $EDITOR. - V Print version number of "less". - --------------------------------------------------------------------------- - - OOPPTTIIOONNSS - - Most options may be changed either on the command line, - or from within less by using the - or -- command. - Options may be given in one of two forms: either a single - character preceded by a -, or a name preceded by --. - - -? ........ --help - Display help (from command line). - -a ........ --search-skip-screen - Search skips current screen. - -A ........ --SEARCH-SKIP-SCREEN - Search starts just after target line. - -b [_N] .... --buffers=[_N] - Number of buffers. - -B ........ --auto-buffers - Don't automatically allocate buffers for pipes. - -c ........ --clear-screen - Repaint by clearing rather than scrolling. - -d ........ --dumb - Dumb terminal. - -D xx_c_o_l_o_r . --color=xx_c_o_l_o_r - Set screen colors. - -e -E .... --quit-at-eof --QUIT-AT-EOF - Quit at end of file. - -f ........ --force - Force open non-regular files. - -F ........ --quit-if-one-screen - Quit if entire file fits on first screen. - -g ........ --hilite-search - Highlight only last match for searches. - -G ........ --HILITE-SEARCH - Don't highlight any matches for searches. - -h [_N] .... --max-back-scroll=[_N] - Backward scroll limit. - -i ........ --ignore-case - Ignore case in searches that do not contain uppercase. - -I ........ --IGNORE-CASE - Ignore case in all searches. - -j [_N] .... --jump-target=[_N] - Screen position of target lines. - -J ........ --status-column - Display a status column at left edge of screen. - -k [_f_i_l_e] . --lesskey-file=[_f_i_l_e] - Use a lesskey file. - -K ........ --quit-on-intr - Exit less in response to ctrl-C. - -L ........ --no-lessopen - Ignore the LESSOPEN environment variable. - -m -M .... --long-prompt --LONG-PROMPT - Set prompt style. - -n ......... --line-numbers - Suppress line numbers in prompts and messages. - -N ......... --LINE-NUMBERS - Display line number at start of each line. - -o [_f_i_l_e] . --log-file=[_f_i_l_e] - Copy to log file (standard input only). - -O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e] - Copy to log file (unconditionally overwrite). - -p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n] - Start at pattern (from command line). - -P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t] - Define new prompt. - -q -Q .... --quiet --QUIET --silent --SILENT - Quiet the terminal bell. - -r -R .... --raw-control-chars --RAW-CONTROL-CHARS - Output "raw" control characters. - -s ........ --squeeze-blank-lines - Squeeze multiple blank lines. - -S ........ --chop-long-lines - Chop (truncate) long lines rather than wrapping. - -t [_t_a_g] .. --tag=[_t_a_g] - Find a tag. - -T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e] - Use an alternate tags file. - -u -U .... --underline-special --UNDERLINE-SPECIAL - Change handling of backspaces, tabs and carriage returns. - -V ........ --version - Display the version number of "less". - -w ........ --hilite-unread - Highlight first new line after forward-screen. - -W ........ --HILITE-UNREAD - Highlight first new line after any forward movement. - -x [_N[,...]] --tabs=[_N[,...]] - Set tab stops. - -X ........ --no-init - Don't use termcap init/deinit strings. - -y [_N] .... --max-forw-scroll=[_N] - Forward scroll limit. - -z [_N] .... --window=[_N] - Set size of window. - -" [_c[_c]] . --quotes=[_c[_c]] - Set shell quote characters. - -~ ........ --tilde - Don't display tildes after end of file. - -# [_N] .... --shift=[_N] - Set horizontal scroll amount (0 = one half screen width). - --exit-follow-on-close - Exit F command on a pipe when writer closes pipe. - --file-size - Automatically determine the size of the input file. - --follow-name - The F command changes files if the input file is renamed. - --header=[_N[,_M]] - Use N lines and M columns to display file headers. - --incsearch - Search file as each pattern character is typed in. - --intr=_C - Use _C instead of ^X to interrupt a read. - --line-num-width=_N - Set the width of the -N line number field to _N characters. - --modelines=_N - Read _N lines from the input file and look for vim modelines. - --mouse - Enable mouse input. - --no-keypad - Don't send termcap keypad init/deinit strings. - --no-histdups - Remove duplicates from command history. - --no-number-headers - Don't give line numbers to header lines. - --no-search-headers - Don't search in header lines or columns. - --no-vbell - Disable the terminal's visual bell. - --redraw-on-quit - Redraw final screen when quitting. - --rscroll=_C - Set the character used to mark truncated lines. - --save-marks - Retain marks across invocations of less. - --search-options=[EFKNRW-] - Set default options for every search. - --show-preproc-errors - Display a message if preprocessor exits with an error status. - --proc-backspace - Process backspaces for bold/underline. - --SPECIAL-BACKSPACE - Treat backspaces as control characters. - --proc-return - Delete carriage returns before newline. - --SPECIAL-RETURN - Treat carriage returns as control characters. - --proc-tab - Expand tabs to spaces. - --SPECIAL-TAB - Treat tabs as control characters. - --status-col-width=_N - Set the width of the -J status column to _N characters. - --status-line - Highlight or color the entire line containing a mark. - --use-backslash - Subsequent options use backslash as escape char. - --use-color - Enables colored text. - --wheel-lines=_N - Each click of the mouse wheel moves _N lines. - --wordwrap - Wrap lines at spaces. - - - --------------------------------------------------------------------------- - - LLIINNEE EEDDIITTIINNGG - - These keys can be used to edit text being entered - on the "command line" at the bottom of the screen. - - RightArrow ..................... ESC-l ... Move cursor right one character. - LeftArrow ...................... ESC-h ... Move cursor left one character. - ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word. - ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word. - HOME ........................... ESC-0 ... Move cursor to start of line. - END ............................ ESC-$ ... Move cursor to end of line. - BACKSPACE ................................ Delete char to left of cursor. - DELETE ......................... ESC-x ... Delete char under cursor. - ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor. - ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor. - ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line. - UpArrow ........................ ESC-k ... Retrieve previous command line. - DownArrow ...................... ESC-j ... Retrieve next command line. - TAB ...................................... Complete filename & cycle. - SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle. - ctrl-L ................................... Complete filename, list all. diff --git a/workspace/skills/tmux/scripts/find-sessions.sh b/workspace/skills/tmux/scripts/find-sessions.sh new file mode 100644 index 000000000..00552c684 --- /dev/null +++ b/workspace/skills/tmux/scripts/find-sessions.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern] + +List tmux sessions on a socket (default tmux socket if none provided). + +Options: + -L, --socket tmux socket name (passed to tmux -L) + -S, --socket-path tmux socket path (passed to tmux -S) + -A, --all scan all sockets under NANOBOT_TMUX_SOCKET_DIR + -q, --query case-insensitive substring to filter session names + -h, --help show this help +USAGE +} + +socket_name="" +socket_path="" +query="" +scan_all=false +socket_dir="${NANOBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/nanobot-tmux-sockets}" + +while [[ $# -gt 0 ]]; do + case "$1" in + -L|--socket) socket_name="${2-}"; shift 2 ;; + -S|--socket-path) socket_path="${2-}"; shift 2 ;; + -A|--all) scan_all=true; shift ;; + -q|--query) query="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then + echo "Cannot combine --all with -L or -S" >&2 + exit 1 +fi + +if [[ -n "$socket_name" && -n "$socket_path" ]]; then + echo "Use either -L or -S, not both" >&2 + exit 1 +fi + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not found in PATH" >&2 + exit 1 +fi + +list_sessions() { + local label="$1"; shift + local tmux_cmd=(tmux "$@") + + if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then + echo "No tmux server found on $label" >&2 + return 1 + fi + + if [[ -n "$query" ]]; then + sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)" + fi + + if [[ -z "$sessions" ]]; then + echo "No sessions found on $label" + return 0 + fi + + echo "Sessions on $label:" + printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do + attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached") + printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created" + done +} + +if [[ "$scan_all" == true ]]; then + if [[ ! -d "$socket_dir" ]]; then + echo "Socket directory not found: $socket_dir" >&2 + exit 1 + fi + + shopt -s nullglob + sockets=("$socket_dir"/*) + shopt -u nullglob + + if [[ "${#sockets[@]}" -eq 0 ]]; then + echo "No sockets found under $socket_dir" >&2 + exit 1 + fi + + exit_code=0 + for sock in "${sockets[@]}"; do + if [[ ! -S "$sock" ]]; then + continue + fi + list_sessions "socket path '$sock'" -S "$sock" || exit_code=$? + done + exit "$exit_code" +fi + +tmux_cmd=(tmux) +socket_label="default socket" + +if [[ -n "$socket_name" ]]; then + tmux_cmd+=(-L "$socket_name") + socket_label="socket name '$socket_name'" +elif [[ -n "$socket_path" ]]; then + tmux_cmd+=(-S "$socket_path") + socket_label="socket path '$socket_path'" +fi + +list_sessions "$socket_label" "${tmux_cmd[@]:1}" diff --git a/workspace/skills/tmux/scripts/wait-for-text.sh b/workspace/skills/tmux/scripts/wait-for-text.sh new file mode 100644 index 000000000..56354be83 --- /dev/null +++ b/workspace/skills/tmux/scripts/wait-for-text.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: wait-for-text.sh -t target -p pattern [options] + +Poll a tmux pane for text and exit when found. + +Options: + -t, --target tmux target (session:window.pane), required + -p, --pattern regex pattern to look for, required + -F, --fixed treat pattern as a fixed string (grep -F) + -T, --timeout seconds to wait (integer, default: 15) + -i, --interval poll interval in seconds (default: 0.5) + -l, --lines number of history lines to inspect (integer, default: 1000) + -h, --help show this help +USAGE +} + +target="" +pattern="" +grep_flag="-E" +timeout=15 +interval=0.5 +lines=1000 + +while [[ $# -gt 0 ]]; do + case "$1" in + -t|--target) target="${2-}"; shift 2 ;; + -p|--pattern) pattern="${2-}"; shift 2 ;; + -F|--fixed) grep_flag="-F"; shift ;; + -T|--timeout) timeout="${2-}"; shift 2 ;; + -i|--interval) interval="${2-}"; shift 2 ;; + -l|--lines) lines="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "$target" || -z "$pattern" ]]; then + echo "target and pattern are required" >&2 + usage + exit 1 +fi + +if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then + echo "timeout must be an integer number of seconds" >&2 + exit 1 +fi + +if ! [[ "$lines" =~ ^[0-9]+$ ]]; then + echo "lines must be an integer" >&2 + exit 1 +fi + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not found in PATH" >&2 + exit 1 +fi + +# End time in epoch seconds (integer, good enough for polling) +start_epoch=$(date +%s) +deadline=$((start_epoch + timeout)) + +while true; do + # -J joins wrapped lines, -S uses negative index to read last N lines + pane_text="$(tmux capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)" + + if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then + exit 0 + fi + + now=$(date +%s) + if (( now >= deadline )); then + echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2 + echo "Last ${lines} lines from $target:" >&2 + printf '%s\n' "$pane_text" >&2 + exit 1 + fi + + sleep "$interval" +done