dsh-project-deck 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,33 @@ deck demo # 演示环境
67
67
  无变化:官网改版、基础设施、…
68
68
  ```
69
69
 
70
+ ## 通知渠道(可选,默认 wxpush)
71
+
72
+ 播报通过 `report.delivery` 选择发送器,**不带任何内置账号**:
73
+
74
+ ```jsonc
75
+ "report": {
76
+ "enabled": true,
77
+ "image": false, // 附图仅 wxpush 通道支持
78
+ "delivery": {
79
+ "type": "wxpush", // wxpush | serverchan | webhook | command
80
+ "url": "http://127.0.0.1:9098", // wxpush
81
+ "key": "", // ServerChan SendKey
82
+ "webhook": "", // 任意 webhook(企业微信机器人等)
83
+ "command": "" // 自写脚本:stdin 收文本,退出码 0 = 成功
84
+ }
85
+ }
86
+ ```
87
+
88
+ | 类型 | 依赖 | 说明 |
89
+ |---|---|---|
90
+ | `wxpush` | 本机 wxpush 服务 | 支持文字 + 看板图;token 过期时会入队等恢复 |
91
+ | `serverchan` | ServerChan SendKey | 纯 HTTP,无 token 过期问题 |
92
+ | `webhook` | 任意 URL | 发 `{"msgtype":"text","text":{"content":...}}`,兼容企业微信机器人 |
93
+ | `command` | 你的脚本 | 最灵活,退出码判定 |
94
+
95
+ `deck status` 会显示当前通道与连通状态;通道不通时播报不会堆积(附图除外,会有限重试)。
96
+
70
97
  ## 配置(`~/.project-deck/config.json`)
71
98
 
72
99
  | key | 默认 | 说明 |
package/backend/deck CHANGED
@@ -1017,6 +1017,97 @@ def cmd_scan(argv):
1017
1017
 
1018
1018
 
1019
1019
  # ── 发送 ────────────────────────────────────────────────────
1020
+ def delivery_cfg(cfg=None):
1021
+ cfg = cfg or load_json(CONFIG, {})
1022
+ d = (cfg.get('report') or {}).get('delivery') or {}
1023
+ return {
1024
+ 'type': (d.get('type') or 'wxpush').lower(),
1025
+ 'url': (d.get('url') or 'http://127.0.0.1:9098').rstrip('/'),
1026
+ 'key': d.get('key') or '',
1027
+ 'webhook': d.get('webhook') or '',
1028
+ 'command': d.get('command') or '',
1029
+ }
1030
+
1031
+
1032
+ def _http_json(url, payload, timeout=20, headers=None):
1033
+ body = json.dumps(payload, ensure_ascii=False).encode()
1034
+ req = urllib.request.Request(url, data=body,
1035
+ headers={'Content-Type': 'application/json', **(headers or {})})
1036
+ with urllib.request.urlopen(req, timeout=timeout) as r:
1037
+ return r.status, json.loads(r.read().decode() or '{}')
1038
+
1039
+
1040
+ def delivery_send(text):
1041
+ """按 report.delivery.type 发送一条文本。返回 (0|2|3, 说明)。"""
1042
+ d = delivery_cfg()
1043
+ t = d['type']
1044
+ try:
1045
+ if t == 'wxpush':
1046
+ if not d['url']:
1047
+ return 3, 'wxpush url 未配置'
1048
+ code, body = _http_json(d['url'] + '/send', {'message': text})
1049
+ if code == 200:
1050
+ return 0, '已送达(wxpush)'
1051
+ if code == 202:
1052
+ return 2, '已入队(wxpush 未接通)'
1053
+ return 3, f'wxpush HTTP {code}'
1054
+ if t == 'serverchan':
1055
+ if not d['key']:
1056
+ return 2, 'ServerChan key 未配置'
1057
+ url = f"https://sctapi.ftqq.com/{d['key']}.send"
1058
+ import urllib.parse
1059
+ data = urllib.parse.urlencode({'title': '项目播报', 'desp': text}).encode()
1060
+ req = urllib.request.Request(url, data=data)
1061
+ with urllib.request.urlopen(req, timeout=20) as r:
1062
+ j = json.loads(r.read().decode() or '{}')
1063
+ if j.get('code') in (0, '0'):
1064
+ return 0, '已送达(ServerChan)'
1065
+ return 3, f"ServerChan 返回 {str(j)[:120]}"
1066
+ if t == 'webhook':
1067
+ if not d['webhook']:
1068
+ return 2, 'webhook 未配置'
1069
+ code, body = _http_json(d['webhook'], {
1070
+ 'msgtype': 'text', 'text': {'content': text}, 'message': text})
1071
+ return (0, '已送达(webhook)') if code in (200, 204) else (3, f'webhook HTTP {code}')
1072
+ if t == 'command':
1073
+ if not d['command']:
1074
+ return 2, 'command 未配置'
1075
+ r = subprocess.run(d['command'], shell=True, input=text, capture_output=True, text=True, timeout=60)
1076
+ if r.returncode == 0:
1077
+ return 0, '已送达(command)'
1078
+ return 3, f"command 退出码 {r.returncode}: {(r.stderr or r.stdout)[:120]}"
1079
+ return 3, f'未知 delivery.type: {t}'
1080
+ except Exception as e:
1081
+ return 3, f'{t} 发送失败: {e}'
1082
+
1083
+
1084
+ def delivery_status():
1085
+ """给面板/status 用:返回 {ok, stale, detail, pending}。"""
1086
+ d = delivery_cfg()
1087
+ out = {'type': d['type'], 'ok': False, 'detail': '', 'pending': 0}
1088
+ try:
1089
+ if d['type'] == 'wxpush':
1090
+ with urllib.request.urlopen(d['url'] + '/health', timeout=5) as r:
1091
+ j = json.loads(r.read().decode() or '{}')
1092
+ out['ok'] = bool(j.get('ready') or j.get('authorized'))
1093
+ out['pending'] = int((j.get('outbox') or {}).get('pending') or 0)
1094
+ out['detail'] = 'wxpush 已接通' if out['ok'] else ('wxpush 未接通:需要扫码授权或给 bot 发消息刷新')
1095
+ if j.get('token_dead'):
1096
+ out['detail'] = 'wxpush 登录态已失效:需要重新扫码'
1097
+ elif d['type'] == 'serverchan':
1098
+ out['ok'] = bool(d['key'])
1099
+ out['detail'] = 'ServerChan 已配置' if out['ok'] else 'ServerChan key 未配置'
1100
+ elif d['type'] == 'webhook':
1101
+ out['ok'] = bool(d['webhook'])
1102
+ out['detail'] = 'webhook 已配置' if out['ok'] else 'webhook 未配置'
1103
+ elif d['type'] == 'command':
1104
+ out['ok'] = bool(d['command'])
1105
+ out['detail'] = f"command: {d['command']}" if out['ok'] else 'command 未配置'
1106
+ except Exception as e:
1107
+ out['detail'] = f'{d["type"]} 不可达: {e}'
1108
+ return out
1109
+
1110
+
1020
1111
  def channel_state():
1021
1112
  return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
1022
1113
 
@@ -1054,15 +1145,12 @@ def mark_channel(ok, err=''):
1054
1145
 
1055
1146
 
1056
1147
  def send_wechat(text):
1057
- if not os.path.exists(WXSEND):
1058
- return 3, 'wxsend 不存在'
1059
- r = subprocess.run([WXSEND], input=text, capture_output=True, text=True, timeout=30)
1060
- msg = (r.stdout or r.stderr).strip()
1061
- if r.returncode == 0:
1148
+ code, msg = delivery_send(text)
1149
+ if code == 0:
1062
1150
  mark_channel(True)
1063
- elif r.returncode == 2:
1064
- mark_channel(False, msg.splitlines()[0] if msg else 'queued')
1065
- return r.returncode, msg
1151
+ elif code == 2:
1152
+ mark_channel(False, msg[:120])
1153
+ return code, msg
1066
1154
 
1067
1155
 
1068
1156
  def refresh_local_node(cfg=None):
@@ -1132,6 +1220,8 @@ def do_report_with_image(cfg, quiet=False, force=None):
1132
1220
  """播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
1133
1221
  code, text, msg = do_report(cfg, quiet)
1134
1222
  want = cfg.get('report', {}).get('image') if force is None else force
1223
+ if delivery_cfg(cfg)['type'] != 'wxpush':
1224
+ want = False
1135
1225
  if code == 0 and want:
1136
1226
  try:
1137
1227
  png = render_image(cfg)
@@ -1203,6 +1293,8 @@ def flush_pending_image():
1203
1293
  p = load_json(PENDING_IMG, None)
1204
1294
  if not p:
1205
1295
  return False
1296
+ if delivery_cfg()['type'] != 'wxpush':
1297
+ return False
1206
1298
  if channel_stale():
1207
1299
  return False
1208
1300
  # 失败后 2 分钟内不重复尝试,避免空转
@@ -1597,7 +1689,8 @@ def cmd_status():
1597
1689
  nxt += 1800
1598
1690
  print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
1599
1691
  wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
1600
- print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
1692
+ st = delivery_status()
1693
+ print(f"通知渠道: {st['type']} · " + ('✅ ' if st['ok'] else '⚠️ ') + st['detail'])
1601
1694
  return 0 if pid else 1
1602
1695
 
1603
1696
 
package/backend/panel.py CHANGED
@@ -48,12 +48,10 @@ P = load_deck()
48
48
 
49
49
  # ── 数据汇总 ────────────────────────────────────────────────
50
50
  def wechat_state():
51
- try:
52
- r = subprocess.run([P.WXSEND, '--status'], capture_output=True, text=True, timeout=10)
53
- ok = r.returncode == 0
54
- return {'ok': ok, 'raw': r.stdout.strip(), 'code': r.returncode}
55
- except (OSError, subprocess.SubprocessError):
56
- return {'ok': False, 'raw': 'wxsend 不可用', 'code': 3}
51
+ st = P.delivery_status()
52
+ return {'ok': bool(st.get('ok')), 'raw': st.get('detail', ''),
53
+ 'code': 0 if st.get('ok') else 2, 'type': st.get('type'),
54
+ 'pending': st.get('pending', 0)}
57
55
 
58
56
 
59
57
  def project_view(p):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-project-deck",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "DeepSeek Harness plugin: a project deck for your repos — registry, dashboard, scheduled WeChat reports, auto-discovery, multi-node scanning.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",