weflow-cli 1.11.1 → 1.13.0
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 +378 -0
- package/OPERATIONS.md +359 -11
- package/README.md +4 -0
- package/bin/weflow-cli.ts +289 -37
- package/dist/bin/weflow-cli.js +300 -35
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +5 -0
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +12 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +23 -3
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +8 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +46 -11
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +3 -1
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +25 -7
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +6 -3
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +80 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -0
- package/dist/src/services/diagnosticReport.js +191 -0
- package/dist/src/services/diagnosticReport.js.map +1 -0
- package/dist/src/services/exportService.d.ts +7 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +1 -1
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +9 -3
- package/dist/src/services/wechatClient.d.ts.map +1 -1
- package/dist/src/services/wechatClient.js +55 -22
- package/dist/src/services/wechatClient.js.map +1 -1
- package/docs/DECISIONS.md +662 -1
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +236 -0
- package/docs/POSITIONING.md +68 -0
- package/docs/PROJECT_STATE.md +119 -17
- package/docs/RELEASING.md +14 -0
- package/package.json +88 -86
- package/scripts/_utils.py +120 -4
- package/scripts/backfill_articles.py +64 -14
- package/scripts/biz_daily.py +118 -11
- package/scripts/daily_task.ps1 +132 -0
- package/scripts/export_chat_html.py +79 -31
- package/scripts/install_tasks.cmd +32 -0
- package/scripts/install_tasks.ps1 +91 -0
- package/scripts/jev_client.py +335 -3
- package/scripts/nt_decrypt.py +79 -14
- package/scripts/pipeline.py +21 -0
- package/scripts/recall_eval.py +247 -0
- package/scripts/repro_customer_init.cmd +86 -0
- package/scripts/semantic_search.py +926 -51
- package/src/core/dbPathService.ts +5 -0
- package/src/core/ntCore.ts +34 -3
- package/src/core/sqlcipherCore.ts +47 -11
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/configService.ts +12 -3
- package/src/services/diagnosticReport.ts +237 -0
- package/src/services/exportService.ts +14 -2
- package/src/services/wechatClient.ts +418 -380
|
@@ -111,10 +111,13 @@ _FILE_ASSET_SUBDIR = 'files'
|
|
|
111
111
|
# 文档那半的计数(图片那半的计数在写盘时现算)。放在模块级是为了不改 `write_assets` 的返回形状 ——
|
|
112
112
|
# 它已经有两个调用方(markdown 导出与 `--media`),与 COVER_STATE/VOICE_STATE 是同一套写法。
|
|
113
113
|
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
114
|
+
# **三个词各管一件事,别混**(媒体状态统一后,报告/清单/JSON 三处都按这套说法):
|
|
115
|
+
# `written` = 现在躺在导出目录里的份数("可用数",同一目录跑第二遍时它不为 0)
|
|
116
|
+
# `copied` = **这一轮**新拷进去的份数(第二遍跑就是 0)
|
|
117
|
+
# `unavailable` = 没能落盘的份数(本地没有那份文件 / 太大 / 拷贝失败)
|
|
118
|
+
# 分开是因为只报 `copied` 会把第二遍打印成"0 document(s)",而实际上 67 份都在 —— 那是给自己制造
|
|
119
|
+
# 假象(2026-10-13 真这么打印过一次)。
|
|
120
|
+
FILE_ASSET_STATE = {'written': 0, 'copied': 0, 'unavailable': 0}
|
|
118
121
|
|
|
119
122
|
# Off by default: full-resolution originals are 5-24MB / 3000-5700px each, and
|
|
120
123
|
# downscaling ~500 of them costs minutes (PIL decode dominates). The cache's
|
|
@@ -242,17 +245,28 @@ def fetch_messages_from_shards(db_path, key_hex, salt_hex, talker, date='', pass
|
|
|
242
245
|
# 原先的 `if not shards: shards = [db_path]` 是给"可能返回空"的那一份实现擦屁股的。
|
|
243
246
|
shards = discover_message_shards(db_path)
|
|
244
247
|
messages = []
|
|
248
|
+
failed = []
|
|
245
249
|
for shard in shards:
|
|
246
250
|
shard_conn = None
|
|
247
251
|
try:
|
|
248
252
|
shard_key, shard_salt = derive_database_key(shard, key_hex, salt_hex, passphrase)
|
|
249
253
|
shard_conn, _ = connect(shard, shard_key, shard_salt)
|
|
250
254
|
messages.extend(fetch_messages(shard_conn, talker, date))
|
|
251
|
-
except Exception:
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
# **少读一个分片 = 导出里少一段聊天,而用户看到的是"导出成功"**。
|
|
257
|
+
# 必须留下分片名与原因:只有知道是哪个分片,才分得清"这个分片本来就空"、
|
|
258
|
+
# "密钥/权限不对"还是"文件损坏"。原先直接 continue,等于静默丢数据。
|
|
259
|
+
failed.append((os.path.basename(shard), '%s: %s' % (type(exc).__name__, exc)))
|
|
252
260
|
continue
|
|
253
261
|
finally:
|
|
254
262
|
if shard_conn is not None:
|
|
255
263
|
shard_conn.close()
|
|
264
|
+
if failed:
|
|
265
|
+
print('[WARN] %d/%d 个消息分片读不了,导出内容会缺这几段(不是"没有消息"):'
|
|
266
|
+
% (len(failed), len(shards)), file=sys.stderr)
|
|
267
|
+
for name, why in failed:
|
|
268
|
+
print(' - %s (%s)' % (name, why), file=sys.stderr)
|
|
269
|
+
sys.stderr.flush()
|
|
256
270
|
messages.sort(key=lambda row: (int(row[5] or 0), int(row[0] or 0)))
|
|
257
271
|
return messages
|
|
258
272
|
|
|
@@ -2145,10 +2159,15 @@ def build_media_record(local_type, image_b64, image_map, local_id, create_time,
|
|
|
2145
2159
|
if file_info is not None:
|
|
2146
2160
|
# 文档本体(appmsg type 6)走的是另一条路:没有缩略图、也不在图片索引里,能不能导出只看
|
|
2147
2161
|
# 本地 `msg/file/` 有没有那份文件。不并入图片那条链,否则报告会把"文件没下载"写成"没有可靠标识"。
|
|
2162
|
+
#
|
|
2163
|
+
# 状态词是 `found` 而**不是** `exported`:这份报告是在**写 assets 之前**生成的,它答的是
|
|
2164
|
+
# "能不能在源数据里定位到"(`found` / `missing`),答不了"有没有写进导出目录"。
|
|
2165
|
+
# 后者由清单的 `documents.written` 回答(`write_assets` 写的)。两件事用两个词,
|
|
2166
|
+
# 免得"这份文件已导出"这种话在拷贝失败时变成假的。
|
|
2148
2167
|
record['kind'] = 'file'
|
|
2149
2168
|
record['bytes'] = file_info.get('bytes') or None
|
|
2150
2169
|
if file_hit:
|
|
2151
|
-
record['status'] = '
|
|
2170
|
+
record['status'] = 'found'
|
|
2152
2171
|
record['mediaKey'] = 'file'
|
|
2153
2172
|
else:
|
|
2154
2173
|
record['status'] = 'missing'
|
|
@@ -2609,15 +2628,15 @@ def write_assets(out_dir, file_prefix, formatted, assets_dir=''):
|
|
|
2609
2628
|
'bytes': size,
|
|
2610
2629
|
}
|
|
2611
2630
|
|
|
2612
|
-
# `
|
|
2631
|
+
# `written` 数的是**合并后清单里真实存在的文档条目**(第二遍跑同一目录时它不为 0,而新拷贝是 0);
|
|
2613
2632
|
# `copied` 才是"这次新拷了几个"。两个都写出来,免得读清单的人把后者当前者。
|
|
2614
|
-
|
|
2615
|
-
FILE_ASSET_STATE['
|
|
2616
|
-
FILE_ASSET_STATE['
|
|
2633
|
+
file_written_total = sum(1 for record in manifest.values() if record.get('kind') == 'file')
|
|
2634
|
+
FILE_ASSET_STATE['written'] = file_written_total
|
|
2635
|
+
FILE_ASSET_STATE['copied'] = file_written
|
|
2617
2636
|
FILE_ASSET_STATE['unavailable'] = file_unavailable
|
|
2618
2637
|
with open(manifest_path, 'w', encoding='utf-8') as fh:
|
|
2619
2638
|
json.dump({'schema': 'weflow-media-manifest/v2', 'messages': manifest,
|
|
2620
|
-
'documents': {'
|
|
2639
|
+
'documents': {'written': file_written_total, 'copied': file_written,
|
|
2621
2640
|
'unavailable': file_unavailable}},
|
|
2622
2641
|
fh, ensure_ascii=False, indent=2)
|
|
2623
2642
|
return os.path.basename(os.path.normpath(assets)), manifest, written + file_written
|
|
@@ -2641,6 +2660,35 @@ def _markdown_link_target(rel):
|
|
|
2641
2660
|
return ''.join(out)
|
|
2642
2661
|
|
|
2643
2662
|
|
|
2663
|
+
def media_json_summary(media_items, media_report_path=None, manifest=None, asset_new=0, assets_dir=''):
|
|
2664
|
+
"""命令行最后那行 JSON 里的媒体摘要 —— **三种格式共用同一份词汇与同一份计数逻辑**。
|
|
2665
|
+
|
|
2666
|
+
每个词只回答一个问题,互不覆盖:
|
|
2667
|
+
|
|
2668
|
+
* `reportStatus`:按**能不能在源数据里定位到**分类(图片是 `embedded`/`remote-fetched`/`missing`,
|
|
2669
|
+
文档是 `found`/`missing`)。这是"这份媒体在源数据里有没有"。
|
|
2670
|
+
* `assets.written / copied`:**有没有写进导出目录**(written 是现在那里的总数,copied 是这一轮新写的)。
|
|
2671
|
+
与上一条是两回事:源数据里找得到的,可能因为太大或拷贝失败而没写进去。
|
|
2672
|
+
* `documents.unavailable`:没能落盘的份数(没找到 / 太大 / 拷贝失败)。
|
|
2673
|
+
|
|
2674
|
+
三处原本各拼一份 JSON(还各写了不同的键名),同一个事实就有三种说法;这里收成一份,
|
|
2675
|
+
顺带把**报告文件名**一起给出来(此前只有 HTML 那条给)。不含路径、正文或密钥。
|
|
2676
|
+
"""
|
|
2677
|
+
counts = {}
|
|
2678
|
+
for item in media_items or []:
|
|
2679
|
+
status = item.get('status') or 'unknown'
|
|
2680
|
+
counts[status] = counts.get(status, 0) + 1
|
|
2681
|
+
return {
|
|
2682
|
+
'report': os.path.basename(media_report_path) if media_report_path else None,
|
|
2683
|
+
'reportItems': len(media_items or []),
|
|
2684
|
+
'reportStatus': counts,
|
|
2685
|
+
'assets': {'items': len(manifest or {}), 'new': asset_new, 'dir': assets_dir or None},
|
|
2686
|
+
'documents': {'written': FILE_ASSET_STATE['written'],
|
|
2687
|
+
'copied': FILE_ASSET_STATE['copied'],
|
|
2688
|
+
'unavailable': FILE_ASSET_STATE['unavailable']},
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
|
|
2644
2692
|
def write_markdown(out_dir, file_prefix, formatted, assets_dir=''):
|
|
2645
2693
|
"""markdown 正文 + 媒体落盘(落盘那半在 `write_assets`,两者共用同一份清单)。"""
|
|
2646
2694
|
assets_name, manifest, written = write_assets(out_dir, file_prefix, formatted, assets_dir)
|
|
@@ -2917,35 +2965,39 @@ def main():
|
|
|
2917
2965
|
if args.assets_only:
|
|
2918
2966
|
# 只抽媒体:其它格式的 `--media` 会先跑这一步,再各自消费清单。
|
|
2919
2967
|
assets_name, manifest, asset_new = write_assets(args.out, file_prefix, formatted, args.assets_dir)
|
|
2920
|
-
|
|
2921
|
-
|
|
2968
|
+
# 这一步也写媒体报告:三种形态的调用方应当拿到同一份东西(状态计数 + 报告文件名),
|
|
2969
|
+
# 少写这一份的话,`--media` 的调用者只能看到文档计数、看不到"哪些媒体没嵌上、为什么"。
|
|
2970
|
+
media_report_path = None
|
|
2971
|
+
if media_items:
|
|
2972
|
+
media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
|
|
2973
|
+
with open(media_report_path, 'w', encoding='utf-8') as handle:
|
|
2974
|
+
json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
|
|
2975
|
+
handle, ensure_ascii=False, indent=2)
|
|
2976
|
+
print(f"Assets: {len(manifest)} media item(s), {FILE_ASSET_STATE['written']} document(s) "
|
|
2977
|
+
f"({FILE_ASSET_STATE['unavailable']} unavailable, {FILE_ASSET_STATE['copied']} new) "
|
|
2922
2978
|
f"({asset_new} new) -> {assets_name}")
|
|
2923
2979
|
print(json.dumps({"success": True, "total": len(formatted),
|
|
2924
2980
|
"files": [os.path.join(args.out, assets_name)], "format": "assets",
|
|
2925
|
-
"
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2929
|
-
"assetsDir": assets_name}, ensure_ascii=False))
|
|
2981
|
+
"media": media_json_summary(media_items, media_report_path, manifest,
|
|
2982
|
+
asset_new, assets_name)},
|
|
2983
|
+
ensure_ascii=False))
|
|
2930
2984
|
conn.close()
|
|
2931
2985
|
return
|
|
2932
2986
|
if args.format == 'md':
|
|
2933
2987
|
md_path, asset_total, asset_new, assets_name = write_markdown(args.out, file_prefix, formatted, args.assets_dir)
|
|
2988
|
+
media_report_path = None # 没有媒体时下面那段不跑,但 JSON 里仍要如实给出 None
|
|
2934
2989
|
if media_items:
|
|
2935
2990
|
media_report_path = os.path.join(args.out, f'{file_prefix}_media.json')
|
|
2936
2991
|
with open(media_report_path, 'w', encoding='utf-8') as handle:
|
|
2937
2992
|
json.dump(build_media_report(media_items, display_name, args.talker, len(formatted)),
|
|
2938
2993
|
handle, ensure_ascii=False, indent=2)
|
|
2939
2994
|
print(f"Markdown: {len(formatted)} message(s), {asset_total} media item(s), "
|
|
2940
|
-
f"{FILE_ASSET_STATE['
|
|
2941
|
-
f"{FILE_ASSET_STATE['
|
|
2995
|
+
f"{FILE_ASSET_STATE['written']} document(s) ({FILE_ASSET_STATE['unavailable']} unavailable, "
|
|
2996
|
+
f"{FILE_ASSET_STATE['copied']} new) ({asset_new} new) -> {os.path.basename(md_path)}")
|
|
2942
2997
|
print(json.dumps({"success": True, "total": len(formatted), "parts": 1,
|
|
2943
2998
|
"files": [md_path], "format": "md",
|
|
2944
|
-
"
|
|
2945
|
-
|
|
2946
|
-
"documentsNew": FILE_ASSET_STATE['written'],
|
|
2947
|
-
"documentsUnavailable": FILE_ASSET_STATE['unavailable'],
|
|
2948
|
-
"assetsDir": assets_name},
|
|
2999
|
+
"media": media_json_summary(media_items, media_report_path, manifest=None,
|
|
3000
|
+
asset_new=asset_new, assets_dir=assets_name)},
|
|
2949
3001
|
ensure_ascii=False))
|
|
2950
3002
|
conn.close()
|
|
2951
3003
|
return
|
|
@@ -2995,17 +3047,13 @@ def main():
|
|
|
2995
3047
|
# Print JSON summary for CLI integration.
|
|
2996
3048
|
# The four original keys are untouched; `media` is purely additive and the
|
|
2997
3049
|
# one existing consumer (src/services/exportService.ts) ignores it.
|
|
3050
|
+
# 三种格式(html/md/assets)用**同一个** media_json_summary,词汇与计数不再各说各话。
|
|
2998
3051
|
print(json.dumps({
|
|
2999
3052
|
"success": True,
|
|
3000
3053
|
"total": total,
|
|
3001
3054
|
"parts": len(html_files),
|
|
3002
3055
|
"files": html_files,
|
|
3003
|
-
"media": (
|
|
3004
|
-
"schema": "weflow-media-report/v1",
|
|
3005
|
-
"records": len(media_items),
|
|
3006
|
-
"status": media_report['counts'],
|
|
3007
|
-
"report": os.path.basename(media_report_path) if media_report_path else None,
|
|
3008
|
-
} if media_report else None),
|
|
3056
|
+
"media": media_json_summary(media_items, media_report_path),
|
|
3009
3057
|
}))
|
|
3010
3058
|
|
|
3011
3059
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
chcp 65001 >nul
|
|
3
|
+
setlocal
|
|
4
|
+
rem One-click installer for the two daily-report scheduled tasks.
|
|
5
|
+
rem
|
|
6
|
+
rem Why this .cmd exists: agent sandboxes and restricted shells refuse Task Scheduler writes
|
|
7
|
+
rem as a class (schtasks /create, the Schedule.Service COM API, and even the in-memory
|
|
8
|
+
rem New-ScheduledTask* builders all return Access is denied while reads keep working).
|
|
9
|
+
rem A process started from your own desktop is outside that boundary, so this asks for
|
|
10
|
+
rem elevation itself and runs install_tasks.ps1 there.
|
|
11
|
+
|
|
12
|
+
set "PS1=%~dp0install_tasks.ps1"
|
|
13
|
+
if not exist "%PS1%" (
|
|
14
|
+
echo [ERROR] install_tasks.ps1 not found next to this file.
|
|
15
|
+
pause
|
|
16
|
+
exit /b 1
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
echo Requesting administrator privileges...
|
|
20
|
+
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-File','\"%PS1%\"') -Verb RunAs -ErrorAction Stop; exit 0 } catch { Write-Host ('Could not elevate: ' + $_.Exception.Message) -ForegroundColor Red; exit 1 }"
|
|
21
|
+
if errorlevel 1 (
|
|
22
|
+
echo.
|
|
23
|
+
echo If the elevation prompt was cancelled: right-click this file and pick "Run as administrator".
|
|
24
|
+
pause
|
|
25
|
+
exit /b 1
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
echo.
|
|
29
|
+
echo A new window opened and is registering the tasks. It stays open so you can read the result.
|
|
30
|
+
echo To remove them later: install_tasks.ps1 -Uninstall
|
|
31
|
+
echo.
|
|
32
|
+
pause
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Register / update the two scheduled tasks for the official-account daily report.
|
|
2
|
+
# Idempotent: running it again overwrites the definitions with the same values.
|
|
3
|
+
#
|
|
4
|
+
# Why this cannot always be done for you: writing to Task Scheduler is refused in a
|
|
5
|
+
# restricted shell / agent sandbox, and refused as a *class* - measured on 2026-10-19 with
|
|
6
|
+
# an elevated Administrator token, all three write paths said "Access is denied" while
|
|
7
|
+
# reads kept working:
|
|
8
|
+
# schtasks /create ... -> Access is denied
|
|
9
|
+
# (New-Object -ComObject Schedule.Service) -> RegisterTaskDefinition 0x80070005
|
|
10
|
+
# New-ScheduledTaskAction / -SettingsSet ... -> 拒绝访问 (even the in-memory builders)
|
|
11
|
+
# So "I can see the tasks but cannot create them" is not an illusion, and it is not fixed
|
|
12
|
+
# by "run as administrator". The one reliable route is a terminal the sandbox does not
|
|
13
|
+
# supervise: double-click scripts\install_tasks.cmd (it asks for elevation itself), or run
|
|
14
|
+
# this file from your own PowerShell window.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1
|
|
18
|
+
# powershell -NoProfile -ExecutionPolicy Bypass -File <repo>\scripts\install_tasks.ps1 -Uninstall
|
|
19
|
+
#
|
|
20
|
+
# The two tasks:
|
|
21
|
+
# WeFlow Daily 00:05 + at logon -> YESTERDAY (the last complete day)
|
|
22
|
+
# WeFlow Daily Noon 13:00 -> TODAY so far (half a day of pushes)
|
|
23
|
+
#
|
|
24
|
+
# Why 00:05 the next day instead of 24:00 tonight: it is the same instant, but "yesterday"
|
|
25
|
+
# then lines up with the date directory the run writes into, and it does not collide with
|
|
26
|
+
# other jobs on the hour. Official accounts keep pushing until 24:00, so this is the only
|
|
27
|
+
# moment that has the complete day.
|
|
28
|
+
#
|
|
29
|
+
# This file is intentionally ASCII-only: Windows PowerShell 5.1 reads a BOM-less file as
|
|
30
|
+
# the ANSI codepage, so non-ASCII text here turns into mojibake *and* a parse error.
|
|
31
|
+
param(
|
|
32
|
+
[switch]$Uninstall
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
$ErrorActionPreference = 'Stop'
|
|
36
|
+
|
|
37
|
+
$repo = Split-Path -Parent $PSScriptRoot
|
|
38
|
+
$script = Join-Path $repo 'scripts\daily_task.ps1'
|
|
39
|
+
if (-not (Test-Path $script)) { throw "not found: $script (this script belongs in the repo's scripts\ folder)" }
|
|
40
|
+
|
|
41
|
+
$isAdmin = (New-Object Security.Principal.WindowsPrincipal(
|
|
42
|
+
[Security.Principal.WindowsIdentity]::GetCurrent())
|
|
43
|
+
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
44
|
+
if (-not $isAdmin) {
|
|
45
|
+
Write-Host 'Need elevation: reopen PowerShell as administrator, then run this again.' -ForegroundColor Yellow
|
|
46
|
+
exit 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ($Uninstall) {
|
|
50
|
+
foreach ($name in @('WeFlow Daily Noon', 'WeFlow Daily')) {
|
|
51
|
+
if (Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue) {
|
|
52
|
+
Unregister-ScheduledTask -TaskName $name -Confirm:$false
|
|
53
|
+
Write-Host "removed: $name"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exit 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew
|
|
60
|
+
$psExe = 'powershell.exe'
|
|
61
|
+
$common = '-NoProfile -ExecutionPolicy Bypass -File ' + $script
|
|
62
|
+
|
|
63
|
+
# End of day: yesterday. The at-logon trigger is the catch-up: this machine slept for
|
|
64
|
+
# 37 hours and StartWhenAvailable did NOT actually start the missed run (NumberOfMissedRuns=2,
|
|
65
|
+
# no run record), so "it will catch up by itself" is not something to rely on.
|
|
66
|
+
Register-ScheduledTask -TaskName 'WeFlow Daily' `
|
|
67
|
+
-Action (New-ScheduledTaskAction -Execute $psExe -Argument $common) `
|
|
68
|
+
-Trigger @((New-ScheduledTaskTrigger -Daily -At '00:05'),
|
|
69
|
+
(New-ScheduledTaskTrigger -AtLogOn)) `
|
|
70
|
+
-Settings $settings `
|
|
71
|
+
-Description 'WeChat daily report for yesterday (00:05 + at logon, idempotent)' -Force | Out-Null
|
|
72
|
+
|
|
73
|
+
# Noon: today so far. Deliberately NO at-logon trigger: a morning logon would produce a
|
|
74
|
+
# very thin "noon" report and write the .noon_done marker, which would then make the real
|
|
75
|
+
# 13:00 run skip itself (the guard keys on that marker).
|
|
76
|
+
Register-ScheduledTask -TaskName 'WeFlow Daily Noon' `
|
|
77
|
+
-Action (New-ScheduledTaskAction -Execute $psExe -Argument ($common + ' noon')) `
|
|
78
|
+
-Trigger (New-ScheduledTaskTrigger -Daily -At '13:00') `
|
|
79
|
+
-Settings $settings `
|
|
80
|
+
-Description 'WeChat daily report for today so far (13:00)' -Force | Out-Null
|
|
81
|
+
|
|
82
|
+
foreach ($name in @('WeFlow Daily', 'WeFlow Daily Noon')) {
|
|
83
|
+
$t = Get-ScheduledTask -TaskName $name
|
|
84
|
+
$i = Get-ScheduledTaskInfo -TaskName $name
|
|
85
|
+
Write-Host ("{0,-20} {1,-8} next {2}" -f $t.TaskName, $t.State, $i.NextRunTime) -ForegroundColor Green
|
|
86
|
+
}
|
|
87
|
+
Write-Host ''
|
|
88
|
+
Write-Host 'Run one now instead of waiting:'
|
|
89
|
+
Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily Noon" # today so far'
|
|
90
|
+
Write-Host ' Start-ScheduledTask -TaskName "WeFlow Daily" # yesterday, complete'
|
|
91
|
+
Write-Host 'Logs: <repo>\logs\daily-<date>[-noon].log'
|