feat: 新增消息发送方向过滤、日志打点与发送稳定性优化
1. 为消息拉取/搜索接口新增is_sender参数,支持按发送方向过滤消息 2. 为send_text接口添加全链路耗时打点与详细日志 3. 为发送队列添加限流、执行状态日志与耗时统计 4. 修复微信4.x自绘UI输入框焦点问题,新增点击输入框聚焦逻辑 5. 为xdotool命令添加超时保护,避免协程永久阻塞 6. 重构数据库查询逻辑,在SQL层提前过滤发送方向消息,减少数据传输
This commit is contained in:
parent
e490c51510
commit
5d84df902a
@ -611,6 +611,46 @@ class DbReader:
|
|||||||
pass
|
pass
|
||||||
return id_to_username
|
return id_to_username
|
||||||
|
|
||||||
|
def _resolve_self_rowid(
|
||||||
|
self, conn: sqlite3.Connection, self_wxid: str
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""返回 self_wxid 在 Name2Id 表中的 rowid,用于 SQL 层按发送方向过滤。
|
||||||
|
|
||||||
|
找不到时返回 None;调用方据此决定是否短路返回空结果
|
||||||
|
(is_sender 非 None 但无法定位本人时无法准确过滤方向)。
|
||||||
|
"""
|
||||||
|
if not self_wxid:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
|
||||||
|
(self_wxid,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
return int(row[0])
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_sender_filter(
|
||||||
|
is_sender: Optional[bool], self_rowid: Optional[int],
|
||||||
|
) -> tuple[str, list]:
|
||||||
|
"""构造发送方向 SQL 过滤片段。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_sender: True=仅本人发送 / False=仅对方发送 / None=不过滤
|
||||||
|
self_rowid: 当前账号在 Name2Id 中的 rowid
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(sql_fragment, params):sql_fragment 为 "" 表示无需过滤;
|
||||||
|
否则形如 "real_sender_id = ?" 或 "real_sender_id != ?"
|
||||||
|
"""
|
||||||
|
if is_sender is None or self_rowid is None:
|
||||||
|
return "", []
|
||||||
|
op = "=" if is_sender else "!="
|
||||||
|
return f"real_sender_id {op} ?", [self_rowid]
|
||||||
|
|
||||||
def _resolve_msg_table_talkers(self, conn: sqlite3.Connection) -> dict[str, str]:
|
def _resolve_msg_table_talkers(self, conn: sqlite3.Connection) -> dict[str, str]:
|
||||||
"""解析所有 Msg_* 分片表名到 talker username 的映射。
|
"""解析所有 Msg_* 分片表名到 talker username 的映射。
|
||||||
|
|
||||||
@ -709,11 +749,24 @@ class DbReader:
|
|||||||
"session_type": session_type,
|
"session_type": session_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_messages_since(self, cursor: int, limit: int = 50) -> dict:
|
def get_messages_since(
|
||||||
|
self,
|
||||||
|
cursor: int,
|
||||||
|
limit: int = 50,
|
||||||
|
is_sender: Optional[bool] = None,
|
||||||
|
) -> dict:
|
||||||
"""读取 create_time > cursor 的增量消息。
|
"""读取 create_time > cursor 的增量消息。
|
||||||
|
|
||||||
WeChat 4.x 消息存储在 Msg_<MD5(talker)> 分片表中,每个会话一张表。
|
WeChat 4.x 消息存储在 Msg_<MD5(talker)> 分片表中,每个会话一张表。
|
||||||
遍历所有分片表合并结果后按 create_time 排序返回。
|
遍历所有分片表合并结果后按 create_time 排序返回。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cursor: 上次拉取的最大 create_time
|
||||||
|
limit: 最多返回条数
|
||||||
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。SQL 层通过 real_sender_id 与
|
||||||
|
Name2Id 中 self_wxid 的 rowid 比对实现,避免读出再裁剪。
|
||||||
|
当 is_sender 非 None 但无法定位本人 rowid 时返回空结果。
|
||||||
"""
|
"""
|
||||||
db_path = self._ensure_decrypted("message/message_0.db")
|
db_path = self._ensure_decrypted("message/message_0.db")
|
||||||
conn = sqlite3.connect(db_path, isolation_level=None)
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
||||||
@ -728,15 +781,36 @@ class DbReader:
|
|||||||
id_to_username = self._load_name2id(conn)
|
id_to_username = self._load_name2id(conn)
|
||||||
self_wxid = self._get_current_wxid() or ""
|
self_wxid = self._get_current_wxid() or ""
|
||||||
|
|
||||||
|
# 发送方向过滤:无法定位本人 rowid 时短路返回空
|
||||||
|
sender_sql: str = ""
|
||||||
|
sender_params: list = []
|
||||||
|
if is_sender is not None:
|
||||||
|
self_rowid = self._resolve_self_rowid(conn, self_wxid)
|
||||||
|
sender_sql, sender_params = self._build_sender_filter(
|
||||||
|
is_sender, self_rowid
|
||||||
|
)
|
||||||
|
if not sender_sql:
|
||||||
|
return {
|
||||||
|
"messages": [],
|
||||||
|
"next_cursor": cursor,
|
||||||
|
"has_more": False,
|
||||||
|
}
|
||||||
|
|
||||||
all_rows: list[dict] = []
|
all_rows: list[dict] = []
|
||||||
for table_name, talker in table_to_talker.items():
|
for table_name, talker in table_to_talker.items():
|
||||||
cur = conn.execute(
|
where_parts = ["create_time > ?"]
|
||||||
|
params: list = [cursor]
|
||||||
|
if sender_sql:
|
||||||
|
where_parts.append(sender_sql)
|
||||||
|
params.extend(sender_params)
|
||||||
|
sql = (
|
||||||
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
||||||
f"message_content, WCDB_CT_message_content "
|
f"message_content, WCDB_CT_message_content "
|
||||||
f"FROM [{table_name}] WHERE create_time > ? "
|
f"FROM [{table_name}] WHERE {' AND '.join(where_parts)} "
|
||||||
f"ORDER BY create_time ASC LIMIT ?",
|
f"ORDER BY create_time ASC LIMIT ?"
|
||||||
(cursor, limit),
|
|
||||||
)
|
)
|
||||||
|
params.append(limit)
|
||||||
|
cur = conn.execute(sql, tuple(params))
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
all_rows.append(self._row_to_message_dict(
|
all_rows.append(self._row_to_message_dict(
|
||||||
row, talker, id_to_username, self_wxid
|
row, talker, id_to_username, self_wxid
|
||||||
@ -761,6 +835,7 @@ class DbReader:
|
|||||||
cursor: int = 0,
|
cursor: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
direction: str = "before",
|
direction: str = "before",
|
||||||
|
is_sender: Optional[bool] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
||||||
|
|
||||||
@ -774,6 +849,8 @@ class DbReader:
|
|||||||
after 模式取该时间之后的新消息
|
after 模式取该时间之后的新消息
|
||||||
limit: 最多返回条数,1~200
|
limit: 最多返回条数,1~200
|
||||||
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
||||||
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。无法定位本人 rowid 时返回空结果。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"messages": [...], "next_cursor": int, "has_more": bool, "talker": str}
|
{"messages": [...], "next_cursor": int, "has_more": bool, "talker": str}
|
||||||
@ -803,32 +880,46 @@ class DbReader:
|
|||||||
id_to_username = self._load_name2id(conn)
|
id_to_username = self._load_name2id(conn)
|
||||||
self_wxid = self._get_current_wxid() or ""
|
self_wxid = self._get_current_wxid() or ""
|
||||||
|
|
||||||
|
# 发送方向过滤:无法定位本人 rowid 时短路返回空
|
||||||
|
sender_sql: str = ""
|
||||||
|
sender_params: list = []
|
||||||
|
if is_sender is not None:
|
||||||
|
self_rowid = self._resolve_self_rowid(conn, self_wxid)
|
||||||
|
sender_sql, sender_params = self._build_sender_filter(
|
||||||
|
is_sender, self_rowid
|
||||||
|
)
|
||||||
|
if not sender_sql:
|
||||||
|
return {
|
||||||
|
"messages": [],
|
||||||
|
"next_cursor": cursor,
|
||||||
|
"has_more": False,
|
||||||
|
"talker": talker,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 收集 WHERE 片段:时间过滤 + 发送方向过滤
|
||||||
|
where_parts: list[str] = []
|
||||||
|
time_params: list = []
|
||||||
if direction == "after":
|
if direction == "after":
|
||||||
where_sql = "create_time > ?"
|
where_parts.append("create_time > ?")
|
||||||
|
time_params.append(cursor)
|
||||||
order_sql = "create_time ASC"
|
order_sql = "create_time ASC"
|
||||||
else:
|
else:
|
||||||
# before 模式:cursor=0 时不加时间过滤(取最新 limit 条)
|
# before 模式:cursor=0 时不加时间过滤(取最新 limit 条)
|
||||||
if cursor > 0:
|
if cursor > 0:
|
||||||
where_sql = "create_time < ?"
|
where_parts.append("create_time < ?")
|
||||||
else:
|
time_params.append(cursor)
|
||||||
where_sql = "1=1"
|
|
||||||
order_sql = "create_time DESC"
|
order_sql = "create_time DESC"
|
||||||
|
if sender_sql:
|
||||||
|
where_parts.append(sender_sql)
|
||||||
|
|
||||||
if cursor > 0 or direction == "after":
|
where_clause = " AND ".join(where_parts) if where_parts else "1=1"
|
||||||
sql = (
|
sql = (
|
||||||
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
||||||
f"message_content, WCDB_CT_message_content "
|
f"message_content, WCDB_CT_message_content "
|
||||||
f"FROM [{table_name}] WHERE {where_sql} "
|
f"FROM [{table_name}] WHERE {where_clause} "
|
||||||
f"ORDER BY {order_sql} LIMIT ?"
|
f"ORDER BY {order_sql} LIMIT ?"
|
||||||
)
|
)
|
||||||
params: tuple = (cursor, limit)
|
params: tuple = (*time_params, *sender_params, limit)
|
||||||
else:
|
|
||||||
sql = (
|
|
||||||
f"SELECT local_id, local_type, create_time, real_sender_id, "
|
|
||||||
f"message_content, WCDB_CT_message_content "
|
|
||||||
f"FROM [{table_name}] ORDER BY {order_sql} LIMIT ?"
|
|
||||||
)
|
|
||||||
params = (limit,)
|
|
||||||
|
|
||||||
cur = conn.execute(sql, params)
|
cur = conn.execute(sql, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
@ -864,6 +955,7 @@ class DbReader:
|
|||||||
start_time: Optional[int] = None,
|
start_time: Optional[int] = None,
|
||||||
end_time: Optional[int] = None,
|
end_time: Optional[int] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
is_sender: Optional[bool] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""按关键词搜索历史消息。
|
"""按关键词搜索历史消息。
|
||||||
|
|
||||||
@ -876,6 +968,8 @@ class DbReader:
|
|||||||
start_time: 可选,起始时间戳(含)
|
start_time: 可选,起始时间戳(含)
|
||||||
end_time: 可选,结束时间戳(含)
|
end_time: 可选,结束时间戳(含)
|
||||||
limit: 最多返回条数,1~200
|
limit: 最多返回条数,1~200
|
||||||
|
is_sender: 发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。无法定位本人 rowid 时返回空结果。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"messages": [...], "total": int}
|
{"messages": [...], "total": int}
|
||||||
@ -900,6 +994,17 @@ class DbReader:
|
|||||||
id_to_username = self._load_name2id(conn)
|
id_to_username = self._load_name2id(conn)
|
||||||
self_wxid = self._get_current_wxid() or ""
|
self_wxid = self._get_current_wxid() or ""
|
||||||
|
|
||||||
|
# 发送方向过滤:无法定位本人 rowid 时短路返回空
|
||||||
|
sender_sql: str = ""
|
||||||
|
sender_params: list = []
|
||||||
|
if is_sender is not None:
|
||||||
|
self_rowid = self._resolve_self_rowid(conn, self_wxid)
|
||||||
|
sender_sql, sender_params = self._build_sender_filter(
|
||||||
|
is_sender, self_rowid
|
||||||
|
)
|
||||||
|
if not sender_sql:
|
||||||
|
return {"messages": [], "total": 0}
|
||||||
|
|
||||||
# 构建 WHERE 子句(LIKE 转义 % _ \ 为字面量)
|
# 构建 WHERE 子句(LIKE 转义 % _ \ 为字面量)
|
||||||
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
escaped = keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
where_clauses = ["message_content LIKE ? ESCAPE '\\'"]
|
where_clauses = ["message_content LIKE ? ESCAPE '\\'"]
|
||||||
@ -910,6 +1015,9 @@ class DbReader:
|
|||||||
if end_time is not None:
|
if end_time is not None:
|
||||||
where_clauses.append("create_time <= ?")
|
where_clauses.append("create_time <= ?")
|
||||||
params.append(end_time)
|
params.append(end_time)
|
||||||
|
if sender_sql:
|
||||||
|
where_clauses.append(sender_sql)
|
||||||
|
params.extend(sender_params)
|
||||||
where_sql = " AND ".join(where_clauses)
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
# 确定要搜索的表集合
|
# 确定要搜索的表集合
|
||||||
|
|||||||
@ -7,12 +7,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from woc_bridge.models import BridgeError
|
from woc_bridge.models import BridgeError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("woc-bridge")
|
||||||
|
|
||||||
# 工厂类型:返回一个待执行的 coroutine
|
# 工厂类型:返回一个待执行的 coroutine
|
||||||
CoroFactory = Callable[[], Awaitable[Any]]
|
CoroFactory = Callable[[], Awaitable[Any]]
|
||||||
|
|
||||||
@ -73,6 +76,7 @@ class SendQueue:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
future: asyncio.Future = loop.create_future()
|
future: asyncio.Future = loop.create_future()
|
||||||
await self._queue.put((coro_factory, future))
|
await self._queue.put((coro_factory, future))
|
||||||
|
logger.info("send_queue: 入队 (pending=%d)", self._queue.qsize())
|
||||||
return await future
|
return await future
|
||||||
|
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
@ -93,6 +97,10 @@ class SendQueue:
|
|||||||
# 计算建议等待秒数:最早一次调用距窗口边界还差多久
|
# 计算建议等待秒数:最早一次调用距窗口边界还差多久
|
||||||
oldest = self._recent_call_times[0]
|
oldest = self._recent_call_times[0]
|
||||||
retry_after = max(1, int(1.0 - (now - oldest)) + 1)
|
retry_after = max(1, int(1.0 - (now - oldest)) + 1)
|
||||||
|
logger.warning(
|
||||||
|
"send_queue: 限流命中,拒绝执行 (recent=%d/%d, retry_after=%ds)",
|
||||||
|
len(self._recent_call_times), self.max_calls_per_sec, retry_after,
|
||||||
|
)
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="RATE_LIMITED",
|
code="RATE_LIMITED",
|
||||||
message=f"发送限流:每秒最多 {self.max_calls_per_sec} 次",
|
message=f"发送限流:每秒最多 {self.max_calls_per_sec} 次",
|
||||||
@ -108,8 +116,10 @@ class SendQueue:
|
|||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
coro_factory, future = await self._queue.get()
|
coro_factory, future = await self._queue.get()
|
||||||
|
logger.info("send_queue: 出队,开始处理 (pending=%d)", self._queue.qsize())
|
||||||
# 标记任务是否真正开始执行(用于决定 finally 是否延时)
|
# 标记任务是否真正开始执行(用于决定 finally 是否延时)
|
||||||
executed = False
|
executed = False
|
||||||
|
t_exec = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
# 执行前检查限流
|
# 执行前检查限流
|
||||||
self._check_rate_limit()
|
self._check_rate_limit()
|
||||||
@ -117,7 +127,12 @@ class SendQueue:
|
|||||||
self._recent_call_times.append(time.monotonic())
|
self._recent_call_times.append(time.monotonic())
|
||||||
executed = True
|
executed = True
|
||||||
# 执行任务
|
# 执行任务
|
||||||
|
logger.info("send_queue: 开始执行任务")
|
||||||
result = await coro_factory()
|
result = await coro_factory()
|
||||||
|
logger.info(
|
||||||
|
"send_queue: 任务执行完成 (%.0fms)",
|
||||||
|
(time.perf_counter() - t_exec) * 1000,
|
||||||
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(result)
|
future.set_result(result)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@ -126,10 +141,18 @@ class SendQueue:
|
|||||||
future.cancel()
|
future.cancel()
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"send_queue: 任务执行抛异常 %s: %s (%.0fms)",
|
||||||
|
type(e).__name__, e, (time.perf_counter() - t_exec) * 1000,
|
||||||
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(e)
|
future.set_exception(e)
|
||||||
finally:
|
finally:
|
||||||
self._queue.task_done()
|
self._queue.task_done()
|
||||||
# 仅在任务真正执行过时延时,限流失败的任务不延时
|
# 仅在任务真正执行过时延时,限流失败的任务不延时
|
||||||
if executed:
|
if executed:
|
||||||
|
logger.info(
|
||||||
|
"send_queue: 延时 %dms 后处理下一个",
|
||||||
|
self.send_delay_ms,
|
||||||
|
)
|
||||||
await asyncio.sleep(self.send_delay_ms / 1000.0)
|
await asyncio.sleep(self.send_delay_ms / 1000.0)
|
||||||
|
|||||||
@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from woc_bridge.config import _require_db_reader, _require_message_streamer
|
from woc_bridge.config import _require_db_reader, _require_message_streamer
|
||||||
@ -22,7 +23,14 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/api/messages/since", response_model=MessagesResponse)
|
@router.get("/api/messages/since", response_model=MessagesResponse)
|
||||||
@with_db_retry
|
@with_db_retry
|
||||||
async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesResponse:
|
async def get_messages_since(
|
||||||
|
cursor: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
is_sender: Optional[bool] = Query(
|
||||||
|
None,
|
||||||
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
||||||
|
),
|
||||||
|
) -> MessagesResponse:
|
||||||
"""增量消息拉取(按 create_time 游标)。
|
"""增量消息拉取(按 create_time 游标)。
|
||||||
|
|
||||||
channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0),
|
channels PullerAdapter 的核心接口:调用方维护本地 cursor(首次为 0),
|
||||||
@ -33,6 +41,8 @@ async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesRespon
|
|||||||
cursor: 上次拉取的最大 create_time,首次传 0(拉全量)
|
cursor: 上次拉取的最大 create_time,首次传 0(拉全量)
|
||||||
limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的
|
limit: 最多返回条数,1~200,默认 50。建议用 /api/status 返回的
|
||||||
max_batch_size 作为上限参考
|
max_batch_size 作为上限参考
|
||||||
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
MessagesResponse:含 messages 列表 / next_cursor / has_more
|
MessagesResponse:含 messages 列表 / next_cursor / has_more
|
||||||
@ -66,11 +76,13 @@ async def get_messages_since(cursor: int = 0, limit: int = 50) -> MessagesRespon
|
|||||||
await _check_db_readable()
|
await _check_db_readable()
|
||||||
|
|
||||||
# DB 读取是同步阻塞操作,用 to_thread 包装
|
# DB 读取是同步阻塞操作,用 to_thread 包装
|
||||||
result = await asyncio.to_thread(db_reader.get_messages_since, cursor, limit)
|
result = await asyncio.to_thread(
|
||||||
|
db_reader.get_messages_since, cursor, limit, is_sender
|
||||||
|
)
|
||||||
msg_count = len(result.get("messages", []))
|
msg_count = len(result.get("messages", []))
|
||||||
logger.info(
|
logger.info(
|
||||||
"messages/since: cursor=%d limit=%d → 返回 %d 条, next_cursor=%s has_more=%s",
|
"messages/since: cursor=%d limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
|
||||||
cursor, limit, msg_count, result.get("next_cursor"), result.get("has_more"),
|
cursor, limit, is_sender, msg_count, result.get("next_cursor"), result.get("has_more"),
|
||||||
)
|
)
|
||||||
return MessagesResponse(**result)
|
return MessagesResponse(**result)
|
||||||
|
|
||||||
@ -161,6 +173,10 @@ async def get_messages_by_session(
|
|||||||
cursor: int = 0,
|
cursor: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
direction: str = "before",
|
direction: str = "before",
|
||||||
|
is_sender: Optional[bool] = Query(
|
||||||
|
None,
|
||||||
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
||||||
|
),
|
||||||
) -> MessagesBySessionResponse:
|
) -> MessagesBySessionResponse:
|
||||||
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
"""按会话拉取历史消息(单会话,O(1) 定位分片表)。
|
||||||
|
|
||||||
@ -173,6 +189,8 @@ async def get_messages_by_session(
|
|||||||
after 模式取该时间之后的新消息
|
after 模式取该时间之后的新消息
|
||||||
limit: 最多返回条数,1~200,默认 50
|
limit: 最多返回条数,1~200,默认 50
|
||||||
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
direction: before(默认,往前翻历史)/ after(往后拉新消息)
|
||||||
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
MessagesBySessionResponse:含 messages / next_cursor / has_more / talker
|
MessagesBySessionResponse:含 messages / next_cursor / has_more / talker
|
||||||
@ -210,12 +228,12 @@ async def get_messages_by_session(
|
|||||||
await _check_db_readable()
|
await _check_db_readable()
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
db_reader.get_messages_by_session, talker, cursor, limit, direction
|
db_reader.get_messages_by_session, talker, cursor, limit, direction, is_sender
|
||||||
)
|
)
|
||||||
msg_count = len(result.get("messages", []))
|
msg_count = len(result.get("messages", []))
|
||||||
logger.info(
|
logger.info(
|
||||||
"messages/by_session: talker=%s cursor=%d direction=%s limit=%d → 返回 %d 条, next_cursor=%s has_more=%s",
|
"messages/by_session: talker=%s cursor=%d direction=%s limit=%d is_sender=%s → 返回 %d 条, next_cursor=%s has_more=%s",
|
||||||
talker, cursor, direction, limit, msg_count,
|
talker, cursor, direction, limit, is_sender, msg_count,
|
||||||
result.get("next_cursor"), result.get("has_more"),
|
result.get("next_cursor"), result.get("has_more"),
|
||||||
)
|
)
|
||||||
return MessagesBySessionResponse(**result)
|
return MessagesBySessionResponse(**result)
|
||||||
@ -232,6 +250,10 @@ async def search_messages(
|
|||||||
start_time: int = 0,
|
start_time: int = 0,
|
||||||
end_time: int = 0,
|
end_time: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
is_sender: Optional[bool] = Query(
|
||||||
|
None,
|
||||||
|
description="按发送方向过滤:True=仅本人发送 / False=仅对方发送 / None=不过滤(默认)",
|
||||||
|
),
|
||||||
) -> MessageSearchResponse:
|
) -> MessageSearchResponse:
|
||||||
"""按关键词搜索历史消息。
|
"""按关键词搜索历史消息。
|
||||||
|
|
||||||
@ -244,6 +266,8 @@ async def search_messages(
|
|||||||
start_time: 可选,起始时间戳(含),0 表示不限制
|
start_time: 可选,起始时间戳(含),0 表示不限制
|
||||||
end_time: 可选,结束时间戳(含),0 表示不限制
|
end_time: 可选,结束时间戳(含),0 表示不限制
|
||||||
limit: 最多返回条数,1~200,默认 50
|
limit: 最多返回条数,1~200,默认 50
|
||||||
|
is_sender: 按发送方向过滤,True=仅本人发送 / False=仅对方发送 /
|
||||||
|
None=不过滤(默认)。SQL 层过滤,无法定位本人时返回空
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
MessageSearchResponse:含 messages / total
|
MessageSearchResponse:含 messages / total
|
||||||
@ -279,10 +303,11 @@ async def search_messages(
|
|||||||
start_time if start_time > 0 else None,
|
start_time if start_time > 0 else None,
|
||||||
end_time if end_time > 0 else None,
|
end_time if end_time > 0 else None,
|
||||||
limit,
|
limit,
|
||||||
|
is_sender,
|
||||||
)
|
)
|
||||||
msg_count = len(result.get("messages", []))
|
msg_count = len(result.get("messages", []))
|
||||||
logger.info(
|
logger.info(
|
||||||
"messages/search: keyword=%r talker=%s → 命中 %d 条(返回 %d 条)",
|
"messages/search: keyword=%r talker=%s is_sender=%s → 命中 %d 条(返回 %d 条)",
|
||||||
keyword, talker or "(全部)", result.get("total", 0), msg_count,
|
keyword, talker or "(全部)", is_sender, result.get("total", 0), msg_count,
|
||||||
)
|
)
|
||||||
return MessageSearchResponse(**result)
|
return MessageSearchResponse(**result)
|
||||||
|
|||||||
@ -115,42 +115,76 @@ async def send_text(req: SendTextRequest) -> SendResponse:
|
|||||||
"""
|
"""
|
||||||
xdotool = _require_xdotool()
|
xdotool = _require_xdotool()
|
||||||
send_queue = _require_send_queue()
|
send_queue = _require_send_queue()
|
||||||
|
t_total = time.perf_counter()
|
||||||
|
|
||||||
# 校验请求体
|
# 校验请求体
|
||||||
if not req.to_wxid:
|
if not req.to_wxid:
|
||||||
|
logger.warning("send/text: 参数校验失败 — to_wxid 为空")
|
||||||
raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空")
|
raise BridgeError(code="INVALID_PARAMS", message="to_wxid 不能为空")
|
||||||
if not req.content:
|
if not req.content:
|
||||||
|
logger.warning("send/text: 参数校验失败 — content 为空 (to_wxid=%s)", req.to_wxid)
|
||||||
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
|
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"send/text: 收到请求 to_wxid=%s content_len=%d display_name=%s",
|
||||||
|
req.to_wxid, len(req.content), req.display_name or "(未提供)",
|
||||||
|
)
|
||||||
|
|
||||||
# 检查登录态
|
# 检查登录态
|
||||||
|
t0 = time.perf_counter()
|
||||||
login_state = await xdotool.detect_login_state()
|
login_state = await xdotool.detect_login_state()
|
||||||
|
logger.info(
|
||||||
|
"send/text: 登录态检测 → %s (%.0fms)",
|
||||||
|
login_state, (time.perf_counter() - t0) * 1000,
|
||||||
|
)
|
||||||
if login_state != "logged_in":
|
if login_state != "logged_in":
|
||||||
|
logger.warning("send/text: 拒绝发送,登录态=%s", login_state)
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="WECHAT_NOT_LOGGED_IN",
|
code="WECHAT_NOT_LOGGED_IN",
|
||||||
message=f"当前登录态为 {login_state},无法发送消息",
|
message=f"当前登录态为 {login_state},无法发送消息",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 解析显示名(优先备注/昵称,不再直接用 wxid 搜索)
|
# 解析显示名(优先备注/昵称,不再直接用 wxid 搜索)
|
||||||
|
t0 = time.perf_counter()
|
||||||
display_name = await _resolve_display_name(
|
display_name = await _resolve_display_name(
|
||||||
_state.db_reader, req.to_wxid, req.display_name
|
_state.db_reader, req.to_wxid, req.display_name
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"send/text: display_name 解析 → %s (%.0fms)",
|
||||||
|
display_name, (time.perf_counter() - t0) * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
# 经发送队列串行执行
|
# 经发送队列串行执行
|
||||||
|
logger.info("send/text: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||||
try:
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
local_send_id = await send_queue.enqueue(
|
local_send_id = await send_queue.enqueue(
|
||||||
lambda: xdotool.send_text(req.to_wxid, req.content, display_name)
|
lambda: xdotool.send_text(req.to_wxid, req.content, display_name)
|
||||||
)
|
)
|
||||||
except BridgeError:
|
logger.info(
|
||||||
|
"send/text: 队列执行完成 → local_send_id=%s (%.0fms)",
|
||||||
|
local_send_id, (time.perf_counter() - t0) * 1000,
|
||||||
|
)
|
||||||
|
except BridgeError as e:
|
||||||
|
logger.warning(
|
||||||
|
"send/text: 发送失败 BridgeError code=%s msg=%s (%.0fms)",
|
||||||
|
e.code, e.message, (time.perf_counter() - t0) * 1000,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"send/text: 发送失败 %s: %s (%.0fms)",
|
||||||
|
type(e).__name__, e, (time.perf_counter() - t0) * 1000,
|
||||||
|
)
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="SEND_FAILED",
|
code="SEND_FAILED",
|
||||||
message=f"发送失败: {e}",
|
message=f"发送失败: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"send/text: to=%s display_name=%s content_len=%d → 成功 local_send_id=%s",
|
"send/text: ✓ to=%s display_name=%s content_len=%d → local_send_id=%s (总耗时 %.0fms)",
|
||||||
req.to_wxid, display_name, len(req.content), local_send_id,
|
req.to_wxid, display_name, len(req.content), local_send_id,
|
||||||
|
(time.perf_counter() - t_total) * 1000,
|
||||||
)
|
)
|
||||||
return SendResponse(success=True, local_send_id=local_send_id, placeholder=False, error=None)
|
return SendResponse(success=True, local_send_id=local_send_id, placeholder=False, error=None)
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,13 @@ from woc_bridge.models import BridgeError, LoginState
|
|||||||
# 模块级 logger:与 server.py 同名,便于 s6 统一收日志
|
# 模块级 logger:与 server.py 同名,便于 s6 统一收日志
|
||||||
logger = logging.getLogger("woc-bridge")
|
logger = logging.getLogger("woc-bridge")
|
||||||
|
|
||||||
|
# 单条 xdotool/pgrep/kill 子进程命令的整体超时(秒)。
|
||||||
|
# 这类命令本身均为毫秒级,15s 足够兜底;超时不重试,退避交给上层 outbox。
|
||||||
|
_CMD_TIMEOUT_SEC = 15.0
|
||||||
|
# send_text 中“粘贴内容 + 短暂等待 + 回车发送”三步整体的超时(秒)。
|
||||||
|
# 配合 _open_session_by_name 的 10s,send_text 总上限约 25s,留给 HTTP 往返与中间件 5s。
|
||||||
|
_SEND_BODY_TIMEOUT_SEC = 15.0
|
||||||
|
|
||||||
|
|
||||||
class XdotoolDriver:
|
class XdotoolDriver:
|
||||||
"""xdotool/xclip 异步驱动。
|
"""xdotool/xclip 异步驱动。
|
||||||
@ -54,9 +61,16 @@ class XdotoolDriver:
|
|||||||
) -> tuple[int, bytes, bytes]:
|
) -> tuple[int, bytes, bytes]:
|
||||||
"""执行一条命令并返回 (returncode, stdout, stderr)。
|
"""执行一条命令并返回 (returncode, stdout, stderr)。
|
||||||
|
|
||||||
|
xdotool / pgrep / kill 均为毫秒级命令,统一用 _CMD_TIMEOUT_SEC 兜底,
|
||||||
|
防止子进程挂死导致协程永久阻塞。超时不在此重试(退避交给上层 outbox,
|
||||||
|
与 httpx.NetworkError 只重试一次的策略对齐,避免双重重试放大延迟)。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
args: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
|
args: 命令及其参数列表,如 ["xdotool", "search", "--name", "微信"]
|
||||||
input_bytes: 需要写入 stdin 的字节
|
input_bytes: 需要写入 stdin 的字节
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BridgeError: 命令超时(code=SEND_FAILED,message 指出超时的命令名)
|
||||||
"""
|
"""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
@ -65,7 +79,20 @@ class XdotoolDriver:
|
|||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=self._env(),
|
env=self._env(),
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate(input=input_bytes)
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
proc.communicate(input=input_bytes),
|
||||||
|
timeout=_CMD_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
# 先杀子进程再收尸,避免僵尸进程残留占住管道/资源
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
logger.error("[ui] command timeout: %s", args[0])
|
||||||
|
raise BridgeError(
|
||||||
|
code="SEND_FAILED",
|
||||||
|
message=f"命令超时: {args[0]}",
|
||||||
|
) from exc
|
||||||
return proc.returncode, stdout, stderr
|
return proc.returncode, stdout, stderr
|
||||||
|
|
||||||
async def _key(self, key: str) -> None:
|
async def _key(self, key: str) -> None:
|
||||||
@ -325,6 +352,42 @@ class XdotoolDriver:
|
|||||||
)
|
)
|
||||||
logger.info("[ui] type done rc=%s", rc)
|
logger.info("[ui] type done rc=%s", rc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 鼠标点击聊天输入框(强制焦点)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。
|
||||||
|
# 实测点击窗口底部中间区域 (800, 800) 能稳定聚焦到聊天输入框。
|
||||||
|
# 坐标基于窗口几何 1435x876,若窗口尺寸变化需重新校准。
|
||||||
|
_INPUT_BOX_X = 800
|
||||||
|
_INPUT_BOX_Y = 800
|
||||||
|
|
||||||
|
async def _click_input_box(self) -> None:
|
||||||
|
"""鼠标点击聊天输入框,强制焦点落到输入框。
|
||||||
|
|
||||||
|
微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框,
|
||||||
|
直接 xdotool type 会导致文字打到错误位置。必须先鼠标点击
|
||||||
|
输入框区域强制聚焦。
|
||||||
|
"""
|
||||||
|
logger.info("[ui] click input box (%d, %d)", self._INPUT_BOX_X, self._INPUT_BOX_Y)
|
||||||
|
rc, _, stderr = await self._run([
|
||||||
|
"xdotool", "mousemove", str(self._INPUT_BOX_X), str(self._INPUT_BOX_Y),
|
||||||
|
])
|
||||||
|
if rc != 0:
|
||||||
|
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||||
|
raise BridgeError(
|
||||||
|
code="SEND_FAILED",
|
||||||
|
message=f"鼠标移动到输入框失败 (code={rc}): {err}",
|
||||||
|
)
|
||||||
|
rc, _, stderr = await self._run(["xdotool", "click", "1"])
|
||||||
|
if rc != 0:
|
||||||
|
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||||
|
raise BridgeError(
|
||||||
|
code="SEND_FAILED",
|
||||||
|
message=f"点击输入框失败 (code={rc}): {err}",
|
||||||
|
)
|
||||||
|
logger.info("[ui] click input box done rc=%s", rc)
|
||||||
|
await self._sleep(0.3)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 会话定位(Ctrl+F 搜索)
|
# 会话定位(Ctrl+F 搜索)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -441,19 +504,36 @@ class XdotoolDriver:
|
|||||||
本地生成的 channel_msg_id,格式 local_<unix秒>_<随机>
|
本地生成的 channel_msg_id,格式 local_<unix秒>_<随机>
|
||||||
"""
|
"""
|
||||||
name = display_name if display_name else to_wxid
|
name = display_name if display_name else to_wxid
|
||||||
logger.info("[send_text] to=%s name=%s content_len=%s", to_wxid, name, len(content))
|
t_total = time.perf_counter()
|
||||||
# 1. 定位会话
|
logger.info("[send_text] 开始 to=%s name=%s content_len=%d", to_wxid, name, len(content))
|
||||||
|
# 1. 定位会话(_open_session_by_name 内部已含 10s 整体超时)
|
||||||
|
t0 = time.perf_counter()
|
||||||
await self._open_session_by_name(name)
|
await self._open_session_by_name(name)
|
||||||
logger.info("[send_text] session opened")
|
logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
||||||
# 2. 粘贴内容并发送
|
# 1.5 鼠标点击聊天输入框强制焦点
|
||||||
|
# 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。
|
||||||
|
# 实测点击 (800, 800) 能稳定聚焦到输入框(窗口几何 1435x876)。
|
||||||
|
await self._click_input_box()
|
||||||
|
# 2. 输入内容并发送(三步整体超时保护,与定位会话的 10s 合计最坏 25s)
|
||||||
|
async def _send_body() -> None:
|
||||||
await self._paste_via_xclip(content)
|
await self._paste_via_xclip(content)
|
||||||
logger.info("[send_text] content pasted")
|
logger.info("[send_text] 文本已输入")
|
||||||
await self._sleep(0.2)
|
await self._sleep(0.2)
|
||||||
await self._key("Return")
|
await self._key("Return")
|
||||||
logger.info("[send_text] return pressed")
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC)
|
||||||
|
logger.info("[send_text] 回车已发送 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
logger.error("[send_text] 输入/发送步骤超时")
|
||||||
|
raise BridgeError(
|
||||||
|
code="SEND_FAILED",
|
||||||
|
message="文本输入/发送步骤超时",
|
||||||
|
) from exc
|
||||||
# 3. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
# 3. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
||||||
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
|
local_send_id = f"local_{int(time.time())}_{random.randint(0, 0xFFFFFF):06x}"
|
||||||
logger.info("[send_text] done local_send_id=%s", local_send_id)
|
logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)",
|
||||||
|
local_send_id, (time.perf_counter() - t_total) * 1000)
|
||||||
return local_send_id
|
return local_send_id
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -497,6 +577,8 @@ class XdotoolDriver:
|
|||||||
|
|
||||||
# 1. 定位会话
|
# 1. 定位会话
|
||||||
await self._open_session_by_name(display_name if display_name else to_wxid)
|
await self._open_session_by_name(display_name if display_name else to_wxid)
|
||||||
|
# 1.5 鼠标点击聊天输入框强制焦点(与 send_text 同理)
|
||||||
|
await self._click_input_box()
|
||||||
|
|
||||||
# 2. MVP:发送文件路径提示文本(真实文件传输留给后续完善)
|
# 2. MVP:发送文件路径提示文本(真实文件传输留给后续完善)
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user