Skill: liusining/weflow-group-summarizer (version 0.1.0)

--- name: weflow-group-summarizer description: Use when user asks to monitor WeChat groups, set up WeFlow API access, or when a heartbeat triggers for WeChat group summarization --- # WeFlow 群聊总结器 设置和运行 WeChat 群聊定期总结。所有脚本和配置都在此技能目录下（`<skill_dir>` 即本 SKILL.md 所在目录）。 **前置条件：** - [WeFlow 桌面端](https://github.com/hicccc77/WeFlow)已在运行，且 API 服务已启用（设置页面 → API 服务 → 启动服务） - Python 3 已安装 - 安装依赖：`pip install pyyaml openpyxl` **目录结构：** ``` scripts/ Python 脚本 weflow-proxy/ Go 反向代理（跨机器访问 WeFlow API 时需要） reference/ API 文档 weflow-groups.yaml 配置模板（勿直接修改，复制到工作目录使用） ``` ## Step a: 检查 WeFlow 连接 ```bash python3 <skill_dir>/scripts/check_weflow.py ``` **如果成功**（输出 host 地址）→ 进入 Step b。 **如果失败**，先排查： 1. WeFlow 是否在运行？ 2. API 服务是否已启用？（WeFlow 设置 → API 服务 → 启动服务，默认端口 5031） 3. 如果 WeFlow 在同一台机器：检查端口 `lsof -i :5031` 4. 如果 WeFlow 在其他机器（如 Windows VM）→ 需要部署代理，见下方 ### 部署代理（WeFlow 在远程机器时） 1. 确认本机已安装 Go（`go version`）。如未安装：`brew install go`（macOS） 2. 编译 Windows 代理： ```bash cd <skill_dir>/weflow-proxy && ./build-windows.sh ``` 3. 告知用户 `weflow-proxy.exe` 的完整路径，请用户复制到 Windows 机器并在命令行（cmd/PowerShell）中运行。**注意**：Windows 防火墙可能拦截，需要允许通过 4. 代理启动后会输出局域网地址，如： ``` weflow-proxy listening 您可通过此地址访问 Weflow API: http://192.168.1.100:5032 ``` 如果显示多个 IP，选择与本机同一子网的地址。请用户提供该地址 5. 验证： ```bash python3 <skill_dir>/scripts/check_weflow.py --host http://<windows-ip>:5032 ``` 6. 如果验证仍然失败：确认代理还在运行、Windows 防火墙已放行 5032 端口、VM 网络为桥接模式（非 NAT） ## Step b: 获取群聊列表 ```bash python3 <skill_dir>/scripts/fetch_groups.py <HOST> ``` 输出格式： ``` 1. 群名A [12345678@chatroom] 2. 群名B [87654321@chatroom] ``` 展示列表给用户，让用户选择要监控的群聊。用户可选多个，对每个群重复 Step c。 ## Step c: 配置群聊 首先准备配置文件（仅首次）： ```bash cp <skill_dir>/weflow-groups.yaml <用户指定路径>/weflow-groups.yaml ``` 让用户选择存放位置，后续统一使用此路径（记为 `CONFIG`）。 对每个选中的群聊，依次收集： ### 1. 总结提示词 询问用户希望如何总结群消息。示例： - `"简洁总结今天的讨论要点"` - `"你是情报分析专家，挖掘群聊中的高价值信息"` - `"关注技术讨论，忽略闲聊"` ### 2. 关注特定群友（可选） 如果用户需要单独追踪某些群友的发言： 1. 请用户从 WeFlow 导出群成员：WeFlow 界面 → 群聊分析 → 群成员查看 → 导出成员（生成 .xlsx 文件）。如果用户还没有文件，等用户提供后再继续 2. 运行转换脚本（仅生成 JSON，不修改配置）： ```bash python3 <skill_dir>/scripts/convert_members.py <xlsx文件> ``` 输出文件为 `<xlsx文件名>_members.json`，格式：`{"wxid_xxx": "昵称（群昵称）"}` 3. 询问用户要关注谁，用 `grep` 在 JSON 中搜索： ```bash grep "张三" <生成的_members.json> ``` 4. 将匹配的 wxid 展示给用户确认 ### 3. 写入配置 ```bash python3 <skill_dir>/scripts/add_group.py CONFIG \ --host <HOST> \ --talker <chatroom_id> \ --name "群聊名称" \ --summary-prompt "总结提示词" \ --members /path/to/members.json \ --subscribe-members wxid_1 wxid_2 ``` - `--host` 仅首次需要 - `--members` 使用绝对路径（即 convert_members.py 输出中 `Written to` 后的路径） - `--subscribe-members` 可选，建议与 `--members` 一起使用以显示昵称 - 修改已有群聊：加 `--update` 标志 ## Step d: 配置心跳 心跳即定期运行以下命令来检查新消息并生成总结。`CONFIG` 即 Step c 中复制的配置文件路径。 **心跳执行命令：** ```bash python3 <skill_dir>/scripts/fetch_messages.py CONFIG ``` 如果脚本报错（连接失败、配置缺失等），将错误信息报告给用户，不要回复 `HEARTBEAT_OK`。 **处理脚本输出：** - 如果所有群的输出都包含 `无新消息` → 回复 `HEARTBEAT_OK` - 如果有新消息 → 脚本会输出每个群的消息，格式如下： ``` 群名：工作群 用户要求：简洁总结今天的讨论要点 近期群信息： [2026-03-10 09:15] 张三: 会议改到下午3点 [2026-03-10 09:20] 李四: 好的，已通知客户 [2026-03-10 10:00] 王五: [图片] /path/to/image.jpg 另外总结关注群友的发言： 张三说： 1. 会议改到下午3点 ``` 对每个有新消息的群： 1. 将「用户要求」作为总结指令 2. 根据指令总结「近期群信息」中的内容 3. 如有「关注群友的发言」部分，单独总结 4. 如有图片路径，读取图片并在总结中描述内容 5. 跳过无新消息的群，不需要提及

