dsh-project-deck 0.1.3 → 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,9 +67,21 @@ deck demo # 演示环境
67
67
  无变化:官网改版、基础设施、…
68
68
  ```
69
69
 
70
- ## 通知渠道(可选,默认 wxpush)
70
+ ## 通知渠道(可选)
71
71
 
72
- 播报通过 `report.delivery` 选择发送器,**不带任何内置账号**:
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
73
85
 
74
86
  ```jsonc
75
87
  "report": {
@@ -77,7 +89,7 @@ deck demo # 演示环境
77
89
  "image": false, // 附图仅 wxpush 通道支持
78
90
  "delivery": {
79
91
  "type": "wxpush", // wxpush | serverchan | webhook | command
80
- "url": "http://127.0.0.1:9098", // wxpush
92
+ "url": "http://127.0.0.1:9098", // wxpush(ilink 模式忽略)
81
93
  "key": "", // ServerChan SendKey
82
94
  "webhook": "", // 任意 webhook(企业微信机器人等)
83
95
  "command": "" // 自写脚本:stdin 收文本,退出码 0 = 成功
package/backend/deck CHANGED
@@ -1017,6 +1017,171 @@ 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
+
1020
1185
  def delivery_cfg(cfg=None):
1021
1186
  cfg = cfg or load_json(CONFIG, {})
1022
1187
  d = (cfg.get('report') or {}).get('delivery') or {}
@@ -1042,6 +1207,8 @@ def delivery_send(text):
1042
1207
  d = delivery_cfg()
1043
1208
  t = d['type']
1044
1209
  try:
1210
+ if t == 'ilink':
1211
+ return wechat_send_text(text)
1045
1212
  if t == 'wxpush':
1046
1213
  if not d['url']:
1047
1214
  return 3, 'wxpush url 未配置'
@@ -1086,6 +1253,11 @@ def delivery_status():
1086
1253
  d = delivery_cfg()
1087
1254
  out = {'type': d['type'], 'ok': False, 'detail': '', 'pending': 0}
1088
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
1089
1261
  if d['type'] == 'wxpush':
1090
1262
  with urllib.request.urlopen(d['url'] + '/health', timeout=5) as r:
1091
1263
  j = json.loads(r.read().decode() or '{}')
@@ -1220,7 +1392,7 @@ def do_report_with_image(cfg, quiet=False, force=None):
1220
1392
  """播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
1221
1393
  code, text, msg = do_report(cfg, quiet)
1222
1394
  want = cfg.get('report', {}).get('image') if force is None else force
1223
- if delivery_cfg(cfg)['type'] != 'wxpush':
1395
+ if delivery_cfg(cfg)['type'] not in ('wxpush', 'ilink'):
1224
1396
  want = False
1225
1397
  if code == 0 and want:
1226
1398
  try:
@@ -1246,6 +1418,9 @@ GRAPHICS = os.path.join(BASE, 'graphics')
1246
1418
 
1247
1419
 
1248
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
1249
1424
  st = load_json(WXPUSH_STATE, {})
1250
1425
  return st.get('bot_token'), st.get('context_token'), st.get('ilink_user_id'), \
1251
1426
  (st.get('api_base') or 'https://ilinkai.weixin.qq.com/ilink')
@@ -1293,7 +1468,7 @@ def flush_pending_image():
1293
1468
  p = load_json(PENDING_IMG, None)
1294
1469
  if not p:
1295
1470
  return False
1296
- if delivery_cfg()['type'] != 'wxpush':
1471
+ if delivery_cfg()['type'] not in ('wxpush', 'ilink'):
1297
1472
  return False
1298
1473
  if channel_stale():
1299
1474
  return False
@@ -1739,6 +1914,25 @@ def cmd_daemon():
1739
1914
  time.sleep(30)
1740
1915
 
1741
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
+
1742
1936
  def cmd_demo(argv):
1743
1937
  """deck demo:生成一个演示环境(假项目 + 独立面板端口),不碰真实注册表。"""
1744
1938
  import shutil
@@ -1837,6 +2031,8 @@ def main():
1837
2031
  return cmd_approvals(rest)
1838
2032
  if cmd == 'decisions':
1839
2033
  return cmd_decisions(rest)
2034
+ if cmd == 'wechat':
2035
+ return cmd_wechat(rest)
1840
2036
  if cmd == 'demo':
1841
2037
  return cmd_demo(rest)
1842
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,6 +47,26 @@ 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
71
  st = P.delivery_status()
52
72
  return {'ok': bool(st.get('ok')), 'raw': st.get('detail', ''),
@@ -102,6 +122,7 @@ def snapshot():
102
122
  'format': rep.get('format', 'markdown'),
103
123
  'image': bool(rep.get('image', False)),
104
124
  'web': cfg.get('web', {}).get('baseUrl', 'http://127.0.0.1:43120'),
125
+ 'delivery': P.delivery_cfg(cfg),
105
126
  'scan': cfg.get('scan', {})},
106
127
  'daemon': {'running': bool(P.daemon_status()), 'pid': P.daemon_status() or 0,
107
128
  'lastRun': last, 'nextRun': (last + interval * 3600) if last else 0},
@@ -566,6 +587,11 @@ def set_config(payload):
566
587
  rep['maxChars'] = int(payload['maxChars'])
567
588
  if 'image' in payload:
568
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]
569
595
  if 'scan' in payload and isinstance(payload['scan'], dict):
570
596
  sc = cfg.setdefault('scan', {})
571
597
  for k in ('enabled', 'autoAdd'):
@@ -785,6 +811,33 @@ class Handler(http.server.BaseHTTPRequestHandler):
785
811
  return self._json({'ok': False, 'error': 'forbidden'}, 403)
786
812
  if path in ('/', '/index.html'):
787
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
788
841
  if path == '/api/state':
789
842
  try:
790
843
  return self._json(snapshot())
@@ -891,6 +944,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
891
944
  return self._json({'ok': False, 'error': '请求体不是合法 JSON'}, 400)
892
945
 
893
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()})
894
953
  if path == '/api/autonomy':
895
954
  return self._json({'ok': True, 'state': set_autonomy(payload.get('project'), payload.get('mode'), payload.get('maxPerDay'))})
896
955
  if path == '/api/approval':
@@ -939,6 +998,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
939
998
  def main():
940
999
  import threading
941
1000
  threading.Thread(target=lambda: session_index(), daemon=True).start() # 预热会话索引
1001
+ threading.Thread(target=wechat_poller, daemon=True).start() # 微信内置通道轮询
942
1002
  httpd = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
943
1003
  P.log(f'[panel] listening http://{HOST}:{PORT}')
944
1004
  try:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-project-deck",
3
- "version": "0.1.3",
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",