dsh-project-deck 0.1.2 → 0.1.4

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,45 @@ deck demo # 演示环境
67
67
  无变化:官网改版、基础设施、…
68
68
  ```
69
69
 
70
+ ## 通知渠道(可选)
71
+
72
+ 播报通过 `report.delivery` 选择发送器,**默认不依赖任何外部服务**:
73
+
74
+ | 类型 | 说明 |
75
+ |---|---|
76
+ | `ilink` | **内置微信连接**:面板点「连接微信」→ 扫码 → 在微信里给 bot 发一句话即可推送,不需要额外服务 |
77
+ | `wxpush` | 复用本机 [wxpush](https://github.com/) 网关(支持附图) |
78
+ | `serverchan` | ServerChan SendKey |
79
+ | `webhook` | 任意 URL(企业微信机器人等) |
80
+ | `command` | 自写脚本,stdin 收文本,退出码 0 = 成功 |
81
+
82
+ 面板顶部有「通知渠道」下拉,选完保存即可;`ilink` 模式下会直接显示二维码。
83
+
84
+ ```jsonc
85
+
86
+ ```jsonc
87
+ "report": {
88
+ "enabled": true,
89
+ "image": false, // 附图仅 wxpush 通道支持
90
+ "delivery": {
91
+ "type": "wxpush", // wxpush | serverchan | webhook | command
92
+ "url": "http://127.0.0.1:9098", // wxpush(ilink 模式忽略)
93
+ "key": "", // ServerChan SendKey
94
+ "webhook": "", // 任意 webhook(企业微信机器人等)
95
+ "command": "" // 自写脚本:stdin 收文本,退出码 0 = 成功
96
+ }
97
+ }
98
+ ```
99
+
100
+ | 类型 | 依赖 | 说明 |
101
+ |---|---|---|
102
+ | `wxpush` | 本机 wxpush 服务 | 支持文字 + 看板图;token 过期时会入队等恢复 |
103
+ | `serverchan` | ServerChan SendKey | 纯 HTTP,无 token 过期问题 |
104
+ | `webhook` | 任意 URL | 发 `{"msgtype":"text","text":{"content":...}}`,兼容企业微信机器人 |
105
+ | `command` | 你的脚本 | 最灵活,退出码判定 |
106
+
107
+ `deck status` 会显示当前通道与连通状态;通道不通时播报不会堆积(附图除外,会有限重试)。
108
+
70
109
  ## 配置(`~/.project-deck/config.json`)
71
110
 
72
111
  | key | 默认 | 说明 |
package/backend/deck CHANGED
@@ -1017,6 +1017,269 @@ def cmd_scan(argv):
1017
1017
 
1018
1018
 
1019
1019
  # ── 发送 ────────────────────────────────────────────────────
