123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. ## 插件化初衷
  2. 之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。在实现多个功能后,不但无法调整功能的优先级顺序,功能的配置项也会变得非常混乱。
  3. 此时插件化应声而出。
  4. **插件化**: 在保证主体功能是ChatGPT的前提下,我们推荐将主体功能外的功能利用插件的方式实现。
  5. - [x] 可根据功能需要,下载不同插件。
  6. - [x] 插件开发成本低,仅需了解插件触发事件,并按照插件定义接口编写插件。
  7. - [x] 插件化能够自由开关和调整优先级。
  8. - [x] 每个插件可在插件文件夹内维护独立的配置文件,方便代码的测试和调试,可以在独立的仓库开发插件。
  9. PS: 插件目前仅支持`itchat`
  10. ## 插件化实现
  11. 插件化实现是在收到消息到发送回复的各个步骤之间插入触发事件实现的。
  12. ### 消息处理过程
  13. 在了解插件触发事件前,首先需要了解程序收到消息到发送回复的整个过程。
  14. 插件化版本中,消息处理过程可以分为4个步骤:
  15. ```
  16. 1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
  17. ```
  18. 以下是它们的默认处理逻辑(太长不看,可跳过):
  19. #### 1. 收到消息
  20. 负责接收用户消息,根据用户的配置,判断本条消息是否触发机器人。如果触发,则会判断该消息的类型(声音、文本、画图命令等),将消息包装成如下的`Context`交付给下一个步骤。
  21. ```python
  22. class ContextType (Enum):
  23. TEXT = 1 # 文本消息
  24. VOICE = 2 # 音频消息
  25. IMAGE_CREATE = 3 # 创建图片命令
  26. class Context:
  27. def __init__(self, type : ContextType = None , content = None, kwargs = dict()):
  28. self.type = type
  29. self.content = content
  30. self.kwargs = kwargs
  31. def __getitem__(self, key):
  32. return self.kwargs[key]
  33. ```
  34. `Context`中除了存放消息类型和内容外,还存放了一些与会话相关的参数。
  35. 例如,当收到用户私聊消息时,会存放以下的会话参数。
  36. ```python
  37. context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
  38. ```
  39. - `isgroup`: `Context`是否是群聊消息。
  40. - `msg`: `itchat`中原始的消息对象。
  41. - `receiver`: 需要回复消息的对象ID。
  42. - `session_id`: 会话ID(一般是发送触发bot消息的用户ID,如果在群聊中并且`conf`里设置了`group_chat_in_one_session`,那么此处便是群聊ID)
  43. #### 2. 产生回复
  44. 处理消息并产生回复。目前默认处理逻辑是根据`Context`的类型交付给对应的bot,并产生回复`Reply`。 如果本步骤没有产生任何回复,那么会跳过之后的所有步骤。
  45. ```python
  46. if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
  47. reply = super().build_reply_content(context.content, context) #文字跟画图交付给chatgpt
  48. elif context.type == ContextType.VOICE: # 声音先进行语音转文字后,修改Context类型为文字后,再交付给chatgpt
  49. msg = context['msg']
  50. file_name = TmpDir().path() + context.content
  51. msg.download(file_name)
  52. reply = super().build_voice_to_text(file_name)
  53. if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
  54. context.content = reply.content # 语音转文字后,将文字内容作为新的context
  55. context.type = ContextType.TEXT
  56. reply = super().build_reply_content(context.content, context)
  57. if reply.type == ReplyType.TEXT:
  58. if conf().get('voice_reply_voice'):
  59. reply = super().build_text_to_voice(reply.content)
  60. ```
  61. 回复`Reply`的定义如下所示,它允许Bot可以回复多类不同的消息。同时也加入了`INFO`和`ERROR`消息类型区分系统提示和系统错误。
  62. ```python
  63. class ReplyType(Enum):
  64. TEXT = 1 # 文本
  65. VOICE = 2 # 音频文件
  66. IMAGE = 3 # 图片文件
  67. IMAGE_URL = 4 # 图片URL
  68. INFO = 9
  69. ERROR = 10
  70. class Reply:
  71. def __init__(self, type : ReplyType = None , content = None):
  72. self.type = type
  73. self.content = content
  74. ```
  75. #### 3. 装饰回复
  76. 根据`Context`和回复`Reply`的类型,对回复的内容进行装饰。目前的装饰有以下两种:
  77. - `TEXT`文本回复,根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。
  78. - `INFO`或`ERROR`类型,会在消息前添加对应的系统提示字样。
  79. 如下是默认逻辑的代码:
  80. ```python
  81. if reply.type == ReplyType.TEXT:
  82. reply_text = reply.content
  83. if context['isgroup']:
  84. reply_text = '@' + context['msg']['ActualNickName'] + ' ' + reply_text.strip()
  85. reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
  86. else:
  87. reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
  88. reply.content = reply_text
  89. elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
  90. reply.content = str(reply.type)+":\n" + reply.content
  91. ```
  92. #### 4. 发送回复
  93. 根据`Reply`的类型,默认逻辑调用不同的发送函数发送回复给接收方`context["receiver"]`。
  94. ### 插件触发事件
  95. 主程序目前会在各个消息步骤间触发事件,监听相应事件的插件会按照优先级,顺序调用事件处理函数。
  96. 目前支持三类触发事件:
  97. ```
  98. 1.收到消息
  99. ---> `ON_HANDLE_CONTEXT`
  100. 2.产生回复
  101. ---> `ON_DECORATE_REPLY`
  102. 3.装饰回复
  103. ---> `ON_SEND_REPLY`
  104. 4.发送回复
  105. ```
  106. 触发事件会产生事件的上下文`EventContext`,它包含了以下信息:
  107. `EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
  108. 插件处理函数可通过修改`EventContext`中的`context`和`reply`来实现功能。
  109. ## 插件编写示例
  110. 以`plugins/hello`为例,其中编写了一个简单的`Hello`插件。
  111. ### 1. 创建插件
  112. 在`plugins`目录下创建一个插件文件夹`hello`。然后,在该文件夹中创建一个与文件夹同名的`.py`文件`hello.py`。
  113. ```
  114. plugins/
  115. └── hello
  116. ├── __init__.py
  117. └── hello.py
  118. ```
  119. ### 2. 编写插件类
  120. 在`hello.py`文件中,创建插件类,它继承自`Plugin`。
  121. 在类定义之前需要使用`@plugins.register`装饰器注册插件,并填写插件的相关信息,其中`desire_priority`表示插件默认的优先级,越大优先级越高。初次加载插件后可在`plugins/plugins.json`中修改插件优先级。
  122. 并在`__init__`中绑定你编写的事件处理函数。
  123. `Hello`插件为事件`ON_HANDLE_CONTEXT`绑定了一个处理函数`on_handle_context`,它表示之后每次生成回复前,都会由`on_handle_context`先处理。
  124. PS: `ON_HANDLE_CONTEXT`是最常用的事件,如果要根据不同的消息来生成回复,就用它。
  125. ```python
  126. @plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
  127. class Hello(Plugin):
  128. def __init__(self):
  129. super().__init__()
  130. self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
  131. logger.info("[Hello] inited")
  132. ```
  133. ### 3. 编写事件处理函数
  134. #### 修改事件上下文
  135. 事件处理函数接收一个`EventContext`对象`e_context`作为参数。`e_context`包含了事件相关信息,利用`e_context['key']`来访问这些信息。
  136. `EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
  137. 处理函数中通过修改`e_context`对象中的事件相关信息来实现所需功能,比如更改`e_context['reply']`中的内容可以修改回复。
  138. #### 决定是否交付给下个插件或默认逻辑
  139. 在处理函数结束时,还需要设置`e_context`对象的`action`属性,它决定如何继续处理事件。目前有以下三种处理方式:
  140. - `EventAction.CONTINUE`: 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑。
  141. - `EventAction.BREAK`: 事件结束,不再给下个插件处理,交付给默认的处理逻辑。
  142. - `EventAction.BREAK_PASS`: 事件结束,不再给下个插件处理,跳过默认的处理逻辑。
  143. #### 示例处理函数
  144. `Hello`插件处理`Context`类型为`TEXT`的消息:
  145. - 如果内容是`Hello`,就将回复设置为`Hello+用户昵称`,并跳过之后的插件和默认逻辑。
  146. - 如果内容是`End`,就将`Context`的类型更改为`IMAGE_CREATE`,并让事件继续,如果最终交付到默认逻辑,会调用默认的画图Bot来画画。
  147. ```python
  148. def on_handle_context(self, e_context: EventContext):
  149. if e_context['context'].type != ContextType.TEXT:
  150. return
  151. content = e_context['context'].content
  152. if content == "Hello":
  153. reply = Reply()
  154. reply.type = ReplyType.TEXT
  155. msg = e_context['context']['msg']
  156. if e_context['context']['isgroup']:
  157. reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
  158. else:
  159. reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
  160. e_context['reply'] = reply
  161. e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
  162. if content == "End":
  163. # 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
  164. e_context['context'].type = ContextType.IMAGE_CREATE
  165. content = "The World"
  166. e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
  167. ```
  168. ## 插件设计建议
  169. - 尽情将你想要的个性化功能设计为插件。
  170. - 一个插件目录建议只注册一个插件类。建议使用单独的仓库维护插件,便于更新。
  171. - 插件的config文件、使用说明`README.md`、`requirement.txt`等放置在插件目录中。
  172. - 默认优先级不要超过管理员插件`Godcmd`的优先级(999),`Godcmd`插件提供了配置管理、插件管理等功能。