New generation init

This commit is contained in:
Richard Chien 2018-06-15 06:58:24 +08:00
parent 6ce021926e
commit a3844eda69
61 changed files with 266 additions and 4063 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
.idea
*.iml
data
config.py
__pycache__

View File

@ -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

View File

@ -1,18 +0,0 @@
FROM python:3.5.1
MAINTAINER Richard Chien <richardchienthebest@gmail.com>
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

View File

@ -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/<string:via>/<string:login_id>`,这里可以见到 `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

View File

@ -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()

38
app.py
View File

@ -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('/<string:via>/<string:login_id>', 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'))

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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: 'IP123.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)

View File

@ -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)

View File

@ -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

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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 <account-to-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 <account-to-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)

View File

@ -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)

View File

@ -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%sPM2.5%sPM10%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

View File

@ -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)

View File

@ -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'
}
]
}

View File

@ -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

View File

@ -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 相同。

View File

@ -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设置了的情况下必填 |

View File

@ -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` 三者之一。

View File

@ -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])
```

View File

@ -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
```
一般建议优先级设置为 0100 之间。
过滤器函数返回 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 | 识别消息中的命令,并进行相应的调用 | |

View File

@ -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-汉语词性标注集))和词语的字符串。

View File

@ -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: []
});

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="cleartype" content="on">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<title>XiaoKai Bot 文档</title>
<link rel="stylesheet" href="https://unpkg.com/docute@latest/dist/docute.css">
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/docute@latest/dist/docute.js"></script>
<script src="./config.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
</body>
</html>

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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]

View File

@ -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', [])

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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<year>\d{2})\s*年\s*)?(?P<month>\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<day_str>(?:前|昨|今|明|大?后)天)?(?P<name>.+?)'
'(?P<day_str2>(?:前|昨|今|明|大?后)天)?(?:会|有)?更(?:不更)?新',
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<name>.+?)(?:(?:什么|啥)时候)?(?:会|有)?更新', 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

View File

@ -1,28 +0,0 @@
import re
from nl_processor import as_processor
_query_lang_matcher = [
re.compile('(?:(?:要|[应]?该)?怎么|怎样|如何)?[把将]?[\s,.,。]?(?P<query>.*?)[\s,.,。]?'
'(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))?翻译[成为到](?P<lang>\w+?[文语])(?![\s:,.。])'),
re.compile('(?P<query>.+?)[\s,.,。]?(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))?[的用](?P<lang>\w+?[文语])'),
re.compile('.*?[把将]?(?:(?:[下后][面])?(?:这[个]?|[下后][面]?)(?:词[组]?|句(?:子|话)?|短语))?'
'翻译[成为到]\s*(?P<lang>\w+?[文语])[\s:,](?P<query>.*)'),
re.compile('.*[用]?(?P<lang>\w+?[文语])\w*?(?:说|讲|表达|表示)'
'(?P<query>.*)(?:这[个]?(?:词[组]?|句(?:子|话)?|短语))'),
re.compile('.*[用]?(?P<lang>\w+?[文语])\w*?(?:说|讲|表达|表示)[\s:,]?(?P<query>.*)'),
]
@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

View File

@ -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

76
none/__init__.py Normal file
View File

@ -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)

108
none/command.py Normal file
View File

@ -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

9
none/default_config.py Normal file
View File

@ -0,0 +1,9 @@
API_ROOT = ''
SECRET = ''
HOST = '127.0.0.1'
PORT = 8080
DEBUG = True
SUPERUSERS = set()
COMMAND_START = {'/', '!', '', ''}
COMMAND_SEP = {'/', '.'}

12
none/helpers.py Normal file
View File

@ -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

12
none/permissions.py Normal file
View File

@ -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

31
none/plugin.py Normal file
View File

@ -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

6
plugins/base.py Normal file
View File

@ -0,0 +1,6 @@
import none
@none.on_command('echo', aliases=('say',))
async def _(bot, ctx, cmd):
await bot.send(ctx, cmd.arg)

View File

@ -1,10 +1 @@
lxml
apscheduler
requests
cachetools
pytz
flask
sqlalchemy
pydub
SpeechRecognition
jieba
aiocqhttp

10
run.py Normal file
View File

@ -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)