release: v1.0.2 跨平台钉钉通知与流程版本编号约定

This commit is contained in:
chenmingxuan committed 2026-09-09 11:52:17 +08:00
1 parent 4f9efd175d
commit a08cfd6a3b
17 files changed
+858 -290

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v python3 >/dev/null 2>&1; then
exec python3 "$script_dir/dingtalk_notify.py" message "$@"
fi
exec python "$script_dir/dingtalk_notify.py" message "$@"
+11
View File
@@ -0,0 +1,11 @@
# Requires PowerShell 7.3+ for native argument passing (quotes and Markdown).
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Please use PowerShell 7.3+ or the Python entry.' }
$PSNativeCommandArgumentPassing = 'Standard'
$env:PYTHONIOENCODING = 'utf-8'
if (Get-Command python -CommandType Application -ErrorAction SilentlyContinue) {
& python "$PSScriptRoot/dingtalk_notify.py" message @args
} elseif (Get-Command py -CommandType Application -ErrorAction SilentlyContinue) {
& py -3 "$PSScriptRoot/dingtalk_notify.py" message @args
} else { throw 'Python 3.9+ is required.' }
exit $LASTEXITCODE
+5 -187
View File
@@ -1,190 +1,8 @@
#!/bin/sh
# macOS/Linux entry; Windows can use the .ps1 or Python entry.
set -eu
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
config_file="$project_root/.dingtalk/config.env"
usage() {
echo "用法:./scripts/dingtalk-progress <start|milestone|blocked|complete|failed> <中文 Markdown 摘要>" >&2
}
if [ "$#" -lt 2 ]; then
usage
exit 2
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if command -v python3 >/dev/null 2>&1; then
exec python3 "$script_dir/dingtalk_notify.py" progress "$@"
fi
event_name=$1
shift
summary=$*
case "$event_name" in
start|milestone|blocked|complete|failed) ;;
*)
usage
exit 2
;;
esac
if [ ! -f "$config_file" ]; then
echo "DingTalk progress reporting is not configured; skipping." >&2
exit 0
fi
file_mode=$(stat -f '%Lp' "$config_file" 2>/dev/null || stat -c '%a' "$config_file" 2>/dev/null || true)
case "$file_mode" in
400|600) ;;
*)
echo "Refusing to read $config_file because its mode is ${file_mode:-unknown}; run chmod 600 on it." >&2
exit 2
;;
esac
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|'#'*) continue ;;
esac
key=${line%%=*}
value=${line#*=}
case "$key" in
DINGTALK_PROGRESS_ENABLED|DINGTALK_PROGRESS_DRY_RUN|DINGTALK_PROGRESS_MODE|DINGTALK_PROGRESS_CLIENT_ID|DINGTALK_PROGRESS_TARGET|DINGTALK_PROGRESS_ROBOT_CODE|DINGTALK_PROGRESS_KEYCHAIN_SERVICE|DINGTALK_PROGRESS_PROJECT|DINGTALK_PROGRESS_TITLE_PREFIX)
export "$key=$value"
;;
*)
echo "Unknown key in $config_file: $key" >&2
exit 2
;;
esac
done < "$config_file"
if [ "${DINGTALK_PROGRESS_ENABLED:-0}" != "1" ]; then
echo "DingTalk progress reporting is disabled; skipping." >&2
exit 0
fi
if [ "${DINGTALK_PROGRESS_MODE:-app-bot}" != "app-bot" ]; then
echo "DINGTALK_PROGRESS_MODE must be app-bot." >&2
exit 2
fi
if [ -z "${DINGTALK_PROGRESS_CLIENT_ID:-}" ] ||
[ -z "${DINGTALK_PROGRESS_TARGET:-}" ] ||
[ -z "${DINGTALK_PROGRESS_ROBOT_CODE:-}" ]; then
echo "App-bot mode requires DINGTALK_PROGRESS_CLIENT_ID, DINGTALK_PROGRESS_TARGET, and DINGTALK_PROGRESS_ROBOT_CODE." >&2
exit 2
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required to construct and validate the DingTalk OpenAPI request." >&2
exit 127
fi
if ! command -v dws >/dev/null 2>&1; then
echo "Global dws command was not found in PATH." >&2
exit 127
fi
dws=$(command -v dws)
if [ -n "${DING_SEC:-}" ]; then
client_secret=$DING_SEC
else
if [ ! -x /usr/bin/security ]; then
echo "DING_SEC is unset and macOS Keychain command /usr/bin/security is unavailable." >&2
exit 127
fi
keychain_service=${DINGTALK_PROGRESS_KEYCHAIN_SERVICE:-com.eapil.dingtalk.$(basename -- "$project_root")}
if ! client_secret=$(/usr/bin/security find-generic-password \
-a "$DINGTALK_PROGRESS_CLIENT_ID" \
-s "$keychain_service" \
-w 2>/dev/null); then
echo "DingTalk Client Secret was not found in DING_SEC or this project's macOS Keychain item." >&2
echo "See .dingtalk/README.md for credential setup." >&2
exit 2
fi
fi
if [ -z "$client_secret" ]; then
echo "The DingTalk Client Secret is empty." >&2
exit 2
fi
case "$event_name" in
start) event_label="开始推进" ;;
milestone) event_label="阶段里程碑" ;;
blocked) event_label="遇到阻塞" ;;
complete) event_label="本轮完成" ;;
failed) event_label="执行失败" ;;
esac
project_name=${DINGTALK_PROGRESS_PROJECT:-$(basename -- "$project_root")}
title_prefix=${DINGTALK_PROGRESS_TITLE_PREFIX:-[项目进度]}
title="$title_prefix $event_label"
timestamp=$(date '+%Y-%m-%d %H:%M:%S %Z')
body=$(printf '## %s\n\n**项目:** %s \n**状态:** %s \n**更新时间:** %s\n\n---\n\n%s' \
"$title" "$project_name" "$event_label" "$timestamp" "$summary")
msg_param=$(jq -cn --arg title "$title" --arg text "$body" '{title:$title,text:$text}')
request_body=$(jq -cn \
--arg msgParam "$msg_param" \
--arg openConversationId "$DINGTALK_PROGRESS_TARGET" \
--arg robotCode "$DINGTALK_PROGRESS_ROBOT_CODE" \
'{msgParam:$msgParam,msgKey:"sampleMarkdown",openConversationId:$openConversationId,robotCode:$robotCode}')
if [ "${DINGTALK_PROGRESS_DRY_RUN:-0}" = "1" ]; then
if DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
DWS_CLIENT_SECRET="$client_secret" \
"$dws" api POST /v1.0/robot/groupMessages/send \
--data "$request_body" --dry-run --yes --format json; then
send_status=0
else
send_status=$?
fi
unset client_secret DWS_CLIENT_SECRET
exit "$send_status"
fi
max_send_attempts=3
retry_delay_seconds=10
attempt=1
final_status=1
while [ "$attempt" -le "$max_send_attempts" ]; do
response=
if response=$(DWS_CLIENT_ID="$DINGTALK_PROGRESS_CLIENT_ID" \
DWS_CLIENT_SECRET="$client_secret" \
"$dws" api POST /v1.0/robot/groupMessages/send \
--data "$request_body" --yes --format json); then
send_status=0
else
send_status=$?
fi
if [ -n "$response" ]; then
printf '%s\n' "$response"
fi
if [ "$send_status" -eq 0 ] &&
printf '%s\n' "$response" |
jq -e '.processQueryKey | type == "string" and length > 0' >/dev/null 2>&1; then
unset client_secret DWS_CLIENT_SECRET
exit 0
fi
if [ "$send_status" -eq 0 ]; then
echo "DingTalk attempt $attempt returned no valid processQueryKey; service acceptance is unverified." >&2
final_status=1
else
echo "DingTalk attempt $attempt failed with status $send_status." >&2
final_status=$send_status
fi
if [ "$attempt" -lt "$max_send_attempts" ]; then
echo "Retrying DingTalk send in $retry_delay_seconds seconds." >&2
sleep "$retry_delay_seconds"
fi
attempt=$((attempt + 1))
done
unset client_secret DWS_CLIENT_SECRET
echo "DingTalk progress reporting failed after $max_send_attempts attempts; Etunel work should continue." >&2
exit "$final_status"
exec python "$script_dir/dingtalk_notify.py" progress "$@"
+11
View File
@@ -0,0 +1,11 @@
# Requires PowerShell 7.3+ for native argument passing (quotes and Markdown).
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [version]'7.3') { throw 'Please use PowerShell 7.3+ or the Python entry.' }
$PSNativeCommandArgumentPassing = 'Standard'
$env:PYTHONIOENCODING = 'utf-8'
if (Get-Command python -CommandType Application -ErrorAction SilentlyContinue) {
& python "$PSScriptRoot/dingtalk_notify.py" progress @args
} elseif (Get-Command py -CommandType Application -ErrorAction SilentlyContinue) {
& py -3 "$PSScriptRoot/dingtalk_notify.py" progress @args
} else { throw 'Python 3.9+ is required.' }
exit $LASTEXITCODE
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Hub-only DingTalk notifications. Python 3.9+, standard library, no shell eval."""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
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
class NoticeError(Exception):
"""A human-safe error: never include raw subprocess/config contents."""
def emit(status, message, **extra):
print(json.dumps({"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):
try:
value = json.loads(output)
return value if isinstance(value, 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 invoke(command, env, sending=False, run=subprocess.run, sleep=time.sleep, stdin_data=None):
for attempt in range(1, ATTEMPTS + 1):
try:
result = run(command, env=env, input=stdin_data, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=COMMAND_TIMEOUT)
except subprocess.TimeoutExpired:
raise NoticeError("钉钉调用超时,结果未知;本次不自动重发,请负责人核对。") from None
except OSError:
raise NoticeError("钉钉命令无法启动,请部署负责人检查安装和进程权限。") from None
data = json_object(result.stdout)
if sending and accepted_key(data):
return data
if result.returncode == 0:
if sending:
raise NoticeError("未收到有效发送回执,结果未知;本次不自动重发,请负责人核对。")
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)
continue
raise NoticeError("钉钉域名解析连续失败 3 次,本次通知已停止;主任务继续。")
raise NoticeError("钉钉调用未成功,未自动重发;请部署负责人检查认证、权限或网络,主任务继续。")
raise AssertionError("unreachable")
def mentions(args, config, cli, run=subprocess.run, sleep=time.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;不要临时切换或猜测账号。")
for name in names:
data = invoke([cli, "aisearch", "person", "--query", name, "--dimension", "name",
"--profile", config["PROFILE"], "--format", "json"],
clean_env(), run=run, sleep=sleep)
candidates = {}
rows = data.get("result", [])
if not isinstance(rows, list):
rows = []
for row in rows:
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
if len(candidates) != 1:
raise NoticeError("提醒对象未唯一匹配,本条未发送;请负责人确认完整姓名或已有的有效人员映射。")
people.update(candidates)
return people
def main(argv=None, root=ROOT, run=subprocess.run, sleep=time.sleep):
args = parse_args(argv)
try:
config = read_config(root)
preview = args.dry_run or config.get("DRY_RUN") == "1"
if not preview and config.get("ENABLED") != "1":
emit("skipped", "钉钉通知未配置或未启用,已跳过。")
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))
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}
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))
return 0
except NoticeError as error:
emit("error", str(error))
return 1
except (OSError, UnicodeError, subprocess.SubprocessError, ValueError):
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())
+323
View File
@@ -0,0 +1,323 @@
"""Offline tests: fake configs, fake credentials, fake DWS. Never use live config."""
import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import dingtalk_notify as notify
DNS = 'lookup api.dingtalk.com on 127.0.0.1:53: i/o timeout'
OK = '{"processQueryKey":"test-receipt"}'
def response(code=0, stdout=OK, stderr=''):
return subprocess.CompletedProcess(['fake-dws'], code, stdout, stderr)
class NotifyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix='通知测试 with spaces ')
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / '.dingtalk').mkdir()
self.config = self.root / '.dingtalk/config.env'
self.configure()
self.environment = mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret',
'DWS_CLIENT_SECRET': 'ignored-dws-secret'}, clear=True)
self.environment.start()
self.addCleanup(self.environment.stop)
self.locator = mock.patch.object(notify, 'dws_path', return_value='fake-dws')
self.locator.start()
self.addCleanup(self.locator.stop)
def configure(self, **updates):
values = dict(ENABLED='1', DRY_RUN='0', MODE='app-bot', CLIENT_ID='fake-client',
TARGET='fake-group', ROBOT_CODE='fake-robot', PROJECT='离线测试',
PROFILE='fake-corp:fake-user', CLIENT_SECRET='fake-secret')
values.update(updates)
self.config.write_text('\n'.join(notify.PREFIX + k + '=' + v for k, v in values.items()),
encoding='utf-8')
self.config.chmod(0o600)
def call(self, args, responses=None):
run = mock.Mock(side_effect=responses or [response()])
sleep = mock.Mock()
output, errors = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
code = notify.main(args, root=self.root, run=run, sleep=sleep)
return code, json.loads(output.getvalue()), run, sleep, errors.getvalue()
def entries(self):
return [['progress', 'milestone', '**完成**\n\n- 内容'],
['message', '--title', '负责人提醒', '--text', '**请确认**\n\n- 内容']]
def test_events(self):
for event, label in notify.EVENTS.items():
with self.subTest(event=event):
code, data, run, _, _ = self.call(['progress', event, '中文摘要'])
self.assertEqual((code, data['status']), (0, 'accepted'))
payload = json.loads(run.call_args.kwargs['input'])
self.assertIn(label, json.loads(payload['msgParam'])['title'])
def test_payload_and_credentials_not_on_argv(self):
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & <内容> \\路径'
code, data, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual(code, 0)
command = run.call_args.args[0]
kwargs = run.call_args.kwargs
payload = json.loads(kwargs['input'])
self.assertEqual(json.loads(payload['msgParam'])['text'], text)
self.assertEqual(payload['openConversationId'], 'fake-group')
self.assertEqual(payload['msgKey'], 'sampleMarkdown')
self.assertEqual(command[command.index('--data') + 1], '-')
self.assertNotIn('fake-secret', str(command))
self.assertNotIn('fake-group', str(command))
self.assertEqual(kwargs['env']['DWS_CLIENT_SECRET'], 'fake-secret')
self.assertNotIn('DING_SEC', kwargs['env'])
self.assertNotIn('fake-secret', json.dumps(data))
def test_dns_retries_both_entries(self):
for args in self.entries():
with self.subTest(args=args):
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS)] * 3)
self.assertEqual((code, data['status'], run.call_count), (1, 'error', 3))
self.assertEqual(sleep.call_args_list, [mock.call(10), mock.call(10)])
def test_transient_dns_success_stops(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, '', DNS), response()])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 2, 1))
def test_ambiguous_and_auth_errors_never_replayed_or_echoed(self):
for args in self.entries():
for error in ['i/o timeout', 'EOF', 'HTTP 503', 'permission denied', 'fake-secret App Token']:
with self.subTest(error=error):
code, data, run, sleep, stderr = self.call(args, [response(1, '', error)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertNotIn(error, json.dumps(data, ensure_ascii=False) + stderr)
def test_receipt_wins_even_nonzero(self):
for args in self.entries():
code, data, run, sleep, _ = self.call(args, [response(1, OK, DNS)])
self.assertEqual((code, data['status'], run.call_count, sleep.call_count), (0, 'accepted', 1, 0))
def test_bad_receipts_do_not_replay(self):
for raw in ['{}', '{"processQueryKey":" "}', '{"processQueryKey":123}', 'not json', '[]']:
code, _, run, sleep, _ = self.call(self.entries()[0], [response(0, raw)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_timeout_no_replay(self):
code, data, run, sleep, _ = self.call(self.entries()[0], [subprocess.TimeoutExpired('fake', 60)])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
self.assertIn('结果未知', data['message'])
self.assertEqual(run.call_args.kwargs['timeout'], 60)
def test_startup_failure_no_replay(self):
code, _, run, sleep, _ = self.call(self.entries()[0], [OSError('private machine detail')])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_missing_credentials_no_commands(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertIn('凭据', data['message'])
def test_skipped_no_tools(self):
self.configure(ENABLED='0')
for args in self.entries():
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'skipped', 0))
self.config.unlink()
self.assertEqual(self.call(self.entries()[0])[1]['status'], 'skipped')
def test_preview_never_uses_or_prints_secret_or_resolves_people(self):
for configured in [False, True]:
self.configure(DRY_RUN='1' if configured else '0')
for args in self.entries():
if args[0] == 'message':
args += ['--at', '完整姓名']
if not configured:
args += ['--dry-run']
with mock.patch.object(notify, 'secret_for', side_effect=AssertionError('secret accessed')):
code, data, run, _, _ = self.call(args)
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
self.assertNotIn('fake-group', str(data))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_secret(self):
self.configure(CLIENT_SECRET='')
code, data, run, _, _ = self.call(self.entries()[0] + ['--dry-run'])
self.assertEqual((code, data['status'], run.call_count), (0, 'preview', 0))
def test_config_cannot_be_used_as_body_even_in_preview(self):
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
for flags in [[], ['--dry-run']]:
code, data, run, _, _ = self.call(args + ['--file', str(self.config)] + flags)
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_preview_without_config(self):
self.config.unlink()
self.assertEqual(self.call(self.entries()[0] + ['--dry-run'])[1]['status'], 'preview')
def test_utf8_bom_crlf_literal_config(self):
raw = self.config.read_text(encoding='utf-8') + '\nDINGTALK_PROGRESS_TITLE_PREFIX=$(not-executed)'
self.config.write_bytes(('\ufeff' + raw.replace('\n', '\r\n')).encode('utf-8'))
self.assertEqual(notify.read_config(self.root)['TITLE_PREFIX'], '$(not-executed)')
def test_invalid_config_safe_error(self):
for raw in ['DING_SEC=fake-secret', 'invalid fake-secret',
'DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_ENABLED=0',
'DINGTALK_PROGRESS_ENABLED=yes']:
self.config.write_text(raw, encoding='utf-8')
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual((code, run.call_count), (1, 0))
self.assertNotIn('fake-secret', str(data))
def test_body_files_both_entries(self):
body = self.root / '中文 summary.md'
body.write_bytes('\ufeff**结果**\r\n\r\n- 已确认 😀'.encode('utf-8'))
for args in [['progress', 'milestone'], ['message', '--title', '摘要']]:
code, _, run, _, _ = self.call(args + ['--file', str(body)])
self.assertEqual(code, 0)
text = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])['text']
self.assertIn('**结果**\n\n- 已确认 😀', text)
def test_empty_or_large_body_fails_before_tools(self):
for text in ['', ' ', '字' * 5000]:
code, _, run, _, _ = self.call(['message', '--title', '摘要', '--text', text])
self.assertEqual((code, run.call_count), (1, 0))
def test_exact_name_and_dedup_same_profile(self):
found = json.dumps({'result': [{'meta': {'name': '完整姓名'}, 'userId': 'fake-person'}]})
args = self.entries()[1] + ['--at', '完整姓名,完整姓名', '--at-id', '完整姓名=fake-person']
code, _, run, _, _ = self.call(args, [response(0, found), response()])
self.assertEqual((code, run.call_count), (0, 2))
for call in run.call_args_list:
command = call.args[0]
self.assertEqual(command[command.index('--profile') + 1], 'fake-corp:fake-user')
self.assertNotIn('DWS_CLIENT_SECRET', run.call_args_list[0].kwargs['env'])
message = json.loads(json.loads(run.call_args.kwargs['input'])['msgParam'])
self.assertEqual(message['atUserIds'], ['fake-person'])
self.assertTrue(message['text'].endswith('@fake-person'))
def test_names_zero_multiple_malformed_stop_before_send(self):
for rows in [[], [{'title': '完整姓名', 'userId': 'a'}, {'title': '完整姓名', 'userId': 'b'}],
[{'title': '别的姓名', 'userId': 'a'}], {}, [None]]:
code, _, run, sleep, _ = self.call(self.entries()[1] + ['--at', '完整姓名'],
[response(0, json.dumps({'result': rows}))])
self.assertEqual((code, run.call_count, sleep.call_count), (1, 1, 0))
def test_name_search_requires_explicit_profile(self):
self.configure(PROFILE='')
code, _, run, _, _ = self.call(self.entries()[1] + ['--at', '完整姓名'])
self.assertEqual((code, run.call_count), (1, 0))
def test_known_mapping_and_group_compatibility(self):
code, _, run, _, _ = self.call(self.entries()[1] + ['--at-id', '姓名=fake-id', '--group', 'fake-other'])
self.assertEqual(code, 0)
self.assertEqual(json.loads(run.call_args.kwargs['input'])['openConversationId'], 'fake-other')
def test_config_secret_is_only_source_on_all_platforms(self):
for platform in ['darwin', 'win32', 'linux']:
with self.subTest(platform=platform), mock.patch.object(sys, 'platform', platform):
self.assertEqual(notify.secret_for({'CLIENT_SECRET': 'fake-secret'}), 'fake-secret')
with self.assertRaises(notify.NoticeError):
notify.secret_for({})
def test_secret_special_characters_are_literal(self):
secret = 'fake=secret#literal$not-expanded'
self.configure(CLIENT_SECRET=secret)
code, data, run, _, _ = self.call(self.entries()[0])
self.assertEqual(code, 0)
self.assertEqual(run.call_args.kwargs['env']['DWS_CLIENT_SECRET'], secret)
self.assertNotIn(secret, str(data))
self.assertNotIn(secret, str(run.call_args.args))
self.assertNotIn(secret, run.call_args.kwargs['input'])
class LauncherTests(unittest.TestCase):
def test_real_subprocess_stdin_without_live_dws(self):
with tempfile.TemporaryDirectory(prefix='通知 fake transport ') as folder:
root = Path(folder)
(root / '.dingtalk').mkdir()
config = root / '.dingtalk/config.env'
config.write_text('DINGTALK_PROGRESS_ENABLED=1\nDINGTALK_PROGRESS_DRY_RUN=0\n'
'DINGTALK_PROGRESS_CLIENT_ID=fake-client\n'
'DINGTALK_PROGRESS_CLIENT_SECRET=fake-secret\n'
'DINGTALK_PROGRESS_TARGET=fake-group\n'
'DINGTALK_PROGRESS_ROBOT_CODE=fake-robot\n', encoding='utf-8')
config.chmod(0o600)
fake = root / 'fake dws.py'
fake.write_text(
'import json, os, sys\n'
'assert sys.argv[1:] == ["api", "POST", "/v1.0/robot/groupMessages/send", '
'"--data", "-", "--yes", "--format", "json"]\n'
'payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))\n'
'assert payload["openConversationId"] == "fake-group"\n'
'assert json.loads(payload["msgParam"])["text"] == "中文\\n\\n- \\\"引号\\\" 😀"\n'
'assert os.environ["DWS_CLIENT_SECRET"] == "fake-secret"\n'
'assert "DING_SEC" not in os.environ\n'
'print(json.dumps({"processQueryKey": "fake-transport-receipt"}))\n', encoding='utf-8')
def transport(command, **kwargs):
return subprocess.run([sys.executable, str(fake), *command[1:]], **kwargs)
out = io.StringIO()
with mock.patch.object(notify, 'dws_path', return_value='fake-dws'), \
mock.patch.dict(os.environ, {'DING_SEC': 'ignored-env-secret'}), \
contextlib.redirect_stdout(out):
code = notify.main(['message', '--title', '摘要', '--text', '中文\n\n- "引号" 😀'],
root=root, run=transport)
self.assertEqual(code, 0, out.getvalue())
self.assertEqual(json.loads(out.getvalue())['status'], 'accepted')
def test_real_launchers_preview_in_isolated_copy(self):
"""Exercise OS processes/quoting, but copy no real config and never run dws."""
with tempfile.TemporaryDirectory(prefix='通知 launch with spaces ') as folder:
scripts = Path(folder) / 'scripts'
scripts.mkdir()
for name in ['dingtalk_notify.py', 'dingtalk-progress', 'dingtalk-bot-message',
'dingtalk-progress.ps1', 'dingtalk-bot-message.ps1']:
shutil.copy2(Path(__file__).parent / name, scripts / name)
text = '**结果**\n\n- "中文" 😀 `代码` $变量 & 内容'
body = Path(folder) / '中文正文.md'
body.write_text(text, encoding='utf-8')
env = {k: v for k, v in os.environ.items()
if not k.startswith(('DING', 'DWS_'))}
# Run from outside the script project: --file is cwd-relative.
commands = [[sys.executable, str(scripts / 'dingtalk_notify.py'), 'progress', 'milestone',
'--file', body.name, '--dry-run']]
pwsh = shutil.which('pwsh')
if os.name == 'nt':
self.assertIsNotNone(pwsh, 'Windows launcher test needs PowerShell 7.3+')
if pwsh:
commands += [[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-progress.ps1'),
'milestone', text, '--dry-run'],
[pwsh, '-NoProfile', '-File', str(scripts / 'dingtalk-bot-message.ps1'),
'--title', '中文摘要', '--text', text, '--dry-run']]
bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash')
if bash and Path(bash).exists():
for name, args in [('dingtalk-progress', ['milestone']),
('dingtalk-bot-message', ['--title', '中文摘要'])]:
commands.append([str(bash), str(scripts / name), *args,
'--file', body.name, '--dry-run'])
for command in commands:
with self.subTest(entry=command[:3]):
result = subprocess.run(command, cwd=folder, env=env, capture_output=True,
text=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'])
if __name__ == '__main__':
unittest.main()