├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── auth.py ├── config.py ├── database.py ├── dispatch.py ├── enum.py ├── exception_handlers.py ├── exceptions.py ├── log.py ├── manage.py ├── models │ ├── __init__.py │ ├── config.py │ ├── network │ │ ├── dicerobot.py │ │ └── napcat.py │ ├── panel │ │ ├── admin.py │ │ ├── napcat.py │ │ └── qq.py │ └── report │ │ ├── __init__.py │ │ ├── message.py │ │ ├── meta_event.py │ │ ├── notice.py │ │ ├── request.py │ │ └── segment.py ├── network │ ├── __init__.py │ ├── dicerobot.py │ └── napcat.py ├── router │ ├── __init__.py │ ├── admin.py │ ├── napcat.py │ ├── qq.py │ └── webhook.py ├── schedule.py ├── task.py ├── utils.py └── version.py ├── deploy.sh ├── plugin ├── __init__.py └── dicerobot │ ├── __init__.py │ ├── event │ ├── __init__.py │ ├── friend_request.py │ └── group_invite.py │ └── order │ ├── __init__.py │ ├── bot.py │ ├── bp_dice.py │ ├── chat.py │ ├── conversation.py │ ├── daily_60s.py │ ├── dice.py │ ├── hidden_dice.py │ ├── paint.py │ ├── skill_roll.py │ └── stable_diffusion.py ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── test_bot.py ├── test_bp_dice.py ├── test_chat.py ├── test_conversation.py ├── test_dice.py ├── test_hidden_dice.py └── test_skill_roll.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.py linguist-language=Python -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea 3 | 4 | # Environment file 5 | .env.test 6 | 7 | # Poetry file 8 | poetry.lock 9 | 10 | # Debug files 11 | /logs 12 | database.db 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2024 Drsanwujiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiceRobot 2 | 3 | DiceRobot,你的 TRPG 小助手。 4 | 5 | ## 环境 6 | 7 | DiceRobot 需要以下环境: 8 | 9 | - Python 3.10 或更高版本 10 | - Poetry 11 | 12 | ## 安装 13 | 14 | ### 克隆仓库 15 | 16 | ```shell 17 | git clone https://github.com/drsanwujiang/DiceRobot.git dicerobot 18 | ``` 19 | 20 | 国内环境可以使用 Gitee 镜像 21 | 22 | ```shell 23 | git clone https://gitee.com/drsanwujiang/DiceRobot.git dicerobot 24 | ``` 25 | 26 | ### 自动安装 27 | 28 | 运行 `deploy.sh` 即可完成自动安装: 29 | 30 | ```shell 31 | cd dicerobot 32 | bash deploy.sh 33 | ``` 34 | 35 | 自动安装脚本在以下操作系统中经过了测试: 36 | 37 | - Debian 12(默认 Python 版本为 3.11) 38 | - Ubuntu 24.04(默认 Python 版本为 3.12) 39 | - Ubuntu 22.04(默认 Python 版本为 3.10) 40 | 41 | 其他操作系统建议手动安装。 42 | 43 | ### 手动安装 44 | 45 | 请参照相关文档,安装以下依赖环境: 46 | 47 | - Python 3.10 或更高版本 48 | - Poetry 49 | 50 | 此外还需要安装 NTQQ 相关依赖环境: 51 | 52 | - `apt install curl xvfb libnss3 libgbm1 libasound2` 53 | - `yum install curl xorg-x11-server-Xvfb libgbm alsa-lib-devel nss dbus-libs at-spi2-atk gtk3 cups-libs` 54 | 55 | ## 管理 56 | 57 | 在 [DiceRobot 控制面板](https://panel.dicerobot.tech/) 可以对 DiceRobot 进行管理,并且支持一键安装 NapCat 和 QQ,上手体验十分简单。 58 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | 3 | from fastapi import FastAPI 4 | 5 | from .version import VERSION 6 | from .log import logger, init_logger 7 | from .exception_handlers import init_exception_handlers 8 | from .router import init_router 9 | from .database import init_database, clean_database 10 | from .config import init_config, save_config 11 | from .schedule import init_scheduler, clean_scheduler 12 | from .dispatch import init_dispatcher 13 | from .manage import init_manager, clean_manager 14 | 15 | 16 | @asynccontextmanager 17 | async def lifespan(_: FastAPI): 18 | init_logger() 19 | 20 | logger.info(f"DiceRobot {VERSION}") 21 | logger.info("Start DiceRobot") 22 | 23 | init_database() 24 | init_config() 25 | 26 | init_dispatcher() 27 | init_scheduler() 28 | init_manager() 29 | 30 | logger.success("DiceRobot started") 31 | 32 | yield 33 | 34 | logger.info("Stop DiceRobot") 35 | 36 | clean_manager() 37 | clean_scheduler() 38 | save_config() 39 | clean_database() 40 | 41 | logger.success("DiceRobot stopped") 42 | 43 | 44 | dicerobot = FastAPI( 45 | title="DiceRobot", 46 | description="A TRPG assistant bot", 47 | version=VERSION, 48 | lifespan=lifespan 49 | ) 50 | 51 | init_exception_handlers(dicerobot) 52 | init_router(dicerobot) 53 | -------------------------------------------------------------------------------- /app/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import hmac 3 | 4 | from fastapi import Request, Header 5 | from werkzeug.security import check_password_hash 6 | import jwt 7 | 8 | from .log import logger 9 | from .config import status, settings 10 | from .exceptions import TokenInvalidError, SignatureInvalidError 11 | 12 | 13 | def verify_password(password: str) -> bool: 14 | if not settings.security.admin.password_hash: 15 | return True 16 | 17 | return check_password_hash(settings.security.admin.password_hash, password) 18 | 19 | 20 | def generate_jwt_token() -> str: 21 | return jwt.encode( 22 | {"exp": datetime.now() + timedelta(days=7)}, 23 | settings.security.jwt.secret, 24 | settings.security.jwt.algorithm 25 | ) 26 | 27 | 28 | def verify_jwt_token(authorization: str = Header()) -> None: 29 | logger.debug("HTTP request received, verify JWT token") 30 | 31 | scheme, _, token = authorization.partition(" ") 32 | 33 | if not authorization or scheme != "Bearer" or not token: 34 | logger.debug("JWT token verification failed, token not exists") 35 | raise TokenInvalidError 36 | 37 | try: 38 | jwt.decode(token, settings.security.jwt.secret, settings.security.jwt.algorithm) 39 | except jwt.InvalidTokenError: 40 | logger.debug("JWT token verification failed, token invalid") 41 | raise TokenInvalidError 42 | 43 | logger.debug("JWT token verification passed") 44 | 45 | 46 | async def verify_signature( 47 | request: Request, 48 | signature: str = Header(alias="X-Signature", min_length=45, max_length=45) 49 | ) -> None: 50 | logger.debug("HTTP request received, verify signature") 51 | 52 | if status.debug: 53 | logger.debug("Signature verification passed, debug mode") 54 | return 55 | 56 | signature = signature[5:] 57 | digest = hmac.digest( 58 | settings.security.webhook.secret.encode(), 59 | await request.body(), 60 | "sha1" 61 | ).hex() 62 | 63 | if signature != digest: 64 | logger.debug("Signature verification failed, signature invalid") 65 | raise SignatureInvalidError 66 | 67 | logger.debug("Signature verification passed") 68 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from sqlalchemy import select 5 | from sqlalchemy.dialects.sqlite import insert 6 | 7 | from .log import logger 8 | from .models.config import ( 9 | Status as StatusModel, Settings as SettingsModel, PluginSettings as PluginSettingsModel, 10 | ChatSettings as ChatSettingsModel, Replies as RepliesModel 11 | ) 12 | from .database import Session, Settings, PluginSettings, Replies, ChatSettings 13 | from .enum import ChatType 14 | 15 | status = StatusModel(debug=os.environ.get("DICEROBOT_DEBUG") is not None) 16 | settings = SettingsModel() 17 | plugin_settings = PluginSettingsModel() 18 | chat_settings = ChatSettingsModel() 19 | replies = RepliesModel() 20 | 21 | 22 | def init_config() -> None: 23 | with Session() as session, session.begin(): 24 | # Settings 25 | _settings = {} 26 | 27 | for item in session.execute(select(Settings)).scalars().fetchall(): # type: Settings 28 | _settings[item.group] = json.loads(item.json) 29 | 30 | settings.update(_settings) 31 | 32 | # Plugin settings 33 | _plugin_settings = {} 34 | 35 | for item in session.execute(select(PluginSettings)).scalars().fetchall(): # type: PluginSettings 36 | _plugin_settings[item.plugin] = json.loads(item.json) 37 | 38 | for _plugin, _settings in _plugin_settings.items(): 39 | plugin_settings.set(plugin=_plugin, settings=_settings) 40 | 41 | # Chat settings 42 | _chat_settings = {} 43 | 44 | for item in session.execute(select(ChatSettings)).scalars().fetchall(): # type: ChatSettings 45 | _chat_settings.setdefault(ChatType(item.chat_type), {}).setdefault(item.chat_id, {})[item.group] = json.loads(item.json) 46 | 47 | for _chat_type, _chat_type_settings in _chat_settings.items(): 48 | for _chat_id, _chat_id_settings in _chat_type_settings.items(): 49 | for _setting_group, _settings in _chat_id_settings.items(): 50 | chat_settings.set(chat_type=_chat_type, chat_id=_chat_id, setting_group=_setting_group, settings=_settings) 51 | 52 | # Replies 53 | _replies = {} 54 | 55 | for item in session.execute(select(Replies)).scalars().fetchall(): # type: Replies 56 | _replies.setdefault(item.group, {})[item.key] = item.value 57 | 58 | for _group, _group_replies in _replies.items(): 59 | replies.set_replies(group=_group, replies=_group_replies) 60 | 61 | logger.info("Config initialized") 62 | 63 | 64 | def save_config() -> None: 65 | logger.info("Save config") 66 | 67 | with Session() as session, session.begin(): 68 | for key, value in settings.model_dump(safe_dump=False).items(): # type: str, dict 69 | serialized = json.dumps(value) 70 | session.execute( 71 | insert(Settings) 72 | .values(group=key, json=serialized) 73 | .on_conflict_do_update(index_elements=["group"], set_={"json": serialized}) 74 | ) 75 | 76 | for key, value in plugin_settings.dict().items(): # type: str, dict 77 | serialized = json.dumps(value) 78 | session.execute( 79 | insert(PluginSettings) 80 | .values(plugin=key, json=serialized) 81 | .on_conflict_do_update(index_elements=["plugin"], set_={"json": serialized}) 82 | ) 83 | 84 | for chat_type, _settings in chat_settings.dict().items(): # type: ChatType, dict[int, dict[str, dict]] 85 | for chat_id, _chat_settings in _settings.items(): # type: int, dict[str, dict] 86 | for key, value in _chat_settings.items(): # type: str, dict 87 | serialized = json.dumps(value) 88 | session.execute( 89 | insert(ChatSettings) 90 | .values(chat_type=chat_type.value, chat_id=chat_id, group=key, json=serialized) 91 | .on_conflict_do_update(index_elements=["chat_type", "chat_id", "group"], set_={"json": serialized}) 92 | ) 93 | 94 | for group, group_replies in replies.dict().items(): # type: str, dict[str, str] 95 | for key, value in group_replies.items(): # type: str, str 96 | session.execute(insert(Replies).values( 97 | group=group, 98 | key=key, 99 | value=value 100 | ).on_conflict_do_update( 101 | index_elements=["group", "key"], 102 | set_={"value": value}) 103 | ) 104 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Enum, create_engine 2 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker 3 | 4 | from .enum import ChatType 5 | 6 | 7 | engine = create_engine("sqlite:///database.db", connect_args={"check_same_thread": False}) 8 | Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) 9 | 10 | 11 | class Base(DeclarativeBase): 12 | pass 13 | 14 | 15 | class Settings(Base): 16 | __tablename__ = "settings" 17 | 18 | group: Mapped[str] = mapped_column(primary_key=True, nullable=False) 19 | json: Mapped[str] = mapped_column(nullable=False) 20 | 21 | 22 | class PluginSettings(Base): 23 | __tablename__ = "plugin_settings" 24 | 25 | plugin: Mapped[str] = mapped_column(primary_key=True, nullable=False) 26 | json: Mapped[str] = mapped_column(nullable=False) 27 | 28 | 29 | class Replies(Base): 30 | __tablename__ = "replies" 31 | 32 | group: Mapped[str] = mapped_column(primary_key=True, nullable=False) 33 | key: Mapped[str] = mapped_column(primary_key=True, nullable=False) 34 | value: Mapped[str] = mapped_column(nullable=False) 35 | 36 | 37 | class ChatSettings(Base): 38 | __tablename__ = "chat_settings" 39 | 40 | chat_type: Mapped[ChatType] = mapped_column(Enum(ChatType, values_callable=lambda x: [e.value for e in x]), primary_key=True, nullable=False) 41 | chat_id: Mapped[int] = mapped_column(primary_key=True, nullable=False) 42 | group: Mapped[str] = mapped_column(primary_key=True, nullable=False) 43 | json: Mapped[str] = mapped_column(nullable=False) 44 | 45 | 46 | def init_database() -> None: 47 | engine.connect() 48 | Base.metadata.create_all(bind=engine) 49 | 50 | 51 | def clean_database() -> None: 52 | engine.dispose() 53 | -------------------------------------------------------------------------------- /app/dispatch.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | import importlib 3 | import pkgutil 4 | import re 5 | 6 | from plugin import DiceRobotPlugin, OrderPlugin, EventPlugin 7 | from .log import logger 8 | from .config import status, plugin_settings 9 | from .exceptions import DiceRobotException 10 | from .models.report.message import Message 11 | from .models.report.notice import Notice 12 | from .models.report.request import Request 13 | 14 | 15 | class Dispatcher: 16 | order_pattern = re.compile(r"^\s*[.\u3002]\s*([\S\s]+?)\s*(?:#([1-9][0-9]*))?$") 17 | 18 | def __init__(self): 19 | self.order_plugins: dict[str, Type[OrderPlugin]] = {} 20 | self.event_plugins: dict[str, Type[EventPlugin]] = {} 21 | self.orders: dict[int, list[dict[str, re.Pattern | str]]] = {} 22 | self.events: dict[str, list[str]] = {} 23 | 24 | def load_plugins(self) -> None: 25 | package = importlib.import_module("plugin") 26 | 27 | for _, name, _ in pkgutil.walk_packages(package.__path__): 28 | try: 29 | importlib.import_module(f"{package.__name__}.{name}") 30 | except ModuleNotFoundError: 31 | continue 32 | 33 | for plugin in OrderPlugin.__subclasses__(): 34 | if hasattr(plugin, "name") and isinstance(plugin.name, str): 35 | self.order_plugins[plugin.name] = plugin 36 | 37 | for plugin in EventPlugin.__subclasses__(): 38 | if hasattr(plugin, "name") and isinstance(plugin.name, str): 39 | self.event_plugins[plugin.name] = plugin 40 | 41 | for plugin in list(self.order_plugins.values()) + list(self.event_plugins.values()): 42 | status.plugins[plugin.name] = status.Plugin( 43 | display_name=plugin.display_name, 44 | description=plugin.description, 45 | version=plugin.version 46 | ) 47 | plugin.load() 48 | plugin.initialize() 49 | 50 | logger.info( 51 | f"{len(self.order_plugins)} order plugins and {len(self.event_plugins)} event plugins loaded" 52 | ) 53 | 54 | def load_orders_and_events(self) -> None: 55 | orders = {} 56 | 57 | for plugin_name, plugin in self.order_plugins.items(): 58 | if hasattr(plugin, "orders") and hasattr(plugin, "priority"): 59 | if isinstance(plugin.priority, int) and plugin.priority not in orders: 60 | orders[plugin.priority] = [] 61 | 62 | plugin_orders = plugin.orders if isinstance(plugin.orders, list) else [plugin.orders] 63 | 64 | for order in plugin_orders: 65 | if isinstance(order, str) and order: 66 | # Orders should be case-insensitive 67 | orders[plugin.priority].append({ 68 | "pattern": re.compile(fr"^({order})\s*([\S\s]*)$", re.I), 69 | "name": plugin_name 70 | }) 71 | 72 | events = {} 73 | 74 | for plugin_name, plugin in self.event_plugins.items(): 75 | if hasattr(plugin, "events"): 76 | plugin_events: list = plugin.events if isinstance(plugin.events, list) else [plugin.events] 77 | 78 | for event in plugin_events: 79 | if issubclass(event, Notice) or issubclass(event, Request): 80 | if event not in events: 81 | events[event.__name__] = [] 82 | 83 | events[event.__name__].append(plugin_name) 84 | 85 | self.orders = dict(sorted(orders.items(), reverse=True)) 86 | self.events = events 87 | 88 | logger.info( 89 | f"{sum(len(orders) for orders in self.orders.values())} orders and {len(self.events)} events loaded" 90 | ) 91 | 92 | def find_plugin(self, plugin_name: str) -> Type[DiceRobotPlugin] | None: 93 | return self.order_plugins.get(plugin_name) or self.event_plugins.get(plugin_name) 94 | 95 | def dispatch_order(self, message: Message, message_content: str) -> None: 96 | match = self.order_pattern.fullmatch(message_content) 97 | 98 | if not match: 99 | logger.debug("Dispatch missed") 100 | raise RuntimeError 101 | 102 | order_and_content = match.group(1) 103 | repetition = int(match.group(2)) if match.group(2) else 1 104 | plugin_name, order, order_content = self.match_plugin(order_and_content) 105 | 106 | if not plugin_name: 107 | logger.debug("Plugin match missed") 108 | raise RuntimeError 109 | elif not plugin_settings.get(plugin=plugin_name)["enabled"]: 110 | logger.info("Plugin disabled, execution skipped") 111 | return 112 | 113 | logger.info(f"Dispatch to plugin {plugin_name}") 114 | 115 | plugin_class = self.order_plugins[plugin_name] 116 | 117 | try: 118 | # Always pass the order converted to lowercase to the plugin 119 | plugin = plugin_class(message, order.lower(), order_content, repetition) 120 | 121 | if not plugin.check_enabled(): 122 | logger.info("Chat disabled, execution skipped") 123 | return 124 | 125 | # Execute plugin 126 | plugin() 127 | except DiceRobotException as e: 128 | plugin_class.reply_to_message_sender(message, e.reply) 129 | 130 | # Raise exception in debug mode 131 | if status.debug: 132 | raise 133 | except Exception as e: 134 | logger.exception( 135 | f"{e.__class__.__name__} occurred while dispatching plugin {plugin_name} to handle " 136 | f"{message.__class__.__name__}" 137 | ) 138 | 139 | # Raise exception in debug mode 140 | if status.debug: 141 | raise 142 | 143 | def match_plugin(self, order_and_content: str) -> tuple[str | None, str | None, str | None]: 144 | for priority, orders in self.orders.items(): 145 | for pattern_and_name in orders: 146 | if match := pattern_and_name["pattern"].fullmatch(order_and_content): 147 | return pattern_and_name["name"], match.group(1), match.group(2) 148 | 149 | return None, None, None 150 | 151 | def dispatch_event(self, event: Notice | Request) -> None: 152 | if event.__class__.__name__ not in self.events: 153 | logger.debug("Dispatch missed") 154 | raise RuntimeError 155 | 156 | for plugin_name in self.events[event.__class__.__name__]: 157 | try: 158 | self.event_plugins[plugin_name](event)() 159 | except DiceRobotException as e: 160 | logger.error( 161 | f"{e.__class__.__name__} occurred while dispatching plugin {plugin_name} to handle " 162 | f"{event.__class__.__name__}" 163 | ) 164 | 165 | # Raise exception in debug mode 166 | if status.debug: 167 | raise 168 | except Exception as e: 169 | logger.exception( 170 | f"{e.__class__.__name__} occurred while dispatching plugin {plugin_name} to handle " 171 | f"{event.__class__.__name__}" 172 | ) 173 | 174 | # Raise exception in debug mode 175 | if status.debug: 176 | raise 177 | 178 | 179 | dispatcher = Dispatcher() 180 | 181 | 182 | def init_dispatcher() -> None: 183 | dispatcher.load_plugins() 184 | dispatcher.load_orders_and_events() 185 | 186 | logger.info("Dispatcher initialized") 187 | -------------------------------------------------------------------------------- /app/enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ApplicationStatus(int, Enum): 5 | HOLDING = -1 6 | RUNNING = 0 7 | STARTED = 1 8 | 9 | 10 | class ChatType(str, Enum): 11 | FRIEND = "friend" 12 | GROUP = "group" 13 | TEMP = "temp" 14 | 15 | 16 | class ReportType(str, Enum): 17 | MESSAGE = "message" 18 | META_EVENT = "meta_event" 19 | NOTICE = "notice" 20 | REQUEST = "request" 21 | 22 | 23 | class MessageType(str, Enum): 24 | PRIVATE = "private" 25 | GROUP = "group" 26 | 27 | 28 | class PrivateMessageSubType(str, Enum): 29 | FRIEND = "friend" 30 | GROUP = "group" 31 | OTHER = "other" 32 | 33 | 34 | class GroupMessageSubType(str, Enum): 35 | NORMAL = "normal" 36 | ANONYMOUS = "anonymous" 37 | NOTICE = "notice" 38 | 39 | 40 | class NoticeType(str, Enum): 41 | GROUP_UPLOAD = "group_upload" 42 | GROUP_ADMIN = "group_admin" 43 | GROUP_DECREASE = "group_decrease" 44 | GROUP_INCREASE = "group_increase" 45 | GROUP_BAN = "group_ban" 46 | FRIEND_ADD = "friend_add" 47 | GROUP_RECALL = "group_recall" 48 | FRIEND_RECALL = "friend_recall" 49 | NOTIFY = "notify" 50 | 51 | 52 | class GroupAdminNoticeSubType(str, Enum): 53 | SET = "set" 54 | UNSET = "unset" 55 | 56 | 57 | class GroupDecreaseNoticeSubType(str, Enum): 58 | LEAVE = "leave" 59 | KICK = "kick" 60 | KICK_ME = "kick_me" 61 | 62 | 63 | class GroupIncreaseNoticeSubType(str, Enum): 64 | APPROVE = "approve" 65 | INVITE = "invite" 66 | 67 | 68 | class GroupBanNoticeSubType(str, Enum): 69 | BAN = "ban" 70 | LIFT_BAN = "lift_ban" 71 | 72 | 73 | class NotifySubType(str, Enum): 74 | POKE = "poke" 75 | LUCKY_KING = "lucky_king" 76 | HONOR = "honor" 77 | 78 | 79 | class RequestType(str, Enum): 80 | FRIEND = "friend" 81 | GROUP = "group" 82 | 83 | 84 | class GroupAddRequestSubType(str, Enum): 85 | ADD = "add" 86 | INVITE = "invite" 87 | 88 | 89 | class MetaEventType(str, Enum): 90 | LIFECYCLE = "lifecycle" 91 | HEARTBEAT = "heartbeat" 92 | 93 | 94 | class LifecycleMetaEventSubType(str, Enum): 95 | ENABLE = "enable" 96 | DISABLE = "disable" 97 | CONNECT = "connect" 98 | 99 | 100 | class SegmentType(str, Enum): 101 | TEXT = "text" 102 | IMAGE = "image" 103 | AT = "at" 104 | 105 | 106 | class Sex(str, Enum): 107 | MALE = "male" 108 | FEMALE = "female" 109 | UNKNOWN = "unknown" 110 | 111 | 112 | class Role(str, Enum): 113 | OWNER = "owner" 114 | ADMIN = "admin" 115 | MEMBER = "member" 116 | -------------------------------------------------------------------------------- /app/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from starlette.exceptions import HTTPException 2 | from fastapi import FastAPI, Request, Response 3 | from fastapi.responses import JSONResponse 4 | from fastapi.exceptions import RequestValidationError 5 | 6 | from .log import logger 7 | from .config import status 8 | from .exceptions import DiceRobotHTTPException 9 | 10 | 11 | def http_exception_handler(_: Request, e: HTTPException) -> Response: 12 | return JSONResponse( 13 | status_code=e.status_code, 14 | content={ 15 | "code": e.status_code * -1, 16 | "message": str(e.detail) 17 | } 18 | ) 19 | 20 | 21 | def request_validation_error_handler(_: Request, __: RequestValidationError) -> Response: 22 | return JSONResponse( 23 | status_code=400, 24 | content={ 25 | "code": -3, 26 | "message": "Invalid request" 27 | } 28 | ) 29 | 30 | 31 | def dicerobot_http_exception_handler(_: Request, e: DiceRobotHTTPException) -> Response: 32 | return JSONResponse( 33 | status_code=e.status_code, 34 | content={"code": e.code, "message": e.message} 35 | ) 36 | 37 | 38 | def exception_handler(_: Request, __: Exception) -> Response: 39 | logger.critical("Unexpected exception occurred") 40 | 41 | return JSONResponse( 42 | status_code=500, 43 | content={ 44 | "code": -500, 45 | "message": "Internal server error" 46 | } 47 | ) 48 | 49 | 50 | def init_exception_handlers(app: FastAPI) -> None: 51 | app.add_exception_handler(HTTPException, http_exception_handler) # type: ignore 52 | app.add_exception_handler(RequestValidationError, request_validation_error_handler) # type: ignore 53 | app.add_exception_handler(DiceRobotHTTPException, dicerobot_http_exception_handler) # type: ignore 54 | 55 | if not status.debug: 56 | app.add_exception_handler(Exception, exception_handler) 57 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | from .config import replies 2 | 3 | 4 | class DiceRobotException(Exception): 5 | def __init__(self, reply: str) -> None: 6 | self.reply = reply 7 | 8 | 9 | class NetworkClientError(DiceRobotException): 10 | def __init__(self) -> None: 11 | super().__init__(replies.get_reply(group="dicerobot", key="network_client_error")) 12 | 13 | 14 | class NetworkServerError(DiceRobotException): 15 | def __init__(self) -> None: 16 | super().__init__(replies.get_reply(group="dicerobot", key="network_server_error")) 17 | 18 | 19 | class NetworkInvalidContentError(DiceRobotException): 20 | def __init__(self) -> None: 21 | super().__init__(replies.get_reply(group="dicerobot", key="network_invalid_content")) 22 | 23 | 24 | class NetworkError(DiceRobotException): 25 | def __init__(self) -> None: 26 | super().__init__(replies.get_reply(group="dicerobot", key="network_error")) 27 | 28 | 29 | class OrderInvalidError(DiceRobotException): 30 | def __init__(self) -> None: 31 | super().__init__(replies.get_reply(group="dicerobot", key="order_invalid")) 32 | 33 | 34 | class OrderSuspiciousError(DiceRobotException): 35 | def __init__(self) -> None: 36 | super().__init__(replies.get_reply(group="dicerobot", key="order_suspicious")) 37 | 38 | 39 | class OrderRepetitionExceededError(DiceRobotException): 40 | def __init__(self) -> None: 41 | super().__init__(replies.get_reply(group="dicerobot", key="order_repetition_exceeded")) 42 | 43 | 44 | class OrderError(DiceRobotException): 45 | pass 46 | 47 | 48 | class DiceRobotHTTPException(Exception): 49 | def __init__(self, status_code: int, code: int, message: str) -> None: 50 | self.status_code = status_code 51 | self.code = code 52 | self.message = message 53 | 54 | 55 | class TokenInvalidError(DiceRobotHTTPException): 56 | def __init__( 57 | self, 58 | status_code: int = 401, 59 | code: int = -1, 60 | message: str = "Invalid token" 61 | ) -> None: 62 | super().__init__(status_code, code, message) 63 | 64 | 65 | class SignatureInvalidError(DiceRobotHTTPException): 66 | def __init__( 67 | self, 68 | status_code: int = 401, 69 | code: int = -1, 70 | message: str = "Invalid signature" 71 | ) -> None: 72 | super().__init__(status_code, code, message) 73 | 74 | 75 | class MessageInvalidError(DiceRobotHTTPException): 76 | def __init__( 77 | self, 78 | status_code: int = 400, 79 | code: int = -2, 80 | message: str = "Invalid message" 81 | ) -> None: 82 | super().__init__(status_code, code, message) 83 | 84 | 85 | class ParametersInvalidError(DiceRobotHTTPException): 86 | def __init__( 87 | self, 88 | status_code: int = 400, 89 | code: int = -3, 90 | message: str = "Invalid parameters" 91 | ) -> None: 92 | super().__init__(status_code, code, message) 93 | 94 | 95 | class ResourceNotFoundError(DiceRobotHTTPException): 96 | def __init__( 97 | self, 98 | status_code: int = 404, 99 | code: int = -4, 100 | message: str = "Resource not found" 101 | ) -> None: 102 | super().__init__(status_code, code, message) 103 | 104 | 105 | class BadRequestError(DiceRobotHTTPException): 106 | def __init__( 107 | self, 108 | status_code: int = 400, 109 | code: int = -5, 110 | message: str = "Bad request" 111 | ) -> None: 112 | super().__init__(status_code, code, message) 113 | -------------------------------------------------------------------------------- /app/log.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | import sys 3 | import os 4 | from datetime import date 5 | import tarfile 6 | 7 | from loguru import logger as _logger 8 | 9 | LOG_DIR = os.path.join(os.getcwd(), "logs") 10 | TEMP_LOG_DIR = "/tmp/dicerobot-logs" 11 | MAX_LENGTH = 1000 12 | MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB 13 | 14 | 15 | def truncate_message(record: dict) -> None: 16 | if len(record["message"]) > MAX_LENGTH: 17 | record["message"] = record["message"][:MAX_LENGTH] + "..." 18 | 19 | 20 | logger = _logger.patch(truncate_message) 21 | logger.remove() 22 | 23 | 24 | def init_logger() -> None: 25 | if os.environ.get("DICEROBOT_DEBUG"): 26 | logger.add(sys.stdout, level="DEBUG") 27 | 28 | log_level = os.environ.get("DICEROBOT_LOG_LEVEL") or "INFO" 29 | logger.add( 30 | os.path.join(LOG_DIR, "dicerobot-{time:YYYY-MM-DD}.log"), 31 | level=log_level, 32 | rotation="00:00", 33 | retention="365 days", 34 | compression="tar.gz" 35 | ) 36 | 37 | logger.debug("Logger initialized") 38 | 39 | 40 | def load_logs(date_: date) -> Union[list[str], None, False]: 41 | date_ = date_.strftime("%Y-%m-%d") 42 | file = f"dicerobot-{date_}.log" 43 | log_file = os.path.join(LOG_DIR, file) 44 | compressed_file = os.path.join(LOG_DIR, f"{file}.tar.gz") 45 | temp_log_file = os.path.join(TEMP_LOG_DIR, file) 46 | 47 | if os.path.isfile(log_file): 48 | return load_log_file(log_file) 49 | elif os.path.isfile(temp_log_file): 50 | return load_log_file(temp_log_file) 51 | elif os.path.isfile(compressed_file): 52 | with tarfile.open(compressed_file, "r:gz") as tar: 53 | tar.extract(file, TEMP_LOG_DIR) 54 | 55 | return load_log_file(temp_log_file) 56 | else: 57 | return None 58 | 59 | 60 | def load_log_file(file: str) -> Union[list[str], False]: 61 | # For performance reasons, large log file will not be loaded 62 | if os.stat(file).st_size > MAX_FILE_SIZE: 63 | return False 64 | 65 | with open(file, "r", encoding="utf-8") as f: 66 | return f.readlines() 67 | -------------------------------------------------------------------------------- /app/manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import zipfile 4 | import shutil 5 | import json 6 | import uuid 7 | 8 | from .log import logger 9 | from .config import settings 10 | 11 | 12 | class QQManager: 13 | qq_dir = "/opt/QQ" 14 | qq_path = os.path.join(qq_dir, "qq") 15 | package_json_path = os.path.join(qq_dir, "resources/app/package.json") 16 | qq_config_dir = "/root/.config/QQ" 17 | 18 | def __init__(self): 19 | self.deb_file: str | None = None 20 | self.download_process: subprocess.Popen | None = None 21 | self.install_process: subprocess.Popen | None = None 22 | 23 | def is_downloading(self) -> bool: 24 | return self.download_process is not None and self.download_process.poll() is None 25 | 26 | def is_downloaded(self) -> bool: 27 | return self.deb_file is not None and os.path.isfile(self.deb_file) 28 | 29 | def is_installing(self) -> bool: 30 | return self.install_process is not None and self.install_process.poll() is None 31 | 32 | def is_installed(self) -> bool: 33 | return os.path.isfile(self.qq_path) 34 | 35 | def get_version(self) -> str | None: 36 | if not os.path.isfile(self.package_json_path): 37 | return None 38 | 39 | with open(self.package_json_path, "r", encoding="utf-8") as f: 40 | try: 41 | data = json.load(f) 42 | return data.get("version") 43 | except ValueError: 44 | return None 45 | 46 | def download(self) -> None: 47 | if self.is_downloading() or self.is_downloaded(): 48 | return 49 | 50 | logger.info("Download QQ") 51 | 52 | self.deb_file = f"/tmp/qq-{uuid.uuid4().hex}.deb" 53 | self.download_process = subprocess.Popen( 54 | f"curl -s -o {self.deb_file} https://dl.drsanwujiang.com/dicerobot/qq.deb", 55 | stdout=subprocess.DEVNULL, 56 | stderr=subprocess.DEVNULL, 57 | shell=True 58 | ) 59 | 60 | logger.info("QQ downloaded") 61 | 62 | def install(self) -> None: 63 | if self.is_installed() or self.is_installing() or not self.is_downloaded(): 64 | return 65 | 66 | logger.info("Install QQ") 67 | 68 | self.install_process = subprocess.Popen( 69 | f"apt-get install -y -qq {self.deb_file}", 70 | stdout=subprocess.DEVNULL, 71 | stderr=subprocess.DEVNULL, 72 | shell=True 73 | ) 74 | 75 | def remove(self, purge: bool = False) -> None: 76 | if not self.is_installed(): 77 | return 78 | 79 | logger.info("Remove QQ") 80 | 81 | subprocess.run("apt-get remove -y -qq linuxqq", shell=True) 82 | shutil.rmtree(self.qq_dir, ignore_errors=True) 83 | 84 | if purge: 85 | shutil.rmtree(self.qq_config_dir, ignore_errors=True) 86 | 87 | def stop(self) -> None: 88 | if self.is_downloading(): 89 | try: 90 | self.download_process.terminate() 91 | self.download_process.wait(3) 92 | except subprocess.TimeoutExpired: 93 | self.download_process.kill() 94 | 95 | if self.is_installing(): 96 | try: 97 | self.install_process.terminate() 98 | self.install_process.wait(3) 99 | except subprocess.TimeoutExpired: 100 | self.install_process.kill() 101 | 102 | self.download_process = None 103 | self.install_process = None 104 | 105 | 106 | class NapCatManager: 107 | service_path = "/etc/systemd/system/napcat.service" 108 | loader_path = os.path.join(QQManager.qq_dir, "resources/app/loadNapCat.js") 109 | napcat_dir = os.path.join(QQManager.qq_dir, "resources/app/app_launcher/napcat") 110 | log_dir = os.path.join(napcat_dir, "logs") 111 | config_dir = os.path.join(napcat_dir, "config") 112 | env_file = "env" 113 | env_path = os.path.join(napcat_dir, env_file) 114 | package_json_file = "package.json" 115 | package_json_path = os.path.join(napcat_dir, package_json_file) 116 | 117 | napcat_config = { 118 | "fileLog": True, 119 | "consoleLog": False, 120 | "fileLogLevel": "debug" if os.environ.get("DICEROBOT_DEBUG") else "warn", 121 | "consoleLogLevel": "error", 122 | "packetBackend": "auto", 123 | "packetServer": "" 124 | } 125 | onebot_config = { 126 | "network": { 127 | "httpServers": [ 128 | { 129 | "name": "httpServer", 130 | "enable": True, 131 | "host": str(settings.napcat.api.host), 132 | "port": settings.napcat.api.port, 133 | "enableCors": True, 134 | "enableWebsocket": True, 135 | "messagePostFormat": "array", 136 | "token": "", 137 | "debug": False 138 | } 139 | ], 140 | "httpClients": [ 141 | { 142 | "name": "httpClient", 143 | "enable": True, 144 | "url": "http://127.0.0.1:9500/report", 145 | "messagePostFormat": "array", 146 | "reportSelfMessage": False, 147 | "token": settings.security.webhook.secret, 148 | "debug": False 149 | } 150 | ], 151 | "websocketServers": [], 152 | "websocketClients": [] 153 | }, 154 | "musicSignUrl": "", 155 | "enableLocalFile2Url": False, 156 | "parseMultMsg": False 157 | } 158 | 159 | def __init__(self): 160 | self.zip_file: str | None = None 161 | self.download_process: subprocess.Popen | None = None 162 | 163 | def is_downloading(self) -> bool: 164 | return self.download_process is not None and self.download_process.poll() is None 165 | 166 | def is_downloaded(self) -> bool: 167 | return self.zip_file is not None and os.path.isfile(self.zip_file) 168 | 169 | def is_installed(self) -> bool: 170 | return os.path.isfile(self.package_json_path) 171 | 172 | @staticmethod 173 | def is_configured() -> bool: 174 | return settings.napcat.account >= 10000 175 | 176 | @staticmethod 177 | def is_running() -> bool: 178 | return subprocess.run("systemctl is-active --quiet napcat", shell=True).returncode == 0 179 | 180 | def get_version(self) -> str | None: 181 | if not self.is_installed(): 182 | return None 183 | 184 | with open(self.package_json_path, "r", encoding="utf-8") as f: 185 | try: 186 | data = json.load(f) 187 | return data.get("version") 188 | except ValueError: 189 | return None 190 | 191 | def download(self) -> None: 192 | if self.is_downloading() or self.is_downloaded(): 193 | return 194 | 195 | logger.info("Download NapCat") 196 | 197 | self.zip_file = f"/tmp/napcat-{uuid.uuid4().hex}.zip" 198 | self.download_process = subprocess.Popen( 199 | f"curl -s -o {self.zip_file} https://dl.drsanwujiang.com/dicerobot/napcat.zip", 200 | stdout=subprocess.DEVNULL, 201 | stderr=subprocess.DEVNULL, 202 | shell=True 203 | ) 204 | 205 | logger.info("NapCat downloaded") 206 | 207 | def install(self) -> None: 208 | if self.is_installed() or not self.is_downloaded(): 209 | return 210 | 211 | logger.info("Install NapCat") 212 | 213 | # Uncompress NapCat 214 | with zipfile.ZipFile(self.zip_file, "r") as z: 215 | z.extractall(self.napcat_dir) 216 | 217 | # Configure systemd 218 | with open(self.service_path, "w") as f: 219 | f.write(f"""[Unit] 220 | Description=NapCat service created by DiceRobot 221 | After=network.target 222 | 223 | [Service] 224 | Type=simple 225 | User=root 226 | EnvironmentFile={self.env_path} 227 | ExecStart=/usr/bin/xvfb-run -a qq --no-sandbox -q $QQ_ACCOUNT 228 | 229 | [Install] 230 | WantedBy=multi-user.target""") 231 | 232 | subprocess.run("systemctl daemon-reload", shell=True) 233 | 234 | # Patch QQ 235 | with open(self.loader_path, "w") as f: 236 | f.write(f"(async () => {{await import(\"file:///{self.napcat_dir}/napcat.mjs\");}})();") 237 | 238 | with open(QQManager.package_json_path, "r+") as f: 239 | data = json.load(f) 240 | data["main"] = "./loadNapCat.js" 241 | f.seek(0) 242 | json.dump(data, f, indent=2) 243 | f.truncate() 244 | 245 | logger.info("NapCat installed") 246 | 247 | def remove(self) -> None: 248 | if not self.is_installed() or self.is_running(): 249 | return 250 | 251 | logger.info("Remove NapCat") 252 | 253 | if os.path.isfile(self.service_path): 254 | os.remove(self.service_path) 255 | subprocess.run("systemctl daemon-reload", shell=True) 256 | 257 | shutil.rmtree(self.napcat_dir, ignore_errors=True) 258 | 259 | logger.info("NapCat removed") 260 | 261 | def start(self) -> None: 262 | if not self.is_installed() or not self.is_configured() or self.is_running(): 263 | return 264 | 265 | logger.info("Start NapCat") 266 | 267 | if os.path.isdir(self.log_dir): 268 | shutil.rmtree(self.log_dir) 269 | 270 | with open(self.env_path, "w") as f: 271 | f.write(f"QQ_ACCOUNT={settings.napcat.account}") 272 | 273 | with open(os.path.join(self.config_dir, "napcat.json"), "w") as f: 274 | json.dump(self.napcat_config, f) 275 | 276 | with open(os.path.join(self.config_dir, f"napcat_{settings.napcat.account}.json"), "w") as f: 277 | json.dump(self.napcat_config, f) 278 | 279 | self.onebot_config["network"]["httpServers"][0]["host"] = str(settings.napcat.api.host) 280 | self.onebot_config["network"]["httpServers"][0]["port"] = settings.napcat.api.port 281 | self.onebot_config["network"]["httpClients"][0]["token"] = settings.security.webhook.secret 282 | 283 | with open(os.path.join(self.config_dir, f"onebot11_{settings.napcat.account}.json"), "w") as f: 284 | json.dump(self.onebot_config, f) 285 | 286 | subprocess.run("systemctl start napcat", shell=True) 287 | 288 | logger.info("NapCat started") 289 | 290 | @classmethod 291 | def stop(cls) -> None: 292 | if not cls.is_running(): 293 | return 294 | 295 | logger.info("Stop NapCat") 296 | 297 | subprocess.run("systemctl stop napcat", shell=True) 298 | 299 | logger.info("NapCat stopped") 300 | 301 | @classmethod 302 | def get_logs(cls) -> list[str] | None: 303 | if not os.path.isdir(cls.log_dir): 304 | return None 305 | 306 | files = os.listdir(cls.log_dir) 307 | 308 | if not files: 309 | return None 310 | 311 | for file in files: 312 | path = os.path.join(cls.log_dir, file) 313 | 314 | if os.path.isfile(path): 315 | with open(path, "r", encoding="utf-8") as f: 316 | return f.readlines()[-100:] 317 | 318 | 319 | qq_manager = QQManager() 320 | napcat_manager = NapCatManager() 321 | 322 | 323 | def init_manager() -> None: 324 | if all([ 325 | settings.app.start_napcat_at_startup, 326 | qq_manager.is_installed(), 327 | napcat_manager.is_installed(), 328 | napcat_manager.is_configured(), 329 | not napcat_manager.is_running() 330 | ]): 331 | logger.info("Automatically start NapCat") 332 | 333 | napcat_manager.start() 334 | 335 | logger.info("Manager initialized") 336 | 337 | 338 | def clean_manager() -> None: 339 | logger.info("Clean manager") 340 | 341 | qq_manager.stop() 342 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydantic import BaseModel as _BaseModel 4 | 5 | 6 | class BaseModel(_BaseModel): 7 | def model_dump(self, **kwargs) -> dict[str, Any]: 8 | return super().model_dump(serialize_as_any=True, **kwargs) 9 | 10 | def model_dump_json(self, **kwargs) -> str: 11 | return super().model_dump_json(serialize_as_any=True, **kwargs) 12 | -------------------------------------------------------------------------------- /app/models/config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | import secrets 3 | from ipaddress import IPv4Address 4 | from copy import deepcopy 5 | 6 | from pydantic import Field, field_serializer 7 | from werkzeug.security import generate_password_hash 8 | 9 | from ..version import VERSION 10 | from ..enum import ApplicationStatus, ChatType 11 | from ..utils import deep_update 12 | from . import BaseModel 13 | 14 | __all__ = [ 15 | "Status", 16 | "Settings", 17 | "PluginSettings", 18 | "ChatSettings", 19 | "Replies" 20 | ] 21 | 22 | 23 | class Status(BaseModel): 24 | """DiceRobot status. 25 | 26 | Attributes: 27 | debug: Whether debug mode is enabled. 28 | version: Version of DiceRobot. 29 | app: Application status. 30 | module: Module status. 31 | plugins: Loaded plugin list. 32 | bot: Bot information. 33 | """ 34 | 35 | class Module(BaseModel): 36 | """DiceRobot module status. 37 | 38 | Attributes: 39 | order: Whether order module (all order plugins) is enabled. 40 | event: Whether event module (all event plugins) is enabled. 41 | """ 42 | 43 | order: bool = True 44 | event: bool = True 45 | 46 | class Plugin(BaseModel): 47 | """Plugin information. 48 | 49 | Attributes: 50 | display_name: Display name. 51 | description: Description. 52 | version: Version. 53 | """ 54 | 55 | display_name: str 56 | description: str 57 | version: str 58 | 59 | class Bot(BaseModel): 60 | """Bot information. 61 | 62 | Attributes: 63 | id: Bot ID. 64 | nickname: Bot nickname. 65 | friends: Friend list. 66 | groups: Group list. 67 | """ 68 | 69 | id: int = -1 70 | nickname: str = "" 71 | friends: list[int] = [] 72 | groups: list[int] = [] 73 | 74 | debug: bool = Field(False, exclude=True) 75 | version: str = VERSION 76 | app: ApplicationStatus = ApplicationStatus.STARTED 77 | module: Module = Module() 78 | plugins: dict[str, Plugin] = Field({}, exclude=True) 79 | bot: Bot = Bot() 80 | 81 | 82 | class Settings: 83 | """DiceRobot settings. 84 | 85 | This class uses an inner class to store actual settings, so that the settings can be updated by `update` method. 86 | """ 87 | 88 | class _Settings(BaseModel): 89 | """Actual DiceRobot settings class. 90 | 91 | Attributes: 92 | security: Security settings. 93 | app: Application settings. 94 | napcat: NapCat settings. 95 | """ 96 | 97 | class Security(BaseModel): 98 | """Security settings. 99 | 100 | Attributes: 101 | webhook: Webhook settings. 102 | jwt: JWT settings. 103 | admin: Administration settings. 104 | """ 105 | 106 | class Webhook(BaseModel): 107 | """Webhook settings. 108 | 109 | Attributes: 110 | secret: Webhook secret. 111 | """ 112 | 113 | secret: str = secrets.token_urlsafe(32) 114 | 115 | class JWT(BaseModel): 116 | """JWT settings. 117 | 118 | Attributes: 119 | secret: JWT secret. 120 | algorithm: JWT algorithm. 121 | """ 122 | 123 | secret: str = secrets.token_urlsafe(32) 124 | algorithm: str = "HS256" 125 | 126 | class Admin(BaseModel): 127 | """Administration settings. 128 | 129 | Attributes: 130 | password_hash: Administrator password hash. 131 | """ 132 | 133 | password_hash: str = "" 134 | 135 | webhook: Webhook = Webhook() 136 | jwt: JWT = JWT() 137 | admin: Admin = Admin() 138 | 139 | class Application(BaseModel): 140 | """Application settings. 141 | 142 | Attributes: 143 | start_napcat_at_startup: Whether to start NapCat at startup. 144 | """ 145 | 146 | start_napcat_at_startup: bool = False 147 | 148 | class NapCat(BaseModel): 149 | """NapCat settings. 150 | 151 | Attributes: 152 | api: NapCat API settings. 153 | account: QQ account. 154 | """ 155 | 156 | class API(BaseModel): 157 | """NapCat API settings. 158 | 159 | Attributes: 160 | host: OneBot HTTP host. 161 | port: OneBot HTTP port. 162 | """ 163 | 164 | host: IPv4Address = IPv4Address("127.0.0.1") 165 | port: int = Field(13579, gt=0) 166 | 167 | @field_serializer("host") 168 | def serialize_host(self, host: IPv4Address, _) -> str: 169 | return str(host) 170 | 171 | @property 172 | def base_url(self) -> str: 173 | return f"http://{self.host}:{self.port}" 174 | 175 | api: API = API() 176 | account: int = -1 177 | 178 | security: Security = Security() 179 | app: Application = Application() 180 | napcat: NapCat = NapCat() 181 | 182 | _settings: _Settings = _Settings() 183 | 184 | @classmethod 185 | def update(cls, settings: dict) -> None: 186 | """Update all the settings. 187 | 188 | Args: 189 | settings: New settings. 190 | """ 191 | 192 | cls._settings = cls._Settings.model_validate(settings) 193 | 194 | @classmethod 195 | def update_security(cls, settings: dict) -> None: 196 | """Update security settings. 197 | 198 | Args: 199 | settings: New security settings. 200 | """ 201 | 202 | security_settings = cls._settings.security.model_dump() 203 | 204 | if "webhook" in settings: 205 | security_settings["webhook"] = deep_update(security_settings["webhook"], settings["webhook"]) 206 | if "jwt" in settings: 207 | security_settings["jwt"] = deep_update(security_settings["jwt"], settings["jwt"]) 208 | if "admin" in settings: 209 | security_settings["admin"]["password_hash"] = generate_password_hash(settings["admin"]["password"]) 210 | 211 | cls._settings.security = cls._Settings.Security.model_validate(security_settings) 212 | 213 | @classmethod 214 | def update_application(cls, settings: dict) -> None: 215 | """Update application settings. 216 | 217 | Args: 218 | settings: New application settings. 219 | """ 220 | 221 | cls._settings.app = cls._Settings.Application.model_validate( 222 | deep_update(cls._settings.app.model_dump(), settings) 223 | ) 224 | 225 | @classmethod 226 | def update_napcat(cls, settings: dict) -> None: 227 | """Update NapCat settings. 228 | 229 | Args: 230 | settings: New NapCat settings. 231 | """ 232 | 233 | cls._settings.napcat = cls._Settings.NapCat.model_validate( 234 | deep_update(cls._settings.napcat.model_dump(), settings) 235 | ) 236 | 237 | @classmethod 238 | def model_dump(cls, safe_dump: bool = True, **kwargs) -> dict: 239 | data = cls._settings.model_dump(**kwargs) 240 | 241 | if safe_dump: 242 | del data["security"] # For security, sensitive data should be excluded 243 | 244 | return data 245 | 246 | def __getattr__(self, item) -> Any: 247 | """Get attribute from inner actual settings class.""" 248 | 249 | return getattr(self._settings, item) 250 | 251 | 252 | class PluginSettings: 253 | """DiceRobot plugin settings.""" 254 | 255 | _plugin_settings: dict[str, dict] = {} 256 | 257 | @classmethod 258 | def get(cls, *, plugin: str) -> dict: 259 | """Get settings of a plugin. 260 | 261 | Args: 262 | plugin: Plugin name. 263 | 264 | Returns: 265 | A deep copy of the settings of the plugin, for preventing modification. 266 | """ 267 | 268 | return deepcopy(cls._plugin_settings.setdefault(plugin, {})) 269 | 270 | @classmethod 271 | def set(cls, *, plugin: str, settings: dict) -> None: 272 | """Set settings of a plugin. 273 | 274 | Args: 275 | plugin: Plugin name. 276 | settings: Settings to be set. 277 | """ 278 | 279 | if plugin in cls._plugin_settings: 280 | cls._plugin_settings[plugin] |= deepcopy(settings) 281 | else: 282 | cls._plugin_settings[plugin] = deepcopy(settings) 283 | 284 | @classmethod 285 | def dict(cls) -> dict: 286 | """Get all plugin settings. 287 | 288 | Returns: 289 | A deep copy of all plugin settings. 290 | """ 291 | 292 | return deepcopy(cls._plugin_settings) 293 | 294 | 295 | class ChatSettings: 296 | """DiceRobot chat settings.""" 297 | 298 | _chat_settings: dict[ChatType, dict[int, dict[str, dict]]] = { 299 | ChatType.FRIEND: {}, 300 | ChatType.GROUP: {}, 301 | ChatType.TEMP: {} 302 | } 303 | 304 | @classmethod 305 | def get(cls, *, chat_type: ChatType, chat_id: int, setting_group: str) -> dict: 306 | """Get settings of a chat. 307 | 308 | Args: 309 | chat_type: Chat type. 310 | chat_id: Chat ID. 311 | setting_group: Setting group. 312 | 313 | Returns: 314 | Settings of the chat. 315 | """ 316 | 317 | return cls._chat_settings[chat_type].setdefault(chat_id, {}).setdefault(setting_group, {}) 318 | 319 | @classmethod 320 | def set(cls, *, chat_type: ChatType, chat_id: int, setting_group: str, settings: dict) -> None: 321 | """Set settings of a chat. 322 | 323 | Args: 324 | chat_type: Chat type. 325 | chat_id: Chat ID. 326 | setting_group: Setting group. 327 | settings: Settings to be set. 328 | """ 329 | 330 | if setting_group in cls._chat_settings[chat_type].setdefault(chat_id, {}): 331 | cls._chat_settings[chat_type][chat_id][setting_group] |= deepcopy(settings) 332 | else: 333 | cls._chat_settings[chat_type][chat_id][setting_group] = deepcopy(settings) 334 | 335 | @classmethod 336 | def dict(cls) -> dict: 337 | """Get all chat settings. 338 | 339 | Returns: 340 | A deep copy of all chat settings. 341 | """ 342 | 343 | return deepcopy(cls._chat_settings) 344 | 345 | 346 | class Replies: 347 | """DiceRobot plugin replies.""" 348 | 349 | _replies: dict[str, dict[str, str]] = { 350 | "dicerobot": { 351 | "network_client_error": "致远星拒绝了我们的请求……请稍后再试", 352 | "network_server_error": "糟糕,致远星出错了……请稍后再试", 353 | "network_invalid_content": "致远星返回了无法解析的内容……请稍后再试", 354 | "network_error": "无法连接到致远星,请检查星际通讯是否正常", 355 | "order_invalid": "不太理解这个指令呢……", 356 | "order_suspicious": "唔……这个指令有点问题……", 357 | "order_repetition_exceeded": "这条指令不可以执行这么多次哦~", 358 | } 359 | } 360 | 361 | @classmethod 362 | def get_replies(cls, *, group: str) -> dict: 363 | """Get replies of a group. 364 | 365 | Args: 366 | group: Reply group, usually the name of the plugin. 367 | 368 | Returns: 369 | A deep copy of the replies of the reply group, for preventing modification. 370 | """ 371 | 372 | return deepcopy(cls._replies.setdefault(group, {})) 373 | 374 | @classmethod 375 | def get_reply(cls, *, group: str, key: str) -> str: 376 | """Get a reply of a group. 377 | 378 | Args: 379 | group: Reply group, usually the name of the plugin. 380 | key: Reply key. 381 | 382 | Returns: 383 | The reply. 384 | """ 385 | 386 | return cls._replies[group][key] 387 | 388 | @classmethod 389 | def set_replies(cls, *, group: str, replies: dict) -> None: 390 | """Set replies of a group. 391 | 392 | Args: 393 | group: Reply group, usually the name of the plugin. 394 | replies: Replies to be set. 395 | """ 396 | 397 | if group in cls._replies: 398 | cls._replies[group] |= deepcopy(replies) 399 | else: 400 | cls._replies[group] = deepcopy(replies) 401 | 402 | @classmethod 403 | def dict(cls) -> dict: 404 | """Get all plugin replies. 405 | 406 | Returns: 407 | A deep copy of all plugin replies. 408 | """ 409 | 410 | return deepcopy(cls._replies) 411 | -------------------------------------------------------------------------------- /app/models/network/dicerobot.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drsanwujiang/DiceRobot/2a208e1d80c80f6a05f63175dc77482e42257077/app/models/network/dicerobot.py -------------------------------------------------------------------------------- /app/models/network/napcat.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ...enum import Sex, Role 4 | from .. import BaseModel 5 | 6 | __all__ = [ 7 | "GetLoginInfoResponse", 8 | "GetFriendListResponse", 9 | "GetGroupInfoResponse", 10 | "GetGroupListResponse", 11 | "GetGroupMemberInfoResponse", 12 | "GetGroupMemberListResponse", 13 | "GetImageResponse", 14 | "SendPrivateMessageResponse", 15 | "SendGroupMessageResponse", 16 | "SetGroupCardResponse", 17 | "SetGroupLeaveResponse", 18 | "SetFriendAddRequestResponse", 19 | "SetGroupAddRequestResponse" 20 | ] 21 | 22 | 23 | class NapCatResponse(BaseModel): 24 | status: str 25 | retcode: int 26 | data: Any 27 | message: str 28 | wording: str 29 | 30 | 31 | class LoginInfo(BaseModel): 32 | user_id: int 33 | nickname: str 34 | 35 | 36 | class UserInfo(BaseModel): 37 | user_id: int 38 | nickname: str 39 | remark: str 40 | sex: Sex 41 | level: int 42 | 43 | 44 | class GroupInfo(BaseModel): 45 | group_id: int 46 | group_name: str 47 | member_count: int 48 | max_member_count: int 49 | 50 | 51 | class SendMessageData(BaseModel): 52 | message_id: int 53 | 54 | 55 | class GroupMemberInfo(BaseModel): 56 | group_id: int 57 | user_id: int 58 | nickname: str 59 | card: str 60 | sex: Sex 61 | age: int 62 | area: str 63 | level: str 64 | qq_level: int 65 | join_time: int 66 | last_sent_time: int 67 | title_expire_time: int 68 | unfriendly: bool 69 | card_changeable: bool 70 | is_robot: bool 71 | shut_up_timestamp: int 72 | role: Role 73 | title: str 74 | 75 | 76 | class GetLoginInfoResponse(NapCatResponse): 77 | data: LoginInfo 78 | 79 | 80 | class GetFriendListResponse(NapCatResponse): 81 | data: list[UserInfo] 82 | 83 | 84 | class GetGroupInfoResponse(NapCatResponse): 85 | data: GroupInfo 86 | 87 | 88 | class GetGroupListResponse(NapCatResponse): 89 | data: list[GroupInfo] 90 | 91 | 92 | class GetGroupMemberInfoResponse(NapCatResponse): 93 | data: GroupMemberInfo 94 | 95 | 96 | class GetGroupMemberListResponse(NapCatResponse): 97 | data: list[GroupMemberInfo] 98 | 99 | 100 | class GetImageResponse(NapCatResponse): 101 | class Data(BaseModel): 102 | file: str 103 | 104 | data: Data 105 | 106 | 107 | class SendPrivateMessageResponse(NapCatResponse): 108 | data: SendMessageData 109 | 110 | 111 | class SendGroupMessageResponse(NapCatResponse): 112 | data: SendMessageData 113 | 114 | 115 | class SetGroupCardResponse(NapCatResponse): 116 | data: None = None 117 | 118 | 119 | class SetGroupLeaveResponse(NapCatResponse): 120 | data: None = None 121 | 122 | 123 | class SetFriendAddRequestResponse(NapCatResponse): 124 | data: None = None 125 | 126 | 127 | class SetGroupAddRequestResponse(NapCatResponse): 128 | data: None = None 129 | -------------------------------------------------------------------------------- /app/models/panel/admin.py: -------------------------------------------------------------------------------- 1 | from ...models import BaseModel 2 | 3 | __all__ = [ 4 | "AuthRequest", 5 | "SetModuleStatusRequest", 6 | "UpdateSecuritySettingsRequest", 7 | "UpdateApplicationSettingsRequest" 8 | ] 9 | 10 | 11 | class AuthRequest(BaseModel): 12 | password: str 13 | 14 | 15 | class SetModuleStatusRequest(BaseModel): 16 | order: bool 17 | event: bool 18 | 19 | 20 | class UpdateSecuritySettingsRequest(BaseModel): 21 | class Webhook(BaseModel): 22 | secret: str 23 | 24 | class JWT(BaseModel): 25 | secret: str 26 | algorithm: str 27 | 28 | class Admin(BaseModel): 29 | password: str 30 | 31 | webhook: Webhook = None 32 | jwt: JWT = None 33 | admin: Admin = None 34 | 35 | 36 | class UpdateApplicationSettingsRequest(BaseModel): 37 | start_napcat_at_startup: bool 38 | -------------------------------------------------------------------------------- /app/models/panel/napcat.py: -------------------------------------------------------------------------------- 1 | from ipaddress import IPv4Address 2 | 3 | from pydantic import Field 4 | 5 | from ...models import BaseModel 6 | 7 | __all__ = [ 8 | "UpdateNapCatSettingsRequest" 9 | ] 10 | 11 | 12 | class UpdateNapCatSettingsRequest(BaseModel): 13 | class API(BaseModel): 14 | host: IPv4Address = None 15 | port: int = Field(None, gt=0) 16 | 17 | api: API = None 18 | account: int = Field(None, gt=10000) 19 | -------------------------------------------------------------------------------- /app/models/panel/qq.py: -------------------------------------------------------------------------------- 1 | from ...models import BaseModel 2 | 3 | __all__ = [ 4 | "RemoveQQRequest" 5 | ] 6 | 7 | 8 | class RemoveQQRequest(BaseModel): 9 | purge: bool 10 | -------------------------------------------------------------------------------- /app/models/report/__init__.py: -------------------------------------------------------------------------------- 1 | from ...enum import ReportType 2 | from .. import BaseModel 3 | 4 | 5 | class Report(BaseModel): 6 | time: int 7 | self_id: int 8 | post_type: ReportType 9 | -------------------------------------------------------------------------------- /app/models/report/message.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from pydantic import field_validator 4 | 5 | from ...enum import ReportType, MessageType, PrivateMessageSubType, GroupMessageSubType, SegmentType, Sex, Role 6 | from .. import BaseModel 7 | from . import Report 8 | from .segment import Segment, Text, Image, At 9 | 10 | __all__ = [ 11 | "Message", 12 | "PrivateMessage", 13 | "GroupMessage" 14 | ] 15 | 16 | 17 | class Message(Report): 18 | post_type: Literal[ReportType.MESSAGE] = ReportType.MESSAGE 19 | message_type: MessageType 20 | message_id: int 21 | user_id: int 22 | message: list[Segment] 23 | raw_message: str 24 | font: int 25 | sender: Any 26 | 27 | @field_validator("message", mode="before") 28 | def validate_message(cls, segments: list[dict]) -> list[Segment]: 29 | parsed_segments: list[Segment] = [] 30 | 31 | for segment in segments: 32 | if "type" not in segment: 33 | raise ValueError 34 | 35 | match segment["type"]: 36 | case SegmentType.TEXT.value: 37 | parsed_segments.append(Text.model_validate(segment)) 38 | case SegmentType.IMAGE.value: 39 | parsed_segments.append(Image.model_validate(segment)) 40 | case SegmentType.AT.value: 41 | parsed_segments.append(At.model_validate(segment)) 42 | case _: 43 | raise ValueError 44 | 45 | return parsed_segments 46 | 47 | 48 | class PrivateMessage(Message): 49 | class Sender(BaseModel): 50 | user_id: int 51 | nickname: str 52 | sex: Sex = None 53 | age: int = None 54 | card: str = None 55 | 56 | message_type: Literal[MessageType.PRIVATE] = MessageType.PRIVATE 57 | sub_type: PrivateMessageSubType 58 | sender: Sender 59 | 60 | 61 | class GroupMessage(Message): 62 | class Anonymous(BaseModel): 63 | id: int 64 | name: str 65 | flag: str 66 | 67 | class Sender(BaseModel): 68 | user_id: int 69 | nickname: str 70 | card: str = None 71 | sex: Sex = None 72 | age: int = None 73 | area: str = None 74 | level: str = None 75 | role: Role = None # In rare cases, the sender of a group message may not have role field 76 | title: str = None 77 | 78 | message_type: Literal[MessageType.GROUP] = MessageType.GROUP 79 | sub_type: GroupMessageSubType 80 | group_id: int 81 | anonymous: Anonymous = None 82 | sender: Sender 83 | -------------------------------------------------------------------------------- /app/models/report/meta_event.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from ...enum import ReportType, MetaEventType, LifecycleMetaEventSubType 4 | from . import Report 5 | 6 | __all__ = [ 7 | "MetaEvent", 8 | "LifecycleMetaEvent", 9 | "HeartbeatMetaEvent" 10 | ] 11 | 12 | 13 | class MetaEvent(Report): 14 | post_type: Literal[ReportType.META_EVENT] = ReportType.META_EVENT 15 | meta_event_type: MetaEventType 16 | 17 | 18 | class LifecycleMetaEvent(Report): 19 | meta_event_type: Literal[MetaEventType.LIFECYCLE] = MetaEventType.LIFECYCLE 20 | sub_type: LifecycleMetaEventSubType 21 | 22 | 23 | class HeartbeatMetaEvent(Report): 24 | meta_event_type: Literal[MetaEventType.HEARTBEAT] = MetaEventType.HEARTBEAT 25 | status: Any 26 | interval: int 27 | -------------------------------------------------------------------------------- /app/models/report/notice.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from ...enum import ( 4 | ReportType, NoticeType, GroupAdminNoticeSubType, GroupDecreaseNoticeSubType, GroupIncreaseNoticeSubType, 5 | GroupBanNoticeSubType, NotifySubType 6 | ) 7 | from . import Report 8 | 9 | __all__ = [ 10 | "Notice", 11 | "GroupUploadNotice", 12 | "GroupAdminNotice", 13 | "GroupDecreaseNotice", 14 | "GroupIncreaseNotice", 15 | "GroupBanNotice", 16 | "FriendAddNotice", 17 | "GroupRecallNotice", 18 | "FriendRecallNotice", 19 | "Notify" 20 | ] 21 | 22 | 23 | class Notice(Report): 24 | post_type: Literal[ReportType.NOTICE] = ReportType.NOTICE 25 | notice_type: NoticeType 26 | user_id: int 27 | 28 | 29 | class GroupUploadNotice(Notice): 30 | notice_type: Literal[NoticeType.GROUP_UPLOAD] = NoticeType.GROUP_UPLOAD 31 | group_id: int 32 | 33 | 34 | class GroupAdminNotice(Notice): 35 | notice_type: Literal[NoticeType.GROUP_ADMIN] = NoticeType.GROUP_ADMIN 36 | sub_type: GroupAdminNoticeSubType 37 | group_id: int 38 | 39 | 40 | class GroupDecreaseNotice(Notice): 41 | notice_type: Literal[NoticeType.GROUP_DECREASE] = NoticeType.GROUP_DECREASE 42 | sub_type: GroupDecreaseNoticeSubType 43 | group_id: int 44 | operator_id: int 45 | 46 | 47 | class GroupIncreaseNotice(Notice): 48 | notice_type: Literal[NoticeType.GROUP_INCREASE] = NoticeType.GROUP_INCREASE 49 | sub_type: GroupIncreaseNoticeSubType 50 | group_id: int 51 | operator_id: int 52 | 53 | 54 | class GroupBanNotice(Notice): 55 | notice_type: Literal[NoticeType.GROUP_BAN] = NoticeType.GROUP_BAN 56 | sub_type: GroupBanNoticeSubType 57 | group_id: int 58 | operator_id: int 59 | duration: int 60 | 61 | 62 | class FriendAddNotice(Notice): 63 | notice_type: Literal[NoticeType.FRIEND_ADD] = NoticeType.FRIEND_ADD 64 | 65 | 66 | class GroupRecallNotice(Notice): 67 | notice_type: Literal[NoticeType.GROUP_RECALL] = NoticeType.GROUP_RECALL 68 | group_id: int 69 | operator_id: int 70 | message_id: int 71 | 72 | 73 | class FriendRecallNotice(Notice): 74 | notice_type: Literal[NoticeType.FRIEND_RECALL] = NoticeType.FRIEND_RECALL 75 | message_id: int 76 | 77 | 78 | class Notify(Notice): 79 | notice_type: Literal[NoticeType.NOTIFY] = NoticeType.NOTIFY 80 | sub_type: NotifySubType 81 | group_id: int 82 | -------------------------------------------------------------------------------- /app/models/report/request.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from ...enum import ReportType, RequestType, GroupAddRequestSubType 4 | from . import Report 5 | 6 | __all__ = [ 7 | "Request", 8 | "FriendAddRequest", 9 | "GroupAddRequest" 10 | ] 11 | 12 | 13 | class Request(Report): 14 | post_type: Literal[ReportType.REQUEST] = ReportType.REQUEST 15 | request_type: RequestType 16 | user_id: int 17 | comment: str 18 | flag: str 19 | 20 | 21 | class FriendAddRequest(Request): 22 | request_type: Literal[RequestType.FRIEND] = RequestType.FRIEND 23 | 24 | 25 | class GroupAddRequest(Request): 26 | request_type: Literal[RequestType.GROUP] = RequestType.GROUP 27 | sub_type: GroupAddRequestSubType 28 | group_id: int 29 | -------------------------------------------------------------------------------- /app/models/report/segment.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from ...enum import SegmentType 4 | from ...models import BaseModel 5 | 6 | __all__ = [ 7 | "Segment", 8 | "Text", 9 | "Image", 10 | "At" 11 | ] 12 | 13 | 14 | class Segment(BaseModel): 15 | type: SegmentType 16 | data: Any 17 | 18 | def model_dump(self, **kwargs) -> dict[str, Any]: 19 | return super().model_dump(exclude_none=True, **kwargs) 20 | 21 | def model_dump_json(self, **kwargs) -> str: 22 | return super().model_dump_json(exclude_none=True, **kwargs) 23 | 24 | 25 | class Text(Segment): 26 | class Data(BaseModel): 27 | text: str 28 | 29 | type: Literal[SegmentType.TEXT] = SegmentType.TEXT 30 | data: Data 31 | 32 | 33 | class Image(Segment): 34 | class Data(BaseModel): 35 | file: str 36 | type: str = None 37 | url: str = None 38 | 39 | type: Literal[SegmentType.IMAGE] = SegmentType.IMAGE 40 | data: Data 41 | 42 | 43 | class At(Segment): 44 | class Data(BaseModel): 45 | qq: int 46 | 47 | type: Literal[SegmentType.AT] = SegmentType.AT 48 | data: Data 49 | -------------------------------------------------------------------------------- /app/network/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import httpx 4 | 5 | from ..version import VERSION 6 | from ..log import logger 7 | from ..exceptions import NetworkServerError, NetworkClientError, NetworkInvalidContentError, NetworkError 8 | 9 | 10 | class Client(httpx.Client): 11 | @staticmethod 12 | def log_request(request: httpx.Request): 13 | request.read() 14 | 15 | logger.debug(f"Request: {request.method} {request.url}, content: {request.content.decode()}") 16 | 17 | @staticmethod 18 | def log_response(response: httpx.Response): 19 | # Check JSON content 20 | if "application/json" in response.headers.get("content-type", ""): 21 | response.read() 22 | 23 | try: 24 | result = response.json() 25 | except json.JSONDecodeError: 26 | logger.error(f"Failed to request {response.request.url}, invalid content returned") 27 | raise NetworkInvalidContentError 28 | 29 | logger.debug(f"Response: {response.request.method} {response.request.url}, content: {result}") 30 | 31 | if ("code" in result and result["code"] != 0 and result["code"] != 200) or \ 32 | ("retcode" in result and result["retcode"] != 0): 33 | error_code = result["code"] 34 | error_message = result["msg"] if "msg" in result else result["message"] if "message" in result else None 35 | 36 | if error_message: 37 | logger.error(f"API returned unexpected code {error_code}, error message: {error_message}") 38 | 39 | # Check HTTP status code 40 | if response.status_code >= 500: 41 | logger.error(f"Failed to request {response.request.url}, HTTP status code {response.status_code} returned") 42 | raise NetworkServerError 43 | elif response.status_code >= 400: 44 | logger.error(f"Failed to request {response.request.url}, HTTP status code {response.status_code} returned") 45 | raise NetworkClientError 46 | 47 | _defaults = { 48 | "headers": { 49 | "Accept": "application/json", 50 | "User-Agent": f"DiceRobot/{VERSION}" 51 | }, 52 | "timeout": 30, # For some file uploading 53 | "event_hooks": { 54 | "request": [log_request], 55 | "response": [log_response] 56 | } 57 | } 58 | 59 | def __init__(self, *args, **kwargs): 60 | kwargs = Client._defaults | kwargs 61 | super().__init__(*args, **kwargs) 62 | 63 | def request(self, *args, **kwargs) -> httpx.Response: 64 | try: 65 | return super().request(*args, **kwargs) 66 | except httpx.HTTPError as e: 67 | logger.error(f"Failed to request {e.request.url}, {e.__class__.__name__} occurred") 68 | raise NetworkError 69 | 70 | 71 | client = Client() 72 | -------------------------------------------------------------------------------- /app/network/dicerobot.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drsanwujiang/DiceRobot/2a208e1d80c80f6a05f63175dc77482e42257077/app/network/dicerobot.py -------------------------------------------------------------------------------- /app/network/napcat.py: -------------------------------------------------------------------------------- 1 | from ..models.network.napcat import ( 2 | GetLoginInfoResponse, GetFriendListResponse, GetGroupInfoResponse, GetGroupListResponse, GetGroupMemberInfoResponse, 3 | GetGroupMemberListResponse, GetImageResponse, SendPrivateMessageResponse, SendGroupMessageResponse, 4 | SetGroupCardResponse, SetGroupLeaveResponse, SetFriendAddRequestResponse, SetGroupAddRequestResponse 5 | ) 6 | from ..config import settings 7 | from ..enum import GroupAddRequestSubType 8 | from ..models.report.segment import Segment 9 | from . import client 10 | 11 | __all__ = [ 12 | "get_login_info", 13 | "get_friend_list", 14 | "get_group_info", 15 | "get_group_list", 16 | "get_group_member_info", 17 | "get_group_member_list", 18 | "get_image", 19 | "send_private_message", 20 | "send_group_message", 21 | "set_group_card", 22 | "set_group_leave", 23 | "set_friend_add_request", 24 | "set_group_add_request" 25 | ] 26 | 27 | 28 | def get_login_info() -> GetLoginInfoResponse: 29 | return GetLoginInfoResponse.model_validate(client.get( 30 | settings.napcat.api.base_url + "/get_login_info" 31 | ).json()) 32 | 33 | 34 | def get_friend_list() -> GetFriendListResponse: 35 | return GetFriendListResponse.model_validate(client.get( 36 | settings.napcat.api.base_url + "/get_friend_list" 37 | ).json()) 38 | 39 | 40 | def get_group_info(group_id: int, no_cache: bool = False) -> GetGroupInfoResponse: 41 | return GetGroupInfoResponse.model_validate(client.get( 42 | settings.napcat.api.base_url + "/get_group_info", 43 | params={ 44 | "group_id": group_id, 45 | "no_cache": no_cache 46 | } 47 | ).json()) 48 | 49 | 50 | def get_group_list() -> GetGroupListResponse: 51 | return GetGroupListResponse.model_validate(client.get( 52 | settings.napcat.api.base_url + "/get_group_list" 53 | ).json()) 54 | 55 | 56 | def get_group_member_info(group_id: int, user_id: int, no_cache: bool = False) -> GetGroupMemberInfoResponse: 57 | return GetGroupMemberInfoResponse.model_validate(client.get( 58 | settings.napcat.api.base_url + "/get_group_member_info", 59 | params={ 60 | "group_id": group_id, 61 | "user_id": user_id, 62 | "no_cache": no_cache 63 | } 64 | ).json()) 65 | 66 | 67 | def get_group_member_list(group_id: int) -> GetGroupMemberListResponse: 68 | return GetGroupMemberListResponse.model_validate(client.get( 69 | settings.napcat.api.base_url + "/get_group_member_list", 70 | params={ 71 | "group_id": group_id 72 | } 73 | ).json()) 74 | 75 | 76 | def get_image(file: str) -> GetImageResponse: 77 | return GetImageResponse.model_validate(client.get( 78 | settings.napcat.api.base_url + "/get_image", 79 | params={ 80 | "file": file 81 | } 82 | ).json()) 83 | 84 | 85 | def send_private_message( 86 | user_id: int, 87 | message: list[Segment], 88 | auto_escape: bool = False 89 | ) -> SendPrivateMessageResponse: 90 | return SendPrivateMessageResponse.model_validate(client.post( 91 | settings.napcat.api.base_url + "/send_private_msg", 92 | json={ 93 | "user_id": user_id, 94 | "message": [segment.model_dump() for segment in message], 95 | "auto_escape": auto_escape 96 | } 97 | ).json()) 98 | 99 | 100 | def send_group_message( 101 | group_id: int, 102 | message: list[Segment], 103 | auto_escape: bool = False 104 | ) -> SendGroupMessageResponse: 105 | return SendGroupMessageResponse.model_validate(client.post( 106 | settings.napcat.api.base_url + "/send_group_msg", 107 | json={ 108 | "group_id": group_id, 109 | "message": [segment.model_dump() for segment in message], 110 | "auto_escape": auto_escape 111 | } 112 | ).json()) 113 | 114 | 115 | def set_group_card( 116 | group_id: int, 117 | user_id: int, 118 | card: str = "" 119 | ) -> SetGroupCardResponse: 120 | return SetGroupCardResponse.model_validate(client.post( 121 | settings.napcat.api.base_url + "/set_group_card", 122 | json={ 123 | "group_id": group_id, 124 | "user_id": user_id, 125 | "card": card 126 | } 127 | ).json()) 128 | 129 | 130 | def set_group_leave( 131 | group_id: int, 132 | is_dismiss: bool = False 133 | ) -> SetGroupLeaveResponse: 134 | return SetGroupLeaveResponse.model_validate(client.post( 135 | settings.napcat.api.base_url + "/set_group_leave", 136 | json={ 137 | "group_id": group_id, 138 | "is_dismiss": is_dismiss 139 | } 140 | ).json()) 141 | 142 | 143 | def set_friend_add_request( 144 | flag: str, 145 | approve: bool, 146 | remark: str = "" 147 | ) -> SetFriendAddRequestResponse: 148 | return SetFriendAddRequestResponse.model_validate(client.post( 149 | settings.napcat.api.base_url + "/set_friend_add_request", 150 | json={ 151 | "flag": flag, 152 | "approve": approve, 153 | "remark": remark 154 | } 155 | ).json()) 156 | 157 | 158 | def set_group_add_request( 159 | flag: str, 160 | sub_type: GroupAddRequestSubType, 161 | approve: bool, 162 | reason: str = "" 163 | ) -> SetGroupAddRequestResponse: 164 | return SetGroupAddRequestResponse.model_validate(client.post( 165 | settings.napcat.api.base_url + "/set_group_add_request", 166 | json={ 167 | "flag": flag, 168 | "sub_type": sub_type.value, 169 | "approve": approve, 170 | "reason": reason 171 | } 172 | ).json()) 173 | -------------------------------------------------------------------------------- /app/router/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.responses import Response as _Response, JSONResponse as _JSONResponse 3 | 4 | from ..version import VERSION 5 | 6 | 7 | class EmptyResponse(_Response): 8 | def __init__(self, status_code: int = 204): 9 | super().__init__(status_code=status_code) 10 | 11 | 12 | class JSONResponse(_JSONResponse): 13 | headers = { 14 | "Access-Control-Allow-Origin": "*", 15 | "Access-Control-Allow-Methods": "GET, POST, PATCH, PUT, DELETE, OPTIONS", 16 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 17 | "Server": f"DiceRobot/{VERSION}" 18 | } 19 | 20 | def __init__( 21 | self, 22 | status_code: int = 200, 23 | code: int = 0, 24 | message: str = "Success", 25 | data: dict | list = None 26 | ) -> None: 27 | content = { 28 | "code": code, 29 | "message": message 30 | } 31 | 32 | if data is not None: 33 | content["data"] = data 34 | 35 | super().__init__(status_code=status_code, content=content) 36 | 37 | 38 | def init_router(app: FastAPI) -> None: 39 | from .webhook import router as webhook 40 | from .admin import router as admin 41 | from .qq import router as qq 42 | from .napcat import router as napcat 43 | 44 | app.include_router(webhook) 45 | app.include_router(admin) 46 | app.include_router(qq) 47 | app.include_router(napcat) 48 | -------------------------------------------------------------------------------- /app/router/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from datetime import date, datetime, timedelta 3 | import signal 4 | 5 | from fastapi import APIRouter, Depends, Query 6 | 7 | from ..log import logger, load_logs 8 | from ..auth import verify_password, generate_jwt_token, verify_jwt_token 9 | from ..config import status, replies, settings, plugin_settings, chat_settings 10 | from ..dispatch import dispatcher 11 | from ..schedule import scheduler 12 | from ..exceptions import ParametersInvalidError, ResourceNotFoundError, BadRequestError 13 | from ..enum import ChatType 14 | from ..models.panel.admin import ( 15 | AuthRequest, SetModuleStatusRequest, UpdateSecuritySettingsRequest, UpdateApplicationSettingsRequest 16 | ) 17 | from . import JSONResponse 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.post("/auth") 23 | async def auth(data: AuthRequest) -> JSONResponse: 24 | logger.info("Admin request received: auth") 25 | 26 | if not verify_password(data.password): 27 | logger.warning("Authentication failed") 28 | raise ParametersInvalidError(message="Wrong password") 29 | 30 | logger.success("Authentication succeeded") 31 | 32 | return JSONResponse(data={ 33 | "token": generate_jwt_token() 34 | }) 35 | 36 | 37 | @router.get("/logs", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 38 | async def get_logs(date_: Annotated[date, Query(alias="date")]) -> JSONResponse: 39 | logger.info(f"Admin request received: get logs, date: {date_}") 40 | 41 | if (logs := load_logs(date_)) is None: 42 | raise ResourceNotFoundError(message="Logs not found") 43 | elif logs is False: 44 | raise BadRequestError(message="Log file too large") 45 | 46 | return JSONResponse(data=logs) 47 | 48 | 49 | @router.get("/status", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 50 | async def get_status() -> JSONResponse: 51 | logger.info("Admin request received: get status") 52 | 53 | return JSONResponse(data=status.model_dump()) 54 | 55 | 56 | @router.post("/status/module", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 57 | async def set_module_status(data: SetModuleStatusRequest) -> JSONResponse: 58 | logger.info("Admin request received: set module status") 59 | 60 | status.module.order = data.order 61 | status.module.event = data.event 62 | 63 | return JSONResponse() 64 | 65 | 66 | @router.patch("/settings/security", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 67 | async def update_security_settings(data: UpdateSecuritySettingsRequest) -> JSONResponse: 68 | logger.info("Admin request received: update security settings") 69 | 70 | settings.update_security(data.model_dump(exclude_none=True)) 71 | 72 | return JSONResponse() 73 | 74 | 75 | @router.get("/settings/app", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 76 | async def get_settings() -> JSONResponse: 77 | logger.info("Admin request received: get application settings") 78 | 79 | return JSONResponse(data=settings.app.model_dump()) 80 | 81 | 82 | @router.patch("/settings/app", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 83 | async def update_application_settings(data: UpdateApplicationSettingsRequest) -> JSONResponse: 84 | logger.info("Admin request received: update application settings") 85 | 86 | settings.update_application(data.model_dump(exclude_none=True)) 87 | 88 | return JSONResponse() 89 | 90 | 91 | @router.get("/plugins", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 92 | async def get_plugin_list() -> JSONResponse: 93 | logger.info("Admin request received: get plugin list") 94 | 95 | return JSONResponse(data={name: plugin.model_dump() for name, plugin in status.plugins.items()}) 96 | 97 | 98 | @router.get("/plugin/{plugin}", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 99 | async def get_plugin_list(plugin: str) -> JSONResponse: 100 | logger.info(f"Admin request received: get plugin, plugin: {plugin}") 101 | 102 | if plugin in dispatcher.order_plugins: 103 | plugin_class = dispatcher.order_plugins[plugin] 104 | 105 | return JSONResponse(data={ 106 | **status.plugins[plugin].model_dump(), 107 | "orders": plugin_class.orders, 108 | "priority": plugin_class.priority 109 | }) 110 | elif plugin in dispatcher.event_plugins: 111 | plugin_class = dispatcher.event_plugins[plugin] 112 | 113 | return JSONResponse(data={ 114 | **status.plugins[plugin].model_dump(), 115 | "events": [event.__name__ for event in plugin_class.events] 116 | }) 117 | else: 118 | raise ResourceNotFoundError(message="Plugin not found") 119 | 120 | 121 | @router.get("/plugin/{plugin}/settings", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 122 | async def get_plugin_settings(plugin: str) -> JSONResponse: 123 | logger.info(f"Admin request received: get plugin settings, plugin: {plugin}") 124 | 125 | if plugin not in status.plugins: 126 | raise ResourceNotFoundError(message="Plugin not found") 127 | 128 | return JSONResponse(data=plugin_settings.get(plugin=plugin)) 129 | 130 | 131 | @router.patch("/plugin/{plugin}/settings", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 132 | async def update_plugin_settings(plugin: str, data: dict) -> JSONResponse: 133 | logger.info(f"Admin request received: update plugin settings, plugin: {plugin}") 134 | 135 | if plugin not in status.plugins: 136 | raise ResourceNotFoundError(message="Plugin not found") 137 | 138 | plugin_settings.set(plugin=plugin, settings=data) 139 | dispatcher.find_plugin(plugin).load() 140 | 141 | return JSONResponse() 142 | 143 | 144 | @router.post("/plugin/{plugin}/settings/reset", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 145 | async def reset_plugin_settings(plugin: str) -> JSONResponse: 146 | logger.info(f"Admin request received: reset plugin settings, plugin: {plugin}") 147 | 148 | if plugin not in status.plugins: 149 | raise ResourceNotFoundError(message="Plugin not found") 150 | 151 | plugin_settings.set(plugin=plugin, settings={}) 152 | dispatcher.find_plugin(plugin).load() 153 | 154 | return JSONResponse() 155 | 156 | 157 | @router.get("/plugin/{plugin}/replies", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 158 | async def get_plugin_replies(plugin: str) -> JSONResponse: 159 | logger.info(f"Admin request received: get plugin replies, plugin: {plugin}") 160 | 161 | if plugin not in status.plugins: 162 | raise ResourceNotFoundError(message="Plugin not found") 163 | 164 | return JSONResponse(data=replies.get_replies(group=plugin)) 165 | 166 | 167 | @router.patch("/plugin/{plugin}/replies", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 168 | async def update_plugin_replies(plugin: str, data: dict[str, str]) -> JSONResponse: 169 | logger.info(f"Admin request received: update plugin replies, plugin: {plugin}") 170 | 171 | if plugin not in status.plugins: 172 | raise ResourceNotFoundError(message="Plugin not found") 173 | 174 | replies.set_replies(group=plugin, replies=data) 175 | dispatcher.find_plugin(plugin).load() 176 | 177 | return JSONResponse() 178 | 179 | 180 | @router.post("/plugin/{plugin}/replies/reset", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 181 | async def reset_plugin_replies(plugin: str) -> JSONResponse: 182 | logger.info(f"Admin request received: reset plugin replies, plugin: {plugin}") 183 | 184 | if plugin not in status.plugins: 185 | raise ResourceNotFoundError(message="Plugin not found") 186 | 187 | replies.set_replies(group=plugin, replies={}) 188 | dispatcher.find_plugin(plugin).load() 189 | 190 | return JSONResponse() 191 | 192 | 193 | @router.get("/chat/{chat_type}/{chat_id}/settings", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 194 | async def get_chat_settings(chat_type: ChatType, chat_id: int, group: str) -> JSONResponse: 195 | logger.info(f"Admin request received: get chat settings, chat type: {chat_type.value}, chat ID: {chat_id}, setting group: {group}") 196 | 197 | return JSONResponse(data=chat_settings.get(chat_type=chat_type, chat_id=chat_id, setting_group=group)) 198 | 199 | 200 | @router.post("/restart", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 201 | async def restart() -> JSONResponse: 202 | logger.info("Admin request received: restart") 203 | 204 | scheduler.modify_job("dicerobot.restart", next_run_time=datetime.now() + timedelta(seconds=1)) 205 | 206 | return JSONResponse() 207 | 208 | 209 | @router.post("/stop", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 210 | async def stop() -> JSONResponse: 211 | logger.info("Admin request received: stop") 212 | 213 | signal.raise_signal(signal.SIGTERM) 214 | 215 | return JSONResponse() 216 | -------------------------------------------------------------------------------- /app/router/napcat.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from ..log import logger 4 | from ..auth import verify_jwt_token 5 | from ..config import settings 6 | from ..exceptions import ResourceNotFoundError, BadRequestError 7 | from ..manage import qq_manager, napcat_manager 8 | from ..models.panel.napcat import UpdateNapCatSettingsRequest 9 | from . import JSONResponse 10 | 11 | router = APIRouter(prefix="/napcat") 12 | 13 | 14 | @router.get("/status", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 15 | async def get_status() -> JSONResponse: 16 | logger.info("NapCat manage request received: get status") 17 | 18 | return JSONResponse(data={ 19 | "downloading": napcat_manager.is_downloading(), 20 | "downloaded": napcat_manager.is_downloaded(), 21 | "installed": napcat_manager.is_installed(), 22 | "configured": napcat_manager.is_configured(), 23 | "running": napcat_manager.is_running(), 24 | "version": napcat_manager.get_version() 25 | }) 26 | 27 | 28 | @router.post("/download", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 29 | async def download() -> JSONResponse: 30 | logger.info("NapCat manage request received: download") 31 | 32 | if napcat_manager.is_downloading(): 33 | raise BadRequestError(message="NapCat ZIP file is downloading") 34 | elif napcat_manager.is_downloaded(): 35 | raise BadRequestError(message="NapCat ZIP file already downloaded") 36 | 37 | napcat_manager.download() 38 | 39 | return JSONResponse() 40 | 41 | 42 | @router.post("/install", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 43 | async def install() -> JSONResponse: 44 | logger.info("NapCat manage request received: install") 45 | 46 | if not qq_manager.is_installed(): 47 | raise BadRequestError(message="QQ not installed") 48 | elif not napcat_manager.is_downloaded() or napcat_manager.is_downloading(): 49 | raise BadRequestError(message="NapCat ZIP file not downloaded") 50 | elif napcat_manager.is_installed(): 51 | raise BadRequestError(message="NapCat already installed") 52 | 53 | napcat_manager.install() 54 | 55 | return JSONResponse() 56 | 57 | 58 | @router.post("/remove", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 59 | async def remove() -> JSONResponse: 60 | logger.info("NapCat manage request received: remove") 61 | 62 | if not napcat_manager.is_installed(): 63 | raise BadRequestError(message="NapCat not installed") 64 | elif napcat_manager.is_running(): 65 | raise BadRequestError(message="NapCat not stopped") 66 | 67 | napcat_manager.remove() 68 | 69 | return JSONResponse() 70 | 71 | 72 | @router.post("/start", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 73 | async def start() -> JSONResponse: 74 | logger.info("NapCat manage request received: start") 75 | 76 | if not qq_manager.is_installed(): 77 | raise BadRequestError(message="QQ not installed") 78 | elif not napcat_manager.is_installed(): 79 | raise BadRequestError(message="NapCat not installed") 80 | elif not napcat_manager.is_configured(): 81 | raise BadRequestError(message="NapCat not configured") 82 | elif napcat_manager.is_running(): 83 | raise BadRequestError(message="NapCat already running") 84 | 85 | napcat_manager.start() 86 | 87 | return JSONResponse() 88 | 89 | 90 | @router.post("/stop", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 91 | async def stop() -> JSONResponse: 92 | logger.info("NapCat manage request received: stop") 93 | 94 | if not napcat_manager.is_running(): 95 | raise BadRequestError(message="NapCat not running") 96 | 97 | napcat_manager.stop() 98 | 99 | return JSONResponse() 100 | 101 | 102 | @router.get("/logs", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 103 | async def get_logs() -> JSONResponse: 104 | logger.info("NapCat manage request received: get logs") 105 | 106 | if not napcat_manager.is_running(): 107 | raise BadRequestError(message="NapCat not running") 108 | elif not (logs := napcat_manager.get_logs()): 109 | raise ResourceNotFoundError(message="Logs not found") 110 | 111 | return JSONResponse(data=logs) 112 | 113 | 114 | @router.get("/settings", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 115 | async def get_settings() -> JSONResponse: 116 | logger.info("NapCat manage request received: get settings") 117 | 118 | return JSONResponse(data=settings.napcat.model_dump()) 119 | 120 | 121 | @router.patch("/settings", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 122 | async def update_napcat_settings(data: UpdateNapCatSettingsRequest) -> JSONResponse: 123 | logger.info("NapCat manage request received: update settings") 124 | 125 | settings.update_napcat(data.model_dump(exclude_none=True)) 126 | 127 | return JSONResponse() 128 | -------------------------------------------------------------------------------- /app/router/qq.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from ..log import logger 4 | from ..auth import verify_jwt_token 5 | from ..exceptions import BadRequestError 6 | from ..manage import qq_manager, napcat_manager 7 | from ..models.panel.qq import RemoveQQRequest 8 | from . import JSONResponse 9 | 10 | router = APIRouter(prefix="/qq") 11 | 12 | 13 | @router.get("/status", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 14 | async def get_status() -> JSONResponse: 15 | logger.info("QQ manage request received: get status") 16 | 17 | return JSONResponse(data={ 18 | "downloading": qq_manager.is_downloading(), 19 | "downloaded": qq_manager.is_downloaded(), 20 | "installing": qq_manager.is_installing(), 21 | "installed": qq_manager.is_installed(), 22 | "version": qq_manager.get_version() 23 | }) 24 | 25 | 26 | @router.post("/download", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 27 | async def download() -> JSONResponse: 28 | logger.info("QQ manage request received: download") 29 | 30 | if qq_manager.is_downloading(): 31 | raise BadRequestError(message="QQ DEB file is downloading") 32 | elif qq_manager.is_downloaded(): 33 | raise BadRequestError(message="QQ DEB file already downloaded") 34 | 35 | qq_manager.download() 36 | 37 | return JSONResponse() 38 | 39 | 40 | @router.post("/install", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 41 | async def install() -> JSONResponse: 42 | logger.info("QQ manage request received: install") 43 | 44 | if not qq_manager.is_downloaded() or qq_manager.is_downloading(): 45 | raise BadRequestError(message="QQ DEB file not downloaded") 46 | elif qq_manager.is_installing(): 47 | raise BadRequestError(message="QQ DEB file is installing") 48 | elif qq_manager.is_installed(): 49 | raise BadRequestError(message="QQ already installed") 50 | 51 | qq_manager.install() 52 | 53 | return JSONResponse() 54 | 55 | 56 | @router.post("/remove", dependencies=[Depends(verify_jwt_token, use_cache=False)]) 57 | async def remove(data: RemoveQQRequest) -> JSONResponse: 58 | logger.info("NapCat manage request received: remove") 59 | 60 | if not qq_manager.is_installed(): 61 | raise BadRequestError(message="QQ not installed") 62 | elif napcat_manager.is_installed(): 63 | raise BadRequestError(message="NapCat not removed") 64 | 65 | qq_manager.remove(**data.model_dump()) 66 | 67 | return JSONResponse() 68 | -------------------------------------------------------------------------------- /app/router/webhook.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from ..log import logger 4 | from ..auth import verify_signature 5 | from ..config import status 6 | from ..dispatch import dispatcher 7 | from ..enum import ApplicationStatus, ReportType, MessageType, NoticeType, RequestType, SegmentType 8 | from ..exceptions import MessageInvalidError 9 | from ..models.report import Report 10 | from ..models.report.message import Message, PrivateMessage, GroupMessage 11 | from ..models.report.notice import ( 12 | Notice, GroupUploadNotice, GroupAdminNotice, GroupDecreaseNotice, GroupIncreaseNotice, GroupBanNotice, 13 | FriendAddNotice, GroupRecallNotice, FriendRecallNotice, Notify 14 | ) 15 | from ..models.report.request import Request, FriendAddRequest, GroupAddRequest 16 | from . import EmptyResponse 17 | 18 | router = APIRouter() 19 | 20 | 21 | @router.post("/report", dependencies=[Depends(verify_signature, use_cache=False)]) 22 | async def message_report(content: dict) -> EmptyResponse: 23 | logger.info("Webhook request received: report") 24 | logger.debug(f"Report content: {content}") 25 | 26 | try: 27 | report = Report.model_validate(content) 28 | 29 | logger.info("Report started") 30 | 31 | match report.post_type: 32 | case ReportType.MESSAGE: 33 | message = Message.model_validate(content) 34 | 35 | match message.message_type: 36 | case MessageType.PRIVATE: 37 | handle_message(PrivateMessage.model_validate(content)) 38 | case MessageType.GROUP: 39 | handle_message(GroupMessage.model_validate(content)) 40 | case ReportType.META_EVENT: 41 | pass 42 | case ReportType.NOTICE: 43 | notice = Notice.model_validate(content) 44 | 45 | match notice.notice_type: 46 | case NoticeType.GROUP_UPLOAD: 47 | handle_event(GroupUploadNotice.model_validate(content)) 48 | case NoticeType.GROUP_ADMIN: 49 | handle_event(GroupAdminNotice.model_validate(content)) 50 | case NoticeType.GROUP_DECREASE: 51 | handle_event(GroupDecreaseNotice.model_validate(content)) 52 | case NoticeType.GROUP_INCREASE: 53 | handle_event(GroupIncreaseNotice.model_validate(content)) 54 | case NoticeType.GROUP_BAN: 55 | handle_event(GroupBanNotice.model_validate(content)) 56 | case NoticeType.FRIEND_ADD: 57 | handle_event(FriendAddNotice.model_validate(content)) 58 | case NoticeType.GROUP_RECALL: 59 | handle_event(GroupRecallNotice.model_validate(content)) 60 | case NoticeType.FRIEND_RECALL: 61 | handle_event(FriendRecallNotice.model_validate(content)) 62 | case NoticeType.NOTIFY: 63 | handle_event(Notify.model_validate(content)) 64 | case ReportType.REQUEST: 65 | request = Request.model_validate(content) 66 | 67 | match request.request_type: 68 | case RequestType.FRIEND: 69 | handle_event(FriendAddRequest.model_validate(content)) 70 | case RequestType.GROUP: 71 | handle_event(GroupAddRequest.model_validate(content)) 72 | 73 | logger.info("Report completed") 74 | except ValueError: 75 | logger.warning("Report finished, message invalid") 76 | raise MessageInvalidError 77 | except RuntimeError: 78 | logger.info("Report filtered") 79 | 80 | return EmptyResponse() 81 | 82 | 83 | def handle_message(message: Message): 84 | # Check app status 85 | if status.app != ApplicationStatus.RUNNING: 86 | logger.info("Report skipped, DiceRobot not running") 87 | return 88 | 89 | # Check module status 90 | if not status.module.order: 91 | logger.info("Report skipped, order module disabled") 92 | return 93 | 94 | message_contents = [] 95 | 96 | for segment in message.message: 97 | match segment.type: 98 | case SegmentType.AT: 99 | if segment.data.qq == status.bot.id: 100 | continue 101 | else: 102 | logger.debug("Message to others detected") 103 | raise RuntimeError 104 | case SegmentType.TEXT: 105 | message_contents.append(segment.data.text.strip()) 106 | case SegmentType.IMAGE: 107 | continue 108 | case _: 109 | logger.debug("Unsupported segment detected") 110 | raise RuntimeError 111 | 112 | dispatcher.dispatch_order(message, "\n".join(message_contents)) 113 | 114 | 115 | def handle_event(event: Notice | Request) -> None: 116 | # Check module status 117 | if not status.module.event: 118 | logger.info("Report skipped, event module disabled") 119 | return 120 | 121 | dispatcher.dispatch_event(event) 122 | -------------------------------------------------------------------------------- /app/schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from apscheduler.schedulers.background import BackgroundScheduler 4 | 5 | from .log import logger 6 | from .config import settings, save_config 7 | 8 | 9 | scheduler = BackgroundScheduler() 10 | 11 | 12 | def init_scheduler() -> None: 13 | from app.task import restart, check_bot_status, refresh_friend_list, refresh_group_list 14 | 15 | scheduler.add_job(restart, id="dicerobot.restart", trigger="cron", year=9999).pause() 16 | scheduler.add_job(save_config, id="dicerobot.save_config", trigger="interval", minutes=5) 17 | scheduler.add_job(check_bot_status, id="dicerobot.check_bot_status", trigger="interval", minutes=1) 18 | scheduler.add_job(refresh_friend_list, id="dicerobot.refresh_friend_list", trigger="interval", minutes=5).pause() 19 | scheduler.add_job(refresh_group_list, id="dicerobot.refresh_group_list", trigger="interval", minutes=5).pause() 20 | 21 | if settings.app.start_napcat_at_startup: 22 | # Give NapCat some time to start 23 | scheduler.modify_job("dicerobot.check_bot_status", next_run_time=datetime.now() + timedelta(seconds=5)) 24 | else: 25 | # Wait for initialization 26 | scheduler.modify_job("dicerobot.check_bot_status", next_run_time=datetime.now() + timedelta(seconds=1)) 27 | 28 | scheduler.start() 29 | 30 | logger.info("Scheduler initialized") 31 | 32 | 33 | def clean_scheduler() -> None: 34 | logger.info("Clean scheduler") 35 | 36 | scheduler.shutdown() 37 | -------------------------------------------------------------------------------- /app/task.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from .log import logger 4 | from .schedule import scheduler 5 | from .config import status 6 | from .exceptions import DiceRobotException 7 | from .enum import ApplicationStatus 8 | from .network.napcat import get_login_info, get_friend_list, get_group_list 9 | 10 | state_tasks = [ 11 | "dicerobot.refresh_friend_list", 12 | "dicerobot.refresh_group_list" 13 | ] 14 | 15 | 16 | def restart() -> None: 17 | logger.info("Restart application") 18 | 19 | subprocess.run("systemctl restart dicerobot", shell=True) 20 | 21 | 22 | def check_bot_status() -> None: 23 | logger.info("Check bot status") 24 | 25 | try: 26 | data = get_login_info().data 27 | 28 | # Update status 29 | status.bot.id = data.user_id 30 | status.bot.nickname = data.nickname 31 | 32 | if status.app != ApplicationStatus.RUNNING: 33 | status.app = ApplicationStatus.RUNNING 34 | 35 | logger.success("Status changed: Running") 36 | 37 | refresh_friend_list() 38 | refresh_group_list() 39 | 40 | # Resume state jobs 41 | for _job in state_tasks: 42 | job = scheduler.get_job(_job) 43 | 44 | if job.next_run_time is None: 45 | job.resume() 46 | except (DiceRobotException, ValueError, RuntimeError): 47 | # Clear status 48 | status.bot.id = -1 49 | status.bot.nickname = "" 50 | status.bot.friends = [] 51 | status.bot.groups = [] 52 | 53 | if status.app != ApplicationStatus.HOLDING: 54 | status.app = ApplicationStatus.HOLDING 55 | 56 | logger.warning("Status changed: Holding") 57 | 58 | # Pause state jobs 59 | for job in state_tasks: 60 | scheduler.pause_job(job) 61 | 62 | 63 | def refresh_friend_list() -> None: 64 | logger.info("Refresh friend list") 65 | 66 | try: 67 | friends = get_friend_list().data 68 | status.bot.friends = [friend.user_id for friend in friends] 69 | except (DiceRobotException, ValueError): 70 | status.bot.friends = [] 71 | 72 | logger.error("Failed to refresh friend list") 73 | 74 | 75 | def refresh_group_list() -> None: 76 | logger.info("Refresh group list") 77 | 78 | try: 79 | groups = get_group_list().data 80 | status.bot.groups = [group.group_id for group in groups] 81 | except (DiceRobotException, ValueError): 82 | status.bot.groups = [] 83 | 84 | logger.error("Failed to refresh group list") 85 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | def deep_update(mapping: dict, *updating_mappings: dict) -> dict: 2 | updated_mapping = mapping.copy() 3 | 4 | for updating_mapping in updating_mappings: 5 | for k, v in updating_mapping.items(): 6 | if k in updated_mapping and isinstance(updated_mapping[k], dict) and isinstance(v, dict): 7 | updated_mapping[k] = deep_update(updated_mapping[k], v) 8 | else: 9 | updated_mapping[k] = v 10 | 11 | return updated_mapping 12 | -------------------------------------------------------------------------------- /app/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "4.4.3" 2 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function success() { 4 | printf "\033[32m%s\033[0m\n" "$1" 5 | } 6 | 7 | function warning() { 8 | printf "\033[33m%s\033[0m\n" "$1" 9 | } 10 | 11 | function error() { 12 | printf "\033[31m%s\033[0m\n" "$1" 13 | exit 1 14 | } 15 | 16 | # Check privilege 17 | if [[ $EUID -ne 0 ]]; then 18 | error "Please run this script as root" 19 | fi 20 | 21 | host="0.0.0.0" 22 | port="9500" 23 | 24 | # Parse arguments 25 | while [[ $# -gt 0 ]]; do 26 | case "$1" in 27 | --host) 28 | host="$2" 29 | shift 30 | ;; 31 | --port) 32 | port="$2" 33 | shift 34 | ;; 35 | --*) 36 | error "Illegal option $1" 37 | ;; 38 | esac 39 | 40 | shift $(( $# > 0 ? 1 : 0 )) 41 | done 42 | 43 | # Check Python 44 | if ! (python3 -V > /dev/null 2>&1); then 45 | error "Python not found" 46 | fi 47 | 48 | # Check Python version 49 | if [[ $(python3 -c "import sys; print(sys.version_info[1])") -lt 10 ]]; then 50 | error "Python 3.10 or higher required" 51 | fi 52 | 53 | # Check pip 54 | if ! (pip3 -V > /dev/null 2>&1); then 55 | warning "Python module pip not found" 56 | echo "Try to install pip" 57 | 58 | apt-get -qq update > /dev/null 2>&1 59 | 60 | if ! (apt-get -y -qq install python3-pip > /dev/null 2>&1); then 61 | error "Failed to install pip" 62 | fi 63 | fi 64 | 65 | # Check venv 66 | if ! (python3 -m venv -h > /dev/null 2>&1); then 67 | warning "Python module venv not found" 68 | echo "Install venv" 69 | 70 | apt-get -qq update > /dev/null 2>&1 71 | 72 | if ! (apt-get -y -qq install python3-venv > /dev/null 2>&1); then 73 | error "Failed to install venv" 74 | fi 75 | fi 76 | 77 | # Install pipx 78 | echo "Install pipx" 79 | 80 | if ! (pipx --version > /dev/null 2>&1); then 81 | if ! (pip3 install --user --index-url https://pypi.tuna.tsinghua.edu.cn/simple pipx > /dev/null 2>&1); then 82 | apt-get -qq update > /dev/null 2>&1 83 | 84 | if ! (apt-get -y -qq install pipx > /dev/null 2>&1); then 85 | error "Failed to install pipx" 86 | fi 87 | fi 88 | 89 | python3 -m pipx ensurepath > /dev/null 2>&1 90 | source "$HOME"/.bashrc 91 | fi 92 | 93 | # Install Poetry 94 | echo "Install Poetry" 95 | 96 | if ! (poetry --version > /dev/null 2>&1); then 97 | if ! (pipx install --index-url https://pypi.tuna.tsinghua.edu.cn/simple poetry > /dev/null 2>&1); then 98 | error "Failed to install Poetry" 99 | fi 100 | 101 | source "$HOME"/.bashrc 102 | fi 103 | 104 | # Install dependencies 105 | echo "Install dependencies" 106 | 107 | if ! (apt-get -y -qq install curl xvfb libnss3 libgbm1 libasound2 > /dev/null 2>&1); then 108 | error "Failed to install dependencies" 109 | fi 110 | 111 | if ! (poetry install > /dev/null 2>&1); then 112 | error "Failed to install dependencies" 113 | fi 114 | 115 | # Create service 116 | echo "Create service" 117 | 118 | cat > /etc/systemd/system/dicerobot.service < /dev/null 2>&1 133 | systemctl enable dicerobot 134 | systemctl start dicerobot 135 | 136 | success "Success" -------------------------------------------------------------------------------- /plugin/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Type, Any 3 | from copy import deepcopy 4 | 5 | from app.config import status, plugin_settings, chat_settings, replies 6 | from app.exceptions import OrderInvalidError, OrderRepetitionExceededError 7 | from app.enum import ChatType, PrivateMessageSubType, GroupMessageSubType 8 | from app.utils import deep_update 9 | from app.models.report.message import Message, PrivateMessage, GroupMessage 10 | from app.models.report.notice import Notice 11 | from app.models.report.request import Request 12 | from app.models.report.segment import Segment, Text 13 | from app.network.napcat import ( 14 | send_private_message as napcat_send_private_message, send_group_message as napcat_send_group_message 15 | ) 16 | 17 | 18 | class DiceRobotPlugin(ABC): 19 | """DiceRobot plugin. 20 | 21 | Attributes: 22 | name: Plugin name. 23 | display_name: Plugin display name. 24 | description: Plugin description. 25 | version: Plugin version. 26 | default_plugin_settings: Default plugin settings. 27 | default_replies: Default plugin replies. 28 | supported_reply_variables: Supported reply variable list. 29 | """ 30 | 31 | name: str 32 | display_name: str 33 | description: str 34 | version: str 35 | 36 | default_plugin_settings: dict[str] = {} 37 | 38 | default_replies: dict[str] = {} 39 | supported_reply_variables: list[str] = [] 40 | 41 | @classmethod 42 | def load(cls) -> None: 43 | """Load plugin settings and replies.""" 44 | 45 | plugin_settings.set(plugin=cls.name, settings=deep_update( 46 | {"enabled": True}, 47 | deepcopy(cls.default_plugin_settings), plugin_settings.get(plugin=cls.name) 48 | )) 49 | replies.set_replies(group=cls.name, replies=deep_update( 50 | deepcopy(cls.default_replies), 51 | replies.get_replies(group=cls.name) 52 | )) 53 | 54 | @classmethod 55 | def initialize(cls) -> None: 56 | """Initialize plugin. 57 | 58 | This method is called when the plugin is loaded. Usually used to initialize some resources or tasks that the 59 | plugin will use. 60 | """ 61 | 62 | pass 63 | 64 | @classmethod 65 | def get_plugin_setting(cls, *, plugin: str = None, key: str) -> Any: 66 | """Get plugin setting. 67 | 68 | This method should only be used to dynamically get plugin settings within a class method. For normal execution, 69 | use `self.plugin_settings` instead. 70 | 71 | Args: 72 | plugin: Plugin name. 73 | key: Setting key. 74 | 75 | Returns: 76 | Setting. 77 | """ 78 | 79 | return plugin_settings.get(plugin=plugin or cls.name)[key] 80 | 81 | @classmethod 82 | def get_reply(cls, *, group: str = None, key: str) -> str: 83 | """Get plugin reply. 84 | 85 | This method should only be used to dynamically get plugin reply within a class method. For normal execution, 86 | use ``self.replies`` instead. 87 | 88 | Args: 89 | group: Reply group. 90 | key: Reply key. 91 | 92 | Returns: 93 | Reply. 94 | """ 95 | 96 | return replies.get_reply(group=group or cls.name, key=key) 97 | 98 | def __init__(self) -> None: 99 | """Initialize DiceRobot plugin.""" 100 | 101 | self.plugin_settings = plugin_settings.get(plugin=self.name) 102 | self.replies = replies.get_replies(group=self.name) 103 | 104 | @abstractmethod 105 | def __call__(self) -> None: 106 | """Execute the plugin. 107 | 108 | When there is a message that meets the conditions, an instance of the plugin will be created, then this method 109 | will be executed. The plugin should implement its main logic here. 110 | """ 111 | 112 | pass 113 | 114 | def save_plugin_settings(self) -> None: 115 | """Save plugin settings. 116 | 117 | Plugin settings must be saved explicitly to avoid inappropriate modification. 118 | """ 119 | 120 | plugin_settings.set(plugin=self.name, settings=self.plugin_settings) 121 | 122 | 123 | class OrderPlugin(DiceRobotPlugin): 124 | """DiceRobot order plugin. 125 | 126 | Attributes: 127 | default_chat_settings: Default chat settings. 128 | 129 | orders: Order or orders that trigger the plugin. 130 | priority: The priority of the plugin. A larger value means a higher priority. 131 | """ 132 | 133 | default_chat_settings = {} 134 | 135 | supported_reply_variables: list[str] = [ 136 | "机器人昵称", 137 | "机器人QQ", 138 | "机器人QQ号", 139 | "群名", 140 | "群号", 141 | "昵称", 142 | "发送者昵称", 143 | "发送者QQ", 144 | "发送者QQ号" 145 | ] 146 | 147 | orders: str | list[str] 148 | priority: int = 100 149 | max_repetition: int = 1 150 | 151 | def __init__(self, message: Message, order: str, order_content: str, repetition: int = 1) -> None: 152 | """Initialize order plugin. 153 | 154 | Args: 155 | message: Message that triggered the plugin. 156 | order: Order that triggered the plugin. 157 | order_content: Order content. 158 | repetition: Repetitions. 159 | """ 160 | 161 | super().__init__() 162 | 163 | self.chat_type: ChatType 164 | self.chat_id = -1 165 | 166 | self.message = message 167 | self.order = order 168 | self.order_content = order_content 169 | self.repetition = repetition 170 | 171 | self.reply_variables = {} 172 | 173 | self._load_chat() 174 | self._init_reply_variables() 175 | 176 | @abstractmethod 177 | def __call__(self) -> None: 178 | pass 179 | 180 | def _load_chat(self) -> None: 181 | """Load chat information and settings.""" 182 | 183 | if isinstance(self.message, PrivateMessage) and self.message.sub_type == PrivateMessageSubType.FRIEND: 184 | self.chat_type = ChatType.FRIEND 185 | self.chat_id = self.message.user_id 186 | elif isinstance(self.message, GroupMessage) and self.message.sub_type == GroupMessageSubType.NORMAL: 187 | self.chat_type = ChatType.GROUP 188 | self.chat_id = self.message.group_id 189 | else: 190 | raise ValueError 191 | 192 | # Settings used by the plugin in this chat 193 | self.chat_settings = chat_settings.get(chat_type=self.chat_type, chat_id=self.chat_id, setting_group=self.name) 194 | # Settings used by DiceRobot in this chat (bot nickname etc.) 195 | self.dicerobot_chat_settings = chat_settings.get(chat_type=self.chat_type, chat_id=self.chat_id, setting_group="dicerobot") 196 | 197 | if not self.chat_settings: 198 | # Use default chat settings in a new chat 199 | self.chat_settings.update(deepcopy(self.default_chat_settings)) 200 | 201 | def _init_reply_variables(self) -> None: 202 | """Initialize common used reply variables.""" 203 | 204 | bot_id = status.bot.id 205 | bot_nickname = self.dicerobot_chat_settings.setdefault("nickname", "") 206 | 207 | self.reply_variables = { 208 | "机器人QQ": bot_id, 209 | "机器人QQ号": bot_id, 210 | "机器人": bot_nickname if bot_nickname else status.bot.nickname, 211 | "机器人昵称": bot_nickname if bot_nickname else status.bot.nickname, 212 | "发送者QQ": self.message.user_id, 213 | "发送者QQ号": self.message.user_id, 214 | "发送者": self.message.sender.nickname, 215 | "发送者昵称": self.message.sender.nickname 216 | } 217 | 218 | def check_enabled(self) -> bool: 219 | """Check whether the plugin is enabled in this chat. 220 | 221 | By default, it will check whether DiceRobot is enabled in this chat. Plugin can override this method as needed. 222 | 223 | Returns: 224 | Whether the plugin is enabled. 225 | """ 226 | 227 | return self.dicerobot_chat_settings.setdefault("enabled", True) 228 | 229 | def check_order_content(self) -> None: 230 | """Check whether the order content is valid. 231 | 232 | This method should not return anything. If the order content is invalid, it should raise an `OrderInvalidError` 233 | exception. 234 | 235 | By default, it will check whether the order content is empty. Plugin can override this method as needed. 236 | 237 | Raises: 238 | OrderInvalidError: Order content is invalid. 239 | """ 240 | 241 | if not self.order_content: 242 | raise OrderInvalidError 243 | 244 | def check_repetition(self) -> None: 245 | """Check whether the repetition is valid. 246 | 247 | This method should not return anything. If the repetition is invalid, it should raise an 248 | `OrderRepetitionExceededError` exception. 249 | 250 | By default, it will check whether the repetition exceeds the maximum. The plugin can modify the `max_repetition` 251 | attribute to control the maximum of repetitions, or override this method as needed. 252 | 253 | Raises: 254 | OrderRepetitionExceededError: Repetition exceeds the maximum. 255 | """ 256 | 257 | if self.repetition > self.max_repetition: 258 | raise OrderRepetitionExceededError 259 | 260 | def update_reply_variables(self, d: dict[str, Any]) -> None: 261 | """Update reply variables used in replies. 262 | 263 | Args: 264 | d: Dictionary of reply variables. 265 | """ 266 | 267 | self.reply_variables |= d 268 | 269 | def format_reply(self, reply: str) -> str: 270 | """Replace the placeholders (reply variables) in the reply with actual values. 271 | 272 | Args: 273 | reply: Reply. 274 | """ 275 | 276 | for key, value in self.reply_variables.items(): 277 | reply = reply.replace(f"{{&{key}}}", str(value)) 278 | 279 | return reply 280 | 281 | def reply_to_sender(self, reply: str | list[Segment]) -> None: 282 | """Send reply to the sender. 283 | 284 | Args: 285 | reply: Reply string or message. Reply string will be formatted and converted to a text message. 286 | """ 287 | 288 | if isinstance(reply, str): 289 | reply = [Text(data=Text.Data(text=self.format_reply(reply)))] 290 | 291 | self.reply_to_message_sender(self.message, reply) 292 | 293 | @classmethod 294 | def reply_to_message_sender(cls, message: Message, reply: str | list[Segment]) -> None: 295 | """Send reply to the sender of specific message. 296 | 297 | Args: 298 | message: Message. 299 | reply: Reply string or message. 300 | """ 301 | 302 | if isinstance(message, PrivateMessage) and message.sub_type == PrivateMessageSubType.FRIEND: 303 | cls.send_friend_message(message.user_id, reply) 304 | elif isinstance(message, GroupMessage) and message.sub_type == GroupMessageSubType.NORMAL: 305 | cls.send_group_message(message.group_id, reply) 306 | else: 307 | raise RuntimeError("Invalid message type or sub type") 308 | 309 | @staticmethod 310 | def send_friend_message(user_id: int, message: str | list[Segment]) -> None: 311 | """Send message to friend. 312 | 313 | Args: 314 | user_id: Friend ID. 315 | message: String or segments. String will be converted to a text message. 316 | """ 317 | 318 | if isinstance(message, str): 319 | message = [Text(data=Text.Data(text=message))] 320 | 321 | napcat_send_private_message(user_id, message) 322 | 323 | @staticmethod 324 | def send_group_message(group_id: int, message: str | list[Message]) -> None: 325 | """Send message to group. 326 | 327 | Args: 328 | group_id: Group ID. 329 | message: String or segments. String will be converted to a text message. 330 | """ 331 | 332 | if isinstance(message, str): 333 | message = [Text(data=Text.Data(text=message))] 334 | 335 | napcat_send_group_message(group_id, message) 336 | 337 | 338 | class EventPlugin(DiceRobotPlugin): 339 | """DiceRobot event plugin. 340 | 341 | Attributes: 342 | events: (class attribute) Events that can trigger the plugin. 343 | """ 344 | 345 | events: list[Type[Notice | Request]] = [] 346 | 347 | def __init__(self, event: Notice | Request) -> None: 348 | """Initialize event plugin. 349 | 350 | Args: 351 | event: Event that triggered the plugin. 352 | """ 353 | 354 | super().__init__() 355 | 356 | self.event = event 357 | self.reply_variables = {} 358 | 359 | @abstractmethod 360 | def __call__(self) -> None: 361 | pass 362 | -------------------------------------------------------------------------------- /plugin/dicerobot/__init__.py: -------------------------------------------------------------------------------- 1 | from .order import ( 2 | Bot, Dice, HiddenDice, BPDice, SkillRoll, Chat, Conversation, Paint, StableDiffusion, DailySixtySeconds 3 | ) 4 | 5 | from .event import FriendRequestHandler, GroupInvitationHandler 6 | 7 | 8 | __all__ = [ 9 | # Orders 10 | "Bot", 11 | "Dice", 12 | "HiddenDice", 13 | "BPDice", 14 | "SkillRoll", 15 | "Chat", 16 | "Conversation", 17 | "Paint", 18 | "StableDiffusion", 19 | "DailySixtySeconds", 20 | 21 | # Events 22 | "FriendRequestHandler", 23 | "GroupInvitationHandler" 24 | ] 25 | -------------------------------------------------------------------------------- /plugin/dicerobot/event/__init__.py: -------------------------------------------------------------------------------- 1 | from .friend_request import FriendRequestHandler 2 | from .group_invite import GroupInvitationHandler 3 | 4 | 5 | __all__ = [ 6 | "FriendRequestHandler", 7 | "GroupInvitationHandler" 8 | ] 9 | -------------------------------------------------------------------------------- /plugin/dicerobot/event/friend_request.py: -------------------------------------------------------------------------------- 1 | from plugin import EventPlugin 2 | from app.log import logger 3 | from app.models.report.request import FriendAddRequest 4 | from app.network.napcat import set_friend_add_request 5 | 6 | 7 | class FriendRequestHandler(EventPlugin): 8 | name = "dicerobot.friend_request" 9 | display_name = "好友申请" 10 | description = "处理好友申请" 11 | version = "1.1.0" 12 | 13 | default_plugin_settings = { 14 | "auto_approve": True 15 | } 16 | 17 | events = [ 18 | FriendAddRequest 19 | ] 20 | 21 | def __call__(self) -> None: 22 | logger.success(f"Friend request from {self.event.user_id} received") 23 | 24 | if self.plugin_settings["auto_approve"]: 25 | set_friend_add_request(self.event.flag, True) 26 | 27 | logger.success(f"Friend request from {self.event.user_id} automatically approved") 28 | -------------------------------------------------------------------------------- /plugin/dicerobot/event/group_invite.py: -------------------------------------------------------------------------------- 1 | from plugin import EventPlugin 2 | from app.log import logger 3 | from app.models.report.request import GroupAddRequest 4 | from app.network.napcat import set_group_add_request 5 | 6 | 7 | class GroupInvitationHandler(EventPlugin): 8 | name = "dicerobot.group_invite" 9 | display_name = "群聊邀请" 10 | description = "处理群聊邀请" 11 | version = "1.1.0" 12 | 13 | default_plugin_settings = { 14 | "auto_accept": True 15 | } 16 | 17 | events = [ 18 | GroupAddRequest 19 | ] 20 | 21 | def __call__(self) -> None: 22 | logger.success(f"Group invitation from {self.event.group_id} received") 23 | 24 | if self.plugin_settings["auto_accept"]: 25 | set_group_add_request(self.event.flag, self.event.sub_type, True) 26 | 27 | logger.success(f"Group invitation from {self.event.group_id} automatically accepted") 28 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import Bot 2 | from .dice import Dice 3 | from .hidden_dice import HiddenDice 4 | from .bp_dice import BPDice 5 | from .skill_roll import SkillRoll 6 | from .chat import Chat 7 | from .conversation import Conversation 8 | from .paint import Paint 9 | from .stable_diffusion import StableDiffusion 10 | from .daily_60s import DailySixtySeconds 11 | 12 | 13 | __all__ = [ 14 | "Bot", 15 | "Dice", 16 | "HiddenDice", 17 | "BPDice", 18 | "SkillRoll", 19 | "Chat", 20 | "Conversation", 21 | "Paint", 22 | "StableDiffusion", 23 | "DailySixtySeconds" 24 | ] 25 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/bot.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from plugin import OrderPlugin 4 | from app.config import status 5 | from app.exceptions import OrderInvalidError, OrderError 6 | from app.enum import ChatType, Role 7 | from app.network.napcat import set_group_card 8 | 9 | 10 | class Bot(OrderPlugin): 11 | name = "dicerobot.bot" 12 | display_name = "Bot 控制" 13 | description = "与 Bot 有关的各种指令" 14 | version = "1.1.1" 15 | 16 | default_replies = { 17 | "about": "DiceRobot {&版本}\nMIT License\n© 2019-{&当前年份} Drsanwujiang", 18 | "enable": "呐呐~{&机器人昵称}为你服务~☆♪", 19 | "enable_denied": "只有群主/管理员才可以叫醒人家哦~", 20 | "disable": "(´〜`*) zzzZZZ", 21 | "disable_denied": "但是群主/管理员还没有让人家休息呀……", 22 | "nickname_set": "之后就请称呼我为「{&机器人昵称}」吧~", 23 | "nickname_unset": "真·名·解·放", 24 | "nickname_denied": "只有群主/管理员才可以修改人家的名字哦~" 25 | } 26 | supported_reply_variables = [ 27 | "版本", 28 | "版权信息" 29 | ] 30 | 31 | orders = [ 32 | "bot", "robot" 33 | ] 34 | priority = 10 35 | 36 | _suborders = { 37 | "about": ["about", "info", "关于", "信息"], 38 | "enable": ["on", "start", "enable", "开启", "启动"], 39 | "disable": ["off", "stop", "disable", "关闭", "停止"], 40 | "nickname": ["nickname", "name", "nn", "昵称"] 41 | } 42 | 43 | def __init__(self, *args, **kwargs) -> None: 44 | super().__init__(*args, **kwargs) 45 | 46 | self.suborder = "" 47 | self.suborder_content = "" 48 | 49 | for suborder, suborders in Bot._suborders.items(): 50 | for _suborder in suborders: 51 | if self.order_content.startswith(_suborder): 52 | self.suborder = suborder 53 | self.suborder_content = self.order_content[len(_suborder):].strip() 54 | 55 | def check_enabled(self) -> bool: 56 | if self.suborder == "enable": 57 | return True 58 | 59 | return super().check_enabled() 60 | 61 | def __call__(self) -> None: 62 | self.check_repetition() 63 | 64 | if self.suborder == "" or self.suborder == "about": 65 | self.about() 66 | elif self.suborder == "enable": 67 | self.enable() 68 | elif self.suborder == "disable": 69 | self.disable() 70 | elif self.suborder == "nickname": 71 | self.nickname() 72 | else: 73 | raise OrderInvalidError 74 | 75 | def about(self) -> None: 76 | if self.suborder_content: 77 | raise OrderInvalidError 78 | 79 | self.update_reply_variables({ 80 | "版本": status.version, 81 | "当前年份": date.today().year 82 | }) 83 | self.reply_to_sender(self.replies["about"]) 84 | 85 | def enable(self) -> None: 86 | # Ignore if not in group chat 87 | if self.chat_type != ChatType.GROUP: 88 | return 89 | 90 | if self.suborder_content: 91 | raise OrderInvalidError 92 | 93 | if self.message.sender.role == Role.MEMBER: 94 | raise OrderError(self.replies["enable_denied"]) 95 | 96 | self.dicerobot_chat_settings["enabled"] = True 97 | self.reply_to_sender(self.replies["enable"]) 98 | 99 | def disable(self) -> None: 100 | # Ignore if not in group chat 101 | if self.chat_type != ChatType.GROUP: 102 | return 103 | 104 | if self.suborder_content: 105 | raise OrderInvalidError 106 | 107 | if self.message.sender.role == Role.MEMBER: 108 | raise OrderError(self.replies["disable_denied"]) 109 | 110 | self.dicerobot_chat_settings["enabled"] = False 111 | self.reply_to_sender(self.replies["disable"]) 112 | 113 | def nickname(self) -> None: 114 | # Ignore if not in group chat 115 | if self.chat_type != ChatType.GROUP: 116 | return 117 | 118 | if self.message.sender.role == Role.MEMBER: 119 | raise OrderError(self.replies["nickname_denied"]) 120 | 121 | if self.suborder_content: 122 | # Set nickname 123 | self.dicerobot_chat_settings["nickname"] = self.suborder_content 124 | set_group_card(self.chat_id, status.bot.id, self.suborder_content) 125 | self.update_reply_variables({ 126 | "机器人": self.suborder_content, 127 | "机器人昵称": self.suborder_content 128 | }) 129 | self.reply_to_sender(self.replies["nickname_set"]) 130 | else: 131 | # Unset nickname 132 | self.dicerobot_chat_settings["nickname"] = "" 133 | set_group_card(self.chat_id, status.bot.id, "") 134 | self.update_reply_variables({ 135 | "机器人": status.bot.nickname, 136 | "机器人昵称": status.bot.nickname 137 | }) 138 | self.reply_to_sender(self.replies["nickname_unset"]) 139 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/bp_dice.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from plugin import OrderPlugin 5 | from app.exceptions import OrderSuspiciousError, OrderError 6 | 7 | 8 | class BPDice(OrderPlugin): 9 | name = "dicerobot.bp_dice" 10 | display_name = "奖励骰/惩罚骰" 11 | description = "掷一个骰子,以及一个或多个奖励骰/惩罚骰" 12 | version = "1.1.0" 13 | 14 | default_plugin_settings = { 15 | "max_count": 100 16 | } 17 | 18 | default_replies = { 19 | "result": "{&发送者}骰出了:{&掷骰结果}", 20 | "result_with_reason": "由于{&掷骰原因},{&发送者}骰出了:{&掷骰结果}", 21 | "max_count_exceeded": "被骰子淹没,不知所措……" 22 | } 23 | supported_reply_variables = [ 24 | "掷骰原因", 25 | "掷骰结果" 26 | ] 27 | 28 | orders = [ 29 | r"r\s*b", "奖励骰", 30 | r"r\s*p", "惩罚骰" 31 | ] 32 | priority = 10 33 | max_repetition = 30 34 | 35 | _content_pattern = re.compile(r"^([1-9]\d*)?\s*([\S\s]*)$", re.I) 36 | _bp_types = { 37 | "bonus": ["rb", "奖励骰"], 38 | "penalty": ["rp", "惩罚骰"] 39 | } 40 | 41 | def __init__(self, *args, **kwargs) -> None: 42 | super().__init__(*args, **kwargs) 43 | 44 | self.order = re.sub(r"\s", "", self.order) # Remove whitespace characters 45 | self.bp_type = None 46 | 47 | self.count = 1 48 | self.reason = "" 49 | 50 | self.dice_result = -1 51 | self.bp_results: list[int] = [] 52 | self.final_result = -1 53 | self.full_result = "" 54 | 55 | def __call__(self) -> None: 56 | self.check_repetition() 57 | self.parse_content() 58 | self.bonus_or_penalty() 59 | result = self.full_result 60 | 61 | if self.repetition > 1: 62 | result = f"\n{result}" 63 | 64 | for _ in range(self.repetition - 1): 65 | self.bonus_or_penalty() 66 | result += f"\n{self.full_result}" 67 | 68 | self.update_reply_variables({ 69 | "掷骰原因": self.reason, 70 | "掷骰结果": result 71 | }) 72 | self.reply_to_sender(self.replies["result_with_reason" if self.reason else "result"]) 73 | 74 | def parse_content(self) -> None: 75 | if self.order in self._bp_types["bonus"]: 76 | self.bp_type = "bonus" 77 | elif self.order in self._bp_types["penalty"]: 78 | self.bp_type = "penalty" 79 | else: 80 | raise OrderError("Invalid order") 81 | 82 | # Parse order content into possible count and reason 83 | match = self._content_pattern.fullmatch(self.order_content) 84 | 85 | # Check count length 86 | if len(match.group(1) or "") > 3: 87 | raise OrderSuspiciousError 88 | 89 | self.count = int(match.group(1)) if match.group(1) else self.count 90 | self.reason = match.group(2) 91 | 92 | # Check count 93 | if self.count > self.plugin_settings["max_count"]: 94 | raise OrderError(self.replies["max_count_exceeded"]) 95 | 96 | def bonus_or_penalty(self) -> None: 97 | bp_type_name = None 98 | 99 | # Calculate result 100 | self.dice_result = random.randint(1, 100) 101 | self.bp_results = [random.randint(1, 10) for _ in range(self.count)] 102 | 103 | # Calculate final result 104 | ones = self.dice_result % 10 105 | tens = self.dice_result // 10 106 | 107 | if self.bp_type == "bonus": 108 | bp_type_name = "奖励骰" 109 | min_result = min(self.bp_results) 110 | tens = min_result if tens > min_result else tens 111 | elif self.bp_type == "penalty": 112 | bp_type_name = "惩罚骰" 113 | max_result = max(self.bp_results) 114 | tens = max_result if tens < max_result else tens 115 | 116 | self.final_result = tens * 10 + ones 117 | self.final_result = 100 if self.final_result > 100 else self.final_result 118 | 119 | detailed_bp_result = " ".join(map(str, self.bp_results)) 120 | self.full_result = f"B{self.count}={self.dice_result}[{bp_type_name}:{detailed_bp_result}]={self.final_result}" 121 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/chat.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | import base64 3 | import mimetypes 4 | 5 | from pydantic import conlist 6 | 7 | from plugin import OrderPlugin 8 | from app.exceptions import OrderInvalidError, OrderError 9 | from app.models import BaseModel 10 | from app.models.report.segment import Text, Image 11 | from app.network import Client 12 | from app.network.napcat import get_image 13 | 14 | 15 | class Chat(OrderPlugin): 16 | name = "dicerobot.chat" 17 | display_name = "聊天(GPT)" 18 | description = "使用 OpenAI 的 GPT 模型进行聊天对话" 19 | version = "1.2.1" 20 | 21 | default_plugin_settings = { 22 | "domain": "api.openai.com", 23 | "api_key": "", 24 | "model": "gpt-4o" 25 | } 26 | 27 | default_replies = { 28 | "unusable": "请先设置神秘代码~", 29 | "rate_limit_exceeded": "哎呀,思考不过来了呢……请重新再试一次吧~" 30 | } 31 | 32 | orders = [ 33 | "chat", "聊天" 34 | ] 35 | priority = 100 36 | 37 | def __call__(self) -> None: 38 | self.check_order_content() 39 | self.check_repetition() 40 | 41 | if not (api_key := self.plugin_settings["api_key"]): 42 | raise OrderError(self.replies["unusable"]) 43 | 44 | try: 45 | content = [] 46 | 47 | for segment in self.message.message: 48 | if isinstance(segment, Text): 49 | content.append(ChatCompletionTextContent.model_validate({ 50 | "type": "text", 51 | "text": segment.data.text 52 | })) 53 | elif isinstance(segment, Image): 54 | file = get_image(segment.data.file).data.file 55 | mime_type, _ = mimetypes.guess_type(file) 56 | 57 | with open(file, "rb") as f: 58 | image_content = base64.b64encode(f.read()).decode() 59 | 60 | content.append(ChatCompletionImageUrlContent.model_validate({ 61 | "type": "image_url", 62 | "image_url": { 63 | "url": f"data:{mime_type};base64,{image_content}" 64 | } 65 | })) 66 | 67 | request = ChatCompletionRequest.model_validate({ 68 | "model": self.plugin_settings["model"], 69 | "messages": [{ 70 | "role": "user", 71 | "content": content 72 | }] 73 | }) 74 | except ValueError: 75 | raise OrderInvalidError 76 | 77 | result = Client().post( 78 | "https://" + self.plugin_settings["domain"] + "/v1/chat/completions", 79 | headers={ 80 | "Authorization": f"Bearer {api_key}" 81 | }, 82 | json=request.model_dump(exclude_none=True), 83 | timeout=30 84 | ).json() 85 | 86 | try: 87 | response = ChatCompletionResponse.model_validate(result) 88 | except ValueError: 89 | raise OrderError(self.replies["rate_limit_exceeded"]) 90 | 91 | self.reply_to_sender(response.choices[0].message.content) 92 | 93 | 94 | class ChatCompletionContent(BaseModel): 95 | type: Literal["text", "image_url"] 96 | 97 | 98 | class ChatCompletionTextContent(ChatCompletionContent): 99 | type: Literal["text"] = "text" 100 | text: str 101 | 102 | 103 | class ChatCompletionImageUrlContent(ChatCompletionContent): 104 | class ImageUrl(BaseModel): 105 | url: str 106 | 107 | type: Literal["image_url"] = "image_url" 108 | image_url: ImageUrl 109 | 110 | 111 | class ChatCompletion(BaseModel): 112 | role: str 113 | content: str | list[ChatCompletionContent] 114 | 115 | 116 | class ChatCompletionRequest(BaseModel): 117 | model: str 118 | messages: list[ChatCompletion] 119 | frequency_penalty: float = None 120 | logit_bias: dict[str, float] = None 121 | max_tokens: int = None 122 | n: int = None 123 | presence_penalty: float = None 124 | response_format: dict[str, str] = None 125 | seed: int = None 126 | stop: str | list[str] = None 127 | stream: bool = None 128 | temperature: float = None 129 | top_p: float = None 130 | tools: list[dict[str, dict[str, str | dict]]] = None 131 | tool_choice: str | dict[str, dict[str, str]] = None 132 | user: str = None 133 | 134 | 135 | class ChatCompletionResponse(BaseModel): 136 | class ChatCompletionChoice(BaseModel): 137 | index: int 138 | message: ChatCompletion 139 | finish_reason: str 140 | 141 | class ChatCompletionUsage(BaseModel): 142 | prompt_tokens: int 143 | completion_tokens: int 144 | total_tokens: int 145 | 146 | id: str 147 | object: str 148 | created: int 149 | model: str 150 | system_fingerprint: str = None 151 | choices: conlist(ChatCompletionChoice, min_length=1) 152 | usage: ChatCompletionUsage 153 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/conversation.py: -------------------------------------------------------------------------------- 1 | from pydantic import TypeAdapter 2 | 3 | from plugin import OrderPlugin 4 | from plugin.dicerobot.order.chat import ChatCompletion, ChatCompletionRequest, ChatCompletionResponse 5 | from app.exceptions import OrderInvalidError, OrderError 6 | from app.network import Client 7 | 8 | 9 | class Conversation(OrderPlugin): 10 | name = "dicerobot.conversation" 11 | display_name = "对话(GPT)" 12 | description = "使用 OpenAI 的 GPT 模型进行连续的聊天对话" 13 | version = "1.1.2" 14 | 15 | default_plugin_settings = { 16 | "domain": "api.openai.com", 17 | "api_key": "", 18 | "model": "gpt-4o" 19 | } 20 | default_chat_settings = { 21 | "conversation": [], 22 | "tokens": 0 23 | } 24 | 25 | default_replies = { 26 | "unusable": "请先设置神秘代码~", 27 | "new_conversation": "让我们开始吧~", 28 | "set_guidance": "【之后的对话将遵循以上设定】", 29 | "query_usage": "当前对话使用了{&当前使用量}个计费单位", 30 | "rate_limit_exceeded": "哎呀,思考不过来了呢……请重新再试一次吧~", 31 | "conversation_invalid": "唔……想不起来之前的对话了呢,让我们重新开始吧~" 32 | } 33 | supported_reply_variables = [ 34 | "设定名称", 35 | "设定内容", 36 | "设定列表", 37 | "当前使用量" 38 | ] 39 | 40 | orders = [ 41 | "conv", "对话", 42 | "guide", "设定", 43 | ] 44 | priority = 100 45 | 46 | def __call__(self) -> None: 47 | self.check_repetition() 48 | 49 | if not self.plugin_settings["api_key"]: 50 | raise OrderError(self.replies["unusable"]) 51 | 52 | if self.order in ["conv", "对话"]: 53 | if self.order_content in ["usage", "使用量"]: 54 | self.query_usage() 55 | else: 56 | self.conversation() 57 | elif self.order in ["guide", "设定"]: 58 | self.set_guidance() 59 | 60 | def conversation(self) -> None: 61 | if self.order_content: 62 | # Continue conversation 63 | conversation = self.load_conversation() 64 | conversation.append(ChatCompletion( 65 | role="user", 66 | content=self.order_content 67 | )) 68 | 69 | try: 70 | request = ChatCompletionRequest.model_validate({ 71 | "model": self.plugin_settings["model"], 72 | "messages": conversation 73 | }) 74 | except ValueError: 75 | raise OrderInvalidError 76 | 77 | result = Client().post( 78 | "https://" + self.plugin_settings["domain"] + "/v1/chat/completions", 79 | headers={ 80 | "Authorization": "Bearer " + self.plugin_settings["api_key"] 81 | }, 82 | json=request.model_dump(exclude_none=True), 83 | timeout=60 84 | ).json() 85 | 86 | try: 87 | response = ChatCompletionResponse.model_validate(result) 88 | except ValueError: 89 | raise OrderError(self.replies["rate_limit_exceeded"]) 90 | 91 | conversation.append(response.choices[0].message) 92 | 93 | # Save conversation and tokens 94 | self.chat_settings["conversation"] = [completion.model_dump() for completion in conversation] 95 | self.chat_settings["tokens"] = response.usage.total_tokens 96 | 97 | self.reply_to_sender(response.choices[0].message.content) 98 | else: 99 | # Clear conversation 100 | self.chat_settings["conversation"] = [] 101 | self.chat_settings["tokens"] = 0 102 | 103 | self.reply_to_sender(self.replies["new_conversation"]) 104 | 105 | def query_usage(self) -> None: 106 | self.update_reply_variables({ 107 | "当前使用量": self.chat_settings["tokens"] 108 | }) 109 | self.reply_to_sender(self.replies["query_usage"]) 110 | 111 | def set_guidance(self) -> None: 112 | if not self.order_content: 113 | raise OrderInvalidError 114 | 115 | conversation = self.load_conversation() 116 | conversation.append(ChatCompletion( 117 | role="system", 118 | content=self.order_content 119 | )) 120 | 121 | # Save conversation 122 | self.chat_settings["conversation"] = [completion.model_dump() for completion in conversation] 123 | self.reply_to_sender(self.replies["set_guidance"]) 124 | 125 | def load_conversation(self) -> list[ChatCompletion]: 126 | if conversation := self.chat_settings["conversation"]: 127 | try: 128 | # Load saved conversation 129 | conversation = TypeAdapter(list[ChatCompletion]).validate_python(conversation) 130 | except ValueError: 131 | # Clear conversation 132 | self.chat_settings["conversation"] = [] 133 | self.chat_settings["tokens"] = 0 134 | 135 | raise OrderError(self.replies["conversation_invalid"]) 136 | 137 | return conversation 138 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/daily_60s.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from plugin import OrderPlugin 4 | from app.schedule import scheduler 5 | from app.exceptions import OrderInvalidError, OrderError 6 | from app.enum import ChatType 7 | from app.models.report.segment import Image 8 | from app.network import Client 9 | 10 | 11 | class DailySixtySeconds(OrderPlugin): 12 | name = "dicerobot.daily_60s" 13 | display_name = "每天60秒读懂世界" 14 | description = "每天60秒读懂世界,15条简报+1条微语,让你瞬间了解世界正在发生的大事" 15 | version = "1.1.4" 16 | 17 | default_plugin_settings = { 18 | "api": "https://api.2xb.cn/zaob", 19 | "subscribers": [] 20 | } 21 | 22 | default_replies = { 23 | "api_error": "哎呀,今天的简报还没有寄过来呢……", 24 | "subscribe": "订阅成功~每天准时带你了解世界正在发生的大事", 25 | "unsubscribe": "取消订阅成功~", 26 | "unsubscribable": "只能在群聊中订阅哦~" 27 | } 28 | 29 | orders = [ 30 | "60s", "60秒" 31 | ] 32 | priority = 100 33 | 34 | @classmethod 35 | def initialize(cls) -> None: 36 | scheduler.add_job(cls.send_daily_60s, id=f"{cls.name}.send", trigger="cron", hour=10) 37 | 38 | @classmethod 39 | def send_daily_60s(cls) -> None: 40 | result = Client().get(cls.get_plugin_setting(key="api")).json() 41 | 42 | if result["datatime"] == str(datetime.date.today()): 43 | message = [Image(data=Image.Data(file=result["imageUrl"]))] 44 | else: 45 | message = cls.get_reply(key="api_error") 46 | 47 | for chat_id in cls.get_plugin_setting(key="subscribers"): 48 | cls.send_group_message(chat_id, message) 49 | 50 | def __call__(self) -> None: 51 | self.check_order_content() 52 | self.check_repetition() 53 | 54 | if self.chat_type != ChatType.GROUP: 55 | raise OrderError(self.replies["unsubscribable"]) 56 | 57 | if self.chat_id not in self.plugin_settings["subscribers"]: 58 | self.plugin_settings["subscribers"].append(self.chat_id) 59 | self.save_plugin_settings() 60 | self.reply_to_sender(self.replies["subscribe"]) 61 | else: 62 | self.plugin_settings["subscribers"].remove(self.chat_id) 63 | self.save_plugin_settings() 64 | self.reply_to_sender(self.replies["unsubscribe"]) 65 | 66 | def check_order_content(self) -> None: 67 | if self.order_content: 68 | raise OrderInvalidError 69 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/dice.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from plugin import OrderPlugin 5 | from app.exceptions import OrderSuspiciousError, OrderError 6 | 7 | 8 | class Dice(OrderPlugin): 9 | name = "dicerobot.dice" 10 | display_name = "掷骰" 11 | description = "掷一个或一堆骰子" 12 | version = "1.1.1" 13 | 14 | default_plugin_settings = { 15 | "max_count": 100, 16 | "max_surface": 1000 17 | } 18 | default_chat_settings = { 19 | "default_surface": 100 20 | } 21 | 22 | default_replies = { 23 | "result": "{&发送者}骰出了:{&掷骰结果}", 24 | "result_with_reason": "由于{&掷骰原因},{&发送者}骰出了:{&掷骰结果}", 25 | "max_count_exceeded": "被骰子淹没,不知所措……", 26 | "max_surface_exceeded": "为什么会有这么多面的骰子啊( д ) ゚ ゚", 27 | "expression_invalid": "掷骰表达式不符合规则……", 28 | "expression_error": "掷骰表达式无法解析……" 29 | } 30 | supported_reply_variables = [ 31 | "掷骰原因", 32 | "掷骰结果" 33 | ] 34 | 35 | orders = [ 36 | "r", "掷骰" 37 | ] 38 | priority = 1 39 | max_repetition = 30 40 | 41 | _content_pattern = re.compile(r"^([\ddk+\-*x×()()]+)?\s*([\S\s]*)$", re.I) 42 | _repeated_symbol_pattern = re.compile(r"([dk+\-*])\1+", re.I) 43 | _dice_expression_split_pattern = re.compile(r"((?:[1-9]\d*)?d(?:[1-9]\d*)?(?:k(?:[1-9]\d*)?)?)", re.I) 44 | _dice_expression_pattern = re.compile(r"^([1-9]\d*)?d([1-9]\d*)?(k([1-9]\d*)?)?$", re.I) 45 | _math_expression_pattern = re.compile(r"^[\d+\-*()]+$") 46 | 47 | def __init__(self, *args, **kwargs) -> None: 48 | super().__init__(*args, **kwargs) 49 | 50 | self.expression = "d" 51 | self.reason = "" 52 | 53 | self.detailed_result = "" 54 | self.brief_result = "" 55 | self.final_result = "" 56 | self.full_result = "" 57 | 58 | def __call__(self) -> None: 59 | self.check_repetition() 60 | self.roll() 61 | result = self.full_result 62 | 63 | if self.repetition > 1: 64 | result = f"\n{result}" 65 | 66 | for _ in range(self.repetition - 1): 67 | self.roll() 68 | result += f"\n{self.full_result}" 69 | 70 | self.update_reply_variables({ 71 | "掷骰原因": self.reason, 72 | "掷骰结果": result 73 | }) 74 | self.reply_to_sender(self.replies["result_with_reason" if self.reason else "result"]) 75 | 76 | def roll(self) -> None: 77 | self.parse_content() 78 | self.calculate_expression() 79 | self.generate_results() 80 | 81 | def parse_content(self) -> None: 82 | # Parse order content into possible expression and reason 83 | match = self._content_pattern.fullmatch(self.order_content) 84 | 85 | # Check expression length 86 | if len(match.group(1) or "") > 100: 87 | raise OrderSuspiciousError 88 | 89 | self.expression = match.group(1) if match.group(1) else self.expression 90 | self.reason = match.group(2) 91 | 92 | def calculate_expression(self) -> None: 93 | # Standardize symbols 94 | self.expression = self.expression \ 95 | .replace("x", "*").replace("X", "*").replace("×", "*") \ 96 | .replace("(", "(").replace(")", ")") 97 | 98 | # Check continuously repeated symbols like "dd" 99 | if self._repeated_symbol_pattern.fullmatch(self.expression): 100 | self.expression = "d" 101 | self.reason = self.order_content 102 | return self.calculate_expression() 103 | 104 | # Search possible dice expressions 105 | parts = self._dice_expression_split_pattern.split(self.expression) 106 | detailed_parts = parts.copy() 107 | result_parts = parts.copy() 108 | 109 | for i in range(len(parts)): 110 | # Check if the part is dice expression like "D100", "2D50" or "5D10K2" 111 | if self._dice_expression_pattern.fullmatch(parts[i]): 112 | dice_expression = DiceExpression( 113 | parts[i], 114 | self.chat_settings["default_surface"], 115 | self.plugin_settings["max_count"], 116 | self.plugin_settings["max_surface"] 117 | ) 118 | 119 | # Check count, surface and max result count (K number) 120 | if dice_expression.count > dice_expression.max_count: 121 | raise OrderError(self.replies["max_count_exceeded"]) 122 | elif dice_expression.surface > dice_expression.max_surface: 123 | raise OrderError(self.replies["max_surface_exceeded"]) 124 | elif dice_expression.max_result_count > dice_expression.count: 125 | raise OrderError(self.replies["expression_invalid"]) 126 | 127 | # Calculate results 128 | dice_expression.calculate() 129 | 130 | parts[i] = str(dice_expression) 131 | detailed_parts[i] = dice_expression.detailed_dice_result 132 | result_parts[i] = dice_expression.dice_result 133 | 134 | # Reassemble expression and results 135 | self.expression = "".join(parts) 136 | self.detailed_result = "".join(detailed_parts) 137 | self.brief_result = "".join(result_parts) 138 | 139 | if not self._math_expression_pattern.fullmatch(self.brief_result): 140 | raise OrderError(self.replies["expression_invalid"]) 141 | 142 | try: 143 | self.final_result = str(eval(self.brief_result, {}, {})) 144 | except (ValueError, SyntaxError): 145 | raise OrderError(self.replies["expression_error"]) 146 | 147 | def generate_results(self) -> None: 148 | # Beautify expression and results 149 | self.expression = self.expression.replace("*", "×") 150 | self.detailed_result = self.detailed_result.replace("*", "×") 151 | self.brief_result = self.brief_result.replace("*", "×") 152 | 153 | # Omit duplicate results 154 | result = "=".join(list(dict.fromkeys([self.detailed_result, self.brief_result, self.final_result]))) 155 | self.full_result = f"{self.expression}={result}" 156 | 157 | 158 | class DiceExpression: 159 | expression_pattern = re.compile(r"^([1-9]\d*)?d([1-9]\d*)?(?:k([1-9]\d*)?)?$", re.I) 160 | 161 | def __init__( 162 | self, 163 | expression: str, 164 | default_surface: int, 165 | max_count: int, 166 | max_surface: int 167 | ) -> None: 168 | self.default_surface = default_surface 169 | self.max_count = max_count 170 | self.max_surface = max_surface 171 | 172 | self.expression = expression 173 | self.count = 0 174 | self.surface = 0 175 | self.max_result_count = 0 176 | self.detailed_dice_result = "" 177 | self.dice_result = "" 178 | 179 | self.parse() 180 | 181 | def parse(self) -> None: 182 | match = self.expression_pattern.fullmatch(self.expression) 183 | self.count = int(match.group(1)) if match.group(1) else 1 184 | self.surface = int(match.group(2)) if match.group(2) else self.default_surface 185 | self.max_result_count = int(match.group(3)) if match.group(3) else 0 186 | 187 | def calculate(self) -> None: 188 | results = [random.randint(1, self.surface) for _ in range(self.count)] 189 | 190 | if self.max_result_count > 0: 191 | results.sort(reverse=True) 192 | results = results[:self.max_result_count] 193 | 194 | self.detailed_dice_result = "(" + "+".join(map(str, results)) + ")" if self.count > 1 else str(results[0]) 195 | self.dice_result = str(sum(results)) 196 | 197 | def __str__(self) -> str: 198 | subexpression = str(self.count) if self.count > 1 else "" 199 | subexpression += "D" + str(self.surface) 200 | subexpression += "K" + str(self.max_result_count) if self.max_result_count > 0 else "" 201 | return subexpression 202 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/hidden_dice.py: -------------------------------------------------------------------------------- 1 | from plugin import OrderPlugin 2 | from plugin.dicerobot.order.dice import Dice 3 | from app.config import status 4 | from app.exceptions import OrderError 5 | from app.models.report.message import GroupMessage 6 | from app.network.napcat import get_group_info 7 | 8 | 9 | class HiddenDice(OrderPlugin): 10 | name = "dicerobot.hidden_dice" 11 | display_name = "暗骰" 12 | description = "掷一个或一堆骰子,并通过私聊发送结果" 13 | version = "1.2.0" 14 | 15 | default_replies = { 16 | "reply": "{&发送者}悄悄地进行了掷骰……", 17 | "reply_with_reason": "由于{&掷骰原因},{&发送者}悄悄地进行了掷骰", 18 | "result": "在{&群名}({&群号})中骰出了:{&掷骰结果}", 19 | "result_with_reason": "由于{&掷骰原因},在{&群名}({&群号})中骰出了:{&掷骰结果}", 20 | "not_in_group": "只能在群聊中使用暗骰哦!", 21 | "not_friend": "必须先添加好友才能使用暗骰哦!" 22 | } 23 | 24 | orders = [ 25 | r"r\s*h", "暗骰" 26 | ] 27 | priority = 10 28 | max_repetition = 30 29 | 30 | def __call__(self) -> None: 31 | self.check_chat_type() 32 | self.check_friend() 33 | self.check_repetition() 34 | 35 | dice = Dice(self.message, ".r", self.order_content) 36 | dice.roll() 37 | result = dice.full_result 38 | 39 | if self.repetition > 1: 40 | result = f"\n{result}" 41 | 42 | for _ in range(self.repetition - 1): 43 | dice.roll() 44 | result += f"\n{dice.full_result}" 45 | 46 | assert isinstance(self.message, GroupMessage) 47 | self.update_reply_variables({ 48 | "掷骰原因": dice.reason, 49 | "掷骰结果": result, 50 | "群号": self.message.group_id, 51 | "群名": get_group_info(self.message.group_id).data.group_name 52 | }) 53 | self.reply_to_sender(self.replies["reply_with_reason" if dice.reason else "reply"]) 54 | self.send_friend_message( 55 | self.message.user_id, 56 | self.format_reply(self.replies["result_with_reason" if dice.reason else "result"]) 57 | ) 58 | 59 | def check_chat_type(self) -> None: 60 | if not isinstance(self.message, GroupMessage): 61 | raise OrderError(self.replies["not_in_group"]) 62 | 63 | def check_friend(self) -> None: 64 | if self.message.user_id not in status.bot.friends: 65 | raise OrderError(self.replies["not_friend"]) 66 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/paint.py: -------------------------------------------------------------------------------- 1 | from plugin import OrderPlugin 2 | from app.exceptions import OrderInvalidError, OrderError 3 | from app.models import BaseModel 4 | from app.models.report.segment import Image 5 | from app.network import Client 6 | 7 | 8 | class Paint(OrderPlugin): 9 | name = "dicerobot.paint" 10 | display_name = "画图(DALL·E)" 11 | description = "使用 OpenAI 的 DALL·E 模型生成图片" 12 | version = "1.2.1" 13 | 14 | default_plugin_settings = { 15 | "domain": "api.openai.com", 16 | "api_key": "", 17 | "model": "dall-e-3", 18 | "size": "1024x1024", 19 | "quality": "hd" 20 | } 21 | 22 | default_replies = { 23 | "unusable": "请先设置神秘代码~", 24 | "content_policy_violated": "啊嘞,画出来的图图不见了……请重新再试一次吧~" 25 | } 26 | 27 | orders = [ 28 | "paint", "画图", "画画", "生成图片", "生成图像" 29 | ] 30 | priority = 100 31 | 32 | def __call__(self) -> None: 33 | self.check_order_content() 34 | self.check_repetition() 35 | 36 | if not (api_key := self.plugin_settings["api_key"]): 37 | raise OrderError(self.replies["unusable"]) 38 | 39 | try: 40 | request = ImageGenerationRequest.model_validate({ 41 | "model": self.plugin_settings["model"], 42 | "prompt": self.order_content, 43 | "n": 1, 44 | "quality": self.plugin_settings["quality"], 45 | "size": self.plugin_settings["size"], 46 | "response_format": "b64_json" 47 | }) 48 | except ValueError: 49 | raise OrderInvalidError 50 | 51 | result = Client().post( 52 | "https://" + self.plugin_settings["domain"] + "/v1/images/generations", 53 | headers={ 54 | "Authorization": f"Bearer {api_key}" 55 | }, 56 | json=request.model_dump(exclude_none=True), 57 | timeout=60 58 | ).json() 59 | 60 | try: 61 | response = ImageGenerationResponse.model_validate(result) 62 | except (ValueError, ValueError): 63 | raise OrderError(self.replies["content_policy_violated"]) 64 | 65 | self.reply_to_sender([Image(data=Image.Data(file=f"base64://{response.data[0].b64_json}"))]) 66 | 67 | 68 | class ImageGenerationRequest(BaseModel): 69 | model: str 70 | prompt: str 71 | n: int = None 72 | quality: str = None 73 | response_format: str = None 74 | size: str = None 75 | style: str = None 76 | user: str = None 77 | 78 | 79 | class ImageGenerationResponse(BaseModel): 80 | class Image(BaseModel): 81 | b64_json: str 82 | revised_prompt: str 83 | 84 | created: int 85 | data: list[Image] 86 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/skill_roll.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | from plugin import OrderPlugin 5 | from app.exceptions import OrderInvalidError, OrderSuspiciousError, OrderError 6 | 7 | 8 | class SkillRoll(OrderPlugin): 9 | name = "dicerobot.skill_roll" 10 | display_name = "技能检定" 11 | description = "根据检定规则进行技能检定;加载指定的检定规则" 12 | version = "1.1.0" 13 | 14 | default_chat_settings = { 15 | "rule": { 16 | "name": "COC 7 检定规则", 17 | "description": "COC 7 版规则书设定的检定规则", 18 | "levels": [ 19 | { 20 | "level": "大成功", 21 | "rule": "骰出 1", 22 | "condition": "{&检定值} == 1" 23 | }, 24 | { 25 | "level": "极难成功", 26 | "rule": "骰出小于等于角色技能或属性值的五分之一(向下取整)", 27 | "condition": "{&检定值} <= {&技能值} // 5" 28 | }, 29 | { 30 | "level": "困难成功", 31 | "rule": "骰出小于等于角色技能或属性值的一半(向下取整)", 32 | "condition": "{&检定值} <= {&技能值} // 2" 33 | }, 34 | { 35 | "level": "成功", 36 | "rule": "骰出小于等于角色技能或属性值,也称为一般成功", 37 | "condition": "{&检定值} <= {&技能值}" 38 | }, 39 | { 40 | "level": "失败", 41 | "rule": "骰出大于角色技能或属性值", 42 | "condition": "({&技能值} < 50 && {&检定值} < 96) || ({&技能值} >= 50 && {&检定值} < 100)" 43 | }, 44 | { 45 | "level": "大失败", 46 | "rule": "骰出 100。若角色技能或属性值低于 50,则大于等于 96 的结果都是大失败", 47 | "condition": "{&检定值} >= 96" 48 | } 49 | ] 50 | } 51 | } 52 | 53 | default_replies = { 54 | "result": "{&发送者}进行了检定:{&检定结果}", 55 | "result_with_reason": "由于{&检定原因},{&发送者}进行了检定:{&检定结果}", 56 | "skill_invalid": "技能或属性值无法识别……", 57 | "rule_invalid": "检定规则无法识别……", 58 | "no_rule_matched": "没有匹配到检定等级……" 59 | } 60 | supported_reply_variables = [ 61 | "检定原因", 62 | "检定结果" 63 | ] 64 | 65 | orders = [ 66 | "ra", "检定", "技能检定", 67 | "rule", "检定规则" 68 | ] 69 | priority = 10 70 | max_repetition = 30 71 | 72 | _content_pattern = re.compile(r"^([1-9]\d*)?\s*([\S\s]*)$", re.I) 73 | _rule_pattern = re.compile(r"^[\d()><=+-/&| ]+$", re.I) 74 | 75 | def __init__(self, *args, **kwargs) -> None: 76 | super().__init__(*args, **kwargs) 77 | 78 | self.skill_value = -1 79 | self.reason = "" 80 | 81 | self.roll_result = -1 82 | self.difficulty_level = "" 83 | self.full_result = "" 84 | 85 | def __call__(self) -> None: 86 | if self.order in ["ra", "检定", "技能检定"]: 87 | self.check_repetition() 88 | self.parse_content() 89 | self.skill_roll() 90 | result = self.full_result 91 | 92 | if self.repetition > 1: 93 | result = f"\n{result}" 94 | 95 | for _ in range(self.repetition - 1): 96 | self.skill_roll() 97 | result += f"\n{self.full_result}" 98 | 99 | self.update_reply_variables({ 100 | "检定原因": self.reason, 101 | "检定结果": result 102 | }) 103 | self.reply_to_sender(self.replies["result_with_reason" if self.reason else "result"]) 104 | elif self.order in ["rule", "检定规则"]: 105 | self.max_repetition = 1 106 | self.check_repetition() 107 | self.show_rule() 108 | 109 | def parse_content(self) -> None: 110 | match = self._content_pattern.fullmatch(self.order_content) 111 | 112 | # Check skill length 113 | if len(match.group(1) or "") > 5: 114 | raise OrderSuspiciousError 115 | 116 | self.skill_value = int(match.group(1)) if match.group(1) else self.skill_value 117 | self.reason = match.group(2) 118 | 119 | if self.skill_value < 0: 120 | raise OrderError(self.replies["skill_invalid"]) 121 | 122 | def skill_roll(self) -> None: 123 | self.roll_result = random.randint(1, 100) 124 | difficulty_level = None 125 | 126 | for level in self.chat_settings["rule"]["levels"]: 127 | expression = level["condition"] \ 128 | .replace("{&技能值}", str(self.skill_value)) \ 129 | .replace("{&检定值}", str(self.roll_result)) 130 | 131 | # Check rule content 132 | if not self._rule_pattern.fullmatch(expression): 133 | raise OrderError(self.replies["rule_invalid"]) 134 | 135 | expression = expression.replace("&&", "and").replace("||", "or") 136 | 137 | try: 138 | eval_result = eval(expression) 139 | except Exception: 140 | raise OrderError(self.replies["rule_invalid"]) 141 | 142 | # Check evaluation result 143 | if not isinstance(eval_result, bool): 144 | raise OrderError(self.replies["rule_invalid"]) 145 | 146 | if eval_result: 147 | difficulty_level = level["level"] 148 | break 149 | 150 | if difficulty_level is None: 151 | raise OrderError(self.replies["no_rule_matched"]) 152 | 153 | self.difficulty_level = difficulty_level 154 | self.full_result = f"D100={self.roll_result}/{self.skill_value},{self.difficulty_level}" 155 | 156 | def show_rule(self) -> None: 157 | if self.order_content: 158 | raise OrderInvalidError 159 | 160 | rule = self.chat_settings["rule"] 161 | rule_content = f"当前使用的检定规则为:【{rule['name']}】\n{rule['description']}\n\n" + \ 162 | "\n".join([f"{level['level']}:{level['rule']}" for level in rule["levels"]]) 163 | 164 | self.reply_to_sender(rule_content) 165 | -------------------------------------------------------------------------------- /plugin/dicerobot/order/stable_diffusion.py: -------------------------------------------------------------------------------- 1 | from plugin import OrderPlugin 2 | from app.exceptions import OrderInvalidError, OrderError 3 | from app.models import BaseModel 4 | from app.models.report.segment import Image 5 | from app.network import Client 6 | 7 | 8 | class StableDiffusion(OrderPlugin): 9 | name = "dicerobot.stable_diffusion" 10 | display_name = "Stable Diffusion" 11 | description = "使用 Stability AI 的 Stable Diffusion 模型生成图片" 12 | version = "1.0.2" 13 | 14 | default_plugin_settings = { 15 | "domain": "api.stability.ai", 16 | "api_key": "", 17 | "service": "ultra", 18 | "aspect_ratio": "1:1" 19 | } 20 | 21 | default_replies = { 22 | "unusable": "请先设置神秘代码~", 23 | "content_policy_violated": "啊嘞,画出来的图图不见了……请重新再试一次吧~" 24 | } 25 | 26 | orders = [ 27 | "sd" 28 | ] 29 | priority = 100 30 | 31 | def __call__(self) -> None: 32 | self.check_order_content() 33 | self.check_repetition() 34 | 35 | if not (api_key := self.plugin_settings["api_key"]): 36 | raise OrderError(self.replies["unusable"]) 37 | 38 | try: 39 | request = ImageGenerationRequest.model_validate({ 40 | "prompt": self.order_content, 41 | "aspect_ratio": self.plugin_settings["aspect_ratio"], 42 | "output_format": "png" 43 | }) 44 | except ValueError: 45 | raise OrderInvalidError 46 | 47 | result = Client().post( 48 | "https://" + self.plugin_settings["domain"] + "/v2beta/stable-image/generate/" + self.plugin_settings["service"], 49 | headers={ 50 | "Authorization": f"Bearer {api_key}" 51 | }, 52 | files={key: (None, value) for key, value in request.model_dump(exclude_none=True).items()}, 53 | timeout=60 54 | ).json() 55 | 56 | try: 57 | response = ImageGenerationResponse.model_validate(result) 58 | except (ValueError, ValueError): 59 | raise OrderError(self.replies["content_policy_violated"]) 60 | 61 | self.reply_to_sender([Image(data=Image.Data(file=f"base64://{response.image}"))]) 62 | 63 | 64 | class ImageGenerationRequest(BaseModel): 65 | prompt: str 66 | negative_prompt: str = None 67 | aspect_ratio: str = None 68 | seed: int = None 69 | output_format: str = None 70 | 71 | 72 | class ImageGenerationResponse(BaseModel): 73 | image: str 74 | finish_reason: str 75 | seed: int 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dicerobot" 3 | version = "4.4.3" 4 | description = "A TRPG assistant bot" 5 | authors = ["Drsanwujiang "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://dicerobot.tech/" 9 | repository = "https://github.com/drsanwujiang/DiceRobot" 10 | documentation = "https://docs.dicerobot.tech/" 11 | include = [ 12 | "LICENSE", 13 | ] 14 | package-mode = false 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.10" 18 | uvicorn = "^0.30.0" 19 | fastapi = "^0.111.0" 20 | loguru = "^0.7.0" 21 | sqlalchemy = "^2.0.0" 22 | pydantic = "^2.7.0" 23 | httpx = "^0.27.0" 24 | apscheduler = "^3.10.0" 25 | pyjwt = "^2.8.0" 26 | werkzeug = "^3.0.0" 27 | 28 | [tool.poetry.group.test.dependencies] 29 | pytest = "^8.1.0" 30 | 31 | [[tool.poetry.source]] 32 | name = "tsinghua" 33 | url = "https://pypi.tuna.tsinghua.edu.cn/simple" 34 | priority = "primary" 35 | 36 | [build-system] 37 | requires = ["poetry-core"] 38 | build-backend = "poetry.core.masonry.api" 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fastapi.testclient import TestClient 4 | 5 | from app.log import logger 6 | from app.config import status 7 | from app.enum import ApplicationStatus 8 | from app.models.report.message import Message, PrivateMessage, GroupMessage 9 | 10 | 11 | class BaseTest: 12 | @staticmethod 13 | def wait_for_running() -> None: 14 | logger.debug("Waiting for DiceRobot running") 15 | 16 | time.sleep(2) 17 | 18 | assert status.app == ApplicationStatus.RUNNING 19 | 20 | @staticmethod 21 | def post_message(client: TestClient, message: Message) -> dict: 22 | response = client.post( 23 | "/report", headers={"X-Signature": "sha1=" + "00" * 20}, json=message.model_dump(exclude_none=True) 24 | ) 25 | 26 | return response.json() if response.status_code == 200 else {} 27 | 28 | @staticmethod 29 | def build_private_message(text: str) -> PrivateMessage: 30 | return PrivateMessage.model_validate({ 31 | "self_id": 99999, 32 | "user_id": 88888, 33 | "time": 1700000000, 34 | "message_id": -1234567890, 35 | "message_seq": -1234567890, 36 | "real_id": -1234567890, 37 | "message_type": "private", 38 | "sender": { 39 | "user_id": 88888, 40 | "nickname": "Kaworu", 41 | "card": "" 42 | }, 43 | "raw_message": text, 44 | "font": 14, 45 | "sub_type": "friend", 46 | "message": [ 47 | { 48 | "data": { 49 | "text": text 50 | }, 51 | "type": "text" 52 | } 53 | ], 54 | "message_format": "array", 55 | "post_type": "message" 56 | }) 57 | 58 | @staticmethod 59 | def build_group_message(text: str) -> GroupMessage: 60 | return GroupMessage.model_validate({ 61 | "self_id": 99999, 62 | "user_id": 88888, 63 | "time": 1700000000, 64 | "message_id": -1234567890, 65 | "message_seq": -1234567890, 66 | "real_id": -1234567890, 67 | "message_type": "group", 68 | "sender": { 69 | "user_id": 88888, 70 | "nickname": "Kaworu", 71 | "card": "", 72 | "role": "owner" 73 | }, 74 | "raw_message": text, 75 | "font": 14, 76 | "sub_type": "normal", 77 | "message": [ 78 | { 79 | "data": { 80 | "text": text 81 | }, 82 | "type": "text" 83 | } 84 | ], 85 | "message_format": "array", 86 | "post_type": "message", 87 | "group_id": 12345 88 | }) 89 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | import pytest 5 | from loguru import logger 6 | from fastapi.testclient import TestClient 7 | 8 | logger.remove() 9 | logger.add(sys.stdout, level="DEBUG") 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def _mock_dicerobot(monkeypatch) -> None: 14 | def _init_logger() -> None: 15 | logger.debug("Mocking init_logger") 16 | 17 | def _init_database() -> None: 18 | logger.debug("Mocking init_database") 19 | 20 | def _clean_database() -> None: 21 | logger.debug("Mocking clean_database") 22 | 23 | def _init_config() -> None: 24 | logger.debug("Mocking init_config") 25 | 26 | def _save_config() -> None: 27 | logger.debug("Mocking save_config") 28 | 29 | def _init_manager() -> None: 30 | logger.debug("Mocking init_manager") 31 | 32 | def _clean_manager() -> None: 33 | logger.debug("Mocking clean_manager") 34 | 35 | monkeypatch.setattr("app.logger", logger) 36 | monkeypatch.setattr("app.init_logger", _init_logger) 37 | monkeypatch.setattr("app.init_database", _init_database) 38 | monkeypatch.setattr("app.clean_database", _clean_database) 39 | monkeypatch.setattr("app.init_config", _init_config) 40 | monkeypatch.setattr("app.save_config", _save_config) 41 | monkeypatch.setattr("app.init_manager", _init_manager) 42 | monkeypatch.setattr("app.clean_manager", _clean_manager) 43 | 44 | 45 | @pytest.fixture(autouse=True) 46 | def _mock_napcat(monkeypatch) -> None: 47 | from app.models.network.napcat import ( 48 | GetLoginInfoResponse, GetFriendListResponse, GetGroupInfoResponse, GetGroupListResponse, 49 | GetGroupMemberInfoResponse, GetGroupMemberListResponse, SendPrivateMessageResponse, SendGroupMessageResponse, 50 | SetGroupCardResponse, SetGroupLeaveResponse, SetFriendAddRequestResponse, SetGroupAddRequestResponse 51 | ) 52 | from app.models.report.segment import Segment 53 | from app.enum import GroupAddRequestSubType 54 | 55 | def _get_login_info() -> GetLoginInfoResponse: 56 | logger.debug("Mocking get_login_info") 57 | 58 | return GetLoginInfoResponse.model_validate({ 59 | "status": "ok", 60 | "retcode": 0, 61 | "data": { 62 | "user_id": 99999, 63 | "nickname": "Shinji" 64 | }, 65 | "message": "", 66 | "wording": "", 67 | "echo": None 68 | }) 69 | 70 | def _get_friend_list() -> GetFriendListResponse: 71 | logger.debug("Mocking get_friend_list") 72 | 73 | return GetFriendListResponse.model_validate({ 74 | "status": "ok", 75 | "retcode": 0, 76 | "data": [ 77 | { 78 | "user_id": 88888, 79 | "nickname": "Kaworu", 80 | "remark": "", 81 | "sex": "male", 82 | "level": 0 83 | }, 84 | { 85 | "user_id": 99999, 86 | "nickname": "Shinji", 87 | "remark": "", 88 | "sex": "male", 89 | "level": 0 90 | } 91 | ], 92 | "message": "", 93 | "wording": "", 94 | "echo": None 95 | }) 96 | 97 | def _get_group_info(_: int, __: bool = False) -> GetGroupInfoResponse: 98 | logger.debug("Mocking get_group_info") 99 | 100 | return GetGroupInfoResponse.model_validate({ 101 | "status": "ok", 102 | "retcode": 0, 103 | "data": { 104 | "group_id": 12345, 105 | "group_name": "Nerv", 106 | "member_count": 2, 107 | "max_member_count": 200 108 | }, 109 | "message": "", 110 | "wording": "", 111 | "echo": None 112 | }) 113 | 114 | def _get_group_list() -> GetGroupListResponse: 115 | logger.debug("Mocking get_group_list") 116 | 117 | return GetGroupListResponse.model_validate({ 118 | "status": "ok", 119 | "retcode": 0, 120 | "data": [ 121 | { 122 | "group_id": 12345, 123 | "group_name": "Nerv", 124 | "member_count": 2, 125 | "max_member_count": 200 126 | } 127 | ], 128 | "message": "", 129 | "wording": "", 130 | "echo": None 131 | }) 132 | 133 | def _get_group_member_info(_: int, __: int, ___: bool = False) -> GetGroupMemberInfoResponse: 134 | logger.debug("Mocking get_group_member_info") 135 | 136 | return GetGroupMemberInfoResponse.model_validate({ 137 | "status": "ok", 138 | "retcode": 0, 139 | "data": { 140 | "group_id": 12345, 141 | "user_id": 88888, 142 | "nickname": "Kaworu", 143 | "card": "", 144 | "sex": "male", 145 | "age": 0, 146 | "area": "", 147 | "level": "0", 148 | "qq_level": 0, 149 | "join_time": 0, 150 | "last_sent_time": 0, 151 | "title_expire_time": 0, 152 | "unfriendly": False, 153 | "card_changeable": True, 154 | "is_robot": False, 155 | "shut_up_timestamp": 0, 156 | "role": "owner", 157 | "title": "" 158 | }, 159 | "message": "", 160 | "wording": "", 161 | "echo": None 162 | }) 163 | 164 | def _get_group_member_list(_: int) -> GetGroupMemberListResponse: 165 | logger.debug("Mocking get_group_member_list") 166 | 167 | return GetGroupMemberListResponse.model_validate({ 168 | "status": "ok", 169 | "retcode": 0, 170 | "data": [ 171 | { 172 | "group_id": 12345, 173 | "user_id": 88888, 174 | "nickname": "Kaworu", 175 | "card": "", 176 | "sex": "male", 177 | "age": 0, 178 | "area": "", 179 | "level": "0", 180 | "qq_level": 0, 181 | "join_time": 0, 182 | "last_sent_time": 0, 183 | "title_expire_time": 0, 184 | "unfriendly": False, 185 | "card_changeable": True, 186 | "is_robot": False, 187 | "shut_up_timestamp": 0, 188 | "role": "owner", 189 | "title": "" 190 | }, 191 | { 192 | "group_id": 12345, 193 | "user_id": 99999, 194 | "nickname": "Shinji", 195 | "card": "", 196 | "sex": "male", 197 | "age": 0, 198 | "area": "", 199 | "level": "0", 200 | "qq_level": 0, 201 | "join_time": 0, 202 | "last_sent_time": 0, 203 | "title_expire_time": 0, 204 | "unfriendly": False, 205 | "card_changeable": True, 206 | "is_robot": False, 207 | "shut_up_timestamp": 0, 208 | "role": "admin", 209 | "title": "" 210 | } 211 | ], 212 | "message": "", 213 | "wording": "", 214 | "echo": None 215 | }) 216 | 217 | def _send_private_message(_: int, message: list[Segment], __: bool = False) -> SendPrivateMessageResponse: 218 | logger.debug("Mocking send_private_message") 219 | logger.debug(f"Message: {[segment.model_dump() for segment in message]}") 220 | 221 | return SendPrivateMessageResponse.model_validate({ 222 | "status": "ok", 223 | "retcode": 0, 224 | "data": { 225 | "message_id": -1234567890 226 | }, 227 | "message": "", 228 | "wording": "", 229 | "echo": None 230 | }) 231 | 232 | def _send_group_message(_: int, message: list[Segment], __: bool = False) -> SendGroupMessageResponse: 233 | logger.debug("Mocking send_group_message") 234 | logger.debug(f"Message: {[segment.model_dump() for segment in message]}") 235 | 236 | return SendGroupMessageResponse.model_validate({ 237 | "status": "ok", 238 | "retcode": 0, 239 | "data": { 240 | "message_id": -1234567890 241 | }, 242 | "message": "", 243 | "wording": "", 244 | "echo": None 245 | }) 246 | 247 | def _set_group_card(_: int, __: int, ___: str = "") -> SetGroupCardResponse: 248 | logger.debug("Mocking set_group_card") 249 | 250 | return SetGroupCardResponse.model_validate({ 251 | "status": "ok", 252 | "retcode": 0, 253 | "data": None, 254 | "message": "", 255 | "wording": "", 256 | "echo": None 257 | }) 258 | 259 | def _set_group_leave(_: int, __: bool = False) -> SetGroupLeaveResponse: 260 | logger.debug("Mocking set_group_leave") 261 | 262 | return SetGroupLeaveResponse.model_validate({ 263 | "status": "ok", 264 | "retcode": 0, 265 | "data": None, 266 | "message": "", 267 | "wording": "", 268 | "echo": None 269 | }) 270 | 271 | def _set_friend_add_request(_: str, __: bool, ___: str = "") -> SetFriendAddRequestResponse: 272 | logger.debug("Mocking set_friend_add_request") 273 | 274 | return SetFriendAddRequestResponse.model_validate({ 275 | "status": "ok", 276 | "retcode": 0, 277 | "data": None, 278 | "message": "", 279 | "wording": "", 280 | "echo": None 281 | }) 282 | 283 | def _set_group_add_request( 284 | _: str, __: GroupAddRequestSubType, ___: bool, ____: str = "" 285 | ) -> SetGroupAddRequestResponse: 286 | logger.debug("Mocking set_group_add_request") 287 | 288 | return SetGroupAddRequestResponse.model_validate({ 289 | "status": "ok", 290 | "retcode": 0, 291 | "data": None, 292 | "message": "", 293 | "wording": "", 294 | "echo": None 295 | }) 296 | 297 | _NAPCAT_MOCKS = { 298 | "app.network.napcat.get_login_info": _get_login_info, 299 | "app.network.napcat.get_friend_list": _get_friend_list, 300 | "app.network.napcat.get_group_info": _get_group_info, 301 | "app.network.napcat.get_group_list": _get_group_list, 302 | "app.network.napcat.get_group_member_info": _get_group_member_info, 303 | "app.network.napcat.get_group_member_list": _get_group_member_list, 304 | "app.network.napcat.send_private_message": _send_private_message, 305 | "app.network.napcat.send_group_message": _send_group_message, 306 | "app.network.napcat.set_group_card": _set_group_card, 307 | "app.network.napcat.set_group_leave": _set_group_leave, 308 | "app.network.napcat.set_friend_add_request": _set_friend_add_request, 309 | "app.network.napcat.set_group_add_request": _set_group_add_request, 310 | "plugin.napcat_send_private_message": _send_private_message, 311 | "plugin.napcat_send_group_message": _send_group_message, 312 | } 313 | 314 | for target, replacement in _NAPCAT_MOCKS.items(): 315 | monkeypatch.setattr(target, replacement) 316 | 317 | 318 | @pytest.fixture() 319 | def client() -> TestClient: 320 | from app import dicerobot 321 | 322 | with TestClient(dicerobot) as client: 323 | yield client 324 | 325 | 326 | @pytest.fixture() 327 | def openai() -> None: 328 | from app.config import plugin_settings 329 | 330 | plugin_settings._plugin_settings["dicerobot.chat"]["api_key"] = os.environ.get("TEST_OPENAI_API_KEY") or "" 331 | plugin_settings._plugin_settings["dicerobot.conversation"]["api_key"] = os.environ.get("TEST_OPENAI_API_KEY") or "" 332 | -------------------------------------------------------------------------------- /tests/test_bot.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.enum import Role 4 | from app.exceptions import OrderError 5 | from . import BaseTest 6 | 7 | 8 | class TestBot(BaseTest): 9 | def test_bot(self, client): 10 | self.wait_for_running() 11 | 12 | # Bot info 13 | message = self.build_group_message(".bot") 14 | result = self.post_message(client, message) 15 | 16 | message = self.build_group_message(".bot about") 17 | self.post_message(client, message) 18 | 19 | # Bot off 20 | message = self.build_group_message(".bot off") 21 | message.sender.role = Role.MEMBER 22 | 23 | with pytest.raises(OrderError): 24 | self.post_message(client, message) 25 | 26 | message.sender.role = Role.ADMIN 27 | self.post_message(client, message) 28 | 29 | message = self.build_group_message(".bot") # Bot should not reply 30 | self.post_message(client, message) 31 | 32 | # Bot on 33 | message = self.build_group_message(".bot on") 34 | message.sender.role = Role.MEMBER 35 | 36 | with pytest.raises(OrderError): 37 | self.post_message(client, message) 38 | 39 | message.sender.role = Role.ADMIN 40 | self.post_message(client, message) 41 | 42 | message = self.build_group_message(".bot") # Bot should reply 43 | self.post_message(client, message) 44 | 45 | # Bot nickname 46 | message = self.build_group_message(".bot name Adam") 47 | message.sender.role = Role.MEMBER 48 | 49 | with pytest.raises(OrderError): 50 | self.post_message(client, message) 51 | 52 | message.sender.role = Role.ADMIN 53 | self.post_message(client, message) 54 | 55 | message = self.build_group_message(".bot name") 56 | message.sender.role = Role.MEMBER 57 | 58 | with pytest.raises(OrderError): 59 | self.post_message(client, message) 60 | 61 | message.sender.role = Role.ADMIN 62 | self.post_message(client, message) 63 | -------------------------------------------------------------------------------- /tests/test_bp_dice.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OrderSuspiciousError, OrderRepetitionExceededError, OrderError 4 | from . import BaseTest 5 | 6 | 7 | class TestBPDice(BaseTest): 8 | def test_bonus_dice(self, client): 9 | self.wait_for_running() 10 | 11 | # Valid expressions 12 | message = self.build_group_message(".rb") 13 | self.post_message(client, message) 14 | 15 | message = self.build_group_message(".rb2") 16 | self.post_message(client, message) 17 | 18 | message = self.build_group_message(".rbReason") 19 | self.post_message(client, message) 20 | 21 | message = self.build_group_message(".r b3Reason") 22 | self.post_message(client, message) 23 | 24 | message = self.build_group_message(".r b4 Reason") 25 | self.post_message(client, message) 26 | 27 | message = self.build_group_message(".rb#0") 28 | self.post_message(client, message) 29 | 30 | message = self.build_group_message(".rb2#3") 31 | self.post_message(client, message) 32 | 33 | message = self.build_group_message(".r b4 Reason #3") 34 | self.post_message(client, message) 35 | 36 | # Invalid order 37 | message = self.build_group_message(".rb#100") 38 | 39 | with pytest.raises(OrderRepetitionExceededError): 40 | self.post_message(client, message) 41 | 42 | # Invalid expressions 43 | message = self.build_group_message(".rb999") 44 | 45 | with pytest.raises(OrderError): 46 | self.post_message(client, message) 47 | 48 | # Suspicious expressions 49 | message = self.build_group_message(".rb99999") 50 | 51 | with pytest.raises(OrderSuspiciousError): 52 | self.post_message(client, message) 53 | 54 | def test_penalty_dice(self, client): 55 | self.wait_for_running() 56 | 57 | # Valid expressions 58 | message = self.build_group_message(".rp") 59 | self.post_message(client, message) 60 | 61 | message = self.build_group_message(".rp2") 62 | self.post_message(client, message) 63 | 64 | message = self.build_group_message(".rpReason") 65 | self.post_message(client, message) 66 | 67 | message = self.build_group_message(".r p3Reason") 68 | self.post_message(client, message) 69 | 70 | message = self.build_group_message(".r p4 Reason") 71 | self.post_message(client, message) 72 | 73 | message = self.build_group_message(".rp#0") 74 | self.post_message(client, message) 75 | 76 | message = self.build_group_message(".rp2#3") 77 | self.post_message(client, message) 78 | 79 | message = self.build_group_message(".r p4 Reason #3") 80 | self.post_message(client, message) 81 | 82 | # Invalid order 83 | message = self.build_group_message(".rp#100") 84 | 85 | with pytest.raises(OrderRepetitionExceededError): 86 | self.post_message(client, message) 87 | 88 | # Invalid expressions 89 | message = self.build_group_message(".rp999") 90 | 91 | with pytest.raises(OrderError): 92 | self.post_message(client, message) 93 | 94 | # Suspicious expressions 95 | message = self.build_group_message(".rp99999") 96 | 97 | with pytest.raises(OrderSuspiciousError): 98 | self.post_message(client, message) 99 | -------------------------------------------------------------------------------- /tests/test_chat.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OrderInvalidError 4 | from . import BaseTest 5 | 6 | 7 | class TestChat(BaseTest): 8 | def test_chat(self, client, openai): 9 | self.wait_for_running() 10 | 11 | # Valid usage 12 | message = self.build_group_message(".chat Who are you?") 13 | self.post_message(client, message) 14 | 15 | # Invalid usage 16 | message = self.build_group_message(".chat") 17 | 18 | with pytest.raises(OrderInvalidError): 19 | self.post_message(client, message) 20 | -------------------------------------------------------------------------------- /tests/test_conversation.py: -------------------------------------------------------------------------------- 1 | from . import BaseTest 2 | 3 | 4 | class TestConversation(BaseTest): 5 | def test_conversation(self, client, openai): 6 | self.wait_for_running() 7 | 8 | message = self.build_group_message(".conv") 9 | self.post_message(client, message) 10 | 11 | message = self.build_group_message(".conv 你是一只猫娘,从现在开始你说的每句话结尾都必须加上喵") 12 | self.post_message(client, message) 13 | 14 | message = self.build_group_message(".conv usage") 15 | self.post_message(client, message) 16 | 17 | message = self.build_group_message(".conv 你是谁?") 18 | self.post_message(client, message) 19 | 20 | message = self.build_group_message(".conv usage") 21 | self.post_message(client, message) 22 | 23 | def test_guidance(self, client): 24 | self.wait_for_running() 25 | 26 | message_chain = self.build_group_message(".conv") 27 | self.post_message(client, message_chain) 28 | 29 | message_chain = self.build_group_message(".guide 你是一只猫娘,从现在开始你说的每句话结尾都必须加上喵") 30 | self.post_message(client, message_chain) 31 | 32 | message_chain = self.build_group_message(".conv 你是谁?") 33 | self.post_message(client, message_chain) 34 | 35 | message_chain = self.build_group_message(".conv usage") 36 | self.post_message(client, message_chain) 37 | -------------------------------------------------------------------------------- /tests/test_dice.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OrderRepetitionExceededError, OrderError 4 | from . import BaseTest 5 | 6 | 7 | class TestDice(BaseTest): 8 | def test_dice(self, client): 9 | self.wait_for_running() 10 | 11 | # Valid expressions 12 | message = self.build_group_message(".r") 13 | self.post_message(client, message) 14 | 15 | message = self.build_group_message(".rd") 16 | self.post_message(client, message) 17 | 18 | message = self.build_group_message(".rd100") 19 | self.post_message(client, message) 20 | 21 | message = self.build_group_message(".r10d100k2") 22 | self.post_message(client, message) 23 | 24 | message = self.build_group_message(".r(5d100+d30+666)*5-2+6d50k2x2+6X5 Some Reason") 25 | self.post_message(client, message) 26 | 27 | message = self.build_group_message(".rd50Reason") 28 | self.post_message(client, message) 29 | 30 | message = self.build_group_message(".rd50 Reason") 31 | self.post_message(client, message) 32 | 33 | message = self.build_group_message(".rdReason") 34 | self.post_message(client, message) 35 | 36 | message = self.build_group_message(".rd 50") 37 | self.post_message(client, message) 38 | 39 | message = self.build_group_message(".rd#0") 40 | self.post_message(client, message) 41 | 42 | message = self.build_group_message(".r10d100k2#3") 43 | self.post_message(client, message) 44 | 45 | message = self.build_group_message(".r(5d100+d30+666)*5-2+6d50k2x2+6X5 Some Reason #3") 46 | self.post_message(client, message) 47 | 48 | # Invalid order 49 | message = self.build_group_message(".r#100") 50 | 51 | with pytest.raises(OrderRepetitionExceededError): 52 | self.post_message(client, message) 53 | 54 | # Invalid expressions 55 | message = self.build_group_message(".r10d100kk2+5") 56 | 57 | with pytest.raises(OrderError): 58 | self.post_message(client, message) 59 | 60 | message = self.build_group_message(".r(10d100k2+5") 61 | 62 | with pytest.raises(OrderError): 63 | self.post_message(client, message) 64 | -------------------------------------------------------------------------------- /tests/test_hidden_dice.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OrderError 4 | from . import BaseTest 5 | 6 | 7 | class TestHiddenDice(BaseTest): 8 | def test_hidden_dice(self, client): 9 | self.wait_for_running() 10 | 11 | # In group 12 | message = self.build_group_message(".rh") 13 | self.post_message(client, message) 14 | 15 | # Not friend 16 | message.user_id = 114514 17 | message.sender.user_id = 114514 18 | 19 | with pytest.raises(OrderError): 20 | self.post_message(client, message) 21 | 22 | # Not in group 23 | message = self.build_private_message(".rh") 24 | 25 | with pytest.raises(OrderError): 26 | self.post_message(client, message) 27 | -------------------------------------------------------------------------------- /tests/test_skill_roll.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.exceptions import OrderSuspiciousError, OrderRepetitionExceededError 4 | from . import BaseTest 5 | 6 | 7 | class TestSkillRoll(BaseTest): 8 | def test_skill_roll(self, client): 9 | self.wait_for_running() 10 | 11 | # Valid expressions 12 | message = self.build_group_message(".ra50") 13 | self.post_message(client, message) 14 | 15 | message = self.build_group_message(".ra75#3") 16 | self.post_message(client, message) 17 | 18 | # Invalid order 19 | message = self.build_group_message(".ra#100") 20 | 21 | with pytest.raises(OrderRepetitionExceededError): 22 | self.post_message(client, message) 23 | 24 | # Suspicious expressions 25 | message = self.build_group_message(".ra999999999") 26 | 27 | with pytest.raises(OrderSuspiciousError): 28 | self.post_message(client, message) 29 | 30 | def test_show_rule(self, client): 31 | self.wait_for_running() 32 | 33 | message_chain = self.build_group_message(".rule") 34 | self.post_message(client, message_chain) 35 | --------------------------------------------------------------------------------