@hupan56/wlkj 2.0.0
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/bin/cli.js +213 -0
- package/package.json +11 -0
- package/templates/cli.js +198 -0
- package/templates/qoder/commands/wl-code.md +43 -0
- package/templates/qoder/commands/wl-commit.md +30 -0
- package/templates/qoder/commands/wl-init.md +80 -0
- package/templates/qoder/commands/wl-insight.md +51 -0
- package/templates/qoder/commands/wl-prd.md +199 -0
- package/templates/qoder/commands/wl-report.md +166 -0
- package/templates/qoder/commands/wl-search.md +52 -0
- package/templates/qoder/commands/wl-spec.md +18 -0
- package/templates/qoder/commands/wl-status.md +51 -0
- package/templates/qoder/commands/wl-task.md +71 -0
- package/templates/qoder/commands/wl-test.md +42 -0
- package/templates/qoder/config.toml +5 -0
- package/templates/qoder/config.yaml +141 -0
- package/templates/qoder/hooks/inject-workflow-state.py +117 -0
- package/templates/qoder/hooks/session-start.py +204 -0
- package/templates/qoder/rules/wl-pipeline.md +105 -0
- package/templates/qoder/scripts/add_session.py +245 -0
- package/templates/qoder/scripts/benchmark.py +209 -0
- package/templates/qoder/scripts/build_style_index.py +268 -0
- package/templates/qoder/scripts/code_index.py +41 -0
- package/templates/qoder/scripts/collect_prds.py +31 -0
- package/templates/qoder/scripts/common/__init__.py +0 -0
- package/templates/qoder/scripts/common/active_task.py +230 -0
- package/templates/qoder/scripts/common/atomicio.py +172 -0
- package/templates/qoder/scripts/common/developer.py +161 -0
- package/templates/qoder/scripts/common/eval_api.py +144 -0
- package/templates/qoder/scripts/common/feishu.py +278 -0
- package/templates/qoder/scripts/common/filelock.py +211 -0
- package/templates/qoder/scripts/common/identity.py +285 -0
- package/templates/qoder/scripts/common/mentions.py +134 -0
- package/templates/qoder/scripts/common/paths.py +311 -0
- package/templates/qoder/scripts/common/reqid.py +218 -0
- package/templates/qoder/scripts/common/search_engine.py +205 -0
- package/templates/qoder/scripts/common/task_utils.py +342 -0
- package/templates/qoder/scripts/common/terms.py +234 -0
- package/templates/qoder/scripts/common/utf8.py +38 -0
- package/templates/qoder/scripts/context_pack.py +196 -0
- package/templates/qoder/scripts/eval_prd.py +225 -0
- package/templates/qoder/scripts/export.py +487 -0
- package/templates/qoder/scripts/git_sync.py +1087 -0
- package/templates/qoder/scripts/handoff.py +22 -0
- package/templates/qoder/scripts/init_developer.py +76 -0
- package/templates/qoder/scripts/init_doctor.py +527 -0
- package/templates/qoder/scripts/install_qoderwork.py +339 -0
- package/templates/qoder/scripts/learn.py +67 -0
- package/templates/qoder/scripts/notify.py +5 -0
- package/templates/qoder/scripts/parse_prds.py +33 -0
- package/templates/qoder/scripts/report.py +281 -0
- package/templates/qoder/scripts/role.py +39 -0
- package/templates/qoder/scripts/run_weekly_update.bat +17 -0
- package/templates/qoder/scripts/run_weekly_update.sh +20 -0
- package/templates/qoder/scripts/search_index.py +352 -0
- package/templates/qoder/scripts/setup.py +453 -0
- package/templates/qoder/scripts/setup_weekly_cron.bat +22 -0
- package/templates/qoder/scripts/setup_weekly_cron.sh +19 -0
- package/templates/qoder/scripts/status.py +389 -0
- package/templates/qoder/scripts/syncgate.py +330 -0
- package/templates/qoder/scripts/task.py +954 -0
- package/templates/qoder/scripts/team.py +29 -0
- package/templates/qoder/scripts/team_sync.py +419 -0
- package/templates/qoder/scripts/workspace_init.py +102 -0
- package/templates/qoder/settings.json +53 -0
- package/templates/qoder/skills/design-review/SKILL.md +25 -0
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -0
- package/templates/qoder/skills/prd-review/SKILL.md +36 -0
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -0
- package/templates/qoder/skills/spec-coder/SKILL.md +69 -0
- package/templates/qoder/skills/spec-generator/SKILL.md +67 -0
- package/templates/qoder/skills/test-generator/SKILL.md +72 -0
- package/templates/qoder/skills/wl-commit/SKILL.md +76 -0
- package/templates/qoder/skills/wl-init/SKILL.md +67 -0
- package/templates/qoder/skills/wl-insight/SKILL.md +81 -0
- package/templates/qoder/skills/wl-report/SKILL.md +87 -0
- package/templates/qoder/skills/wl-search/SKILL.md +75 -0
- package/templates/qoder/skills/wl-status/SKILL.md +61 -0
- package/templates/qoder/skills/wl-task/SKILL.md +58 -0
- package/templates/qoder/templates/prd-full-template.md +103 -0
- package/templates/qoder/templates/prd-quick-template.md +69 -0
- package/templates/qoder/templates/prototype-app.html +344 -0
- package/templates/qoder/templates/prototype-web.html +310 -0
- package/templates/root/AGENTS.md +182 -0
- package/templates/root/README-pipeline.md +56 -0
- package/templates/root/ROLES.md +85 -0
- package/templates/root//346/226/260/346/211/213/346/214/207/345/215/227.md +186 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
identity.py - 零信任身份层: 可验证身份 + HMAC 操作签名
|
|
4
|
+
|
|
5
|
+
解决"身份可伪造、操作无归属、审计可篡改"三类治理问题。
|
|
6
|
+
|
|
7
|
+
设计:
|
|
8
|
+
- 每个成员注册时生成一个本地签名密钥 (32 字节 hex), 存在
|
|
9
|
+
workspace/members/{name}/.signing_key (gitignore, 永不离开本机)
|
|
10
|
+
- member.json 是团队共享的注册表, 只含 member_id / git_author / role
|
|
11
|
+
(不含密钥), 提交到 git 让全员可查
|
|
12
|
+
- 关键操作 (push / publish / finish / eval) 用密钥对 payload 做 HMAC-SHA256
|
|
13
|
+
签名, 审计日志里每行带 sig, 篡改即检出
|
|
14
|
+
- git 作者强制: member.json 的 git_author 与本地 git config 不一致则拒绝 push
|
|
15
|
+
|
|
16
|
+
核心 API:
|
|
17
|
+
register_member(name, role, git_author) # 首次注册, 生成密钥
|
|
18
|
+
get_member(name) -> dict | None # 查注册表
|
|
19
|
+
verify_member(name) -> bool # 本机有密钥 + 注册表有记录
|
|
20
|
+
get_signing_key(name) -> bytes # 读本地密钥
|
|
21
|
+
sign(name, payload: dict) -> str # HMAC 签名
|
|
22
|
+
verify_signature(name, sig, payload) -> bool
|
|
23
|
+
require_member(name) # 不存在则 exit(4) authz_denied
|
|
24
|
+
|
|
25
|
+
安全模型说明:
|
|
26
|
+
- 这是内部 20 人工具, 用对称密钥 (HMAC) 而非 PKI。够用且零依赖。
|
|
27
|
+
- 密钥泄露 = 该成员身份被冒用; 但 member.json 在 git 历史里,
|
|
28
|
+
注册表本身的完整性靠 git 保护。
|
|
29
|
+
- 不防"管理员作恶"(admin 可改任意 member.json), 这是内部信任边界内可接受的。
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import secrets
|
|
37
|
+
import sys
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Optional, Dict, Any, Union
|
|
41
|
+
|
|
42
|
+
# 相对导入兄弟模块
|
|
43
|
+
try:
|
|
44
|
+
from .atomicio import atomic_write_json, safe_read_json
|
|
45
|
+
from .paths import get_repo_root, MEMBERS_DIR
|
|
46
|
+
except ImportError:
|
|
47
|
+
_COMMON_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
if _COMMON_DIR not in sys.path:
|
|
49
|
+
sys.path.insert(0, _COMMON_DIR)
|
|
50
|
+
from atomicio import atomic_write_json, safe_read_json
|
|
51
|
+
# paths 可能不在 common/ (在 scripts/common/paths.py)
|
|
52
|
+
_SCRIPTS_DIR = os.path.dirname(_COMMON_DIR)
|
|
53
|
+
if _SCRIPTS_DIR not in sys.path:
|
|
54
|
+
sys.path.insert(0, _SCRIPTS_DIR)
|
|
55
|
+
from common.paths import get_repo_root, MEMBERS_DIR
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"IdentityError",
|
|
59
|
+
"register_member",
|
|
60
|
+
"get_member",
|
|
61
|
+
"list_members",
|
|
62
|
+
"verify_member",
|
|
63
|
+
"get_signing_key",
|
|
64
|
+
"sign",
|
|
65
|
+
"verify_signature",
|
|
66
|
+
"require_member",
|
|
67
|
+
"ensure_current_member",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# 退出码 (全脚本统一): 4 = authz_denied
|
|
71
|
+
EXIT_AUTHZ = 4
|
|
72
|
+
|
|
73
|
+
# 名字合法字符: 字母/数字/中文/下划线/短横, 2-32 字符
|
|
74
|
+
_NAME_RE = re.compile(r"^[\w\u4e00-\u9fa5\-]{2,32}$")
|
|
75
|
+
|
|
76
|
+
# git_author 格式: "Name <email@example.com>"
|
|
77
|
+
_AUTHOR_RE = re.compile(r"^[^<>]+ <[^<>]+@[^<>]+>$")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IdentityError(Exception):
|
|
81
|
+
"""身份相关错误。"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ============================================================
|
|
85
|
+
# 路径辅助
|
|
86
|
+
# ============================================================
|
|
87
|
+
|
|
88
|
+
def _member_dir(name: str, repo_root: Optional[Union[str, Path]] = None) -> Path:
|
|
89
|
+
if repo_root is None:
|
|
90
|
+
repo_root = get_repo_root()
|
|
91
|
+
return Path(repo_root) / "workspace" / "members" / name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _member_json(name: str, repo_root: Optional[Union[str, Path]] = None) -> Path:
|
|
95
|
+
return _member_dir(name, repo_root) / "member.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _key_file(name: str, repo_root: Optional[Union[str, Path]] = None) -> Path:
|
|
99
|
+
return _member_dir(name, repo_root) / ".signing_key"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ============================================================
|
|
103
|
+
# 注册表读写
|
|
104
|
+
# ============================================================
|
|
105
|
+
|
|
106
|
+
def get_member(name: str, repo_root: Optional[Union[str, Path]] = None) -> Optional[Dict[str, Any]]:
|
|
107
|
+
"""查注册表。返回 member.json 内容 (不含密钥) 或 None。"""
|
|
108
|
+
if not name or not _NAME_RE.match(name):
|
|
109
|
+
return None
|
|
110
|
+
mp = _member_json(name, repo_root)
|
|
111
|
+
data = safe_read_json(mp, default=None)
|
|
112
|
+
if data and data.get("name") == name:
|
|
113
|
+
return data
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_members(repo_root: Optional[Union[str, Path]] = None) -> Dict[str, Dict[str, Any]]:
|
|
118
|
+
"""列出所有注册成员 {name: member_json}。"""
|
|
119
|
+
if repo_root is None:
|
|
120
|
+
repo_root = get_repo_root()
|
|
121
|
+
members_dir = Path(repo_root) / "workspace" / "members"
|
|
122
|
+
out = {}
|
|
123
|
+
if not members_dir.is_dir():
|
|
124
|
+
return out
|
|
125
|
+
for child in members_dir.iterdir():
|
|
126
|
+
if not child.is_dir():
|
|
127
|
+
continue
|
|
128
|
+
mj = child / "member.json"
|
|
129
|
+
if mj.is_file():
|
|
130
|
+
data = safe_read_json(mj, default=None)
|
|
131
|
+
if data and data.get("name"):
|
|
132
|
+
out[data["name"]] = data
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def register_member(
|
|
137
|
+
name: str,
|
|
138
|
+
role: str = "pm",
|
|
139
|
+
git_author: Optional[str] = None,
|
|
140
|
+
repo_root: Optional[Union[str, Path]] = None,
|
|
141
|
+
) -> Dict[str, Any]:
|
|
142
|
+
"""注册/升级一个成员。
|
|
143
|
+
|
|
144
|
+
- 首次注册: 生成密钥, 写 member.json + .signing_key
|
|
145
|
+
- 已存在: 仅更新 role/git_author (密钥不动), 保留 member_id 和 joined_at
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name: 成员名 (2-32 字符, 字母数字中文下划线短横)
|
|
149
|
+
role: 角色 (pm/design/dev/admin)
|
|
150
|
+
git_author: git 作者 "Name <email>", 用于强制 git config 一致
|
|
151
|
+
"""
|
|
152
|
+
if not name or not _NAME_RE.match(name):
|
|
153
|
+
raise IdentityError("非法成员名 (需 2-32 字符): %r" % name)
|
|
154
|
+
if git_author and not _AUTHOR_RE.match(git_author):
|
|
155
|
+
raise IdentityError("git_author 格式应为 'Name <email>': %r" % git_author)
|
|
156
|
+
|
|
157
|
+
mdir = _member_dir(name, repo_root)
|
|
158
|
+
mdir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
mj = _member_json(name, repo_root)
|
|
160
|
+
kf = _key_file(name, repo_root)
|
|
161
|
+
|
|
162
|
+
existing = safe_read_json(mj, default=None)
|
|
163
|
+
|
|
164
|
+
# 生成或保留密钥
|
|
165
|
+
if existing and kf.is_file():
|
|
166
|
+
# 已注册 —— 保留密钥和 ID
|
|
167
|
+
signing_key_hex = kf.read_text(encoding="utf-8").strip()
|
|
168
|
+
member_id = existing.get("member_id") or "u_%s_%s" % (
|
|
169
|
+
name.lower()[:16], datetime.now().strftime("%Y")
|
|
170
|
+
)
|
|
171
|
+
joined_at = existing.get("joined_at") or datetime.now().isoformat(timespec="seconds")
|
|
172
|
+
else:
|
|
173
|
+
# 首次注册
|
|
174
|
+
signing_key_hex = secrets.token_hex(32)
|
|
175
|
+
kf.write_text(signing_key_hex + "\n", encoding="utf-8")
|
|
176
|
+
# 尽量只本用户可读 (POSIX); Windows 上 chmod 影响有限但不报错
|
|
177
|
+
try:
|
|
178
|
+
os.chmod(str(kf), 0o600)
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
member_id = "u_%s_%s" % (name.lower()[:16], datetime.now().strftime("%Y"))
|
|
182
|
+
joined_at = datetime.now().isoformat(timespec="seconds")
|
|
183
|
+
|
|
184
|
+
record = {
|
|
185
|
+
"name": name,
|
|
186
|
+
"member_id": member_id,
|
|
187
|
+
"role": role,
|
|
188
|
+
"git_author": git_author or existing.get("git_author") or "%s <unknown@local>" % name,
|
|
189
|
+
"joined_at": joined_at,
|
|
190
|
+
}
|
|
191
|
+
atomic_write_json(mj, record)
|
|
192
|
+
return record
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ============================================================
|
|
196
|
+
# 密钥与签名
|
|
197
|
+
# ============================================================
|
|
198
|
+
|
|
199
|
+
def get_signing_key(name: str, repo_root: Optional[Union[str, Path]] = None) -> bytes:
|
|
200
|
+
"""读本地签名密钥。不存在则抛 IdentityError。"""
|
|
201
|
+
kf = _key_file(name, repo_root)
|
|
202
|
+
if not kf.is_file():
|
|
203
|
+
raise IdentityError("成员 %s 在本机无签名密钥 (未注册或换机器)" % name)
|
|
204
|
+
return bytes.fromhex(kf.read_text(encoding="utf-8").strip())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def verify_member(name: str, repo_root: Optional[Union[str, Path]] = None) -> bool:
|
|
208
|
+
"""本机有密钥 + 注册表有记录 = 可验证身份。"""
|
|
209
|
+
if not get_member(name, repo_root):
|
|
210
|
+
return False
|
|
211
|
+
return _key_file(name, repo_root).is_file()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _canonical(payload: Any) -> bytes:
|
|
215
|
+
"""payload 规范化为稳定字节串 (sorted keys, 无 ascii 转义, 无空格)。"""
|
|
216
|
+
import json
|
|
217
|
+
return json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def sign(name: str, payload: Any, repo_root: Optional[Union[str, Path]] = None) -> str:
|
|
221
|
+
"""用成员密钥对 payload 做 HMAC-SHA256, 返回 hex 签名。
|
|
222
|
+
|
|
223
|
+
payload 会被规范化 (sorted keys), 同样的 payload 签名稳定。
|
|
224
|
+
"""
|
|
225
|
+
key = get_signing_key(name, repo_root)
|
|
226
|
+
return hmac.new(key, _canonical(payload), hashlib.sha256).hexdigest()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def verify_signature(
|
|
230
|
+
name: str,
|
|
231
|
+
signature: str,
|
|
232
|
+
payload: Any,
|
|
233
|
+
repo_root: Optional[Union[str, Path]] = None,
|
|
234
|
+
) -> bool:
|
|
235
|
+
"""校验签名。密钥不在本机则返回 False (不抛错, 让调用方决定)。"""
|
|
236
|
+
try:
|
|
237
|
+
expected = sign(name, payload, repo_root)
|
|
238
|
+
except IdentityError:
|
|
239
|
+
return False
|
|
240
|
+
return hmac.compare_digest(expected, signature)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ============================================================
|
|
244
|
+
# 守卫函数 (给 CLI 脚本用)
|
|
245
|
+
# ============================================================
|
|
246
|
+
|
|
247
|
+
def require_member(name: Optional[str] = None, repo_root: Optional[Union[str, Path]] = None) -> str:
|
|
248
|
+
"""要求当前身份已注册且本机有密钥。不满足则 exit(EXIT_AUTHZ)。
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
name: 指定名字; 不传则从 .developer 读。
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
已验证的成员名。
|
|
255
|
+
"""
|
|
256
|
+
if name is None:
|
|
257
|
+
try:
|
|
258
|
+
from .developer_paths import get_developer
|
|
259
|
+
except ImportError:
|
|
260
|
+
_this = os.path.dirname(os.path.abspath(__file__))
|
|
261
|
+
sys.path.insert(0, _this)
|
|
262
|
+
from paths import get_developer # type: ignore
|
|
263
|
+
name = get_developer(repo_root)
|
|
264
|
+
if not name:
|
|
265
|
+
sys.stderr.write("[authz] 拒绝: 未设置开发者身份。先跑 /wl-init 注册。\n")
|
|
266
|
+
sys.exit(EXIT_AUTHZ)
|
|
267
|
+
if not verify_member(name, repo_root):
|
|
268
|
+
sys.stderr.write(
|
|
269
|
+
"[authz] 拒绝: 成员 %s 未注册或本机无密钥。先跑 /wl-init %s 注册。\n" % (name, name)
|
|
270
|
+
)
|
|
271
|
+
sys.exit(EXIT_AUTHZ)
|
|
272
|
+
return name
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def ensure_current_member(repo_root: Optional[Union[str, Path]] = None) -> Optional[str]:
|
|
276
|
+
"""软检查: 返回当前已验证的成员名, 或 None (不 exit)。给非破坏性命令用。"""
|
|
277
|
+
try:
|
|
278
|
+
from paths import get_developer
|
|
279
|
+
except ImportError:
|
|
280
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
281
|
+
from paths import get_developer # type: ignore
|
|
282
|
+
name = get_developer(repo_root)
|
|
283
|
+
if name and verify_member(name, repo_root):
|
|
284
|
+
return name
|
|
285
|
+
return None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
mentions.py - @提及解析与飞书通知 (阶段 D4)
|
|
4
|
+
|
|
5
|
+
从 PRD 评审/任务评论/报告内容里提取 @用户名, 映射到飞书 user_key,
|
|
6
|
+
通过飞书机器人推送提及通知。
|
|
7
|
+
|
|
8
|
+
触发点:
|
|
9
|
+
- PRD 评审意见里有 @某人
|
|
10
|
+
- 任务评论 (task.py comment, 未来) 有 @某人
|
|
11
|
+
- 报告里 @某人
|
|
12
|
+
|
|
13
|
+
member.json 扩展字段: feishu_user_key (可选, 飞书 open_id/user_id)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Tuple, Optional
|
|
20
|
+
|
|
21
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
22
|
+
if _THIS_DIR not in sys.path:
|
|
23
|
+
sys.path.insert(0, _THIS_DIR)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from .identity import get_member, list_members
|
|
27
|
+
from .feishu import send_card, is_enabled
|
|
28
|
+
except ImportError:
|
|
29
|
+
from identity import get_member, list_members
|
|
30
|
+
from feishu import send_card, is_enabled
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"extract_mentions",
|
|
34
|
+
"resolve_to_feishu_keys",
|
|
35
|
+
"notify_mentions",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# @提及模式: @用户名 (支持中文/英文/数字/下划线, 2-32 字符)
|
|
39
|
+
MENTION_RE = re.compile(r'@([\w\u4e00-\u9fa5\-]{2,32})')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_mentions(text: str, exclude: Optional[str] = None) -> List[str]:
|
|
43
|
+
"""从文本提取 @提及的用户名。
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
text: 要扫描的文本。
|
|
47
|
+
exclude: 排除的用户名 (通常是作者自己, 避免自提及)。
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
去重后的用户名列表 (只保留在注册表里的成员)。
|
|
51
|
+
"""
|
|
52
|
+
raw = set(m.group(1) for m in MENTION_RE.finditer(text))
|
|
53
|
+
if exclude:
|
|
54
|
+
raw.discard(exclude)
|
|
55
|
+
# 只保留已注册的成员 (避免 @system @all 等噪音)
|
|
56
|
+
members = list_members()
|
|
57
|
+
return sorted(name for name in raw if name in members)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_to_feishu_keys(names: List[str]) -> List[Tuple[str, str]]:
|
|
61
|
+
"""把用户名映射到飞书 user_key。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
names: 用户名列表。
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
[(name, feishu_user_key), ...] 只含配了 feishu_user_key 的成员。
|
|
68
|
+
"""
|
|
69
|
+
out = []
|
|
70
|
+
for name in names:
|
|
71
|
+
m = get_member(name)
|
|
72
|
+
if m and m.get('feishu_user_key'):
|
|
73
|
+
out.append((name, m['feishu_user_key']))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def notify_mentions(
|
|
78
|
+
text: str,
|
|
79
|
+
context: str,
|
|
80
|
+
author: Optional[str] = None,
|
|
81
|
+
link: Optional[str] = None,
|
|
82
|
+
) -> int:
|
|
83
|
+
"""扫描文本里的 @提及, 给每个被提及者发飞书通知。
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
text: 含 @提及的文本 (如 PRD 评审意见)。
|
|
87
|
+
context: 上下文描述 (如 "REQ-2026-007 评审")。
|
|
88
|
+
author: 提及的发起人 (从 .developer 读)。
|
|
89
|
+
link: 相关链接 (可选)。
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
成功通知的人数。
|
|
93
|
+
"""
|
|
94
|
+
if not is_enabled():
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
names = extract_mentions(text, exclude=author)
|
|
98
|
+
if not names:
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
# 解析到飞书 user_key
|
|
102
|
+
keyed = resolve_to_feishu_keys(names)
|
|
103
|
+
# 未配 feishu_user_key 的成员, 仍然在通知内容里显示名字 (但不定向)
|
|
104
|
+
unkeyed = [n for n in names if n not in dict(keyed)]
|
|
105
|
+
|
|
106
|
+
sent = 0
|
|
107
|
+
for name, user_key in keyed:
|
|
108
|
+
lines = [
|
|
109
|
+
f'**你被 @{author or "某人"} 提及了**',
|
|
110
|
+
f'上下文: {context}',
|
|
111
|
+
]
|
|
112
|
+
if link:
|
|
113
|
+
lines.append(f'链接: {link}')
|
|
114
|
+
lines.append(f'飞书 ID: {user_key}')
|
|
115
|
+
try:
|
|
116
|
+
if send_card(f'@提及: {context}', lines, event_type=None, theme='orange'):
|
|
117
|
+
sent += 1
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
# 未配 key 的合并发一条到群 (作为 fallback)
|
|
122
|
+
if unkeyed and not keyed:
|
|
123
|
+
lines = [
|
|
124
|
+
f'**{", ".join(unkeyed)} 被 @{author or "某人"} 提及**',
|
|
125
|
+
f'(以上成员未配置 feishu_user_key, 此为群广播)',
|
|
126
|
+
f'上下文: {context}',
|
|
127
|
+
]
|
|
128
|
+
try:
|
|
129
|
+
send_card(f'@提及: {context}', lines, event_type=None, theme='grey')
|
|
130
|
+
sent += 1
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
return sent
|