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.
@@ -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())
@@ -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
- if (!this.connected || this.activeVersion !== '4.x' || !this.ntCore) return false
392
- const favPath = configService.get('favDbPath')
393
- const favKey = configService.get('favKey')
394
- const favPass = configService.get('favPassphrase')
395
- return !!(favPath && (favKey || favPass))
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
- const ExcelJS = await import('exceljs')
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
- return { success: false, error: 'Excel 导出失败' }
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
- return `${label} (exit ${String(value.code)})`
57
+ const detail = stderrTail(value?.stderr)
58
+ return `${label} (exit ${String(value.code)})${detail ? `: ${detail}` : ''}`
43
59
  }
44
60
  return label
45
61
  }