初见端倪
This commit is contained in:
commit
217af5a40c
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
dist
|
||||||
|
/db
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 开发环境
|
||||||
|
|
||||||
|
* Nodejs `v20.18.0`
|
||||||
|
* npm `10.8.2`
|
||||||
|
* deno `1.41.3`
|
||||||
|
* (`release`, `x86_64-pc-windows-msvc`)
|
||||||
|
* v8 `12.3.219.9`
|
||||||
|
* typescript `5.3.3`
|
||||||
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "qqbot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Cloyir's QQBot",
|
||||||
|
"main": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "deno run -A src/index.ts",
|
||||||
|
"test": "/root/.deno/bin/deno run -A ./src/index.js",
|
||||||
|
"nodemon": "deno run -A --watch src/index.ts",
|
||||||
|
"build:linux": "deno compile --no-check -o dist/test --target x86_64-unknown-linux-gnu -A src/index.ts",
|
||||||
|
"build:win": "deno compile --no-check -o dist/test --target x86_64-pc-windows-msvc -A src/index.ts"
|
||||||
|
},
|
||||||
|
"author": "mine123456@foxmail.com",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@koa/router": "^13.1.0",
|
||||||
|
"@noble/ed25519": "^2.1.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"koa": "^2.15.3",
|
||||||
|
"koa-static": "^5.0.0",
|
||||||
|
"lowdb": "^7.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/koa": "^2.15.0",
|
||||||
|
"@types/koa__router": "^12.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/db/config.ts
Normal file
35
src/db/config.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { JSONFilePreset } from 'lowdb/node';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as process from 'node:process'
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
async function createConfigFile(dbDir: string, filename: string, defaultValue: string) {
|
||||||
|
const configFilePath = path.join(dbDir, filename);
|
||||||
|
if (fs.existsSync(configFilePath)) return;
|
||||||
|
await fs.promises.mkdir(dbDir, { recursive: true });
|
||||||
|
await fs.promises.writeFile(configFilePath, defaultValue);
|
||||||
|
console.log(chalk.red('已生成配置文件, 请手动补充机器人基本信息'));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataConfig = {
|
||||||
|
account: string,
|
||||||
|
appId: string,
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultDataConfig: DataConfig = {
|
||||||
|
account: "",
|
||||||
|
appId: "",
|
||||||
|
token: "",
|
||||||
|
secret: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function db_config_get(): Promise<DataConfig> {
|
||||||
|
await createConfigFile('db', 'config.json', JSON.stringify(DefaultDataConfig));
|
||||||
|
const config = await JSONFilePreset<DataConfig>('db/config.json', DefaultDataConfig);
|
||||||
|
config.read();
|
||||||
|
return config.data;
|
||||||
|
}
|
||||||
52
src/index.ts
Normal file
52
src/index.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { QQHook } from "./qqhook/index.ts";
|
||||||
|
import { db_config_get } from "./db/config.ts";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const config = await db_config_get();
|
||||||
|
|
||||||
|
type T = 'GROUP_AT_MESSAGE_CREATE' | 'MESSAGE_CREATE';
|
||||||
|
|
||||||
|
const app = new QQHook<T>({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3000,
|
||||||
|
qqbot: config,
|
||||||
|
log: console.log,
|
||||||
|
path: '/webhook/qqbot'
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('GROUP_AT_MESSAGE_CREATE', data => { // 用户在群聊@机器人发送消息
|
||||||
|
console.log(chalk.red('[还没打算做群聊]'));
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('MESSAGE_CREATE', data => { // 用户在文字子频道内发送的所有聊天消息(私域)
|
||||||
|
console.log(chalk.yellow('文字子频道:'), data.content);
|
||||||
|
app.axios.post(`/channels/${data.channel_id}/messages`, {
|
||||||
|
// content: `<@${data.author.id}> 😅`,
|
||||||
|
msg_id: data.id,
|
||||||
|
embed: {
|
||||||
|
"title": "标题",
|
||||||
|
"prompt": "消息通知",
|
||||||
|
"thumbnail": {
|
||||||
|
"url": "xxxxxx"
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "当前等级:黄金"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "之前等级:白银"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "😁继续努力"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(() => {
|
||||||
|
app.context.log(chalk.blue(`服务开启于http://${app.host}:${app.port}${app.path}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
17
src/qqhook/api.ts
Normal file
17
src/qqhook/api.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export async function getAppAccessToken(appId: string, clientSecret: string) {
|
||||||
|
const ans = await axios.post('https://bots.qq.com/app/getAppAccessToken', {
|
||||||
|
appId, clientSecret
|
||||||
|
}).then(res => {
|
||||||
|
if (res.status === 200) {
|
||||||
|
const { access_token, expires_in } = res.data;
|
||||||
|
return { access_token, expires_in };
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get access token');
|
||||||
|
});
|
||||||
|
return ans as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/qqhook/index.ts
Normal file
63
src/qqhook/index.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import Koa from "koa";
|
||||||
|
import { createRouter } from "./router/index.ts";
|
||||||
|
import axios, { type AxiosInstance } from "axios";
|
||||||
|
import { getAppAccessToken } from "./api.ts";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
export type QQHookConfigType = {
|
||||||
|
"account": string,
|
||||||
|
"appId": string,
|
||||||
|
"token": string,
|
||||||
|
"secret": string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QQHookConstructType = {
|
||||||
|
host?: string,
|
||||||
|
port?: number,
|
||||||
|
log?: (...data: any[]) => void,
|
||||||
|
qqbot: QQHookConfigType,
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QQHook<T extends string | symbol = string> extends Koa {
|
||||||
|
private _config: {
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
qqbot: QQHookConfigType,
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
private _qqAxios: AxiosInstance = axios.create({ baseURL: 'https://api.sgroup.qq.com' });
|
||||||
|
|
||||||
|
public get host() { return this._config.host; }
|
||||||
|
public get port() { return this._config.port; }
|
||||||
|
public get path() { return this._config.path; }
|
||||||
|
public get axios() { return this._qqAxios }
|
||||||
|
|
||||||
|
private async authFlushed() {
|
||||||
|
try {
|
||||||
|
const config = this._config.qqbot;
|
||||||
|
const auth = await getAppAccessToken(config.appId, config.secret);
|
||||||
|
this._qqAxios.defaults.headers.common['Authorization'] = `QQBot ${auth.access_token}`;
|
||||||
|
this.context.log(chalk.yellow('[鉴权: 成功]'), auth.expires_in);
|
||||||
|
|
||||||
|
setTimeout(() => this.authFlushed(), Number.parseInt(auth.expires_in) * 1000 + 5000);
|
||||||
|
} catch (error) {
|
||||||
|
this.context.log(chalk.red('[鉴权: 获取access_token失败]'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(options: QQHookConstructType) {
|
||||||
|
super();
|
||||||
|
const { host = '127.0.0.1', port = 3000, log = console.log, path = '/' } = options;
|
||||||
|
this._config = { host, port, qqbot: options.qqbot, path };
|
||||||
|
this.context.log = log;
|
||||||
|
|
||||||
|
const router = createRouter(this._config.path);
|
||||||
|
this.use(router.routes()).use(router.allowedMethods());
|
||||||
|
|
||||||
|
this.authFlushed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public listen(callback: any) { return super.listen(this._config.port, this._config.host, callback); }
|
||||||
|
public on(type: T, handler: (data: any) => void) { return super.on(type, handler); }
|
||||||
|
}
|
||||||
8
src/qqhook/router/index.ts
Normal file
8
src/qqhook/router/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import Router from "@koa/router";
|
||||||
|
import qqbotRouter from './qqbot.ts';
|
||||||
|
|
||||||
|
export function createRouter(path: string): Router {
|
||||||
|
const router = new Router();
|
||||||
|
router.use(path, qqbotRouter.routes(), qqbotRouter.allowedMethods());
|
||||||
|
return router;
|
||||||
|
}
|
||||||
60
src/qqhook/router/qqbot.ts
Normal file
60
src/qqhook/router/qqbot.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import Router from "@koa/router";
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { getSignature } from "../utils/sign.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post('/', async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
const content_type = ctx.req.headers["content-type"];
|
||||||
|
if (content_type !== 'application/json') {
|
||||||
|
ctx.log('POST:', chalk.red('receive data without content-type application/json'));
|
||||||
|
ctx.body = '错误: content-type 不为 application/json';
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let data = '';
|
||||||
|
ctx.req.addListener('data', async buf => { data += buf; });
|
||||||
|
ctx.req.addListener('end', async () => {
|
||||||
|
try {
|
||||||
|
ctx.state.data = JSON.parse(data);
|
||||||
|
resolve();
|
||||||
|
} catch (_) {
|
||||||
|
ctx.log('POST:', chalk.red('receive data without json format:'), data);
|
||||||
|
ctx.body = '错误: data parse error';
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await next();
|
||||||
|
} catch (error) {
|
||||||
|
ctx.log('POST:', chalk.red('data parse reject'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (ctx, next) => {
|
||||||
|
const data = ctx.state.data;
|
||||||
|
|
||||||
|
// 回调地址验证
|
||||||
|
if (data.op === 13) {
|
||||||
|
const { plain_token, event_ts } = data.d;
|
||||||
|
const res = {
|
||||||
|
"plain_token": plain_token,
|
||||||
|
"signature": await getSignature(ctx.config.secret, event_ts, plain_token)
|
||||||
|
}
|
||||||
|
ctx.log(chalk.blue('[收到签名校验请求]'));
|
||||||
|
ctx.body = res;
|
||||||
|
} else if (data.op === 0) {
|
||||||
|
ctx.body = { "op": 12 };
|
||||||
|
if (!ctx.app.emit(data.t, data.d)) {
|
||||||
|
ctx.log(chalk.red('[收到未处理类型数据]'), data.t);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.log(chalk.red('[收到未预期请求]'));
|
||||||
|
ctx.log(chalk.blue('headers:'), ctx.headers);
|
||||||
|
ctx.log(chalk.blue('data:'), data)
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router;
|
||||||
15
src/qqhook/utils/sign.ts
Normal file
15
src/qqhook/utils/sign.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import * as ed from '@noble/ed25519';
|
||||||
|
|
||||||
|
// https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||||
|
export async function getSignature(secret: string, event_ts: string, plain_token: string): Promise<string> {
|
||||||
|
const stringToUint8Array = (str: string) => new Uint8Array(str.split('').map(item => item.charCodeAt(0)));
|
||||||
|
|
||||||
|
secret = (secret.repeat(Math.ceil(secret.length / 32)).slice(0, 32));
|
||||||
|
const point = ed.ExtendedPoint.fromHex(stringToUint8Array(secret));
|
||||||
|
const privateKey = ed.utils.precompute(32, point).toRawBytes();
|
||||||
|
|
||||||
|
const content = stringToUint8Array(event_ts + plain_token)
|
||||||
|
const signature = await ed.signAsync(content, privateKey);
|
||||||
|
|
||||||
|
return Array.from(signature, item => item.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noImplicitAny": false
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
],
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user