Compare commits

...

38 Commits

Author SHA1 Message Date
linyuchen
1f657f3e84 Merge branch 'dev' 2024-02-26 23:24:03 +08:00
linyuchen
329dc433fb refactor: ffmpeg setting ui 2024-02-26 23:22:34 +08:00
linyuchen
90f64ab04e docs: update readme thanks 2024-02-26 22:30:27 +08:00
linyuchen
1583a36c2e Merge remote-tracking branch 'origin/main' 2024-02-26 22:26:38 +08:00
linyuchen
d70e95a451 chore: ver 3.8 2024-02-26 22:25:21 +08:00
linyuchen
c6256abcb2 fix: report image url filed 2024-02-26 22:21:20 +08:00
linyuchen
d57c14a8b9 feat: convert wav by ffmpeg 2024-02-26 22:19:37 +08:00
linyuchen
82268c619c Merge pull request #82 from disymayufei/patch-1 2024-02-26 17:37:53 +08:00
Disy
befdf8571a chore: Update README.md
补全README的一些信息
2024-02-26 16:04:57 +08:00
linyuchen
730294236c feat: convert wav by ffmpeg 2024-02-25 12:46:37 +08:00
linyuchen
d9d7e9e830 feat: auto delete receive file 2024-02-25 02:17:18 +08:00
linyuchen
6170307241 Merge remote-tracking branch 'origin/dev' into dev 2024-02-25 01:28:44 +08:00
linyuchen
138614cc4a feat: 好友请求时间,处理好友请求api 2024-02-25 01:28:15 +08:00
linyuchen
62870576a1 feat: 好友请求时间,处理还有请求api 2024-02-25 01:27:25 +08:00
linyuchen
cfb066971f feat: 上报支持CQCode 2024-02-24 18:27:49 +08:00
linyuchen
4941f0071a Merge remote-tracking branch 'origin/dev' into dev 2024-02-24 17:53:35 +08:00
linyuchen
6e61621f44 Merge pull request #75 from MisaLiu/feat_msg_format
增加对 `event.message_format` 和 CQ 码(仅接收)的支持
2024-02-24 17:47:44 +08:00
linyuchen
eb1a867a0e refactor: senderShowName of forward message 2024-02-24 17:24:30 +08:00
Misa Liu
f9ec7eddf2 feat: Support CQCode message format 2024-02-24 01:06:41 +08:00
Misa Liu
ffdec86209 feat: Add setting section of messagePostFormat 2024-02-24 00:40:45 +08:00
Misa Liu
66de0076d4 feat: Add message_format to message event 2024-02-23 21:48:36 +08:00
linyuchen
2eb0ad589a chore: ver 3.7.0 2024-02-23 19:57:20 +08:00
linyuchen
829aba18f8 feat: 管理员变动事件
feat: 加群事件
feat: 加群请求处理api
feat: 退群api
fix: 回复消息id改为string
2024-02-23 19:56:20 +08:00
linyuchen
67dfd7c22f Merge branch 'main' into dev 2024-02-23 14:06:36 +08:00
linyuchen
27745087ad Merge pull request #69 from MisaLiu/fix_app_version 2024-02-23 11:50:37 +08:00
linyuchen
4ba333b6f5 Merge pull request #68 from MisaLiu/fix_echo 2024-02-23 11:50:28 +08:00
Misa Liu
f4fe26fbe1 fix: Fix app_version in get_version_info 2024-02-23 10:33:15 +08:00
Misa Liu
30e488aeaf fix: Fix var type of echo 2024-02-23 10:22:42 +08:00
linyuchen
1f0dad786c feat: group admin change notice 2024-02-23 04:08:20 +08:00
linyuchen
8dfc71ab6d fix: message id int32 2024-02-22 23:05:07 +08:00
linyuchen
12d1f87ad5 fix: message id int32 2024-02-22 23:02:23 +08:00
linyuchen
b27dadbbca temp save 2024-02-22 22:55:52 +08:00
linyuchen
688624500f docs: tg 2024-02-22 17:35:14 +08:00
linyuchen
eefb919f0f docs: update readme 2024-02-21 22:21:32 +08:00
linyuchen
5044d24ee1 feat: go-cqhttp api get_stranger_info
feat: api send_like
fix: some image of message use base64 instead of http url
2024-02-21 22:19:02 +08:00
linyuchen
7664e746b4 feat: some go-cqhttp feature 2024-02-21 17:17:15 +08:00
linyuchen
ebea755731 perf: log long string 2024-02-21 16:43:10 +08:00
linyuchen
e4508ea5c7 feat: go-cqhttp api send_private_forward_msg & send_group_forward_msg 2024-02-21 16:36:40 +08:00
46 changed files with 2072 additions and 276 deletions

View File

@@ -17,7 +17,7 @@ jobs:
node-version: 18
- name: install dependenies
run: npm install
run: export ELECTRON_SKIP_BINARY_DOWNLOAD=1 && npm install
- name: build
run: npm run build

View File

