Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin: 增加插件编写文档,添加Dungeon插件 #518

Merged
merged 2 commits into from
Mar 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions plugins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# 插件说明
本项目主体是调用ChatGPT接口的Wechat自动回复机器人。之前未插件化的代码耦合程度高,很难定制一些个性化功能(如流量控制、接入本地的NovelAI画图平台等),多个功能的优先级顺序也难以调度。
**插件化**: 在保证主体功能是ChatGPT的前提下,推荐将主体功能外的功能分离成不同的插件。有个性化需求的用户仅需按照插件提供的接口编写插件,无需了解程序主体的代码结构,同时也方便代码的测试和调试。(插件调用目前仅支持 itchat)

## 插件触发时机

### 消息处理过程
了解插件触发时机前,首先需要了解程序收到消息后的执行过程。插件化版本的消息处理过程如下:
```
1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
```
以下是它们的默认处理逻辑(太长不看,可跳过):

- 1. 收到消息
本过程接收到用户消息,根据用户设置,判断本条消息是否触发。若触发,则判断该消息的命令类型,如声音、聊天、画图等。之后,将消息包装成如下的 Context 交付给下一个步骤。
```python
class ContextType (Enum):
TEXT = 1 # 文本消息
VOICE = 2 # 音频消息
IMAGE_CREATE = 3 # 创建图片命令
class Context:
def __init__(self, type : ContextType = None , content = None, kwargs = dict()):
self.type = type
self.content = content
self.kwargs = kwargs
def __getitem__(self, key):
return self.kwargs[key]
```
`Context`中除了存放消息类型和内容外,还存放了与会话相关的参数。一个例子是,当收到用户私聊消息时,还会存放以下的会话参数,`isgroup`标识`Context`是否时群聊消息,`msg`是`itchat`中原始的消息对象,`receiver`是应回复消息的对象ID,`session_id`是会话ID(一般是触发bot的消息发送方,群聊中如果`conf`里设置了`group_chat_in_one_session`,那么此处便是群聊的ID)
```
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
```
2. 产生回复
本过程用于处理消息。目前默认处理逻辑是根据`Context`的类型交付给对应的bot:
```python
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
reply = super().build_reply_content(context.content, context) #文字跟画图交付给chatgpt
elif context.type == ContextType.VOICE: # 声音先进行语音转文字后,修改Context类型为文字后,再交付给chatgpt
msg = context['msg']
file_name = TmpDir().path() + context.content
msg.download(file_name)
reply = super().build_voice_to_text(file_name)
if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
context.content = reply.content # 语音转文字后,将文字内容作为新的context
context.type = ContextType.TEXT
reply = super().build_reply_content(context.content, context)
if reply.type == ReplyType.TEXT:
if conf().get('voice_reply_voice'):
reply = super().build_text_to_voice(reply.content)
```
Bot可产生的回复如下所示,它允许Bot可以回复多类不同的消息,未来可能不止能返回文字,而是能根据文字回复音频/图片,这时候便能派上用场。同时也加入了`INFO`和`ERROR`消息类型区分系统提示和系统错误。
```python
class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL

INFO = 9
ERROR = 10
class Reply:
def __init__(self, type : ReplyType = None , content = None):
self.type = type
self.content = content
```
3. 装饰回复
本过程根据`Context`和回复的类型,对回复的内容进行装饰。目前的装饰有以下两种,如果是文本回复,会根据是否在群聊中来决定是否艾特收方或添加回复前缀。
如果是`INFO`或`ERROR`类型,会在消息前添加对应字样。
```python
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if context['isgroup']:
reply_text = '@' + context['msg']['ActualNickName'] + ' ' + reply_text.strip()
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
else:
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
reply.content = reply_text
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = str(reply.type)+":\n" + reply.content
```
4. 发送回复
本过程根据回复的类型来发送回复给接收方`context["receiver"]`。

### 插件触发事件

主程序会在各消息处理过程之间触发插件事件,插件可以监听相应事件编写相应的处理逻辑。
```
1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
```
目前加入了三类事件的触发:
```
1.收到消息
---> `ON_HANDLE_CONTEXT`
2.产生回复
---> `ON_DECORATE_REPLY`
3.包装回复
---> `ON_SEND_REPLY`
4.发送回复
```
触发事件会产生事件上下文`EventContext`,它包含了以下信息:
`EventContext(Event事件类型, {'channel' : 消息channel, 'context': context, 'reply': reply})`

插件的处理函数可以修改`Context`和`Reply`的内容来定制化处理逻辑。

## 插件编写
以`plugins/hello`为例,它编写了一个简单`Hello`插件。

1. 创建插件
在`plugins`目录下创建一个插件文件夹,例如`hello`。然后,在该文件夹中创建一个与文件夹同名的`.py`文件,例如`hello.py`。
```
plugins/
└── hello
├── __init__.py
└── hello.py
```

