refactor(bridge+panel): 重构发送逻辑并优化默认配置
1. 调整默认发送延迟从800ms改为3000ms,更新配置默认值与环境变量 fallback 2. 重写Fastify代理路由,修复POST body被提前消费导致的转发超时问题 3. 新增DB校验机制,发送后轮询确认消息是否到达目标会话 4. 重构xdotool操作流程:改用鼠标点击搜索与发送,替代快捷键组合以适配微信4.x自绘UI 5. 优化窗口激活、会话搜索与输入焦点处理逻辑,提升发送稳定性
This commit is contained in:
parent
5d84df902a
commit
2e056f9bae
@ -125,7 +125,7 @@ class BridgeConfig:
|
|||||||
wechat_db: str = "/config"
|
wechat_db: str = "/config"
|
||||||
display: str = ":1"
|
display: str = ":1"
|
||||||
# 环境变量
|
# 环境变量
|
||||||
send_delay_ms: int = 800
|
send_delay_ms: int = 3000
|
||||||
max_calls_per_sec: int = 10
|
max_calls_per_sec: int = 10
|
||||||
max_batch_size: int = 50
|
max_batch_size: int = 50
|
||||||
poll_interval_ms: int = 2000
|
poll_interval_ms: int = 2000
|
||||||
@ -139,7 +139,7 @@ class BridgeConfig:
|
|||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
wechat_db=args.wechat_db,
|
wechat_db=args.wechat_db,
|
||||||
display=args.display,
|
display=args.display,
|
||||||
send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "800")),
|
send_delay_ms=int(os.environ.get("WOC_BRIDGE_SEND_DELAY_MS", "3000")),
|
||||||
max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")),
|
max_calls_per_sec=int(os.environ.get("WOC_BRIDGE_MAX_CALLS_PER_SEC", "10")),
|
||||||
max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")),
|
max_batch_size=int(os.environ.get("WOC_BRIDGE_MAX_BATCH_SIZE", "50")),
|
||||||
poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")),
|
poll_interval_ms=int(os.environ.get("WOC_BRIDGE_POLL_INTERVAL_MS", "2000")),
|
||||||
|
|||||||
@ -24,12 +24,12 @@ class SendQueue:
|
|||||||
"""串行化发送队列 + 限流。
|
"""串行化发送队列 + 限流。
|
||||||
|
|
||||||
通过 asyncio.Queue 串行执行所有发送任务;执行间隔可配置
|
通过 asyncio.Queue 串行执行所有发送任务;执行间隔可配置
|
||||||
(默认 800ms),单实例每秒调用上限可配置(默认 10)。
|
(默认 3000ms),单实例每秒调用上限可配置(默认 10)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
send_delay_ms: int = 800,
|
send_delay_ms: int = 3000,
|
||||||
max_calls_per_sec: int = 10,
|
max_calls_per_sec: int = 10,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""初始化队列配置。
|
"""初始化队列配置。
|
||||||
|
|||||||
@ -80,6 +80,74 @@ async def _resolve_display_name(
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_latest_create_time_for_talker(
|
||||||
|
db_reader: Optional[DbReader],
|
||||||
|
to_wxid: str,
|
||||||
|
) -> int:
|
||||||
|
"""返回目标 talker 当前最新消息的 create_time,DB 不可用时返回 0。"""
|
||||||
|
if db_reader is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
db_reader.get_messages_by_session,
|
||||||
|
to_wxid,
|
||||||
|
cursor=0,
|
||||||
|
limit=1,
|
||||||
|
direction="before",
|
||||||
|
is_sender=None,
|
||||||
|
)
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
if messages:
|
||||||
|
return int(messages[0].get("create_time", 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_sent_to_talker(
|
||||||
|
db_reader: Optional[DbReader],
|
||||||
|
to_wxid: str,
|
||||||
|
before_ct: int,
|
||||||
|
max_wait_sec: float = 10.0,
|
||||||
|
) -> bool:
|
||||||
|
"""轮询 DB,确认目标 talker 在 before_ct 之后出现了新消息。
|
||||||
|
|
||||||
|
用于发送后校验消息是否真的落到了目标会话。DB 不可用时静默跳过。
|
||||||
|
默认最多等待 10 秒,兼容 WeChat 4.x 异步写入 DB 的延迟。
|
||||||
|
"""
|
||||||
|
if db_reader is None:
|
||||||
|
return True
|
||||||
|
deadline = time.monotonic() + max_wait_sec
|
||||||
|
attempts = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
db_reader.get_messages_by_session,
|
||||||
|
to_wxid,
|
||||||
|
cursor=before_ct,
|
||||||
|
limit=3,
|
||||||
|
direction="after",
|
||||||
|
is_sender=None,
|
||||||
|
)
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
if messages:
|
||||||
|
logger.info(
|
||||||
|
"send/text: DB 校验成功,目标 talker 发现 %d 条新消息 (attempt=%d)",
|
||||||
|
len(messages), attempts,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logger.debug("send/text: DB 校验第 %d 次轮询未找到新消息", attempts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("send/text: DB 校验第 %d 次轮询异常: %s", attempts, e)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
logger.warning(
|
||||||
|
"send/text: DB 校验超时,%ds 内目标 talker 未出现新消息 (attempts=%d)",
|
||||||
|
max_wait_sec, attempts,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/send/text", response_model=SendResponse)
|
@router.post("/api/send/text", response_model=SendResponse)
|
||||||
@with_db_retry
|
@with_db_retry
|
||||||
async def send_text(req: SendTextRequest) -> SendResponse:
|
async def send_text(req: SendTextRequest) -> SendResponse:
|
||||||
@ -89,9 +157,11 @@ async def send_text(req: SendTextRequest) -> SendResponse:
|
|||||||
1. 校验 to_wxid / content 非空
|
1. 校验 to_wxid / content 非空
|
||||||
2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid
|
2. 解析 display_name:显式传入 > 备注 > 昵称 > wxid
|
||||||
3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为)
|
3. 检测登录态,非 logged_in 直接拒绝(避免 UI 操作引发不可预期行为)
|
||||||
4. 经 send_queue 串行执行:activate_window → Ctrl+F 搜索会话(按 display_name) →
|
4. 记录目标会话当前最新 create_time
|
||||||
选中并进入 → xclip 粘贴 content → 回车发送
|
5. 经 send_queue 串行执行:activate_window → 鼠标点击左侧搜索框 →
|
||||||
5. 返回本地生成的 local_send_id(不等待微信发送回执)
|
输入 display_name → 点击第一个搜索结果 → 点击输入框 → 输入 content → 回车发送
|
||||||
|
6. 发送后轮询 DB 校验目标会话是否出现新消息,未出现则抛 SEND_FAILED
|
||||||
|
7. 返回本地生成的 local_send_id(不等待微信发送回执)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
req: SendTextRequest(to_wxid + content + 可选 display_name)
|
req: SendTextRequest(to_wxid + content + 可选 display_name)
|
||||||
@ -106,12 +176,12 @@ async def send_text(req: SendTextRequest) -> SendResponse:
|
|||||||
BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500)
|
BridgeError(SEND_FAILED): xdotool/xclip 操作失败(HTTP 500)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- 发送队列串行化避免 Ctrl+F 搜索框竞态(前一条消息的搜索未关闭时,
|
- 发送队列串行化避免搜索框/鼠标点击竞态
|
||||||
后一条会粘到错误的会话)
|
|
||||||
- local_send_id 由 bridge 本地生成,不对应微信真实 msg_id;
|
- local_send_id 由 bridge 本地生成,不对应微信真实 msg_id;
|
||||||
调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体**
|
调用方应用它做幂等去重,**禁止用它到 /api/media/{msg_id} 查媒体**
|
||||||
- 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS /
|
- 发送队列的发送间隔与限频由 .env 的 WOC_BRIDGE_SEND_DELAY_MS /
|
||||||
WOC_BRIDGE_MAX_CALLS_PER_SEC 控制
|
WOC_BRIDGE_MAX_CALLS_PER_SEC 控制
|
||||||
|
- DB 校验失败时消息可能已经发出(可能到错误会话),调用方需人工排查
|
||||||
"""
|
"""
|
||||||
xdotool = _require_xdotool()
|
xdotool = _require_xdotool()
|
||||||
send_queue = _require_send_queue()
|
send_queue = _require_send_queue()
|
||||||
@ -154,6 +224,10 @@ async def send_text(req: SendTextRequest) -> SendResponse:
|
|||||||
display_name, (time.perf_counter() - t0) * 1000,
|
display_name, (time.perf_counter() - t0) * 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 发送前记录目标会话最新消息时间,用于发送后校验
|
||||||
|
before_ct = await _get_latest_create_time_for_talker(_state.db_reader, req.to_wxid)
|
||||||
|
logger.info("send/text: 目标会话当前最新 create_time=%d", before_ct)
|
||||||
|
|
||||||
# 经发送队列串行执行
|
# 经发送队列串行执行
|
||||||
logger.info("send/text: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
logger.info("send/text: 入队 send_queue (pending=%d)", send_queue.pending_count())
|
||||||
try:
|
try:
|
||||||
@ -181,6 +255,18 @@ async def send_text(req: SendTextRequest) -> SendResponse:
|
|||||||
message=f"发送失败: {e}",
|
message=f"发送失败: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 发送后校验:目标会话是否出现了新消息
|
||||||
|
verified = await _verify_sent_to_talker(_state.db_reader, req.to_wxid, before_ct)
|
||||||
|
if not verified:
|
||||||
|
logger.error(
|
||||||
|
"send/text: 发送后 DB 校验失败,消息可能未进入目标会话 to=%s before_ct=%d",
|
||||||
|
req.to_wxid, before_ct,
|
||||||
|
)
|
||||||
|
raise BridgeError(
|
||||||
|
code="SEND_FAILED",
|
||||||
|
message="发送后校验失败:消息未到达目标会话",
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"send/text: ✓ to=%s display_name=%s content_len=%d → local_send_id=%s (总耗时 %.0fms)",
|
"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,
|
||||||
|
|||||||
@ -95,10 +95,19 @@ class XdotoolDriver:
|
|||||||
) from exc
|
) 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, repeat: int = 1) -> None:
|
||||||
"""执行 xdotool key <key>。"""
|
"""执行 xdotool key [--repeat N] <key>。
|
||||||
logger.info("[ui] key: %s", key)
|
|
||||||
rc, stdout, stderr = await self._run(["xdotool", "key", key])
|
Args:
|
||||||
|
key: 按键名,如 "Escape" / "Return" / "BackSpace"
|
||||||
|
repeat: 重复次数,默认 1
|
||||||
|
"""
|
||||||
|
logger.info("[ui] key: %s repeat=%d", key, repeat)
|
||||||
|
args = ["xdotool", "key"]
|
||||||
|
if repeat > 1:
|
||||||
|
args.extend(["--repeat", str(repeat)])
|
||||||
|
args.append(key)
|
||||||
|
rc, _, _ = await self._run(args)
|
||||||
logger.info("[ui] key %s -> rc=%s", key, rc)
|
logger.info("[ui] key %s -> rc=%s", key, rc)
|
||||||
|
|
||||||
async def _sleep(self, seconds: float) -> None:
|
async def _sleep(self, seconds: float) -> None:
|
||||||
@ -163,7 +172,13 @@ class XdotoolDriver:
|
|||||||
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
await self._run(["xdotool", "windowactivate", "--sync", str(window_id)])
|
||||||
|
|
||||||
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
|
async def _activate_window_fast(self, window_id: Optional[int] = None) -> None:
|
||||||
"""非阻塞激活微信窗口,避免 --sync 在 VNC 无人操作时死等。"""
|
"""激活微信窗口并校验焦点是否真正落到该窗口上。
|
||||||
|
|
||||||
|
先用非阻塞 windowactivate 避免 --sync 在 VNC 无人操作时死等,
|
||||||
|
然后轮询 getactivewindow 最多 2 秒确认目标窗口已获得焦点。
|
||||||
|
校验失败不阻塞流程(部分窗口管理器 getactivewindow 不可靠),
|
||||||
|
但会打 warning 便于排查。
|
||||||
|
"""
|
||||||
if window_id is None:
|
if window_id is None:
|
||||||
window_id = await self.find_wechat_window()
|
window_id = await self.find_wechat_window()
|
||||||
if window_id is None:
|
if window_id is None:
|
||||||
@ -173,6 +188,22 @@ class XdotoolDriver:
|
|||||||
)
|
)
|
||||||
await self._run(["xdotool", "windowactivate", str(window_id)])
|
await self._run(["xdotool", "windowactivate", str(window_id)])
|
||||||
|
|
||||||
|
# 轮询校验焦点(最多 2 秒,步进 100ms)
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
rc, stdout, _ = await self._run(["xdotool", "getactivewindow"])
|
||||||
|
if rc == 0:
|
||||||
|
active_text = stdout.decode(errors="ignore").strip()
|
||||||
|
try:
|
||||||
|
if int(active_text) == window_id:
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
await self._sleep(0.1)
|
||||||
|
logger.warning(
|
||||||
|
"[ui] 窗口激活校验失败: window_id=%s 未在 2s 内成为活动窗口", window_id
|
||||||
|
)
|
||||||
|
|
||||||
async def restart_wechat(self, timeout_sec: int = 30) -> int:
|
async def restart_wechat(self, timeout_sec: int = 30) -> int:
|
||||||
"""重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。
|
"""重启微信进程:发 SIGTERM 让微信优雅退出,等待 autostart 拉起新进程。
|
||||||
|
|
||||||
@ -353,59 +384,90 @@ class XdotoolDriver:
|
|||||||
logger.info("[ui] type done rc=%s", rc)
|
logger.info("[ui] type done rc=%s", rc)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 鼠标点击聊天输入框(强制焦点)
|
# 微信 4.x 自绘 UI 布局常量(基于窗口几何的相对坐标)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。
|
# 截图显示:左侧会话列表宽度约 300px,顶部搜索框在左侧栏上方,输入框在右下面板底部。
|
||||||
# 实测点击窗口底部中间区域 (800, 800) 能稳定聚焦到聊天输入框。
|
# 所有坐标通过 getwindowgeometry 获取窗口 (X, Y, WIDTH, HEIGHT) 后动态计算。
|
||||||
# 坐标基于窗口几何 1435x876,若窗口尺寸变化需重新校准。
|
_LEFT_PANEL_WIDTH = 300 # 左侧会话列表宽度(像素)
|
||||||
_INPUT_BOX_X = 800
|
_SEARCH_BOX_Y_OFFSET = 55 # 搜索框中心相对窗口顶部的 Y 偏移
|
||||||
_INPUT_BOX_Y = 800
|
_SEARCH_RESULT_Y_OFFSET = 120 # 搜索结果/会话列表第一项中心相对窗口顶部的 Y 偏移
|
||||||
|
_INPUT_BOX_Y_OFFSET = 50 # 输入框中心相对窗口底部的 Y 偏移(向上)
|
||||||
|
_SEND_BUTTON_X_OFFSET = 60 # 发送按钮中心相对窗口右边缘的 X 偏移(向左)
|
||||||
|
_SEND_BUTTON_Y_OFFSET = 30 # 发送按钮中心相对窗口底部的 Y 偏移(向上)
|
||||||
|
_DEBUG_SCREENSHOTS = True # 是否在关键步骤保存调试截图到 /tmp
|
||||||
|
|
||||||
async def _click_input_box(self) -> None:
|
async def _click_at(self, x: int, y: int, desc: str = "") -> None:
|
||||||
"""鼠标点击聊天输入框,强制焦点落到输入框。
|
"""移动鼠标到窗口相对/绝对坐标 (x, y) 并左键单击。"""
|
||||||
|
logger.info("[ui] click at (%d, %d) %s", x, y, desc)
|
||||||
微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框,
|
rc, _, stderr = await self._run(
|
||||||
直接 xdotool type 会导致文字打到错误位置。必须先鼠标点击
|
["xdotool", "mousemove", "--sync", str(x), str(y)]
|
||||||
输入框区域强制聚焦。
|
)
|
||||||
"""
|
|
||||||
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:
|
if rc != 0:
|
||||||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="SEND_FAILED",
|
code="SEND_FAILED",
|
||||||
message=f"鼠标移动到输入框失败 (code={rc}): {err}",
|
message=f"移动鼠标到 ({x},{y}) 失败: {err}",
|
||||||
)
|
)
|
||||||
rc, _, stderr = await self._run(["xdotool", "click", "1"])
|
rc, _, stderr = await self._run(["xdotool", "click", "1"])
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="SEND_FAILED",
|
code="SEND_FAILED",
|
||||||
message=f"点击输入框失败 (code={rc}): {err}",
|
message=f"点击 ({x},{y}) 失败: {err}",
|
||||||
)
|
)
|
||||||
logger.info("[ui] click input box done rc=%s", rc)
|
logger.info("[ui] click done rc=%s", rc)
|
||||||
|
|
||||||
|
async def _debug_screenshot(self, step: str) -> None:
|
||||||
|
"""保存调试截图到 /tmp,文件名包含步骤名和时间戳。"""
|
||||||
|
if not self._DEBUG_SCREENSHOTS:
|
||||||
|
return
|
||||||
|
path = f"/tmp/woc_send_debug_{step}_{int(time.time() * 1000)}.png"
|
||||||
|
rc, _, stderr = await self._run(["scrot", path])
|
||||||
|
if rc == 0:
|
||||||
|
logger.info("[ui] debug screenshot saved: %s", path)
|
||||||
|
else:
|
||||||
|
err = stderr.decode(errors="ignore").strip() if stderr else "unknown"
|
||||||
|
logger.warning("[ui] debug screenshot failed (%s): %s", step, err)
|
||||||
|
|
||||||
|
async def _click_input_box(self) -> None:
|
||||||
|
"""鼠标点击聊天输入框,强制焦点落到输入框。
|
||||||
|
|
||||||
|
微信 4.x 自绘 UI 通过搜索进入会话后,焦点不在聊天输入框,
|
||||||
|
直接 xdotool type 会导致文字打到错误位置。先获取窗口几何,
|
||||||
|
点击右下面板底部区域强制聚焦。
|
||||||
|
"""
|
||||||
|
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||||
|
input_x = win_x + self._LEFT_PANEL_WIDTH + (win_w - self._LEFT_PANEL_WIDTH) // 2
|
||||||
|
input_y = win_y + win_h - self._INPUT_BOX_Y_OFFSET
|
||||||
|
await self._click_at(input_x, input_y, desc="input box")
|
||||||
await self._sleep(0.3)
|
await self._sleep(0.3)
|
||||||
|
|
||||||
|
async def _click_send_button(self) -> None:
|
||||||
|
"""鼠标点击右下角发送按钮,替代可能失效的 Return 键。"""
|
||||||
|
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||||
|
send_x = win_x + win_w - self._SEND_BUTTON_X_OFFSET
|
||||||
|
send_y = win_y + win_h - self._SEND_BUTTON_Y_OFFSET
|
||||||
|
await self._click_at(send_x, send_y, desc="send button")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 会话定位(Ctrl+F 搜索)
|
# 会话定位(鼠标点击搜索框 + 点击结果)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def _open_session_by_name(
|
async def _open_session_by_name(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
timeout_sec: float = 10.0,
|
timeout_sec: float = 20.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""通过微信搜索框定位并进入指定联系人的会话。
|
"""通过微信左侧搜索框定位并进入指定联系人的会话。
|
||||||
|
|
||||||
流程:
|
截图显示顶部左侧有独立搜索框。WeChat 4.x 自绘 UI 对 Ctrl+F 等
|
||||||
1. 非阻塞激活微信窗口
|
X11 修饰键组合不响应,因此全部改用鼠标点击:
|
||||||
|
1. 激活窗口并校验焦点
|
||||||
2. 按 Esc 关闭可能存在的搜索框/弹窗
|
2. 按 Esc 关闭可能存在的搜索框/弹窗
|
||||||
3. Ctrl+F 打开搜索
|
3. 点击左侧顶部搜索框
|
||||||
4. Ctrl+A 全选旧内容,粘贴 display_name
|
4. 输入 display_name
|
||||||
5. 等待搜索结果渲染
|
5. 等待搜索结果渲染
|
||||||
6. 按 ↓ 选中第一个结果,回车进入会话
|
6. 点击第一个搜索结果进入会话
|
||||||
7. 再按 Esc 确保退出搜索模式,焦点落在聊天输入框
|
7. 按 Esc 退出搜索覆盖层
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 用于搜索的显示名(备注/昵称/微信号)
|
name: 用于搜索的显示名(备注/昵称/微信号)
|
||||||
@ -413,7 +475,7 @@ class XdotoolDriver:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
|
BridgeError(WINDOW_NOT_FOUND): 找不到微信窗口
|
||||||
BridgeError(SEND_FAILED): 超时或 xdotool/xclip 操作失败
|
BridgeError(SEND_FAILED): 超时或 xdotool 操作失败
|
||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
@ -442,7 +504,7 @@ class XdotoolDriver:
|
|||||||
) from exc
|
) from exc
|
||||||
logger.info("[ui] step done: %s", desc)
|
logger.info("[ui] step done: %s", desc)
|
||||||
|
|
||||||
# 1. 激活窗口(非阻塞,避免 --sync 死等)
|
# 1. 查找并激活窗口
|
||||||
window_id = await self.find_wechat_window()
|
window_id = await self.find_wechat_window()
|
||||||
logger.info("[ui] found window_id=%s", window_id)
|
logger.info("[ui] found window_id=%s", window_id)
|
||||||
if window_id is None:
|
if window_id is None:
|
||||||
@ -451,33 +513,36 @@ class XdotoolDriver:
|
|||||||
message="未找到微信窗口,无法定位会话",
|
message="未找到微信窗口,无法定位会话",
|
||||||
)
|
)
|
||||||
await _step(self._activate_window_fast(window_id), "激活窗口")
|
await _step(self._activate_window_fast(window_id), "激活窗口")
|
||||||
await _step(self._sleep(0.3), "等待窗口激活")
|
await _step(self._sleep(1.0), "等待窗口激活")
|
||||||
|
|
||||||
# 2. 关闭可能存在的搜索框/弹窗
|
# 2. 关闭可能存在的搜索框/弹窗
|
||||||
await _step(self._key("Escape"), "关闭搜索框")
|
await _step(self._key("Escape"), "关闭搜索框/弹窗")
|
||||||
await _step(self._key("Escape"), "再按一次 Esc")
|
await _step(self._sleep(1.0), "等待 Esc 生效")
|
||||||
await _step(self._key("Escape"), "第三次 Esc 确保退出")
|
|
||||||
await _step(self._sleep(0.2), "等待 Esc 生效")
|
|
||||||
|
|
||||||
# 3. 打开搜索
|
# 3. 获取窗口几何,计算搜索框/结果坐标
|
||||||
await _step(self._key("ctrl+f"), "打开搜索")
|
win_x, win_y, win_w, win_h = await self._get_window_geometry()
|
||||||
await _step(self._sleep(0.4), "等待搜索框打开")
|
search_x = win_x + self._LEFT_PANEL_WIDTH // 2
|
||||||
|
search_y = win_y + self._SEARCH_BOX_Y_OFFSET
|
||||||
|
result_x = win_x + self._LEFT_PANEL_WIDTH // 2
|
||||||
|
result_y = win_y + self._SEARCH_RESULT_Y_OFFSET
|
||||||
|
|
||||||
# 4. 清空并输入搜索关键词
|
# 4. 点击搜索框并输入关键词
|
||||||
await _step(self._key("ctrl+a"), "全选搜索框内容")
|
await _step(self._click_at(search_x, search_y, desc="search box"), "点击搜索框")
|
||||||
await _step(self._sleep(0.1), "等待全选")
|
await _step(self._sleep(1.0), "等待搜索框聚焦")
|
||||||
await _step(self._paste_via_xclip(name), "粘贴搜索关键词")
|
# 清空可能存在的旧内容:用多次 Backspace 规避 Ctrl+A 可能失效的问题
|
||||||
await _step(self._sleep(0.8), "等待搜索结果")
|
await _step(self._key("BackSpace", repeat=30), "清空搜索框旧内容")
|
||||||
|
await _step(self._sleep(1.0), "等待清空")
|
||||||
|
await _step(self._paste_via_xclip(name), "输入搜索关键词")
|
||||||
|
await _step(self._sleep(1.0), "等待搜索结果")
|
||||||
|
|
||||||
# 5. 选中第一个结果并进入会话
|
# 5. 点击第一个搜索结果进入会话
|
||||||
await _step(self._key("Down"), "选中搜索结果")
|
await _step(self._click_at(result_x, result_y, desc="first search result"), "点击第一个搜索结果")
|
||||||
await _step(self._sleep(0.3), "等待选中")
|
await _step(self._sleep(1.0), "等待会话打开")
|
||||||
await _step(self._key("Return"), "进入会话")
|
await self._debug_screenshot("after_open_session")
|
||||||
await _step(self._sleep(0.6), "等待会话打开")
|
|
||||||
|
|
||||||
# 6. 退出搜索模式,确保焦点在输入框
|
# 6. 退出搜索模式,确保焦点在会话区域
|
||||||
await _step(self._key("Escape"), "退出搜索模式")
|
await _step(self._key("Escape"), "退出搜索模式")
|
||||||
await _step(self._sleep(0.2), "等待焦点稳定")
|
await _step(self._sleep(1.0), "等待焦点稳定")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 发送文本
|
# 发送文本
|
||||||
@ -491,8 +556,8 @@ class XdotoolDriver:
|
|||||||
"""发送文本消息。
|
"""发送文本消息。
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1. 通过微信搜索框定位会话(使用 display_name,未提供时回退到 to_wxid)
|
1. 通过微信左侧搜索框定位会话(使用 display_name,未提供时回退到 to_wxid)
|
||||||
2. 粘贴文本内容并回车发送
|
2. 输入文本内容并回车发送
|
||||||
3. 返回本地生成的 channel_msg_id
|
3. 返回本地生成的 channel_msg_id
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -510,27 +575,36 @@ class XdotoolDriver:
|
|||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
await self._open_session_by_name(name)
|
await self._open_session_by_name(name)
|
||||||
logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
logger.info("[send_text] 会话定位完成 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
||||||
|
await self._debug_screenshot("after_session")
|
||||||
# 1.5 鼠标点击聊天输入框强制焦点
|
# 1.5 鼠标点击聊天输入框强制焦点
|
||||||
# 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。
|
# 微信 4.x 自绘 UI 进入会话后焦点不在输入框,xdotool type 会打到错误位置。
|
||||||
# 实测点击 (800, 800) 能稳定聚焦到输入框(窗口几何 1435x876)。
|
|
||||||
await self._click_input_box()
|
await self._click_input_box()
|
||||||
|
await self._debug_screenshot("after_click_input")
|
||||||
# 2. 输入内容并发送(三步整体超时保护,与定位会话的 10s 合计最坏 25s)
|
# 2. 输入内容并发送(三步整体超时保护,与定位会话的 10s 合计最坏 25s)
|
||||||
async def _send_body() -> None:
|
async def _send_body() -> None:
|
||||||
await self._paste_via_xclip(content)
|
await self._paste_via_xclip(content)
|
||||||
logger.info("[send_text] 文本已输入")
|
logger.info("[send_text] 文本已输入")
|
||||||
await self._sleep(0.2)
|
await self._debug_screenshot("after_type")
|
||||||
await self._key("Return")
|
await self._sleep(1.0)
|
||||||
|
# 优先用鼠标点击右下角发送按钮,规避 Return 键可能不触发发送的问题
|
||||||
|
await self._click_send_button()
|
||||||
try:
|
try:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC)
|
await asyncio.wait_for(_send_body(), timeout=_SEND_BODY_TIMEOUT_SEC)
|
||||||
logger.info("[send_text] 回车已发送 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
logger.info("[send_text] 发送按钮已点击 (%.0fms)", (time.perf_counter() - t0) * 1000)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
logger.error("[send_text] 输入/发送步骤超时")
|
logger.error("[send_text] 输入/发送步骤超时")
|
||||||
raise BridgeError(
|
raise BridgeError(
|
||||||
code="SEND_FAILED",
|
code="SEND_FAILED",
|
||||||
message="文本输入/发送步骤超时",
|
message="文本输入/发送步骤超时",
|
||||||
) from exc
|
) from exc
|
||||||
# 3. 生成 local_send_id(本地 ID,非微信原生 msg_id)
|
await self._sleep(1.0)
|
||||||
|
await self._debug_screenshot("after_send")
|
||||||
|
# 3. 按 Esc 退出当前聊天界面,回到会话列表,避免影响下一次发送
|
||||||
|
await self._key("Escape")
|
||||||
|
await self._sleep(1.0)
|
||||||
|
await self._debug_screenshot("after_escape")
|
||||||
|
# 4. 生成 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] 完成 local_send_id=%s (总耗时 %.0fms)",
|
logger.info("[send_text] 完成 local_send_id=%s (总耗时 %.0fms)",
|
||||||
local_send_id, (time.perf_counter() - t_total) * 1000)
|
local_send_id, (time.perf_counter() - t_total) * 1000)
|
||||||
|
|||||||
@ -1291,7 +1291,11 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
return diff === 0;
|
return diff === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => {
|
// 在 onRequest 阶段执行代理:此时 Fastify 尚未通过 content-type parser 消费 req.raw,
|
||||||
|
// 请求体仍完整可读,http-proxy 能正确转发 POST body。
|
||||||
|
// 原方案在 handler 中 reply.hijack() + proxy.web(req.raw, ...) 时 body 已被 Fastify
|
||||||
|
// 在 preValidation 阶段读取完毕,req.raw 已 end,导致上游收到 Content-Length 头但 body 为空 → 30s 超时。
|
||||||
|
const bridgeOnRequest = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
// 鉴权 1:Bearer token(M2M 调用)
|
// 鉴权 1:Bearer token(M2M 调用)
|
||||||
// 仅当面板配置了 WOC_BRIDGE_API_TOKEN 时启用,避免空 token 误放行
|
// 仅当面板配置了 WOC_BRIDGE_API_TOKEN 时启用,避免空 token 误放行
|
||||||
let tokenAuthorized = false;
|
let tokenAuthorized = false;
|
||||||
@ -1317,28 +1321,28 @@ const bridgeHandler = (req: FastifyRequest, reply: FastifyReply) => {
|
|||||||
}
|
}
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
req.raw.url = parsed.rest;
|
req.raw.url = parsed.rest;
|
||||||
proxy.web(req.raw, reply.raw, {
|
proxy.web(req.raw, reply.raw, { target: `http://${inst.containerName}:8088` }, (err: any) => {
|
||||||
target: `http://${inst.containerName}:8088`,
|
try {
|
||||||
onError: (err: any) => {
|
reply.raw.writeHead(502, { 'content-type': 'application/json' });
|
||||||
try {
|
reply.raw.end(JSON.stringify({
|
||||||
reply.raw.writeHead(502, { 'content-type': 'application/json' });
|
success: false,
|
||||||
reply.raw.end(JSON.stringify({
|
error: {
|
||||||
success: false,
|
code: 'BRIDGE_UNAVAILABLE',
|
||||||
error: {
|
message: 'bridge 服务暂时不可用',
|
||||||
code: 'BRIDGE_UNAVAILABLE',
|
details: String(err?.message || err || ''),
|
||||||
message: 'bridge 服务暂时不可用',
|
},
|
||||||
details: String(err?.message || err || ''),
|
}));
|
||||||
},
|
} catch {
|
||||||
}));
|
// 响应已部分写出,无法再写 JSON
|
||||||
} catch {
|
}
|
||||||
// 响应已部分写出,无法再写 JSON,仅记日志
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
app.all('/api/bridge/:id', bridgeHandler);
|
// handler 不会被触发:onRequest 中 reply.hijack() 后 Fastify 不再走 body parser / handler
|
||||||
app.all('/api/bridge/:id/*', bridgeHandler);
|
const bridgeHandler = async (_req: FastifyRequest, _reply: FastifyReply) => {};
|
||||||
|
|
||||||
|
app.all('/api/bridge/:id', { onRequest: bridgeOnRequest }, bridgeHandler);
|
||||||
|
app.all('/api/bridge/:id/*', { onRequest: bridgeOnRequest }, bridgeHandler);
|
||||||
|
|
||||||
// ---------- 静态 SPA + 前端路由回退 ----------
|
// ---------- 静态 SPA + 前端路由回退 ----------
|
||||||
await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });
|
await app.register(fstatic, { root: STATIC_DIR, wildcard: false, index: ['index.html'] });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user