diff --git a/export_all_chats.py b/export_all_chats.py
index b2326b6..866ac19 100644
--- a/export_all_chats.py
+++ b/export_all_chats.py
@@ -299,6 +299,12 @@ def main():
action="store_true",
help="预览模式:显示将导出的会话数和新消息数,不实际写入",
)
+ parser.add_argument(
+ "--users",
+ default=None,
+ help="只导出指定 username 的会话, 逗号分隔 (如 wxid_xxx,12345@chatroom). "
+ "为空时导出全部 (旧行为). 也可用 env WECHAT_EXPORT_USERS",
+ )
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -338,6 +344,18 @@ def main():
print(f"会话数据库查询失败: {e}", file=sys.stderr)
sys.exit(1)
+ # username 白名单过滤 (--users 参数 / WECHAT_EXPORT_USERS 环境变量)
+ users_filter_raw = args.users or os.environ.get("WECHAT_EXPORT_USERS", "")
+ if users_filter_raw.strip():
+ wanted = {u.strip() for u in users_filter_raw.split(",") if u.strip()}
+ before = len(sessions)
+ sessions = [u for u in sessions if u in wanted]
+ print(f"按 --users 过滤: {before} → {len(sessions)} 会话")
+ if not sessions:
+ print(f"[!] 指定的 username 列表跟会话表没交集 (wanted={list(wanted)[:5]}...)",
+ file=sys.stderr)
+ sys.exit(1)
+
names = mcp_server.get_contact_names()
# 显示模式信息
diff --git a/monitor_web.py b/monitor_web.py
index 5b04de7..58d353e 100644
--- a/monitor_web.py
+++ b/monitor_web.py
@@ -1782,6 +1782,45 @@ a.msg-link{text-decoration:none;color:inherit}
.add-rule-btn:hover{background:rgba(79,195,247,.2)}
/* 通知高亮 */
.msg.notify-hl{border-left:3px solid #ffd54f;background:rgba(255,213,79,.08);box-shadow:0 0 12px rgba(255,213,79,.1)}
+/* 导出筛选模态框 */
+.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);z-index:1100;align-items:center;justify-content:center}
+.modal-overlay.show{display:flex;animation:fadeIn .15s ease-out}
+.modal{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--r3);width:560px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column;box-shadow:var(--shadow-2);overflow:hidden}
+.modal-h{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
+.modal-h h2{font-size:var(--t4);color:var(--text);font-weight:600}
+.modal-close{background:none;border:none;color:var(--text-faint);font-size:20px;cursor:pointer;padding:4px 10px;border-radius:var(--r1);transition:all .15s}
+.modal-close:hover{color:var(--text);background:var(--surface)}
+.modal-b{padding:16px 20px;overflow-y:auto;flex:1}
+.modal-f{padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:8px;align-items:center;flex-shrink:0;background:rgba(0,0,0,.2)}
+.modal-search{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:var(--r1);padding:8px 12px;color:var(--text);font-size:var(--t3);outline:none;font-family:inherit;margin-bottom:var(--s3)}
+.modal-search:focus{border-color:var(--accent)}
+.modal-search::placeholder{color:var(--text-faint)}
+.session-list{max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--r1);background:rgba(0,0,0,.2)}
+.session-item{display:flex;align-items:center;gap:var(--s2);padding:8px 12px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.03);transition:background .1s;font-size:var(--t3)}
+.session-item:hover{background:var(--surface-hover)}
+.session-item:last-child{border-bottom:none}
+.session-item input[type=checkbox]{accent-color:var(--accent);width:14px;height:14px;cursor:pointer;margin:0}
+.session-type{font-size:10px;padding:2px 6px;border-radius:var(--r-pill);color:var(--text-dim);background:var(--surface);min-width:28px;text-align:center;flex-shrink:0}
+.session-type.grp{background:rgba(206,147,216,.12);color:#ce93d8}
+.session-type.single{background:rgba(79,195,247,.12);color:var(--accent)}
+.session-name{flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.session-ts{font-size:var(--t1);color:var(--text-faint);flex-shrink:0;font-family:Consolas,monospace}
+.modal-selctrl{display:flex;gap:var(--s2);margin:var(--s3) 0;flex-wrap:wrap}
+.modal-selctrl button{background:var(--surface);border:1px solid var(--border);color:var(--text-dim);padding:5px 10px;border-radius:var(--r1);font-size:var(--t1);cursor:pointer;transition:all .15s;font-family:inherit}
+.modal-selctrl button:hover{background:var(--surface-hover);color:var(--text)}
+.modal-section{margin-top:var(--s4)}
+.modal-section-label{font-size:var(--t1);color:var(--text-faint);margin-bottom:var(--s2);text-transform:uppercase;letter-spacing:1.2px;font-weight:600}
+.modal-fmt{display:flex;gap:var(--s4);flex-wrap:wrap}
+.modal-fmt label{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:var(--t3);color:var(--text)}
+.modal-fmt input{accent-color:var(--accent);width:14px;height:14px;cursor:pointer}
+.modal-selcount{color:var(--text-dim);font-size:var(--t2)}
+.modal-btn{padding:8px 18px;border-radius:var(--r1);font-size:var(--t3);cursor:pointer;border:none;font-family:inherit;font-weight:500;transition:all .15s}
+.modal-btn.secondary{background:var(--surface);color:var(--text-dim);border:1px solid var(--border)}
+.modal-btn.secondary:hover{background:var(--surface-hover);color:var(--text)}
+.modal-btn.primary{background:linear-gradient(135deg,#4fc3f7,#29b6f6);color:#001528;font-weight:600;box-shadow:0 4px 14px rgba(79,195,247,.35)}
+.modal-btn.primary:hover:not(:disabled){background:linear-gradient(135deg,#5fd0ff,#3fc4ff);transform:translateY(-1px)}
+.modal-btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
+.modal-loading{text-align:center;color:var(--text-faint);padding:30px;font-size:var(--t3)}
/* Icon 通用样式 (替代 emoji) */
.i{width:16px;height:16px;display:inline-block;vertical-align:-3px;flex-shrink:0;color:inherit}
.i-sm{width:13px;height:13px;vertical-align:-2px}
@@ -1957,6 +1996,39 @@ a.msg-link{text-decoration:none;color:inherit}
![]()
+
+
+
+
+
导出聊天
+
+
+
+
+
+
+
+
+
+ 已选 0 个
+
+
+
+
+
+
+
+
+
@@ -2090,16 +2162,111 @@ async function cancelTool(){
await fetch('/api/tool/cancel',{method:'POST'});
}catch(e){}
}
+
+// —— 导出筛选模态框 ——
+window.__exportCtx = { source: null, task: null, btn: null, sessions: [] };
+
+async function openExportModal(modalKind, task, btn){
+ const source = modalKind === 'export_wxwork' ? 'wxwork' : 'wechat';
+ window.__exportCtx = { source, task, btn, sessions: [] };
+ document.getElementById('exportModalTitle').textContent =
+ source === 'wxwork' ? '导出企业微信聊天' : '导出个人微信聊天';
+ document.getElementById('exportSearch').value = '';
+ // 企微脚本支持 --formats, 个人微信脚本目前只 JSON; 隐藏个人微信的格式选项
+ document.getElementById('exportFmtSection').style.display = source === 'wxwork' ? 'block' : 'none';
+ document.getElementById('exportConfirmBtn').disabled = true;
+ document.getElementById('exportSelCount').textContent = '已选 0 个';
+ document.getElementById('exportSessionList').innerHTML = '加载会话列表...
';
+ document.getElementById('exportModal').classList.add('show');
+ try{
+ const r = await fetch('/api/sessions?source=' + source);
+ const sessions = await r.json();
+ if(sessions.error) throw new Error(sessions.error);
+ window.__exportCtx.sessions = sessions;
+ renderSessions(sessions, '');
+ }catch(e){
+ document.getElementById('exportSessionList').innerHTML =
+ '加载失败: ' + esc(e.message) + '
';
+ }
+}
+function closeExportModal(){
+ document.getElementById('exportModal').classList.remove('show');
+}
+function renderSessions(sessions, filter){
+ const list = document.getElementById('exportSessionList');
+ const lo = filter.toLowerCase();
+ const filtered = sessions.filter(s =>
+ !lo || s.name.toLowerCase().includes(lo) || (s.username||'').toLowerCase().includes(lo)
+ );
+ if(!filtered.length){
+ list.innerHTML = '没匹配的会话
';
+ return;
+ }
+ list.innerHTML = filtered.map((s, idx) => {
+ const tsLabel = s.last_ts ? new Date(s.last_ts*1000).toISOString().slice(0,10) : '';
+ const typeCls = s.type === '群' ? 'grp' : (s.type === '单聊' ? 'single' : '');
+ return ``;
+ }).join('');
+}
+function filterSessions(){
+ renderSessions(window.__exportCtx.sessions, document.getElementById('exportSearch').value);
+ updateSelCount(); // refilter 后保留 selection 但 count 重新统计当前可见的
+}
+function updateSelCount(){
+ const checked = document.querySelectorAll('#exportSessionList input[type=checkbox]:checked');
+ document.getElementById('exportSelCount').textContent = '已选 ' + checked.length + ' 个';
+ document.getElementById('exportConfirmBtn').disabled = checked.length === 0;
+}
+function selectAllSessions(yes){
+ document.querySelectorAll('#exportSessionList input[type=checkbox]').forEach(c => c.checked = yes);
+ updateSelCount();
+}
+function selectRecentSessions(days){
+ const cutoff = Date.now()/1000 - days*86400;
+ const list = window.__exportCtx.sessions;
+ document.querySelectorAll('#exportSessionList input[type=checkbox]').forEach(c => {
+ const u = c.dataset.username;
+ const s = list.find(x => x.username === u);
+ c.checked = s && s.last_ts >= cutoff;
+ });
+ updateSelCount();
+}
+function confirmExport(){
+ const users = [...document.querySelectorAll('#exportSessionList input[type=checkbox]:checked')]
+ .map(c => c.dataset.username);
+ const formats = [...document.querySelectorAll('#exportFmtSection input[type=checkbox]:checked')]
+ .map(c => c.value);
+ closeExportModal();
+ // 实际触发任务,带 args
+ const { task, btn } = window.__exportCtx;
+ runToolWithArgs(task, btn, { users, formats });
+}
+// 需要弹模态框先筛选会话的任务
+const NEEDS_MODAL = { 'export_all': 'export_wechat', 'wxwork_export': 'export_wxwork' };
+
async function runTool(task, btn){
- // 如果已经在运行 (按钮变成 cancel) → 取消
+ // 取消已运行任务
if(btn.classList.contains('cancel')){
btn.disabled = true;
btn.textContent = '终止中...';
cancelTool();
return;
}
+ // 导出类任务先弹模态框
+ if(NEEDS_MODAL[task]){
+ openExportModal(NEEDS_MODAL[task], task, btn);
+ return;
+ }
+ runToolWithArgs(task, btn, null);
+}
+
+async function runToolWithArgs(task, btn, args){
const s=document.getElementById('toolStatus');
- // 禁用其他按钮, 当前按钮改成"终止"
document.querySelectorAll('.tool-task-btn').forEach(b=>{
if(b!==btn) b.disabled=true;
});
@@ -2108,22 +2275,22 @@ async function runTool(task, btn){
btn.innerHTML = ' 终止';
btn.classList.add('cancel');
window.__runningBtn = btn;
- // 清当前 pane 的日志框
const L=document.getElementById('toolLog_'+window.__activeToolPane);
if(L) L.textContent='';
s.style.display='inline-block';
s.className='tool-status running';
s.innerHTML=' 运行中: '+esc(btn.dataset.origText.trim());
try{
- const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:task})});
+ const payload = { task: task };
+ if(args) payload.args = args;
+ const r=await fetch('/api/tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json();
if(!r.ok){
s.className='tool-status err';
s.textContent='✗ '+(d.error||'启动失败');
- // 启动失败立刻还原
document.querySelectorAll('.tool-task-btn').forEach(b=>{
b.disabled=false;
- if(b.dataset.origText){b.textContent = b.dataset.origText; b.dataset.origText='';}
+ if(b.dataset.origHtml){b.innerHTML = b.dataset.origHtml; b.dataset.origHtml=''; b.dataset.origText='';}
b.classList.remove('cancel');
});
}
@@ -2335,6 +2502,30 @@ fetch('/api/history').then(r=>r.json()).then(ms=>{
# 前端在 HTML_PAGE 顶部加一个折叠区,点按钮 POST /api/tool {task: "..."}。
# SSE 事件用 event=tool_log / tool_done 跟原 message 事件区分。
+def _build_export_steps(users, formats):
+ """根据用户选择的会话 + 格式拼 export_all_chats.py argv"""
+ cmd = [sys.executable, "export_all_chats.py"]
+ if users:
+ cmd += ["--users", ",".join(users)]
+ # export_all_chats 当前只输出 JSON, formats 暂时忽略
+ # (export_messages.py 有 CSV/HTML/JSON, 但走的是另一条路径, 不在 v1 范围)
+ return [cmd]
+
+
+def _build_wxwork_export_steps(users, formats):
+ """根据用户选择拼 export_wxwork_messages.py argv (--conversation 可重复)"""
+ cmd = [sys.executable, "export_wxwork_messages.py"]
+ for u in (users or []):
+ cmd += ["--conversation", u]
+ if formats:
+ cmd += ["--formats", ",".join(formats)]
+ return [cmd]
+
+
+# task 配置:
+# steps — 固定 cmd 列表 (无参任务)
+# build_steps — fn(args)->[cmd, ...] 动态构造 (需要用户选会话/格式的导出任务)
+# needs_modal — 前端点这个 task 时要弹模态框 ('export_wechat' | 'export_wxwork')
TOOL_TASKS = {
# —— 个人微信 ——
"wechat_decrypt": {
@@ -2346,8 +2537,9 @@ TOOL_TASKS = {
"steps": [[sys.executable, "find_image_key.py"]],
},
"export_all": {
- "name": "③ 导出全部聊天 (JSON)",
- "steps": [[sys.executable, "export_all_chats.py"]],
+ "name": "③ 导出聊天",
+ "build_steps": _build_export_steps,
+ "needs_modal": "export_wechat",
},
"decode_images": {
"name": "④ 批量解密图片",
@@ -2371,7 +2563,8 @@ TOOL_TASKS = {
},
"wxwork_export": {
"name": "⑦ 企业微信导出",
- "steps": [[sys.executable, "export_wxwork_messages.py"]],
+ "build_steps": _build_wxwork_export_steps,
+ "needs_modal": "export_wxwork",
},
# —— 工具 ——
"voice_mp3": {
@@ -2384,13 +2577,87 @@ _tool_lock = threading.Lock()
_tool_running = {"job": None, "proc": None, "cancelled": False} # 同时只允许一个任务
+def _list_sessions(source):
+ """列会话, 供导出筛选模态框消费。返回 [{name, username, type, last_ts}]
+ 按 last_ts 降序。
+
+ source:
+ wechat — 个人微信, 从 decrypted/session/session.db 读 SessionTable
+ wxwork — 企业微信, 从 wxwork_decrypted/session.db 读 conversation_table
+ """
+ out = []
+ if source == "wechat":
+ if not os.path.exists(DECRYPTED_SESSION):
+ return []
+ # 复用 load_contact_names (静态快照即可, 模态框开时调一次性 OK)
+ names = load_contact_names()
+ try:
+ with closing(sqlite3.connect(f"file:{DECRYPTED_SESSION}?mode=ro&immutable=1", uri=True)) as conn:
+ for r in conn.execute(
+ "SELECT username, type, last_timestamp, last_sender_display_name, summary "
+ "FROM SessionTable WHERE username IS NOT NULL AND username != '' "
+ "ORDER BY last_timestamp DESC"
+ ):
+ username, type_, ts, sender, summary = r
+ is_group = username.endswith("@chatroom") if username else False
+ is_public = username.startswith("gh_") if username else False
+ type_label = "群" if is_group else ("公众号" if is_public else "单聊")
+ name = names.get(username) or sender or username
+ out.append({
+ "username": username,
+ "name": name,
+ "type": type_label,
+ "last_ts": ts or 0,
+ "summary": (summary or "")[:60],
+ })
+ except Exception:
+ pass
+ elif source == "wxwork":
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ path = os.path.join(script_dir, "wxwork_decrypted", "session.db")
+ if not os.path.exists(path):
+ return []
+ try:
+ with closing(sqlite3.connect(f"file:{path}?mode=ro&immutable=1", uri=True)) as conn:
+ conn.text_factory = lambda b: b.decode("utf-8", errors="replace") if isinstance(b, bytes) else b
+ for r in conn.execute(
+ "SELECT id, name, last_message_time FROM conversation_table "
+ "WHERE id IS NOT NULL AND id != '' "
+ "ORDER BY last_message_time DESC"
+ ):
+ cid, name, ts = r
+ # id 前缀: R=群 / S=单聊 / E=外部/系统 / Y=其他
+ if cid.startswith("R:"):
+ type_label = "群"
+ elif cid.startswith("S:"):
+ type_label = "单聊"
+ elif cid.startswith("E:"):
+ type_label = "外部"
+ else:
+ type_label = "其他"
+ out.append({
+ "username": cid,
+ "name": name or cid,
+ "type": type_label,
+ "last_ts": ts or 0,
+ "summary": "",
+ })
+ except Exception:
+ pass
+ return out
+
+
def _broadcast_tool_event(event, **fields):
payload = {"event": event, **fields}
broadcast_sse(payload)
-def _run_tool_task(job_id, task_name):
- """后台线程: 顺序跑 TOOL_TASKS[task_name].steps 的每条命令,实时推 SSE。"""
+def _run_tool_task(job_id, task_name, args=None):
+ """后台线程: 顺序跑 TOOL_TASKS[task_name].steps 的每条命令,实时推 SSE。
+
+ args 含用户在前端模态框选的参数 (users / formats 等), 由 build_steps
+ 动态构造 cmd。固定任务 (无 build_steps) 直接用 steps。
+ """
task = TOOL_TASKS.get(task_name)
if not task:
_broadcast_tool_event("tool_done", job_id=job_id, ok=False,
@@ -2399,12 +2666,19 @@ def _run_tool_task(job_id, task_name):
_tool_running["job"] = None
return
+ # 动态构造 steps (导出类) 或用固定 steps
+ if "build_steps" in task:
+ a = args or {}
+ steps = task["build_steps"](a.get("users", []), a.get("formats", []))
+ else:
+ steps = task["steps"]
+
_broadcast_tool_event("tool_log", job_id=job_id,
line=f"━━━ 开始: {task['name']} ━━━\n")
exit_code = 0
cancelled = False
- for step in task["steps"]:
+ for step in steps:
cmd_str = " ".join(step)
_broadcast_tool_event("tool_log", job_id=job_id,
line=f"\n>>> {cmd_str}\n\n")
@@ -2575,6 +2849,23 @@ class Handler(BaseHTTPRequestHandler):
with sse_lock:
if q in sse_clients:
sse_clients.remove(q)
+ elif self.path == "/api/sessions" or self.path.startswith("/api/sessions?"):
+ # 列会话(用于导出筛选模态框)
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+ source = params.get("source", ["wechat"])[0]
+ try:
+ sessions = _list_sessions(source)
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(json.dumps(sessions, ensure_ascii=False).encode())
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": str(e)}).encode())
+
else:
self.send_error(404)
@@ -2585,6 +2876,7 @@ class Handler(BaseHTTPRequestHandler):
body = self.rfile.read(length).decode("utf-8") if length else "{}"
req = json.loads(body)
task_name = req.get("task", "")
+ task_args = req.get("args", {}) or {}
except Exception as e:
self.send_response(400)
self.send_header("Content-Type", "application/json")
@@ -2613,7 +2905,8 @@ class Handler(BaseHTTPRequestHandler):
job_id = "j_" + uuid.uuid4().hex[:8]
_tool_running["job"] = job_id
- threading.Thread(target=_run_tool_task, args=(job_id, task_name),
+ threading.Thread(target=_run_tool_task,
+ args=(job_id, task_name, task_args),
daemon=True).start()
self.send_response(200)