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:
1 parent
92d9ae066b
commit
e055bc8cf5
15 files changed
+1058
-15
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 := ,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !no_weibo
|
||||
|
||||
package main
|
||||
|
||||
import _ "github.com/chenhg5/cc-connect/platform/weibo"
|
||||
@@ -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
|
||||
# 登录企业微信管理后台
|
||||
|
||||
@@ -299,6 +299,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。
|
||||
## 下一步
|
||||
|
||||
- [接入飞书](./feishu.md)
|
||||
- [接入微博](./weibo.md)
|
||||
- [接入 Telegram](./telegram.md)
|
||||
- [接入 Slack](./slack.md)
|
||||
- [接入 Discord](./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)
|
||||
@@ -404,6 +404,7 @@ cc-connect 内置了自动重连机制,断开后会自动尝试重新连接。
|
||||
## 下一步
|
||||
|
||||
- [接入钉钉](./dingtalk.md)
|
||||
- [接入微博](./weibo.md)
|
||||
- [接入 Telegram](./telegram.md)
|
||||
- [接入 Slack](./slack.md)
|
||||
- [接入 Discord](./discord.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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user