path: reference/HTTP-API.md
content: # WeFlow HTTP API 接口文档

WeFlow 提供 HTTP API 服务，支持通过 HTTP 接口查询消息数据，支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。

## 启用 API 服务

在设置页面 → API 服务 → 点击「启动服务」按钮。

默认端口：`5031`

## 基础地址

```
http://127.0.0.1:5031
```

---

## 接口列表

### 1. 健康检查

检查 API 服务是否正常运行。

**请求**
```
GET /health
```

**响应**
```json
{
  "status": "ok"
}
```

---

### 2. 获取消息列表

获取指定会话的消息，支持 ChatLab 格式输出。

**请求**
```
GET /api/v1/messages
```

**参数**

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `talker` | string | ✅ | 会话 ID（wxid 或群 ID） |
| `limit` | number | ❌ | 返回数量限制，默认 100，范围 `1~10000` |
| `offset` | number | ❌ | 偏移量，用于分页，默认 0 |
| `start` | string | ❌ | 开始时间，格式 YYYYMMDD |
| `end` | string | ❌ | 结束时间，格式 YYYYMMDD |
| `keyword` | string | ❌ | 关键词过滤（基于消息显示文本） |
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
| `format` | string | ❌ | 输出格式：`json`（默认）或 `chatlab` |
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径（兼容别名 `meiti`）；`0` 时媒体返回占位符 |
| `image` | string | ❌ | 在 `media=1` 时控制图片导出，`1/0`（兼容别名 `tupian`） |
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出，`1/0`（兼容别名 `vioce`） |
| `video` | string | ❌ | 在 `media=1` 时控制视频导出，`1/0` |
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出，`1/0` |

默认媒体导出目录：`%USERPROFILE%\\Documents\\WeFlow\\api-media`

**示例请求**

```bash
# 获取消息（原始格式）
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50

# 获取消息（ChatLab 格式）
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1

# 带时间范围查询
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100

# 开启媒体导出（只导出图片和语音）
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0

# 关键词过滤
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
```

**响应（原始格式）**
```json
{
  "success": true,
  "talker": "wxid_xxx",
  "count": 50,
  "hasMore": true,
  "media": {
    "enabled": true,
    "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
    "count": 12
  },
  "messages": [
    {
      "localId": 123,
      "localType": 3,
      "content": "[图片]",
      "createTime": 1738713600000,
      "senderUsername": "wxid_sender",
      "mediaType": "image",
      "mediaFileName": "image_123.jpg",
      "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
      "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
    }
  ]
}
```

**响应（ChatLab 格式）**
```json
{
  "chatlab": {
    "version": "0.0.2",
    "exportedAt": 1738713600000,
    "generator": "WeFlow",
    "description": "Exported from WeFlow"
  },
  "meta": {
    "name": "会话名称",
    "platform": "wechat",
    "type": "private",
    "ownerId": "wxid_me"
  },
  "members": [
    {
      "platformId": "wxid_xxx",
      "accountName": "用户名",
      "groupNickname": "群昵称"
    }
  ],
  "messages": [
    {
      "sender": "wxid_xxx",
      "accountName": "用户名",
      "timestamp": 1738713600000,
      "type": 0,
      "content": "消息内容",
      "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
    }
  ],
  "media": {
    "enabled": true,
    "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
    "count": 12
  }
}
```

---

### 3. 访问导出媒体文件

通过 HTTP 直接访问已导出的媒体文件（图片、语音、视频、表情）。

**请求**
```
GET /api/v1/media/{relativePath}
```

**路径参数**

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `relativePath` | string | ✅ | 媒体文件的相对路径，如 `wxid_xxx/images/image_123.jpg` |

**支持的媒体类型**

| 扩展名 | Content-Type |
|--------|-------------|
| `.png` | image/png |
| `.jpg` / `.jpeg` | image/jpeg |
| `.gif` | image/gif |
| `.webp` | image/webp |
| `.wav` | audio/wav |
| `.mp3` | audio/mpeg |
| `.mp4` | video/mp4 |

**示例请求**
```bash
# 访问导出的图片
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg

# 访问导出的语音
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav

# 访问导出的视频
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
```

**响应**

成功时直接返回文件内容，`Content-Type` 根据文件扩展名自动设置。

失败时返回：
```json
{ "error": "Media not found" }
```

> 注意：媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。

---

### 4. 获取会话列表

获取所有会话列表。

**请求**
```
GET /api/v1/sessions
```

**参数**

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词，匹配会话名或 ID |
| `limit` | number | ❌ | 返回数量限制，默认 100 |

**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/sessions

GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
```

**响应**
```json
{
  "success": true,
  "count": 50,
  "total": 100,
  "sessions": [
    {
      "username": "wxid_xxx",
      "displayName": "用户名",
      "lastMessage": "最后一条消息",
      "lastTime": 1738713600000,
      "unreadCount": 0
    }
  ]
}
```

---

### 4. 获取联系人列表

获取所有联系人信息。

**请求**
```
GET /api/v1/contacts
```

**参数**

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词 |
| `limit` | number | ❌ | 返回数量限制，默认 100 |

**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/contacts

GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
```

**响应**
```json
{
  "success": true,
  "count": 50,
  "contacts": [
    {
      "userName": "wxid_xxx",
      "alias": "微信号",
      "nickName": "昵称",
      "remark": "备注名"
    }
  ]
}
```

---

## ChatLab 格式说明

ChatLab 是一种标准化的聊天记录交换格式，版本 0.0.2。

### 消息类型映射

| ChatLab Type | 值 | 说明 |
|--------------|-----|------|
| TEXT | 0 | 文本消息 |
| IMAGE | 1 | 图片 |
| VOICE | 2 | 语音 |
| VIDEO | 3 | 视频 |
| FILE | 4 | 文件 |
| EMOJI | 5 | 表情 |
| LINK | 7 | 链接 |
| LOCATION | 8 | 位置 |
| RED_PACKET | 20 | 红包 |
| TRANSFER | 21 | 转账 |
| CALL | 23 | 通话 |
| SYSTEM | 80 | 系统消息 |
| RECALL | 81 | 撤回消息 |
| OTHER | 99 | 其他 |

---

## 使用示例

### PowerShell

```powershell
# 健康检查
Invoke-RestMethod http://127.0.0.1:5031/health

# 获取会话列表
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions

# 获取消息
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"

# 获取 ChatLab 格式
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
```

### cURL

```bash
# 健康检查
curl http://127.0.0.1:5031/health

# 获取会话列表
curl http://127.0.0.1:5031/api/v1/sessions

# 获取消息（ChatLab 格式）
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
```

### Python

```python
import requests

BASE_URL = "http://127.0.0.1:5031"

# 获取会话列表
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
print(sessions)

# 获取消息
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
    "talker": "wxid_xxx",
    "limit": 100,
    "chatlab": 1
}).json()
print(messages)
```

### JavaScript / Node.js

```javascript
const BASE_URL = "http://127.0.0.1:5031";

// 获取会话列表
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
console.log(sessions);

// 获取消息（ChatLab 格式）
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
  .then(r => r.json());
console.log(messages);
```

---

## 注意事项

1. API 仅监听本地地址 `127.0.0.1`，不对外网开放
2. 需要先连接数据库才能查询数据
3. 时间参数格式为 `YYYYMMDD`（如 20260205）
4. 支持 CORS，可从浏览器前端直接调用


path: requirements.txt
content: pyyaml
openpyxl


path: scripts/add_group.py
content: #!/usr/bin/env python3
"""Add a WeChat group to weflow-groups.yaml."""

import argparse
import sys

try:
    import yaml
except ImportError:
    print("PyYAML is required: pip install pyyaml", file=sys.stderr)
    sys.exit(1)


def main():
    parser = argparse.ArgumentParser(description="Add a WeChat group to weflow-groups.yaml")
    parser.add_argument("yaml_file", help="Path to weflow-groups.yaml")
    parser.add_argument("--host", help="WeFlow API host (e.g. http://<windows-ip>:5032)")
    parser.add_argument("--talker", required=True, help="Chatroom ID (e.g. 12345678@chatroom)")
    parser.add_argument("--name", required=True, help="Group display name")
    parser.add_argument("--summary-prompt", required=True, help="Prompt for AI summarization")
    parser.add_argument("--members", help="Path to members.json (generated by convert_members.py)")
    parser.add_argument("--subscribe-members", nargs="+", help="wxid list of members to follow")
    parser.add_argument("--update", action="store_true", help="Update existing group instead of failing")
    args = parser.parse_args()

    try:
        with open(args.yaml_file, "r") as f:
            config = yaml.safe_load(f) or {}
    except FileNotFoundError:
        config = {}

    if "groups" not in config:
        config["groups"] = []

    if args.host:
        config["host"] = args.host

    existing = None
    for g in config["groups"]:
        if g.get("talker") == args.talker:
            existing = g
            break

    if existing and not args.update:
        print(f"Error: talker {args.talker} already exists in {args.yaml_file} (use --update to modify)", file=sys.stderr)
        sys.exit(1)

    if existing:
        existing["name"] = args.name
        existing["summary_prompt"] = args.summary_prompt
        if args.members:
            existing["members"] = args.members
        if args.subscribe_members:
            existing["subscribe_members"] = args.subscribe_members
        print(f"Updated group '{args.name}' [{args.talker}] in {args.yaml_file}")
    else:
        group = {
            "talker": args.talker,
            "name": args.name,
            "summary_prompt": args.summary_prompt,
            "last_local_id": 0,
        }
        if args.members:
            group["members"] = args.members
        if args.subscribe_members:
            group["subscribe_members"] = args.subscribe_members
        config["groups"].append(group)
        print(f"Added group '{args.name}' [{args.talker}] to {args.yaml_file}")

    with open(args.yaml_file, "w") as f:
        yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False)


if __name__ == "__main__":
    main()


path: scripts/check_weflow.py
content: #!/usr/bin/env python3
"""Check WeFlow API connectivity."""

import argparse
import json
import sys
import urllib.request
import urllib.error


def main():
    parser = argparse.ArgumentParser(description="Check WeFlow API connectivity")
    parser.add_argument(
        "--host",
        default="http://127.0.0.1:5031",
        help="WeFlow API host (default: http://127.0.0.1:5031)",
    )
    args = parser.parse_args()

    host = args.host.rstrip("/")
    url = f"{host}/health"

    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=3) as resp:
            data = json.loads(resp.read())
            if data.get("status") == "ok":
                print(host)
                sys.exit(0)
            else:
                print(f"Unexpected response: {data}", file=sys.stderr)
                sys.exit(1)
    except urllib.error.URLError as e:
        print(f"Connection failed: {e.reason}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/convert_members.py
content: #!/usr/bin/env python3
"""Convert WeFlow group member Excel export to a wxid->nickname JSON mapping."""

import argparse
import json
import os
import sys

try:
    import openpyxl
except ImportError:
    print("openpyxl is required: pip install openpyxl", file=sys.stderr)
    sys.exit(1)

try:
    import yaml
except ImportError:
    yaml = None  # Only needed with --yaml flag


def parse_args():
    parser = argparse.ArgumentParser(
        description="Convert WeFlow group member Excel to wxid->nickname JSON"
    )
    parser.add_argument("excel", help="Path to the Excel file exported by WeFlow")
    parser.add_argument("--yaml", help="Path to weflow-groups.yaml (updates members path for talker)")
    parser.add_argument("--talker", help="Talker chatroom ID (e.g. 12345@chatroom), required with --yaml")
    return parser.parse_args()


def build_mapping(excel_path):
    wb = openpyxl.load_workbook(excel_path)
    ws = wb.active

    # Row 4 (0-indexed row 3) is the header: 微信昵称, 微信备注, 群昵称, wxid, ...
    # Data starts from row 5 (0-indexed row 4)
    mapping = {}
    for i, row in enumerate(ws.iter_rows(values_only=True)):
        if i < 4:  # skip metadata and header rows
            continue
        nickname = row[0] or ""
        group_alias = row[2] or ""
        wxid = row[3] or ""
        if not wxid:
            continue
        display = nickname
        if group_alias:
            display = f"{nickname}（{group_alias}）"
        mapping[wxid] = display

    return mapping


def update_yaml(yaml_path, talker, members_json_path):
    with open(yaml_path, "r") as f:
        data = yaml.safe_load(f)

    abs_path = os.path.abspath(members_json_path)

    found = False
    for group in data.get("groups", []):
        if group.get("talker") == talker:
            group["members"] = abs_path
            found = True
            break

    if not found:
        print(f"Warning: talker '{talker}' not found in {yaml_path}, appending new entry")
        data.setdefault("groups", []).append({"talker": talker, "members": abs_path})

    with open(yaml_path, "w") as f:
        yaml.dump(data, f, allow_unicode=True, default_flow_style=False)


def main():
    args = parse_args()

    mapping = build_mapping(args.excel)
    print(f"Found {len(mapping)} members")

    # Write JSON next to the Excel file
    json_path = os.path.splitext(args.excel)[0] + "_members.json"
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(mapping, f, ensure_ascii=False, indent=2)
    print(f"Written to {json_path}")

    if args.yaml:
        if yaml is None:
            print("PyYAML is required for --yaml: pip install pyyaml", file=sys.stderr)
            sys.exit(1)
        if not args.talker:
            print("Error: --talker is required when using --yaml", file=sys.stderr)
            sys.exit(1)
        update_yaml(args.yaml, args.talker, json_path)
        print(f"Updated {args.yaml} with members path for talker {args.talker}")


if __name__ == "__main__":
    main()


path: scripts/fetch_groups.py
content: #!/usr/bin/env python3
"""List WeChat group chats from WeFlow sessions API."""

import argparse
import json
import sys
import urllib.request
import urllib.error


def main():
    parser = argparse.ArgumentParser(description="List WeChat group chats")
    parser.add_argument("host", help="WeFlow API host (e.g. http://<windows-ip>:5032)")
    args = parser.parse_args()

    host = args.host.rstrip("/")
    url = f"{host}/api/v1/sessions?limit=1000"

    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read())
    except urllib.error.URLError as e:
        print(f"Connection failed: {e.reason}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

    sessions = data.get("sessions", [])
    groups = [s for s in sessions if s.get("username", "").endswith("@chatroom")]

    if not groups:
        print("No group chats found.")
        sys.exit(0)

    for i, g in enumerate(groups, 1):
        name = g.get("displayName", "unknown")
        username = g.get("username", "")
        print(f"{i}. {name} [{username}]")


if __name__ == "__main__":
    main()


path: scripts/fetch_messages.py
content: #!/usr/bin/env python3
"""Fetch new messages from WeFlow groups and format for summarization."""

import argparse
import json
import os
import sys
import urllib.request
import urllib.error
from datetime import datetime

import yaml


def load_config(config_path):
    with open(config_path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def save_config(config_path, config):
    with open(config_path, "w", encoding="utf-8") as f:
        yaml.dump(config, f, allow_unicode=True, default_flow_style=False)


def load_members(members_path):
    if not members_path or not os.path.exists(members_path):
        return {}
    with open(members_path, "r", encoding="utf-8") as f:
        return json.load(f)


def fetch_messages(host, talker):
    url = (
        f"{host}/api/v1/messages"
        f"?talker={talker}&limit=500&media=1&image=1&voice=0&video=0&emoji=0"
    )
    req = urllib.request.Request(url)
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())


def download_image(media_url, save_dir):
    os.makedirs(save_dir, exist_ok=True)
    filename = media_url.rsplit("/", 1)[-1]
    save_path = os.path.join(save_dir, filename)
    if os.path.exists(save_path):
        return save_path
    try:
        req = urllib.request.Request(media_url)
        with urllib.request.urlopen(req, timeout=30) as resp:
            with open(save_path, "wb") as f:
                f.write(resp.read())
        return save_path
    except Exception as e:
        print(f"Warning: failed to download {media_url}: {e}", file=sys.stderr)
        return None


def format_time(timestamp_ms):
    dt = datetime.fromtimestamp(timestamp_ms / 1000)
    return dt.strftime("[%Y-%m-%d %H:%M]")


def resolve_nickname(sender, members):
    return members.get(sender, sender)


def process_group(host, group, images_dir):
    talker = group["talker"]
    name = group.get("name", talker)
    members_path = group.get("members")
    prompt = group.get("summary_prompt", "")
    subscribe = group.get("subscribe_members", [])
    last_id = group.get("last_local_id", 0)

    members = load_members(members_path)

    try:
        data = fetch_messages(host, talker)
    except Exception as e:
        print(f"Error fetching messages for {name}: {e}", file=sys.stderr)
        return last_id

    all_msgs = data.get("messages", [])
    new_msgs = [m for m in all_msgs if m.get("localId", 0) > last_id]

    if not new_msgs:
        print(f"群名：{name}")
        print("无新消息。")
        print()
        return last_id

    # Sort by localId ascending
    new_msgs.sort(key=lambda m: m.get("localId", 0))

    img_dir = os.path.join(images_dir, talker) if images_dir else None

    # Format main messages
    lines = []
    subscribe_messages = {wxid: [] for wxid in subscribe}

    for msg in new_msgs:
        sender = msg.get("senderUsername", "")
        nickname = resolve_nickname(sender, members)
        ts = msg.get("createTime", 0)
        time_str = format_time(ts) if ts else ""
        content = msg.get("content", "")

        # Handle image messages
        if msg.get("mediaType") == "image" and msg.get("mediaUrl"):
            img_path = None
            if img_dir:
                img_path = download_image(msg["mediaUrl"], img_dir)
            if img_path:
                content = f"[图片] {os.path.abspath(img_path)}"
            else:
                content = "[图片]"

        line = f"{time_str} {nickname}: {content}"
        lines.append(line)

        # Track subscribe_members messages
        if sender in subscribe_messages:
            subscribe_messages[sender].append(content)

    # Output
    print(f"群名：{name}")
    if prompt:
        print(f"用户要求：{prompt}")
    print("近期群信息：")
    for line in lines:
        print(line)

    # Subscribe members section
    has_subscribe = any(msgs for msgs in subscribe_messages.values())
    if has_subscribe:
        print()
        print("另外总结关注群友的发言：")
        for wxid, msgs in subscribe_messages.items():
            if not msgs:
                continue
            member_name = resolve_nickname(wxid, members)
            print(f"{member_name}说：")
            for i, m in enumerate(msgs, 1):
                print(f"{i}. {m}")

    print()

    # Return new last_local_id
    return max(m.get("localId", 0) for m in new_msgs)


def main():
    parser = argparse.ArgumentParser(description="Fetch WeFlow group messages")
    parser.add_argument("config", help="Path to weflow-groups.yaml")
    parser.add_argument(
        "--images-dir",
        default="images",
        help="Directory to save downloaded images (default: images)",
    )
    args = parser.parse_args()

    config = load_config(args.config)
    host = config.get("host", "").rstrip("/")

    if not host:
        print("Error: 'host' not set in config", file=sys.stderr)
        sys.exit(1)

    groups = config.get("groups", [])
    if not groups:
        print("Error: no groups configured", file=sys.stderr)
        sys.exit(1)

    print("根据用户要求，总结各群消息")
    print()

    updated = False
    for group in groups:
        new_last_id = process_group(host, group, args.images_dir)
        if new_last_id > group.get("last_local_id", 0):
            group["last_local_id"] = new_last_id
            updated = True

    if updated:
        save_config(args.config, config)


if __name__ == "__main__":
    main()


path: weflow-groups.yaml
content: # WeFlow 群聊监控配置
#
# host: WeFlow API 地址
#   - 直连: http://127.0.0.1:5031
#   - 通过代理: http://<proxy-ip>:5032
host: ""

# groups: 需要监控的群聊列表
#   - talker: "12345678@chatroom"      # 群聊 ID
#     name: "群聊名称"                  # 群聊名称（方便识别）
#     members: "/path/to/members.json"  # 群成员 JSON（由 convert_members.py 生成）
#     summary_prompt: "你是情报分析专家，挖掘群聊中的高价值信息"   # 总结提示词
#     subscribe_members:                # 特别关注的群友 wxid
#       - "wxid_example"
#     last_local_id: 0                  # 上次读取的消息 ID（脚本自动更新，勿手动修改）
groups: []


path: weflow-proxy/build-windows.sh
content: #!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")"

# Clawhub filters .mod files, so we store as .mod.txt and restore before build
cp go.mod.txt go.mod
trap 'rm -f go.mod' EXIT

echo "Cross-compiling weflow-proxy for Windows (amd64)..."
GOOS=windows GOARCH=amd64 go build -o weflow-proxy.exe .

echo "Done: $(ls -lh weflow-proxy.exe | awk '{print $5, $NF}')"


path: weflow-proxy/go.mod.txt
content: module weflow-proxy

go 1.25.1


path: weflow-proxy/main.go
content: package main

import (
	"fmt"
	"log"
	"net"
	"net/http"

	"weflow-proxy/proxy"
)

func main() {
	const (
		listenAddr  = "0.0.0.0:5032"
		upstreamURL = "http://127.0.0.1:5031"
	)

	handler := proxy.NewHandler(upstreamURL)

	fmt.Printf("weflow-proxy listening\n")
	if addrs := lanAddresses(); len(addrs) > 0 {
		for _, ip := range addrs {
			fmt.Printf("您可通过此地址访问 Weflow API: http://%s:%s\n", ip, "5032")
		}
	} else {
		fmt.Println("  warning: no LAN addresses detected")
	}
	if err := http.ListenAndServe(listenAddr, handler); err != nil {
		log.Fatal(err)
	}
}

func lanAddresses() []string {
	ifaces, err := net.Interfaces()
	if err != nil {
		return nil
	}
	var ips []string
	for _, iface := range ifaces {
		if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
			continue
		}
		addrs, err := iface.Addrs()
		if err != nil {
			continue
		}
		for _, addr := range addrs {
			ipNet, ok := addr.(*net.IPNet)
			if !ok {
				continue
			}
			if ipNet.IP.To4() == nil {
				continue
			}
			ips = append(ips, ipNet.IP.String())
		}
	}
	return ips
}


path: weflow-proxy/proxy/proxy.go
content: package proxy

import (
	"bytes"
	"context"
	"io"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strconv"
	"strings"
)

type contextKey int

const incomingHostKey contextKey = iota

// NewHandler creates an http.Handler that reverse-proxies all requests
// to the given upstream URL. It panics if upstream is not a valid URL,
// as this is expected to be called once at startup with a known-good value.
//
// JSON responses have upstream URLs rewritten to the proxy's host,
// so that clients can follow mediaUrl links directly.
func NewHandler(upstream string) http.Handler {
	target, err := url.Parse(upstream)
	if err != nil {
		panic("invalid upstream URL: " + err.Error())
	}

	upstreamOrigin := []byte(target.Scheme + "://" + target.Host)

	rp := &httputil.ReverseProxy{
		Rewrite: func(r *httputil.ProxyRequest) {
			ctx := context.WithValue(r.Out.Context(), incomingHostKey, r.In.Host)
			r.Out = r.Out.WithContext(ctx)
			r.SetURL(target)
		},
		ModifyResponse: func(resp *http.Response) error {
			if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
				return nil
			}

			body, err := io.ReadAll(resp.Body)
			resp.Body.Close()
			if err != nil {
				return err
			}

			if host, ok := resp.Request.Context().Value(incomingHostKey).(string); ok && host != "" {
				proxyOrigin := []byte("http://" + host)
				body = bytes.ReplaceAll(body, upstreamOrigin, proxyOrigin)
			}

			resp.Body = io.NopCloser(bytes.NewReader(body))
			resp.ContentLength = int64(len(body))
			resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
			return nil
		},
	}

	return rp
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)