278 lines
14 KiB
Python
278 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Hub notifications via a confirmed personal DWS account. Python 3.9+, stdlib."""
|
||
import argparse
|
||
import base64
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
PREFIX = 'DINGTALK_PROGRESS_'
|
||
KEYS = {'ENABLED', 'PROFILE', 'TARGET', 'PROJECT'}
|
||
EVENTS = {'start': '开始推进', 'milestone': '阶段里程碑', 'blocked': '遇到阻塞',
|
||
'complete': '本轮完成', 'failed': '执行失败'}
|
||
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)
|
||
|
||
|
||
class NoticeError(Exception):
|
||
"""Safe diagnostics: never echo raw DWS errors or configuration."""
|
||
|
||
|
||
def emit(status, message, **extra):
|
||
print(json.dumps(dict(status=status, message=message, **extra), ensure_ascii=False))
|
||
|
||
|
||
def json_object(text):
|
||
try:
|
||
data = json.loads(text)
|
||
return data if isinstance(data, dict) else {}
|
||
except (ValueError, TypeError):
|
||
return {}
|
||
|
||
|
||
def read_config(root):
|
||
path = root / '.dingtalk' / 'config.env'
|
||
if not path.exists():
|
||
return {}
|
||
result = {}
|
||
for line in path.read_text(encoding='utf-8-sig').splitlines():
|
||
if not line.strip() or line.lstrip().startswith('#'):
|
||
continue
|
||
key, sep, value = line.partition('=')
|
||
key = key.strip()
|
||
if not sep or key not in {PREFIX + k for k in KEYS} or key in result:
|
||
raise NoticeError('通知配置不是当前个人账号格式,请由 Hub 确认账号和群后重新 init;不沿用机器人配置。')
|
||
result[key] = value.strip()
|
||
config = {k[len(PREFIX):]: v for k, v in result.items()}
|
||
if config.get('ENABLED', '0') not in ('0', '1'):
|
||
raise NoticeError('通知开关只能是 0 或 1。')
|
||
return config
|
||
|
||
|
||
def clean_env():
|
||
return {k: v for k, v in os.environ.items()
|
||
if k not in ('DING_SEC', 'DWS_CLIENT_ID', 'DWS_CLIENT_SECRET') and not k.startswith(PREFIX)}
|
||
|
||
|
||
def dws_path():
|
||
executable = sys.executable.replace('\\', '/').lower()
|
||
if sys.platform == 'win32' and ('/windowsapps/' in executable or 'pythonsoftwarefoundation.python.' in executable):
|
||
raise NoticeError('请使用项目 PowerShell 入口或独立 Python 3.9+;商店版 Python 可能无法识别 DWS 登录。')
|
||
path = shutil.which('dws')
|
||
if not path or (os.name == 'nt' and Path(path).suffix.lower() != '.exe'):
|
||
raise NoticeError('未找到原生 dws,请安装并加入当前 Hub 运行环境的 PATH。')
|
||
return path
|
||
|
||
|
||
def accepted_task(data):
|
||
if (data.get('ok') is not True or data.get('identity') != 'user'
|
||
or data.get('tool') != 'send_personal_message' or data.get('partial') or data.get('failures')):
|
||
return None
|
||
result = data.get('result')
|
||
if not isinstance(result, dict) or result.get('success') is not True or result.get('errorCode') not in (None, 0, ''):
|
||
return None
|
||
payload = result.get('result')
|
||
task = payload.get('openTaskId') if isinstance(payload, dict) else None
|
||
return task.strip() if isinstance(task, str) and task.strip() else None
|
||
|
||
|
||
def invoke(command, run=subprocess.run, sleep=time.sleep, sending=False):
|
||
for attempt in range(3):
|
||
try:
|
||
result = run(command, env=clean_env(), capture_output=True, text=True,
|
||
encoding='utf-8', errors='replace', timeout=60)
|
||
except subprocess.TimeoutExpired:
|
||
raise NoticeError('钉钉调用超时,结果未知,不自动重发;主任务继续。') from None
|
||
except OSError:
|
||
raise NoticeError('无法启动 DWS,请检查安装;本次停止。') from None
|
||
data = json_object(result.stdout)
|
||
if sending and accepted_task(data):
|
||
return data
|
||
if result.returncode == 0:
|
||
if sending:
|
||
raise NoticeError('未取得有效的个人发送回执,结果未知,不自动重发。')
|
||
if not data or data.get('success') is False or data.get('ok') is False:
|
||
raise NoticeError('DWS 查询未返回有效结果,本次停止。')
|
||
return data
|
||
error = json_object(result.stderr).get('error', {})
|
||
if isinstance(error, dict) and error.get('category') == 'auth':
|
||
raise NoticeError('个人 DWS 登录失效,请在 Hub 的运行账号下恢复登录;不换身份发送。')
|
||
if isinstance(error, dict) and error.get('retryable') is False:
|
||
raise NoticeError('DWS 拒绝本次调用,请检查权限或参数;不自动重试。')
|
||
if DNS_ERROR.search(result.stdout + '\n' + result.stderr):
|
||
if attempt < 2:
|
||
sleep(10)
|
||
continue
|
||
raise NoticeError('域名解析连续失败 3 次,通知停止,主任务继续。')
|
||
raise NoticeError('钉钉调用失败或结果未知;请检查登录、权限或网络,不自动重发。')
|
||
|
||
|
||
def check_login(cli, profile, run, sleep):
|
||
if not profile or ':' not in profile or any(c.isspace() for c in profile):
|
||
raise NoticeError('缺少已确认的发送账号,请先由 Hub 初始化通知。')
|
||
data = invoke([cli, 'auth', 'status', '--profile', profile, '--format', 'json'], run, sleep)
|
||
if data.get('authenticated') is not True or data.get('token_valid') is not True:
|
||
raise NoticeError('个人 DWS 未登录或授权已失效,请负责人完成登录;本次未发送。')
|
||
|
||
|
||
def mentions(names, cli, profile, run, sleep):
|
||
people = {}
|
||
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
|
||
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 = {}
|
||
for row in data.get('result', []):
|
||
if not isinstance(row, dict):
|
||
continue
|
||
meta = row.get('meta') if isinstance(row.get('meta'), dict) else {}
|
||
person = row.get('openDingTalkId')
|
||
if (meta.get('name') or row.get('title') or row.get('author')) == name and isinstance(person, str) and person.strip():
|
||
candidates[person] = name
|
||
if len(candidates) != 1:
|
||
raise NoticeError('提醒对象未唯一匹配,请确认完整姓名;不猜编号、不删掉 @ 后发送。')
|
||
people.update(candidates)
|
||
return people
|
||
|
||
|
||
def initialize(args, root, run, sleep):
|
||
if not args.yes:
|
||
raise NoticeError('先向负责人确认发送账号、项目群及自动通知,再使用 init --yes。')
|
||
values = {'ENABLED': '1', 'PROFILE': args.profile.strip(), 'TARGET': args.group.strip(), 'PROJECT': args.project.strip()}
|
||
if any(not v or '\n' in v or '\r' in v or '\0' in v for v in values.values()):
|
||
raise NoticeError('初始化内容不能为空或含换行。')
|
||
path = root / '.dingtalk' / 'config.env'
|
||
if path.is_symlink() or path.parent.is_symlink():
|
||
raise NoticeError('配置路径不能是符号链接。')
|
||
if path.exists() and not args.replace:
|
||
raise NoticeError('配置已存在;变更账号或群须重新确认后使用 --replace。')
|
||
cli = dws_path()
|
||
profiles = invoke([cli, 'profile', 'list', '--format', 'json'], run, sleep).get('profiles', [])
|
||
if len([p for p in profiles if isinstance(p, dict) and p.get('profile') == values['PROFILE']]) != 1:
|
||
raise NoticeError('初始化账号不在 DWS 实际账号列表中。')
|
||
check_login(cli, values['PROFILE'], run, sleep)
|
||
# Hub supplies a group ID from same-profile discovery after human confirmation.
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
content = '# Hub 经负责人确认后生成;仅个人 DWS,不保存登录凭据。\n' + ''.join(
|
||
f'{PREFIX}{k}={v}\n' for k, v in values.items())
|
||
descriptor, temporary = tempfile.mkstemp(prefix='.notify-', suffix='.tmp', dir=path.parent)
|
||
try:
|
||
with os.fdopen(descriptor, 'w', encoding='utf-8', newline='\n') as stream:
|
||
stream.write(content)
|
||
os.replace(temporary, path)
|
||
finally:
|
||
if os.path.exists(temporary):
|
||
os.unlink(temporary)
|
||
emit('initialized', '已保存负责人确认的个人账号和项目群,通知已启用;未发送测试消息。')
|
||
|
||
|
||
def parse_args(argv):
|
||
parser = argparse.ArgumentParser(description='Hub 个人 DWS 通知:初始化、进度、提醒、只读检查。')
|
||
commands = parser.add_subparsers(dest='command', required=True)
|
||
init = commands.add_parser('init', help='负责人确认后创建配置,不发送消息')
|
||
init.add_argument('--profile', required=True)
|
||
init.add_argument('--group', required=True)
|
||
init.add_argument('--project', required=True)
|
||
init.add_argument('--yes', action='store_true')
|
||
init.add_argument('--replace', action='store_true')
|
||
progress = commands.add_parser('progress')
|
||
progress.add_argument('event', choices=EVENTS)
|
||
progress.add_argument('summary', nargs='*')
|
||
progress.add_argument('--file')
|
||
message = commands.add_parser('message')
|
||
message.add_argument('--title', required=True)
|
||
body = message.add_mutually_exclusive_group(required=True)
|
||
body.add_argument('--text')
|
||
body.add_argument('--file')
|
||
check = commands.add_parser('check', help='检查固定账号登录及可选姓名,不发送')
|
||
for command in (progress, message, check):
|
||
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)
|
||
|
||
|
||
def render(args, config, root):
|
||
if args.command == 'progress' and bool(args.summary) == bool(args.file):
|
||
raise NoticeError('进度摘要和 --file 必须且只能提供一项。')
|
||
if args.file:
|
||
path = Path(args.file).resolve()
|
||
config_path = root / '.dingtalk' / 'config.env'
|
||
if path == config_path.resolve() or (path.exists() and config_path.exists() and path.samefile(config_path)):
|
||
raise NoticeError('不能把配置文件当作消息正文。')
|
||
text = path.read_text(encoding='utf-8')
|
||
else:
|
||
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:
|
||
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}'
|
||
|
||
|
||
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||
try:
|
||
encoded = os.environ.pop(PREFIX + 'ARGV_B64', '')
|
||
if argv is None and encoded:
|
||
argv = json.loads(base64.b64decode(encoded, validate=True).decode('utf-8'))
|
||
if not isinstance(argv, list) or not all(isinstance(v, str) for v in argv):
|
||
raise NoticeError('PowerShell 参数无效。')
|
||
args = parse_args(argv)
|
||
if args.command == 'init':
|
||
initialize(args, root, run, 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)
|
||
return 0
|
||
if args.command != 'check' and config.get('ENABLED') != '1':
|
||
emit('skipped', '通知尚未初始化或未启用;本次未发送,主任务继续。')
|
||
return 0
|
||
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
|
||
cli = dws_path()
|
||
profile = config['PROFILE']
|
||
if args.command == 'check':
|
||
check_login(cli, profile, run, sleep)
|
||
people = mentions(args.at, cli, profile, run, 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)
|
||
command = [cli, 'chat', '+messages-send', '--as', 'user', '--group', config['TARGET'],
|
||
'--markdown', text, '--profile', profile, '--yes', '--format', 'json']
|
||
if people:
|
||
command += ['--at-open-dingtalk-ids', ','.join(people)]
|
||
data = invoke(command, run, sleep, sending=True)
|
||
emit('accepted', '个人 DWS 已接受发送请求,不代表已投递、已读或已触发接收端提醒。',
|
||
openTaskId=accepted_task(data), mentions=list(people.values()))
|
||
return 0
|
||
except NoticeError as error:
|
||
emit('error', str(error))
|
||
return 1
|
||
except (OSError, ValueError, UnicodeError, TypeError, subprocess.SubprocessError):
|
||
emit('error', '配置、正文或调用结果异常;本次停止,不自动重发,主任务继续。')
|
||
return 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
for stream in (sys.stdout, sys.stderr):
|
||
if hasattr(stream, 'reconfigure'):
|
||
stream.reconfigure(encoding='utf-8')
|
||
sys.exit(main())
|