1020
+ # ── 内置微信通道(iLink,扫码即用)────────────────────────────
1021
+ WECHAT_STATE = os.path.join(BASE, 'wechat.json')
1022
+ WECHAT_QR = os.path.join(BASE, 'wechat-qr.png')
1023
+ ILINK_BASE = 'https://ilinkai.weixin.qq.com/ilink'
1024
+
1025
+
1026
+ def wechat_state():
1027
+ return load_json(WECHAT_STATE, {})
1028
+
1029
+
1030
+ def save_wechat_state(d):
1031
+ save_json(WECHAT_STATE, d)
1032
+
1033
+
1034
+ def _ilink_headers(token=None):
1035
+ uin = base64.b64encode(str(secrets.randbelow(2**32)).encode()).decode()
1036
+ h = {'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token',
1037
+ 'X-WECHAT-UIN': uin, 'iLink-App-Id': 'bot', 'iLink-App-ClientVersion': '132105'}
1038
+ if token:
1039
+ h['Authorization'] = f'Bearer {token}'
1040
+ return h
1041
+
1042
+
1043
+ def _ilink_request(path, body=None, method='POST', params=None, token=None, timeout=30):
1044
+ url = ILINK_BASE + path
1045
+ if params:
1046
+ url += '?' + urllib.parse.urlencode(params)
1047
+ data = json.dumps(body).encode() if body is not None else None
1048
+ req = urllib.request.Request(url, data=data, headers=_ilink_headers(token), method=method)
1049
+ with urllib.request.urlopen(req, timeout=timeout) as r:
1050
+ return json.loads(r.read().decode() or '{}')
1051
+
1052
+
1053
+ def _extract_ctx(data):
1054
+ found = {}
1055
+
1056
+ def walk(node):
1057
+ if isinstance(node, dict):
1058
+ for k, v in node.items():
1059
+ if k == 'context_token' and isinstance(v, str) and v and 'ctx' not in found:
1060
+ found['ctx'] = v
1061
+ if k in ('from_user_id', 'user_id', 'ilink_user_id') and isinstance(v, str) and v and 'uid' not in found:
1062
+ found['uid'] = v
1063
+ walk(v)
1064
+ elif isinstance(node, list):
1065
+ for it in node:
1066
+ walk(it)
1067
+ walk(data)
1068
+ return found.get('ctx'), found.get('uid')
1069
+
1070
+
1071
+ def wechat_qr_png(url):
1072
+ """用本机 Python 生成二维码 PNG(面板运行在系统 Python 上,可能没有 qrcode 模块)。"""
1073
+ for py in ('/usr/local/bin/python3', sys.executable, '/usr/bin/python3'):
1074
+ try:
1075
+ r = subprocess.run([py, '-c', 'import qrcode,sys;qrcode.make(sys.argv[1]).save(sys.argv[2])', url, WECHAT_QR],
1076
+ capture_output=True, timeout=20)
1077
+ if r.returncode == 0 and os.path.exists(WECHAT_QR):
1078
+ return True
1079
+ except Exception:
1080
+ continue
1081
+ return False
1082
+
1083
+
1084
+ def wechat_start_login():
1085
+ r = _ilink_request('/bot/get_bot_qrcode', method='GET', params={'bot_type': 3})
1086
+ ticket = r.get('qrcode') or r.get('qrcode_id')
1087
+ url = r.get('qrcode_img_content') or r.get('qrcode_url') or r.get('url')
1088
+ st = wechat_state()
1089
+ st.update({'qr_ticket': ticket, 'qr_url': url, 'qr_at': time.time(),
1090
+ 'bot_token': None, 'context_token': None, 'last_error': None})
1091
+ save_wechat_state(st)
1092
+ if url:
1093
+ wechat_qr_png(url)
1094
+ return st
1095
+
1096
+
1097
+ def wechat_check_login():
1098
+ st = wechat_state()
1099
+ ticket = st.get('qr_ticket')
1100
+ if not ticket:
1101
+ return {'status': 'none'}
1102
+ try:
1103
+ r = _ilink_request('/bot/get_qrcode_status', method='GET', params={'qrcode': ticket}, timeout=15)
1104
+ except Exception as e:
1105
+ st['last_error'] = f'扫码状态查询失败: {e}'
1106
+ save_wechat_state(st)
1107
+ return {'status': 'error', 'error': str(e)}
1108
+ status = str(r.get('status') or '')
1109
+ if status == 'confirmed':
1110
+ token = r.get('bot_token')
1111
+ uid = r.get('ilink_user_id')
1112
+ st.update({'bot_token': token, 'user_id': uid, 'authorized_at': time.time(),
1113
+ 'qr_ticket': None, 'qr_url': None, 'last_error': None})
1114
+ save_wechat_state(st)
1115
+ log('[wechat] 扫码授权成功')
1116
+ return {'status': 'confirmed'}
1117
+ if status == 'expired':
1118
+ wechat_start_login()
1119
+ return {'status': 'expired'}
1120
+ return {'status': status or 'wait'}
1121
+
1122
+
1123
+ def wechat_poll_once(timeout=60):
1124
+ st = wechat_state()
1125
+ token = st.get('bot_token')
1126
+ if not token:
1127
+ return
1128
+ body = {'timeout': timeout, 'limit': 100,
1129
+ 'get_updates_buf': st.get('updates_buf') or '',
1130
+ 'base_info': {'channel_version': '1.0.0'}}
1131
+ try:
1132
+ r = _ilink_request('/bot/getupdates', body, token=token, timeout=timeout + 10)
1133
+ except Exception:
1134
+ return
1135
+ ctx, uid = _extract_ctx(r)
1136
+ if r.get('get_updates_buf'):
1137
+ st['updates_buf'] = r.get('get_updates_buf')
1138
+ if ctx:
1139
+ st['context_token'] = ctx
1140
+ log('[wechat] 捕获 context_token,可主动推送')
1141
+ if uid:
1142
+ st['user_id'] = uid
1143
+ save_wechat_state(st)
1144
+
1145
+
1146
+ def wechat_send_text(text):
1147
+ st = wechat_state()
1148
+ token = st.get('bot_token')
1149
+ ctx = st.get('context_token')
1150
+ uid = st.get('user_id')
1151
+ if not token:
1152
+ return 2, '微信未登录:请在面板扫码'
1153
+ if not ctx:
1154
+ return 2, '缺少 context_token:请在微信里给 bot 发一条消息'
1155
+ body = {'msg': {'from_user_id': '', 'to_user_id': uid or '',
1156
+ 'client_id': f'deck:{int(time.time())}:{secrets.randbelow(999999)}',
1157
+ 'message_type': 2, 'message_state': 2, 'context_token': ctx,
1158
+ 'item_list': [{'type': 1, 'text_item': {'text': text}}]},
1159
+ 'base_info': {'channel_version': '1.0.0'}}
1160
+ try:
1161
+ r = _ilink_request('/bot/sendmessage', body, token=token, timeout=25)
1162
+ except Exception as e:
1163
+ return 3, f'微信发送失败: {e}'
1164
+ if r.get('message_id'):
1165
+ return 0, '已送达(微信)'
1166
+ if r.get('ret', 0) == -2:
1167
+ return 2, 'context_token 失效:请在微信里给 bot 发一条消息'
1168
+ return 2, f'未被投递: {str(r)[:120]}'
1169
+
1170
+
1171
+ def wechat_status():
1172
+ st = wechat_state()
1173
+ logged = bool(st.get('bot_token'))
1174
+ has_ctx = bool(st.get('context_token'))
1175
+ return {'logged': logged, 'hasContext': has_ctx,
1176
+ 'qrUrl': st.get('qr_url') if not logged else None,
1177
+ 'qrAt': st.get('qr_at') if not logged else None,
1178
+ 'authorizedAt': st.get('authorized_at'),
1179
+ 'lastError': st.get('last_error'),
1180
+ 'detail': ('微信已连接,可主动推送' if logged and has_ctx else
1181
+ ('微信已扫码,但还没收到你的消息(去给 bot 发一句)' if logged else
1182
+ ('等待扫码' if st.get('qr_url') else '未连接:点「连接微信」生成二维码')))}
1183
+
1184
+
1185
+ def delivery_cfg(cfg=None):
1186
+ cfg = cfg or load_json(CONFIG, {})
1187
+ d = (cfg.get('report') or {}).get('delivery') or {}
1188
+ return {
1189
+ 'type': (d.get('type') or 'wxpush').lower(),
1190
+ 'url': (d.get('url') or 'http://127.0.0.1:9098').rstrip('/'),
1191
+ 'key': d.get('key') or '',
1192
+ 'webhook': d.get('webhook') or '',
1193
+ 'command': d.get('command') or '',
1194
+ }
1195
+
1196
+
1197
+ def _http_json(url, payload, timeout=20, headers=None):
1198
+ body = json.dumps(payload, ensure_ascii=False).encode()
1199
+ req = urllib.request.Request(url, data=body,
1200
+ headers={'Content-Type': 'application/json', **(headers or {})})
1201
+ with urllib.request.urlopen(req, timeout=timeout) as r:
1202
+ return r.status, json.loads(r.read().decode() or '{}')
1203
+
1204
+
1205
+ def delivery_send(text):
1206
+ """按 report.delivery.type 发送一条文本。返回 (0|2|3, 说明)。"""
1207
+ d = delivery_cfg()
1208
+ t = d['type']
1209
+ try:
1210
+ if t == 'ilink':
1211
+ return wechat_send_text(text)
1212
+ if t == 'wxpush':
1213
+ if not d['url']:
1214
+ return 3, 'wxpush url 未配置'
1215
+ code, body = _http_json(d['url'] + '/send', {'message': text})
1216
+ if code == 200:
1217
+ return 0, '已送达(wxpush)'
1218
+ if code == 202:
1219
+ return 2, '已入队(wxpush 未接通)'
1220
+ return 3, f'wxpush HTTP {code}'
1221
+ if t == 'serverchan':
1222
+ if not d['key']:
1223
+ return 2, 'ServerChan key 未配置'
1224
+ url = f"https://sctapi.ftqq.com/{d['key']}.send"
1225
+ import urllib.parse
1226
+ data = urllib.parse.urlencode({'title': '项目播报', 'desp': text}).encode()
1227
+ req = urllib.request.Request(url, data=data)
1228
+ with urllib.request.urlopen(req, timeout=20) as r:
1229
+ j = json.loads(r.read().decode() or '{}')
1230
+ if j.get('code') in (0, '0'):
1231
+ return 0, '已送达(ServerChan)'
1232
+ return 3, f"ServerChan 返回 {str(j)[:120]}"
1233
+ if t == 'webhook':
1234
+ if not d['webhook']:
1235
+ return 2, 'webhook 未配置'
1236
+ code, body = _http_json(d['webhook'], {
1237
+ 'msgtype': 'text', 'text': {'content': text}, 'message': text})
1238
+ return (0, '已送达(webhook)') if code in (200, 204) else (3, f'webhook HTTP {code}')
1239
+ if t == 'command':
1240
+ if not d['command']:
1241
+ return 2, 'command 未配置'
1242
+ r = subprocess.run(d['command'], shell=True, input=text, capture_output=True, text=True, timeout=60)
1243
+ if r.returncode == 0:
1244
+ return 0, '已送达(command)'
1245
+ return 3, f"command 退出码 {r.returncode}: {(r.stderr or r.stdout)[:120]}"
1246
+ return 3, f'未知 delivery.type: {t}'
1247
+ except Exception as e:
1248
+ return 3, f'{t} 发送失败: {e}'
1249
+
1250
+
1251
+ def delivery_status():
1252
+ """给面板/status 用:返回 {ok, stale, detail, pending}。"""
1253
+ d = delivery_cfg()
1254
+ out = {'type': d['type'], 'ok': False, 'detail': '', 'pending': 0}
1255
+ try:
1256
+ if d['type'] == 'ilink':
1257
+ w = wechat_status()
1258
+ out['ok'] = bool(w['logged'] and w['hasContext'])
1259
+ out['detail'] = w['detail']
1260
+ return out
1261
+ if d['type'] == 'wxpush':
1262
+ with urllib.request.urlopen(d['url'] + '/health', timeout=5) as r:
1263
+ j = json.loads(r.read().decode() or '{}')
1264
+ out['ok'] = bool(j.get('ready') or j.get('authorized'))
1265
+ out['pending'] = int((j.get('outbox') or {}).get('pending') or 0)
1266
+ out['detail'] = 'wxpush 已接通' if out['ok'] else ('wxpush 未接通:需要扫码授权或给 bot 发消息刷新')
1267
+ if j.get('token_dead'):
1268
+ out['detail'] = 'wxpush 登录态已失效:需要重新扫码'
1269
+ elif d['type'] == 'serverchan':
1270
+ out['ok'] = bool(d['key'])
1271
+ out['detail'] = 'ServerChan 已配置' if out['ok'] else 'ServerChan key 未配置'
1272
+ elif d['type'] == 'webhook':
1273
+ out['ok'] = bool(d['webhook'])
1274
+ out['detail'] = 'webhook 已配置' if out['ok'] else 'webhook 未配置'
1275
+ elif d['type'] == 'command':
1276
+ out['ok'] = bool(d['command'])
1277
+ out['detail'] = f"command: {d['command']}" if out['ok'] else 'command 未配置'
1278
+ except Exception as e:
1279
+ out['detail'] = f'{d["type"]} 不可达: {e}'
1280
+ return out
1281
+
1282
+
1020
1283
  def channel_state():
