weflow-cli 1.5.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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ AI 日报生成 — 基于 git diff 分析今日知识变更,生成结构化学习日报。
4
+
5
+ 用法:
6
+ python scripts/generate_review.py --api-key <key> [--date 2026-05-15] [--output Reviews]
7
+ """
8
+ import sys, os, json, re, time, subprocess
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+ from _utils import create_engine, parse_frontmatter
13
+
14
+ DAILY_PROMPT = """你是知识管理助手,基于今日文章内容生成学习日报。
15
+
16
+ 今日新增文章(标题 + 来源 + 摘要):
17
+ {articles_text}
18
+
19
+ 请按格式返回:
20
+ 【今日焦点】
21
+ (2-3句话,今日最重要的知识收获)
22
+
23
+ 【按主题】
24
+ AI:
25
+ - 要点1
26
+ - 要点2
27
+
28
+ 学术:
29
+ - 要点1
30
+ - 要点2
31
+
32
+ 投资:
33
+ - 要点1
34
+
35
+ 【关键收获】
36
+ (1句话,今日最值得记住的一点)
37
+
38
+ 要求:只基于提供的文章内容,不要编造。无对应主题则标注"(无)"。"""
39
+
40
+
41
+ def scan_today_articles(target_date: str, source='output/biz-daily') -> list[dict]:
42
+ """扫描指定日期的文章目录,提取 frontmatter + 摘要。"""
43
+ date_dir = Path(source) / target_date
44
+ if not date_dir.exists():
45
+ return []
46
+
47
+ articles = []
48
+ for md_file in sorted(date_dir.rglob('*.md')):
49
+ if md_file.name == 'README.md' or md_file.name.startswith('.'):
50
+ continue
51
+ try:
52
+ with open(md_file, 'r', encoding='utf-8') as f:
53
+ content = f.read()
54
+ except:
55
+ continue
56
+
57
+ fm, body = parse_frontmatter(content)
58
+ if not fm:
59
+ continue
60
+
61
+ # Extract summary
62
+ summary = ''
63
+ m = re.search(r'## (?:AI 摘要|深度解析)\n\n(.+?)(?=\n\n(?:##|---)|\Z)', body, re.DOTALL)
64
+ if m:
65
+ summary = m.group(1).strip()[:300]
66
+
67
+ articles.append({
68
+ 'title': fm.get('title', md_file.stem),
69
+ 'source': fm.get('source', ''),
70
+ 'topic': fm.get('topic', '学术'),
71
+ 'tags': fm.get('tags', []),
72
+ 'summary': summary,
73
+ })
74
+ return articles
75
+
76
+
77
+ def main():
78
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
79
+ import argparse
80
+ parser = argparse.ArgumentParser(description='AI 学习日报生成')
81
+ parser.add_argument('--api-key', help='DeepSeek API key(或环境变量 DEEPSEEK_API_KEY)')
82
+ parser.add_argument('--date', help='日期 YYYY-MM-DD')
83
+ parser.add_argument('--engine', default='deepseek', help='AI 引擎(deepseek/claude/ollama)')
84
+ parser.add_argument('--source', default='output/biz-daily', help='文章目录')
85
+ parser.add_argument('--output', default='output/reviews', help='输出目录')
86
+ args = parser.parse_args()
87
+
88
+ api_key = args.api_key or os.environ.get('DEEPSEEK_API_KEY', '')
89
+ if not api_key:
90
+ print('[ERROR] 需要 API key')
91
+ sys.exit(1)
92
+
93
+ # Date
94
+ from datetime import datetime, timezone, timedelta
95
+ if args.date:
96
+ target_date = args.date
97
+ else:
98
+ tz = timezone(timedelta(hours=8))
99
+ target_date = datetime.now(tz).strftime('%Y-%m-%d')
100
+
101
+ print(f'=== AI 日报 — {target_date} ===\n')
102
+
103
+ # Scan articles
104
+ articles = scan_today_articles(target_date, args.source)
105
+ if not articles:
106
+ print(f'未找到 {target_date} 的文章,请先运行 biz_daily.py')
107
+ return
108
+
109
+ print(f'找到 {len(articles)} 篇文章')
110
+
111
+ # Topic distribution
112
+ from collections import Counter
113
+ topics = Counter(a['topic'] for a in articles)
114
+ print(f' 主题: {dict(topics)}')
115
+
116
+ # Format articles for prompt
117
+ article_texts = []
118
+ for a in articles:
119
+ article_texts.append(f'- [{a["topic"]}] {a["title"]}({a["source"]}):{a["summary"][:150]}')
120
+ articles_text = '\n'.join(article_texts[: 30]) # max 30 articles
121
+
122
+ # Call AI
123
+ print(f'\n生成日报中...')
124
+ engine = create_engine(args.engine, api_key)
125
+ try:
126
+ response = engine.chat(DAILY_PROMPT.format(articles_text=articles_text), max_tokens=1000)
127
+ except Exception as e:
128
+ print(f'[ERR] AI 调用失败: {e}')
129
+ sys.exit(1)
130
+
131
+ # Parse response
132
+ focus_m = re.search(r'【今日焦点】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
133
+ takeaway_m = re.search(r'【关键收获】\s*(.+?)(?=\n【|$)', response, re.DOTALL)
134
+
135
+ focus = focus_m.group(1).strip() if focus_m else ''
136
+ takeaway = takeaway_m.group(1).strip() if takeaway_m else ''
137
+
138
+ # Output
139
+ out_dir = Path(args.output) / 'Daily'
140
+ out_dir.mkdir(parents=True, exist_ok=True)
141
+ out_file = out_dir / f'Daily-{target_date}.md'
142
+
143
+ with open(out_file, 'w', encoding='utf-8') as f:
144
+ f.write(f'---\ntitle: "学习日报 — {target_date}"\ntype: daily-review\ndate: {target_date}\ncreated: {target_date}\n---\n\n')
145
+ f.write(f'# 学习日报 — {target_date}\n\n')
146
+ f.write(f'> 共 {len(articles)} 篇文章 | 主题: {", ".join(f"{t}({c})" for t, c in topics.most_common())}\n\n')
147
+ f.write(f'---\n\n')
148
+ if focus:
149
+ f.write(f'## 今日焦点\n\n{focus}\n\n')
150
+ # Topic breakdown
151
+ f.write(f'## 知识输入\n\n')
152
+ for topic, count in topics.most_common():
153
+ topic_articles = [a for a in articles if a['topic'] == topic]
154
+ f.write(f'### {topic}({count}篇)\n\n')
155
+ for a in topic_articles[:8]:
156
+ f.write(f'- **{a["title"]}** — {a["source"]}\n')
157
+ if a['summary']:
158
+ f.write(f' {a["summary"][:200]}\n')
159
+ f.write('\n')
160
+ f.write(f'---\n\n')
161
+ if takeaway:
162
+ f.write(f'## 关键收获\n\n> {takeaway}\n\n')
163
+ f.write(f'*由 weflow-cli generate-review 自动生成*\n')
164
+
165
+ print(f'\n✓ 日报: {out_file}')
166
+ print(f' 焦点: {focus[:100] if focus else "(无)"}...')
167
+
168
+
169
+ if __name__ == '__main__':
170
+ main()
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MCP Bridge — 为 MCP Server 提供微信数据库查询能力。
4
+
5
+ 用法(由 MCP Server 通过 child_process 调用):
6
+ python scripts/mcp_bridge.py list_sessions [--limit 30]
7
+ python scripts/mcp_bridge.py search_messages --keyword "遥感" --talker "张三" --days 30 --limit 20
8
+ python scripts/mcp_bridge.py get_todos [--days 30]
9
+ python scripts/mcp_bridge.py get_action_suggestions [--date 2026-05-15]
10
+ python scripts/mcp_bridge.py get_chat_stats [--period week]
11
+
12
+ 输出: JSON 格式
13
+ """
14
+
15
+ import sys, os, json, hashlib, argparse
16
+ from datetime import datetime, timezone, timedelta
17
+ from pathlib import Path
18
+
19
+ try:
20
+ from sqlcipher3 import dbapi2 as sqlcipher
21
+ except ImportError:
22
+ print(json.dumps({"error": "请安装: pip install sqlcipher3"}))
23
+ sys.exit(1)
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+ from _utils import load_config, decrypt_lock, parse_frontmatter
27
+
28
+ OUTPUT_ROOT = 'output'
29
+ TZ = timezone(timedelta(hours=8))
30
+
31
+
32
+ def json_output(data):
33
+ """Output as JSON."""
34
+ print(json.dumps(data, ensure_ascii=False, indent=2))
35
+
36
+
37
+ # ====== Database ======
38
+
39
+ def open_db(db_path, key_hex, salt_hex):
40
+ raw_key = f"x'{key_hex}{salt_hex}'"
41
+ conn = sqlcipher.connect(db_path)
42
+ c = conn.cursor()
43
+ c.execute(f'PRAGMA key = "{raw_key}";')
44
+ c.execute("SELECT count(*) FROM sqlite_master")
45
+ return conn
46
+
47
+
48
+ def get_name_map(contact_db, contact_key, contact_salt):
49
+ name_map = {}
50
+ if not contact_db or not contact_key or not os.path.exists(contact_db):
51
+ return name_map
52
+ try:
53
+ conn = open_db(contact_db, contact_key, contact_salt)
54
+ c = conn.cursor()
55
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), username) FROM contact")
56
+ for r in c.fetchall():
57
+ name_map[r[0]] = r[1]
58
+ conn.close()
59
+ except:
60
+ pass
61
+ return name_map
62
+
63
+
64
+ def get_biz_keys(config):
65
+ nt_db = config.get('ntDbPath', '')
66
+ msg_dir = os.path.dirname(nt_db.replace('\\', '/'))
67
+ wxid_dir = os.path.dirname(os.path.dirname(msg_dir))
68
+ contact_db = os.path.join(wxid_dir, 'db_storage', 'contact', 'contact.db')
69
+ contact_key_enc = config.get('contactKey', '')
70
+ contact_salt = config.get('contactSalt', '')
71
+ contact_key = decrypt_lock(contact_key_enc) if contact_key_enc else ''
72
+ biz_db = os.path.join(msg_dir, 'biz_message_0.db')
73
+ biz_key_enc = config.get('bizKey', '')
74
+ biz_salt = config.get('bizSalt', '')
75
+ if biz_key_enc and biz_salt:
76
+ biz_key = decrypt_lock(biz_key_enc)
77
+ else:
78
+ return {'error': '缺少 biz_message_0.db 密钥,请运行 python scripts/nt_decrypt.py scan --json'}
79
+ return {
80
+ 'biz_db': biz_db, 'biz_key': biz_key, 'biz_salt': biz_salt,
81
+ 'contact_db': contact_db, 'contact_key': contact_key, 'contact_salt': contact_salt,
82
+ }
83
+
84
+
85
+ def load_dbs():
86
+ """Load all databases and return connections + metadata."""
87
+ config = load_config()
88
+ nt_db = config.get('ntDbPath', '')
89
+ if not nt_db:
90
+ return None, None, None, None, {"error": "未初始化,请先运行 weflow-cli init"}
91
+
92
+ nt_key = decrypt_lock(config.get('ntKey', ''))
93
+ nt_salt = config.get('ntSalt', '')
94
+ keys = get_biz_keys(config)
95
+ name_map = get_name_map(keys['contact_db'], keys['contact_key'], keys['contact_salt'])
96
+
97
+ try:
98
+ conn = open_db(nt_db, nt_key, nt_salt)
99
+ except Exception as e:
100
+ return None, None, None, None, {"error": f"数据库连接失败: {e}"}
101
+
102
+ return conn, keys, name_map, nt_db, None
103
+
104
+
105
+ # ====== Commands ======
106
+
107
+ def cmd_list_sessions(args):
108
+ """List recent chat sessions."""
109
+ conn, keys, name_map, nt_db, err = load_dbs()
110
+ if err:
111
+ return err
112
+
113
+ c = conn.cursor()
114
+ limit = args.limit or 30
115
+ now = datetime.now(TZ)
116
+ start_ts = int((now - timedelta(days=90)).timestamp())
117
+ end_ts = int(now.timestamp())
118
+
119
+ try:
120
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
121
+ sessions = [r[0] for r in c.fetchall()]
122
+ except:
123
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
124
+ sessions = [r[0] for r in c.fetchall()]
125
+
126
+ results = []
127
+ for talker in sessions:
128
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
129
+ try:
130
+ c.execute(f'SELECT COUNT(*), MAX(create_time) FROM "{tbl}" WHERE create_time >= ? AND create_time < ?', (start_ts, end_ts))
131
+ count, last_ts = c.fetchone()
132
+ if count > 0:
133
+ name = name_map.get(talker, talker)
134
+ last_time = datetime.fromtimestamp(last_ts, tz=TZ).strftime('%m-%d %H:%M')
135
+ results.append({
136
+ "name": name,
137
+ "wxid": talker,
138
+ "count": count,
139
+ "last_active": last_time,
140
+ })
141
+ except:
142
+ pass
143
+
144
+ conn.close()
145
+ results.sort(key=lambda x: x['count'], reverse=True)
146
+ return {"sessions": results[:limit], "total": len(results)}
147
+
148
+
149
+ def cmd_search_messages(args):
150
+ """Search messages by keyword."""
151
+ conn, keys, name_map, nt_db, err = load_dbs()
152
+ if err:
153
+ return err
154
+
155
+ keyword = args.keyword or ''
156
+ talker_query = args.talker or ''
157
+ days = args.days or 30
158
+ limit = args.limit or 20
159
+
160
+ now = datetime.now(TZ)
161
+ start_ts = int((now - timedelta(days=days)).timestamp())
162
+ end_ts = int(now.timestamp())
163
+
164
+ c = conn.cursor()
165
+ try:
166
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
167
+ sessions = [r[0] for r in c.fetchall()]
168
+ except:
169
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
170
+ sessions = [r[0] for r in c.fetchall()]
171
+
172
+ # Filter by talker if specified
173
+ if talker_query:
174
+ filtered = []
175
+ for s in sessions:
176
+ name = name_map.get(s, s)
177
+ if talker_query in name or talker_query in s:
178
+ filtered.append(s)
179
+ sessions = filtered
180
+ if not sessions:
181
+ conn.close()
182
+ return {"results": [], "message": f"未找到联系人: {talker_query}"}
183
+
184
+ results = []
185
+ for talker in sessions[:50]: # Limit scan scope
186
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
187
+ try:
188
+ c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
189
+ if c.fetchone()[0] == 0:
190
+ continue
191
+
192
+ c.execute(f'''
193
+ SELECT create_time, real_sender_id, message_content
194
+ FROM "{tbl}"
195
+ WHERE create_time >= ? AND create_time < ?
196
+ ORDER BY create_time DESC
197
+ ''', (start_ts, end_ts))
198
+
199
+ for ts, sender, content in c.fetchall():
200
+ if not content or not isinstance(content, str):
201
+ continue
202
+ if keyword and keyword.lower() not in content.lower():
203
+ continue
204
+ sender_name = name_map.get(sender, sender) if sender else name_map.get(talker, talker)
205
+ dt = datetime.fromtimestamp(ts, tz=TZ)
206
+ results.append({
207
+ "talker": name_map.get(talker, talker),
208
+ "sender": sender_name,
209
+ "time": dt.strftime('%Y-%m-%d %H:%M'),
210
+ "content": content[:500],
211
+ })
212
+ if len(results) >= limit:
213
+ break
214
+ except:
215
+ pass
216
+ if len(results) >= limit:
217
+ break
218
+
219
+ conn.close()
220
+ results.sort(key=lambda x: x['time'], reverse=True)
221
+ return {"results": results, "total": len(results), "keyword": keyword, "days": days}
222
+
223
+
224
+ def cmd_get_todos(args):
225
+ """Get existing todo files."""
226
+ days = args.days or 30
227
+ todo_dir = Path(OUTPUT_ROOT)
228
+ results = []
229
+
230
+ # Find all 待办_*.md files
231
+ for f in sorted(todo_dir.glob('待办_*.md'), reverse=True):
232
+ content = f.read_text(encoding='utf-8')
233
+ # Extract todo items
234
+ items = []
235
+ for line in content.split('\n'):
236
+ line = line.strip()
237
+ if line.startswith('- [ ]') or line.startswith('- [x]'):
238
+ items.append(line)
239
+ if items:
240
+ results.append({
241
+ "file": f.name,
242
+ "items": items,
243
+ "count": len(items),
244
+ })
245
+
246
+ if not results:
247
+ return {"todos": [], "message": "暂无待办事项文件,请先运行 extract_todos.py"}
248
+
249
+ return {"todos": results}
250
+
251
+
252
+ def cmd_get_action_suggestions(args):
253
+ """Get action suggestion files."""
254
+ date = args.date or ''
255
+ daily_dir = Path(OUTPUT_ROOT) / 'biz-daily'
256
+
257
+ if not daily_dir.exists():
258
+ return {"suggestions": [], "message": "日报目录不存在"}
259
+
260
+ if date:
261
+ # Specific date
262
+ action_file = daily_dir / date / '行动建议.md'
263
+ if action_file.exists():
264
+ content = action_file.read_text(encoding='utf-8')
265
+ return {"date": date, "content": content}
266
+ return {"suggestions": [], "message": f"未找到 {date} 的行动建议"}
267
+ else:
268
+ # Latest
269
+ dates = sorted([d.name for d in daily_dir.iterdir() if d.is_dir()], reverse=True)
270
+ for d in dates[:7]: # Check last 7 days
271
+ action_file = daily_dir / d / '行动建议.md'
272
+ if action_file.exists():
273
+ content = action_file.read_text(encoding='utf-8')
274
+ return {"date": d, "content": content}
275
+ return {"suggestions": [], "message": "最近 7 天无行动建议"}
276
+
277
+
278
+ def cmd_get_chat_stats(args):
279
+ """Get chat statistics."""
280
+ conn, keys, name_map, nt_db, err = load_dbs()
281
+ if err:
282
+ return err
283
+
284
+ period = args.period or 'week'
285
+ now = datetime.now(TZ)
286
+
287
+ if period == 'week':
288
+ days_since_monday = now.weekday()
289
+ start_date = (now - timedelta(days=days_since_monday)).replace(hour=0, minute=0, second=0, microsecond=0)
290
+ else:
291
+ start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
292
+
293
+ start_ts = int(start_date.timestamp())
294
+ end_ts = int(now.timestamp())
295
+
296
+ c = conn.cursor()
297
+ try:
298
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1")
299
+ sessions = [r[0] for r in c.fetchall()]
300
+ except:
301
+ c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
302
+ sessions = [r[0] for r in c.fetchall()]
303
+
304
+ total_sent = 0
305
+ total_recv = 0
306
+ talker_counts = {}
307
+ hour_dist = [0] * 24
308
+
309
+ for talker in sessions:
310
+ tbl = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
311
+ try:
312
+ c.execute(f'SELECT COUNT(*) FROM sqlite_master WHERE name="{tbl}"')
313
+ if c.fetchone()[0] == 0:
314
+ continue
315
+ c.execute(f'SELECT create_time FROM "{tbl}" WHERE create_time >= ? AND create_time < ?', (start_ts, end_ts))
316
+ count = 0
317
+ for (ts,) in c.fetchall():
318
+ count += 1
319
+ dt = datetime.fromtimestamp(ts, tz=TZ)
320
+ hour_dist[dt.hour] += 1
321
+ if count > 0:
322
+ name = name_map.get(talker, talker)
323
+ talker_counts[name] = talker_counts.get(name, 0) + count
324
+ total_recv += count # Approximate
325
+ except:
326
+ pass
327
+
328
+ conn.close()
329
+
330
+ # Top talkers
331
+ top_talkers = sorted(talker_counts.items(), key=lambda x: x[1], reverse=True)[:10]
332
+
333
+ # Most active hour
334
+ most_active_hour = max(range(24), key=lambda h: hour_dist[h])
335
+
336
+ return {
337
+ "period": period,
338
+ "start_date": start_date.strftime('%Y-%m-%d'),
339
+ "end_date": now.strftime('%Y-%m-%d'),
340
+ "total_messages": sum(talker_counts.values()),
341
+ "active_contacts": len(talker_counts),
342
+ "top_talkers": [{"name": n, "count": c} for n, c in top_talkers],
343
+ "most_active_hour": f"{most_active_hour:02d}:00",
344
+ "hour_distribution": {f"{h:02d}:00": hour_dist[h] for h in range(24)},
345
+ }
346
+
347
+
348
+ # ====== Main ======
349
+
350
+ def main():
351
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
352
+ parser = argparse.ArgumentParser()
353
+ subparsers = parser.add_subparsers(dest='command')
354
+
355
+ # list_sessions
356
+ p = subparsers.add_parser('list_sessions')
357
+ p.add_argument('--limit', type=int, default=30)
358
+
359
+ # search_messages
360
+ p = subparsers.add_parser('search_messages')
361
+ p.add_argument('--keyword', default='')
362
+ p.add_argument('--talker', default='')
363
+ p.add_argument('--days', type=int, default=30)
364
+ p.add_argument('--limit', type=int, default=20)
365
+
366
+ # get_todos
367
+ p = subparsers.add_parser('get_todos')
368
+ p.add_argument('--days', type=int, default=30)
369
+
370
+ # get_action_suggestions
371
+ p = subparsers.add_parser('get_action_suggestions')
372
+ p.add_argument('--date', default='')
373
+
374
+ # get_chat_stats
375
+ p = subparsers.add_parser('get_chat_stats')
376
+ p.add_argument('--period', choices=['week', 'month'], default='week')
377
+
378
+ args = parser.parse_args()
379
+
380
+ handlers = {
381
+ 'list_sessions': cmd_list_sessions,
382
+ 'search_messages': cmd_search_messages,
383
+ 'get_todos': cmd_get_todos,
384
+ 'get_action_suggestions': cmd_get_action_suggestions,
385
+ 'get_chat_stats': cmd_get_chat_stats,
386
+ }
387
+
388
+ handler = handlers.get(args.command)
389
+ if not handler:
390
+ json_output({"error": f"未知命令: {args.command}", "available": list(handlers.keys())})
391
+ sys.exit(1)
392
+
393
+ result = handler(args)
394
+ json_output(result)
395
+
396
+
397
+ if __name__ == '__main__':
398
+ main()