release: v1.0.2 跨平台钉钉通知与流程版本编号约定
This commit is contained in:
1 parent
4f9efd175d
commit
a08cfd6a3b
17 files changed
+858
-290
No files matched your search
@@ -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 "$@"
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user