refactor(bridge+panel): 重构发送逻辑并优化默认配置

1. 调整默认发送延迟从800ms改为3000ms,更新配置默认值与环境变量 fallback
2. 重写Fastify代理路由,修复POST body被提前消费导致的转发超时问题
3. 新增DB校验机制,发送后轮询确认消息是否到达目标会话
4. 重构xdotool操作流程:改用鼠标点击搜索与发送,替代快捷键组合以适配微信4.x自绘UI
5. 优化窗口激活、会话搜索与输入焦点处理逻辑,提升发送稳定性
This commit is contained in:
Kris 2026-07-15 20:14:33 +08:00
parent 5d84df902a
commit 2e056f9bae
5 changed files with 256 additions and 92 deletions

View File

@ -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")),

View File

@ -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:
"""初始化队列配置。 """初始化队列配置。

View File

@ -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_timeDB 不可用时返回 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: SendTextRequestto_wxid + content + 可选 display_name req: SendTextRequestto_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,

View File

@ -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)

View File

@ -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) => {
// 鉴权 1Bearer tokenM2M 调用) // 鉴权 1Bearer tokenM2M 调用)
// 仅当面板配置了 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'] });