diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2129662d7..4a492faca 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -14,6 +14,7 @@ import ( "io" "io/fs" "os" + "os/exec" "os/signal" "path/filepath" "runtime" @@ -138,6 +139,8 @@ func main() { migrateCmd() case "auth": authCmd() + case "install": + installCmd() case "cron": cronCmd() case "skills": @@ -207,6 +210,7 @@ func printHelp() { fmt.Println(" agent Interact with the agent directly") fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" gateway Start picoclaw gateway") + fmt.Println(" install Install/uninstall launchd service (macOS)") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") @@ -1410,3 +1414,205 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { fmt.Println("----------------------") fmt.Println(content) } + +// installCmd handles the install subcommand +func installCmd() { + if len(os.Args) < 3 { + doInstall() + return + } + + switch os.Args[2] { + case "--help", "-h": + installHelp() + case "--uninstall": + uninstallInstall() + case "--status": + statusInstall() + default: + fmt.Printf("Unknown install option: %s\n", os.Args[2]) + installHelp() + } +} + +// installHelp displays help information for install command +func installHelp() { + fmt.Println("\nInstall PicoClaw as a macOS service (launch agent)") + fmt.Println() + fmt.Println("Usage: picoclaw install [options]") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --uninstall Uninstall the launch agent") + fmt.Println(" --status Show installation status") + fmt.Println(" --help, -h Show this help message") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" picoclaw install Install as launch agent") + fmt.Println(" picoclaw install --uninstall Uninstall the launch agent") + fmt.Println(" picoclaw install --status Check installation status") +} + +// doInstall executes the installation +func doInstall() { + execPath, err := findExecutable() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Found picoclaw at: %s\n", execPath) + + // Generate plist content + plistContent := generatePlist(execPath) + + // Get plist path + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %v\n", err) + os.Exit(1) + } + plistDir := filepath.Join(home, "Library", "LaunchAgents") + plistPath := filepath.Join(plistDir, "io.picoclaw.gateway.plist") + + // Create directory if not exists + if err := os.MkdirAll(plistDir, 0755); err != nil { + fmt.Printf("Error creating LaunchAgents directory: %v\n", err) + os.Exit(1) + } + + // Write plist file + if err := os.WriteFile(plistPath, []byte(plistContent), 0644); err != nil { + fmt.Printf("Error writing plist file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Created plist at: %s\n", plistPath) + + // Load the launch agent + cmd := exec.Command("launchctl", "load", plistPath) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Error loading launch agent: %v\n", err) + fmt.Printf("Output: %s\n", string(output)) + os.Exit(1) + } + + fmt.Println("\n✓ PicoClaw gateway installed successfully!") + fmt.Println("The gateway will start automatically on login.") +} + +// findExecutable finds the picoclaw executable path +func findExecutable() (string, error) { + // 使用 exec.LookPath 查找 + path, err := exec.LookPath("picoclaw") + if err == nil { + return path, nil + } + + // 如果不是 "not found" 错误,返回原错误 + if !strings.Contains(err.Error(), "not found") { + return "", err + } + + // 尝试常见路径 + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find home directory: %w", err) + } + + commonPaths := []string{ + "/usr/local/bin/picoclaw", + "/usr/bin/picoclaw", + filepath.Join(home, "go", "bin", "picoclaw"), + } + + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + + return "", fmt.Errorf("picoclaw not found. Please run 'go install' first or ensure picoclaw is in your PATH") +} + +// generatePlist generates the plist content for launch agent +func generatePlist(execPath string) string { + currentPath := os.Getenv("PATH") + return fmt.Sprintf(` + + + + Label + io.picoclaw.gateway + ProgramArguments + + %s + gateway + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + %s + + +`, execPath, currentPath) +} + +// uninstallInstall uninstalls the launch agent +func uninstallInstall() { + home, _ := os.UserHomeDir() + plistPath := filepath.Join(home, "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("PicoClaw is not installed.") + return + } + + // Unload the launch agent + cmd := exec.Command("launchctl", "unload", plistPath) + if output, err := cmd.CombinedOutput(); err != nil { + fmt.Printf("Error unloading launch agent: %v\n", err) + fmt.Printf("Output: %s\n", string(output)) + } + + // Remove plist file + if err := os.Remove(plistPath); err != nil { + fmt.Printf("Error removing plist file: %v\n", err) + os.Exit(1) + } + + fmt.Println("✓ PicoClaw gateway uninstalled successfully!") +} + +// statusInstall shows the installation status +func statusInstall() { + home, _ := os.UserHomeDir() + plistPath := filepath.Join(home, "Library", "LaunchAgents", "io.picoclaw.gateway.plist") + + fmt.Println("\nPicoClaw Gateway Status:") + fmt.Println("------------------------") + + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + fmt.Println("Status: Not installed") + fmt.Println("Run 'picoclaw install' to install as launch agent") + return + } + + fmt.Println("Status: Installed") + fmt.Printf("Plist: %s\n", plistPath) + + // Check if loaded + cmd := exec.Command("launchctl", "list", "io.picoclaw.gateway") + output, err := cmd.CombinedOutput() + if err == nil { + fmt.Println("\nLaunch agent is loaded.") + if len(output) > 0 { + fmt.Println(string(output)) + } + } else { + fmt.Println("\nLaunch agent is not running.") + } +} diff --git a/go.mod b/go.mod index 98aecd6ab..8b7b75bbd 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,6 @@ require ( golang.org/x/oauth2 v0.35.0 ) - - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -28,9 +26,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/google/jsonschema-go v0.4.2 // indirect - github.com/go-resty/resty/v2 v2.17.1 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -47,5 +45,4 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - ) diff --git a/go.sum b/go.sum index 6a565b93e..84a94df31 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,8 @@ github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQ github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index b14b1632e..1670ec48d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -29,6 +29,7 @@ type TelegramChannel struct { transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel + threadIDs sync.Map // chatIDStr -> MessageThreadID } type thinkingCancel struct { @@ -124,7 +125,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("telegram bot not running") } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID: %w", err) } @@ -144,6 +145,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err c.placeholders.Delete(msg.ChatID) editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) editMsg.ParseMode = telego.ModeHTML + // Note: EditMessageText doesn't require MessageThreadID as it edits existing message if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { return nil @@ -153,6 +155,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + + // Set thread ID if present + if threadID != 0 { + tgMsg.MessageThreadID = threadID + } if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ @@ -195,6 +202,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat chatID := message.Chat.ID c.chatIDs[senderID] = chatID + // Check for message thread (forum topic) + messageThreadID := message.MessageThreadID + chatIDStr := fmt.Sprintf("%d", chatID) + if messageThreadID != 0 { + // Store thread ID for later use + c.threadIDs.Store(chatIDStr, messageThreadID) + // Encode thread ID into chatID string (similar to Slack pattern) + chatIDStr = fmt.Sprintf("%d:%d", chatID, messageThreadID) + } + content := "" mediaPaths := []string{} localFiles := []string{} // 跟踪需要清理的本地文件 @@ -301,11 +318,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat logger.DebugCF("telegram", "Received message", map[string]interface{}{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), + "thread_id": messageThreadID, "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + // Thinking indicator - include thread ID if present + chatAction := tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping) + if messageThreadID != 0 { + chatAction.MessageThreadID = messageThreadID + } + err := c.bot.SendChatAction(ctx, chatAction) if err != nil { logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ "error": err.Error(), @@ -313,7 +335,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { cf.Cancel() @@ -324,7 +345,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + // Send "Thinking..." message - include thread ID if present + thinkingMsg := tu.Message(tu.ID(chatID), "Thinking... 💭") + if messageThreadID != 0 { + thinkingMsg.MessageThreadID = messageThreadID + } + pMsg, err := c.bot.SendMessage(ctx, thinkingMsg) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) @@ -337,8 +363,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + + if messageThreadID != 0 { + metadata["message_thread_id"] = fmt.Sprintf("%d", messageThreadID) + metadata["is_topic_message"] = "true" + } - c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage(senderID, chatIDStr, content, mediaPaths, metadata) } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { @@ -386,6 +417,30 @@ func parseChatID(chatIDStr string) (int64, error) { return id, err } +// parseTelegramChatID extracts chatID and threadID from a combined chatID string +// Format: "chatID" or "chatID:threadID" +func parseTelegramChatID(chatIDStr string) (chatID int64, threadID int, err error) { + parts := strings.SplitN(chatIDStr, ":", 2) + + var id int64 + _, err = fmt.Sscanf(parts[0], "%d", &id) + if err != nil { + return 0, 0, err + } + chatID = id + + if len(parts) > 1 { + var tid int + _, err = fmt.Sscanf(parts[1], "%d", &tid) + if err != nil { + return chatID, 0, fmt.Errorf("invalid thread ID '%s': %w", parts[1], err) + } + threadID = tid + } + + return chatID, threadID, nil +} + func markdownToTelegramHTML(text string) string { if text == "" { return "" diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go new file mode 100644 index 000000000..969f03222 --- /dev/null +++ b/pkg/channels/telegram_test.go @@ -0,0 +1,128 @@ +package channels + +import ( + "testing" +) + +func TestParseTelegramChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + wantChatID int64 + wantThreadID int + wantErr bool + }{ + { + name: "chat only", + chatIDStr: "123456789", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: false, + }, + { + name: "negative chat ID (private chat)", + chatIDStr: "-987654321", + wantChatID: -987654321, + wantThreadID: 0, + wantErr: false, + }, + { + name: "chat with thread", + chatIDStr: "123456789:42", + wantChatID: 123456789, + wantThreadID: 42, + wantErr: false, + }, + { + name: "negative chat with thread", + chatIDStr: "-987654321:100", + wantChatID: -987654321, + wantThreadID: 100, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + { + name: "invalid thread ID", + chatIDStr: "123456789:invalid", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: true, + }, + { + name: "empty string", + chatIDStr: "", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chatID, threadID, err := parseTelegramChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseTelegramChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr { + if chatID != tt.wantChatID { + t.Errorf("parseTelegramChatID(%q) chatID = %d, want %d", tt.chatIDStr, chatID, tt.wantChatID) + } + if threadID != tt.wantThreadID { + t.Errorf("parseTelegramChatID(%q) threadID = %d, want %d", tt.chatIDStr, threadID, tt.wantThreadID) + } + } + }) + } +} + +func TestParseChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + want int64 + wantErr bool + }{ + { + name: "positive chat ID", + chatIDStr: "123456789", + want: 123456789, + wantErr: false, + }, + { + name: "negative chat ID", + chatIDStr: "-987654321", + want: -987654321, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.want { + t.Errorf("parseChatID(%q) = %d, want %d", tt.chatIDStr, got, tt.want) + } + }) + } +}