feat(weibo): add Weibo DM platform support

Implement Weibo private message platform via the Open IM WebSocket
protocol (open-im.api.weibo.com). Supports app_id/app_secret auth,
automatic token refresh, heartbeat keep-alive, reconnect with
exponential backoff, message dedup, and chunked sending for long
messages (2000 char limit).

Includes plugin registration, build tag (no_weibo), unit tests,
setup documentation, and cross-references in all platform docs.

Made-with: Cursor
This commit is contained in:
Claude committed 2026-04-17 13:18:33 +08:00
1 parent 92d9ae066b
commit e055bc8cf5
15 files changed
+1058 -15

No files matched your search

+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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 := ,
+12 -10
View File
@@ -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**<br>*(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**<br>*(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 |
+5
View File
@@ -0,0 +1,5 @@
//go:build !no_weibo
package main
import _ "github.com/chenhg5/cc-connect/platform/weibo"
+17
View File
@@ -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
# 登录企业微信管理后台
+1
View File
@@ -299,6 +299,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。
## 下一步
- [接入飞书](./feishu.md)
- [接入微博](./weibo.md)
- [接入 Telegram](./telegram.md)
- [接入 Slack](./slack.md)
- [接入 Discord](./discord.md)
+1
View File
@@ -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)
+1
View File
@@ -404,6 +404,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。
## 下一步
- [接入钉钉](./dingtalk.md)
- [接入微博](./weibo.md)
- [接入 Telegram](./telegram.md)
- [接入 Slack](./slack.md)
- [接入 Discord](./discord.md)
+1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -438,6 +438,7 @@ proxy = "http://vps-ip:8888"
- [接入飞书](./feishu.md)
- [接入钉钉](./dingtalk.md)
- [接入微博](./weibo.md)
- [接入 Telegram](./telegram.md)
- [接入 Slack](./slack.md)
- [接入 Discord](./discord.md)
+210
View File
@@ -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)
+482
View File
@@ -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
}
+319
View File
@@ -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")
}
}