1021
1284
  return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
1022
1285
 
@@ -1054,15 +1317,12 @@ def mark_channel(ok, err=''):
1054
1317
 
1055
1318
 
1056
1319
  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:
1320
+ code, msg = delivery_send(text)
1321
+ if code == 0:
1062
1322
  mark_channel(True)
1063
- elif r.returncode == 2:
1064
- mark_channel(False, msg.splitlines()[0] if msg else 'queued')
1065
- return r.returncode, msg
1323
+ elif code == 2:
1324
+ mark_channel(False, msg[:120])
1325
+ return code, msg
1066
1326
 
1067
1327
 
1068
1328
  def refresh_local_node(cfg=None):
@@ -1132,6 +1392,8 @@ def do_report_with_image(cfg, quiet=False, force=None):
1132
1392
  """播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
1133
1393
  code, text, msg = do_report(cfg, quiet)
1134
1394
  want = cfg.get('report', {}).get('image') if force is None else force
1395
+ if delivery_cfg(cfg)['type'] not in ('wxpush', 'ilink'):
1396
+ want = False
1135
1397
  if code == 0 and want:
1136
1398
  try:
1137
1399
  png = render_image(cfg)
@@ -1156,6 +1418,9 @@ GRAPHICS = os.path.join(BASE, 'graphics')
1156
1418
 
1157
1419
 
1158
1420
  def _wx_state():
1421
+ if delivery_cfg()['type'] == 'ilink':
1422
+ st = wechat_state()
1423
+ return st.get('bot_token'), st.get('context_token'), st.get('user_id'), ILINK_BASE
1159
1424
  st = load_json(WXPUSH_STATE, {})
1160
1425
  return st.get('bot_token'), st.get('context_token'), st.get('ilink_user_id'), \
1161
1426
  (st.get('api_base') or 'https://ilinkai.weixin.qq.com/ilink')
@@ -1203,6 +1468,8 @@ def flush_pending_image():
1203
1468
  p = load_json(PENDING_IMG, None)
1204
1469
  if not p:
1205
1470
  return False
1471
+ if delivery_cfg()['type'] not in ('wxpush', 'ilink'):
1472
+ return False
1206
1473
  if channel_stale():
1207
1474
  return False
1208
1475
  # 失败后 2 分钟内不重复尝试,避免空转
@@ -1597,7 +1864,8 @@ def cmd_status():
1597
1864
  nxt += 1800
1598
1865
  print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
1599
1866
  wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
1600
- print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
1867
+ st = delivery_status()
1868
+ print(f"通知渠道: {st['type']} · " + ('✅ ' if st['ok'] else '⚠️ ') + st['detail'])
1601
1869
  return 0 if pid else 1
1602
1870
 
1603
1871
 
@@ -1646,6 +1914,25 @@ def cmd_daemon():
1646
1914
  time.sleep(30)
1647
1915
 
1648
1916
 
1917
+ def cmd_wechat(argv):
1918
+ action = argv[0] if argv else 'status'
1919
+ if action == 'login':
1920
+ st = wechat_start_login()
1921
+ print('二维码链接:', st.get('qr_url'))
1922
+ print('二维码图片:', WECHAT_QR if os.path.exists(WECHAT_QR) else '(生成失败,可用链接自行转二维码)')
1923
+ print('扫码后运行: deck wechat status')
1924
+ return 0
1925
+ if action == 'logout':
1926
+ save_wechat_state({})
1927
+ print('已清除微信登录态')
1928
+ return 0
1929
+ w = wechat_status()
1930
+ print(f"微信: {w['detail']}")
1931
+ if w.get('qrUrl'):
1932
+ print('二维码:', w['qrUrl'])
1933
+ return 0
1934
+
1935
+
1649
1936
  def cmd_demo(argv):
1650
1937
  """deck demo:生成一个演示环境(假项目 + 独立面板端口),不碰真实注册表。"""
1651
1938
  import shutil
@@ -1744,6 +2031,8 @@ def main():
1744
2031
  return cmd_approvals(rest)
1745
2032
  if cmd == 'decisions':
1746
2033
  return cmd_decisions(rest)
2034
+ if cmd == 'wechat':
2035
+ return cmd_wechat(rest)
1747
2036
  if cmd == 'demo':
1748
2037
  return cmd_demo(rest)
1749
2038
  if cmd == 'panel':
@@ -184,6 +184,31 @@
184
184
  <div class="hint" id="imgHint" style="margin-top:6px"></div>
185
185
  </div>
186
186
 
187
+ <div class="controls" id="wxBox" style="padding:10px 12px">
188
+ <span class="pill" style="margin:0"><i class="dot" id="wxDot"></i><b id="wxTitle">通知渠道</b></span>
189
+ <select id="wxType" onchange="onWxTypeChange()" style="font-size:12px;padding:5px 8px">
190
+ <option value="ilink">内置微信(扫码)</option>
191
+ <option value="wxpush">wxpush 网关</option>
192
+ <option value="serverchan">ServerChan</option>
193
+ <option value="webhook">Webhook</option>
194
+ <option value="command">自定义命令</option>
195
+ </select>
196
+ <input id="wxField" placeholder="" style="flex:1;min-width:160px;display:none">
197
+ <button class="ghost" id="wxSaveBtn" onclick="saveWx()" style="display:none">保存</button>
198
+ <span class="hint" id="wxDetail"></span>
199
+ <button class="ghost" id="wxLoginBtn" onclick="wxLogin()" style="display:none">连接微信</button>
200
+ <button class="ghost" id="wxLogoutBtn" onclick="wxLogout()" style="display:none">断开</button>
201
+ </div>
202
+ <div id="wxQrWrap" style="display:none;margin:-6px 0 18px;display:none">
203
+ <div style="display:flex;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px">
204
+ <img id="wxQr" style="width:150px;height:150px;border-radius:8px;background:#fff;padding:6px">
205
+ <div>
206
+ <div style="color:#e6edf3;font-size:13px;margin-bottom:6px">用微信扫码连接</div>
207
+ <div class="hint">扫码确认后,<b>还要在微信里给这个 bot 发任意一句话</b>(iLink 需要你发一次消息才能主动推送)。二维码约 2 分钟过期,会自动刷新。</div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
187
212
  <div class="controls" style="padding:9px 12px">
188
213
  <input id="q" placeholder="搜索项目(名称 / id / 路径 / 说明)" style="flex:1;min-width:180px" oninput="render()">
189
214
  <select id="fStatus" onchange="render()">
@@ -581,6 +606,67 @@ function sendToPlugin(payload){
581
606
  };
582
607
  }
583
608
 
609
+ let WX = null, wxTimer = null;
610
+
611
+ async function refreshWechat(){
612
+ try{
613
+ const r = await fetch('/api/wechat'); const j = await r.json();
614
+ WX = j.wechat || {};
615
+ const dot = $('wxDot'); const title = $('wxTitle');
616
+ if (dot) dot.className = 'dot ' + (WX.ok ? 'ok' : 'bad');
617
+ if (title) title.textContent = WX.mode === 'ilink' ? '微信' : (WX.mode || '通知');
618
+ $('wxDetail').textContent = WX.detail || '';
619
+ const curType = (S && S.config && S.config.delivery && S.config.delivery.type) || WX.mode || 'ilink';
620
+ if ($('wxType') && $('wxType').value !== curType) { $('wxType').value = curType; onWxTypeChange(); }
621
+ const needQr = WX.mode === 'ilink' && !WX.logged && WX.qrUrl;
622
+ $('wxQrWrap').style.display = needQr ? 'block' : 'none';
623
+ if (needQr) $('wxQr').src = '/api/wechat/qr.png?t=' + Date.now();
624
+ $('wxLoginBtn').style.display = (WX.mode === 'ilink' && !WX.logged) ? '' : 'none';
625
+ $('wxLogoutBtn').style.display = (WX.mode === 'ilink' && WX.logged) ? '' : 'none';
626
+ if (WX.mode === 'ilink' && !WX.ok) {
627
+ if (!wxTimer) wxTimer = setInterval(refreshWechat, 2500);
628
+ } else if (wxTimer) { clearInterval(wxTimer); wxTimer = null; }
629
+ if (S && S.wechat) { S.wechat.ok = WX.ok; render(); }
630
+ }catch(e){ /* 忽略 */ }
631
+ }
632
+
633
+ async function wxLogin(){ try{ await api('/api/wechat/login', {}); refreshWechat(); }catch(e){ toast('生成二维码失败:' + e.message, true); } }
634
+ async function wxLogout(){ try{ await api('/api/wechat/logout', {}); refreshWechat(); toast('已断开微信'); }catch(e){ toast('断开失败:' + e.message, true); } }
635
+
636
+ const WX_FIELD = {
637
+ serverchan: ['key', 'ServerChan SendKey(SCT...)'],
638
+ webhook: ['webhook', 'webhook URL(企业微信机器人等)'],
639
+ command: ['command', '命令(stdin 收文本,退出码 0=成功)'],
640
+ wxpush: ['url', 'wxpush 地址,默认 http://127.0.0.1:9098'],
641
+ ilink: null,
642
+ };
643
+
644
+ function onWxTypeChange(){
645
+ const type = $('wxType').value;
646
+ const f = WX_FIELD[type];
647
+ $('wxField').style.display = f ? '' : 'none';
648
+ $('wxSaveBtn').style.display = f ? '' : 'none';
649
+ if (f) {
650
+ const cur = (S && S.config && S.config.delivery) || {};
651
+ $('wxField').placeholder = f[1];
652
+ $('wxField').value = cur[f[0]] || '';
653
+ }
654
+ const isIlink = type === 'ilink';
655
+ $('wxLoginBtn').style.display = isIlink ? '' : 'none';
656
+ $('wxLogoutBtn').style.display = 'none';
657
+ }
658
+
659
+ async function saveWx(){
660
+ const type = $('wxType').value;
661
+ const f = WX_FIELD[type];
662
+ if (!f) return;
663
+ const delivery = { type, [f[0]]: $('wxField').value.trim() };
664
+ try{
665
+ const r = await api('/api/config', { delivery });
666
+ S = r.state; render(); refreshWechat(); toast('通知渠道已更新');
667
+ }catch(e){ toast('保存失败:' + e.message, true); }
668
+ }
669
+
584
670
  function setFabPos(pos){
585
671
  if (!inPluginFrame()) { toast('请在 DSH 内嵌面板里使用(浏览器打开时无效)', true); return; }
586
672
  window.parent.postMessage({ type: 'deck:set-fab-pos', pos }, '*');
package/backend/panel.py CHANGED
@@ -47,13 +47,31 @@ P = load_deck()
47
47
 
48
48
 
49
49
  # ── 数据汇总 ────────────────────────────────────────────────
50
+ def wechat_view():
51
+ d = P.delivery_cfg()
52
+ if d['type'] == 'ilink':
53
+ P.wechat_check_login() if P.wechat_state().get('qr_ticket') else None
54
+ return {'mode': 'ilink', **P.wechat_status()}
55
+ st = P.delivery_status()
56
+ return {'mode': d['type'], **st}
57
+
58
+
59
+ def wechat_poller():
60
+ while True:
61
+ try:
62
+ if P.delivery_cfg()['type'] == 'ilink' and P.wechat_state().get('bot_token'):
63
+ P.wechat_poll_once(50)
64
+ else:
65
+ time.sleep(4)
66
+ except Exception:
67
+ time.sleep(5)
68
+
69
+
50
70
  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}
71
+ st = P.delivery_status()
72
+ return {'ok': bool(st.get('ok')), 'raw': st.get('detail', ''),
73
+ 'code': 0 if st.get('ok') else 2, 'type': st.get('type'),
74
+ 'pending': st.get('pending', 0)}
57
75
 
58
76
 
59
77
  def project_view(p):
@@ -104,6 +122,7 @@ def snapshot():
104
122
  'format': rep.get('format', 'markdown'),
105
123
  'image': bool(rep.get('image', False)),
106
124
  'web': cfg.get('web', {}).get('baseUrl', 'http://127.0.0.1:43120'),
125
+ 'delivery': P.delivery_cfg(cfg),
107
126
  'scan': cfg.get('scan', {})},
108
127
  'daemon': {'running': bool(P.daemon_status()), 'pid': P.daemon_status() or 0,
109
128
  'lastRun': last, 'nextRun': (last + interval * 3600) if last else 0},
@@ -568,6 +587,11 @@ def set_config(payload):
568
587
  rep['maxChars'] = int(payload['maxChars'])
569
588
  if 'image' in payload:
570
589
  rep['image'] = bool(payload['image'])
590
+ if 'delivery' in payload and isinstance(payload['delivery'], dict):
591
+ dl = rep.setdefault('delivery', {})
592
+ for k in ('type', 'url', 'key', 'webhook', 'command'):
593
+ if k in payload['delivery']:
594
+ dl[k] = payload['delivery'][k]
571
595
  if 'scan' in payload and isinstance(payload['scan'], dict):
572
596
  sc = cfg.setdefault('scan', {})
573
597
  for k in ('enabled', 'autoAdd'):
@@ -787,6 +811,33 @@ class Handler(http.server.BaseHTTPRequestHandler):
787
811
  return self._json({'ok': False, 'error': 'forbidden'}, 403)
788
812
  if path in ('/', '/index.html'):
789
813
  return self._html(HTML)
814
+ if path == '/api/wechat':
815
+ return self._json({'ok': True, 'wechat': wechat_view()})
816
+ if path == '/api/wechat/qr.png':
817
+ fp = P.WECHAT_QR
818
+ if not os.path.isfile(fp):
819
+ return self._json({'ok': False, 'error': '二维码尚未生成'}, 404)
820
+ body = open(fp, 'rb').read()
821
+ self.send_response(200)
822
+ self.send_header('Content-Type', 'image/png')
823
+ self.send_header('Cache-Control', 'no-store')
824
+ self.send_header('Content-Length', str(len(body)))
825
+ self.end_headers()
826
+ self.wfile.write(body)
827
+ return
828
+ if path == '/icons':
829
+ fp = os.path.join(BASE, 'icons.html')
830
+ if not os.path.isfile(fp):
831
+ return self._json({'ok': False, 'error': 'icons.html 不存在'}, 404)
832
+ body = open(fp, 'rb').read()
833
+ self.send_response(200)
834
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
835
+ self.send_header('Content-Length', str(len(body)))
836
+ self.end_headers()
837
+ self.wfile.write(body)
838
+ return
839
+ if path in ('/api/node/scanner.py', '/api/node/scanner.js', '/api/node/scanner.ps1'):
840
+ pass
790
841
  if path == '/api/state':
791
842
  try:
792
843
  return self._json(snapshot())
@@ -893,6 +944,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
893
944
  return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
894
945
 
895
946
  try:
947
+ if path == '/api/wechat/login':
948
+ P.wechat_start_login()
949
+ return self._json({'ok': True, 'wechat': wechat_view()})
950
+ if path == '/api/wechat/logout':
951
+ P.save_wechat_state({})
952
+ return self._json({'ok': True, 'wechat': wechat_view()})
896
953
  if path == '/api/autonomy':
897
954
  return self._json({'ok': True, 'state': set_autonomy(payload.get('project'), payload.get('mode'), payload.get('maxPerDay'))})
898
955
  if path == '/api/approval':
@@ -941,6 +998,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
941
998
  def main():
942
999
  import threading
943
1000
  threading.Thread(target=lambda: session_index(), daemon=True).start() # 预热会话索引
1001
+ threading.Thread(target=wechat_poller, daemon=True).start() # 微信内置通道轮询
944
1002
  httpd = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
945
1003
  P.log(f'[panel] listening http://{HOST}:{PORT}')
946
1004
  try:
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.4",
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",