新增QQ Bot适配器完整代码栈,包含: 1. 基础适配器入口与工具类封装 2. 会话管理、重试队列与流量控制 3. 命令系统与内置指令(ping/help/status等) 4. 富媒体消息处理与格式转换 5. 引用存储与审批管理 6. 凭证备份与会话持久化 7. 健康检查与交互回调系统
1389 lines
52 KiB
Python
1389 lines
52 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from collections.abc import AsyncIterator, AsyncGenerator
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from yuxi.channels.base import BaseChannelAdapter
|
|
from yuxi.channels.capabilities import ChannelCapabilities
|
|
from yuxi.channels.meta import ChannelMeta
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
from yuxi.channels.exceptions import (
|
|
ChannelAuthenticationError,
|
|
ChannelNotConnectedError,
|
|
DeliveryFailedError,
|
|
MessageFormatError,
|
|
)
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
DeliveryResult,
|
|
EventType,
|
|
HealthStatus,
|
|
MessageType,
|
|
MentionsInfo,
|
|
)
|
|
from yuxi.channels.registry import register_builtin_adapter
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
from .constants import (
|
|
DM_CHAT_PREFIX,
|
|
GROUP_CHAT_PREFIX,
|
|
Intent,
|
|
Opcode,
|
|
ECode,
|
|
)
|
|
from .format import format_outbound, MarkdownChunker
|
|
from .probe import health_check_dsm
|
|
from .credential_backup import CredentialBackup, CredentialSnapshot
|
|
from .session_store import SessionStore, SessionRecord
|
|
from .known_users import KnownUserTracker
|
|
from .rate_limiter import RouteRateLimiter
|
|
from .security import QQBotSecurityPolicy, verify_webhook_ed25519
|
|
from .send import send_with_retry, MessageSeqManager
|
|
from .token import QQBotTokenManager
|
|
from .reconnect import QQBotReconnectManager, ReconnectState
|
|
from .c2c_stream import C2CStreamingController
|
|
from .inbound_pipeline import QQBotInboundPipeline
|
|
from .group_buffer import GroupHistoryBuffer
|
|
from .reply_dispatcher import ReplyDispatcher
|
|
from .streaming import ParagraphChunker, stream_content
|
|
from .commands.framework import CommandRegistry
|
|
from .commands.builtin import register_builtin_commands
|
|
from .interaction import InteractionRegistry, InteractionSessionManager, InteractionContext
|
|
from .approval.manager import ExecApprovalManager
|
|
from .audio import TTSProvider, STTProvider, AudioFormatPolicy, AudioFormat
|
|
|
|
|
|
async def _handler_to_generator(handler, msg: ChannelMessage) -> AsyncGenerator[str, None]:
|
|
try:
|
|
async for chunk in handler(msg):
|
|
yield chunk
|
|
except TypeError:
|
|
result = await handler(msg)
|
|
if isinstance(result, str):
|
|
yield result
|
|
elif result is not None:
|
|
yield str(result)
|
|
|
|
|
|
@register_builtin_adapter
|
|
class QQBotAdapter(BaseChannelAdapter):
|
|
channel_id = "qqbot"
|
|
channel_type = ChannelType.QQ_BOT
|
|
|
|
text_chunk_limit = 2000
|
|
supports_markdown = True
|
|
supports_streaming = True
|
|
streaming_modes = ["off", "block"]
|
|
max_media_size_mb = 100
|
|
|
|
capabilities = ChannelCapabilities(
|
|
chat_types=["direct", "group", "guild_channel"],
|
|
replies=True,
|
|
edit=False,
|
|
unsend=False,
|
|
reply=True,
|
|
media=True,
|
|
supports_markdown=True,
|
|
supports_streaming=True,
|
|
streaming_modes=["off", "block"],
|
|
text_chunk_limit=2000,
|
|
max_media_size_mb=100,
|
|
)
|
|
meta = ChannelMeta(id="qqbot", label="QQ Bot")
|
|
|
|
def __init__(self, config: dict[str, Any] | None = None):
|
|
super().__init__(config)
|
|
self._status = ChannelStatus.DISCONNECTED
|
|
self._http_client: aiohttp.ClientSession | None = None
|
|
self._token_manager: QQBotTokenManager | None = None
|
|
self._circuit_breaker = CircuitBreaker(failure_threshold=5)
|
|
self._bot_info: dict[str, Any] | None = None
|
|
self._sandbox = self.config.get("sandbox", False)
|
|
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
self._seq: int | None = None
|
|
self._session_id: str | None = None
|
|
self._heartbeat_interval: float = 30.0
|
|
self._heartbeat_task: asyncio.Task | None = None
|
|
self._last_heartbeat_ack: float = 0
|
|
self._heartbeat_miss_count: int = 0
|
|
self._heartbeat_latency_ms: float = 0
|
|
self._receive_task: asyncio.Task | None = None
|
|
self._message_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue()
|
|
self._recent_msg_ids: dict[str, float] = {}
|
|
self._dedup_window_s: int = int(self.config.get("dedup_window_s", 60))
|
|
|
|
chat_types = self.config.get("chat_types", ["direct", "group", "guild_channel"])
|
|
has_guild = "guild_channel" in chat_types
|
|
self.capabilities = ChannelCapabilities(
|
|
chat_types=list(chat_types),
|
|
replies=True,
|
|
edit=has_guild,
|
|
unsend=has_guild,
|
|
reply=True,
|
|
media=True,
|
|
supports_markdown=True,
|
|
supports_streaming=True,
|
|
streaming_modes=["off", "block"],
|
|
text_chunk_limit=2000,
|
|
max_media_size_mb=100,
|
|
)
|
|
|
|
self._security = QQBotSecurityPolicy(self.config)
|
|
self._credential_backup: CredentialBackup | None = None
|
|
self._session_store: SessionStore | None = None
|
|
self._known_users: KnownUserTracker | None = None
|
|
self._rate_limiter = RouteRateLimiter()
|
|
reconnect_cfg = self.config.get("reconnect", {})
|
|
self._reconnect_manager = QQBotReconnectManager(
|
|
max_retries=reconnect_cfg.get("max_retries", 10),
|
|
base_delay=reconnect_cfg.get("base_delay", 1.0),
|
|
max_delay=reconnect_cfg.get("max_delay", 60.0),
|
|
jitter=reconnect_cfg.get("jitter", 0.3),
|
|
)
|
|
self._reconnect_manager.add_state_listener(self._on_reconnect_state_change)
|
|
self._c2c_streaming: C2CStreamingController | None = None
|
|
self._markdown_chunker: MarkdownChunker = MarkdownChunker()
|
|
self._group_buffer = GroupHistoryBuffer()
|
|
self._msg_seq_manager = MessageSeqManager()
|
|
self._reply_dispatcher = ReplyDispatcher(self)
|
|
self._par_chunker = ParagraphChunker()
|
|
self._command_registry = register_builtin_commands(CommandRegistry())
|
|
self._interaction_registry = InteractionRegistry()
|
|
self._interaction_sessions = InteractionSessionManager()
|
|
self._approval_manager = ExecApprovalManager()
|
|
self._tts_provider = TTSProvider(
|
|
default_voice=self.config.get("tts_default_voice", "zh-CN-XiaoxiaoNeural"),
|
|
default_format=AudioFormat(self.config.get("tts_default_format", "mp3")),
|
|
)
|
|
self._stt_provider = STTProvider.from_config(self.config)
|
|
self._audio_format_policy = AudioFormatPolicy.from_config(self.config)
|
|
|
|
# ========== çå½å¨æ ==========
|
|
|
|
async def connect(self) -> None:
|
|
if self._status == ChannelStatus.CONNECTED:
|
|
return
|
|
|
|
self._status = ChannelStatus.CONNECTING
|
|
await self._reconnect_manager.transition(ReconnectState.CONNECTING)
|
|
|
|
self._restore_credentials()
|
|
self._init_session_store()
|
|
self._init_known_users()
|
|
|
|
logger.info(f"[QQBot] Starting channel '{self.channel_id}'...")
|
|
|
|
try:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = (
|
|
self.config.get("app_secret")
|
|
or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
or self._read_app_secret_file()
|
|
)
|
|
if not app_id or not app_secret:
|
|
raise ChannelAuthenticationError(
|
|
"Missing app_id or app_secret in config, "
|
|
"QQBOT_APP_ID/QQBOT_CLIENT_SECRET env vars, or client_secret_file"
|
|
)
|
|
|
|
self._http_client = aiohttp.ClientSession()
|
|
|
|
self._token_manager = QQBotTokenManager(
|
|
app_id=app_id,
|
|
app_secret=app_secret,
|
|
sandbox=self._sandbox,
|
|
http_client=self._http_client,
|
|
)
|
|
|
|
token = await self._token_manager.get_token()
|
|
logger.debug("[QQBot] Access token obtained")
|
|
|
|
self._token_manager.start_background_refresh()
|
|
|
|
ws_url = await self._get_gateway_url(token)
|
|
logger.debug(f"[QQBot] Gateway URL: {ws_url}")
|
|
|
|
self._ws = await self._http_client.ws_connect(ws_url)
|
|
|
|
hello_data = await self._wait_for_hello()
|
|
self._heartbeat_interval = hello_data.get("heartbeat_interval", 30000) / 1000.0
|
|
|
|
await self._send_identify()
|
|
|
|
self._bot_info = {
|
|
"app_id": app_id,
|
|
"username": self.config.get("bot_name", f"QQBot_{app_id[:6]}"),
|
|
}
|
|
|
|
self._c2c_streaming = C2CStreamingController(
|
|
send_message_fn=self._send_stream_message,
|
|
flush_interval=self.config.get("stream_flush_interval", 0.6),
|
|
)
|
|
|
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
self._receive_task = asyncio.create_task(self._ws_receive_loop())
|
|
|
|
self._status = ChannelStatus.CONNECTED
|
|
await self._reconnect_manager.on_identify_success(self._session_id or "")
|
|
self._reconnect_manager.mark_connected()
|
|
await self._reconnect_manager.transition(ReconnectState.CONNECTED)
|
|
|
|
self._backup_credentials()
|
|
self._save_session()
|
|
|
|
logger.info(
|
|
f"[QQBot] Channel '{self.channel_id}' started "
|
|
f"(sandbox={self._sandbox}, heartbeat={self._heartbeat_interval:.0f}s)"
|
|
)
|
|
|
|
except ChannelAuthenticationError:
|
|
self._status = ChannelStatus.ERROR
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
await self._cleanup_ws()
|
|
raise
|
|
except Exception as e:
|
|
self._status = ChannelStatus.ERROR
|
|
logger.error(f"[QQBot] Failed to start channel '{self.channel_id}': {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
await self._cleanup_ws()
|
|
raise
|
|
|
|
async def disconnect(self) -> None:
|
|
if self._status == ChannelStatus.DISCONNECTED:
|
|
return
|
|
|
|
logger.info(f"[QQBot] Stopping channel '{self.channel_id}'...")
|
|
self._status = ChannelStatus.DISCONNECTED
|
|
await self._reconnect_manager.reset()
|
|
await self._cleanup_ws()
|
|
|
|
if self._token_manager:
|
|
self._token_manager.stop_background_refresh()
|
|
|
|
if self._http_client:
|
|
await self._http_client.close()
|
|
self._http_client = None
|
|
|
|
self._token_manager = None
|
|
self._c2c_streaming = None
|
|
|
|
if self._credential_backup:
|
|
self._credential_backup.clear()
|
|
self._credential_backup = None
|
|
|
|
if self._session_store:
|
|
self._session_store.clear()
|
|
self._session_store = None
|
|
|
|
logger.info(f"[QQBot] Channel '{self.channel_id}' stopped")
|
|
|
|
async def _cleanup_ws(self) -> None:
|
|
for task in (self._heartbeat_task, self._receive_task):
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
self._heartbeat_task = None
|
|
self._receive_task = None
|
|
|
|
if self._ws and not self._ws.closed:
|
|
try:
|
|
await self._ws.close()
|
|
except Exception:
|
|
pass
|
|
self._ws = None
|
|
self._seq = None
|
|
self._session_id = None
|
|
|
|
# ========== WebSocket å
é¨é»è¾ ==========
|
|
|
|
async def _wait_for_hello(self) -> dict:
|
|
if not self._ws:
|
|
raise ChannelAuthenticationError("WebSocket not connected")
|
|
async for msg in self._ws:
|
|
data = json.loads(msg.data)
|
|
op = data.get("op")
|
|
if op == Opcode.HELLO:
|
|
return data.get("d", {})
|
|
logger.debug(f"[QQBot] Skipping op={op} before HELLO")
|
|
|
|
async def _send_identify(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
token = await self._token_manager.get_token()
|
|
payload = {
|
|
"op": Opcode.IDENTIFY,
|
|
"d": {
|
|
"token": f"QQBot {token}",
|
|
"intents": self.config.get("intents", Intent.DEFAULT),
|
|
"shard": [0, 1],
|
|
"properties": {},
|
|
},
|
|
}
|
|
await self._ws.send_json(payload)
|
|
|
|
async def _send_resume(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
token = await self._token_manager.get_token()
|
|
payload = {
|
|
"op": Opcode.RESUME,
|
|
"d": {
|
|
"token": f"QQBot {token}",
|
|
"session_id": self._session_id,
|
|
"seq": self._seq,
|
|
},
|
|
}
|
|
await self._ws.send_json(payload)
|
|
|
|
async def _heartbeat_loop(self) -> None:
|
|
max_misses = 3
|
|
while self._status == ChannelStatus.CONNECTED and self._ws and not self._ws.closed:
|
|
try:
|
|
await asyncio.sleep(self._heartbeat_interval)
|
|
|
|
if self._heartbeat_miss_count >= max_misses:
|
|
logger.warning(f"[QQBot] Heartbeat missed {max_misses} consecutive ACKs, disconnecting")
|
|
self._status = ChannelStatus.RECONNECTING
|
|
break
|
|
|
|
send_time = time.monotonic()
|
|
await self._ws.send_json({"op": Opcode.HEARTBEAT, "d": self._seq})
|
|
|
|
await asyncio.sleep(5)
|
|
ack_received = self._last_heartbeat_ack > send_time
|
|
if not ack_received:
|
|
self._heartbeat_miss_count += 1
|
|
logger.warning(
|
|
"[QQBot] Heartbeat ACK missed (%d/%d), latency=%.0fms",
|
|
self._heartbeat_miss_count, max_misses, self._heartbeat_latency_ms,
|
|
)
|
|
|
|
if self._heartbeat_latency_ms > 0:
|
|
adaptive = max(2.0, min(60.0, self._heartbeat_latency_ms / 1000 * 5))
|
|
self._heartbeat_interval = adaptive
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"[QQBot] Heartbeat failed: {e}")
|
|
self._status = ChannelStatus.RECONNECTING
|
|
break
|
|
|
|
async def _ws_receive_loop(self) -> None:
|
|
if not self._ws:
|
|
return
|
|
try:
|
|
async for msg in self._ws:
|
|
data = json.loads(msg.data)
|
|
op = data.get("op")
|
|
|
|
if op == Opcode.DISPATCH:
|
|
self._seq = data.get("s", self._seq)
|
|
if self._seq:
|
|
await self._reconnect_manager.record_seq(self._seq)
|
|
event_type = data.get("t", "")
|
|
event_data = data.get("d", {})
|
|
await self._dispatch_event(event_type, event_data)
|
|
|
|
elif op == Opcode.HEARTBEAT_ACK:
|
|
self._last_heartbeat_ack = time.monotonic()
|
|
self._heartbeat_miss_count = 0
|
|
logger.debug("[QQBot] Heartbeat ACK received")
|
|
|
|
elif op == Opcode.RECONNECT:
|
|
logger.warning("[QQBot] Server requested reconnect")
|
|
await self._reconnect_manager.on_disconnect(ECode.SESSION_TIMEOUT)
|
|
break
|
|
|
|
elif op == Opcode.INVALID_SESSION:
|
|
logger.warning("[QQBot] Invalid session, will reconnect")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
break
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except aiohttp.ClientConnectionError as e:
|
|
logger.error(f"[QQBot] WebSocket connection error: {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] WebSocket receive error: {e}")
|
|
await self._reconnect_manager.on_disconnect(None)
|
|
finally:
|
|
state = self._reconnect_manager.state
|
|
if state in (ReconnectState.IDENTIFYING, ReconnectState.RESUMING):
|
|
logger.info("[QQBot] WebSocket disconnected, attempting reconnect")
|
|
await self._attempt_reconnect()
|
|
elif state == ReconnectState.FROZEN:
|
|
logger.error("[QQBot] Reconnect manager frozen, giving up")
|
|
self._status = ChannelStatus.ERROR
|
|
|
|
async def _dispatch_event(self, event_type: str, event_data: dict) -> None:
|
|
pipeline = QQBotInboundPipeline(self)
|
|
await pipeline.initialize()
|
|
ctx = await pipeline.process(event_type, event_data)
|
|
|
|
def _prune_old_msg_ids(self, now: float) -> None:
|
|
expired = [mid for mid, ts in self._recent_msg_ids.items() if now - ts >= self._dedup_window_s]
|
|
for mid in expired:
|
|
del self._recent_msg_ids[mid]
|
|
|
|
async def _on_pipeline_dispatch(self, ctx) -> None:
|
|
if self._known_users and ctx.sender_id:
|
|
self._known_users.record(
|
|
user_id=ctx.sender_id,
|
|
username=ctx.sender_name or "",
|
|
chat_type=ctx.chat_type or "",
|
|
)
|
|
|
|
msg = self._build_channel_message(ctx)
|
|
if msg is None:
|
|
return
|
|
|
|
if msg.message_type.value == "command":
|
|
handled = await self._handle_command(msg)
|
|
if handled:
|
|
return
|
|
|
|
if msg.message_type.value == "interaction":
|
|
handled = await self._handle_interaction(msg, ctx)
|
|
if handled:
|
|
return
|
|
|
|
if self.config.get("auto_download_attachments", True) and msg.attachments:
|
|
await self._download_attachments(msg.attachments)
|
|
|
|
if self._message_handler:
|
|
try:
|
|
await self._message_handler(msg)
|
|
except Exception:
|
|
logger.exception("[QQBot] Error in message handler")
|
|
|
|
await self._message_queue.put(msg)
|
|
|
|
def _build_channel_message(self, ctx) -> ChannelMessage | None:
|
|
|
|
chat_type_map = {
|
|
"dm": ChatType.DIRECT,
|
|
"group": ChatType.GROUP,
|
|
"interaction": ChatType.GROUP,
|
|
}
|
|
chat_type = chat_type_map.get(ctx.chat_type, ChatType.GROUP)
|
|
|
|
if ctx.chat_type == "dm":
|
|
channel_chat_id = f"{DM_CHAT_PREFIX}{ctx.sender_id}"
|
|
elif ctx.chat_type == "group":
|
|
channel_chat_id = f"{GROUP_CHAT_PREFIX}{ctx.chat_id}"
|
|
else:
|
|
channel_chat_id = ctx.chat_id
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=ctx.sender_id,
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=ctx.msg_id,
|
|
)
|
|
|
|
content = ctx.content or ""
|
|
message_type = MessageType.TEXT
|
|
if content.startswith("/"):
|
|
message_type = MessageType.COMMAND
|
|
|
|
mentions = None
|
|
if ctx.chat_type == "group":
|
|
mentions = MentionsInfo(is_bot_mentioned=True, raw_text=content)
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
message_type=message_type,
|
|
chat_type=chat_type,
|
|
content=content,
|
|
mentions=mentions,
|
|
metadata={
|
|
"event_type": ctx.event_type,
|
|
"pipeline_version": "qqbot_v2",
|
|
**(ctx.metadata or {}),
|
|
},
|
|
timestamp=datetime.now(),
|
|
)
|
|
|
|
async def _handle_command(self, msg: ChannelMessage) -> bool:
|
|
content = msg.content or ""
|
|
resolved = self._command_registry.resolve(content)
|
|
if resolved is None:
|
|
return False
|
|
|
|
command_name, args = resolved
|
|
result = await self._command_registry.dispatch(
|
|
command_name, args, content, msg, self,
|
|
)
|
|
|
|
chat_id = msg.identity.channel_chat_id
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=msg.identity.channel_user_id,
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=result.message,
|
|
)
|
|
try:
|
|
delivery = await self.send(response)
|
|
if not delivery.success:
|
|
logger.warning("[QQBot] Command response failed: %s", delivery.error)
|
|
except Exception:
|
|
logger.exception("[QQBot] Failed to send command response")
|
|
|
|
return True
|
|
|
|
async def _handle_interaction(self, msg: ChannelMessage, ctx) -> bool:
|
|
try:
|
|
interaction_ctx = InteractionContext(
|
|
interaction_id=ctx.metadata.get("interaction_id", ""),
|
|
interaction_type=ctx.metadata.get("interaction_type", ""),
|
|
chat_type=ctx.chat_type or "",
|
|
chat_id=ctx.chat_id or "",
|
|
user_id=ctx.sender_id or "",
|
|
user_name=ctx.sender_name or "",
|
|
data=ctx.metadata.get("interaction_data", {}),
|
|
msg_id=ctx.msg_id or "",
|
|
)
|
|
|
|
handled = await self._interaction_registry.dispatch(interaction_ctx)
|
|
if not handled:
|
|
session_handler = await self._interaction_sessions.get(
|
|
interaction_ctx.interaction_id
|
|
)
|
|
if session_handler:
|
|
await self._interaction_sessions.resolve(
|
|
interaction_ctx.interaction_id,
|
|
interaction_ctx.data,
|
|
)
|
|
return True
|
|
return handled
|
|
except Exception:
|
|
logger.exception("[QQBot] Interaction handling failed")
|
|
return False
|
|
|
|
# ========== æ¶æ¯æ¶å ==========
|
|
|
|
async def send(self, response: ChannelResponse) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
chat_id = response.identity.channel_chat_id
|
|
payload = self.format_outbound(response)
|
|
if response.metadata.get("qq_msg_type") != "markdown":
|
|
payload["msg_seq"] = await self._msg_seq_manager.acquire_active()
|
|
|
|
silent = response.metadata.get("silent") or self.config.get("silent", False)
|
|
if silent:
|
|
payload["no_notify"] = True
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
async def _refresh_token() -> str:
|
|
return await self._token_manager.force_refresh()
|
|
|
|
async def _do_send():
|
|
return await send_with_retry(
|
|
self._http_client,
|
|
token,
|
|
api_base,
|
|
payload,
|
|
chat_id,
|
|
self.config,
|
|
token_refresh_cb=_refresh_token,
|
|
)
|
|
|
|
try:
|
|
await self._rate_limiter.acquire("send_message")
|
|
return await self._circuit_breaker.call(_do_send)
|
|
except CircuitBreakerOpenError:
|
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
|
except (DeliveryFailedError, Exception) as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
if media_type not in ("image", "voice", "video", "file"):
|
|
return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}")
|
|
|
|
if not isinstance(data, bytes):
|
|
return DeliveryResult(success=False, error="Media data must be bytes")
|
|
|
|
from .media_upload import (
|
|
upload_media,
|
|
validate_media_size,
|
|
FILE_TYPE_IMAGE,
|
|
FILE_TYPE_VOICE,
|
|
FILE_TYPE_VIDEO,
|
|
FILE_TYPE_FILE,
|
|
)
|
|
|
|
validate_media_size(data, max_size_mb=self.max_media_size_mb, label=media_type)
|
|
|
|
token = await self._token_manager.get_token()
|
|
|
|
file_type_map = {
|
|
"image": FILE_TYPE_IMAGE,
|
|
"voice": FILE_TYPE_VOICE,
|
|
"video": FILE_TYPE_VIDEO,
|
|
"file": FILE_TYPE_FILE,
|
|
}
|
|
file_type = file_type_map.get(media_type, FILE_TYPE_FILE)
|
|
|
|
group_openid = None
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
group_openid = chat_id.replace(GROUP_CHAT_PREFIX, "")
|
|
|
|
try:
|
|
file_id = await upload_media(
|
|
data,
|
|
token,
|
|
http_client=self._http_client,
|
|
filename=f"upload.{media_type}",
|
|
file_type=file_type,
|
|
group_openid=group_openid,
|
|
sandbox=self._sandbox,
|
|
)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
_MEDIA_TYPE_TO_MESSAGE_TYPE = {
|
|
"image": MessageType.IMAGE,
|
|
"voice": MessageType.AUDIO,
|
|
"video": MessageType.VIDEO,
|
|
"file": MessageType.FILE,
|
|
}
|
|
message_type = _MEDIA_TYPE_TO_MESSAGE_TYPE.get(media_type, MessageType.FILE)
|
|
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
message_type=message_type,
|
|
content="",
|
|
attachments=[Attachment(type=media_type, file_id=file_id)],
|
|
metadata={"qq_msg_type": media_type, "media_file_id": file_id},
|
|
)
|
|
return await self.send(response)
|
|
|
|
async def send_keyboard(self, chat_id: str, text: str, buttons: list[dict]) -> DeliveryResult:
|
|
from .ark_templates import build_text_card
|
|
|
|
payload = build_text_card(text, buttons)
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_ark(
|
|
self,
|
|
chat_id: str,
|
|
template_id: int,
|
|
kv_pairs: list[dict[str, str]],
|
|
) -> DeliveryResult:
|
|
payload = {
|
|
"msg_type": 3,
|
|
"ark": {"template_id": template_id, "kv": kv_pairs},
|
|
}
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_embed(
|
|
self,
|
|
chat_id: str,
|
|
title: str = "",
|
|
description: str = "",
|
|
prompt: str = "",
|
|
fields: list[dict] | None = None,
|
|
) -> DeliveryResult:
|
|
from .ark_templates import build_embed
|
|
|
|
payload = build_embed(title=title, description=description, prompt=prompt, fields=fields)
|
|
return await self._send_raw(chat_id, payload)
|
|
|
|
async def send_tts_voice(
|
|
self,
|
|
chat_id: str,
|
|
text: str,
|
|
voice: str = "",
|
|
fmt: AudioFormat | None = None,
|
|
) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
try:
|
|
audio_data = await self._tts_provider.synthesize(text, voice, fmt)
|
|
except Exception as e:
|
|
return DeliveryResult(success=False, error=f"TTS synthesis failed: {e}")
|
|
|
|
output_fmt = fmt or self._tts_provider._default_format
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
from .voice_send import send_voice
|
|
|
|
return await send_voice(
|
|
audio_data,
|
|
chat_id,
|
|
token,
|
|
self._http_client,
|
|
api_base,
|
|
filename=f"tts.{output_fmt.value}",
|
|
)
|
|
|
|
async def transcribe_voice(self, audio_data: bytes, fmt: AudioFormat | None = None) -> str:
|
|
try:
|
|
return await self._stt_provider.transcribe(audio_data, fmt)
|
|
except Exception:
|
|
logger.exception("[QQBot] STT transcription failed")
|
|
return ""
|
|
|
|
async def _send_raw(self, chat_id: str, payload: dict) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
payload["msg_seq"] = await self._msg_seq_manager.acquire_active()
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
async def _refresh_token() -> str:
|
|
return await self._token_manager.force_refresh()
|
|
|
|
async def _do_send():
|
|
return await send_with_retry(
|
|
self._http_client,
|
|
token,
|
|
api_base,
|
|
payload,
|
|
chat_id,
|
|
self.config,
|
|
token_refresh_cb=_refresh_token,
|
|
)
|
|
|
|
try:
|
|
return await self._circuit_breaker.call(_do_send)
|
|
except CircuitBreakerOpenError:
|
|
return DeliveryResult(success=False, error="Circuit breaker open")
|
|
except (DeliveryFailedError, Exception) as e:
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
|
|
from .format import build_text_payload
|
|
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support editing group messages")
|
|
if chat_id.startswith(DM_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support editing direct messages")
|
|
|
|
payload = build_text_payload(
|
|
ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
)
|
|
payload.pop("msg_type", None)
|
|
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
url = f"{api_base}/v2/channels/{chat_id}/messages/{msg_id}"
|
|
|
|
try:
|
|
async with self._http_client.patch(url, json=payload, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return DeliveryResult(success=True, message_id=msg_id)
|
|
return DeliveryResult(success=False, error=f"Edit failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] Edit message failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
|
|
if not self._http_client or not self._token_manager:
|
|
return DeliveryResult(success=False, error="Client not initialized")
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
|
|
if chat_id.startswith(GROUP_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support deleting group messages")
|
|
if chat_id.startswith(DM_CHAT_PREFIX):
|
|
return DeliveryResult(success=False, error="QQ Bot does not support deleting direct messages")
|
|
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
url = f"{api_base}/v2/channels/{chat_id}/messages/{msg_id}"
|
|
|
|
try:
|
|
async with self._http_client.delete(url, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return DeliveryResult(success=True, message_id=msg_id)
|
|
return DeliveryResult(success=False, error=f"Delete failed: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.error(f"[QQBot] Delete message failed: {e}")
|
|
return DeliveryResult(success=False, error=str(e))
|
|
|
|
async def receive(self) -> AsyncIterator[ChannelMessage]:
|
|
while self._status == ChannelStatus.CONNECTED:
|
|
try:
|
|
msg = await self._message_queue.get()
|
|
yield msg
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
# ========== æ ¼å¼è½¬æ¢ ==========
|
|
|
|
def normalize_inbound(self, raw: dict) -> ChannelMessage:
|
|
event_type = raw.get("event_type", "")
|
|
event_data = raw.get("event", {})
|
|
|
|
chat_type = ChatType.GROUP
|
|
channel_chat_id = ""
|
|
|
|
if event_type == "at_message_create":
|
|
chat_type = ChatType.GROUP
|
|
channel_chat_id = f"{GROUP_CHAT_PREFIX}{event_data.get('group_openid', '')}"
|
|
elif event_type == "direct_message_create":
|
|
chat_type = ChatType.DIRECT
|
|
channel_chat_id = f"{DM_CHAT_PREFIX}{event_data.get('author', {}).get('id', '')}"
|
|
elif event_type == "message_create":
|
|
chat_type = ChatType.GUILD_CHANNEL
|
|
channel_chat_id = event_data.get("channel_id", "")
|
|
elif event_type in ("guild_member_add", "group_add_robot"):
|
|
return self._build_event_message(raw, EventType.MEMBER_JOINED)
|
|
elif event_type in ("guild_member_remove", "group_del_robot"):
|
|
return self._build_event_message(raw, EventType.MEMBER_LEFT)
|
|
elif event_type == "guild_member_update":
|
|
return self._build_event_message(raw, EventType.MEMBER_JOINED)
|
|
elif event_type == "message_delete":
|
|
return self._build_event_message(raw, EventType.MESSAGE_DELETED)
|
|
else:
|
|
logger.warning(f"[QQBot] Unknown event type: {event_type}")
|
|
raise MessageFormatError()
|
|
|
|
author = event_data.get("author", {})
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=str(author.get("id", "")),
|
|
channel_chat_id=channel_chat_id,
|
|
channel_message_id=str(event_data.get("id", "")),
|
|
)
|
|
|
|
content = event_data.get("content", "")
|
|
message_type = MessageType.TEXT
|
|
if content and content.startswith("/"):
|
|
message_type = MessageType.COMMAND
|
|
|
|
attachments = []
|
|
for attach in event_data.get("attachments", []):
|
|
attach_type = attach.get("content_type", "")
|
|
if attach_type.startswith("image/"):
|
|
attachments.append(
|
|
Attachment(
|
|
type="image",
|
|
url=attach.get("url"),
|
|
filename=attach.get("filename"),
|
|
)
|
|
)
|
|
else:
|
|
attachments.append(
|
|
Attachment(
|
|
type="file",
|
|
url=attach.get("url"),
|
|
filename=attach.get("filename"),
|
|
size_bytes=attach.get("size", 0),
|
|
)
|
|
)
|
|
|
|
mentions = None
|
|
if event_type == "at_message_create":
|
|
mentions = MentionsInfo(
|
|
is_bot_mentioned=True,
|
|
raw_text=content,
|
|
)
|
|
|
|
metadata = {
|
|
"event_type": event_type,
|
|
"qq_chat_type": chat_type.value,
|
|
}
|
|
if event_data.get("guild_id"):
|
|
metadata["guild_id"] = event_data["guild_id"]
|
|
if event_data.get("channel_id"):
|
|
metadata["channel_id"] = event_data["channel_id"]
|
|
if event_data.get("timestamp"):
|
|
metadata["event_timestamp"] = event_data["timestamp"]
|
|
|
|
timestamp = datetime.now()
|
|
if event_data.get("timestamp"):
|
|
try:
|
|
timestamp = datetime.fromisoformat(event_data["timestamp"])
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
message_type=message_type,
|
|
chat_type=chat_type,
|
|
content=content,
|
|
attachments=attachments,
|
|
mentions=mentions,
|
|
metadata=metadata,
|
|
timestamp=timestamp,
|
|
)
|
|
|
|
def _build_event_message(self, raw: dict, event_type: EventType) -> ChannelMessage:
|
|
event_data = raw.get("event", {})
|
|
author = event_data.get("user", event_data.get("op_user", event_data.get("author", {})))
|
|
|
|
chat_id = ""
|
|
chat_type = ChatType.GROUP
|
|
group_id = event_data.get("group_openid", "")
|
|
guild_id = event_data.get("guild_id", "")
|
|
channel_id = event_data.get("channel_id", "")
|
|
|
|
if group_id:
|
|
chat_id = f"{GROUP_CHAT_PREFIX}{group_id}"
|
|
chat_type = ChatType.GROUP
|
|
elif channel_id:
|
|
chat_id = channel_id
|
|
chat_type = ChatType.GUILD_CHANNEL
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id=str(author.get("id", "")),
|
|
channel_chat_id=chat_id,
|
|
channel_message_id=str(event_data.get("id", "")),
|
|
)
|
|
|
|
return ChannelMessage(
|
|
identity=identity,
|
|
chat_type=chat_type,
|
|
message_type=MessageType.TEXT,
|
|
content="",
|
|
event_type=event_type,
|
|
metadata={
|
|
"event_type": raw.get("event_type", ""),
|
|
"guild_id": guild_id,
|
|
"group_openid": group_id,
|
|
"channel_id": channel_id,
|
|
"guild_name": event_data.get("guild", {}).get("name", ""),
|
|
},
|
|
)
|
|
|
|
def format_outbound(self, response: ChannelResponse) -> dict:
|
|
return format_outbound(
|
|
response,
|
|
use_markdown=self.config.get("use_markdown", False),
|
|
markdown_template_id=self.config.get("markdown_template_id"),
|
|
)
|
|
|
|
# ========== å¥åº·æ£æ?==========
|
|
|
|
async def health_check(self) -> HealthStatus:
|
|
if not self._token_manager:
|
|
return HealthStatus(status="unhealthy", last_error="Token manager not initialized")
|
|
|
|
try:
|
|
token = await self._token_manager.get_token()
|
|
return await health_check_dsm(
|
|
self._token_manager.api_base,
|
|
token,
|
|
self._http_client,
|
|
sandbox=self._sandbox,
|
|
ws_connected=self._status == ChannelStatus.CONNECTED,
|
|
)
|
|
except Exception as e:
|
|
return HealthStatus(status="unhealthy", last_error=str(e))
|
|
|
|
async def download_media(self, file_id: str) -> bytes:
|
|
if not self._token_manager:
|
|
raise ChannelNotConnectedError()
|
|
from .media_upload import download_media as _download
|
|
|
|
token = await self._token_manager.get_token()
|
|
return await _download(file_id, token, self._http_client, self._sandbox)
|
|
|
|
# ========== Token 管理 ==========
|
|
|
|
async def _refresh_token_if_needed(self) -> bool:
|
|
if self._token_manager is None:
|
|
return False
|
|
token_before = self._token_manager._access_token
|
|
await self._token_manager.get_token()
|
|
return token_before != self._token_manager._access_token
|
|
|
|
async def _get_valid_token(self) -> str:
|
|
if self._token_manager is None:
|
|
raise ChannelNotConnectedError()
|
|
return await self._token_manager.get_token()
|
|
|
|
def _backup_credentials(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
backup_dir = self.config.get("credential_backup_dir")
|
|
self._credential_backup = CredentialBackup(app_id, backup_dir)
|
|
|
|
snapshot = CredentialSnapshot(
|
|
app_id=app_id,
|
|
app_secret=(
|
|
self.config.get("app_secret")
|
|
or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
or self._read_app_secret_file()
|
|
),
|
|
access_token=self._token_manager._access_token if self._token_manager else "",
|
|
expires_at=self._token_manager._expires_at if self._token_manager else 0,
|
|
session_id=self._session_id or "",
|
|
sandbox=self._sandbox,
|
|
)
|
|
self._credential_backup.save(snapshot)
|
|
|
|
def _restore_credentials(self) -> bool:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return False
|
|
|
|
backup_dir = self.config.get("credential_backup_dir")
|
|
self._credential_backup = CredentialBackup(app_id, backup_dir)
|
|
|
|
snapshot = self._credential_backup.restore()
|
|
if snapshot is None:
|
|
return False
|
|
if snapshot.token_expired():
|
|
logger.info("[QQBot] Restored token expired, will refresh")
|
|
return False
|
|
|
|
if not self.config.get("app_secret") and snapshot.app_secret:
|
|
logger.info("[QQBot] Restored app_secret from credential backup")
|
|
|
|
self._session_id = snapshot.session_id
|
|
return True
|
|
|
|
def _init_session_store(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
store_dir = self.config.get("session_store_dir")
|
|
self._session_store = SessionStore(app_id, store_dir)
|
|
|
|
record = self._session_store.load()
|
|
if record and record.session_id:
|
|
logger.info(
|
|
"[QQBot] Restored session_id=%s seq=%s",
|
|
record.session_id, record.last_seq,
|
|
)
|
|
self._reconnect_manager._session_id = record.session_id
|
|
if record.last_seq is not None:
|
|
self._reconnect_manager._last_seq = record.last_seq
|
|
|
|
def _save_session(self) -> None:
|
|
if self._session_store is None:
|
|
return
|
|
|
|
record = SessionRecord(
|
|
session_id=self._reconnect_manager._session_id or self._session_id or "",
|
|
last_seq=self._reconnect_manager._last_seq,
|
|
last_heartbeat=time.monotonic(),
|
|
identify_at=time.time(),
|
|
shard_id=self.config.get("shard_id", 0),
|
|
shard_count=self.config.get("shard_count", 1),
|
|
)
|
|
self._session_store.save(record)
|
|
|
|
def _init_known_users(self) -> None:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
if not app_id:
|
|
return
|
|
|
|
max_users = self.config.get("known_users_max", 10000)
|
|
persist_dir = self.config.get("known_users_persist_dir")
|
|
self._known_users = KnownUserTracker(
|
|
app_id=app_id,
|
|
max_users=max_users,
|
|
persist_dir=persist_dir,
|
|
)
|
|
|
|
# ========== å®å
¨çç¥ ==========
|
|
|
|
async def _check_security(self, msg: ChannelMessage) -> bool:
|
|
chat_id = msg.identity.channel_chat_id
|
|
user_id = msg.identity.channel_user_id
|
|
chat_type = msg.chat_type
|
|
|
|
if chat_type == ChatType.DIRECT:
|
|
result = self._security.check_dm_access(user_id)
|
|
if not result.allowed:
|
|
logger.debug(f"[QQBot] DM access denied: {result.reason} {result.detail}")
|
|
return result.allowed
|
|
elif chat_type == ChatType.GROUP:
|
|
group_policy = self.config.get("group_policy", "")
|
|
if group_policy == "open":
|
|
return True
|
|
if group_policy == "disabled":
|
|
return False
|
|
group_id = chat_id.replace(GROUP_CHAT_PREFIX, "")
|
|
result = self._security.check_group_access(group_id)
|
|
if not result.allowed:
|
|
logger.debug(f"[QQBot] Group access denied: {result.reason} {result.detail}")
|
|
return False
|
|
bot_names = []
|
|
if self._bot_info:
|
|
bot_names = [self._bot_info.get("username", "")]
|
|
return self._security.check_mention_required(chat_id, msg, bot_names)
|
|
return True
|
|
|
|
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
|
|
bot_secret = self.config.get("bot_secret", "")
|
|
if not bot_secret:
|
|
return True
|
|
return verify_webhook_ed25519(headers, body, bot_secret)
|
|
|
|
# ========== å
鍿¹æ³ ==========
|
|
|
|
async def _get_gateway_url(self, token: str) -> str:
|
|
if not self._http_client:
|
|
raise ChannelNotConnectedError()
|
|
api_base = self._token_manager.api_base if self._token_manager else ""
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
|
|
async with self._http_client.get(f"{api_base}/gateway", headers=headers) as resp:
|
|
if resp.status != 200:
|
|
raise ChannelAuthenticationError(f"Failed to get gateway URL: HTTP {resp.status}")
|
|
data = await resp.json()
|
|
return data.get("url", "")
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
return self._status.value
|
|
|
|
async def pre_connect(self) -> dict:
|
|
app_id = self.config.get("app_id") or os.environ.get("QQBOT_APP_ID", "")
|
|
app_secret = self.config.get("app_secret") or os.environ.get("QQBOT_CLIENT_SECRET", "")
|
|
if not app_id or not app_secret:
|
|
return {"status": "error", "message": "Missing app_id or app_secret in config or env vars"}
|
|
|
|
api_base = "https://sandbox.api.sgroup.qq.com" if self._sandbox else "https://api.sgroup.qq.com"
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
f"{api_base}/oauth2/token",
|
|
json={"app_id": app_id, "app_secret": app_secret},
|
|
) as resp:
|
|
if resp.status == 200:
|
|
return {"status": "ok", "app_id": app_id[:6] + "...", "sandbox": self._sandbox}
|
|
return {"status": "error", "message": f"Auth failed: HTTP {resp.status}"}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
|
|
if not self._http_client or not self._token_manager:
|
|
raise ChannelNotConnectedError()
|
|
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
|
|
async with self._http_client.get(f"{api_base}/v2/users/{channel_user_id}", headers=headers) as resp:
|
|
if resp.status != 200:
|
|
raise DeliveryFailedError(f"Failed to get user info: HTTP {resp.status}")
|
|
return await resp.json()
|
|
|
|
async def send_dm_message(
|
|
self, chat_id: str, content: str, msg_id: str = "", payload: dict | None = None
|
|
) -> DeliveryResult:
|
|
if payload is None:
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
if msg_id:
|
|
response.reply_to_message_id = msg_id
|
|
payload = format_outbound(response)
|
|
payload["msg_id"] = msg_id
|
|
|
|
return await self.send(
|
|
ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
async def send_group_message(
|
|
self, chat_id: str, content: str = "", payload: dict | None = None, msg_id: str = ""
|
|
) -> DeliveryResult:
|
|
if payload is None:
|
|
response = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
if msg_id:
|
|
response.reply_to_message_id = msg_id
|
|
payload = format_outbound(response)
|
|
payload["msg_id"] = msg_id
|
|
|
|
return await self.send(
|
|
ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id=chat_id,
|
|
),
|
|
content=content,
|
|
)
|
|
)
|
|
|
|
async def _send_stream_message(self, chat_id: str, payload: dict) -> Any:
|
|
if not self._http_client or not self._token_manager:
|
|
return None
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
from .send import _resolve_send_url
|
|
|
|
url = _resolve_send_url(api_base, chat_id)
|
|
headers = {
|
|
"Authorization": f"QQBot {token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
try:
|
|
async with self._http_client.post(url, json=payload, headers=headers) as resp:
|
|
if resp.status == 200:
|
|
return await resp.json()
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
async def _stream_content(self, ctx) -> AsyncGenerator[str, None]:
|
|
handler = self._message_handler
|
|
if handler is None:
|
|
if False:
|
|
yield ""
|
|
return
|
|
|
|
msg = self._build_channel_message(ctx)
|
|
if msg is None:
|
|
if False:
|
|
yield ""
|
|
return
|
|
|
|
async for chunk in stream_content(
|
|
_handler_to_generator(handler, msg),
|
|
self._c2c_streaming,
|
|
self._par_chunker,
|
|
):
|
|
yield chunk
|
|
|
|
async def _put_interaction(self, interaction_id: str, content: str) -> DeliveryResult:
|
|
if self._http_client and self._token_manager:
|
|
token = await self._token_manager.get_token()
|
|
api_base = self._token_manager.api_base
|
|
headers = {"Authorization": f"QQBot {token}"}
|
|
payload = {"code": 0}
|
|
url = f"{api_base}/interactions/{interaction_id}"
|
|
try:
|
|
async with self._http_client.put(url, json=payload, headers=headers) as resp:
|
|
if resp.status not in (200, 202):
|
|
logger.warning(f"[QQBot] Interaction ACK failed for {interaction_id}: HTTP {resp.status}")
|
|
except Exception as e:
|
|
logger.warning(f"[QQBot] Interaction ACK error for {interaction_id}: {e}")
|
|
|
|
msg = ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=self.channel_id,
|
|
channel_type=self.channel_type,
|
|
channel_user_id="",
|
|
channel_chat_id="",
|
|
channel_message_id=interaction_id,
|
|
),
|
|
content=content,
|
|
chat_type=ChatType.GROUP,
|
|
message_type=MessageType.TEXT,
|
|
metadata={"interaction_id": interaction_id},
|
|
)
|
|
await self._message_queue.put(msg)
|
|
return DeliveryResult(success=True, message_id=interaction_id)
|
|
|
|
async def _download_attachments(self, attachments: list[Attachment]) -> None:
|
|
from .media_upload import download_media
|
|
|
|
if not self._http_client or not self._token_manager or not attachments:
|
|
return
|
|
|
|
token = await self._token_manager.get_token()
|
|
for attach in attachments:
|
|
if attach.data is not None:
|
|
continue
|
|
url = attach.url
|
|
if not url:
|
|
continue
|
|
try:
|
|
data = await download_media(url, token)
|
|
attach.data = data
|
|
except Exception:
|
|
logger.debug(f"[QQBot] Failed to auto-download attachment: {url}")
|
|
|
|
async def _read_app_secret_file(self) -> str:
|
|
file_path = self.config.get("client_secret_file", "")
|
|
if not file_path:
|
|
return ""
|
|
try:
|
|
with open(file_path, encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except OSError as e:
|
|
logger.warning(f"[QQBot] Failed to read client_secret_file '{file_path}': {e}")
|
|
return ""
|
|
|
|
async def _on_reconnect_state_change(self, old: ReconnectState, new: ReconnectState) -> None:
|
|
logger.debug(f"[QQBot] Reconnect state: {old.name} -> {new.name}")
|
|
|
|
async def _attempt_reconnect(self) -> None:
|
|
await self._cleanup_ws()
|
|
await self.connect()
|