feat: 统一阶段通知模板并支持逐项提醒负责人

This commit is contained in:
liyun committed 2026-09-29 20:05:30 +08:00
1 parent 1d85de4c7c
commit 3fb9c0658d
10 files changed
+301 -58

No files matched your search

+40 -12
View File
@@ -16,8 +16,8 @@ import time
ROOT = Path(__file__).resolve().parent.parent
PREFIX = 'DINGTALK_PROGRESS_'
KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'}
EVENTS = {'start': '开始推进', 'milestone': '阶段里程碑', 'blocked': '遇到阻塞',
'complete': '本轮完成', 'failed': '执行失败'}
EVENTS = ('start', 'milestone', 'blocked', 'complete', 'failed')
INLINE_MENTION = re.compile(r'@\{([^{}\r\n]+)\}')
DNS_ERROR = re.compile(r'lookup [\w.-]+(?: on [^:\s]+(?::\d+)?)?: [^\n]*'
r'(?:i/o timeout|no such host|server misbehaving|temporary failure in name resolution)', re.I)
@@ -123,12 +123,36 @@ def check_login(cli, profile, run, sleep):
raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。')
def mention_names(names):
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
if any(not name for name in names):
raise NoticeError('提醒对象不能为空。')
return names
def format_mentions(text, names, people=None):
names = mention_names(names)
placed = {match.group(1).strip() for match in INLINE_MENTION.finditer(text)}
if '@{' in INLINE_MENTION.sub('', text) or not placed.issubset(names):
raise NoticeError('正文中的 @{完整姓名} 必须完整填写,并在 --at 中列出同一姓名。')
by_name = {name: person for person, name in people.items()} if people is not None else None
if by_name is not None and not set(names).issubset(by_name):
raise NoticeError('提醒对象未全部唯一匹配,本次停止,不删掉 @ 后发送。')
def marker(name):
return f'<@{by_name[name]}>' if by_name is not None else f'@{name}'
text = INLINE_MENTION.sub(lambda match: marker(match.group(1).strip()), text)
remaining = [marker(name) for name in names if name not in placed]
if remaining:
text += '\n\n' + ' '.join(remaining)
return text
def mentions(names, cli, profile, run, sleep):
people = {}
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
names = mention_names(names)
for name in names:
if not name:
raise NoticeError('提醒对象不能为空。')
data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name',
'--profile', profile, '--format', 'json'], run, sleep)
candidates = {}
@@ -187,6 +211,7 @@ def parse_args(argv):
init.add_argument('--replace', action='store_true')
progress = commands.add_parser('progress')
progress.add_argument('event', choices=EVENTS)
progress.add_argument('--stage', required=True, help='项目当前阶段编号及中文名称;每次从有效项目记录取得')
progress.add_argument('summary', nargs='*')
progress.add_argument('--file')
message = commands.add_parser('message')
@@ -196,7 +221,7 @@ def parse_args(argv):
body.add_argument('--file')
check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送')
for command in (progress, message, check):
command.add_argument('--at', action='append', default=[], help='完整姓名;可重复或用逗号分隔')
command.add_argument('--at', action='append', default=[], help='完整姓名;正文可用 @{完整姓名} 定位,可重复或用逗号分隔')
for command in (progress, message):
command.add_argument('--dry-run', action='store_true', help='完全离线,只预览中文正文')
return parser.parse_args(argv)
@@ -215,12 +240,13 @@ def render(args, config, root):
text = ' '.join(args.summary) if args.command == 'progress' else args.text
if not text.strip() or len(text.encode('utf-8')) > 12000:
raise NoticeError('请提供不超过 12000 字节的简短中文摘要。')
title = EVENTS[args.event] if args.command == 'progress' else args.title.strip()
if not title or len(title) > 200 or '\n' in title or '\r' in title:
raw_title = args.stage if args.command == 'progress' else args.title
title = raw_title.strip()
if not title or len(title) > 200 or any(ord(c) < 32 or ord(c) == 127 for c in raw_title):
raise NoticeError('请使用不超过 200 字的单行标题。')
project = config.get('PROJECT') or root.name
timestamp = dt.datetime.now().astimezone().strftime('%Y-%m-%d %H:%M %z')
return f'## {project}|{title}\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
return f'**{project}|{title}**\n\n项目进度自动汇报 · {timestamp}\n\n{text}'
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
@@ -236,7 +262,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
return 0
config = read_config(root)
if args.command != 'check' and args.dry_run:
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=render(args, config, root), mentions=args.at)
text = format_mentions(render(args, config, root), args.at)
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=text, mentions=mention_names(args.at))
return 0
if args.command != 'check' and config.get('ENABLED') != '1':
emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。')
@@ -244,6 +271,8 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
if not config.get('PROFILE') or not config.get('TARGET'):
raise NoticeError('请由 Hub 先确认个人账号和项目群并执行 init。')
text = render(args, config, root) if args.command != 'check' else None
if text is not None:
format_mentions(text, args.at) # Validate inline names before any DWS call.
cli = dws_path()
profile = config['PROFILE']
if args.command == 'check':
@@ -252,8 +281,7 @@ def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
if args.command == 'check':
emit('checked', '登录及所请求的姓名查询通过;未发送,未验证实际提醒效果。', names=list(people.values()))
return 0
if people:
text += '\n\n' + ' '.join(f'<@{person}>' for person in people)
text = format_mentions(text, args.at, people)
command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'],
'--markdown', text, '--profile', profile, '--yes', '--format', 'json']
if people:
+145 -24
View File
@@ -61,8 +61,8 @@ class NotifyTests(unittest.TestCase):
return code, json.loads(out.getvalue())
def test_each_event_personal_markdown(self):
for event, label in n.EVENTS.items():
code, result = self.execute(['progress', event, '**进展**\n\n- 已归档'])
for event in n.EVENTS:
code, result = self.execute(['progress', event, '--stage', '测试验证', '**进展**\n\n- 已归档'])
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'accepted')
cmd = self.calls[-1][0]
@@ -70,12 +70,37 @@ class NotifyTests(unittest.TestCase):
self.assertEqual(cmd[cmd.index('--group') + 1], GROUP)
self.assertEqual(cmd[cmd.index('--profile') + 1], PROFILE)
body = cmd[cmd.index('--markdown') + 1]
self.assertIn(label, body)
self.assertEqual(body.splitlines()[0], '**测试项目|测试验证**')
self.assertIn('项目进度自动汇报', body)
self.assertIn('**进展**\n\n- 已归档', body)
self.assertNotIn('--ai-tag=false', cmd)
self.assertNotIn('--robot-code', cmd)
def test_progress_requires_stage_before_any_dws_call(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as error:
self.execute(['progress', 'milestone', '已更新'])
self.assertEqual(error.exception.code, 2)
self.assertFalse(self.calls)
def test_invalid_stage_never_sends(self):
for stage in ['', ' ', '测试\n验证', '测试验证\r', '测试\t验证', '测试\0验证', '验' * 201]:
with self.subTest(stage=stage):
code, result = self.execute(['progress', 'milestone', '已更新', '--stage', stage])
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertFalse(self.calls)
def test_stage_changes_per_notification_without_config_change(self):
original = self.path.read_bytes()
text = '**待办事项**\n\n- 由 Hub 请项目负责人确认测试结果。\n\n**遗留**\n\n- 无。'
for stage in [' 阶段六:测试验证 ', '阶段七:平台/客户验收']:
code, result = self.execute(['progress', 'milestone', text, '--stage', stage, '--dry-run'])
self.assertEqual(code, 0)
self.assertEqual(result['text'].splitlines()[0], '**测试项目|' + stage.strip() + '**')
self.assertTrue(result['text'].endswith(text))
self.assertEqual(self.path.read_bytes(), original)
self.assertFalse(self.calls)
def test_message_mentions_exact_name_fixed_profile(self):
code, data = self.execute(['message', '--title', '提醒', '--text', '请补充资料', '--at', '测试人,测试人'])
self.assertEqual(code, 0)
@@ -88,11 +113,105 @@ class NotifyTests(unittest.TestCase):
self.assertEqual(data['mentions'], ['测试人'])
self.assertEqual(data['openTaskId'], 'fake-task')
def test_approved_closing_template_preserves_conditions_and_scope(self):
body = '''**已确认的进展**
- 相关负责人已确认:产品验收材料已在线下完成,业务已确认客户验收通过。
- 项目已进入资料归档与复盘结项阶段,目前开始整理结项资料,项目尚未关闭。
- 已通知相关负责人进行阶段交接,不新增审批。
**待办事项**
- 结项资料整理:由 Hub 整理四类结项工作稿。
- 资料补充:由产品 Agent、业务 Agent 补充现有线下成果及确认索引,不重做验收。
- 旧事项停止:由 Hub 停止此前的验收准备催办。
- 结项确认:四类成果具备后,由 Hub 请 @{测试人} 确认结项,确认后再锁定成果。
- 本轮工作范围:不新增应用修复、设备测试或发布验证。
**遗留**
- 原测试不通过的结果如实保留,不因进入结项阶段改为通过。
- 已接受延期的问题如实保留,后续沿用 Bug 系统中的记录和已有处理安排继续跟进。'''
path = self.root / '通知正文.md'
path.write_bytes(body.replace('\n', '\r\n').encode('utf-8'))
before = self.path.read_bytes()
args = ['progress', 'milestone', '--stage', '阶段八:复盘结项', '--file', str(path), '--at', '测试人']
code, preview = self.execute(args + ['--dry-run'])
self.assertEqual(code, 0)
self.assertFalse(self.calls)
self.assertEqual(preview['text'].splitlines()[0], '**测试项目|阶段八:复盘结项**')
self.assertEqual(preview['text'].split('\n\n', 2)[2], body.replace('@{测试人}', '@测试人'))
code, result = self.execute(args)
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'accepted')
self.assertEqual(len(self.calls), 2)
command = self.calls[-1][0]
markdown = command[command.index('--markdown') + 1]
self.assertEqual(markdown.split('\n\n', 2)[2], body.replace('@{测试人}', '<@fake-person>'))
self.assertEqual(command[command.index('--at-open-dingtalk-ids') + 1], PERSON)
self.assertEqual(self.path.read_bytes(), before)
def test_progress_can_include_mention_once(self):
code, _ = self.execute(['progress', 'blocked', '缺输入', '--at', '测试人'])
code, _ = self.execute(['progress', 'blocked', '--stage', '测试验证', '缺输入', '--at', '测试人'])
self.assertEqual(code, 0)
self.assertEqual(sum(c[0][1:3] == ['chat', '+messages-send'] for c in self.calls), 1)
def test_inline_mentions_match_each_item_and_deduplicate_recipients(self):
ids = {'甲': 'fake-a', '乙': 'fake-b', '丙': 'fake-c'}
def lookup(command, **kwargs):
if command[1:3] == ['aisearch', 'person']:
self.calls.append((command, kwargs))
name = command[command.index('--query') + 1]
return reply({'result': [{'openDingTalkId': ids[name], 'meta': {'name': name}}]})
return self.fake(command, **kwargs)
body = '**待办事项**\n\n- 由 Hub 请 @{甲} 确认测试结果。\n- 请 @{乙} 补充说明。\n\n**遗留**\n\n- @{乙} 按已定计划跟进未解决问题。'
code, data = self.execute(['progress', 'milestone', '--stage', '阶段六:测试验证',
body, '--at', '甲,乙,甲', '--at', '丙'], lookup)
self.assertEqual(code, 0)
self.assertEqual(data['mentions'], ['甲', '乙', '丙'])
self.assertEqual(len(self.calls), 4)
cmd = self.calls[-1][0]
actual = cmd[cmd.index('--markdown') + 1]
expected = body.replace('@{甲}', '<@fake-a>').replace('@{乙}', '<@fake-b>')
self.assertTrue(actual.endswith(expected + '\n\n<@fake-c>'))
self.assertEqual(cmd[cmd.index('--at-open-dingtalk-ids') + 1], 'fake-a,fake-b,fake-c')
for command, _ in self.calls:
self.assertEqual(command[command.index('--profile') + 1], PROFILE)
def test_inline_mentions_in_message_file(self):
path = self.root / '正文.md'
path.write_text('**待办事项**\n\n- 请 @{测试人} 补充资料。', encoding='utf-8')
code, _ = self.execute(['message', '--title', '阶段三:方案设计', '--file', str(path), '--at', '测试人'])
self.assertEqual(code, 0)
cmd = self.calls[-1][0]
text = cmd[cmd.index('--markdown') + 1]
self.assertTrue(text.endswith('- 请 <@fake-person> 补充资料。'))
self.assertEqual(text.count('<@fake-person>'), 1)
def test_inline_preview_shows_names_without_lookup(self):
code, data = self.execute(['progress', 'blocked', '--stage', '阶段三:方案设计',
'- 请 @{甲} 补充接口说明。', '--at', '甲,甲', '--at', '乙', '--dry-run'])
self.assertEqual(code, 0)
self.assertTrue(data['text'].endswith('- 请 @甲 补充接口说明。\n\n@乙'))
self.assertEqual(data['mentions'], ['甲', '乙'])
self.assertFalse(self.calls)
def test_invalid_inline_mentions_stop_before_lookup_or_send(self):
for body in ['请 @{乙} 确认', '请 @{} 确认', '请 @{甲 确认', '请 @{甲\n} 确认']:
for options in [[], ['--dry-run']]:
with self.subTest(body=body, options=options):
code, result = self.execute(['message', '--title', '提醒', '--text', body, '--at', '甲'] + options)
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertFalse(self.calls)
def test_inline_mentions_with_conflicting_lookup_results_never_send(self):
code, result = self.execute(['message', '--title', '提醒', '--text', '- @{甲} 和 @{乙} 请核对。', '--at', '甲,乙'])
self.assertEqual(code, 1)
self.assertEqual(result['status'], 'error')
self.assertEqual(len(self.calls), 2)
self.assertTrue(all(c[0][1:3] == ['aisearch', 'person'] for c in self.calls))
def test_lookup_ambiguity_no_match_and_malformed_never_send(self):
for rows in [[], [{'openDingTalkId': 'a', 'meta': {'name': '甲'}},
{'openDingTalkId': 'b', 'meta': {'name': '甲'}}],
@@ -119,14 +238,14 @@ class NotifyTests(unittest.TestCase):
def test_disabled_and_missing_config_no_tools(self):
self.config(ENABLED='0')
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
self.path.unlink()
self.assertEqual(self.execute(['progress', 'start', '开始'])[1]['status'], 'skipped')
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'])[1]['status'], 'skipped')
self.assertFalse(self.calls)
def test_preview_without_config_or_dws(self):
self.path.unlink()
code, result = self.execute(['progress', 'blocked', '说明', '--at', '甲', '--dry-run'])
code, result = self.execute(['progress', 'blocked', '--stage', '测试验证', '说明', '--at', '甲', '--dry-run'])
self.assertEqual(code, 0)
self.assertEqual(result['status'], 'preview')
self.assertFalse(self.calls)
@@ -136,7 +255,7 @@ class NotifyTests(unittest.TestCase):
self.config()
with self.path.open('a', encoding='utf-8') as f:
f.write(n.PREFIX + suffix + '\n')
code, data = self.execute(['progress', 'start', '开始'])
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
self.assertEqual(code, 1)
self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.calls)
@@ -144,20 +263,20 @@ class NotifyTests(unittest.TestCase):
def test_config_bom_crlf_and_literal_values(self):
raw = self.path.read_text(encoding='utf-8').replace('测试项目', '中文 "引号" $()')
self.path.write_bytes(b'\xef\xbb\xbf' + raw.replace('\n', '\r\n').encode())
code, data = self.execute(['progress', 'start', '开始', '--dry-run'])
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始', '--dry-run'])
self.assertEqual(code, 0)
self.assertIn('中文 "引号" $()', data['text'])
def test_files_and_invalid_bodies(self):
body = self.root / '正文.md'
body.write_text('**进展**\n\n- 中文 "引号" $()', encoding='utf-8')
for args in [['progress', 'milestone'], ['message', '--title', '说明']]:
for args in [['progress', 'milestone', '--stage', '测试验证'], ['message', '--title', '说明']]:
self.assertEqual(self.execute(args + ['--file', str(body)])[0], 0)
before = len(self.calls)
for args in [['progress', 'start', ''], ['progress', 'start', 'x' * 12001],
['progress', 'start', '正文', '--file', str(body)],
for args in [['progress', 'start', '--stage', '测试验证', ''], ['progress', 'start', '--stage', '测试验证', 'x' * 12001],
['progress', 'start', '--stage', '测试验证', '正文', '--file', str(body)],
['message', '--title', '坏\n标题', '--text', '内容'],
['progress', 'start', '--file', str(self.path), '--dry-run']]:
['progress', 'start', '--stage', '测试验证', '--file', str(self.path), '--dry-run']]:
self.assertEqual(self.execute(args)[0], 1)
self.assertEqual(len(self.calls), before)
@@ -166,14 +285,14 @@ class NotifyTests(unittest.TestCase):
def failed(*a, **kw):
calls.append(a)
return reply(code=1, error='lookup api.dingtalk.com: no such host')
code, _ = self.execute(['progress', 'start', '开始'], failed)
code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
self.assertEqual(code, 1)
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [10, 10])
def test_dns_then_success(self):
responses = iter([reply(code=1, error='lookup api.dingtalk.com: no such host'), reply(OK)])
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: next(responses))[0], 0)
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: next(responses))[0], 0)
self.assertEqual(self.sleeps, [10])
def test_failure_and_unknown_never_retry(self):
@@ -186,14 +305,14 @@ class NotifyTests(unittest.TestCase):
def failed(*a, **kw):
count.append(1)
return result
code, data = self.execute(['progress', 'start', '开始'], failed)
code, data = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], failed)
self.assertEqual(code, 1)
self.assertEqual(len(count), 1)
self.assertNotIn('fake-secret', json.dumps(data))
self.assertFalse(self.sleeps)
def test_receipt_wins_nonzero_exit(self):
self.assertEqual(self.execute(['progress', 'start', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '开始'], lambda *a, **kw: reply(OK, code=1))[0], 0)
def test_wrong_receipts_rejected(self):
for bad in [OK | {'identity': 'bot'}, OK | {'tool': 'other'}, OK | {'failures': [1]},
@@ -207,13 +326,13 @@ class NotifyTests(unittest.TestCase):
def fail(*a, **kw):
calls.append(1)
raise error
code, _ = self.execute(['progress', 'start', '开始'], fail)
code, _ = self.execute(['progress', 'start', '--stage', '测试验证', '开始'], fail)
self.assertEqual(code, 1)
self.assertEqual(len(calls), 1)
def test_no_app_credentials_in_child_environment(self):
with patch.dict(os.environ, {'DWS_CLIENT_SECRET': 'fake-secret', 'DWS_CLIENT_ID': 'fake-app', 'DING_SEC': 'fake-key'}):
self.execute(['progress', 'start', '开始'])
self.execute(['progress', 'start', '--stage', '测试验证', '开始'])
env = self.calls[-1][1]['env']
self.assertNotIn('DWS_CLIENT_SECRET', env)
self.assertNotIn('DWS_CLIENT_ID', env)
@@ -282,7 +401,7 @@ class NotifyTests(unittest.TestCase):
def test_hardlinked_config_cannot_be_message_body(self):
other = self.root / 'not-a-summary.md'
os.link(self.path, other)
self.assertEqual(self.execute(['progress', 'start', '--file', str(other), '--dry-run'])[0], 1)
self.assertEqual(self.execute(['progress', 'start', '--stage', '测试验证', '--file', str(other), '--dry-run'])[0], 1)
def test_real_powershell_launcher_in_isolated_project(self):
pwsh = shutil.which('pwsh')
@@ -295,15 +414,17 @@ class NotifyTests(unittest.TestCase):
shutil.copyfile(source / name, folder / name)
env = n.clean_env()
env['DINGTALK_PYTHON'] = sys.executable
text = '**中文**\n\n- "引号" $() `原样`'
for script, args in [('dingtalk-progress.ps1', ['milestone', text]),
text = '**中文**\n\n- 请 @{测试人} 确认 "引号" $() `原样`'
for script, args in [('dingtalk-progress.ps1', ['milestone', text, '--stage', '测试验证']),
('dingtalk-message.ps1', ['--title', '中文 "标题"', '--text', text])]:
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--dry-run'],
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / script), *args, '--at', '测试人', '--dry-run'],
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
data = json.loads(result.stdout)
self.assertEqual(data['status'], 'preview')
self.assertIn(text, data['text'])
self.assertIn(text.replace('@{测试人}', '@测试人'), data['text'])
if script == 'dingtalk-progress.ps1':
self.assertEqual(data['text'].splitlines()[0], '**测试项目|测试验证**')
result = subprocess.run([pwsh, '-NoProfile', '-File', str(folder / 'dingtalk-python.ps1'), 'init',
'--profile', PROFILE, '--group', GROUP, '--project', '甲'],
cwd=self.root, env=env, capture_output=True, encoding='utf-8', timeout=30)