├── .gitignore ├── README.md ├── bot.py └── nullbot └── plugins ├── nullfs_client ├── __init__.py └── config.py ├── repeater ├── __init__.py ├── config.py └── model.py ├── social_butterfly └── __init__.py ├── startup_hook └── __init__.py └── turingbot ├── __init__.py ├── config.py └── model.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env* 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # nohup log 141 | nohup.out 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nullbot 2 | 3 | Reworking :) 4 | 5 | ## Plan 6 | 7 | ### Protocol server 8 | 9 | https://github.com/Mrs4s/go-cqhttp 10 | 11 | ### Nonebot2 instance 12 | 13 | https://github.com/nonebot/nonebot2 14 | 15 | ### Plugins implementation 16 | 17 | - [x] Repeat bullshit 18 | - [x] Random reply (calling TURING api) 19 | - [ ] Help 20 | - [ ] Blog manager 21 | - [ ] OJ manager 22 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot.adapters.cqhttp import Bot as NullBot 3 | 4 | nonebot.init() 5 | driver = nonebot.get_driver() 6 | driver.register_adapter("cqhttp", NullBot) 7 | nonebot.load_builtin_plugins() 8 | nonebot.load_plugins("nullbot/plugins") 9 | 10 | if __name__ == "__main__": 11 | nonebot.run() 12 | -------------------------------------------------------------------------------- /nullbot/plugins/nullfs_client/__init__.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import nonebot 3 | from nonebot import on_message 4 | from nonebot.log import logger 5 | from nonebot.adapters.cqhttp import Bot, MessageEvent 6 | from nonebot.matcher import Matcher 7 | from nonebot.permission import SUPERUSER 8 | from .config import Config 9 | 10 | global_config = nonebot.get_driver().config 11 | plugin_config = Config(**global_config.dict()) 12 | 13 | def mount_nullfs(): 14 | with httpx.Client() as client: 15 | client.post(f"{plugin_config.nullfs_api_url}/mount", data={'mount_point': plugin_config.nullfs_mount_point}) 16 | 17 | matcher = on_message(priority=1, block=False, permission=SUPERUSER) 18 | 19 | @matcher.handle() 20 | async def nullfs_entry(bot: Bot, event: MessageEvent, matcher: Matcher): 21 | message = str(event.get_message()).strip() 22 | cmd_args = message.split() 23 | if len(cmd_args) == 0: 24 | return 25 | 26 | cmd = cmd_args[0] 27 | async with httpx.AsyncClient() as client: 28 | mount_nullfs() 29 | nullfs_api = plugin_config.nullfs_api_url 30 | r = await client.get(f"{nullfs_api}/supports?cmd={cmd}") 31 | if r.status_code == 200: 32 | user_id = event.user_id 33 | cmd_res = await client.put(f"{nullfs_api}/user/{user_id}/{cmd}", data={'arg': cmd_args[1:]}) 34 | if cmd_res.status_code == 200: 35 | data = cmd_res.json()['data'] 36 | logger.debug(data) 37 | await matcher.send(str(data)) 38 | matcher.stop_propagation() 39 | -------------------------------------------------------------------------------- /nullbot/plugins/nullfs_client/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | class Config(BaseSettings): 4 | 5 | nullfs_api_url: str = "http://127.0.0.1:5000" 6 | nullfs_mount_point: str = "/home/flask/image/nullfs" 7 | 8 | class Config: 9 | extra = "ignore" 10 | -------------------------------------------------------------------------------- /nullbot/plugins/repeater/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import on_message 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.log import logger 5 | from .config import Config 6 | from .model import LFUCache, LFUNode 7 | 8 | global_config = nonebot.get_driver().config 9 | plugin_config = Config(**global_config.dict()) 10 | 11 | matcher = on_message(priority=10, block=False) 12 | 13 | async def callback(node: LFUNode): 14 | if node.count == plugin_config.repeat_threhold: 15 | logger.debug(f"Repeating bullshit: {node.key}") 16 | await node.object.send(node.key) 17 | 18 | message_cache = LFUCache(plugin_config.cache_capacity, callback) 19 | 20 | @matcher.handle() 21 | async def _(bot: Bot, event: Event): 22 | await message_cache.put(str(event.get_message()).strip(), matcher) 23 | -------------------------------------------------------------------------------- /nullbot/plugins/repeater/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | class Config(BaseSettings): 4 | 5 | repeat_threhold: int = 2 6 | cache_capacity: int = 4 7 | 8 | class Config: 9 | extra = "ignore" 10 | -------------------------------------------------------------------------------- /nullbot/plugins/repeater/model.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, OrderedDict 2 | 3 | class LFUNode: 4 | def __init__(self, key, object, count): 5 | self.key = key 6 | self.object = object 7 | self.count = count 8 | 9 | class LFUCache: 10 | """Least frequently used cache for repeating bullshit""" 11 | def __init__(self, capacity: int, callback = None): 12 | self.capacity = capacity 13 | self.key_to_node = {} 14 | self.count_to_ordered_node = defaultdict(OrderedDict) 15 | self.min_count = -1 16 | self.callback = callback 17 | 18 | async def get(self, key): 19 | if key not in self.key_to_node: 20 | return None 21 | 22 | node = self.key_to_node[key] 23 | del self.count_to_ordered_node[node.count][key] 24 | if not self.count_to_ordered_node[node.count]: 25 | del self.count_to_ordered_node[node.count] 26 | if self.min_count == node.count: 27 | self.min_count += 1 28 | 29 | node.count += 1 30 | self.count_to_ordered_node[node.count][key] = node 31 | 32 | # check callback trigger 33 | await self.callback(node) 34 | 35 | return node.object 36 | 37 | async def put(self, key, object = None): 38 | if key in self.key_to_node: 39 | self.key_to_node[key].object = object 40 | await self.get(key) 41 | return 42 | 43 | if len(self.key_to_node) == self.capacity: 44 | k, _ = self.count_to_ordered_node[self.min_count].popitem(last=False) # FIFO 45 | del self.key_to_node[k] 46 | 47 | self.key_to_node[key] = LFUNode(key, object, 1) 48 | self.count_to_ordered_node[1][key] = self.key_to_node[key] 49 | self.min_count = 1 50 | 51 | return 52 | -------------------------------------------------------------------------------- /nullbot/plugins/social_butterfly/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import on_request 3 | from nonebot.adapters import Bot, Event 4 | from nonebot.adapters.cqhttp import GroupRequestEvent 5 | 6 | matcher = on_request() 7 | 8 | @matcher.handle() 9 | async def _(bot: Bot, event: GroupRequestEvent): 10 | if event.sub_type == "invite": 11 | await bot.send_group_msg(group_id=event.group_id, message="大家好,我是闹闹~") 12 | -------------------------------------------------------------------------------- /nullbot/plugins/startup_hook/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot.adapters import Bot 3 | from nonebot.log import logger 4 | 5 | driver = nonebot.get_driver() 6 | 7 | @driver.on_bot_connect 8 | async def notify_superusers(bot: Bot): 9 | for su in driver.config.superusers: 10 | logger.debug(f"Notifying superuser {su} on bot connect") 11 | await bot.send_msg(user_id=su, message="Bot connected to protocol server!") 12 | -------------------------------------------------------------------------------- /nullbot/plugins/turingbot/__init__.py: -------------------------------------------------------------------------------- 1 | import nonebot 2 | from nonebot import on_message 3 | from nonebot.adapters.cqhttp import Bot, MessageEvent 4 | from .model import get_reply_strategy 5 | from .config import Config 6 | 7 | global_config = nonebot.get_driver().config 8 | plugin_config = Config(**global_config.dict()) 9 | 10 | matcher = on_message(priority=2, block=False) 11 | strategy = get_reply_strategy() 12 | 13 | @matcher.handle() 14 | async def turingbot_reply(bot: Bot, event: MessageEvent): 15 | await strategy.process(event, matcher) 16 | -------------------------------------------------------------------------------- /nullbot/plugins/turingbot/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings 2 | 3 | class Config(BaseSettings): 4 | 5 | turingbot_api_url: str = "http://openapi.tuling123.com/openapi/api/v2" 6 | turingbot_api_key: str 7 | daily_api_quota: int = 500 8 | 9 | strategy: str = "uniform_and_boost" 10 | 11 | # UniformProb 12 | uniform_prob: float = 1/20 13 | 14 | # UniformAndBoost 15 | boosted_prob: float = 4/5 16 | 17 | class Config: 18 | extra = "ignore" 19 | -------------------------------------------------------------------------------- /nullbot/plugins/turingbot/model.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import json 3 | from datetime import datetime 4 | import random 5 | import nonebot 6 | from nonebot.adapters.cqhttp import MessageEvent 7 | from nonebot.matcher import Matcher 8 | from nonebot.log import logger 9 | from .config import Config 10 | 11 | global_config = nonebot.get_driver().config 12 | plugin_config = Config(**global_config.dict()) 13 | 14 | def refresh_quota_daily(last_timestamp: datetime): 15 | if last_timestamp is None: 16 | return True 17 | now = datetime.now() 18 | return now.date() != last_timestamp.date() 19 | 20 | class ReplyStrategy: 21 | def __init__(self, predicate_func, quota_limit = plugin_config.daily_api_quota, refresh_quota_func = refresh_quota_daily): 22 | self.predicate = predicate_func 23 | self.refresh = refresh_quota_func 24 | self.quota_limit = quota_limit 25 | self.quota_used = 0 26 | self.last_message_timestamp = None 27 | 28 | def is_available(self): 29 | if self.refresh(self.last_message_timestamp): 30 | self.quota_used = 0 31 | 32 | return self.quota_used < self.quota_limit 33 | 34 | async def process(self, event: MessageEvent, matcher: Matcher): 35 | self.last_message_timestamp = datetime.now() 36 | if not self.is_available(): 37 | return 38 | 39 | should_reply = self.predicate(event) 40 | if should_reply: 41 | await self.do_reply(event, matcher) 42 | self.post_reply(should_reply, event) 43 | 44 | async def do_reply(self, event: MessageEvent, matcher: Matcher): 45 | reply = await self.request_api(event) 46 | logger.debug(f"Turingbot reply: {reply}") 47 | if reply: 48 | await matcher.send(reply) 49 | 50 | def post_reply(self, replied: bool, event: MessageEvent): 51 | return 52 | 53 | async def request_api(self, event: MessageEvent): 54 | message = str(event.get_message()) 55 | user_id = event.get_user_id() 56 | user_nickname = event.sender.nickname 57 | payload = { 58 | 'reqType': 0, 59 | 'perception': { 60 | 'inputText': { 61 | 'text': message 62 | } 63 | }, 64 | 'userInfo': { 65 | 'apiKey': plugin_config.turingbot_api_key, 66 | 'userId': user_id, 67 | 'userIdName': user_nickname 68 | } 69 | } 70 | 71 | try: 72 | async with httpx.AsyncClient() as client: 73 | r = await client.post(plugin_config.turingbot_api_url, json=payload) 74 | if r.status_code == 200: 75 | self.quota_used += 1 76 | data = json.loads(r.text) 77 | logger.debug(f"Got turingapi data: {data}") 78 | if data['intent']['code'] == 4003: 79 | self.quota_used = self.quota_limit 80 | logger.warning("Reached turingapi limit!") 81 | return "" 82 | 83 | reply = "" 84 | for result in data['results']: 85 | reply += "\n".join(result['values'].values()) 86 | 87 | return reply 88 | except Exception as e: 89 | logger.warning(f"Exception calling turingapi: {e}") 90 | return "" 91 | 92 | class UniformProbStrategy(ReplyStrategy): 93 | @staticmethod 94 | def predicate(_: MessageEvent): 95 | t = random.uniform(0, 1) 96 | logger.debug(f"Uniform strategy predicate: {t}") 97 | return t < plugin_config.uniform_prob 98 | 99 | def __init__(self): 100 | super().__init__(UniformProbStrategy.predicate) 101 | 102 | class UniformAndBoostStrategy(ReplyStrategy): 103 | """Reply probability is uniform at the normal state, 104 | and is boosted to a higher value agaist the user to whom the bot have just replied.""" 105 | def predicate(self, event: MessageEvent): 106 | p = plugin_config.boosted_prob if event.user_id == self.last_replied_user_id else plugin_config.uniform_prob 107 | t = random.uniform(0, 1) 108 | logger.debug(f"UniformAndBoost predicate: {t} < {p}?") 109 | return t < p 110 | 111 | def __init__(self): 112 | self.last_replied_user_id = None 113 | super().__init__(self.predicate) 114 | 115 | def post_reply(self, replied: bool, event: MessageEvent): 116 | self.last_replied_user_id = event.user_id if replied else None 117 | 118 | def get_reply_strategy(): 119 | if plugin_config.strategy == "uniform": 120 | return UniformProbStrategy() 121 | if plugin_config.strategy == "uniform_and_boost": 122 | return UniformAndBoostStrategy() 123 | raise NotImplementedError(f"{plugin_config.strategy} reply strategy not implemented!") 124 | --------------------------------------------------------------------------------