@@ -902,6 +902,14 @@ def __init__(self, sync_message_queue, lanlan_name, lanlan_prompt):
902902 # 用户活动时间戳:用于主动搭话检测最近是否有用户输入
903903 self .last_user_activity_time = None # float timestamp or None
904904
905+ # 用户「真实消息」时间戳:仅在非空、非 AI 回声的真用户输入时刷新(语音
906+ # 真转录 / 文本输入),不含 VAD 空噪声、麦克风录回 AI 自己 TTS 的回声。
907+ # 区别于 last_user_activity_time(顶部无条件刷新,含回声/空噪声)——后者拿
908+ # 来判 mini-game 邀请「用户是否已回应」会被 AI 念邀请台词的回声污染,导致
909+ # 隐式 dismiss 在用户还没点按钮前就把 pending 邀请清掉、按钮撤走,用户随后
910+ # 点「现在不想玩」落到 expired、真正的 decline 冷却起不来、邀请反复重来。
911+ self .last_user_message_time = None # float timestamp or None
912+
905913 # 用户静默 ≥ IDLE_SESSION_RESET_THRESHOLD_SECONDS 时主动断 session 的
906914 # 后台 loop。lazily 在首次 start_session 时启动,永久存活(per-manager
907915 # 单例),无 active session 时 sleep 后继续轮询。
@@ -2098,6 +2106,50 @@ def _should_suppress_dirty_voice_transcript(self, transcript_text: str) -> bool:
20982106 recent_ai_text = getattr (self , "_recent_ai_voice_echo_text" , "" ) or ""
20992107 return _looks_like_recent_ai_echo (transcript_text , recent_ai_text )
21002108
2109+ async def _dispatch_mini_game_invite_keyword (self , user_text : str ) -> None :
2110+ """扫一遍用户原话里的 mini-game 邀请 accept/decline/later 关键词,命中即
2111+ 触发对应 state 转换 + 推 ``mini_game_invite_resolved`` 让前端 dismiss
2112+ ChoicePrompt(accept 时兼当 launch 信号带 game_url)。
2113+
2114+ 文本输入路径(``_process_stream_data_internal``)与语音转写路径
2115+ (``handle_input_transcript``)共用——语音用户没法点 ChoicePrompt 三按钮,
2116+ 只能说话;口头"现在不想玩"必须和打字 / 点按钮一样触发真正的 decline 冷却。
2117+ 否则语音口头拒绝既不算 decline,又会被下一个 proactive tick 的
2118+ ``_mini_game_invite_advance_response`` 当成隐式 dismiss = 'later'(只抑制
2119+ 5min),邀请反复重来。**不吃掉消息**:普通 chat 流水线仍然回应这条话。
2120+
2121+ main_routers' keyword matcher is registered as a hook on the bus
2122+ (see app/runtime_bindings.py). Dispatcher swallows per-hook errors;
2123+ if no hook is bound (e.g. entrypoint without main_routers), result
2124+ is None.
2125+ """
2126+ outcome = dispatch_text_user_message (self .lanlan_name , user_text or '' )
2127+ # 推一条 mini_game_invite_resolved 给前端:accept 时兼当 launch 信号
2128+ # (带 game_url),decline/later 时让 ChoicePrompt UI 清掉不让按钮挂着——
2129+ # codex P2 指出,原版只对 accept 推,decline/later 命中后前端 prompt 不
2130+ # 消失,用户后续点按钮会被 endpoint 当 expired,state 早变了。
2131+ if not (outcome and outcome .get ('action' )):
2132+ return
2133+ try :
2134+ if self .websocket and hasattr (self .websocket , 'send_json' ):
2135+ ws_state = getattr (self .websocket , 'client_state' , None )
2136+ if ws_state is None or ws_state == ws_state .CONNECTED :
2137+ payload = {
2138+ 'type' : 'mini_game_invite_resolved' ,
2139+ 'session_id' : outcome .get ('session_id' ) or '' ,
2140+ 'action' : outcome ['action' ],
2141+ }
2142+ if outcome .get ('game_url' ):
2143+ payload ['game_url' ] = outcome ['game_url' ]
2144+ if outcome .get ('game_type' ):
2145+ payload ['game_type' ] = outcome ['game_type' ]
2146+ await self .websocket .send_json (payload )
2147+ except Exception as _push_err :
2148+ logger .warning (
2149+ f"[{ self .lanlan_name } ] mini_game_invite_resolved "
2150+ f"WS push failed: { _push_err } " ,
2151+ )
2152+
21012153 async def handle_input_transcript (self , transcript : str , * , is_voice_source : bool = True ):
21022154 """输入转录回调:同步转录文本到消息队列和缓存,并发送到前端显示
21032155
@@ -2114,8 +2166,13 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21142166 transcript_text = transcript .strip ()
21152167 voice_rms_recorded = False
21162168
2117- # 更新用户活动时间戳(用于主动搭话检测)
2118- self .last_user_activity_time = time .time ()
2169+ # 更新用户活动时间戳(用于主动搭话检测)。先捕获「转写到达时刻」局部变量,
2170+ # 下面 last_user_message_time 复用同一时刻——若 takeover dispatcher 注册,
2171+ # 这条转写会先 await 它再走到下面的真消息块;用 await 之后的 time.time() 会
2172+ # 把时间戳推迟,万一 await 期间投递了 invite,invite 之前说的话会被记成 >
2173+ # delivered_at、被下个 tick 误判成 invite 之后的回应(codex P2)。
2174+ _transcript_arrival_ts = time .time ()
2175+ self .last_user_activity_time = _transcript_arrival_ts
21192176 if (
21202177 is_voice_source
21212178 and transcript_text
@@ -2169,6 +2226,11 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21692226 # emotion-tier LLM 用——空 transcript 这些副作用都不该触发。
21702227 if transcript_text :
21712228 self ._activity_tracker .on_user_message (text = transcript )
2229+ # 真实用户语音消息(已过 echo 抑制 + 非空)才刷「真消息」时间戳,
2230+ # 给 mini-game 邀请隐式 dismiss 用,避免回声/空噪声误判用户已回应。
2231+ # 用顶部捕获的到达时刻而非此处 time.time():takeover dispatcher 的
2232+ # await 不会把它推迟到 await 之后(codex P2)。
2233+ self .last_user_message_time = _transcript_arrival_ts
21722234 self ._session_turn_count += 1
21732235 # Telemetry:D1 漏斗——本进程首条用户消息(语音路径)。
21742236 try :
@@ -2187,6 +2249,13 @@ async def handle_input_transcript(self, transcript: str, *, is_voice_source: boo
21872249 # 这里只覆盖语音路径,避免 openclaw handoff(is_voice_source=False)
21882250 # 重复发布。
21892251 self ._publish_user_utterance_to_plugin_bus (transcript , is_voice_source = True )
2252+
2253+ # Mini-game 邀请关键词兜底:与文本路径
2254+ # (_process_stream_data_internal)对偶。语音用户没法点
2255+ # ChoicePrompt 三按钮,只能说话——口头"现在不想玩"必须和打字 /
2256+ # 点按钮一样触发真正的 decline 冷却,否则邀请会按 5min 隐式
2257+ # dismiss 反复重来。详见 _dispatch_mini_game_invite_keyword。
2258+ await self ._dispatch_mini_game_invite_keyword (transcript )
21902259 else :
21912260 # Non-voice reuse of this method (e.g. openclaw text handoff).
21922261 # Skip activity-tracker hooks entirely — the text-mode entry
@@ -7146,6 +7215,13 @@ async def _process_stream_data_internal(self, message: dict):
71467215 # 对偶)。idle reset loop 依赖该字段判断静默时长,文本路径不补的话
71477216 # 纯文本会话永远满足"静默 ≥ 30 min"被误重置。
71487217 self .last_user_activity_time = time .time ()
7218+ # 「真消息」时间戳:strip 后非空才刷,与语音路径
7219+ # `if transcript_text:` 对偶——空白输入不算真实回应,否则会误
7220+ # 推进 mini-game 邀请隐式 dismiss 判定(CodeRabbit)。注意
7221+ # last_user_activity_time 仍无条件刷(服务 idle reset,语义是
7222+ # 「有没有发请求」,与「是不是真消息」不同)。
7223+ if data .strip ():
7224+ self .last_user_message_time = time .time ()
71497225
71507226 # 更新字数限制(可能用户在对话期间修改了设置)
71517227 if hasattr (self .session , 'update_max_response_length' ):
@@ -7199,46 +7275,13 @@ async def _process_stream_data_internal(self, message: dict):
71997275 )
72007276
72017277 # Mini-game 邀请的关键词文本兜底(PR #1141 follow-up E2)。
7202- # 用户在 pending 邀请期间自己打字(没点 ChoicePrompt 三按
7203- # 钮)→ 扫关键词命中就触发对应 state 转换。**不吃掉消息**:
7204- # 继续走普通 chat 流水线,AI 仍然会回应这条话——AI 收到的
7205- # 上下文里也含这条用户输入,所以模型会自然把"好啊"、"不
7206- # 玩了"之类的回复处理掉。仅做 state side effect + accept 时
7207- # 推一条 mini_game_launch WS 让前端 window.open 游戏。
7208- # main_routers' keyword matcher is registered as a hook
7209- # on the bus (see app/runtime_bindings.py). Dispatcher
7210- # swallows per-hook errors; if no hook is bound (e.g.
7211- # entrypoint without main_routers), result is None.
7212- _kw_outcome = dispatch_text_user_message (
7213- self .lanlan_name ,
7278+ # 用户在 pending 邀请期间自己打字(没点 ChoicePrompt 三按钮)
7279+ # → 扫关键词命中就触发对应 state 转换。与语音转写路径
7280+ # (handle_input_transcript)共用同一方法,逻辑见
7281+ # _dispatch_mini_game_invite_keyword。
7282+ await self ._dispatch_mini_game_invite_keyword (
72147283 data if isinstance (data , str ) else '' ,
72157284 )
7216- # 推一条 mini_game_invite_resolved 给前端:accept 时兼当 launch
7217- # 信号(带 game_url),decline/later 时让 ChoicePrompt UI 清掉
7218- # 不让按钮挂着——codex P2 指出,原版只对 accept 推,
7219- # decline/later keyword 命中后前端 prompt 不消失,用户后续点
7220- # 按钮会被 endpoint 当 expired,state 早变了。
7221- if _kw_outcome and _kw_outcome .get ('action' ):
7222- try :
7223- if (self .websocket
7224- and hasattr (self .websocket , 'send_json' )):
7225- ws_state = getattr (self .websocket , 'client_state' , None )
7226- if ws_state is None or ws_state == ws_state .CONNECTED :
7227- payload = {
7228- 'type' : 'mini_game_invite_resolved' ,
7229- 'session_id' : _kw_outcome .get ('session_id' ) or '' ,
7230- 'action' : _kw_outcome ['action' ],
7231- }
7232- if _kw_outcome .get ('game_url' ):
7233- payload ['game_url' ] = _kw_outcome ['game_url' ]
7234- if _kw_outcome .get ('game_type' ):
7235- payload ['game_type' ] = _kw_outcome ['game_type' ]
7236- await self .websocket .send_json (payload )
7237- except Exception as _push_err :
7238- logger .warning (
7239- f"[{ self .lanlan_name } ] mini_game_invite_resolved "
7240- f"WS push failed: { _push_err } " ,
7241- )
72427285
72437286 should_handoff , openclaw_messages = await self ._should_handoff_text_to_openclaw (data )
72447287 if should_handoff :
0 commit comments