diff --git a/.gitignore b/.gitignore index c0a392d1..b10bd6a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .idea -*.iml data config.py __pycache__ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6386f073..00000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -sudo: required - -language: python -python: - - "3.5" - -services: - - docker - -script: - - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS - - export TAG=`if [[ $TRAVIS_BRANCH =~ ^v[0-9.]+$ ]]; then echo ${TRAVIS_BRANCH#v}; else echo $TRAVIS_BRANCH; fi` - - docker build -f Dockerfile -t $DOCKER_REPO:$TAG . - - if [[ $TRAVIS_BRANCH =~ ^v[0-9.]+$ ]]; then docker tag $DOCKER_REPO:$TAG $DOCKER_REPO:latest; fi - - docker push $DOCKER_REPO \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 603f4fa9..00000000 --- a/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM python:3.5.1 -MAINTAINER Richard Chien - -COPY *.py ./ -COPY msg_src_adapters msg_src_adapters -COPY filters filters -COPY commands commands -COPY nl_processors nl_processors -COPY requirements.txt requirements.txt - -RUN pip install --upgrade pip -RUN pip install -r requirements.txt - -RUN apt-get update \ - && apt-get install -y libav-tools \ - && rm -rf /var/lib/apt/lists/* - -CMD python app.py \ No newline at end of file diff --git a/README.md b/README.md index b0962283..fbf40fc3 100644 --- a/README.md +++ b/README.md @@ -1,96 +1 @@ -# XiaoKai Bot 小开机器人 - -[![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](https://github.com/CCZU-DEV/xiaokai-bot/blob/master/LICENSE) -[![Build Status](https://travis-ci.org/CCZU-DEV/xiaokai-bot.svg?branch=master)](https://travis-ci.org/CCZU-DEV/xiaokai-bot) -[![Tag](https://img.shields.io/github/tag/CCZU-DEV/xiaokai-bot.svg)](https://github.com/CCZU-DEV/xiaokai-bot/tags) -[![Docker Repository](https://img.shields.io/badge/docker-richardchien/xiaokai--bot-blue.svg)](https://hub.docker.com/r/richardchien/xiaokai-bot/) -[![Docker Pulls](https://img.shields.io/docker/pulls/richardchien/xiaokai-bot.svg)](https://hub.docker.com/r/richardchien/xiaokai-bot/) -![QQ](https://img.shields.io/badge/qq-1647869577-orange.svg) -![WeChat](https://img.shields.io/badge/wechat-cczu__xiaokai-brightgreen.svg) - -用 Python 编写的即时聊天平台机器人,通过适配器模式支持使用多种 bot 框架/平台作为消息源(目前支持 [Mojo-Webqq](https://github.com/sjdy521/Mojo-Webqq)、[Mojo-Weixin](https://github.com/sjdy521/Mojo-Weixin)、[CoolQ HTTP API](https://github.com/richardchien/coolq-http-api)),支持自定义插件。 - -请注意区分此程序和其它模拟登录或封装接口的聊天平台**客户端**,此程序不负责登录或维护即时聊天平台的账号的状态,而只负责收到消息之后对消息的分析、处理、回复等逻辑,本程序通过适配器来与所支持的聊天平台客户端进行通讯,通常包括上报数据的统一化、调用接口获取额外信息、发送消息等,而这些聊天平台客户端(很多时候它们的项目名称也是「某某 bot」,相当于机器人的前端)需要你自行运行。 - -## 如何运行 - -### 预备 - -首先你需要了解如何运行你需要的消息源。以 Mojo-Weixin 为例,查看它的 [官方使用文档](https://github.com/sjdy521/Mojo-Weixin#如何使用) 来了解如何运行,其它消息源基本类似。 - -注意消息源必须已有相应的消息源适配器,消息源的概念解释及目前支持的消息源见 [消息源列表](https://cczu-dev.github.io/xiaokai-bot/#/Message_Sources)。 - -### 配置 - -复制 `config.sample.py` 为 `config.py`,然后修改 `config.py` 中的 `message_sources` 字段,定义你需要的消息源,例如: - -```python -{ - 'via': 'mojo_weixin', - 'login_id': 'your_login_id', - 'superuser_id': 'your_superuser_id', - 'api_url': 'http://127.0.0.1:5001/openwx', -} -``` - -上面的定义了一个 Mojo-Weixin 消息源,登录号是 `your_login_id`,超级用户 ID 是 `your_superuser_id`,Mojo-Weixin API 地址是 `http://127.0.0.1:5001/openwx`,`via` 和 `login_id` 是必须的,其它字段根据不同消息源适配器可能略有不同,具体请查看 [消息源列表](https://cczu-dev.github.io/xiaokai-bot/#/Message_Sources)。 - -与此同时,当你决定了本 bot 程序要运行的 IP 和端口之后,要把相应的上报 URL 填写到消息源程序的配置参数中,上报 URL 格式必须为 `http://your_host:your_port//`,这里可以见到 `via` 和 `login_id`,即为之前定义消息源时必填的项,用来唯一确定一个消息来源。比如如果你使用 Mojo-Weixin 登录一个 bot,微信号为 `my_bot`,而本 bot 程序跑在 `127.0.0.1` 的 `8888` 端口,那么你需要在 Mojo-Weixin 的参数中设置 `post_url` 为 `http://127.0.0.1:8888/mojo_weixin/my_bot`。 - -### 运行 - -推荐使用 Docker 运行,因为基本可以一键开启,如果你想手动运行,也可以参考第二个小标题「手动运行」。 - -#### 使用 Docker 运行 - -本仓库根目录下的 `docker-compose.yml` 即为 Docker Compose 的配置文件,直接跑就行(某些功能可能需要自行修改一下 `docker-compose.yml` 里的环境变量,例如如果要使用天气功能,需要在里面填上你的和风天气 API KEY)。如果你想对镜像进行修改,可以自行更改 Dockerfile 来构建或者继承已经构建好的镜像。 - -#### 手动运行 - -```sh -pip3 install -r requirements.txt -python3 app.py -``` - -你可以通过设置环境变量来控制程序的某些行为,请参考 `docker-compose.yml` 文件中的最后一个容器的环境变量设置。 - -## 如何使用 - -如果不是出于修改程序以适应自己的需求的目的,建议直接使用已经跑起来的小开 bot 即可,使用文档见 [如何使用 CCZU 小开机器人](http://fenkipedia.cn/wiki/%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8CCZU%E5%B0%8F%E5%BC%80%E6%9C%BA%E5%99%A8%E4%BA%BA)。而如果是自行修改,那么使用方式就由你自己的插件决定了。 - -下面是一个示例的使用截图: - -![](https://ww3.sinaimg.cn/large/006tNbRwgw1fb4a75bp2dj30ku1nsaey.jpg) - -## 局限性 - -这里不讨论消息源客户端的局限性,那不是后端所负责的范围。只讨论本程序(聊天机器人后端)的局限性: - -- 直接忽略了所有事件类型的上报,比如好友请求、群请求,只接受消息类型 -- 目前只能处理文字消息(微信语音消息会通过语音识别转成文字) - -## 配置文件 - -本程序的配置文件(`config.py`)非常简单,重要的配置只有消息源定义、默认命令等,还有一些对标记的定义,如命令开始标记、命令名与参数分割标记等,基本上都是字面义,通过字段名即可明白,这里不再给出具体的文档。 - -## 消息源适配器 - -简称「适配器」,用来在消息源和本程序之间进行数据格式的转换,相当于一个驱动程序,通过不同的驱动程序,本程序便可以接入多种聊天平台。用户可以自行开发适配器来适配尚未支持的消息源,见 [编写消息源适配器](https://cczu-dev.github.io/xiaokai-bot/#/Write_Adapter)。 - -## 插件 - -程序支持三种插件形式,分别是过滤器/Filter、命令/Command、自然语言处理器/NLProcessor,也即程序的三个处理层次。 - -用户可以自行编写插件来扩展功能,具体请看 [文档](https://cczu-dev.github.io/xiaokai-bot/)。下面简要介绍三层命令的执行流程。 - -### 过滤器 - -收到消息后,依次运行所有过滤器,即按照优先级从大到小顺序运行 `filters` 目录中的 `.py` 文件中指定的过滤器函数,函数返回非 False 即表示不拦截消息,从而消息继续传给下一个过滤器,如果返回了 False,则消息不再进行后续处理,而直接抛弃。 - -### 命令 - -命令分发器(`filters/command_dispatcher0.py`)是一个预设的优先级为 0 的过滤器,它根据命令的开始标志判断消息中有没有指定命令,如果指定了,则执行指定的命令,如果没指定,则看当前用户有没有开启交互式会话,如果开启了会话,则执行会话指定的命令,否则,使用默认的 fallback 命令(`config.py` 中 `fallback_command` 指定,默认为 `natural_language.process`)。 - -### 自然语言处理器 - -程序默认的 fallback 命令是 `natural_language.process`,也即自然语言处理命令,这个命令会通过消息的分词结果寻找注册了相应关键词的 NL 处理器并调用它们,得到一个有可能的等价命令列表,然后选择其中置信度最高且超过 60 的命令作为最佳识别结果执行。如果没有超过 60 的命令,则调用另一个 fallback 命令(`config.py` 中 `fallback_command_after_nl_processors` 指定,默认为 `ai.tuling123`)。 +# None diff --git a/apiclient.py b/apiclient.py deleted file mode 100644 index 0e236abc..00000000 --- a/apiclient.py +++ /dev/null @@ -1,100 +0,0 @@ -import os - -import requests - - -class ApiClient: - qq_api_url = os.environ.get('QQ_API_URL') - wx_api_url = os.environ.get('WX_API_URL') - - def _api_url(self, via): - if via == 'qq': - return self.qq_api_url - elif via == 'wx': - return self.wx_api_url - return None - - def send_message(self, content: str, ctx_msg: dict): - msg_type = ctx_msg.get('type') - if msg_type == 'group_message': - return self.send_group_message(content=content, ctx_msg=ctx_msg) - elif msg_type == 'discuss_message': - return self.send_discuss_message(content=content, ctx_msg=ctx_msg) - elif msg_type == 'friend_message': - return self.send_friend_message(content=content, ctx_msg=ctx_msg) - return None - - def send_group_message(self, content: str, ctx_msg: dict): - try: - if ctx_msg.get('via') == 'qq' and self.qq_api_url: - params = {'content': content} - if ctx_msg.get('group_uid'): - params['uid'] = ctx_msg.get('group_uid') - elif ctx_msg.get('group_id'): - params['id'] = ctx_msg.get('group_id') - return requests.get(self.qq_api_url + '/send_group_message', params=params) - elif ctx_msg.get('via') == 'wx' and self.wx_api_url: - params = {'content': content} - if ctx_msg.get('group_id'): - params['id'] = ctx_msg.get('group_id') - return requests.get(self.wx_api_url + '/send_group_message', params=params) - except requests.exceptions.ConnectionError: - pass - return None - - def send_discuss_message(self, content: str, ctx_msg: dict): - try: - if ctx_msg.get('via') == 'qq' and self.qq_api_url: - params = {'content': content} - if ctx_msg.get('discuss_id'): - params['id'] = ctx_msg.get('discuss_id') - return requests.get(self.qq_api_url + '/send_discuss_message', params=params) - except requests.exceptions.ConnectionError: - pass - return None - - def send_friend_message(self, content: str, ctx_msg: dict): - try: - if ctx_msg.get('via') == 'qq' and self.qq_api_url: - params = {'content': content} - if ctx_msg.get('sender_uid'): - params['uid'] = ctx_msg.get('sender_uid') - elif ctx_msg.get('sender_id'): - params['id'] = ctx_msg.get('sender_id') - return requests.get(self.qq_api_url + '/send_friend_message', params=params) - elif ctx_msg.get('via') == 'wx' and self.wx_api_url: - params = {'content': content} - if ctx_msg.get('sender_account'): - params['account'] = ctx_msg.get('sender_account') - elif ctx_msg.get('sender_id'): - params['id'] = ctx_msg.get('sender_id') - return requests.get(self.wx_api_url + '/send_friend_message', params=params) - except requests.exceptions.ConnectionError: - pass - return None - - def get_group_info(self, ctx_msg: dict): - url = self._api_url(ctx_msg.get('via')) - if url: - try: - return requests.get(url + '/get_group_info') - except requests.exceptions.ConnectionError: - return None - - def get_user_info(self, ctx_msg: dict): - url = self._api_url(ctx_msg.get('via')) - if url: - try: - return requests.get(url + '/get_user_info') - except requests.exceptions.ConnectionError: - return None - - def wx_consult(self, account, content): - if self.wx_api_url: - try: - return requests.get(self.wx_api_url + '/consult', params={'account': account, 'content': content}) - except requests.exceptions.ConnectionError: - return None - - -client = ApiClient() diff --git a/app.py b/app.py deleted file mode 100644 index b4e2f0bb..00000000 --- a/app.py +++ /dev/null @@ -1,38 +0,0 @@ -import os - -from flask import Flask, request - -from little_shit import SkipException, load_plugins -from filter import apply_filters -from msg_src_adapter import get_adapter - -app = Flask(__name__) - - -@app.route('//', methods=['POST'], strict_slashes=False) -def _handle_via_account(via: str, login_id: str): - ctx_msg = request.json - ctx_msg['via'] = via - ctx_msg['login_id'] = login_id - return _main(ctx_msg) - - -def _main(ctx_msg: dict): - try: - adapter = get_adapter(ctx_msg.get('via'), ctx_msg.get('login_id')) - if not adapter: - raise SkipException - ctx_msg = adapter.unitize_context(ctx_msg) - if not apply_filters(ctx_msg): - raise SkipException - except SkipException: - # Skip this message - pass - - return '', 204 - - -if __name__ == '__main__': - load_plugins('msg_src_adapters') - load_plugins('filters') - app.run(host=os.environ.get('HOST', '0.0.0.0'), port=os.environ.get('PORT', '8080')) diff --git a/command.py b/command.py deleted file mode 100644 index 0144a58d..00000000 --- a/command.py +++ /dev/null @@ -1,333 +0,0 @@ -import functools -import re - -from little_shit import get_command_name_separators, get_command_args_separators -from msg_src_adapter import get_adapter_by_ctx - -_command_name_seps = get_command_name_separators() -_command_args_seps = get_command_args_separators() - - -class CommandNotExistsError(Exception): - pass - - -class CommandPermissionError(Exception): - pass - - -class CommandScopeError(Exception): - def __init__(self, msg_type): - self.msg_type = msg_type - - -class CommandRegistry: - """ - Represent a map of commands and functions. - """ - - def __init__(self, init_func=None): - self.init_func = init_func - self.command_map = {} - self.alias_map = {} - self.hidden_command_names = [] - - def register(self, command_name, *other_names, hidden=False): - """ - Register command names and map them to a command function. - - :param command_name: command name to register - :param other_names: other names of this command - :param hidden: hide the command name or not - NOTE: This is kind of like the 'full_command_only' in restrict(), - but only controls ONE command name, - while the later controls the whole command. - """ - - def decorator(func): - if hidden: - self.hidden_command_names.append(command_name) - if not hasattr(func, 'restricted'): - # Apply a default restriction - func = self.restrict()(func) - self.command_map[command_name] = func - for name in other_names: - self.command_map[name] = func - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return wrapper - - return decorator - - # noinspection PyMethodMayBeStatic - def restrict(self, full_command_only=False, superuser_only=False, - group_owner_only=False, group_admin_only=False, - allow_private=True, allow_discuss=True, allow_group=True): - """ - Give a command some restriction. - This decorator must be put below all register() decorators. - Example: - @cr.register('wow', hidden=True) - @cr.register('another_command_name') - @cr.restrict(full_command_only=True) - def wow(_1, _2): - pass - - :param full_command_only: whether to be called with full command (including registry name) - :param superuser_only: superuser only - :param group_owner_only: group owner only when processing group message - :param group_admin_only: group admin only when processing group message - :param allow_private: allow private message - :param allow_discuss: allow discuss message - :param allow_group: allow group message - """ - - def decorator(func): - func.restricted = True - # Visibility - func.full_command_only = full_command_only - # Permission - func.superuser_only = superuser_only - func.group_owner_only = group_owner_only - func.group_admin_only = group_admin_only - # Scope - func.allow_private = allow_private - func.allow_discuss = allow_discuss - func.allow_group = allow_group - return func - - return decorator - - def call(self, command_name, args_text, ctx_msg, **options): - """ - Call the command matching the specified command name. - - :param command_name: command name - :param args_text: arguments as a string - :param ctx_msg: context message - :param options: other possible options - :return: things returned by the command function - :raises CommandScopeError: the message scope (group or private) is not allowed - :raises CommandPermissionError: the user is not permitted to call this command - """ - if command_name in self.command_map: - func = self.command_map[command_name] - if not self._check_scope(func, ctx_msg): - msg_type = ctx_msg.get('msg_type') - if msg_type == 'group': - msg_type_str = '群组消息' - elif msg_type == 'discuss': - msg_type_str = '讨论组消息' - elif msg_type == 'private': - msg_type_str = '私聊消息' - else: - msg_type_str = '未知来源消息' - raise CommandScopeError(msg_type_str) - if not self._check_permission(func, ctx_msg): - raise CommandPermissionError - return func(args_text, ctx_msg, **options) - - @staticmethod - def _check_scope(func, ctx_msg): - """ - Check if current message scope (group or private) is allowed. - - :param func: command function to check - :param ctx_msg: context message - :return: allowed or not - """ - allowed_msg_type = set() - if func.allow_group: - allowed_msg_type.add('group') - if func.allow_discuss: - allowed_msg_type.add('discuss') - if func.allow_private: - allowed_msg_type.add('private') - - if ctx_msg.get('msg_type') in allowed_msg_type: - return True - return False - - # noinspection PyTypeChecker - @staticmethod - def _check_permission(func, ctx_msg): - """ - Check if current message sender is permitted to call this command. - - :param func: command function to check - :param ctx_msg: context message - :return: permitted or not - """ - adapter = get_adapter_by_ctx(ctx_msg) - if adapter.is_sender_superuser(ctx_msg): - return True # Superuser is the BIG BOSS - - if func.superuser_only: - return False - - if ctx_msg.get('msg_type') == 'group': - # TODO: 在酷 Q 测试一下 - allowed_roles = {'owner', 'admin', 'member'} - if func.group_admin_only: - allowed_roles.intersection_update({'owner', 'admin'}) - if func.group_owner_only: - allowed_roles.intersection_update({'owner'}) - - role = adapter.get_sender_group_role(ctx_msg) - if role not in allowed_roles: - return False - - # Still alive, let go - return True - - def has(self, command_name): - """ - Check if this registry has the specified command name, - except command names that is hidden and full command only. - - :param command_name: command name - :return: has or not - """ - return command_name in self.command_map \ - and command_name not in self.hidden_command_names \ - and not self.command_map.get(command_name).full_command_only - - def has_include_hidden(self, command_name): - """ - Check if this registry has the specified command name, - including command names that is hidden and full command only. - - :param command_name: command name - :return: has or not - """ - return command_name in self.command_map - - -class CommandHub: - """ - Represent series of command registries, - which means it's used as a collection of different registries - and allows same command names. - """ - - def __init__(self): - self.registry_map = {} - - def add_registry(self, registry_name, registry): - """ - Add a registry to the hub, running the init function of the registry. - - :param registry_name: registry name - :param registry: registry object - """ - if registry.init_func: - registry.init_func() - self.registry_map[registry_name] = registry - - def call(self, command_name, args_text, ctx_msg, **options): - """ - Call the commands matching the specified command name. - - :param command_name: command name - :param args_text: arguments as a string - :param ctx_msg: context message - :param options: other possible options - :return: things returned by the command function - (list of things if more than one matching command) - :raises CommandNotExistsError: no command exists - :raises CommandScopeError: the message scope is disallowed by all commands - :raises CommandPermissionError: the user is baned by all commands - """ - if not command_name: - # If the command name is empty, we just return - return None - - command = re.split('|'.join(_command_name_seps), command_name, 1) - if len(command) == 2 and command[0] in self.registry_map: - registry = self.registry_map.get(command[0]) - if registry.has_include_hidden(command[1]): - return registry.call(command[1], args_text, ctx_msg, **options) - else: - raise CommandNotExistsError - else: - results = [] - cmd_exists = False - permitted = False - for registry in self.registry_map.values(): - # Trying to call all commands with the name - if registry.has(command_name): - cmd_exists = True - try: - results.append( - registry.call(command_name, args_text, ctx_msg, **options)) - permitted = True # If it's permitted, this will be set - except CommandPermissionError: - pass - if not cmd_exists: - raise CommandNotExistsError - if not permitted: - # No command was permitted - raise CommandPermissionError - return results - - -hub = CommandHub() - - -class CommandArgumentError(Exception): - pass - - -def split_arguments(maxsplit=0): - """ - To use this decorator, you should add a parameter exactly named 'argv' to the function of the command, - which will be set to the split argument list when called. - - However, the first parameter, typically 'args_text', will remain to be the whole argument string, like before. - - :param maxsplit: max split time - """ - - def decorator(func): - @functools.wraps(func) - def wrapper(argument, *args, **kwargs): - if argument is None: - raise CommandArgumentError - if kwargs.get('argv') is not None: - argv = kwargs['argv'] - del kwargs['argv'] - elif isinstance(argument, (list, tuple)): - argv = list(argument) - else: - regexp = re.compile('|'.join(_command_args_seps)) - if maxsplit == 0: - argv = list(filter(lambda arg: arg, regexp.split(argument))) - else: - cur = 0 - tmp_argument = argument - argv = [] - while cur <= maxsplit: - sl = regexp.split(tmp_argument, 1) - if len(sl) == 1: - if sl[0]: - argv.append(sl[0]) - break - # Here len(sl) is > 1 (== 2) - if not sl[0]: - tmp_argument = sl[1] - continue - if cur < maxsplit: - argv.append(sl[0]) - tmp_argument = sl[1] - else: - # Last time - argv.append(tmp_argument) - cur += 1 - return func(argument, argv=argv, *args, **kwargs) - - return wrapper - - return decorator diff --git a/commands/ai.py b/commands/ai.py deleted file mode 100644 index b8a6565d..00000000 --- a/commands/ai.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import requests - -from command import CommandRegistry -from commands import core -from little_shit import get_source, get_message_sources -from msg_src_adapter import get_adapter - -__registry__ = cr = CommandRegistry() - - -@cr.register('tuling123', 'chat', '聊天') -def tuling123(args_text, ctx_msg, internal=False): - url = 'http://www.tuling123.com/openapi/api' - data = { - 'key': os.environ.get('TURING123_API_KEY'), - 'info': args_text, - 'userid': get_source(ctx_msg) - } - resp = requests.post(url, data=data) - if resp.status_code == 200: - json = resp.json() - if internal: - return json - if int(json.get('code', 0)) == 100000: - reply = json.get('text', '') - else: - # Is not text type - reply = '腊鸡图灵机器人返回了一堆奇怪的东西,就不发出来了' - else: - if internal: - return None - reply = '腊鸡图灵机器人出问题了,先不管他,过会儿再玩他' - core.echo(reply, ctx_msg) - - -@cr.register('xiaoice', '小冰') -def xiaoice(args_text, ctx_msg, internal=False): - msg_sources = get_message_sources() - for src in msg_sources: - if src['via'] == 'mojo_weixin': - # Only MojoWeixin support this function - adapter = get_adapter('mojo_weixin', src['login_id']) - if adapter: - json = adapter.consult(account='xiaoice-ms', content=args_text) - if json and json.get('reply'): - reply = json['reply'] - core.echo(reply, ctx_msg, internal) - return reply - core.echo('小冰现在无法回复,请稍后再试', ctx_msg, internal) - return None diff --git a/commands/bilibili.py b/commands/bilibili.py deleted file mode 100644 index c3a0c6ed..00000000 --- a/commands/bilibili.py +++ /dev/null @@ -1,147 +0,0 @@ -import re -import math -from datetime import datetime, timedelta - -import requests -import pytz - -from command import CommandRegistry, split_arguments -from commands import core - -__registry__ = cr = CommandRegistry() - - -@cr.register('anime_index', 'anime-index', '番剧索引', '番剧', '新番') -@split_arguments() -def anime_index(_, ctx_msg, argv=None): - now = datetime.now(pytz.timezone('Asia/Shanghai')) - year = now.year - month = now.month - if len(argv) == 2 and re.fullmatch('(?:20)?\d{2}', argv[0]) and re.fullmatch('\d{1,2}', argv[1]): - year = int(argv[0]) if len(argv[0]) > 2 else 2000 + int(argv[0]) - month = int(argv[1]) - elif len(argv) == 1 and re.fullmatch('\d{1,2}', argv[0]): - month = int(argv[0]) - elif len(argv) == 1 and re.fullmatch('(?:20)?\d{2}-\d{1,2}', argv[0]): - year, month = [int(x) for x in argv[0].split('-')] - year = 2000 + year if year < 100 else year - elif len(argv): - core.echo('抱歉无法识别的输入的参数,下面将给出本季度的番剧~', ctx_msg) - - quarter = math.ceil(month / 3) - json = requests.get('http://bangumi.bilibili.com/web_api/season/index_global' - '?page=1&page_size=20&version=0&is_finish=0' - '&start_year=%d&quarter=%d&tag_id=&index_type=1&index_sort=0' % (year, quarter)).json() - if json and json.get('result') and int(json['result'].get('count', 0)) > 0: - anime_list = json['result'].get('list', []) - reply = '%d年%d月番剧\n按追番人数排序,前20部如下:\n\n' % (year, 1 + (quarter - 1) * 3) - reply += '\n'.join([anime.get('title', '未知动画') + ' ' - + ('未开播' if anime.get('total_count', -1) < 0 - else ('全%d话' % anime['total_count'] - if anime['newest_ep_index'] == str(anime['total_count']) - else '更新至%s' % anime['newest_ep_index'] - + ('话' if anime['newest_ep_index'].isdigit() else ''))) - for anime in anime_list]) - - reply += '\n\n更多详细资料见 bilibili 官网 ' \ - 'http://bangumi.bilibili.com/anime/index' \ - '#p=1&v=0&area=&stat=0&y=%d&q=%d&tag=&t=1&sort=0' % (year, quarter) - else: - reply = '没有查询到%d年%d月开播的番剧……' % (year, 1 + (quarter - 1) * 3) - - core.echo(reply, ctx_msg) - - -@cr.register('anime_timeline', 'anime-timeline', '番剧时间表', '新番时间表') -@split_arguments(maxsplit=1) -def anime_timeline(args_text, ctx_msg, internal=False, argv=None): - if len(argv) == 0: - core.echo('请指定要查询的日期或番剧名称,例如下面(主要看参数,你的命令是对的~):\n\n' - '/新番时间表 02-21\n' - '/新番时间表 0\n' - '/新番时间表 小林家的龙女仆\n' - '/新番时间表 02-21 小林家的龙女仆\n\n' - '上面第二个例子的「0」代表和今天相差的天数,0表示今天,1表示明天,-1表示昨天,以此类推\n' - '参数中间记得用用空格隔开哦~', ctx_msg, internal) - return None - - json = requests.get('http://bangumi.bilibili.com/web_api/timeline_v4').json() - if not json or 'result' not in json: - return None - - timeline_list = json['result'] or [] - - date_str = None - anime_name = None - - if re.fullmatch('\d{1,2}-\d{1,2}', argv[0]): - # month-day - date_str = '%02d-%02d' % tuple(map(lambda x: int(x), argv[0].split('-'))) - argv = argv[1:] - elif re.fullmatch('-?\d', argv[0]): - # timedelta (days) - delt = timedelta(days=int(argv[0])) - dt = datetime.now() + delt - date_str = dt.strftime('%m-%d') - argv = argv[1:] - - if len(argv) > 1: - anime_name = args_text.strip() - elif len(argv) == 1: - anime_name = argv[0].rstrip() - - if date_str: - timeline_list = list(filter(lambda item: item.get('pub_date', '').endswith(date_str), timeline_list)) - if anime_name: - timeline_list = list(filter( - lambda item: anime_name.lower() in item.get('title', '').lower() - and len(anime_name) > len(item.get('title', '')) / 4, - timeline_list - )) - - if internal: - return timeline_list - - if date_str and anime_name: - if not timeline_list: - reply = '没更新' - else: - reply = '' - for item in timeline_list: - reply += '\n' + ('更新了' if item['is_published'] else '将在%s更新' % item['ontime']) \ - + '第%s话' % item['ep_index'] if item['ep_index'].isdigit() else item['ep_index'] - reply = reply.lstrip() - - core.echo(reply, ctx_msg, internal) - return - - if not timeline_list: - core.echo('没有找到符合条件的时间表……', ctx_msg, internal) - return - - if date_str and not anime_name: - month, day = [int(x) for x in date_str.split('-')] - reply = '在%d月%d日更新的番剧有:\n\n' % (month, day) - reply += '\n'.join([item.get('title', '未知动画') + ' ' - + item.get('ontime', '未知时间') + ' ' - + ('第%s话' % item.get('ep_index') - if item.get('ep_index', '').isdigit() - else item.get('ep_index', '')) - for item in timeline_list]) - core.echo(reply, ctx_msg, internal) - elif anime_name and not date_str: - anime_dict = {} - for item in timeline_list: - k = item.get('title', '未知动画') - if k not in anime_dict: - anime_dict[k] = [] - anime_dict[k].append(item) - - for name, items in anime_dict.items(): - reply = name + '\n' - for item in items: - _, month, day = [int(x) for x in item['pub_date'].split('-')] - reply += '\n' + ('已' if item['is_published'] else '将') \ - + '在%d月%d日%s更新' % (month, day, item['ontime']) \ - + '第%s话' % item['ep_index'] if item['ep_index'].isdigit() else item['ep_index'] - core.echo(reply, ctx_msg, internal) diff --git a/commands/core.py b/commands/core.py deleted file mode 100644 index e519ba6b..00000000 --- a/commands/core.py +++ /dev/null @@ -1,38 +0,0 @@ -from command import CommandRegistry -from msg_src_adapter import get_adapter_by_ctx - -__registry__ = cr = CommandRegistry() - - -@cr.register('echo', '重复', '跟我念') -def echo(args_text, ctx_msg, internal=False): - if internal: - return None - else: - return get_adapter_by_ctx(ctx_msg).send_message( - target=ctx_msg, - content=args_text - ) - - -@cr.register('help', '帮助', '用法', '使用帮助', '使用指南', '使用说明', '使用方法', '怎么用') -def help(_, ctx_msg): - echo( - '你好!我是 CCZU 小开机器人,由常州大学开发者协会开发。\n' - '我可以为你做一些简单的事情,如发送知乎日报内容、翻译一段文字等。\n' - '下面是我现在能做的一些事情:\n\n' - '(1)/查天气 常州\n' - '(2)/翻译 こんにちは\n' - '(3)/翻译到 英语 你好\n' - '(4)/历史上的今天\n' - '(5)/知乎日报\n' - '(6)/记笔记 笔记内容\n' - '(7)/查看所有笔记\n' - '(8)/查百科 常州大学\n' - '(9)/说个笑话\n' - '(10)/聊天 你好啊\n\n' - '把以上内容之一(包括斜杠,不包括序号,某些部分替换成你需要的内容)发给我,我就会按你的要求去做啦。\n' - '上面只给出了 10 条功能,还有更多功能和使用方法,请查看 http://t.cn/RIr177e\n\n' - '祝你使用愉快~', - ctx_msg - ) diff --git a/commands/encode.py b/commands/encode.py deleted file mode 100644 index 948d5539..00000000 --- a/commands/encode.py +++ /dev/null @@ -1,42 +0,0 @@ -import base64 as b64lib -import hashlib - -from command import CommandRegistry -from commands import core - -__registry__ = cr = CommandRegistry() - - -@cr.register('base64') -def base64(args_text, ctx_msg, internal=False): - encoded = b64lib.b64encode(args_text.encode('utf-8')).decode('utf-8') - core.echo(encoded, ctx_msg, internal) - return encoded - - -@cr.register('base64_decode', 'base64-decode', 'base64d') -def base64(args_text, ctx_msg, internal=False): - decoded = b64lib.b64decode(args_text.encode('utf-8')).decode('utf-8') - core.echo(decoded, ctx_msg, internal) - return decoded - - -@cr.register('md5') -def md5(args_text, ctx_msg, internal=False): - encoded = hashlib.md5(args_text.encode('utf-8')).hexdigest() - core.echo(encoded, ctx_msg, internal) - return encoded - - -@cr.register('sha1') -def sha1(args_text, ctx_msg, internal=False): - encoded = hashlib.sha1(args_text.encode('utf-8')).hexdigest() - core.echo(encoded, ctx_msg, internal) - return encoded - - -@cr.register('sha256') -def sha1(args_text, ctx_msg, internal=False): - encoded = hashlib.sha256(args_text.encode('utf-8')).hexdigest() - core.echo(encoded, ctx_msg, internal) - return encoded diff --git a/commands/natural_language.py b/commands/natural_language.py deleted file mode 100644 index a2b39353..00000000 --- a/commands/natural_language.py +++ /dev/null @@ -1,44 +0,0 @@ -from command import CommandRegistry -from commands import core -from nl_processor import parse_potential_commands -from little_shit import load_plugins, get_fallback_command_after_nl_processors -from command import hub as cmdhub - - -def _init(): - load_plugins('nl_processors') - - -__registry__ = cr = CommandRegistry(init_func=_init) - -_fallback_command = get_fallback_command_after_nl_processors() - - -@cr.register('process') -@cr.restrict(full_command_only=True) -def process(args_text, ctx_msg): - sentence = args_text.strip() - if not sentence: - core.echo('你什么都没说哦~', ctx_msg) - return - - potential_commands = parse_potential_commands(sentence) - potential_commands = sorted(filter(lambda x: x[0] > 60, potential_commands), key=lambda x: x[0], reverse=True) - # If it's a fallback and with empty start flag, then don't send verbose information - hide_verbose = ctx_msg.get('is_fallback') and ctx_msg.get('start_flag') == '' - if len(potential_commands) > 0: - most_possible_cmd = potential_commands[0] - core.echo( - '识别出最可能的等价命令:\n' + ' '.join((most_possible_cmd[1], most_possible_cmd[2])), - ctx_msg, - internal=hide_verbose - ) - ctx_msg['parsed_data'] = most_possible_cmd[3] - cmdhub.call(most_possible_cmd[1], most_possible_cmd[2], ctx_msg) - else: - if _fallback_command: - core.echo('暂时无法理解你的意思,下面将使用备用命令 ' + _fallback_command + '……', - ctx_msg, - internal=hide_verbose) - cmdhub.call(_fallback_command, sentence, ctx_msg) - return diff --git a/commands/networktools.py b/commands/networktools.py deleted file mode 100644 index 25b7ce33..00000000 --- a/commands/networktools.py +++ /dev/null @@ -1,59 +0,0 @@ -import json - -import requests -from lxml import etree - -from command import CommandRegistry -from commands import core - -__registry__ = cr = CommandRegistry() - - -@cr.register('ip') -def ip(args_text, ctx_msg): - query = args_text.strip() - if not query: - core.echo('请指定要查询的 IP 或域名', ctx_msg) - return - - core.echo('正在查询,请稍等……', ctx_msg) - - chinaz_url = 'http://ip.chinaz.com/%s' - ipcn_url = 'http://ip.cn/?ip=%s' - ipipnet_url = 'http://freeapi.ipip.net/%s' - - found = False - - # Get data from ChinaZ.com - resp = requests.get(chinaz_url % query) - if resp.status_code == 200: - html = etree.HTML(resp.text) - p_elems = html.xpath('//p[@class="WhwtdWrap bor-b1s col-gray03"]') - if len(p_elems) > 0: - reply = 'ChinaZ.com:' - for p_elem in p_elems: - span_elems = p_elem.getchildren() - reply += '\n' + span_elems[1].text + ', ' + span_elems[3].text - core.echo(reply, ctx_msg) - found = True - - # Get data from ip.cn - resp = requests.get(ipcn_url % query, headers={'User-Agent': 'curl/7.47.0'}) - if resp.status_code == 200: - # Example: 'IP:123.125.114.144 来自:北京市 联通' - items = resp.text.strip().split(':') - if len(items) == 3: - reply = 'IP.cn:\n' + items[1].split(' ')[0] + ', ' + items[2] - core.echo(reply, ctx_msg) - found = True - - # Get data from ipip.net - resp = requests.get(ipipnet_url % query, headers={'User-Agent': 'curl/7.47.0'}) - if resp.status_code == 200 and resp.text.strip(): - # Example: '["中国","江苏","常州","","教育网"]' - parts = json.loads(resp.text) - reply = 'IPIP.net\n' + query + ' ' + ''.join(parts) - core.echo(reply, ctx_msg) - found = True - - core.echo('以上' if found else '查询失败', ctx_msg) diff --git a/commands/note.py b/commands/note.py deleted file mode 100644 index 81ff87db..00000000 --- a/commands/note.py +++ /dev/null @@ -1,163 +0,0 @@ -import sqlite3 -from datetime import datetime - -import pytz - -from command import CommandRegistry -from commands import core -from interactive import * -from little_shit import get_default_db_path, get_source, get_target, check_target - -__registry__ = cr = CommandRegistry() - -_create_table_sql = """CREATE TABLE IF NOT EXISTS cmd_note ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - content TEXT NOT NULL, - dt INTEGER NOT NULL, - target TEXT NOT NULL -)""" - - -def _open_db_conn(): - conn = sqlite3.connect(get_default_db_path()) - conn.execute(_create_table_sql) - conn.commit() - return conn - - -_cmd_take = 'note.take' -_cmd_remove = 'note.remove' - - -@cr.register('记笔记', '添加笔记') -@cr.register('take', 'add', hidden=True) -@cr.restrict(group_admin_only=True) -@check_target -def take(args_text, ctx_msg, allow_interactive=True): - source = get_source(ctx_msg) - if allow_interactive and (not args_text or has_session(source, _cmd_take)): - # Be interactive - return _take_interactively(args_text, ctx_msg, source) - - conn = _open_db_conn() - dt_unix = int(datetime.now(tz=pytz.utc).timestamp()) - target = get_target(ctx_msg) - conn.execute( - 'INSERT INTO cmd_note (content, dt, target) VALUES (?, ?, ?)', - (args_text, dt_unix, target) - ) - conn.commit() - conn.close() - core.echo('好的,记下了~', ctx_msg) - - -@cr.register('列出所有笔记', '查看所有笔记', '所有笔记') -@cr.register('list', hidden=True) -@check_target -def list_all(_, ctx_msg): - conn = _open_db_conn() - target = get_target(ctx_msg) - cursor = conn.execute('SELECT id, dt, content FROM cmd_note WHERE target = ?', (target,)) - rows = list(cursor) - conn.close() - if len(rows) == 0: - core.echo('还没有笔记哦~', ctx_msg) - return - for row in rows: - tz_china = pytz.timezone('Asia/Shanghai') - dt_raw = datetime.fromtimestamp(row[1], tz=pytz.utc) - core.echo('ID:' + str(row[0]) - + '\n时间:' + dt_raw.astimezone(tz_china).strftime('%Y.%m.%d %H:%M') - + '\n内容:' + str(row[2]), - ctx_msg) - core.echo('以上~', ctx_msg) - - -@cr.register('删除笔记') -@cr.register('remove', 'delete', hidden=True) -@cr.restrict(group_admin_only=True) -@check_target -def remove(args_text, ctx_msg, allow_interactive=True): - source = get_source(ctx_msg) - if allow_interactive and (not args_text or has_session(source, _cmd_remove)): - # Be interactive - return _remove_interactively(args_text, ctx_msg, source) - - try: - note_id = int(args_text) - except ValueError: - # Failed to cast - core.echo('你输入的 ID 格式不正确哦~应该是个数字才对~', ctx_msg) - return - conn = _open_db_conn() - target = get_target(ctx_msg) - cursor = conn.cursor() - cursor.execute('DELETE FROM cmd_note WHERE target = ? AND id = ?', (target, note_id)) - if cursor.rowcount > 0: - core.echo('删除成功了~', ctx_msg) - else: - core.echo('没找到这个 ID 的笔记哦~', ctx_msg) - conn.commit() - conn.close() - - -@cr.register('清空笔记', '清空所有笔记', '删除所有笔记') -@cr.register('clear', hidden=True) -@cr.restrict(group_admin_only=True) -@check_target -def clear(_, ctx_msg): - conn = _open_db_conn() - target = get_target(ctx_msg) - cursor = conn.cursor() - cursor.execute('DELETE FROM cmd_note WHERE target = ?', (target,)) - if cursor.rowcount > 0: - core.echo('成功删除了所有的笔记,共 %s 条~' % cursor.rowcount, ctx_msg) - else: - core.echo('本来就没有笔记哦~', ctx_msg) - conn.commit() - conn.close() - - -_state_machines = {} - - -def _take_interactively(args_text, ctx_msg, source): - def wait_for_content(s, a, c): - core.echo('请发送你要记录的内容:', c) - s.state += 1 - - def save_content(s, a, c): - take(a, c, allow_interactive=False) - return True - - if _cmd_take not in _state_machines: - _state_machines[_cmd_take] = ( - wait_for_content, # 0 - save_content # 1 - ) - - sess = get_session(source, _cmd_take) - if _state_machines[_cmd_take][sess.state](sess, args_text, ctx_msg): - # Done - remove_session(source, _cmd_take) - - -def _remove_interactively(args_text, ctx_msg, source): - def wait_for_note_id(s, a, c): - core.echo('请发送你要删除的笔记的 ID:', c) - s.state += 1 - - def remove_note(s, a, c): - remove(a, c, allow_interactive=False) - return True - - if _cmd_remove not in _state_machines: - _state_machines[_cmd_remove] = ( - wait_for_note_id, # 0 - remove_note # 1 - ) - - sess = get_session(source, _cmd_remove) - if _state_machines[_cmd_remove][sess.state](sess, args_text, ctx_msg): - # Done - remove_session(source, _cmd_remove) diff --git a/commands/random.py b/commands/random.py deleted file mode 100644 index 9a3387b3..00000000 --- a/commands/random.py +++ /dev/null @@ -1,82 +0,0 @@ -import re -import random -import string - -from command import CommandRegistry, split_arguments -from commands import core - -__registry__ = cr = CommandRegistry() - - -@cr.register('随机数') -@cr.register('number', hidden=True) -@split_arguments() -def number(_, ctx_msg, internal=False, argv=None): - if len(argv) == 0 or not re.match('x\d+', argv[-1]): - n = 1 - else: - n = max(1, int(argv[-1][1:])) - argv = argv[:-1] - - if len(argv) > 2 or any((not re.match('-?\d+', num) for num in argv)): - core.echo('参数有错误哦~\n正确的使用方法(主要看参数,你的命令是对的~):\n\n' - '/random.number\n' - '/random.number x5\n' - '/random.number 100\n' - '/random.number 100 x10\n' - '/random.number 50 100\n' - '/random.number 50 100 x3', - ctx_msg, internal) - return - - if len(argv) == 1: - argv.append(1) - - start, end = (int(argv[0]), int(argv[1])) if len(argv) == 2 else (None, None) - start, end = (min(start, end), max(start, end)) if start is not None else (start, end) - - result = [] - - for _ in range(n): - result.append(random.randint(start, end) if start is not None else random.random()) - - core.echo(', '.join(str(num) for num in result), ctx_msg, internal) - return result - - -@cr.register('随机字符') -@cr.register('char', hidden=True) -@split_arguments() -def char(_, ctx_msg, internal=False, argv=None): - if len(argv) > 2 or (len(argv) == 2 and not re.match('x\d+', argv[-1])): - core.echo('参数有错误哦~\n正确的使用方法(主要看参数,你的命令是对的~):\n\n' - '/random.char\n' - '/random.char x5\n' - '/random.char ABCDEFG\n' - '/random.char ABCDEFG x10\n', - ctx_msg, internal) - return - - chars = string.ascii_letters + string.digits - size = 1 - if len(argv) and re.match('x\d+', argv[-1]): - size = max(1, int(argv[-1][1:])) - argv = argv[:-1] - if len(argv): - chars = argv[0] - - result = ''.join(random.choice(chars) for _ in range(size)) - core.echo(result, ctx_msg, internal) - return result - - -@cr.register('随机化') -@cr.register('shuffle', hidden=True) -@split_arguments() -def char(_, ctx_msg, internal=False, argv=None): - if len(argv) == 0: - core.echo('请传入正确的参数哦~', ctx_msg, internal) - return argv - random.shuffle(argv) - core.echo(', '.join(argv), ctx_msg, internal) - return argv diff --git a/commands/scheduler.py b/commands/scheduler.py deleted file mode 100644 index 6f48e6ed..00000000 --- a/commands/scheduler.py +++ /dev/null @@ -1,353 +0,0 @@ -import os -import re -from functools import wraps - -import pytz -import requests -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.executors.pool import ProcessPoolExecutor -from apscheduler.jobstores.base import JobLookupError - -from command import CommandRegistry, hub as cmdhub -from command import CommandNotExistsError, CommandScopeError, CommandPermissionError -from commands import core -from little_shit import get_db_dir, get_command_args_start_flags, get_target - -_db_url = 'sqlite:///' + os.path.join(get_db_dir(), 'scheduler.sqlite') - -_scheduler = BackgroundScheduler( - jobstores={ - 'default': SQLAlchemyJobStore(url=_db_url) - }, - executors={ - 'default': ProcessPoolExecutor(max_workers=5) - }, - timezone=pytz.timezone('Asia/Shanghai') -) - -_command_args_start_flags = get_command_args_start_flags() - -_args_split_sep = ' |\r?\n|\t' -_job_id_suffix_start = '@' - - -def _init(): - _scheduler.start() - - -__registry__ = cr = CommandRegistry(init_func=_init) - - -class _InvalidTriggerArgsError(Exception): - pass - - -class _IncompleteArgsError(Exception): - pass - - -def _call_commands(job_id, command_list, ctx_msg, internal=False): - for command in command_list: - try: - cmdhub.call(command[0], command[1], ctx_msg) - except CommandNotExistsError: - core.echo('没有找到计划任务 %s 中的命令 %s' % (job_id, command[0]), ctx_msg, internal) - except CommandPermissionError: - core.echo('你没有权限执行计划任务 %s 中的命令 %s' % (job_id, command[0]), ctx_msg, internal) - except CommandScopeError as se: - core.echo( - '计划任务 %s 中的命令 %s 不支持 %s' % (job_id, command[0], se.msg_type), - ctx_msg, internal - ) - - -def _check_target(func): - @wraps(func) - def wrapper(args_text, ctx_msg, internal=False, *args, **kwargs): - target = get_target(ctx_msg) - if not target: - _send_fail_to_get_target_msg(ctx_msg, internal) - return None - else: - return func(args_text, ctx_msg, internal, *args, **kwargs) - - return wrapper - - -@cr.register('cron_check', 'cron-check', 'cron_test', 'cron-test') -def cron_check(args_text, ctx_msg): - cron = args_text.strip() - if not cron: - core.echo('请指定要检查的 Cron 时间表达式', ctx_msg) - return - - resp = requests.post('http://tool.lu/crontab/ajax.html', data={'expression': cron}) - if resp.status_code == 200: - data = resp.json() - if data.get('status') and 'dates' in data: - reply = '接下来 7 次的执行时间:\n' + '\n'.join(data['dates']) - core.echo(reply, ctx_msg) - return - - core.echo('检查失败,可能因为表达式格式错误或服务器连接不上', ctx_msg) - - -@cr.register('add_job', 'add-job', 'add') -@cr.restrict(full_command_only=True, group_admin_only=True) -@_check_target -def add_job(args_text, ctx_msg, internal=False): - if args_text.strip() in ('', 'help', '-h', '--help') and not internal: - _send_add_job_help_msg(ctx_msg, internal) - return - - args_text = args_text.lstrip() - try: - # Parse trigger args - trigger_args = {} - if args_text.startswith('-'): - # options mode - key_dict = { - '-M': 'minute', - '-H': 'hour', - '-d': 'day', - '-m': 'month', - '-w': 'day_of_week' - } - while args_text.startswith('-') and not args_text.startswith('--'): - try: - option, value, args_text = re.split(_args_split_sep, args_text, 2) - trigger_args[key_dict[option]] = value - args_text = args_text.lstrip() - except (ValueError, KeyError): - # Split failed or get key failed, which means format is not correct - raise _InvalidTriggerArgsError - else: - # cron mode - try: - trigger_args['minute'], \ - trigger_args['hour'], \ - trigger_args['day'], \ - trigger_args['month'], \ - trigger_args['day_of_week'], \ - args_text = re.split(_args_split_sep, args_text, 5) - args_text = args_text.lstrip() - except ValueError: - # Split failed, which means format is not correct - raise _InvalidTriggerArgsError - - # Parse '--multi' option - multi = False - if args_text.startswith('--multi '): - multi = True - tmp = re.split(_args_split_sep, args_text, 1) - if len(tmp) < 2: - raise _IncompleteArgsError - args_text = tmp[1].lstrip() - - tmp = re.split(_args_split_sep, args_text, 1) - if len(tmp) < 2: - raise _IncompleteArgsError - job_id_without_suffix, command_raw = tmp - job_id = job_id_without_suffix + _job_id_suffix_start + get_target(ctx_msg) - command_list = [] - if multi: - command_raw_list = re.split('\r?\n', command_raw) - for cmd_raw in command_raw_list: - cmd_raw = cmd_raw.lstrip() - if not cmd_raw: - continue - tmp = re.split('|'.join(_command_args_start_flags), cmd_raw, 1) - if len(tmp) < 2: - tmp.append('') - command_list.append(tuple(tmp)) - else: - command_raw = command_raw.lstrip() - tmp = re.split('|'.join(_command_args_start_flags), command_raw, 1) - if len(tmp) < 2: - tmp.append('') - command_list.append(tuple(tmp)) - - job_args = { - 'job_id': job_id_without_suffix, - 'command_list': command_list, - 'ctx_msg': ctx_msg - } - job = _scheduler.add_job(_call_commands, kwargs=job_args, trigger='cron', **trigger_args, - id=job_id, replace_existing=True, misfire_grace_time=30) - _send_text('成功添加计划任务 ' + job_id_without_suffix, ctx_msg, internal) - if job: - job.id = job_id_without_suffix - return job - except _InvalidTriggerArgsError: - _send_add_job_trigger_args_invalid_msg(ctx_msg, internal) - except _IncompleteArgsError: - _send_add_job_incomplete_args_msg(ctx_msg, internal) - - -@cr.register('remove_job', 'remove-job', 'remove') -@cr.restrict(full_command_only=True, group_admin_only=True) -@_check_target -def remove_job(args_text, ctx_msg, internal=False): - job_id_without_suffix = args_text.strip() - if not job_id_without_suffix: - _send_text('请指定计划任务的 ID', ctx_msg, internal) - return False - job_id = job_id_without_suffix + _job_id_suffix_start + get_target(ctx_msg) - try: - _scheduler.remove_job(job_id, 'default') - _send_text('成功删除计划任务 ' + job_id_without_suffix, ctx_msg, internal) - return True - except JobLookupError: - _send_text('没有找到计划任务 ' + job_id_without_suffix, ctx_msg, internal) - return False - - -@cr.register('get_job', 'get-job', 'get') -@cr.restrict(full_command_only=True) -@_check_target -def get_job(args_text, ctx_msg, internal=False): - job_id_without_suffix = args_text.strip() - if not job_id_without_suffix: - _send_text('请指定计划任务的 ID', ctx_msg, internal) - return None - job_id = job_id_without_suffix + _job_id_suffix_start + get_target(ctx_msg) - job = _scheduler.get_job(job_id, 'default') - if internal: - if job: - job.id = job_id_without_suffix - return job - if not job: - core.echo('没有找到该计划任务,请指定正确的计划任务 ID', ctx_msg, internal) - return - reply = '找到计划任务如下:\n' - reply += 'ID:' + job_id_without_suffix + '\n' - reply += '下次触发时间:\n%s\n' % job.next_run_time.strftime('%Y-%m-%d %H:%M') - reply += '命令:\n' - command_list = job.kwargs['command_list'] - reply += convert_command_list_to_str(command_list) - _send_text(reply, ctx_msg, internal) - - -@cr.register('list_jobs', 'list-jobs', 'list') -@cr.restrict(full_command_only=True) -@_check_target -def list_jobs(_, ctx_msg, internal=False): - target = get_target(ctx_msg) - job_id_suffix = _job_id_suffix_start + target - jobs = list(filter(lambda j: j.id.endswith(job_id_suffix), _scheduler.get_jobs('default'))) - if internal: - for job in jobs: - job.id = job.id[:-len(job_id_suffix)] - return jobs - - for job in jobs: - job_id = job.id[:-len(job_id_suffix)] - command_list = job.kwargs['command_list'] - reply = 'ID:' + job_id + '\n' - reply += '下次触发时间:\n%s\n' % job.next_run_time.strftime('%Y-%m-%d %H:%M') - reply += '命令:\n' - reply += convert_command_list_to_str(command_list) - _send_text(reply, ctx_msg, internal) - if len(jobs): - _send_text('以上', ctx_msg, internal) - else: - _send_text('还没有添加计划任务', ctx_msg, internal) - - -@cr.register('execute_job', 'execute-job', 'execute', 'exec', 'trigger', 'do') -@cr.restrict(full_command_only=True, group_admin_only=True) -@_check_target -def execute_job(args_text, ctx_msg, internal=False): - job = get_job(args_text, ctx_msg, internal=True) - if not job: - core.echo('没有找到该计划任务,请指定正确的计划任务 ID', ctx_msg, internal) - return - job_id_suffix = _job_id_suffix_start + get_target(ctx_msg) - job_id = job.id[:-len(job_id_suffix)] - _call_commands(job_id, job.kwargs['command_list'], job.kwargs['ctx_msg'], internal) - - -def convert_command_list_to_str(command_list): - s = '' - if len(command_list) > 1: - for c in command_list: - s += c[0] + (' ' + c[1] if c[1] else '') + '\n' - s = s.rstrip('\n') - else: - s = command_list[0][0] + ' ' + command_list[0][1] - return s - - -def _send_text(text, ctx_msg, internal): - if not internal: - core.echo(text, ctx_msg) - - -def _send_fail_to_get_target_msg(ctx_msg, internal): - _send_text( - '无法获取 target,可能因为不支持当前消息类型(如,不支持微信群组消息)' - '或由于延迟还没能加载到用户的固定 ID(如,微信号)', - ctx_msg, - internal - ) - - -def _send_add_job_help_msg(ctx_msg, internal): - _send_text( - '此为高级命令!如果你不知道自己在做什么,请不要使用此命令。\n\n' - '使用方法:\n' - '/scheduler.add_job options|cron [--multi] job_id command\n' - '说明:\n' - 'options 和 cron 用来表示触发参数,有且只能有其一,格式分别如下:\n' - 'options:\n' - ' -M 分,0 到 59\n' - ' -H 时,0 到 23\n' - ' -d 日,1 到 31\n' - ' -m 月,1 到 12\n' - ' -w 星期,0 到 6,其中 0 表示星期一,6 表示星期天\n' - ' 以上选项的值的表示法和下面的 cron 模式相同\n' - 'cron:\n' - ' 此模式和 Linux 的 crontab 文件的格式、顺序相同(除了星期是从 0 到 6),一共 5 个用空格隔开的参数\n' - '\n' - '剩下三个参数见下一条', - ctx_msg, - internal - ) - _send_text( - '--multi 为可选项,表示读取多条命令\n' - 'job_id 为必填项,允许使用符合正则 [_\-a-zA-Z0-9] 的字符,作为计划任务的唯一标识,如果指定重复的 ID,则会覆盖原先已有的\n' - 'command 为必填项,从 job_id 之后第一个非空白字符开始,如果加了 --multi 选项,则每行算一条命令,否则一直到消息结束算作一整条命令(注意这里的命令不要加 / 前缀)\n' - '\n' - '例 1:\n' - '以下命令将添加计划在每天晚上 10 点推送当天的知乎日报,并发送一条鼓励的消息:\n' - '/scheduler.add_job 0 22 * * * --multi zhihu-daily-job\n' - 'zhihu\n' - 'echo 今天又是很棒的一天哦!\n' - '\n' - '例 2:\n' - '以下命令将每 5 分钟发送一条提示:\n' - '/scheduler.add_job -M */5 tip-job echo 提示内容', - ctx_msg, - internal - ) - - -def _send_add_job_trigger_args_invalid_msg(ctx_msg, internal): - _send_text( - '触发参数的格式不正确\n' - '如需帮助,请发送如下命令:\n' - '/scheduler.add_job --help', - ctx_msg, - internal - ) - - -def _send_add_job_incomplete_args_msg(ctx_msg, internal): - _send_text( - '缺少必须的参数\n' - '如需帮助,请发送如下命令:\n' - '/scheduler.add_job --help', - ctx_msg, - internal - ) diff --git a/commands/simpletools.py b/commands/simpletools.py deleted file mode 100644 index d35486d7..00000000 --- a/commands/simpletools.py +++ /dev/null @@ -1,117 +0,0 @@ -import base64 - -import requests -from lxml import etree - -from command import CommandRegistry -from commands import core, ai - -__registry__ = cr = CommandRegistry() - - -@cr.register('money_zh', 'money-zh') -@cr.register('人民币大写', '金额大写', '人民币金额大写') -def money_zh(args_text, ctx_msg): - query = args_text.strip() - try: - _ = float(query) - except ValueError: - query = None - if not query: - core.echo('请在命令后加上要转换成大写的人民币金额哦~(命令和数字用空格或逗号隔开)', ctx_msg) - return - - resp = requests.get('http://tool.lu/daxie/ajax.html?number=%s' % query) - if resp.status_code == 200: - data = resp.json() - if data.get('status') and 'text' in data: - reply = query + ' 的汉字大写是:' + data['text'].strip() - core.echo(reply, ctx_msg) - return - - -@cr.register('short_url', 'short-url') -@cr.register('生成短网址', '生成短链接', '短网址', '短链接') -def short_url(args_text, ctx_msg): - raw_url = args_text.strip() - if not raw_url: - core.echo('请在命令后加上要转换成短链接的网址哦~(命令和网址用空格或逗号隔开)', ctx_msg) - return - - core.echo('正在生成,请稍等……', ctx_msg) - - session = requests.Session() - short_urls = [] - - resp = session.get( - 'http://dwz.wailian.work/', - headers={ - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36', - 'Referer': 'http://dwz.wailian.work/' - } - ) - if resp.status_code == 200: - api_url = 'http://dwz.wailian.work/api.php?url=%s&site=%s' - encoded_url = base64.b64encode(bytes(raw_url, 'utf-8')).decode('utf-8') - for site in ('sina', 'googl'): - resp = session.get(api_url % (encoded_url, site)) - data = resp.json() - if resp.status_code == 200 and data.get('result') == 'ok': - short_urls.append(data['data']['short_url']) - - if short_urls: - core.echo('\n'.join(short_urls), ctx_msg) - else: - core.echo('生成失败,可能因为链接格式错误或服务器连接不上', ctx_msg) - - -# @cr.register('weather') -# @cr.register('天气', '查天气') -def weather(args_text, ctx_msg): - city = args_text.strip() - if not city: - core.echo('请在命令后加上要查的城市哦~(命令和城市用空格或逗号隔开)', ctx_msg) - return - - data = ai.tuling123(city + '天气', ctx_msg, internal=True) - core.echo(data.get('text', ''), ctx_msg) - - -@cr.register('joke') -@cr.register('笑话', '说笑话', '说个笑话') -def joke(_, ctx_msg): - data = ai.tuling123('说个笑话', ctx_msg, internal=True) - core.echo(data.get('text', ''), ctx_msg) - - -@cr.register('baike') -@cr.register('百科', '查百科') -def baike(args_text, ctx_msg): - query = args_text.strip() - if not query: - core.echo('请在命令后加上要查的关键词哦~(命令和关键词用空格或逗号隔开)', ctx_msg) - return - data = ai.tuling123('百科 ' + query, ctx_msg, internal=True) - core.echo(data.get('text', ''), ctx_msg) - - -@cr.register('today_in_history', 'today-in-history', '历史上的今天') -def today_in_history(_, ctx_msg): - resp = requests.get('http://tool.lu/todayonhistory/') - ok = False - if resp.status_code == 200: - core.echo('历史上的今天:', ctx_msg) - html = etree.HTML(resp.text) - li_elems = html.xpath('//ul[@id="tohlis"]/li') - # reply = reduce(lambda x, y: x.text + '\n' + y.text, li_elems) - step = 10 - for start in range(0, len(li_elems), step): - reply = '' - for item in li_elems[start:start + step]: - reply += item.text + '\n' - reply = reply.rstrip() - core.echo(reply, ctx_msg) - core.echo('以上~', ctx_msg) - ok = True - if not ok: - core.echo('很抱歉,网络出错了……建议等会儿再试吧~', ctx_msg) diff --git a/commands/subscribe.py b/commands/subscribe.py deleted file mode 100644 index 2326db5a..00000000 --- a/commands/subscribe.py +++ /dev/null @@ -1,163 +0,0 @@ -import re -from datetime import datetime - -from command import CommandRegistry, split_arguments -from commands import core, scheduler -from interactive import * -from little_shit import get_source, check_target - -__registry__ = cr = CommandRegistry() - -_cmd_subscribe = 'subscribe.subscribe' -_scheduler_job_id_prefix = _cmd_subscribe + '_' - - -@cr.register('subscribe', '订阅') -@cr.restrict(group_admin_only=True) -@split_arguments(maxsplit=1) -@check_target -def subscribe(args_text, ctx_msg, argv=None, internal=False, allow_interactive=True): - source = get_source(ctx_msg) - if not internal and allow_interactive and has_session(source, _cmd_subscribe): - # Already in a session, no need to pass in data, - # because the interactive version of this command will take care of it - return _subscribe_interactively(args_text, ctx_msg, source, None) - - data = {} - if argv: - m = re.match('([0-1]\d|[2][0-3])(?::|:)?([0-5]\d)', argv[0]) - if not m: - # Got command but no time - data['command'] = args_text - else: - # Got time - data['hour'], data['minute'] = m.group(1), m.group(2) - if len(argv) == 2: - # Got command - data['command'] = argv[1] - - if not internal and allow_interactive: - if data.keys() != {'command', 'hour', 'minute'}: - # First visit and data is not enough - return _subscribe_interactively(args_text, ctx_msg, source, data) - - # Got both time and command, do the job! - hour, minute = data['hour'], data['minute'] - command = data['command'] - job = scheduler.add_job( - '-H %s -M %s --multi %s %s' % ( - hour, minute, _scheduler_job_id_prefix + str(int(datetime.now().timestamp())), command), - ctx_msg, internal=True - ) - if internal: - return job - if job: - # Succeeded to add a job - reply = '订阅成功,我会在每天 %s 推送哦~' % ':'.join((hour, minute)) - else: - reply = '订阅失败,可能后台出了点小问题~' - - core.echo(reply, ctx_msg, internal) - - -@cr.register('subscribe_list', 'subscribe-list', '订阅列表', '查看订阅', '查看所有订阅', '所有订阅') -@cr.restrict(group_admin_only=True) -@check_target -def subscribe_list(_, ctx_msg, internal=False): - jobs = sorted(filter( - lambda j: j.id.startswith(_scheduler_job_id_prefix), - scheduler.list_jobs('', ctx_msg, internal=True) - ), key=lambda j: j.id) - - if internal: - return jobs - - if not jobs: - core.echo('暂时还没有订阅哦~', ctx_msg) - return - - for index, job in enumerate(jobs): - command_list = job.kwargs['command_list'] - reply = 'ID:' + job.id[len(_scheduler_job_id_prefix):] + '\n' - reply += '下次推送时间:\n%s\n' % job.next_run_time.strftime('%Y-%m-%d %H:%M') - reply += '命令:\n' - reply += scheduler.convert_command_list_to_str(command_list) - core.echo(reply, ctx_msg) - core.echo('以上~', ctx_msg) - - -@cr.register('unsubscribe', '取消订阅') -@cr.restrict(group_admin_only=True) -@split_arguments() -@check_target -def unsubscribe(_, ctx_msg, argv=None, internal=False): - if not argv: - core.echo('请在命令名后指定要取消订阅的 ID(多个 ID、ID 和命令名之间用空格隔开)哦~\n\n' - '你可以通过「查看所有订阅」命令来查看所有订阅项目的 ID', ctx_msg, internal) - return - - result = [] - for job_id_without_prefix in argv: - result.append(scheduler.remove_job(_scheduler_job_id_prefix + job_id_without_prefix, ctx_msg, internal=True)) - - if internal: - return result[0] if len(result) == 1 else result - - if all(result): - core.echo('取消订阅成功~', ctx_msg, internal) - else: - core.echo('可能有订阅 ID 没有找到,请使用「查看所有订阅」命令来检查哦~', - ctx_msg, internal) - - -def _subscribe_interactively(args_text, ctx_msg, source, data): - sess = get_session(source, _cmd_subscribe) - if data: - sess.data.update(data) - - state_command = 1 - state_time = 2 - state_finish = -1 - if sess.state == state_command: - if not args_text.strip(): - core.echo('你输入的命令不正确,请重新发送订阅命令哦~', ctx_msg) - sess.state = state_finish - else: - sess.data['command'] = args_text - elif sess.state == state_time: - m = re.match('([0-1]\d|[2][0-3])(?::|:)?([0-5]\d)', args_text.strip()) - if not m: - core.echo('你输入的时间格式不正确,请重新发送订阅命令哦~', ctx_msg) - sess.state = state_finish - else: - sess.data['hour'], sess.data['minute'] = m.group(1), m.group(2) - - if sess.state == state_finish: - remove_session(source, _cmd_subscribe) - return - - if 'command' not in sess.data: - # Ask for command - core.echo( - '请输入你需要订阅的命令(包括所需的参数),每行一条,不需要加开头的斜杠哦~\n\n' - '例如(序号后的):\n' - '(1) 天气 南京\n' - '(2) 知乎日报\n' - '(3) 历史上的今天', - ctx_msg - ) - sess.state = state_command - return - - if 'hour' not in sess.data or 'minute' not in sess.data: - # Ask for time - core.echo('请输入你需要推送的时间,格式如 22:00', ctx_msg) - sess.state = state_time - return - - subscribe( - '', ctx_msg, - argv=[':'.join((sess.data['hour'], sess.data['minute'])), sess.data['command']], - allow_interactive=False - ) - remove_session(source, _cmd_subscribe) diff --git a/commands/sudo.py b/commands/sudo.py deleted file mode 100644 index b61749cc..00000000 --- a/commands/sudo.py +++ /dev/null @@ -1,99 +0,0 @@ -import sqlite3 - -from command import CommandRegistry, split_arguments -from commands import core -from little_shit import get_default_db_path, get_target - -__registry__ = cr = CommandRegistry() - -_create_table_sql = """CREATE TABLE IF NOT EXISTS blocked_target_list ( - target TEXT NOT NULL -)""" - - -def _open_db_conn(): - conn = sqlite3.connect(get_default_db_path()) - conn.execute(_create_table_sql) - conn.commit() - return conn - - -@cr.register('test') -@cr.restrict(full_command_only=True, superuser_only=True) -def test(_, ctx_msg): - core.echo('Your are the superuser!', ctx_msg) - - -@cr.register('block') -@cr.restrict(full_command_only=True, superuser_only=True) -@split_arguments(maxsplit=1) -def block(_, ctx_msg, argv=None): - def _send_error_msg(): - core.echo('参数不正确。\n\n正确使用方法:\nsudo.block ', ctx_msg) - - if len(argv) != 1: - _send_error_msg() - return - - account = argv[0] - # Get a target using a fake context message - target = get_target({ - 'via': 'default', - 'msg_type': 'private', - 'sender_id': account - }) - - if not target: - _send_error_msg() - return - - conn = _open_db_conn() - conn.execute('INSERT INTO blocked_target_list (target) VALUES (?)', (target,)) - conn.commit() - conn.close() - core.echo('成功屏蔽用户 ' + account, ctx_msg) - - -@cr.register('block_list', 'block-list') -@cr.restrict(full_command_only=True, superuser_only=True) -def block_list(_, ctx_msg, internal=False): - conn = _open_db_conn() - cursor = conn.execute('SELECT target FROM blocked_target_list') - blocked_targets = list(set([x[0] for x in list(cursor)])) # Get targets and remove duplications - conn.close() - if internal: - return blocked_targets - if blocked_targets: - core.echo('已屏蔽的用户:\n' + ', '.join(blocked_targets), ctx_msg) - else: - core.echo('还没有屏蔽过用户', ctx_msg) - - -@cr.register('unblock') -@cr.restrict(full_command_only=True, superuser_only=True) -@split_arguments(maxsplit=1) -def unblock(_, ctx_msg, argv=None): - def _send_error_msg(): - core.echo('参数不正确。\n\n正确使用方法:\nsudo.unblock ', ctx_msg) - - if len(argv) != 1: - _send_error_msg() - return - - account = argv[0] - # Get a target using a fake context message - target = get_target({ - 'via': 'default', - 'msg_type': 'private', - 'sender_id': account - }) - - if not target: - _send_error_msg() - return - - conn = _open_db_conn() - conn.execute('DELETE FROM blocked_target_list WHERE target = ?', (target,)) - conn.commit() - conn.close() - core.echo('成功取消屏蔽用户 ' + account, ctx_msg) diff --git a/commands/translate.py b/commands/translate.py deleted file mode 100644 index 0b9eab8e..00000000 --- a/commands/translate.py +++ /dev/null @@ -1,121 +0,0 @@ -import os -import hashlib -from datetime import datetime - -import requests - -from command import CommandRegistry -from commands import core - -__registry__ = cr = CommandRegistry() - -_app_id = os.environ.get('BAIDU_FANYI_APP_ID') -_api_key = os.environ.get('BAIDU_FANYI_API_KEY') - -_lang_map = { - '中文': 'zh', - '繁体中文': 'cht', - '英语': 'en', - '粤语': 'yue', - '文言文': 'wyw', - '日语': 'jp', - '韩语': 'kor', - '法语': 'fra', - '西班牙语': 'spa', - '阿拉伯语': 'ara', - '俄语': 'ru', - '葡萄牙语': 'pt', - '德语': 'de', - '意大利语': 'it', - '希腊语': 'el', - '荷兰语': 'nl', - '波兰语': 'pl', - '保加利亚语': 'bul', - '爱沙尼亚语': 'est', - '丹麦语': 'dan', - '芬兰语': 'fin', - '捷克语': 'cs', - '罗马尼亚语': 'rom', - '斯洛文尼亚语': 'slo', - '瑞典语': 'swe', - '匈牙利语': 'hu', - '越南语': 'vie' -} - -_lang_alias_map = { - '简体中文': 'zh', - '汉语': 'zh', - '英文': 'en', - '日文': 'jp', - '韩文': 'kor', - '法文': 'fra', - '西班牙文': 'spa', - '阿拉伯文': 'ara', - '俄文': 'ru', - '葡萄牙文': 'pt', - '德文': 'de', - '意大利文': 'it', - '希腊文': 'el', - '荷兰文': 'nl', - '波兰文': 'pl', - '保加利亚文': 'bul', - '爱沙尼亚文': 'est', - '丹麦文': 'dan', - '芬兰文': 'fin', - '捷克文': 'cs', - '罗马尼亚文': 'rom', - '斯洛文尼亚文': 'slo', - '瑞典文': 'swe', - '匈牙利文': 'hu', - '越南文': 'vie' -} - - -@cr.register('translate', '翻译', '翻訳') -def translate(args_text, ctx_msg): - query = args_text.strip() - if not query: - core.echo('请在命令后加上要翻译的内容哦~(命令和要翻译的内容用空格或逗号隔开)', ctx_msg) - return - - cmd = ctx_msg.get('command') - if cmd == 'translate': - return translate_to('英语 ' + args_text, ctx_msg) - elif cmd == '翻訳': - return translate_to('日语 ' + args_text, ctx_msg) - else: - return translate_to('简体中文 ' + args_text, ctx_msg) - - -@cr.register('translate_to', 'translate-to', '翻译到', '翻译成', '翻译为') -def translate_to(args_text, ctx_msg): - args = args_text.strip().split(' ', 1) - if len(args) < 2 or (args[0] not in _lang_map and args[0] not in _lang_alias_map): - core.echo( - '请指定目标语言和要翻译的内容哦~(命令、目标语言、要翻译的内容之间用空格或逗号隔开\n目前支持的语言:' - + '、'.join(_lang_map.keys()), - ctx_msg - ) - return - - core.echo('正在翻译,请稍等……', ctx_msg) - - to_lang = _lang_map.get(args[0]) or _lang_alias_map.get(args[0]) - query = args[1] - api_url = 'https://fanyi-api.baidu.com/api/trans/vip/translate' - salt = str(int(datetime.now().timestamp())) - sign = hashlib.md5((_app_id + query + salt + _api_key).encode('utf-8')).hexdigest() - resp = requests.post(api_url, data={ - 'q': query, - 'from': 'auto', - 'to': to_lang, - 'appid': _app_id, - 'salt': salt, - 'sign': sign - }) - if resp.status_code == 200: - data = resp.json() - if 'trans_result' in data: - core.echo('翻译结果(百度翻译):\n' + '\n'.join([x['dst'] for x in data['trans_result']]), ctx_msg) - return - core.echo('翻译失败,可能因为后台接口的频率限制或服务器连接不上', ctx_msg) diff --git a/commands/weather.py b/commands/weather.py deleted file mode 100644 index 0da4dd8d..00000000 --- a/commands/weather.py +++ /dev/null @@ -1,217 +0,0 @@ -import os -import json -import sqlite3 -from datetime import datetime, timedelta - -import requests - -from command import CommandRegistry, split_arguments -from commands import core -from little_shit import get_source, get_db_dir, get_tmp_dir -from interactive import * - -__registry__ = cr = CommandRegistry() - -_api_key = os.environ.get('HEWEATHER_API_KEY') -_base_api_url = 'https://free-api.heweather.com/v5' -_search_api_url = _base_api_url + '/search' -_detail_api_url = _base_api_url + '/weather' - -_cmd_weather = 'weather.weather' -_cmd_suggestion = 'weather.suggestion' - -_weekday_string = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - - -@cr.register('weather') -@cr.register('天气', '查天气', '天气预报', '查天气预报') -@split_arguments() -def weather(args_text, ctx_msg, argv: list = None, allow_interactive=True): - source = get_source(ctx_msg) - if allow_interactive and (not argv or not argv[0].startswith('CN') or has_session(source, _cmd_weather)): - # Be interactive - return _do_interactively(_cmd_weather, weather, args_text.strip(), ctx_msg, source) - - city_id = argv[0] - text = '' - - data = _get_weather(city_id) - if data: - text += '%s天气\n更新时间:%s' % (data['basic']['city'], data['basic']['update']['loc']) - - now = data['now'] - aqi = data['aqi']['city'] - text += '\n\n实时:\n\n%s,气温%s°C,体感温度%s°C,%s%s级,' \ - '能见度%skm,空气质量指数:%s,%s,PM2.5:%s,PM10:%s' \ - % (now['cond']['txt'], now['tmp'], now['fl'], now['wind']['dir'], now['wind']['sc'], now['vis'], - aqi['aqi'], aqi['qlty'], aqi['pm25'], aqi['pm10']) - - daily_forecast = data['daily_forecast'] - text += '\n\n预报:\n\n' - - for forecast in daily_forecast: - d = datetime.strptime(forecast['date'], '%Y-%m-%d') - text += '%d月%d日%s,' % (d.month, d.day, _weekday_string[d.weekday()]) - - cond_d = forecast['cond']['txt_d'] - cond_n = forecast['cond']['txt_n'] - text += cond_d + ('转' + cond_n if cond_d != cond_n else '') + ',' - - text += forecast['tmp']['min'] + '~' + forecast['tmp']['max'] + '°C,' - text += forecast['wind']['dir'] + forecast['wind']['sc'] + '级,' - text += '降雨概率%s%%' % forecast['pop'] - text += '\n\n' - - text = text.rstrip() - if text: - core.echo(text, ctx_msg) - else: - core.echo('查询失败了,请稍后再试哦~', ctx_msg) - - -@cr.register('suggestion', hidden=True) -@cr.register('生活指数', '生活建议', '天气建议') -@split_arguments() -def suggestion(args_text, ctx_msg, argv: list = None, allow_interactive=True): - source = get_source(ctx_msg) - if allow_interactive and (len(argv) < 1 or not argv[0].startswith('CN') or has_session(source, _cmd_suggestion)): - # Be interactive - return _do_interactively(_cmd_suggestion, suggestion, args_text.strip(), ctx_msg, source) - - city_id = argv[0] - text = '' - - data = _get_weather(city_id) - if data: - data = data['suggestion'] - text += '生活指数:\n\n' \ - '舒适度:%s\n\n' \ - '洗车指数:%s\n\n' \ - '穿衣指数:%s\n\n' \ - '感冒指数:%s\n\n' \ - '运动指数:%s\n\n' \ - '旅游指数:%s\n\n' \ - '紫外线指数:%s' \ - % tuple([data[k]['txt'] for k in ('comf', 'cw', 'drsg', 'flu', 'sport', 'trav', 'uv')]) - - if text: - core.echo(text, ctx_msg) - else: - core.echo('查询失败了,请稍后再试哦~', ctx_msg) - - -_state_machines = {} - - -def _do_interactively(command_name, func, args_text, ctx_msg, source): - def ask_for_city(s, a, c): - if a: - if search_city(s, a, c): - return True - else: - core.echo('你要查询哪个城市呢?', c) - s.state += 1 - - def search_city(s, a, c): - if not a: - core.echo('你输入的城市不正确哦,请重新发送命令~', c) - return True - - city_list = _get_city_list(a) - - if not city_list: - core.echo('没有找到你输入的城市哦,请重新发送命令~', c) - return True - - s.data['city_list'] = city_list - - if len(city_list) == 1: - # Directly choose the first one - choose_city(s, '1', c) - return True - - # Here comes more than one city with the same name - core.echo( - '找到 %d 个重名城市,请选择你要查询的那个,发送它的序号:\n\n' % len(city_list) - + '\n'.join( - [str(i + 1) + '. ' + c['prov'] + c['city'] for i, c in enumerate(city_list)] - ), - c - ) - - s.state += 1 - - def choose_city(s, a, c): - if not a or not a.isdigit(): - core.echo('你输入的序号不正确哦,请重新发送命令~', c) - return True - - choice = int(a) - 1 # Should be from 0 to len(city_list) - 1 - city_list = s.data['city_list'] - if choice < 0 or choice >= len(city_list): - core.echo('你输入的序号超出范围了,请重新发送命令~', c) - return True - - city_id = city_list[choice]['id'] - # sess.data['func']([city_id], c, allow_interactive=False) - func([city_id], c, allow_interactive=False) - return True - - if command_name not in _state_machines: - _state_machines[command_name] = ( - ask_for_city, # 0 - search_city, # 1 - choose_city # 2 - ) - - sess = get_session(source, command_name) - sess.data['func'] = func - if _state_machines[command_name][sess.state](sess, args_text, ctx_msg): - # Done - remove_session(source, command_name) - - -_weather_db_path = os.path.join(get_db_dir(), 'weather.sqlite') - - -def _get_city_list(city_name): - city_name = city_name.lower() - if not os.path.exists(_weather_db_path): - resp = requests.get('http://7xo46j.com1.z0.glb.clouddn.com/weather.sqlite', stream=True) - with resp.raw as s, open(_weather_db_path, 'wb') as d: - d.write(s.read()) - - conn = sqlite3.connect(_weather_db_path) - cities = list(conn.execute( - 'SELECT code, name, province FROM city WHERE name = ? OR name_en = ? OR province || name = ?', - (city_name, city_name, city_name) - )) - return [{'id': x[0], 'city': x[1], 'prov': x[2]} for x in cities] - - -_weather_cache_dir = os.path.join(get_tmp_dir(), 'weather') - - -def _get_weather(city_id): - if not os.path.exists(_weather_cache_dir): - os.makedirs(_weather_cache_dir) - - file_name = city_id + '.json' - file_path = os.path.join(_weather_cache_dir, file_name) - if os.path.exists(file_path): - update_time = datetime.fromtimestamp(os.path.getmtime(file_path)) - if (datetime.now() - update_time) < timedelta(hours=1): - with open(file_path, 'r') as f: - data = json.load(f) - data['from_cache'] = True - return data - - data = requests.get(_detail_api_url, params={'city': city_id, 'key': _api_key}).json() - if data and 'HeWeather5' in data and data['HeWeather5'][0].get('status') == 'ok': - data = data['HeWeather5'][0] - with open(file_path, 'w') as f: - json.dump(data, f) - data['from_cache'] = False - return data - - return None diff --git a/commands/zhihu.py b/commands/zhihu.py deleted file mode 100644 index d4b24dab..00000000 --- a/commands/zhihu.py +++ /dev/null @@ -1,169 +0,0 @@ -import re -from datetime import date, timedelta - -import requests - -from command import CommandRegistry -from commands import core -from commands import scheduler -from interactive import * -from little_shit import SkipException, get_source - -__registry__ = cr = CommandRegistry() - - -@cr.register('zhihu_daily', 'zhihu-daily', 'zhihu', '知乎日报') -def zhihu_daily(args_text, ctx_msg): - arg = args_text.strip() - reply = None - try: - if not arg: - sub_url = '/latest' - else: - m = re.match('(\d{4})-(\d{2})-(\d{2})', arg) - if m and ''.join(m.groups()) >= '20130519': - thedate = date(year=int(m.group(1)), month=int(m.group(2)), day=int(m.group(3))) - sub_url = '/before/' + (thedate + timedelta(days=1)).strftime('%Y%m%d') - else: - reply = '命令格式错误,正确的命令格式:\n' \ - '/zhihu\n' \ - '或\n' \ - '/zhihu 2016-11-29\n' \ - '注意如果指定日期,格式一定要对,且日期需在 2013-05-19 之后(这一天知乎日报诞生)。' - raise SkipException - full_url = 'https://news-at.zhihu.com/api/4/news' + sub_url - resp = requests.get( - full_url, - headers={ - 'Host': 'news-at.zhihu.com', - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36' - ' (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36' - } - ) - if resp.status_code == 200: - json = resp.json() - if 'stories' not in json: - reply = '获取知乎日报数据失败,知乎返回了一堆迷之数据' - raise SkipException - reply = ('今天' if sub_url == '/latest' else '这天') + '的知乎日报内容如下:' - core.echo(reply, ctx_msg) - step = 6 # Send 8 items per time - items = list(reversed(json.get('stories'))) - for start in range(0, len(items), step): - reply = '' - for item in items[start:min(start + step, len(items))]: - reply += item.get('title') + '\n' + \ - 'https://daily.zhihu.com/story/' + str(item.get('id')) + '\n\n' - reply = reply.rstrip() - core.echo(reply, ctx_msg) - return - else: - reply = '获取知乎日报数据失败,可能知乎服务器又宕机了((' - raise SkipException - except SkipException: - reply = reply if reply else '发生了未知错误……' - core.echo(reply, ctx_msg) - - -_cmd_subscribe = 'zhihu.subscribe' -_scheduler_job_id = _cmd_subscribe - - -@cr.register('订阅知乎日报') -@cr.register('subscribe', hidden=True) -@cr.restrict(group_admin_only=True) -def subscribe(args_text, ctx_msg, allow_interactive=True): - arg = args_text.strip() - source = get_source(ctx_msg) - if allow_interactive and (not arg or has_session(source, _cmd_subscribe)): - # Be interactive - return _subscribe_interactively(args_text, ctx_msg, source) - - force = False - if arg.startswith('-f '): - force = True - arg = arg.split(' ', 1)[1].strip() - reply = None - try: - m = re.match('([0-1]\d|[2][0-3])(?::|:)?([0-5]\d)', arg) - if m: - job = scheduler.get_job(_scheduler_job_id, ctx_msg, internal=True) - if job and not force: - reply = '已经订阅过了哦~\n' \ - + '下次推送时间:\n' \ - + job.next_run_time.strftime('%Y-%m-%d %H:%M') + '\n' \ - + '如果需要更改推送时间,请先取消订阅再重新订阅,' \ - + '或在订阅命令的时间参数前面加 -f 来强制更新推送时间' - raise SkipException - job = scheduler.add_job( - '-M %s -H %s %s zhihu.zhihu-daily' % (m.group(2), m.group(1), _scheduler_job_id), - ctx_msg, - internal=True - ) - if job: - # Succeeded to add a job - reply = '订阅成功,我会在每天 %s 推送哦~' % ':'.join((m.group(1), m.group(2))) - else: - reply = '订阅失败,可能后台出了点问题呢~' - else: - reply = '命令格式错误,正确的命令格式:\n' \ - '/zhihu.subscribe\n' \ - '或\n' \ - '/zhihu.subscribe [-f] 20:30\n' - except SkipException: - reply = reply if reply else '发生了未知错误……' - core.echo(reply, ctx_msg) - - -@cr.register('取消订阅知乎日报') -@cr.register('unsubscribe', hidden=True) -@cr.restrict(group_admin_only=True) -def unsubscribe(_, ctx_msg): - if scheduler.remove_job(_scheduler_job_id, ctx_msg, internal=True): - core.echo('取消订阅成功~', ctx_msg) - else: - core.echo('还没有订阅过哦~', ctx_msg) - - -_state_machines = {} - - -def _subscribe_interactively(args_text, ctx_msg, source): - def confirm_override(s, a, c): - job = scheduler.get_job(_scheduler_job_id, c, internal=True) - if job: - core.echo('先前已经订阅过了哦~\n' - + '下次推送时间:\n' - + job.next_run_time.strftime('%Y-%m-%d %H:%M') + '\n' - + '要更改推送时间吗?\n' - + '回复 1 继续,回复 0 放弃', c) - s.data['need_confirm'] = True - else: - s.data['need_confirm'] = False - wait_for_time(s, a, c) - s.state += 1 - - def wait_for_time(s, a, c): - if s.data['need_confirm']: - if a.strip() != '1': - # Cancel - core.echo('已放弃更改~', c) - return True - core.echo('请发送想要获取推送的时间(格式如 20:05):', c) - s.state += 1 - - def save(s, a, c): - subscribe('-f ' + a, c, allow_interactive=False) - return True - - if _cmd_subscribe not in _state_machines: - _state_machines[_cmd_subscribe] = ( - confirm_override, # 0 - wait_for_time, # 1 - save # 2 - ) - - sess = get_session(source, _cmd_subscribe) - if _state_machines[_cmd_subscribe][sess.state](sess, args_text, ctx_msg): - # Done - remove_session(source, _cmd_subscribe) diff --git a/config.sample.py b/config.sample.py deleted file mode 100644 index 62a5ecfc..00000000 --- a/config.sample.py +++ /dev/null @@ -1,30 +0,0 @@ -config = { - 'fallback_command': 'natural_language.process', - 'fallback_command_after_nl_processors': 'ai.tuling123', - 'command_start_flags': ('/', '/', '来,', '来,'), # Add '' (empty string) here to allow commands without start flags - 'command_name_separators': ('->', '::', '/'), # Regex - 'command_args_start_flags': (',', ':', ',', ', ', ':', ': '), # Regex - 'command_args_separators': (',', ','), # Regex - - 'message_sources': [ - { - 'via': 'mojo_webqq', - 'login_id': '12345678', - 'superuser_id': '23456789', - 'api_url': 'http://127.0.0.1:5000/openqq', - }, - { - 'via': 'mojo_weixin', - 'login_id': 'your_login_id', - 'superuser_id': 'your_superuser_id', - 'api_url': 'http://127.0.0.1:5001/openwx', - }, - { - 'via': 'coolq_http_api', - 'login_id': '12345678', - 'superuser_id': '23456789', - 'api_url': 'http://192.168.0.100:5700', - 'token': 'HJGiudaUYSDkn' - } - ] -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e15f7d20..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: '2' -services: - xiaokai-bot: - image: richardchien/xiaokai-bot - container_name: xiaokai-bot - networks: - - my-net - expose: - - '8888' - volumes: - - ./data/xiaokai-bot:/data - - /tmp:/tmp - environment: - - TURING123_API_KEY=YOUR_API_KEY - - HOST=0.0.0.0 - - PORT=8888 - - BAIDU_FANYI_APP_ID=YOUR_APP_ID - - BAIDU_FANYI_API_KEY=YOUR_API_KEY - - SPEECH_RECOGNITION_SERVICE=baidu|bing - - BAIDU_SPEECH_API_KEY=YOUR_API_KEY - - BAIDU_SPEECH_SECRET_KEY=YOUR_SECRET_KEY - - BING_SPEECH_API_KEY=YOUR_API_KEY - - HEWEATHER_API_KEY=YOUR_API_KEY - restart: always -networks: - my-net: - driver: bridge \ No newline at end of file diff --git a/docs/Context.md b/docs/Context.md deleted file mode 100644 index 38b15d38..00000000 --- a/docs/Context.md +++ /dev/null @@ -1,28 +0,0 @@ -# 统一消息上下文 - -消息上下文是本程序中最重要的概念,也是贯穿整个程序执行流程的一个对象,无论命令、过滤器或是其它插件类型,都需要使用这个对象,在代码中常以 `ctx_msg`、`ctx` 等形式出现,它是字典类型,其中保存了当前(目前正在处理的这条)消息的上报类型、消息内容、消息类型、发送者、接受者、消息源名称等等重要信息。消息源适配器的主要工作,就是将不同消息源的上报数据统一成下方所定义的消息上下文的格式。 - -下面定义一个统一化之后的消息上下文对象应当符合的形式: - -| 字段名 | 是否必须(是/否/建议) | 数据类型 | 支持的值 | 说明 | -| ---------------------------- | ------------ | ---- | --------------------------- | ---------------------------------------- | -| `raw_ctx` | 是 | dict | - | 消息源上报过来的原始数据 | -| `post_type` | 是 | str | `message` | 上报类型,可以随意设置,但此字段值非 `message` 的消息上下文将会被某一层过滤器过滤掉 | -| `time` | 建议 | int | - | 消息、事件等发生的时间戳 | -| `msg_id` | 建议 | str | - | 消息的唯一 ID | -| `msg_type` | 是 | str | `private`、`group`、`discuss` | 消息类型,标识私聊消息或群组消息等 | -| `format` | 是 | str | `text`、`media` 等 | 可随意填写,但实际上能够直接理解的只有 `text`,对于 `media` 格式,只有语音识别过滤器对 Mojo-Webxin 进行了单独支持 | -| `content` | 是 | str | - | 消息内容文本 | -| `receiver` | 建议 | str | - | 接受者显示名(当有备注名时为备注名,否则为昵称,见表格下方注释 1) | -| `receiver_name` | 否 | str | - | 接受者昵称 | -| `receiver_id`/`receiver_tid` | 建议 | str | - | 接受者 ID(分别是固定 ID 和 临时 ID,见注释 2) | -| `sender` | 建议 | str | - | 发送者显示名(当有备注名时为备注名,否则为昵称) | -| `sender_name` | 否 | str | - | 发送者昵称 | -| `sender_id`/`sender_tid` | 是 | str | - | 发送者 ID | -| `group` | 建议 | str | - | 来源群组显示名 | -| `group_id`/`group_tid` | 是(当为群组消息时) | str | - | 来源群组 ID | -| `discuss` | 建议 | str | - | 来源讨论组显示名 | -| `discuss_id`/`discuss_tid` | 是(当为讨论组消息时) | str | - | 来源讨论组 ID | - -- 注释 1:消息的接受者通常情况下表示当前消息源中登录的账号。 -- 注释 2:所有 `xxx_id` 和 `xxx_tid` 分别表示固定 ID 和临时 ID:固定 ID(`xxx_id`)表示重新登录不会变的 ID,通常即为该消息平台的账号(微信 ID、QQ 号);临时 ID(`xxx_tid`)表示在消息源的此次登录中不会变的 ID,但下次登录可能同一个用户的 ID 和上次不同,对于某些平台(如微信),可能有时完全无法获取到固定 ID,此时临时 ID 将成为发送消息时的重要依据。当没有(或无法获取)固定 ID 时,可将固定 ID 置空,如果同时也没有临时 ID,将意味着程序可能无法回复消息(因为没有任何能够唯一标记消息来源的值),当有固定 ID 没有临时 ID 时,应直接将临时 ID 设置为和固定 ID 相同。 diff --git a/docs/Message_Sources.md b/docs/Message_Sources.md deleted file mode 100644 index 33082ae4..00000000 --- a/docs/Message_Sources.md +++ /dev/null @@ -1,9 +0,0 @@ -# 消息源列表 - -「消息源」在文档的某些位置可能还称为「消息平台」「聊天平台客户端」「消息源客户端」「机器人前端」等。比如网上很多开源的 SmartQQ 封装或网页微信封装,我们这里就称它们为「消息源」,它们的功能通常是模拟登录账号、维护登录状态、上报接收到的消息、通过某种方式调用接口发送消息等。通过适配器,本程序可以支持多种消息源,下面是目前所支持的消息源列表: - -| 消息源名称 | 官网/项目地址 | 配置文件中定义时填写项目 | -| -------------- | ---------------------------------------- | ---------------------------------------- | -| mojo_webqq | https://github.com/sjdy521/Mojo-Webqq | `api_url`:API 根地址(必填) | -| mojo_weixin | https://github.com/sjdy521/Mojo-Weixin | `api_url`:API 根地址(必填) | -| coolq_http_api | https://github.com/richardchien/coolq-http-api | `api_url`:API 根地址(必填);`token`:发送请求时的 token(设置了的情况下必填) | diff --git a/docs/Write_Adapter.md b/docs/Write_Adapter.md deleted file mode 100644 index 3a7cf0c7..00000000 --- a/docs/Write_Adapter.md +++ /dev/null @@ -1,152 +0,0 @@ -# 编写消息源适配器 - -消息源适配器是用来在消息源和本程序之间进行数据格式的一类程序,相当于一个驱动程序,通过不同的驱动程序,本程序便可以接入多种聊天平台。后文中简称为「适配器」。 - -通常情况下一个消息源需要能够支持通过 HTTP 来上报消息和调用操作,才能够便于开发适配器,不过实际上如果有需求,你也可以直接在适配器中对程序的 HTTP 服务端进行请求,例如某些直接以模块形式给出的消息平台客户端,通过回调函数来通知事件,此时你可以在这个事件的回调函数中,手动请求本程序的上报地址并发送相应的数据。但这不在此文的讨论范围之内,这属于另一类适配器,与本程序无直接关联。 - -我们这里讨论在本程序接收到 HTTP 上报消息之后、及内部逻辑中产生了对适配器的接口调用之后,需要将上报数据转换成本程序能够识别的数据格式,或将本程序中发出的接口调用转换成消息源客户端能够识别的接口调用,例如我们调用 `adapter.send_private_message`,相对应的适配器将会在内部通过 HTTP 请求这个消息源客户端的用来发送私聊消息的接口。 - -为了形象的理解,你可能需要去参考已有的那些适配器的代码。 - -## 写法 - -其实写起来非常简单,就和那些 Web 框架的 Handler 一样,继承一个基类,实现某几个固定的函数接口即可,这里需要继承的是 `msg_src_adapter.py` 中的 `Adapter` 类,此基类中已经实现了一些通用的、或默认的逻辑,对于像 `unitize_context`(上报数据统一化)、`send_private_message`(发送私聊消息)、`get_sender_group_role`(获取发送者在群组中的身份)等等接口,通常需要在子类中进行具体的、差异化的操作。 - -我们直接以 Mojo-Webqq 的适配器 `msg_src_adapters/mojo_webqq.py` 为例,代码如下(可能不是最新): - -```python -import requests - -from msg_src_adapter import Adapter, as_adapter, ConfigurationError - - -@as_adapter(via='mojo_webqq') -class MojoWebqqAdapter(Adapter): - def __init__(self, config: dict): - super().__init__(config) - if not config.get('api_url'): - raise ConfigurationError - self.api_url = config['api_url'] - - def unitize_context(self, ctx_msg: dict): - new_ctx = {'raw_ctx': ctx_msg, 'post_type': ctx_msg['post_type'], 'via': ctx_msg['via'], - 'login_id': ctx_msg['login_id']} - if new_ctx['post_type'] != 'receive_message': - return new_ctx - new_ctx['post_type'] = 'message' # Just handle 'receive_message', and make 'post_type' 'message' - new_ctx['time'] = ctx_msg['time'] - new_ctx['msg_id'] = str(ctx_msg['id']) - new_ctx['msg_type'] = ctx_msg['type'].split('_')[0] - new_ctx['msg_type'] = 'private' if new_ctx['msg_type'] == 'friend' else new_ctx['msg_type'] - new_ctx['format'] = 'text' - new_ctx['content'] = ctx_msg['content'] - - new_ctx['receiver'] = ctx_msg.get('receiver', '') - new_ctx['receiver_name'] = (requests.get(self.api_url + '/get_user_info').json() or {}).get('name', '') - new_ctx['receiver_id'] = str(ctx_msg.get('receiver_uid', '')) - new_ctx['receiver_tid'] = str(ctx_msg.get('receiver_id', '')) - - new_ctx['sender'] = ctx_msg.get('sender', '') - friend = list(filter( - lambda f: f.get('uid') == ctx_msg['sender_uid'], - requests.get(self.api_url + '/get_friend_info').json() or [] - )) - new_ctx['sender_name'] = friend[0].get('name', '') if friend else '' - new_ctx['sender_id'] = str(ctx_msg.get('sender_uid', '')) - new_ctx['sender_tid'] = str(ctx_msg.get('sender_id', '')) - - if new_ctx['msg_type'] == 'group': - new_ctx['group'] = ctx_msg.get('group', '') - new_ctx['group_id'] = str(ctx_msg.get('group_uid', '')) - new_ctx['group_tid'] = str(ctx_msg.get('group_id', '')) - - if new_ctx['msg_type'] == 'discuss': - new_ctx['discuss'] = ctx_msg.get('discuss', '') - new_ctx['discuss_tid'] = str(ctx_msg.get('discuss_id', '')) - - return new_ctx - - def get_login_info(self, ctx_msg: dict): - json = requests.get(self.api_url + '/get_user_info').json() - if json: - json['user_tid'] = json.get('id') - json['user_id'] = json.get('uid') - json['nickname'] = json.get('name') - return json - - def _get_group_info(self): - return requests.get(self.api_url + '/get_group_info').json() - - def get_sender_group_role(self, ctx_msg: dict): - groups = list(filter( - lambda g: str(g.get('id')) == ctx_msg['raw_ctx'].get('group_id'), - self._get_group_info() or [] - )) - if len(groups) <= 0 or 'member' not in groups[0]: - # This is strange, not likely happens - return 'member' - members = list(filter( - lambda m: str(m.get('id')) == ctx_msg['raw_ctx'].get('sender_id'), - groups[0].get('member') - )) - if len(members) <= 0: - # This is strange, not likely happens - return 'member' - return members[0].get('role', 'member') - - def send_private_message(self, target: dict, content: str): - params = None - if target.get('user_id'): - params = {'uid': target.get('user_id')} - elif target.get('user_tid'): - params = {'id': target.get('user_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_friend_message', params=params) - - def send_group_message(self, target: dict, content: str): - params = None - if target.get('group_id'): - params = {'uid': target.get('group_id')} - elif target.get('group_tid'): - params = {'id': target.get('group_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_group_message', params=params) - - def send_discuss_message(self, target: dict, content: str): - params = None - if target.get('discuss_tid'): - params = {'id': target.get('discuss_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_discuss_message', params=params) -``` - -代码逻辑上很简单,首先调用 `@as_adapter(via='mojo_webqq')` 把类注册为适配器,`via` 就是配置文件里定义消息源时候要填的那个 `via`,同时也是上报消息路径里的那个 `via`。初始化函数里面要求配置文件中的消息源定义里必须有 `api_url`。 - -`unitize_context` 函数是用来统一上报消息上下文的,这个上下文(Context)是一个字典类型,在整个程序中起核心作用,此函数需要将消息源发送来的数据转换成本程序能够理解的格式,也就是对字段进行翻译,需要翻译成一个统一的格式,这个格式见 [统一消息上下文](https://cczu-dev.github.io/xiaokai-bot/#/Context)。 - -其它的函数就是对调用操作的翻译,例如把 `send_group_message` 的调用翻译成对 `self.api_url + '/send_group_message'` 的 HTTP 请求。 - -### 消息发送目标的定义 - -由于发送消息使用一个统一接口,插件中调用时并不知道是哪个适配器接收到调用,所以发送消息的目标同样是需要统一的,也即 `send_message` 函数的 `target` 参数,此参数应当和消息上下文兼容,也就是说,当调用发送消息的接口时,直接把消息上下文传入,就应当能正确发送到此消息上下文所在的语境(比如和某个用户的私聊消息或某个群组中)。 - -它主要应当接受如下字段: - -| 字段名 | 说明 | -| -------------------------- | ---------------------------------------- | -| `user_id`/`user_tid` | 消息要发送的对象(私聊用户)的 ID | -| `group_id`/`group_tid` | 要发送的群组 ID | -| `discuss_id`/`discuss_tid` | 要发送的讨论组 ID | -| `content` | 消息内容,通常是 str 类型,目前所有适配器只支持发送文本消息(str 类型) | - -以上所有 `xxx_id` 和 `xxx_tid` 分别表示固定 ID 和临时 ID,这和消息上下文中的定义一样,即,固定 ID(`xxx_id`)表示重新登录不会变的 ID,通常即为该消息平台的账号(微信 ID、QQ 号),临时 ID(`xxx_tid`)表示在消息源的此次登录中不会变的 ID,但下次登录可能同一个用户的 ID 和上次不同,对于某些平台(如微信),可能有时完全无法获取到固定 ID,此时临时 ID 将成为发送消息时的重要依据。 - -### 其它 - -对于需要对群组中用户身份进行区分的情况,例如某些命令只允许群组管理员运行,要实现 `get_sender_group_role` 函数,此函数返回的成员身份应为 `member`、`admin`、`owner` 三者之一。 diff --git a/docs/Write_Command.md b/docs/Write_Command.md deleted file mode 100644 index c37be8b1..00000000 --- a/docs/Write_Command.md +++ /dev/null @@ -1,120 +0,0 @@ -# 编写命令 - -`commands` 目录中所有不以 `_` 开头的 `.py` 文件会被加载进程序,一般把命令放在这个目录里。对于临时不需要的命令,可以通过在文件名前加 `_` 来屏蔽掉。要自行编写命令,需要涉及到的概念比较多,请参考下面内容,或参考已内置的命令的写法。 - -## 命令仓库 Command Registry - -每个 `.py` 文件,就是一个命令仓库,里面可以注册多个命令,每个命令也可以注册多个命令名。 - -程序启动时,会自动加载 `commands` 目录下的所有 `.py` 文件(模块)中的 `__registry__` 对象,这是一个 `CommandRegistry` 类型的对象,在创建这个对象的时候,可以指定一个 `init_func` 参数作为初始化函数,将会在命令仓库被加载时调用。 - -使用 `__registry__` 对象的 `register` 装饰器可用来将一个函数注册为一个命令,装饰器的一个必填参数为命令名,可选参数 `hidden` 表示是否将命令名暴露为可直接调用(即形如 `/command_name` 这样调用),如果此项设为 True 则只能在命令名前加仓库名调用。 - -同一个函数可以注册多次不同命令名(相当于别名),以适应不同的语境。 - -`CommandRegistry` 类的 `restrict` 装饰器用于限制命令的调用权限,这个装饰器必须在注册命令到仓库之前调用,表现在代码上就是 `restrict` 装饰器必须在所有 `register` 装饰器下方。关于此装饰器的参数基本上从参数名就能看出含义,具体见 `command.py` 中的代码。 - -有一点需要注意的是,`full_command_only` 参数和 `register` 装饰器的 `hidden` 参数表现出的特性相同(都是防止直接访问),但不同之处在于它对整个命令有效,无论命令被以不同命令名注册过多少次。这导致 `restrict` 的 `full_command_only` 参数和 `register` 的 `hidden` 参数的建议使用场景有所不同,比如,当需要添加一个可以确定不希望普通用户意外调用到的命令时,可使用如下方式注册: - -```python -@__registry__.register('pro_command') -@__registry__.restrict(full_command_only=True, allow_group=False) -def pro_command(args_text, ctx_msg): - pass -``` - -而如果需要添加一个希望普通用户使用、同时也让高级用户使用起来更舒适的命令,可能用如下方式注册: - -```python -@__registry__.register('list_all', hidden=True) -@__registry__.register('列出所有笔记') -@__registry__.restrict(group_admin_only=True) -def list_all(args_text, ctx_msg): - pass -``` - -这样可以在保持高级用户可以通过简洁的方式调用命令的同时,避免不同命令仓库下同名命令都被调用的问题(因为在默认情况下命令中心在调用命令时,不同仓库中的同名命令都会被依次调用)。 - -16.12.29 注:由于 Mojo-Weixin 无法获取到群组中成员的身份(普通成员还是管理员或群主),因此这里的对群组的限制在微信上不起效果,超级用户限制在能够获取到发送者微信 ID 的情况下有效。 - -17.2.15 注:如果使用 Mojo-Webqq 作为消息源,现在也无法获取群组中的成员身份了,因此造成对群成员身份有要求的命令在此种情况下也无法使用。 - -## 命令中心 Command Hub - -程序启动时加载的命令仓库全部被集中在了命令中心,以 `.py` 文件名(除去后缀)(也即模块名)为仓库名。命令中心实际上应作为单例使用(插件编写者不应当自己创建实例),即 `command.py` 中的 `hub` 对象,类型是 CommandHub,调用它的 `call` 方法将会执行相应的命令,如果调用失败(如命令不存在、没有权限等)会抛出相应的异常。 - -## 命令之间内部调用 - -调用命令中心的 `call` 方法是一种可行的命令内部调用方法,不过由于 `call` 方法内会进行很多额外操作(例如命令名匹配、权限检查等),所以并不建议使用这个方法来进行内部调用。 - -一般而言,当编写命令时发现需要调用另一个已有的命令,可以直接导入相应的模块,然后调用那个命令函数,这样避免了冗杂的命令名匹配过程,例如: - -```python -from commands import core - -@__registry__.register('cmd_need_call_another') -def cmd_need_call_another(args_text, ctx_msg): - core.echo(args_text, ctx_msg) -``` - -这里直接调用了 `core.echo`。 - -## 数据持久化 - -可以使用数据库或文件来对数据进行持久化,理论上只要自行实现数据库和文件的操作即可,这里为了方便起见,在 `little_shit.py` 提供了获取默认数据库路径、默认临时文件路径等若干函数。 - -使用默认的路径,可以保持文件结构相对比较清晰。 - -## Source 和 Target - -对于用户发送的消息,我们需要用某种标志来区分来源,对于用户保存的数据,也要区分这份数据属于谁。在私聊消息的情况下,这个很容易理解,不同的 QQ 号就是不同的来源,然而在群消息的情况下,会产生一点区别,因此这里引入 Source 和 Target 两个概念。 - -Source 表示命令的来源(由谁发出),Target 表示命令将对谁产生效果。 - -在私聊消息中,这两者没有区别。在群聊中,每个用户(如果限制了权限,则可能只有管理员或群主,但这不影响理解)都可以给 bot 发送命令,但命令在后台保存的数据,应当是属于整个群组的,而不是发送命令的这个用户。与此同时,不同的用户在群聊中发送命令时,命令应当能区分他们,并在需要交互时正确地区分不同用户的会话(关于会话的概念,在下一个标题下)。 - -以 `commands/note.py` 中的命令为例,多个管理员可以同时开启会话来添加笔记,最终,这些笔记都会存入群组的数据中,因此这些命令通过 Source 来区分会话,用 Target 来在数据库中区分数据的归属。这也是建议的用法。 - -至于如何获取 Source 和 Target,可用 `little_shit.py` 中的 `get_source` 和 `get_target` 函数,当然,如果你对默认的行为感到不满意,也可以自己去实现不一样的区分方法。 - -16.12.29 注:在支持了 Mojo-Weixin 消息源之后,此处有所变化,由于一些限制,有时候无法获得发送者的微信 ID,而群组甚至没有一个固定 ID,因此,这里对 Source 和 Target 做一个精确定义:Source 是一个用来表示当前消息的发送者的唯一值,但重新登录后可能变化,并且每次获取,一定可以获取到;Target 是一个用来表示当前消息所产生的效果需要作用的对象,这个值是永久(或至少长期)不变的,如果当前的消息语境下不存在这样的值,则为 None。 - -17.2.15 注:在使用了适配器模式后,`get_source` 和 `get_target` 函数的实现实际上已经移到了 `msg_src_adapter.py` 中的 `Adapter` 基类。 - -## 交互式命令 Interactive Command - -通常我们会希望一条命令直接在一条消息中发出然后直接执行就好,但这在某些情况下对普通用户并不友好,因此这里支持了交互式命令,也就是在一条命令调用之后,进行多次后续互动,来引导用户完成数据的输入。 - -为了实现交互式命令,引入了「会话 Session」的概念。 - -我们以 `commands/note.py` 里的 `note.take` 命令为例,如果发送命令 `/记笔记`(此为一个别名,和 `/note.take` 等价),且不加参数,那么 `note.take` 命令就认为需要开启交互式会话来引导用户输入需要记录的内容,调用 `interactive.py` 中的 `get_session` 函数,由于原先不存在该 Source 的会话,且传入了 `cmd` 参数,就会新创建一个会话,并注册在 `interactive.py` 中的 `_sessions` 字典(这是一个 TTL 字典,目前写死了有效时间 5 分钟,如果需要实现其它有效时间的会话,请先自行实现)。在这个获取的会话对象中,可以保存当前会话的状态和数据。 - -注意这里的会话对象,是每个 Source 对应一个。一旦创建了一个会话对象,该 Source 原来可能对应的会话就会被关闭。 - -另外,主程序 `app.py` 中处理接收到的消息时,面临两种消息,一种是命令,一种是不带命令的普通消息,对这两种消息,分别作如下处理: - -- 如果是命令,那么不管前面是不是在会话中,都会清除原来的会话,然后启动新的命令(至于新的命令会不会开启新的会话,并没有影响); -- 如果是普通消息,那么如果当前 Source 在某个会话中,就会将消息内容作为参数,调用该会话的命令,如果没有在会话中,则调用 fallback 命令(一般让图灵机器人去处理)。 - -除了获取会话对象,还需要在命令中自己实现一个状态机,根据会话对象中保存的状态来判断当前这个 Source 处在交互式命令的哪一个阶段。 - -总体来说交互式命令相比普通命令写起来更复杂一点,具体写法可以参考 `commands/note.py`。 - -## 计划任务型命令 - -高级用户可以使用 `commands/scheduler.py` 里的命令来添加计划任务以定期执行某一个或一连串命令,但对普通用户来说可能较难使用,因此对于可能有需要定期执行的命令,可以编写相应的订阅命令来方便用户使用。 - -命令编写者只需要在后台帮用户把要执行的任务翻译成 `commands/scheduler.py` 能够处理的形式,并直接调用其中的函数即可,该文件中的命令一般接受一个 `internal` 参数来表示是否是命令间的内部调用,在调用时,指定该参数为 True 将不会对用户发送消息,并且在执行结束后会返回相应的返回值以便调用者知道命令执行是否成功等,具体可参见 `commands/scheduler.py` 的代码。 - -## 命令参数 - -命令的函数的第一个参数为命令参数,默认情况下,是一个字符串,即用户发送的消息中命令后面的内容,可以自行切割、分析。如果需要使用默认的命令参数分隔符,可以使用 `command.py` 中的 `split_arguments` 装饰器,使用之后,命令的函数将接受到一个名为 `argv` 的参数,为分割后的参数列表,而原先第一个参数还保留为原字符串。例如: - -```python -@__registry__.register('test') -@__registry__.restrict(group_admin_only=True) -@split_arguments() -def test(args_text, ctx_msg, argv=None): - if argv: - print(args[0]) -``` diff --git a/docs/Write_Filter.md b/docs/Write_Filter.md deleted file mode 100644 index 4ab21951..00000000 --- a/docs/Write_Filter.md +++ /dev/null @@ -1,46 +0,0 @@ -# 编写过滤器 - -`filters` 目录中所有不以 `_` 开头的 `.py` 文件会被加载进程序,一般把过滤器放在这个目录里。对于临时不需要的过滤器,可以通过在文件名前加 `_` 来屏蔽掉。 - -## 写法 - -编写过滤器比较简单,只需要调用 `filter.py` 中的 `add_filter` 函数或 `as_filter` 装饰器,传入过滤器函数和优先级,即可。 - -比如我们需要做一个消息拦截器,当匹配到消息中有不文明词汇,就发送一条警告,并拦截消息不让后续过滤器和命令处理,代码可能如下: - -```python -from filter import add_filter, as_filter -from commands import core - - -def _interceptor(ctx_msg): - if 'xxx' in ctx_msg.get('content', ''): - core.echo('请不要说脏话', ctx_msg) - return False - return True - -add_filter(_interceptor, priority=100) - -# 或下面这样 - -@as_filter(priority=100) -def _interceptor(ctx_msg): - if 'xxx' in ctx_msg.get('content', ''): - core.echo('请不要说脏话', ctx_msg) - return False - return True -``` - -一般建议优先级设置为 0~100 之间。 - -过滤器函数返回 True 表示让消息继续传递,返回 False 表示拦截消息。由于很多情况下可能不需要拦截,因此为了方便起见,将不返回值的情况(返回 None)作为不拦截处理,因此只要返回结果 is not False 就表示不拦截。 - -## 现有的几个重要过滤器 - -| 文件 | 优先级 | 作用 | 备注 | -| ------------------------------------- | ---- | ---------------------------------------- | -------------------------------------- | -| message_logger_1000.py | 1000 | 把收到的消息打印在标准输出 | 不建议添加比它优先级更高的过滤器 | -| intercept_some_message_formats_100.py | 100 | 拦截某些不支持的消息类型,对于文本消息,会把 `content` 字段复制到 `text` 字段 | 如果要自己编写插件,这里可以按需修改 | -| speech_recognition_90.py | 90 | 对语音消息进行语音识别(仅私聊消息),并把识别出的文字放到 `text` 字段,并标记 `from_voice` 字段为 True | 此过滤器只对 Mojo-Weixin 消息源生效,如果不需要可以删掉 | -| split_at_xiaokai_50.py | 50 | 分离群组和讨论组中消息开头的 `@CCZU 小开`,并更新 `text` 字段为剩余部分 | 也就是说通过此过滤器的消息,就是确定用户的意图就是和这个 bot 说话的消息 | -| command_dispatcher_0.py | 0 | 识别消息中的命令,并进行相应的调用 | | \ No newline at end of file diff --git a/docs/Write_NLProcessor.md b/docs/Write_NLProcessor.md deleted file mode 100644 index daaf2b0e..00000000 --- a/docs/Write_NLProcessor.md +++ /dev/null @@ -1,24 +0,0 @@ -# 编写自然语言处理器 - -`nl_processors` 目录中所有不以 `_` 开头的 `.py` 文件会被加载进程序,一般把自然语言处理器(后面称 NL 处理器)放在这个目录里。对于临时不需要的 NL 处理器,可以通过在文件名前加 `_` 来屏蔽掉。 - -## 流程 - -程序执行时 `natural_language.process` 命令会调用 `nl_processor.py` 中的 `parse_potential_commands` 函数来解析可能的等价命令,此函数会对消息文本进行分词,然后进行关键词匹配(关键词在注册 NL 处理器是传入),并调用所有匹配到的 NL 处理器,每个 NL 处理器会返回 None 或一个四元组(从 0 到 3 分别是:置信度(0~100)、命令名、参数、已解析到的数据)。 - -完成后,`parse_potential_commands` 把所有非 None 的结果放在一个 list 返回给 `natural_language.process` 命令,该命令再从中选择置信度最高,且超过 60 的命令执行(在调用之前会把已解析到的数据放在消息上下文的 `parsed_data` 字段)。如果没有置信度超过 60 的命令,则调用 `config.py` 中 `fallback_command_after_nl_processors` 字段指定的命令。 - -## 写法 - -由以上流程可知,在编写 NL 处理器时需要注册关键词,然后返回一个包含可能的等价命令和置信度的四元组。例子如下: - -```python -from nl_processor import as_processor - - -@as_processor(keywords=('翻译(为|成|到)?', '.+(文|语)')) -def _processor(sentence, segmentation): - return 90, 'translate.translate_to', '', None -``` - -注意关键词需要传入一个可迭代对象,每个元素为一个正则表达式字符串;函数接收的参数有且只有两个必填项,第一个为原文本字符串,第二个为使用 jieba 分词之后的分词列表,每个元素都包含 `flag` 和 `word` 两个属性(是对象的属性,不是字典的键),分别是词性标记(jieba 分词的词性标记见 [ICTCLAS 汉语词性标注集](https://gist.github.com/luw2007/6016931#ictclas-汉语词性标注集))和词语的字符串。 \ No newline at end of file diff --git a/docs/config.js b/docs/config.js deleted file mode 100644 index 35378bec..00000000 --- a/docs/config.js +++ /dev/null @@ -1,36 +0,0 @@ -docute.init({ - title: 'XiaoKai Bot 文档', - home: 'https://raw.githubusercontent.com/CCZU-DEV/xiaokai-bot/master/README.md', - repo: 'CCZU-DEV/xiaokai-bot', - nav: { - default: [ - { - title: '首页', path: '/' - }, - { - title: '消息源列表', path: '/Message_Sources' - }, - { - title: '开发', type: 'dropdown', - items: [ - { - title: '统一消息上下文', path: '/Context' - }, - { - title: '编写消息源适配器', path: '/Write_Adapter' - }, - { - title: '编写过滤器', path: '/Write_Filter' - }, - { - title: '编写命令', path: '/Write_Command' - }, - { - title: '编写自然语言处理器', path: '/Write_NLProcessor' - } - ] - } - ] - }, - plugins: [] -}); diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index 5074bfbb..00000000 --- a/docs/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - XiaoKai Bot 文档 - - - -
- - - - - - \ No newline at end of file diff --git a/filter.py b/filter.py deleted file mode 100644 index fc9045b2..00000000 --- a/filter.py +++ /dev/null @@ -1,22 +0,0 @@ -_filters = [] - - -def apply_filters(ctx_msg): - filters = sorted(_filters, key=lambda x: x[0], reverse=True) - for f in filters: - r = f[1](ctx_msg) - if r is False: - return False - return True - - -def add_filter(func, priority=10): - _filters.append((priority, func)) - - -def as_filter(priority=10): - def decorator(func): - add_filter(func, priority) - return func - - return decorator diff --git a/filters/_how_to_use_1.py b/filters/_how_to_use_1.py deleted file mode 100644 index 32c07fef..00000000 --- a/filters/_how_to_use_1.py +++ /dev/null @@ -1,20 +0,0 @@ -import re - -from filter import as_filter -from commands import core - - -@as_filter(priority=1) -def _print_help_message(ctx_msg): - a = ['help', '怎么用', '怎么用啊', '你好', '你好啊', '你好呀', '帮助', - '用法', '使用帮助', '使用指南', '使用说明', '使用方法', - '你能做什么', '你能做些什么', '你会做什么', '你会做些什么', - '你可以做什么', '你可以做些什么'] - text = ctx_msg.get('text', '').strip() - sender = ctx_msg.get('sender', '') - if text in a or re.match('^' + sender + '刚刚把你添加到通讯录,现在可以开始聊天了。$', text): - core.help('', ctx_msg) - return False - elif re.match('^你已添加了' + sender + ',现在可以开始聊天了。$', text): - return False - return True diff --git a/filters/allow_only_message_10000.py b/filters/allow_only_message_10000.py deleted file mode 100644 index 0f13473e..00000000 --- a/filters/allow_only_message_10000.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -This filter intercepts all post data except ones of which 'post_type' is 'message'. -""" - -from filter import as_filter - - -@as_filter(priority=10000) -def _filter(ctx_msg): - return ctx_msg.get('post_type') == 'message' diff --git a/filters/command_dispatcher_0.py b/filters/command_dispatcher_0.py deleted file mode 100644 index c017bb89..00000000 --- a/filters/command_dispatcher_0.py +++ /dev/null @@ -1,92 +0,0 @@ -import re -import sys - -import interactive -from filter import as_filter -from command import CommandNotExistsError, CommandScopeError, CommandPermissionError -from little_shit import * -from commands import core -from command import hub as cmdhub - -_fallback_command = get_fallback_command() -_command_start_flags = get_command_start_flags() -_command_args_start_flags = get_command_args_start_flags() - - -# noinspection PyBroadException -@as_filter(priority=0) -def _dispatch_command(ctx_msg): - text = ctx_msg.get('text', '').lstrip() - try: - if not text: - raise SkipException - source = get_source(ctx_msg) - start_flag = None - for flag in _command_start_flags: - # Match the command start flag - if text.startswith(flag): - start_flag = flag - break - ctx_msg['start_flag'] = start_flag - if start_flag is None or len(text) <= len(start_flag): - # Note: use `start_flag is None` here because empty string is allowed to be the start flag - # No command, check if a session exists - if interactive.has_session(source): - command = [interactive.get_session(source).cmd, text] - else: - # Use fallback - if _fallback_command: - command = [_fallback_command, text] - ctx_msg['is_fallback'] = True - else: - # No fallback - raise SkipException - else: - if start_flag == '' and interactive.has_session(source): - # Start flag is empty, so we don't override any sessions - command = [interactive.get_session(source).cmd, text] - else: - # Split command and arguments - command = re.split('|'.join(_command_args_start_flags), - text[len(start_flag):], 1) - if len(command) == 1: - # Add an empty argument - command.append('') - # Starting a new command, so remove previous command session, if any - interactive.remove_session(source) - - command[0] = command[0].lower() - ctx_msg['command'] = command[0] - cmdhub.call(command[0], command[1], ctx_msg) - except SkipException: - # Skip this message - pass - except CommandNotExistsError: - if ctx_msg['start_flag'] == '' and _fallback_command: - # Empty command start flag is allowed, use fallback - command = [_fallback_command, text] - command[0] = command[0].lower() - ctx_msg['command'] = command[0] - ctx_msg['is_fallback'] = True - cmdhub.call(command[0], command[1], ctx_msg) - else: - core.echo('暂时还没有这个命令哦~', ctx_msg) - except CommandPermissionError: - core.echo('你没有权限使用这个命令哦~', ctx_msg) - except CommandScopeError as se: - core.echo('这个命令不支持' + se.msg_type + '哦~', ctx_msg) - except Exception as e: - # Ignore all exceptions raised during command running - print(e) - core.echo('程序执行命令时发生了一点错误,可能没法回复啦~', ctx_msg) - - -def _add_registry_mod_cb(mod): - mod_name = mod.__name__.split('.')[1] - try: - cmdhub.add_registry(mod_name, mod.__registry__) - except AttributeError: - print('Failed to load command module "' + mod_name + '.py".', file=sys.stderr) - - -load_plugins('commands', module_callback=_add_registry_mod_cb) diff --git a/filters/intercept_blocked_targets_100.py b/filters/intercept_blocked_targets_100.py deleted file mode 100644 index 387759f8..00000000 --- a/filters/intercept_blocked_targets_100.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -This filter intercepts messages from blocked targets (blocked using sudo.block command). -""" - -from commands import sudo -from filter import as_filter -from little_shit import get_target - - -@as_filter(priority=100) -def _filter(ctx_msg): - target = get_target(ctx_msg) - if not target: - return True - - if target in sudo.block_list('', ctx_msg, internal=True): - return False - return True diff --git a/filters/intercept_massive_platform_100.py b/filters/intercept_massive_platform_100.py deleted file mode 100644 index 8abc74da..00000000 --- a/filters/intercept_massive_platform_100.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -This filter intercepts messages that are from massive platforms. -""" - -from filter import as_filter - - -@as_filter(priority=100) -def _filter(ctx_msg): - return not ctx_msg.get('is_massive_platform', False) diff --git a/filters/intercept_some_message_formats_100.py b/filters/intercept_some_message_formats_100.py deleted file mode 100644 index 6bb8091a..00000000 --- a/filters/intercept_some_message_formats_100.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -This filter intercepts messages that contains content not allowed and move text content to 'text' field. -""" - -from filter import as_filter - - -@as_filter(priority=100) -def _filter(ctx_msg): - msg_format = ctx_msg.get('format') - if msg_format != 'text' and ctx_msg.get('msg_type') != 'private': - return False - if msg_format not in ('text', 'media'): - return False - if msg_format == 'text': - # Directly use the text in content as the 'text' - ctx_msg['text'] = ctx_msg.get('content') - return True diff --git a/filters/message_logger_1000.py b/filters/message_logger_1000.py deleted file mode 100644 index 1ae29db1..00000000 --- a/filters/message_logger_1000.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This filter just log message to stdout. -""" - -from filter import as_filter - - -@as_filter(priority=1000) -def _log_message(ctx_msg): - log = ctx_msg.get('sender') or ctx_msg.get('sender_name') or ctx_msg.get('sender_id') or '未知用户' - if ctx_msg.get('msg_type') == 'group': - log += '@' + (ctx_msg.get('group') or ctx_msg.get('group_id') or '未知群组') - if ctx_msg.get('msg_type') == 'discuss': - log += '@' + (ctx_msg.get('discuss') or ctx_msg.get('discuss_id') or '未知讨论组') - log += ': ' + ctx_msg.get('content', '') - print(log) diff --git a/filters/speech_recognition_90.py b/filters/speech_recognition_90.py deleted file mode 100644 index 978897e3..00000000 --- a/filters/speech_recognition_90.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -This filter recognizes speech in voice message and stores it in 'text' field of context message. - -NOTE! This filter is only for Mojo-Weixin platform. -""" - -import re -import os -import sys -import base64 - -import requests -from pydub import AudioSegment -import speech_recognition as sr - -from filter import as_filter -from commands import core -from little_shit import get_source - - -def _recognize_baidu(wav_path, unique_id, api_key, secret_key, language='zh'): - api_url = 'http://vop.baidu.com/server_api' - auth_url = 'https://openapi.baidu.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s' \ - % (api_key, secret_key) - resp = requests.get(auth_url) - if resp.status_code == 200: - data = resp.json() - if data and 'access_token' in data: - token = data['access_token'] - with open(wav_path, 'rb') as f: - audio_data = f.read() - audio_data_b64 = base64.b64encode(audio_data).decode('utf-8') - json = { - 'format': 'wav', - 'rate': 8000, - 'channel': 1, - 'cuid': unique_id, - 'token': token, - 'lan': language, - 'speech': audio_data_b64, - 'len': len(audio_data) - } - resp = requests.post(api_url, json=json) - if resp.status_code == 200: - data = resp.json() - if data and 'result' in data: - return ''.join(data['result']).strip(',。?!') - return None - - -def _recognize_bing(wav_path, api_key, language='zh-CN'): - r = sr.Recognizer() - with sr.AudioFile(wav_path) as source: - audio = r.record(source) - try: - text = r.recognize_bing(audio, key=api_key, language=language) - return text - except (sr.UnknownValueError, sr.RequestError): - return None - - -@as_filter(priority=90) -def _filter(ctx_msg): - if ctx_msg.get('format') == 'media' and ctx_msg['raw_ctx'].get('media_type') == 'voice': - m = re.match('\[语音\]\(([/_A-Za-z0-9]+\.mp3)\)', ctx_msg.get('content')) - if m: - core.echo('正在识别语音内容,请稍等……', ctx_msg) - mp3_path = m.group(1) - wav_path = os.path.splitext(mp3_path)[0] + '.wav' - voice = AudioSegment.from_mp3(mp3_path) - voice.export(wav_path, format='wav') - - service = os.environ.get('SPEECH_RECOGNITION_SERVICE', '').lower() - text = None - service_full_name = None - if service == 'baidu': - service_full_name = '百度语音识别' - text = _recognize_baidu( - wav_path, - get_source(ctx_msg), - os.environ.get('BAIDU_SPEECH_API_KEY'), - os.environ.get('BAIDU_SPEECH_SECRET_KEY'), - language='zh' - ) - elif service == 'bing': - service_full_name = '必应语音识别' - text = _recognize_bing( - wav_path, - os.environ.get('BING_SPEECH_API_KEY'), - language='zh-CN' - ) - else: - print('Unknown speech recognition service name.', file=sys.stderr) - - if text: - reply = '识别结果(' + service_full_name + '):\n%s\n\n下面将把识别到的内容作为文字消息处理……' % text - ctx_msg['text'] = text - ctx_msg['from_voice'] = True - else: - reply = '抱歉哦,没有识别出你说的是什么' - core.echo(reply, ctx_msg) - os.remove(wav_path) diff --git a/filters/split_at_xiaokai_50.py b/filters/split_at_xiaokai_50.py deleted file mode 100644 index 44a9c0f6..00000000 --- a/filters/split_at_xiaokai_50.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -This filter intercepts messages not intended to the bot and removes the beginning "@xxx". -""" - -from filter import as_filter -from msg_src_adapter import get_adapter_by_ctx - - -@as_filter(priority=50) -def _split_at_xiaokai(ctx_msg): - if ctx_msg.get('is_at_me'): - # Directly return because it has been confirmed by previous processes - return True - - if ctx_msg.get('msg_type') == 'group' or ctx_msg.get('msg_type') == 'discuss': - text = ctx_msg.get('text', '') - if text.startswith('@'): - my_group_nick = ctx_msg.get('receiver') or ctx_msg.get('receiver_name') or '' - at_me = '@' + my_group_nick - if not my_group_nick or not text.startswith(at_me): - user_info = get_adapter_by_ctx(ctx_msg).get_login_info() - my_nick = user_info.get('nickname') - if not my_nick: - return False - at_me = '@' + my_nick - if not text.startswith(at_me): - return False - text = text[len(at_me):] - else: - # Not starts with '@' - return False - ctx_msg['text'] = text.lstrip() - return True diff --git a/interactive.py b/interactive.py deleted file mode 100644 index c27ad221..00000000 --- a/interactive.py +++ /dev/null @@ -1,35 +0,0 @@ -from cachetools import TTLCache as TTLDict - - -class _Session: - __dict__ = ('cmd', 'state', 'data') - - def __init__(self, cmd): - self.cmd = cmd - self.state = 0 - self.data = {} - - -_sessions = TTLDict(maxsize=10000, ttl=5 * 60) - - -def get_session(source, cmd=None): - if cmd: - if source in _sessions and _sessions[source].cmd == cmd: - # It's already in a session of this command - return _sessions[source] - sess = _Session(cmd) - _sessions[source] = sess - return sess - else: - return _sessions.get(source) - - -def has_session(source, cmd=None): - return source in _sessions and (not cmd or _sessions[source].cmd == cmd) - - -def remove_session(source, cmd=None): - if source in _sessions: - if not cmd or _sessions[source].cmd == cmd: - del _sessions[source] diff --git a/little_shit.py b/little_shit.py deleted file mode 100644 index 4e2d50ed..00000000 --- a/little_shit.py +++ /dev/null @@ -1,104 +0,0 @@ -import importlib -import os -import functools - -from config import config - - -class SkipException(Exception): - pass - - -def _mkdir_if_not_exists_and_return_path(path): - os.makedirs(path, exist_ok=True) - return path - - -def get_root_dir(): - return os.path.split(os.path.realpath(__file__))[0] - - -def get_plugin_dir(plugin_dir_name): - return _mkdir_if_not_exists_and_return_path(os.path.join(get_root_dir(), plugin_dir_name)) - - -def load_plugins(plugin_dir_name, module_callback=None): - plugin_dir = get_plugin_dir(plugin_dir_name) - plugin_files = filter( - lambda filename: filename.endswith('.py') and not filename.startswith('_'), - os.listdir(plugin_dir) - ) - plugins = [os.path.splitext(file)[0] for file in plugin_files] - for mod_name in plugins: - mod = importlib.import_module(plugin_dir_name + '.' + mod_name) - if module_callback: - module_callback(mod) - - -def get_db_dir(): - return _mkdir_if_not_exists_and_return_path(os.path.join(get_root_dir(), 'data', 'db')) - - -def get_default_db_path(): - return os.path.join(get_db_dir(), 'default.sqlite') - - -def get_tmp_dir(): - return _mkdir_if_not_exists_and_return_path(os.path.join(get_root_dir(), 'data', 'tmp')) - - -def get_source(ctx_msg): - from msg_src_adapter import get_adapter_by_ctx - return get_adapter_by_ctx(ctx_msg).get_source(ctx_msg) - - -def get_target(ctx_msg): - from msg_src_adapter import get_adapter_by_ctx - return get_adapter_by_ctx(ctx_msg).get_target(ctx_msg) - - -def check_target(func): - """ - This decorator checks whether there is a target value, and prevent calling the function if not. - """ - - @functools.wraps(func) - def wrapper(args_text, ctx_msg, *args, **kwargs): - from msg_src_adapter import get_adapter_by_ctx - adapter = get_adapter_by_ctx(ctx_msg) - target = adapter.get_target(ctx_msg) - if not target: - adapter.send_message(ctx_msg, '当前语境无法使用这个命令,请尝试发送私聊消息或稍后再试吧~') - return - else: - return func(args_text, ctx_msg, *args, **kwargs) - - return wrapper - - -def get_command_start_flags(): - return tuple(sorted(config.get('command_start_flags', ('',)), reverse=True)) - - -def get_command_name_separators(): - return tuple(sorted(('\.',) + config.get('command_name_separators', ()), reverse=True)) - - -def get_command_args_start_flags(): - return tuple(sorted(('[ \\t\\n]+',) + config.get('command_args_start_flags', ()), reverse=True)) - - -def get_command_args_separators(): - return tuple(sorted(('[ \\t\\n]+',) + config.get('command_args_separators', ()), reverse=True)) - - -def get_fallback_command(): - return config.get('fallback_command') - - -def get_fallback_command_after_nl_processors(): - return config.get('fallback_command_after_nl_processors') - - -def get_message_sources(): - return config.get('message_sources', []) diff --git a/msg_src_adapter.py b/msg_src_adapter.py deleted file mode 100644 index bbccf0e7..00000000 --- a/msg_src_adapter.py +++ /dev/null @@ -1,133 +0,0 @@ -import hashlib -import random -from datetime import datetime - -from little_shit import get_message_sources - -_adapter_classes = {} # one message source, one adapter class -_adapter_instances = {} # one login account, one adapter instance - - -def as_adapter(via): - def decorator(cls): - _adapter_classes[via] = cls - return cls - - return decorator - - -class Adapter(object): - def __init__(self, config: dict): - self.login_id = config.get('login_id') - self.superuser_id = config.get('superuser_id') - - def unitize_context(self, ctx_msg: dict): - return None - - def send_message(self, target: dict, content): - if target is None or content is None: - return - - msg_type = target.get('msg_type', 'private') - if msg_type == 'group' and hasattr(self, 'send_group_message'): - self.send_group_message(target, content) - elif msg_type == 'discuss' and hasattr(self, 'send_discuss_message'): - self.send_discuss_message(target, content) - elif msg_type == 'private' and hasattr(self, 'send_private_message'): - if 'user_id' not in target and 'sender_id' in target: - target['user_id'] = target['sender_id'] # compatible with ctx_msg - if 'user_tid' not in target and 'sender_tid' in target: - target['user_tid'] = target.get('sender_tid') # compatible with ctx_msg - - self.send_private_message(target, content) - - if 'user_id' in target and 'sender_id' in target: - del target['user_id'] - if 'user_tid' in target and 'sender_tid' in target: - del target['user_tid'] - - def get_login_info(self): - return {} - - def is_sender_superuser(self, ctx_msg: dict): - return ctx_msg.get('sender_id') == self.superuser_id - - def get_sender_group_role(self, ctx_msg: dict): - return 'member' - - @staticmethod - def get_target(ctx_msg: dict): - """ - Target is used to distinguish the records in database. - Note: This value will not change after restarting the bot. - - :return: an unique string (account id with some flags) representing a target, - or None if there is no persistent unique value - """ - if ctx_msg.get('msg_type') == 'group' and ctx_msg.get('group_id'): - return 'g#' + ctx_msg.get('group_id') - elif ctx_msg.get('msg_type') == 'discuss' and ctx_msg.get('discuss_id'): - return 'd#' + ctx_msg.get('discuss_id') - elif ctx_msg.get('msg_type') == 'private' and ctx_msg.get('sender_id'): - return 'p#' + ctx_msg.get('sender_id') - return None - - @staticmethod - def get_source(ctx_msg: dict): - """ - Source is used to distinguish the interactive sessions. - Note: This value may change after restarting the bot. - - :return: a 32 character unique string (md5) representing a source, or a random value if something strange happened - """ - source = None - if ctx_msg.get('msg_type') == 'group' and ctx_msg.get('group_tid') and ctx_msg.get('sender_tid'): - source = 'g#' + ctx_msg.get('group_tid') + '#p#' + ctx_msg.get('sender_tid') - elif ctx_msg.get('msg_type') == 'discuss' and ctx_msg.get('discuss_tid') and ctx_msg.get('sender_tid'): - source = 'd#' + ctx_msg.get('discuss_tid') + '#p#' + ctx_msg.get('sender_tid') - elif ctx_msg.get('msg_type') == 'private' and ctx_msg.get('sender_tid'): - source = 'p#' + ctx_msg.get('sender_tid') - if not source: - source = str(int(datetime.now().timestamp())) + str(random.randint(100, 999)) - return hashlib.md5(source.encode('utf-8')).hexdigest() - - -class ConfigurationError(KeyError): - pass - - -def get_adapter_by_ctx(ctx_msg: dict): - if ctx_msg: - via = ctx_msg.get('via') - login_id = ctx_msg.get('login_id') - return get_adapter(via, login_id) - return None - - -def get_adapter(via: str, login_id: str): - if via == 'default': - # For the situations where 'via' does not matter, e.g. when we just want 'get_target' (which is universal) - if 'default' in _adapter_instances: - return _adapter_instances['default'] - else: - _adapter_instances['default'] = Adapter({}) - return _adapter_instances['default'] - - if not (via and login_id): - return None - - key = hashlib.md5(via.encode('utf-8') + login_id.encode('utf-8')).hexdigest() - if key in _adapter_instances: - return _adapter_instances[key] - else: - msg_src_list = list(filter( - lambda msg_src: msg_src['via'] == via and msg_src['login_id'] == login_id, - get_message_sources() - )) - if len(msg_src_list): - if via not in _adapter_classes: - return None - _adapter_instances[key] = _adapter_classes[via](msg_src_list[0]) - return _adapter_instances[key] - else: - return None diff --git a/msg_src_adapters/coolq_http_api.py b/msg_src_adapters/coolq_http_api.py deleted file mode 100644 index df7afa60..00000000 --- a/msg_src_adapters/coolq_http_api.py +++ /dev/null @@ -1,106 +0,0 @@ -import requests -from flask import request as flask_req - -from msg_src_adapter import Adapter, as_adapter, ConfigurationError - - -@as_adapter(via='coolq_http_api') -class CoolQHttpApiAdapter(Adapter): - def __init__(self, config: dict): - super().__init__(config) - if not config.get('api_url'): - raise ConfigurationError - self.api_url = config['api_url'] - self.token = config.get('token') - self.session = requests.Session() - if self.token: - self.session.headers['Authorization'] = 'token ' + self.token - - def unitize_context(self, ctx_msg: dict): - # Check token - if flask_req.headers.get('Authorization') != self.session.headers.get('Authorization'): - return None - - new_ctx = {'raw_ctx': ctx_msg, 'post_type': ctx_msg['post_type'], 'via': ctx_msg['via'], - 'login_id': ctx_msg['login_id']} - if new_ctx['post_type'] != 'message': - return new_ctx - - new_ctx['time'] = ctx_msg['time'] - new_ctx['msg_type'] = ctx_msg['message_type'] - new_ctx['format'] = 'text' - new_ctx['content'] = ctx_msg['message'] - - login_info = self.get_login_info() - new_ctx['receiver_name'] = login_info['nickname'] - new_ctx['receiver_id'] = login_info['user_id'] - new_ctx['receiver_tid'] = login_info['user_id'] - - new_ctx['sender_id'] = str(ctx_msg.get('user_id', '')) - new_ctx['sender_tid'] = new_ctx['sender_id'] - json = self.session.get(self.api_url + '/get_stranger_info', - params={'user_id': new_ctx['sender_id']}).json() - if json and json.get('data'): - new_ctx['sender_name'] = json['data']['nickname'] - - if new_ctx['msg_type'] == 'group': - new_ctx['group_id'] = str(ctx_msg.get('group_id', '')) - new_ctx['group_tid'] = new_ctx['group_id'] - - if new_ctx['msg_type'] == 'discuss': - new_ctx['discuss_id'] = str(ctx_msg.get('discuss_id', '')) - new_ctx['discuss_tid'] = new_ctx['discuss_id'] - - import re - if re.search('\\[CQ:at,qq=%s\\]' % new_ctx['receiver_id'], new_ctx['content']): - new_ctx['content'] = re.sub('\\[CQ:at,qq=%s\\]' % new_ctx['receiver_id'], '', new_ctx['content']).lstrip() - new_ctx['is_at_me'] = True - - return new_ctx - - def get_login_info(self): - json = self.session.get(self.api_url + '/get_login_info').json() - if json and json.get('data'): - json['user_id'] = str(json['data'].get('user_id', '')) - json['user_tid'] = json['data']['user_id'] - json['nickname'] = json['data'].get('nickname', '') - return json - - def get_sender_group_role(self, ctx_msg: dict): - json = self.session.get( - self.api_url + '/get_group_member_info', - params={'group_id': ctx_msg.get('group_id'), 'user_id': ctx_msg.get('sender_id')} - ).json() - if json and json.get('data'): - return json['data']['role'] - return 'member' - - def send_private_message(self, target: dict, content: str): - params = None - if target.get('user_id'): - params = {'user_id': target.get('user_id')} - - if params: - params['message'] = content - params['is_raw'] = True - self.session.get(self.api_url + '/send_private_msg', params=params) - - def send_group_message(self, target: dict, content: str): - params = None - if target.get('group_id'): - params = {'group_id': target.get('group_id')} - - if params: - params['message'] = content - params['is_raw'] = True - self.session.get(self.api_url + '/send_group_msg', params=params) - - def send_discuss_message(self, target: dict, content: str): - params = None - if target.get('discuss_id'): - params = {'discuss_id': target.get('discuss_id')} - - if params: - params['message'] = content - params['is_raw'] = True - self.session.get(self.api_url + '/send_discuss_msg', params=params) diff --git a/msg_src_adapters/mojo_webqq.py b/msg_src_adapters/mojo_webqq.py deleted file mode 100644 index f600aabe..00000000 --- a/msg_src_adapters/mojo_webqq.py +++ /dev/null @@ -1,109 +0,0 @@ -import requests - -from msg_src_adapter import Adapter, as_adapter, ConfigurationError - - -@as_adapter(via='mojo_webqq') -class MojoWebqqAdapter(Adapter): - def __init__(self, config: dict): - super().__init__(config) - if not config.get('api_url'): - raise ConfigurationError - self.api_url = config['api_url'] - - def unitize_context(self, ctx_msg: dict): - new_ctx = {'raw_ctx': ctx_msg, 'post_type': ctx_msg['post_type'], 'via': ctx_msg['via'], - 'login_id': ctx_msg['login_id']} - if new_ctx['post_type'] != 'receive_message': - return new_ctx - new_ctx['post_type'] = 'message' # Just handle 'receive_message', and make 'post_type' 'message' - new_ctx['time'] = ctx_msg['time'] - new_ctx['msg_id'] = str(ctx_msg['id']) - new_ctx['msg_type'] = ctx_msg['type'].split('_')[0] - new_ctx['msg_type'] = 'private' if new_ctx['msg_type'] == 'friend' else new_ctx['msg_type'] - new_ctx['format'] = 'text' - new_ctx['content'] = ctx_msg['content'] - - new_ctx['receiver'] = ctx_msg.get('receiver', '') - new_ctx['receiver_name'] = (requests.get(self.api_url + '/get_user_info').json() or {}).get('name', '') - new_ctx['receiver_id'] = str(ctx_msg.get('receiver_uid', '')) - new_ctx['receiver_tid'] = str(ctx_msg.get('receiver_id', '')) - - new_ctx['sender'] = ctx_msg.get('sender', '') - friend = list(filter( - lambda f: f.get('uid') == ctx_msg['sender_uid'], - requests.get(self.api_url + '/get_friend_info').json() or [] - )) - new_ctx['sender_name'] = friend[0].get('name', '') if friend else '' - new_ctx['sender_id'] = str(ctx_msg.get('sender_uid', '')) - new_ctx['sender_tid'] = str(ctx_msg.get('sender_id', '')) - - if new_ctx['msg_type'] == 'group': - new_ctx['group'] = ctx_msg.get('group', '') - new_ctx['group_id'] = str(ctx_msg.get('group_uid', '')) - new_ctx['group_tid'] = str(ctx_msg.get('group_id', '')) - - if new_ctx['msg_type'] == 'discuss': - new_ctx['discuss'] = ctx_msg.get('discuss', '') - new_ctx['discuss_tid'] = str(ctx_msg.get('discuss_id', '')) - - return new_ctx - - def get_login_info(self): - json = requests.get(self.api_url + '/get_user_info').json() - if json: - json['user_tid'] = json.get('id') - json['user_id'] = json.get('uid') - json['nickname'] = json.get('name') - return json - - def _get_group_info(self): - return requests.get(self.api_url + '/get_group_info').json() - - def get_sender_group_role(self, ctx_msg: dict): - groups = list(filter( - lambda g: str(g.get('id')) == ctx_msg['raw_ctx'].get('group_id'), - self._get_group_info() or [] - )) - if len(groups) <= 0 or 'member' not in groups[0]: - # This is strange, not likely happens - return 'member' - members = list(filter( - lambda m: str(m.get('id')) == ctx_msg['raw_ctx'].get('sender_id'), - groups[0].get('member') - )) - if len(members) <= 0: - # This is strange, not likely happens - return 'member' - return members[0].get('role', 'member') - - def send_private_message(self, target: dict, content: str): - params = None - if target.get('user_id'): - params = {'uid': target.get('user_id')} - elif target.get('user_tid'): - params = {'id': target.get('user_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_friend_message', params=params) - - def send_group_message(self, target: dict, content: str): - params = None - if target.get('group_id'): - params = {'uid': target.get('group_id')} - elif target.get('group_tid'): - params = {'id': target.get('group_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_group_message', params=params) - - def send_discuss_message(self, target: dict, content: str): - params = None - if target.get('discuss_tid'): - params = {'id': target.get('discuss_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_discuss_message', params=params) diff --git a/msg_src_adapters/mojo_weixin.py b/msg_src_adapters/mojo_weixin.py deleted file mode 100644 index 1aa7f748..00000000 --- a/msg_src_adapters/mojo_weixin.py +++ /dev/null @@ -1,84 +0,0 @@ -import requests - -from msg_src_adapter import Adapter, as_adapter, ConfigurationError - - -@as_adapter(via='mojo_weixin') -class MojoWeixinAdapter(Adapter): - def __init__(self, config: dict): - super().__init__(config) - if not config.get('api_url'): - raise ConfigurationError - self.api_url = config['api_url'] - - def unitize_context(self, ctx_msg: dict): - new_ctx = {'raw_ctx': ctx_msg, 'post_type': ctx_msg['post_type'], 'via': ctx_msg['via'], - 'login_id': ctx_msg['login_id']} - - if new_ctx['post_type'] == 'receive_message' and ctx_msg.get('type', '').endswith('notice'): - new_ctx['post_type'] = 'notice' # Make 'group_notice' a notice but not a message, and ignore it later - - if new_ctx['post_type'] != 'receive_message': - return new_ctx - - new_ctx['post_type'] = 'message' # Just handle 'receive_message', and make 'post_type' 'message' - new_ctx['time'] = ctx_msg['time'] - new_ctx['msg_id'] = str(ctx_msg['id']) - new_ctx['msg_type'] = ctx_msg['type'].split('_')[0] - new_ctx['msg_type'] = 'private' if new_ctx['msg_type'] == 'friend' else new_ctx['msg_type'] - new_ctx['format'] = ctx_msg.get('format', 'text') - new_ctx['content'] = ctx_msg['content'] - - new_ctx['receiver'] = ctx_msg.get('receiver', '') - new_ctx['receiver_name'] = ctx_msg.get('receiver_name', '') - new_ctx['receiver_id'] = ctx_msg.get('receiver_account', '') - new_ctx['receiver_tid'] = ctx_msg.get('receiver_id', '') - - new_ctx['sender'] = ctx_msg.get('sender', '') - new_ctx['sender_name'] = ctx_msg.get('sender_name', '') - new_ctx['sender_id'] = ctx_msg.get('sender_account', '') - new_ctx['sender_tid'] = ctx_msg.get('sender_id', '') - - if new_ctx['msg_type'] == 'group': - new_ctx['group'] = ctx_msg.get('group', '') - new_ctx['group_id'] = '' # WeChat does not has a unique group id that won't change after re-login - new_ctx['group_tid'] = ctx_msg.get('group_id', '') - - # Check if the sender is a massive platform - friend_list = requests.get(self.api_url + '/search_friend', params={'id': ctx_msg.get('sender_id')}).json() - if friend_list and len(friend_list) > 0: - if friend_list[0].get('category') == '公众号': - new_ctx['is_massive_platform'] = True - - return new_ctx - - def get_login_info(self): - json = requests.get(self.api_url + '/get_user_info').json() - if json: - json['user_tid'] = json.get('id') - json['user_id'] = json.get('account') - json['nickname'] = json.get('name') - return json - - def send_private_message(self, target: dict, content: str): - params = None - if target.get('user_id'): - params = {'account': target.get('user_id')} - elif target.get('user_tid'): - params = {'id': target.get('user_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_friend_message', params=params) - - def send_group_message(self, target: dict, content: str): - params = None - if target.get('group_tid'): - params = {'id': target.get('group_tid')} - - if params: - params['content'] = content - requests.get(self.api_url + '/send_group_message', params=params) - - def consult(self, account: str, content: str): - return requests.get(self.api_url + '/consult', params={'account': account, 'content': content}).json() diff --git a/nl_processor.py b/nl_processor.py deleted file mode 100644 index b3797482..00000000 --- a/nl_processor.py +++ /dev/null @@ -1,44 +0,0 @@ -import re - -import jieba.posseg - -_processors = [] -_processors_without_keyword = [] - - -def as_processor(keywords=None): - def decorator(func): - if keywords: - _processors.append((keywords, func)) - else: - _processors_without_keyword.append(func) - return func - - return decorator - - -def parse_potential_commands(sentence): - segmentation = list(jieba.posseg.cut(sentence=sentence)) - print('分词结果:', ' '.join(['[' + s.flag + ']' + s.word for s in segmentation])) - potential_commands = [] - for processor in _processors: - processed = False - for regex in processor[0]: - for s in segmentation: - word, flag = s.word, s.flag - if re.search(regex, word): - result = processor[1](sentence, segmentation) - if result: - potential_commands.append(result) - processed = True - # A word matched, skip the rest of words - break - if processed: - # Current processor has processed, skip the rest of keywords - break - for func in _processors_without_keyword: - result = func(sentence, segmentation) - if result: - potential_commands.append(result) - print('可能的命令:', potential_commands) - return potential_commands diff --git a/nl_processors/bilibili.py b/nl_processors/bilibili.py deleted file mode 100644 index eabb1c50..00000000 --- a/nl_processors/bilibili.py +++ /dev/null @@ -1,65 +0,0 @@ -import re - -from nl_processor import as_processor - - -@as_processor(keywords=('番', '动漫', '动画')) -def _processor_anime_index(sentence, segmentation): - m = re.search('(?:(?P\d{2})\s*年\s*)?(?P\d{1,2})\s*月', sentence) - year, month = None, None - if m: - year = m.group('year') - month = m.group('month') - - args_text = month if month else '' - args_text = (str(year) + ' ' + args_text) if year else args_text - - possibility = 90 - if '哪些' in sentence or '什么' in sentence: - possibility += 3 - if not re.search('b\s*站', sentence.lower()): - possibility -= 10 - - return possibility, 'bilibili.anime_index', args_text, None - - -@as_processor(keywords=('更新',)) -def _processor_anime_timeline(sentence, segmentation): - m = re.match('(?:b\s*站)?(?P(?:前|昨|今|明|大?后)天)?(?P.+?)' - '(?P(?:前|昨|今|明|大?后)天)?(?:会|有)?更(?:不更)?新', - sentence.lower()) - day_str, name = None, None - if m: - day_str = m.group('day_str') or m.group('day_str2') - name = m.group('name') - - if not name: - return None - - possibility = 90 - if not day_str: - possibility -= 5 - if '吗' in sentence: - possibility += 5 - if not re.search('b\s*站', sentence.lower()): - possibility -= 10 - - delta_day_dict = {'前天': -2, '昨天': -1, '今天': 0, '明天': 1, '后天': 2, '大后天': 3} - delta_day = delta_day_dict.get(day_str, 0) - - return possibility, 'bilibili.anime_timeline', str(delta_day) + ' ' + name, None - - -@as_processor(keywords=('更新',)) -def _processor_anime_timeline_2(sentence, segmentation): - m = re.match('(?:b\s*站)?(?P.+?)(?:(?:什么|啥)时候)?(?:会|有)?更新', sentence.lower()) - name = m.group('name') if m else None - - if not name: - return None - - possibility = 90 - if not re.search('b\s*站', sentence.lower()): - possibility -= 10 - - return possibility, 'bilibili.anime_timeline', name, None diff --git a/nl_processors/translate.py b/nl_processors/translate.py deleted file mode 100644 index 4b06fa0e..00000000 --- a/nl_processors/translate.py +++ /dev/null @@ -1,28 +0,0 @@ -import re - -from nl_processor import as_processor - -_query_lang_matcher = [ - re.compile('(?:(?:要|[应]?该)?怎么|怎样|如何)?[把将]?[\s,.,。]?(?P.*?)[\s,.,。]?' - '(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))?翻译[成为到](?P\w+?[文语])(?![\s::,,.。])'), - re.compile('(?P.+?)[\s,.,。]?(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))?[的用](?P\w+?[文语])'), - re.compile('.*?[把将]?(?:(?:[下后][面])?(?:这[个]?|[下后][面]?)(?:词[组]?|句(?:子|话)?|短语))?' - '翻译[成为到]\s*(?P\w+?[文语])[\s::,,](?P.*)'), - re.compile('.*[用]?(?P\w+?[文语])\w*?(?:说|讲|表达|表示)' - '(?P.*)(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))'), - re.compile('.*[用]?(?P\w+?[文语])\w*?(?:说|讲|表达|表示)[\s::,,]?(?P.*)'), -] - - -@as_processor(keywords=('翻译(为|成|到)?', '.+(文|语)')) -def _processor(sentence, segmentation): - lang = None - query = None - for matcher in _query_lang_matcher: - m = matcher.match(sentence) - if m: - lang, query = m.group('lang'), m.group('query') - break - if lang and query: - return 90, 'translate.translate_to', ' '.join((lang.strip(), query.strip(' ,,'))), None - return None diff --git a/nl_processors/weather.py b/nl_processors/weather.py deleted file mode 100644 index d77e2ca0..00000000 --- a/nl_processors/weather.py +++ /dev/null @@ -1,48 +0,0 @@ -import re - -from nl_processor import as_processor - -_keywords = ('天气', '气温', '空气(质量)?', '温度', '多少度', '风|雨|雪|冰雹|霜|雾|霾') - - -def _match_keywords(word): - for regex in _keywords: - if re.search(regex, word): - return True - return False - - -@as_processor(keywords=_keywords) -def _processor(sentence, segmentation): - possibility = 100 - location_segs = list(filter(lambda x: x.flag == 'ns', segmentation)) - if not location_segs: - return None - - if len(location_segs) == 1: - # Just city name - city = location_segs[0].word.rstrip('市县区') - elif len(location_segs) == 2: - # Maybe has both province and city name - city = location_segs[0].word.rstrip('省') + location_segs[1].word.rstrip('市县区') - else: - # More than 3 location name, use the last one - city = location_segs[-1].word.rstrip('市县区') - - for seg in location_segs: - segmentation.remove(seg) - - for seg in segmentation: - # Scan over all segments and decrease possibility - if _match_keywords(seg.word): - continue - - flag = seg.flag - score_dict = {'v': -10, 'l': -8, 'n': -5, 'p': -3, 'y': 0, 't': +3, 'other': -1} - for k, v in score_dict.items(): - if flag.startswith(k): - possibility += v - continue - possibility += score_dict['other'] - - return possibility, 'weather.weather', city, None diff --git a/none/__init__.py b/none/__init__.py new file mode 100644 index 00000000..e18d9432 --- /dev/null +++ b/none/__init__.py @@ -0,0 +1,76 @@ +import os +import importlib +import logging +import re +import asyncio +from typing import Any + +from aiocqhttp import CQHttp +from aiocqhttp.message import Message + +logger = logging.getLogger('none') + +from .plugin import handle_message, handle_notice, handle_request +from .command import on_command + + +def create_bot(config_object: Any = None): + if config_object is None: + from . import default_config as config_object + + kwargs = {k.lower(): v for k, v in config_object.__dict__.items() + if k.isupper() and not k.startswith('_')} + + bot = CQHttp(message_class=Message, **kwargs) + bot.config = config_object + if bot.config.DEBUG: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + bot.asgi.debug = bot.config.DEBUG + + @bot.on_message + async def _(ctx): + asyncio.ensure_future(handle_message(bot, ctx)) + + @bot.on_notice + async def _(ctx): + asyncio.ensure_future(handle_notice(bot, ctx)) + + @bot.on_request + async def _(ctx): + asyncio.ensure_future(handle_request(bot, ctx)) + + return bot + + +_plugins = set() + + +def load_plugins(): + _plugins.clear() + root_dir = os.path.dirname(__path__[0]) + plugins_dir = os.path.join(root_dir, 'plugins') + saved_cwd = os.getcwd() + os.chdir(root_dir) + for item in os.listdir(plugins_dir): + path = os.path.join(plugins_dir, item) + if os.path.isfile(path) and \ + (path.startswith('_') or not path.endswith('.py')): + continue + if os.path.isdir(path) and \ + (path.startswith('_') or not os.path.exists( + os.path.join(path, '__init__.py'))): + continue + + m = re.match(r'([_A-Z0-9a-z]+)(.py)?', item) + if not m: + continue + + mod_name = 'plugins.' + m.group(1) + try: + _plugins.add(importlib.import_module(mod_name)) + logger.info('Succeeded to import "{}"'.format(mod_name)) + except ImportError: + logger.warning('Failed to import "{}"'.format(mod_name)) + os.chdir(saved_cwd) diff --git a/none/command.py b/none/command.py new file mode 100644 index 00000000..d963a8af --- /dev/null +++ b/none/command.py @@ -0,0 +1,108 @@ +import re +from typing import Tuple, Union, Callable, Iterable, Dict, Any + +from aiocqhttp import CQHttp + +from . import permissions as perm, logger + +_command_tree = {} + +# Key: str +# Value: tuple that identifies a command +_command_aliases = {} + +# Key: context source +# Value: Command object +_command_sessions = {} + + +# TODO: Command 类只用来表示注册的命令,Session 类用来在运行时表示命令的参数等 + +class Command: + __slots__ = ('name', 'arg', 'images', 'data', 'last_interaction') + + def __init__(self, name: Tuple[str]): + self.name = name + + async def __call__(self, bot: CQHttp, ctx: Dict[str, Any], + *args, **kwargs) -> bool: + logger.info(repr(self.images)) + cmd_tree = _command_tree + for part in self.name: + if part not in cmd_tree: + return False + cmd_tree = cmd_tree[part] + cmd = cmd_tree + if 'func' not in cmd or not isinstance(cmd['func'], Callable): + return False + # TODO: check permission + await cmd['func'](bot, ctx, self) + return True + + +async def handle_command(bot: CQHttp, ctx: Dict[str, Any]) -> bool: + # TODO: check if there is a session + msg_text = ctx['message'].extract_plain_text().lstrip() + + for start in bot.config.COMMAND_START: + if isinstance(start, type(re.compile(''))): + m = start.search(msg_text) + if m: + full_command = msg_text[len(m.group(0)):].lstrip() + break + elif isinstance(start, str): + if msg_text.startswith(start): + full_command = msg_text[len(start):].lstrip() + break + else: + # it's not a command + return False + + if not full_command: + # command is empty + return False + + cmd_name_text, *cmd_remained = full_command.split(maxsplit=1) + cmd_name = _command_aliases.get(cmd_name_text) + + if not cmd_name: + for sep in bot.config.COMMAND_SEP: + if isinstance(sep, type(re.compile(''))): + cmd_name = tuple(sep.split(cmd_name_text)) + break + elif isinstance(sep, str): + cmd_name = tuple(cmd_name_text.split(sep)) + break + else: + cmd_name = (cmd_name_text,) + + cmd = Command(cmd_name) + cmd.arg = ''.join(cmd_remained) + cmd.images = [s.data['url'] for s in ctx['message'] + if s.type == 'image' and 'url' in s.data] + return await cmd(bot, ctx) + + +def on_command(name: Union[str, Tuple[str]], aliases: Iterable = (), + permission: int = perm.EVERYONE) -> Callable: + def deco(func: Callable) -> Callable: + if not isinstance(name, (str, tuple)): + raise TypeError('the name of a command must be a str or tuple') + if not name: + raise ValueError('the name of a command must not be empty') + + cmd_name = name if isinstance(name, tuple) else (name,) + current_parent = _command_tree + for parent_key in cmd_name[:-1]: + current_parent[parent_key] = {} + current_parent = current_parent[parent_key] + current_parent[cmd_name[-1]] = { + 'name': cmd_name, + 'func': func, + 'permission': permission + } + for alias in aliases: + _command_aliases[alias] = cmd_name + return func + + return deco diff --git a/none/default_config.py b/none/default_config.py new file mode 100644 index 00000000..d21cebe2 --- /dev/null +++ b/none/default_config.py @@ -0,0 +1,9 @@ +API_ROOT = '' +SECRET = '' +HOST = '127.0.0.1' +PORT = 8080 +DEBUG = True + +SUPERUSERS = set() +COMMAND_START = {'/', '!', '/', '!'} +COMMAND_SEP = {'/', '.'} diff --git a/none/helpers.py b/none/helpers.py new file mode 100644 index 00000000..3320fe76 --- /dev/null +++ b/none/helpers.py @@ -0,0 +1,12 @@ +from typing import Dict, Any + + +def context_source(ctx: Dict[str, Any]) -> str: + src = '' + if ctx.get('group_id'): + src += 'g%s' % ctx['group_id'] + elif ctx.get('discuss_id'): + src += 'd%s' % ctx['discuss_id'] + if ctx.get('user_id'): + src += 'p%s' % ctx['user_id'] + return src diff --git a/none/permissions.py b/none/permissions.py new file mode 100644 index 00000000..4a843697 --- /dev/null +++ b/none/permissions.py @@ -0,0 +1,12 @@ +PRIVATE_FRIEND = 0x0001 +PRIVATE_GROUP = 0x0002 +PRIVATE_DISCUSS = 0x0004 +PRIVATE_OTHER = 0x0008 +PRIVATE = 0x000F +DISCUSS = 0x00F0 +GROUP_MEMBER = 0x0100 +GROUP_ADMIN = 0x0200 +GROUP_OWNER = 0x0400 +GROUP = 0x0F00 +SUPERUSER = 0xF000 +EVERYONE = 0xFFFF diff --git a/none/plugin.py b/none/plugin.py new file mode 100644 index 00000000..8eecbc9e --- /dev/null +++ b/none/plugin.py @@ -0,0 +1,31 @@ +from typing import Dict, Any + +from aiocqhttp import CQHttp +from aiocqhttp.message import MessageSegment + +from . import command, logger + + +async def handle_message(bot: CQHttp, ctx: Dict[str, Any]): + if ctx['message_type'] != 'private': + # group or discuss + first_message_seg = ctx['message'][0] + if first_message_seg != MessageSegment.at(ctx['self_id']): + return + del ctx['message'][0] + if not ctx['message']: + ctx['message'].append(MessageSegment.text('')) + + handled = await command.handle_command(bot, ctx) + if handled: + logger.debug('Message is handled as a command') + else: + await bot.send(ctx, '你在说什么我看不懂诶') + + +async def handle_notice(bot: CQHttp, ctx: Dict[str, Any]): + pass + + +async def handle_request(bot: CQHttp, ctx: Dict[str, Any]): + pass diff --git a/plugins/base.py b/plugins/base.py new file mode 100644 index 00000000..9d054842 --- /dev/null +++ b/plugins/base.py @@ -0,0 +1,6 @@ +import none + + +@none.on_command('echo', aliases=('say',)) +async def _(bot, ctx, cmd): + await bot.send(ctx, cmd.arg) diff --git a/requirements.txt b/requirements.txt index 4e2ce970..8358b1da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1 @@ -lxml -apscheduler -requests -cachetools -pytz -flask -sqlalchemy -pydub -SpeechRecognition -jieba +aiocqhttp diff --git a/run.py b/run.py new file mode 100644 index 00000000..ff4eb441 --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +import config +import none + +bot = none.create_bot(config) +none.load_plugins() + +app = bot.asgi + +if __name__ == '__main__': + bot.run(host=config.HOST, port=config.PORT)