#!/usr/bin/env python3 # -*- coding: utf-8 -*- """注入一条「主动消息」:先落一行 assistant 历史行,再广播带**同一 id** 的 `proactive_message` 帧。 为什么要成对做:真后端 `SendProactive`(`Cyrene_v2/internal/services/gateway/proactive.go:59`)的顺序是 **先落库、再发帧** —— 帧载荷里的 `id`/`createdAt` 就是那条落库行的(`proactive_persist.go:36`)。 客户端据此把实时气泡对齐到历史行:**实时可见 + 刷新后仍在 + 按 id 不会重复**。 只发帧不落行,就相当于真后端「落库失败」那一档(那时帧上**没有** `id`),验的是退化路径,不是正常路径 —— `--without-id` 走的就是这一档,且它**连历史行都不造**(落了行就验不出「刷新后它不在了」)。 帧壳**不带 `sessionId`**(真后端 `wsEvent{Type, Data}` 逐字如此)—— 客户端必须自己钉死主会话, 否则会在每个次级会话里都冒一条。 python proactive_inject.py --port 18080 --text "验收:她主动说了一句" python proactive_inject.py --port 18080 --without-id # 退化档:落库失败 判据(App 侧): 1. 主会话(`web`)里**实时**多一条助手气泡,正文就是 `--text`; 2. 切到任一次级会话 —— **不**出现这条; 3. 下拉/重进聊天页(触发 `GET .../messages`)—— 它**还在**,且**只有一个**(没有被合并成两条)。 """ import argparse import json import sys import urllib.parse import urllib.request def control(port, action, params=None, body=None): url = "http://127.0.0.1:{}/__control/{}".format(port, action) if params: url += "?" + urllib.parse.urlencode(params) data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request(url, data=data, method="POST") request.add_header("Content-Type", "application/json; charset=utf-8") with urllib.request.urlopen(request, timeout=5) as response: return json.loads(response.read().decode("utf-8")) def main(argv=None): parser = argparse.ArgumentParser(description="注入一条带落库行的主动消息") parser.add_argument("--port", type=int, default=18080) parser.add_argument("--session", default="web", help="落库行进哪个会话(真后端恒为主会话)") parser.add_argument("--text", default="验收:她主动说了一句") parser.add_argument("--sender", default="昔涟") parser.add_argument("--without-id", action="store_true", help="故意不带 id/createdAt —— 等价于真后端落库失败那一档") args = parser.parse_args(argv) if args.without_id: data = { "content": args.text, "platform": "web", "sender": args.sender, "messageId": "im-orphan-1", } print("(落库失败档)**不**造历史行 —— 落了行就验不出「刷新后它不在了」") else: seeded = control(args.port, "messages", { "session": args.session, "role": "assistant", "text": args.text, })["data"] data = { "content": args.text, "platform": "web", "sender": args.sender, "messageId": "im-{}".format(seeded["lastId"]), "id": seeded["lastId"], "createdAt": seeded["newestCreatedAt"], } print("落库行 id={} createdAt={}({})".format( seeded["lastId"], seeded["newestCreatedAt"], args.session)) frame = {"type": "proactive_message", "data": data} delivered = control(args.port, "frame", body=frame)["data"]["delivered"] print("广播 proactive_message delivered={} —— {}".format( delivered, json.dumps(frame, ensure_ascii=False))) if not delivered: print("⚠ delivered=0:这一刻没有客户端连着 WS(看 GET /__control/state 的 wsClients)。" "广播没有补发,重跑前先确认连接在。") return 0 if __name__ == "__main__": for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass sys.exit(main())