@dorakika/bot-qq 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -0
- package/src/adaptor/hono.ts +13 -0
- package/src/http/group.ts +13 -0
- package/src/http/users.ts +20 -0
- package/src/index.ts +131 -0
- package/src/types/index.ts +65 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dorakika/bot-qq",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "QQ Bot 开发 SDK",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./hono": "./src/adaptor/hono.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@stablelib/ed25519": "^2.0.2",
|
|
22
|
+
"hono": "^4.11.7"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "latest",
|
|
26
|
+
"typescript": "^5.7.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ApiPayload, QQBot } from "..";
|
|
3
|
+
|
|
4
|
+
/** 创建hono路由 */
|
|
5
|
+
export function createHonoAdaptor(client: QQBot) {
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
app.all('/', async c => {
|
|
8
|
+
const payload = await c.req.json<ApiPayload>();
|
|
9
|
+
const result = await client.onPayload(payload);
|
|
10
|
+
return c.json(result);
|
|
11
|
+
})
|
|
12
|
+
return app;
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QQBot } from "..";
|
|
2
|
+
|
|
3
|
+
export class GroupClient {
|
|
4
|
+
private client: QQBot;
|
|
5
|
+
constructor(client: QQBot) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 向用户发送单聊消息 */
|
|
10
|
+
sendMessage(group_openid: string, msg: any) {
|
|
11
|
+
return this.client.request(`/v2/groups/${group_openid}/messages`, msg);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { QQBot } from "..";
|
|
2
|
+
|
|
3
|
+
export class UserClient {
|
|
4
|
+
private client: QQBot;
|
|
5
|
+
constructor(client: QQBot) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 向用户发送单聊消息 */
|
|
10
|
+
sendMessage(openid: string, msg: any) {
|
|
11
|
+
return this.client.request(`/v2/users/${openid}/messages`, msg);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 撤回消息 */
|
|
15
|
+
recallMessage(openid: string, msgId: string) {
|
|
16
|
+
return this.client.request(`/v2/users/${openid}/messages/${msgId}`, {}, 'delete');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** ark */
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as ed from '@stablelib/ed25519';
|
|
2
|
+
import { UserClient } from './http/users';
|
|
3
|
+
import { GroupClient } from './http/group';
|
|
4
|
+
import type { ApiPayload, EventHandlers, EventType } from './types';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
|
|
7
|
+
export * from './types'
|
|
8
|
+
|
|
9
|
+
interface QQBotOptions {
|
|
10
|
+
appId: string;
|
|
11
|
+
clientSecret: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class QQBot {
|
|
15
|
+
readonly appId: string;
|
|
16
|
+
readonly clientSecret: string;
|
|
17
|
+
private eventBus = new EventBus();
|
|
18
|
+
private baseApiBot = 'https://bots.qq.com';
|
|
19
|
+
private baseApiSgroup = 'https://api.sgroup.qq.com';
|
|
20
|
+
constructor({ appId, clientSecret }: QQBotOptions) {
|
|
21
|
+
this.appId = appId;
|
|
22
|
+
this.clientSecret = clientSecret;
|
|
23
|
+
this.generateKeypair(clientSecret);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 密钥 */
|
|
27
|
+
private keyPair!: ed.KeyPair;
|
|
28
|
+
/** 生成密钥 */
|
|
29
|
+
private generateKeypair(seed: string) {
|
|
30
|
+
while (seed.length < 32) {
|
|
31
|
+
seed = seed.repeat(2);
|
|
32
|
+
}
|
|
33
|
+
seed = seed.slice(0, 32);
|
|
34
|
+
this.keyPair = ed.generateKeyPairFromSeed(Buffer.from(seed));
|
|
35
|
+
}
|
|
36
|
+
/** 签名 */
|
|
37
|
+
sign(data: any[]) {
|
|
38
|
+
const msg = Buffer.from([]);
|
|
39
|
+
data.forEach(d => {
|
|
40
|
+
msg.write(d);
|
|
41
|
+
});
|
|
42
|
+
const signature = ed.sign(this.keyPair.secretKey, msg);
|
|
43
|
+
return signature;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 输入输出payload */
|
|
47
|
+
async onPayload(payload: any) {
|
|
48
|
+
if (payload.op === 13) {
|
|
49
|
+
const signature = this.sign([payload.d.event_ts, payload.d.plain_token]);
|
|
50
|
+
return { signature, event_ts: payload.d.event_ts };
|
|
51
|
+
} else if (payload.op === 0) {
|
|
52
|
+
const eventType = payload.id.split(':')[0];
|
|
53
|
+
this.eventBus.emit(eventType, payload.d);
|
|
54
|
+
return {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** webhook事件监听 */
|
|
59
|
+
on<E extends EventType>(eventType: E, callback: EventHandlers[E]) {
|
|
60
|
+
this.eventBus.on(eventType, callback);
|
|
61
|
+
}
|
|
62
|
+
off<E extends EventType>(eventType: E, callback: EventHandlers[E]) {
|
|
63
|
+
this.eventBus.off(eventType, callback);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** token 过期时间 */
|
|
67
|
+
private expiredTime: number = 0;
|
|
68
|
+
/** accessToken */
|
|
69
|
+
private accessToken: string = "";
|
|
70
|
+
/** 刷新Token */
|
|
71
|
+
protected async refreshAccessToken() {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
// 未过期
|
|
74
|
+
if (now <= this.expiredTime) return;
|
|
75
|
+
|
|
76
|
+
const res = await fetch(this.baseApiBot + '/app/getAppAccessToken', {
|
|
77
|
+
method: 'post',
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
appId: this.appId,
|
|
80
|
+
clientSecret: this.clientSecret,
|
|
81
|
+
}),
|
|
82
|
+
headers: { 'Content-Type': 'application/json' }
|
|
83
|
+
}).then(res => res.json());
|
|
84
|
+
const { access_token, expires_in } = res as any;
|
|
85
|
+
this.accessToken = access_token;
|
|
86
|
+
this.expiredTime = now + expires_in * 1000;
|
|
87
|
+
console.log('refresh accessToken success, expired in', new Date(this.expiredTime).toLocaleString())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 带鉴权的请求 */
|
|
91
|
+
async request<Req = Record<string, unknown>, Res = unknown>(url: string, body: Req, method = 'post') {
|
|
92
|
+
await this.refreshAccessToken();
|
|
93
|
+
return fetch(this.baseApiSgroup + url, {
|
|
94
|
+
method,
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
headers: { 'Authorization': `QQBot ${this.accessToken}`, 'Content-Type': 'application/json' }
|
|
97
|
+
}).then(res => res.json()) as Promise<Res>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 用户相关 */
|
|
101
|
+
users = new UserClient(this);
|
|
102
|
+
/** 群聊相关 */
|
|
103
|
+
groups = new GroupClient(this);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class EventBus {
|
|
108
|
+
private listeners: { [key: string]: Function[] } = {};
|
|
109
|
+
|
|
110
|
+
// 注册事件监听器
|
|
111
|
+
on(event: string, listener: Function) {
|
|
112
|
+
if (!this.listeners[event]) {
|
|
113
|
+
this.listeners[event] = [];
|
|
114
|
+
}
|
|
115
|
+
this.listeners[event].push(listener);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 触发事件
|
|
119
|
+
emit(event: string, ...args: any[]) {
|
|
120
|
+
if (this.listeners[event]) {
|
|
121
|
+
this.listeners[event].forEach(listener => listener(...args));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 移除事件监听器
|
|
126
|
+
off(event: string, listener: Function) {
|
|
127
|
+
if (!this.listeners[event]) return;
|
|
128
|
+
|
|
129
|
+
this.listeners[event] = this.listeners[event].filter(l => l !== listener);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Payload类型 */
|
|
2
|
+
export type ApiPayload<OpCode extends number = number, Id = string, D = Record<string, unknown>> = {
|
|
3
|
+
/** 事件id */
|
|
4
|
+
id: Id;
|
|
5
|
+
/** opcode、连接维护 */
|
|
6
|
+
op: OpCode;
|
|
7
|
+
/** 下行消息序列号,标识消息唯一性 */
|
|
8
|
+
s: number;
|
|
9
|
+
/** 事件类型 */
|
|
10
|
+
t: string;
|
|
11
|
+
/** 事件内容 */
|
|
12
|
+
d: D;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export enum EventType {
|
|
16
|
+
/** 机器人加入群聊 */
|
|
17
|
+
GROUP_ADD_ROBOT = 'GROUP_ADD_ROBOT',
|
|
18
|
+
/** 用户单聊发消息给机器人 */
|
|
19
|
+
C2C_MESSAGE_CREATE = 'C2C_MESSAGE_CREATE',
|
|
20
|
+
/** 机器人群聊被@ */
|
|
21
|
+
GROUP_AT_MESSAGE_CREATE = 'GROUP_AT_MESSAGE_CREATE',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/** 用户单聊发送消息给机器人 */
|
|
26
|
+
export interface C2CMessageCreate {
|
|
27
|
+
/** 平台方消息ID,可以用于被动消息发送 */
|
|
28
|
+
id: string;
|
|
29
|
+
author: {
|
|
30
|
+
/** 用户openid */
|
|
31
|
+
user_openid: string;
|
|
32
|
+
};
|
|
33
|
+
content: string;
|
|
34
|
+
timestamp: string;
|
|
35
|
+
attachments: {
|
|
36
|
+
content_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'file' | 'video/mp4' | 'voice';
|
|
37
|
+
filename: string;
|
|
38
|
+
height: number;
|
|
39
|
+
width: number;
|
|
40
|
+
size: number;
|
|
41
|
+
url: string;
|
|
42
|
+
}[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 机器人群聊被@ */
|
|
46
|
+
export interface GroupAtMessageCreate {
|
|
47
|
+
id: string;
|
|
48
|
+
content: string;
|
|
49
|
+
timestamp: string;
|
|
50
|
+
author: {
|
|
51
|
+
id: string;
|
|
52
|
+
member_openid: string;
|
|
53
|
+
union_openid: string;
|
|
54
|
+
};
|
|
55
|
+
group_id: string;
|
|
56
|
+
group_openid: string;
|
|
57
|
+
message_scene: { source: 'default' };
|
|
58
|
+
message_type: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type EventHandlers = {
|
|
62
|
+
[EventType.C2C_MESSAGE_CREATE]: (data: C2CMessageCreate) => void;
|
|
63
|
+
[EventType.GROUP_AT_MESSAGE_CREATE]: (data: GroupAtMessageCreate) => void;
|
|
64
|
+
[key: string]: (data: any) => void;
|
|
65
|
+
}
|