weflow-cli 1.6.1 → 1.6.2
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/CHANGELOG.md +18 -0
- package/README.md +1 -0
- package/bin/weflow-cli.ts +36 -9
- package/dist/bin/weflow-cli.js +38 -9
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +4 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/chatService.d.ts +7 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +15 -5
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +15 -3
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -1
- package/dist/src/utils/pythonProcessEnv.js +16 -1
- package/dist/src/utils/pythonProcessEnv.js.map +1 -1
- package/docs/HEALTH-CHECK.md +98 -0
- package/docs/RELEASING.md +177 -124
- package/package.json +1 -1
- package/scripts/biz_daily.py +3 -1
- package/scripts/export_chat_html.py +9 -0
- package/scripts/health_check.py +336 -0
- package/scripts/nt_decrypt.py +1352 -1216
- package/scripts/watch_issues.py +212 -0
- package/scripts/watch_mail.py +131 -0
- package/src/core/ntCore.ts +4 -0
- package/src/services/chatService.ts +14 -5
- package/src/services/exportService.ts +15 -3
- package/src/utils/pythonProcessEnv.ts +18 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Watch a GitHub repo's issues and email new activity.
|
|
3
|
+
|
|
4
|
+
Meant to run from Windows Task Scheduler rather than from an editor session, so
|
|
5
|
+
notifications arrive whether or not anything else is running.
|
|
6
|
+
|
|
7
|
+
Credentials are read from outside the repository:
|
|
8
|
+
|
|
9
|
+
~/.weflow-issue-watch.json
|
|
10
|
+
{
|
|
11
|
+
"smtp_user": "1473517806@qq.com",
|
|
12
|
+
"smtp_auth": "<QQ 邮箱 SMTP 授权码,不是登录密码>",
|
|
13
|
+
"mail_to": "1473517806@qq.com",
|
|
14
|
+
"repo": "zhuobichen/weflow-cli"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Only `smtp_auth` is required; the rest default to the values above. The file is
|
|
18
|
+
never written by this script and never belongs in the repo.
|
|
19
|
+
|
|
20
|
+
First run seeds the state file without sending anything, so a fresh install does
|
|
21
|
+
not mail the entire issue history.
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import smtplib
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from email.header import Header
|
|
29
|
+
from email.mime.text import MIMEText
|
|
30
|
+
from email.utils import formataddr
|
|
31
|
+
|
|
32
|
+
CONFIG_PATH = os.path.join(os.path.expanduser('~'), '.weflow-issue-watch.json')
|
|
33
|
+
STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-issue-watch-state.json')
|
|
34
|
+
|
|
35
|
+
DEFAULTS = {
|
|
36
|
+
'repo': 'zhuobichen/weflow-cli',
|
|
37
|
+
'smtp_host': 'smtp.qq.com',
|
|
38
|
+
'smtp_port': 465,
|
|
39
|
+
'smtp_user': '1473517806@qq.com',
|
|
40
|
+
'mail_to': '1473517806@qq.com',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config():
|
|
45
|
+
config = dict(DEFAULTS)
|
|
46
|
+
try:
|
|
47
|
+
with open(CONFIG_PATH, encoding='utf-8') as handle:
|
|
48
|
+
config.update(json.load(handle))
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
pass
|
|
51
|
+
if os.environ.get('WEFLOW_SMTP_AUTH'):
|
|
52
|
+
config['smtp_auth'] = os.environ['WEFLOW_SMTP_AUTH']
|
|
53
|
+
return config
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Task Scheduler starts with a thinner PATH than an interactive shell, so `gh`
|
|
57
|
+
# is not always resolvable by name there.
|
|
58
|
+
GH_CANDIDATES = ['gh', r'C:\Program Files\GitHub CLI\gh.exe']
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _runnable(candidate):
|
|
62
|
+
if os.sep in candidate:
|
|
63
|
+
return os.path.isfile(candidate)
|
|
64
|
+
from shutil import which
|
|
65
|
+
return which(candidate) is not None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def gh(args):
|
|
69
|
+
"""Run `gh` and parse its JSON output, or None when it fails."""
|
|
70
|
+
executable = next((c for c in GH_CANDIDATES if _runnable(c)), GH_CANDIDATES[0])
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
[executable, *args], capture_output=True, timeout=60, encoding='utf-8')
|
|
74
|
+
except (OSError, subprocess.SubprocessError):
|
|
75
|
+
return None
|
|
76
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(result.stdout)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_activity(repo, since_issue=0):
|
|
85
|
+
"""(issues, comments) newer than what has already been reported.
|
|
86
|
+
|
|
87
|
+
Comments are fetched per issue rather than from the repo-wide endpoint so an
|
|
88
|
+
issue that is merely edited does not look like new activity.
|
|
89
|
+
"""
|
|
90
|
+
issues = gh(['api', f'repos/{repo}/issues?state=all&sort=created&direction=desc&per_page=30'])
|
|
91
|
+
if issues is None:
|
|
92
|
+
return None, None
|
|
93
|
+
|
|
94
|
+
new_issues = [i for i in issues if not i.get('pull_request') and i['number'] > since_issue]
|
|
95
|
+
comments = []
|
|
96
|
+
# Only the busiest recent issues can have new replies; 30 is plenty.
|
|
97
|
+
for issue in issues:
|
|
98
|
+
if issue.get('pull_request'):
|
|
99
|
+
continue
|
|
100
|
+
rows = gh(['api', f'repos/{repo}/issues/{issue["number"]}/comments?per_page=100'])
|
|
101
|
+
if not rows:
|
|
102
|
+
continue
|
|
103
|
+
for row in rows:
|
|
104
|
+
if row['user']['login'] != 'zhuobichen':
|
|
105
|
+
comments.append({
|
|
106
|
+
'issue': issue['number'],
|
|
107
|
+
'title': issue['title'],
|
|
108
|
+
'id': row['id'],
|
|
109
|
+
'author': row['user']['login'],
|
|
110
|
+
'body': row['body'],
|
|
111
|
+
})
|
|
112
|
+
return new_issues, comments
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_state():
|
|
116
|
+
try:
|
|
117
|
+
with open(STATE_PATH, encoding='utf-8') as handle:
|
|
118
|
+
return json.load(handle)
|
|
119
|
+
except (OSError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_state(state):
|
|
124
|
+
try:
|
|
125
|
+
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
126
|
+
json.dump(state, handle, ensure_ascii=False, indent=2)
|
|
127
|
+
except OSError:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def send_mail(config, subject, body):
|
|
132
|
+
message = MIMEText(body, 'plain', 'utf-8')
|
|
133
|
+
message['Subject'] = Header(subject, 'utf-8')
|
|
134
|
+
message['From'] = formataddr(('WeFlow Issue Watch', config['smtp_user']))
|
|
135
|
+
message['To'] = config['mail_to']
|
|
136
|
+
with smtplib.SMTP_SSL(config['smtp_host'], config['smtp_port'], timeout=30) as server:
|
|
137
|
+
server.login(config['smtp_user'], config['smtp_auth'])
|
|
138
|
+
server.sendmail(config['smtp_user'], [config['mail_to']], message.as_string())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
config = load_config()
|
|
143
|
+
if not config.get('smtp_auth'):
|
|
144
|
+
print(f'未配置邮件授权码,跳过。请创建 {CONFIG_PATH},填入 smtp_auth。')
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
repo = config['repo']
|
|
148
|
+
state = load_state()
|
|
149
|
+
issues = gh(['api', f'repos/{repo}/issues?state=all&sort=created&direction=desc&per_page=30'])
|
|
150
|
+
if issues is None:
|
|
151
|
+
print('无法访问 GitHub(gh 未登录或网络不通),本次跳过。')
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# Seed on first run: remember what exists, send nothing.
|
|
155
|
+
if state is None:
|
|
156
|
+
highest = max([i['number'] for i in issues if not i.get('pull_request')] or [0])
|
|
157
|
+
seen_comments = []
|
|
158
|
+
for issue in issues:
|
|
159
|
+
if issue.get('pull_request'):
|
|
160
|
+
continue
|
|
161
|
+
rows = gh(['api', f'repos/{repo}/issues/{issue["number"]}/comments?per_page=100']) or []
|
|
162
|
+
seen_comments.extend(r['id'] for r in rows if r['user']['login'] != 'zhuobichen')
|
|
163
|
+
save_state({'last_issue': highest, 'seen_comments': seen_comments})
|
|
164
|
+
print(f'首次运行,已记录基线(最新 issue #{highest},{len(seen_comments)} 条历史回复),不发送邮件。')
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
new_issues, comments = fetch_activity(repo, state.get('last_issue', 0))
|
|
168
|
+
if new_issues is None:
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
seen = set(state.get('seen_comments', []))
|
|
172
|
+
fresh = [c for c in comments if c['id'] not in seen]
|
|
173
|
+
|
|
174
|
+
if not new_issues and not fresh:
|
|
175
|
+
print('无新动态。')
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
lines = []
|
|
179
|
+
for issue in new_issues:
|
|
180
|
+
lines.append(f'【新 issue】#{issue["number"]} {issue["title"]}')
|
|
181
|
+
lines.append(f' 来自: {issue["user"]["login"]}')
|
|
182
|
+
lines.append(f' {issue["html_url"]}')
|
|
183
|
+
lines.append('')
|
|
184
|
+
for comment in fresh:
|
|
185
|
+
lines.append(f'【新回复】#{comment["issue"]} {comment["title"]}')
|
|
186
|
+
lines.append(f' 来自: {comment["author"]}')
|
|
187
|
+
lines.append(' ' + comment['body'][:600].replace('\n', '\n '))
|
|
188
|
+
lines.append(f' https://github.com/{repo}/issues/{comment["issue"]}')
|
|
189
|
+
lines.append('')
|
|
190
|
+
|
|
191
|
+
body = '\n'.join(lines)
|
|
192
|
+
summary = []
|
|
193
|
+
if new_issues:
|
|
194
|
+
summary.append(f'{len(new_issues)} 个新 issue')
|
|
195
|
+
if fresh:
|
|
196
|
+
summary.append(f'{len(fresh)} 条新回复')
|
|
197
|
+
try:
|
|
198
|
+
send_mail(config, f'[WeFlow] {" / ".join(summary)}', body)
|
|
199
|
+
except Exception as error:
|
|
200
|
+
# Leave the state untouched so the next run retries instead of
|
|
201
|
+
# silently swallowing the notification.
|
|
202
|
+
print(f'发送失败,本次不记录状态,下次重试: {type(error).__name__}: {error}')
|
|
203
|
+
return 1
|
|
204
|
+
|
|
205
|
+
highest = max([i['number'] for i in issues if not i.get('pull_request')] + [state.get('last_issue', 0)])
|
|
206
|
+
save_state({'last_issue': highest, 'seen_comments': list(seen | {c['id'] for c in fresh})})
|
|
207
|
+
print(f'已发送通知:{" / ".join(summary)}')
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == '__main__':
|
|
212
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Queue instructions the user sends to the agent mailbox.
|
|
3
|
+
|
|
4
|
+
A pure script - no model, no tokens. It runs from Windows Task Scheduler and
|
|
5
|
+
its only job is to notice new mail from an allowlisted sender and append it to
|
|
6
|
+
a local queue file. Acting on those instructions happens later, from a normal
|
|
7
|
+
session, which is when a model is running anyway.
|
|
8
|
+
|
|
9
|
+
That split matters: waking a model every few minutes just to discover there is
|
|
10
|
+
no mail costs tokens for nothing. This script does the waiting for free.
|
|
11
|
+
|
|
12
|
+
~/.weflow-mail-queue.jsonl one JSON object per pending instruction
|
|
13
|
+
~/.weflow-mail-state.json message ids already queued
|
|
14
|
+
|
|
15
|
+
Only mail whose From address is exactly the allowlisted one is ever read or
|
|
16
|
+
queued. Mail from anyone else is left untouched - not read, not marked, not
|
|
17
|
+
queued.
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
STATE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-state.json')
|
|
25
|
+
QUEUE_PATH = os.path.join(os.path.expanduser('~'), '.weflow-mail-queue.jsonl')
|
|
26
|
+
|
|
27
|
+
# Exact address, not a display name: a sender name can say anything.
|
|
28
|
+
ALLOWED_SENDERS = {'1473517806@qq.com'}
|
|
29
|
+
|
|
30
|
+
AGENTLY_CANDIDATES = [
|
|
31
|
+
# The npm shim is a .cmd on Windows; subprocess needs the extension spelled
|
|
32
|
+
# out, since a bare `agently-cli` is not an executable image.
|
|
33
|
+
r'C:\Users\Administrator\AppData\Roaming\npm\agently-cli.cmd',
|
|
34
|
+
'agently-cli.cmd',
|
|
35
|
+
'agently-cli',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _runnable(candidate):
|
|
40
|
+
if os.path.isabs(candidate):
|
|
41
|
+
return os.path.isfile(candidate)
|
|
42
|
+
from shutil import which
|
|
43
|
+
return which(candidate) is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def agently(args):
|
|
47
|
+
"""Run agently-cli and return its parsed JSON, or None."""
|
|
48
|
+
executable = next((c for c in AGENTLY_CANDIDATES if _runnable(c)), None)
|
|
49
|
+
if not executable:
|
|
50
|
+
print('未找到 agently-cli,跳过。')
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[executable, *args], capture_output=True, timeout=90, encoding='utf-8')
|
|
55
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
56
|
+
print(f'agently-cli 调用失败: {type(error).__name__}')
|
|
57
|
+
return None
|
|
58
|
+
# agently-cli pretty-prints its JSON across many lines, so parse the whole
|
|
59
|
+
# payload from the first brace rather than looking for a single-line blob.
|
|
60
|
+
text = result.stdout.strip()
|
|
61
|
+
start = text.find('{')
|
|
62
|
+
if start < 0:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return json.JSONDecoder().raw_decode(text[start:])[0]
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_json(path, fallback):
|
|
71
|
+
try:
|
|
72
|
+
with open(path, encoding='utf-8') as handle:
|
|
73
|
+
return json.load(handle)
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return fallback
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
state = load_json(STATE_PATH, {'seen': []})
|
|
80
|
+
seen = set(state.get('seen', []))
|
|
81
|
+
|
|
82
|
+
listing = agently(['message', '+list', '--limit', '20'])
|
|
83
|
+
if not listing or not listing.get('ok'):
|
|
84
|
+
print('读取邮箱失败(未授权或网络不通),本次跳过。')
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
messages = (listing.get('data') or {}).get('data') or []
|
|
88
|
+
queued = 0
|
|
89
|
+
for summary in messages:
|
|
90
|
+
sender = (summary.get('from') or {}).get('email', '')
|
|
91
|
+
if sender not in ALLOWED_SENDERS:
|
|
92
|
+
continue
|
|
93
|
+
message_id = summary.get('message_id', '')
|
|
94
|
+
if not message_id or message_id in seen:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
detail = agently(['message', '+read', '--id', message_id])
|
|
98
|
+
body = ''
|
|
99
|
+
if detail and detail.get('ok'):
|
|
100
|
+
body = ((detail.get('data') or {}).get('text')
|
|
101
|
+
or (detail.get('data') or {}).get('body') or '')
|
|
102
|
+
if not body:
|
|
103
|
+
body = summary.get('snippet', '')
|
|
104
|
+
|
|
105
|
+
entry = {
|
|
106
|
+
'message_id': message_id,
|
|
107
|
+
'from': sender,
|
|
108
|
+
'subject': summary.get('subject', ''),
|
|
109
|
+
'created_at': summary.get('created_at', ''),
|
|
110
|
+
'body': body,
|
|
111
|
+
}
|
|
112
|
+
with open(QUEUE_PATH, 'a', encoding='utf-8') as handle:
|
|
113
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
114
|
+
seen.add(message_id)
|
|
115
|
+
queued += 1
|
|
116
|
+
|
|
117
|
+
if queued:
|
|
118
|
+
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
119
|
+
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
120
|
+
print(f'已排队 {queued} 封来自授权发件人的邮件。')
|
|
121
|
+
else:
|
|
122
|
+
# Still persist first-run state so the backlog is not queued later.
|
|
123
|
+
if not os.path.exists(STATE_PATH):
|
|
124
|
+
with open(STATE_PATH, 'w', encoding='utf-8') as handle:
|
|
125
|
+
json.dump({'seen': sorted(seen)}, handle, ensure_ascii=False, indent=2)
|
|
126
|
+
print('无新指令邮件。')
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
sys.exit(main())
|
package/src/core/ntCore.ts
CHANGED
|
@@ -119,6 +119,10 @@ export class NtCore {
|
|
|
119
119
|
WEFLOW_DB_PATH: values.dbPath,
|
|
120
120
|
WEFLOW_NT_KEY: values.key,
|
|
121
121
|
WEFLOW_NT_SALT: values.salt,
|
|
122
|
+
// WeChat rolls conversations into new message_*.db shards, each with
|
|
123
|
+
// a key PBKDF2-derived from this shared passphrase. Without it only
|
|
124
|
+
// shard 0 opens, and every read silently stops at its last write.
|
|
125
|
+
WEFLOW_NT_PASSPHRASE: (configService.get('favPassphrase') || configService.get('decryptKey') || '') || undefined,
|
|
122
126
|
WEFLOW_CONTACT_DB: values.contactDbPath || undefined,
|
|
123
127
|
WEFLOW_CONTACT_KEY: values.contactKey || undefined,
|
|
124
128
|
WEFLOW_CONTACT_SALT: values.contactSalt || undefined,
|
|
@@ -388,11 +388,20 @@ export class ChatService {
|
|
|
388
388
|
|
|
389
389
|
/** 当前连接是否支持收藏查询 */
|
|
390
390
|
isFavSupported(): boolean {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
391
|
+
return this.favUnavailableReason() === null
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 收藏不可用的具体原因, null 表示可用。
|
|
396
|
+
*
|
|
397
|
+
* 旧实现把「通道不对」和「没配密钥」压成一个 false, 于是没配 favKey 时报的是
|
|
398
|
+
* 「需 4.x NT 连接」—— 与用户实际看到的状态(4.x 已连接)矛盾, 也指错了修改方向。
|
|
399
|
+
*/
|
|
400
|
+
favUnavailableReason(): 'channel' | 'path' | 'key' | null {
|
|
401
|
+
if (!this.connected || this.activeVersion !== '4.x' || !this.ntCore) return 'channel'
|
|
402
|
+
if (!configService.get('favDbPath')) return 'path'
|
|
403
|
+
if (!configService.get('favKey') && !configService.get('favPassphrase')) return 'key'
|
|
404
|
+
return null
|
|
396
405
|
}
|
|
397
406
|
|
|
398
407
|
/** 解析收藏密钥: 优先 favKey, 其次从 favPassphrase + 库 salt 派生 */
|
|
@@ -150,6 +150,10 @@ export class ExportService {
|
|
|
150
150
|
// hundred of them costs minutes in PIL.
|
|
151
151
|
WEFLOW_FULL_IMAGES: options.fullImages ? '1' : undefined,
|
|
152
152
|
WEFLOW_EXPORT_DATE: date || undefined,
|
|
153
|
+
// Without this the Python path exported the entire history: `--limit`
|
|
154
|
+
// only ever reached the fallback renderer, so a capped export of a
|
|
155
|
+
// busy group silently produced hundreds of files.
|
|
156
|
+
WEFLOW_EXPORT_LIMIT: limit > 0 ? String(limit) : undefined,
|
|
153
157
|
}),
|
|
154
158
|
})
|
|
155
159
|
|
|
@@ -205,7 +209,12 @@ export class ExportService {
|
|
|
205
209
|
|
|
206
210
|
async exportExcel(talker: string, outputDir: string, limit = 0, from?: number, to?: number): Promise<ExportResult> {
|
|
207
211
|
try {
|
|
208
|
-
|
|
212
|
+
// exceljs is CJS-only, so a dynamic import hands back a namespace whose
|
|
213
|
+
// only member is `default`. `new ExcelJS.Workbook()` on the namespace
|
|
214
|
+
// itself is a TypeError, which the catch below used to swallow into a
|
|
215
|
+
// bare "Excel 导出失败".
|
|
216
|
+
const mod: any = await import('exceljs')
|
|
217
|
+
const ExcelJS = mod.default ?? mod
|
|
209
218
|
const messages = await chatService.getMessagesInRange(talker, limit, from, to)
|
|
210
219
|
if (messages.length === 0) {
|
|
211
220
|
return { success: false, error: '未找到消息' }
|
|
@@ -236,8 +245,11 @@ export class ExportService {
|
|
|
236
245
|
const filePath = join(dir, `${talker}_messages.xlsx`)
|
|
237
246
|
await workbook.xlsx.writeFile(filePath)
|
|
238
247
|
return { success: true, path: filePath, count: messages.length }
|
|
239
|
-
} catch {
|
|
240
|
-
|
|
248
|
+
} catch (e: any) {
|
|
249
|
+
// Keep the reason: a bare label turned a one-line library error into an
|
|
250
|
+
// undiagnosable "export failed" for both users and agents.
|
|
251
|
+
const reason = e?.message ? `: ${String(e.message).slice(0, 200)}` : ''
|
|
252
|
+
return { success: false, error: `Excel 导出失败${reason}` }
|
|
241
253
|
}
|
|
242
254
|
}
|
|
243
255
|
|
|
@@ -34,12 +34,28 @@ export function createPythonProcessEnv(
|
|
|
34
34
|
return env
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Last non-empty stderr line, with anything key-shaped redacted.
|
|
39
|
+
*
|
|
40
|
+
* The scripts report the actual cause on stderr ("缺少公众号数据库密钥: ..."),
|
|
41
|
+
* and discarding it left operators with a bare exit code to search for.
|
|
42
|
+
*/
|
|
43
|
+
function stderrTail(stderr: unknown): string {
|
|
44
|
+
const text = typeof stderr === 'string'
|
|
45
|
+
? stderr
|
|
46
|
+
: Buffer.isBuffer(stderr) ? stderr.toString('utf8') : ''
|
|
47
|
+
const lines = text.split('\n').map(line => line.trim()).filter(Boolean)
|
|
48
|
+
const line = lines[lines.length - 1] || ''
|
|
49
|
+
return line.replace(/\b[0-9a-fA-F]{32,}\b/g, '<已隐藏>').slice(0, 200)
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export function safeSubprocessError(error: unknown, label = '子进程执行失败'): string {
|
|
38
|
-
const value = error as { code?: unknown; killed?: boolean; signal?: unknown }
|
|
53
|
+
const value = error as { code?: unknown; killed?: boolean; signal?: unknown; stderr?: unknown }
|
|
39
54
|
if (value?.code === 'ENOENT') return `${label}: 运行时或命令不可用`
|
|
40
55
|
if (value?.code === 'ETIMEDOUT' || value?.killed || value?.signal) return `${label}: 执行超时或被终止`
|
|
41
56
|
if (typeof value?.code === 'number' || /^\d+$/.test(String(value?.code || ''))) {
|
|
42
|
-
|
|
57
|
+
const detail = stderrTail(value?.stderr)
|
|
58
|
+
return `${label} (exit ${String(value.code)})${detail ? `: ${detail}` : ''}`
|
|
43
59
|
}
|
|
44
60
|
return label
|
|
45
61
|
}
|