diff --git a/Makefile b/Makefile index 6e65d7f..07b25d8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build build-variants build-linux-slim build-all build-all-variants package-all install install-win uninstall clean help test test-docker install-bootstrap-docs sync-embed-workspace cleanup-embed-workspace test-only clean-test-artifacts dev +.PHONY: all build build-linux-slim build-all package-all install install-win uninstall clean help test test-docker install-bootstrap-docs sync-embed-workspace cleanup-embed-workspace test-only clean-test-artifacts dev # Build variables BINARY_NAME=clawgo @@ -33,14 +33,7 @@ LINUX_SLIM_PATH=$(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)-slim # Cross-platform build matrix (space-separated GOOS/GOARCH pairs) BUILD_TARGETS?=linux/amd64 linux/arm64 linux/riscv64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 -CHANNELS?=telegram discord feishu maixcam qq dingtalk whatsapp -CHANNEL_PACKAGE_VARIANTS?=full none $(CHANNELS) -empty:= -space:=$(empty) $(empty) -comma:=, -ALL_CHANNEL_OMIT_TAGS=$(subst $(space),$(comma),$(addprefix omit_,$(CHANNELS))) FULL_BUILD_TAGS=with_tui -NOCHANNELS_TAGS=$(ALL_CHANNEL_OMIT_TAGS),with_tui # Installation INSTALL_PREFIX?=/usr/local @@ -111,40 +104,6 @@ build: sync-embed-workspace @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) -## build-variants: Build current-platform full, no-channel, and per-channel binaries -build-variants: sync-embed-workspace - @echo "Building channel variants for $(PLATFORM)/$(ARCH): $(CHANNEL_PACKAGE_VARIANTS)" - @mkdir -p $(BUILD_DIR) - @set -e; trap '$(MAKE) cleanup-embed-workspace' EXIT; \ - for variant in $(CHANNEL_PACKAGE_VARIANTS); do \ - tags=""; \ - suffix=""; \ - if [ "$$variant" = "none" ]; then \ - tags="$(NOCHANNELS_TAGS)"; \ - suffix="-nochannels"; \ - elif [ "$$variant" = "full" ]; then \ - tags="$(FULL_BUILD_TAGS)"; \ - elif [ "$$variant" != "full" ]; then \ - for ch in $(CHANNELS); do \ - if [ "$$ch" != "$$variant" ]; then \ - tags="$${tags:+$$tags,}omit_$$ch"; \ - fi; \ - done; \ - suffix="-$$variant"; \ - fi; \ - out="$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)$$suffix"; \ - echo " -> $$variant"; \ - if [ -n "$$tags" ]; then \ - $(GO) build $(GOFLAGS) $(BUILD_FLAGS) -tags "$$tags" $(LDFLAGS) -o "$$out" ./$(CMD_DIR); \ - else \ - $(GO) build $(GOFLAGS) $(BUILD_FLAGS) $(LDFLAGS) -o "$$out" ./$(CMD_DIR); \ - fi; \ - if [ "$(COMPRESS_BINARY)" = "1" ] && command -v upx >/dev/null 2>&1; then \ - upx $(UPX_FLAGS) "$$out" >/dev/null; \ - fi; \ - done - @echo "Variant builds complete: $(BUILD_DIR)" - ## build-linux-slim: Build a Linux-only slim binary (no feature trimming, no channel disabling) build-linux-slim: sync-embed-workspace @echo "Building $(BINARY_NAME) slim profile for linux/$(ARCH)..." @@ -179,72 +138,22 @@ build-all: sync-embed-workspace done @echo "All builds complete" -## build-all-variants: Build full, no-channel, and per-channel binaries for all configured platforms -build-all-variants: sync-embed-workspace - @echo "Building all channel variants for multiple platforms: $(BUILD_TARGETS)" - @mkdir -p $(BUILD_DIR) - @set -e; trap '$(MAKE) cleanup-embed-workspace' EXIT; \ - for target in $(BUILD_TARGETS); do \ - goos="$${target%/*}"; \ - goarch="$${target#*/}"; \ - for variant in $(CHANNEL_PACKAGE_VARIANTS); do \ - tags=""; \ - suffix=""; \ - if [ "$$variant" = "none" ]; then \ - tags="$(NOCHANNELS_TAGS)"; \ - suffix="-nochannels"; \ - elif [ "$$variant" = "full" ]; then \ - tags="$(FULL_BUILD_TAGS)"; \ - elif [ "$$variant" != "full" ]; then \ - for ch in $(CHANNELS); do \ - if [ "$$ch" != "$$variant" ]; then \ - tags="$${tags:+$$tags,}omit_$$ch"; \ - fi; \ - done; \ - suffix="-$$variant"; \ - fi; \ - out="$(BUILD_DIR)/$(BINARY_NAME)-$$goos-$$goarch$$suffix"; \ - if [ "$$goos" = "windows" ]; then out="$$out.exe"; fi; \ - echo " -> $$goos/$$goarch [$$variant]"; \ - if [ -n "$$tags" ]; then \ - CGO_ENABLED=0 GOOS=$$goos GOARCH=$$goarch $(GO) build $(GOFLAGS) $(BUILD_FLAGS) -tags "$$tags" $(LDFLAGS) -o "$$out" ./$(CMD_DIR); \ - else \ - CGO_ENABLED=0 GOOS=$$goos GOARCH=$$goarch $(GO) build $(GOFLAGS) $(BUILD_FLAGS) $(LDFLAGS) -o "$$out" ./$(CMD_DIR); \ - fi; \ - if [ "$(COMPRESS_BINARY)" = "1" ] && command -v upx >/dev/null 2>&1; then \ - upx $(UPX_FLAGS) "$$out" >/dev/null; \ - fi; \ - done; \ - done - @echo "All variant builds complete" - -## package-all: Create compressed archives and checksums for full, no-channel, and per-channel build variants -package-all: build-all-variants +## package-all: Create compressed archives and checksums for full builds +package-all: build-all @echo "Packaging build artifacts..." @set -e; cd $(BUILD_DIR); \ for target in $(BUILD_TARGETS); do \ goos="$${target%/*}"; \ goarch="$${target#*/}"; \ - for variant in $(CHANNEL_PACKAGE_VARIANTS); do \ - suffix=""; \ - archive_suffix=""; \ - if [ "$$variant" = "none" ]; then \ - suffix="-nochannels"; \ - archive_suffix="-nochannels"; \ - elif [ "$$variant" != "full" ]; then \ - suffix="-$$variant"; \ - archive_suffix="-$$variant"; \ - fi; \ - bin="$(BINARY_NAME)-$$goos-$$goarch$$suffix"; \ - if [ "$$goos" = "windows" ]; then \ - bin="$$bin.exe"; \ - archive="$(BINARY_NAME)-$$goos-$$goarch$$archive_suffix.zip"; \ - zip -q -j "$$archive" "$$bin"; \ - else \ - archive="$(BINARY_NAME)-$$goos-$$goarch$$archive_suffix.tar.gz"; \ - tar -czf "$$archive" "$$bin"; \ - fi; \ - done; \ + bin="$(BINARY_NAME)-$$goos-$$goarch"; \ + if [ "$$goos" = "windows" ]; then \ + bin="$$bin.exe"; \ + archive="$(BINARY_NAME)-$$goos-$$goarch.zip"; \ + zip -q -j "$$archive" "$$bin"; \ + else \ + archive="$(BINARY_NAME)-$$goos-$$goarch.tar.gz"; \ + tar -czf "$$archive" "$$bin"; \ + fi; \ done @set -e; cd $(BUILD_DIR); \ if command -v sha256sum >/dev/null 2>&1; then \ diff --git a/README.md b/README.md index 06fb71b..fa04fd4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ClawGo 不是“又一个聊天壳子”,而是一套可长期运行、可观测、可恢复、可编排的 Agent 运行时。 - `main agent` 负责入口、路由、派发、汇总 -- `subagent runtime` 负责本地或远端分支执行 +- `subagent runtime` 负责本地分支执行 - `runtime store` 持久化 run、event、thread、message、memory - WebUI 负责检查、状态展示和账号管理,不负责运行时配置写入 @@ -23,7 +23,7 @@ ClawGo 更关注真正的运行时能力: - 多 Agent 拓扑和内部协作流 - 可恢复的 subagent run -- 本地主控加远端 node 分支 +- 本地主控加本地 subagent 分支 - 配置、审计、日志、记忆的工程化闭环 一句话: @@ -34,7 +34,7 @@ ClawGo 更关注真正的运行时能力: ### 1. 多 Agent 拓扑 -- 统一展示 `main / subagents / remote branches` +- 统一展示 `main / subagents` - 内部协作流与用户对话分离 - 子代理执行过程可追踪,但不会污染用户主通道 @@ -149,9 +149,7 @@ user -> main -> worker -> main -> user 负责用户入口、路由、派发、汇总 2. `local subagents` 在 `config.json -> agents.subagents` 中声明,使用独立 session 和 memory namespace -3. `node-backed branches` - 远端节点作为受控 agent 分支挂载到主拓扑 -4. `runtime store` +3. `runtime store` 保存运行态、线程、消息、事件和审计数据 说明: @@ -205,10 +203,6 @@ user -> main -> worker -> main -> user - WebUI 配置编辑已禁用 - 运行时配置修改请直接改 `config.json` - 启用中的本地 subagent 必须配置 `system_prompt_file` -- 远端分支需要: - - `transport: "node"` - - `node_id` - - `parent_agent_id` 完整示例见 [config.example.json](/G:/gopro/clawgo/config.example.json)。 diff --git a/README_EN.md b/README_EN.md index 0202443..a8babbd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,7 +6,7 @@ ClawGo is not just another chat wrapper. It is a long-running, observable, recov - 👀 **Observable**: agent topology, internal streams, task audit, and runtime visibility - 🔁 **Recoverable**: persisted runtime state and restart recovery -- 🧩 **Orchestrated**: `main agent -> subagent -> main`, with local and remote node branches +- 🧩 **Orchestrated**: `main agent -> subagent -> main`, with local subagent branches - ⚙️ **Operational**: `config.json`, `AGENT.md`, hot reload, WebUI, declarative registry [中文](./README.md) @@ -23,7 +23,7 @@ ClawGo focuses on runtime capabilities: - `main agent` handles entry, routing, dispatch, and merge - `subagents` execute coding, testing, product, docs, and other focused tasks -- `node branches` attach remote nodes as controlled agent branches +- `subagent branches` execute as controlled local branches - `runtime store` persists runs, events, threads, messages, and memory In one line: @@ -34,7 +34,7 @@ In one line: ### 1. Observable multi-agent topology -- unified view of `main / subagents / remote branches` +- unified view of `main / subagents` - internal subagent streams are visible - user-facing chat stays clean while internal collaboration remains inspectable @@ -160,16 +160,14 @@ ClawGo currently has four layers: user-facing entry, routing, dispatch, and merge 2. `local subagents` declared in `config.json -> agents.subagents`, with isolated sessions and memory namespaces -3. `node-backed branches` - remote nodes mounted as controlled agent branches -4. `runtime store` +3. `runtime store` persisted runtime, threads, messages, events, and audit data ## What It Is Good For - 🤖 long-running local personal agents - 🧪 multi-agent flows like `pm -> coder -> tester` -- 🌐 local control with remote node branches +- 🌐 local control with isolated subagent branches - 🔍 systems that need strong observability, auditability, and recovery - 🏭 teams that want agent config, prompts, tool permissions, and runtime policy managed as code @@ -223,10 +221,6 @@ Notes: - keep runtime config changes in `config.json` - runtime panels now consume the unified `runtime snapshot / runtime live` - enabled local subagents must define `system_prompt_file` -- remote branches require: - - `transport: "node"` - - `node_id` - - `parent_agent_id` See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) for a full example. diff --git a/cmd/cli_common.go b/cmd/cli_common.go index 8cf0e3a..b683190 100644 --- a/cmd/cli_common.go +++ b/cmd/cli_common.go @@ -96,7 +96,6 @@ func printHelp() { fmt.Println(" config Get/set config values") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" channel Test and manage messaging channels") - fmt.Println(" node Register remote node metadata and heartbeat") fmt.Println(" skills Manage skills (install, list, remove)") if tuiEnabled { fmt.Println(" tui Chat in terminal using the gateway chat API") diff --git a/cmd/cmd_channel.go b/cmd/cmd_channel.go index 70a1909..93c629c 100644 --- a/cmd/cmd_channel.go +++ b/cmd/cmd_channel.go @@ -2,19 +2,11 @@ package main import ( "context" - "encoding/json" "fmt" - "net/http" "os" - "path/filepath" - "strings" - "time" "github.com/YspCoder/clawgo/pkg/bus" "github.com/YspCoder/clawgo/pkg/channels" - "github.com/YspCoder/clawgo/pkg/config" - - qrterminal "github.com/mdp/qrterminal/v3" ) func channelCmd() { @@ -28,8 +20,6 @@ func channelCmd() { switch subcommand { case "test": channelTestCmd() - case "whatsapp": - whatsAppChannelCmd() default: fmt.Printf("Unknown channel command: %s\n", subcommand) channelHelp() @@ -39,17 +29,11 @@ func channelCmd() { func channelHelp() { fmt.Println("\nChannel commands:") fmt.Println(" test Send a test message to a specific channel") - fmt.Println(" whatsapp Run and inspect the built-in WhatsApp bridge") fmt.Println() fmt.Println("Test options:") fmt.Println(" --to Recipient ID") - fmt.Println(" --channel Channel name (telegram, discord, etc.)") + fmt.Println(" --channel Channel name (weixin, feishu, telegram)") fmt.Println(" -m, --message Message to send") - fmt.Println() - fmt.Println("WhatsApp bridge:") - fmt.Println(" clawgo channel whatsapp bridge run") - fmt.Println(" clawgo channel whatsapp bridge status") - fmt.Println(" clawgo channel whatsapp bridge logout") } func channelTestCmd() { @@ -110,258 +94,3 @@ func channelTestCmd() { fmt.Println("Test message sent successfully.") } - -func whatsAppChannelCmd() { - if len(os.Args) < 4 { - whatsAppChannelHelp() - return - } - if os.Args[3] != "bridge" { - fmt.Printf("Unknown WhatsApp channel command: %s\n", os.Args[3]) - whatsAppChannelHelp() - return - } - if len(os.Args) < 5 { - whatsAppBridgeHelp() - return - } - switch os.Args[4] { - case "run": - whatsAppBridgeRunCmd() - case "status": - whatsAppBridgeStatusCmd() - case "logout": - whatsAppBridgeLogoutCmd() - default: - fmt.Printf("Unknown WhatsApp bridge command: %s\n", os.Args[4]) - whatsAppBridgeHelp() - } -} - -func whatsAppChannelHelp() { - fmt.Println("\nWhatsApp channel commands:") - fmt.Println(" clawgo channel whatsapp bridge run") - fmt.Println(" clawgo channel whatsapp bridge status") - fmt.Println(" clawgo channel whatsapp bridge logout") -} - -func whatsAppBridgeHelp() { - fmt.Println("\nWhatsApp bridge commands:") - fmt.Println(" run Run the built-in local WhatsApp bridge with QR login") - fmt.Println(" status Show current WhatsApp bridge status") - fmt.Println(" logout Unlink the current WhatsApp companion session") - fmt.Println() - fmt.Println("Run options:") - fmt.Println(" --addr Override listen address (defaults to channels.whatsapp.bridge_url host)") - fmt.Println(" --state-dir Override session store directory") - fmt.Println(" --no-print-qr Disable terminal QR rendering") -} - -func whatsAppBridgeRunCmd() { - cfg, _ := loadConfig() - bridgeURL := "ws://127.0.0.1:3001" - if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { - bridgeURL = cfg.Channels.WhatsApp.BridgeURL - } - addr, err := channels.ParseWhatsAppBridgeListenAddr(bridgeURL) - if err != nil { - fmt.Printf("Error parsing WhatsApp bridge url: %v\n", err) - os.Exit(1) - } - stateDir := filepath.Join(config.GetConfigDir(), "channels", "whatsapp") - printQR := true - args := os.Args[5:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--addr": - if i+1 < len(args) { - addr = strings.TrimSpace(args[i+1]) - i++ - } - case "--state-dir": - if i+1 < len(args) { - stateDir = strings.TrimSpace(args[i+1]) - i++ - } - case "--no-print-qr": - printQR = false - } - } - - fmt.Printf("Starting WhatsApp bridge on %s\n", addr) - fmt.Printf("Session store: %s\n", stateDir) - statusURL, _ := channels.BridgeStatusURL(addr) - fmt.Printf("Status endpoint: %s\n", statusURL) - if printQR { - fmt.Println("QR codes will be rendered below when login is required.") - } - - svc := channels.NewWhatsAppBridgeService(addr, stateDir, printQR) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go renderWhatsAppBridgeQR(ctx, addr, printQR) - go renderWhatsAppBridgeState(ctx, addr) - if err := svc.Start(ctx); err != nil { - fmt.Printf("WhatsApp bridge stopped with error: %v\n", err) - os.Exit(1) - } -} - -func whatsAppBridgeStatusCmd() { - cfg, _ := loadConfig() - bridgeURL := "ws://127.0.0.1:3001" - if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { - bridgeURL = cfg.Channels.WhatsApp.BridgeURL - } - args := os.Args[5:] - if len(args) >= 2 && args[0] == "--url" { - bridgeURL = strings.TrimSpace(args[1]) - } - statusURL, err := channels.BridgeStatusURL(bridgeURL) - if err != nil { - fmt.Printf("Error building status url: %v\n", err) - os.Exit(1) - } - status, err := fetchWhatsAppBridgeStatus(statusURL) - if err != nil { - fmt.Printf("Error fetching WhatsApp bridge status: %v\n", err) - os.Exit(1) - } - data, _ := json.MarshalIndent(status, "", " ") - fmt.Println(string(data)) - if status.QRAvailable && strings.TrimSpace(status.QRCode) != "" { - fmt.Println() - fmt.Println("Scan this QR code with WhatsApp:") - qrterminal.GenerateHalfBlock(status.QRCode, qrterminal.L, os.Stdout) - } -} - -func whatsAppBridgeLogoutCmd() { - cfg, _ := loadConfig() - bridgeURL := "ws://127.0.0.1:3001" - if cfg != nil && strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) != "" { - bridgeURL = cfg.Channels.WhatsApp.BridgeURL - } - args := os.Args[5:] - if len(args) >= 2 && args[0] == "--url" { - bridgeURL = strings.TrimSpace(args[1]) - } - logoutURL, err := channels.BridgeLogoutURL(bridgeURL) - if err != nil { - fmt.Printf("Error building logout url: %v\n", err) - os.Exit(1) - } - req, _ := http.NewRequest(http.MethodPost, logoutURL, nil) - resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) - if err != nil { - fmt.Printf("Error calling WhatsApp bridge logout: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - fmt.Printf("WhatsApp bridge logout failed: %s\n", resp.Status) - os.Exit(1) - } - fmt.Println("WhatsApp bridge logout requested successfully.") -} - -func fetchWhatsAppBridgeStatus(statusURL string) (channels.WhatsAppBridgeStatus, error) { - resp, err := (&http.Client{Timeout: 8 * time.Second}).Get(statusURL) - if err != nil { - return channels.WhatsAppBridgeStatus{}, err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return channels.WhatsAppBridgeStatus{}, fmt.Errorf("status request failed: %s", resp.Status) - } - var status channels.WhatsAppBridgeStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return channels.WhatsAppBridgeStatus{}, err - } - return status, nil -} - -func renderWhatsAppBridgeQR(ctx context.Context, bridgeURL string, enabled bool) { - if !enabled { - return - } - statusURL, err := channels.BridgeStatusURL(bridgeURL) - if err != nil { - return - } - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - lastQR := "" - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := fetchWhatsAppBridgeStatus(statusURL) - if err != nil { - continue - } - if !status.QRAvailable || strings.TrimSpace(status.QRCode) == "" || status.QRCode == lastQR { - continue - } - lastQR = status.QRCode - fmt.Println() - fmt.Println("Scan this QR code with WhatsApp:") - qrterminal.GenerateHalfBlock(status.QRCode, qrterminal.L, os.Stdout) - fmt.Println() - } - } -} - -func renderWhatsAppBridgeState(ctx context.Context, bridgeURL string) { - statusURL, err := channels.BridgeStatusURL(bridgeURL) - if err != nil { - return - } - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - var lastSig string - var lastPrintedUser string - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - status, err := fetchWhatsAppBridgeStatus(statusURL) - if err != nil { - continue - } - sig := fmt.Sprintf("%s|%t|%t|%s|%s", status.State, status.Connected, status.LoggedIn, status.UserJID, status.LastEvent) - if sig == lastSig { - continue - } - lastSig = sig - - switch { - case status.QRAvailable: - fmt.Println("Waiting for WhatsApp QR scan...") - case status.State == "paired": - fmt.Println("WhatsApp QR scanned. Finalizing companion link...") - case status.Connected && status.LoggedIn: - if status.UserJID != "" && status.UserJID != lastPrintedUser { - fmt.Printf("WhatsApp connected as %s\n", status.UserJID) - lastPrintedUser = status.UserJID - } else { - fmt.Println("WhatsApp bridge connected.") - } - fmt.Println("Bridge is ready. Start or keep `make dev` running to receive messages.") - case status.State == "stored_session": - fmt.Println("Existing WhatsApp session found. Reconnecting...") - case status.State == "disconnected": - fmt.Println("WhatsApp bridge disconnected. Waiting for reconnect...") - case status.State == "logged_out": - fmt.Println("WhatsApp session logged out. Restart bridge to scan a new QR code.") - case status.LastError != "": - fmt.Printf("WhatsApp bridge status: %s (%s)\n", status.State, status.LastError) - case status.State != "": - fmt.Printf("WhatsApp bridge status: %s\n", status.State) - } - } - } -} diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 03059a0..5afda3a 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -2,10 +2,10 @@ package main import ( "context" + "crypto/sha256" "fmt" "io" "net/http" - "net/url" "os" "os/exec" "os/signal" @@ -81,9 +81,6 @@ func gatewayCmd() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if shouldEmbedWhatsAppBridge(cfg) { - cfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(cfg) - } agentLoop, channelManager, err := buildGatewayRuntime(ctx, cfg, msgBus, cronService) if err != nil { @@ -175,19 +172,109 @@ func gatewayCmd() { } bindAgentLoopHandlers(agentLoop) var reloadMu sync.Mutex - var applyReload func(forceRuntimeReload bool) error - registryServer.SetConfigAfterHook(func(forceRuntimeReload bool) error { + triggerReload := func(source string, forceRuntimeReload bool) error { reloadMu.Lock() defer reloadMu.Unlock() - if applyReload == nil { - return fmt.Errorf("reload handler not ready") + fmt.Printf("\nReloading config (source=%s)...\n", strings.TrimSpace(source)) + newCfg, err := config.LoadConfig(getConfigPath()) + if err != nil { + return fmt.Errorf("load config: %w", err) } - return applyReload(forceRuntimeReload) - }) - whatsAppBridge, whatsAppEmbedded := setupEmbeddedWhatsAppBridge(ctx, cfg) - if whatsAppBridge != nil { - registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) + if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { + applyMaximumPermissionPolicy(newCfg) + } + configureCronServiceRuntime(cronService, newCfg) + heartbeatService.Stop() + heartbeatService = buildHeartbeatService(newCfg, msgBus) + if err := heartbeatService.Start(); err != nil { + fmt.Printf("Error starting heartbeat service: %v\n", err) + } + + if !forceRuntimeReload && reflect.DeepEqual(cfg, newCfg) { + fmt.Println("Config unchanged, skip reload") + return nil + } + + if cfg.Gateway.Host != newCfg.Gateway.Host || cfg.Gateway.Port != newCfg.Gateway.Port { + fmt.Printf("Warning: gateway host/port change detected (%s:%d -> %s:%d); restart required to rebind listener\n", + cfg.Gateway.Host, cfg.Gateway.Port, newCfg.Gateway.Host, newCfg.Gateway.Port) + } + + runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) && + reflect.DeepEqual(cfg.Models, newCfg.Models) && + reflect.DeepEqual(cfg.Tools, newCfg.Tools) && + reflect.DeepEqual(cfg.Channels, newCfg.Channels) + + if runtimeSame && !forceRuntimeReload { + configureLogging(newCfg) + sentinelService.Stop() + sentinelService = sentinel.NewService( + getConfigPath(), + newCfg.WorkspacePath(), + newCfg.Sentinel.IntervalSec, + newCfg.Sentinel.AutoHeal, + buildSentinelAlertHandler(newCfg, msgBus), + ) + if newCfg.Sentinel.Enabled { + sentinelService.SetManager(channelManager) + sentinelService.Start() + } + cfg = newCfg + runtimecfg.Set(cfg) + registryServer.SetToken(cfg.Gateway.Token) + registryServer.SetWorkspacePath(cfg.WorkspacePath()) + registryServer.SetLogFilePath(cfg.LogFilePath()) + fmt.Println("Config hot-reload applied (logging/metadata only)") + return nil + } + + newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService) + if err != nil { + return fmt.Errorf("init runtime: %w", err) + } + + channelManager.StopAll(ctx) + agentLoop.Stop() + channelManager = newChannelManager + agentLoop = newAgentLoop + cfg = newCfg + runtimecfg.Set(cfg) + bindAgentLoopHandlers(agentLoop) + configureLogging(newCfg) + registryServer.SetToken(cfg.Gateway.Token) + registryServer.SetWorkspacePath(cfg.WorkspacePath()) + registryServer.SetLogFilePath(cfg.LogFilePath()) + if rawWeixin, ok := channelManager.GetChannel("weixin"); ok { + if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok { + weixinChannel.SetConfigPath(getConfigPath()) + registryServer.SetWeixinChannel(weixinChannel) + } + } else { + registryServer.SetWeixinChannel(nil) + } + sentinelService.Stop() + sentinelService = sentinel.NewService( + getConfigPath(), + newCfg.WorkspacePath(), + newCfg.Sentinel.IntervalSec, + newCfg.Sentinel.AutoHeal, + buildSentinelAlertHandler(newCfg, msgBus), + ) + if newCfg.Sentinel.Enabled { + sentinelService.Start() + } + sentinelService.SetManager(channelManager) + + if err := channelManager.StartAll(ctx); err != nil { + return fmt.Errorf("start channels: %w", err) + } + go agentLoop.Run(ctx) + fmt.Println("Config hot-reload applied") + return nil } + registryServer.SetConfigAfterHook(func(forceRuntimeReload bool) error { + return triggerReload("api", forceRuntimeReload) + }) if rawWeixin, ok := channelManager.GetChannel("weixin"); ok { if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok { weixinChannel.SetConfigPath(getConfigPath()) @@ -332,9 +419,9 @@ func gatewayCmd() { } }) if err := registryServer.Start(ctx); err != nil { - fmt.Printf("Error starting node registry server: %v\n", err) + fmt.Printf("Error starting gateway server: %v\n", err) } else { - fmt.Printf("Node registry server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf("Gateway server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) } if err := channelManager.StartAll(ctx); err != nil { @@ -345,137 +432,26 @@ func gatewayCmd() { go runGatewayStartupCompactionCheck(ctx, agentLoop) go runGatewayBootstrapInit(ctx, cfg, agentLoop) + stopConfigWatcher := startGatewayConfigWatcher(ctx, getConfigPath(), 500*time.Millisecond, 250*time.Millisecond, func() error { + return triggerReload("watcher", false) + }) + defer stopConfigWatcher() + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, gatewayNotifySignals()...) - applyReload = func(forceRuntimeReload bool) error { - fmt.Println("\nReloading config...") - newCfg, err := config.LoadConfig(getConfigPath()) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { - applyMaximumPermissionPolicy(newCfg) - } - configureCronServiceRuntime(cronService, newCfg) - heartbeatService.Stop() - heartbeatService = buildHeartbeatService(newCfg, msgBus) - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - - if !forceRuntimeReload && reflect.DeepEqual(cfg, newCfg) { - fmt.Println("Config unchanged, skip reload") - return nil - } - - if cfg.Gateway.Host != newCfg.Gateway.Host || cfg.Gateway.Port != newCfg.Gateway.Port { - fmt.Printf("Warning: gateway host/port change detected (%s:%d -> %s:%d); restart required to rebind listener\n", - cfg.Gateway.Host, cfg.Gateway.Port, newCfg.Gateway.Host, newCfg.Gateway.Port) - } - - if shouldEmbedWhatsAppBridge(newCfg) { - newCfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(newCfg) - } - - runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) && - reflect.DeepEqual(cfg.Models, newCfg.Models) && - reflect.DeepEqual(cfg.Tools, newCfg.Tools) && - reflect.DeepEqual(cfg.Channels, newCfg.Channels) - - if runtimeSame && !forceRuntimeReload { - configureLogging(newCfg) - sentinelService.Stop() - sentinelService = sentinel.NewService( - getConfigPath(), - newCfg.WorkspacePath(), - newCfg.Sentinel.IntervalSec, - newCfg.Sentinel.AutoHeal, - buildSentinelAlertHandler(newCfg, msgBus), - ) - if newCfg.Sentinel.Enabled { - sentinelService.SetManager(channelManager) - sentinelService.Start() - } - cfg = newCfg - runtimecfg.Set(cfg) - registryServer.SetToken(cfg.Gateway.Token) - registryServer.SetWorkspacePath(cfg.WorkspacePath()) - registryServer.SetLogFilePath(cfg.LogFilePath()) - fmt.Println("Config hot-reload applied (logging/metadata only)") - return nil - } - - newAgentLoop, newChannelManager, err := buildGatewayRuntime(ctx, newCfg, msgBus, cronService) - if err != nil { - return fmt.Errorf("init runtime: %w", err) - } - - channelManager.StopAll(ctx) - agentLoop.Stop() - if whatsAppBridge != nil { - whatsAppBridge.Stop() - } - - newWhatsAppBridge, _ := setupEmbeddedWhatsAppBridge(ctx, newCfg) - - channelManager = newChannelManager - agentLoop = newAgentLoop - cfg = newCfg - whatsAppBridge = newWhatsAppBridge - whatsAppEmbedded = newWhatsAppBridge != nil - runtimecfg.Set(cfg) - bindAgentLoopHandlers(agentLoop) - configureLogging(newCfg) - registryServer.SetToken(cfg.Gateway.Token) - registryServer.SetWorkspacePath(cfg.WorkspacePath()) - registryServer.SetLogFilePath(cfg.LogFilePath()) - registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) - if rawWeixin, ok := channelManager.GetChannel("weixin"); ok { - if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok { - weixinChannel.SetConfigPath(getConfigPath()) - registryServer.SetWeixinChannel(weixinChannel) - } - } else { - registryServer.SetWeixinChannel(nil) - } - sentinelService.Stop() - sentinelService = sentinel.NewService( - getConfigPath(), - newCfg.WorkspacePath(), - newCfg.Sentinel.IntervalSec, - newCfg.Sentinel.AutoHeal, - buildSentinelAlertHandler(newCfg, msgBus), - ) - if newCfg.Sentinel.Enabled { - sentinelService.Start() - } - sentinelService.SetManager(channelManager) - - if err := channelManager.StartAll(ctx); err != nil { - return fmt.Errorf("start channels: %w", err) - } - go agentLoop.Run(ctx) - fmt.Println("Config hot-reload applied") - return nil - } for { select { case sig := <-sigChan: switch { case isGatewayReloadSignal(sig): - reloadMu.Lock() - err := applyReload(false) - reloadMu.Unlock() + err := triggerReload("signal", false) if err != nil { fmt.Printf("Reload failed: %v\n", err) } default: fmt.Println("\nShutting down...") cancel() - if whatsAppEmbedded && whatsAppBridge != nil { - whatsAppBridge.Stop() - } heartbeatService.Stop() sentinelService.Stop() cronService.Stop() @@ -488,8 +464,6 @@ func gatewayCmd() { } } -const embeddedWhatsAppBridgeBasePath = "/whatsapp" - func runGatewayStartupCompactionCheck(parent context.Context, agentLoop *agent.AgentLoop) { if agentLoop == nil { return @@ -542,6 +516,89 @@ func runGatewayBootstrapInit(parent context.Context, cfg *config.Config, agentLo logger.InfoC("gateway", logger.C0114) } +type configFileFingerprint struct { + Size int64 + ModUnixNano int64 + SHA256 [32]byte +} + +func readConfigFileFingerprint(path string) (configFileFingerprint, error) { + info, err := os.Stat(path) + if err != nil { + return configFileFingerprint{}, err + } + content, err := os.ReadFile(path) + if err != nil { + return configFileFingerprint{}, err + } + return configFileFingerprint{ + Size: info.Size(), + ModUnixNano: info.ModTime().UnixNano(), + SHA256: sha256.Sum256(content), + }, nil +} + +func (f configFileFingerprint) sameContent(other configFileFingerprint) bool { + return f.Size == other.Size && f.SHA256 == other.SHA256 +} + +func startGatewayConfigWatcher(ctx context.Context, configPath string, debounce, pollInterval time.Duration, onContentChanged func() error) func() { + if debounce <= 0 { + debounce = 500 * time.Millisecond + } + if pollInterval <= 0 { + pollInterval = 250 * time.Millisecond + } + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + last, err := readConfigFileFingerprint(configPath) + haveLast := err == nil + pending := false + lastDetectedAt := time.Time{} + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + current, err := readConfigFileFingerprint(configPath) + if err != nil { + continue + } + if !haveLast { + last = current + haveLast = true + continue + } + if !current.sameContent(last) { + last = current + pending = true + lastDetectedAt = time.Now() + continue + } + if pending && !lastDetectedAt.IsZero() && time.Since(lastDetectedAt) >= debounce { + pending = false + if onContentChanged != nil { + if err := onContentChanged(); err != nil { + fmt.Printf("Config watcher reload failed: %v\n", err) + } + } + } + } + } + }() + return func() { + select { + case <-done: + case <-time.After(2 * time.Second): + } + } +} + func applyMaximumPermissionPolicy(cfg *config.Config) { cfg.Tools.Shell.Enabled = true cfg.Tools.Shell.Sandbox.Enabled = false @@ -1123,69 +1180,3 @@ func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbea return "queued", nil }, hbInterval, cfg.Agents.Defaults.Heartbeat.Enabled, cfg.Agents.Defaults.Heartbeat.PromptTemplate) } - -func setupEmbeddedWhatsAppBridge(ctx context.Context, cfg *config.Config) (*channels.WhatsAppBridgeService, bool) { - if !shouldStartEmbeddedWhatsAppBridge(cfg) { - return nil, false - } - cfg.Channels.WhatsApp.BridgeURL = embeddedWhatsAppBridgeURL(cfg) - stateDir := filepath.Join(filepath.Dir(getConfigPath()), "channels", "whatsapp") - svc := channels.NewWhatsAppBridgeService(fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port), stateDir, false) - if err := svc.StartEmbedded(ctx); err != nil { - fmt.Printf("Error starting embedded WhatsApp bridge: %v\n", err) - return nil, false - } - return svc, true -} - -func shouldStartEmbeddedWhatsAppBridge(cfg *config.Config) bool { - return cfg != nil && shouldEmbedWhatsAppBridge(cfg) -} - -func shouldEmbedWhatsAppBridge(cfg *config.Config) bool { - raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) - if raw == "" { - return true - } - hostPort := comparableBridgeHostPort(raw) - if hostPort == "" { - return false - } - if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" { - return true - } - return hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) -} - -func embeddedWhatsAppBridgeURL(cfg *config.Config) string { - host := strings.TrimSpace(cfg.Gateway.Host) - switch host { - case "", "0.0.0.0", "::", "[::]": - host = "127.0.0.1" - } - return fmt.Sprintf("ws://%s:%d%s/ws", host, cfg.Gateway.Port, embeddedWhatsAppBridgeBasePath) -} - -func comparableBridgeHostPort(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if !strings.Contains(raw, "://") { - return strings.ToLower(raw) - } - u, err := url.Parse(raw) - if err != nil { - return "" - } - return strings.ToLower(strings.TrimSpace(u.Host)) -} - -func comparableGatewayHostPort(host string, port int) string { - host = strings.TrimSpace(strings.ToLower(host)) - switch host { - case "", "0.0.0.0", "::", "[::]": - host = "127.0.0.1" - } - return fmt.Sprintf("%s:%d", host, port) -} diff --git a/cmd/cmd_gateway_test.go b/cmd/cmd_gateway_test.go index b313c91..123ca5a 100644 --- a/cmd/cmd_gateway_test.go +++ b/cmd/cmd_gateway_test.go @@ -1,32 +1,117 @@ package main import ( + "context" + "os" + "path/filepath" + "sync/atomic" "testing" - - "github.com/YspCoder/clawgo/pkg/config" + "time" ) -func TestShouldStartEmbeddedWhatsAppBridge(t *testing.T) { +func TestConfigFileFingerprintSameContentIgnoresTouch(t *testing.T) { t.Parallel() - cfg := config.DefaultConfig() - cfg.Channels.WhatsApp.Enabled = false - cfg.Channels.WhatsApp.BridgeURL = "" - if !shouldStartEmbeddedWhatsAppBridge(cfg) { - t.Fatalf("expected embedded bridge to start when using default embedded url") + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + first, err := readConfigFileFingerprint(path) + if err != nil { + t.Fatalf("read first fingerprint: %v", err) } - cfg.Channels.WhatsApp.BridgeURL = "ws://127.0.0.1:3001" - if !shouldStartEmbeddedWhatsAppBridge(cfg) { - t.Fatalf("expected embedded bridge to start for legacy local bridge url") + target := time.Now().Add(2 * time.Second) + if err := os.Chtimes(path, target, target); err != nil { + t.Fatalf("touch file: %v", err) + } + second, err := readConfigFileFingerprint(path) + if err != nil { + t.Fatalf("read second fingerprint: %v", err) } - cfg.Channels.WhatsApp.BridgeURL = "ws://example.com:3001/ws" - if shouldStartEmbeddedWhatsAppBridge(cfg) { - t.Fatalf("expected external bridge url to disable embedded bridge") + if first.sameContent(second) != true { + t.Fatalf("expected touch to keep content fingerprint unchanged") } - - if shouldStartEmbeddedWhatsAppBridge(nil) { - t.Fatalf("expected nil config to disable embedded bridge") + if first.ModUnixNano == second.ModUnixNano { + t.Fatalf("expected touch to change mod time") + } +} + +func TestGatewayConfigWatcherReloadOnContentChangeWithDebounce(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var reloadCalls atomic.Int32 + stop := startGatewayConfigWatcher(ctx, path, 150*time.Millisecond, 30*time.Millisecond, func() error { + reloadCalls.Add(1) + return nil + }) + defer stop() + + time.Sleep(120 * time.Millisecond) + if err := os.WriteFile(path, []byte(`{"a":2}`), 0644); err != nil { + t.Fatalf("write changed file #1: %v", err) + } + time.Sleep(40 * time.Millisecond) + if err := os.WriteFile(path, []byte(`{"a":3}`), 0644); err != nil { + t.Fatalf("write changed file #2: %v", err) + } + time.Sleep(40 * time.Millisecond) + if err := os.WriteFile(path, []byte(`{"a":4}`), 0644); err != nil { + t.Fatalf("write changed file #3: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for reloadCalls.Load() < 1 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if got := reloadCalls.Load(); got != 1 { + t.Fatalf("expected exactly one reload after debounced content changes, got %d", got) + } + + time.Sleep(300 * time.Millisecond) + if got := reloadCalls.Load(); got != 1 { + t.Fatalf("expected no extra reload after debounce settles, got %d", got) + } +} + +func TestGatewayConfigWatcherTouchDoesNotReload(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(`{"a":1}`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var reloadCalls atomic.Int32 + stop := startGatewayConfigWatcher(ctx, path, 120*time.Millisecond, 30*time.Millisecond, func() error { + reloadCalls.Add(1) + return nil + }) + defer stop() + + time.Sleep(120 * time.Millisecond) + target := time.Now().Add(2 * time.Second) + if err := os.Chtimes(path, target, target); err != nil { + t.Fatalf("touch file: %v", err) + } + + time.Sleep(400 * time.Millisecond) + if got := reloadCalls.Load(); got != 0 { + t.Fatalf("expected touch-only update to skip reload, got %d", got) } } diff --git a/cmd/cmd_tui_stub.go b/cmd/cmd_tui_stub.go index d65b1cd..952d7c8 100644 --- a/cmd/cmd_tui_stub.go +++ b/cmd/cmd_tui_stub.go @@ -8,5 +8,5 @@ var tuiEnabled = false func tuiCmd() { fmt.Println("TUI is not included in this build.") - fmt.Println("Install the no-channel variant to use `clawgo tui`.") + fmt.Println("Build with `with_tui` tag to use `clawgo tui`.") } diff --git a/config.example.json b/config.example.json index 063326c..018b750 100644 --- a/config.example.json +++ b/config.example.json @@ -161,22 +161,6 @@ "enable_groups": true, "require_mention_in_groups": true }, - "discord": { - "enabled": false, - "token": "YOUR_DISCORD_BOT_TOKEN", - "allow_from": [] - }, - "maixcam": { - "enabled": false, - "host": "0.0.0.0", - "port": 18790, - "allow_from": [] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "allow_from": [] - }, "feishu": { "enabled": false, "app_id": "", @@ -187,12 +171,6 @@ "allow_chats": [], "enable_groups": true, "require_mention_in_groups": true - }, - "dingtalk": { - "enabled": false, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] } }, "models": { diff --git a/docs/assets/readme-agents.png b/docs/assets/readme-agents.png deleted file mode 100644 index ecdb505..0000000 Binary files a/docs/assets/readme-agents.png and /dev/null differ diff --git a/docs/assets/readme-config.png b/docs/assets/readme-config.png deleted file mode 100644 index d4013dc..0000000 Binary files a/docs/assets/readme-config.png and /dev/null differ diff --git a/docs/assets/readme-dashboard.png b/docs/assets/readme-dashboard.png deleted file mode 100644 index 0b40de7..0000000 Binary files a/docs/assets/readme-dashboard.png and /dev/null differ diff --git a/go.mod b/go.mod index 3072398..7918c21 100644 --- a/go.mod +++ b/go.mod @@ -3,35 +3,30 @@ module github.com/YspCoder/clawgo go 1.25.7 require ( - github.com/bwmarrin/discordgo v0.29.0 + github.com/andybalholm/brotli v1.2.0 github.com/caarlos0/env/v11 v11.4.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/chzyer/readline v1.5.1 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/klauspost/compress v1.18.4 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/mymmrac/telego v1.7.0 - github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 - github.com/pion/webrtc/v4 v4.2.9 + github.com/refraction-networking/utls v1.8.2 github.com/robfig/cron/v3 v3.0.1 - github.com/tencent-connect/botgo v0.2.1 - go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 + golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/time v0.15.0 - google.golang.org/protobuf v1.36.11 - modernc.org/sqlite v1.46.1 rsc.io/qr v0.2.0 ) require ( - filippo.io/edwards25519 v1.2.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/beeper/argo-go v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -43,65 +38,27 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/coder/websocket v1.8.14 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/uuid v1.6.0 // 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 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect - github.com/pion/datachannel v1.6.0 // indirect - github.com/pion/dtls/v3 v3.1.2 // indirect - github.com/pion/ice/v4 v4.2.1 // indirect - github.com/pion/interceptor v0.1.44 // indirect - github.com/pion/logging v0.2.4 // indirect - github.com/pion/mdns/v2 v2.1.0 // indirect - github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/rtp v1.10.1 // indirect - github.com/pion/sctp v1.9.2 // indirect - github.com/pion/sdp/v3 v3.0.18 // indirect - github.com/pion/srtp/v3 v3.0.10 // indirect - github.com/pion/stun/v3 v3.1.1 // indirect - github.com/pion/transport/v4 v4.0.1 // indirect - github.com/pion/turn/v4 v4.1.4 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.2.0 // indirect - github.com/tidwall/pretty v1.2.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/vektah/gqlparser/v2 v2.5.32 // indirect - github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.6 // indirect golang.org/x/arch v0.25.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect - modernc.org/libc v1.70.0 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index d863cc6..c4f4acd 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,9 @@ -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -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/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= -github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= -github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= -github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -25,8 +12,6 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -55,90 +40,36 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -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/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -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/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.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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/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.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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -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/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -149,96 +80,24 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= -github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= -github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= -github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= -github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo= -github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY= -github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c= -github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I= -github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= -github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= -github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= -github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= -github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= -github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= -github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= -github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= -github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= -github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= -github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= -github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= -github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= -github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE= -github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -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/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= -github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= -github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= -github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -247,23 +106,12 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= -github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= -github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= -go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= -go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= -go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4 h1:FGA3NtCVNeCJ+C+KBg1pODsrfxC/trM3RHFWIeY7y4c= -go.mau.fi/whatsmeow v0.0.0-20260305215846-fc65416c22c4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= @@ -271,84 +119,37 @@ golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -356,66 +157,14 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= -modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= -modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= -modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/install.sh b/install.sh index 34567f6..bfefc77 100755 --- a/install.sh +++ b/install.sh @@ -5,9 +5,6 @@ OWNER="YspCoder" REPO="clawgo" BIN="clawgo" INSTALL_DIR="/usr/local/bin" -VARIANT="${CLAWGO_CHANNEL_VARIANT:-full}" -VARIANT_EXPLICIT=0 -CHANNEL_VARIANTS=(full none telegram discord feishu maixcam qq dingtalk whatsapp) CONFIG_DIR="$HOME/.clawgo" CONFIG_PATH="$CONFIG_DIR/config.json" WORKSPACE_DIR="$HOME/.clawgo/workspace" @@ -15,13 +12,10 @@ LEGACY_WORKSPACE_DIR="$HOME/.openclaw/workspace" usage() { cat <= 300 { - body, _ := io.ReadAll(resp.Body) - return map[string]interface{}{ - "ok": false, - "enabled": waCfg.Enabled, - "bridge_url": bridgeURL, - "bridge_running": false, - "error": strings.TrimSpace(string(body)), - }, http.StatusOK - } - var status channels.WhatsAppBridgeStatus - if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return map[string]interface{}{ - "ok": false, - "enabled": waCfg.Enabled, - "bridge_url": bridgeURL, - "bridge_running": false, - "error": err.Error(), - }, http.StatusOK - } - return map[string]interface{}{ - "ok": true, - "enabled": waCfg.Enabled, - "bridge_url": bridgeURL, - "bridge_running": true, - "status": map[string]interface{}{ - "state": status.State, - "connected": status.Connected, - "logged_in": status.LoggedIn, - "bridge_addr": status.BridgeAddr, - "user_jid": status.UserJID, - "push_name": status.PushName, - "platform": status.Platform, - "qr_available": status.QRAvailable, - "qr_code": status.QRCode, - "last_event": status.LastEvent, - "last_error": status.LastError, - "updated_at": status.UpdatedAt, - }, - }, http.StatusOK -} - func (s *Server) handleWebUIWeixinStatus(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -1562,60 +1438,6 @@ func (s *Server) loadConfig() (*cfgpkg.Config, error) { return cfg, nil } -func (s *Server) resolveWhatsAppBridgeURL(cfg *cfgpkg.Config) string { - if cfg == nil { - return "" - } - raw := strings.TrimSpace(cfg.Channels.WhatsApp.BridgeURL) - if raw == "" { - return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) - } - hostPort := comparableBridgeHostPort(raw) - if hostPort == "" { - return raw - } - if hostPort == "127.0.0.1:3001" || hostPort == "localhost:3001" { - return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) - } - if hostPort == comparableGatewayHostPort(cfg.Gateway.Host, cfg.Gateway.Port) { - return embeddedWhatsAppBridgeURL(cfg.Gateway.Host, cfg.Gateway.Port) - } - return raw -} - -func embeddedWhatsAppBridgeURL(host string, port int) string { - host = strings.TrimSpace(host) - switch host { - case "", "0.0.0.0", "::", "[::]": - host = "127.0.0.1" - } - return fmt.Sprintf("ws://%s:%d/whatsapp/ws", host, port) -} - -func comparableBridgeHostPort(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if !strings.Contains(raw, "://") { - return strings.ToLower(raw) - } - u, err := url.Parse(raw) - if err != nil { - return "" - } - return strings.ToLower(strings.TrimSpace(u.Host)) -} - -func comparableGatewayHostPort(host string, port int) string { - host = strings.TrimSpace(strings.ToLower(host)) - switch host { - case "", "0.0.0.0", "::", "[::]": - host = "127.0.0.1" - } - return fmt.Sprintf("%s:%d", host, port) -} - func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string { if code == nil || code.Size <= 0 { return "" @@ -1898,7 +1720,7 @@ type mcpInstallSpec struct { func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec { if pkgName := strings.TrimSpace(server.Package); pkgName != "" { - return mcpInstallSpec{Installer: "npm", Package: pkgName, AutoInstallSupported: true} + return mcpInstallSpec{Package: pkgName, AutoInstallSupported: false} } command := strings.TrimSpace(server.Command) args := make([]string, 0, len(server.Args)) @@ -1910,7 +1732,7 @@ func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec { base := filepath.Base(command) switch base { case "npx": - return mcpInstallSpec{Installer: "npm", Package: firstNonFlagArg(args), AutoInstallSupported: firstNonFlagArg(args) != ""} + return mcpInstallSpec{Package: firstNonFlagArg(args), AutoInstallSupported: false} case "uvx": pkgName := firstNonFlagArg(args) return mcpInstallSpec{Installer: "uv", Package: pkgName, AutoInstallSupported: pkgName != ""} @@ -2250,20 +2072,6 @@ func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) { return } action := strings.ToLower(stringFromMap(body, "action")) - if action == "install_clawhub" { - output, err := ensureClawHubReady(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "output": output, - "installed": true, - "clawhub_path": resolveClawHubBinary(r.Context()), - }) - return - } id := stringFromMap(body, "id") name := stringFromMap(body, "name") if strings.TrimSpace(name) == "" { @@ -2639,17 +2447,9 @@ func resolveClawHubBinary(ctx context.Context) string { if p, err := exec.LookPath("clawhub"); err == nil { return p } - prefix := strings.TrimSpace(npmGlobalPrefix(ctx)) - if prefix != "" { - cand := filepath.Join(prefix, "bin", "clawhub") - if st, err := os.Stat(cand); err == nil && !st.IsDir() { - return cand - } - } cands := []string{ "/usr/local/bin/clawhub", "/opt/homebrew/bin/clawhub", - filepath.Join(os.Getenv("HOME"), ".npm-global", "bin", "clawhub"), } for _, cand := range cands { if st, err := os.Stat(cand); err == nil && !st.IsDir() { @@ -2659,16 +2459,6 @@ func resolveClawHubBinary(ctx context.Context) string { return "" } -func npmGlobalPrefix(ctx context.Context) string { - cctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - out, err := exec.CommandContext(cctx, "npm", "config", "get", "prefix").Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - func runInstallCommand(ctx context.Context, cmdline string) (string, error) { cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() @@ -2684,144 +2474,11 @@ func runInstallCommand(ctx context.Context, cmdline string) (string, error) { return msg, nil } -func ensureNodeRuntime(ctx context.Context) (string, error) { - if nodePath, err := exec.LookPath("node"); err == nil { - if _, err := exec.LookPath("npm"); err == nil { - if major, verr := detectNodeMajor(ctx, nodePath); verr == nil && major == 22 { - return "node@22 and npm already installed", nil - } - } - } - - var output []string - switch runtime.GOOS { - case "darwin": - if _, err := exec.LookPath("brew"); err != nil { - return strings.Join(output, "\n"), fmt.Errorf("nodejs/npm missing and Homebrew not found; please install Homebrew then retry") - } - out, err := runInstallCommand(ctx, "brew install node@22 && brew link --overwrite --force node@22") - if out != "" { - output = append(output, out) - } - if err != nil { - return strings.Join(output, "\n"), err - } - case "linux": - var out string - var err error - switch { - case commandExists("apt-get"): - if commandExists("curl") { - out, err = runInstallCommand(ctx, "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs") - } else if commandExists("wget") { - out, err = runInstallCommand(ctx, "wget -qO- https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs") - } else { - err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x") - } - case commandExists("dnf"): - if commandExists("curl") { - out, err = runInstallCommand(ctx, "curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && dnf install -y nodejs") - } else if commandExists("wget") { - out, err = runInstallCommand(ctx, "wget -qO- https://rpm.nodesource.com/setup_22.x | bash - && dnf install -y nodejs") - } else { - err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x") - } - case commandExists("yum"): - if commandExists("curl") { - out, err = runInstallCommand(ctx, "curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && yum install -y nodejs") - } else if commandExists("wget") { - out, err = runInstallCommand(ctx, "wget -qO- https://rpm.nodesource.com/setup_22.x | bash - && yum install -y nodejs") - } else { - err = fmt.Errorf("missing curl/wget required for NodeSource setup_22.x") - } - case commandExists("pacman"): - out, err = runInstallCommand(ctx, "pacman -Sy --noconfirm nodejs npm") - case commandExists("apk"): - out, err = runInstallCommand(ctx, "apk add --no-cache nodejs npm") - default: - return strings.Join(output, "\n"), fmt.Errorf("nodejs/npm missing and no supported package manager found") - } - if out != "" { - output = append(output, out) - } - if err != nil { - return strings.Join(output, "\n"), err - } - default: - return strings.Join(output, "\n"), fmt.Errorf("unsupported OS for auto install: %s", runtime.GOOS) - } - - if _, err := exec.LookPath("node"); err != nil { - return strings.Join(output, "\n"), fmt.Errorf("node installation completed but `node` still not found in PATH") - } - if _, err := exec.LookPath("npm"); err != nil { - return strings.Join(output, "\n"), fmt.Errorf("node installation completed but `npm` still not found in PATH") - } - nodePath, _ := exec.LookPath("node") - major, err := detectNodeMajor(ctx, nodePath) - if err != nil { - return strings.Join(output, "\n"), fmt.Errorf("failed to detect node major version: %w", err) - } - if major != 22 { - return strings.Join(output, "\n"), fmt.Errorf("node version is %d, expected 22", major) - } - output = append(output, "node@22/npm installed") - return strings.Join(output, "\n"), nil -} - func commandExists(name string) bool { _, err := exec.LookPath(name) return err == nil } -func detectNodeMajor(ctx context.Context, nodePath string) (int, error) { - nodePath = strings.TrimSpace(nodePath) - if nodePath == "" { - return 0, fmt.Errorf("node path empty") - } - cctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - out, err := exec.CommandContext(cctx, nodePath, "-p", "process.versions.node.split('.')[0]").Output() - if err != nil { - return 0, err - } - majorStr := strings.TrimSpace(string(out)) - if majorStr == "" { - return 0, fmt.Errorf("empty node major version") - } - v, err := strconv.Atoi(majorStr) - if err != nil { - return 0, err - } - return v, nil -} - -func ensureClawHubReady(ctx context.Context) (string, error) { - outs := make([]string, 0, 4) - if p := resolveClawHubBinary(ctx); p != "" { - return "clawhub already installed at: " + p, nil - } - nodeOut, err := ensureNodeRuntime(ctx) - if nodeOut != "" { - outs = append(outs, nodeOut) - } - if err != nil { - return strings.Join(outs, "\n"), err - } - clawOut, err := runInstallCommand(ctx, "npm i -g clawhub") - if clawOut != "" { - outs = append(outs, clawOut) - } - if err != nil { - return strings.Join(outs, "\n"), err - } - if p := resolveClawHubBinary(ctx); p != "" { - outs = append(outs, "clawhub installed at: "+p) - return strings.Join(outs, "\n"), nil - } - return strings.Join(outs, "\n"), fmt.Errorf("installed clawhub but executable still not found in PATH") -} - func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) { pkgName = strings.TrimSpace(pkgName) if pkgName == "" { @@ -2829,29 +2486,10 @@ func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, instal } installer = strings.ToLower(strings.TrimSpace(installer)) if installer == "" { - installer = "npm" + installer = "uv" } outs := make([]string, 0, 4) switch installer { - case "npm": - nodeOut, err := ensureNodeRuntime(ctx) - if nodeOut != "" { - outs = append(outs, nodeOut) - } - if err != nil { - return strings.Join(outs, "\n"), "", "", err - } - installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName)) - if installOut != "" { - outs = append(outs, installOut) - } - if err != nil { - return strings.Join(outs, "\n"), "", "", err - } - binName, err = resolveNpmPackageBin(ctx, pkgName) - if err != nil { - return strings.Join(outs, "\n"), "", "", err - } case "uv": if !commandExists("uv") { return "", "", "", fmt.Errorf("uv is not installed; install uv first to auto-install %s", pkgName) @@ -2897,35 +2535,8 @@ func guessSimpleCommandName(pkgName string) string { return strings.TrimSpace(pkgName) } -func resolveNpmPackageBin(ctx context.Context, pkgName string) (string, error) { - cctx, cancel := context.WithTimeout(ctx, 15*time.Second) - defer cancel() - cmd := exec.CommandContext(cctx, "npm", "view", pkgName, "bin", "--json") - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to query npm bin for %s: %w", pkgName, err) - } - trimmed := strings.TrimSpace(string(out)) - if trimmed == "" || trimmed == "null" { - return "", fmt.Errorf("npm package %s does not expose a bin", pkgName) - } - var obj map[string]interface{} - if err := json.Unmarshal(out, &obj); err == nil && len(obj) > 0 { - keys := make([]string, 0, len(obj)) - for key := range obj { - keys = append(keys, key) - } - sort.Strings(keys) - return keys[0], nil - } - var text string - if err := json.Unmarshal(out, &text); err == nil && strings.TrimSpace(text) != "" { - return strings.TrimSpace(text), nil - } - return "", fmt.Errorf("unable to resolve bin for npm package %s", pkgName) -} - func resolveInstalledBinary(ctx context.Context, binName string) string { + _ = ctx binName = strings.TrimSpace(binName) if binName == "" { return "" @@ -2933,17 +2544,9 @@ func resolveInstalledBinary(ctx context.Context, binName string) string { if p, err := exec.LookPath(binName); err == nil { return p } - prefix := strings.TrimSpace(npmGlobalPrefix(ctx)) - if prefix != "" { - cand := filepath.Join(prefix, "bin", binName) - if st, err := os.Stat(cand); err == nil && !st.IsDir() { - return cand - } - } cands := []string{ filepath.Join("/usr/local/bin", binName), filepath.Join("/opt/homebrew/bin", binName), - filepath.Join(os.Getenv("HOME"), ".npm-global", "bin", binName), } for _, cand := range cands { if st, err := os.Stat(cand); err == nil && !st.IsDir() { @@ -3341,8 +2944,6 @@ func isUserFacingSessionKey(key string) bool { return false case strings.HasPrefix(k, "hook:"): return false - case strings.HasPrefix(k, "node:"): - return false default: return true } @@ -3610,7 +3211,7 @@ func hotReloadFieldInfo() []map[string]interface{} { {"path": "agents.*", "name": "Agent", "description": "Models, policies, and default behavior"}, {"path": "models.providers.*", "name": "Providers", "description": "LLM provider registry and auth settings"}, {"path": "tools.*", "name": "Tools", "description": "Tool toggles and runtime options"}, - {"path": "channels.*", "name": "Channels", "description": "Telegram and other channel settings"}, + {"path": "channels.*", "name": "Channels", "description": "Weixin, Feishu, and Telegram channel settings"}, {"path": "cron.*", "name": "Cron", "description": "Global cron runtime settings"}, {"path": "agents.defaults.heartbeat.*", "name": "Heartbeat", "description": "Heartbeat interval and prompt template"}, {"path": "gateway.*", "name": "Gateway", "description": "Mostly hot-reloadable; host/port may require restart"}, diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index 372d85e..e8191e0 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -3,14 +3,11 @@ package api import ( "context" "encoding/json" - "net" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "runtime" - "strconv" "strings" "testing" "time" @@ -19,73 +16,6 @@ import ( "github.com/gorilla/websocket" ) -func TestHandleWebUIWhatsAppStatusMapsLegacyBridgeURLToEmbeddedPath(t *testing.T) { - t.Parallel() - - bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/whatsapp/status": - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "state": "connected", - "connected": true, - "logged_in": true, - "bridge_addr": "127.0.0.1:7788", - "user_jid": "8613012345678@s.whatsapp.net", - "qr_available": false, - "last_event": "connected", - "updated_at": "2026-03-09T12:00:00+08:00", - }) - default: - http.NotFound(w, r) - } - })) - defer bridge.Close() - - u, err := url.Parse(bridge.URL) - if err != nil { - t.Fatalf("parse bridge url: %v", err) - } - host, portRaw, err := net.SplitHostPort(u.Host) - if err != nil { - t.Fatalf("split host port: %v", err) - } - port, err := strconv.Atoi(portRaw) - if err != nil { - t.Fatalf("atoi port: %v", err) - } - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.json") - cfg := cfgpkg.DefaultConfig() - cfg.Logging.Enabled = false - cfg.Gateway.Host = host - cfg.Gateway.Port = port - cfg.Channels.WhatsApp.Enabled = true - cfg.Channels.WhatsApp.BridgeURL = "ws://localhost:3001" - if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - srv := NewServer("127.0.0.1", 0, "") - srv.SetConfigPath(cfgPath) - - req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil) - rec := httptest.NewRecorder() - srv.handleWebUIWhatsAppStatus(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - var payload map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { - t.Fatalf("unmarshal payload: %v", err) - } - bridgeURL, _ := payload["bridge_url"].(string) - if !strings.HasSuffix(bridgeURL, "/whatsapp/ws") { - t.Fatalf("expected embedded whatsapp bridge url, got: %s", rec.Body.String()) - } -} - func TestHandleWebUIConfigPostSavesRawConfig(t *testing.T) { t.Parallel() @@ -131,6 +61,35 @@ func TestHandleWebUIConfigPostSavesRawConfig(t *testing.T) { } } +func TestHandleWebUIConfigPostReturnsErrorWhenReloadHookFails(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "config.json") + cfg := cfgpkg.DefaultConfig() + if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + + srv := NewServer("127.0.0.1", 0, "") + srv.SetConfigPath(cfgPath) + srv.SetConfigAfterHook(func(forceRuntimeReload bool) error { + return context.DeadlineExceeded + }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", strings.NewReader(`{"gateway":{"host":"127.0.0.1","port":7788,"token":"abc"},"logging":{"enabled":true,"dir":"logs","filename":"app.log","max_size_mb":20,"retention_days":3}}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleWebUIConfig(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when reload hook fails, got %d: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), context.DeadlineExceeded.Error()) { + t.Fatalf("expected response body to include reload hook error, got: %s", rec.Body.String()) + } +} + func TestHandleWebUIConfigPostSavesNormalizedConfig(t *testing.T) { t.Parallel() diff --git a/pkg/channels/channel_disabled.go b/pkg/channels/channel_disabled.go deleted file mode 100644 index ac87077..0000000 --- a/pkg/channels/channel_disabled.go +++ /dev/null @@ -1,44 +0,0 @@ -package channels - -import ( - "context" - "fmt" - - "github.com/YspCoder/clawgo/pkg/bus" -) - -func errChannelDisabled(name string) error { - return fmt.Errorf("%s channel is disabled at build time", name) -} - -type disabledChannel struct { - name string -} - -func (c disabledChannel) Name() string { - return c.name -} - -func (c disabledChannel) Start(ctx context.Context) error { - return errChannelDisabled(c.name) -} - -func (c disabledChannel) Stop(ctx context.Context) error { - return nil -} - -func (c disabledChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errChannelDisabled(c.name) -} - -func (c disabledChannel) IsRunning() bool { - return false -} - -func (c disabledChannel) IsAllowed(senderID string) bool { - return false -} - -func (c disabledChannel) HealthCheck(ctx context.Context) error { - return errChannelDisabled(c.name) -} diff --git a/pkg/channels/compiled_channels.go b/pkg/channels/compiled_channels.go index 02506bd..2b83b6d 100644 --- a/pkg/channels/compiled_channels.go +++ b/pkg/channels/compiled_channels.go @@ -3,31 +3,7 @@ package channels import "sort" func CompiledChannelKeys() []string { - out := make([]string, 0, 8) - if weixinCompiled { - out = append(out, "weixin") - } - if telegramCompiled { - out = append(out, "telegram") - } - if whatsappCompiled { - out = append(out, "whatsapp") - } - if discordCompiled { - out = append(out, "discord") - } - if feishuCompiled { - out = append(out, "feishu") - } - if qqCompiled { - out = append(out, "qq") - } - if dingtalkCompiled { - out = append(out, "dingtalk") - } - if maixcamCompiled { - out = append(out, "maixcam") - } + out := []string{"weixin", "telegram", "feishu"} sort.Strings(out) return out } diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go deleted file mode 100644 index 9241670..0000000 --- a/pkg/channels/dingtalk.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build !omit_dingtalk - -// ClawGo - Ultra-lightweight personal AI agent -// DingTalk channel implementation using Stream Mode - -package channels - -import ( - "context" - "fmt" - "sync" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/logger" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" -) - -// DingTalkChannel implements the Channel interface for DingTalk. -// It uses WebSocket for receiving messages via stream mode and API for sending -type DingTalkChannel struct { - *BaseChannel - config config.DingTalkConfig - clientID string - clientSecret string - streamClient *client.StreamClient - runCancel cancelGuard - // Map to store session webhooks for each chat - sessionWebhooks sync.Map // chatID -> sessionWebhook -} - -const dingtalkCompiled = true - -// NewDingTalkChannel creates a new DingTalk channel instance -func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { - if cfg.ClientID == "" || cfg.ClientSecret == "" { - return nil, fmt.Errorf("dingtalk client_id and client_secret are required") - } - - base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom) - - return &DingTalkChannel{ - BaseChannel: base, - config: cfg, - clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret, - }, nil -} - -// Start initializes the DingTalk channel with Stream Mode -func (c *DingTalkChannel) Start(ctx context.Context) error { - if c.IsRunning() { - return nil - } - logger.InfoC("dingtalk", logger.C0115) - - // The upstream SDK has a race in StreamClient.Close() that can panic with - // "send on closed channel". Keep the existing websocket alive across local - // stop/start cycles instead of tearing it down here. - if c.streamClient != nil { - c.streamClient.AutoReconnect = true - c.setRunning(true) - logger.InfoC("dingtalk", logger.C0116) - return nil - } - - runCtx, cancel := context.WithCancel(ctx) - c.runCancel.set(cancel) - - // Create credential config - cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) - - // Create the stream client with options - c.streamClient = client.NewStreamClient( - client.WithAppCredential(cred), - client.WithAutoReconnect(true), - ) - - // Register chatbot callback handler (IChatBotMessageHandler is a function type) - c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) - - // Start the stream client - if err := c.streamClient.Start(runCtx); err != nil { - return fmt.Errorf("failed to start stream client: %w", err) - } - - c.setRunning(true) - logger.InfoC("dingtalk", logger.C0116) - return nil -} - -// Stop gracefully stops the DingTalk channel -func (c *DingTalkChannel) Stop(ctx context.Context) error { - if !c.IsRunning() { - return nil - } - logger.InfoC("dingtalk", logger.C0117) - - c.runCancel.cancelAndClear() - - if c.streamClient != nil { - // Avoid StreamClient.Close(): the SDK can panic internally during shutdown. - // Disabling auto-reconnect plus the running flag is enough for our local - // lifecycle, and callbacks below will ignore messages while stopped. - c.streamClient.AutoReconnect = false - } - - c.setRunning(false) - logger.InfoC("dingtalk", logger.C0118) - return nil -} - -// Send sends a message to DingTalk via the chatbot reply API -func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("dingtalk channel not running") - } - - // Get session webhook from storage - sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) - if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) - } - - sessionWebhook, ok := sessionWebhookRaw.(string) - if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) - } - - logger.InfoCF("dingtalk", logger.C0119, map[string]interface{}{ - logger.FieldChatID: msg.ChatID, - logger.FieldPreview: truncateString(msg.Content, 100), - "platform": "dingtalk", - }) - - // Use the session webhook to send the reply - return c.SendDirectReply(sessionWebhook, msg.Content) -} - -// onChatBotMessageReceived implements the IChatBotMessageHandler function signature -// This is called by the Stream SDK when a new message arrives -// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) -func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { - if !c.IsRunning() { - return nil, nil - } - - // Extract message content from Text field - content := data.Text.Content - if content == "" { - // Try to extract from Content interface{} if Text is empty - if contentMap, ok := data.Content.(map[string]interface{}); ok { - if textContent, ok := contentMap["content"].(string); ok { - content = textContent - } - } - } - - if content == "" { - return nil, nil // Ignore empty messages - } - - senderID := data.SenderStaffId - senderNick := data.SenderNick - chatID := senderID - if data.ConversationType != "1" { - // For group chats - chatID = data.ConversationId - } - - // Store the session webhook for this chat so we can reply later - c.sessionWebhooks.Store(chatID, data.SessionWebhook) - - metadata := map[string]string{ - "sender_name": senderNick, - "conversation_id": data.ConversationId, - "conversation_type": data.ConversationType, - "platform": "dingtalk", - "session_webhook": data.SessionWebhook, - } - - logger.InfoCF("dingtalk", logger.C0120, map[string]interface{}{ - "sender_name": senderNick, - logger.FieldSenderID: senderID, - logger.FieldChatID: chatID, - logger.FieldPreview: truncateString(content, 50), - }) - - // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) - - // Return nil to indicate we've handled the message asynchronously - // The response will be sent through the message bus - return nil, nil -} - -// SendDirectReply sends a direct reply using the session webhook -func (c *DingTalkChannel) SendDirectReply(sessionWebhook, content string) error { - replier := chatbot.NewChatbotReplier() - - // Convert string content to []byte for the API - contentBytes := []byte(content) - titleBytes := []byte("ClawGo") - - // Send markdown formatted reply - err := replier.SimpleReplyMarkdown( - context.Background(), - sessionWebhook, - titleBytes, - contentBytes, - ) - - if err != nil { - return fmt.Errorf("failed to send reply: %w", err) - } - - return nil -} diff --git a/pkg/channels/dingtalk_stub.go b/pkg/channels/dingtalk_stub.go deleted file mode 100644 index f11b518..0000000 --- a/pkg/channels/dingtalk_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_dingtalk - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type DingTalkChannel struct{ disabledChannel } - -const dingtalkCompiled = false - -func NewDingTalkChannel(cfg config.DingTalkConfig, bus *bus.MessageBus) (*DingTalkChannel, error) { - return nil, errChannelDisabled("dingtalk") -} diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go deleted file mode 100644 index d22e537..0000000 --- a/pkg/channels/discord.go +++ /dev/null @@ -1,238 +0,0 @@ -//go:build !omit_discord - -package channels - -import ( - "context" - "fmt" - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/logger" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/bwmarrin/discordgo" -) - -type DiscordChannel struct { - *BaseChannel - session *discordgo.Session - config config.DiscordConfig -} - -const discordCompiled = true - -func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { - session, err := discordgo.New("Bot " + cfg.Token) - if err != nil { - return nil, fmt.Errorf("failed to create discord session: %w", err) - } - - base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom) - - return &DiscordChannel{ - BaseChannel: base, - session: session, - config: cfg, - }, nil -} - -func (c *DiscordChannel) Start(ctx context.Context) error { - logger.InfoC("discord", logger.C0069) - - c.session.AddHandler(c.handleMessage) - - if err := c.session.Open(); err != nil { - return fmt.Errorf("failed to open discord session: %w", err) - } - - c.setRunning(true) - - botUser, err := c.session.User("@me") - if err != nil { - return fmt.Errorf("failed to get bot user: %w", err) - } - logger.InfoCF("discord", logger.C0070, map[string]interface{}{ - "username": botUser.Username, - "user_id": botUser.ID, - }) - - return nil -} - -func (c *DiscordChannel) Stop(ctx context.Context) error { - logger.InfoC("discord", logger.C0071) - c.setRunning(false) - - if err := c.session.Close(); err != nil { - return fmt.Errorf("failed to close discord session: %w", err) - } - - return nil -} - -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("discord bot not running") - } - - channelID := msg.ChatID - if channelID == "" { - return fmt.Errorf("channel ID is empty") - } - - message := msg.Content - - if _, err := c.session.ChannelMessageSend(channelID, message); err != nil { - return fmt.Errorf("failed to send discord message: %w", err) - } - - return nil -} - -func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) { - if m == nil || m.Author == nil { - return - } - - if m.Author.ID == s.State.User.ID { - return - } - - senderID := m.Author.ID - senderName := m.Author.Username - if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { - senderName += "#" + m.Author.Discriminator - } - - content := m.Content - mediaPaths := []string{} - - for _, attachment := range m.Attachments { - isAudio := isAudioFile(attachment.Filename, attachment.ContentType) - - if isAudio { - localPath := c.downloadAttachment(attachment.URL, attachment.Filename) - if localPath != "" { - mediaPaths = append(mediaPaths, localPath) - - transcribedText := fmt.Sprintf("[audio: %s]", localPath) - - if content != "" { - content += "\n" - } - content += transcribedText - } else { - mediaPaths = append(mediaPaths, attachment.URL) - if content != "" { - content += "\n" - } - content += fmt.Sprintf("[attachment: %s]", attachment.URL) - } - } else { - mediaPaths = append(mediaPaths, attachment.URL) - if content != "" { - content += "\n" - } - content += fmt.Sprintf("[attachment: %s]", attachment.URL) - } - } - - if content == "" && len(mediaPaths) == 0 { - return - } - - if content == "" { - content = "[media only]" - } - - logger.DebugCF("discord", logger.C0072, map[string]interface{}{ - "sender_name": senderName, - logger.FieldSenderID: senderID, - logger.FieldPreview: truncateString(content, 50), - }) - - metadata := map[string]string{ - "message_id": m.ID, - "user_id": senderID, - "username": m.Author.Username, - "display_name": senderName, - "guild_id": m.GuildID, - "channel_id": m.ChannelID, - "is_dm": fmt.Sprintf("%t", m.GuildID == ""), - } - - c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) -} - -func isAudioFile(filename, contentType string) bool { - audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} - audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"} - - for _, ext := range audioExtensions { - if strings.HasSuffix(strings.ToLower(filename), ext) { - return true - } - } - - for _, audioType := range audioTypes { - if strings.HasPrefix(strings.ToLower(contentType), audioType) { - return true - } - } - - return false -} - -func (c *DiscordChannel) downloadAttachment(url, filename string) string { - mediaDir := filepath.Join(os.TempDir(), "clawgo_media") - if err := os.MkdirAll(mediaDir, 0755); err != nil { - logger.WarnCF("discord", logger.C0073, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return "" - } - - localPath := filepath.Join(mediaDir, filename) - - resp, err := http.Get(url) - if err != nil { - logger.WarnCF("discord", logger.C0074, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return "" - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - logger.WarnCF("discord", logger.C0075, map[string]interface{}{ - "status_code": resp.StatusCode, - }) - return "" - } - - out, err := os.Create(localPath) - if err != nil { - logger.WarnCF("discord", logger.C0076, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return "" - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - if err != nil { - logger.WarnCF("discord", logger.C0077, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return "" - } - - logger.DebugCF("discord", logger.C0078, map[string]interface{}{ - "path": localPath, - }) - return localPath -} diff --git a/pkg/channels/discord_stub.go b/pkg/channels/discord_stub.go deleted file mode 100644 index b0f1d3a..0000000 --- a/pkg/channels/discord_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_discord - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type DiscordChannel struct{ disabledChannel } - -const discordCompiled = false - -func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { - return nil, errChannelDisabled("discord") -} diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu.go index e627fce..e1dfdff 100644 --- a/pkg/channels/feishu.go +++ b/pkg/channels/feishu.go @@ -1,5 +1,3 @@ -//go:build !omit_feishu - package channels import ( @@ -45,8 +43,6 @@ type FeishuChannel struct { tenantTokenErr error } -const feishuCompiled = true - func (c *FeishuChannel) SupportsAction(action string) bool { switch strings.ToLower(strings.TrimSpace(action)) { case "", "send": @@ -410,7 +406,6 @@ func (c *FeishuChannel) buildFeishuMediaOutbound(ctx context.Context, media stri return larkim.MsgTypeFile, string(b), nil } - func readFeishuMedia(media string) (string, []byte, error) { if strings.HasPrefix(media, "http://") || strings.HasPrefix(media, "https://") { req, err := http.NewRequest(http.MethodGet, media, nil) diff --git a/pkg/channels/feishu_stub.go b/pkg/channels/feishu_stub.go deleted file mode 100644 index 29e7c0f..0000000 --- a/pkg/channels/feishu_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_feishu - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type FeishuChannel struct{ disabledChannel } - -const feishuCompiled = false - -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - return nil, errChannelDisabled("feishu") -} diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go deleted file mode 100644 index 51cd211..0000000 --- a/pkg/channels/maixcam.go +++ /dev/null @@ -1,247 +0,0 @@ -//go:build !omit_maixcam - -package channels - -import ( - "context" - "encoding/json" - "fmt" - "net" - "sync" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/logger" -) - -type MaixCamChannel struct { - *BaseChannel - config config.MaixCamConfig - listener net.Listener - clients map[net.Conn]bool - clientsMux sync.RWMutex -} - -const maixcamCompiled = true - -type MaixCamMessage struct { - Type string `json:"type"` - Tips string `json:"tips"` - Timestamp float64 `json:"timestamp"` - Data map[string]interface{} `json:"data"` -} - -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) - - return &MaixCamChannel{ - BaseChannel: base, - config: cfg, - clients: make(map[net.Conn]bool), - }, nil -} - -func (c *MaixCamChannel) Start(ctx context.Context) error { - logger.InfoC("maixcam", logger.C0079) - - addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", addr, err) - } - - c.listener = listener - c.setRunning(true) - - logger.InfoCF("maixcam", logger.C0080, map[string]interface{}{ - "host": c.config.Host, - "port": c.config.Port, - }) - - go c.acceptConnections(ctx) - - return nil -} - -func (c *MaixCamChannel) acceptConnections(ctx context.Context) { - logger.DebugC("maixcam", logger.C0081) - - for { - select { - case <-ctx.Done(): - logger.InfoC("maixcam", logger.C0082) - return - default: - conn, err := c.listener.Accept() - if err != nil { - if c.IsRunning() { - logger.ErrorCF("maixcam", logger.C0083, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } - return - } - - logger.InfoCF("maixcam", logger.C0084, map[string]interface{}{ - "remote_addr": conn.RemoteAddr().String(), - }) - - c.clientsMux.Lock() - c.clients[conn] = true - c.clientsMux.Unlock() - - go c.handleConnection(conn, ctx) - } - } -} - -func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { - logger.DebugC("maixcam", logger.C0085) - - defer func() { - conn.Close() - c.clientsMux.Lock() - delete(c.clients, conn) - c.clientsMux.Unlock() - logger.DebugC("maixcam", logger.C0086) - }() - - decoder := json.NewDecoder(conn) - - for { - select { - case <-ctx.Done(): - return - default: - var msg MaixCamMessage - if err := decoder.Decode(&msg); err != nil { - if err.Error() != "EOF" { - logger.ErrorCF("maixcam", logger.C0087, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } - return - } - - c.processMessage(msg, conn) - } - } -} - -func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { - switch msg.Type { - case "person_detected": - c.handlePersonDetection(msg) - case "heartbeat": - logger.DebugC("maixcam", logger.C0088) - case "status": - c.handleStatusUpdate(msg) - default: - logger.WarnCF("maixcam", logger.C0089, map[string]interface{}{ - "message_type": msg.Type, - }) - } -} - -func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { - logger.InfoCF("maixcam", logger.C0090, map[string]interface{}{ - logger.FieldSenderID: "maixcam", - logger.FieldChatID: "default", - "timestamp": msg.Timestamp, - "data": msg.Data, - }) - - senderID := "maixcam" - chatID := "default" - - classInfo, ok := msg.Data["class_name"].(string) - if !ok { - classInfo = "person" - } - - score, _ := msg.Data["score"].(float64) - x, _ := msg.Data["x"].(float64) - y, _ := msg.Data["y"].(float64) - w, _ := msg.Data["w"].(float64) - h, _ := msg.Data["h"].(float64) - - content := fmt.Sprintf("Person detected.\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f", - classInfo, score*100, x, y, w, h) - - metadata := map[string]string{ - "timestamp": fmt.Sprintf("%.0f", msg.Timestamp), - "class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]), - "score": fmt.Sprintf("%.2f", score), - "x": fmt.Sprintf("%.0f", x), - "y": fmt.Sprintf("%.0f", y), - "w": fmt.Sprintf("%.0f", w), - "h": fmt.Sprintf("%.0f", h), - } - - c.HandleMessage(senderID, chatID, content, []string{}, metadata) -} - -func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { - logger.InfoCF("maixcam", logger.C0091, map[string]interface{}{ - "status": msg.Data, - }) -} - -func (c *MaixCamChannel) Stop(ctx context.Context) error { - logger.InfoC("maixcam", logger.C0092) - c.setRunning(false) - - if c.listener != nil { - c.listener.Close() - } - - c.clientsMux.Lock() - defer c.clientsMux.Unlock() - - for conn := range c.clients { - conn.Close() - } - c.clients = make(map[net.Conn]bool) - - logger.InfoC("maixcam", logger.C0093) - return nil -} - -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("maixcam channel not running") - } - - c.clientsMux.RLock() - defer c.clientsMux.RUnlock() - - if len(c.clients) == 0 { - logger.WarnC("maixcam", logger.C0094) - return fmt.Errorf("no connected MaixCam devices") - } - - response := map[string]interface{}{ - "type": "command", - "timestamp": float64(0), - "message": msg.Content, - logger.FieldChatID: msg.ChatID, - } - - data, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - - var sendErr error - for conn := range c.clients { - if _, err := conn.Write(data); err != nil { - logger.ErrorCF("maixcam", logger.C0095, map[string]interface{}{ - "client": conn.RemoteAddr().String(), - logger.FieldError: err.Error(), - }) - sendErr = err - } - } - - return sendErr -} diff --git a/pkg/channels/maixcam_stub.go b/pkg/channels/maixcam_stub.go deleted file mode 100644 index 97794d4..0000000 --- a/pkg/channels/maixcam_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_maixcam - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type MaixCamChannel struct{ disabledChannel } - -const maixcamCompiled = false - -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - return nil, errChannelDisabled("maixcam") -} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index e4af353..124cf8e 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -107,22 +107,6 @@ func (m *Manager) initChannels() error { } } - if m.config.Channels.WhatsApp.Enabled { - if m.config.Channels.WhatsApp.BridgeURL == "" { - logger.WarnC("channels", logger.C0009) - } else { - whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus) - if err != nil { - logger.ErrorCF("channels", logger.C0010, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } else { - m.channels["whatsapp"] = whatsapp - logger.InfoC("channels", logger.C0011) - } - } - } - if m.config.Channels.Feishu.Enabled { feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) if err != nil { @@ -135,62 +119,6 @@ func (m *Manager) initChannels() error { } } - if m.config.Channels.Discord.Enabled { - if m.config.Channels.Discord.Token == "" { - logger.WarnC("channels", logger.C0014) - } else { - discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus) - if err != nil { - logger.ErrorCF("channels", logger.C0015, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } else { - m.channels["discord"] = discord - logger.InfoC("channels", logger.C0016) - } - } - } - - if m.config.Channels.MaixCam.Enabled { - maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) - if err != nil { - logger.ErrorCF("channels", logger.C0017, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } else { - m.channels["maixcam"] = maixcam - logger.InfoC("channels", logger.C0018) - } - } - - if m.config.Channels.QQ.Enabled { - qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) - if err != nil { - logger.ErrorCF("channels", logger.C0019, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } else { - m.channels["qq"] = qq - logger.InfoC("channels", logger.C0020) - } - } - - if m.config.Channels.DingTalk.Enabled { - if m.config.Channels.DingTalk.ClientID == "" { - logger.WarnC("channels", logger.C0021) - } else { - dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus) - if err != nil { - logger.ErrorCF("channels", logger.C0022, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } else { - m.channels["dingtalk"] = dingtalk - logger.InfoC("channels", logger.C0023) - } - } - } - logger.InfoCF("channels", logger.C0024, map[string]interface{}{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go deleted file mode 100644 index 5bdc931..0000000 --- a/pkg/channels/qq.go +++ /dev/null @@ -1,248 +0,0 @@ -//go:build !omit_qq - -package channels - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/tencent-connect/botgo" - "github.com/tencent-connect/botgo/dto" - "github.com/tencent-connect/botgo/event" - "github.com/tencent-connect/botgo/openapi" - "github.com/tencent-connect/botgo/token" - "golang.org/x/oauth2" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/logger" -) - -type QQChannel struct { - *BaseChannel - config config.QQConfig - api openapi.OpenAPI - tokenSource oauth2.TokenSource - runCancel cancelGuard - sessionManager botgo.SessionManager - processedIDs map[string]bool - mu sync.RWMutex -} - -const qqCompiled = true - -func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) - - return &QQChannel{ - BaseChannel: base, - config: cfg, - processedIDs: make(map[string]bool), - }, nil -} - -func (c *QQChannel) Start(ctx context.Context) error { - if c.IsRunning() { - return nil - } - if c.config.AppID == "" || c.config.AppSecret == "" { - return fmt.Errorf("QQ app_id and app_secret not configured") - } - - logger.InfoC("qq", logger.C0099) - - // Create token source - credentials := &token.QQBotCredentials{ - AppID: c.config.AppID, - AppSecret: c.config.AppSecret, - } - c.tokenSource = token.NewQQBotTokenSource(credentials) - - // Create child context - runCtx, cancel := context.WithCancel(ctx) - c.runCancel.set(cancel) - - // Start token auto-refresh goroutine - if err := token.StartRefreshAccessToken(runCtx, c.tokenSource); err != nil { - return fmt.Errorf("failed to start token refresh: %w", err) - } - - // Initialize OpenAPI client - c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) - - // Register event handlers - intent := event.RegisterHandlers( - c.handleC2CMessage(), - c.handleGroupATMessage(), - ) - - // Get WebSocket endpoint - wsInfo, err := c.api.WS(runCtx, nil, "") - if err != nil { - return fmt.Errorf("failed to get websocket info: %w", err) - } - - logger.InfoCF("qq", logger.C0100, map[string]interface{}{ - "shards": wsInfo.Shards, - }) - - // Create and store session manager - c.sessionManager = botgo.NewSessionManager() - - // Start WebSocket connection in a goroutine to avoid blocking - runChannelTask("qq", "websocket session", func() error { - return c.sessionManager.Start(wsInfo, c.tokenSource, &intent) - }, func(_ error) { - c.setRunning(false) - }) - - c.setRunning(true) - logger.InfoC("qq", logger.C0101) - - return nil -} - -func (c *QQChannel) Stop(ctx context.Context) error { - if !c.IsRunning() { - return nil - } - logger.InfoC("qq", logger.C0102) - c.setRunning(false) - - c.runCancel.cancelAndClear() - return nil -} - -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("QQ bot not running") - } - - // Build message - msgToCreate := &dto.MessageToCreate{ - Content: msg.Content, - } - - // Send C2C message - _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) - if err != nil { - logger.ErrorCF("qq", logger.C0103, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return err - } - - return nil -} - -// handleC2CMessage handles QQ private messages -func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { - return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { - // Deduplication check - if c.isDuplicate(data.ID) { - return nil - } - - // Extract sender information - var senderID string - if data.Author != nil && data.Author.ID != "" { - senderID = data.Author.ID - } else { - logger.WarnC("qq", logger.C0104) - return nil - } - - // Extract message content - content := data.Content - if content == "" { - logger.DebugC("qq", logger.C0105) - return nil - } - - logger.InfoCF("qq", logger.C0106, map[string]interface{}{ - logger.FieldSenderID: senderID, - logger.FieldChatID: senderID, - logger.FieldMessageContentLength: len(content), - }) - - // Forward to message bus - metadata := map[string]string{ - "message_id": data.ID, - } - - c.HandleMessage(senderID, senderID, content, []string{}, metadata) - - return nil - } -} - -// handleGroupATMessage handles group @ messages -func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { - return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { - // Deduplication check - if c.isDuplicate(data.ID) { - return nil - } - - // Extract sender information - var senderID string - if data.Author != nil && data.Author.ID != "" { - senderID = data.Author.ID - } else { - logger.WarnC("qq", logger.C0107) - return nil - } - - // Extract message content (remove bot mention) - content := data.Content - if content == "" { - logger.DebugC("qq", logger.C0108) - return nil - } - - logger.InfoCF("qq", logger.C0109, map[string]interface{}{ - logger.FieldSenderID: senderID, - "group_id": data.GroupID, - logger.FieldMessageContentLength: len(content), - }) - - // Forward to message bus (use GroupID as ChatID) - metadata := map[string]string{ - "message_id": data.ID, - "group_id": data.GroupID, - } - - c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata) - - return nil - } -} - -// isDuplicate checks whether a message is duplicated -func (c *QQChannel) isDuplicate(messageID string) bool { - c.mu.Lock() - defer c.mu.Unlock() - - if c.processedIDs[messageID] { - return true - } - - c.processedIDs[messageID] = true - - // Simple cleanup: limit map size - if len(c.processedIDs) > 10000 { - // Remove half of the entries - count := 0 - for id := range c.processedIDs { - if count >= 5000 { - break - } - delete(c.processedIDs, id) - count++ - } - } - - return false -} diff --git a/pkg/channels/qq_stub.go b/pkg/channels/qq_stub.go deleted file mode 100644 index 1543b8e..0000000 --- a/pkg/channels/qq_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_qq - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type QQChannel struct{ disabledChannel } - -const qqCompiled = false - -func NewQQChannel(cfg config.QQConfig, bus *bus.MessageBus) (*QQChannel, error) { - return nil, errChannelDisabled("qq") -} diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 27e14c4..b47e241 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -1,5 +1,3 @@ -//go:build !omit_telegram - package channels import ( @@ -38,8 +36,6 @@ const ( telegramStreamMaxRetries = 4 ) -const telegramCompiled = true - type TelegramChannel struct { *BaseChannel bot *telego.Bot diff --git a/pkg/channels/telegram_stub.go b/pkg/channels/telegram_stub.go deleted file mode 100644 index 6730242..0000000 --- a/pkg/channels/telegram_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_telegram - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type TelegramChannel struct{ disabledChannel } - -const telegramCompiled = false - -func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { - return nil, errChannelDisabled("telegram") -} diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go index 4521205..79d8a0d 100644 --- a/pkg/channels/telegram_test.go +++ b/pkg/channels/telegram_test.go @@ -1,5 +1,3 @@ -//go:build !omit_telegram - package channels import ( diff --git a/pkg/channels/weixin.go b/pkg/channels/weixin.go index 8e7085d..c64aa90 100644 --- a/pkg/channels/weixin.go +++ b/pkg/channels/weixin.go @@ -23,12 +23,22 @@ import ( ) const ( - weixinCompiled = true weixinDefaultBaseURL = "https://ilinkai.weixin.qq.com" weixinDefaultTimeout = 45 * time.Second weixinRetryDelay = 2 * time.Second - weixinChannelVersion = "1.0.2" - weixinPersistDelay = 1200 * time.Millisecond + weixinBackoffDelay = 30 * time.Second + weixinChannelVersion = "2.1.1" + weixinIlinkAppID = "bot" + // 2.1.1 encoded as 0x00MMNNPP => 0x00020101 => 131329 + weixinClientVersion = "131329" + weixinMaxConsecutiveFails = 3 + weixinPersistDelay = 1200 * time.Millisecond + weixinDefaultPollTimeout = 35 * time.Second + weixinSessionExpiredCode = -14 + weixinSessionPauseWindow = time.Hour + weixinConfigCacheTTL = 24 * time.Hour + weixinConfigRetryInitial = 2 * time.Second + weixinConfigRetryMax = time.Hour ) type WeixinChannel struct { @@ -38,6 +48,7 @@ type WeixinChannel struct { httpClient *http.Client runCancel cancelGuard runCtx context.Context + onPersist func(string) mu sync.RWMutex accounts map[string]*weixinAccountState @@ -48,6 +59,16 @@ type WeixinChannel struct { pendingLogins map[string]*WeixinPendingLogin loginOrder []string persistTimer *time.Timer + typingMu sync.Mutex + typingCache map[string]weixinTypingCacheEntry + pauseMu sync.Mutex + pauseUntil time.Time +} + +type weixinTypingCacheEntry struct { + ticket string + nextFetchAt time.Time + retryDelay time.Duration } type WeixinAccountSnapshot struct { @@ -115,6 +136,17 @@ type weixinAPIResponse struct { ErrMsg string `json:"err_msg"` } +type weixinAPIStatusError struct { + Operation string + Ret int + Errcode int + Message string +} + +func (e *weixinAPIStatusError) Error() string { + return fmt.Sprintf("%s failed: ret=%d errcode=%d msg=%s", e.Operation, e.Ret, e.Errcode, e.Message) +} + type weixinQRCodeResponse struct { QRcode string `json:"qrcode"` QRcodeImgContent string `json:"qrcode_img_content"` @@ -144,6 +176,7 @@ func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*Wei chatContexts: map[string]string{}, pollers: map[string]context.CancelFunc{}, pendingLogins: map[string]*WeixinPendingLogin{}, + typingCache: map[string]weixinTypingCacheEntry{}, } for _, account := range normalizeWeixinAccounts(cfg) { ch.accounts[account.BotID] = &weixinAccountState{cfg: account} @@ -168,6 +201,12 @@ func (c *WeixinChannel) SetConfigPath(path string) { c.configPath = strings.TrimSpace(path) } +func (c *WeixinChannel) SetPersistHook(fn func(string)) { + c.mu.Lock() + defer c.mu.Unlock() + c.onPersist = fn +} + func (c *WeixinChannel) Start(ctx context.Context) error { if c.IsRunning() { return nil @@ -182,11 +221,11 @@ func (c *WeixinChannel) Start(ctx context.Context) error { "base_url": c.config.BaseURL, }) - c.mu.Lock() + c.mu.RLock() accountIDs := append([]string(nil), c.accountOrder...) - c.mu.Unlock() + c.mu.RUnlock() for _, botID := range accountIDs { - c.startAccountPollerLocked(botID) + c.startAccountPoller(botID) } return nil } @@ -215,6 +254,9 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if !c.IsRunning() { return fmt.Errorf("weixin channel not running") } + if err := c.ensureSessionActive(); err != nil { + return err + } action := strings.ToLower(strings.TrimSpace(msg.Action)) switch action { @@ -232,6 +274,7 @@ func (c *WeixinChannel) StartLogin(ctx context.Context) (*WeixinPendingLogin, er if err != nil { return nil, err } + c.applyHeaders(req, false, "", false) resp, err := c.httpClient.Do(req) if err != nil { return nil, err @@ -433,8 +476,10 @@ func (c *WeixinChannel) sendMessage(ctx context.Context, msg bus.OutboundMessage c.updateAccountError(account.cfg.BotID, err) return err } - if resp.Ret != 0 || resp.Errcode != 0 { - err := fmt.Errorf("sendmessage failed: ret=%d errcode=%d msg=%s", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + if err := c.validateAPIStatus("sendmessage", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)); err != nil { + if c.isSessionExpiredError(err) { + c.pauseSession("sendmessage", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + } c.updateAccountError(account.cfg.BotID, err) return err } @@ -443,6 +488,9 @@ func (c *WeixinChannel) sendMessage(ctx context.Context, msg bus.OutboundMessage } func (c *WeixinChannel) sendTyping(ctx context.Context, chatID string, status int) error { + if err := c.ensureSessionActive(); err != nil { + return err + } account, _, contextToken, err := c.resolveAccountForChat(chatID) if err != nil { return err @@ -461,7 +509,7 @@ func (c *WeixinChannel) sendTyping(ctx context.Context, chatID string, status in "typing_ticket": ticket, "status": status, "base_info": map[string]string{ - "channel_version": "1.0.0", + "channel_version": weixinChannelVersion, }, } @@ -469,60 +517,128 @@ func (c *WeixinChannel) sendTyping(ctx context.Context, chatID string, status in if err := c.doJSON(ctx, "/ilink/bot/sendtyping", reqBody, &resp, account.cfg.BotToken); err != nil { return err } - if resp.Ret != 0 { - return fmt.Errorf("sendtyping failed: ret=%d", resp.Ret) + if err := c.validateAPIStatus("sendtyping", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)); err != nil { + if c.isSessionExpiredError(err) { + c.pauseSession("sendtyping", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + } + return err } return nil } func (c *WeixinChannel) getTypingTicket(ctx context.Context, account config.WeixinAccountConfig, contextToken string) (string, error) { + key := strings.TrimSpace(account.BotID) + if key == "" { + key = strings.TrimSpace(account.IlinkUserID) + } + now := time.Now() + c.typingMu.Lock() + entry, ok := c.typingCache[key] + if ok && now.Before(entry.nextFetchAt) { + ticket := entry.ticket + c.typingMu.Unlock() + return ticket, nil + } + cachedTicket := entry.ticket + retryDelay := entry.retryDelay + c.typingMu.Unlock() + reqBody := map[string]interface{}{ "ilink_user_id": account.IlinkUserID, "context_token": contextToken, "base_info": map[string]string{ - "channel_version": "1.0.0", + "channel_version": weixinChannelVersion, }, } var resp struct { Ret int `json:"ret"` + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + ErrMsg string `json:"err_msg"` TypingTicket string `json:"typing_ticket"` } if err := c.doJSON(ctx, "/ilink/bot/getconfig", reqBody, &resp, account.BotToken); err != nil { + c.storeTypingCacheFailure(key, cachedTicket, retryDelay, now) + if cachedTicket != "" { + return cachedTicket, nil + } return "", err } - if resp.Ret != 0 || strings.TrimSpace(resp.TypingTicket) == "" { - return "", fmt.Errorf("getconfig failed: ret=%d", resp.Ret) + if err := c.validateAPIStatus("getconfig", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)); err != nil { + c.storeTypingCacheFailure(key, cachedTicket, retryDelay, now) + if c.isSessionExpiredError(err) { + c.pauseSession("getconfig", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + } + if cachedTicket != "" { + return cachedTicket, nil + } + return "", err } - return strings.TrimSpace(resp.TypingTicket), nil + ticket := strings.TrimSpace(resp.TypingTicket) + if ticket == "" { + c.storeTypingCacheFailure(key, cachedTicket, retryDelay, now) + if cachedTicket != "" { + return cachedTicket, nil + } + return "", fmt.Errorf("getconfig failed: empty typing_ticket") + } + c.typingMu.Lock() + c.typingCache[key] = weixinTypingCacheEntry{ + ticket: ticket, + nextFetchAt: now.Add(weixinConfigCacheTTL), + retryDelay: weixinConfigRetryInitial, + } + c.typingMu.Unlock() + return ticket, nil } func (c *WeixinChannel) pollAccount(ctx context.Context, botID string) { + consecutiveFails := 0 + pollTimeout := weixinDefaultPollTimeout for { if ctx.Err() != nil { return } + if err := c.waitWhileSessionPaused(ctx); err != nil { + return + } account, ok := c.accountConfig(botID) if !ok { return } - resp, err := c.getUpdates(ctx, account) + resp, err := c.getUpdates(ctx, account, pollTimeout) if err != nil { c.updateAccountError(botID, err) - if !sleepWithContext(ctx, weixinRetryDelay) { + consecutiveFails++ + if c.isSessionExpiredError(err) { + continue + } + delay := pollDelayForAttempt(consecutiveFails) + logger.WarnCF("weixin", 0, map[string]interface{}{ + "operation": "getupdates", + "bot_id": botID, + "attempt": consecutiveFails, + "delay_ms": delay.Milliseconds(), + "error": err.Error(), + }) + if !sleepWithContext(ctx, delay) { return } continue } + consecutiveFails = 0 if resp.LongpollingTimeoutMs > 0 { - c.httpClient.Timeout = time.Duration(resp.LongpollingTimeoutMs+10000) * time.Millisecond + pollTimeout = time.Duration(resp.LongpollingTimeoutMs+5000) * time.Millisecond } if next := strings.TrimSpace(resp.GetUpdatesBuf); next != "" { c.mu.Lock() if state := c.accounts[botID]; state != nil { - state.cfg.GetUpdatesBuf = next - c.schedulePersistLocked() + if state.cfg.GetUpdatesBuf != next { + state.cfg.GetUpdatesBuf = next + c.schedulePersistLocked() + } } c.mu.Unlock() } @@ -534,7 +650,7 @@ func (c *WeixinChannel) pollAccount(ctx context.Context, botID string) { } } -func (c *WeixinChannel) getUpdates(ctx context.Context, account config.WeixinAccountConfig) (*weixinGetUpdatesResponse, error) { +func (c *WeixinChannel) getUpdates(ctx context.Context, account config.WeixinAccountConfig, timeout time.Duration) (*weixinGetUpdatesResponse, error) { reqBody := map[string]interface{}{ "get_updates_buf": strings.TrimSpace(account.GetUpdatesBuf), "base_info": map[string]string{ @@ -543,11 +659,14 @@ func (c *WeixinChannel) getUpdates(ctx context.Context, account config.WeixinAcc } var resp weixinGetUpdatesResponse - if err := c.doJSON(ctx, "/ilink/bot/getupdates", reqBody, &resp, account.BotToken); err != nil { + if err := c.doJSONWithTimeout(ctx, "/ilink/bot/getupdates", reqBody, &resp, account.BotToken, timeout); err != nil { return nil, err } - if resp.Ret != 0 || resp.Errcode != 0 { - return nil, fmt.Errorf("getupdates failed: ret=%d errcode=%d msg=%s", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + if err := c.validateAPIStatus("getupdates", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)); err != nil { + if c.isSessionExpiredError(err) { + c.pauseSession("getupdates", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + } + return nil, err } return &resp, nil } @@ -566,7 +685,7 @@ func (c *WeixinChannel) handleInboundMessage(botID string, msg weixinInboundMess } c.chatBindings[chatID] = botID if state := c.accounts[botID]; state != nil { - if contextToken != "" { + if contextToken != "" && state.cfg.ContextToken != contextToken { state.cfg.ContextToken = contextToken c.schedulePersistLocked() } @@ -670,6 +789,7 @@ func (c *WeixinChannel) addOrUpdateAccount(account config.WeixinAccountConfig) e return fmt.Errorf("bot_id and bot_token are required") } + shouldStartPoller := false c.mu.Lock() if state := c.accounts[account.BotID]; state != nil { state.cfg = mergeWeixinAccount(state.cfg, account) @@ -681,13 +801,12 @@ func (c *WeixinChannel) addOrUpdateAccount(account config.WeixinAccountConfig) e if strings.TrimSpace(c.config.DefaultBotID) == "" { c.config.DefaultBotID = account.BotID } + shouldStartPoller = c.IsRunning() c.schedulePersistLocked() c.mu.Unlock() - c.mu.Lock() - defer c.mu.Unlock() - if c.IsRunning() { - c.startAccountPollerLocked(account.BotID) + if shouldStartPoller { + c.startAccountPoller(account.BotID) } return nil } @@ -710,6 +829,7 @@ func (c *WeixinChannel) persistAccounts() error { configPath := strings.TrimSpace(c.configPath) cfgCopy := c.config accounts := c.accountConfigsLocked() + onPersist := c.onPersist c.mu.RUnlock() if configPath == "" { return nil @@ -736,6 +856,9 @@ func (c *WeixinChannel) persistAccounts() error { c.persistTimer = nil } c.mu.Unlock() + if err == nil && onPersist != nil { + onPersist("weixin") + } return err } @@ -751,6 +874,12 @@ func (c *WeixinChannel) accountConfigsLocked() []config.WeixinAccountConfig { return out } +func (c *WeixinChannel) startAccountPoller(botID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.startAccountPollerLocked(botID) +} + func (c *WeixinChannel) startAccountPollerLocked(botID string) { if !c.IsRunning() || c.runCtx == nil { return @@ -804,8 +933,7 @@ func (c *WeixinChannel) refreshLoginStatus(ctx context.Context, loginID string) if err != nil { return err } - c.applyHeaders(req, false, "") - req.Header.Set("iLink-App-ClientVersion", "1") + c.applyHeaders(req, false, "", false) resp, err := c.httpClient.Do(req) if err != nil { @@ -887,7 +1015,109 @@ func (c *WeixinChannel) updateAccountError(botID string, err error) { c.updateAccountEvent(botID, "error", false, err) } +func (c *WeixinChannel) validateAPIStatus(operation string, ret, errcode int, msg string) error { + if ret == 0 && errcode == 0 { + return nil + } + return &weixinAPIStatusError{ + Operation: operation, + Ret: ret, + Errcode: errcode, + Message: strings.TrimSpace(msg), + } +} + +func (c *WeixinChannel) isSessionExpiredError(err error) bool { + apiErr, ok := err.(*weixinAPIStatusError) + return ok && (apiErr.Ret == weixinSessionExpiredCode || apiErr.Errcode == weixinSessionExpiredCode) +} + +func (c *WeixinChannel) pauseSession(operation string, ret, errcode int, errmsg string) time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + until := time.Now().Add(weixinSessionPauseWindow) + if until.After(c.pauseUntil) { + c.pauseUntil = until + } + remaining := time.Until(c.pauseUntil) + logger.ErrorCF("weixin", 0, map[string]interface{}{ + "operation": operation, + "ret": ret, + "errcode": errcode, + "errmsg": strings.TrimSpace(errmsg), + "pause_until": c.pauseUntil.UTC().Format(time.RFC3339), + "minutes": int((remaining + time.Minute - 1) / time.Minute), + }) + return remaining +} + +func (c *WeixinChannel) remainingPause() time.Duration { + c.pauseMu.Lock() + defer c.pauseMu.Unlock() + if c.pauseUntil.IsZero() { + return 0 + } + remaining := time.Until(c.pauseUntil) + if remaining <= 0 { + c.pauseUntil = time.Time{} + return 0 + } + return remaining +} + +func (c *WeixinChannel) waitWhileSessionPaused(ctx context.Context) error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + timer := time.NewTimer(remaining) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (c *WeixinChannel) ensureSessionActive() error { + remaining := c.remainingPause() + if remaining <= 0 { + return nil + } + return fmt.Errorf("weixin session paused (%d min remaining)", int((remaining+time.Minute-1)/time.Minute)) +} + +func pollDelayForAttempt(attempt int) time.Duration { + if attempt >= weixinMaxConsecutiveFails { + return weixinBackoffDelay + } + return weixinRetryDelay +} + +func (c *WeixinChannel) storeTypingCacheFailure(key, cachedTicket string, retryDelay time.Duration, now time.Time) { + if retryDelay <= 0 { + retryDelay = weixinConfigRetryInitial + } else { + retryDelay *= 2 + if retryDelay > weixinConfigRetryMax { + retryDelay = weixinConfigRetryMax + } + } + c.typingMu.Lock() + c.typingCache[key] = weixinTypingCacheEntry{ + ticket: cachedTicket, + nextFetchAt: now.Add(retryDelay), + retryDelay: retryDelay, + } + c.typingMu.Unlock() +} + func (c *WeixinChannel) doJSON(ctx context.Context, path string, payload interface{}, out interface{}, token string) error { + return c.doJSONWithTimeout(ctx, path, payload, out, token, 0) +} + +func (c *WeixinChannel) doJSONWithTimeout(ctx context.Context, path string, payload interface{}, out interface{}, token string, timeout time.Duration) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal request: %w", err) @@ -897,9 +1127,15 @@ func (c *WeixinChannel) doJSON(ctx context.Context, path string, payload interfa if err != nil { return fmt.Errorf("build request: %w", err) } - c.applyHeaders(req, true, token) + c.applyHeaders(req, true, token, true) - resp, err := c.httpClient.Do(req) + client := c.httpClient + if timeout > 0 { + clone := *c.httpClient + clone.Timeout = timeout + client = &clone + } + resp, err := client.Do(req) if err != nil { return err } @@ -920,14 +1156,18 @@ func (c *WeixinChannel) doJSON(ctx context.Context, path string, payload interfa return nil } -func (c *WeixinChannel) applyHeaders(req *http.Request, jsonBody bool, token string) { +func (c *WeixinChannel) applyHeaders(req *http.Request, jsonBody bool, token string, withAuth bool) { if jsonBody { req.Header.Set("Content-Type", "application/json") } - req.Header.Set("AuthorizationType", "ilink_bot_token") - req.Header.Set("X-WECHAT-UIN", randomWeixinUIN()) - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + req.Header.Set("iLink-App-Id", weixinIlinkAppID) + req.Header.Set("iLink-App-ClientVersion", weixinClientVersion) + if withAuth { + req.Header.Set("AuthorizationType", "ilink_bot_token") + req.Header.Set("X-WECHAT-UIN", randomWeixinUIN()) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + } } } diff --git a/pkg/channels/weixin_stub.go b/pkg/channels/weixin_stub.go deleted file mode 100644 index 51a9baa..0000000 --- a/pkg/channels/weixin_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_weixin - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type WeixinChannel struct{ disabledChannel } - -const weixinCompiled = false - -func NewWeixinChannel(cfg config.WeixinConfig, bus *bus.MessageBus) (*WeixinChannel, error) { - return nil, errChannelDisabled("weixin") -} diff --git a/pkg/channels/weixin_test.go b/pkg/channels/weixin_test.go index 426da35..c57baff 100644 --- a/pkg/channels/weixin_test.go +++ b/pkg/channels/weixin_test.go @@ -4,6 +4,11 @@ package channels import ( "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" "testing" "time" @@ -11,6 +16,12 @@ import ( "github.com/YspCoder/clawgo/pkg/config" ) +type weixinRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f weixinRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func TestBuildAndSplitWeixinChatID(t *testing.T) { chatID := buildWeixinChatID("bot-a", "wx-user-1") if chatID != "bot-a|wx-user-1" { @@ -140,3 +151,274 @@ func TestWeixinCancelPendingLogin(t *testing.T) { t.Fatalf("expected no pending logins after cancel, got %d", len(got)) } } + +func TestWeixinSendSessionExpiredTriggersPause(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a", ContextToken: "ctx-a"}, + }, + DefaultBotID: "bot-a", + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.setRunning(true) + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"ret":-14,"errcode":0,"errmsg":"expired"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + err = ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "bot-a|wx-user-1", + Action: "send", + Content: "hello", + }) + if err == nil { + t.Fatalf("expected send error") + } + if !strings.Contains(err.Error(), "sendmessage failed") { + t.Fatalf("unexpected send error: %v", err) + } + if remaining := ch.remainingPause(); remaining <= 0 { + t.Fatalf("expected session pause > 0, got %s", remaining) + } +} + +func TestWeixinGetUpdatesSessionExpiredTriggersPause(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a"}, + }, + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"ret":0,"errcode":-14,"errmsg":"expired"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + _, err = ch.getUpdates(context.Background(), config.WeixinAccountConfig{ + BotID: "bot-a", + BotToken: "token-a", + }, time.Second) + if err == nil { + t.Fatalf("expected getupdates error") + } + if _, ok := err.(*weixinAPIStatusError); !ok { + t.Fatalf("expected weixinAPIStatusError, got %T", err) + } + if remaining := ch.remainingPause(); remaining <= 0 { + t.Fatalf("expected session pause > 0, got %s", remaining) + } +} + +func TestWeixinHeadersForAuthAndLogin(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a", ContextToken: "ctx-a"}, + }, + DefaultBotID: "bot-a", + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.setRunning(true) + + var mu sync.Mutex + requests := map[string]*http.Request{} + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + mu.Lock() + requests[req.URL.Path] = req.Clone(req.Context()) + mu.Unlock() + switch req.URL.Path { + case "/ilink/bot/get_bot_qrcode": + body := `{"qrcode":"abc","qrcode_img_content":"img"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + case "/ilink/bot/sendmessage": + body := `{"ret":0,"errcode":0}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + default: + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + }, nil + } + })} + + if _, err := ch.StartLogin(context.Background()); err != nil { + t.Fatalf("start login: %v", err) + } + if err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "bot-a|wx-user-1", + Action: "send", + Content: "hello", + }); err != nil { + t.Fatalf("send: %v", err) + } + + mu.Lock() + loginReq := requests["/ilink/bot/get_bot_qrcode"] + sendReq := requests["/ilink/bot/sendmessage"] + mu.Unlock() + + if loginReq == nil || sendReq == nil { + t.Fatalf("expected both login and send requests") + } + if got := loginReq.Header.Get("iLink-App-Id"); got != weixinIlinkAppID { + t.Fatalf("login iLink-App-Id = %q", got) + } + if got := loginReq.Header.Get("iLink-App-ClientVersion"); got != weixinClientVersion { + t.Fatalf("login iLink-App-ClientVersion = %q", got) + } + if loginReq.Header.Get("AuthorizationType") != "" || loginReq.Header.Get("Authorization") != "" || loginReq.Header.Get("X-WECHAT-UIN") != "" { + t.Fatalf("login request should not include auth headers") + } + + if got := sendReq.Header.Get("iLink-App-Id"); got != weixinIlinkAppID { + t.Fatalf("send iLink-App-Id = %q", got) + } + if got := sendReq.Header.Get("iLink-App-ClientVersion"); got != weixinClientVersion { + t.Fatalf("send iLink-App-ClientVersion = %q", got) + } + if got := sendReq.Header.Get("AuthorizationType"); got != "ilink_bot_token" { + t.Fatalf("send AuthorizationType = %q", got) + } + if got := sendReq.Header.Get("Authorization"); got != "Bearer token-a" { + t.Fatalf("send Authorization = %q", got) + } + if sendReq.Header.Get("X-WECHAT-UIN") == "" { + t.Fatalf("send X-WECHAT-UIN should not be empty") + } +} + +func TestWeixinGetTypingTicketCachesAndFallsBack(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a", IlinkUserID: "u-1"}, + }, + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + + var calls int + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + body := `{"ret":0,"errcode":0,"typing_ticket":"ticket-1"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + } + body := `{"ret":1,"errcode":1,"errmsg":"bad"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + account := config.WeixinAccountConfig{ + BotID: "bot-a", + BotToken: "token-a", + IlinkUserID: "u-1", + } + + ticket, err := ch.getTypingTicket(context.Background(), account, "ctx-1") + if err != nil { + t.Fatalf("first get typing ticket: %v", err) + } + if ticket != "ticket-1" { + t.Fatalf("first ticket = %q", ticket) + } + + ticket, err = ch.getTypingTicket(context.Background(), account, "ctx-1") + if err != nil { + t.Fatalf("cached get typing ticket: %v", err) + } + if ticket != "ticket-1" { + t.Fatalf("cached ticket = %q", ticket) + } + if calls != 1 { + t.Fatalf("expected 1 upstream call for cache hit, got %d", calls) + } + + ch.typingMu.Lock() + entry := ch.typingCache["bot-a"] + entry.nextFetchAt = time.Now().Add(-time.Second) + ch.typingCache["bot-a"] = entry + ch.typingMu.Unlock() + + ticket, err = ch.getTypingTicket(context.Background(), account, "ctx-1") + if err != nil { + t.Fatalf("fallback get typing ticket: %v", err) + } + if ticket != "ticket-1" { + t.Fatalf("fallback ticket = %q", ticket) + } + if calls != 2 { + t.Fatalf("expected 2 upstream calls, got %d", calls) + } + ch.typingMu.Lock() + defer ch.typingMu.Unlock() + if ch.typingCache["bot-a"].retryDelay < weixinConfigRetryInitial { + t.Fatalf("expected retry delay >= initial, got %s", ch.typingCache["bot-a"].retryDelay) + } +} + +func TestPollDelayForAttempt(t *testing.T) { + if got := pollDelayForAttempt(1); got != weixinRetryDelay { + t.Fatalf("attempt 1 delay = %s", got) + } + if got := pollDelayForAttempt(weixinMaxConsecutiveFails); got != weixinBackoffDelay { + t.Fatalf("threshold delay = %s", got) + } +} + +func TestWeixinValidateAPIStatusErrorShape(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{BaseURL: "https://ilinkai.weixin.qq.com"}, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + err = ch.validateAPIStatus("sendmessage", 1, 2, "bad") + if err == nil { + t.Fatalf("expected error") + } + apiErr, ok := err.(*weixinAPIStatusError) + if !ok { + t.Fatalf("expected weixinAPIStatusError") + } + b, _ := json.Marshal(apiErr.Error()) + if len(b) == 0 { + t.Fatalf("marshal error text") + } +} diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go deleted file mode 100644 index f8b54d4..0000000 --- a/pkg/channels/whatsapp.go +++ /dev/null @@ -1,289 +0,0 @@ -//go:build !omit_whatsapp - -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net" - "strconv" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/logger" -) - -type WhatsAppChannel struct { - *BaseChannel - conn *websocket.Conn - config config.WhatsAppConfig - url string - runCancel cancelGuard - mu sync.Mutex - connected bool -} - -const whatsappCompiled = true - -func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) - - return &WhatsAppChannel{ - BaseChannel: base, - config: cfg, - url: cfg.BridgeURL, - connected: false, - }, nil -} - -func (c *WhatsAppChannel) Start(ctx context.Context) error { - if c.IsRunning() { - return nil - } - logger.InfoCF("whatsapp", logger.C0121, map[string]interface{}{ - "url": c.url, - }) - runCtx, cancel := context.WithCancel(ctx) - c.runCancel.set(cancel) - - dialer := websocket.DefaultDialer - dialer.HandshakeTimeout = 10 * time.Second - - conn, _, err := dialer.Dial(c.url, nil) - if err != nil { - return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - c.setRunning(true) - logger.InfoC("whatsapp", logger.C0122) - - go c.listen(runCtx) - - return nil -} - -func (c *WhatsAppChannel) Stop(ctx context.Context) error { - if !c.IsRunning() { - return nil - } - logger.InfoC("whatsapp", logger.C0123) - c.runCancel.cancelAndClear() - - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - if err := c.conn.Close(); err != nil { - logger.WarnCF("whatsapp", logger.C0124, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - } - c.conn = nil - } - - c.connected = false - c.setRunning(false) - - return nil -} - -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("whatsapp connection not established") - } - - payload := map[string]interface{}{ - "type": "message", - "to": msg.ChatID, - "content": msg.Content, - } - if replyToID := strings.TrimSpace(msg.ReplyToID); replyToID != "" { - payload["reply_to_id"] = replyToID - } - if replyToSender := strings.TrimSpace(msg.ReplyToSender); replyToSender != "" { - payload["reply_to_sender"] = replyToSender - } - if media := strings.TrimSpace(msg.Media); media != "" { - payload["media"] = []string{media} - } - - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("failed to send message: %w", err) - } - - return nil -} - -func (c *WhatsAppChannel) listen(ctx context.Context) { - backoff := 200 * time.Millisecond - const maxBackoff = 3 * time.Second - - for { - select { - case <-ctx.Done(): - return - default: - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - if !sleepWithContext(ctx, backoff) { - return - } - backoff = nextBackoff(backoff, maxBackoff) - continue - } - - _, message, err := conn.ReadMessage() - if err != nil { - if ctx.Err() != nil { - return - } - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) || errors.Is(err, net.ErrClosed) { - logger.InfoCF("whatsapp", logger.C0125, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - return - } - logger.WarnCF("whatsapp", logger.C0126, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - if !sleepWithContext(ctx, backoff) { - return - } - backoff = nextBackoff(backoff, maxBackoff) - continue - } - backoff = 200 * time.Millisecond - - var msg map[string]interface{} - if err := json.Unmarshal(message, &msg); err != nil { - logger.WarnCF("whatsapp", logger.C0127, map[string]interface{}{ - logger.FieldError: err.Error(), - }) - continue - } - - msgType, ok := msg["type"].(string) - if !ok { - continue - } - - if msgType == "message" { - c.handleIncomingMessage(msg) - } - } - } -} - -func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { - senderID, ok := msg["from"].(string) - if !ok { - return - } - - chatID, ok := msg["chat"].(string) - if !ok { - chatID = senderID - } - - content, ok := msg["content"].(string) - if !ok { - content = "" - } - - var mediaPaths []string - if mediaData, ok := msg["media"].([]interface{}); ok { - mediaPaths = make([]string, 0, len(mediaData)) - for _, m := range mediaData { - if path, ok := m.(string); ok { - mediaPaths = append(mediaPaths, path) - } - } - } - - metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID - } - if userName, ok := msg["from_name"].(string); ok { - metadata["user_name"] = userName - } - isGroup := parseBoolish(msg["is_group"]) - if isGroup { - metadata["is_group"] = "true" - } - mentionedSelf := parseBoolish(msg["mentioned_self"]) - if mentionedSelf { - metadata["mentioned_self"] = "true" - } - replyToMe := parseBoolish(msg["reply_to_me"]) - if replyToMe { - metadata["reply_to_me"] = "true" - } - - logger.InfoCF("whatsapp", logger.C0128, map[string]interface{}{ - logger.FieldSenderID: senderID, - logger.FieldPreview: truncateString(content, 50), - }) - - if !c.shouldHandleIncomingMessage(isGroup, mentionedSelf, replyToMe) { - return - } - - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) -} - -func (c *WhatsAppChannel) shouldHandleIncomingMessage(isGroup, mentionedSelf, replyToMe bool) bool { - if !isGroup { - return true - } - if !c.config.EnableGroups { - return false - } - if !c.config.RequireMentionInGroups { - return true - } - return mentionedSelf || replyToMe -} - -func parseBoolish(v interface{}) bool { - switch value := v.(type) { - case bool: - return value - case string: - parsed, err := strconv.ParseBool(strings.TrimSpace(value)) - return err == nil && parsed - default: - return false - } -} - -func nextBackoff(current, max time.Duration) time.Duration { - next := current * 2 - if next > max { - return max - } - return next -} diff --git a/pkg/channels/whatsapp_bridge.go b/pkg/channels/whatsapp_bridge.go deleted file mode 100644 index 8e3732f..0000000 --- a/pkg/channels/whatsapp_bridge.go +++ /dev/null @@ -1,839 +0,0 @@ -//go:build !omit_whatsapp - -package channels - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "mime" - "net" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" - "go.mau.fi/whatsmeow" - waProto "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/store/sqlstore" - "go.mau.fi/whatsmeow/types" - "go.mau.fi/whatsmeow/types/events" - waLog "go.mau.fi/whatsmeow/util/log" - "google.golang.org/protobuf/proto" - _ "modernc.org/sqlite" -) - -type WhatsAppBridgeService struct { - addr string - stateDir string - printQR bool - httpServer *http.Server - client *whatsmeow.Client - container *sqlstore.Container - rawDB *sql.DB - cancel context.CancelFunc - wsUpgrader websocket.Upgrader - wsClients map[*websocket.Conn]struct{} - statusMu sync.RWMutex - status WhatsAppBridgeStatus - wsClientsMu sync.Mutex - markReadFn func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error - localOnly bool -} - -type whatsappBridgeWSMessage struct { - Type string `json:"type"` - To string `json:"to,omitempty"` - From string `json:"from,omitempty"` - Chat string `json:"chat,omitempty"` - Content string `json:"content,omitempty"` - ReplyToID string `json:"reply_to_id,omitempty"` - ReplyToSender string `json:"reply_to_sender,omitempty"` - ID string `json:"id,omitempty"` - FromName string `json:"from_name,omitempty"` - Media []string `json:"media,omitempty"` -} - -func NewWhatsAppBridgeService(addr, stateDir string, printQR bool) *WhatsAppBridgeService { - return &WhatsAppBridgeService{ - addr: strings.TrimSpace(addr), - stateDir: strings.TrimSpace(stateDir), - printQR: printQR, - wsUpgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - }, - wsClients: map[*websocket.Conn]struct{}{}, - status: WhatsAppBridgeStatus{ - State: "starting", - BridgeAddr: strings.TrimSpace(addr), - UpdatedAt: time.Now().Format(time.RFC3339), - }, - } -} - -func (s *WhatsAppBridgeService) Start(ctx context.Context) error { - if err := s.startRuntime(ctx); err != nil { - return err - } - - mux := http.NewServeMux() - s.RegisterRoutes(mux, "") - s.httpServer = &http.Server{ - Addr: s.addr, - Handler: mux, - } - - ln, err := net.Listen("tcp", s.addr) - if err != nil { - return fmt.Errorf("listen whatsapp bridge: %w", err) - } - - if err := s.httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -func (s *WhatsAppBridgeService) StartEmbedded(ctx context.Context) error { - s.localOnly = true - return s.startRuntime(ctx) -} - -func (s *WhatsAppBridgeService) startRuntime(ctx context.Context) error { - if strings.TrimSpace(s.addr) == "" { - return fmt.Errorf("bridge address is required") - } - if strings.TrimSpace(s.stateDir) == "" { - return fmt.Errorf("bridge state directory is required") - } - if err := os.MkdirAll(s.stateDir, 0o755); err != nil { - return fmt.Errorf("create whatsapp state dir: %w", err) - } - if err := s.initClient(ctx); err != nil { - return err - } - - runCtx, cancel := context.WithCancel(ctx) - s.cancel = cancel - - go func() { - <-runCtx.Done() - if s.httpServer != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = s.httpServer.Shutdown(shutdownCtx) - } - s.closeWSClients() - if s.client != nil { - s.client.Disconnect() - } - if s.rawDB != nil { - _ = s.rawDB.Close() - } - }() - - go func() { - _ = s.connectClient(runCtx) - }() - return nil -} - -func (s *WhatsAppBridgeService) Stop() { - if s.cancel != nil { - s.cancel() - } -} - -func (s *WhatsAppBridgeService) RegisterRoutes(mux *http.ServeMux, basePath string) { - if mux == nil { - return - } - basePath = normalizeBridgeBasePath(basePath) - mux.HandleFunc(basePath, s.ServeWS) - mux.HandleFunc(joinBridgeRoute(basePath, "ws"), s.ServeWS) - mux.HandleFunc(joinBridgeRoute(basePath, "status"), s.ServeStatus) - mux.HandleFunc(joinBridgeRoute(basePath, "logout"), s.ServeLogout) -} - -func (s *WhatsAppBridgeService) StatusSnapshot() WhatsAppBridgeStatus { - s.statusMu.RLock() - defer s.statusMu.RUnlock() - return s.status -} - -func (s *WhatsAppBridgeService) initClient(ctx context.Context) error { - dbPath := filepath.Join(s.stateDir, "whatsmeow.sqlite") - rawDB, err := sql.Open("sqlite", dbPath) - if err != nil { - return fmt.Errorf("open whatsapp sqlite store: %w", err) - } - if _, err := rawDB.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { - _ = rawDB.Close() - return fmt.Errorf("enable whatsapp sqlite foreign keys: %w", err) - } - container := sqlstore.NewWithDB(rawDB, "sqlite", waLog.Noop) - if err := container.Upgrade(ctx); err != nil { - _ = rawDB.Close() - return fmt.Errorf("upgrade whatsapp sqlite store: %w", err) - } - deviceStore, err := container.GetFirstDevice(ctx) - if err != nil { - _ = rawDB.Close() - return fmt.Errorf("load whatsapp device store: %w", err) - } - client := whatsmeow.NewClient(deviceStore, waLog.Noop) - client.EnableAutoReconnect = true - client.AddEventHandler(s.handleWAEvent) - - s.rawDB = rawDB - s.container = container - s.client = client - s.markReadFn = func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error { - return client.MarkRead(ctx, ids, timestamp, chat, sender) - } - if deviceStore.ID != nil { - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.LoggedIn = true - st.UserJID = deviceStore.ID.String() - st.State = "stored_session" - st.LastEvent = "stored_session" - }) - } - return nil -} - -func (s *WhatsAppBridgeService) connectClient(ctx context.Context) error { - if s.client == nil { - return fmt.Errorf("whatsapp bridge client is not initialized") - } - - var qrChan <-chan whatsmeow.QRChannelItem - var err error - if s.client.Store.ID == nil { - qrChan, err = s.client.GetQRChannel(ctx) - if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) { - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "error" - st.LastError = err.Error() - st.LastEvent = "qr_init_failed" - }) - return err - } - if qrChan != nil { - go s.consumeQRChannel(ctx, qrChan) - } - } - - if err := s.client.Connect(); err != nil { - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "error" - st.Connected = false - st.LastError = err.Error() - st.LastEvent = "connect_failed" - }) - return fmt.Errorf("connect whatsapp bridge: %w", err) - } - return nil -} - -func (s *WhatsAppBridgeService) consumeQRChannel(ctx context.Context, qrChan <-chan whatsmeow.QRChannelItem) { - for { - select { - case <-ctx.Done(): - return - case item, ok := <-qrChan: - if !ok { - return - } - switch item.Event { - case "code": - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "qr_ready" - st.QRCode = item.Code - st.QRAvailable = item.Code != "" - st.LastEvent = "qr_ready" - }) - default: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.LastEvent = item.Event - if item.Event == whatsmeow.QRChannelSuccess.Event { - st.State = "paired" - st.QRCode = "" - st.QRAvailable = false - } - }) - } - } - } -} - -func (s *WhatsAppBridgeService) handleWAEvent(evt interface{}) { - switch v := evt.(type) { - case *events.Connected: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "connected" - st.Connected = true - st.LoggedIn = s.client != nil && s.client.Store.ID != nil - st.QRCode = "" - st.QRAvailable = false - st.LastEvent = "connected" - if s.client != nil && s.client.Store.ID != nil { - st.UserJID = s.client.Store.ID.String() - } - }) - case *events.Disconnected: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.Connected = false - if st.LoggedIn { - st.State = "disconnected" - } else { - st.State = "waiting_qr" - } - st.LastEvent = "disconnected" - }) - case *events.PairSuccess: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "paired" - st.LoggedIn = true - st.UserJID = v.ID.String() - st.Platform = v.Platform - st.QRCode = "" - st.QRAvailable = false - st.LastEvent = "pair_success" - }) - case *events.LoggedOut: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "logged_out" - st.Connected = false - st.LoggedIn = false - st.UserJID = "" - st.QRCode = "" - st.QRAvailable = false - st.LastEvent = "logged_out" - }) - case *events.StreamReplaced: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "stream_replaced" - st.Connected = false - st.LastEvent = "stream_replaced" - }) - case *events.ClientOutdated: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "client_outdated" - st.Connected = false - st.LastError = "whatsapp web client outdated" - st.LastEvent = "client_outdated" - }) - case *events.ConnectFailure: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "connect_failed" - st.Connected = false - st.LastError = v.Reason.String() - st.LastEvent = "connect_failure" - }) - case *events.TemporaryBan: - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "temporary_ban" - st.Connected = false - st.LastError = v.String() - st.LastEvent = "temporary_ban" - }) - case *events.Message: - if v.Info.IsFromMe { - return - } - isGroup := v.Info.Chat.Server == types.GroupServer - mentionedSelf, replyToMe := s.matchCurrentUserContext(v.Message) - payload := whatsappBridgeWSMessage{ - Type: "message", - From: v.Info.Sender.ToNonAD().String(), - Chat: v.Info.Chat.ToNonAD().String(), - Content: extractWhatsAppMessageText(v.Message), - ID: v.Info.ID, - FromName: v.Info.PushName, - } - s.broadcastWSMap(map[string]interface{}{ - "type": payload.Type, - "from": payload.From, - "chat": payload.Chat, - "content": payload.Content, - "id": payload.ID, - "from_name": payload.FromName, - "is_group": isGroup, - "mentioned_self": mentionedSelf, - "reply_to_me": replyToMe, - }) - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.InboundCount++ - st.LastInboundAt = time.Now().Format(time.RFC3339) - st.LastInboundFrom = payload.From - st.LastInboundText = truncateString(strings.TrimSpace(payload.Content), 120) - st.LastEvent = "message_inbound" - }) - s.markIncomingReadReceipt(v.Info.Chat.ToNonAD(), v.Info.Sender.ToNonAD(), v.Info.ID, v.Info.Timestamp) - } -} - -func (s *WhatsAppBridgeService) handleWS(w http.ResponseWriter, r *http.Request) { - if !websocket.IsWebSocketUpgrade(r) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "message": "whatsapp bridge websocket endpoint", - }) - return - } - conn, err := s.wsUpgrader.Upgrade(w, r, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - s.wsClientsMu.Lock() - s.wsClients[conn] = struct{}{} - s.wsClientsMu.Unlock() - defer func() { - s.wsClientsMu.Lock() - delete(s.wsClients, conn) - s.wsClientsMu.Unlock() - _ = conn.Close() - }() - - for { - var msg whatsappBridgeWSMessage - if err := conn.ReadJSON(&msg); err != nil { - return - } - if strings.TrimSpace(msg.Type) != "message" { - continue - } - if err := s.sendOutboundMessage(r.Context(), msg.To, msg.Content, msg.Media, msg.ReplyToID, msg.ReplyToSender); err != nil { - _ = conn.WriteJSON(map[string]string{ - "type": "error", - "error": err.Error(), - }) - continue - } - } -} - -func (s *WhatsAppBridgeService) ServeWS(w http.ResponseWriter, r *http.Request) { - s.wrapHandler(s.handleWS)(w, r) -} - -func (s *WhatsAppBridgeService) wrapHandler(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if s.localOnly && !isLocalRequest(r) { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - next(w, r) - } -} - -func (s *WhatsAppBridgeService) handleStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.StatusSnapshot()) -} - -func (s *WhatsAppBridgeService) ServeStatus(w http.ResponseWriter, r *http.Request) { - s.wrapHandler(s.handleStatus)(w, r) -} - -func (s *WhatsAppBridgeService) handleLogout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if s.client == nil { - http.Error(w, "whatsapp bridge client is not initialized", http.StatusServiceUnavailable) - return - } - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - if err := s.client.Logout(ctx); err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.State = "logged_out" - st.Connected = false - st.LoggedIn = false - st.UserJID = "" - st.QRCode = "" - st.QRAvailable = false - st.LastEvent = "logout" - }) - go func() { - _ = s.connectClient(context.Background()) - }() - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.StatusSnapshot()) -} - -func (s *WhatsAppBridgeService) sendTextMessage(ctx context.Context, rawTo, content, replyToID, replyToSender string) error { - if s.client == nil { - return fmt.Errorf("whatsapp client not initialized") - } - if strings.TrimSpace(content) == "" { - return fmt.Errorf("message content is required") - } - to, err := normalizeWhatsAppRecipientJID(rawTo) - if err != nil { - return err - } - text := strings.TrimSpace(content) - msg := &waProto.Message{ - Conversation: &text, - } - applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) - _, err = s.client.SendMessage(ctx, to, msg) - if err == nil { - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.OutboundCount++ - st.LastOutboundAt = time.Now().Format(time.RFC3339) - st.LastOutboundTo = to.String() - st.LastOutboundText = truncateString(text, 120) - st.LastEvent = "message_outbound" - }) - } - return err -} - -func (s *WhatsAppBridgeService) sendOutboundMessage(ctx context.Context, rawTo, content string, mediaPaths []string, replyToID, replyToSender string) error { - if len(mediaPaths) == 0 { - return s.sendTextMessage(ctx, rawTo, content, replyToID, replyToSender) - } - to, err := normalizeWhatsAppRecipientJID(rawTo) - if err != nil { - return err - } - caption := strings.TrimSpace(content) - for idx, mediaPath := range mediaPaths { - msg, err := s.buildMediaMessage(ctx, to, strings.TrimSpace(mediaPath), caption, replyToID, replyToSender) - if err != nil { - return err - } - if _, err := s.client.SendMessage(ctx, to, msg); err != nil { - return err - } - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.OutboundCount++ - st.LastOutboundAt = time.Now().Format(time.RFC3339) - st.LastOutboundTo = to.String() - st.LastOutboundText = truncateString(strings.TrimSpace(content), 120) - st.LastEvent = "message_outbound" - }) - if idx == 0 { - caption = "" - } - } - return nil -} - -func (s *WhatsAppBridgeService) buildMediaMessage(ctx context.Context, to types.JID, mediaPath, caption, replyToID, replyToSender string) (*waProto.Message, error) { - if s.client == nil { - return nil, fmt.Errorf("whatsapp client not initialized") - } - mediaPath = strings.TrimSpace(mediaPath) - if mediaPath == "" { - return nil, fmt.Errorf("media path is required") - } - data, err := os.ReadFile(mediaPath) - if err != nil { - return nil, fmt.Errorf("read media file: %w", err) - } - kind, mimeType := detectWhatsAppMediaType(mediaPath, data) - uploadType := whatsmeow.MediaDocument - switch kind { - case "image": - uploadType = whatsmeow.MediaImage - case "video": - uploadType = whatsmeow.MediaVideo - case "audio": - uploadType = whatsmeow.MediaAudio - } - resp, err := s.client.Upload(ctx, data, uploadType) - if err != nil { - return nil, fmt.Errorf("upload media: %w", err) - } - fileLength := resp.FileLength - fileName := filepath.Base(mediaPath) - switch kind { - case "image": - msg := &waProto.Message{ - ImageMessage: &waProto.ImageMessage{ - Caption: proto.String(strings.TrimSpace(caption)), - Mimetype: proto.String(mimeType), - URL: proto.String(resp.URL), - DirectPath: proto.String(resp.DirectPath), - MediaKey: resp.MediaKey, - FileEncSHA256: resp.FileEncSHA256, - FileSHA256: resp.FileSHA256, - FileLength: proto.Uint64(fileLength), - }, - } - applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) - return msg, nil - case "video": - msg := &waProto.Message{ - VideoMessage: &waProto.VideoMessage{ - Caption: proto.String(strings.TrimSpace(caption)), - Mimetype: proto.String(mimeType), - URL: proto.String(resp.URL), - DirectPath: proto.String(resp.DirectPath), - MediaKey: resp.MediaKey, - FileEncSHA256: resp.FileEncSHA256, - FileSHA256: resp.FileSHA256, - FileLength: proto.Uint64(fileLength), - }, - } - applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) - return msg, nil - case "audio": - msg := &waProto.Message{ - AudioMessage: &waProto.AudioMessage{ - Mimetype: proto.String(mimeType), - URL: proto.String(resp.URL), - DirectPath: proto.String(resp.DirectPath), - MediaKey: resp.MediaKey, - FileEncSHA256: resp.FileEncSHA256, - FileSHA256: resp.FileSHA256, - FileLength: proto.Uint64(fileLength), - }, - } - applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) - return msg, nil - default: - msg := &waProto.Message{ - DocumentMessage: &waProto.DocumentMessage{ - Caption: proto.String(strings.TrimSpace(caption)), - Mimetype: proto.String(mimeType), - Title: proto.String(fileName), - FileName: proto.String(fileName), - URL: proto.String(resp.URL), - DirectPath: proto.String(resp.DirectPath), - MediaKey: resp.MediaKey, - FileEncSHA256: resp.FileEncSHA256, - FileSHA256: resp.FileSHA256, - FileLength: proto.Uint64(fileLength), - }, - } - applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender)) - return msg, nil - } -} - -func detectWhatsAppMediaType(path string, data []byte) (kind string, mimeType string) { - ext := strings.ToLower(filepath.Ext(path)) - mimeType = mime.TypeByExtension(ext) - if mimeType == "" && len(data) > 0 { - mimeType = http.DetectContentType(data) - } - if mimeType == "" { - mimeType = "application/octet-stream" - } - switch { - case strings.HasPrefix(mimeType, "image/"): - return "image", mimeType - case strings.HasPrefix(mimeType, "video/"): - return "video", mimeType - case strings.HasPrefix(mimeType, "audio/"): - return "audio", mimeType - default: - return "document", mimeType - } -} - -func (s *WhatsAppBridgeService) matchCurrentUserContext(msg *waProto.Message) (mentionedSelf bool, replyToMe bool) { - if s.client == nil || s.client.Store.ID == nil || msg == nil { - return false, false - } - ctx := extractWhatsAppContextInfo(msg) - if ctx == nil { - return false, false - } - own := s.client.Store.ID.ToNonAD().String() - for _, mentioned := range ctx.GetMentionedJID() { - if normalizeComparableJID(mentioned) == own { - mentionedSelf = true - break - } - } - replyParticipant := normalizeComparableJID(ctx.GetParticipant()) - if replyParticipant != "" && replyParticipant == own { - replyToMe = true - } - return mentionedSelf, replyToMe -} - -func extractWhatsAppContextInfo(msg *waProto.Message) *waProto.ContextInfo { - switch { - case msg == nil: - return nil - case msg.GetExtendedTextMessage() != nil: - return msg.GetExtendedTextMessage().GetContextInfo() - case msg.GetImageMessage() != nil: - return msg.GetImageMessage().GetContextInfo() - case msg.GetVideoMessage() != nil: - return msg.GetVideoMessage().GetContextInfo() - case msg.GetAudioMessage() != nil: - return msg.GetAudioMessage().GetContextInfo() - case msg.GetDocumentMessage() != nil: - return msg.GetDocumentMessage().GetContextInfo() - case msg.GetDocumentWithCaptionMessage() != nil && msg.GetDocumentWithCaptionMessage().GetMessage() != nil: - return extractWhatsAppContextInfo(msg.GetDocumentWithCaptionMessage().GetMessage()) - default: - return nil - } -} - -func normalizeComparableJID(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - jid, err := types.ParseJID(raw) - if err == nil { - return jid.ToNonAD().String() - } - return raw -} - -func applyWhatsAppReplyContext(msg *waProto.Message, chatJID types.JID, replyToID, replyToSender string) { - if msg == nil || strings.TrimSpace(replyToID) == "" { - return - } - ctx := &waProto.ContextInfo{ - StanzaID: proto.String(strings.TrimSpace(replyToID)), - } - if chatJID.Server == types.GroupServer { - ctx.RemoteJID = proto.String(chatJID.ToNonAD().String()) - if sender := normalizeComparableJID(replyToSender); sender != "" { - ctx.Participant = proto.String(sender) - } - } - switch { - case msg.GetExtendedTextMessage() != nil: - msg.GetExtendedTextMessage().ContextInfo = ctx - case msg.GetImageMessage() != nil: - msg.GetImageMessage().ContextInfo = ctx - case msg.GetVideoMessage() != nil: - msg.GetVideoMessage().ContextInfo = ctx - case msg.GetAudioMessage() != nil: - msg.GetAudioMessage().ContextInfo = ctx - case msg.GetDocumentMessage() != nil: - msg.GetDocumentMessage().ContextInfo = ctx - default: - msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{ - Text: proto.String(msg.GetConversation()), - ContextInfo: ctx, - } - msg.Conversation = nil - } -} - -func (s *WhatsAppBridgeService) markIncomingReadReceipt(chat, sender types.JID, id types.MessageID, timestamp time.Time) { - if s == nil || s.markReadFn == nil || id == "" || chat.IsEmpty() { - return - } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - effectiveSender := types.EmptyJID - if chat.Server == types.GroupServer { - effectiveSender = sender - } - if err := s.markReadFn(ctx, []types.MessageID{id}, timestamp, chat, effectiveSender); err != nil { - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.LastError = "mark_read_failed: " + err.Error() - st.LastEvent = "mark_read_failed" - }) - return - } - s.updateStatus(func(st *WhatsAppBridgeStatus) { - st.ReadReceiptCount++ - st.LastReadAt = time.Now().Format(time.RFC3339) - st.LastEvent = "mark_read" - }) - }() -} - -func (s *WhatsAppBridgeService) updateStatus(mut func(*WhatsAppBridgeStatus)) { - s.statusMu.Lock() - defer s.statusMu.Unlock() - mut(&s.status) - s.status.UpdatedAt = time.Now().Format(time.RFC3339) -} - -func (s *WhatsAppBridgeService) broadcastWSMap(payload map[string]interface{}) { - s.wsClientsMu.Lock() - defer s.wsClientsMu.Unlock() - for conn := range s.wsClients { - _ = conn.WriteJSON(payload) - } -} - -func (s *WhatsAppBridgeService) closeWSClients() { - s.wsClientsMu.Lock() - defer s.wsClientsMu.Unlock() - for conn := range s.wsClients { - _ = conn.Close() - delete(s.wsClients, conn) - } -} - -func (s *WhatsAppBridgeService) ServeLogout(w http.ResponseWriter, r *http.Request) { - s.wrapHandler(s.handleLogout)(w, r) -} - -func normalizeWhatsAppRecipientJID(raw string) (types.JID, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return types.EmptyJID, fmt.Errorf("recipient is required") - } - if strings.Contains(raw, "@") { - jid, err := types.ParseJID(raw) - if err != nil { - return types.EmptyJID, fmt.Errorf("parse recipient jid: %w", err) - } - return jid.ToNonAD(), nil - } - if strings.Contains(raw, "-") { - return types.NewJID(raw, types.GroupServer), nil - } - return types.NewJID(raw, types.DefaultUserServer), nil -} - -func extractWhatsAppMessageText(msg *waProto.Message) string { - if msg == nil { - return "" - } - switch { - case strings.TrimSpace(msg.GetConversation()) != "": - return msg.GetConversation() - case msg.GetExtendedTextMessage() != nil && strings.TrimSpace(msg.GetExtendedTextMessage().GetText()) != "": - return msg.GetExtendedTextMessage().GetText() - case msg.GetImageMessage() != nil && strings.TrimSpace(msg.GetImageMessage().GetCaption()) != "": - return msg.GetImageMessage().GetCaption() - case msg.GetVideoMessage() != nil && strings.TrimSpace(msg.GetVideoMessage().GetCaption()) != "": - return msg.GetVideoMessage().GetCaption() - case msg.GetDocumentMessage() != nil && strings.TrimSpace(msg.GetDocumentMessage().GetCaption()) != "": - return msg.GetDocumentMessage().GetCaption() - case msg.GetAudioMessage() != nil: - return "[audio]" - case msg.GetStickerMessage() != nil: - return "[sticker]" - case msg.GetImageMessage() != nil: - return "[image]" - case msg.GetVideoMessage() != nil: - return "[video]" - case msg.GetDocumentMessage() != nil: - return "[document]" - default: - return "" - } -} diff --git a/pkg/channels/whatsapp_bridge_stub.go b/pkg/channels/whatsapp_bridge_stub.go deleted file mode 100644 index f37eb52..0000000 --- a/pkg/channels/whatsapp_bridge_stub.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build omit_whatsapp - -package channels - -import ( - "context" - "net/http" - "strings" -) - -type WhatsAppBridgeService struct { - addr string - stateDir string - printQR bool - status WhatsAppBridgeStatus -} - -func NewWhatsAppBridgeService(addr, stateDir string, printQR bool) *WhatsAppBridgeService { - return &WhatsAppBridgeService{ - addr: strings.TrimSpace(addr), - stateDir: strings.TrimSpace(stateDir), - printQR: printQR, - status: WhatsAppBridgeStatus{ - State: "disabled", - BridgeAddr: strings.TrimSpace(addr), - }, - } -} - -func (s *WhatsAppBridgeService) Start(ctx context.Context) error { - return errChannelDisabled("whatsapp") -} - -func (s *WhatsAppBridgeService) StartEmbedded(ctx context.Context) error { - return errChannelDisabled("whatsapp") -} - -func (s *WhatsAppBridgeService) Stop() {} - -func (s *WhatsAppBridgeService) RegisterRoutes(mux *http.ServeMux, basePath string) {} - -func (s *WhatsAppBridgeService) StatusSnapshot() WhatsAppBridgeStatus { - return s.status -} - -func (s *WhatsAppBridgeService) ServeWS(w http.ResponseWriter, r *http.Request) { - http.Error(w, errChannelDisabled("whatsapp").Error(), http.StatusNotImplemented) -} - -func (s *WhatsAppBridgeService) ServeStatus(w http.ResponseWriter, r *http.Request) { - http.Error(w, errChannelDisabled("whatsapp").Error(), http.StatusNotImplemented) -} - -func (s *WhatsAppBridgeService) ServeLogout(w http.ResponseWriter, r *http.Request) { - http.Error(w, errChannelDisabled("whatsapp").Error(), http.StatusNotImplemented) -} diff --git a/pkg/channels/whatsapp_bridge_test.go b/pkg/channels/whatsapp_bridge_test.go deleted file mode 100644 index bd02848..0000000 --- a/pkg/channels/whatsapp_bridge_test.go +++ /dev/null @@ -1,238 +0,0 @@ -//go:build !omit_whatsapp - -package channels - -import ( - "context" - "encoding/json" - "net" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/bus" - waProto "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" - "google.golang.org/protobuf/proto" -) - -func TestParseWhatsAppBridgeListenAddr(t *testing.T) { - tests := []struct { - name string - input string - want string - wantErr bool - }{ - {name: "raw host", input: "127.0.0.1:3001", want: "127.0.0.1:3001"}, - {name: "ws url", input: "ws://localhost:3001", want: "localhost:3001"}, - {name: "ws url path", input: "ws://localhost:3001/ws", want: "localhost:3001"}, - {name: "missing host", input: "ws:///ws", wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ParseWhatsAppBridgeListenAddr(tt.input) - if tt.wantErr { - if err == nil { - t.Fatalf("expected error, got none") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Fatalf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestBridgeStatusURL(t *testing.T) { - got, err := BridgeStatusURL("ws://localhost:3001/ws") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "http://localhost:3001/status" { - t.Fatalf("got %q", got) - } -} - -func TestBridgeStatusURLWithNestedPath(t *testing.T) { - got, err := BridgeStatusURL("ws://localhost:7788/whatsapp/ws") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "http://localhost:7788/whatsapp/status" { - t.Fatalf("got %q", got) - } -} - -func TestIsLocalRemoteAddr(t *testing.T) { - ipv4Net := &net.IPNet{IP: net.ParseIP("192.168.1.10"), Mask: net.CIDRMask(24, 32)} - ipv6Net := &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)} - - tests := []struct { - name string - remoteAddr string - want bool - }{ - {name: "loopback", remoteAddr: "127.0.0.1:4321", want: true}, - {name: "local interface ipv4", remoteAddr: "192.168.1.10:4321", want: true}, - {name: "local interface ipv6", remoteAddr: "[fe80::1]:4321", want: true}, - {name: "non local ip", remoteAddr: "192.168.1.11:4321", want: false}, - {name: "invalid host", remoteAddr: "not-an-ip", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isLocalRemoteAddr(tt.remoteAddr, []net.Addr{ipv4Net, ipv6Net}) - if got != tt.want { - t.Fatalf("got %v want %v", got, tt.want) - } - }) - } -} - -func TestNormalizeWhatsAppRecipientJID(t *testing.T) { - tests := []struct { - input string - want string - }{ - {input: "8613012345678", want: "8613012345678@s.whatsapp.net"}, - {input: "1203630-123456789@g.us", want: "1203630-123456789@g.us"}, - {input: "1203630-123456789", want: "1203630-123456789@g.us"}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got, err := normalizeWhatsAppRecipientJID(tt.input) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.String() != tt.want { - t.Fatalf("got %q, want %q", got.String(), tt.want) - } - }) - } -} - -func TestDetectWhatsAppMediaType(t *testing.T) { - tests := []struct { - path string - data []byte - wantKind string - wantMime string - }{ - {path: "photo.jpg", data: []byte{0xff, 0xd8, 0xff, 0xe0}, wantKind: "image", wantMime: "image/jpeg"}, - {path: "clip.mp4", data: []byte("...."), wantKind: "video", wantMime: "video/mp4"}, - {path: "voice.ogg", data: []byte("OggS"), wantKind: "audio", wantMime: "audio/ogg"}, - {path: "report.pdf", data: []byte("%PDF-1.4"), wantKind: "document", wantMime: "application/pdf"}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - gotKind, gotMime := detectWhatsAppMediaType(tt.path, tt.data) - if gotKind != tt.wantKind { - t.Fatalf("kind got %q want %q", gotKind, tt.wantKind) - } - if gotMime != tt.wantMime { - t.Fatalf("mime got %q want %q", gotMime, tt.wantMime) - } - }) - } -} - -func TestWhatsAppSendIncludesMediaPayload(t *testing.T) { - msg := bus.OutboundMessage{ - Channel: "whatsapp", - ChatID: "12345@s.whatsapp.net", - Content: "hello", - Media: "/tmp/demo.png", - ReplyToID: "wamid.demo", - } - payload := map[string]interface{}{ - "type": "message", - "to": msg.ChatID, - "content": msg.Content, - } - if msg.ReplyToID != "" { - payload["reply_to_id"] = msg.ReplyToID - } - if msg.Media != "" { - payload["media"] = []string{msg.Media} - } - data, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal: %v", err) - } - var parsed map[string]interface{} - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal: %v", err) - } - media, ok := parsed["media"].([]interface{}) - if !ok || len(media) != 1 || media[0] != msg.Media { - t.Fatalf("unexpected media payload: %#v", parsed["media"]) - } - if parsed["reply_to_id"] != msg.ReplyToID { - t.Fatalf("unexpected reply_to_id payload: %#v", parsed["reply_to_id"]) - } -} - -func TestExtractWhatsAppContextInfo(t *testing.T) { - ctx := &waProto.ContextInfo{MentionedJID: []string{"8613012345678@s.whatsapp.net"}} - msg := &waProto.Message{ - ExtendedTextMessage: &waProto.ExtendedTextMessage{ - Text: proto.String("hi"), - ContextInfo: ctx, - }, - } - got := extractWhatsAppContextInfo(msg) - if got == nil || len(got.GetMentionedJID()) != 1 { - t.Fatalf("expected context info to be extracted") - } -} - -func TestNormalizeComparableJID(t *testing.T) { - jid := types.NewJID("8613012345678", types.DefaultUserServer) - got := normalizeComparableJID(jid.ADString()) - if got != jid.String() { - t.Fatalf("got %q want %q", got, jid.String()) - } -} - -func TestApplyWhatsAppReplyContext(t *testing.T) { - msg := &waProto.Message{Conversation: proto.String("hello")} - applyWhatsAppReplyContext(msg, types.NewJID("12345", types.DefaultUserServer), "wamid.reply", "") - if msg.GetExtendedTextMessage() == nil || msg.GetExtendedTextMessage().GetContextInfo().GetStanzaID() != "wamid.reply" { - t.Fatalf("expected reply context on text message") - } -} - -func TestMarkIncomingReadReceiptUsesSenderOnlyForGroups(t *testing.T) { - service := &WhatsAppBridgeService{} - done := make(chan struct{}, 2) - var gotChat, gotSender types.JID - service.markReadFn = func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error { - gotChat = chat - gotSender = sender - done <- struct{}{} - return nil - } - - service.markIncomingReadReceipt(types.NewJID("1203630-123456789", types.GroupServer), types.NewJID("8613012345678", types.DefaultUserServer), types.MessageID("abc"), time.Now()) - select { - case <-done: - case <-time.After(500 * time.Millisecond): - t.Fatalf("timed out waiting for group mark read") - } - if gotChat.Server != types.GroupServer || gotSender.Server != types.DefaultUserServer { - t.Fatalf("unexpected group mark read args: chat=%s sender=%s", gotChat, gotSender) - } - - service.markIncomingReadReceipt(types.NewJID("8613012345678", types.DefaultUserServer), types.NewJID("8620000000000", types.DefaultUserServer), types.MessageID("def"), time.Now()) - select { - case <-done: - case <-time.After(500 * time.Millisecond): - t.Fatalf("timed out waiting for direct mark read") - } - if !gotSender.IsEmpty() { - t.Fatalf("expected empty sender for direct chat, got %s", gotSender) - } -} diff --git a/pkg/channels/whatsapp_common.go b/pkg/channels/whatsapp_common.go deleted file mode 100644 index 703bc5a..0000000 --- a/pkg/channels/whatsapp_common.go +++ /dev/null @@ -1,159 +0,0 @@ -package channels - -import ( - "fmt" - "net" - "net/http" - "net/url" - "strings" -) - -type WhatsAppBridgeStatus struct { - State string `json:"state"` - Connected bool `json:"connected"` - LoggedIn bool `json:"logged_in"` - BridgeAddr string `json:"bridge_addr"` - UserJID string `json:"user_jid,omitempty"` - PushName string `json:"push_name,omitempty"` - Platform string `json:"platform,omitempty"` - QRCode string `json:"qr_code,omitempty"` - QRAvailable bool `json:"qr_available"` - LastEvent string `json:"last_event,omitempty"` - LastError string `json:"last_error,omitempty"` - UpdatedAt string `json:"updated_at"` - InboundCount int `json:"inbound_count"` - OutboundCount int `json:"outbound_count"` - ReadReceiptCount int `json:"read_receipt_count"` - LastInboundAt string `json:"last_inbound_at,omitempty"` - LastOutboundAt string `json:"last_outbound_at,omitempty"` - LastReadAt string `json:"last_read_at,omitempty"` - LastInboundFrom string `json:"last_inbound_from,omitempty"` - LastOutboundTo string `json:"last_outbound_to,omitempty"` - LastInboundText string `json:"last_inbound_text,omitempty"` - LastOutboundText string `json:"last_outbound_text,omitempty"` -} - -func ParseWhatsAppBridgeListenAddr(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", fmt.Errorf("bridge url is required") - } - if strings.Contains(raw, "://") { - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("parse bridge url: %w", err) - } - if strings.TrimSpace(u.Host) == "" { - return "", fmt.Errorf("bridge url host is required") - } - return u.Host, nil - } - return raw, nil -} - -func BridgeStatusURL(raw string) (string, error) { - return bridgeEndpointURL(raw, "status") -} - -func BridgeLogoutURL(raw string) (string, error) { - return bridgeEndpointURL(raw, "logout") -} - -func bridgeEndpointURL(raw, endpoint string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return "", fmt.Errorf("bridge url is required") - } - if !strings.Contains(raw, "://") { - raw = "ws://" + raw - } - u, err := url.Parse(raw) - if err != nil { - return "", fmt.Errorf("parse bridge url: %w", err) - } - switch u.Scheme { - case "wss": - u.Scheme = "https" - default: - u.Scheme = "http" - } - u.Path = bridgeSiblingPath(u.Path, endpoint) - u.RawQuery = "" - u.Fragment = "" - return u.String(), nil -} - -func bridgeSiblingPath(pathValue, endpoint string) string { - pathValue = strings.TrimSpace(pathValue) - if endpoint == "" { - endpoint = "status" - } - if pathValue == "" || pathValue == "/" { - return "/" + endpoint - } - trimmed := strings.TrimSuffix(pathValue, "/") - if strings.HasSuffix(trimmed, "/ws") { - return strings.TrimSuffix(trimmed, "/ws") + "/" + endpoint - } - return trimmed + "/" + endpoint -} - -func normalizeBridgeBasePath(basePath string) string { - basePath = strings.TrimSpace(basePath) - if basePath == "" || basePath == "/" { - return "/" - } - if !strings.HasPrefix(basePath, "/") { - basePath = "/" + basePath - } - return strings.TrimSuffix(basePath, "/") -} - -func joinBridgeRoute(basePath, endpoint string) string { - basePath = normalizeBridgeBasePath(basePath) - if basePath == "/" { - return "/" + strings.TrimPrefix(endpoint, "/") - } - return basePath + "/" + strings.TrimPrefix(endpoint, "/") -} - -func isLocalRequest(r *http.Request) bool { - if r == nil { - return false - } - addrs, err := net.InterfaceAddrs() - if err != nil { - return false - } - return isLocalRemoteAddr(strings.TrimSpace(r.RemoteAddr), addrs) -} - -func isLocalRemoteAddr(remoteAddr string, localAddrs []net.Addr) bool { - host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr)) - if err != nil { - host = strings.TrimSpace(remoteAddr) - } - ip := net.ParseIP(host) - if ip == nil { - return false - } - if ip.IsLoopback() { - return true - } - for _, addr := range localAddrs { - if addr == nil { - continue - } - switch v := addr.(type) { - case *net.IPNet: - if v.IP != nil && v.IP.Equal(ip) { - return true - } - case *net.IPAddr: - if v.IP != nil && v.IP.Equal(ip) { - return true - } - } - } - return false -} diff --git a/pkg/channels/whatsapp_stub.go b/pkg/channels/whatsapp_stub.go deleted file mode 100644 index c3c53f3..0000000 --- a/pkg/channels/whatsapp_stub.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build omit_whatsapp - -package channels - -import ( - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -type WhatsAppChannel struct{ disabledChannel } - -const whatsappCompiled = false - -func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - return nil, errChannelDisabled("whatsapp") -} diff --git a/pkg/channels/whatsapp_test.go b/pkg/channels/whatsapp_test.go deleted file mode 100644 index 1d5e7e4..0000000 --- a/pkg/channels/whatsapp_test.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build !omit_whatsapp - -package channels - -import ( - "testing" - - "github.com/YspCoder/clawgo/pkg/config" -) - -func TestWhatsAppShouldHandleIncomingMessage(t *testing.T) { - ch := &WhatsAppChannel{ - config: config.WhatsAppConfig{ - EnableGroups: true, - RequireMentionInGroups: true, - }, - } - if !ch.shouldHandleIncomingMessage(false, false, false) { - t.Fatalf("private chats should always be allowed") - } - if ch.shouldHandleIncomingMessage(true, false, false) { - t.Fatalf("group message without mention should be blocked") - } - if !ch.shouldHandleIncomingMessage(true, true, false) { - t.Fatalf("group mention should be allowed") - } - if !ch.shouldHandleIncomingMessage(true, false, true) { - t.Fatalf("reply-to-me should be allowed") - } - - ch.config.EnableGroups = false - if ch.shouldHandleIncomingMessage(true, true, true) { - t.Fatalf("groups should be blocked when disabled") - } - - ch.config.EnableGroups = true - ch.config.RequireMentionInGroups = false - if !ch.shouldHandleIncomingMessage(true, false, false) { - t.Fatalf("group should be allowed when mention is not required") - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1b08eca..7267ddb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -171,21 +171,8 @@ type ChannelsConfig struct { InboundContentDedupeWindowSeconds int `json:"inbound_content_dedupe_window_seconds" env:"CLAWGO_CHANNELS_INBOUND_CONTENT_DEDUPE_WINDOW_SECONDS"` OutboundDedupeWindowSeconds int `json:"outbound_dedupe_window_seconds" env:"CLAWGO_CHANNELS_OUTBOUND_DEDUPE_WINDOW_SECONDS"` Weixin WeixinConfig `json:"weixin"` - WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` -} - -type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"CLAWGO_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_WHATSAPP_ALLOW_FROM"` - EnableGroups bool `json:"enable_groups" env:"CLAWGO_CHANNELS_WHATSAPP_ENABLE_GROUPS"` - RequireMentionInGroups bool `json:"require_mention_in_groups" env:"CLAWGO_CHANNELS_WHATSAPP_REQUIRE_MENTION_IN_GROUPS"` } type WeixinConfig struct { @@ -231,33 +218,6 @@ type FeishuConfig struct { RequireMentionInGroups bool `json:"require_mention_in_groups" env:"CLAWGO_CHANNELS_FEISHU_REQUIRE_MENTION_IN_GROUPS"` } -type DiscordConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"CLAWGO_CHANNELS_DISCORD_TOKEN"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_DISCORD_ALLOW_FROM"` -} - -type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"CLAWGO_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"CLAWGO_CHANNELS_MAIXCAM_PORT"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_MAIXCAM_ALLOW_FROM"` -} - -type QQConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"CLAWGO_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"CLAWGO_CHANNELS_QQ_APP_SECRET"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_QQ_ALLOW_FROM"` -} - -type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"CLAWGO_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"CLAWGO_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_DINGTALK_ALLOW_FROM"` -} - type ModelsConfig struct { Providers map[string]ProviderConfig `json:"providers,omitempty"` } @@ -494,13 +454,6 @@ func DefaultConfig() *Config { Accounts: []WeixinAccountConfig{}, AllowFrom: []string{}, }, - WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "", - AllowFrom: []string{}, - EnableGroups: true, - RequireMentionInGroups: true, - }, Telegram: TelegramConfig{ Enabled: false, Token: "", @@ -521,29 +474,6 @@ func DefaultConfig() *Config { EnableGroups: true, RequireMentionInGroups: true, }, - Discord: DiscordConfig{ - Enabled: false, - Token: "", - AllowFrom: []string{}, - }, - MaixCam: MaixCamConfig{ - Enabled: false, - Host: "0.0.0.0", - Port: 18790, - AllowFrom: []string{}, - }, - QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: []string{}, - }, - DingTalk: DingTalkConfig{ - Enabled: false, - ClientID: "", - ClientSecret: "", - AllowFrom: []string{}, - }, }, Models: ModelsConfig{ Providers: map[string]ProviderConfig{ diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 5032595..529824b 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -190,17 +190,6 @@ func Validate(cfg *Config) []error { if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { errs = append(errs, fmt.Errorf("channels.telegram.token is required when channels.telegram.enabled=true")) } - if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" { - errs = append(errs, fmt.Errorf("channels.discord.token is required when channels.discord.enabled=true")) - } - if cfg.Channels.DingTalk.Enabled { - if cfg.Channels.DingTalk.ClientID == "" { - errs = append(errs, fmt.Errorf("channels.dingtalk.client_id is required when channels.dingtalk.enabled=true")) - } - if cfg.Channels.DingTalk.ClientSecret == "" { - errs = append(errs, fmt.Errorf("channels.dingtalk.client_secret is required when channels.dingtalk.enabled=true")) - } - } if cfg.Channels.Feishu.Enabled { if cfg.Channels.Feishu.AppID == "" { errs = append(errs, fmt.Errorf("channels.feishu.app_id is required when channels.feishu.enabled=true")) @@ -209,14 +198,6 @@ func Validate(cfg *Config) []error { errs = append(errs, fmt.Errorf("channels.feishu.app_secret is required when channels.feishu.enabled=true")) } } - if cfg.Channels.QQ.Enabled { - if cfg.Channels.QQ.AppID == "" { - errs = append(errs, fmt.Errorf("channels.qq.app_id is required when channels.qq.enabled=true")) - } - if cfg.Channels.QQ.AppSecret == "" { - errs = append(errs, fmt.Errorf("channels.qq.app_secret is required when channels.qq.enabled=true")) - } - } return errs } diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index c865f5d..e1df981 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -111,6 +111,35 @@ func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) { } } +func TestValidateSubagentsRejectsNodeTransport(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + cfg.Agents.Subagents["coder"] = SubagentConfig{ + Enabled: true, + Transport: "node", + SystemPromptFile: "agents/coder/AGENT.md", + Runtime: SubagentRuntimeConfig{ + Provider: "openai", + }, + } + + errs := Validate(cfg) + if len(errs) == 0 { + t.Fatalf("expected validation errors") + } + found := false + for _, err := range errs { + if strings.Contains(err.Error(), "agents.subagents.coder.transport") { + found = true + break + } + } + if !found { + t.Fatalf("expected transport validation error, got %v", errs) + } +} + func TestValidateSentinelWebhookURLRejectsInvalidScheme(t *testing.T) { t.Parallel() diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 37fadae..430b0a6 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -359,8 +359,6 @@ func detectSessionKind(key string) string { return "subagent" case strings.HasPrefix(k, "hook:"): return "hook" - case strings.HasPrefix(k, "node:"): - return "node" case strings.Contains(k, ":"): return "main" default: @@ -444,7 +442,7 @@ func mapKindToChatType(kind string) string { switch strings.ToLower(strings.TrimSpace(kind)) { case "main": return "direct" - case "cron", "subagent", "hook", "node": + case "cron", "subagent", "hook": return "internal" default: return "unknown" diff --git a/pkg/tools/message.go b/pkg/tools/message.go index f293460..07d5601 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -54,7 +54,7 @@ func (t *MessageTool) Parameters() map[string]interface{} { }, "channel": map[string]interface{}{ "type": "string", - "description": "Optional: target channel (telegram, whatsapp, etc.)", + "description": "Optional: target channel (telegram, feishu, weixin)", }, "chat_id": map[string]interface{}{ "type": "string", diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go index 16df5b0..765c404 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/subagent_profile.go @@ -177,7 +177,7 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { if p.Name == "" { p.Name = p.AgentID } - p.Transport = normalizeProfileTransport(p.Transport) + p.ParentAgentID = normalizeSubagentIdentifier(p.ParentAgentID) p.NotifyMainPolicy = normalizeNotifyMainPolicy(p.NotifyMainPolicy) p.Role = strings.TrimSpace(p.Role) @@ -207,17 +207,6 @@ func normalizeProfileStatus(s string) string { } } -func normalizeProfileTransport(s string) string { - switch strings.ToLower(strings.TrimSpace(s)) { - case "", "local": - return "local" - case "node": - return "node" - default: - return "local" - } -} - func normalizeStringList(in []string) []string { if len(in) == 0 { return nil diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md index 7628b41..034ac2d 100644 --- a/workspace/IDENTITY.md +++ b/workspace/IDENTITY.md @@ -20,7 +20,7 @@ Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. - Web search and content fetching - File system operations (read, write, edit) - Shell command execution -- Multi-channel messaging (Telegram, WhatsApp, Feishu) +- Multi-channel messaging (Weixin, Telegram, Feishu) - Skill-based extensibility - Memory and context management diff --git a/workspace/embedkeep.txt b/workspace/embedkeep.txt deleted file mode 100644 index b54a93c..0000000 --- a/workspace/embedkeep.txt +++ /dev/null @@ -1 +0,0 @@ -embed workspace placeholder diff --git a/workspace/skills/clawhub/SKILL.md b/workspace/skills/clawhub/SKILL.md index f44c82b..3b23551 100644 --- a/workspace/skills/clawhub/SKILL.md +++ b/workspace/skills/clawhub/SKILL.md @@ -1,21 +1,11 @@ --- name: clawhub -description: Use the ClawHub CLI to search, install, update, and publish agent skills from clawhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawhub CLI. +description: Use the ClawHub CLI to search, install, update, and publish agent skills from clawhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders. metadata: { "openclaw": { "requires": { "bins": ["clawhub"] }, - "install": - [ - { - "id": "node", - "kind": "node", - "package": "clawhub", - "bins": ["clawhub"], - "label": "Install ClawHub CLI (npm)", - }, - ], }, } ---