@@ -1,7 +1,9 @@
# LLOneBot API
# LLOneBot API
LiteLoaderQQNT的OneBot11协议插件
TG群<https://t.me/+nLZEnpne-pQ1OWFl>
*注意:本文档对应的是 LiteLoader 1.0.0及以上版本如果你使用的是旧版本请切换到本项目v1分支查看文档*
*V3之后不再需要LLAPI*
@@ -31,11 +33,18 @@ LiteLoaderQQNT的OneBot11协议插件
- [x] 获取群列表
- [x] 获取群成员列表
- [x] 撤回消息
- [x] 处理添加好友请求
- [x] 处理加群请求
- [x] 退群
- [x] 上报好友消息
- [x] 上报添加好友请求
- [x] 上报群消息
- [x] 上报好友、群消息撤回
- [x] 上报加群请求
- [x] 上报群员人数变动(尚不支持识别群员人数变动原因)
消息格式支持:
- [x] cq码
- [x] 文字
- [x] 表情
- [x] 图片
@@ -58,12 +67,21 @@ LiteLoaderQQNT的OneBot11协议插件
- [x] get_group_member_list
- [x] get_group_member_info
- [x] get_friend_list
- [x] set_friend_add_request
- [x] get_msg
- [x] send_like
- [x] set_group_add_request
- [x] set_group_leave
- [x] get_version_info
- [x] get_status
- [x] can_send_image
- [x] can_send_record
支持的go-cqhtp api:
- [x] send_private_forward_msg
- [x] send_group_forward_msg
- [x] get_stranger_info
## 示例
![](doc/image/example.jpg)
@@ -91,13 +109,6 @@ LiteLoaderQQNT的OneBot11协议插件
</details>
<br/>
<details>
<summary>不支持cq码</summary>
<br/>
cq码已经过时了没有支持的打算(主要是我不用这玩意儿,加上我懒)
</details>
<br/>
<details>
<summary>QQ变得很卡</summary>
<br/>
@@ -110,7 +121,13 @@ LiteLoaderQQNT的OneBot11协议插件
- [x] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 支持正、反向websocket感谢@disymayufei的PR
- [x] 转发消息记录
- [ ] 好友点赞api
- [x] 好友点赞api
## onebot11文档
<https://11.onebot.dev/>
## 鸣谢
* [LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
* [LLAPI](https://github.com/Night-stars-1/LiteLoaderQQNT-Plugin-LLAPI)
* chronocat
* [koishi-plugin-adapter-onebot](https://github.com/koishijs/koishi-plugin-adapter-onebot)

View File

@@ -4,7 +4,7 @@
"name": "LLOneBot",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "3.4.0",
"version": "3.8.0",
"thumbnail": "./icon.png",
"authors": [
{

883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@
"main": "dist/main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"postinstall": "cross-env ELECTRON_SKIP_BINARY_DOWNLOAD=1 && npm install electron --no-save",
"build": "npm run build-main && npm run build-preload && npm run build-renderer",
"build-main": "webpack --config webpack.main.config.js",
"build-preload": "webpack --config webpack.preload.config.js",
@@ -19,21 +18,24 @@
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.2",
"json-bigint": "^1.0.0",
"music-metadata": "^8.1.4",
"silk-wasm": "^3.2.3",
"utf-8-validate": "^6.0.3",
"uuid": "^9.0.1",
"ws": "^8.16.0"
},
"devDependencies": {
"@babel/preset-env": "^7.23.2",
"@types/express": "^4.17.20",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^20.11.19",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"cross-env": "^7.0.3",
"electron": "^29.0.1",
"ts-loader": "^9.5.0",
"typescript": "^5.2.2",
"webpack": "^5.89.0",

View File

@@ -2,4 +2,6 @@ import {Peer} from "../ntqqapi/ntcall";
export const CHANNEL_GET_CONFIG = "llonebot_get_config"
export const CHANNEL_SET_CONFIG = "llonebot_set_config"
export const CHANNEL_LOG = "llonebot_log"
export const CHANNEL_LOG = "llonebot_log"
export const CHANNEL_ERROR = "llonebot_error"
export const CHANNEL_SELECT_FILE = "llonebot_select_ffmpeg"

View File

@@ -2,6 +2,8 @@ import fs from "fs";
import {Config, OB11Config} from "./types";
import {mergeNewProperties} from "./utils";
export const HOOK_LOG = false;
export class ConfigUtil {
private readonly configPath: string;
private config: Config | null = null;
@@ -27,7 +29,8 @@ export class ConfigUtil {
enableHttp: true,
enableHttpPost: true,
enableWs: true,
enableWsReverse: false
enableWsReverse: false,
messagePostFormat: "array",
}
let defaultConfig: Config = {
ob11: ob11Default,
@@ -36,7 +39,8 @@ export class ConfigUtil {
enableLocalFile2Url: false,
debug: false,
log: false,
reportSelfMessage: false
reportSelfMessage: false,
autoDeleteFile: false,
};
if (!fs.existsSync(this.configPath)) {

View File

@@ -1,15 +1,22 @@
import {NTQQApi} from '../ntqqapi/ntcall';
import {Friend, Group, GroupMember, RawMessage, SelfInfo} from "../ntqqapi/types";
import {Friend, FriendRequest, Group, GroupMember, GroupNotify, RawMessage, SelfInfo} from "../ntqqapi/types";
import {LLOneBotError} from "./types";
export let groups: Group[] = []
export let friends: Friend[] = []
export let msgHistory: Record<string, RawMessage> = {} // msgId: RawMessage
export const version = "3.8.0"
export let groupNotifies: Map<string, GroupNotify> = new Map<string, GroupNotify>();
export let friendRequests: Map<number, FriendRequest> = new Map<number, FriendRequest>();
export let llonebotError: LLOneBotError = {
ffmpegError: "",
otherError: ""
}
let globalMsgId = Math.floor(Date.now() / 1000);
let globalMsgId = Date.now()
export function addHistoryMsg(msg: RawMessage): boolean{
export function addHistoryMsg(msg: RawMessage): boolean {
let existMsg = msgHistory[msg.msgId]
if (existMsg){
if (existMsg) {
Object.assign(existMsg, msg)
msg.msgShortId = existMsg.msgShortId;
return false
@@ -19,7 +26,7 @@ export function addHistoryMsg(msg: RawMessage): boolean{
return true
}
export function getHistoryMsgByShortId(shortId: number | string){
export function getHistoryMsgByShortId(shortId: number | string) {
// log("getHistoryMsgByShortId", shortId, Object.values(msgHistory).map(m=>m.msgShortId))
return Object.values(msgHistory).find(msg => msg.msgShortId.toString() == shortId.toString())
}
@@ -43,20 +50,19 @@ export async function getGroup(qq: string): Promise<Group | undefined> {
return group
}
export async function getGroupMember(groupQQ: string, memberQQ: string=null, memberUid: string=null) {
export async function getGroupMember(groupQQ: string, memberQQ: string, memberUid: string = null) {
const group = await getGroup(groupQQ)
if (group) {
let filterFunc: (member: GroupMember) => boolean
if (memberQQ){
if (memberQQ) {
filterFunc = member => member.uin === memberQQ
}
else if (memberUid){
} else if (memberUid) {
filterFunc = member => member.uid === memberUid
}
let member = group.members?.find(filterFunc)
if (!member){
if (!member) {
const _members = await NTQQApi.getGroupMembers(groupQQ)
if (_members.length){
if (_members.length) {
group.members = _members
}
member = group.members?.find(filterFunc)
@@ -77,7 +83,7 @@ export function getHistoryMsgBySeq(seq: string) {
}
export let uidMaps:Record<string, string> = {} // 一串加密的字符串(uid) -> qq号
export let uidMaps: Record<string, string> = {} // 一串加密的字符串(uid) -> qq号
export function getUidByUin(uin: string) {
for (const key in uidMaps) {
@@ -87,4 +93,3 @@ export function getUidByUin(uin: string) {
}
}
export const version = "v3.4.0"

View File

@@ -91,6 +91,7 @@ export abstract class HttpServerBase {
if (method == "get"){
payload = req.query
}
log("收到http请求", url, payload);
try{
res.send(await handler(res, payload))
}catch (e) {

View File

@@ -7,6 +7,7 @@ export interface OB11Config {
enableHttpPost?: boolean
enableWs?: boolean
enableWsReverse?: boolean
messagePostFormat?: 'array' | 'string'
}
export interface Config {
@@ -17,4 +18,11 @@ export interface Config {
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
autoDeleteFile?: boolean
ffmpeg?: string // ffmpeg路径
}
export type LLOneBotError = {
ffmpegError?: string
otherError?: string
}

View File

@@ -5,6 +5,8 @@ import util from "util";
import {encode, getDuration} from "silk-wasm";
import fs from 'fs';
import {v4 as uuidv4} from "uuid";
import {exec} from "node:child_process";
import ffmpeg from "fluent-ffmpeg"
export const CONFIG_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
@@ -13,6 +15,23 @@ export function getConfigUtil() {
return new ConfigUtil(configFilePath)
}
function truncateString(obj: any, maxLength = 500) {
if (obj !== null && typeof obj === 'object') {
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'string') {
// 如果是字符串且超过指定长度,则截断
if (obj[key].length > maxLength) {
obj[key] = obj[key].substring(0, maxLength) + '...';
}
} else if (typeof obj[key] === 'object') {
// 如果是对象或数组,则递归调用
truncateString(obj[key], maxLength);
}
});
}
return obj;
}
export function log(...msg: any[]) {
if (!getConfigUtil().getConfig().log) {
return
@@ -28,7 +47,8 @@ export function log(...msg: any[]) {
for (let msgItem of msg) {
// 判断是否是对象
if (typeof msgItem === "object") {
logMsg += JSON.stringify(msgItem) + " ";
let obj = JSON.parse(JSON.stringify(msgItem));
logMsg += JSON.stringify(truncateString(obj)) + " ";
continue;
}
logMsg += msgItem + " ";
@@ -118,7 +138,23 @@ export function mergeNewProperties(newObj: any, oldObj: any) {
});
}
export function checkFFMPEG(newPath: string=null): Promise<boolean> {
return new Promise((resolve, reject) => {
const ffmpegPath = newPath || 'ffmpeg'
exec(ffmpegPath + ' -version', (error, stdout, stderr) => {
if (error) {
log('ffmpeg is not installed or not found in PATH:', error);
resolve(false)
}
log('ffmpeg is installed. Version info:', stdout);
resolve(true);
});
});
}
export async function encodeSilk(filePath: string) {
const fsp = require("fs").promises
function getFileHeader(filePath: string) {
// 定义要读取的字节数
const bytesToRead = 7;
@@ -136,6 +172,35 @@ export async function encodeSilk(filePath: string) {
}
}
function isWavFile(filePath: string) {
return new Promise((resolve, reject) => {
fs.open(filePath, 'r', (err, fd) => {
if (err) {
reject(err);
return;
}
// 读取前12个字节
const buffer = Buffer.alloc(12);
fs.read(fd, buffer, 0, 12, 0, (err, bytesRead, buffer) => {
if (err) {
reject(err);
return;
}
fs.close(fd, (err) => {
if (err) {
reject(err);
return;
}
// 检查RIFF头和WAVE格式标识
const isRIFF = buffer.toString('utf8', 0, 4) === 'RIFF';
const isWAVE = buffer.toString('utf8', 8, 12) === 'WAVE';
resolve(isRIFF && isWAVE);
});
});
});
});
}
async function getAudioSampleRate(filePath: string) {
try {
const mm = await import('music-metadata');
@@ -150,29 +215,59 @@ export async function encodeSilk(filePath: string) {
try {
const fileName = path.basename(filePath);
const pcm = fs.readFileSync(filePath);
const pttPath = path.join(CONFIG_DIR, uuidv4());
if (getFileHeader(filePath) !== "02232153494c4b") {
log(`语音文件${filePath}需要转换`)
const sampleRate = await getAudioSampleRate(filePath) || 44100;
const silk = await encode(pcm, sampleRate);
fs.writeFileSync(pttPath, silk.data);
log(`语音文件${filePath}转换成功!`)
return {
converted: true,
path: pttPath,
duration: silk.duration,
};
} else {
const duration = getDuration(pcm);
return {
converted: false,
path: filePath,
duration: duration,
};
const isWav = await isWavFile(filePath);
if (!isWav) {
log(`语音文件${filePath}正在转换成wav`)
// let voiceData = await fsp.readFile(filePath)
const wavPath = pttPath + ".wav"
await new Promise((resolve, reject) => {
const ffmpegPath = getConfigUtil().getConfig().ffmpeg;
if (ffmpegPath){
ffmpeg.setFfmpegPath(ffmpegPath);
}
ffmpeg(filePath).toFormat("wav").on('end', function () {
log('wav转换完成');
})
.on('error', function (err) {
log(`wav转换出错: `, err.message,);
reject(err);
})
.save(wavPath)
.on("end", ()=>{
filePath = wavPath
resolve(wavPath);
});
})
const sampleRate = await getAudioSampleRate(filePath) || 44100;
const pcm = fs.readFileSync(filePath);
const silk = await encode(pcm, sampleRate);
fs.writeFileSync(pttPath, silk.data);
fs.unlink(wavPath, (err) => {});
log(`语音文件${filePath}转换成功!`, pttPath)
return {
converted: true,
path: pttPath,
duration: silk.duration,
};
} else {
const pcm = fs.readFileSync(filePath);
const duration = getDuration(pcm);
return {
converted: false,
path: filePath,
duration: duration,
};
}
}
} catch (error) {
log("convert silk failed", error.stack);
return {};
}
}
export function isNull(value: any) {
return value === undefined || value === null;
}

2
src/global.d.ts vendored
View File

@@ -4,7 +4,7 @@ import {LLOneBot} from "./preload";
declare global {
interface Window {
llonebot: typeof llonebot;
llonebot: LLOneBot;
LiteLoader: any;
}
}

View File

@@ -1,21 +1,48 @@
// 运行在 Electron 主进程 下的插件入口
import {BrowserWindow, ipcMain} from 'electron';
import {BrowserWindow, dialog, ipcMain} from 'electron';
import fs from 'fs';
import {Config} from "../common/types";
import {CHANNEL_GET_CONFIG, CHANNEL_LOG, CHANNEL_SET_CONFIG,} from "../common/channels";
import {
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
} from "../common/channels";
import {ob11WebsocketServer} from "../onebot11/server/ws/WebsocketServer";
import {CONFIG_DIR, getConfigUtil, log} from "../common/utils";
import {addHistoryMsg, getGroupMember, msgHistory, selfInfo} from "../common/data";
import {checkFFMPEG, CONFIG_DIR, getConfigUtil, log} from "../common/utils";
import {
addHistoryMsg,
friendRequests,
getGroup,
getGroupMember,
groupNotifies,
llonebotError,
msgHistory,
selfInfo
} from "../common/data";
import {hookNTQQApiCall, hookNTQQApiReceive, ReceiveCmd, registerReceiveHook} from "../ntqqapi/hook";
import {OB11Constructor} from "../onebot11/constructor";
import {NTQQApi} from "../ntqqapi/ntcall";
import {ChatType, RawMessage} from "../ntqqapi/types";
import {
ChatType,
FriendRequestNotify,
GroupMember,
GroupNotifies,
GroupNotifyTypes,
RawMessage
} from "../ntqqapi/types";
import {ob11HTTPServer} from "../onebot11/server/http";
import {OB11FriendRecallNoticeEvent} from "../onebot11/event/notice/OB11FriendRecallNoticeEvent";
import {OB11GroupRecallNoticeEvent} from "../onebot11/event/notice/OB11GroupRecallNoticeEvent";
import {postEvent} from "../onebot11/server/postevent";
import {postOB11Event} from "../onebot11/server/postOB11Event";
import {ob11ReverseWebsockets} from "../onebot11/server/ws/ReverseWebsocket";
import {OB11GroupAdminNoticeEvent} from "../onebot11/event/notice/OB11GroupAdminNoticeEvent";
import {OB11GroupDecreaseEvent} from "../onebot11/event/notice/OB11GroupDecreaseEvent";
import {OB11GroupRequestEvent} from "../onebot11/event/request/OB11GroupRequest";
import {OB11FriendRequestEvent} from "../onebot11/event/request/OB11FriendRequest";
import * as path from "node:path";
let running = false;
@@ -24,13 +51,48 @@ let running = false;
// 加载插件时触发
function onLoad() {
log("llonebot main onLoad");
ipcMain.handle(CHANNEL_SELECT_FILE, async (event, arg) => {
const selectPath = new Promise<string>((resolve, reject) => {
dialog
.showOpenDialog({
title: "请选择ffmpeg",
properties: ["openFile"],
buttonLabel: "确定",
})
.then((result) => {
log("选择文件", result);
if (!result.canceled) {
const _selectPath = path.join(result.filePaths[0]);
resolve(_selectPath);
// let config = getConfigUtil().getConfig()
// config.ffmpeg = path.join(result.filePaths[0]);
// getConfigUtil().setConfig(config);
}
resolve("")
})
.catch((err) => {
reject(err);
});
})
try {
return await selectPath;
} catch (e) {
log("选择文件出错", e)
return ""
}
})
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, {recursive: true});
}
ipcMain.handle(CHANNEL_GET_CONFIG, (event: any, arg: any) => {
return getConfigUtil().getConfig()
ipcMain.handle(CHANNEL_ERROR, (event, arg) => {
return llonebotError;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event: any, arg: Config) => {
ipcMain.handle(CHANNEL_GET_CONFIG, async (event, arg) => {
const config = getConfigUtil().getConfig()
return config;
})
ipcMain.on(CHANNEL_SET_CONFIG, (event, arg: Config) => {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(arg)
if (arg.ob11.httpPort != oldConfig.ob11.httpPort && arg.ob11.enableHttp) {
@@ -75,13 +137,20 @@ function onLoad() {
}
}
}
// 检查ffmpeg
if (arg.ffmpeg) {
checkFFMPEG(arg.ffmpeg).then(success => {
llonebotError.ffmpegError = ''
})
}
})
ipcMain.on(CHANNEL_LOG, (event: any, arg: any) => {
ipcMain.on(CHANNEL_LOG, (event, arg) => {
log(arg);
})
function postReceiveMsg(msgList: RawMessage[]) {
const {debug, reportSelfMessage} = getConfigUtil().getConfig();
for (let message of msgList) {
@@ -94,17 +163,17 @@ function onLoad() {
if (debug) {
msg.raw = message;
}
if (msg.user_id.toString() == selfInfo.uin && !reportSelfMessage) {
const isSelfMsg = msg.user_id.toString() == selfInfo.uin
if (isSelfMsg && !reportSelfMessage) {
return
}
postEvent(msg);
postOB11Event(msg);
// log("post msg", msg)
}).catch(e => log("constructMessage error: ", e.toString()));
}
}
async function start() {
async function startReceiveHook() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
try {
postReceiveMsg(payload.msgList);
@@ -123,7 +192,7 @@ function onLoad() {
}
if (message.chatType == ChatType.friend) {
const friendRecallEvent = new OB11FriendRecallNoticeEvent(parseInt(message.senderUin), oriMessage.msgShortId);
postEvent(friendRecallEvent);
postOB11Event(friendRecallEvent);
} else if (message.chatType == ChatType.group) {
let operatorId = message.senderUin
for (const element of message.elements) {
@@ -138,7 +207,7 @@ function onLoad() {
oriMessage.msgShortId
)
postEvent(groupRecallEvent);
postOB11Event(groupRecallEvent);
}
continue
}
@@ -157,8 +226,109 @@ function onLoad() {
log("report self message error: ", e.toString());
}
})
registerReceiveHook<{
"doubt": boolean,
"oldestUnreadSeq": string,
"unreadCount": number
}>(ReceiveCmd.UNREAD_GROUP_NOTIFY, async (payload) => {
if (payload.unreadCount) {
log("开始获取群通知详情")
let notify: GroupNotifies;
try {
notify = await NTQQApi.getGroupNotifies();
} catch (e) {
// log("获取群通知详情失败", e);
return
}
const notifies = notify.notifies.slice(0, payload.unreadCount)
log("获取群通知详情完成", notifies, payload);
try {
for (const notify of notifies) {
const notifyTime = parseInt(notify.seq) / 1000
log(`加群通知时间${notifyTime}`, `LLOneBot启动时间${startTime}`);
if (notifyTime < startTime) {
continue;
}
const member1 = await getGroupMember(notify.group.groupCode, null, notify.user1.uid);
let member2: GroupMember;
if (notify.user2.uid) {
member2 = await getGroupMember(notify.group.groupCode, null, notify.user2.uid);
}
if ([GroupNotifyTypes.ADMIN_SET, GroupNotifyTypes.ADMIN_UNSET].includes(notify.type)) {
log("有管理员变动通知");
let groupAdminNoticeEvent = new OB11GroupAdminNoticeEvent()
groupAdminNoticeEvent.group_id = parseInt(notify.group.groupCode);
log("开始获取变动的管理员")
if (member1) {
log("变动管理员获取成功")
groupAdminNoticeEvent.user_id = parseInt(member1.uin);
groupAdminNoticeEvent.sub_type = notify.type == GroupNotifyTypes.ADMIN_UNSET ? "unset" : "set";
postOB11Event(groupAdminNoticeEvent, true);
} else {
log("获取群通知的成员信息失败", notify, getGroup(notify.group.groupCode));
}
} else if (notify.type == GroupNotifyTypes.MEMBER_EXIT) {
log("有成员退出通知");
let groupDecreaseEvent = new OB11GroupDecreaseEvent(parseInt(notify.group.groupCode), parseInt(member1.uin))
// postEvent(groupDecreaseEvent, true);
} else if ([GroupNotifyTypes.JOIN_REQUEST].includes(notify.type)) {
log("有加群请求");
groupNotifies[notify.seq] = notify;
let groupRequestEvent = new OB11GroupRequestEvent();
groupRequestEvent.group_id = parseInt(notify.group.groupCode);
let requestQQ = ""
try {
requestQQ = (await NTQQApi.getUserDetailInfo(notify.user1.uid)).uin;
} catch (e) {
log("获取加群人QQ号失败", e)
}
groupRequestEvent.user_id = parseInt(requestQQ) || 0;
groupRequestEvent.sub_type = "add"
groupRequestEvent.comment = notify.postscript;
groupRequestEvent.flag = notify.seq;
postOB11Event(groupRequestEvent);
}
}
} catch (e) {
log("解析群通知失败", e.stack);
}
}
})
registerReceiveHook<FriendRequestNotify>(ReceiveCmd.FRIEND_REQUEST, async (payload) => {
for (const req of payload.data.buddyReqs) {
if (req.isUnread && !friendRequests[req.sourceId] && (parseInt(req.reqTime) > startTime / 1000)) {
friendRequests[req.sourceId] = req;
log("有新的好友请求", req);
let friendRequestEvent = new OB11FriendRequestEvent();
try {
let requester = await NTQQApi.getUserDetailInfo(req.friendUid)
friendRequestEvent.user_id = parseInt(requester.uin);
} catch (e) {
log("获取加好友者QQ号失败", e);
}
friendRequestEvent.flag = req.sourceId.toString();
friendRequestEvent.comment = req.extWords;
postOB11Event(friendRequestEvent);
}
}
})
}
let startTime = 0;
async function start() {
startTime = Date.now();
startReceiveHook().then();
NTQQApi.getGroups(true).then()
const config = getConfigUtil().getConfig()
// 检查ffmpeg
checkFFMPEG(config.ffmpeg).then(exist => {
if (!exist) {
llonebotError.ffmpegError = `没有找到ffmpeg,音频只能发送wav和silk`
}
})
if (config.ob11.enableHttp) {
try {
ob11HTTPServer.start(config.ob11.httpPort)
@@ -166,16 +336,17 @@ function onLoad() {
log("http server start failed", e);
}
}
if (config.ob11.enableWs){
if (config.ob11.enableWs) {
ob11WebsocketServer.start(config.ob11.wsPort);
}
if (config.ob11.enableWsReverse){
if (config.ob11.enableWsReverse) {
ob11ReverseWebsockets.start();
}
log("LLOneBot start")
}
let getSelfNickCount = 0;
const init = async () => {
try {
const _ = await NTQQApi.getSelfInfo();
@@ -192,7 +363,10 @@ function onLoad() {
if (userInfo) {
selfInfo.nick = userInfo.nick;
} else {
return setTimeout(init, 1000);
getSelfNickCount++;
if (getSelfNickCount < 10) {
return setTimeout(init, 1000);
}
}
} catch (e) {
log("get self nickname failed", e.toString());

View File

@@ -8,7 +8,7 @@ import {
SendTextElement
} from "./types";
import {NTQQApi} from "./ntcall";
import {encodeSilk, log} from "../common/utils";
import {encodeSilk} from "../common/utils";
import fs from "fs";
@@ -83,7 +83,7 @@ export class SendMsgElementConstructor {
static async ptt(pttPath: string): Promise<SendPttElement> {
const {converted, path: silkPath, duration} = await encodeSilk(pttPath);
// log("生成语音", silkPath, duration);
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath);
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(silkPath, ElementType.PTT);
if (converted){
fs.unlink(silkPath, ()=>{});
}

View File

@@ -1,12 +1,14 @@
import {BrowserWindow} from 'electron';
import {log, sleep} from "../common/utils";
import {getConfigUtil, log, sleep} from "../common/utils";
import {NTQQApi, NTQQApiClass, sendMessagePool} from "./ntcall";
import {Group, RawMessage, User} from "./types";
import {addHistoryMsg, friends, groups, msgHistory} from "../common/data";
import {OB11GroupDecreaseEvent} from "../onebot11/event/notice/OB11GroupDecreaseEvent";
import {OB11GroupIncreaseEvent} from "../onebot11/event/notice/OB11GroupIncreaseEvent";
import {v4 as uuidv4} from "uuid"
import {postEvent} from "../onebot11/server/postevent";
import {postOB11Event} from "../onebot11/server/postOB11Event";
import {HOOK_LOG} from "../common/config";
import fs from "fs";
export let hookApiCallbacks: Record<string, (apiReturn: any) => void> = {}
@@ -15,9 +17,14 @@ export enum ReceiveCmd {
NEW_MSG = "nodeIKernelMsgListener/onRecvMsg",
SELF_SEND_MSG = "nodeIKernelMsgListener/onAddSendMsg",
USER_INFO = "nodeIKernelProfileListener/onProfileSimpleChanged",
USER_DETAIL_INFO = "nodeIKernelProfileListener/onProfileDetailInfoChanged",
GROUPS = "nodeIKernelGroupListener/onGroupListUpdate",
GROUPS_UNIX = "onGroupListUpdate",
FRIENDS = "onBuddyListChange"
FRIENDS = "onBuddyListChange",
MEDIA_DOWNLOAD_COMPLETE = "nodeIKernelMsgListener/onRichMediaDownloadComplete",
UNREAD_GROUP_NOTIFY = "nodeIKernelGroupListener/onGroupNotifiesUnreadCountUpdated",
GROUP_NOTIFY = "nodeIKernelGroupListener/onGroupSingleScreenNotifies",
FRIEND_REQUEST = "nodeIKernelBuddyListener/onBuddyReqChange"
}
interface NTQQApiReturnData<PayloadType = unknown> extends Array<any> {
@@ -43,7 +50,7 @@ let receiveHooks: Array<{
export function hookNTQQApiReceive(window: BrowserWindow) {
const originalSend = window.webContents.send;
const patchSend = (channel: string, ...args: NTQQApiReturnData) => {
// log(`received ntqq api message: ${channel}`, JSON.stringify(args))
HOOK_LOG && log(`received ntqq api message: ${channel}`, JSON.stringify(args))
if (args?.[1] instanceof Array) {
for (let receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName;
@@ -87,15 +94,15 @@ export function hookNTQQApiCall(window: BrowserWindow) {
const proxyIpcMsg = new Proxy(ipc_message_proxy, {
apply(target, thisArg, args) {
log("call NTQQ api", thisArg, args);
HOOK_LOG && log("call NTQQ api", thisArg, args);
return target.apply(thisArg, args);
},
});
// if (webContents._events["-ipc-message"]?.[0]) {
// webContents._events["-ipc-message"][0] = proxyIpcMsg;
// } else {
// webContents._events["-ipc-message"] = proxyIpcMsg;
// }
if (webContents._events["-ipc-message"]?.[0]) {
webContents._events["-ipc-message"][0] = proxyIpcMsg;
} else {
webContents._events["-ipc-message"] = proxyIpcMsg;
}
}
export function registerReceiveHook<PayloadType>(method: ReceiveCmd, hookFunc: (payload: PayloadType) => void): string {
@@ -118,8 +125,7 @@ async function updateGroups(_groups: Group[], needUpdate: boolean = true) {
let existGroup = groups.find(g => g.groupCode == group.groupCode);
if (existGroup) {
Object.assign(existGroup, group);
}
else {
} else {
groups.push(group);
existGroup = group;
}
@@ -155,13 +161,12 @@ async function processGroupEvent(payload) {
for (const member of oldMembers) {
if (!newMembersSet.has(member.uin)) {
postEvent(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)));
postOB11Event(new OB11GroupDecreaseEvent(group.groupCode, parseInt(member.uin)));
break;
}
}
}
else if (existGroup.memberCount < group.memberCount) {
} else if (existGroup.memberCount < group.memberCount) {
const oldMembers = existGroup.members;
const oldMembersSet = new Set<string>();
for (const member of oldMembers) {
@@ -174,7 +179,7 @@ async function processGroupEvent(payload) {
group.members = newMembers;
for (const member of newMembers) {
if (!oldMembersSet.has(member.uin)) {
postEvent(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)));
postOB11Event(new OB11GroupIncreaseEvent(group.groupCode, parseInt(member.uin)));
break;
}
}
@@ -183,8 +188,7 @@ async function processGroupEvent(payload) {
}
updateGroups(newGroupList, false).then();
}
catch (e) {
} catch (e) {
updateGroups(payload.groupList).then();
console.log(e);
}
@@ -193,8 +197,7 @@ async function processGroupEvent(payload) {
registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUPS, (payload) => {
if (payload.updateType != 2) {
updateGroups(payload.groupList).then();
}
else {
} else {
if (process.platform == "win32") {
processGroupEvent(payload).then();
}
@@ -203,13 +206,13 @@ registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUP
registerReceiveHook<{ groupList: Group[], updateType: number }>(ReceiveCmd.GROUPS_UNIX, (payload) => {
if (payload.updateType != 2) {
updateGroups(payload.groupList).then();
}
else {
} else {
if (process.platform != "win32") {
processGroupEvent(payload).then();
}
}
})
registerReceiveHook<{
data: { categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }[]
}>(ReceiveCmd.FRIENDS, payload => {
@@ -227,9 +230,32 @@ registerReceiveHook<{
})
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
const {autoDeleteFile} = getConfigUtil().getConfig();
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message)
addHistoryMsg(message)
// 清理文件
if (!autoDeleteFile) {
continue
}
for (const msgElement of message.elements) {
setTimeout(() => {
const picPath = msgElement.picElement?.sourcePath;
const pttPath = msgElement.pttElement?.filePath;
const pathList = [picPath, pttPath];
if (msgElement.picElement){
pathList.push(...Object.values(msgElement.picElement.thumbPath));
}
// log("需要清理的文件", pathList);
for (const path of pathList) {
if (path) {
fs.unlink(picPath, () => {
log("删除文件成功", path)
});
}
}
}, 60 * 1000)
}
}
const msgIds = Object.keys(msgHistory);
if (msgIds.length > 30000) {
@@ -250,3 +276,4 @@ registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, ({msgRe
}
}
})

View File

@@ -1,9 +1,23 @@
import {ipcMain} from "electron";
import {hookApiCallbacks, ReceiveCmd, registerReceiveHook, removeReceiveHook} from "./hook";
import {log} from "../common/utils";
import {ChatType, Friend, Group, GroupMember, RawMessage, SelfInfo, SendMessageElement, User} from "./types";
import {
ChatType,
ElementType,
Friend,
FriendRequest,
Group,
GroupMember,
GroupNotifies,
GroupNotify,
GroupRequestOperateTypes,
RawMessage,
SelfInfo,
SendMessageElement,
User
} from "./types";
import * as fs from "fs";
import {addHistoryMsg, msgHistory, selfInfo, uidMaps} from "../common/data";
import {addHistoryMsg, friendRequests, groupNotifies, msgHistory, selfInfo} from "../common/data";
import {v4 as uuidv4} from "uuid"
interface IPCReceiveEvent {
@@ -32,6 +46,7 @@ export enum NTQQApiMethod {
GROUP_MEMBER_SCENE = "nodeIKernelGroupService/createMemberListScene",
GROUP_MEMBERS = "nodeIKernelGroupService/getNextMemberList",
USER_INFO = "nodeIKernelProfileService/getUserSimpleInfo",
USER_DETAIL_INFO = "nodeIKernelProfileService/getUserDetailInfo",
FILE_TYPE = "getFileType",
FILE_MD5 = "getFileMd5",
FILE_COPY = "copyFile",
@@ -41,7 +56,12 @@ export enum NTQQApiMethod {
RECALL_MSG = "nodeIKernelMsgService/recallMsg",
SEND_MSG = "nodeIKernelMsgService/sendMsg",
DOWNLOAD_MEDIA = "nodeIKernelMsgService/downloadRichMedia",
MULTI_FORWARD_MSG = "nodeIKernelMsgService/multiForwardMsgWithComment" // 合并转发
MULTI_FORWARD_MSG = "nodeIKernelMsgService/multiForwardMsgWithComment", // 合并转发
GET_GROUP_NOTICE = "nodeIKernelGroupService/getSingleScreenNotifies",
HANDLE_GROUP_REQUEST = "nodeIKernelGroupService/operateSysNotify",
QUIT_GROUP = "nodeIKernelGroupService/quitGroup",
// READ_FRIEND_REQUEST = "nodeIKernelBuddyListener/onDoubtBuddyReqUnreadNumChange"
HANDLE_FRIEND_REQUEST = "nodeIKernelBuddyService/approvalFriendRequest",
}
enum NTQQApiChannel {
@@ -56,35 +76,40 @@ export interface Peer {
guildId?: ""
}
enum CallBackType {
UUID,
METHOD
}
interface NTQQApiParams {
methodName: NTQQApiMethod,
methodName: NTQQApiMethod | string,
className?: NTQQApiClass,
channel?: NTQQApiChannel,
classNameIsRegister?: boolean
args?: unknown[],
cbCmd?: ReceiveCmd | null
cbCmd?: ReceiveCmd | null,
cmdCB?: (payload: any) => boolean;
afterFirstCmd?: boolean, // 是否在methodName调用完之后再去hook cbCmd
timeoutSecond?: number,
}
function callNTQQApi<ReturnType>(params: NTQQApiParams) {
let {
className, methodName, channel, args,
cbCmd, timeoutSecond: timeout
cbCmd, timeoutSecond: timeout,
classNameIsRegister, cmdCB, afterFirstCmd
} = params;
className = className ?? NTQQApiClass.NT_API;
channel = channel ?? NTQQApiChannel.IPC_UP_2;
args = args ?? [];
timeout = timeout ?? 5;
afterFirstCmd = afterFirstCmd ?? true;
const uuid = uuidv4();
// log("callNTQQApi", channel, className, methodName, args, uuid)
return new Promise((resolve: (data: ReturnType) => void, reject) => {
// log("callNTQQApiPromise", channel, className, methodName, args, uuid)
const _timeout = timeout * 1000
let success = false
let eventName = className + "-" + channel[channel.length - 1];
if (classNameIsRegister) {
eventName += "-register";
}
const apiArgs = [methodName, ...args]
if (!cbCmd) {
// QQ后端会返回结果并且可以插根据uuid识别
hookApiCallbacks[uuid] = (r: ReturnType) => {
@@ -93,15 +118,27 @@ function callNTQQApi<ReturnType>(params: NTQQApiParams) {
};
} else {
// 这里的callback比较特殊QQ后端先返回是否调用成功再返回一条结果数据
hookApiCallbacks[uuid] = (result: GeneralCallResult) => {
log(`${methodName} callback`, result)
if (result.result == 0) {
const hookId = registerReceiveHook<ReturnType>(cbCmd, (payload) => {
log(methodName, "second callback", cbCmd, payload);
const secondCallback = () => {
const hookId = registerReceiveHook<ReturnType>(cbCmd, (payload) => {
// log(methodName, "second callback", cbCmd, payload, cmdCB);
if (!!cmdCB) {
if (cmdCB(payload)) {
removeReceiveHook(hookId);
success = true
resolve(payload);
}
} else {
removeReceiveHook(hookId);
success = true
resolve(payload);
})
}
})
}
!afterFirstCmd && secondCallback();
hookApiCallbacks[uuid] = (result: GeneralCallResult) => {
log(`${methodName} callback`, result)
if (result?.result == 0 || result === undefined) {
afterFirstCmd && secondCallback();
} else {
success = true
reject(`ntqq api call failed, ${result.errMsg}`);
@@ -111,12 +148,11 @@ function callNTQQApi<ReturnType>(params: NTQQApiParams) {
setTimeout(() => {
// log("ntqq api timeout", success, channel, className, methodName)
if (!success) {
log(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
reject(`ntqq api timeout ${channel}, ${className}, ${methodName}`)
log(`ntqq api timeout ${channel}, ${eventName}, ${methodName}`, apiArgs);
reject(`ntqq api timeout ${channel}, ${eventName}, ${methodName}, ${apiArgs}`)
}
}, _timeout)
const eventName = className + "-" + channel[channel.length - 1];
const apiArgs = [methodName, ...args]
ipcMain.emit(
channel,
{},
@@ -138,7 +174,7 @@ interface GeneralCallResult {
export class NTQQApi {
// static likeFriend = defineNTQQApi<void>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.LIKE_FRIEND)
static likeFriend(uid: string, count = 1) {
return callNTQQApi({
return callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.LIKE_FRIEND,
args: [{
doLikeUserInfo: {
@@ -167,6 +203,26 @@ export class NTQQApi {
return result.profiles.get(uid)
}
static async getUserDetailInfo(uid: string) {
const result = await callNTQQApi<{ info: User }>({
methodName: NTQQApiMethod.USER_DETAIL_INFO,
cbCmd: ReceiveCmd.USER_DETAIL_INFO,
afterFirstCmd: false,
cmdCB: (payload) => {
const success = payload.info.uid == uid
// log("get user detail info", success, uid, payload)
return success
},
args: [
{
uid
},
null
]
})
return result.info
}
static async getFriends(forced = false) {
const data = await callNTQQApi<{
data: {
@@ -225,11 +281,12 @@ export class NTQQApi {
let values = result.result.infos.values()
let members = Array.from(values) as GroupMember[]
for(const member of members){
uidMaps[member.uid] = member.uin;
for (const member of members) {
// uidMaps[member.uid] = member.uin;
}
log(uidMaps);
// log(uidMaps);
// log("members info", values);
log(`get group ${groupQQ} members success`)
return members
} catch (e) {
log(`get group ${groupQQ} members failed`, e)
@@ -274,7 +331,7 @@ export class NTQQApi {
}
// 上传文件到QQ的文件夹
static async uploadFile(filePath: string) {
static async uploadFile(filePath: string, elementType: ElementType=ElementType.PIC) {
const md5 = await NTQQApi.getFileMd5(filePath);
let ext = (await NTQQApi.getFileType(filePath))?.ext
if (ext) {
@@ -289,7 +346,7 @@ export class NTQQApi {
path_info: {
md5HexStr: md5,
fileName: fileName,
elementType: 2,
elementType: elementType,
elementSubType: 0,
thumbSize: 0,
needCreate: true,
@@ -328,7 +385,16 @@ export class NTQQApi {
},
undefined,
]
await callNTQQApi({methodName: NTQQApiMethod.DOWNLOAD_MEDIA, args: apiParams})
// log("需要下载media", sourcePath);
await callNTQQApi({
methodName: NTQQApiMethod.DOWNLOAD_MEDIA,
args: apiParams,
cbCmd: ReceiveCmd.MEDIA_DOWNLOAD_COMPLETE,
cmdCB: (payload: { notifyInfo: { filePath: string } }) => {
// log("media 下载完成判断", payload.notifyInfo.filePath, sourcePath);
return payload.notifyInfo.filePath == sourcePath;
}
})
return sourcePath
}
@@ -341,8 +407,8 @@ export class NTQQApi {
})
}
static sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false) {
const sendTimeout = 10 * 1000
static sendMsg(peer: Peer, msgElements: SendMessageElement[], waitComplete = false, timeout = 10000) {
const sendTimeout = timeout
return new Promise<RawMessage>((resolve, reject) => {
const peerUid = peer.peerUid;
@@ -379,6 +445,7 @@ export class NTQQApi {
return reject("发送超时")
}
if (msgHistory[rawMessage.msgId]?.sendStatus == 2) {
log(`${peerUid}发送消息成功`)
success = true;
resolve(rawMessage);
} else {
@@ -389,6 +456,7 @@ export class NTQQApi {
checkSendComplete();
} else {
success = true;
log(`${peerUid}发送消息成功`)
resolve(rawMessage);
}
}
@@ -408,7 +476,7 @@ export class NTQQApi {
static multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
let msgInfos = msgIds.map(id => {
return {msgId: id, senderShowName: "LLOneBot"}
return {msgId: id, senderShowName: selfInfo.nick}
})
const apiArgs = [
{
@@ -458,4 +526,78 @@ export class NTQQApi {
})
})
}
static async getGroupNotifies() {
// 获取管理员变更
// 加群通知,退出通知,需要管理员权限
callNTQQApi<GeneralCallResult>({
methodName: ReceiveCmd.GROUP_NOTIFY,
classNameIsRegister: true,
}).then()
return await callNTQQApi<GroupNotifies>({
methodName: NTQQApiMethod.GET_GROUP_NOTICE,
cbCmd: ReceiveCmd.GROUP_NOTIFY,
afterFirstCmd: false,
args: [
{"doubt": false, "startSeq": "", "number": 14},
null
]
});
}
static async handleGroupRequest(seq: string, operateType: GroupRequestOperateTypes, reason?: string) {
const notify: GroupNotify = groupNotifies[seq];
if (!notify) {
throw `${seq}对应的加群通知不存在`
}
return await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.HANDLE_GROUP_REQUEST,
args: [
{
"doubt": false,
"operateMsg": {
"operateType": operateType, // 2 拒绝
"targetMsg": {
"seq": seq, // 通知序列号
"type": notify.type,
"groupCode": notify.group.groupCode,
"postscript": reason
}
}
},
null
]
});
}
static async quitGroup(groupQQ: string) {
await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.QUIT_GROUP,
args: [
{"groupCode": groupQQ},
null
]
})
}
static async handleFriendRequest(sourceId: number, accept: boolean,) {
const request: FriendRequest = friendRequests[sourceId]
if (!request){
throw `sourceId ${sourceId}, 对应的好友请求不存在`
}
const result = await callNTQQApi<GeneralCallResult>({
methodName: NTQQApiMethod.HANDLE_FRIEND_REQUEST,
args: [
{
"approvalInfo": {
"friendUid": request.friendUid,
"reqTime": request.reqTime,
accept
}
}
]
})
delete friendRequests[sourceId];
return result;
}
}

View File

@@ -241,3 +241,58 @@ export interface RawMessage {
faceElement: FaceElement;
}[];
}
export enum GroupNotifyTypes {
INVITED_JOIN = 4, // 有人接受了邀请入群
JOIN_REQUEST = 7,
ADMIN_SET = 8,
ADMIN_UNSET = 12,
MEMBER_EXIT = 11, // 主动退出?
}
export interface GroupNotifies {
doubt: boolean,
nextStartSeq: string,
notifies: GroupNotify[],
}
export interface GroupNotify {
seq: string, // 转成数字再除以1000应该就是时间戳
type: GroupNotifyTypes,
status: 0, // 未知
group: { groupCode: string, groupName: string },
user1: { uid: string, nickName: string }, // 被设置管理员的人
user2: { uid: string, nickName: string }, // 操作者
actionUser: { uid: string, nickName: string }, //未知
actionTime: string,
invitationExt: {
srcType: number, // 0?未知
groupCode: string, waitStatus: number
},
postscript: string, // 加群用户填写的验证信息
repeatSeqs: [],
warningTips: string
}
export enum GroupRequestOperateTypes{
approve = 1,
reject = 2
}
export interface FriendRequest{
friendUid: string,
reqTime: string, // 时间戳,秒
extWords: string, // 申请人填写的验证消息
isUnread: boolean,
friendNick: string,
sourceId: number,
groupCode: string
}
export interface FriendRequestNotify{
data: {
unreadNums: number,
buddyReqs: FriendRequest[]
}
}

View File

@@ -1,6 +1,6 @@
import {ActionName, BaseCheckResult} from "./types"
import {OB11Response, OB11WebsocketResponse} from "./utils"
import {OB11Return, OB11WebsocketReturn} from "../types";
import {OB11Response} from "./utils"
import {OB11Return} from "../types";
class BaseAction<PayloadType, ReturnDataType> {
actionName: ActionName
@@ -23,16 +23,16 @@ class BaseAction<PayloadType, ReturnDataType> {
}
}
public async websocketHandle(payload: PayloadType, echo: string): Promise<OB11WebsocketReturn<ReturnDataType | null>> {
public async websocketHandle(payload: PayloadType, echo: any): Promise<OB11Return<ReturnDataType | null>> {
const result = await this.check(payload)
if (!result.valid) {
return OB11WebsocketResponse.error(result.message, 1400)
return OB11Response.error(result.message, 1400)
}
try {
const resData = await this._handle(payload)
return OB11WebsocketResponse.ok(resData, echo);
return OB11Response.ok(resData, echo);
} catch (e) {
return OB11WebsocketResponse.error(e.toString(), 1200)
return OB11Response.error(e.toString(), 1200, echo)
}
}

View File

@@ -0,0 +1,28 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {friends} from "../../common/data";
import {ActionName} from "./types";
import {log} from "../../common/utils";
interface Payload{
method: string,
args: any[],
}
export default class Debug extends BaseAction<Payload, any>{
actionName = ActionName.Debug
protected async _handle(payload: Payload): Promise<any> {
log("debug call ntqq api", payload);
const method = NTQQApi[payload.method]
if (!method){
throw `${method} 不存在`
}
const result = method(...payload.args);
if (method.constructor.name === "AsyncFunction"){
return await result
}
return result
// const info = await NTQQApi.getUserDetailInfo(friends[0].uid);
// return info
}
}

View File

@@ -0,0 +1,9 @@
import BaseAction from "./BaseAction";
import {ActionName} from "./types";
export default class GetGuildList extends BaseAction<null, null>{
actionName = ActionName.GetGuildList
protected async _handle(payload: null): Promise<null> {
return null;
}
}

View File

@@ -0,0 +1,31 @@
import BaseAction from "./BaseAction";
import {getFriend} from "../../common/data";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
import { log } from "../../common/utils";
interface Payload {
user_id: number,
times: number
}
export default class SendLike extends BaseAction<Payload, null> {
actionName = ActionName.SendLike
protected async _handle(payload: Payload): Promise<null> {
const qq = payload.user_id.toString();
const friend = await getFriend(qq)
if (!friend) {
throw (`点赞失败,${qq}不是好友`)
}
try {
let result = await NTQQApi.likeFriend(friend.uid, parseInt(payload.times.toString()) || 1);
if (result.result !== 0){
throw result.errMsg
}
} catch (e) {
throw `点赞失败 ${e}`
}
return null
}
}

View File

@@ -9,6 +9,7 @@ import {ActionName, BaseCheckResult} from "./types";
import * as fs from "fs";
import {log} from "../../common/utils";
import {v4 as uuidv4} from "uuid"
import {decodeCQCode} from "../cqcode";
function checkSendMessage(sendMsgList: OB11MessageData[]) {
function checkUri(uri: string): boolean {
@@ -49,7 +50,7 @@ export interface ReturnDataType {
message_id: number
}
class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
export class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
actionName = ActionName.SendMsg
protected async check(payload: OB11PostSendMsg): Promise<BaseCheckResult> {
@@ -115,14 +116,15 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
private convertMessage2List(message: OB11MessageMixType) {
protected convertMessage2List(message: OB11MessageMixType) {
if (typeof message === "string") {
message = [{
type: OB11MessageDataType.text,
data: {
text: message
}
}] as OB11MessageData[]
// message = [{
// type: OB11MessageDataType.text,
// data: {
// text: message
// }
// }] as OB11MessageData[]
message = decodeCQCode(message.toString())
} else if (!Array.isArray(message)) {
message = [message]
}
@@ -143,24 +145,27 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
peerUid: selfInfo.uid
}
let nodeIds: string[] = []
for (const messageNode of messageNodes) {
for (const messageNode of messageNodes){
// 一个node表示一个人的消息
let nodeId = messageNode.data.id;
// 有nodeId表示一个子转发消息卡片
if (nodeId) {
nodeIds.push(nodeId)
let nodeMsg = getHistoryMsgByShortId(nodeId);
if (nodeMsg){
nodeIds.push(nodeMsg.msgId);
}
} else {
// 自定义的消息
// 提取消息段发给自己生成消息id
const {
sendElements,
deleteAfterSentFiles
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group)
try {
const {
sendElements,
deleteAfterSentFiles
} = await this.createSendElements(this.convertMessage2List(messageNode.data.content), group);
log("开始生成转发节点", sendElements);
const nodeMsg = await this.send(selfPeer, sendElements, deleteAfterSentFiles, true);
nodeIds.push(nodeMsg.msgId)
log("转发节点生成成功", nodeMsg.msgId);
} catch (e) {
log("生效转发消息节点失败", e)
}
@@ -240,7 +245,8 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
}
}
}
} break;
}
break;
}
}
@@ -255,7 +261,7 @@ class SendMsg extends BaseAction<OB11PostSendMsg, ReturnDataType> {
if (!sendElements.length) {
throw ("消息体无法解析")
}
const returnMsg = await NTQQApi.sendMsg(peer, sendElements, waitComplete)
const returnMsg = await NTQQApi.sendMsg(peer, sendElements, waitComplete, 20000);
addHistoryMsg(returnMsg)
deleteAfterSentFiles.map(f => fs.unlink(f, () => {
}))

View File

@@ -0,0 +1,18 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload {
flag: string,
approve: boolean,
remark?: string,
}
export default class SetFriendAddRequest extends BaseAction<Payload, null> {
actionName = ActionName.SetFriendAddRequest;
protected async _handle(payload: Payload): Promise<null> {
await NTQQApi.handleFriendRequest(parseInt(payload.flag), payload.approve)
return null;
}
}

View File

@@ -0,0 +1,30 @@
import BaseAction from "./BaseAction";
import {groupNotifies} from "../../common/data";
import {GroupNotify, GroupRequestOperateTypes} from "../../ntqqapi/types";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {ActionName} from "./types";
interface Payload{
flag: string,
// sub_type: "add" | "invite",
// type: "add" | "invite"
approve: boolean,
reason: string
}
export default class SetGroupAddRequest extends BaseAction<Payload, null>{
actionName = ActionName.SetGroupAddRequest
protected async _handle(payload: Payload): Promise<null> {
const seq = payload.flag.toString();
const notify: GroupNotify = groupNotifies[seq]
try{
await NTQQApi.handleGroupRequest(seq,
payload.approve ? GroupRequestOperateTypes.approve: GroupRequestOperateTypes.reject,
payload.reason
)
}catch (e) {
throw e
}
return null
}
}

View File

@@ -0,0 +1,22 @@
import BaseAction from "./BaseAction";
import {NTQQApi} from "../../ntqqapi/ntcall";
import {log} from "../../common/utils";
import {ActionName} from "./types";
interface Payload{
group_id: number,
is_dismiss: boolean
}
export default class SetGroupLeave extends BaseAction<Payload, any>{
actionName = ActionName.SetGroupLeave
protected async _handle(payload: Payload): Promise<any> {
try{
await NTQQApi.quitGroup(payload.group_id.toString())
}
catch (e) {
log("退群失败", e)
throw e
}
}
}

View File

@@ -0,0 +1,24 @@
import BaseAction from "../BaseAction";
import {OB11GroupMember, OB11User} from "../../types";
import {friends, getFriend, getGroupMember, groups} from "../../../common/data";
import {OB11Constructor} from "../../constructor";
import {ActionName} from "../types";
export default class GoCQHTTPGetStrangerInfo extends BaseAction<{user_id: number}, OB11User>{
actionName = ActionName.GoCQHTTP_GetStrangerInfo
protected async _handle(payload: { user_id: number }): Promise<OB11User> {
const user_id = payload.user_id.toString()
const friend = await getFriend(user_id)
if (friend){
return OB11Constructor.friend(friend);
}
for(const group of groups){
const member = await getGroupMember(group.groupCode, user_id)
if (member){
return OB11Constructor.groupMember(group.groupCode, member) as OB11User
}
}
throw ("查无此人")
}
}

View File

@@ -0,0 +1,15 @@
import SendMsg, {ReturnDataType} from "../SendMsg";
import {OB11MessageMixType, OB11PostSendMsg} from "../../types";
import {ActionName, BaseCheckResult} from "../types";
export class GoCQHTTPSendGroupForwardMsg extends SendMsg{
actionName = ActionName.GoCQHTTP_SendGroupForwardMsg;
protected async check(payload: OB11PostSendMsg){
payload.message = this.convertMessage2List(payload.messages);
return super.check(payload);
}
}
export class GoCQHTTPSendPrivateForwardMsg extends GoCQHTTPSendGroupForwardMsg{
actionName = ActionName.GoCQHTTP_SendPrivateForwardMsg;
}

View File

@@ -14,18 +14,38 @@ import GetVersionInfo from "./GetVersionInfo";
import CanSendRecord from "./CanSendRecord";
import CanSendImage from "./CanSendImage";
import GetStatus from "./GetStatus";
import {GoCQHTTPSendGroupForwardMsg, GoCQHTTPSendPrivateForwardMsg} from "./go-cqhttp/SendForwardMsg";
import GoCQHTTPGetStrangerInfo from "./go-cqhttp/GetStrangerInfo";
import SendLike from "./SendLike";
import SetGroupAddRequest from "./SetGroupAddRequest";
import SetGroupLeave from "./SetGroupLeave";
import GetGuildList from "./GetGuildList";
import Debug from "./Debug";
import SetFriendAddRequest from "./SetFriendAddRequest";
export const actionHandlers = [
new Debug(),
new SendLike(),
new GetMsg(),
new GetLoginInfo(),
new GetFriendList(),
new GetGroupList(), new GetGroupInfo(), new GetGroupMemberList(), new GetGroupMemberInfo(),
new SendGroupMsg(), new SendPrivateMsg(), new SendMsg(),
new DeleteMsg(),
new SetGroupAddRequest(),
new SetFriendAddRequest(),
new SetGroupLeave(),
new GetVersionInfo(),
new CanSendRecord(),
new CanSendImage(),
new GetStatus()
new GetStatus(),
//以下为go-cqhttp api
new GoCQHTTPSendGroupForwardMsg(),
new GoCQHTTPSendPrivateForwardMsg(),
new GoCQHTTPGetStrangerInfo(),
new GetGuildList()
]
function initActionMap() {

View File

@@ -1,3 +1,5 @@
import GetGuildList from "./GetGuildList";
export type BaseCheckResult = ValidCheckResult | InvalidCheckResult
export interface ValidCheckResult {
@@ -12,7 +14,8 @@ export interface InvalidCheckResult {
}
export enum ActionName {
TestForwardMsg = "test_forward_msg",
Debug = "llonebot_debug",
SendLike = "send_like",
GetLoginInfo = "get_login_info",
GetFriendList = "get_friend_list",
GetGroupInfo = "get_group_info",
@@ -24,8 +27,16 @@ export enum ActionName {
SendGroupMsg = "send_group_msg",
SendPrivateMsg = "send_private_msg",
DeleteMsg = "delete_msg",
SetGroupAddRequest = "set_group_add_request",
SetFriendAddRequest = "set_friend_add_request",
SetGroupLeave = "set_group_leave",
GetVersionInfo = "get_version_info",
GetStatus = "get_status",
CanSendRecord = "can_send_record",
CanSendImage = "can_send_image",
// 以下为go-cqhttp api
GoCQHTTP_SendGroupForwardMsg = "send_group_forward_msg",
GoCQHTTP_SendPrivateForwardMsg = "send_private_forward_msg",
GoCQHTTP_GetStrangerInfo = "get_stranger_info",
GetGuildList = "get_guild_list",
}

View File

@@ -1,4 +1,5 @@
import {OB11Return, OB11WebsocketReturn} from '../types';
import {OB11Return} from '../types';
import {isNull} from '../../common/utils';
export class OB11Response {
static res<T>(data: T, status: string, retcode: number, message: string = ""): OB11Return<T> {
@@ -6,31 +7,25 @@ export class OB11Response {
status: status,
retcode: retcode,
data: data,
message: message
message: message,
wording: message,
echo: null
}
}
static ok<T>(data: T) {
return OB11Response.res<T>(data, "ok", 0)
}
static error(err: string, retcode: number) {
return OB11Response.res(null, "failed", retcode, err)
}
}
export class OB11WebsocketResponse {
static res<T>(data: T, status: string, retcode: number, echo: string, message: string = ""): OB11WebsocketReturn<T> {
return {
status: status,
retcode: retcode,
data: data,
echo: echo,
message: message
static ok<T>(data: T, echo: any = null) {
let res = OB11Response.res<T>(data, "ok", 0)
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
static ok<T>(data: T, echo: string = "") {
return OB11WebsocketResponse.res<T>(data, "ok", 0, echo)
}
static error(err: string, retcode: number, echo: string = "") {
return OB11WebsocketResponse.res(null, "failed", retcode, echo, err)
static error(err: string, retcode: number, echo: any = null) {
let res = OB11Response.res(null, "failed", retcode, err)
if (!isNull(echo)) {
res.echo = echo;
}
return res;
}
}

View File

@@ -11,12 +11,14 @@ import {AtType, ChatType, Group, GroupMember, IMAGE_HTTP_HOST, RawMessage, SelfI
import {getFriend, getGroupMember, getHistoryMsgBySeq, selfInfo} from '../common/data';
import {file2base64, getConfigUtil, log} from "../common/utils";
import {NTQQApi} from "../ntqqapi/ntcall";
import {EventType} from "./event/OB11BaseEvent";
import {encodeCQCode} from "./cqcode";
export class OB11Constructor {
static async message(msg: RawMessage): Promise<OB11Message> {
const {enableLocalFile2Url} = getConfigUtil().getConfig()
const {enableLocalFile2Url, ob11: {messagePostFormat}} = getConfigUtil().getConfig()
const message_type = msg.chatType == ChatType.group ? "group" : "private";
const resMsg: OB11Message = {
self_id: parseInt(selfInfo.uin),
@@ -33,11 +35,12 @@ export class OB11Constructor {
raw_message: "",
font: 14,
sub_type: "friend",
message: [],
post_type: "message",
message: messagePostFormat === 'string' ? '' : [],
message_format: messagePostFormat === 'string' ? 'string' : 'array',
post_type: selfInfo.uin == msg.senderUin ? EventType.MESSAGE_SENT : EventType.MESSAGE,
}
if (msg.chatType == ChatType.group) {
resMsg.sub_type = "normal"
resMsg.sub_type = "normal" // 这里go-cqhttp是group而onebot11标准是normal, 蛋疼
resMsg.group_id = parseInt(msg.peerUin)
const member = await getGroupMember(msg.peerUin, msg.senderUin);
if (member) {
@@ -80,13 +83,17 @@ export class OB11Constructor {
}
} else if (element.textElement) {
message_data["type"] = "text"
resMsg.raw_message += message_data["data"]["text"] = element.textElement.content
let text = element.textElement.content
if (!text.trim()) {
continue;
}
message_data["data"]["text"] = text
} else if (element.picElement) {
message_data["type"] = "image"
message_data["data"]["file_id"] = element.picElement.fileUuid
message_data["data"]["path"] = element.picElement.sourcePath
message_data["data"]["file"] = element.picElement.sourcePath
message_data["data"]["http_file"] = IMAGE_HTTP_HOST + element.picElement.originImageUrl
message_data["data"]["url"] = IMAGE_HTTP_HOST + element.picElement.originImageUrl
try {
await NTQQApi.downloadMedia(msg.msgId, msg.chatType, msg.peerUid,
element.elementId, element.picElement.thumbPath.get(0), element.picElement.sourcePath)
@@ -96,7 +103,7 @@ export class OB11Constructor {
message_data["type"] = "reply"
const replyMsg = getHistoryMsgBySeq(element.replyElement.replayMsgSeq)
if (replyMsg) {
message_data["data"]["id"] = replyMsg.msgShortId
message_data["data"]["id"] = replyMsg.msgShortId.toString()
} else {
continue
}
@@ -123,8 +130,8 @@ export class OB11Constructor {
if (!enableLocalFile2Url) {
message_data.data.file = "file://" + filePath
} else { // 不使用本地路径
if (message_data.data.http_file) {
message_data.data.file = message_data.data.http_file
if (message_data.data.url && !message_data.data.url.startsWith(IMAGE_HTTP_HOST + "/download")) {
message_data.data.file = message_data.data.url
} else {
let {err, data} = await file2base64(filePath);
if (err) {
@@ -135,10 +142,16 @@ export class OB11Constructor {
}
}
}
if (message_data.type !== "unknown" && message_data.data) {
resMsg.message.push(message_data);
if (messagePostFormat === 'string') {
const cqCode = encodeCQCode(message_data);
(resMsg.message as string) += cqCode;
resMsg.raw_message += cqCode;
} else (resMsg.message as OB11MessageData[]).push(message_data);
}
}
resMsg.raw_message = resMsg.raw_message.trim();
return resMsg;
}
@@ -175,7 +188,8 @@ export class OB11Constructor {
group_id: parseInt(group_id),
user_id: parseInt(member.uin),
nickname: member.nick,
card: member.cardName
card: member.cardName,
role: OB11Constructor.groupMemberRole(member.role),
}
}
@@ -196,4 +210,4 @@ export class OB11Constructor {
static groups(groups: Group[]): OB11Group[] {
return groups.map(OB11Constructor.group)
}
}
}

71
src/onebot11/cqcode.ts Normal file
View File

@@ -0,0 +1,71 @@
import {OB11MessageData} from "./types";
const pattern = /\[CQ:(\w+)((,\w+=[^,\]]*)*)\]/
function unescape(source: string) {
return String(source)
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&')
}
function from(source: string) {
const capture = pattern.exec(source)
if (!capture) return null
const [, type, attrs] = capture
const data: Record<string, any> = {}
attrs && attrs.slice(1).split(',').forEach((str) => {
const index = str.indexOf('=')
data[str.slice(0, index)] = unescape(str.slice(index + 1))
})
return {type, data, capture}
}
function h(type: string, data: any) {
return {
type,
data,
}
}
export function decodeCQCode(source: string): OB11MessageData[] {
const elements: any[] = []
let result: ReturnType<typeof from>
while ((result = from(source))) {
const {type, data, capture} = result
if (capture.index) {
elements.push(h('text', {text: unescape(source.slice(0, capture.index))}))
}
elements.push(h(type, data))
source = source.slice(capture.index + capture[0].length)
}
if (source) elements.push(h('text', {text: unescape(source)}))
return elements
}
export function encodeCQCode(data: OB11MessageData) {
const CQCodeEscape = (text: string) => {
return text.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/\&/g, '&amp;')
.replace(/,/g, '&#44;');
};
if (data.type === 'text') {
return CQCodeEscape(data.data.text);
}
let result = '[CQ:' + data.type;
for (const name in data.data) {
const value = data.data[name];
result += `,${name}=${CQCodeEscape(value)}`;
}
result += ']';
return result;
}
// const result = parseCQCode("[CQ:at,qq=114514]早上好啊[CQ:image,file=http://baidu.com/1.jpg,type=show,id=40004]")
// const result = parseCQCode("好好好")
// console.log(JSON.stringify(result))

View File

@@ -4,7 +4,8 @@ export enum EventType {
META = "meta_event",
REQUEST = "request",
NOTICE = "notice",
MESSAGE = "message"
MESSAGE = "message",
MESSAGE_SENT = "message_sent",
}

View File

@@ -1,6 +1,7 @@
import {OB11BaseNoticeEvent} from "./OB11BaseNoticeEvent";
import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupAdminNoticeEvent extends OB11BaseNoticeEvent {
export class OB11GroupAdminNoticeEvent extends OB11GroupNoticeEvent {
notice_type = "group_admin"
sub_type: string // "set" | "unset"
sub_type: "set" | "unset" // "set" | "unset"
}

View File

@@ -2,7 +2,7 @@ import {OB11GroupNoticeEvent} from "./OB11GroupNoticeEvent";
export class OB11GroupDecreaseEvent extends OB11GroupNoticeEvent {
notice_type = "group_decrease";
sub_type = "leave"; // TODO: 实现其他几种子类型的识别 ("leave" | "kick" | "kick_me")
sub_type: "leave" | "kick" | "kick_me" = "leave"; // TODO: 实现其他几种子类型的识别 ("leave" | "kick" | "kick_me")
operate_id: number;
constructor(groupId: number, userId: number) {

View File

@@ -0,0 +1,11 @@
import {OB11BaseNoticeEvent} from "../notice/OB11BaseNoticeEvent";
import {EventType} from "../OB11BaseEvent";
export class OB11FriendRequestEvent extends OB11BaseNoticeEvent {
post_type = EventType.REQUEST
user_id: number;
request_type: "friend" = "friend";
comment: string;
flag: string;
}

View File

@@ -0,0 +1,9 @@
import {OB11GroupNoticeEvent} from "../notice/OB11GroupNoticeEvent";
export class OB11GroupRequestEvent extends OB11GroupNoticeEvent{
request_type: "group" = "group";
sub_type: "add" | "invite" = "add";
comment: string;
flag: string;
}

View File

@@ -29,10 +29,10 @@ export function postWsEvent(event: PostEventType) {
}
}
export function postEvent(msg: PostEventType) {
export function postOB11Event(msg: PostEventType, reportSelf=false) {
const config = getConfigUtil().getConfig();
// 判断msg是否是event
if (!config.reportSelfMessage) {
if (!config.reportSelfMessage && !reportSelf) {
if ((msg as OB11Message).user_id.toString() == selfInfo.uin) {
return
}

View File

@@ -4,10 +4,10 @@ import * as WebSocket from "ws";
import {selfInfo} from "../../../common/data";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
import {ActionName} from "../../action/types";
import {OB11WebsocketResponse} from "../../action/utils";
import {OB11Response} from "../../action/utils";
import BaseAction from "../../action/BaseAction";
import {actionMap} from "../../action";
import {registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {wsReply} from "./reply";
export let rwsList: ReverseWebsocket[] = [];
@@ -33,24 +33,24 @@ export class ReverseWebsocket {
}
public async onmessage(msg: string) {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
log("收到反向Websocket消息", msg.toString())
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
log("收到反向Websocket消息", receiveData)
} catch (e) {
return wsReply(this.websocket, OB11WebsocketResponse.error("json解析失败请检查数据格式", 1400, echo))
return wsReply(this.websocket, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
const action: BaseAction<any, any> = actionMap.get(receiveData.action);
if (!action) {
return wsReply(this.websocket, OB11WebsocketResponse.error("不支持的api " + receiveData.action, 1404, echo))
return wsReply(this.websocket, OB11Response.error("不支持的api " + receiveData.action, 1404, echo))
}
try {
let handleResult = await action.websocketHandle(receiveData.params, echo);
wsReply(this.websocket, handleResult)
} catch (e) {
wsReply(this.websocket, OB11WebsocketResponse.error(`api处理出错:${e}`, 1200, echo))
wsReply(this.websocket, OB11Response.error(`api处理出错:${e}`, 1200, echo))
}
}
@@ -89,18 +89,18 @@ export class ReverseWebsocket {
log("Trying to connect to the websocket server: " + this.url);
this.websocket.on("open", ()=> {
this.websocket.on("open", () => {
log("Connected to the websocket server: " + this.url);
this.onopen();
});
this.websocket.on("message", async (data)=>{
this.websocket.on("message", async (data) => {
await this.onmessage(data.toString());
});
this.websocket.on("error", log);
this.websocket.on("close", ()=> {
this.websocket.on("close", () => {
log("The websocket connection: " + this.url + " closed, trying reconnecting...");
this.onclose();
});

View File

@@ -1,8 +1,8 @@
import {WebSocket} from "ws";
import {getConfigUtil, log} from "../../../common/utils";
import {actionMap} from "../../action";
import {OB11WebsocketResponse} from "../../action/utils";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postevent";
import {OB11Response} from "../../action/utils";
import {postWsEvent, registerWsEventSender, unregisterWsEventSender} from "../postOB11Event";
import {ActionName} from "../../action/types";
import BaseAction from "../../action/BaseAction";
import {LifeCycleSubType, OB11LifeCycleEvent} from "../../event/meta/OB11LifeCycleEvent";
@@ -15,33 +15,33 @@ let heartbeatRunning = false;
class OB11WebsocketServer extends WebsocketServerBase {
authorizeFailed(wsClient: WebSocket) {
wsClient.send(JSON.stringify(OB11WebsocketResponse.res(null, "failed", 1403, "token验证失败")))
wsClient.send(JSON.stringify(OB11Response.res(null, "failed", 1403, "token验证失败")))
}
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: string) {
async handleAction(wsClient: WebSocket, actionName: string, params: any, echo?: any) {
const action: BaseAction<any, any> = actionMap.get(actionName);
if (!action) {
return wsReply(wsClient, OB11WebsocketResponse.error("不支持的api " + actionName, 1404, echo))
return wsReply(wsClient, OB11Response.error("不支持的api " + actionName, 1404, echo))
}
try {
let handleResult = await action.websocketHandle(params, echo);
wsReply(wsClient, handleResult)
} catch (e) {
wsReply(wsClient, OB11WebsocketResponse.error(`api处理出错:${e}`, 1200, echo))
wsReply(wsClient, OB11Response.error(`api处理出错:${e}`, 1200, echo))
}
}
onConnect(wsClient: WebSocket, url: string, req: IncomingMessage) {
if (url == "/api" || url == "/api/" || url == "/") {
wsClient.on("message", async (msg) => {
let receiveData: { action: ActionName, params: any, echo?: string } = {action: null, params: {}}
let echo = ""
log("收到正向Websocket消息", msg.toString())
let receiveData: { action: ActionName, params: any, echo?: any } = {action: null, params: {}}
let echo = null
try {
receiveData = JSON.parse(msg.toString())
echo = receiveData.echo
log("收到正向Websocket消息", receiveData);
} catch (e) {
return wsReply(wsClient, OB11WebsocketResponse.error("json解析失败请检查数据格式", 1400, echo))
return wsReply(wsClient, OB11Response.error("json解析失败请检查数据格式", 1400, echo))
}
this.handleAction(wsClient, receiveData.action, receiveData.params, receiveData.echo).then()
})

View File

@@ -1,17 +1,17 @@
import * as websocket from "ws";
import {OB11WebsocketResponse} from "../../action/utils";
import {PostEventType} from "../postevent";
import {log} from "../../../common/utils";
import {OB11Response} from "../../action/utils";
import {PostEventType} from "../postOB11Event";
import {isNull, log} from "../../../common/utils";
export function wsReply(wsClient: websocket.WebSocket, data: OB11WebsocketResponse | PostEventType) {
export function wsReply(wsClient: websocket.WebSocket, data: OB11Response | PostEventType) {
try {
let packet = Object.assign({
}, data);
if (!packet["echo"]){
if (isNull(packet["echo"])){
delete packet["echo"];
}
wsClient.send(JSON.stringify(packet))
log("ws 消息上报", wsClient.url, data)
log("ws 消息上报", wsClient.url || "", data)
} catch (e) {
log("websocket 回复失败", e)
}

View File

@@ -1,4 +1,5 @@
import {AtType, RawMessage} from "../ntqqapi/types";
import {EventType} from "./event/OB11BaseEvent";
export interface OB11User {
user_id: number;
@@ -64,10 +65,11 @@ export interface OB11Message {
message_type: "private" | "group",
sub_type?: "friend" | "group" | "normal",
sender: OB11Sender,
message: OB11MessageData[],
message: OB11MessageData[] | string,
message_format: 'array' | 'string',
raw_message: string,
font: number,
post_type?: "message",
post_type?: EventType,
raw?: RawMessage
}
@@ -76,10 +78,8 @@ export interface OB11Return<DataType> {
retcode: number
data: DataType
message: string,
}
export interface OB11WebsocketReturn<DataType> extends OB11Return<DataType>{
echo: string
echo?: any, // ws调用api才有此字段
wording?: string, // go-cqhttp字段错误信息
}
export enum OB11MessageDataType {
@@ -103,7 +103,7 @@ export interface OB11MessageText {
interface OB11MessageFileBase {
data: {
file: string,
http_file?: string;
url?: string;
}
}
@@ -160,6 +160,7 @@ export interface OB11PostSendMsg {
user_id: string,
group_id?: string,
message: OB11MessageMixType;
messages?: OB11MessageMixType; // 兼容 go-cqhttp
}
export interface OB11Version {

View File

@@ -60,40 +60,4 @@ export async function uri2local(fileName: string, uri: string){
res.success = true
res.path = filePath
return res
}
function checkSendMessage(sendMsgList: OB11MessageData[]) {
function checkUri(uri: string): boolean {
const pattern = /^(file:\/\/|http:\/\/|https:\/\/|base64:\/\/)/;
return pattern.test(uri);
}
for (let msg of sendMsgList) {
if (msg["type"] && msg["data"]) {
let type = msg["type"];
let data = msg["data"];
if (type === "text" && !data["text"]) {
return 400;
} else if (["image", "voice", "record"].includes(type)) {
if (!data["file"]) {
return 400;
} else {
if (checkUri(data["file"])) {
return 200;
} else {
return 400;
}
}
} else if (type === "at" && !data["qq"]) {
return 400;
} else if (type === "reply" && !data["id"]) {
return 400;
}
} else {
return 400
}
}
return 200;
}
}

View File

@@ -1,7 +1,14 @@
// Electron 主进程 与 渲染进程 交互的桥梁
import {Config} from "./common/types";
import {CHANNEL_GET_CONFIG, CHANNEL_LOG, CHANNEL_SET_CONFIG,} from "./common/channels";
import {Config, LLOneBotError} from "./common/types";
import {
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
} from "./common/channels";
const {contextBridge} = require("electron");
const {ipcRenderer} = require('electron');
@@ -12,9 +19,15 @@ const llonebot = {
setConfig: (config: Config) => {
ipcRenderer.send(CHANNEL_SET_CONFIG, config);
},
getConfig: async () => {
getConfig: async (): Promise<Config> => {
return ipcRenderer.invoke(CHANNEL_GET_CONFIG);
},
getError: async (): Promise<LLOneBotError> => {
return ipcRenderer.invoke(CHANNEL_ERROR);
},
selectFile: (): Promise<string> => {
return ipcRenderer.invoke(CHANNEL_SELECT_FILE);
}
}
export type LLOneBot = typeof llonebot;

View File

@@ -2,14 +2,17 @@
// 打开设置界面时触发
async function onSettingWindowCreated(view: Element) {
window.llonebot.log("setting window created");
const isEmpty = (value: any) => value === undefined || value === null || value === '';
let config = await window.llonebot.getConfig()
const httpClass = "http";
const httpPostClass = "http-post";
const wsClass = "ws";
const reverseWSClass = "reverse-ws";
const llonebotError = await window.llonebot.getError();
window.llonebot.log("获取error" + JSON.stringify(llonebotError));
function createHttpHostEleStr(host: string) {
let eleStr = `
<setting-item data-direction="row" class="hostItem vertical-list-item ${httpPostClass}">
@@ -47,6 +50,18 @@ async function onSettingWindowCreated(view: Element) {
let html = `
<div class="config_view llonebot">
<setting-section>
<setting-panel id="llonebotError" style="display:${llonebotError.ffmpegError || llonebotError.otherError ? '' : 'none'}">
<setting-item id="ffmpegError" data-direction="row"
style="diplay:${llonebotError.ffmpegError ? '' : 'none'}"
class="hostItem vertical-list-item">
<setting-text data-type="secondary" class="err-content">${llonebotError.ffmpegError}</setting-text>
</setting-item>
<setting-item id="otherError" data-direction="row"
style="diplay:${llonebotError.otherError ? '' : 'none'}"
class="hostItem vertical-list-item">
<setting-text data-type="secondary" class="err-content">${llonebotError.otherError}</setting-text>
</setting-item>
</setting-panel>
<setting-panel>
<setting-list class="wrap">
<setting-item data-direction="row" class="hostItem vertical-list-item">
@@ -102,38 +117,65 @@ async function onSettingWindowCreated(view: Element) {
<setting-text>Access Token</setting-text>
<input id="token" type="text" placeholder="可为空" value="${config.token}"/>
</setting-item>
<setting-item data-direction="row" class="vertical-list-item">
<setting-item data-direction="row" class="vertical-list-item" style="width: 80%">
<setting-text>ffmpeg路径</setting-text>
<input id="ffmpegPath" class="input-text" type="text"
style="width:80%;padding: 5px"
value="${config.ffmpeg || ''}"/>
</setting-item>
<button id="selectFFMPEG" class="q-button q-button--small q-button--secondary">选择ffmpeg</button>
</setting-item>
<button id="save" class="q-button">保存</button>
</setting-list>
</setting-panel>
<setting-panel>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<setting-item data-direction="row" class="vertical-list-item">
<div>
<setting-text>消息上报数据类型</setting-text>
<setting-text data-type="secondary">如客户端无特殊需求推荐保持默认设置,两者的详细差异可参考 <a href="javascript:LiteLoader.api.openExternal('https://github.com/botuniverse/onebot-11/tree/master/message#readme');">OneBot v11 文档</a></setting-text>
</div>
<setting-select id="messagePostFormat">
<setting-option data-value="array" ${config.ob11.messagePostFormat !== "string" ? "is-selected" : ""}>消息段</setting-option>
<setting-option data-value="string" ${config.ob11.messagePostFormat === "string" ? "is-selected" : ""}>CQ码</setting-option>
</setting-select>
</setting-item>
<setting-item data-direction="row" class="vertical-list-item">
<div>
<div>上报文件不采用本地路径</div>
<div class="tips">开启后,上报图片为http连接语音为base64编码</div>
<div class="tips">开启后,上报文件(图片语音等)为http链接或base64编码</div>
</div>
<setting-switch id="switchFileUrl" ${config.enableLocalFile2Url ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<setting-item data-direction="row" class="vertical-list-item">
<div>
<div>debug模式</div>
<div class="tips">开启后上报消息添加raw字段附带原始消息</div>
</div>
<setting-switch id="debug" ${config.debug ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<setting-item data-direction="row" class="vertical-list-item">
<div>
<div>上报自身消息</div>
<div class="tips">慎用,不然会自己和自己聊个不停</div>
</div>
<setting-switch id="reportSelfMessage" ${config.reportSelfMessage ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="hostItem vertical-list-item">
<setting-item data-direction="row" class="vertical-list-item">
<div>
<div>日志</div>
<div class="tips">目录:${window.LiteLoader.plugins["LLOneBot"].path.data}</div>
</div>
<setting-switch id="log" ${config.log ? "is-active" : ""}></setting-switch>
</setting-item>
<setting-item data-direction="row" class="vertical-list-item">
<div>
<div>自动删除收到的文件</div>
<div class="tips">一分钟后会删除收到的图片语音</div>
</div>
<setting-switch id="autoDeleteFile" ${config.autoDeleteFile ? "is-active" : ""}></setting-switch>
</setting-item>
</setting-panel>
</setting-section>
</div>
@@ -155,6 +197,36 @@ async function onSettingWindowCreated(view: Element) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const getError = async ()=> {
const llonebotError = await window.llonebot.getError();
console.log(llonebotError);
const llonebotErrorEle = document.getElementById("llonebotError");
const ffmpegErrorEle = document.getElementById("ffmpegError");
const otherErrorEle = document.getElementById("otherError");
if (llonebotError.otherError || llonebotError.ffmpegError){
llonebotErrorEle.style.display = ''
}
else{
llonebotErrorEle.style.display = 'none'
}
if (llonebotError.ffmpegError) {
const errContentEle = doc.querySelector("#ffmpegError .err-content")
// const errContent = ffmpegErrorEle.getElementsByClassName("err-content")[0];
errContentEle.textContent = llonebotError.ffmpegError;
(ffmpegErrorEle as HTMLElement).style.display = ''
}
else{
ffmpegErrorEle.style.display = ''
}
if (llonebotError.otherError) {
const errContentEle = doc.querySelector("#otherError .err-content")
errContentEle.textContent = llonebotError.otherError;
otherErrorEle.style.display = ''
}
else{
otherErrorEle.style.display = 'none'
}
}
function addHostEle(type: string, initValue: string = "") {
let addressEle, hostItemsEle;
@@ -174,9 +246,13 @@ async function onSettingWindowCreated(view: Element) {
doc.getElementById("addHttpHost").addEventListener("click", () => addHostEle("http"))
doc.getElementById("addWsHost").addEventListener("click", () => addHostEle("ws"))
doc.getElementById("messagePostFormat").addEventListener("selected", (e: CustomEvent) => {
config.ob11.messagePostFormat = e.detail && !isEmpty(e.detail.value) ? e.detail.value : 'array';
window.llonebot.setConfig(config);
})
function switchClick(eleId: string, configKey: string, _config=null) {
if (!_config){
function switchClick(eleId: string, configKey: string, _config = null) {
if (!_config) {
_config = config
}
doc.getElementById(eleId)?.addEventListener("click", (e) => {
@@ -211,6 +287,7 @@ async function onSettingWindowCreated(view: Element) {
switchClick("switchFileUrl", "enableLocalFile2Url");
switchClick("reportSelfMessage", "reportSelfMessage");
switchClick("log", "log");
switchClick("autoDeleteFile", "autoDeleteFile");
doc.getElementById("save")?.addEventListener("click",
() => {
@@ -219,6 +296,7 @@ async function onSettingWindowCreated(view: Element) {
const wsPortEle: HTMLInputElement = document.getElementById("wsPort") as HTMLInputElement;
const wsHostEles: HTMLCollectionOf<HTMLInputElement> = document.getElementsByClassName("wsHost") as HTMLCollectionOf<HTMLInputElement>;
const tokenEle = document.getElementById("token") as HTMLInputElement;
const ffmpegPathEle = document.getElementById("ffmpegPath") as HTMLInputElement;
// 获取端口和host
const httpPort = httpPortEle.value
@@ -243,15 +321,26 @@ async function onSettingWindowCreated(view: Element) {
config.ob11.wsPort = parseInt(wsPort);
config.ob11.wsHosts = wsHosts;
config.token = token;
config.ffmpeg = ffmpegPathEle.value.trim();
window.llonebot.setConfig(config);
setTimeout(()=>{
getError().then();
}, 1000);
alert("保存成功");
})
doc.getElementById("selectFFMPEG")?.addEventListener("click", ()=>{
window.llonebot.selectFile().then(selectPath=>{
if (selectPath){
config.ffmpeg = (document.getElementById("ffmpegPath") as HTMLInputElement).value = selectPath;
// window.llonebot.setConfig(config);
}
});
})
doc.body.childNodes.forEach(node => {
view.appendChild(node);
});
}

View File

@@ -1,4 +1,5 @@
// import path from "path";
const webpack = require('webpack');
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
@@ -62,7 +63,7 @@ let config = {
},
plugins: [
new CopyPlugin({
patterns: copyModules.map(m=>{
patterns: copyModules.map(m => {
m = `node_modules/${m}`
return {
from: m,
@@ -70,6 +71,9 @@ let config = {
}
})
}),
new webpack.DefinePlugin({
'process.env.FLUENTFFMPEG_COV': false,
}),
], // devtool: 'source-map',
}