Merge 05c08dfa74 into 811e4f8728
This commit is contained in:
commit
afddee7b90
5 changed files with 401 additions and 13 deletions
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
@ -138,6 +139,8 @@ func main() {
|
||||||
migrateCmd()
|
migrateCmd()
|
||||||
case "auth":
|
case "auth":
|
||||||
authCmd()
|
authCmd()
|
||||||
|
case "install":
|
||||||
|
installCmd()
|
||||||
case "cron":
|
case "cron":
|
||||||
cronCmd()
|
cronCmd()
|
||||||
case "skills":
|
case "skills":
|
||||||
|
|
@ -207,6 +210,7 @@ func printHelp() {
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||||
fmt.Println(" gateway Start picoclaw gateway")
|
fmt.Println(" gateway Start picoclaw gateway")
|
||||||
|
fmt.Println(" install Install/uninstall launchd service (macOS)")
|
||||||
fmt.Println(" status Show picoclaw status")
|
fmt.Println(" status Show picoclaw status")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||||
|
|
@ -1410,3 +1414,205 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
||||||
fmt.Println("----------------------")
|
fmt.Println("----------------------")
|
||||||
fmt.Println(content)
|
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>io.picoclaw.gateway</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>%s</string>
|
||||||
|
<string>gateway</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>%s</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>`, 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -19,8 +19,6 @@ require (
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // 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/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/github/copilot-sdk/go v0.1.23
|
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.2 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
|
||||||
github.com/gogo/protobuf v1.3.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/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // 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/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
6
go.sum
6
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/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-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.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.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
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-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 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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.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.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.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 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type TelegramChannel struct {
|
||||||
transcriber *voice.GroqTranscriber
|
transcriber *voice.GroqTranscriber
|
||||||
placeholders sync.Map // chatID -> messageID
|
placeholders sync.Map // chatID -> messageID
|
||||||
stopThinking sync.Map // chatID -> thinkingCancel
|
stopThinking sync.Map // chatID -> thinkingCancel
|
||||||
|
threadIDs sync.Map // chatIDStr -> MessageThreadID
|
||||||
}
|
}
|
||||||
|
|
||||||
type thinkingCancel struct {
|
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")
|
return fmt.Errorf("telegram bot not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID: %w", err)
|
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)
|
c.placeholders.Delete(msg.ChatID)
|
||||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
editMsg.ParseMode = telego.ModeHTML
|
||||||
|
// Note: EditMessageText doesn't require MessageThreadID as it edits existing message
|
||||||
|
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -154,6 +156,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
|
// Set thread ID if present
|
||||||
|
if threadID != 0 {
|
||||||
|
tgMsg.MessageThreadID = threadID
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -195,6 +202,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
||||||
chatID := message.Chat.ID
|
chatID := message.Chat.ID
|
||||||
c.chatIDs[senderID] = chatID
|
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 := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
localFiles := []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{}{
|
logger.DebugCF("telegram", "Received message", map[string]interface{}{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
"chat_id": fmt.Sprintf("%d", chatID),
|
||||||
|
"thread_id": messageThreadID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Thinking indicator
|
// Thinking indicator - include thread ID if present
|
||||||
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
|
chatAction := tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)
|
||||||
|
if messageThreadID != 0 {
|
||||||
|
chatAction.MessageThreadID = messageThreadID
|
||||||
|
}
|
||||||
|
err := c.bot.SendChatAction(ctx, chatAction)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -313,7 +335,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop any previous thinking animation
|
// Stop any previous thinking animation
|
||||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
|
||||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
||||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
||||||
cf.Cancel()
|
cf.Cancel()
|
||||||
|
|
@ -324,7 +345,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
||||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
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 {
|
if err == nil {
|
||||||
pID := pMsg.MessageID
|
pID := pMsg.MessageID
|
||||||
c.placeholders.Store(chatIDStr, pID)
|
c.placeholders.Store(chatIDStr, pID)
|
||||||
|
|
@ -338,7 +364,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
|
if messageThreadID != 0 {
|
||||||
|
metadata["message_thread_id"] = fmt.Sprintf("%d", messageThreadID)
|
||||||
|
metadata["is_topic_message"] = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatIDStr, content, mediaPaths, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||||
|
|
@ -386,6 +417,30 @@ func parseChatID(chatIDStr string) (int64, error) {
|
||||||
return id, err
|
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 {
|
func markdownToTelegramHTML(text string) string {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
128
pkg/channels/telegram_test.go
Normal file
128
pkg/channels/telegram_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue