一些准备

  • 服务器(运行机器人等物理设备)
  • 机器人账号和登录状态(提供运行环境和 Api)
  • Api:OneBot 等(提供与机器人框架对接的方式以及功能开发接口)
  • 机器人框架:Koishi,Nonebot,Mirai 等

Koishi

Koishi 是一个开源的跨平台机器人框架

Koishi 的意思是古明地恋,东方 Project 中的角色

docker 部署一个对接 qq 的机器人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
version: "3"

services:
bot:
container_name: koishi
# 如果服务器性能不佳,或者确认完全不需要puppeteer功能,那么可以不安装chromium
# https://koishi.chat/zh-CN/manual/starter/docker.html#%E5%90%AF%E5%8A%A8%E5%AE%B9%E5%99%A8
image: docker.1ms.run/koishijs/koishi
restart: unless-stopped
volumes:
- ./koishi-data:/koishi
environment:
- TZ=Asia/Shanghai
network_mode: host
# ports:
# - "5140:5140"

qq:
container_name: llonebot
image: docker.1ms.run/initialencounter/llonebot:latest
privileged: true
restart: unless-stopped
environment:
- VNC_PASSWD=llonebotpass
network_mode: host
# ports:
# - "7081:7081" # login
# - "3000:3000" # http
# - "3001:3001" # ws

聊天机器人的控制台

docker 服务成功运行后,浏览器访问云服务器的 5140 端口,可以看到控制台

控制台提供了对 bot 的所有管理能力,比如运行状态,插件安装卸载,日志等

安装插件

相关文档:安装和配置插件

插件系统是 Koishi 的核心,bot 所有的功能都来自于插件

Koishi 的插件是以”koishi-plugin-“开头的 npm 包,安装插件的过程实际上也是安装了一个 npm 包

由于 Koishi 官方插件市场镜像长期没有更新,可以使用官方插件提供的社区镜像源

进入插件配置,找到分组:console 下的 market(插件市场本身也是插件,可以在插件配置中修改)

可以看到说明中会提供几个社区镜像,选择一个填入 search.endpoint 就可以了

因为现在 Koishi 控制台部署在公网上,任何人都可以通过 ip:5140 访问到控制台,会有安全隐患

现在尝试在控制台种安装auth插件,这个插件可以给控制台提供用户和密码登录的功能

在插件市场里搜索auth,安装后进入插件配置页面

进入auth的插件配置,设置密码,点击右上角的三角形按钮启用插件

至此,完成了一次插件的安装和配置,以及控制台的密码登录设置

koishi 的插件大致可以分为以下几类:

  • 适配器(adapter),用来对接不同的 IM 平台或者 API 协议,如 qq、telegram、onebot 等
  • 控制台(console)增强控制台功能的插件,如 auth 等
  • 服务类,不直接在聊天中体现,但是给其他插件添加依赖,如 puppeteer,mysql,webdav 等
  • 功能类,直接在聊天中体现,如 翻译、图片搜索、天气查询等

对接 qq

登录 qq(llonebot)

在终端中输入 docker logs llonebot 可以获取二维码,用手机 qq 扫描后就可以登录

登录后,使用 vnc 连接到服务器地址的 7081 端口,输入密码llonebotpassword,可以对 docker 中的 qq 进行设置

这里我用的是 NxShell 进行 vnc 连接

设置 ws 反向监听地址(不设置也没问题,可以在适配器里设置正向 ws 监听。不同的适配器会要求不同的监听方式)

连接 onebot

配置 adapter-onebot

启用插件之后,会发现控制台右下角变绿,并出现上下行流量的变化。表示已经配置成功,koishi 平台已经与 qq 建立联系

排错

如果是首次设置,可以进入依赖管理中更新官方插件版本

如果是连接问题,则需要考虑 qq 是否在有 llonebot 的客户端中登录,以及对接的 ip 和端口是否正确

如果是功能性插件出现错误,可以在控制台中查看日志

koishi 的插件的可用性和安全性并不可靠,有些甚至需要去查看源码,安装的时候需要仔细甄别

使用插件市场里的插件丰富 bot 的功能

接下来就可以进入插件市场挑选自己想加入的插件

在论坛中可以找到有趣的推荐,以及一些问题的解决方式

Koishi 论坛

开发自己的插件

插件市场虽然很丰富,但是很难完全满足自己的定制化需求,这个时候就需要自己开发插件了

插件开发文档

创建一个本地的 koishi 插件开发环境

1
2
3
4
yarn create koishi

yarn dev

在工作区创建插件

1
yarn setup [name]

为插件添加依赖

1
yarn workspace koishi-plugin-[name] add [...deps]

koishi 的开发工作区默认是 monorepo 的结构,创建好的插件在 external 文件夹下

1
2
3
4
5
6
7
8
root
├── external
│ └── example
│ ├── src
│ │ └── index.ts
│ └── package.json
├── koishi.yml
└── package.json

开发完成后,可以把插件发布到插件市场上

在发布之前,为插件的 package.json 补充信息,所补充的信息会在插件市场中展示

其中比较值得注意的是 koishi 字段,这个字段会影响到插件配置页面的展示以及其所依赖的其他服务

在 koishi.service 中可以声明此插件所依赖的服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"name": "koishi-plugin-example",
"version": "1.0.0",
"contributors": [
// 贡献者
"Alice <alice@gmail.com>",
"Bob <bob@gmail.com>"
],
"license": "MIT", // 许可证
"homepage": "https://example.com", // 主页
"repository": {
// 源码仓库
"type": "git",
"url": "git+https://github.com/alice/koishi-plugin-example.git"
},
"keywords": ["example"], // 关键词
"peerDependencies": {
"koishi": "^4.3.2"
},
"koishi": {
"description": {
// 不同语言的插件描述
"en": "English Description",
"zh": "中文描述"
},
"service": {
"required": ["database"], // 必需的服务
"optional": ["assets"], // 可选的服务
"implements": ["dialogue"] // 实现的服务
}
}
}

获取表情图并存到云盘的插件

动机和期望

由于 qq 和微信的某次更新,无法直接保存其他人发送的表情包,所以我就想把自己的表情图独立于平台,保存到自己的网盘上,可以在任何设备任何软件上使用。

可以说是盗图的终极方案

期望

我想象中的这个插件的用法大概是这样的:

  1. 我看到了一张表情图,摩拳擦掌想塞进自己的口袋
  2. 我把这张表情图发给 bot,(用指令)告诉 bot,把这张图片存下来
  3. bot 获取到这张图片,并且对图片做统一处理(尺寸,质量,格式)
  4. 将处理好的图片保存在云盘上,云盘同步设备文件夹。
  5. 使用的时候直接选择图片发送,图片不会被放大,动画效果不会丢失
  6. 可以存官方表情图,也可以存自己的表情图

实现方式

通过引用消息告诉 bot 要存哪张图片:session.quote.elements

1
2
3
4
5
6
7
8
quote.elements.forEach((el) => {
if (el.type === "img") {
images.push(el.attrs as ImageElement);
}
if (el.type === "mface") {
mfaces.push(el.attrs as MfaceElement);
}
});

注册指令,然后在控制台中配置别名,增加参数可以指定存储到云盘的某个子文件夹

1
2
3
4
5
6
7
ctx
.command("save-sticker <folder:text>", "获取表情包", {
// @ts-ignore-next-line
hidden: true, // 隐藏指令,不会在help指令中被发送
captureQuote: false, // 运行指令避免捕获引用内容 https://github.com/koishijs/koishi/issues/1432
})
.alias("盗图", "保存图片");

使用 webdav 的方式连接云盘(坚果云),并将图片上传到云盘

1
2
3
4
5
6
7
8
9
10
11
12
// 引入纯ESM包
async function loadWebDAV() {
const webdav = await import("webdav");
console.log(webdav);
return webdav;
}
const { createClient } = await loadWebDAV();
// 创建webdav客户端
const webdavClient = createClient(webDavUrl, {
username: ctx.config.webdavUsername,
password: ctx.config.webdavPassword,
});

将图片处理成特定格式,默认 gif,并且将宽度固定到 300px,提供两个配置项,可以在控制台中配置

为什么要处理成 gif 和 300px

  1. 透明度:gif 可以保留表情图的透明度,同时不会变黑(经测试 png 会出现背景变黑的问题,qq 应该不支持 png 的复杂透明度)
  2. gif 是官方表情包(mface)的格式
  3. gif 支持动图
  4. 300 像素是比较通用的尺寸,发送后不会被放大,不太清楚 qq 和微信识别表情包的逻辑,经过尝试得到的结果

最终效果

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import { type Context, Random, Schema, Session } from "koishi";
import sharp from "sharp";
import { imageSize } from "image-size";
async function loadWebDAV() {
const webdav = await import("webdav");
console.log(webdav);
// 这里可以使用 webdav
return webdav;
}
async function loadImageType() {
const imageType = await import("image-type");
console.log(imageType);
return imageType.default;
}

export const name = "webdav-stickers";
export const usage = `
### 你这图很不错,可惜下一秒就是我的了!

这个插件可以把你看上的表情图片保存到支持 WebDAV 的云盘上,通过云盘,你可以将图片分享给其他朋友,或者同步到自己的其他设备上。

<a href="https://oss.homu.space/imgs/2569b559fd71d9a8af6435c729b6d283.jpg" target="_blank">使用效果</a>

使用方法:
1. 从支持 WebDAV 的云盘(如坚果云等)获取 WebDAV 地址和授权密码,并设置一个存放图片的根目录
2. 配置允许使用该插件的用户
3. 为命令[设置别名](/commands/save-sticker)
4. 把表情图发送给bot可以接收到的地方。引用表情图,使用命令

---

特性:
- 支持批量保存和动图保存
- 静态图片统一使用了png格式(支持透明背景)
- 可设置表情包的最大宽度,避免了表情包尺寸很大的情况
- 支持保存mface表情(官方表情图片)
- 可以通过 folder 参数,将表情图保存到云盘的不同文件夹中

`;

export const inject = ["http"];
export interface Config {
webdavUrl: string;
webdavUsername: string;
webdavPassword: string;
rootFolder: string;
allowUsers: string[];
// triggerWords: string[];
successMessage: string[];
stickerWidth: number;
staticImageFormat: "png" | "gif" | "jpg";
}

interface ImageElement {
file: string; // 文件名
fileSize: string; // 文件大小
src: string; // 文件地址
subType: string; // 文件类型
}

interface MfaceElement {
emojiId: string;
emojiPackageId: string;
key: string;
summary: string;
url: string;
}

export const Config: Schema<Config> = Schema.intersect([
Schema.object({
webdavUrl: Schema.string().required().description("WebDAV 服务器地址"),
webdavUsername: Schema.string().required().description("WebDAV 用户名"),
webdavPassword: Schema.string()
.role("secret")
.required()
.description("WebDAV 授权密码"),
rootFolder: Schema.string()
.default("Stickers")
.description("设定一个存放图片的根目录"),
}).description("WebDAV 配置"),
Schema.object({
allowUsers: Schema.array(Schema.string())
.required()
.description("允许使用该插件的用户(qq号)"),
successMessage: Schema.array(Schema.string())
.default(["搞定!"])
.description("获取成功后的消息"),
}).description("用户配置"),
Schema.object({
stickerWidth: Schema.number()
.default(300)
.description("表情包的宽度,默认300px,超过尺寸的静态图片会被压缩"),
staticImageFormat: Schema.union(["png", "gif", "jpg"])
.default("gif")
.description("静态图片的格式,默认gif,不会处理mface"),
}).description("图片配置"),
]);

export async function apply(ctx: Context) {
const { createClient } = await loadWebDAV();
const imageType = await loadImageType();
ctx.logger.info("成功加载 imageType 和 webdav");
const webDavUrl = ctx.config.webdavUrl.endsWith("/")
? ctx.config.webdavUrl
: ctx.config.webdavUrl + "/" + ctx.config.rootFolder;
ctx.logger.info("webDavUrl: " + webDavUrl);
const webdavClient = createClient(webDavUrl, {
username: ctx.config.webdavUsername,
password: ctx.config.webdavPassword,
});

async function saveImage(image: ImageElement, folder?: string) {
if (folder && !(await webdavClient.exists(`/${folder}`))) {
await webdavClient.createDirectory(`/${folder}`);
}
let buffer = await ctx.http.get(image.src);

// 检测文件是否是gif
const fileType = await imageType(buffer);
ctx.logger.info(`文件类型: ${fileType.ext}`);
let filename: string;

if (fileType.ext === "gif") {
filename = `${Date.now()}.gif`;
} else {
filename = `${Date.now()}.${ctx.config.staticImageFormat}`;
const size = imageSize(new Uint8Array(buffer));
if (size.width > ctx.config.stickerWidth) {
buffer = await sharp(buffer)
.resize({ width: ctx.config.stickerWidth })
.toFormat(ctx.config.staticImageFormat)
.toBuffer();
} else {
buffer = await sharp(buffer)
.toFormat(ctx.config.staticImageFormat)
.toBuffer();
}
}

// 如果图片大于设定大小,并且不是gif,则压缩
const savePath = `${folder ? folder + "/" : ""}${filename}`;

await webdavClient.putFileContents(savePath, buffer, {
overwrite: true,
});

ctx.logger.info(
`${filename} 已保存至 ${ctx.config.webdavUrl}/${ctx.config.rootFolder}/${savePath}`
);
}

async function saveMface(mface: MfaceElement, folder?: string) {
if (folder && !(await webdavClient.exists(`/${folder}`))) {
await webdavClient.createDirectory(`/${folder}`);
}

const extension = mface.url.split(".").pop();
const filename = `${mface.emojiPackageId}-${mface.summary}.${extension}`;
const savePath = `${folder ? folder + "/" : ""}${filename}`;

let buffer = await ctx.http.get(mface.url);
await webdavClient.putFileContents(savePath, buffer, {
overwrite: true,
});

ctx.logger.info(
`${filename} 已保存至 ${ctx.config.webdavUrl}/${ctx.config.rootFolder}/${savePath}`
);
}

ctx
.user(...ctx.config.allowUsers)
.command("save-sticker <folder:text>", "获取表情包", {
// @ts-ignore-next-line
hidden: true,
captureQuote: false,
})
.alias("盗图", "保存图片")
.action(async ({ session }, folder) => {
const quote = session.quote;
ctx.logger.info(quote, "quote");
if (!quote) return;

const images: Array<ImageElement> = [];
const mfaces: Array<MfaceElement> = [];

quote.elements.forEach((el) => {
if (el.type === "img") {
images.push(el.attrs as ImageElement);
}
if (el.type === "mface") {
mfaces.push(el.attrs as MfaceElement);
}
});

if (images.length === 0 && mfaces.length === 0) {
session.send("引用的消息里没有图片吧");
return;
}

ctx.logger.info({ images, mfaces, folder });
const promises = [
...images.map((image) => saveImage(image, folder)),
...mfaces.map((mface) => saveMface(mface, folder)),
];

Promise.allSettled(promises).then((results) => {
const rejected = results.filter((r) => r.status === "rejected");
const fulfilled = results.filter((r) => r.status === "fulfilled");

if (fulfilled.length === 0) {
session.send("没有拿到图片,好像出了点问题...");
} else if (fulfilled.length > 0 && rejected.length > 0) {
session.send("部分图片没有拿到...");
} else {
session.send(Random.pick(ctx.config.successMessage));
}
if (rejected.length > 0) {
ctx.logger.error(rejected.map((r) => r.reason));
}
});
});
}

抽签