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")
+ }
+}