128 lines
6.2 KiB
Python
128 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
"""Send one signed daily DingTalk result from cloud, without an AI dependency."""
|
||
import argparse
|
||
import base64
|
||
import fcntl
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
from ding_daily_sync import STATE, atomic, now, read_json
|
||
|
||
|
||
def current(status, today):
|
||
try:
|
||
return status and datetime.fromisoformat(status['checked_at']).astimezone(
|
||
ZoneInfo('Asia/Shanghai')).date().isoformat() == today
|
||
except (KeyError, ValueError, TypeError):
|
||
return False
|
||
|
||
|
||
def message(cloud, local, today, test=False):
|
||
lines = [f"采购数据同步结果 | {today}"]
|
||
if test:
|
||
lines = ['钉钉机器人接入测试:通知通道及@配置验证。', '以下为最近记录,不代表今天09:00定时任务已执行。']
|
||
cloud_ok = bool(current(cloud, today) and cloud.get('state') in {'success', 'waiting_source'}
|
||
and cloud.get('version') and cloud.get('cache_refreshed'))
|
||
local_ok = bool(cloud_ok and current(local, today) and local.get('state') in {'success', 'waiting_source'}
|
||
and local.get('version') == cloud['version'] and local.get('cache_refreshed')
|
||
and local.get('target_inventory') == cloud.get('target_inventory'))
|
||
if cloud_ok:
|
||
coverage = cloud.get('coverage', {})
|
||
lines += [f"库存日期:{cloud.get('target_inventory')},已同步{coverage.get('inventory', 0)}份仓库文件",
|
||
f"出库日期:{cloud.get('target_outbound')},已同步{coverage.get('outbound', 0)}份来源文件",
|
||
'云端:已上传文件同步成功,缓存已刷新。']
|
||
elif current(cloud, today) and cloud.get('state') == 'error':
|
||
lines += ['云端:同步失败,请查看服务器任务日志。']
|
||
else:
|
||
lines += ['云端:未发现当天完成结果(任务未完成或未运行)。']
|
||
if local_ok:
|
||
lines += ['8.186:对应版本同步成功,缓存已刷新。']
|
||
elif current(local, today) and local.get('state') == 'error':
|
||
lines += ['8.186:同步失败,请查看本机任务日志。']
|
||
else:
|
||
lines += ['8.186:未收到当天对应版本完成回执,可能未开机、未联网或任务未完成。']
|
||
if cloud_ok:
|
||
missing = cloud.get('missing', [])
|
||
lines += [f'未上传文件:{len(missing)}份,已跳过,不等待补录。']
|
||
if missing:
|
||
labels = {'inventory': '库存', 'outbound': '出库', 'dymuto': '抖音M', 'dyqsimelr': '抖音Q'}
|
||
lines += ['跳过:' + '、'.join('/'.join(labels.get(part, part) for part in item.split(':')) for item in missing[:30])]
|
||
if len(missing) > 30:
|
||
lines += [f'其余{len(missing) - 30}份详见日志。']
|
||
lines += ['每日一次;同步和通知均不调用AI模型。']
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def send(config, content):
|
||
url = urllib.parse.urlsplit(config['webhook'])
|
||
if url.scheme != 'https' or url.netloc != 'oapi.dingtalk.com' or url.path != '/robot/send':
|
||
raise ValueError('Invalid DingTalk endpoint')
|
||
timestamp = str(int(time.time() * 1000))
|
||
signature = base64.b64encode(hmac.new(config['secret'].encode(),
|
||
(timestamp + '\n' + config['secret']).encode(), hashlib.sha256).digest()).decode()
|
||
query = urllib.parse.parse_qsl(url.query) + [('timestamp', timestamp), ('sign', signature)]
|
||
signed_url = urllib.parse.urlunsplit(url._replace(query=urllib.parse.urlencode(query)))
|
||
mobiles = config.get('at_mobiles', [])
|
||
content += '\n' + ' '.join('@' + mobile for mobile in mobiles)
|
||
payload = {'msgtype': 'text', 'text': {'content': content},
|
||
'at': {'atMobiles': mobiles, 'isAtAll': False}}
|
||
request = urllib.request.Request(signed_url, data=json.dumps(payload, ensure_ascii=False).encode(),
|
||
headers={'Content-Type': 'application/json; charset=utf-8'}, method='POST')
|
||
# Suppress exceptions and API messages that might contain the signed URL or credentials.
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=20) as response:
|
||
result = json.load(response)
|
||
except Exception:
|
||
raise RuntimeError('DingTalk request failed; no retry') from None
|
||
if result.get('errcode') != 0:
|
||
raise RuntimeError(f"DingTalk rejected notification (errcode={result.get('errcode')}); no retry")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--test', action='store_true', help='Send one explicitly labelled setup test')
|
||
args = parser.parse_args()
|
||
os.umask(0o077)
|
||
with open(STATE / 'notify.lock', 'w') as lock:
|
||
try:
|
||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except BlockingIOError:
|
||
print('Notifier already running; skipped')
|
||
return
|
||
today = now().date().isoformat()
|
||
receipt = STATE / 'notifications' / (('test-' if args.test else '') + today + '.json')
|
||
if receipt.exists():
|
||
print('Notification already attempted for this day; skipped')
|
||
return
|
||
config_path = STATE / 'robot.json'
|
||
if config_path.stat().st_mode & 0o077:
|
||
raise RuntimeError('Robot configuration permissions must be 0600')
|
||
config = read_json(config_path)
|
||
content = message(read_json(STATE / 'status.json', {}), read_json(STATE / 'local-ack.json', {}), today, args.test)
|
||
record = {'attempted_at': now().isoformat(), 'state': 'attempted', 'test': args.test}
|
||
# Record before sending: an ambiguous network failure must not send duplicate daily messages.
|
||
atomic(receipt, record)
|
||
try:
|
||
send(config, content)
|
||
except Exception as error:
|
||
atomic(receipt, {**record, 'state': 'error', 'error': str(error)})
|
||
raise
|
||
atomic(receipt, {**record, 'state': 'sent', 'sent_at': now().isoformat()})
|
||
print('DingTalk accepted notification; configured @ mention included')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
try:
|
||
main()
|
||
except Exception as error:
|
||
print(str(error), file=sys.stderr)
|
||
sys.exit(1)
|