bub-qq 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bub_qq/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """QQ Open Platform channel for Bub.
2
+
3
+ Package layout:
4
+
5
+ - ``protocol``: auth, OpenAPI client, signatures, and event models
6
+ - ``gateway``: webhook / websocket receive transports
7
+ - ``inbound``: QQ events adapted to Bub ``ChannelMessage``
8
+ - ``outbound``: Bub messages delivered through QQ OpenAPI
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .channel import QQChannel
14
+ from .config import QQConfig
15
+ from .gateway import QQGatewayInfo
16
+ from .gateway import QQSessionStartLimit
17
+ from .gateway import QQWebhookServer
18
+ from .gateway import QQWebSocketClient
19
+ from .protocol import QQC2CMessage
20
+ from .protocol import QQGroupMessage
21
+ from .protocol import QQOpenAPI
22
+ from .protocol import QQOpenAPIError
23
+ from .protocol import QQTokenProvider
24
+
25
+ __all__ = [
26
+ "QQChannel",
27
+ "QQConfig",
28
+ "QQGatewayInfo",
29
+ "QQOpenAPI",
30
+ "QQOpenAPIError",
31
+ "QQSessionStartLimit",
32
+ "QQTokenProvider",
33
+ "QQWebhookServer",
34
+ "QQWebSocketClient",
35
+ "QQC2CMessage",
36
+ "QQGroupMessage",
37
+ ]
bub_qq/channel.py ADDED
@@ -0,0 +1,290 @@
1
+ """QQ channel with auth, OpenAPI and pluggable receive transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import bub
10
+ from bub.channels import Channel
11
+ from bub.channels.message import ChannelMessage
12
+ from bub.channels.contracts import MessageHandler
13
+ from loguru import logger
14
+
15
+ from .config import QQConfig
16
+ from .gateway.webhook import QQWebhookServer
17
+ from .gateway.websocket import QQWebSocketClient
18
+ from .inbound.c2c import QQC2CInboundService
19
+ from .inbound.group import GROUP_EVENTS
20
+ from .inbound.group import QQGroupInboundService
21
+ from .inbound.group import group_was_mentioned
22
+ from .inbound.interaction import INTERACTION_QUERY
23
+ from .inbound.interaction import INTERACTION_UPDATE
24
+ from .inbound.interaction import build_claw_cfg
25
+ from .inbound.interaction import extract_claw_cfg_update
26
+ from .inbound.interaction import parse_interaction_event
27
+ from .outbound.c2c import QQC2CSendService
28
+ from .outbound.group import QQGroupSendService
29
+ from .protocol.auth import QQTokenProvider
30
+ from .protocol.errors import QQOpenAPIError
31
+ from .protocol.openapi import QQOpenAPI
32
+ from .runtime import set_active_channel
33
+ from .security import QQAccessPolicy
34
+ from .session import QQInboundDeduper
35
+ from .session import QQSessionState
36
+ from .store import QQPlatformStore
37
+
38
+ # Admin toggles for proactive messages, pushed when a group admin or C2C
39
+ # user flips the "allow active messages" switch in the QQ client.
40
+ _MSG_TOGGLE_EVENTS: dict[str, tuple[str, str, bool]] = {
41
+ "GROUP_MSG_RECEIVE": ("group", "group_openid", True),
42
+ "GROUP_MSG_REJECT": ("group", "group_openid", False),
43
+ "C2C_MSG_RECEIVE": ("c2c", "openid", True),
44
+ "C2C_MSG_REJECT": ("c2c", "openid", False),
45
+ }
46
+
47
+
48
+ class QQChannel(Channel):
49
+ """QQ channel registration with reusable auth and OpenAPI client."""
50
+
51
+ name = "qq"
52
+
53
+ def __init__(self, on_receive: MessageHandler) -> None:
54
+ self._on_receive = on_receive
55
+ self._config = bub.ensure_config(QQConfig)
56
+ self._token_provider = QQTokenProvider(self._config)
57
+ self._openapi = QQOpenAPI(self._config, self._token_provider)
58
+ self._webhook = QQWebhookServer(self._config, self._handle_transport_payload)
59
+ self._websocket = QQWebSocketClient(
60
+ self._config, self._openapi, self._handle_transport_payload
61
+ )
62
+ self._deduper = QQInboundDeduper(self._config.inbound_dedupe_size)
63
+ self._session_state = QQSessionState(
64
+ max_entries=self._config.session_state_size
65
+ )
66
+ self._policy = QQAccessPolicy.from_config(self._config)
67
+ self._platform_store = QQPlatformStore(self._resolve_state_path())
68
+ # In tool reply mode the model must reply through the qq.send tool,
69
+ # so direct model output is routed to the "null" channel and dropped.
70
+ suppress_direct_output = self._config.reply_mode == "tool"
71
+ self._c2c_inbound = QQC2CInboundService(
72
+ channel_name=self.name,
73
+ deduper=self._deduper,
74
+ state=self._session_state,
75
+ policy=self._policy,
76
+ suppress_direct_output=suppress_direct_output,
77
+ )
78
+ self._group_inbound = QQGroupInboundService(
79
+ channel_name=self.name,
80
+ deduper=self._deduper,
81
+ state=self._session_state,
82
+ policy=self._policy,
83
+ suppress_direct_output=suppress_direct_output,
84
+ )
85
+ self._c2c_send = QQC2CSendService(
86
+ channel_name=self.name,
87
+ receive_mode=self._config.receive_mode,
88
+ state=self._session_state,
89
+ openapi=self._openapi,
90
+ passive_reply_window_seconds=self._config.passive_reply_window_seconds,
91
+ passive_replies_per_msg_id=self._config.passive_replies_per_msg_id,
92
+ )
93
+ self._group_send = QQGroupSendService(
94
+ channel_name=self.name,
95
+ receive_mode=self._config.receive_mode,
96
+ state=self._session_state,
97
+ openapi=self._openapi,
98
+ passive_reply_window_seconds=self._config.passive_reply_window_seconds,
99
+ passive_replies_per_msg_id=self._config.passive_replies_per_msg_id,
100
+ active_messages=self._config.active_messages,
101
+ platform_store=self._platform_store,
102
+ )
103
+ set_active_channel(self)
104
+
105
+ def _resolve_state_path(self) -> Path:
106
+ raw = (self._config.state_file or "").strip()
107
+ if raw:
108
+ return Path(raw).expanduser()
109
+ return bub.home / "qq" / "state.json"
110
+
111
+ @property
112
+ def needs_debounce(self) -> bool:
113
+ return True
114
+
115
+ async def start(self, stop_event: asyncio.Event | None) -> None:
116
+ if not self._config.appid or not self._config.secret:
117
+ raise RuntimeError("qq appid/secret is empty")
118
+
119
+ mode = self._normalize_receive_mode()
120
+ if mode == "webhook":
121
+ await self._webhook.start()
122
+ logger.info(
123
+ "qq.start mode=webhook reply_mode={} token_url={} openapi_base_url={} webhook=http://{}:{}{} websocket=disabled",
124
+ self._config.reply_mode,
125
+ self._config.token_url,
126
+ self._config.openapi_base_url,
127
+ self._config.webhook_host,
128
+ self._config.webhook_port,
129
+ self._config.webhook_path,
130
+ )
131
+ return
132
+
133
+ await self._websocket.start(stop_event)
134
+ logger.info(
135
+ "qq.start mode=websocket reply_mode={} token_url={} openapi_base_url={} intents={} webhook=disabled",
136
+ self._config.reply_mode,
137
+ self._config.token_url,
138
+ self._config.openapi_base_url,
139
+ self._config.websocket_intents,
140
+ )
141
+
142
+ async def stop(self) -> None:
143
+ await self._webhook.stop()
144
+ await self._websocket.stop()
145
+ await self._openapi.aclose()
146
+ logger.info("qq.stopped")
147
+
148
+ async def send(self, message: ChannelMessage) -> None:
149
+ await self.send_for_result(message)
150
+
151
+ async def send_for_result(self, message: ChannelMessage) -> dict[str, object] | None:
152
+ """Send and return the send-service result (used by the qq.send tool)."""
153
+
154
+ if _is_group_target(self.name, message):
155
+ return await self._group_send.send(message)
156
+ return await self._c2c_send.send(message)
157
+
158
+ async def _handle_transport_payload(self, payload: dict[str, Any]) -> None:
159
+ op = payload.get("op")
160
+ event_type = payload.get("t")
161
+ if op != 0:
162
+ logger.info("qq.transport.ignored op={} t={}", op, event_type)
163
+ return
164
+ if event_type == "READY":
165
+ logger.info("qq.websocket.ready")
166
+ return
167
+ if event_type == "RESUMED":
168
+ logger.info("qq.websocket.resumed")
169
+ return
170
+ if event_type == "C2C_MESSAGE_CREATE":
171
+ await self._handle_c2c_message(payload)
172
+ return
173
+ if event_type in GROUP_EVENTS:
174
+ await self._handle_group_message(payload)
175
+ return
176
+ if event_type == "INTERACTION_CREATE":
177
+ await self._handle_interaction(payload)
178
+ return
179
+ if event_type in _MSG_TOGGLE_EVENTS:
180
+ self._handle_msg_toggle(event_type, payload)
181
+ return
182
+ logger.info("qq.transport.unhandled event={} op={}", event_type, op)
183
+
184
+ def _handle_msg_toggle(self, event_type: str, payload: dict[str, Any]) -> None:
185
+ scope, id_field, allowed = _MSG_TOGGLE_EVENTS[event_type]
186
+ data = payload.get("d")
187
+ openid = str(data.get(id_field) or "").strip() if isinstance(data, dict) else ""
188
+ if not openid:
189
+ logger.warning(
190
+ "qq.msg_toggle.invalid_payload event={} reason=missing_{}",
191
+ event_type,
192
+ id_field,
193
+ )
194
+ return
195
+ self._platform_store.update(scope, openid, active_messages=allowed)
196
+ logger.info(
197
+ "qq.msg_toggle event={} scope={} openid={} active_messages={}",
198
+ event_type,
199
+ scope,
200
+ openid,
201
+ allowed,
202
+ )
203
+
204
+ async def _handle_c2c_message(self, payload: dict[str, Any]) -> None:
205
+ parsed = self._c2c_inbound.parse_inbound(payload)
206
+ if parsed is None:
207
+ return
208
+ message, channel_message = parsed
209
+ logger.info(
210
+ "qq.c2c.inbound session_id={} user_openid={} content_len={} attachments={}",
211
+ channel_message.session_id,
212
+ message.user_openid,
213
+ len(message.content),
214
+ len(message.attachments),
215
+ )
216
+ await self._on_receive(channel_message)
217
+
218
+ async def _handle_group_message(self, payload: dict[str, Any]) -> None:
219
+ parsed = self._group_inbound.parse_inbound(payload)
220
+ if parsed is None:
221
+ return
222
+ message, channel_message = parsed
223
+ logger.info(
224
+ "qq.group.inbound session_id={} group_openid={} member_openid={} was_mentioned={} is_active={} content_len={}",
225
+ channel_message.session_id,
226
+ message.group_openid,
227
+ message.member_openid,
228
+ group_was_mentioned(message),
229
+ channel_message.is_active,
230
+ len(message.content),
231
+ )
232
+ await self._on_receive(channel_message)
233
+
234
+ async def _handle_interaction(self, payload: dict[str, Any]) -> None:
235
+ event = parse_interaction_event(payload)
236
+ if event is None:
237
+ return
238
+ event_type = event["type"]
239
+ if event_type in {INTERACTION_QUERY, INTERACTION_UPDATE}:
240
+ group_openid = event["group_openid"]
241
+ if event_type == INTERACTION_UPDATE and group_openid:
242
+ update = extract_claw_cfg_update(event)
243
+ if update:
244
+ self._platform_store.update("group", group_openid, **update)
245
+ logger.info(
246
+ "qq.interaction.claw_cfg_updated group_openid={} update={}",
247
+ group_openid,
248
+ update,
249
+ )
250
+ require_mention = (
251
+ self._platform_store.require_mention(group_openid)
252
+ if group_openid
253
+ else None
254
+ )
255
+ claw_cfg = (
256
+ build_claw_cfg(require_mention=require_mention)
257
+ if require_mention
258
+ else build_claw_cfg()
259
+ )
260
+ try:
261
+ await self._openapi.put_interaction(
262
+ interaction_id=event["id"],
263
+ code=0,
264
+ data={"claw_cfg": claw_cfg},
265
+ )
266
+ except QQOpenAPIError as exc:
267
+ logger.warning(
268
+ "qq.interaction.ack_failed id={} code={} error={}",
269
+ event["id"],
270
+ exc.error_code,
271
+ exc.error_message,
272
+ )
273
+ return
274
+ logger.info("qq.interaction.unhandled type={}", event_type)
275
+
276
+ def _normalize_receive_mode(self) -> str:
277
+ mode = (self._config.receive_mode or "").strip().lower()
278
+ if mode not in {"webhook", "websocket"}:
279
+ raise RuntimeError(
280
+ f"qq receive_mode must be webhook or websocket, got {self._config.receive_mode!r}"
281
+ )
282
+ return mode
283
+
284
+
285
+ def _is_group_target(channel_name: str, message: ChannelMessage) -> bool:
286
+ chat_id = message.chat_id or ""
287
+ session_id = message.session_id or ""
288
+ return chat_id.startswith("group:") or session_id.startswith(
289
+ f"{channel_name}:group:"
290
+ )
bub_qq/config.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ import bub
6
+ from pydantic import Field
7
+ from pydantic_settings import SettingsConfigDict
8
+
9
+ type ToolPolicy = Literal["open", "restricted", "locked"]
10
+ type ReplyMode = Literal["direct", "tool"]
11
+
12
+
13
+ @bub.config(name="qq")
14
+ class QQConfig(bub.Settings):
15
+ """QQ Open Platform adapter config."""
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_prefix="BUB_QQ_",
19
+ env_file=".env",
20
+ extra="ignore",
21
+ )
22
+
23
+ appid: str = ""
24
+ secret: str = ""
25
+ token_url: str = "https://bots.qq.com/app/getAppAccessToken"
26
+ openapi_base_url: str = "https://api.bot.qq.com"
27
+ timeout_seconds: float = 30.0
28
+ token_refresh_skew_seconds: int = 60
29
+ receive_mode: str = Field(
30
+ default="",
31
+ description="QQ inbound transport mode. Must be set to 'webhook' or 'websocket' before gateway start.",
32
+ )
33
+ webhook_host: str = "127.0.0.1"
34
+ webhook_port: int = 8080
35
+ webhook_path: str = "/qq/webhook"
36
+ webhook_callback_timeout_seconds: float = 15.0
37
+ verify_signature: bool = True
38
+ webhook_signature_timestamp_tolerance_seconds: float = Field(
39
+ default=0.0,
40
+ ge=0,
41
+ description=(
42
+ "Reject webhook requests whose signature timestamp deviates from local"
43
+ " time by more than this many seconds. 0 disables the freshness check."
44
+ ),
45
+ )
46
+ inbound_dedupe_size: int = Field(default=1024, ge=1)
47
+ session_state_size: int = Field(
48
+ default=1024,
49
+ ge=1,
50
+ description="Max sessions/send records kept in memory for passive replies.",
51
+ )
52
+ passive_reply_window_seconds: float = Field(
53
+ default=3600.0,
54
+ gt=0,
55
+ description="How long after an inbound message passive replies are attempted.",
56
+ )
57
+ active_messages: bool = Field(
58
+ default=False,
59
+ description=(
60
+ "Send proactive group messages (no msg_id) when a passive reply"
61
+ " is impossible. Requires the group admin to allow proactive"
62
+ " messages in the QQ client; consumes platform quota."
63
+ ),
64
+ )
65
+ reply_mode: ReplyMode = Field(
66
+ default="direct",
67
+ description=(
68
+ "How model output reaches QQ. 'direct' forwards the model's"
69
+ " final text as the reply (output exactly <no_reply/> to stay"
70
+ " silent). 'tool' disables direct forwarding and exposes the"
71
+ " qq.send tool instead; staying silent means not calling it."
72
+ ),
73
+ )
74
+ passive_replies_per_msg_id: int = Field(
75
+ default=4,
76
+ ge=1,
77
+ description=(
78
+ "Local cap of passive replies per inbound msg_id, aligned with"
79
+ " the platform limit; beyond it the send falls back to an active"
80
+ " message (when enabled) or is skipped."
81
+ ),
82
+ )
83
+ state_file: str = Field(
84
+ default="",
85
+ description=(
86
+ "Path of the JSON file persisting platform switches (active"
87
+ "-message opt-ins, group claw_cfg). Empty uses"
88
+ " <bub home>/qq/state.json."
89
+ ),
90
+ )
91
+ websocket_intents: int = 1 << 25
92
+ websocket_use_shard_gateway: bool = False
93
+ websocket_reconnect_delay_seconds: float = 5.0
94
+ websocket_reconnect_max_delay_seconds: float = 300.0
95
+ websocket_max_identify_rejections: int = 5
96
+ admin_users: str = Field(
97
+ default="",
98
+ description=(
99
+ "Comma-separated user openids with full comma-command and tool"
100
+ " access in every scope. Comma commands from anyone else are"
101
+ " treated as plain text (in groups, owners/admins also qualify)."
102
+ ),
103
+ )
104
+ allow_users: str = Field(
105
+ default="",
106
+ description=(
107
+ "Comma-separated C2C user openid allowlist. When set, C2C"
108
+ " messages from anyone else are dropped. Empty allows everyone."
109
+ ),
110
+ )
111
+ allow_groups: str = Field(
112
+ default="",
113
+ description=(
114
+ "Comma-separated group openid allowlist. When set, messages from"
115
+ " other groups are dropped. Empty allows every group."
116
+ ),
117
+ )
118
+ group_tool_policy: ToolPolicy = Field(
119
+ default="restricted",
120
+ description=(
121
+ "Tool policy for group sessions: 'open' allows all tools,"
122
+ " 'restricted' denies shell/file-write/subagent tools,"
123
+ " 'locked' denies every tool. Group owners/admins and"
124
+ " admin_users bypass the policy."
125
+ ),
126
+ )
127
+ c2c_tool_policy: ToolPolicy = Field(
128
+ default="open",
129
+ description="Tool policy for C2C sessions; same values as group_tool_policy.",
130
+ )
131
+ denied_tools: str = Field(
132
+ default="",
133
+ description=(
134
+ "Extra comma-separated tool-name glob patterns denied under the"
135
+ " 'restricted' policy, e.g. 'web.fetch,tape.*'."
136
+ ),
137
+ )
138
+ llm_rate_limit_per_minute: int = Field(
139
+ default=0,
140
+ ge=0,
141
+ description=(
142
+ "Max LLM calls per sender per session per minute; exceeding calls"
143
+ " are short-circuited with llm_rate_limit_notice. 0 disables."
144
+ ),
145
+ )
146
+ llm_rate_limit_notice: str = Field(
147
+ default="请求过于频繁,请稍后再试。",
148
+ description="Reply text used when a sender hits the LLM rate limit.",
149
+ )
@@ -0,0 +1,25 @@
1
+ """Receive transports (webhook / websocket) and gateway session helpers."""
2
+
3
+ from .info import QQGatewayInfo
4
+ from .info import QQSessionStartLimit
5
+ from .info import get_gateway
6
+ from .info import get_shard_gateway
7
+ from .info import heartbeat_payload
8
+ from .info import identify_payload
9
+ from .info import resume_payload
10
+ from .webhook import QQWebhookServer
11
+ from .websocket import QQWebSocketClient
12
+ from .ws_errors import QQWebSocketFatalError
13
+
14
+ __all__ = [
15
+ "QQGatewayInfo",
16
+ "QQSessionStartLimit",
17
+ "QQWebSocketClient",
18
+ "QQWebSocketFatalError",
19
+ "QQWebhookServer",
20
+ "get_gateway",
21
+ "get_shard_gateway",
22
+ "heartbeat_payload",
23
+ "identify_payload",
24
+ "resume_payload",
25
+ ]
bub_qq/gateway/info.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from ..protocol.openapi import QQOpenAPI
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class QQSessionStartLimit:
11
+ total: int
12
+ remaining: int
13
+ reset_after: int
14
+ max_concurrency: int
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class QQGatewayInfo:
19
+ url: str
20
+ shards: int | None = None
21
+ session_start_limit: QQSessionStartLimit | None = None
22
+
23
+ @property
24
+ def max_concurrency(self) -> int | None:
25
+ if self.session_start_limit is None:
26
+ return None
27
+ return self.session_start_limit.max_concurrency
28
+
29
+
30
+ async def get_gateway(openapi: QQOpenAPI) -> QQGatewayInfo:
31
+ payload = await openapi.get("/gateway")
32
+ return QQGatewayInfo(url=str(payload["url"]))
33
+
34
+
35
+ async def get_shard_gateway(openapi: QQOpenAPI) -> QQGatewayInfo:
36
+ payload = await openapi.get("/gateway/bot")
37
+ return QQGatewayInfo(
38
+ url=str(payload["url"]),
39
+ shards=int(payload["shards"]) if payload.get("shards") is not None else None,
40
+ session_start_limit=_parse_session_start_limit(
41
+ payload.get("session_start_limit")
42
+ ),
43
+ )
44
+
45
+
46
+ def identify_payload(
47
+ *, token: str, intents: int, shard: tuple[int, int] | None = None
48
+ ) -> dict[str, Any]:
49
+ data: dict[str, Any] = {
50
+ "token": f"QQBot {token}",
51
+ "intents": intents,
52
+ "properties": {
53
+ "$os": "macos",
54
+ "$browser": "bub-qq",
55
+ "$device": "bub-qq",
56
+ },
57
+ }
58
+ if shard is not None:
59
+ data["shard"] = [shard[0], shard[1]]
60
+ return {"op": 2, "d": data}
61
+
62
+
63
+ def resume_payload(*, token: str, session_id: str, sequence: int) -> dict[str, Any]:
64
+ return {
65
+ "op": 6,
66
+ "d": {
67
+ "token": f"QQBot {token}",
68
+ "session_id": session_id,
69
+ "seq": sequence,
70
+ },
71
+ }
72
+
73
+
74
+ def heartbeat_payload(sequence: int | None) -> dict[str, Any]:
75
+ return {"op": 1, "d": sequence}
76
+
77
+
78
+ def _parse_session_start_limit(payload: Any) -> QQSessionStartLimit | None:
79
+ if not isinstance(payload, dict):
80
+ return None
81
+ required_fields = ("total", "remaining", "reset_after", "max_concurrency")
82
+ if any(payload.get(field) is None for field in required_fields):
83
+ return None
84
+ return QQSessionStartLimit(
85
+ total=int(payload["total"]),
86
+ remaining=int(payload["remaining"]),
87
+ reset_after=int(payload["reset_after"]),
88
+ max_concurrency=int(payload["max_concurrency"]),
89
+ )