Files
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

399 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from urllib.parse import unquote, urlsplit
# tg_channel.py — Telegram 通道适配(utility / chat 两个入口共用)
# 职责: 把统一 content(str 或 OneBot 段数组)转成 TG API 调用;
# TG id 命名空间换算(业务状态按通道隔离)。
# 注意: 本模块是平台适配层,不含任何业务逻辑。
import asyncio
import base64
import contextlib
import os
import re
import urllib.request
import tempfile
from aiogram.types import FSInputFile
from channel import Channel, admin_list
# ---------------- TG id 命名空间 ----------------
# 业务状态(聊天历史/邮箱/live关注/游戏)以"裸 id"做 key。
# QQ 侧沿用原始 QQ 号;TG 侧加偏移,避免两端 id 撞号串状态。
TG_USER_OFFSET = 10 ** 10 # TG 私聊用户: ns = raw + offset
TG_GROUP_OFFSET = 10 ** 12 # TG 群: ns = offset + abs(raw_chat_id)
TG_CHUNK = 4000 # TG 单条上限 4096,留余量
class TGChannel(Channel):
name = "tg"
def __init__(self, bot, tg_username: str = "", admin_ids=None):
self.bot = bot
self.tg_username = tg_username
if admin_ids is not None:
self._raw_admin = set(int(x) for x in admin_ids)
else:
self._raw_admin = set(admin_list("tg"))
# ---- id 换算 ----
@staticmethod
def ns_user(raw_uid: int) -> int:
return TG_USER_OFFSET + int(raw_uid)
@staticmethod
def ns_group(raw_chat_id: int) -> int:
return TG_GROUP_OFFSET + abs(int(raw_chat_id))
def _to_raw_target(self, target_id) -> int:
tid = int(target_id)
if tid >= TG_GROUP_OFFSET:
return -(tid - TG_GROUP_OFFSET)
if tid >= TG_USER_OFFSET:
return tid - TG_USER_OFFSET
return tid # 原样(监控循环直接传裸 id 的场景)
def typing(self, target_id):
"""TG「正在输入」: sendChatAction("typing"),服务端状态约 5s 过期,
上下文存续期间每 4s 续一次。"""
chat_id = self._to_raw_target(target_id)
bot = self.bot
@contextlib.asynccontextmanager
async def _typing():
stop = asyncio.Event()
async def beat():
while True:
try:
await bot.send_chat_action(chat_id=chat_id, action="typing")
except Exception as e:
print(f"[tg] typing 发送失败: {e}")
try:
await asyncio.wait_for(stop.wait(), timeout=4)
return
except asyncio.TimeoutError:
continue
task = asyncio.create_task(beat())
try:
yield
finally:
stop.set()
await asyncio.gather(task, return_exceptions=True)
return _typing()
def is_admin(self, user_id) -> bool:
try:
uid = int(user_id)
except (TypeError, ValueError):
return False
if uid >= TG_USER_OFFSET:
uid -= TG_USER_OFFSET
return uid in self._raw_admin or super().is_admin(user_id)
# ---- 发送 ----
@staticmethod
def _translate_oss_path(file_uri: str) -> str:
"""把远端 OSS 挂载或旧盘符 URI 映射到本机 OSS;普通本地 URI 保持原路径。"""
uri = urlsplit(file_uri.replace("\\", "/"))
if uri.scheme != "file" or uri.query or uri.fragment:
raise ValueError("需要有效的 file URI,文件名中的特殊字符须进行 URL 编码")
if re.fullmatch(r"[A-Za-z]:", uri.netloc):
p = uri.netloc + unquote(uri.path)
elif uri.netloc.lower() in ("", "localhost"):
p = unquote(uri.path)
else:
raise ValueError("不支持远程文件主机")
p = p.replace("\\", "/")
if "\x00" in p:
raise ValueError("文件路径包含空字符")
if re.match(r"^/[A-Za-z]:/", p):
p = p[1:]
relative = None
if p == "/mnt/A" or p.startswith("/mnt/A/"):
relative = p[len("/mnt/A"):].lstrip("/")
elif p.lower() == "c:/oss" or p.lower().startswith("c:/oss/"):
relative = p[len("c:/oss"):].lstrip("/")
elif re.match(r"^[A-Za-z]:", p):
raise ValueError("旧盘符路径不属于 OSS 挂载")
if relative is not None:
root = os.environ.get("OSS_MOUNT_PATH", "/mnt/oss")
if not os.path.isabs(root):
raise ValueError("OSS_MOUNT_PATH 必须为绝对路径")
root = os.path.realpath(root)
local = os.path.realpath(os.path.join(root, relative))
if os.path.commonpath((root, local)) != root:
raise ValueError("OSS 文件路径越出了挂载目录")
return local
if not os.path.isabs(p):
raise ValueError("本地文件路径必须为绝对路径")
return os.path.normpath(p)
async def _send_text(self, chat_id, text: str):
for i in range(0, len(text), TG_CHUNK):
chunk = text[i:i + TG_CHUNK]
await _retry_send(
lambda c=chunk: self.bot.send_message(chat_id=chat_id, text=c,
parse_mode=None,
disable_web_page_preview=True),
"发文本")
async def _send_file(self, chat_id, file: str, manual: str = None):
"""file 段:file:// 或本地路径发 document;manual 为失败提示里的手动访问链接。"""
if not file:
return False
local = self._translate_oss_path(file) if file.startswith("file://") else file
if not os.path.exists(local):
print(f"[tg] 文件本地不存在: {local}", flush=True)
await _send_notice(self.bot, chat_id,
f"文件发送失败(本地文件缺失)。请手动访问 {manual or TG_MANUAL_URL}")
return False
result = await _send_file_robust(self.bot, chat_id, local, manual)
return result is not None
async def _send_sticker(self, chat_id, file: str):
"""Send Agent sticker segments through Telegram's sticker API."""
if not file:
return False
local = self._translate_oss_path(file) if file.startswith("file://") else file
if not os.path.exists(local):
print(f"[tg] 贴纸本地不存在: {local}", flush=True)
return False
result = await _send_upload_with_policy(
self.bot, chat_id,
lambda: self.bot.send_sticker(
chat_id=chat_id, sticker=FSInputFile(local),
request_timeout=TG_UPLOAD_TIMEOUT,
),
"发贴纸", _manual_access_url(local),
)
return result is not None
async def _send_image(self, chat_id, file: str):
if file.startswith("file://"):
local = self._translate_oss_path(file)
if os.path.exists(local):
result = await _send_image_robust(self.bot, chat_id, local)
return result is not None
print(f"[tg] OSS 图片本地不存在: {local}")
return False
if file.startswith("http://") or file.startswith("https://"):
manual = _manual_access_url(file)
size = await asyncio.to_thread(_probe_url_size, file)
if size > TG_DOC_LIMIT:
print(f"[tg] URL 文件过大({size} 字节),跳过发送", flush=True)
await _send_notice(self.bot, chat_id, f"文件过大。请手动访问 {manual}")
return False
if size > TG_PHOTO_LIMIT:
result = await _send_upload_with_policy(
self.bot, chat_id,
lambda: self.bot.send_document(chat_id=chat_id, document=file,
request_timeout=TG_UPLOAD_TIMEOUT),
"按URL发文件", manual)
return result is not None
result = await _send_upload_with_policy(
self.bot, chat_id,
lambda: self.bot.send_photo(chat_id=chat_id, photo=file,
request_timeout=TG_UPLOAD_TIMEOUT),
"按URL发图片", manual)
return result is not None
# base64(可能带 data: 前缀)
b64 = file.split(",", 1)[-1] if file.startswith("data:") else file
data = base64.b64decode(b64)
suffix = ".jpg"
if file.startswith("data:"):
mime = file.split(";", 1)[0].split(":", 1)[-1]
suffix = {"image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}.get(mime, ".jpg")
fd, path = tempfile.mkstemp(suffix=suffix)
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
result = await _send_image_robust(self.bot, chat_id, path)
return result is not None
finally:
try:
os.unlink(path)
except OSError:
pass
async def send(self, msg_type, target_id, content):
chat_id = self._to_raw_target(target_id)
if isinstance(content, str):
if not content:
return True
await self._send_text(chat_id, content)
return True
# 段数组(OneBot 段格式)
text_buf = []
all_ok = True
for seg in content:
stype = seg.get("type")
sdata = seg.get("data", {})
if stype == "text":
text_buf.append(sdata.get("text", ""))
elif stype == "image":
if text_buf:
await self._send_text(chat_id, "".join(text_buf))
text_buf = []
if sdata.get("sub_type") == 1:
ok = await self._send_sticker(chat_id, sdata.get("file", ""))
else:
ok = await self._send_image(chat_id, sdata.get("file", ""))
all_ok = ok and all_ok
elif stype == "file":
if text_buf:
await self._send_text(chat_id, "".join(text_buf))
text_buf = []
ok = await self._send_file(chat_id, sdata.get("file", ""),
sdata.get("manual"))
all_ok = ok and all_ok
if text_buf:
await self._send_text(chat_id, "".join(text_buf))
return all_ok
TG_SEND_RETRIES = 3 # 发送失败重试次数(仅网络类瞬时错误)
def _is_retryable(e: Exception) -> bool:
"""只有瞬时错误才重试;BadRequest/Forbidden 等永久错误直接抛出"""
name = type(e).__name__
for key in ("Network", "Timeout", "Server", "Disconnected",
"Connector", "ClientError", "RetryAfter"):
if key in name:
return True
return False
TG_IMAGE_RETRIES = 5 # 图片/文件发送重试次数(仅 TG 侧;QQ 侧发送政策见 qq_channel/napcat 链路)
TG_UPLOAD_TIMEOUT = 300 # 上传类请求总超时(官方默认 60s 对大图太紧)
TG_PHOTO_LIMIT = 10 * 1024 * 1024 # send_photo 官方上限 10MB
TG_DOC_LIMIT = 50 * 1024 * 1024 # send_document 官方上限 50MB
TG_MANUAL_URL = "https://openapi.rrrosmontis.icu/"
def _manual_access_url(source) -> str:
"""从图片来源推导精确的手动访问链接(本地文件名/URL 中的数字 pid)。"""
try:
if isinstance(source, str) and source.startswith("http"):
m = re.search(r"[?&]id=(\d+)", source)
if m:
return f"{TG_MANUAL_URL}pixiv/get_image?id={m.group(1)}"
else:
stem = os.path.splitext(os.path.basename(str(source)))[0]
if stem.isdigit():
return f"{TG_MANUAL_URL}pixiv/get_image?id={stem}"
except Exception:
pass
return TG_MANUAL_URL
def _probe_url_size(url: str) -> int:
"""HEAD 后端镜像拿 Content-Length;失败返回 0(未知)。"""
try:
req = urllib.request.Request(url, method="HEAD",
headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
return int(resp.headers.get("Content-Length") or 0)
except Exception as e:
print(f"[tg] HEAD 探测大小失败: {type(e).__name__}: {str(e)[:80]}", flush=True)
return 0
def _looks_timeout(e: Exception) -> bool:
"""超时类错误识别(aiohttp/aiogram 的 timeout 命名不统一,按名字+文本兜)"""
text = type(e).__name__ + " " + str(e)
return "imeout" in text
async def _send_notice(bot, chat_id, text: str):
try:
await _retry_send(lambda: bot.send_message(chat_id=chat_id, text=text, parse_mode=None),
"发送失败提示")
except Exception as ex:
print(f"[tg] 失败提示也发不出: {type(ex).__name__}: {str(ex)[:80]}", flush=True)
async def _send_upload_with_policy(bot, chat_id, factory, desc: str, manual_url: str = None):
"""统一上传策略:5 次重试、300s 超时;放弃后按错误类型给用户一句可操作的提示。"""
last = None
timeout_seen = False
for attempt in range(TG_IMAGE_RETRIES):
try:
return await factory()
except Exception as e:
last = e
if _looks_timeout(e):
timeout_seen = True
if not _is_retryable(e):
break
if attempt < TG_IMAGE_RETRIES - 1:
delay = min(2 ** attempt * 2, 15)
print(f"[tg] {desc}失败(第{attempt + 1}/{TG_IMAGE_RETRIES}次): "
f"{type(e).__name__}: {str(e)[:100]},{delay}s 后重试", flush=True)
await asyncio.sleep(delay)
reason = "上传超时" if (timeout_seen or _looks_timeout(last)) else "多次尝试发送失败"
notice = f"{reason}。请手动访问 {manual_url or TG_MANUAL_URL}"
print(f"[tg] {desc}放弃: {type(last).__name__}: {str(last)[:80]};已通知用户", flush=True)
await _send_notice(bot, chat_id, notice)
return None
async def _send_image_robust(bot, chat_id, local: str):
"""本地图片按大小分流:>10MB 直接走文件通道,>50MB 不发送只提示。"""
try:
size = os.path.getsize(local)
except OSError:
size = 0
manual = _manual_access_url(local)
if size > TG_DOC_LIMIT:
print(f"[tg] 文件过大({size} 字节),跳过发送", flush=True)
await _send_notice(bot, chat_id, f"文件过大。请手动访问 {manual}")
return None
if size > TG_PHOTO_LIMIT:
return await _send_upload_with_policy(
bot, chat_id,
lambda: bot.send_document(chat_id=chat_id, document=FSInputFile(local),
request_timeout=TG_UPLOAD_TIMEOUT),
"发文件", manual)
return await _send_upload_with_policy(
bot, chat_id,
lambda: bot.send_photo(chat_id=chat_id, photo=FSInputFile(local),
request_timeout=TG_UPLOAD_TIMEOUT),
"发图片", manual)
async def _send_file_robust(bot, chat_id, local: str, manual_url: str = None):
"""发送任意文件(压缩包等):>50MB 拒发,其余一律走 send_document。"""
try:
size = os.path.getsize(local)
except OSError:
size = 0
if size > TG_DOC_LIMIT:
print(f"[tg] 文件过大({size} 字节),跳过发送", flush=True)
await _send_notice(bot, chat_id, f"文件过大。请手动访问 {manual_url or TG_MANUAL_URL}")
return None
return await _send_upload_with_policy(
bot, chat_id,
lambda: bot.send_document(chat_id=chat_id, document=FSInputFile(local),
request_timeout=TG_UPLOAD_TIMEOUT),
"发文件", manual_url or TG_MANUAL_URL)
async def _retry_send(factory, desc: str):
last = None
for attempt in range(TG_SEND_RETRIES):
try:
return await factory()
except Exception as e:
last = e
if not _is_retryable(e):
raise
if attempt < TG_SEND_RETRIES - 1:
print(f"[tg] {desc}失败(第{attempt + 1}/{TG_SEND_RETRIES}次): "
f"{type(e).__name__}: {str(e)[:120]},{2 ** attempt}s 后重试")
await asyncio.sleep(2 ** attempt)
raise last