9.2 KiB
编写命令
当你需要自己编写命令时,可能需要了解或参考以下内容。
命令仓库 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
参数的建议使用场景有所不同,比如,当需要添加一个可以确定不希望普通用户意外调用到的命令时,可使用如下方式注册:
@__registry__.register('pro_command')
@__registry__.restrict(full_command_only=True, allow_group=False)
def pro_command(args_text, ctx_msg):
pass
而如果需要添加一个希望普通用户使用、同时也让高级用户使用起来更舒适的命令,可能用如下方式注册:
@__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 注:由于微信限制,无法获取到群组中成员的身份(普通成员还是管理员或群主),因此这里的对群组的限制在微信上不起效果,超级用户限制在能够获取到发送者微信 ID 的情况下有效。
命令中心 Command Hub
程序启动时加载的命令仓库全部被集中在了命令中心,以 .py
文件名(除去后缀)(也即模块名)为仓库名。命令中心实际上应作为单例使用(插件编写者不应当自己创建实例),即 command.py
中的 hub
对象,类型是 CommandHub,调用它的 call
方法将会执行相应的命令,如果调用失败(如命令不存在、没有权限等)会抛出相应的异常。
命令之间内部调用
调用命令中心的 call
方法是一种可行的命令内部调用方法,不过由于 call
方法内会进行很多额外操作(例如命令名匹配、权限检查等),所以并不建议使用这个方法来进行内部调用。
一般而言,当编写命令时发现需要调用另一个已有的命令,可以直接导入相应的模块,然后调用那个命令函数,这样避免了冗杂的命令名匹配过程,例如:
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 注:在支持了微信之后,此处有所变化,由于微信消息的限制,有时候无法获得发送者的微信 ID,而群组甚至没有一个固定 ID,因此,这里对 Source 和 Target 做一个精确定义:Source 是一个用来表示当前消息的发送者的唯一值,但重新登录后可能变化,并且每次获取,一定可以获取到;Target 是一个用来表示当前消息所产生的效果需要作用的对象,这个值是永久(或至少长期)不变的,如果当前的消息语境下不存在这样的值,则为 None。
交互式命令 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
的代码。