release: v1.0.3 统一个人 DWS 通知与简化初始化配置
This commit is contained in:
1 parent
a08cfd6a3b
commit
20c0de9b38
17 files changed
+670
-693
No files matched your search
+214
-212
@@ -1,275 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hub-only DingTalk notifications. Python 3.9+, standard library, no shell eval."""
|
||||
|
||||
"""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 stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PREFIX = "DINGTALK_PROGRESS_"
|
||||
KEYS = {
|
||||
"ENABLED", "DRY_RUN", "MODE", "CLIENT_ID", "TARGET", "ROBOT_CODE",
|
||||
"CLIENT_SECRET", "PROJECT", "TITLE_PREFIX", "DWS_PATH", "PROFILE",
|
||||
}
|
||||
EVENTS = {
|
||||
"start": "开始推进", "milestone": "阶段里程碑", "blocked": "遇到阻塞",
|
||||
"complete": "本轮完成", "failed": "执行失败",
|
||||
}
|
||||
# Only an explicit DNS resolution failure is safe to replay automatically.
|
||||
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.IGNORECASE,
|
||||
)
|
||||
ATTEMPTS = 3
|
||||
RETRY_SECONDS = 10
|
||||
COMMAND_TIMEOUT = 60
|
||||
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):
|
||||
"""A human-safe error: never include raw subprocess/config contents."""
|
||||
"""Safe diagnostics: never echo raw DWS errors or configuration."""
|
||||
|
||||
|
||||
def emit(status, message, **extra):
|
||||
print(json.dumps({"status": status, "message": message, **extra}, ensure_ascii=False))
|
||||
print(json.dumps(dict(status=status, message=message, **extra), ensure_ascii=False))
|
||||
|
||||
|
||||
def read_config(root):
|
||||
path = root / ".dingtalk" / "config.env"
|
||||
if not path.exists():
|
||||
return {}
|
||||
if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) not in (0o400, 0o600):
|
||||
raise NoticeError("配置权限过宽,请部署负责人设为仅当前用户可读写(600 或 400)。")
|
||||
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 + name for name in KEYS}:
|
||||
raise NoticeError("配置含不支持的字段或格式;请部署负责人对照示例检查,不要回传配置内容。")
|
||||
if key in result:
|
||||
raise NoticeError("配置存在重复字段,请部署负责人检查。")
|
||||
result[key] = value.strip()
|
||||
for name in ("ENABLED", "DRY_RUN"):
|
||||
if result.get(PREFIX + name, "0") not in ("0", "1"):
|
||||
raise NoticeError("通知开关只能填写 0 或 1。")
|
||||
return {key[len(PREFIX):]: value for key, value in result.items()}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser(description="Hub 钉钉辅助通知;不派发 Etunel 任务。")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
progress = commands.add_parser("progress", help="项目事件通知")
|
||||
progress.add_argument("event", choices=EVENTS)
|
||||
progress.add_argument("summary", nargs="*", help="简短中文 Markdown 摘要")
|
||||
progress.add_argument("--file", help="UTF-8 正文文件,与摘要二选一")
|
||||
message = commands.add_parser("message", help="自定义中文摘要或负责人提醒")
|
||||
message.add_argument("--title", required=True)
|
||||
body = message.add_mutually_exclusive_group(required=True)
|
||||
body.add_argument("--text")
|
||||
body.add_argument("--file", help="UTF-8 正文文件;不是上传附件")
|
||||
message.add_argument("--at", action="append", default=[], help="完整姓名,逗号分隔,可重复")
|
||||
message.add_argument("--at-id", action="append", default=[], help="兼容已核实的 姓名=userId;不得猜编号")
|
||||
message.add_argument("--group", help="仅用于用户明确指定的其他群;默认使用部署配置")
|
||||
for command in (progress, message):
|
||||
command.add_argument("--dry-run", action="store_true", help="离线预览,不查人员、不认证、不输出密钥、不发送")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "progress" and bool(args.summary) == bool(args.file):
|
||||
parser.error("请提供摘要或 --file,且只能选择一种。")
|
||||
return args
|
||||
|
||||
|
||||
def body_text(args, root=ROOT):
|
||||
if args.file:
|
||||
body_path = Path(args.file)
|
||||
config_path = root / ".dingtalk" / "config.env"
|
||||
if config_path.exists() and body_path.samefile(config_path):
|
||||
raise NoticeError("不能将通知配置文件作为正文发送或预览。")
|
||||
value = body_path.read_text(encoding="utf-8-sig")
|
||||
else:
|
||||
value = " ".join(args.summary) if args.command == "progress" else args.text
|
||||
if not value.strip():
|
||||
raise NoticeError("通知正文不能为空。")
|
||||
if len(value.encode("utf-8")) > 12000:
|
||||
raise NoticeError("通知过长,请改为简短摘要;正文文件也不是日志或成果附件通道。")
|
||||
return value
|
||||
|
||||
|
||||
def render(args, config, body):
|
||||
if args.command == "message":
|
||||
title = args.title.strip()
|
||||
text = body
|
||||
else:
|
||||
project = config.get("PROJECT") or ROOT.name
|
||||
label = EVENTS[args.event]
|
||||
title = f"{config.get('TITLE_PREFIX') or '[项目进度]'} {project}|{label}"
|
||||
timestamp = dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
|
||||
text = f"## {title}\n\n**项目:** {project} \n**状态:** {label} \n**更新时间:** {timestamp}\n\n---\n\n{body}"
|
||||
if not title or "\n" in title or "\r" in title or len(title) > 200:
|
||||
raise NoticeError("请使用不超过 200 字的单行中文标题。")
|
||||
return title, text
|
||||
|
||||
|
||||
def clean_env():
|
||||
return {key: value for key, value in os.environ.items()
|
||||
if key not in ("DING_SEC", "DWS_CLIENT_ID", "DWS_CLIENT_SECRET")
|
||||
and not key.startswith(PREFIX)}
|
||||
|
||||
|
||||
def secret_for(config):
|
||||
# One source on every platform; never fall back to inherited credentials.
|
||||
secret = config.get("CLIENT_SECRET", "")
|
||||
if not secret:
|
||||
raise NoticeError("未配置机器人凭据,请部署负责人在本地 config.env 填写 DINGTALK_PROGRESS_CLIENT_SECRET;不要在会话中提供密钥。")
|
||||
return secret
|
||||
|
||||
|
||||
def dws_path(config):
|
||||
supplied = config.get("DWS_PATH")
|
||||
path = shutil.which(supplied or "dws")
|
||||
if not path:
|
||||
raise NoticeError("未找到 dws,请部署负责人安装原生可执行文件并加入 Hub 的 PATH,或配置 DWS_PATH。")
|
||||
if os.name == "nt" and Path(path).suffix.lower() != ".exe":
|
||||
raise NoticeError("Windows 请使用原生 dws.exe(可配置 DWS_PATH),不通过 cmd/bat 转发 Markdown。")
|
||||
return path
|
||||
|
||||
|
||||
def json_object(output):
|
||||
def json_object(text):
|
||||
try:
|
||||
value = json.loads(output)
|
||||
return value if isinstance(value, dict) else {}
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def accepted_key(data):
|
||||
value = data.get("processQueryKey")
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
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 invoke(command, env, sending=False, run=subprocess.run, sleep=time.sleep, stdin_data=None):
|
||||
for attempt in range(1, ATTEMPTS + 1):
|
||||
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=env, input=stdin_data, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=COMMAND_TIMEOUT)
|
||||
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
|
||||
raise NoticeError('钉钉调用超时,结果未知,不自动重发;主任务继续。') from None
|
||||
except OSError:
|
||||
raise NoticeError("钉钉命令无法启动,请部署负责人检查安装和进程权限。") from None
|
||||
raise NoticeError('无法启动 DWS,请检查安装;本次停止。') from None
|
||||
data = json_object(result.stdout)
|
||||
if sending and accepted_key(data):
|
||||
if sending and accepted_task(data):
|
||||
return data
|
||||
if result.returncode == 0:
|
||||
if sending:
|
||||
raise NoticeError("未收到有效发送回执,结果未知;本次不自动重发,请负责人核对。")
|
||||
raise NoticeError('未取得有效的个人发送回执,结果未知,不自动重发。')
|
||||
if not data or data.get('success') is False or data.get('ok') is False:
|
||||
raise NoticeError('DWS 查询未返回有效结果,本次停止。')
|
||||
return data
|
||||
if DNS_ERROR.search(result.stdout + "\n" + result.stderr):
|
||||
if attempt < ATTEMPTS:
|
||||
print(f"钉钉域名解析失败,10 秒后进行第 {attempt + 1}/3 次尝试。", file=sys.stderr)
|
||||
sleep(RETRY_SECONDS)
|
||||
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("钉钉调用未成功,未自动重发;请部署负责人检查认证、权限或网络,主任务继续。")
|
||||
raise AssertionError("unreachable")
|
||||
raise NoticeError('域名解析连续失败 3 次,通知停止,主任务继续。')
|
||||
raise NoticeError('钉钉调用失败或结果未知;请检查登录、权限或网络,不自动重发。')
|
||||
|
||||
|
||||
def mentions(args, config, cli, run=subprocess.run, sleep=time.sleep):
|
||||
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 = {}
|
||||
for item in args.at_id:
|
||||
name, sep, user_id = item.partition("=")
|
||||
if not sep or not name.strip() or not user_id.strip() or re.search(r"\s", user_id):
|
||||
raise NoticeError("已核实人员映射的格式应为 姓名=userId;不要自行编造编号。")
|
||||
people[user_id.strip()] = name.strip()
|
||||
names = list(dict.fromkeys(name.strip() for group in args.at
|
||||
for name in group.replace(",", ",").split(",") if name.strip()))
|
||||
if args.at and not names:
|
||||
raise NoticeError("提醒对象不能为空。")
|
||||
if names and not config.get("PROFILE"):
|
||||
raise NoticeError("按姓名提醒需要部署负责人配置同组织的 PROFILE;不要临时切换或猜测账号。")
|
||||
names = list(dict.fromkeys(n.strip() for value in names for n in value.replace(',', ',').split(',')))
|
||||
for name in names:
|
||||
data = invoke([cli, "aisearch", "person", "--query", name, "--dimension", "name",
|
||||
"--profile", config["PROFILE"], "--format", "json"],
|
||||
clean_env(), run=run, sleep=sleep)
|
||||
if not name:
|
||||
raise NoticeError('提醒对象不能为空。')
|
||||
data = invoke([cli, 'aisearch', 'person', '--query', name, '--dimension', 'name',
|
||||
'--profile', profile, '--format', 'json'], run, sleep)
|
||||
candidates = {}
|
||||
rows = data.get("result", [])
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
for row in rows:
|
||||
for row in data.get('result', []):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
|
||||
actual_name = meta.get("name") or row.get("title") or row.get("author")
|
||||
user_id = row.get("userId")
|
||||
if actual_name == name and isinstance(user_id, str) and user_id.strip():
|
||||
candidates[user_id] = name
|
||||
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("提醒对象未唯一匹配,本条未发送;请负责人确认完整姓名或已有的有效人员映射。")
|
||||
raise NoticeError('提醒对象未唯一匹配,请确认完整姓名;不猜编号、不删掉 @ 后发送。')
|
||||
people.update(candidates)
|
||||
return people
|
||||
|
||||
|
||||
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
|
||||
args = parse_args(argv)
|
||||
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)
|
||||
preview = args.dry_run or config.get("DRY_RUN") == "1"
|
||||
if not preview and config.get("ENABLED") != "1":
|
||||
emit("skipped", "钉钉通知未配置或未启用,已跳过。")
|
||||
if args.command != 'check' and args.dry_run:
|
||||
emit('preview', '仅离线预览,账号、群与 @ 效果未验证。', text=render(args, config, root), mentions=args.at)
|
||||
return 0
|
||||
title, text = render(args, config, body_text(args, root))
|
||||
if preview:
|
||||
emit("preview", "仅离线预览,未验证群配置或提醒对象;未查人员、认证或发送,不输出配置中的密钥。",
|
||||
title=title, text=text,
|
||||
mentions_requested=args.command == "message" and bool(args.at or args.at_id))
|
||||
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 config.get("MODE", "app-bot") != "app-bot":
|
||||
raise NoticeError("当前封装仅支持 app-bot,请部署负责人检查配置。")
|
||||
if any(not config.get(key) for key in ("CLIENT_ID", "TARGET", "ROBOT_CODE")):
|
||||
raise NoticeError("机器人配置不完整,请部署负责人检查,不要在会话中粘贴配置。")
|
||||
cli = dws_path(config)
|
||||
secret = secret_for(config)
|
||||
people = mentions(args, config, cli, run, sleep) if args.command == "message" else {}
|
||||
if people:
|
||||
text += "\n\n" + " ".join("@" + user_id for user_id in people)
|
||||
message = {"title": title, "text": text}
|
||||
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:
|
||||
message["atUserIds"] = list(people)
|
||||
payload = {
|
||||
"msgParam": json.dumps(message, ensure_ascii=False), "msgKey": "sampleMarkdown",
|
||||
"openConversationId": (args.group if args.command == "message" else None) or config["TARGET"],
|
||||
"robotCode": config["ROBOT_CODE"],
|
||||
}
|
||||
# DWS accepts --data -: avoid shell quoting, argv length limits and
|
||||
# exposing message/target contents in the process command line.
|
||||
command = [cli, "api", "POST", "/v1.0/robot/groupMessages/send",
|
||||
"--data", "-", "--yes", "--format", "json"]
|
||||
if config.get("PROFILE"):
|
||||
command += ["--profile", config["PROFILE"]]
|
||||
env = clean_env()
|
||||
env.update(DWS_CLIENT_ID=config["CLIENT_ID"], DWS_CLIENT_SECRET=secret)
|
||||
data = invoke(command, env, sending=True, run=run, sleep=sleep,
|
||||
stdin_data=json.dumps(payload, ensure_ascii=False))
|
||||
emit("accepted", "钉钉服务端已接受通知,不代表群成员已读。", processQueryKey=accepted_key(data))
|
||||
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))
|
||||
emit('error', str(error))
|
||||
return 1
|
||||
except (OSError, UnicodeError, subprocess.SubprocessError, ValueError):
|
||||
emit("error", "通知配置、正文文件或本机调用异常;请部署负责人检查,本次停止且不影响主任务。")
|
||||
except (OSError, ValueError, UnicodeError, TypeError, subprocess.SubprocessError):
|
||||
emit('error', '配置、正文或调用结果异常;本次停止,不自动重发,主任务继续。')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
if hasattr(stream, 'reconfigure'):
|
||||
stream.reconfigure(encoding='utf-8')
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user