乍一看到某个问题,你会觉得很简单,其实你并没有理解其复杂性。当你把问题搞清楚之后,又会发现真的很复杂,于是你就拿出一套复杂的方案来。实际上,你的工作只做了一半,大多数人也都会到此为止……。但是,真正伟大的人还会继续向前,直至找到问题的关键和深层次原因,然后再拿出一个优雅的、堪称完美的有效方案。—— 乔布斯
项目信息
项目名称:AliceBot 插件商店实现
项目产出要求:编写一个插件商店用于用户分享自己编写插件和适配器使用 GitHub Workflow 和 GitHub App 自动获取用户提交的信息并更新商店页面界面美观,易于使用。
时间规划:2023.7-2023.9
项目进度
根据项目计划书内容和项目导师要求,本项目已全部完成,功能内容测试完成,已做相关优化。
插件商店页面部分
要求描述
AliceBot使用了非常灵活且易于使用的插件编写方式,用户只需要编写两个方法即可实现一个功能强大的插件。随着AliceBot用户量的提高,许多用户都编写了自己的插件和适配器,为了方便用户交流,避免“重复造轮子”,急需开发一个商店页面用于用户分享自己编写插件和适配器。
项目思路
插件商店的需求是聚合相关插件、适配器、样例的资源站,提供插件等内容的浏览与链接跳转。
资源信息主要包含名称、类型(插件/适配器/样例)、模组名、描述、作者、仓库链接、标签、是否为官方出品等元信息。这些内容可通过pr合并至文档仓库的plugin.json、bot.json等文件内,用于存储相关信息。
界面可以采用vue3.3+vitepress开发,无需引入额外的库。
由于vitepress并无直接的内容聚合型展示功能,需要进行额外开发。主要思路是在vitepress上开发一个插件商店页面,包含多个相关组件。通过vitepress的markdown内置vue功能开发相关组件及页面功能和样式。通过vitepress提供的数据加载器功能实现插件商店数据的实时获取。
完成情况
商店主要用于展示官方/第三方经审核的插件、适配器、机器人
商店具有以下功能:
- 展示插件、适配器、机器人信息
- 寻找相应插件、适配器、机器人信息
- 复制模块安装命令 ( 默认pip
自适应
完美适配移动端、 PC端等各种尺寸设备
统一风格 & 暗色模式
由于AliceBot文档采用Vitepress,因此商店页面整体 UI风格色调与Vitepress风格色调一致,包括暗色模式、动画样式、交互体验等
精确实时搜索
在搜索栏键入关键词即可搜索
分页
顶部、底部均有分页组件,分页组件具有自适应宽度,具体显示和省略效果均会随长度和页面宽度做相应适配调整
信息展示 & 类型选择
可根据插件、适配器、机器人按钮选择对应信息查看。每个模块显示前均经过自动化验证 + 人工验证,均公开分享,代码符合规范。模块除当前页面外,均可在Pypi查询到相应信息
模块具体信息包含以下内容 ( 缺失信息不做显示 ) :
- 名称
- 描述
- 标签
- Github地址(可选
- 是否为官方模块
- 作者
- Pypi名称
- 模块名称
- 实时版本
- 开源协议
信息实时更新
为能够信息实时更新,采用非静态页面设计,通过API获取模块信息
API信息来源于另一个仓库,后续或可用于模块提交:https://github.com/MarleneJiang/issue-ops
PR详细链接
https://github.com/AliceBotProject/alicebot/pull/87
Workflow部分
要求描述
用户想要分享自己的插件或者适配器,可以通过github仓库进行开源分享。为了更方便插件审核以及插件商店的插件提交。急需一个基于GitHub Action开发的workflow,用于插件的提交和审核。
完成情况
第一版参考相关workflow进行修改,实现以下功能:
- 用户提交issue、issue被评论/validate即可触发流程
- 完善的issue标签管理和部分的显式错误提示
- 根据是否勾选验证条款进行流程分支
- 支持获取插件信息,并添加信息到PR
后与导师进行讨论交流,进一步完善项目需求,并重写第二版。
整体框架和第一版类似。在代码方面进行更细一步的简化和错误拦截提示,在插件验证方面进一步优化。支持bot、plugin、adapter多种插件类型提交,验证流程也有所区别。
具体流程如下:
- 用户新建(或者重启、编辑)issue(按照issue template 填写)或者评论/validate触发流程
- 输入信息解析及初级验证
- python脚本进行更进一步验证
- 添加插件信息至pr
- 人工审核pr,合并自动关闭issue
- 出现错误结束流程,并添加issue标签和评论相关错误原因。
具体的一些验证如下:
信息解析:
- 标题的type解析,如果不符合就报错
- 提取name、module_name、pypi_name,如果不符合就报错
- pypi_name在pip网站中检查,不存在则报错
脚本验证:
- 安装python环境,安装所有依赖及模块
- 测试模块是否能被正常import
- 使用subprocess测试模块能否被AliceBot加载,错误则报错
- 获取其他元信息,获取不成功则默认替代
添加信息:
- 获取对应json文件并解析
- 查询是否有同名模块,若存在则覆盖,并更新时间
- 否则添加至最后一行,并附上更新时间
- 保存至文件
正常流程
错误流程
PR链接
https://github.com/AliceBotProject/alicebot-store/pull/1
遇到的问题及解决方案
如何让用户输入规定的信息
为了让issue能够提供必要的信息,并且workflow能提取issue的信息,必须要对issue做一些规范,例如比如提供一些数据,按某种方式提供。
对此,github提供了ISSUE_TEMPLATE功能。例如编写一个yaml即可支持固定的issue输入。
name: Submit to marketplace
description: Submit your plugin to be automatically validated and published on the marketplace.
title: '[plugin/adapter/bot]: xxx'
body:
- type: markdown
attributes:
value: |
请在标题中括号内填入类型,例如:[plugin],冒号后附带插件名称,例如:[plugin]: xxx
- type: input
attributes:
label: module-name
description: 导入时的模块名称
- type: input
attributes:
label: pypi-name
description: 导入时的模块名称
- type: markdown
attributes:
value: |
感谢你的贡献,提交issue后将会进行自动审核。
如何通过issue完成整个流程
首先,需要明确,我们到底需要什么功能:用户通过issue提交信息,然后我们对信息进行初步审核,然后将信息提交pr。
所以,我们的整体思路可以是,用户提交issue,就能触发某个workflow进行执行,然后获取用户输入的信息,对其审查,然后提交pr。
因此,最重要的第一步,如何触发?
workflow提供了很多的触发条件,我们根据项目要求,确定了如下方式:
on:
issues:
types:
- opened
- reopened
- edited
issue_comment:
types:
- created
- edited
如何使整个流程更加交互友好
由于完成整个时间需要花费一些时间,我们需要有明确的消息提示给到开发者用户。例如刚开始处理,可以进行消息提示。
react-to-new-issue:
name: New Issue
if: github.event_name == 'issues' && github.event.action == 'opened'
runs-on: ubuntu-latest
outputs:
comment-id: ${{ steps.comment.outputs.comment-id }}
steps:
- name: Add comment
id: comment
uses: peter-evans/create-or-update-comment@v2
with:
issue-number: ${{ github.event.issue.number }}
body: |
感谢你的提交。
自动验证将在几分钟内开始。
结束时也可进行消息提示,错误也可以显示错误因素。对于消息中大部分内容可以分离到某个文件中,然后通过插槽渲染的方式进行提示。
//validation-failed.md
# 自动检查失败
:x: 验证失败,以下是错误原因
> [!IMPORTANT]
>
> {{ .validation_output }}
请修复问题并在本地确认一切正常后,再次触发验证,方法是在评论中输入 `/validate`。
validation-failed:
name: Validation Failed
runs-on: ubuntu-latest
needs: validate
if: always() && needs.validate.outputs.result == 'error'
steps:
- uses: actions/checkout@v3.3.0
- name: Render template
id: render
uses: chuhlomin/render-template@v1.6
with:
template: .github/workflows/templates/validation-failed.md
vars: |
validation_output: ${{ needs.validate.outputs.output }}
- name: Add comment
uses: peter-evans/create-or-update-comment@v3.0.2
with:
issue-number: ${{ github.event.issue.number }}
body: ${{ steps.render.outputs.result }}
如何处理复杂的条件控制
整个workflow流程很多,也有很多分支结构,那么如何编写yaml,实现复杂的条件控制功能呢?
github对此提供needs和if。我们可以通过if确定是否执行某一步。通过needs明确需要先通过某个步骤然后才能执行当前步骤。有些情况下,某些步骤可能会跳过,可以通过if: always() &&...来强制执行。
根据以上,我们可以设计出一个具有复杂分支的流程,同时做一个比较好的错误处理流程。
validation-failed:
name: Validation Failed
runs-on: ubuntu-latest
needs: validate
if: always() && needs.validate.outputs.result == 'error'
validation-succeeded:
name: Validation Succeeded
runs-on: ubuntu-latest
needs: [validate, create-pr]
if: always() && needs.validate.outputs.result == 'success' && needs.create-pr.outputs.result == 'success'
如何在workflow中执行py脚本
首先,可以通过run: pdm run .github/actions_scripts/test.py执行对应的脚本。
那么如何在脚本中获取环境变量,如何在yaml中传递环境变量,如何在脚本中输出内容呢?
首先,先通过env传递环境变量。
- name: Run Python script
id: run
env:
TITLE: ${{ env.ISSUE_TITLE }}
PYPI_NAME: ${{ steps.set-output.outputs.pypi_name }}
run: |
pdm run .github/actions_scripts/parse.py
然后在脚本中,通过os获取环境变量。
import os
title = os.environ["TITLE"]
pypi_name = os.environ["PYPI_NAME"]
我们通过GITHUB_OUTPUT输出内容。
def set_action_outputs(output_pairs: dict[str, str]) -> None:
"""设置 GitHub Action outputs。"""
if "GITHUB_OUTPUT" in os.environ:
with Path(os.environ["GITHUB_OUTPUT"]).open(mode="a") as f:
for key, value in output_pairs.items():
f.write(f"{key}={value}\n")
else:
for key, value in output_pairs.items():
print(f"::set-output name={key}::{value}")
#...
set_action_outputs(
{
"result": "success",
"output": "",
"type": parsed.get("type", ""),
"name": parsed.get("name", ""),
}
)
最后通过steps.xxx.outputs.xxx获取输出内容。
outputs:
module_name: ${{ steps.set-output.outputs.module_name }}
pypi_name: ${{ steps.set-output.outputs.pypi_name }}
result: ${{ steps.run.outputs.result }}
output: ${{ steps.run.outputs.output }}
type: ${{ steps.run.outputs.type }}
name: ${{ steps.run.outputs.name }}
如何进行错误处理
我们知道workflow如果在执行之中,如果发生什么问题,必须可以显示出来,什么原因,而不是在workflow直接exit。那么可以使用大量的try和catch去做错误的拦截,在最外面一层进行错误的输出,通过消息进行提示。
if __name__ == "__main__":
if TYPE != "bot" and (check_module(MODULE_NAME) is False):
set_action_outputs({"result": "error", "output": "输入的 module_name 存在问题"})
else:
try:
if TYPE != "bot":
alicebot_test()
except ValueError as e:
set_action_outputs({"result": "error", "output": str(e)})
else:
get_meta_info()
如何处理插件依赖冲突
在开发调试的过程中,使用pdm进行虚拟python环境管理和包管理方便线上环境与线下环境像统一,避免因环境不同而导致的意外问题。
但是即便如此,也会存在一系列插件所需库的版本和已有库版本之类的存在冲突,那么遇到这种因为执行命令而引起的冲突,如何进行拦截,进行消息提示呢?
首先,错误是发生在workflow的某一个步骤的命令执行阶段。因此,我们可以通过workflow强大的条件判断,进行相应的处理。例如当某个步骤执行结果存在问题则执行这一步,否则执行另一步等等。由于发生错误,会造成所有流程终止,那么没法做相应的错误处理,我们可以通过添加continue-on-error: true来继续处理任务。
- uses: actions/checkout@v3
- uses: pdm-project/setup-pdm@v3
name: Setup PDM
with:
python-version: 3.9
cache: true
- name: Install dependencies
run: pdm install --prod
# 若 type 不为 bot,则安装 pypi_name
- name: Install pypi_name
id: install-pypi
if: needs.parse-issue.outputs.type != 'bot'
continue-on-error: true
run: pdm add ${{ needs.parse-issue.outputs.pypi_name }}
- name: Install Error
id: install-pypi-error
if: steps.install-pypi.outcome != 'success' || steps.install-pypi.conclusion != 'success'
run: | # 输出错误信息
echo "result=error" >> $GITHUB_OUTPUT
echo "output=安装pypi包失败,请检查是否存在依赖冲突或者其他情况" >> $GITHUB_OUTPUT
- name: alicebot test
id: test
if: steps.install-pypi.outcome == 'success' && steps.install-pypi.conclusion == 'success'
env:
PYPI_NAME: ${{ needs.parse-issue.outputs.pypi_name }}
MODULE_NAME: ${{ needs.parse-issue.outputs.module_name }}
TYPE: ${{ needs.parse-issue.outputs.type }}
run: |
pdm run .github/actions_scripts/test.py
如何获取插件的信息
通过module_name获取插件的信息是必不可少的关键一步。导师建议使用importlib获取相关的信息 。但通过实际测试发现有一些脚本未install的情况下,完全没办法进行获取信息。这将导致一些初步验证等问题,例如需要预先验证module_name再安装。很显然,这存在悖论。因此,这里采用了一种更为简单的方法——借助https://pypi.org/pypi获取插件的信息。
最终,封装出了一个pypi对象。
class PyPi:
"""PyPi 工具类。"""
PYPI_BASE_URL = "https://pypi.org/pypi"
def __init__(self, name: str) -> None:
"""初始化。"""
self.name: str = name
target_url = f"{self.PYPI_BASE_URL}/{self.name}/json"
response = requests.get(target_url, timeout=5)
if response.status_code != requests.codes.ok:
msg = "pypi_name 检查出错"
raise ValueError(msg)
res = response.json()
if not isinstance(res, dict) or "info" not in res or not res["info"]:
msg = "请求插件 PyPi 信息失败"
raise ValueError(msg)
self.data: dict[str, Any] = response.json()["info"]
def check_pypi(self) -> None:
"""检查 pypi_name。"""
msg = "输入的 pypi_name 存在问题"
if self.name == "null":
raise ValueError(msg)
package_url = self.data.get("package_url")
if package_url is None:
raise ValueError(msg)
module_name = package_url.split("/")[-2]
if self.name.lower() != module_name.lower():
raise ValueError(msg)
def get_info(self) -> dict[str, str]:
"""获取 PyPi 包信息"""
name = self.data.get("name")
if (name is None) or (name == ""):
msg = "模块名称获取失败"
raise ValueError(msg)
description = self.data.get("summary")
if (description is None) or (description == ""):
msg = "模块描述获取失败"
raise ValueError(msg)
author = self.data.get("author")
if (author is None) or (author == ""):
email = self.data.get("author_email")
if email is not None and "<" in email: # PDM发包问题
author = email.split("<")[0].strip()
else:
msg = "作者信息获取失败"
raise ValueError(msg)
license_info = self.data.get("license")
if license_info is None:
license_info = ""
homepage = self.data.get("home_page")
if homepage is None:
homepage = ""
tags = self.data.get("keywords")
if (tags is None) or (tags == ""):
msg = "标签信息获取失败"
raise ValueError(msg)
return {
"name": name,
"description": description,
"author": author,
"license": license_info,
"homepage": homepage,
"tags": tags,
}
如何动态执行脚本并处理错误
由于项目的需求,需要对插件进行运行验证。但是python需要通过import才能调用相关插件功能。因此,这里必须采用动态执行python脚本。如果采用生成python文件,然后执行,或许过于麻烦,我们可以直接使用传递执行参数的方式,告诉脚本需要执行的插件。动态执行也可以采用subprocess执行。
def alicebot_test() -> None:
"""验证插件是否能在 AliceBot 中正常运行。"""
try:
# 要执行的 Python 脚本路径
python_script_path = ".github/actions_scripts/plugin_test.py"
result = subprocess.run(
f"python {python_script_path} {MODULE_NAME} {TYPE}",
timeout=10,
check=True,
shell=True, # noqa: S602
capture_output=True,
)
if result.returncode != 0:
msg = f"脚本执行失败: {result.stdout}"
raise ValueError(msg)
except subprocess.TimeoutExpired as e:
msg = f"脚本执行超时: {e.stdout}"
raise ValueError(msg) from e
except subprocess.CalledProcessError as e:
msg = f"脚本执行错误: {e.stdout}"
raise ValueError(msg) from e
然后可以通过sys的argv获取到执行参数。
import sys
MODULE_NAME = sys.argv[1]
TYPE = sys.argv[2]
在上文代码可以看到如何通过catch和e.stdout接住动态执行python脚本的错误信息或者成果信息,那么如何在动态脚本中输出这些信息呢?
if MODULE_NAME == "null":
sys.stdout.write("Invalid MODULE_NAME value. Must be a valid Python module name.")
sys.exit(1)
if TYPE not in {"plugin", "adapter", "bot"}:
sys.stdout.write(
"Invalid TYPE value. Must be one of 'plugin', 'adapter', or 'bot'."
)
sys.exit(1)
如何进行alicebot的插件验证
根据项目需求和导师建议,在bot运行的一个钩子生命周期(加载完插件,但还没启动bot)中直接设置程序结束。
@bot.bot_run_hook
async def bot_run_hook(bot: Bot) -> None:
"""在 Bot 启动后直接退出。"""
if TYPE == "plugin":
bot.should_exit.set()
sys.exit(0)
那么如果出现意外错误,如何进行错误处理,或者拦截错误呢?根据项目需求和导师建议,这里采用代码插桩的方式,替换bot原有的错误处理函数,将错误通过write暴露给外面一层的脚本,再蹭蹭外传,最终通过issue评论显示错误和错误原因。
bot = Bot(config_file=None)
old_error_or_exception = Bot.error_or_exception
def error_or_exception(self: Bot, message: str, exception: Exception) -> None:
"""出现错误直接退出."""
old_error_or_exception(self, message, exception)
sys.stdout.write(message)
sys.exit(1)
Bot.error_or_exception = error_or_exception
如何进行PR的自动提交
首先,如果要进行pr,需要先检查pr是否已存在,否则不要重新生成pr,防止冲突。
- name: Get open linked PR
id: get_open_linked_pr
run: |
open_linked_pr_length=$(\
gh pr list \
--repo $REPO \
--state open \
--search "close #$ISSUE_NUM in:body" \
--json number | jq '. | length'\
)
echo "::set-output name=open_linked_pr_length::$open_linked_pr_length"
- name: Check open linked pr length
if: steps.get_open_linked_pr.outputs.open_linked_pr_length != 0
run: |
echo "Unclosed pull request is existing."
exit 1
然后,如果要生成pr,很重要的就是,将文件写进需要修改的文件里。
首先需要根据信息,判断需要写入的文件是哪个。然后需要查看原来的文件是否存在相同的插件,有则进行更新。没有相同,则直接进行添加。同时,附赠一个最新的时间戳,方便前端页面可以按照时间戳进行排序。在时间的获取时,需要注意使用东八区时间,github运行的默认环境是在美国,所以时区非常需要注意。
pr的一个前提是它在不同的分支里才能pr到主分支,因此,需要先创建一个随机命名的新分支。这里按照时间命名新分支,防止重复。
- name: Define new branch name
id: define_new_branch_name
run: |
new_branch_name=$(echo "${ISSUE_NUM}-$(TZ=UTC-9 date '+%Y%m%d-%H%M%S')")
echo "::set-output name=new_branch_name::$new_branch_name"
- name: Create branch
uses: EndBug/add-and-commit@v9
with:
new_branch: ${{ steps.define_new_branch_name.outputs.new_branch_name }}
接着就是创建一个pr,然后吧之前的一些内容全部推上去,等待合并。在pr的信息里写close issue即可在合并时自动关闭issue。
- name: Create PR
run: |
gh pr create \
--head $NEW_BRANCH_NAME \
--base $BASE_BRANCH_NAME \
--title "$ISSUE_TITLE" \
--body "close #${ISSUE_NUM}"
env:
NEW_BRANCH_NAME: ${{ steps.define_new_branch_name.outputs.new_branch_name }}
BASE_BRANCH_NAME: ${{ github.event.repository.default_branch }}
- name: Copy Commands
id: copy-commands
run: |
echo "git fetch origin ${NEW_BRANCH}"
echo "git checkout ${NEW_BRANCH}"
echo "code --reuse-window ${TYPE}.json"
result="success"
echo "result=$result" >> $GITHUB_OUTPUT
env:
NEW_BRANCH: ${{ steps.define_new_branch_name.outputs.new_branch_name }}
如何设计一份翻页组件
由于项目状况,仓库不引入新的ui组件库,但是翻页组件因为实际需求,所以需要自行设计和编写。该组件能够在不同屏幕给出不同的长度和内容。在不同的页码不同的显示效果,做到一个自适应。
显示逻辑
如果总页数小于数组大小+2:所有页码都会显示,没有省略符号。
- 例如,如果总页数是 5,数组大小是 6,那么显示为:[1, 2, 3, 4, 5]
如果当前页码离第一页很近:显示前几页,然后是一个省略符号,最后是最后一页。
- 例如,如果当前页是 2,总页数是 20,数组大小是 10,那么显示为:[1, 2, 3, 4, 5, 6, 7, 8, ..., 20]
如果当前页码离最后一页很近:显示第一页,然后是一个省略符号,最后是最后几页。
- 例如,如果当前页是 19,总页数是 20,数组大小是 10,那么显示为:[1, ..., 13, 14, 15, 16, 17, 18, 19, 20]
如果当前页码在中间:显示第一页,然后是一个省略符号,中间是一些页码,再然后是一个省略符号,最后是最后一页。
- 例如,如果当前页是 10,总页数是 20,数组大小是 10,那么显示为:[1, ..., 8, 9, 10, 11, 12, ..., 20]
const genPageArray = (current: number, total: number, size: number) => {
let arr: Array<string | number> = [];
if (total < size + 2) {
arr = Array.from({ length: total }, (v, k) => k + 1);
} else if (current < size - 2) {
arr = Array.from(
(function* gen(i, l) {
while (i < l) yield i++;
})(1, size - 2 + 1)
);
arr.push("...");
arr.push(total);
} else if (total - current < size - 2) {
arr.push(1);
arr.push("...");
arr = arr.concat(
Array.from(
(function* gen(i, l) {
while (i < l) yield i++;
})(total - size + 2, total + 1)
)
);
} else {
arr.push(1);
arr.push("...");
arr = arr.concat(
Array.from(
(function* gen(i, l) {
while (i < l) yield i++;
})(
current - Math.floor((size - 4) / 2),
current - Math.floor((size - 4) / 2) + size - 4 + 1
)
)
);
arr.push("...");
arr.push(total);
}
return arr;
};
自适应尺寸
这些逻辑会根据三种不同的尺寸(pageArrayLg, pageArrayMd,pageArraySm)进行调整,以适应不同屏幕尺寸。
- 大屏幕(pageArrayLg): 17个元素(数字或省略符号)。
- 中等屏幕(pageArrayMd): 10个元素。
- 小屏幕(pageArraySm): 6个元素。
因此,当你缩放浏览器或在不同设备上查看时,页码数组的大小和内容会相应地改变。
总结与心得
前端的界面由于自身的技术背景,开发很顺利。就连翻页的组件,设计一下逻辑也不过家常便饭。但是issue-ops的构建,就没有想象中的那么顺利了。
根据waketime的统计,整体issue-ops开发调试时长达到了119h。经常一个小问题调试到凌晨4-5点。当然,其中很大一部分原因是整体workflow还是不够熟练,对于bug无法发觉和修复。对于调试,很难做到本地调试,大部分时间浪费在线上调试。项目开发也在不断改版,最后为了能够适应各种情况,做出各种错误信息显示,进行了大量的边缘测试,耗费许多时间。
在本次项目开源开发过程中,作为一名初学者获得了一个较为完整的issue-ops开发经验,给之后个人的其他项目,例如pr-ops,llm-ops带来了很多便利和帮助,对于个人的成长来说很有价值和意义。作为一个开源项目,其本身的完成就是得益于很多网络代码的开源,使得能够站在他人的肩膀上继续开发。很感谢ospp官方和alicebot导师给予这次机会,从这次经历中可以获得很多的技术经验和能力提升。
后续工作安排
- 提供基于前端的插件信息提交
- 将workflow打包成action app
- ...