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 +37 -0
- bub_qq/channel.py +290 -0
- bub_qq/config.py +149 -0
- bub_qq/gateway/__init__.py +25 -0
- bub_qq/gateway/info.py +89 -0
- bub_qq/gateway/webhook.py +227 -0
- bub_qq/gateway/websocket.py +522 -0
- bub_qq/gateway/ws_errors.py +46 -0
- bub_qq/inbound/__init__.py +13 -0
- bub_qq/inbound/c2c.py +140 -0
- bub_qq/inbound/common.py +50 -0
- bub_qq/inbound/group.py +191 -0
- bub_qq/inbound/interaction.py +64 -0
- bub_qq/outbound/__init__.py +9 -0
- bub_qq/outbound/c2c.py +108 -0
- bub_qq/outbound/group.py +140 -0
- bub_qq/outbound/markdown.py +80 -0
- bub_qq/outbound/send_errors.py +142 -0
- bub_qq/outbound/send_flow.py +378 -0
- bub_qq/plugin.py +251 -0
- bub_qq/protocol/__init__.py +21 -0
- bub_qq/protocol/auth.py +113 -0
- bub_qq/protocol/errors.py +840 -0
- bub_qq/protocol/models.py +238 -0
- bub_qq/protocol/openapi.py +309 -0
- bub_qq/protocol/signature.py +56 -0
- bub_qq/py.typed +0 -0
- bub_qq/runtime.py +24 -0
- bub_qq/security.py +196 -0
- bub_qq/session.py +91 -0
- bub_qq/store.py +99 -0
- bub_qq/tools.py +87 -0
- bub_qq-0.0.1.dist-info/METADATA +361 -0
- bub_qq-0.0.1.dist-info/RECORD +38 -0
- bub_qq-0.0.1.dist-info/WHEEL +4 -0
- bub_qq-0.0.1.dist-info/entry_points.txt +3 -0
- skills/qq/SKILL.md +63 -0
- skills/qq/__init__.py +1 -0
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
|
+
)
|