Commit 63b67403 authored by nanahira's avatar nanahira

migrate to 4.0.0

parent 48036c61
......@@ -9,7 +9,6 @@
"version": "1.4.4",
"license": "UNLICENSED",
"dependencies": {
"@koishijs/plugin-adapter-onebot": "^4.0.0-rc.0",
"@nestjs/common": "^8.0.0",
"@nestjs/config": "^1.0.3",
"@nestjs/core": "^8.0.0",
......@@ -18,8 +17,9 @@
"@nestjs/websockets": "^8.1.2",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"koishi": "^4.0.0-rc.1",
"koishi-nestjs": "^4.0.0",
"koishi": "^4.0.0",
"koishi-nestjs": "^4.3.0",
"qface": "^1.2.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
......@@ -1348,54 +1348,22 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/@koishijs/core": {
"version": "4.0.0-rc.1",
"resolved": "https://registry.npmjs.org/@koishijs/core/-/core-4.0.0-rc.1.tgz",
"integrity": "sha512-5T2+DUvm2TUpNjWgLD6mlh8+2pNN1edF/80A1eKy5sXyrA86dzR9VkHWysY+ZZpEVedzpVPkc9RXlLwN3byQ2g==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@koishijs/core/-/core-4.0.0.tgz",
"integrity": "sha512-xonpueYfWhcTw2eJVc/SG7EwutFgzqtvdpuCGoBFvfRRSZlHOWAQfWsRw5he06+qn7rVOj3cyxrtFMP0fAthNA==",
"dependencies": {
"@koishijs/utils": "^5.0.0-rc.0",
"@koishijs/utils": "^5.0.0",
"fastest-levenshtein": "^1.0.12",
"schemastery": "^2.1.2"
"schemastery": "^2.1.3"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@koishijs/plugin-adapter-onebot": {
"version": "4.0.0-rc.0",
"resolved": "https://registry.npmjs.org/@koishijs/plugin-adapter-onebot/-/plugin-adapter-onebot-4.0.0-rc.0.tgz",
"integrity": "sha512-IU7EQGXt35V73qGRHPn1WBGmdgaMkVky/OestbLAtZq07hQmBCbnZ8HTSakOMx34day+TS5Qn04QSUm/kfVIGA==",
"dependencies": {
"qface": "^1.2.0",
"ws": "^8.2.1"
},
"peerDependencies": {
"koishi": "^4.0.0-rc.0"
}
},
"node_modules/@koishijs/plugin-adapter-onebot/node_modules/ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@koishijs/utils": {
"version": "5.0.0-rc.0",
"resolved": "https://registry.npmjs.org/@koishijs/utils/-/utils-5.0.0-rc.0.tgz",
"integrity": "sha512-aG1FjFB9NKiSqGS/tjjsSnmSfrW5yXQK7aZAR7DhsSaiF+/CdWZ0sQ9XS830xZ6PV1UwKib9alluVMULyWvrbA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@koishijs/utils/-/utils-5.0.0.tgz",
"integrity": "sha512-3ng7VkQZAP+EHdRLSbio5H36LDKRxy4OfooRKgol+gef2Yd0KOKYbVmGVud7/mZZExCPkaZVCwocKB/bKA4Xvw==",
"dependencies": {
"supports-color": "^8.1.0"
}
......@@ -6888,13 +6856,13 @@
}
},
"node_modules/koishi": {
"version": "4.0.0-rc.1",
"resolved": "https://registry.npmjs.org/koishi/-/koishi-4.0.0-rc.1.tgz",
"integrity": "sha512-liXILmB7yHaUUOqNqEZ4/Q/bZwsBVsQ8aIwQ7JolZfxQjlZtmwE69B3AEYmY2dWcO3+lnJhjCiBkvKGj01kpSg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/koishi/-/koishi-4.0.0.tgz",
"integrity": "sha512-7m9kv+8EOJlOOrwnk3KolU+Dt+tjd8fp2xNQviHj7+Z97kQlc6AnS+gXExikiiQHJpPshSgkBzbxbP1HuV668w==",
"dependencies": {
"@koa/router": "^10.1.1",
"@koishijs/core": "^4.0.0-rc.1",
"@koishijs/utils": "^5.0.0-rc.0",
"@koishijs/core": "^4.0.0",
"@koishijs/utils": "^5.0.0",
"@types/koa": "*",
"@types/koa__router": "*",
"@types/ws": "^7.4.7",
......@@ -6912,9 +6880,9 @@
}
},
"node_modules/koishi-nestjs": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/koishi-nestjs/-/koishi-nestjs-4.0.0.tgz",
"integrity": "sha512-kYWOwYkJSmrZuX6fPA8cTa7tCt+F/sVWKdw2KSEaqRIsLvv9J+d8kjh3GLs9SJIm99S43yaFhNntWxyT2XKUcA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/koishi-nestjs/-/koishi-nestjs-4.3.0.tgz",
"integrity": "sha512-E7rmi08mlJsUAOaMxEFcCMsffzYHXwON2aQTD87M5/fj+eDhuU3lUBZBidXeuEE4YLlbjIS0MRImqV+SR1tnAQ==",
"dependencies": {
"@nestjs/platform-ws": "^8.1.2",
"@nestjs/websockets": "^8.1.2",
......@@ -6927,7 +6895,7 @@
"peerDependencies": {
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"koishi": "^4.0.0-rc.1",
"koishi": "^4.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.4.0"
}
......@@ -8399,9 +8367,9 @@
"dev": true
},
"node_modules/schemastery": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/schemastery/-/schemastery-2.1.2.tgz",
"integrity": "sha512-iHwWfKxWaTFgZmKNULNtgyo8VDpdEWx31b6+j0tGTST8dBIYU7VYAHmq5qdYKYZ0uNSM5u57c09cuN75Yf7WwQ=="
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/schemastery/-/schemastery-2.1.3.tgz",
"integrity": "sha512-AH6dgucxUSkuK/LvIJtPor8/6KCEq5L9weD5JQJ6/HEDFqD4KbO7NqQsJmbW4TKubZ4FtPj7eXSEaz5nie+y+A=="
},
"node_modules/semver": {
"version": "7.3.5",
......@@ -11046,36 +11014,19 @@
}
},
"@koishijs/core": {
"version": "4.0.0-rc.1",
"resolved": "https://registry.npmjs.org/@koishijs/core/-/core-4.0.0-rc.1.tgz",
"integrity": "sha512-5T2+DUvm2TUpNjWgLD6mlh8+2pNN1edF/80A1eKy5sXyrA86dzR9VkHWysY+ZZpEVedzpVPkc9RXlLwN3byQ2g==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@koishijs/core/-/core-4.0.0.tgz",
"integrity": "sha512-xonpueYfWhcTw2eJVc/SG7EwutFgzqtvdpuCGoBFvfRRSZlHOWAQfWsRw5he06+qn7rVOj3cyxrtFMP0fAthNA==",
"requires": {
"@koishijs/utils": "^5.0.0-rc.0",
"@koishijs/utils": "^5.0.0",
"fastest-levenshtein": "^1.0.12",
"schemastery": "^2.1.2"
}
},
"@koishijs/plugin-adapter-onebot": {
"version": "4.0.0-rc.0",
"resolved": "https://registry.npmjs.org/@koishijs/plugin-adapter-onebot/-/plugin-adapter-onebot-4.0.0-rc.0.tgz",
"integrity": "sha512-IU7EQGXt35V73qGRHPn1WBGmdgaMkVky/OestbLAtZq07hQmBCbnZ8HTSakOMx34day+TS5Qn04QSUm/kfVIGA==",
"requires": {
"qface": "^1.2.0",
"ws": "^8.2.1"
},
"dependencies": {
"ws": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
"requires": {}
}
"schemastery": "^2.1.3"
}
},
"@koishijs/utils": {
"version": "5.0.0-rc.0",
"resolved": "https://registry.npmjs.org/@koishijs/utils/-/utils-5.0.0-rc.0.tgz",
"integrity": "sha512-aG1FjFB9NKiSqGS/tjjsSnmSfrW5yXQK7aZAR7DhsSaiF+/CdWZ0sQ9XS830xZ6PV1UwKib9alluVMULyWvrbA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@koishijs/utils/-/utils-5.0.0.tgz",
"integrity": "sha512-3ng7VkQZAP+EHdRLSbio5H36LDKRxy4OfooRKgol+gef2Yd0KOKYbVmGVud7/mZZExCPkaZVCwocKB/bKA4Xvw==",
"requires": {
"supports-color": "^8.1.0"
},
......@@ -15269,13 +15220,13 @@
}
},
"koishi": {
"version": "4.0.0-rc.1",
"resolved": "https://registry.npmjs.org/koishi/-/koishi-4.0.0-rc.1.tgz",
"integrity": "sha512-liXILmB7yHaUUOqNqEZ4/Q/bZwsBVsQ8aIwQ7JolZfxQjlZtmwE69B3AEYmY2dWcO3+lnJhjCiBkvKGj01kpSg==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/koishi/-/koishi-4.0.0.tgz",
"integrity": "sha512-7m9kv+8EOJlOOrwnk3KolU+Dt+tjd8fp2xNQviHj7+Z97kQlc6AnS+gXExikiiQHJpPshSgkBzbxbP1HuV668w==",
"requires": {
"@koa/router": "^10.1.1",
"@koishijs/core": "^4.0.0-rc.1",
"@koishijs/utils": "^5.0.0-rc.0",
"@koishijs/core": "^4.0.0",
"@koishijs/utils": "^5.0.0",
"@types/koa": "*",
"@types/koa__router": "*",
"@types/ws": "^7.4.7",
......@@ -15311,9 +15262,9 @@
}
},
"koishi-nestjs": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/koishi-nestjs/-/koishi-nestjs-4.0.0.tgz",
"integrity": "sha512-kYWOwYkJSmrZuX6fPA8cTa7tCt+F/sVWKdw2KSEaqRIsLvv9J+d8kjh3GLs9SJIm99S43yaFhNntWxyT2XKUcA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/koishi-nestjs/-/koishi-nestjs-4.3.0.tgz",
"integrity": "sha512-E7rmi08mlJsUAOaMxEFcCMsffzYHXwON2aQTD87M5/fj+eDhuU3lUBZBidXeuEE4YLlbjIS0MRImqV+SR1tnAQ==",
"requires": {
"@nestjs/platform-ws": "^8.1.2",
"@nestjs/websockets": "^8.1.2",
......@@ -16399,9 +16350,9 @@
}
},
"schemastery": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/schemastery/-/schemastery-2.1.2.tgz",
"integrity": "sha512-iHwWfKxWaTFgZmKNULNtgyo8VDpdEWx31b6+j0tGTST8dBIYU7VYAHmq5qdYKYZ0uNSM5u57c09cuN75Yf7WwQ=="
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/schemastery/-/schemastery-2.1.3.tgz",
"integrity": "sha512-AH6dgucxUSkuK/LvIJtPor8/6KCEq5L9weD5JQJ6/HEDFqD4KbO7NqQsJmbW4TKubZ4FtPj7eXSEaz5nie+y+A=="
},
"semver": {
"version": "7.3.5",
......
......@@ -21,7 +21,6 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@koishijs/plugin-adapter-onebot": "^4.0.0-rc.0",
"@nestjs/common": "^8.0.0",
"@nestjs/config": "^1.0.3",
"@nestjs/core": "^8.0.0",
......@@ -30,8 +29,9 @@
"@nestjs/websockets": "^8.1.2",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"koishi": "^4.0.0-rc.1",
"koishi-nestjs": "^4.0.0",
"koishi": "^4.0.0",
"koishi-nestjs": "^4.3.0",
"qface": "^1.2.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
......
import {
Bot,
segment,
Adapter,
Dict,
Schema,
Quester,
Logger,
camelize,
noop,
} from 'koishi';
import * as OneBot from './utils';
export function renderText(source: string) {
return segment.parse(source).reduce((prev, { type, data }) => {
if (type === 'at') {
if (data.type === 'all') return prev + '[CQ:at,qq=all]';
return prev + `[CQ:at,qq=${data.id}]`;
} else if (['video', 'audio', 'image'].includes(type)) {
if (type === 'audio') type = 'record';
if (!data.file) data.file = data.url;
} else if (type === 'quote') {
type = 'reply';
}
return prev + segment(type, data);
}, '');
}
export interface BotConfig extends Bot.BaseConfig, Quester.Config {
selfId?: string;
token?: string;
}
export const BotConfig: Schema<BotConfig> = Schema.intersect([
Schema.object({
selfId: Schema.string(),
token: Schema.string(),
}),
Quester.Config,
]);
export class OneBotBot extends Bot<BotConfig> {
static schema = OneBot.AdapterConfig;
public internal = new Internal();
public guildBot: QQGuildBot;
constructor(adapter: Adapter, config: BotConfig) {
super(adapter, config);
this.selfId = config.selfId;
this.avatar = `http://q.qlogo.cn/headimg_dl?dst_uin=${config.selfId}&spec=640`;
}
get status() {
return super.status;
}
set status(status) {
super.status = status;
if (this.guildBot && this.app.bots.includes(this.guildBot)) {
this.app.emit('bot-status-updated', this.guildBot);
}
}
async stop() {
if (this.guildBot) {
// QQGuild stub bot should also be removed
await this.app.bots.remove(this.guildBot.sid);
}
await super.stop();
}
async initialize() {
await Promise.all([
this.getSelf().then((data) => Object.assign(this, data)),
// this.setupGuildService().catch(noop),
]).then(
() => this.resolve(),
(error) => this.reject(error),
);
}
async setupGuildService() {
const profile = await this.internal.getGuildServiceProfile();
// guild service is not supported in this account
if (!profile?.tiny_id || profile.tiny_id === '0') return;
this.guildBot = this.app.bots.create('onebot', this.config, QQGuildBot);
this.guildBot.internal = this.internal;
this.guildBot.parentBot = this;
this.guildBot.platform = 'qqguild';
this.guildBot.selfId = profile.tiny_id;
this.guildBot.avatar = profile.avatar_url;
this.guildBot.username = profile.nickname;
}
sendMessage(channelId: string, content: string, guildId?: string) {
content = renderText(content);
return channelId.startsWith('private:')
? this.sendPrivateMessage(channelId.slice(8), content)
: this.sendGuildMessage(guildId, channelId, content);
}
async getMessage(channelId: string, messageId: string) {
const data = await this.internal.getMsg(messageId);
return OneBot.adaptMessage(data);
}
async deleteMessage(channelId: string, messageId: string) {
await this.internal.deleteMsg(messageId);
}
async getSelf() {
const data = await this.internal.getLoginInfo();
return OneBot.adaptUser(data);
}
async getUser(userId: string) {
const data = await this.internal.getStrangerInfo(userId);
return OneBot.adaptUser(data);
}
async getFriendList() {
const data = await this.internal.getFriendList();
return data.map(OneBot.adaptUser);
}
async getChannel(channelId: string) {
const data = await this.internal.getGroupInfo(channelId);
return OneBot.adaptChannel(data);
}
async getGuild(guildId: string) {
const data = await this.internal.getGroupInfo(guildId);
return OneBot.adaptGuild(data);
}
async getGuildList() {
const data = await this.internal.getGroupList();
return data.map(OneBot.adaptGuild);
}
async getGuildMember(guildId: string, userId: string) {
const data = await this.internal.getGroupMemberInfo(guildId, userId);
return OneBot.adaptGuildMember(data);
}
async getGuildMemberList(guildId: string) {
const data = await this.internal.getGroupMemberList(guildId);
return data.map(OneBot.adaptGuildMember);
}
protected async sendGuildMessage(
guildId: string,
channelId: string,
content: string,
) {
if (!content) return;
const session = this.createSession({
content,
subtype: 'group',
guildId,
channelId,
});
if (this.app.bail(session, 'before-send', session)) return;
session.messageId =
'' + (await this.internal.sendGroupMsg(channelId, content));
this.app.emit(session, 'send', session);
return [session.messageId];
}
async sendPrivateMessage(userId: string, content: string) {
if (!content) return;
const session = this.createSession({
content,
subtype: 'private',
userId,
channelId: 'private:' + userId,
});
if (this.app.bail(session, 'before-send', session)) return;
session.messageId =
'' + (await this.internal.sendPrivateMsg(userId, content));
this.app.emit(session, 'send', session);
return [session.messageId];
}
async handleFriendRequest(
messageId: string,
approve: boolean,
comment?: string,
) {
await this.internal.setFriendAddRequest(messageId, approve, comment);
}
async handleGuildRequest(
messageId: string,
approve: boolean,
comment?: string,
) {
await this.internal.setGroupAddRequest(
messageId,
'invite',
approve,
comment,
);
}
async handleGuildMemberRequest(
messageId: string,
approve: boolean,
comment?: string,
) {
await this.internal.setGroupAddRequest(messageId, 'add', approve, comment);
}
async deleteFriend(userId: string) {
await this.internal.deleteFriend(userId);
}
}
export class QQGuildBot extends OneBotBot {
parentBot: OneBotBot;
get status() {
if (!this.parentBot) {
return 'offline';
}
return this.parentBot.status;
}
set status(status) {
// cannot change status here
}
async start() {
await this.app.parallel('bot-connect', this);
}
async stop() {
// Don't stop this bot twice
if (!this.parentBot) return;
// prevent circular reference and use this as already disposed
this.parentBot = undefined;
await this.app.parallel('bot-disconnect', this);
}
async sendGuildMessage(guildId: string, channelId: string, content: string) {
if (!content) return;
const session = this.createSession({
content,
subtype: 'group',
guildId,
channelId,
});
if (this.app.bail(session, 'before-send', session)) return;
session.messageId =
'' +
(await this.internal.sendGuildChannelMsg(guildId, channelId, content));
this.app.emit(session, 'send', session);
return [session.messageId];
}
async getChannel(channelId: string, guildId?: string) {
const channels = await this.getChannelList(guildId);
return channels.find((channel) => channel.channelId === channelId);
}
async getChannelList(guildId: string) {
const data = await this.internal.getGuildChannelList(guildId, false);
return (data || []).map(OneBot.adaptChannel);
}
async getGuild(guildId: string) {
const data = await this.internal.getGuildMetaByGuest(guildId);
return OneBot.adaptGuild(data);
}
async getGuildList() {
const data = await this.internal.getGuildList();
return data.map(OneBot.adaptGuild);
}
async getGuildMember(guildId: string, userId: string) {
const memberList = await this.getGuildMemberList(guildId);
return memberList.find((member) => member.userId === userId);
}
async getGuildMemberList(guildId: string) {
const { members, bots, admins } = await this.internal.getGuildMembers(
guildId,
);
return [
...(members || []).map((member) =>
OneBot.adaptQQGuildMember(member, 'member'),
),
...(bots || []).map((member) => OneBot.adaptQQGuildMember(member, 'bot')),
...(admins || []).map((member) =>
OneBot.adaptQQGuildMember(member, 'admin'),
),
];
}
}
class SenderError extends Error {
constructor(args: Dict, url: string, retcode: number) {
super(
`Error when trying to send to ${url}, args: ${JSON.stringify(
args,
)}, retcode: ${retcode}`,
);
Object.defineProperties(this, {
name: { value: 'SenderError' },
code: { value: retcode },
args: { value: args },
url: { value: url },
});
}
}
const logger = new Logger('onebot');
export interface Internal extends OneBot.Internal {
noop: never;
}
export class Internal {
_request?(action: string, params: Dict): Promise<OneBot.Response>;
private async _get<T = any>(action: string, params = {}): Promise<T> {
logger.debug('[request] %s %o', action, params);
const response = await this._request(action, params);
logger.debug('[response] %o', response);
const { data, retcode } = response;
if (retcode === 0) return data;
throw new SenderError(params, action, retcode);
}
async setGroupAnonymousBan(
group_id: string,
meta: string | object,
duration?: number,
) {
const args = { group_id, duration } as any;
args[typeof meta === 'string' ? 'flag' : 'anonymous'] = meta;
await this._get('set_group_anonymous_ban', args);
}
async setGroupAnonymousBanAsync(
group_id: string,
meta: string | object,
duration?: number,
) {
const args = { group_id, duration } as any;
args[typeof meta === 'string' ? 'flag' : 'anonymous'] = meta;
await this._get('set_group_anonymous_ban_async', args);
}
private static asyncPrefixes = ['set', 'send', 'delete', 'create', 'upload'];
private static prepareMethod(name: string) {
const prop = camelize(name.replace(/^[_.]/, ''));
const isAsync = Internal.asyncPrefixes.some((prefix) =>
prop.startsWith(prefix),
);
return [prop, isAsync] as const;
}
static define(name: string, ...params: string[]) {
const [prop, isAsync] = Internal.prepareMethod(name);
Internal.prototype[prop] = async function (this: Internal, ...args: any[]) {
const data = await this._get(
name,
Object.fromEntries(params.map((name, index) => [name, args[index]])),
);
if (!isAsync) return data;
};
isAsync &&
(Internal.prototype[prop + 'Async'] = async function (
this: Internal,
...args: any[]
) {
await this._get(
name + '_async',
Object.fromEntries(params.map((name, index) => [name, args[index]])),
);
});
}
static defineExtract(name: string, key: string, ...params: string[]) {
const [prop, isAsync] = Internal.prepareMethod(name);
Internal.prototype[prop] = async function (this: Internal, ...args: any[]) {
const data = await this._get(
name,
Object.fromEntries(params.map((name, index) => [name, args[index]])),
);
return data[key];
};
isAsync &&
(Internal.prototype[prop + 'Async'] = async function (
this: Internal,
...args: any[]
) {
await this._get(
name + '_async',
Object.fromEntries(params.map((name, index) => [name, args[index]])),
);
});
}
}
Internal.defineExtract(
'send_private_msg',
'message_id',
'user_id',
'message',
'auto_escape',
);
Internal.defineExtract(
'send_group_msg',
'message_id',
'group_id',
'message',
'auto_escape',
);
Internal.defineExtract(
'send_group_forward_msg',
'message_id',
'group_id',
'messages',
);
Internal.define('delete_msg', 'message_id');
Internal.define('set_essence_msg', 'message_id');
Internal.define('delete_essence_msg', 'message_id');
Internal.define('send_like', 'user_id', 'times');
Internal.define('get_msg', 'message_id');
Internal.define('get_essence_msg_list', 'group_id');
Internal.define('ocr_image', 'image');
Internal.defineExtract('get_forward_msg', 'messages', 'message_id');
Internal.defineExtract('.get_word_slices', 'slices', 'content');
Internal.define('get_group_msg_history', 'group_id', 'message_seq');
Internal.define('set_friend_add_request', 'flag', 'approve', 'remark');
Internal.define(
'set_group_add_request',
'flag',
'sub_type',
'approve',
'reason',
);
Internal.defineExtract('_get_model_show', 'variants', 'model');
Internal.define('_set_model_show', 'model', 'model_show');
Internal.define('set_group_kick', 'group_id', 'user_id', 'reject_add_request');
Internal.define('set_group_ban', 'group_id', 'user_id', 'duration');
Internal.define('set_group_whole_ban', 'group_id', 'enable');
Internal.define('set_group_admin', 'group_id', 'user_id', 'enable');
Internal.define('set_group_anonymous', 'group_id', 'enable');
Internal.define('set_group_card', 'group_id', 'user_id', 'card');
Internal.define('set_group_leave', 'group_id', 'is_dismiss');
Internal.define(
'set_group_special_title',
'group_id',
'user_id',
'special_title',
'duration',
);
Internal.define('set_group_name', 'group_id', 'group_name');
Internal.define('set_group_portrait', 'group_id', 'file', 'cache');
Internal.define('_send_group_notice', 'group_id', 'content');
Internal.define('get_group_at_all_remain', 'group_id');
Internal.define('get_login_info');
Internal.define('get_stranger_info', 'user_id', 'no_cache');
Internal.define('_get_vip_info', 'user_id');
Internal.define('get_friend_list');
Internal.define('get_group_info', 'group_id', 'no_cache');
Internal.define('get_group_list');
Internal.define('get_group_member_info', 'group_id', 'user_id', 'no_cache');
Internal.define('get_group_member_list', 'group_id');
Internal.define('get_group_honor_info', 'group_id', 'type');
Internal.define('get_group_system_msg');
Internal.define('get_group_file_system_info', 'group_id');
Internal.define('get_group_root_files', 'group_id');
Internal.define('get_group_files_by_folder', 'group_id', 'folder_id');
Internal.define('upload_group_file', 'group_id', 'file', 'name', 'folder');
Internal.define('create_group_file_folder', 'group_id', 'folder_id', 'name');
Internal.define('delete_group_folder', 'group_id', 'folder_id');
Internal.define(
'delete_group_file',
'group_id',
'folder_id',
'file_id',
'busid',
);
Internal.defineExtract(
'get_group_file_url',
'url',
'group_id',
'file_id',
'busid',
);
Internal.defineExtract(
'download_file',
'file',
'url',
'headers',
'thread_count',
);
Internal.defineExtract('get_online_clients', 'clients', 'no_cache');
Internal.defineExtract('check_url_safely', 'level', 'url');
Internal.define('delete_friend', 'user_id');
Internal.defineExtract('get_cookies', 'cookies', 'domain');
Internal.defineExtract('get_csrf_token', 'token');
Internal.define('get_credentials', 'domain');
Internal.define('get_record', 'file', 'out_format', 'full_path');
Internal.define('get_image', 'file');
Internal.defineExtract('can_send_image', 'yes');
Internal.defineExtract('can_send_record', 'yes');
Internal.define('get_status');
Internal.define('get_version_info');
Internal.define('set_restart', 'delay');
Internal.define('reload_event_filter');
Internal.define('get_guild_service_profile');
Internal.define('get_guild_list');
Internal.define('get_guild_meta_by_guest', 'guild_id');
Internal.define('get_guild_channel_list', 'guild_id', 'no_cache');
Internal.define('get_guild_members', 'guild_id');
Internal.defineExtract(
'send_guild_channel_msg',
'message_id',
'guild_id',
'channel_id',
'message',
);
import {
Adapter,
Logger,
assertProperty,
Schema,
Quester,
omit,
Context,
} from 'koishi';
import { BotConfig, OneBotBot } from './bot';
import { dispatchSession, AdapterConfig } from './utils';
import { createHmac } from 'crypto';
const logger = new Logger('onebot');
export class HttpServer extends Adapter<BotConfig, AdapterConfig> {
static schema: Schema<BotConfig> = Schema.object({
selfId: Schema.string().description('机器人的账号。').required(),
token: Schema.string().description(
'发送信息时用于验证的字段,应与 OneBot 配置文件中的 access_token 保持一致。',
),
endpoint: Schema.string()
.description('要连接的 OneBot 服务器地址。')
.required(),
...omit(Quester.Config.dict, ['endpoint']),
});
public bots: OneBotBot[];
constructor(ctx: Context, config: AdapterConfig = {}) {
super(ctx, config);
assertProperty(ctx.app.options, 'port');
this.http = ctx.http.extend(config.request);
}
async connect(bot: OneBotBot) {
const { endpoint, token } = bot.config;
if (!endpoint) return;
const http = this.http.extend(bot.config).extend({
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
});
bot.internal._request = async (action, params) => {
return http.post('/' + action, params);
};
return bot.initialize();
}
async start() {
const { secret, path = '/onebot' } = this.config;
this.ctx.router.post(path, (ctx) => {
if (secret) {
// no signature
const signature = ctx.headers['x-signature'];
if (!signature) return (ctx.status = 401);
// invalid signature
const sig = createHmac('sha1', secret)
.update(ctx.request.rawBody)
.digest('hex');
if (signature !== `sha1=${sig}`) return (ctx.status = 403);
}
const selfId = ctx.headers['x-self-id'].toString();
const bot = this.bots.find((bot) => bot.selfId === selfId);
if (!bot) return (ctx.status = 403);
logger.debug('receive %o', ctx.request.body);
dispatchSession(bot, ctx.request.body);
});
}
stop() {
logger.debug('http server closing');
}
}
import { Adapter } from 'koishi';
import { OneBotBot } from './bot';
import { WebSocketClient, WebSocketServer } from './ws';
import { HttpServer } from './http';
import * as OneBot from './types';
declare module 'koishi' {
interface Modules {
'adapter-onebot': typeof import('.');
}
interface Session {
onebot?: OneBot.Payload & OneBot.Internal;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Session {
interface Events {
onebot: {
// eslint-disable-next-line @typescript-eslint/ban-types
'message-reactions-updated': {};
// eslint-disable-next-line @typescript-eslint/ban-types
'channel-updated': {};
// eslint-disable-next-line @typescript-eslint/ban-types
'channel-created': {};
// eslint-disable-next-line @typescript-eslint/ban-types
'channel-destroyed': {};
};
}
}
}
export { OneBot };
export * from './bot';
export * from './ws';
export * from './http';
export default Adapter.define(
'OneBot',
OneBotBot,
{
http: HttpServer,
ws: WebSocketClient,
'ws-reverse': WebSocketServer,
},
({ endpoint }) => {
return !endpoint ? 'ws-reverse' : endpoint.startsWith('ws') ? 'ws' : 'http';
},
);
export interface Response {
status: string;
retcode: number;
data: any;
echo?: number;
}
export interface MessageId {
message_id: number;
}
export interface AccountInfo {
user_id: string;
tiny_id?: string;
nickname: string;
}
export interface StrangerInfo extends AccountInfo {
sex: 'male' | 'female' | 'unknown';
age: number;
}
export interface TalkativeMemberInfo extends AccountInfo {
avatar: string;
day_count: number;
}
export type GroupRole = 'member' | 'admin' | 'owner';
export type HonorType =
| 'talkative'
| 'performer'
| 'legend'
| 'strong_newbie'
| 'emotion';
export interface HonoredMemberInfo {
avatar: string;
description: string;
}
export interface HonorInfo {
current_talkative: TalkativeMemberInfo;
talkative_list: HonoredMemberInfo[];
performer_list: HonoredMemberInfo[];
legend_list: HonoredMemberInfo[];
strong_newbie_list: HonoredMemberInfo[];
emotion_list: HonoredMemberInfo[];
}
export interface SenderInfo extends StrangerInfo {
area?: string;
level?: string;
title?: string;
role?: GroupRole;
card?: string;
}
export interface Message extends MessageId {
real_id?: number;
time: number;
message_type: 'private' | 'group' | 'guild';
sender: SenderInfo;
group_id?: number;
guild_id?: string;
channel_id?: string;
message: string | any[];
anonymous?: AnonymousInfo;
}
export interface AnonymousInfo {
id: number;
name: string;
flag: string;
}
export type RecordFormat =
| 'mp3'
| 'amr'
| 'wma'
| 'm4a'
| 'spx'
| 'ogg'
| 'wav'
| 'flac';
export type DataDirectory = 'image' | 'record' | 'show' | 'bface';
export interface FriendInfo extends AccountInfo {
remark: string;
}
export interface GroupBase {
group_id: number;
group_name: string;
}
export interface GroupInfo extends GroupBase {
member_count: number;
max_member_count: number;
}
export interface GroupMemberInfo extends SenderInfo {
card_changeable: boolean;
group_id: number;
join_time: number;
last_sent_time: number;
title_expire_time: number;
unfriendly: boolean;
}
export interface Credentials {
cookies: string;
csrf_token: number;
}
export interface ImageInfo {
file: string;
}
export interface RecordInfo {
file: string;
}
export interface VersionInfo {
coolq_directory: string;
coolq_edition: 'air' | 'pro';
plugin_version: string;
plugin_build_number: number;
plugin_build_configuration: 'debug' | 'release';
version?: string;
go_cqhttp?: boolean;
runtime_version?: string;
runtime_os?: string;
}
export interface ImageInfo {
size?: number;
filename?: string;
url?: string;
}
export interface ForwardMessage {
sender: AccountInfo;
time: number;
content: string;
}
export interface EssenceMessage extends MessageId {
sender_id: number;
sender_nick: string;
sender_time: number;
operator_id: number;
operator_nick: string;
operator_time: number;
}
interface CQNode {
type: 'node';
data:
| {
id: number;
}
| {
name: string;
uin: number;
content: string;
};
}
export interface VipInfo extends AccountInfo {
level: number;
level_speed: number;
vip_level: number;
vip_growth_speed: number;
vip_growth_total: string;
}
export interface GroupNotice {
cn: number;
fid: string;
fn: number;
msg: {
text: string;
text_face: string;
title: string;
};
pubt: number;
read_num: number;
settings: {
is_show_edit_card: number;
remind_ts: number;
};
u: number;
vn: number;
}
export interface Statistics {
packet_received: number;
packet_sent: number;
packet_lost: number;
message_received: number;
message_sent: number;
disconnect_times: number;
lost_times: number;
}
export interface StatusInfo {
app_initialized: boolean;
app_enabled: boolean;
plugins_good: boolean;
app_good: boolean;
online: boolean;
good: boolean;
stat: Statistics;
}
export interface TextDetection {
text: string;
confidence: string;
coordinates: any;
}
export interface OcrResult {
language: string;
texts: TextDetection[];
}
export interface GroupRequest extends GroupBase {
request_id: number;
invitor_uin: number;
invitor_nick: string;
checked: boolean;
actor: number;
}
export type InvitedRequest = GroupRequest;
export interface JoinRequest extends GroupRequest {
message: string;
}
export interface GroupSystemMessageInfo {
invited_qequests: InvitedRequest[];
join_requests: JoinRequest[];
}
export interface GroupFileSystemInfo {
file_count: number;
limit_count: number;
used_space: number;
total_space: number;
}
export interface GroupFile {
file_id: string;
file_name: string;
busid: number;
file_size: number;
upload_time: number;
dead_time: number;
modify_time: number;
download_time: number;
uploader: number;
uploader_name: string;
}
export interface GroupFolder {
folder_id: string;
folder_name: string;
create_time: number;
creator: number;
creator_name: string;
total_file_count: number;
}
export interface GroupFileList {
files: GroupFile[];
folders: GroupFolder[];
}
export interface AtAllRemain {
can_at_all: boolean;
remain_at_all_count_for_group: number;
remain_at_all_count_for_uin: number;
}
export interface Device {
app_id: number;
device_name: string;
device_kind: string;
}
export interface ModelVariant {
model_show: string;
need_pay: boolean;
}
export enum SafetyLevel {
safe,
unknown,
danger,
}
export interface GuildServiceProfile {
nickname: string;
tiny_id: string;
avatar_url: string;
}
export interface GuildBaseInfo {
guild_id: string;
guild_name: string;
}
export interface GuildInfo extends GuildBaseInfo {
guild_display_id: string;
}
export interface GuildMeta extends GuildBaseInfo {
guild_profile: string;
create_time: number;
max_member_count: number;
max_robot_count: number;
max_admin_count: number;
member_count: number;
owner_id: string;
}
export interface ChannelInfo {
owner_guild_id: string;
channel_id: string;
channel_type: number;
channel_name: string;
create_time: number;
creator_id: string;
creator_tiny_id: string;
talk_permission: number;
visible_type: number;
current_slow_mode: number;
slow_modes: SlowModeInfo[];
}
export interface SlowModeInfo {
slow_mode_key: number;
slow_mode_text: string;
speak_frequency: number;
slow_mode_circle: number;
}
export interface GuildMemberInfo {
tiny_id: string;
title: string;
nickname: string;
role: number;
}
export interface GuildMembers {
members: GuildMemberInfo[];
bots: GuildMemberInfo[];
admins: GuildMemberInfo[];
}
export interface ReactionInfo {
emoji_id: string;
emoji_index: number;
emoji_type: number;
emoji_name: string;
count: number;
clicked: boolean;
}
export interface Payload extends Message {
time: number;
self_id: number;
self_tiny_id?: string;
post_type: string;
request_type: string;
notice_type: string;
meta_event_type: string;
honor_type: string;
sub_type: string;
message_id: number;
user_id: number;
target_id: number;
operator_id: number;
raw_message: string;
font: number;
comment: string;
flag: string;
old_info: ChannelInfo;
new_info: ChannelInfo;
channel_info: ChannelInfo;
current_reactions: ReactionInfo[];
}
type id = string | number;
export interface Internal {
sendPrivateMsg(
user_id: id,
message: string,
auto_escape?: boolean,
): Promise<number>;
sendPrivateMsgAsync(
user_id: id,
message: string,
auto_escape?: boolean,
): Promise<void>;
sendGroupMsg(
group_id: id,
message: string,
auto_escape?: boolean,
): Promise<number>;
sendGroupMsgAsync(
group_id: id,
message: string,
auto_escape?: boolean,
): Promise<void>;
sendGroupForwardMsg(
group_id: id,
messages: readonly CQNode[],
): Promise<number>;
sendGroupForwardMsgAsync(
group_id: id,
messages: readonly CQNode[],
): Promise<void>;
deleteMsg(message_id: id): Promise<void>;
deleteMsgAsync(message_id: id): Promise<void>;
setEssenceMsg(message_id: id): Promise<void>;
setEssenceMsgAsync(message_id: id): Promise<void>;
deleteEssenceMsg(message_id: id): Promise<void>;
deleteEssenceMsgAsync(message_id: id): Promise<void>;
sendLike(user_id: id, times?: number): Promise<void>;
sendLikeAsync(user_id: id, times?: number): Promise<void>;
getMsg(message_id: id): Promise<Message>;
getForwardMsg(message_id: id): Promise<ForwardMessage[]>;
getEssenceMsgList(group_id: id): Promise<EssenceMessage[]>;
getWordSlices(content: string): Promise<string[]>;
ocrImage(image: string): Promise<OcrResult>;
getGroupMsgHistory(group_id: id, message_seq: id): Promise<Message[]>;
deleteFriend(user_id: id): Promise<void>;
deleteFriendAsync(user_id: id): Promise<void>;
setFriendAddRequest(
flag: string,
approve: boolean,
remark?: string,
): Promise<void>;
setFriendAddRequestAsync(
flag: string,
approve: boolean,
remark?: string,
): Promise<void>;
setGroupAddRequest(
flag: string,
subType: 'add' | 'invite',
approve: boolean,
reason?: string,
): Promise<void>;
setGroupAddRequestAsync(
flag: string,
subType: 'add' | 'invite',
approve: boolean,
reason?: string,
): Promise<void>;
setGroupKick(
group_id: id,
user_id: id,
reject_add_request?: boolean,
): Promise<void>;
setGroupKickAsync(
group_id: id,
user_id: id,
reject_add_request?: boolean,
): Promise<void>;
setGroupBan(group_id: id, user_id: id, duration?: number): Promise<void>;
setGroupBanAsync(group_id: id, user_id: id, duration?: number): Promise<void>;
setGroupWholeBan(group_id: id, enable?: boolean): Promise<void>;
setGroupWholeBanAsync(group_id: id, enable?: boolean): Promise<void>;
setGroupAdmin(group_id: id, user_id: id, enable?: boolean): Promise<void>;
setGroupAdminAsync(
group_id: id,
user_id: id,
enable?: boolean,
): Promise<void>;
setGroupAnonymous(group_id: id, enable?: boolean): Promise<void>;
setGroupAnonymousAsync(group_id: id, enable?: boolean): Promise<void>;
setGroupCard(group_id: id, user_id: id, card?: string): Promise<void>;
setGroupCardAsync(group_id: id, user_id: id, card?: string): Promise<void>;
setGroupLeave(group_id: id, is_dismiss?: boolean): Promise<void>;
setGroupLeaveAsync(group_id: id, is_dismiss?: boolean): Promise<void>;
setGroupSpecialTitle(
group_id: id,
user_id: id,
special_title?: string,
duration?: number,
): Promise<void>;
setGroupSpecialTitleAsync(
group_id: id,
user_id: id,
special_title?: string,
duration?: number,
): Promise<void>;
setGroupName(group_id: id, name: string): Promise<void>;
setGroupNameAsync(group_id: id, name: string): Promise<void>;
setGroupPortrait(group_id: id, file: string, cache?: boolean): Promise<void>;
setGroupPortraitAsync(
group_id: id,
file: string,
cache?: boolean,
): Promise<void>;
getGroupAtAllRemain(group_id: id): Promise<AtAllRemain>;
sendGroupNotice(group_id: id, content: string): Promise<void>;
sendGroupNoticeAsync(group_id: id, content: string): Promise<void>;
getLoginInfo(): Promise<AccountInfo>;
getVipInfo(): Promise<VipInfo>;
getStrangerInfo(user_id: id, no_cache?: boolean): Promise<StrangerInfo>;
getFriendList(): Promise<FriendInfo[]>;
getGroupInfo(group_id: id, no_cache?: boolean): Promise<GroupInfo>;
getGroupList(): Promise<GroupInfo[]>;
getGroupMemberInfo(
group_id: id,
user_id: id,
no_cache?: boolean,
): Promise<GroupMemberInfo>;
getGroupMemberList(
group_id: id,
no_cache?: boolean,
): Promise<GroupMemberInfo[]>;
getGroupHonorInfo(group_id: id, type: HonorType): Promise<HonorInfo>;
getGroupSystemMsg(): Promise<GroupSystemMessageInfo>;
getGroupFileSystemInfo(group_id: id): Promise<GroupFileSystemInfo>;
getGroupRootFiles(group_id: id): Promise<GroupFileList>;
getGroupFilesByFolder(
group_id: id,
folder_id: string,
): Promise<GroupFileList>;
getGroupFileUrl(
group_id: id,
file_id: string,
busid: number,
): Promise<string>;
downloadFile(
url: string,
headers?: string | readonly string[],
thread_count?: number,
): Promise<string>;
uploadGroupFile(
group_id: id,
file: string,
name: string,
folder?: string,
): Promise<void>;
createGroupFileFolder(
group_id: id,
folder_id: string,
name: string,
): Promise<void>;
deleteGroupFolder(group_id: id, folder_id: string): Promise<void>;
deleteGroupFile(
group_id: id,
folder_id: string,
file_id: string,
busid: number,
): Promise<void>;
getOnlineClients(no_cache?: boolean): Promise<Device[]>;
checkUrlSafely(url: string): Promise<SafetyLevel>;
getModelShow(model: string): Promise<ModelVariant[]>;
setModelShow(model: string, model_show: string): Promise<void>;
getCookies(domain?: string): Promise<string>;
getCsrfToken(): Promise<number>;
getCredentials(domain?: string): Promise<Credentials>;
getRecord(
file: string,
out_format: RecordFormat,
full_path?: boolean,
): Promise<RecordInfo>;
getImage(file: string): Promise<ImageInfo>;
canSendImage(): Promise<boolean>;
canSendRecord(): Promise<boolean>;
getStatus(): Promise<StatusInfo>;
getVersionInfo(): Promise<VersionInfo>;
setRestart(delay?: number): Promise<void>;
reloadEventFilter(): Promise<void>;
getGuildServiceProfile(): Promise<GuildServiceProfile>;
getGuildList(): Promise<GuildInfo[]>;
getGuildMetaByGuest(guild_id: id): Promise<GuildMeta>;
getGuildChannelList(guild_id: id, no_cache: boolean): Promise<ChannelInfo[]>;
getGuildMembers(guild_id: id): Promise<GuildMembers>;
sendGuildChannelMsg(
guild_id: id,
channel_id: id,
message: string,
): Promise<number>;
}
import {
Adapter,
Bot,
Session,
paramCase,
segment,
Schema,
App,
defineProperty,
} from 'koishi';
import * as qface from 'qface';
import { OneBotBot } from './bot';
import * as OneBot from './types';
export * from './types';
export interface AdapterConfig
extends Adapter.WebSocketClient.Config,
App.Config.Request {
path?: string;
secret?: string;
responseTimeout?: number;
}
export const AdapterConfig: Schema<AdapterConfig> = Schema.intersect([
Schema.object({
path: Schema.string()
.description('服务器监听的路径,用于 http 和 ws-reverse 协议。')
.default('/onebot'),
secret: Schema.string().description(
'接收事件推送时用于验证的字段,应该与 OneBot 的 secret 配置保持一致。',
),
}),
Adapter.WebSocketClient.Config,
App.Config.Request,
]);
export const adaptUser = (user: OneBot.AccountInfo): Bot.User => ({
userId: user.tiny_id || user.user_id.toString(),
avatar: user.user_id
? `http://q.qlogo.cn/headimg_dl?dst_uin=${user.user_id}&spec=640`
: undefined,
username: user.nickname,
});
export const adaptGuildMember = (user: OneBot.SenderInfo): Bot.GuildMember => ({
...adaptUser(user),
nickname: user.card,
roles: [user.role],
});
export const adaptQQGuildMember = (
user: OneBot.GuildMemberInfo,
presetRole?: string,
): Bot.GuildMember => ({
userId: user.tiny_id,
username: user.nickname,
nickname: user.nickname,
roles: [...(presetRole ? [presetRole] : []), user.role.toString()],
isBot: presetRole === 'bot',
});
export const adaptAuthor = (
user: OneBot.SenderInfo,
anonymous?: OneBot.AnonymousInfo,
): Bot.Author => ({
...adaptUser(user),
nickname: anonymous?.name || user.card,
anonymous: anonymous?.flag,
roles: [user.role],
});
export function adaptMessage(message: OneBot.Message): Bot.Message {
const author = adaptAuthor(message.sender, message.anonymous);
const result: Bot.Message = {
author,
userId: author.userId,
messageId: message.message_id.toString(),
timestamp: message.time * 1000,
content: segment.transform(message.message, {
at({ qq }) {
if (qq !== 'all') return segment.at(qq);
return segment('at', { type: 'all' });
},
face: ({ id }) => segment('face', { id, url: qface.getUrl(id) }),
reply: (data) => segment('quote', data),
}),
};
if (message.guild_id) {
result.guildId = message.guild_id;
result.channelId = message.channel_id;
} else if (message.group_id) {
result.guildId = result.channelId = message.group_id.toString();
} else {
result.channelId = 'private:' + author.userId;
}
return result;
}
export const adaptGuild = (
info: OneBot.GroupInfo | OneBot.GuildBaseInfo,
): Bot.Guild => {
if ((info as OneBot.GuildBaseInfo).guild_id) {
const guild = info as OneBot.GuildBaseInfo;
return {
guildId: guild.guild_id,
guildName: guild.guild_name,
};
} else {
const group = info as OneBot.GroupInfo;
return {
guildId: group.group_id.toString(),
guildName: group.group_name,
};
}
};
export const adaptChannel = (
info: OneBot.GroupInfo | OneBot.ChannelInfo,
): Bot.Channel => {
if ((info as OneBot.ChannelInfo).channel_id) {
const channel = info as OneBot.ChannelInfo;
return {
channelId: channel.channel_id.toString(),
channelName: channel.channel_name,
};
} else {
const group = info as OneBot.GroupInfo;
return {
channelId: group.group_id.toString(),
channelName: group.group_name,
};
}
};
export function dispatchSession(bot: OneBotBot, data: OneBot.Payload) {
/*
if (data.self_tiny_id) {
// don't dispatch any guild message without guild initialization
if (!bot.guildBot) return;
bot = bot.guildBot;
}
*/
const payload = adaptSession(data);
if (!payload) return;
const session = new Session(bot, payload);
defineProperty(session, 'onebot', Object.create(bot.internal));
Object.assign(session.onebot, data);
bot.adapter.dispatch(session);
}
export function adaptSession(data: OneBot.Payload) {
const session: Partial<Session> = {};
session.selfId = data.self_tiny_id ? data.self_tiny_id : '' + data.self_id;
session.type = data.post_type;
if (data.post_type === 'message') {
Object.assign(session, adaptMessage(data));
session.subtype =
data.message_type === 'guild' ? 'group' : data.message_type;
session.subsubtype = data.message_type;
return session;
}
session.subtype = data.sub_type;
if (data.user_id) session.userId = '' + data.user_id;
if (data.group_id) session.guildId = session.channelId = '' + data.group_id;
if (data.guild_id) session.guildId = '' + data.guild_id;
if (data.channel_id) session.channelId = '' + data.channel_id;
if (data.target_id) session.targetId = '' + data.target_id;
if (data.operator_id) session.operatorId = '' + data.operator_id;
if (data.message_id) session.messageId = '' + data.message_id;
if (data.post_type === 'request') {
session.content = data.comment;
session.messageId = data.flag;
if (data.request_type === 'friend') {
session.type = 'friend-request';
session.channelId = `private:${session.userId}`;
} else if (data.sub_type === 'add') {
session.type = 'guild-member-request';
} else {
session.type = 'guild-request';
}
} else if (data.post_type === 'notice') {
switch (data.notice_type) {
case 'group_recall':
session.type = 'message-deleted';
session.subtype = 'group';
break;
case 'friend_recall':
session.type = 'message-deleted';
session.subtype = 'private';
session.channelId = `private:${session.userId}`;
break;
case 'friend_add':
session.type = 'friend-added';
break;
case 'group_upload':
session.type = 'group-file-added';
break;
case 'group_admin':
session.type = 'group-member';
session.subtype = 'role';
break;
case 'group_ban':
session.type = 'group-member';
session.subtype = 'ban';
break;
case 'group_decrease':
session.type =
session.userId === session.selfId
? 'group-deleted'
: 'group-member-deleted';
session.subtype =
session.userId === session.operatorId ? 'active' : 'passive';
break;
case 'group_increase':
session.type =
session.userId === session.selfId
? 'group-added'
: 'group-member-added';
session.subtype =
session.userId === session.operatorId ? 'active' : 'passive';
break;
case 'group_card':
session.type = 'group-member';
session.subtype = 'nickname';
break;
case 'notify':
session.type = 'notice';
session.subtype = paramCase(data.sub_type) as any;
if (session.subtype === 'poke') {
session.channelId ||= `private:${session.userId}`;
} else if (session.subtype === 'honor') {
session.subsubtype = paramCase(data.honor_type) as any;
}
break;
case 'message_reactions_updated':
session.type = 'onebot';
session.subtype = 'message-reactions-updated';
break;
case 'channel_created':
session.type = 'onebot';
session.subtype = 'channel-created';
break;
case 'channel_updated':
session.type = 'onebot';
session.subtype = 'channel-updated';
break;
case 'channel_destroyed':
session.type = 'onebot';
session.subtype = 'channel-destroyed';
break;
}
}
return session;
}
import {
Adapter,
Logger,
assertProperty,
Time,
Schema,
Context,
WebSocketLayer,
} from 'koishi';
import { BotConfig, OneBotBot } from './bot';
import { AdapterConfig, dispatchSession, Response } from './utils';
import WebSocket from 'ws';
const logger = new Logger('onebot');
export class WebSocketClient extends Adapter.WebSocketClient<
BotConfig,
AdapterConfig
> {
static schema: Schema<BotConfig> = Schema.object({
selfId: Schema.string().description('机器人的账号。').required(),
token: Schema.string().description(
'发送信息时用于验证的字段,应与 OneBot 的 access_token 配置保持一致。',
),
endpoint: Schema.string()
.description('要连接的 OneBot 服务器地址。')
.required(),
});
protected accept = accept;
prepare(bot: OneBotBot) {
const { endpoint, token } = bot.config;
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
return new WebSocket(endpoint, { headers });
}
}
export class WebSocketServer extends Adapter<BotConfig, AdapterConfig> {
static schema: Schema<BotConfig> = Schema.object({
selfId: Schema.string().description('机器人的账号。').required(),
});
public wsServer?: WebSocketLayer;
protected accept = accept;
constructor(ctx: Context, config: AdapterConfig) {
super(ctx, config);
assertProperty(ctx.app.options, 'port');
const { path = '/onebot' } = config;
this.wsServer = ctx.router.ws(path, (socket, { headers }) => {
logger.debug('connected with', headers);
if (headers['x-client-role'] !== 'Universal') {
return socket.close(1008, 'invalid x-client-role');
}
const selfId = headers['x-self-id'].toString();
const bot = this.bots.find((bot) => bot.selfId === selfId);
if (!bot) return socket.close(1008, 'invalid x-self-id');
bot.socket = socket;
this.accept(bot as OneBotBot);
});
}
connect() {}
start() {}
stop() {
logger.debug('ws server closing');
this.wsServer.close();
for (const bot of this.bots) {
bot.socket = null;
}
}
}
let counter = 0;
const listeners: Record<number, (response: Response) => void> = {};
export function accept(
this: Adapter<BotConfig, AdapterConfig>,
bot: OneBotBot,
) {
bot.socket.on('message', (data) => {
data = data.toString();
let parsed: any;
try {
parsed = JSON.parse(data);
} catch (error) {
return logger.warn('cannot parse message', data);
}
if ('post_type' in parsed) {
logger.debug('receive %o', parsed);
dispatchSession(bot, parsed);
} else if (parsed.echo in listeners) {
listeners[parsed.echo](parsed);
delete listeners[parsed.echo];
}
});
bot.socket.on('close', () => {
delete bot.internal._request;
});
bot.internal._request = (action, params) => {
const data = { action, params, echo: ++counter };
data.echo = ++counter;
return new Promise((resolve, reject) => {
listeners[data.echo] = resolve;
setTimeout(() => {
delete listeners[data.echo];
reject(new Error('response timeout'));
}, this.config.responseTimeout || Time.minute);
bot.socket.send(JSON.stringify(data), (error) => {
if (error) reject(error);
});
});
};
bot.initialize();
}
......@@ -20,6 +20,7 @@ import { BotRegistryService } from './bot-registry/bot-registry.service';
}),
KoishiModule.register({
prefix: '__never_prefix',
help: false,
minSimilarity: 1,
useWs: true,
}),
......
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Adapter, Context, Session } from 'koishi';
import PluginOnebot from '../adapter-onebot';
import { ConfigService } from '@nestjs/config';
import { InjectContext, PluginDef, UsePlugin } from 'koishi-nestjs';
import { BotConfig } from '../adapter-onebot';
import { AdapterConfig } from '../adapter-onebot/utils';
declare module 'koishi' {
interface EventMap {
......@@ -14,24 +19,14 @@ Adapter.prototype.dispatch = function (this: Adapter, session: Session) {
this.ctx.emit(session, 'dispatch', session);
};
import PluginOnebot from '@koishijs/plugin-adapter-onebot';
import { ConfigService } from '@nestjs/config';
import { InjectContext, PluginDef, UsePlugin } from 'koishi-nestjs';
import { BotConfig } from '@koishijs/plugin-adapter-onebot/lib/bot';
import { AdapterConfig } from '@koishijs/plugin-adapter-onebot/lib/utils';
@Injectable()
export class BotLoaderService implements OnModuleInit {
constructor(
private config: ConfigService,
@InjectContext() private ctx: Context,
) {}
export class BotLoaderService {
constructor(private config: ConfigService) {}
@UsePlugin()
loadBots() {
const onebotConfig = this.config.get<
Adapter.PluginConfig<AdapterConfig, BotConfig>
>('onebot');
const onebotConfig =
this.config.get<Adapter.PluginConfig<AdapterConfig, BotConfig>>('onebot');
if (onebotConfig.selfId) {
onebotConfig.selfId = onebotConfig.selfId.toString();
}
......@@ -42,13 +37,4 @@ export class BotLoaderService implements OnModuleInit {
}
return PluginDef(PluginOnebot, onebotConfig);
}
onModuleInit() {
const helpCommand = this.ctx.command('help');
if (!helpCommand) {
return;
}
const helpCtx = helpCommand.context;
helpCommand.context = helpCtx.never();
}
}
import { Injectable } from '@nestjs/common';
import { WireContextService } from 'koishi-nestjs';
import { OneBotBot } from '@koishijs/plugin-adapter-onebot/lib/bot';
import { OneBotBot } from '../adapter-onebot';
import { Adapter } from 'koishi';
@Injectable()
export class BotRegistryService {
@WireContextService('bots')
private bots: OneBotBot[];
private bots: Adapter.BotList;
private botMap = new Map<string, OneBotBot>();
getBotWithId(selfId: string) {
if (!this.botMap.has(selfId)) {
const bot = this.bots.find((bot) => bot.selfId === selfId);
if (bot) {
this.botMap.set(selfId, bot);
}
}
return this.botMap.get(selfId);
getBotWithId(selfId: string): OneBotBot {
return this.bots.get(`onebot:${selfId}`) as OneBotBot;
}
getAllBots() {
return this.bots;
getAllBots(): OneBotBot[] {
return this.bots as unknown as OneBotBot[];
}
}
......@@ -5,6 +5,7 @@ export class HealthInfoDto {
name: string;
@ApiProperty({ description: '是否健康' })
healthy: boolean;
constructor(name: string, healthy: boolean) {
this.name = name;
this.healthy = healthy;
......
......@@ -19,6 +19,7 @@ export class BlankReturnMessageDto implements BlankReturnMessage {
message: string;
@ApiProperty({ description: '是否成功' })
success: boolean;
constructor(statusCode: number, message?: string) {
this.statusCode = statusCode;
this.message = message || 'success';
......@@ -32,9 +33,11 @@ export class BlankReturnMessageDto implements BlankReturnMessage {
export class ReturnMessageDto<T>
extends BlankReturnMessageDto
implements ReturnMessage<T> {
implements ReturnMessage<T>
{
@ApiProperty({ description: '返回内容' })
data?: T;
constructor(statusCode: number, message?: string, data?: T) {
super(statusCode, message);
this.data = data;
......
import { Injectable } from '@nestjs/common';
import { RouteService } from '../route/route.service';
import { InjectContext } from 'koishi-nestjs';
import { Context } from 'koishi';
import { HealthInfoDto } from '../dto/HealthInfo.dto';
import { BotRegistryService } from '../bot-registry/bot-registry.service';
......
......@@ -28,4 +28,5 @@ async function bootstrap() {
config.get<string>('host') || '::',
);
}
bootstrap();
import { ConsoleLogger, Injectable } from '@nestjs/common';
import { InjectContext } from 'koishi-nestjs';
import { Context } from 'koishi';
import WebSocket from 'ws';
import { Route } from '../route/Route';
import { genMetaEvent } from '../utility/oicq';
import {
OnebotProtocol,
OnebotAsyncResponseWithEcho,
OnebotProtocol,
} from '../utility/onebot-protocol';
import { OneBotBot } from '@koishijs/plugin-adapter-onebot/lib/bot';
import { OneBotBot } from '../adapter-onebot';
import { WaitBotService } from '../wait-bot/wait-bot.service';
import { BotRegistryService } from '../bot-registry/bot-registry.service';
......
......@@ -2,7 +2,6 @@ import {
OnGatewayConnection,
OnGatewayDisconnect,
WebSocketGateway,
WsException,
} from '@nestjs/websockets';
import { IncomingMessage } from 'http';
import { RouteService } from './route/route.service';
......@@ -21,15 +20,18 @@ interface ClientInfo {
@WebSocketGateway({ path: '^/route/(.+?)/?$' })
export class OnebotGateway
extends ConsoleLogger
implements OnGatewayConnection, OnGatewayDisconnect {
implements OnGatewayConnection, OnGatewayDisconnect
{
constructor(
private routeService: RouteService,
private messageService: MessageService,
) {
super('ws');
}
private clientRouteMap = new Map<WebSocket, ClientInfo>();
private matchingRegex = new RegExp('^/route/(.+?)/?$');
handleConnection(client: WebSocket, request: IncomingMessage) {
const baseUrl = 'ws://' + request.headers.host + '/';
const url = new URL(request.url, baseUrl);
......@@ -62,6 +64,7 @@ export class OnebotGateway
`Client ${clientInfo.ip} of route ${clientInfo.routeName} connected.`,
);
}
handleDisconnect(client: WebSocket) {
const clientInfo = this.clientRouteMap.get(client);
if (!clientInfo) {
......
......@@ -9,6 +9,7 @@ export class ReverseWsService extends ConsoleLogger {
constructor(private meesageService: MessageService) {
super('reverse-ws');
}
initializeReverseWs(route: Route, revConfig: ReverseWsConfig) {
const headers: OutgoingHttpHeaders = {
'X-Self-ID': route.selfId,
......
import type WebSocket from 'ws';
import { Context, Session, Selection } from 'koishi';
import { Random, remove } from 'koishi';
import { Context, Random, remove, Selection, Session } from 'koishi';
import { createHash } from 'crypto';
import { SendTask } from '../message/message.service';
import { HealthInfoDto } from '../dto/HealthInfo.dto';
......@@ -26,6 +25,7 @@ export interface RouteConfig {
bufferAppMessage?: boolean;
bufferBotMessage?: boolean;
}
export class Route implements RouteConfig {
private connections: WebSocket[] = [];
private roundCount = 0;
......@@ -43,6 +43,7 @@ export class Route implements RouteConfig {
bufferAppMessage?: boolean;
bufferBotMessage?: boolean;
preMessages: { data: any; session: Session }[] = [];
constructor(routeConfig: RouteConfig, ctx: Context) {
Object.assign(this, routeConfig);
this.balancePolicy ||= 'hash';
......@@ -61,12 +62,15 @@ export class Route implements RouteConfig {
}, this.heartbeat);
}
}
isHealthy() {
return this.connections.length > 0;
}
getHealthyInfo() {
return new HealthInfoDto(this.name, this.isHealthy());
}
send(data: any, session: Session, allConns = this.connections) {
if (!allConns.length) {
if (this.bufferAppMessage) {
......@@ -96,6 +100,7 @@ export class Route implements RouteConfig {
});
}
}
broadcast(data: any) {
const message = JSON.stringify(data);
for (const conn of this.connections) {
......@@ -108,6 +113,7 @@ export class Route implements RouteConfig {
});
}
}
getFilteredContext(ctx: Context) {
const idCtx = ctx.self(this.selfId);
if (!this.select) {
......@@ -115,6 +121,7 @@ export class Route implements RouteConfig {
}
return idCtx.select(this.select);
}
static sessionKeys: (keyof Session)[] = [
'selfId',
'guildId',
......@@ -125,6 +132,7 @@ export class Route implements RouteConfig {
'subtype',
'subsubtype',
];
private getSequenceFromSession(sess: Session) {
const hash = createHash('md5');
for (const key of Route.sessionKeys) {
......@@ -135,6 +143,7 @@ export class Route implements RouteConfig {
}
return parseInt(hash.digest('hex'), 16) % 4294967295;
}
getRelatedConnections(
sess: Session,
allConns = this.connections,
......@@ -159,6 +168,7 @@ export class Route implements RouteConfig {
return [];
}
}
addConnection(conn: WebSocket) {
this.connections.push(conn);
if (!this.bufferAppMessage) {
......@@ -170,12 +180,15 @@ export class Route implements RouteConfig {
this.send(message.data, message.session);
}
}
removeConnection(conn: WebSocket) {
remove(this.connections, conn);
}
addSendTask(task: SendTask) {
this.sendQueue.push(task);
}
fetchSendTask() {
if (!this.sendQueue.length) {
return;
......
......@@ -13,8 +13,10 @@ import { MessageService } from '../message/message.service';
@Injectable()
export class RouteService
extends ConsoleLogger
implements OnApplicationBootstrap {
implements OnApplicationBootstrap
{
private routes = new Map<string, Route>();
constructor(
config: ConfigService,
@InjectContextPlatform('onebot') private ctx: Context,
......
......@@ -2,8 +2,8 @@ import yaml from 'yaml';
import * as fs from 'fs';
import { RouteConfig } from '../route/Route';
import { Adapter } from 'koishi';
import { AdapterConfig } from '@koishijs/plugin-adapter-onebot/lib/utils';
import { BotConfig } from '@koishijs/plugin-adapter-onebot/lib/bot';
import { AdapterConfig } from '../adapter-onebot/utils';
import { BotConfig } from '../adapter-onebot';
export interface LbConfig {
host: string;
......
......@@ -14,6 +14,7 @@ export const BOOLS = [
'approve',
'block',
];
export function toBool(v: any) {
if (v === '0' || v === 'false') v = false;
return Boolean(v);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment