sophhub 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/online-bug-report/skill.json +55 -0
- package/skills/online-bug-report/src/SKILL.md +131 -0
- package/skills/online-bug-report/src/pyproject.toml +5 -0
- package/skills/online-bug-report/src/references/config.example.json +11 -0
- package/skills/online-bug-report/src/scripts/report_bug.py +631 -0
- package/skills/online-bug-report/src/secrets/bug-report.json +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "online-bug-report",
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"types": [
|
|
5
|
+
"store"
|
|
6
|
+
],
|
|
7
|
+
"displayName": "在线提Bug",
|
|
8
|
+
"description": "用户说「在线提交bug」时收集 Bug 并经由虾友 DM 发送工单(含 session 附件)",
|
|
9
|
+
"changelog": [
|
|
10
|
+
{
|
|
11
|
+
"changes": [
|
|
12
|
+
"凭证移至 src/secrets/bug-report.json,与 npm 打包仅含 src/ 对齐"
|
|
13
|
+
],
|
|
14
|
+
"date": "2026-05-19",
|
|
15
|
+
"version": "1.0.5"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"changes": [
|
|
19
|
+
"凭证仅使用 secrets/bug-report.json,移除 .secrets 候选路径"
|
|
20
|
+
],
|
|
21
|
+
"date": "2026-05-19",
|
|
22
|
+
"version": "1.0.4"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"changes": [
|
|
26
|
+
"内置 secrets/bug-report.json 随 skill 下发,下载即用"
|
|
27
|
+
],
|
|
28
|
+
"date": "2026-05-19",
|
|
29
|
+
"version": "1.0.3"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"changes": [
|
|
33
|
+
"凭证改为 .secrets/bug-report.json;明确唯一触发词;移除仓库内嵌凭证"
|
|
34
|
+
],
|
|
35
|
+
"date": "2026-05-19",
|
|
36
|
+
"version": "1.0.2"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"changes": [
|
|
40
|
+
"工单环境区增加「模型」字段;支持 --model 与 default_model 配置"
|
|
41
|
+
],
|
|
42
|
+
"date": "2026-05-18",
|
|
43
|
+
"version": "1.0.1"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"changes": [
|
|
47
|
+
"初次提交:精简工单模板、preview/send、虾友 DM 外推"
|
|
48
|
+
],
|
|
49
|
+
"date": "2026-05-18",
|
|
50
|
+
"version": "1.0.0"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"createdAt": "2026-05-18",
|
|
54
|
+
"updatedAt": "2026-05-19"
|
|
55
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: online-bug-report
|
|
3
|
+
description: |
|
|
4
|
+
仅当用户说「在线提交bug」(大小写不敏感)时触发。收集 Bug 描述、上传 session .jsonl 到 OSS,
|
|
5
|
+
通过虾友 DM 发送工单。勿因「提 Bug」「报障」「系统坏了」等其它说法触发。
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# 在线提 Bug
|
|
9
|
+
|
|
10
|
+
用户说「**在线提交 Bug**」后,收集 Bug 描述并提交工单。
|
|
11
|
+
|
|
12
|
+
## Processing Mode
|
|
13
|
+
|
|
14
|
+
**STRICT SERIAL PROCESSING ONLY** — 单次顺序执行。
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- Python 3.10+、`uv`
|
|
19
|
+
- 有效 JWT:`/home/node/.openclaw/jwt.json`(字段 `web_jwt`)
|
|
20
|
+
- `exec` 工具可用
|
|
21
|
+
|
|
22
|
+
## 凭证文件
|
|
23
|
+
|
|
24
|
+
**内置凭证(随 skill 下发):** `{baseDir}/secrets/bug-report.json`,下载后可直接使用。修改收件人请直接编辑该文件,或使用 `--cred-file` 指定其它路径。
|
|
25
|
+
|
|
26
|
+
## 触发词
|
|
27
|
+
|
|
28
|
+
**唯一触发词**:「在线提交bug」(`bug` 不区分大小写;允许 `在线提交 bug` 中间有空格)
|
|
29
|
+
|
|
30
|
+
| 用户说的话 | 是否触发 |
|
|
31
|
+
|-----------|----------|
|
|
32
|
+
| 「在线提交bug」/「在线提交 Bug」 | ✅ 触发 |
|
|
33
|
+
| 「提 Bug」 | ❌ 不触发,正常回答 |
|
|
34
|
+
| 「报 Bug」 | ❌ 不触发,正常回答 |
|
|
35
|
+
| 「系统坏了」 | ❌ 不触发,正常回答 |
|
|
36
|
+
| 「有个 Bug」 | ❌ 不触发,正常回答 |
|
|
37
|
+
|
|
38
|
+
## Agent 角色定位
|
|
39
|
+
|
|
40
|
+
**Agent 是 Bug 信息收集者,不是问题解决者。**
|
|
41
|
+
|
|
42
|
+
| Agent 行为 | 正确/错误 |
|
|
43
|
+
|-----------|----------|
|
|
44
|
+
| 收集用户描述 → 提交工单 | ✅ 正确 |
|
|
45
|
+
| 尝试自己分析/修复 Bug | ❌ 错误 |
|
|
46
|
+
| 询问技术细节、API、配置 | ❌ 错误 |
|
|
47
|
+
| 让用户补充现象描述 | ✅ 正确 |
|
|
48
|
+
|
|
49
|
+
## 执行流程
|
|
50
|
+
|
|
51
|
+
### Step 1:用户说「在线提交 Bug」
|
|
52
|
+
|
|
53
|
+
Agent **立即停止任何尝试解决问题的行为**,回复:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
好的,我来帮您提交 Bug 工单。
|
|
57
|
+
|
|
58
|
+
请补充一下问题描述:
|
|
59
|
+
- 具体是什么问题/现象?
|
|
60
|
+
- 大概什么时候发生的?
|
|
61
|
+
- 有复现步骤吗?(可选)
|
|
62
|
+
|
|
63
|
+
补充完之后告诉我,我会提交处理。
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**关键点**:
|
|
67
|
+
- **不尝试**:不分析原因、不尝试修复、不给解决方案
|
|
68
|
+
- **只收集**:等待用户补充描述
|
|
69
|
+
- **不询问技术细节**
|
|
70
|
+
|
|
71
|
+
### Step 2:等待用户补充描述
|
|
72
|
+
|
|
73
|
+
用户补充描述后,记录用户原话作为 Bug 描述。
|
|
74
|
+
|
|
75
|
+
若描述不足,追问:「请问具体现象是什么?」
|
|
76
|
+
不追问技术细节。
|
|
77
|
+
|
|
78
|
+
### Step 3:获取完整 session 文件
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
SESSION_KEY="{当前sessionKey}"
|
|
82
|
+
SESSION_ID=$(cat {agentDir}/sessions/sessions.json | jq -r '."${SESSION_KEY}".sessionId')
|
|
83
|
+
SESSION_FILE="{agentDir}/sessions/${SESSION_ID}.jsonl"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**上传完整的 `.jsonl` 文件**(包含整个对话历史)。
|
|
87
|
+
|
|
88
|
+
### Step 4:上传到 OSS
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd {workspace}/skills/sophnet-oss
|
|
92
|
+
uv run --with sophnet-tools python scripts/upload_file.py \
|
|
93
|
+
--file "${SESSION_FILE}" --timeout 60
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Step 5:发送工单
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cd {workspace}/skills/online-bug-report/src
|
|
100
|
+
uv run scripts/report_bug.py send \
|
|
101
|
+
--sender-id "{sender_id}" \
|
|
102
|
+
--sender-name "{sender_name}" \
|
|
103
|
+
--description "{用户完整描述}" \
|
|
104
|
+
--session-key "${SESSION_KEY}" \
|
|
105
|
+
--oss-url "${OSS_URL}" \
|
|
106
|
+
--json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Step 6:回复用户
|
|
110
|
+
|
|
111
|
+
- 成功:「已提交 Bug 工单,会跟进处理。」
|
|
112
|
+
- 失败:「已记录问题,稍后将补发工单。」
|
|
113
|
+
|
|
114
|
+
## 与「知识库反馈」分流
|
|
115
|
+
|
|
116
|
+
| 用户意图 | 处理 |
|
|
117
|
+
|---------|------|
|
|
118
|
+
| 「文档错了」「知识库内容有误」 | `memory/feedback-*.md` 流程 |
|
|
119
|
+
| 「在线提交 Bug」 | **本 Skill** → 收集并提交 |
|
|
120
|
+
|
|
121
|
+
## 安全约束
|
|
122
|
+
|
|
123
|
+
- **不暴露**:JWT、凭证路径、friendId、API 地址
|
|
124
|
+
- **不询问**:技术细节、配置参数
|
|
125
|
+
- **不尝试**:分析或修复 Bug
|
|
126
|
+
|
|
127
|
+
## Forbidden
|
|
128
|
+
|
|
129
|
+
- 不要尝试解决用户的问题
|
|
130
|
+
- 不要向用户暴露 JWT、凭证
|
|
131
|
+
- 不要询问技术细节
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "字段说明参考;实际凭证见 secrets/bug-report.json(与 SKILL.md 同级的 src/secrets/)",
|
|
3
|
+
"dev_friend_id": 0,
|
|
4
|
+
"dev_friend_name": "收件人名称",
|
|
5
|
+
"terminal_label": "虾友 DM",
|
|
6
|
+
"terminal_url": "",
|
|
7
|
+
"session_url_template": "{base_url}/chat?session={session_key}",
|
|
8
|
+
"api_base_url": "https://yagent.sophnet.com/api",
|
|
9
|
+
"openclaw_base_url": "",
|
|
10
|
+
"default_model": "Qwen3.5-122B-A10B"
|
|
11
|
+
}
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""智能客服在线 Bug:组单并通过虾友 DM 发送给研发值班。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 30
|
|
21
|
+
DEFAULT_API_BASE_URL = "https://yagent.sophnet.com/api"
|
|
22
|
+
DEFAULT_JWT_PATH = Path("/home/node/.openclaw/jwt.json")
|
|
23
|
+
DEFAULT_OPENCLAW_BASE_PATH = Path("/home/node/.openclaw/.base.json")
|
|
24
|
+
DEFAULT_SOURCE_LABEL = "虾友 DM"
|
|
25
|
+
|
|
26
|
+
CONFIG_ALIASES: dict[str, tuple[str, ...]] = {
|
|
27
|
+
"dev_friend_id": ("dev_friend_id", "DEV_FRIEND_ID", "friend_id"),
|
|
28
|
+
"dev_friend_name": ("dev_friend_name", "DEV_FRIEND_NAME", "friend_name"),
|
|
29
|
+
"terminal_label": ("terminal_label", "TERMINAL_LABEL"),
|
|
30
|
+
"terminal_url": ("terminal_url", "TERMINAL_URL"),
|
|
31
|
+
"session_url_template": (
|
|
32
|
+
"session_url_template",
|
|
33
|
+
"SESSION_URL_TEMPLATE",
|
|
34
|
+
"bug_session_url_template",
|
|
35
|
+
),
|
|
36
|
+
"api_base_url": ("api_base_url", "API_BASE_URL", "base_url"),
|
|
37
|
+
"openclaw_base_url": (
|
|
38
|
+
"openclaw_base_url",
|
|
39
|
+
"OPENCLAW_BASE_URL",
|
|
40
|
+
"sophclaw_base_url",
|
|
41
|
+
),
|
|
42
|
+
"default_model": ("default_model", "DEFAULT_MODEL", "llm", "model"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AppError(RuntimeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configure_stdio() -> None:
|
|
51
|
+
for name in ("stdout", "stderr"):
|
|
52
|
+
stream = getattr(sys, name, None)
|
|
53
|
+
if stream is not None and hasattr(stream, "reconfigure"):
|
|
54
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_json(payload: Any) -> None:
|
|
58
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_http_url(url: str, *, field_name: str = "url") -> str:
|
|
62
|
+
value = (url or "").strip().rstrip("/")
|
|
63
|
+
if not value:
|
|
64
|
+
raise AppError(f"{field_name} 不能为空")
|
|
65
|
+
if not value.startswith(("http://", "https://")):
|
|
66
|
+
raise AppError(f"{field_name} 必须以 http:// 或 https:// 开头")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_json_file(path: Path) -> dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
with path.open("r", encoding="utf-8") as fp:
|
|
73
|
+
data = json.load(fp)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise AppError(f"无法读取文件 {path}: {exc}") from exc
|
|
76
|
+
except json.JSONDecodeError as exc:
|
|
77
|
+
raise AppError(f"文件不是有效 JSON: {path}: {exc}") from exc
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
raise AppError(f"JSON 根对象必须是对象: {path}")
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_openclaw_base_url(path: Path) -> Optional[str]:
|
|
84
|
+
if not path.is_file():
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
data = load_json_file(path)
|
|
88
|
+
except AppError:
|
|
89
|
+
return None
|
|
90
|
+
raw = data.get("base_url")
|
|
91
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
92
|
+
return None
|
|
93
|
+
return raw.strip().rstrip("/")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def skill_src_dir() -> Path:
|
|
97
|
+
"""Skill 可打包目录(与 prepublish 仅复制 src/ 一致)。"""
|
|
98
|
+
return Path(__file__).resolve().parent.parent
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def default_cred_file() -> Path:
|
|
102
|
+
return skill_src_dir() / "secrets" / "bug-report.json"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_cred_path(explicit: Optional[str]) -> Optional[Path]:
|
|
106
|
+
if explicit and str(explicit).strip():
|
|
107
|
+
return Path(explicit).expanduser()
|
|
108
|
+
default = default_cred_file()
|
|
109
|
+
return default if default.is_file() else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_cred_file(path: Optional[str], *, required: bool = False) -> dict[str, Any]:
|
|
113
|
+
file_path = resolve_cred_path(path)
|
|
114
|
+
if file_path is None:
|
|
115
|
+
if required:
|
|
116
|
+
raise AppError(
|
|
117
|
+
f"未找到凭证文件,请使用 --cred-file 或确认存在:{default_cred_file()}"
|
|
118
|
+
)
|
|
119
|
+
return {}
|
|
120
|
+
if not file_path.is_file():
|
|
121
|
+
raise AppError(f"凭证文件不存在: {file_path}")
|
|
122
|
+
return load_json_file(file_path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cfg_pick(cred: dict[str, Any], key: str, env_name: Optional[str] = None) -> Optional[str]:
|
|
126
|
+
for alias in CONFIG_ALIASES.get(key, (key,)):
|
|
127
|
+
value = cred.get(alias)
|
|
128
|
+
if isinstance(value, str) and value.strip():
|
|
129
|
+
return value.strip()
|
|
130
|
+
if isinstance(value, (int, float)) and key == "dev_friend_id":
|
|
131
|
+
return str(int(value))
|
|
132
|
+
if env_name:
|
|
133
|
+
env_val = os.environ.get(env_name)
|
|
134
|
+
if isinstance(env_val, str) and env_val.strip():
|
|
135
|
+
return env_val.strip()
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_session_url(
|
|
140
|
+
session_key: Optional[str],
|
|
141
|
+
session_url: Optional[str],
|
|
142
|
+
template: Optional[str],
|
|
143
|
+
openclaw_base_url: Optional[str],
|
|
144
|
+
) -> Optional[str]:
|
|
145
|
+
if isinstance(session_url, str) and session_url.strip():
|
|
146
|
+
return session_url.strip()
|
|
147
|
+
if not session_key or not template or not openclaw_base_url:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return template.format(
|
|
151
|
+
base_url=openclaw_base_url.rstrip("/"),
|
|
152
|
+
session_key=session_key.strip(),
|
|
153
|
+
)
|
|
154
|
+
except (KeyError, ValueError) as exc:
|
|
155
|
+
raise AppError(f"session_url_template 格式无效: {exc}") from exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class BugReport:
|
|
160
|
+
sender_id: str
|
|
161
|
+
sender_name: str
|
|
162
|
+
user_description: str
|
|
163
|
+
openclaw_base_url: Optional[str] = None
|
|
164
|
+
terminal_label: str = DEFAULT_SOURCE_LABEL
|
|
165
|
+
terminal_url: Optional[str] = None
|
|
166
|
+
session_key: Optional[str] = None
|
|
167
|
+
session_url: Optional[str] = None
|
|
168
|
+
context: Optional[str] = None
|
|
169
|
+
model: Optional[str] = None
|
|
170
|
+
oss_urls: list[str] = field(default_factory=list)
|
|
171
|
+
reported_at: Optional[str] = None
|
|
172
|
+
|
|
173
|
+
def validate(self) -> None:
|
|
174
|
+
if not self.sender_id.strip():
|
|
175
|
+
raise AppError("sender-id 不能为空")
|
|
176
|
+
if not self.sender_name.strip():
|
|
177
|
+
raise AppError("sender-name 不能为空")
|
|
178
|
+
if not self.user_description.strip():
|
|
179
|
+
raise AppError("description 不能为空")
|
|
180
|
+
for url in self.oss_urls:
|
|
181
|
+
normalize_http_url(url, field_name="oss-url")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_bug_message(report: BugReport) -> str:
|
|
185
|
+
ts = report.reported_at or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
186
|
+
lines = [
|
|
187
|
+
f"【在线 Bug】{ts}",
|
|
188
|
+
"",
|
|
189
|
+
"▎发件人",
|
|
190
|
+
f"- 用户: {report.sender_name.strip()} ({report.sender_id.strip()})",
|
|
191
|
+
f"- 来源: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}",
|
|
192
|
+
"",
|
|
193
|
+
"▎用户描述",
|
|
194
|
+
report.user_description.strip(),
|
|
195
|
+
"",
|
|
196
|
+
"▎环境",
|
|
197
|
+
]
|
|
198
|
+
if report.openclaw_base_url:
|
|
199
|
+
lines.append(f"- OpenClaw Base URL: {report.openclaw_base_url}")
|
|
200
|
+
else:
|
|
201
|
+
lines.append("- OpenClaw Base URL: (未配置)")
|
|
202
|
+
terminal_line = f"- 终端: {report.terminal_label.strip() or DEFAULT_SOURCE_LABEL}"
|
|
203
|
+
if report.terminal_url and report.terminal_url.strip():
|
|
204
|
+
terminal_line += f" ({report.terminal_url.strip()})"
|
|
205
|
+
lines.append(terminal_line)
|
|
206
|
+
if report.model and report.model.strip():
|
|
207
|
+
lines.append(f"- 模型: {report.model.strip()}")
|
|
208
|
+
else:
|
|
209
|
+
lines.append("- 模型: (未知)")
|
|
210
|
+
|
|
211
|
+
lines.extend(["", "▎会话"])
|
|
212
|
+
if report.session_key and report.session_key.strip():
|
|
213
|
+
lines.append(f"- sessionKey: {report.session_key.strip()}")
|
|
214
|
+
else:
|
|
215
|
+
lines.append("- sessionKey: (无)")
|
|
216
|
+
if report.session_url and report.session_url.strip():
|
|
217
|
+
lines.append(f"- Session 链接: {report.session_url.strip()}")
|
|
218
|
+
if report.context and report.context.strip():
|
|
219
|
+
lines.extend(["- 近期上下文:", report.context.strip()])
|
|
220
|
+
|
|
221
|
+
lines.extend(["", "▎附件"])
|
|
222
|
+
if report.oss_urls:
|
|
223
|
+
lines.extend(report.oss_urls)
|
|
224
|
+
else:
|
|
225
|
+
lines.append("(无)")
|
|
226
|
+
|
|
227
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read_bearer_token(jwt_path: Path) -> str:
|
|
231
|
+
data = load_json_file(jwt_path)
|
|
232
|
+
token = data.get("web_jwt")
|
|
233
|
+
if not isinstance(token, str) or not token.strip():
|
|
234
|
+
raise AppError(f"JWT 文件缺少有效 web_jwt: {jwt_path}")
|
|
235
|
+
token = token.strip()
|
|
236
|
+
if token.lower().startswith("bearer "):
|
|
237
|
+
token = token[7:].strip()
|
|
238
|
+
return token
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def jwt_exp_unix(token: str) -> Optional[int]:
|
|
242
|
+
try:
|
|
243
|
+
parts = token.split(".")
|
|
244
|
+
if len(parts) != 3:
|
|
245
|
+
return None
|
|
246
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
247
|
+
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
|
|
248
|
+
data = json.loads(raw)
|
|
249
|
+
exp = data.get("exp")
|
|
250
|
+
return int(exp) if isinstance(exp, (int, float)) else None
|
|
251
|
+
except Exception:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def ensure_jwt_valid(token: str, jwt_path: Path, allow_expired: bool) -> None:
|
|
256
|
+
if allow_expired:
|
|
257
|
+
return
|
|
258
|
+
exp = jwt_exp_unix(token)
|
|
259
|
+
if exp is None:
|
|
260
|
+
return
|
|
261
|
+
now = int(time.time())
|
|
262
|
+
if now >= exp + 60:
|
|
263
|
+
raise AppError(f"JWT 已过期,请重新登录后刷新 {jwt_path}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def request_json(
|
|
267
|
+
method: str,
|
|
268
|
+
url: str,
|
|
269
|
+
token: str,
|
|
270
|
+
timeout: int,
|
|
271
|
+
payload: Optional[dict[str, Any]] = None,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
274
|
+
body = None
|
|
275
|
+
if payload is not None:
|
|
276
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
277
|
+
headers["Content-Type"] = "application/json"
|
|
278
|
+
req = urllib.request.Request(url=url, data=body, headers=headers, method=method)
|
|
279
|
+
try:
|
|
280
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
281
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
282
|
+
except urllib.error.HTTPError as exc:
|
|
283
|
+
err_body = exc.read().decode("utf-8", errors="replace")
|
|
284
|
+
hint = ""
|
|
285
|
+
if exc.code == 401:
|
|
286
|
+
hint = f"(请检查 {jwt_path_hint()} 中的 web_jwt)"
|
|
287
|
+
raise AppError(f"HTTP {exc.code} {url}: {err_body}{hint}") from exc
|
|
288
|
+
except urllib.error.URLError as exc:
|
|
289
|
+
raise AppError(f"请求失败 {url}: {exc}") from exc
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
data = json.loads(raw)
|
|
293
|
+
except json.JSONDecodeError as exc:
|
|
294
|
+
raise AppError(f"接口返回不是有效 JSON: {url}") from exc
|
|
295
|
+
if isinstance(data, dict) and data.get("status") not in (None, 0):
|
|
296
|
+
raise AppError(
|
|
297
|
+
f"接口错误: status={data.get('status')} message={data.get('message')!r}"
|
|
298
|
+
)
|
|
299
|
+
return data if isinstance(data, dict) else {"result": data}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def jwt_path_hint() -> str:
|
|
303
|
+
return str(DEFAULT_JWT_PATH)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def unwrap_result(data: dict[str, Any]) -> Any:
|
|
307
|
+
return data.get("result") if "result" in data else data
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_or_create_dm(api_base: str, token: str, friend_id: int, timeout: int) -> str:
|
|
311
|
+
url = f"{api_base}/sys/openclaw/friend/dm"
|
|
312
|
+
data = request_json("POST", url, token, timeout, payload={"friendId": friend_id})
|
|
313
|
+
result = unwrap_result(data) or {}
|
|
314
|
+
channel = result.get("channel") if isinstance(result, dict) else None
|
|
315
|
+
if not isinstance(channel, dict):
|
|
316
|
+
channel = result if isinstance(result, dict) else {}
|
|
317
|
+
rid = channel.get("_id")
|
|
318
|
+
if not rid:
|
|
319
|
+
raise AppError("DM 创建成功但缺少 result.channel._id")
|
|
320
|
+
return str(rid)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def send_im_message(api_base: str, token: str, rid: str, text: str, timeout: int) -> dict[str, Any]:
|
|
324
|
+
url = f"{api_base}/sys/openclaw/im/chat.sendMessage"
|
|
325
|
+
data = request_json("POST", url, token, timeout, payload={"rid": rid, "msg": text})
|
|
326
|
+
result = unwrap_result(data) or {}
|
|
327
|
+
message = result.get("message") if isinstance(result, dict) else None
|
|
328
|
+
return message if isinstance(message, dict) else {}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def load_friends(api_base: str, token: str, timeout: int) -> list[dict[str, Any]]:
|
|
332
|
+
url = f"{api_base}/sys/openclaw/friend/list"
|
|
333
|
+
data = request_json("GET", url, token, timeout)
|
|
334
|
+
result = unwrap_result(data)
|
|
335
|
+
if not isinstance(result, dict):
|
|
336
|
+
raise AppError("虾友列表接口返回格式异常")
|
|
337
|
+
friends = result.get("friends")
|
|
338
|
+
if not isinstance(friends, list):
|
|
339
|
+
raise AppError("虾友列表缺少 friends 数组")
|
|
340
|
+
out: list[dict[str, Any]] = []
|
|
341
|
+
for item in friends:
|
|
342
|
+
if not isinstance(item, dict):
|
|
343
|
+
continue
|
|
344
|
+
fid = item.get("friendId")
|
|
345
|
+
if fid is None:
|
|
346
|
+
continue
|
|
347
|
+
display = str(item.get("remark") or item.get("nickname") or "未命名虾友").strip()
|
|
348
|
+
out.append({"friendId": fid, "displayName": display})
|
|
349
|
+
return out
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def find_nearest_agent_config(start_dir: Path | None = None) -> Path | None:
|
|
353
|
+
current = (start_dir or Path.cwd()).resolve()
|
|
354
|
+
for candidate in [current, *current.parents]:
|
|
355
|
+
config_path = candidate / ".config.json"
|
|
356
|
+
if config_path.is_file():
|
|
357
|
+
return config_path
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def read_model_from_agent_config(config_path: Path | None = None) -> Optional[str]:
|
|
362
|
+
path = config_path or find_nearest_agent_config()
|
|
363
|
+
if path is None:
|
|
364
|
+
return None
|
|
365
|
+
try:
|
|
366
|
+
data = load_json_file(path)
|
|
367
|
+
except AppError:
|
|
368
|
+
return None
|
|
369
|
+
llm = data.get("llm")
|
|
370
|
+
if isinstance(llm, str) and llm.strip():
|
|
371
|
+
return llm.strip()
|
|
372
|
+
model = data.get("model")
|
|
373
|
+
if isinstance(model, str) and model.strip():
|
|
374
|
+
return model.strip()
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def read_context_file(path: Optional[str]) -> Optional[str]:
|
|
379
|
+
if not path:
|
|
380
|
+
return None
|
|
381
|
+
file_path = Path(path).expanduser()
|
|
382
|
+
try:
|
|
383
|
+
text = file_path.read_text(encoding="utf-8")
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
raise AppError(f"无法读取 context 文件 {file_path}: {exc}") from exc
|
|
386
|
+
text = text.strip()
|
|
387
|
+
return text or None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def build_report_from_args(args: argparse.Namespace) -> BugReport:
|
|
391
|
+
cred = load_cred_file(args.cred_file)
|
|
392
|
+
|
|
393
|
+
openclaw_base = (
|
|
394
|
+
(args.openclaw_base_url or "").strip()
|
|
395
|
+
or cfg_pick(cred, "openclaw_base_url", "OPENCLAW_BASE_URL")
|
|
396
|
+
or read_openclaw_base_url(Path(args.openclaw_base_path).expanduser())
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
terminal_label = (
|
|
400
|
+
(args.terminal_label or "").strip()
|
|
401
|
+
or cfg_pick(cred, "terminal_label", "BUG_TERMINAL_LABEL")
|
|
402
|
+
or DEFAULT_SOURCE_LABEL
|
|
403
|
+
)
|
|
404
|
+
terminal_url = (
|
|
405
|
+
(args.terminal_url or "").strip()
|
|
406
|
+
or cfg_pick(cred, "terminal_url", "BUG_TERMINAL_URL")
|
|
407
|
+
or None
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
session_key = (args.session_key or "").strip() or None
|
|
411
|
+
template = (args.session_url_template or "").strip() or cfg_pick(
|
|
412
|
+
cred, "session_url_template", "BUG_SESSION_URL_TEMPLATE"
|
|
413
|
+
)
|
|
414
|
+
explicit_session_url = (args.session_url or "").strip() or None
|
|
415
|
+
session_url = resolve_session_url(
|
|
416
|
+
session_key,
|
|
417
|
+
explicit_session_url,
|
|
418
|
+
template,
|
|
419
|
+
openclaw_base,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
context = (args.context or "").strip() or read_context_file(args.context_file)
|
|
423
|
+
|
|
424
|
+
oss_urls = [normalize_http_url(u, field_name="oss-url") for u in (args.oss_url or [])]
|
|
425
|
+
|
|
426
|
+
model = (
|
|
427
|
+
(args.model or "").strip()
|
|
428
|
+
or cfg_pick(cred, "default_model", "BUG_MODEL")
|
|
429
|
+
or read_model_from_agent_config()
|
|
430
|
+
) or None
|
|
431
|
+
|
|
432
|
+
return BugReport(
|
|
433
|
+
sender_id=args.sender_id,
|
|
434
|
+
sender_name=args.sender_name,
|
|
435
|
+
user_description=args.description,
|
|
436
|
+
openclaw_base_url=openclaw_base,
|
|
437
|
+
terminal_label=terminal_label,
|
|
438
|
+
terminal_url=terminal_url,
|
|
439
|
+
session_key=session_key,
|
|
440
|
+
session_url=session_url,
|
|
441
|
+
context=context,
|
|
442
|
+
model=model,
|
|
443
|
+
oss_urls=oss_urls,
|
|
444
|
+
reported_at=(args.reported_at or "").strip() or None,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def resolve_dev_friend(args: argparse.Namespace, cred: dict[str, Any]) -> tuple[int, str]:
|
|
449
|
+
if args.friend_id is not None and args.friend_id > 0:
|
|
450
|
+
friend_id = int(args.friend_id)
|
|
451
|
+
else:
|
|
452
|
+
raw = cfg_pick(cred, "dev_friend_id", "BUG_DEV_FRIEND_ID")
|
|
453
|
+
if not raw:
|
|
454
|
+
raise AppError("未配置研发虾友:请使用 --friend-id 或凭证文件 dev_friend_id")
|
|
455
|
+
try:
|
|
456
|
+
friend_id = int(raw)
|
|
457
|
+
except ValueError as exc:
|
|
458
|
+
raise AppError(f"dev_friend_id 不是有效整数: {raw}") from exc
|
|
459
|
+
if friend_id <= 0:
|
|
460
|
+
raise AppError("friend-id 必须为正整数")
|
|
461
|
+
friend_name = (
|
|
462
|
+
(args.friend_name or "").strip()
|
|
463
|
+
or cfg_pick(cred, "dev_friend_name", "BUG_DEV_FRIEND_NAME")
|
|
464
|
+
or "研发值班"
|
|
465
|
+
)
|
|
466
|
+
return friend_id, friend_name
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def add_report_args(parser: argparse.ArgumentParser) -> None:
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--cred-file",
|
|
472
|
+
"-c",
|
|
473
|
+
help="凭证 JSON 路径(默认 src/secrets/bug-report.json)",
|
|
474
|
+
)
|
|
475
|
+
parser.add_argument("--sender-id", required=True, help="发件人 ID(虾友/Bot senderId)")
|
|
476
|
+
parser.add_argument("--sender-name", required=True, help="发件人展示名")
|
|
477
|
+
parser.add_argument("--description", required=True, help="用户 Bug 描述")
|
|
478
|
+
parser.add_argument("--session-key", default="", help="当前会话 sessionKey")
|
|
479
|
+
parser.add_argument(
|
|
480
|
+
"--session-url",
|
|
481
|
+
default="",
|
|
482
|
+
help="Session 可点开链接(优先于 template)",
|
|
483
|
+
)
|
|
484
|
+
parser.add_argument(
|
|
485
|
+
"--session-url-template",
|
|
486
|
+
default="",
|
|
487
|
+
help="Session URL 模板,占位符 {base_url} {session_key}",
|
|
488
|
+
)
|
|
489
|
+
parser.add_argument(
|
|
490
|
+
"--openclaw-base-url",
|
|
491
|
+
default="",
|
|
492
|
+
help="OpenClaw 实例根 URL(默认读 ~/.openclaw/.base.json)",
|
|
493
|
+
)
|
|
494
|
+
parser.add_argument(
|
|
495
|
+
"--openclaw-base-path",
|
|
496
|
+
default=str(DEFAULT_OPENCLAW_BASE_PATH),
|
|
497
|
+
help="base_url JSON 路径",
|
|
498
|
+
)
|
|
499
|
+
parser.add_argument("--terminal-label", default="", help="终端名称,默认「虾友 DM」")
|
|
500
|
+
parser.add_argument("--terminal-url", default="", help="终端入口 URL(可选)")
|
|
501
|
+
parser.add_argument(
|
|
502
|
+
"--model",
|
|
503
|
+
default="",
|
|
504
|
+
help="用户/客服会话使用的模型(默认读凭证 default_model 或就近 .config.json 的 llm)",
|
|
505
|
+
)
|
|
506
|
+
parser.add_argument("--context", default="", help="近期对话上下文(多行文本)")
|
|
507
|
+
parser.add_argument("--context-file", help="从文件读取近期上下文")
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"--oss-url",
|
|
510
|
+
action="append",
|
|
511
|
+
default=[],
|
|
512
|
+
help="OSS 附件链接,可重复传入",
|
|
513
|
+
)
|
|
514
|
+
parser.add_argument("--reported-at", default="", help="工单时间,默认当前时间")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def add_http_args(parser: argparse.ArgumentParser) -> None:
|
|
518
|
+
parser.add_argument(
|
|
519
|
+
"--api-base-url",
|
|
520
|
+
default="",
|
|
521
|
+
help=f"虾友 API 根地址(默认 {DEFAULT_API_BASE_URL})",
|
|
522
|
+
)
|
|
523
|
+
parser.add_argument("--jwt-path", default=str(DEFAULT_JWT_PATH))
|
|
524
|
+
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
|
525
|
+
parser.add_argument("--allow-expired-jwt", action="store_true")
|
|
526
|
+
parser.add_argument("--friend-id", type=int, help="研发值班虾友 friendId")
|
|
527
|
+
parser.add_argument("--friend-name", default="", help="研发虾友展示名(日志用)")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
531
|
+
parser = argparse.ArgumentParser(description="在线 Bug:组单并发送给研发虾友")
|
|
532
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
533
|
+
|
|
534
|
+
p_preview = sub.add_parser("preview", help="预览工单正文(不发送)")
|
|
535
|
+
add_report_args(p_preview)
|
|
536
|
+
p_preview.add_argument("--json", action="store_true", help="JSON 输出")
|
|
537
|
+
|
|
538
|
+
p_send = sub.add_parser("send", help="发送工单到研发虾友")
|
|
539
|
+
add_report_args(p_send)
|
|
540
|
+
add_http_args(p_send)
|
|
541
|
+
p_send.add_argument("--json", action="store_true", help="JSON 输出")
|
|
542
|
+
|
|
543
|
+
p_friends = sub.add_parser("list-friends", help="列出虾友(配置 dev_friend_id 时用)")
|
|
544
|
+
p_friends.add_argument(
|
|
545
|
+
"--cred-file",
|
|
546
|
+
"-c",
|
|
547
|
+
help="凭证 JSON 路径(默认 src/secrets/bug-report.json)",
|
|
548
|
+
)
|
|
549
|
+
add_http_args(p_friends)
|
|
550
|
+
|
|
551
|
+
return parser
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
555
|
+
configure_stdio()
|
|
556
|
+
args = build_parser().parse_args(argv)
|
|
557
|
+
try:
|
|
558
|
+
if getattr(args, "timeout", DEFAULT_TIMEOUT) <= 0:
|
|
559
|
+
raise AppError("timeout 必须为正整数")
|
|
560
|
+
|
|
561
|
+
if args.command == "list-friends":
|
|
562
|
+
cred = load_cred_file(getattr(args, "cred_file", None))
|
|
563
|
+
api_base = normalize_http_url(
|
|
564
|
+
(args.api_base_url or "").strip()
|
|
565
|
+
or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
|
|
566
|
+
or DEFAULT_API_BASE_URL,
|
|
567
|
+
field_name="api-base-url",
|
|
568
|
+
)
|
|
569
|
+
token = read_bearer_token(Path(args.jwt_path).expanduser())
|
|
570
|
+
ensure_jwt_valid(token, Path(args.jwt_path).expanduser(), args.allow_expired_jwt)
|
|
571
|
+
friends = load_friends(api_base, token, args.timeout)
|
|
572
|
+
print_json({"friends": friends})
|
|
573
|
+
return 0
|
|
574
|
+
|
|
575
|
+
report = build_report_from_args(args)
|
|
576
|
+
report.validate()
|
|
577
|
+
message = format_bug_message(report)
|
|
578
|
+
|
|
579
|
+
if args.command == "preview":
|
|
580
|
+
payload = {
|
|
581
|
+
"message": message,
|
|
582
|
+
"report": {
|
|
583
|
+
"senderId": report.sender_id,
|
|
584
|
+
"senderName": report.sender_name,
|
|
585
|
+
"model": report.model,
|
|
586
|
+
"sessionKey": report.session_key,
|
|
587
|
+
"sessionUrl": report.session_url,
|
|
588
|
+
"ossUrls": report.oss_urls,
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
if args.json:
|
|
592
|
+
print_json(payload)
|
|
593
|
+
else:
|
|
594
|
+
sys.stdout.write(message)
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
cred = load_cred_file(args.cred_file, required=True)
|
|
598
|
+
friend_id, friend_name = resolve_dev_friend(args, cred)
|
|
599
|
+
api_base = normalize_http_url(
|
|
600
|
+
(args.api_base_url or "").strip()
|
|
601
|
+
or cfg_pick(cred, "api_base_url", "BUG_API_BASE_URL")
|
|
602
|
+
or DEFAULT_API_BASE_URL,
|
|
603
|
+
field_name="api-base-url",
|
|
604
|
+
)
|
|
605
|
+
jwt_path = Path(args.jwt_path).expanduser()
|
|
606
|
+
token = read_bearer_token(jwt_path)
|
|
607
|
+
ensure_jwt_valid(token, jwt_path, args.allow_expired_jwt)
|
|
608
|
+
rid = get_or_create_dm(api_base, token, friend_id, args.timeout)
|
|
609
|
+
sent = send_im_message(api_base, token, rid, message, args.timeout)
|
|
610
|
+
result = {
|
|
611
|
+
"success": True,
|
|
612
|
+
"friend": {"friendId": friend_id, "displayName": friend_name},
|
|
613
|
+
"rid": rid,
|
|
614
|
+
"messageId": sent.get("_id"),
|
|
615
|
+
"message": message,
|
|
616
|
+
}
|
|
617
|
+
if args.json:
|
|
618
|
+
print_json(result)
|
|
619
|
+
else:
|
|
620
|
+
print("✅ Bug 工单已发送给研发虾友。")
|
|
621
|
+
print(f"收件人: {friend_name} (friendId={friend_id})")
|
|
622
|
+
if sent.get("_id"):
|
|
623
|
+
print(f"messageId: {sent.get('_id')}")
|
|
624
|
+
return 0
|
|
625
|
+
except AppError as exc:
|
|
626
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
627
|
+
return 1
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
if __name__ == "__main__":
|
|
631
|
+
raise SystemExit(main())
|