diff --git a/AGENTS.md b/AGENTS.md index 0bbfa09..0469017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,8 @@ CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemin │ ├── opencode/ │ ├── wecom/ │ │ ├── acp/ │ ├── qq/ │ │ └── qoder/ │ ├── qqbot/ │ -│ │ └── line/ │ +│ │ ├── line/ │ +│ │ └── weibo/ │ ├──────────────────────┴──────────────────────────┤ │ daemon/ │ ← systemd/launchd service └─────────────────────────────────────────────────┘ @@ -204,7 +205,7 @@ go build -tags 'no_discord no_dingtalk no_qq no_qqbot no_line' ./cmd/cc-connect Available tags: `no_acp`, `no_claudecode`, `no_codex`, `no_cursor`, `no_gemini`, `no_iflow`, `no_opencode`, `no_qoder`, `no_feishu`, `no_telegram`, `no_discord`, `no_slack`, `no_dingtalk`, `no_wecom`, `no_weixin`, `no_qq`, `no_qqbot`, -`no_line`. +`no_line`, `no_weibo`. ## Pre-Commit Checklist diff --git a/CLAUDE.md b/CLAUDE.md index 0bbfa09..0469017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,8 @@ CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemin │ ├── opencode/ │ ├── wecom/ │ │ ├── acp/ │ ├── qq/ │ │ └── qoder/ │ ├── qqbot/ │ -│ │ └── line/ │ +│ │ ├── line/ │ +│ │ └── weibo/ │ ├──────────────────────┴──────────────────────────┤ │ daemon/ │ ← systemd/launchd service └─────────────────────────────────────────────────┘ @@ -204,7 +205,7 @@ go build -tags 'no_discord no_dingtalk no_qq no_qqbot no_line' ./cmd/cc-connect Available tags: `no_acp`, `no_claudecode`, `no_codex`, `no_cursor`, `no_gemini`, `no_iflow`, `no_opencode`, `no_qoder`, `no_feishu`, `no_telegram`, `no_discord`, `no_slack`, `no_dingtalk`, `no_wecom`, `no_weixin`, `no_qq`, `no_qqbot`, -`no_line`. +`no_line`, `no_weibo`. ## Pre-Commit Checklist diff --git a/Makefile b/Makefile index c10b8f7..02f5077 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ PLATFORMS := \ # --------------------------------------------------------------------------- ALL_AGENTS := acp claudecode codex cursor gemini iflow kimi opencode pi qoder -ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo ALL_EXTRAS := web COMMA := , diff --git a/README.md b/README.md index fa6c037..bf682c6 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,15 @@ High-level view of what each **built-in platform** can do in cc-connect. Inspire † **QQ (NapCat / OneBot)** — unofficial self-hosted bridge; behaviour depends on your NapCat / network setup. -| Capability | Feishu | DingTalk | Telegram | Slack | Discord | LINE | WeCom | **Weixin**
*(personal)* | QQ† | QQ Bot | -|------------|:------:|:--------:|:--------:|:-----:|:-------:|:----:|:-----:|:-------------------------:|:---:|:------:| -| Text & slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | -| Markdown / cards | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ✅(beta) | ✅ | ✅ | -| Streaming / chunked replies | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | -| Images & files | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅(beta) | ✅ | ✅ | -| Voice / STT / TTS | ⚠️ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ✅(beta) | ⚠️ | ⚠️ | -| Private (DM) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | -| Group / channel | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅(beta) | ✅ | ✅ | +| Capability | Feishu | DingTalk | Telegram | Slack | Discord | LINE | WeCom | Weibo | **Weixin**
*(personal)* | QQ† | QQ Bot | +|------------|:------:|:--------:|:--------:|:-----:|:-------:|:----:|:-----:|:-----:|:-------------------------:|:---:|:------:| +| Text & slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | +| Markdown / cards | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅(beta) | ✅ | ✅ | +| Streaming / chunked replies | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | +| Images & files | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅(beta) | ✅ | ✅ | +| Voice / STT / TTS | ⚠️ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ❌ | ✅(beta) | ⚠️ | ⚠️ | +| Private (DM) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅(beta) | ✅ | ✅ | +| Group / channel | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅(beta) | ✅ | ✅ | > **Weixin column:** every **✅(beta)** means “available only when you run a **beta / pre-release** build,” not a statement that the feature is incomplete — the whole **personal WeChat (ilink)** channel is still **pre-stable**. > **WeCom:** Webhook mode needs a **public URL**; long-connection / WS style setups often do not. @@ -140,7 +140,7 @@ High-level view of what each **built-in platform** can do in cc-connect. Inspire **7 AI Agents** — Claude Code, Codex, Cursor Agent, Qoder CLI, Gemini CLI, OpenCode, iFlow CLI. Use whichever fits your workflow, or all of them at once. ### 📱 Platform Flexibility -**10 Chat Platforms** — Feishu, DingTalk, Slack, Telegram, Discord, WeChat Work, LINE, QQ, QQ Bot (Official), plus **Weixin (personal ilink)** for **personal WeChat**. *Personal WeChat is **beta / pre-release only*** — install [`cc-connect@beta`](https://www.npmjs.com/package/cc-connect?activeTab=versions) or a [GitHub pre-release](https://github.com/chenhg5/cc-connect/releases) binary; the default **stable** npm package does **not** ship the `weixin` platform yet. Most platforms need **zero public IP**. +**11 Chat Platforms** — Feishu, DingTalk, Slack, Telegram, Discord, WeChat Work, Weibo, LINE, QQ, QQ Bot (Official), plus **Weixin (personal ilink)** for **personal WeChat**. *Personal WeChat is **beta / pre-release only*** — install [`cc-connect@beta`](https://www.npmjs.com/package/cc-connect?activeTab=versions) or a [GitHub pre-release](https://github.com/chenhg5/cc-connect/releases) binary; the default **stable** npm package does **not** ship the `weixin` platform yet. Most platforms need **zero public IP**. ### 🔄 Multi-Agent Orchestration **Multi-Bot Relay** — Bind multiple bots in a group chat and let them communicate with each other. Ask Claude, get insights from Gemini — all in one conversation. @@ -271,6 +271,7 @@ cc-connect update --pre # Beta (includes pre-releases) | Platform | Telegram | ✅ Long Polling — no public IP needed | | Platform | Slack | ✅ Socket Mode — no public IP needed | | Platform | Discord | ✅ Gateway — no public IP needed | +| Platform | Weibo | ✅ WebSocket — no public IP needed | | Platform | LINE | ✅ Webhook — public URL required | | Platform | WeChat Work | ✅ WebSocket / Webhook | | Platform | Weixin (personal, ilink) | ✅(beta)— HTTP long polling — no public IP needed | @@ -287,6 +288,7 @@ cc-connect update --pre # Beta (includes pre-releases) | Telegram | [docs/telegram.md](docs/telegram.md) | Long Polling | No | | Slack | [docs/slack.md](docs/slack.md) | Socket Mode | No | | Discord | [docs/discord.md](docs/discord.md) | Gateway | No | +| Weibo | [docs/weibo.md](docs/weibo.md) | WebSocket | No | | WeChat Work | [docs/wecom.md](docs/wecom.md) | WebSocket / Webhook | No (WS) / Yes (Webhook) | | Weixin (personal) | [docs/weixin.md](docs/weixin.md) | HTTP long polling (ilink) — **beta only** | No | | QQ / QQ Bot | [docs/qq.md](docs/qq.md) | WebSocket | No | diff --git a/cmd/cc-connect/plugin_platform_weibo.go b/cmd/cc-connect/plugin_platform_weibo.go new file mode 100644 index 0000000..2a9fceb --- /dev/null +++ b/cmd/cc-connect/plugin_platform_weibo.go @@ -0,0 +1,5 @@ +//go:build !no_weibo + +package main + +import _ "github.com/chenhg5/cc-connect/platform/weibo" diff --git a/config.example.toml b/config.example.toml index 41438ce..3944ebd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -898,6 +898,23 @@ app_secret = "your-feishu-app-secret" # callback_path = "/callback" # allow_from = "*" # Allowed LINE user IDs / 允许的 LINE 用户 ID +# Weibo / 微博私信 (uncomment to enable / 取消注释以启用) +# 1. Register an app at https://open.weibo.com/ via 微博龙虾助手 +# 在微博开放平台注册应用,获取 app_id 和 app_secret +# 2. The bot receives and sends DMs via WebSocket (open-im.api.weibo.com) +# 机器人通过 WebSocket 收发微博私信 +# Connection: WebSocket — no public URL required / 无需公网地址 + +# [[projects.platforms]] +# type = "weibo" +# +# [projects.platforms.options] +# app_id = "your-weibo-app-id" +# app_secret = "your-weibo-app-secret" +# allow_from = "*" # Allowed Weibo user IDs / 允许的微博用户 ID +# token_endpoint = "" # Optional: override token endpoint / 可选:自定义 token 接口地址 +# ws_endpoint = "" # Optional: override WebSocket endpoint / 可选:自定义 WebSocket 地址 + # WeChat Work / 企业微信 (uncomment to enable / 取消注释以启用) # 1. Log in to https://work.weixin.qq.com/wework_admin/frame # 登录企业微信管理后台 diff --git a/docs/dingtalk.md b/docs/dingtalk.md index 9da5e63..9ee8869 100644 --- a/docs/dingtalk.md +++ b/docs/dingtalk.md @@ -299,6 +299,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。 ## 下一步 - [接入飞书](./feishu.md) +- [接入微博](./weibo.md) - [接入 Telegram](./telegram.md) - [接入 Slack](./slack.md) - [接入 Discord](./discord.md) diff --git a/docs/discord.md b/docs/discord.md index 5a9a52b..e181ac3 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -303,6 +303,7 @@ Check: - [Feishu Setup](./feishu.md) - [DingTalk Setup](./dingtalk.md) +- [Weibo Setup](./weibo.md) - [Telegram Setup](./telegram.md) - [Slack Setup](./slack.md) - [Back to README](../README.md) diff --git a/docs/feishu.md b/docs/feishu.md index e2719dc..fb2db59 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -404,6 +404,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。 ## 下一步 - [接入钉钉](./dingtalk.md) +- [接入微博](./weibo.md) - [接入 Telegram](./telegram.md) - [接入 Slack](./slack.md) - [接入 Discord](./discord.md) diff --git a/docs/slack.md b/docs/slack.md index 1dcf3c2..18b9501 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -320,6 +320,7 @@ Make sure: - [Feishu Setup](./feishu.md) - [DingTalk Setup](./dingtalk.md) +- [Weibo Setup](./weibo.md) - [Telegram Setup](./telegram.md) - [Discord Setup](./discord.md) - [Back to README](../README.md) diff --git a/docs/telegram.md b/docs/telegram.md index 2099c07..b75c315 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -267,6 +267,7 @@ Make sure Group Privacy mode is disabled. In BotFather: `/mybots` → select bot - [Feishu Setup](./feishu.md) - [DingTalk Setup](./dingtalk.md) +- [Weibo Setup](./weibo.md) - [Slack Setup](./slack.md) - [Discord Setup](./discord.md) - [Back to README](../README.md) diff --git a/docs/wecom.md b/docs/wecom.md index 2580dce..5957684 100644 --- a/docs/wecom.md +++ b/docs/wecom.md @@ -438,6 +438,7 @@ proxy = "http://vps-ip:8888" - [接入飞书](./feishu.md) - [接入钉钉](./dingtalk.md) +- [接入微博](./weibo.md) - [接入 Telegram](./telegram.md) - [接入 Slack](./slack.md) - [接入 Discord](./discord.md) diff --git a/docs/weibo.md b/docs/weibo.md new file mode 100644 index 0000000..13efcd9 --- /dev/null +++ b/docs/weibo.md @@ -0,0 +1,210 @@ +# 微博私信接入指南 + +本文档介绍如何将 **cc-connect** 接入微博私信,让你可以通过微博私信远程调用 AI 编程 Agent。 + +## 前置要求 + +- 微博账号 +- 一台可运行 cc-connect 的设备(无需公网 IP) +- AI 编程 Agent(Claude Code、Codex 等)已安装并配置完成 + +> 💡 **优势**:使用 WebSocket 长连接,无需公网 IP、无需域名、无需反向代理 + +--- + +## 第一步:注册微博开放平台应用 + +### 1.1 进入微博开放平台 + +访问 [微博开放平台](https://open.weibo.com/),通过微博龙虾助手注册应用。 + +### 1.2 创建应用 + +按照平台指引创建一个新的应用,获取 Open IM 的 `app_id` 和 `app_secret`。 + +> ⚠️ **重要**:请妥善保存这两个凭证,后续配置 cc-connect 时需要用到。 + +--- + +## 第二步:配置 cc-connect + +### 2.1 编辑配置文件 + +将凭证配置到 cc-connect 的 `config.toml` 中: + +```toml +[[projects]] +name = "my-project" + +[projects.agent] +type = "claudecode" + +[projects.agent.options] +work_dir = "/path/to/your/project" + +[[projects.platforms]] +type = "weibo" + +[projects.platforms.options] +app_id = "your-weibo-app-id" +app_secret = "your-weibo-app-secret" +``` + +### 2.2 使用 CLI 引导配置(推荐) + +也可以使用交互式 CLI 来配置: + +```bash +cc-connect new +# 选择 weibo 平台,按提示输入 app_id 和 app_secret +``` + +### 2.3 可选配置项 + +```toml +[projects.platforms.options] +app_id = "your-weibo-app-id" +app_secret = "your-weibo-app-secret" +# allow_from = "*" # 允许的微博用户 ID,逗号分隔;"*" 表示所有(默认) +# token_endpoint = "" # 自定义 token 接口地址(默认:https://open-im.api.weibo.com/open/auth/ws_token) +# ws_endpoint = "" # 自定义 WebSocket 地址(默认:ws://open-im.api.weibo.com/ws/stream) +``` + +--- + +## 第三步:启动 cc-connect + +### 3.1 启动服务 + +```bash +cc-connect +# 或指定配置文件 +cc-connect -config /path/to/config.toml +``` + +### 3.2 验证连接 + +启动后,cc-connect 会自动与微博建立 WebSocket 长连接。你会在日志中看到: + +``` +level=INFO msg="weibo: authenticated" uid=1234567890 +level=INFO msg="weibo: websocket connected" +level=INFO msg="platform started" project=my-project platform=weibo +level=INFO msg="cc-connect is running" projects=1 +``` + +--- + +## 第四步:开始使用 + +### 4.1 发送私信 + +在微博中给你的应用账号发送私信,即可与 AI Agent 对话: + +``` +用户: 帮我分析一下当前项目的结构 + +cc-connect: 🤔 思考中... +cc-connect: 🔧 执行: Bash(ls -la) +cc-connect: ✅ 这是一个 Go 项目,包含以下模块... +``` + +### 4.2 使用命令 + +所有 cc-connect 命令均可在微博私信中使用: + +| 命令 | 功能 | +|------|------| +| `/status` | 查看 Agent 状态 | +| `/new` | 新建会话 | +| `/list` | 查看会话列表 | +| `/stop` | 停止当前会话 | +| `/help` | 查看帮助 | + +--- + +## 连接方式说明 + +微博私信平台使用 WebSocket 长连接: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 微博 Open IM │ +│ │ +│ 用户私信 ──→ open-im.api.weibo.com ──→ WebSocket Stream │ +│ │ │ +└──────────────────────────────────────────────┼───────────────┘ + │ + │ WebSocket 长连接 + │ (无需公网IP) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 你的本地环境 │ +│ │ +│ cc-connect ◄──► AI Agent CLI ◄──► 你的项目代码 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +| 特性 | 说明 | +|------|------| +| ✅ 无需公网 IP | 内网环境也能接入 | +| ✅ 无需域名 | 不需要配置域名 | +| ✅ 自动重连 | 断线后自动重连(指数退避) | +| ✅ 心跳保活 | 30 秒心跳间隔,40 秒超时检测 | +| ✅ Token 自动刷新 | 过期前自动续期 | + +--- + +## 技术细节 + +### 消息长度限制 + +微博私信文本限制约 2000 字符。cc-connect 会自动将超长消息分块发送,接收端会按顺序收到完整内容。 + +### Token 管理 + +- 首次启动时通过 `app_id` + `app_secret` 获取 WebSocket Token +- Token 过期前 60 秒自动刷新 +- WebSocket 断开时(code 4002 / invalid token)自动清除并重新获取 + +### 安全建议 + +- 使用 `allow_from` 限制允许使用的微博用户 ID +- 发送 `/whoami` 获取你的用户 ID +- 不要将 `app_secret` 提交到代码仓库 + +--- + +## 常见问题 + +### Q: 连接后收不到消息? + +检查以下项目: +1. cc-connect 服务是否正常运行 +2. WebSocket 连接是否建立成功(查看日志) +3. `app_id` 和 `app_secret` 是否正确 + +### Q: 长连接断开怎么办? + +cc-connect 内置了自动重连机制(指数退避,最大 10 秒间隔),断开后会自动尝试重新连接。 + +### Q: 提示 Token 无效? + +- Token 过期后会自动刷新,一般无需手动干预 +- 如果持续失败,检查 `app_secret` 是否有效 + +### Q: 消息发送后显示不完整? + +微博私信有约 2000 字符的限制,cc-connect 会自动分块发送。如果仍有问题,检查网络连接。 + +--- + +## 下一步 + +- [接入飞书](./feishu.md) +- [接入钉钉](./dingtalk.md) +- [接入 Telegram](./telegram.md) +- [接入 Discord](./discord.md) +- [接入 Slack](./slack.md) +- [返回首页](../README.md) diff --git a/platform/weibo/weibo.go b/platform/weibo/weibo.go new file mode 100644 index 0000000..73fe306 --- /dev/null +++ b/platform/weibo/weibo.go @@ -0,0 +1,482 @@ +package weibo + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/gorilla/websocket" +) + +const ( + defaultTokenEndpoint = "https://open-im.api.weibo.com/open/auth/ws_token" + defaultWSEndpoint = "ws://open-im.api.weibo.com/ws/stream" + + pingInterval = 30 * time.Second + pongTimeout = 40 * time.Second + reconnectDelay = 3 * time.Second + maxReconnect = 10 * time.Second + tokenRenewBuf = 60 * time.Second + maxTextPerChunk = 2000 + maxSeenMessages = 1000 +) + +func init() { + core.RegisterPlatform("weibo", New) +} + +type replyContext struct { + fromUserID string + sessionKey string +} + +type Platform struct { + name string + appID string + appSecret string + tokenEndpoint string + wsEndpoint string + allowFrom string + + handler core.MessageHandler + + ws *websocket.Conn + wsMu sync.Mutex + connMu sync.Mutex + + token string + tokenExpiry time.Time + tokenMu sync.Mutex + uid string + + ctx context.Context + cancel context.CancelFunc + + seen map[string]struct{} + seenMu sync.Mutex +} + +func New(opts map[string]any) (core.Platform, error) { + name, _ := opts["name"].(string) + if name == "" { + name = "weibo" + } + appID, _ := opts["app_id"].(string) + appSecret, _ := opts["app_secret"].(string) + if appID == "" || appSecret == "" { + return nil, fmt.Errorf("weibo: app_id and app_secret are required") + } + + tokenEndpoint, _ := opts["token_endpoint"].(string) + if tokenEndpoint == "" { + tokenEndpoint = defaultTokenEndpoint + } + wsEndpoint, _ := opts["ws_endpoint"].(string) + if wsEndpoint == "" { + wsEndpoint = defaultWSEndpoint + } + + allowFrom, _ := opts["allow_from"].(string) + core.CheckAllowFrom(name, allowFrom) + + return &Platform{ + name: name, + appID: appID, + appSecret: appSecret, + tokenEndpoint: tokenEndpoint, + wsEndpoint: wsEndpoint, + allowFrom: allowFrom, + seen: make(map[string]struct{}), + }, nil +} + +func (p *Platform) Name() string { return p.name } + +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + p.ctx, p.cancel = context.WithCancel(context.Background()) + + if _, err := p.refreshToken(); err != nil { + return fmt.Errorf("weibo: initial token fetch: %w", err) + } + slog.Info(p.tag()+": authenticated", "uid", p.uid) + + go p.connectLoop() + return nil +} + +func (p *Platform) Reply(ctx context.Context, rctx any, content string) error { + return p.sendMessage(rctx, content) +} + +func (p *Platform) Send(ctx context.Context, rctx any, content string) error { + return p.sendMessage(rctx, content) +} + +func (p *Platform) Stop() error { + if p.cancel != nil { + p.cancel() + } + p.wsMu.Lock() + defer p.wsMu.Unlock() + if p.ws != nil { + p.ws.Close() + } + return nil +} + +// --- Token management --- + +type tokenResponse struct { + Data struct { + Token string `json:"token"` + ExpireIn int64 `json:"expire_in"` // seconds + UID json.RawMessage `json:"uid"` + } `json:"data"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` +} + +func (p *Platform) refreshToken() (string, error) { + p.tokenMu.Lock() + defer p.tokenMu.Unlock() + + if p.token != "" && time.Now().Before(p.tokenExpiry.Add(-tokenRenewBuf)) { + return p.token, nil + } + + body := fmt.Sprintf(`{"app_id":"%s","app_secret":"%s"}`, p.appID, p.appSecret) + req, err := http.NewRequest("POST", p.tokenEndpoint, strings.NewReader(body)) + if err != nil { + return "", fmt.Errorf("weibo: build token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("weibo: token request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("weibo: read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("weibo: token HTTP %d: %s", resp.StatusCode, string(raw)) + } + + var tr tokenResponse + if err := json.Unmarshal(raw, &tr); err != nil { + return "", fmt.Errorf("weibo: parse token response: %w", err) + } + if tr.Data.Token == "" { + return "", fmt.Errorf("weibo: empty token in response: %s", string(raw)) + } + + p.token = tr.Data.Token + p.tokenExpiry = time.Now().Add(time.Duration(tr.Data.ExpireIn) * time.Second) + if len(tr.Data.UID) > 0 { + p.uid = strings.Trim(string(tr.Data.UID), `"`) + } + + slog.Debug(p.tag()+": token refreshed", "expires_in", tr.Data.ExpireIn) + return p.token, nil +} + +func (p *Platform) getToken() (string, error) { + p.tokenMu.Lock() + tok := p.token + exp := p.tokenExpiry + p.tokenMu.Unlock() + + if tok != "" && time.Now().Before(exp.Add(-tokenRenewBuf)) { + return tok, nil + } + return p.refreshToken() +} + +func (p *Platform) invalidateToken() { + p.tokenMu.Lock() + defer p.tokenMu.Unlock() + p.token = "" + p.tokenExpiry = time.Time{} +} + +// --- WebSocket connection --- + +func (p *Platform) connectLoop() { + delay := reconnectDelay + for { + select { + case <-p.ctx.Done(): + return + default: + } + + if err := p.connect(); err != nil { + slog.Error(p.tag()+": connect failed", "error", err) + } + + select { + case <-p.ctx.Done(): + return + case <-time.After(delay): + } + + delay = min(delay*2, maxReconnect) + } +} + +func (p *Platform) connect() error { + p.connMu.Lock() + defer p.connMu.Unlock() + + tok, err := p.getToken() + if err != nil { + return err + } + + u, err := url.Parse(p.wsEndpoint) + if err != nil { + return fmt.Errorf("weibo: parse ws endpoint: %w", err) + } + q := u.Query() + q.Set("app_id", p.appID) + q.Set("token", tok) + u.RawQuery = q.Encode() + + ws, _, err := websocket.DefaultDialer.DialContext(p.ctx, u.String(), nil) + if err != nil { + return fmt.Errorf("weibo: ws dial: %w", err) + } + + p.wsMu.Lock() + if p.ws != nil { + p.ws.Close() + } + p.ws = ws + p.wsMu.Unlock() + + slog.Info(p.tag() + ": websocket connected") + + go p.pingLoop(ws) + p.readLoop(ws) + return nil +} + +func (p *Platform) pingLoop(ws *websocket.Conn) { + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + for { + select { + case <-p.ctx.Done(): + return + case <-ticker.C: + p.wsMu.Lock() + if p.ws != ws { + p.wsMu.Unlock() + return + } + err := ws.WriteJSON(map[string]string{"type": "ping"}) + p.wsMu.Unlock() + if err != nil { + slog.Debug(p.tag()+": ping write error", "error", err) + return + } + } + } +} + +type wsMessage struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type messagePayload struct { + MessageID string `json:"messageId"` + FromUserID string `json:"fromUserId"` + Text string `json:"text"` + Timestamp int64 `json:"timestamp"` +} + +func (p *Platform) readLoop(ws *websocket.Conn) { + ws.SetPongHandler(func(string) error { + ws.SetReadDeadline(time.Now().Add(pongTimeout)) + return nil + }) + ws.SetReadDeadline(time.Now().Add(pongTimeout)) + + for { + _, raw, err := ws.ReadMessage() + if err != nil { + if p.ctx.Err() != nil { + return + } + if websocket.IsCloseError(err, 4002) { + slog.Warn(p.tag() + ": invalid token, clearing cache") + p.invalidateToken() + } + slog.Debug(p.tag()+": ws read error", "error", err) + return + } + + ws.SetReadDeadline(time.Now().Add(pongTimeout)) + + var msg wsMessage + if err := json.Unmarshal(raw, &msg); err != nil { + slog.Debug(p.tag()+": ws unmarshal error", "error", err) + continue + } + + switch msg.Type { + case "pong": + // heartbeat response, already handled via deadline reset + case "message": + if msg.Payload != nil { + p.handleInbound(msg.Payload) + } + default: + slog.Debug(p.tag()+": unhandled ws message type", "type", msg.Type) + } + } +} + +func (p *Platform) handleInbound(raw json.RawMessage) { + var payload messagePayload + if err := json.Unmarshal(raw, &payload); err != nil { + slog.Error(p.tag()+": parse inbound payload", "error", err) + return + } + + if payload.FromUserID == "" || payload.Text == "" { + return + } + + msgID := payload.MessageID + if msgID == "" { + msgID = fmt.Sprintf("%x", sha256.Sum256(raw))[:16] + } + + if p.isDuplicate(msgID) { + return + } + + userID := payload.FromUserID + if !core.AllowList(p.allowFrom, userID) { + slog.Debug(p.tag()+": message from unauthorized user", "user", userID) + return + } + + sessionKey := p.name + ":" + userID + ":" + userID + + rctx := replyContext{ + fromUserID: userID, + sessionKey: sessionKey, + } + + p.handler(p, &core.Message{ + SessionKey: sessionKey, + Platform: p.name, + MessageID: msgID, + UserID: userID, + UserName: userID, + Content: payload.Text, + ReplyCtx: rctx, + }) +} + +// --- Sending --- + +type sendPayload struct { + ToUserID string `json:"toUserId"` + Text string `json:"text"` + MessageID string `json:"messageId"` + ChunkID int `json:"chunkId"` + Done bool `json:"done"` +} + +func (p *Platform) sendMessage(rctx any, content string) error { + rc, ok := rctx.(replyContext) + if !ok { + return fmt.Errorf("weibo: invalid reply context type: %T", rctx) + } + + chunks := splitText(content, maxTextPerChunk) + msgID := fmt.Sprintf("out-%s-%d", rc.fromUserID, time.Now().UnixMilli()) + + for i, chunk := range chunks { + env := map[string]any{ + "type": "send_message", + "payload": sendPayload{ + ToUserID: rc.fromUserID, + Text: chunk, + MessageID: msgID, + ChunkID: i, + Done: i == len(chunks)-1, + }, + } + p.wsMu.Lock() + ws := p.ws + p.wsMu.Unlock() + if ws == nil { + return fmt.Errorf("weibo: not connected") + } + if err := ws.WriteJSON(env); err != nil { + return fmt.Errorf("weibo: ws send: %w", err) + } + } + return nil +} + +// --- Helpers --- + +func (p *Platform) tag() string { return p.name } + +func (p *Platform) isDuplicate(msgID string) bool { + p.seenMu.Lock() + defer p.seenMu.Unlock() + if _, ok := p.seen[msgID]; ok { + return true + } + if len(p.seen) >= maxSeenMessages { + // prune half + i := 0 + for k := range p.seen { + if i >= maxSeenMessages/2 { + break + } + delete(p.seen, k) + i++ + } + } + p.seen[msgID] = struct{}{} + return false +} + +func splitText(text string, limit int) []string { + runes := []rune(text) + if len(runes) <= limit { + return []string{text} + } + var chunks []string + for len(runes) > 0 { + end := limit + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[:end])) + runes = runes[end:] + } + return chunks +} diff --git a/platform/weibo/weibo_test.go b/platform/weibo/weibo_test.go new file mode 100644 index 0000000..3006fb7 --- /dev/null +++ b/platform/weibo/weibo_test.go @@ -0,0 +1,319 @@ +package weibo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/gorilla/websocket" +) + +func TestNew_RequiredFields(t *testing.T) { + tests := []struct { + name string + opts map[string]any + wantErr bool + }{ + {"missing both", map[string]any{}, true}, + {"missing app_secret", map[string]any{"app_id": "id"}, true}, + {"missing app_id", map[string]any{"app_secret": "secret"}, true}, + {"valid", map[string]any{"app_id": "id", "app_secret": "secret"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := New(tt.opts) + if tt.wantErr { + if err == nil { + t.Error("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Name() != "weibo" { + t.Errorf("name = %q, want %q", p.Name(), "weibo") + } + }) + } +} + +func TestNew_CustomName(t *testing.T) { + p, err := New(map[string]any{ + "app_id": "id", + "app_secret": "secret", + "name": "my-weibo", + }) + if err != nil { + t.Fatal(err) + } + if p.Name() != "my-weibo" { + t.Errorf("name = %q, want %q", p.Name(), "my-weibo") + } +} + +func TestNew_CustomEndpoints(t *testing.T) { + p, err := New(map[string]any{ + "app_id": "id", + "app_secret": "secret", + "token_endpoint": "https://custom.example.com/token", + "ws_endpoint": "ws://custom.example.com/ws", + }) + if err != nil { + t.Fatal(err) + } + plat := p.(*Platform) + if plat.tokenEndpoint != "https://custom.example.com/token" { + t.Errorf("tokenEndpoint = %q", plat.tokenEndpoint) + } + if plat.wsEndpoint != "ws://custom.example.com/ws" { + t.Errorf("wsEndpoint = %q", plat.wsEndpoint) + } +} + +func TestSplitText(t *testing.T) { + tests := []struct { + name string + text string + limit int + chunks int + }{ + {"short", "hello", 100, 1}, + {"exact", "abcde", 5, 1}, + {"split", "abcdefgh", 3, 3}, + {"empty", "", 10, 1}, + {"unicode", "你好世界测试", 3, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitText(tt.text, tt.limit) + if len(result) != tt.chunks { + t.Errorf("splitText(%q, %d) = %d chunks, want %d", tt.text, tt.limit, len(result), tt.chunks) + } + joined := strings.Join(result, "") + if joined != tt.text { + t.Errorf("joined = %q, want %q", joined, tt.text) + } + }) + } +} + +func TestIsDuplicate(t *testing.T) { + p := &Platform{seen: make(map[string]struct{})} + + if p.isDuplicate("msg1") { + t.Error("first occurrence should not be duplicate") + } + if !p.isDuplicate("msg1") { + t.Error("second occurrence should be duplicate") + } + if p.isDuplicate("msg2") { + t.Error("different message should not be duplicate") + } +} + +func TestIsDuplicate_Prune(t *testing.T) { + p := &Platform{seen: make(map[string]struct{})} + + for i := 0; i < maxSeenMessages+100; i++ { + p.isDuplicate(strings.Repeat("x", 10) + string(rune(i))) + } + if len(p.seen) > maxSeenMessages { + t.Errorf("seen map should be pruned, got %d entries", len(p.seen)) + } +} + +func TestHandleInbound(t *testing.T) { + p := &Platform{ + name: "weibo", + allowFrom: "*", + seen: make(map[string]struct{}), + } + + var received *core.Message + var mu sync.Mutex + p.handler = func(_ core.Platform, msg *core.Message) { + mu.Lock() + received = msg + mu.Unlock() + } + + payload := messagePayload{ + MessageID: "test-123", + FromUserID: "user1", + Text: "hello world", + Timestamp: 1234567890, + } + raw, _ := json.Marshal(payload) + p.handleInbound(raw) + + mu.Lock() + defer mu.Unlock() + if received == nil { + t.Fatal("handler not called") + } + if received.SessionKey != "weibo:user1:user1" { + t.Errorf("sessionKey = %q", received.SessionKey) + } + if received.Content != "hello world" { + t.Errorf("content = %q", received.Content) + } + if received.UserID != "user1" { + t.Errorf("userID = %q", received.UserID) + } + if received.MessageID != "test-123" { + t.Errorf("messageID = %q", received.MessageID) + } +} + +func TestHandleInbound_AllowList(t *testing.T) { + p := &Platform{ + name: "weibo", + allowFrom: "user2,user3", + seen: make(map[string]struct{}), + } + + called := false + p.handler = func(_ core.Platform, _ *core.Message) { + called = true + } + + payload := messagePayload{ + MessageID: "blocked-1", + FromUserID: "user1", + Text: "hello", + } + raw, _ := json.Marshal(payload) + p.handleInbound(raw) + + if called { + t.Error("handler should not be called for unauthorized user") + } +} + +func TestHandleInbound_EmptyText(t *testing.T) { + p := &Platform{ + name: "weibo", + allowFrom: "*", + seen: make(map[string]struct{}), + } + + called := false + p.handler = func(_ core.Platform, _ *core.Message) { + called = true + } + + payload := messagePayload{ + MessageID: "empty-1", + FromUserID: "user1", + Text: "", + } + raw, _ := json.Marshal(payload) + p.handleInbound(raw) + + if called { + t.Error("handler should not be called for empty text") + } +} + +func TestRefreshToken(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("method = %s, want POST", r.Method) + } + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "token": "test-token-abc", + "expire_in": 3600, + "uid": 12345, + }, + }) + })) + defer ts.Close() + + p := &Platform{ + appID: "test-app", + appSecret: "test-secret", + tokenEndpoint: ts.URL, + seen: make(map[string]struct{}), + } + + tok, err := p.refreshToken() + if err != nil { + t.Fatal(err) + } + if tok != "test-token-abc" { + t.Errorf("token = %q, want %q", tok, "test-token-abc") + } + if p.uid != "12345" { + t.Errorf("uid = %q, want %q", p.uid, "12345") + } +} + +func TestSendMessage(t *testing.T) { + upgrader := websocket.Upgrader{} + gotMsg := make(chan map[string]any, 1) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + for { + _, msg, err := c.ReadMessage() + if err != nil { + return + } + var m map[string]any + json.Unmarshal(msg, &m) + gotMsg <- m + } + })) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatal(err) + } + defer ws.Close() + + p := &Platform{ + name: "weibo", + ws: ws, + seen: make(map[string]struct{}), + } + + rctx := replyContext{fromUserID: "user1", sessionKey: "weibo:user1:user1"} + err = p.sendMessage(rctx, "short message") + if err != nil { + t.Fatal(err) + } + + select { + case m := <-gotMsg: + if m["type"] != "send_message" { + t.Errorf("type = %v", m["type"]) + } + payload := m["payload"].(map[string]any) + if payload["toUserId"] != "user1" { + t.Errorf("toUserId = %v", payload["toUserId"]) + } + if payload["text"] != "short message" { + t.Errorf("text = %v", payload["text"]) + } + if payload["done"] != true { + t.Errorf("done = %v", payload["done"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for message") + } +}