2. 编写插件类
在`hello.py`文件中,创建插件类,它继承自Plugin类。在类定义之前使用`@plugins.register`装饰器注册插件,并填写插件的相关信息,其中`desire_priority`表示插件默认的优先级,越大优先级越高,扫描到插件后可在`plugins/plugins.json`中修改插件优先级。并在`__init__`中绑定你编写的事件处理函数:
```python
@plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
class Hello(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Hello] inited")
```

3. 编写事件处理函数
事件处理函数接收一个`EventContext`对象作为参数。`EventContext`对象包含了事件相关的信息,如消息内容和当前回复等。可以通过`e_context['key']`访问这些信息。

处理函数中,你可以修改`EventContext`对象的信息,比如更改回复内容。在处理函数结束时,需要设置`EventContext`对象的`action`属性,以决定如何继续处理事件。有以下三种处理方式:
- `EventAction.CONTINUE`: 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑。
- `EventAction.BREAK`: 事件结束,不再给下个插件处理,交付给默认的处理逻辑。
- `EventAction.BREAK_PASS`: 事件结束,不再给下个插件处理,跳过默认的处理逻辑。

以`Hello`插件为例,它处理`Context`类型为`TEXT`的消息。
- 如果内容是`Hello`,直接将回复设置为`Hello+用户昵称`,并跳过之后的插件和默认逻辑。
- 如果内容是`End`,它会将`Context`的类型更改为`IMAGE_CREATE`,并让事件继续,如果最终交付到默认逻辑,会调用默认的画图Bot。
```python
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
content = e_context['context'].content
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
else:
reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
e_context['context'].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
```

## 插件设计规范
- 个性化功能推荐设计为插件。
- 一个插件目录建议只注册一个插件类。建议使用单独的仓库维护插件,便于更新。
- 插件的config文件、使用说明`README.md`、`requirement.txt`放置在插件目录中。
- 默认优先级不要超过管理员插件`Godcmd`的优先级(999),`Godcmd`插件提供了配置管理、插件管理等功能。
3 changes: 3 additions & 0 deletions plugins/dungeon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
玩地牢游戏的聊天插件,触发方法如下:
- `$开始冒险 <背景故事>` - 以<背景故事>开始一个地牢游戏,不填写会使用默认背景故事。之后聊天中你的所有消息会帮助ai完善这个故事。
- `$停止冒险` - 停止一个地牢游戏,回归正常的ai。
Empty file added plugins/dungeon/__init__.py
Empty file.
76 changes: 76 additions & 0 deletions plugins/dungeon/dungeon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# encoding:utf-8

from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
import plugins
from plugins import *
from common.log import logger

# https://github.com/bupticybee/ChineseAiDungeonChatGPT
class StoryTeller():
def __init__(self, bot, sessionid, story):
self.bot = bot
self.sessionid = sessionid
bot.sessions.clear_session(sessionid)
self.first_interact = True
self.story = story

def reset(self):
self.bot.sessions.clear_session(self.sessionid)
self.first_interact = True

def action(self, user_action):
if user_action[-1] != "。":
user_action = user_action + "。"
if self.first_interact:
prompt = """现在来充当一个冒险文字游戏,描述时候注意节奏,不要太快,仔细描述各个人物的心情和周边环境。一次只需写四到六句话。
开头是,""" + self.story + " " + user_action
self.first_interact = False
else:
prompt = """继续,一次只需要续写四到六句话,总共就只讲5分钟内发生的事情。""" + user_action
return prompt


@plugins.register(name="Dungeon", desc="A plugin to play dungeon game", version="1.0", author="lanvent", desire_priority= 0)
class Dungeon(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Dungeon] inited")
self.games = {}

def on_handle_context(self, e_context: EventContext):

if e_context['context'].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype != "chatGPT":
return
bot = Bridge().get_bot("chat")
content = e_context['context'].content[:]
clist = e_context['context'].content.split(maxsplit=1)
sessionid = e_context['context']['session_id']
logger.debug("[Dungeon] on_handle_context. content: %s" % clist)
if clist[0] == "$停止冒险":
if sessionid in self.games:
self.games[sessionid].reset()
del self.games[sessionid]
reply = Reply(ReplyType.INFO, "冒险结束!")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
elif clist[0] == "$开始冒险" or sessionid in self.games:
if sessionid not in self.games or clist[0] == "$开始冒险":
if len(clist)>1 :
story = clist[1]
else:
story = "你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。"
self.games[sessionid] = StoryTeller(bot, sessionid, story)
reply = Reply(ReplyType.INFO, "冒险开始,你可以输入任意内容,让故事继续下去。故事背景是:" + story)
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束,并跳过处理context的默认逻辑
else:
prompt = self.games[sessionid].action(content)
e_context['context'].type = ContextType.TEXT
e_context['context'].content = prompt
e_context.action = EventAction.CONTINUE
2 changes: 2 additions & 0 deletions plugins/godcmd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
管理员插件
`#help` - 输出帮助文档。
2 changes: 1 addition & 1 deletion plugins/hello/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ def on_handle_context(self, e_context: EventContext):

if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE",并将content设置为"The World"
e_context['context'].type = "IMAGE_CREATE"
e_context['context'].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