Merge remote-tracking branch 'origin/main'
# Conflicts: # nanobot/channels/qq.py # nanobot/channels/telegram.py # nanobot/channels/whatsapp.py # tests/test_qq_channel.py
This commit is contained in:
@@ -29,6 +29,7 @@ export interface InboundMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
|
wasMentioned?: boolean;
|
||||||
media?: string[];
|
media?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +49,31 @@ export class WhatsAppClient {
|
|||||||
this.options = options;
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeJid(jid: string | undefined | null): string {
|
||||||
|
return (jid || '').split(':')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private wasMentioned(msg: any): boolean {
|
||||||
|
if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false;
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid,
|
||||||
|
msg?.message?.imageMessage?.contextInfo?.mentionedJid,
|
||||||
|
msg?.message?.videoMessage?.contextInfo?.mentionedJid,
|
||||||
|
msg?.message?.documentMessage?.contextInfo?.mentionedJid,
|
||||||
|
msg?.message?.audioMessage?.contextInfo?.mentionedJid,
|
||||||
|
];
|
||||||
|
const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : []));
|
||||||
|
if (mentioned.length === 0) return false;
|
||||||
|
|
||||||
|
const selfIds = new Set(
|
||||||
|
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
|
||||||
|
.map((jid) => this.normalizeJid(jid))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
|
||||||
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
const logger = pino({ level: 'silent' });
|
const logger = pino({ level: 'silent' });
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
|
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
|
||||||
@@ -145,6 +171,7 @@ export class WhatsAppClient {
|
|||||||
if (!finalContent && mediaPaths.length === 0) continue;
|
if (!finalContent && mediaPaths.length === 0) continue;
|
||||||
|
|
||||||
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
|
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
|
||||||
|
const wasMentioned = this.wasMentioned(msg);
|
||||||
|
|
||||||
this.options.onMessage({
|
this.options.onMessage({
|
||||||
id: msg.key.id || '',
|
id: msg.key.id || '',
|
||||||
@@ -153,6 +180,7 @@ export class WhatsAppClient {
|
|||||||
content: finalContent,
|
content: finalContent,
|
||||||
timestamp: msg.messageTimestamp as number,
|
timestamp: msg.messageTimestamp as number,
|
||||||
isGroup,
|
isGroup,
|
||||||
|
...(isGroup ? { wasMentioned } : {}),
|
||||||
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
|
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -938,6 +938,9 @@ class FeishuChannel(BaseChannel):
|
|||||||
reply_message_id: str | None = None
|
reply_message_id: str | None = None
|
||||||
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
||||||
reply_message_id = msg.metadata.get("message_id") or None
|
reply_message_id = msg.metadata.get("message_id") or None
|
||||||
|
# For topic group messages, always reply to keep context in thread
|
||||||
|
elif msg.metadata.get("thread_id"):
|
||||||
|
reply_message_id = msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
|
||||||
|
|
||||||
first_send = True
|
first_send = True
|
||||||
|
|
||||||
@@ -1095,6 +1098,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
parent_id = getattr(message, "parent_id", None) or None
|
parent_id = getattr(message, "parent_id", None) or None
|
||||||
root_id = getattr(message, "root_id", None) or None
|
root_id = getattr(message, "root_id", None) or None
|
||||||
|
thread_id = getattr(message, "thread_id", None) or None
|
||||||
|
|
||||||
if parent_id and self._client:
|
if parent_id and self._client:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -1120,6 +1124,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
"msg_type": msg_type,
|
"msg_type": msg_type,
|
||||||
"parent_id": parent_id,
|
"parent_id": parent_id,
|
||||||
"root_id": root_id,
|
"root_id": root_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -16,6 +20,11 @@ from nanobot.config.schema import QQConfig, QQInstanceConfig
|
|||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import validate_url_target
|
||||||
from nanobot.utils.delivery import delivery_artifacts_root, is_image_file
|
from nanobot.utils.delivery import delivery_artifacts_root, is_image_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
from nanobot.config.paths import get_media_dir
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
get_media_dir = None # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import botpy
|
import botpy
|
||||||
from botpy.http import Route
|
from botpy.http import Route
|
||||||
@@ -34,6 +43,33 @@ if TYPE_CHECKING:
|
|||||||
from botpy.message import C2CMessage, GroupMessage
|
from botpy.message import C2CMessage, GroupMessage
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_EXTS = {
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".bmp",
|
||||||
|
".webp",
|
||||||
|
".tif",
|
||||||
|
".tiff",
|
||||||
|
".ico",
|
||||||
|
".svg",
|
||||||
|
}
|
||||||
|
_SAFE_NAME_RE = re.compile(r"[^\w.\-()\[\]()【】\u4e00-\u9fff]+", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize filename to avoid traversal and problematic characters."""
|
||||||
|
name = Path(name or "").name.strip()
|
||||||
|
name = _SAFE_NAME_RE.sub("_", name).strip("._ ")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _is_image_name(name: str) -> bool:
|
||||||
|
"""Return whether the file name looks like an image."""
|
||||||
|
return Path(name).suffix.lower() in _IMAGE_EXTS
|
||||||
|
|
||||||
|
|
||||||
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
||||||
"""Create a botpy Client subclass bound to the given channel."""
|
"""Create a botpy Client subclass bound to the given channel."""
|
||||||
intents = botpy.Intents(public_messages=True, direct_message=True)
|
intents = botpy.Intents(public_messages=True, direct_message=True)
|
||||||
@@ -71,17 +107,21 @@ class QQChannel(BaseChannel):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: QQConfig | QQInstanceConfig,
|
config: QQConfig | QQInstanceConfig | dict,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
workspace: str | Path | None = None,
|
workspace: str | Path | None = None,
|
||||||
):
|
):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = QQConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: QQConfig | QQInstanceConfig = config
|
self.config: QQConfig | QQInstanceConfig = config
|
||||||
self._client: "botpy.Client | None" = None
|
self._client: "botpy.Client | None" = None
|
||||||
self._processed_ids: deque = deque(maxlen=1000)
|
self._http: aiohttp.ClientSession | None = None
|
||||||
|
self._processed_ids: deque[str] = deque(maxlen=1000)
|
||||||
self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重
|
self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重
|
||||||
self._chat_type_cache: dict[str, str] = {}
|
self._chat_type_cache: dict[str, str] = {}
|
||||||
self._workspace = Path(workspace).expanduser() if workspace is not None else None
|
self._workspace = Path(workspace).expanduser() if workspace is not None else None
|
||||||
|
self._media_root = self._init_media_root()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_remote_media(path: str) -> bool:
|
def _is_remote_media(path: str) -> bool:
|
||||||
@@ -153,14 +193,24 @@ class QQChannel(BaseChannel):
|
|||||||
"""Encode a local media file as base64 for QQ rich-media upload."""
|
"""Encode a local media file as base64 for QQ rich-media upload."""
|
||||||
return base64.b64encode(path.read_bytes()).decode("ascii")
|
return base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
|
|
||||||
async def _post_text_message(self, chat_id: str, msg_type: str, content: str, msg_id: str | None) -> None:
|
async def _post_text_message(
|
||||||
"""Send a plain-text QQ message."""
|
self,
|
||||||
payload = {
|
chat_id: str,
|
||||||
"msg_type": 0,
|
msg_type: str,
|
||||||
"content": content,
|
content: str,
|
||||||
|
msg_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a plain-text or markdown QQ message."""
|
||||||
|
use_markdown = self.config.msg_format == "markdown"
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"msg_type": 2 if use_markdown else 0,
|
||||||
"msg_id": msg_id,
|
"msg_id": msg_id,
|
||||||
"msg_seq": self._next_msg_seq(),
|
"msg_seq": self._next_msg_seq(),
|
||||||
}
|
}
|
||||||
|
if use_markdown:
|
||||||
|
payload["markdown"] = {"content": content}
|
||||||
|
else:
|
||||||
|
payload["content"] = content
|
||||||
if msg_type == "group":
|
if msg_type == "group":
|
||||||
await self._client.api.post_group_message(group_openid=chat_id, **payload)
|
await self._client.api.post_group_message(group_openid=chat_id, **payload)
|
||||||
else:
|
else:
|
||||||
@@ -248,8 +298,24 @@ class QQChannel(BaseChannel):
|
|||||||
msg_seq=self._next_msg_seq(),
|
msg_seq=self._next_msg_seq(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _init_media_root(self) -> Path:
|
||||||
|
"""Choose a directory for saving inbound attachments."""
|
||||||
|
if self.config.media_dir:
|
||||||
|
root = Path(self.config.media_dir).expanduser()
|
||||||
|
elif get_media_dir:
|
||||||
|
try:
|
||||||
|
root = Path(get_media_dir("qq"))
|
||||||
|
except Exception:
|
||||||
|
root = Path.home() / ".nanobot" / "media" / "qq"
|
||||||
|
else:
|
||||||
|
root = Path.home() / ".nanobot" / "media" / "qq"
|
||||||
|
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info("QQ media directory: {}", str(root))
|
||||||
|
return root
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the QQ bot."""
|
"""Start the QQ bot with auto-reconnect."""
|
||||||
if not QQ_AVAILABLE:
|
if not QQ_AVAILABLE:
|
||||||
logger.error("QQ SDK not installed. Run: pip install qq-botpy")
|
logger.error("QQ SDK not installed. Run: pip install qq-botpy")
|
||||||
return
|
return
|
||||||
@@ -259,8 +325,8 @@ class QQChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
bot_class = _make_bot_class(self)
|
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
||||||
self._client = bot_class()
|
self._client = _make_bot_class(self)()
|
||||||
logger.info("QQ bot started (C2C & Group supported)")
|
logger.info("QQ bot started (C2C & Group supported)")
|
||||||
await self._run_bot()
|
await self._run_bot()
|
||||||
|
|
||||||
@@ -276,13 +342,20 @@ class QQChannel(BaseChannel):
|
|||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the QQ bot."""
|
"""Stop bot and cleanup resources."""
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
try:
|
try:
|
||||||
await self._client.close()
|
await self._client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._client = None
|
||||||
|
if self._http:
|
||||||
|
try:
|
||||||
|
await self._http.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._http = None
|
||||||
logger.info("QQ bot stopped")
|
logger.info("QQ bot stopped")
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
@@ -297,11 +370,13 @@ class QQChannel(BaseChannel):
|
|||||||
content_sent = False
|
content_sent = False
|
||||||
fallback_lines: list[str] = []
|
fallback_lines: list[str] = []
|
||||||
|
|
||||||
for media_path in msg.media:
|
for media_path in msg.media or []:
|
||||||
local_media_path: Path | None = None
|
local_media_path: Path | None = None
|
||||||
local_file_type: int | None = None
|
local_file_type: int | None = None
|
||||||
if not self._is_remote_media(media_path):
|
if not self._is_remote_media(media_path):
|
||||||
local_media_path, local_file_type, publish_error = self._resolve_local_media(media_path)
|
local_media_path, local_file_type, publish_error = self._resolve_local_media(
|
||||||
|
media_path
|
||||||
|
)
|
||||||
if local_media_path is None:
|
if local_media_path is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"QQ outbound local media could not be uploaded directly: {} ({})",
|
"QQ outbound local media could not be uploaded directly: {} ({})",
|
||||||
@@ -353,7 +428,9 @@ class QQChannel(BaseChannel):
|
|||||||
logger.error("Error sending QQ media {}: {}", media_path, media_error)
|
logger.error("Error sending QQ media {}: {}", media_path, media_error)
|
||||||
if local_media_path is not None:
|
if local_media_path is not None:
|
||||||
fallback_lines.append(
|
fallback_lines.append(
|
||||||
self._failed_media_notice(media_path, "QQ local file_data upload failed")
|
self._failed_media_notice(
|
||||||
|
media_path, "QQ local file_data upload failed"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
fallback_lines.append(self._failed_media_notice(media_path))
|
fallback_lines.append(self._failed_media_notice(media_path))
|
||||||
@@ -372,29 +449,161 @@ class QQChannel(BaseChannel):
|
|||||||
async def _on_message(self, data: "C2CMessage | GroupMessage", is_group: bool = False) -> None:
|
async def _on_message(self, data: "C2CMessage | GroupMessage", is_group: bool = False) -> None:
|
||||||
"""Handle incoming message from QQ."""
|
"""Handle incoming message from QQ."""
|
||||||
try:
|
try:
|
||||||
# Dedup by message ID
|
|
||||||
if data.id in self._processed_ids:
|
if data.id in self._processed_ids:
|
||||||
return
|
return
|
||||||
self._processed_ids.append(data.id)
|
self._processed_ids.append(data.id)
|
||||||
|
|
||||||
content = (data.content or "").strip()
|
|
||||||
if not content:
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_group:
|
if is_group:
|
||||||
chat_id = data.group_openid
|
chat_id = data.group_openid
|
||||||
user_id = data.author.member_openid
|
user_id = data.author.member_openid
|
||||||
self._chat_type_cache[chat_id] = "group"
|
self._chat_type_cache[chat_id] = "group"
|
||||||
else:
|
else:
|
||||||
chat_id = str(getattr(data.author, 'id', None) or getattr(data.author, 'user_openid', 'unknown'))
|
chat_id = str(
|
||||||
|
getattr(data.author, "id", None)
|
||||||
|
or getattr(data.author, "user_openid", "unknown")
|
||||||
|
)
|
||||||
user_id = chat_id
|
user_id = chat_id
|
||||||
self._chat_type_cache[chat_id] = "c2c"
|
self._chat_type_cache[chat_id] = "c2c"
|
||||||
|
|
||||||
|
content = (data.content or "").strip()
|
||||||
|
attachments = getattr(data, "attachments", None) or []
|
||||||
|
media_paths, recv_lines, att_meta = await self._handle_attachments(attachments)
|
||||||
|
if recv_lines:
|
||||||
|
tag = "[Image]" if any(_is_image_name(Path(p).name) for p in media_paths) else "[File]"
|
||||||
|
file_block = "Received files:\n" + "\n".join(recv_lines)
|
||||||
|
content = f"{content}\n\n{file_block}".strip() if content else f"{tag}\n{file_block}"
|
||||||
|
|
||||||
|
if not content and not media_paths:
|
||||||
|
return
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=user_id,
|
sender_id=user_id,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
content=content,
|
content=content,
|
||||||
metadata={"message_id": data.id},
|
media=media_paths or None,
|
||||||
|
metadata={
|
||||||
|
"message_id": data.id,
|
||||||
|
"attachments": att_meta,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling QQ message")
|
logger.exception("Error handling QQ message")
|
||||||
|
|
||||||
|
async def _handle_attachments(self, attachments: list[Any]) -> tuple[list[str], list[str], list[dict[str, Any]]]:
|
||||||
|
"""Extract, download, and format QQ attachments for downstream tools."""
|
||||||
|
media_paths: list[str] = []
|
||||||
|
recv_lines: list[str] = []
|
||||||
|
att_meta: list[dict[str, Any]] = []
|
||||||
|
if not attachments:
|
||||||
|
return media_paths, recv_lines, att_meta
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
url = getattr(att, "url", None)
|
||||||
|
filename = getattr(att, "filename", None)
|
||||||
|
content_type = getattr(att, "content_type", None)
|
||||||
|
local_path = (
|
||||||
|
await self._download_to_media_dir_chunked(url, filename_hint=filename or "")
|
||||||
|
if url
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
att_meta.append(
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": content_type,
|
||||||
|
"saved_path": local_path,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
shown_name = filename or url or "file"
|
||||||
|
if local_path:
|
||||||
|
media_paths.append(local_path)
|
||||||
|
recv_lines.append(f"- {shown_name}\n saved: {local_path}")
|
||||||
|
else:
|
||||||
|
recv_lines.append(f"- {shown_name}\n saved: [download failed]")
|
||||||
|
|
||||||
|
return media_paths, recv_lines, att_meta
|
||||||
|
|
||||||
|
async def _download_to_media_dir_chunked(self, url: str, filename_hint: str = "") -> str | None:
|
||||||
|
"""Download an inbound attachment using chunked streaming writes."""
|
||||||
|
if not self._http:
|
||||||
|
self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120))
|
||||||
|
|
||||||
|
safe = _sanitize_filename(filename_hint)
|
||||||
|
timestamp_ms = int(time.time() * 1000)
|
||||||
|
tmp_path: Path | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._http.get(
|
||||||
|
url,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
allow_redirects=True,
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("QQ download failed: status={} url={}", resp.status, url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
content_type = (resp.headers.get("Content-Type") or "").lower()
|
||||||
|
ext = Path(urlparse(url).path).suffix or Path(filename_hint).suffix
|
||||||
|
if not ext:
|
||||||
|
if "png" in content_type:
|
||||||
|
ext = ".png"
|
||||||
|
elif "jpeg" in content_type or "jpg" in content_type:
|
||||||
|
ext = ".jpg"
|
||||||
|
elif "gif" in content_type:
|
||||||
|
ext = ".gif"
|
||||||
|
elif "webp" in content_type:
|
||||||
|
ext = ".webp"
|
||||||
|
elif "pdf" in content_type:
|
||||||
|
ext = ".pdf"
|
||||||
|
else:
|
||||||
|
ext = ".bin"
|
||||||
|
|
||||||
|
if safe and not Path(safe).suffix:
|
||||||
|
safe = safe + ext
|
||||||
|
filename = safe or f"qq_file_{timestamp_ms}{ext}"
|
||||||
|
target = self._media_root / filename
|
||||||
|
if target.exists():
|
||||||
|
target = self._media_root / f"{target.stem}_{timestamp_ms}{target.suffix}"
|
||||||
|
tmp_path = target.with_suffix(target.suffix + ".part")
|
||||||
|
|
||||||
|
chunk_size = max(1024, int(self.config.download_chunk_size or 262144))
|
||||||
|
max_bytes = max(
|
||||||
|
1024 * 1024,
|
||||||
|
int(self.config.download_max_bytes or (200 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
def _open_tmp() -> Any:
|
||||||
|
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return open(tmp_path, "wb") # noqa: SIM115
|
||||||
|
|
||||||
|
f = await asyncio.to_thread(_open_tmp)
|
||||||
|
try:
|
||||||
|
async for chunk in resp.content.iter_chunked(chunk_size):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > max_bytes:
|
||||||
|
logger.warning(
|
||||||
|
"QQ download exceeded max_bytes={} url={} -> abort",
|
||||||
|
max_bytes,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
await asyncio.to_thread(f.write, chunk)
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(f.close)
|
||||||
|
|
||||||
|
await asyncio.to_thread(os.replace, tmp_path, target)
|
||||||
|
tmp_path = None
|
||||||
|
logger.info("QQ file saved: {}", str(target))
|
||||||
|
return str(target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("QQ download error: {}", e)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if tmp_path is not None:
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from telegram import BotCommand, ReplyParameters, Update
|
from telegram import BotCommand, ReactionTypeEmoji, ReplyParameters, Update
|
||||||
from telegram.error import TimedOut
|
from telegram.error import TimedOut
|
||||||
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
||||||
from telegram.request import HTTPXRequest
|
from telegram.request import HTTPXRequest
|
||||||
@@ -800,6 +800,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
}
|
}
|
||||||
self._start_typing(str_chat_id)
|
self._start_typing(str_chat_id)
|
||||||
|
await self._add_reaction(str_chat_id, message.message_id, self.config.react_emoji)
|
||||||
buf = self._media_group_buffers[key]
|
buf = self._media_group_buffers[key]
|
||||||
if content and content != "[empty message]":
|
if content and content != "[empty message]":
|
||||||
buf["contents"].append(content)
|
buf["contents"].append(content)
|
||||||
@@ -810,6 +811,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
# Start typing indicator before processing
|
# Start typing indicator before processing
|
||||||
self._start_typing(str_chat_id)
|
self._start_typing(str_chat_id)
|
||||||
|
await self._add_reaction(str_chat_id, message.message_id, self.config.react_emoji)
|
||||||
|
|
||||||
# Forward to the message bus
|
# Forward to the message bus
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
@@ -849,6 +851,19 @@ class TelegramChannel(BaseChannel):
|
|||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
async def _add_reaction(self, chat_id: str, message_id: int, emoji: str) -> None:
|
||||||
|
"""Add an emoji reaction best-effort without interrupting message handling."""
|
||||||
|
if not self._app or not emoji:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._app.bot.set_message_reaction(
|
||||||
|
chat_id=int(chat_id),
|
||||||
|
message_id=message_id,
|
||||||
|
reaction=[ReactionTypeEmoji(emoji=emoji)],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Telegram reaction failed: {}", e)
|
||||||
|
|
||||||
async def _typing_loop(self, chat_id: str) -> None:
|
async def _typing_loop(self, chat_id: str) -> None:
|
||||||
"""Repeatedly send 'typing' action until cancelled."""
|
"""Repeatedly send 'typing' action until cancelled."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
def default_config(cls) -> dict[str, object]:
|
def default_config(cls) -> dict[str, object]:
|
||||||
return WhatsAppConfig().model_dump(by_alias=True)
|
return WhatsAppConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
def __init__(self, config: WhatsAppConfig | WhatsAppInstanceConfig, bus: MessageBus):
|
def __init__(self, config: WhatsAppConfig | WhatsAppInstanceConfig | dict, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WhatsAppConfig.model_validate(config)
|
||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self.config: WhatsAppConfig | WhatsAppInstanceConfig = config
|
self.config: WhatsAppConfig | WhatsAppInstanceConfig = config
|
||||||
self._ws = None
|
self._ws = None
|
||||||
@@ -175,6 +177,12 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
self._processed_message_ids.popitem(last=False)
|
self._processed_message_ids.popitem(last=False)
|
||||||
|
|
||||||
# Extract just the phone number or lid as chat_id
|
# Extract just the phone number or lid as chat_id
|
||||||
|
is_group = data.get("isGroup", False)
|
||||||
|
was_mentioned = data.get("wasMentioned", False)
|
||||||
|
|
||||||
|
if is_group and self.config.group_policy == "mention" and not was_mentioned:
|
||||||
|
return
|
||||||
|
|
||||||
user_id = pn if pn else sender
|
user_id = pn if pn else sender
|
||||||
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
|
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
|
||||||
logger.info("Sender {}", sender)
|
logger.info("Sender {}", sender)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class WhatsAppConfig(Base):
|
|||||||
bridge_url: str = "ws://localhost:3001"
|
bridge_url: str = "ws://localhost:3001"
|
||||||
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||||
|
group_policy: Literal["open", "mention"] = "open"
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppInstanceConfig(WhatsAppConfig):
|
class WhatsAppInstanceConfig(WhatsAppConfig):
|
||||||
@@ -46,6 +47,7 @@ class TelegramConfig(Base):
|
|||||||
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||||
)
|
)
|
||||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
reply_to_message: bool = False # If true, bot replies quote the original message
|
||||||
|
react_emoji: str = "👀"
|
||||||
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
|
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
|
||||||
connection_pool_size: int = 32 # Outbound Telegram API HTTP pool size
|
connection_pool_size: int = 32 # Outbound Telegram API HTTP pool size
|
||||||
pool_timeout: float = 5.0 # Shared HTTP pool timeout for bot sends and getUpdates
|
pool_timeout: float = 5.0 # Shared HTTP pool timeout for bot sends and getUpdates
|
||||||
@@ -319,6 +321,10 @@ class QQConfig(Base):
|
|||||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user openids
|
allow_from: list[str] = Field(default_factory=list) # Allowed user openids
|
||||||
|
msg_format: Literal["plain", "markdown"] = "plain"
|
||||||
|
media_dir: str = ""
|
||||||
|
download_chunk_size: int = 1024 * 256
|
||||||
|
download_max_bytes: int = 1024 * 1024 * 200
|
||||||
media_base_url: str = "" # Public base URL used to expose workspace/out QQ media files
|
media_base_url: str = "" # Public base URL used to expose workspace/out QQ media files
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ async def test_on_group_message_routes_to_group_chat_id() -> None:
|
|||||||
content="hello",
|
content="hello",
|
||||||
group_openid="group123",
|
group_openid="group123",
|
||||||
author=SimpleNamespace(member_openid="user1"),
|
author=SimpleNamespace(member_openid="user1"),
|
||||||
|
attachments=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
await channel._on_message(data, is_group=True)
|
await channel._on_message(data, is_group=True)
|
||||||
@@ -142,6 +143,35 @@ async def test_send_c2c_message_uses_plain_text_c2c_api_with_msg_seq() -> None:
|
|||||||
assert not channel._client.api.group_calls
|
assert not channel._client.api.group_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_group_message_uses_markdown_when_configured() -> None:
|
||||||
|
channel = QQChannel(
|
||||||
|
QQConfig(app_id="app", secret="secret", allow_from=["*"], msg_format="markdown"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._client = _FakeClient()
|
||||||
|
channel._chat_type_cache["group123"] = "group"
|
||||||
|
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(
|
||||||
|
channel="qq",
|
||||||
|
chat_id="group123",
|
||||||
|
content="**hello**",
|
||||||
|
metadata={"message_id": "msg1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(channel._client.api.group_calls) == 1
|
||||||
|
call = channel._client.api.group_calls[0]
|
||||||
|
assert call == {
|
||||||
|
"group_openid": "group123",
|
||||||
|
"msg_type": 2,
|
||||||
|
"markdown": {"content": "**hello**"},
|
||||||
|
"msg_id": "msg1",
|
||||||
|
"msg_seq": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_group_remote_media_url_uses_file_api_then_media_message(monkeypatch) -> None:
|
async def test_send_group_remote_media_url_uses_file_api_then_media_message(monkeypatch) -> None:
|
||||||
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
||||||
|
|||||||
@@ -106,3 +106,52 @@ async def test_send_when_disconnected_is_noop():
|
|||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
ch._ws.send.assert_not_called()
|
ch._ws.send.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_skips_unmentioned_group_message():
|
||||||
|
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
|
||||||
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
await ch._handle_bridge_message(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"id": "m1",
|
||||||
|
"sender": "12345@g.us",
|
||||||
|
"pn": "user@s.whatsapp.net",
|
||||||
|
"content": "hello group",
|
||||||
|
"timestamp": 1,
|
||||||
|
"isGroup": True,
|
||||||
|
"wasMentioned": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._handle_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_accepts_mentioned_group_message():
|
||||||
|
ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock())
|
||||||
|
ch._handle_message = AsyncMock()
|
||||||
|
|
||||||
|
await ch._handle_bridge_message(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"id": "m1",
|
||||||
|
"sender": "12345@g.us",
|
||||||
|
"pn": "user@s.whatsapp.net",
|
||||||
|
"content": "hello @bot",
|
||||||
|
"timestamp": 1,
|
||||||
|
"isGroup": True,
|
||||||
|
"wasMentioned": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ch._handle_message.assert_awaited_once()
|
||||||
|
kwargs = ch._handle_message.await_args.kwargs
|
||||||
|
assert kwargs["chat_id"] == "12345@g.us"
|
||||||
|
assert kwargs["sender_id"] == "user"
|
||||||
|
|||||||
Reference in New Issue
Block a user