Skip to content

Commit 5cf8d14

Browse files
authored
fix(qq): isolate conversation sessions and preserve reply routes (#7169)
1 parent dd0c65e commit 5cf8d14

3 files changed

Lines changed: 445 additions & 25 deletions

File tree

src/qwenpaw/app/channels/qq/cards/tool_guard.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ async def _send_resolved_message(
473473
guild_id = str(session_ctx.get("gid") or "")
474474
msg_id = str(session_ctx.get("mid") or "")
475475

476-
if not sender_id and not group_openid and not channel_id:
476+
if not any((sender_id, group_openid, channel_id, guild_id)):
477477
logger.warning("qq resolved message: no target to send to")
478478
return
479479

@@ -525,7 +525,9 @@ def _enqueue_approval_command(
525525
session_id = str(session_ctx.get("sid") or "")
526526
message_type = str(session_ctx.get("mt") or "c2c")
527527
group_openid = str(session_ctx.get("goid") or "")
528-
is_group = message_type == "group"
528+
channel_id = str(session_ctx.get("cid") or "")
529+
guild_id = str(session_ctx.get("gid") or "")
530+
is_group = message_type in ("group", "guild")
529531

530532
command_text = f"/approval {action} {request_id}"
531533
payload = {
@@ -540,6 +542,8 @@ def _enqueue_approval_command(
540542
"message_type": message_type,
541543
"sender_id": sender_id,
542544
"group_openid": group_openid,
545+
"channel_id": channel_id,
546+
"guild_id": guild_id,
543547
"is_group": is_group,
544548
"from_card_action": True,
545549
},

src/qwenpaw/app/channels/qq/channel.py

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -857,29 +857,122 @@ def _resolve_send_path(
857857
guild_id: Optional[str] = None,
858858
) -> tuple[str, bool, str]:
859859
"""Return (api_path, use_msg_seq, seq_key)."""
860-
if message_type == "dm" and guild_id:
860+
if message_type == "dm":
861+
if not guild_id:
862+
raise ValueError("QQ guild DM route requires guild_id")
861863
return (
862864
f"/dms/{guild_id}/messages",
863865
False,
864866
"",
865867
)
866-
if message_type == "group" and group_openid:
868+
if message_type == "group":
869+
if not group_openid:
870+
raise ValueError("QQ group route requires group_openid")
867871
return (
868872
f"/v2/groups/{group_openid}/messages",
869873
True,
870874
"group",
871875
)
872-
if message_type == "guild" and channel_id:
876+
if message_type == "guild":
877+
if not channel_id:
878+
raise ValueError("QQ guild route requires channel_id")
873879
return (
874880
f"/channels/{channel_id}/messages",
875881
False,
876882
"",
877883
)
878-
# c2c or fallback
884+
if message_type == "c2c":
885+
if not sender_id:
886+
raise ValueError("QQ C2C route requires user_openid")
887+
return (
888+
f"/v2/users/{sender_id}/messages",
889+
True,
890+
"c2c",
891+
)
892+
raise ValueError(f"Unsupported QQ message_type: {message_type}")
893+
894+
# ------------------------------------------------------------------
895+
# Session / route helpers
896+
# ------------------------------------------------------------------
897+
898+
def resolve_session_id(
899+
self,
900+
sender_id: str,
901+
channel_meta: Optional[Dict[str, Any]] = None,
902+
) -> str:
903+
"""Return a session ID scoped to one QQ conversation."""
904+
meta = channel_meta or {}
905+
message_type = str(meta.get("message_type") or "c2c")
906+
if message_type == "group":
907+
group_openid = str(meta.get("group_openid") or "unknown")
908+
return f"qq:group:{group_openid}"
909+
if message_type == "guild":
910+
channel_id = str(meta.get("channel_id") or "unknown")
911+
return f"qq:guild:{channel_id}"
912+
if message_type == "dm":
913+
guild_id = str(meta.get("guild_id") or "unknown")
914+
return f"qq:dm:{guild_id}"
915+
return f"qq:c2c:{sender_id or 'unknown'}"
916+
917+
@staticmethod
918+
def _route_meta_from_handle(to_handle: str) -> Dict[str, str]:
919+
"""Decode a QQ session ID or direct handle into routing metadata."""
920+
handle = (to_handle or "").strip()
921+
routes = (
922+
("qq:c2c:", "c2c", "sender_id"),
923+
("qq:group:", "group", "group_openid"),
924+
("qq:guild:", "guild", "channel_id"),
925+
("qq:dm:", "dm", "guild_id"),
926+
("qq:", "c2c", "sender_id"),
927+
("group:", "group", "group_openid"),
928+
("channel:", "guild", "channel_id"),
929+
)
930+
for prefix, message_type, target_key in routes:
931+
if handle.startswith(prefix):
932+
target = handle.removeprefix(prefix)
933+
if target == "unknown":
934+
return {"message_type": message_type}
935+
return {
936+
"message_type": message_type,
937+
target_key: target,
938+
}
939+
return {}
940+
941+
def _normalize_route_meta(
942+
self,
943+
to_handle: str,
944+
meta: Optional[Dict[str, Any]],
945+
) -> Dict[str, Any]:
946+
"""Merge durable route data encoded in ``to_handle`` into metadata."""
947+
route_meta = dict(meta or {})
948+
encoded_route = self._route_meta_from_handle(to_handle)
949+
if encoded_route:
950+
route_meta.update(encoded_route)
951+
else:
952+
route_meta.setdefault("message_type", "c2c")
953+
if to_handle:
954+
route_meta.setdefault("sender_id", to_handle)
955+
return route_meta
956+
957+
def to_handle_from_target(self, *, user_id: str, session_id: str) -> str:
958+
"""Return a durable QQ session handle for proactive sends."""
959+
return session_id or f"qq:c2c:{user_id}"
960+
961+
def get_to_handle_from_request(self, request: Any) -> str:
962+
"""Return the request session ID so replies retain their route."""
963+
session_id = getattr(request, "session_id", "") or ""
964+
user_id = getattr(request, "user_id", "") or ""
965+
return session_id or f"qq:c2c:{user_id}"
966+
967+
def get_on_reply_sent_args(
968+
self,
969+
request: Any,
970+
to_handle: str,
971+
) -> tuple:
972+
"""Report the original QQ user and isolated session to the callback."""
879973
return (
880-
f"/v2/users/{sender_id}/messages",
881-
True,
882-
"c2c",
974+
getattr(request, "user_id", "") or "",
975+
getattr(request, "session_id", "") or "",
883976
)
884977

885978
async def _dispatch_text(
@@ -1093,12 +1186,12 @@ async def send(
10931186
meta: Optional[Dict[str, Any]] = None,
10941187
) -> None:
10951188
"""Send one text via QQ HTTP API.
1096-
Routes by meta or to_handle (group:/channel:/openid).
1189+
Routes by metadata or a conversation-scoped QQ session handle.
10971190
"""
10981191
if not self.enabled or not text.strip():
10991192
return
11001193
text = text.strip()
1101-
meta = meta or {}
1194+
meta = self._normalize_route_meta(to_handle, meta)
11021195
use_markdown = _as_bool(
11031196
meta.get("markdown_enabled", self._markdown_enabled),
11041197
)
@@ -1108,21 +1201,12 @@ async def send(
11081201
logger.info(
11091202
"qq send: stripped URL content for API compatibility",
11101203
)
1111-
message_type = meta.get("message_type")
1204+
message_type = str(meta.get("message_type") or "c2c")
11121205
msg_id = meta.get("message_id")
11131206
sender_id = meta.get("sender_id") or to_handle
11141207
channel_id = meta.get("channel_id")
11151208
group_openid = meta.get("group_openid")
11161209
guild_id = meta.get("guild_id")
1117-
if message_type is None:
1118-
if to_handle.startswith("group:"):
1119-
message_type = "group"
1120-
group_openid = to_handle[6:]
1121-
elif to_handle.startswith("channel:"):
1122-
message_type = "guild"
1123-
channel_id = to_handle[8:]
1124-
else:
1125-
message_type = "c2c"
11261210
try:
11271211
token = await self._get_access_token_async()
11281212
except Exception:
@@ -1326,14 +1410,19 @@ def build_agent_request_from_native(self, native_payload: Any) -> Any:
13261410
if attachments:
13271411
media_parts = self._parse_qq_attachments(attachments)
13281412
content_parts = list(content_parts) + media_parts
1329-
session_id = self.resolve_session_id(sender_id, meta)
1330-
return self.build_agent_request_from_user_content(
1413+
session_id = payload.get("session_id") or self.resolve_session_id(
1414+
sender_id,
1415+
meta,
1416+
)
1417+
request = self.build_agent_request_from_user_content(
13311418
channel_id=channel_id,
13321419
sender_id=sender_id,
13331420
session_id=session_id,
13341421
content_parts=content_parts,
13351422
channel_meta=meta,
13361423
)
1424+
request.channel_meta = meta
1425+
return request
13371426

13381427
# ------------------------------------------------------------------
13391428
# Instant acknowledgment
@@ -1573,6 +1662,7 @@ async def on_event_message_completed(
15731662
send_meta: Dict[str, Any],
15741663
) -> None:
15751664
"""Render card-flagged events via the card handler; else default."""
1665+
send_meta = self._normalize_route_meta(to_handle, send_meta)
15761666
if await self._card_handler.try_send_card_for_event(
15771667
to_handle,
15781668
event,
@@ -2041,7 +2131,7 @@ async def send_content_parts(
20412131

20422132
body = "\n".join(text_parts).strip() if text_parts else ""
20432133

2044-
meta = meta or {}
2134+
meta = self._normalize_route_meta(to_handle, meta)
20452135
message_type = meta.get("message_type", "c2c")
20462136
msg_id = meta.get("message_id")
20472137

@@ -2105,7 +2195,7 @@ async def send_media(
21052195
if not self.enabled:
21062196
return
21072197

2108-
meta = meta or {}
2198+
meta = self._normalize_route_meta(to_handle, meta)
21092199
(
21102200
message_type,
21112201
sender_id,

0 commit comments

Comments
 (0)