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: