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 +39 -0
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +298 -9
- package/backend/panel.html +86 -0
- package/backend/panel.py +64 -6
- package/package.json +1 -1
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 | 默认 | 说明 |
|
|
Binary file
|
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
|
-
|
|
1058
|
-
|
|
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
|
|
1064
|
-
mark_channel(False, msg
|
|
1065
|
-
return
|
|
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
|
-
|
|
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':
|
package/backend/panel.html
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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",
|