Compare commits

..

1 Commits

Author SHA1 Message Date
linyuchen
85c6d9a836 fix: ws listen 0.0.0.0 2024-02-16 01:12:26 +08:00
256 changed files with 7383 additions and 19939 deletions

View File

@@ -1,9 +0,0 @@
root = true
[*]
insert_final_newline = true
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true

7
.gitattributes vendored
View File

@@ -1,7 +0,0 @@
* text eol=lf
*.png -text
*.jpg -text
*.ico -text
*.gif -text
*.webp -text

20
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,20 @@
---
name: Bug反馈
about: 报个Bug
title: ''
labels: bug
assignees: ''
---
QQ版本
LLOneBot版本
调用LLOneBot的方式或者应用端(如postman直接调用或NoneBot2、Koishi)
BUG描述
复现步骤:
LLOneBot日志:

View File

@@ -1,76 +0,0 @@
name: Bug 反馈
description: 报告可能的 LLOneBot 异常行为
title: '[BUG] '
labels: bug
body:
- type: markdown
attributes:
value: |
欢迎来到 LLOneBot 的 Issue Tracker请填写以下表格来提交 Bug。
- type: input
id: system-version
attributes:
label: 系统版本
description: 运行 QQNT 的系统版本
placeholder: Windows 10 Pro Workstation 22H2
validations:
required: true
- type: input
id: qqnt-version
attributes:
label: QQNT 版本
description: 可在 QQNT 的「关于」或是在 LiteLoaderQQNT 的设置页中找到
placeholder: 9.9.7-21804
validations:
required: true
- type: input
id: llonebot-version
attributes:
label: LLOneBot 版本
description: 可在 LiteLoaderQQNT 的设置页或是 QQNT 的设置页侧栏中找到
placeholder: 3.18.0
validations:
required: true
- type: input
id: onebot-client-version
attributes:
label: OneBot 客户端
description: 连接至 LLOneBot 的客户端版本信息
placeholder: Overflow 2.16.0-2cf7991-SNAPSHOT
- type: textarea
id: what-happened
attributes:
label: 发生了什么?
description: 填写你认为的 LLOneBot 的不正常行为
validations:
required: true
- type: textarea
id: how-reproduce
attributes:
label: 如何复现
description: 填写应当如何操作才能触发这个不正常行为
placeholder: |
1. xxx
2. xxx
3. xxx
validations:
required: true
- type: textarea
id: what-expected
attributes:
label: 期望的结果?
description: 填写你认为 LLOneBot 应当执行的正常行为
validations:
required: true
- type: textarea
id: llonebot-log
attributes:
label: LLOneBot 运行日志
description: 在 LLOneBot 的设置页中打开「写入日志」然后粘贴相关日志内容到此处
render: shell
- type: textarea
id: onebot-client-log
attributes:
label: OneBot 客户端运行日志
description: 粘贴 OneBot 客户端的相关日志内容到此处
render: shell

View File

@@ -1,43 +1,36 @@
name: Publish
name: "publish"
on:
push:
tags:
- 'v*'
- "v*"
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: checkout
uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- name: setup node
uses: actions/setup-node@v2
with:
node-version: 18
- name: Install dependenies
run: |
export ELECTRON_SKIP_BINARY_DOWNLOAD=1
npm install
- name: install dependenies
run: npm install
- name: Build
run: npm run build
- name: build
run: npm run build
- name: Compress
run: |
sudo apt install zip -y
cd ./dist/
zip -r ../LLOneBot.zip ./*
- name: Extract version from tag
id: get-version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: 'LLOneBot.zip'
draft: true
token: ${{ secrets.RELEASE_TOKEN }}
name: LLOneBot v${{ steps.get-version.outputs.VERSION }}
- name: zip
run: |
sudo apt install zip -y
cp manifest.json ./dist/manifest.json
zip LLOneBot.zip ./dist/* -j
- name: publish
uses: ncipollo/release-action@v1
with:
artifacts: "LLOneBot.zip"
draft: true
token: ${{ secrets.RELEASE_TOKEN }}

20
.gitignore vendored
View File

@@ -1,16 +1,4 @@
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
package-lock.json
yarn.lock
node_modules
dist
out
.DS_Store
.idea
.vscode
node_modules/
dist/
.idea/
.DS_Store

View File

@@ -1,4 +0,0 @@
semi: false
singleQuote: true
trailingComma: all
printWidth: 120

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

25
LICENSE
View File

@@ -1,25 +0,0 @@
MIT Without Public Social Media Promotion License
Copyright (c) 2024 LLOneBot
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You may use this software in accordance with the above terms, but you are not
allowed to promote this project or your projects based on this project on any
public social media.

114
README.md
View File

@@ -1,36 +1,110 @@
# LLOneBot
# LLOneBot API
LiteLoaderQQNT 插件,实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发
LiteLoaderQQNTOneBot11协议插件
> [!CAUTION]\
> 请不要在 QQ 官方群聊和任何影响力较大的简中互联网平台(包括但不限于: 哔哩哔哩,微博,知乎,抖音等)发布和讨论任何与本插件存在相关性的信息
*注意:本文档对应的是 LiteLoader 1.0.0及以上版本如果你使用的是旧版本请切换到本项目v1分支查看文档*
TG 群:<https://t.me/+nLZEnpne-pQ1OWFl>
*V3之后不再需要LLAPI*
## 安装方法
<https://llonebot.github.io/zh-CN/guide/getting-started>
1.安装[LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
## 设置界面
2.安装本项目插件[OneBotApi](https://github.com/linyuchen/LiteLoaderQQNT-OneBotApi/releases/), 注意本插件2.0以下的版本不支持LiteLoader 1.0.0及以上版本
<img src="./doc/image/setting.png" width="400px" alt="设置界面"/>
*关于插件的安装方法: 下载后解压复制到插件目录*
## 支持的 API
*插件目录:`LiteLoaderQQNT/plugins`*
<https://llonebot.github.io/zh-CN/develop/api>
## 支持的API
## Stargazers over time
目前支持的协议
- [x] http调用api
- [x] http事件上报
- [x] 正向websocket
- [ ] 反向websocket
[![Stargazers over time](https://starchart.cc/LLOneBot/LLOneBot.svg?variant=adaptive)](https://starchart.cc/LLOneBot/LLOneBot)
主要功能:
- [x] 发送好友消息
- [x] 发送群消息
- [x] 获取好友列表
- [x] 获取群列表
- [x] 获取群成员列表
- [x] 撤回消息
- [x] 上报好友消息
- [x] 上报群消息
## 鸣谢
消息格式支持:
- [x] 文字
- [x] 图片
- [x] 引用消息
- [x] @群成员
- [x] 语音
- [x] json消息(只上报)
- [ ] 红包
- [ ] 转发消息记录
- [ ] xml
- [NapCatQQ](https://github.com/NapNeko/NapCatQQ)
- [LiteLoaderQQNT](https://liteloaderqqnt.github.io/guide/install.html)
- [Chronocat](https://github.com/chrononeko/chronocat)
- [koishi-plugin-adapter-onebot](https://github.com/koishijs/koishi-plugin-adapter-onebot)
- [silk-wasm](https://github.com/idranme/silk-wasm)
支持的api:
- [x] get_login_info
- [x] send_msg
- [x] send_group_msg
- [x] send_private_msg
- [x] delete_msg
- [x] get_group_list
- [x] get_group_info
- [x] get_group_member_list
- [x] get_group_member_info
- [x] get_friend_list
- [x] get_msg
## 友链
## 示例
- [Lagrange.Core](https://github.com/LagrangeDev/Lagrange.Core): An Implementation of NTQQ Protocol
![](doc/image/example.jpg)
## 一些坑
<details>
<summary>下载了插件但是没有看到在NTQQ中生效</summary>
<br/>
检查是否下载的是插件release的版本如果是源码的话需要自行编译。依然不生效请查阅<a href="https://liteloaderqqnt.github.io/guide/plugins.html">LiteLoaderQQNT的文档</a>
</details>
<br/>
<details>
<summary>调用接口报404</summary>
<br/>
目前没有支持全部的onebot规范接口请检查是否调用了不支持的接口
</details>
<br/>
<details>
<summary>发送不了图片和语音</summary>
<br/>
检查当前操作用户是否有LiteLoaderQQNT/data/LLOneBot的写入权限如Windows把QQ上安装到C盘有可能会出现无权限导致发送失败
</details>
<br/>
<details>
<summary>不支持cq码</summary>
<br/>
cq码已经过时了没有支持的打算(主要是我不用这玩意儿,加上我懒)
</details>
<br/>
<details>
<summary>QQ变得很卡</summary>
<br/>
这是你的群特别多导致的,因为启动后会批量获取群成员列表,获取完之后就正常了
</details>
<br/>
## TODO
- [x] 重构摆脱LLAPI目前调用LLAPI只能在renderer进程调用需重构成在main进程调用
- [x] 支持正向websocket
- [ ] 转发消息记录
- [ ] 好友点赞api
## onebot11文档
<https://11.onebot.dev/>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,80 +0,0 @@
import cp from 'vite-plugin-cp'
import path from 'node:path'
import './scripts/gen-manifest'
import type { ElectronViteConfig } from 'electron-vite'
const external = [
'silk-wasm',
'ws',
'@minatojs/sql.js',
]
function genCpModule(module: string) {
return { src: `./node_modules/${module}`, dest: `dist/node_modules/${module}`, flatten: false }
}
const config: ElectronViteConfig = {
main: {
build: {
outDir: 'dist/main',
emptyOutDir: true,
lib: {
formats: ['cjs'],
entry: { main: 'src/main/main.ts' },
},
rollupOptions: {
external,
input: 'src/main/main.ts',
},
minify: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
cp({
targets: [
...external.map(genCpModule),
{ src: './manifest.json', dest: 'dist' },
{ src: './icon.webp', dest: 'dist' },
],
}),
],
},
preload: {
// vite config options
build: {
outDir: 'dist/preload',
emptyOutDir: true,
lib: {
formats: ['cjs'],
entry: { preload: 'src/preload.ts' },
},
rollupOptions: {
// external: externalAll,
input: 'src/preload.ts',
},
},
resolve: {},
},
renderer: {
// vite config options
build: {
outDir: 'dist/renderer',
emptyOutDir: true,
lib: {
formats: ['es'],
entry: { renderer: 'src/renderer/index.ts' },
},
rollupOptions: {
// external: externalAll,
input: 'src/renderer/index.ts',
},
},
resolve: {},
},
}
export default config

BIN
icon.webp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,33 +1,31 @@
{
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot",
"slug": "LLOneBot",
"description": "实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发",
"version": "4.0.1",
"icon": "./icon.webp",
"authors": [
{
"name": "linyuchen",
"link": "https://github.com/linyuchen"
}
],
"repository": {
"repo": "LLOneBot/LLOneBot",
"manifest_version": 4,
"type": "extension",
"name": "LLOneBot",
"slug": "LLOneBot",
"description": "LiteLoaderQQNT的OneBotApi",
"version": "3.1.0",
"thumbnail": "./icon.png",
"authors": [{
"name": "linyuchen",
"link": "https://github.com/linyuchen"
}],
"repository": {
"repo": "linyuchen/LiteLoaderQQNT-OneBotApi",
"branch": "main",
"release": {
"tag": "latest",
"name": "LLOneBot.zip"
"tag": "latest",
"name": "LLOneBot.zip"
}
},
"platform": [
"win32",
"linux",
"darwin"
],
"injects": {
"renderer": "./renderer/index.js",
"main": "./main/main.cjs",
"preload": "./preload/preload.cjs"
}
}
},
"platform": [
"win32",
"linux",
"darwin"
],
"injects": {
"renderer": "./renderer.js",
"main": "./main.js",
"preload": "./preload.js"
}
}

4745
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,50 +1,37 @@
{
"name": "llonebot",
"version": "1.0.0",
"type": "module",
"description": "",
"description": "NTQQLiteLoaderOneBotApi",
"main": "dist/main.js",
"scripts": {
"build": "electron-vite build",
"build-mac": "npm run build && npm run deploy-mac",
"deploy-mac": "cp -r dist/* ~/Library/Containers/com.tencent.qq/Data/Documents/LiteLoaderQQNT/plugins/LLOneBot/",
"build-win": "npm run build && npm run deploy-win",
"deploy-win": "cmd /c \"xcopy /C /S /Y dist\\* %LITELOADERQQNT_PROFILE%\\plugins\\LLOneBot\\\"",
"format": "prettier -cw .",
"check": "tsc",
"compile:proto": "pbjs --no-create --no-convert --no-encode --no-verify -t static-module -w es6 -p src/ntqqapi/proto -o src/ntqqapi/proto/compiled.js systemMessage.proto profileLikeTip.proto && pbts -o src/ntqqapi/proto/compiled.d.ts src/ntqqapi/proto/compiled.js"
"test": "echo \"Error: no test specified\" && exit 1",
"postinstall": "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",
"build-renderer": "webpack --config webpack.renderer.config.js",
"build-mac": "npm run build && cp manifest.json dist/ && npm run deploy-mac",
"deploy-mac": "cp dist/* ~/Library/Containers/com.tencent.qq/Data/LiteLoaderQQNT/plugins/LLOnebot/",
"build-win": "npm run build && cp manifest.json dist/ && npm run deploy-win",
"deploy-win": "cmd /c \"copy dist\\* %USERPROFILE%\\documents\\LiteLoaderQQNT\\plugins\\LLOnebot\\\""
},
"author": "",
"license": "MIT",
"license": "ISC",
"dependencies": {
"@minatojs/driver-sqlite": "^4.6.0",
"@satorijs/element": "^3.1.7",
"@satorijs/protocol": "^1.4.2",
"compare-versions": "^6.1.1",
"cordis": "^3.18.1",
"cors": "^2.8.5",
"cosmokit": "^1.6.3",
"express": "^5.0.0",
"fast-xml-parser": "^4.5.0",
"fluent-ffmpeg": "^2.1.3",
"minato": "^3.6.0",
"protobufjs": "^7.4.0",
"silk-wasm": "^3.6.1",
"ts-case-convert": "^2.1.0",
"ws": "^8.18.0"
"express": "^4.18.2",
"json-bigint": "^1.0.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.26",
"@types/node": "^20.14.15",
"@types/ws": "^8.5.12",
"electron": "^31.4.0",
"electron-vite": "^2.3.0",
"protobufjs-cli": "^1.1.3",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vite-plugin-cp": "^4.0.8"
},
"packageManager": "yarn@4.5.0"
"@babel/preset-env": "^7.23.2",
"@types/express": "^4.17.20",
"@types/uuid": "^9.0.8",
"@types/ws": "^8.5.10",
"babel-loader": "^9.1.3",
"ts-loader": "^9.5.0",
"typescript": "^5.2.2",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"ws": "^8.16.0"
}
}

View File

@@ -1,38 +0,0 @@
import { version } from '../src/version'
import { writeFileSync } from 'node:fs'
const manifest = {
manifest_version: 4,
type: 'extension',
name: 'LLOneBot',
slug: 'LLOneBot',
description: '实现 OneBot 11 和 Satori 协议,用于 QQ 机器人开发',
version,
icon: './icon.webp',
authors: [
{
name: 'linyuchen',
link: 'https://github.com/linyuchen'
}
],
repository: {
repo: 'LLOneBot/LLOneBot',
branch: 'main',
release: {
tag: 'latest',
name: 'LLOneBot.zip'
}
},
platform: [
'win32',
'linux',
'darwin'
],
injects: {
renderer: './renderer/index.js',
main: './main/main.cjs',
preload: './preload/preload.cjs'
}
}
writeFileSync('manifest.json', JSON.stringify(manifest, null, 2))

View File

@@ -1,8 +1,3 @@
export const CHANNEL_GET_CONFIG = 'llonebot_get_config'
export const CHANNEL_SET_CONFIG = 'llonebot_set_config'
export const CHANNEL_SET_CONFIG_CONFIRMED = 'llonebot_set_config_confirmed'
export const CHANNEL_LOG = 'llonebot_log'
export const CHANNEL_ERROR = 'llonebot_error'
export const CHANNEL_UPDATE = 'llonebot_update'
export const CHANNEL_CHECK_VERSION = 'llonebot_check_version'
export const CHANNEL_SELECT_FILE = 'llonebot_select_ffmpeg'
export const CHANNEL_GET_CONFIG = "llonebot_get_config"
export const CHANNEL_SET_CONFIG = "llonebot_set_config"
export const CHANNEL_LOG = "llonebot_log"

View File

@@ -1,104 +1,31 @@
import fs from 'node:fs'
import path from 'node:path'
import { Config, OB11Config, SatoriConfig } from './types'
import { selfInfo, DATA_DIR } from './globalVars'
import { mergeNewProperties } from './utils/misc'
import { Config } from "./types";
const fs = require("fs")
export class ConfigUtil {
private readonly configPath: string
private config: Config | null = null
configPath: string;
constructor(configPath: string) {
this.configPath = configPath
}
getConfig(cache = true) {
if (this.config && cache) {
return this.config
constructor(configPath: string) {
this.configPath = configPath;
}
return this.reloadConfig()
}
reloadConfig(): Config {
const ob11Default: OB11Config = {
enable: true,
httpPort: 3000,
httpHosts: [],
httpSecret: '',
wsPort: 3001,
wsHosts: [],
enableHttp: true,
enableHttpPost: true,
enableWs: true,
enableWsReverse: false,
messagePostFormat: 'array',
enableHttpHeart: false,
listenLocalhost: false
}
const satoriDefault: SatoriConfig = {
enable: true,
port: 5600,
listen: '0.0.0.0',
token: ''
}
const defaultConfig: Config = {
satori: satoriDefault,
ob11: ob11Default,
heartInterval: 60000,
token: '',
enableLocalFile2Url: false,
debug: false,
log: false,
reportSelfMessage: false,
autoDeleteFile: false,
autoDeleteFileSecond: 60,
musicSignUrl: '',
msgCacheExpire: 120
getConfig(): Config {
if (!fs.existsSync(this.configPath)) {
return {port: 3000, hosts: ["http://192.168.1.2:5000/"], wsPort: 3001}
} else {
const data = fs.readFileSync(this.configPath, "utf-8");
let jsonData = JSON.parse(data);
if (!jsonData.hosts) {
jsonData.hosts = []
}
if (!jsonData.wsPort){
jsonData.wsPort = 3001
}
return jsonData;
}
}
if (!fs.existsSync(this.configPath)) {
this.config = defaultConfig
return this.config
} else {
const data = fs.readFileSync(this.configPath, 'utf-8')
let jsonData: Config = defaultConfig
try {
jsonData = JSON.parse(data)
} catch (e) {
this.config = defaultConfig
return this.config
}
mergeNewProperties(defaultConfig, jsonData)
this.checkOldConfig(jsonData.ob11, jsonData, 'httpPort', 'http')
this.checkOldConfig(jsonData.ob11, jsonData, 'httpHosts', 'hosts')
this.checkOldConfig(jsonData.ob11, jsonData, 'wsPort', 'wsPort')
this.config = jsonData
return this.config
setConfig(config: Config) {
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), "utf-8")
}
}
setConfig(config: Config) {
this.config = config
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8')
}
private checkOldConfig(
currentConfig: OB11Config,
oldConfig: Config,
currentKey: 'httpPort' | 'httpHosts' | 'wsPort',
oldKey: 'http' | 'hosts' | 'wsPort',
) {
// 迁移旧的配置到新配置,避免用户重新填写配置
const oldValue = oldConfig[oldKey]
if (oldValue) {
Object.assign(currentConfig, { [currentKey]: oldValue })
delete oldConfig[oldKey]
}
}
}
export function getConfigUtil() {
const configFilePath = path.join(DATA_DIR, `config_${selfInfo.uin}.json`)
return new ConfigUtil(configFilePath)
}

89
src/common/data.ts Normal file
View File

@@ -0,0 +1,89 @@
import { NTQQApi } from '../ntqqapi/ntcall';
import { Friend, Group, GroupMember, RawMessage, SelfInfo } from "../ntqqapi/types";
import { log } from "./utils";
export let groups: Group[] = []
export let friends: Friend[] = []
export let msgHistory: Record<string, RawMessage> = {} // msgId: RawMessage
let globalMsgId = Date.now()
export function addHistoryMsg(msg: RawMessage): boolean{
let existMsg = msgHistory[msg.msgId]
if (existMsg){
Object.assign(existMsg, msg)
msg.msgShortId = existMsg.msgShortId;
return false
}
msg.msgShortId = ++globalMsgId
msgHistory[msg.msgId] = msg
return true
}
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())
}
export async function getFriend(qq: string): Promise<Friend | undefined> {
let friend = friends.find(friend => friend.uin === qq)
// if (!friend){
// friends = (await NTQQApi.getFriends(true))
// friend = friends.find(friend => friend.uin === qq)
// }
return friend
}
export async function getGroup(qq: string): Promise<Group | undefined> {
let group = groups.find(group => group.groupCode === qq)
// if (!group){
// groups = await NTQQApi.getGroups(true);
// group = groups.find(group => group.groupCode === qq)
// }
return group
}
export async function getGroupMember(groupQQ: string, memberQQ: string=null, memberUid: string=null) {
const group = await getGroup(groupQQ)
if (group) {
let filterFunc: (member: GroupMember) => boolean
if (memberQQ){
filterFunc = member => member.uin === memberQQ
}
else if (memberUid){
filterFunc = member => member.uid === memberUid
}
let member = group.members?.find(filterFunc)
if (!member){
const _members = await NTQQApi.getGroupMembers(groupQQ)
if (_members.length){
group.members = _members
}
member = group.members?.find(filterFunc)
}
return member
}
}
export let selfInfo: SelfInfo = {
uid: "",
uin: "",
nick: "",
}
export function getHistoryMsgBySeq(seq: string) {
return Object.values(msgHistory).find(msg => msg.msgSeq === seq)
}
export let uidMaps:Record<string, Friend> = {} // 一串加密的字符串(uid) -> qq号
export function getStrangerByUin(uin: string) {
for (const key in uidMaps) {
if (uidMaps[key].uin === uin) {
return uidMaps[key];
}
}
}

View File

@@ -1,22 +0,0 @@
import { LLOneBotError } from './types'
import { SelfInfo } from '../ntqqapi/types'
import path from 'node:path'
export const llonebotError: LLOneBotError = {
ffmpegError: '',
httpServerError: '',
wsServerError: '',
otherError: 'LLOneBot 未能正常启动,请检查日志查看错误',
}
export const DATA_DIR: string = global.LiteLoader.plugins['LLOneBot'].path.data
export const TEMP_DIR: string = path.join(DATA_DIR, 'temp')
export const PLUGIN_DIR: string = global.LiteLoader.plugins['LLOneBot'].path.plugin
export const LOG_DIR = path.join(DATA_DIR, 'logs')
export const selfInfo: SelfInfo = {
uid: '',
uin: '',
nick: '',
online: true,
}

View File

@@ -1,87 +1,10 @@
export interface OB11Config {
enable: boolean
httpPort: number
httpHosts: string[]
httpSecret?: string
wsPort: number
wsHosts: string[]
enableHttp?: boolean
enableHttpPost?: boolean
enableWs?: boolean
enableWsReverse?: boolean
messagePostFormat?: 'array' | 'string'
enableHttpHeart?: boolean
/**
* 快速操作回复自动引用原消息
* @deprecated
*/
enableQOAutoQuote?: boolean
listenLocalhost: boolean
}
export interface SatoriConfig {
enable: boolean
listen: string
port: number
token: string
}
export interface Config {
satori: SatoriConfig
ob11: OB11Config
token?: string
heartInterval: number // ms
enableLocalFile2Url?: boolean // 开启后本地文件路径图片会转成http链接, 语音会转成base64
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
autoDeleteFile?: boolean
autoDeleteFileSecond?: number
ffmpeg?: string // ffmpeg路径
musicSignUrl?: string
ignoreBeforeLoginMsg?: boolean
/** 单位为秒 */
msgCacheExpire?: number
/** @deprecated */
http?: string
/** @deprecated */
hosts?: string[]
/** @deprecated */
wsPort?: string
/** @deprecated */
enableLLOB?: boolean
port: number
wsPort: number
hosts: string[]
enableBase64?: boolean
debug?: boolean
reportSelfMessage?: boolean
log?: boolean
}
export interface CheckVersion {
result: boolean
version: string
}
export interface LLOneBotError {
httpServerError?: string
wsServerError?: string
ffmpegError?: string
otherError?: string
}
export interface FileCache {
fileName: string
fileSize: string
msgId: string
peerUid: string
chatType: number
elementId: string
elementType: number
}
export interface FileCacheV2 {
fileName: string
fileSize: string
fileUuid: string
msgId: string
msgTime: number
peerUid: string
chatType: number
elementId: string
elementType: number
}

96
src/common/utils.ts Normal file
View File

@@ -0,0 +1,96 @@
import * as path from "path";
import {selfInfo} from "./data";
import {ConfigUtil} from "./config";
import util from "util";
import { sendLog } from '../main/ipcsend';
const fs = require('fs');
export const CONFIG_DIR = global.LiteLoader.plugins["LLOneBot"].path.data;
export function getConfigUtil() {
const configFilePath = path.join(CONFIG_DIR, `config_${selfInfo.uin}.json`)
return new ConfigUtil(configFilePath)
}
export function log(...msg: any[]) {
if (!getConfigUtil().getConfig().log){
return
}
let currentDateTime = new Date().toLocaleString();
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const currentDate = `${year}-${month}-${day}`;
const userInfo = selfInfo.uin ? `${selfInfo.nick}(${selfInfo.uin})` : ""
let logMsg = "";
for (let msgItem of msg){
// 判断是否是对象
if (typeof msgItem === "object"){
logMsg += JSON.stringify(msgItem) + " ";
continue;
}
logMsg += msgItem + " ";
}
logMsg = `${currentDateTime} ${userInfo}: ${logMsg}\n`
// sendLog(...msg);
// console.log(msg)
fs.appendFile(path.join(CONFIG_DIR , `llonebot-${currentDate}.log`), logMsg, (err: any) => {
})
}
export function isGIF(path: string) {
const buffer = Buffer.alloc(4);
const fd = fs.openSync(path, 'r');
fs.readSync(fd, buffer, 0, 4, 0);
fs.closeSync(fd);
return buffer.toString() === 'GIF8'
}
// 定义一个异步函数来检查文件是否存在
export function checkFileReceived(path: string, timeout: number=3000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
function check() {
if (fs.existsSync(path)) {
resolve();
} else if (Date.now() - startTime > timeout) {
reject(new Error(`文件不存在: ${path}`));
} else {
setTimeout(check, 100);
}
}
check();
});
}
export async function file2base64(path: string){
const readFile = util.promisify(fs.readFile);
let result = {
err: "",
data: ""
}
try {
// 读取文件内容
// if (!fs.existsSync(path)){
// path = path.replace("\\Ori\\", "\\Thumb\\");
// }
try {
await checkFileReceived(path, 5000);
} catch (e: any) {
result.err = e.toString();
return result;
}
const data = await readFile(path);
// 转换为Base64编码
result.data = data.toString('base64');
} catch (err) {
result.err = err.toString();
}
return result;
}

View File

@@ -1,118 +0,0 @@
import path from 'node:path'
import ffmpeg from 'fluent-ffmpeg'
import fsPromise from 'node:fs/promises'
import { decode, encode, getDuration, getWavFileInfo, isWav, isSilk, EncodeResult } from 'silk-wasm'
import { TEMP_DIR } from '../globalVars'
import { randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
interface FFmpegOptions {
input?: string[]
output?: string[]
}
type Input = string | Readable
function convert(ctx: Context, input: Input, options: FFmpegOptions): Promise<Buffer>
function convert(ctx: Context, input: Input, options: FFmpegOptions, outputPath: string): Promise<string>
function convert(ctx: Context, input: Input, options: FFmpegOptions, outputPath?: string): Promise<Buffer | string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let command = ffmpeg(input)
.on('error', err => {
ctx.logger.error(`FFmpeg处理转换出错: `, err.message)
reject(err)
})
.on('end', () => {
if (!outputPath) {
resolve(Buffer.concat(chunks))
} else {
resolve(outputPath)
}
})
if (options.input) {
command = command.inputOptions(options.input)
}
if (options.output) {
command = command.outputOptions(options.output)
}
const ffmpegPath: string | undefined = ctx.config.ffmpeg
if (ffmpegPath) {
command = command.setFfmpegPath(ffmpegPath)
}
if (!outputPath) {
const stream = command.pipe()
stream.on('data', chunk => {
chunks.push(chunk)
})
} else {
command.save(outputPath)
}
})
}
export async function encodeSilk(ctx: Context, filePath: string) {
try {
const file = await fsPromise.readFile(filePath)
if (!isSilk(file)) {
ctx.logger.info(`语音文件${filePath}需要转换成silk`)
let result: EncodeResult
const allowSampleRate = [8000, 12000, 16000, 24000, 32000, 44100, 48000]
if (isWav(file) && allowSampleRate.includes(getWavFileInfo(file).fmt.sampleRate)) {
result = await encode(file, 0)
} else {
const input = await convert(ctx, filePath, {
output: [
'-ar 24000',
'-ac 1',
'-f s16le'
]
})
result = await encode(input, 24000)
}
const pttPath = path.join(TEMP_DIR, randomUUID())
await fsPromise.writeFile(pttPath, result.data)
ctx.logger.info(`语音文件${filePath}转换成功!`, pttPath, `时长:`, result.duration)
return {
converted: true,
path: pttPath,
duration: result.duration / 1000,
}
} else {
const silk = file
let duration = 1
try {
duration = getDuration(silk) / 1000
} catch (e) {
ctx.logger.warn('获取语音文件时长失败, 默认为1秒', filePath, (e as Error).stack)
}
return {
converted: false,
path: filePath,
duration,
}
}
} catch (err) {
ctx.logger.error('convert silk failed', (err as Error).stack)
return {}
}
}
type OutFormat = 'mp3' | 'amr' | 'wma' | 'm4a' | 'spx' | 'ogg' | 'wav' | 'flac'
export async function decodeSilk(ctx: Context, inputFilePath: string, outFormat: OutFormat) {
const silk = await fsPromise.readFile(inputFilePath)
const { data } = await decode(silk, 24000)
const tmpPath = path.join(TEMP_DIR, path.basename(inputFilePath))
const outFilePath = tmpPath + `.${outFormat}`
const pcmFilePath = tmpPath + '.pcm'
await fsPromise.writeFile(pcmFilePath, data)
return convert(ctx, pcmFilePath, {
input: [
'-f s16le',
'-ar 24000',
'-ac 1'
]
}, outFilePath)
}

View File

@@ -1,193 +0,0 @@
import fs from 'node:fs'
import fsPromise from 'node:fs/promises'
import path from 'node:path'
import { TEMP_DIR } from '../globalVars'
import { randomUUID, createHash } from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
// 定义一个异步函数来检查文件是否存在
export function checkFileReceived(path: string, timeout: number = 3000): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now()
function check() {
if (fs.existsSync(path)) {
resolve()
} else if (Date.now() - startTime > timeout) {
reject(new Error(`文件不存在: ${path}`))
} else {
setTimeout(check, 200)
}
}
check()
})
}
export function calculateFileMD5(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
// 创建一个流式读取器
const stream = fs.createReadStream(filePath)
const hash = createHash('md5')
stream.on('data', (data: Buffer) => {
// 当读取到数据时,更新哈希对象的状态
hash.update(data)
})
stream.on('end', () => {
// 文件读取完成,计算哈希
const md5 = hash.digest('hex')
resolve(md5)
})
stream.on('error', (err: Error) => {
// 处理可能的读取错误
reject(err)
})
})
}
export enum FileUriType {
Unknown = 0,
FileURL = 1,
RemoteURL = 2,
OneBotBase64 = 3,
DataURL = 4,
Path = 5
}
export function checkUriType(uri: string): { type: FileUriType } {
if (uri.startsWith('base64://')) {
return { type: FileUriType.OneBotBase64 }
}
if (uri.startsWith('data:')) {
return { type: FileUriType.DataURL }
}
if (uri.startsWith('http://') || uri.startsWith('https://')) {
return { type: FileUriType.RemoteURL }
}
if (uri.startsWith('file://')) {
return { type: FileUriType.FileURL }
}
try {
if (fs.existsSync(uri)) return { type: FileUriType.Path }
} catch { }
return { type: FileUriType.Unknown }
}
interface FetchFileRes {
data: Buffer
url: string
}
export async function fetchFile(url: string, headersInit?: Record<string, string>): Promise<FetchFileRes> {
const headers = new Headers({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
'Host': new URL(url).hostname,
...headersInit
})
let raw = await fetch(url, { headers }).catch((err) => {
if (err.cause) {
throw err.cause
}
throw err
})
if (raw.status === 403 && !headers.has('Referer')) {
headers.set('Referer', url)
raw = await fetch(url, { headers }).catch((err) => {
if (err.cause) {
throw err.cause
}
throw err
})
}
if (!raw.ok) throw new Error(`statusText: ${raw.statusText}`)
return {
data: Buffer.from(await raw.arrayBuffer()),
url: raw.url
}
}
type Uri2LocalRes = {
success: boolean
errMsg: string
fileName: string
path: string
isLocal: boolean
}
export async function uri2local(ctx: Context, uri: string, needExt?: boolean): Promise<Uri2LocalRes> {
const { type } = checkUriType(uri)
if (type === FileUriType.FileURL) {
const filePath = fileURLToPath(uri)
const fileName = path.basename(filePath)
return { success: true, errMsg: '', fileName, path: filePath, isLocal: true }
}
if (type === FileUriType.Path) {
const fileName = path.basename(uri)
return { success: true, errMsg: '', fileName, path: uri, isLocal: true }
}
if (type === FileUriType.RemoteURL) {
try {
const res = await fetchFile(uri)
const match = res.url.match(/.+\/([^/?]*)(?=\?)?/)
let filename: string
if (match?.[1]) {
filename = match[1].replace(/[/\\:*?"<>|]/g, '_')
} else {
filename = randomUUID()
}
let filePath = path.join(TEMP_DIR, filename)
await fsPromise.writeFile(filePath, res.data)
if (needExt && !path.extname(filePath)) {
const ext = (await ctx.ntFileApi.getFileType(filePath)).ext
filename += `.${ext}`
await fsPromise.rename(filePath, `${filePath}.${ext}`)
filePath = `${filePath}.${ext}`
}
return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false }
} catch (e) {
const errMsg = `${uri} 下载失败, ${(e as Error).message}`
return { success: false, errMsg, fileName: '', path: '', isLocal: false }
}
}
if (type === FileUriType.OneBotBase64) {
let filename = randomUUID()
let filePath = path.join(TEMP_DIR, filename)
const base64 = uri.replace(/^base64:\/\//, '')
await fsPromise.writeFile(filePath, base64, 'base64')
if (needExt) {
const ext = (await ctx.ntFileApi.getFileType(filePath)).ext
filename += `.${ext}`
await fsPromise.rename(filePath, `${filePath}.${ext}`)
filePath = `${filePath}.${ext}`
}
return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false }
}
if (type === FileUriType.DataURL) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
const capture = /^data:([\w/.+-]+);base64,(.*)$/.exec(uri)
if (capture) {
let filename = randomUUID()
const [, _type, base64] = capture
let filePath = path.join(TEMP_DIR, filename)
await fsPromise.writeFile(filePath, base64, 'base64')
if (needExt) {
const ext = (await ctx.ntFileApi.getFileType(filePath)).ext
filename += `.${ext}`
await fsPromise.rename(filePath, `${filePath}.${ext}`)
filePath = `${filePath}.${ext}`
}
return { success: true, errMsg: '', fileName: filename, path: filePath, isLocal: false }
}
}
return { success: false, errMsg: '未知文件类型', fileName: '', path: '', isLocal: false }
}

View File

@@ -1,7 +0,0 @@
export * from './file'
export * from './misc'
export * from './legacyLog'
export * from './misc'
export * from './upgrade'
export { getVideoInfo, checkFfmpeg } from './video'
export { encodeSilk } from './audio'

View File

@@ -1,29 +0,0 @@
import fs from 'fs'
import path from 'node:path'
import { getConfigUtil } from '../config'
import { LOG_DIR } from '../globalVars'
import { inspect } from 'node:util'
export const logFileName = `llonebot-${new Date().toLocaleString('zh-CN')}.log`.replace(/\//g, '-').replace(/:/g, '-')
export function log(...msg: unknown[]) {
if (!getConfigUtil().getConfig().log) {
return
}
let logMsg = ''
for (const msgItem of msg) {
if (typeof msgItem === 'object') {
logMsg += inspect(msgItem, {
depth: 10,
compact: true,
breakLength: Infinity,
maxArrayLength: 220
}) + ' '
} else {
logMsg += msgItem + ' '
}
}
const currentDateTime = new Date().toLocaleString()
logMsg = `${currentDateTime} ${logMsg}\n\n`
fs.appendFile(path.join(LOG_DIR, logFileName), logMsg, () => { })
}

View File

@@ -1,47 +0,0 @@
import { QQLevel } from '@/ntqqapi/types'
import { Dict, isNullable } from 'cosmokit'
export function isNumeric(str: string) {
return /^\d+$/.test(str)
}
export function calcQQLevel(level: QQLevel) {
const { crownNum, sunNum, moonNum, starNum } = level
return crownNum * 64 + sunNum * 16 + moonNum * 4 + starNum
}
/** QQ Build Version */
export function getBuildVersion(): number {
//const version: string = globalThis.LiteLoader.versions.qqnt
//return +version.split('-')[1]
return +globalThis.LiteLoader.package.qqnt.buildVersion
}
/** 在保证老对象已有的属性不变化的情况下将新对象的属性复制到老对象 */
export function mergeNewProperties(newObj: Dict, oldObj: Dict) {
Object.keys(newObj).forEach((key) => {
// 如果老对象不存在当前属性,则直接复制
if (!oldObj.hasOwnProperty(key)) {
oldObj[key] = newObj[key]
} else {
// 如果老对象和新对象的当前属性都是对象,则递归合并
if (typeof oldObj[key] === 'object' && typeof newObj[key] === 'object') {
mergeNewProperties(newObj[key], oldObj[key])
} else if (typeof oldObj[key] === 'object' || typeof newObj[key] === 'object') {
// 属性冲突,有一方不是对象,直接覆盖
oldObj[key] = newObj[key]
}
}
})
}
export function filterNullable<T>(array: T[]) {
return array.filter(e => !isNullable(e)) as NonNullable<T>[]
}
export function parseBool(value: string) {
if (['', 'true', '1'].includes(value)) {
return true
}
return false
}

View File

@@ -1,104 +0,0 @@
import https from 'node:https'
import http from 'node:http'
import { Dict } from 'cosmokit'
export class RequestUtil {
// 适用于获取服务器下发cookies时获取仅GET
static async HttpsGetCookies(url: string): Promise<{ [key: string]: string }> {
const client = url.startsWith('https') ? https : http
return new Promise((resolve, reject) => {
client.get(url, (res) => {
let cookies: { [key: string]: string } = {}
const handleRedirect = (res: http.IncomingMessage) => {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url)
RequestUtil.HttpsGetCookies(redirectUrl.href).then((redirectCookies) => {
// 合并重定向过程中的cookies
//log('redirectCookies', redirectCookies)
cookies = { ...cookies, ...redirectCookies }
resolve(cookies)
})
} else {
resolve(cookies)
}
} else {
resolve(cookies)
}
}
res.on('data', () => { }) // Necessary to consume the stream
res.on('end', () => {
handleRedirect(res)
})
if (res.headers['set-cookie']) {
//log('set-cookie', url, res.headers['set-cookie'])
res.headers['set-cookie'].forEach((cookie) => {
const parts = cookie.split(';')[0].split('=')
const key = parts[0]
const value = parts[1]
if (key && value && key.length > 0 && value.length > 0) {
cookies[key] = value
}
})
}
}).on('error', (err) => {
reject(err)
})
})
}
// 请求和回复都是JSON data传原始内容 自动编码json
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: unknown, headers: Record<string, string> = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
const option = new URL(url)
const protocol = url.startsWith('https://') ? https : http
const options = {
hostname: option.hostname,
port: option.port,
path: option.href,
method: method,
headers: headers
}
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: Dict) => {
let responseBody = ''
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString()
})
res.on('end', () => {
try {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
if (isJsonRet) {
const responseJson = JSON.parse(responseBody)
resolve(responseJson as T)
} else {
resolve(responseBody as T)
}
} else {
reject(new Error(`Unexpected status code: ${res.statusCode}`))
}
} catch (parseError) {
reject(parseError)
}
})
})
req.on('error', (error) => {
reject(error)
})
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
if (isArgJson) {
req.write(JSON.stringify(data))
} else {
req.write(data)
}
}
req.end()
})
}
// 请求返回都是原始内容
static async HttpGetText(url: string, method: string = 'GET', data?: unknown, headers: Record<string, string> = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false)
}
}

View File

@@ -1,37 +0,0 @@
import { Context } from 'cordis'
export interface IdMusicSignPostData {
type: 'qq' | '163'
id: string | number
}
export interface CustomMusicSignPostData {
type: 'custom'
url: string
audio: string
title: string
image?: string
singer?: string
}
export type MusicSignPostData = IdMusicSignPostData | CustomMusicSignPostData
export class MusicSign {
private readonly url: string
constructor(protected ctx: Context, url: string) {
this.url = url
}
async sign(postData: MusicSignPostData): Promise<string> {
const resp = await fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData),
})
if (!resp.ok) throw new Error(resp.statusText)
const data = await resp.text()
this.ctx.logger.info('音乐消息生成成功', data)
return data
}
}

View File

@@ -1,71 +0,0 @@
// forked from https://github.com/NapNeko/NapCatQQ/blob/6f6b258f22d7563f15d84e7172c4d4cbb547f47e/src/common/utils/MessageUnique.ts#L5
export class LimitedHashTable<K, V> {
private keyToValue: Map<K, V> = new Map()
private valueToKey: Map<V, K> = new Map()
private maxSize: number
constructor(maxSize: number) {
this.maxSize = maxSize
}
resize(count: number) {
this.maxSize = count
}
set(key: K, value: V): void {
this.keyToValue.set(key, value)
this.valueToKey.set(value, key)
while (this.keyToValue.size !== this.valueToKey.size) {
this.keyToValue.clear()
this.valueToKey.clear()
}
while (this.keyToValue.size > this.maxSize || this.valueToKey.size > this.maxSize) {
const oldestKey = this.keyToValue.keys().next().value!
this.valueToKey.delete(this.keyToValue.get(oldestKey)!)
this.keyToValue.delete(oldestKey)
}
}
getValue(key: K): V | undefined {
return this.keyToValue.get(key)
}
getKey(value: V): K | undefined {
return this.valueToKey.get(value)
}
deleteByValue(value: V): void {
const key = this.valueToKey.get(value)
if (key !== undefined) {
this.keyToValue.delete(key)
this.valueToKey.delete(value)
}
}
deleteByKey(key: K): void {
const value = this.keyToValue.get(key)
if (value !== undefined) {
this.keyToValue.delete(key)
this.valueToKey.delete(value)
}
}
getKeyList(): K[] {
return Array.from(this.keyToValue.keys())
}
//获取最近刚写入的几个值
getHeads(size: number): { key: K, value: V }[] | undefined {
const keyList = this.getKeyList()
if (keyList.length === 0) {
return undefined
}
const result: { key: K; value: V }[] = []
const listSize = Math.min(size, keyList.length)
for (let i = 0; i < listSize; i++) {
const key = keyList[listSize - i]
result.push({ key, value: this.keyToValue.get(key)! })
}
return result
}
}

View File

@@ -1,73 +0,0 @@
import path from 'node:path'
import { writeFile } from 'node:fs/promises'
import { version } from '../../version'
import { log, fetchFile } from '.'
import { TEMP_DIR } from '../globalVars'
import { compare } from 'compare-versions'
const downloadMirrorHosts = ['https://ghp.ci/']
const releasesMirrorHosts = ['https://kkgithub.com']
export async function checkNewVersion() {
const latestVersion = await getRemoteVersion()
log('LLOneBot latest version', latestVersion)
if (latestVersion === '') {
return { result: false, version: latestVersion }
}
if (compare(latestVersion, version, '>')) {
return { result: true, version: latestVersion }
}
return { result: false, version: version }
}
export async function upgradeLLOneBot(): Promise<boolean> {
const latestVersion = await getRemoteVersion()
if (latestVersion && latestVersion != '') {
const downloadUrl = `https://github.com/LLOneBot/LLOneBot/releases/download/v${latestVersion}/LLOneBot.zip`
const filePath = path.join(TEMP_DIR, './update-' + latestVersion + '.zip')
// 多镜像下载
for (const mirrorGithub of downloadMirrorHosts) {
try {
const res = await fetchFile(mirrorGithub + downloadUrl)
await writeFile(filePath, res.data)
return globalThis.LiteLoader.api.plugin.install(filePath)
} catch (e) {
log('llonebot upgrade error', e)
}
}
}
return false
}
export async function getRemoteVersion() {
for (const mirror of releasesMirrorHosts) {
const version = await getRemoteVersionByReleasesMirror(mirror)
if (version) {
return version
}
}
for (const mirror of downloadMirrorHosts) {
const version = await getRemoteVersionByDownloadMirror(mirror)
if (version) {
return version
}
}
return ''
}
export async function getRemoteVersionByDownloadMirror(mirrorGithub: string) {
try {
const source = 'https://raw.githubusercontent.com/LLOneBot/LLOneBot/main/src/version.ts'
const page = (await fetchFile(mirrorGithub + source)).data.toString()
return page.match(/(\d+\.\d+\.\d+)/)?.[0]
} catch (e) {
log(e?.toString())
}
}
export async function getRemoteVersionByReleasesMirror(mirrorGithub: string) {
try {
const page = (await fetchFile(mirrorGithub + '/LLOneBot/LLOneBot/releases')).data.toString()
return page.match(new RegExp('(?<=(tag/v)).*?(?=("))'))?.[0]
} catch { }
}

File diff suppressed because one or more lines are too long

20
src/global.d.ts vendored
View File

@@ -1,9 +1,15 @@
import type { LLOneBot } from './preload'
import { Dict } from 'cosmokit'
import { Config } from "./common/types";
declare var llonebot: {
log(data: any): void,
setConfig(config: Config):void;
getConfig():Promise<Config>;
};
declare global {
var llonebot: LLOneBot
var LiteLoader: Dict
var authData: Dict | undefined
var navigation: Dict | undefined
}
interface Window {
llonebot: typeof llonebot;
LiteLoader: any;
}
}

18
src/main/ipcsend.ts Normal file
View File

@@ -0,0 +1,18 @@
import {webContents} from 'electron';
import { CHANNEL_LOG } from '../common/channels';
function sendIPCMsg(channel: string, ...data: any) {
let contents = webContents.getAllWebContents();
for (const content of contents) {
try {
content.send(channel, ...data)
} catch (e) {
console.log("llonebot send ipc msg to render error:", e)
}
}
}
export function sendLog(...args){
sendIPCMsg(CHANNEL_LOG, ...args)
}

View File

@@ -1,35 +0,0 @@
import path from 'node:path'
import { Context, Logger } from 'cordis'
import { appendFile } from 'node:fs'
import { LOG_DIR, selfInfo } from '@/common/globalVars'
import { noop } from 'cosmokit'
interface Config {
enable: boolean
filename: string
}
export default class Log {
static name = 'logger'
constructor(ctx: Context, cfg: Config) {
Logger.targets.splice(0, Logger.targets.length)
let enable = cfg.enable
const file = path.join(LOG_DIR, cfg.filename)
const target: Logger.Target = {
colors: 0,
record: (record: Logger.Record) => {
if (!enable) {
return
}
const dateTime = new Date(record.timestamp).toLocaleString()
const content = `${dateTime} [${record.type}] ${selfInfo.nick}(${selfInfo.uin}) | ${record.name} ${record.content}\n\n`
appendFile(file, content, noop)
},
}
Logger.targets.push(target)
ctx.on('llob/config-updated', input => {
enable = input.log!
})
}
}

View File

@@ -1,242 +1,155 @@
import path from 'node:path'
import Log from './log'
import Core from '../ntqqapi/core'
import OneBot11Adapter from '../onebot11/adapter'
import SatoriAdapter from '../satori/adapter'
import Database from 'minato'
import SQLiteDriver from '@minatojs/driver-sqlite'
import Store from './store'
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { Config as LLOBConfig } from '../common/types'
import {
CHANNEL_CHECK_VERSION,
CHANNEL_ERROR,
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SELECT_FILE,
CHANNEL_SET_CONFIG,
CHANNEL_UPDATE,
CHANNEL_SET_CONFIG_CONFIRMED
} from '../common/channels'
import { hookNTQQApiCall, hookNTQQApiReceive } from '../ntqqapi/hook'
import { checkNewVersion, upgradeLLOneBot } from '../common/utils/upgrade'
import { getConfigUtil } from '../common/config'
import { checkFfmpeg } from '../common/utils/video'
import { Context } from 'cordis'
import { llonebotError, selfInfo, LOG_DIR, DATA_DIR, TEMP_DIR } from '../common/globalVars'
import { log, logFileName } from '../common/utils/legacyLog'
import {
NTQQFileApi,
NTQQFileCacheApi,
NTQQFriendApi,
NTQQGroupApi,
NTQQMsgApi,
NTQQUserApi,
NTQQWebApi,
NTQQWindowApi
} from '../ntqqapi/api'
import { mkdir } from 'node:fs/promises'
import { existsSync, mkdirSync } from 'node:fs'
// 运行在 Electron 主进程 下的插件入口
declare module 'cordis' {
interface Events {
'llob/config-updated': (input: LLOBConfig) => void
}
}
import * as path from "path";
import { BrowserWindow, ipcMain } from 'electron';
import * as util from 'util';
import { Config } from "../common/types";
import {
CHANNEL_GET_CONFIG,
CHANNEL_LOG,
CHANNEL_SET_CONFIG,
} from "../common/channels";
import { postMsg, startHTTPServer, startWSServer } from "../onebot11/server";
import { CONFIG_DIR, getConfigUtil, log } from "../common/utils";
import { addHistoryMsg, msgHistory, selfInfo } from "../common/data";
import { hookNTQQApiReceive, ReceiveCmd, registerReceiveHook } from "../ntqqapi/hook";
import { OB11Constructor } from "../onebot11/constructor";
import { NTQQApi } from "../ntqqapi/ntcall";
import { Group, RawMessage, SelfInfo } from "../ntqqapi/types";
const fs = require('fs');
let running = false;
let mainWindow: BrowserWindow | null = null
// 加载插件时触发
function onLoad() {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true })
}
log("llonebot main onLoad");
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR)
}
// const config_dir = browserWindow.LiteLoader.plugins["LLOneBot"].path.data;
ipcMain.handle(CHANNEL_CHECK_VERSION, async () => {
return checkNewVersion()
})
ipcMain.handle(CHANNEL_UPDATE, async () => {
return upgradeLLOneBot()
})
ipcMain.handle(CHANNEL_SELECT_FILE, async () => {
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)
} else {
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.on(CHANNEL_SET_CONFIG, (event: any, arg: Config) => {
let oldConfig = getConfigUtil().getConfig();
getConfigUtil().setConfig(arg)
if (arg.port != oldConfig.port){
startHTTPServer(arg.port)
}
if (arg.wsPort != oldConfig.wsPort){
startWSServer(arg.wsPort)
}
})
ipcMain.handle(CHANNEL_ERROR, async () => {
const ffmpegOk = await checkFfmpeg(getConfigUtil().getConfig().ffmpeg)
llonebotError.ffmpegError = ffmpegOk ? '' : '没有找到 FFmpeg, 音频只能发送 WAV 和 SILK, 视频尺寸可能异常'
const { httpServerError, wsServerError, otherError, ffmpegError } = llonebotError
let error = `${otherError}\n${httpServerError}\n${wsServerError}\n${ffmpegError}`
error = error.replace('\n\n', '\n')
error = error.trim()
log('查询 LLOneBot 错误信息', error)
return error
})
ipcMain.on(CHANNEL_LOG, (event: any, arg: any) => {
log(arg)
})
ipcMain.handle(CHANNEL_GET_CONFIG, async () => {
const config = getConfigUtil().getConfig()
return config
})
ipcMain.handle(CHANNEL_SET_CONFIG, (_event, ask: boolean, config: LLOBConfig) => {
return new Promise<boolean>(resolve => {
if (!ask) {
getConfigUtil().setConfig(config)
log('配置已更新', config)
checkFfmpeg(config.ffmpeg).then()
resolve(true)
return
}
dialog
.showMessageBox(mainWindow!, {
type: 'question',
buttons: ['确认', '取消'],
defaultId: 0, // 默认选中的按钮0 代表第一个按钮,即 "确认"
title: '确认保存',
message: '是否保存?',
detail: 'LLOneBot配置已更改是否保存',
function postRawMsg(msgList: RawMessage[]) {
const {debug, reportSelfMessage} = getConfigUtil().getConfig();
for (let message of msgList) {
message.msgShortId = msgHistory[message.msgId]?.msgShortId
if (!message.msgShortId) {
addHistoryMsg(message)
}
OB11Constructor.message(message).then((msg) => {
if (debug) {
msg.raw = message;
}
if (msg.user_id == selfInfo.uin && !reportSelfMessage) {
return
}
postMsg(msg);
// log("post msg", msg)
}).catch(e => log("constructMessage error: ", e.toString()));
}
}
function start() {
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
try {
// log("received msg length", payload.msgList.length);
postRawMsg(payload.msgList);
} catch (e) {
log("report message error: ", e.toString())
}
})
.then((result) => {
if (result.response === 0) {
getConfigUtil().setConfig(config)
log('配置已更新', config)
checkFfmpeg(config.ffmpeg).then()
resolve(true)
}
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, (payload) => {
const {reportSelfMessage} = getConfigUtil().getConfig()
if (!reportSelfMessage) {
return
}
// log("reportSelfMessage", payload)
try {
postRawMsg([payload.msgRecord]);
} catch (e) {
log("report self message error: ", e.toString())
}
})
.catch((err) => {
log('保存设置询问弹窗错误', err)
resolve(false)
})
})
})
NTQQApi.getGroups(true).then()
const config = getConfigUtil().getConfig()
startHTTPServer(config.port)
startWSServer(config.wsPort)
log("LLOneBot start")
}
ipcMain.on(CHANNEL_LOG, (_event, arg) => {
log(arg)
})
async function start() {
log('process pid', process.pid)
const config = getConfigUtil().getConfig()
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR)
const init = async () => {
try {
const _ = await NTQQApi.getSelfInfo()
Object.assign(selfInfo, _)
selfInfo.nick = selfInfo.uin
log("get self simple info", _)
} catch (e) {
log("retry get self info")
}
if (selfInfo.uin) {
try {
const userInfo = (await NTQQApi.getUserInfo(selfInfo.uid))
log("self info", userInfo);
if (userInfo) {
selfInfo.nick = userInfo.nick
} else {
return setTimeout(init, 1000)
}
} catch (e) {
log("get self nickname failed", e.toString())
return setTimeout(init, 1000)
}
start();
}
else{
setTimeout(init, 1000)
}
}
const dbDir = path.join(DATA_DIR, 'database')
if (!existsSync(dbDir)) {
await mkdir(dbDir)
}
const ctx = new Context()
ctx.plugin(Log, {
enable: config.log!,
filename: logFileName
})
ctx.plugin(NTQQFileApi)
ctx.plugin(NTQQFileCacheApi)
ctx.plugin(NTQQFriendApi)
ctx.plugin(NTQQGroupApi)
ctx.plugin(NTQQMsgApi)
ctx.plugin(NTQQUserApi)
ctx.plugin(NTQQWebApi)
ctx.plugin(NTQQWindowApi)
ctx.plugin(Core, config)
ctx.plugin(Database)
ctx.plugin(SQLiteDriver, {
path: path.join(dbDir, `${selfInfo.uin}.db`)
})
ctx.plugin(Store, {
msgCacheExpire: config.msgCacheExpire! * 1000
})
if (config.ob11.enable) {
ctx.plugin(OneBot11Adapter, {
...config.ob11,
heartInterval: config.heartInterval,
token: config.token!,
debug: config.debug!,
reportSelfMessage: config.reportSelfMessage!,
musicSignUrl: config.musicSignUrl,
enableLocalFile2Url: config.enableLocalFile2Url!,
ffmpeg: config.ffmpeg,
})
}
if (config.satori.enable) {
ctx.plugin(SatoriAdapter, {
...config.satori,
ffmpeg: config.ffmpeg,
})
}
ctx.start()
llonebotError.otherError = ''
ipcMain.on(CHANNEL_SET_CONFIG_CONFIRMED, (event, config: LLOBConfig) => {
ctx.parallel('llob/config-updated', config)
})
}
const intervalId = setInterval(() => {
const self = Object.assign(selfInfo, {
uin: globalThis.authData?.uin,
uid: globalThis.authData?.uid,
online: true
})
if (self.uin) {
clearInterval(intervalId)
start()
}
}, 600)
setTimeout(init, 1000)
}
// 创建窗口时触发
function onBrowserWindowCreated(window: BrowserWindow) {
if (![2, 4, 6].includes(window.id)) {
return
}
if (window.id === 2) {
mainWindow = window
}
//log('window create', window.webContents.getURL().toString())
try {
hookNTQQApiCall(window, window.id !== 2)
hookNTQQApiReceive(window, window.id !== 2)
} catch (e) {
log('LLOneBot hook error: ', String(e))
}
try {
hookNTQQApiReceive(window);
} catch (e) {
log("LLOneBot hook error: ", e.toString())
}
}
try {
onLoad()
} catch (e) {
console.log(e)
onLoad();
} catch (e: any) {
console.log(e.toString())
}
// 这两个函数都是可选的
export { onBrowserWindowCreated }
export {
onBrowserWindowCreated
}

View File

@@ -1,154 +0,0 @@
import { Peer, RawMessage } from '@/ntqqapi/types'
import { createHash } from 'node:crypto'
import { LimitedHashTable } from '@/common/utils/table'
import { FileCacheV2 } from '@/common/types'
import { Context, Service } from 'cordis'
declare module 'cordis' {
interface Context {
store: Store
}
interface Tables {
message: {
shortId: number
msgId: string
chatType: number
peerUid: string
}
file_v2: FileCacheV2
}
}
interface MsgInfo {
msgId: string
peer: Peer
}
class Store extends Service {
static inject = ['database', 'model']
private cache: LimitedHashTable<string, number>
private messages: Map<string, RawMessage>
constructor(protected ctx: Context, public config: Store.Config) {
super(ctx, 'store', true)
this.cache = new LimitedHashTable(1000)
this.messages = new Map()
this.initDatabase()
}
private async initDatabase() {
this.ctx.model.extend('message', {
shortId: 'integer(10)',
chatType: 'unsigned',
msgId: 'string(24)',
peerUid: 'string(24)'
}, {
primary: 'shortId'
})
this.ctx.model.extend('file_v2', {
fileName: 'string',
fileSize: 'string',
fileUuid: 'string(128)',
msgId: 'string(24)',
msgTime: 'unsigned(10)',
peerUid: 'string(24)',
chatType: 'unsigned',
elementId: 'string(24)',
elementType: 'unsigned',
}, {
primary: 'fileUuid',
indexes: ['fileName']
})
}
createMsgShortId(peer: Peer, msgId: string): number {
// OneBot 11 要求 message_id 为 int32
const cacheKey = `${msgId}|${peer.chatType}|${peer.peerUid}`
const hash = createHash('md5').update(cacheKey).digest()
hash[0] &= 0x7f //保证shortId为正数
const shortId = hash.readInt32BE()
this.cache.set(cacheKey, shortId)
this.ctx.database.upsert('message', [{
msgId,
shortId,
chatType: peer.chatType,
peerUid: peer.peerUid
}], 'shortId').then()
return shortId
}
async getMsgInfoByShortId(shortId: number): Promise<MsgInfo | undefined> {
const data = this.cache.getKey(shortId)
if (data) {
const [msgId, chatTypeStr, peerUid] = data.split('|')
return {
msgId,
peer: {
chatType: +chatTypeStr,
peerUid,
guildId: ''
}
}
}
const items = await this.ctx.database.get('message', { shortId })
if (items?.length) {
const { msgId, chatType, peerUid } = items[0]
return {
msgId,
peer: {
chatType,
peerUid,
guildId: ''
}
}
}
}
async getShortIdByMsgId(msgId: string): Promise<number | undefined> {
return (await this.ctx.database.get('message', { msgId }))[0]?.shortId
}
getShortIdByMsgInfo(peer: Peer, msgId: string) {
const cacheKey = `${msgId}|${peer.chatType}|${peer.peerUid}`
return this.cache.getValue(cacheKey)
}
addFileCache(data: FileCacheV2) {
return this.ctx.database.upsert('file_v2', [data], 'fileUuid')
}
getFileCacheByName(fileName: string) {
return this.ctx.database.get('file_v2', { fileName }, {
sort: { msgTime: 'desc' }
})
}
getFileCacheById(fileUuid: string) {
return this.ctx.database.get('file_v2', { fileUuid })
}
async addMsgCache(msg: RawMessage) {
const expire = this.config.msgCacheExpire
if (expire === 0) {
return
}
const id = msg.msgId
this.messages.set(id, msg)
setTimeout(() => {
this.messages.delete(id)
}, expire)
}
getMsgCache(msgId: string) {
return this.messages.get(msgId)
}
}
namespace Store {
export interface Config {
/** 单位为毫秒 */
msgCacheExpire: number
}
}
export default Store

View File

@@ -1,253 +0,0 @@
import { invoke, NTClass, NTMethod } from '../ntcall'
import { GeneralCallResult } from '../services'
import {
CacheFileList,
CacheFileListItem,
CacheFileType,
CacheScanResult,
ChatCacheListItemBasic,
ChatType,
ElementType,
IMAGE_HTTP_HOST,
IMAGE_HTTP_HOST_NT,
PicElement,
} from '../types'
import path from 'node:path'
import { existsSync } from 'node:fs'
import { ReceiveCmdS } from '../hook'
import { RkeyManager } from '@/ntqqapi/helper/rkey'
import { RichMediaDownloadCompleteNotify, Peer } from '@/ntqqapi/types/msg'
import { calculateFileMD5 } from '@/common/utils/file'
import { copyFile, stat, unlink } from 'node:fs/promises'
import { Time } from 'cosmokit'
import { Service, Context } from 'cordis'
declare module 'cordis' {
interface Context {
ntFileApi: NTQQFileApi
ntFileCacheApi: NTQQFileCacheApi
}
}
export class NTQQFileApi extends Service {
private rkeyManager: RkeyManager
constructor(protected ctx: Context) {
super(ctx, 'ntFileApi', true)
this.rkeyManager = new RkeyManager(ctx, 'https://llob.linyuchen.net/rkey')
}
async getVideoUrl(peer: Peer, msgId: string, elementId: string): Promise<string | undefined> {
const data = await invoke('nodeIKernelRichMediaService/getVideoPlayUrlV2', [{
peer,
msgId,
elemId: elementId,
videoCodecFormat: 0,
params: {
downSourceType: 1,
triggerType: 1
}
}])
if (data.result !== 0) {
this.ctx.logger.warn('getVideoUrl', data)
}
return data.urlResult.domainUrl[0]?.url
}
async getFileType(filePath: string) {
return await invoke<{
ext: string
mime: string
}>(NTMethod.FILE_TYPE, [filePath], {
className: NTClass.FS_API
})
}
/** 上传文件到 QQ 的文件夹 */
async uploadFile(filePath: string, elementType = ElementType.Pic, elementSubType = 0) {
const fileMd5 = await calculateFileMD5(filePath)
let fileName = path.basename(filePath)
if (!fileName.includes('.')) {
const ext = (await this.getFileType(filePath))?.ext
fileName += ext ? '.' + ext : ''
}
const mediaPath = await invoke(NTMethod.MEDIA_FILE_PATH, [{
path_info: {
md5HexStr: fileMd5,
fileName: fileName,
elementType: elementType,
elementSubType,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: '',
},
}])
await copyFile(filePath, mediaPath)
const fileSize = (await stat(filePath)).size
return {
md5: fileMd5,
fileName,
path: mediaPath,
fileSize,
}
}
async downloadMedia(
msgId: string,
chatType: ChatType,
peerUid: string,
elementId: string,
thumbPath = '',
sourcePath = '',
timeout = 1000 * 60 * 2,
force = false
) {
// 用于下载收到的消息中的图片等
if (sourcePath && existsSync(sourcePath)) {
if (force) {
try {
await unlink(sourcePath)
} catch { }
} else {
return sourcePath
}
}
const data = await invoke<{ notifyInfo: RichMediaDownloadCompleteNotify }>(
'nodeIKernelMsgService/downloadRichMedia',
[{
getReq: {
fileModelId: '0',
downloadSourceType: 0,
triggerType: 1,
msgId: msgId,
chatType: chatType,
peerUid: peerUid,
elementId: elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath,
},
}],
{
cbCmd: ReceiveCmdS.MEDIA_DOWNLOAD_COMPLETE,
cmdCB: payload => payload.notifyInfo.msgId === msgId,
timeout
}
)
return data.notifyInfo.filePath
}
async getImageSize(filePath: string) {
return await invoke<{
width: number
height: number
type: string
}>(
NTMethod.IMAGE_SIZE,
[filePath],
{
className: NTClass.FS_API,
}
)
}
async getImageUrl(element: PicElement) {
if (!element) {
return ''
}
const url: string = element.originImageUrl! // 没有域名
const md5HexStr = element.md5HexStr
const fileMd5 = element.md5HexStr
if (url) {
const parsedUrl = new URL(IMAGE_HTTP_HOST + url) //临时解析拼接
const imageAppid = parsedUrl.searchParams.get('appid')
const isNTPic = imageAppid && ['1406', '1407'].includes(imageAppid)
if (isNTPic) {
let rkey = parsedUrl.searchParams.get('rkey')
if (rkey) {
return IMAGE_HTTP_HOST_NT + url
}
const rkeyData = await this.rkeyManager.getRkey()
rkey = imageAppid === '1406' ? rkeyData.private_rkey : rkeyData.group_rkey
return IMAGE_HTTP_HOST_NT + url + rkey
} else {
// 老的图片url不需要rkey
return IMAGE_HTTP_HOST + url
}
} else if (fileMd5 || md5HexStr) {
// 没有url需要自己拼接
return `${IMAGE_HTTP_HOST}/gchatpic_new/0/0-0-${(fileMd5 || md5HexStr)!.toUpperCase()}/0`
}
this.ctx.logger.error('图片url获取失败', element)
return ''
}
async downloadFileForModelId(peer: Peer, fileModelId: string, timeout = 2 * Time.minute) {
const data = await invoke<{ notifyInfo: RichMediaDownloadCompleteNotify }>(
'nodeIKernelRichMediaService/downloadFileForModelId',
[{
peer,
fileModelIdList: [fileModelId],
save_path: ''
}],
{
cbCmd: ReceiveCmdS.MEDIA_DOWNLOAD_COMPLETE,
cmdCB: payload => payload.notifyInfo.fileModelId === fileModelId,
timeout,
afterFirstCmd: false
}
)
return data.notifyInfo.filePath
}
}
export class NTQQFileCacheApi extends Service {
constructor(protected ctx: Context) {
super(ctx, 'ntFileCacheApi', true)
}
async setCacheSilentScan(isSilent: boolean = true) {
return await invoke<GeneralCallResult>(NTMethod.CACHE_SET_SILENCE, [{ isSilent }])
}
getCacheSessionPathList() {
return invoke<Array<{
key: string
value: string
}>>(NTMethod.CACHE_PATH_SESSION, [], { className: NTClass.OS_API })
}
scanCache() {
invoke<GeneralCallResult>(ReceiveCmdS.CACHE_SCAN_FINISH, [], { registerEvent: true })
return invoke<CacheScanResult>(NTMethod.CACHE_SCAN, [], { timeout: 300 * Time.second })
}
getHotUpdateCachePath() {
return invoke<string>(NTMethod.CACHE_PATH_HOT_UPDATE, [], { className: NTClass.HOTUPDATE_API })
}
getDesktopTmpPath() {
return invoke<string>(NTMethod.CACHE_PATH_DESKTOP_TEMP, [], { className: NTClass.BUSINESS_API })
}
getFileCacheInfo(fileType: CacheFileType, pageSize: number = 1000, lastRecord?: CacheFileListItem) {
const _lastRecord = lastRecord ? lastRecord : { fileType: fileType }
return invoke<CacheFileList>(NTMethod.CACHE_FILE_GET, [{
fileType: fileType,
restart: true,
pageSize: pageSize,
order: 1,
lastRecord: _lastRecord,
}])
}
async clearChatCache(chats: ChatCacheListItemBasic[] = [], fileKeys: string[] = []) {
return await invoke<GeneralCallResult>(NTMethod.CACHE_CHAT_CLEAR, [{
chats,
fileKeys,
}])
}
}

View File

@@ -1,129 +0,0 @@
import { Friend, SimpleInfo, CategoryFriend } from '../types'
import { ReceiveCmdS } from '../hook'
import { invoke, NTMethod, NTClass } from '../ntcall'
import { Service, Context } from 'cordis'
declare module 'cordis' {
interface Context {
ntFriendApi: NTQQFriendApi
}
}
export class NTQQFriendApi extends Service {
constructor(protected ctx: Context) {
super(ctx, 'ntFriendApi', true)
}
/** 大于或等于 26702 应使用 getBuddyV2 */
async getFriends() {
const data = await invoke<{
data: {
categoryId: number
categroyName: string
categroyMbCount: number
buddyList: Friend[]
}[]
}>('getBuddyList', [], {
className: NTClass.NODE_STORE_API,
cbCmd: ReceiveCmdS.FRIENDS,
afterFirstCmd: false
})
const _friends: Friend[] = []
for (const item of data.data) {
_friends.push(...item.buddyList)
}
return _friends
}
async handleFriendRequest(friendUid: string, reqTime: string, accept: boolean) {
return await invoke(NTMethod.HANDLE_FRIEND_REQUEST, [{
approvalInfo: {
friendUid,
reqTime,
accept,
},
}])
}
async getBuddyV2(refresh = false): Promise<SimpleInfo[]> {
const data = await invoke<{
buddyCategory: CategoryFriend[]
userSimpleInfos: Record<string, SimpleInfo>
}>(
'getBuddyList',
[refresh],
{
className: NTClass.NODE_STORE_API,
cbCmd: ReceiveCmdS.FRIENDS,
afterFirstCmd: false,
}
)
const uids = data.buddyCategory.flatMap(item => item.buddyUids)
return Object.values(data.userSimpleInfos).filter(v => uids.includes(v.uid!))
}
/** uid => uin */
async getBuddyIdMap(refresh = false): Promise<Map<string, string>> {
const retMap: Map<string, string> = new Map()
const data = await invoke<{
buddyCategory: CategoryFriend[]
userSimpleInfos: Record<string, SimpleInfo>
}>(
'getBuddyList',
[refresh],
{
className: NTClass.NODE_STORE_API,
cbCmd: ReceiveCmdS.FRIENDS,
afterFirstCmd: false,
}
)
for (const item of Object.values(data.userSimpleInfos)) {
if (retMap.size > 5000) {
break
}
retMap.set(item.uid!, item.uin!)
}
return retMap
}
async getBuddyV2WithCate(refresh = false) {
const data = await invoke<{
buddyCategory: CategoryFriend[]
userSimpleInfos: Record<string, SimpleInfo>
}>(
'getBuddyList',
[refresh],
{
className: NTClass.NODE_STORE_API,
cbCmd: ReceiveCmdS.FRIENDS,
afterFirstCmd: false,
}
)
return data
}
async isBuddy(uid: string): Promise<boolean> {
return await invoke('nodeIKernelBuddyService/isBuddy', [{ uid }])
}
async getBuddyRecommendContact(uin: string) {
const ret = await invoke('nodeIKernelBuddyService/getBuddyRecommendContactArkJson', [{ uin }])
return ret.arkMsg
}
async setBuddyRemark(uid: string, remark: string) {
return await invoke('nodeIKernelBuddyService/setBuddyRemark', [{
remarkParams: { uid, remark }
}])
}
async delBuddy(friendUid: string) {
return await invoke('nodeIKernelBuddyService/delBuddy', [{
delInfo: {
friendUid,
tempBlock: false,
tempBothDel: true
}
}])
}
}

View File

@@ -1,299 +0,0 @@
import { ReceiveCmdS } from '../hook'
import {
Group,
GroupMember,
GroupMemberRole,
GroupNotifies,
GroupRequestOperateTypes,
GetFileListParam,
PublishGroupBulletinReq,
GroupAllInfo,
GroupFileInfo,
GroupBulletinListResult
} from '../types'
import { invoke, NTClass, NTMethod } from '../ntcall'
import { GeneralCallResult } from '../services'
import { NTQQWindows } from './window'
import { Service, Context } from 'cordis'
declare module 'cordis' {
interface Context {
ntGroupApi: NTQQGroupApi
}
}
export class NTQQGroupApi extends Service {
static inject = ['ntWindowApi']
constructor(protected ctx: Context) {
super(ctx, 'ntGroupApi', true)
}
async getGroups(): Promise<Group[]> {
const result = await invoke<{
updateType: number
groupList: Group[]
}>(
'getGroupList',
[],
{
className: NTClass.NODE_STORE_API,
cbCmd: ReceiveCmdS.GROUPS_STORE,
afterFirstCmd: false,
}
)
return result.groupList
}
async getGroupMembers(groupCode: string, num = 3000): Promise<Map<string, GroupMember>> {
const sceneId = await invoke(NTMethod.GROUP_MEMBER_SCENE, [{ groupCode, scene: 'groupMemberList_MainWindow' }])
const data = await invoke(NTMethod.GROUP_MEMBERS, [{ sceneId, num }])
if (data.errCode !== 0) {
throw new Error('获取群成员列表出错,' + data.errMsg)
}
return data.result.infos
}
async getGroupMember(groupCode: string, uid: string, forceUpdate = false) {
invoke('nodeIKernelGroupListener/onMemberInfoChange', [], {
registerEvent: true
})
const data = await invoke<{
groupCode: string
members: Map<string, GroupMember>
}>(
'nodeIKernelGroupService/getMemberInfo',
[{
groupCode,
uids: [uid],
forceUpdate
}],
{
cbCmd: 'nodeIKernelGroupListener/onMemberInfoChange',
afterFirstCmd: false,
cmdCB: payload => payload.members.has(uid),
timeout: 2000
}
)
return data.members.get(uid)!
}
async getGroupIgnoreNotifies() {
await this.getSingleScreenNotifies(14)
return await this.ctx.ntWindowApi.openWindow<GeneralCallResult & GroupNotifies>(
NTQQWindows.GroupNotifyFilterWindow,
[],
ReceiveCmdS.GROUP_NOTIFY,
)
}
async getSingleScreenNotifies(number: number, startSeq = '') {
invoke(ReceiveCmdS.GROUP_NOTIFY, [], { registerEvent: true })
return (await invoke<GroupNotifies>(
'nodeIKernelGroupService/getSingleScreenNotifies',
[{ doubt: false, startSeq, number }],
{
cbCmd: ReceiveCmdS.GROUP_NOTIFY,
afterFirstCmd: false,
}
)).notifies
}
async handleGroupRequest(flag: string, operateType: GroupRequestOperateTypes, reason?: string) {
const flagitem = flag.split('|')
const groupCode = flagitem[0]
const seq = flagitem[1]
const type = parseInt(flagitem[2])
return await invoke(NTMethod.HANDLE_GROUP_REQUEST, [{
doubt: false,
operateMsg: {
operateType,
targetMsg: {
seq,
type,
groupCode,
postscript: reason || ' ' // 仅传空值可能导致处理失败,故默认给个空格
},
},
}])
}
async quitGroup(groupCode: string) {
return await invoke(NTMethod.QUIT_GROUP, [{ groupCode }])
}
async kickMember(groupCode: string, kickUids: string[], refuseForever = false, kickReason = '') {
return await invoke(NTMethod.KICK_MEMBER, [{ groupCode, kickUids, refuseForever, kickReason }])
}
/** timeStamp为秒数, 0为解除禁言 */
async banMember(groupCode: string, memList: Array<{ uid: string, timeStamp: number }>) {
return await invoke(NTMethod.MUTE_MEMBER, [{ groupCode, memList }])
}
async banGroup(groupCode: string, shutUp: boolean) {
return await invoke(NTMethod.MUTE_GROUP, [{ groupCode, shutUp }])
}
async setMemberCard(groupCode: string, memberUid: string, cardName: string) {
return await invoke(NTMethod.SET_MEMBER_CARD, [{ groupCode, uid: memberUid, cardName }])
}
async setMemberRole(groupCode: string, memberUid: string, role: GroupMemberRole) {
return await invoke(NTMethod.SET_MEMBER_ROLE, [{ groupCode, uid: memberUid, role }])
}
async setGroupName(groupCode: string, groupName: string) {
return await invoke(NTMethod.SET_GROUP_NAME, [{ groupCode, groupName }])
}
async getGroupRemainAtTimes(groupCode: string) {
return await invoke(NTMethod.GROUP_AT_ALL_REMAIN_COUNT, [{ groupCode }])
}
async removeGroupEssence(groupCode: string, msgId: string) {
const ntMsgApi = this.ctx.get('ntMsgApi')!
const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false)
return await invoke('nodeIKernelGroupService/removeGroupEssence', [{
req: {
groupCode: groupCode,
msgRandom: Number(data?.msgList[0].msgRandom),
msgSeq: Number(data?.msgList[0].msgSeq)
}
}])
}
async addGroupEssence(groupCode: string, msgId: string) {
const ntMsgApi = this.ctx.get('ntMsgApi')!
const data = await ntMsgApi.getMsgHistory({ chatType: 2, guildId: '', peerUid: groupCode }, msgId, 1, false)
return await invoke('nodeIKernelGroupService/addGroupEssence', [{
req: {
groupCode: groupCode,
msgRandom: Number(data?.msgList[0].msgRandom),
msgSeq: Number(data?.msgList[0].msgSeq)
}
}])
}
async createGroupFileFolder(groupId: string, folderName: string) {
return await invoke('nodeIKernelRichMediaService/createGroupFolder', [{ groupId, folderName }])
}
async deleteGroupFileFolder(groupId: string, folderId: string) {
return await invoke('nodeIKernelRichMediaService/deleteGroupFolder', [{ groupId, folderId }])
}
async deleteGroupFile(groupId: string, fileIdList: string[], busIdList: number[]) {
return await invoke('nodeIKernelRichMediaService/deleteGroupFile', [{ groupId, busIdList, fileIdList }])
}
async getGroupFileList(groupId: string, fileListForm: GetFileListParam) {
invoke('nodeIKernelMsgListener/onGroupFileInfoUpdate', [], { registerEvent: true })
const data = await invoke<{ fileInfo: GroupFileInfo }>(
'nodeIKernelRichMediaService/getGroupFileList',
[{
groupId,
fileListForm
}],
{
cbCmd: 'nodeIKernelMsgListener/onGroupFileInfoUpdate',
afterFirstCmd: false,
cmdCB: (payload, result) => payload.fileInfo.reqId === result
}
)
return data.fileInfo
}
async publishGroupBulletin(groupCode: string, req: PublishGroupBulletinReq) {
const ntUserApi = this.ctx.get('ntUserApi')!
const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!
return await invoke('nodeIKernelGroupService/publishGroupBulletin', [{ groupCode, psKey, req }])
}
async uploadGroupBulletinPic(groupCode: string, path: string) {
const ntUserApi = this.ctx.get('ntUserApi')!
const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!
return await invoke('nodeIKernelGroupService/uploadGroupBulletinPic', [{ groupCode, psKey, path }])
}
async getGroupRecommendContact(groupCode: string) {
const ret = await invoke('nodeIKernelGroupService/getGroupRecommendContactArkJson', [{ groupCode }])
return ret.arkJson
}
async queryCachedEssenceMsg(groupCode: string, msgSeq = '0', msgRandom = '0') {
return await invoke('nodeIKernelGroupService/queryCachedEssenceMsg', [{
key: {
groupCode,
msgSeq: +msgSeq,
msgRandom: +msgRandom
}
}])
}
async getGroupHonorList(groupCode: string) {
// 还缺点东西
return await invoke('nodeIKernelGroupService/getGroupHonorList', [{
req: {
groupCode: [+groupCode]
}
}])
}
async getGroupAllInfo(groupCode: string) {
invoke('nodeIKernelGroupListener/onGroupAllInfoChange', [], {
registerEvent: true
})
return await invoke<{ groupAll: GroupAllInfo }>(
'nodeIKernelGroupService/getGroupAllInfo',
[{
groupCode,
source: 4
}],
{
cbCmd: 'nodeIKernelGroupListener/onGroupAllInfoChange',
afterFirstCmd: false,
cmdCB: payload => payload.groupAll.groupCode === groupCode
}
)
}
async getGroupBulletinList(groupCode: string) {
invoke('nodeIKernelGroupListener/onGetGroupBulletinListResult', [], {
registerEvent: true
})
const ntUserApi = this.ctx.get('ntUserApi')!
const psKey = (await ntUserApi.getPSkey(['qun.qq.com'])).domainPskeyMap.get('qun.qq.com')!
return await invoke<{
groupCode: string
context: string
result: GroupBulletinListResult
}>(
'nodeIKernelGroupService/getGroupBulletinList',
[{
groupCode,
psKey,
context: '',
req: {
startIndex: -1,
num: 20,
needInstructionsForJoinGroup: 1,
needPublisherInfo: 1
}
}],
{
cbCmd: 'nodeIKernelGroupListener/onGetGroupBulletinListResult',
cmdCB: payload => payload.groupCode === groupCode,
afterFirstCmd: false
}
)
}
async setGroupAvatar(groupCode: string, path: string) {
return await invoke('nodeIKernelGroupService/setHeader', [{ path, groupCode }])
}
}

View File

@@ -1,7 +0,0 @@
export * from './file'
export * from './friend'
export * from './group'
export * from './msg'
export * from './user'
export * from './webapi'
export * from './window'

View File

@@ -1,266 +0,0 @@
import { invoke, NTMethod } from '../ntcall'
import { RawMessage, SendMessageElement, Peer, ChatType } from '../types'
import { Service, Context } from 'cordis'
import { selfInfo } from '@/common/globalVars'
declare module 'cordis' {
interface Context {
ntMsgApi: NTQQMsgApi
}
}
export class NTQQMsgApi extends Service {
static inject = ['ntUserApi']
constructor(protected ctx: Context) {
super(ctx, 'ntMsgApi', true)
}
async getTempChatInfo(chatType: ChatType, peerUid: string) {
return await invoke('nodeIKernelMsgService/getTempChatInfo', [{ chatType, peerUid }])
}
async setEmojiLike(peer: Peer, msgSeq: string, emojiId: string, setEmoji: boolean) {
// nt_qq/global/nt_data/Emoji/emoji-resource/sysface_res/apng/ 下可以看到所有QQ表情预览
// nt_qq/global/nt_data/Emoji/emoji-resource/face_config.json 里面有所有表情的id, 自带表情id是QSid, 标准emoji表情id是QCid
// 其实以官方文档为准是最好的https://bot.q.qq.com/wiki/develop/api-v2/openapi/emoji/model.html#EmojiType
const emojiType = emojiId.length > 3 ? '2' : '1'
return await invoke(NTMethod.EMOJI_LIKE, [{ peer, msgSeq, emojiId, emojiType, setEmoji }])
}
async getMultiMsg(peer: Peer, rootMsgId: string, parentMsgId: string) {
return await invoke(NTMethod.GET_MULTI_MSG, [{ peer, rootMsgId, parentMsgId }])
}
async activateChat(peer: Peer) {
return await invoke(NTMethod.ACTIVE_CHAT_PREVIEW, [{ peer, cnt: 1 }])
}
async activateChatAndGetHistory(peer: Peer, cnt: number) {
// 消息从旧到新
return await invoke(NTMethod.ACTIVE_CHAT_HISTORY, [{ peer, cnt, msgId: '0', queryOrder: true }])
}
async getAioFirstViewLatestMsgs(peer: Peer, cnt: number) {
return await invoke('nodeIKernelMsgService/getAioFirstViewLatestMsgs', [{ peer, cnt }])
}
async getMsgsByMsgId(peer: Peer, msgIds: string[]) {
if (!peer) throw new Error('peer is not allowed')
if (!msgIds) throw new Error('msgIds is not allowed')
return await invoke('nodeIKernelMsgService/getMsgsByMsgId', [{ peer, msgIds }])
}
async getMsgHistory(peer: Peer, msgId: string, cnt: number, queryOrder = false) {
// 默认情况下消息时间从旧到新
return await invoke(NTMethod.HISTORY_MSG, [{ peer, msgId, cnt, queryOrder }])
}
async recallMsg(peer: Peer, msgIds: string[]) {
return await invoke(NTMethod.RECALL_MSG, [{ peer, msgIds }])
}
async sendMsg(peer: Peer, msgElements: SendMessageElement[], timeout = 10000) {
const uniqueId = await this.generateMsgUniqueId(peer.chatType)
peer.guildId = uniqueId
const data = await invoke<{ msgList: RawMessage[] }>(
'nodeIKernelMsgService/sendMsg',
[{
msgId: '0',
peer,
msgElements,
msgAttributeInfos: new Map()
}],
{
cbCmd: 'nodeIKernelMsgListener/onMsgInfoListUpdate',
afterFirstCmd: false,
cmdCB: payload => {
for (const msgRecord of payload.msgList) {
if (msgRecord.guildId === uniqueId && msgRecord.sendStatus === 2) {
return true
}
}
return false
},
timeout
}
)
delete peer.guildId
return data.msgList.find(msgRecord => msgRecord.guildId === uniqueId)
}
async forwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]) {
const uniqueId = await this.generateMsgUniqueId(destPeer.chatType)
destPeer.guildId = uniqueId
const data = await invoke<{ msgList: RawMessage[] }>(
'nodeIKernelMsgService/forwardMsg',
[{
msgIds,
srcContact: srcPeer,
dstContacts: [destPeer],
commentElements: [],
msgAttributeInfos: new Map(),
}],
{
cbCmd: 'nodeIKernelMsgListener/onMsgInfoListUpdate',
afterFirstCmd: false,
cmdCB: payload => {
for (const msgRecord of payload.msgList) {
if (msgRecord.guildId === uniqueId && msgRecord.sendStatus === 2) {
return true
}
}
return false
}
}
)
delete destPeer.guildId
return data.msgList.filter(msgRecord => msgRecord.guildId === uniqueId)
}
async multiForwardMsg(srcPeer: Peer, destPeer: Peer, msgIds: string[]): Promise<RawMessage> {
const senderShowName = await this.ctx.ntUserApi.getSelfNick(true)
const msgInfos = msgIds.map(id => {
return { msgId: id, senderShowName }
})
const selfUid = selfInfo.uid
const data = await invoke<{ msgList: RawMessage[] }>(
'nodeIKernelMsgService/multiForwardMsgWithComment',
[{
msgInfos,
srcContact: srcPeer,
dstContact: destPeer,
commentElements: [],
msgAttributeInfos: new Map(),
}],
{
cbCmd: 'nodeIKernelMsgListener/onMsgInfoListUpdate',
afterFirstCmd: false,
cmdCB: payload => {
for (const msgRecord of payload.msgList) {
if (msgRecord.peerUid === destPeer.peerUid && msgRecord.senderUid === selfUid) {
return true
}
}
return false
}
}
)
for (const msg of data.msgList) {
const arkElement = msg.elements.find(ele => ele.arkElement)
if (!arkElement) {
continue
}
const forwardData = JSON.parse(arkElement.arkElement!.bytesData)
if (forwardData.app !== 'com.tencent.multimsg') {
continue
}
if (msg.peerUid === destPeer.peerUid && msg.senderUid === selfUid) {
return msg
}
}
throw new Error('转发消息超时')
}
async getSingleMsg(peer: Peer, msgSeq: string) {
return await invoke('nodeIKernelMsgService/getSingleMsg', [{ peer, msgSeq }])
}
async queryFirstMsgBySeq(peer: Peer, msgSeq: string) {
return await invoke('nodeIKernelMsgService/queryMsgsWithFilterEx', [{
msgId: '0',
msgTime: '0',
msgSeq,
params: {
chatInfo: peer,
filterMsgType: [],
filterSendersUid: [],
filterMsgToTime: '0',
filterMsgFromTime: '0',
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 1,
}
}])
}
async queryMsgsWithFilterExBySeq(peer: Peer, msgSeq: string, filterMsgTime: string, filterSendersUid: string[] = []) {
return await invoke('nodeIKernelMsgService/queryMsgsWithFilterEx', [{
msgId: '0',
msgTime: '0',
msgSeq,
params: {
chatInfo: peer,
filterMsgType: [],
filterSendersUid,
filterMsgToTime: filterMsgTime,
filterMsgFromTime: filterMsgTime,
isReverseOrder: true,
isIncludeCurrent: true,
pageLimit: 1,
}
}])
}
async setMsgRead(peer: Peer) {
return await invoke('nodeIKernelMsgService/setMsgRead', [{ peer }])
}
async getMsgEmojiLikesList(peer: Peer, msgSeq: string, emojiId: string, emojiType: string, count: number) {
return await invoke('nodeIKernelMsgService/getMsgEmojiLikesList', [{
peer,
msgSeq,
emojiId,
emojiType,
cnt: count
}])
}
async fetchFavEmojiList(count: number) {
return await invoke('nodeIKernelMsgService/fetchFavEmojiList', [{
resId: '',
count,
backwardFetch: true,
forceRefresh: true
}])
}
async generateMsgUniqueId(chatType: number) {
const time = await this.getServerTime()
const uniqueId = await invoke('nodeIKernelMsgService/generateMsgUniqueId', [{ chatType, time }])
if (typeof uniqueId === 'string') {
return uniqueId
} else {
const random = Math.trunc(Math.random() * 100)
return `${Date.now()}${random}`
}
}
async queryMsgsById(chatType: ChatType, msgId: string) {
const msgTime = this.getMsgTimeFromId(msgId)
return await invoke('nodeIKernelMsgService/queryMsgsWithFilterEx', [{
msgId,
msgTime: '0',
msgSeq: '0',
params: {
chatInfo: {
peerUid: '',
chatType
},
filterMsgToTime: msgTime,
filterMsgFromTime: msgTime,
isIncludeCurrent: true,
pageLimit: 1,
}
}])
}
getMsgTimeFromId(msgId: string) {
// 小概率相差1毫秒
return String(BigInt(msgId) >> 32n)
}
async getServerTime() {
return await invoke('nodeIKernelMSFService/getServerTime', [])
}
}

View File

@@ -1,286 +0,0 @@
import { User, UserDetailInfoByUin, UserDetailInfoByUinV2, UserDetailInfo, UserDetailSource, ProfileBizType, SimpleInfo } from '../types'
import { invoke } from '../ntcall'
import { getBuildVersion } from '@/common/utils'
import { RequestUtil } from '@/common/utils/request'
import { isNullable, pick, Time } from 'cosmokit'
import { Service, Context } from 'cordis'
import { selfInfo } from '@/common/globalVars'
declare module 'cordis' {
interface Context {
ntUserApi: NTQQUserApi
}
}
export class NTQQUserApi extends Service {
static inject = ['ntFriendApi', 'ntGroupApi']
constructor(protected ctx: Context) {
super(ctx, 'ntUserApi', true)
}
async setSelfAvatar(path: string) {
return await invoke(
'nodeIKernelProfileService/setHeader',
[{ path }],
{
timeout: 10 * Time.second // 10秒不一定够
}
)
}
async fetchUserDetailInfo(uid: string) {
const result = await invoke<{ info: UserDetailInfo }>(
'nodeIKernelProfileService/fetchUserDetailInfo',
[{
callFrom: 'BuddyProfileStore',
uid: [uid],
source: UserDetailSource.KSERVER,
bizList: [ProfileBizType.KALL]
}],
{
cbCmd: 'nodeIKernelProfileListener/onUserDetailInfoChanged',
afterFirstCmd: false,
cmdCB: payload => payload.info.uid === uid,
}
)
const { info } = result
const ret: User = {
...info.simpleInfo.coreInfo,
...info.simpleInfo.status,
...info.simpleInfo.vasInfo,
...info.commonExt,
...info.simpleInfo.baseInfo,
qqLevel: info.commonExt?.qqLevel,
pendantId: ''
}
return ret
}
async getUserDetailInfo(uid: string) {
if (getBuildVersion() >= 26702) {
return this.fetchUserDetailInfo(uid)
}
const result = await invoke<{ info: User }>(
'nodeIKernelProfileService/getUserDetailInfoWithBizInfo',
[{
uid,
bizList: [0]
}],
{
cbCmd: 'nodeIKernelProfileListener/onProfileDetailInfoChanged',
afterFirstCmd: false,
cmdCB: (payload) => payload.info.uid === uid,
}
)
return result.info
}
async getCookies(domain: string) {
const clientKeyData = await this.forceFetchClientKey()
if (clientKeyData?.result !== 0) {
throw new Error('获取clientKey失败')
}
const uin = selfInfo.uin
const requestUrl = 'https://ssl.ptlogin2.qq.com/jump?ptlang=1033&clientuin=' + uin + '&clientkey=' + clientKeyData.clientKey + '&u1=https%3A%2F%2F' + domain + '%2F' + uin + '%2Finfocenter&keyindex=19%27'
const cookies: { [key: string]: string } = await RequestUtil.HttpsGetCookies(requestUrl)
return cookies
}
async getPSkey(domains: string[]) {
return await invoke('nodeIKernelTipOffService/getPskey', [{ domains, isForNewPCQQ: true }])
}
async like(uid: string, count = 1) {
return await invoke(
'nodeIKernelProfileLikeService/setBuddyProfileLike',
[{
doLikeUserInfo: {
friendUid: uid,
sourceId: 71,
doLikeCount: count,
doLikeTollCount: 0
}
}]
)
}
async getUidByUinV1(uin: string, groupCode?: string) {
let uid = (await invoke('nodeIKernelUixConvertService/getUid', [{ uins: [uin] }])).uidInfo.get(uin)
if (!uid) {
const unveifyUid = (await this.getUserDetailInfoByUin(uin)).info.uid //特殊转换
if (unveifyUid.indexOf('*') === -1) {
uid = unveifyUid
}
}
if (!uid) {
const friends = await this.ctx.ntFriendApi.getFriends() //从好友列表转
uid = friends.find(item => item.uin === uin)?.uid
}
if (!uid && groupCode) {
const members = await this.ctx.ntGroupApi.getGroupMembers(groupCode)
uid = Array.from(members.values()).find(e => e.uin === uin)?.uid
}
return uid
}
async getUidByUinV2(uin: string) {
let uid = (await invoke('nodeIKernelGroupService/getUidByUins', [{ uinList: [uin] }])).uids.get(uin)
if (uid) return uid
uid = (await invoke('nodeIKernelProfileService/getUidByUin', [{ callFrom: 'FriendsServiceImpl', uin: [uin] }])).get(uin)
if (uid) return uid
uid = (await invoke('nodeIKernelUixConvertService/getUid', [{ uins: [uin] }])).uidInfo.get(uin)
if (uid) return uid
const unveifyUid = (await this.getUserDetailInfoByUinV2(uin)).detail.uid
//if (!unveifyUid.includes('*')) return unveifyUid
return unveifyUid
}
async getUidByUin(uin: string, groupCode?: string) {
if (getBuildVersion() >= 26702) {
return this.getUidByUinV2(uin)
}
return this.getUidByUinV1(uin, groupCode)
}
async getUserDetailInfoByUinV2(uin: string) {
return await invoke<UserDetailInfoByUinV2>(
'nodeIKernelProfileService/getUserDetailInfoByUin',
[{ uin }]
)
}
async getUserDetailInfoByUin(uin: string) {
return await invoke<UserDetailInfoByUin>(
'nodeIKernelProfileService/getUserDetailInfoByUin',
[{ uin }]
)
}
async getUinByUidV1(uid: string) {
const ret = await invoke('nodeIKernelUixConvertService/getUin', [{ uids: [uid] }])
let uin = ret.uinInfo.get(uid)
if (!uin) {
uin = (await this.getUserDetailInfo(uid)).uin
}
return uin
}
async getUinByUidV2(uid: string) {
let uin = (await invoke('nodeIKernelGroupService/getUinByUids', [{ uidList: [uid] }])).uins.get(uid)
if (uin) return uin
uin = (await invoke('nodeIKernelProfileService/getUinByUid', [{ callFrom: 'FriendsServiceImpl', uid: [uid] }])).get(uid)
if (uin) return uin
uin = (await invoke('nodeIKernelUixConvertService/getUin', [{ uids: [uid] }])).uinInfo.get(uid)
if (uin) return uin
uin = (await this.ctx.ntFriendApi.getBuddyIdMap(true)).get(uid)
if (uin) return uin
uin = (await this.getUserDetailInfo(uid)).uin
return uin
}
async getUinByUid(uid: string) {
if (getBuildVersion() >= 26702) {
return this.getUinByUidV2(uid)
}
return this.getUinByUidV1(uid)
}
async forceFetchClientKey() {
return await invoke('nodeIKernelTicketService/forceFetchClientKey', [{ url: '' }])
}
async getSelfNick(refresh = true) {
if ((refresh || !selfInfo.nick) && selfInfo.uid) {
const data = await this.getUserSimpleInfo(selfInfo.uid)
selfInfo.nick = data.nick
}
return selfInfo.nick
}
async setSelfStatus(status: number, extStatus: number, batteryStatus: number) {
return await invoke('nodeIKernelMsgService/setStatus', [{
statusReq: {
status,
extStatus,
batteryStatus,
}
}])
}
async getProfileLike(uid: string) {
return await invoke('nodeIKernelProfileLikeService/getBuddyProfileLike', [{
req: {
friendUids: [uid],
basic: 1,
vote: 1,
favorite: 0,
userProfile: 1,
type: 2,
start: 0,
limit: 20,
}
}])
}
async getUserSimpleInfoV2(uid: string, force = true) {
const data = await invoke<{ profiles: Record<string, SimpleInfo> }>(
'nodeIKernelProfileService/getUserSimpleInfo',
[{
uids: [uid],
force
}],
{
cbCmd: 'onProfileSimpleChanged',
afterFirstCmd: false,
cmdCB: payload => !isNullable(payload.profiles[uid]),
}
)
return data.profiles[uid].coreInfo
}
async getUserSimpleInfo(uid: string, force = true) {
if (getBuildVersion() >= 26702) {
return this.getUserSimpleInfoV2(uid, force)
}
const data = await invoke<{ profiles: Map<string, User> }>(
'nodeIKernelProfileService/getUserSimpleInfo',
[{
uids: [uid],
force
}],
{
cbCmd: 'nodeIKernelProfileListener/onProfileSimpleChanged',
afterFirstCmd: false,
cmdCB: payload => payload.profiles.has(uid),
}
)
const profile = data.profiles.get(uid)!
return pick(profile, ['nick', 'remark', 'uid', 'uin'])
}
async getCoreAndBaseInfo(uids: string[]) {
return await invoke(
'nodeIKernelProfileService/getCoreAndBaseInfo',
[{
uids,
callFrom: 'nodeStore'
}]
)
}
async getRobotUinRange() {
const data = await invoke(
'nodeIKernelRobotService/getRobotUinRange',
[{
req: {
justFetchMsgConfig: '1',
type: 1,
version: 0,
aioKeywordVersion: 0
}
}]
)
return data.response.robotUinRanges
}
}

View File

@@ -1,156 +0,0 @@
import { RequestUtil } from '@/common/utils/request'
import { Service, Context } from 'cordis'
import { Dict } from 'cosmokit'
declare module 'cordis' {
interface Context {
ntWebApi: NTQQWebApi
}
}
export enum WebHonorType {
ALL = 'all',
TALKACTIVE = 'talkative',
PERFROMER = 'performer',
LEGEND = 'legend',
STORONGE_NEWBI = 'strong_newbie',
EMOTION = 'emotion'
}
export class NTQQWebApi extends Service {
static inject = ['ntUserApi']
constructor(protected ctx: Context) {
super(ctx, 'ntWebApi', true)
}
genBkn(sKey: string) {
sKey = sKey || ''
let hash = 5381
for (let i = 0; i < sKey.length; i++) {
const code = sKey.charCodeAt(i)
hash = hash + (hash << 5) + code
}
return (hash & 0x7FFFFFFF).toString()
}
async getGroupHonorInfo(groupCode: string, getType: string) {
const getDataInternal = async (groupCode: string, type: number) => {
const url = 'https://qun.qq.com/interactive/honorlist?gc=' + groupCode + '&type=' + type
let resJson
try {
const res = await RequestUtil.HttpGetText(url, 'GET', '', { 'Cookie': cookieStr })
const match = res.match(/window\.__INITIAL_STATE__=(.*?);/)
if (match) {
resJson = JSON.parse(match[1].trim())
}
if (type === 1) {
return resJson?.talkativeList
} else {
return resJson?.actorList
}
} catch (e) {
this.ctx.logger.error('获取当前群荣耀失败', url, e)
}
return undefined
}
const honorInfo: Dict = { group_id: groupCode }
const cookieObject = await this.ctx.ntUserApi.getCookies('qun.qq.com')
const cookieStr = this.cookieToString(cookieObject)
if (getType === WebHonorType.TALKACTIVE || getType === WebHonorType.ALL) {
try {
const RetInternal = await getDataInternal(groupCode, 1)
if (!RetInternal) {
throw new Error('获取龙王信息失败')
}
honorInfo.current_talkative = {
user_id: RetInternal[0]?.uin,
avatar: RetInternal[0]?.avatar,
nickname: RetInternal[0]?.name,
day_count: 0,
description: RetInternal[0]?.desc
}
honorInfo.talkative_list = [];
for (const talkative_ele of RetInternal) {
honorInfo.talkative_list.push({
user_id: talkative_ele?.uin,
avatar: talkative_ele?.avatar,
description: talkative_ele?.desc,
day_count: 0,
nickname: talkative_ele?.name
})
}
} catch (e) {
this.ctx.logger.error(e)
}
}
if (getType === WebHonorType.PERFROMER || getType === WebHonorType.ALL) {
try {
const RetInternal = await getDataInternal(groupCode, 2)
if (!RetInternal) {
throw new Error('获取群聊之火失败')
}
honorInfo.performer_list = []
for (const performer_ele of RetInternal) {
honorInfo.performer_list.push({
user_id: performer_ele?.uin,
nickname: performer_ele?.name,
avatar: performer_ele?.avatar,
description: performer_ele?.desc
})
}
} catch (e) {
this.ctx.logger.error(e)
}
}
if (getType === WebHonorType.PERFROMER || getType === WebHonorType.ALL) {
try {
const RetInternal = await getDataInternal(groupCode, 3)
if (!RetInternal) {
throw new Error('获取群聊炽焰失败')
}
honorInfo.legend_list = []
for (const legend_ele of RetInternal) {
honorInfo.legend_list.push({
user_id: legend_ele?.uin,
nickname: legend_ele?.name,
avatar: legend_ele?.avatar,
desc: legend_ele?.description
})
}
} catch (e) {
this.ctx.logger.error('获取群聊炽焰失败', e)
}
}
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
try {
const RetInternal = await getDataInternal(groupCode, 6)
if (!RetInternal) {
throw new Error('获取快乐源泉失败')
}
honorInfo.emotion_list = []
for (const emotion_ele of RetInternal) {
honorInfo.emotion_list.push({
user_id: emotion_ele?.uin,
nickname: emotion_ele?.name,
avatar: emotion_ele?.avatar,
desc: emotion_ele?.description
})
}
} catch (e) {
this.ctx.logger.error('获取快乐源泉失败', e)
}
}
//冒尖小春笋好像已经被tx扬了
if (getType === WebHonorType.EMOTION || getType === WebHonorType.ALL) {
honorInfo.strong_newbie_list = []
}
return honorInfo
}
private cookieToString(cookieObject: Dict) {
return Object.entries(cookieObject).map(([key, value]) => `${key}=${value}`).join('; ')
}
}

View File

@@ -1,63 +0,0 @@
import { invoke, NTClass, NTMethod } from '../ntcall'
import { GeneralCallResult } from '../services'
import { ReceiveCmdS } from '../hook'
import { BrowserWindow } from 'electron'
import { Service, Context } from 'cordis'
declare module 'cordis' {
interface Context {
ntWindowApi: NTQQWindowApi
}
}
export interface NTQQWindow {
windowName: string
windowUrlHash: string
}
export namespace NTQQWindows {
export const GroupHomeWorkWindow: NTQQWindow = {
windowName: 'GroupHomeWorkWindow',
windowUrlHash: '#/group-home-work',
}
export const GroupNotifyFilterWindow: NTQQWindow = {
windowName: 'GroupNotifyFilterWindow',
windowUrlHash: '#/group-notify-filter',
}
export const GroupEssenceWindow: NTQQWindow = {
windowName: 'GroupEssenceWindow',
windowUrlHash: '#/group-essence',
}
}
export class NTQQWindowApi extends Service {
constructor(protected ctx: Context) {
super(ctx, 'ntWindowApi', true)
}
/** 打开窗口并获取对应的下发事件 */
async openWindow<R = GeneralCallResult>(
ntQQWindow: NTQQWindow,
args: unknown[],
cbCmd: ReceiveCmdS | undefined,
autoCloseSeconds: number = 2,
) {
const result = await invoke<R>(
NTMethod.OPEN_EXTRA_WINDOW,
[ntQQWindow.windowName, ...args],
{
className: NTClass.WINDOW_API,
cbCmd,
afterFirstCmd: false,
}
)
setTimeout(() => {
for (const w of BrowserWindow.getAllWindows()) {
if (w.webContents.getURL().indexOf(ntQQWindow.windowUrlHash) != -1) {
w.close()
}
}
}, autoCloseSeconds * 1000)
return result
}
}

View File

@@ -0,0 +1,97 @@
import {ElementType, SendPicElement, SendPttElement, SendReplyElement, SendTextElement, AtType} from "./types";
import {NTQQApi} from "./ntcall";
export class SendMsgElementConstructor {
static text(content: string): SendTextElement {
return {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content,
atType: AtType.notAt,
atUid: "",
atTinyId: "",
atNtUid: "",
},
};
}
static at(atUid: string, atNtUid: string, atType: AtType, atName: string): SendTextElement {
return {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content: `@${atName}`,
atType,
atUid,
atTinyId: "",
atNtUid,
},
};
}
static reply(msgSeq: string, msgId: string, senderUin: string, senderUinStr: string): SendReplyElement {
return {
elementType: ElementType.REPLY,
elementId: "",
replyElement: {
replayMsgSeq: msgSeq, // raw.msgSeq
replayMsgId: msgId, // raw.msgId
senderUin: senderUin,
senderUinStr: senderUinStr,
}
}
}
static async pic(picPath: string): Promise<SendPicElement>{
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(picPath);
const imageSize = await NTQQApi.getImageSize(picPath);
const picElement = {
md5HexStr: md5,
fileSize: fileSize,
picWidth: imageSize.width,
picHeight: imageSize.height,
fileName: fileName,
sourcePath: path,
original: true,
picType: 1001,
picSubType: 0,
fileUuid: "",
fileSubId: "",
thumbFileSize: 0,
summary: "",
};
return {
elementType: ElementType.PIC,
elementId: "",
picElement
};
}
static async ptt(pttPath: string):Promise<SendPttElement> {
const {md5, fileName, path, fileSize} = await NTQQApi.uploadFile(pttPath);
return {
elementType: ElementType.PTT,
elementId: "",
pttElement: {
fileName: fileName,
filePath: path,
md5HexStr: md5,
fileSize: fileSize,
duration: Math.max(1, Math.round(fileSize / 1024 / 3)), // 一秒钟大概是3kb大小, 小于1秒的按1秒算
formatType: 1,
voiceType: 1,
voiceChangeType: 0,
canConvert2Text: true,
waveAmplitudes: [
0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17,
],
fileSubId: "",
playState: 1,
autoConvertText: 0,
}
};
}
}

View File

@@ -1,267 +0,0 @@
import { unlink } from 'node:fs/promises'
import { Service, Context } from 'cordis'
import { registerCallHook, registerReceiveHook, ReceiveCmdS } from './hook'
import { Config as LLOBConfig } from '../common/types'
import { isNumeric } from '../common/utils/misc'
import { NTMethod } from './ntcall'
import {
RawMessage,
GroupNotify,
FriendRequestNotify,
FriendRequest,
GroupMember,
CategoryFriend,
SimpleInfo,
ChatType,
BuddyReqType,
GrayTipElementSubType
} from './types'
import { selfInfo, llonebotError } from '../common/globalVars'
import { version } from '../version'
import { invoke } from './ntcall'
declare module 'cordis' {
interface Context {
app: Core
}
interface Events {
'nt/message-created': (input: RawMessage) => void
'nt/message-deleted': (input: RawMessage) => void
'nt/message-sent': (input: RawMessage) => void
'nt/group-notify': (input: GroupNotify) => void
'nt/friend-request': (input: FriendRequest) => void
'nt/group-member-info-updated': (input: { groupCode: string, members: GroupMember[] }) => void
'nt/system-message-created': (input: Uint8Array) => void
}
}
class Core extends Service {
static inject = ['ntMsgApi', 'ntFriendApi', 'ntGroupApi', 'store']
public startTime = 0
constructor(protected ctx: Context, public config: Core.Config) {
super(ctx, 'app', true)
}
public start() {
if (!this.config.ob11.enable && !this.config.satori.enable) {
llonebotError.otherError = 'LLOneBot 未启动'
this.ctx.logger.info('LLOneBot 开关设置为关闭,不启动 LLOneBot')
return
}
this.startTime = Date.now()
this.registerListener()
this.ctx.logger.info(`LLOneBot/${version}`)
this.ctx.on('llob/config-updated', input => {
Object.assign(this.config, input)
})
}
private registerListener() {
registerReceiveHook<{
data?: CategoryFriend[]
userSimpleInfos?: Map<string, SimpleInfo> //V2
buddyCategory?: CategoryFriend[] //V2
}>(ReceiveCmdS.FRIENDS, (payload) => {
let uids: string[] = []
if (payload.buddyCategory) {
uids = payload.buddyCategory.flatMap(item => item.buddyUids)
} else if (payload.data) {
uids = payload.data.flatMap(item => item.buddyList.map(e => e.uid))
}
for (const uid of uids) {
this.ctx.ntMsgApi.activateChat({ peerUid: uid, chatType: ChatType.C2C })
}
this.ctx.logger.info('好友列表变动', uids.length)
})
// 自动清理新消息文件
registerReceiveHook<{ msgList: RawMessage[] }>([ReceiveCmdS.NEW_MSG, ReceiveCmdS.NEW_ACTIVE_MSG], (payload) => {
if (!this.config.autoDeleteFile) {
return
}
for (const message of payload.msgList) {
for (const msgElement of message.elements) {
setTimeout(() => {
const picPath = msgElement.picElement?.sourcePath
const picThumbPath = [...(msgElement.picElement?.thumbPath ?? []).values()]
const pttPath = msgElement.pttElement?.filePath
const filePath = msgElement.fileElement?.filePath
const videoPath = msgElement.videoElement?.filePath
const videoThumbPath = [...(msgElement.videoElement?.thumbPath ?? []).values()]
const pathList = [picPath, ...picThumbPath, pttPath, filePath, videoPath, ...videoThumbPath]
if (msgElement.picElement) {
pathList.push(...Object.values(msgElement.picElement.thumbPath))
}
for (const path of pathList) {
if (path) {
unlink(path).then(() => this.ctx.logger.info('删除文件成功', path))
}
}
}, this.config.autoDeleteFileSecond! * 1000)
}
}
})
registerReceiveHook<{ info: { status: number } }>(ReceiveCmdS.SELF_STATUS, (info) => {
Object.assign(selfInfo, { online: info.info.status !== 20 })
})
const activatedPeerUids: string[] = []
registerReceiveHook<{
changedRecentContactLists: {
listType: number
sortedContactList: string[]
changedList: {
id: string // peerUid
chatType: ChatType
}[]
}[]
}>(ReceiveCmdS.RECENT_CONTACT, async (payload) => {
for (const recentContact of payload.changedRecentContactLists) {
for (const contact of recentContact.changedList) {
if (activatedPeerUids.includes(contact.id)) continue
activatedPeerUids.push(contact.id)
const peer = { peerUid: contact.id, chatType: contact.chatType }
if (contact.chatType === ChatType.TempC2CFromGroup) {
this.ctx.ntMsgApi.activateChatAndGetHistory(peer, 2).then(res => {
for (const msg of res.msgList) {
if (Date.now() / 1000 - Number(msg.msgTime) > 3) {
continue
}
if (msg.senderUin && msg.senderUin !== '0') {
this.ctx.store.addMsgCache(msg)
}
this.ctx.parallel('nt/message-created', msg)
}
})
} else {
this.ctx.ntMsgApi.activateChat(peer)
}
}
}
})
registerCallHook(NTMethod.DELETE_ACTIVE_CHAT, async (payload) => {
const peerUid = payload[0] as string
this.ctx.logger.info('激活的聊天窗口被删除,准备重新激活', peerUid)
let chatType = ChatType.C2C
if (isNumeric(peerUid)) {
chatType = ChatType.Group
}
else if (!(await this.ctx.ntFriendApi.isBuddy(peerUid))) {
chatType = ChatType.TempC2CFromGroup
}
const peer = { peerUid, chatType }
await this.ctx.sleep(1000)
this.ctx.ntMsgApi.activateChat(peer).then((r) => {
this.ctx.logger.info('重新激活聊天窗口', peer, { result: r.result, errMsg: r.errMsg })
})
})
registerReceiveHook<{
groupCode: string
dataSource: number
members: Set<GroupMember>
}>(ReceiveCmdS.GROUP_MEMBER_INFO_UPDATE, async (payload) => {
const groupCode = payload.groupCode
const members = Array.from(payload.members.values())
this.ctx.parallel('nt/group-member-info-updated', { groupCode, members })
})
registerReceiveHook<{ msgList: RawMessage[] }>([ReceiveCmdS.NEW_MSG, ReceiveCmdS.NEW_ACTIVE_MSG], payload => {
for (const message of payload.msgList) {
// 过滤启动之前的消息
if (parseInt(message.msgTime) < this.startTime / 1000) {
continue
}
if (message.senderUin && message.senderUin !== '0') {
this.ctx.store.addMsgCache(message)
}
this.ctx.parallel('nt/message-created', message)
}
})
const sentMsgIds = new Map<string, boolean>()
const recallMsgIds: string[] = [] // 避免重复上报
registerReceiveHook<{ msgList: RawMessage[] }>([ReceiveCmdS.UPDATE_MSG], payload => {
for (const msg of payload.msgList) {
if (
msg.recallTime !== '0' &&
msg.msgType === 5 &&
msg.subMsgType === 4 &&
msg.elements[0]?.grayTipElement?.subElementType === GrayTipElementSubType.Revoke &&
!recallMsgIds.includes(msg.msgId)
) {
recallMsgIds.shift()
recallMsgIds.push(msg.msgId)
this.ctx.parallel('nt/message-deleted', msg)
} else if (sentMsgIds.get(msg.msgId)) {
sentMsgIds.delete(msg.msgId)
this.ctx.parallel('nt/message-sent', msg)
}
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmdS.SELF_SEND_MSG, payload => {
if (!this.config.reportSelfMessage) {
return
}
sentMsgIds.set(payload.msgRecord.msgId, true)
})
const groupNotifyFlags: string[] = []
registerReceiveHook<{
doubt: boolean
oldestUnreadSeq: string
unreadCount: number
}>(ReceiveCmdS.UNREAD_GROUP_NOTIFY, async (payload) => {
if (payload.unreadCount) {
let notifies: GroupNotify[]
try {
notifies = await this.ctx.ntGroupApi.getSingleScreenNotifies(payload.unreadCount)
} catch (e) {
return
}
for (const notify of notifies) {
const flag = notify.group.groupCode + '|' + notify.seq + '|' + notify.type
const notifyTime = parseInt(notify.seq) / 1000
if (groupNotifyFlags.includes(flag) || notifyTime < this.startTime) {
continue
}
groupNotifyFlags.shift()
groupNotifyFlags.push(flag)
this.ctx.parallel('nt/group-notify', notify)
}
}
})
registerReceiveHook<FriendRequestNotify>(ReceiveCmdS.FRIEND_REQUEST, payload => {
for (const req of payload.data.buddyReqs) {
if (!!req.isInitiator || (req.isDecide && req.reqType !== BuddyReqType.MeInitiatorWaitPeerConfirm)) {
continue
}
if (+req.reqTime < this.startTime / 1000) {
continue
}
this.ctx.parallel('nt/friend-request', req)
}
})
invoke('nodeIKernelMsgListener/onRecvSysMsg', [], { registerEvent: true })
registerReceiveHook<{
msgBuf: number[]
}>('nodeIKernelMsgListener/onRecvSysMsg', payload => {
this.ctx.parallel('nt/system-message-created', Uint8Array.from(payload.msgBuf))
})
}
}
namespace Core {
export interface Config extends LLOBConfig {
}
}
export default Core

View File

@@ -1,365 +0,0 @@
import ffmpeg from 'fluent-ffmpeg'
import faceConfig from './helper/face_config.json'
import pathLib from 'node:path'
import {
AtType,
ElementType,
FaceIndex,
PicType,
SendArkElement,
SendFaceElement,
SendFileElement,
SendMarketFaceElement,
SendPicElement,
SendPttElement,
SendReplyElement,
SendTextElement,
SendVideoElement,
} from './types'
import { stat, writeFile, copyFile, unlink } from 'node:fs/promises'
import { calculateFileMD5 } from '../common/utils/file'
import { defaultVideoThumb, getVideoInfo } from '../common/utils/video'
import { encodeSilk } from '../common/utils/audio'
import { Context } from 'cordis'
import { isNullable } from 'cosmokit'
export namespace SendElement {
export function text(content: string): SendTextElement {
return {
elementType: ElementType.Text,
elementId: '',
textElement: {
content,
atType: AtType.Unknown,
atUid: '',
atTinyId: '',
atNtUid: '',
},
}
}
export function at(atUid: string, atNtUid: string, atType: AtType, display: string): SendTextElement {
return {
elementType: ElementType.Text,
elementId: '',
textElement: {
content: display,
atType,
atUid,
atTinyId: '',
atNtUid,
},
}
}
export function reply(msgSeq: string, msgId: string, senderUin: string): SendReplyElement {
return {
elementType: ElementType.Reply,
elementId: '',
replyElement: {
replayMsgSeq: msgSeq,
replayMsgId: msgId,
senderUin: senderUin,
senderUinStr: senderUin,
},
}
}
export async function pic(ctx: Context, picPath: string, summary = '', subType: 0 | 1 = 0, isFlashPic?: boolean): Promise<SendPicElement> {
const { md5, fileName, path, fileSize } = await ctx.ntFileApi.uploadFile(picPath, ElementType.Pic, subType)
if (fileSize === 0) {
throw '文件异常,大小为 0'
}
const imageSize = await ctx.ntFileApi.getImageSize(picPath)
const picElement = {
md5HexStr: md5,
fileSize: fileSize.toString(),
picWidth: imageSize.width,
picHeight: imageSize.height,
fileName: fileName,
sourcePath: path,
original: true,
picType: imageSize.type === 'gif' ? PicType.GIF : PicType.JPEG,
picSubType: subType,
fileUuid: '',
fileSubId: '',
thumbFileSize: 0,
summary,
isFlashPic,
}
ctx.logger.info('图片信息', picElement)
return {
elementType: ElementType.Pic,
elementId: '',
picElement,
}
}
export async function file(ctx: Context, filePath: string, fileName: string, folderId = ''): Promise<SendFileElement> {
const fileSize = (await stat(filePath)).size.toString()
if (fileSize === '0') {
ctx.logger.warn(`文件${fileName}异常,大小为 0`)
throw new Error('文件异常,大小为 0')
}
const element: SendFileElement = {
elementType: ElementType.File,
elementId: '',
fileElement: {
fileName,
folderId,
filePath,
fileSize,
},
}
return element
}
export async function video(ctx: Context, filePath: string, fileName = '', diyThumbPath = ''): Promise<SendVideoElement> {
try {
await stat(filePath)
} catch (e) {
throw `文件${filePath}异常,不存在`
}
ctx.logger.info('复制视频到QQ目录', filePath)
const { fileName: _fileName, path, fileSize, md5 } = await ctx.ntFileApi.uploadFile(filePath, ElementType.Video)
ctx.logger.info('复制视频到QQ目录完成', path)
if (fileSize === 0) {
throw '文件异常大小为0'
}
const maxMB = 100;
if (fileSize > 1024 * 1024 * maxMB) {
throw `视频过大,最大支持${maxMB}MB当前文件大小${fileSize}B`
}
let thumbDir = path.replace(`${pathLib.sep}Ori${pathLib.sep}`, `${pathLib.sep}Thumb${pathLib.sep}`)
thumbDir = pathLib.dirname(thumbDir)
// log("thumb 目录", thumb)
let videoInfo = {
width: 1920,
height: 1080,
time: 15,
format: 'mp4',
size: fileSize,
filePath,
}
try {
videoInfo = await getVideoInfo(ctx, path)
ctx.logger.info('视频信息', videoInfo)
} catch (e) {
ctx.logger.info('获取视频信息失败', e)
}
const createThumb = new Promise<string>((resolve, reject) => {
const thumbFileName = `${md5}_0.png`
const thumbPath = pathLib.join(thumbDir, thumbFileName)
ctx.logger.info('开始生成视频缩略图', filePath)
let completed = false
function useDefaultThumb() {
if (completed) return
ctx.logger.info('获取视频封面失败,使用默认封面')
writeFile(thumbPath, defaultVideoThumb)
.then(() => {
resolve(thumbPath)
})
.catch(reject)
}
setTimeout(useDefaultThumb, 5000)
ffmpeg(filePath)
.on('error', () => {
if (diyThumbPath) {
copyFile(diyThumbPath, thumbPath)
.then(() => {
completed = true
resolve(thumbPath)
})
.catch(reject)
} else {
useDefaultThumb()
}
})
.screenshots({
timestamps: [0],
filename: thumbFileName,
folder: thumbDir,
size: videoInfo.width + 'x' + videoInfo.height,
})
.on('end', () => {
ctx.logger.info('生成视频缩略图', thumbPath)
completed = true
resolve(thumbPath)
})
})
const thumbPath = new Map()
const _thumbPath = await createThumb
ctx.logger.info('生成视频缩略图', _thumbPath)
const thumbSize = (await stat(_thumbPath)).size
// log("生成缩略图", _thumbPath)
thumbPath.set(0, _thumbPath)
const thumbMd5 = await calculateFileMD5(_thumbPath)
const element: SendVideoElement = {
elementType: ElementType.Video,
elementId: '',
videoElement: {
fileName: fileName || _fileName,
filePath: path,
videoMd5: md5,
thumbMd5,
fileTime: videoInfo.time,
thumbPath: thumbPath,
thumbSize,
thumbWidth: videoInfo.width,
thumbHeight: videoInfo.height,
fileSize: String(fileSize),
},
}
ctx.logger.info('videoElement', element)
return element
}
export async function ptt(ctx: Context, pttPath: string): Promise<SendPttElement> {
const { converted, path: silkPath, duration } = await encodeSilk(ctx, pttPath)
if (!silkPath) {
throw '语音转换失败, 请检查语音文件是否正常'
}
// log("生成语音", silkPath, duration);
const { md5, fileName, path, fileSize } = await ctx.ntFileApi.uploadFile(silkPath, ElementType.Ptt)
if (fileSize === 0) {
throw '文件异常大小为0'
}
if (converted) {
unlink(silkPath)
}
return {
elementType: ElementType.Ptt,
elementId: '',
pttElement: {
fileName: fileName,
filePath: path,
md5HexStr: md5,
fileSize: String(fileSize),
duration: duration,
formatType: 1,
voiceType: 1,
voiceChangeType: 0,
canConvert2Text: true,
waveAmplitudes: [0, 18, 9, 23, 16, 17, 16, 15, 44, 17, 24, 20, 14, 15, 17],
fileSubId: '',
playState: 1,
autoConvertText: 0,
},
}
}
export function face(faceId: number, faceType?: number): SendFaceElement {
// 从face_config.json中获取表情名称
const sysFaces = faceConfig.sysface
const face = sysFaces.find(face => face.QSid === String(faceId))
if (!faceType) {
if (faceId < 222) {
faceType = 1
} else {
faceType = 2
}
if (face?.AniStickerType) {
faceType = 3
}
}
return {
elementType: ElementType.Face,
elementId: '',
faceElement: {
faceIndex: faceId,
faceType,
faceText: face?.QDes,
stickerId: face?.AniStickerId,
stickerType: face?.AniStickerType,
packId: face?.AniStickerPackId,
sourceType: 1,
},
}
}
export function mface(emojiPackageId: number, emojiId: string, key: string, summary?: string): SendMarketFaceElement {
return {
elementType: ElementType.MarketFace,
elementId: '',
marketFaceElement: {
imageWidth: 300,
imageHeight: 300,
emojiPackageId,
emojiId,
key,
faceName: summary || '[商城表情]',
},
}
}
export function dice(resultId?: string | number): SendFaceElement {
// 实际测试并不能控制结果
// 随机1到6
if (isNullable(resultId)) resultId = Math.floor(Math.random() * 6) + 1
return {
elementType: ElementType.Face,
elementId: '',
faceElement: {
faceIndex: FaceIndex.Dice,
faceType: 3,
faceText: '[骰子]',
packId: '1',
stickerId: '33',
sourceType: 1,
stickerType: 2,
resultId: resultId.toString(),
surpriseId: '',
// "randomType": 1,
},
}
}
// 猜拳(石头剪刀布)表情
export function rps(resultId?: string | number): SendFaceElement {
// 实际测试并不能控制结果
if (isNullable(resultId)) resultId = Math.floor(Math.random() * 3) + 1
return {
elementType: ElementType.Face,
elementId: '',
faceElement: {
faceIndex: FaceIndex.RPS,
faceText: '[包剪锤]',
faceType: 3,
packId: '1',
stickerId: '34',
sourceType: 1,
stickerType: 2,
resultId: resultId.toString(),
surpriseId: '',
// "randomType": 1,
},
}
}
export function ark(data: string): SendArkElement {
return {
elementType: ElementType.Ark,
elementId: '',
arkElement: {
bytesData: data,
linkInfo: null,
subElementType: null,
},
}
}
export function shake(): SendFaceElement {
return {
elementType: ElementType.Face,
elementId: '',
faceElement: {
faceIndex: 1,
faceType: 5,
pokeType: 1,
},
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
import { Context } from 'cordis'
interface ServerRkeyData {
group_rkey: string
private_rkey: string
expired_time: number
}
export class RkeyManager {
private serverUrl: string = ''
private rkeyData: ServerRkeyData = {
group_rkey: '',
private_rkey: '',
expired_time: 0
}
constructor(protected ctx: Context, serverUrl: string) {
this.serverUrl = serverUrl
}
async getRkey() {
if (this.isExpired()) {
try {
await this.refreshRkey()
} catch (e) {
this.ctx.logger.error('获取rkey失败', e)
}
}
return this.rkeyData
}
isExpired(): boolean {
const now = new Date().getTime() / 1000
return now > this.rkeyData.expired_time
}
async refreshRkey() {
this.rkeyData = await this.fetchServerRkey()
}
async fetchServerRkey(): Promise<ServerRkeyData> {
const response = await fetch(this.serverUrl)
if (!response.ok) {
throw new Error(response.statusText)
}
return response.json()
}
}

View File

@@ -1,175 +1,161 @@
import type { BrowserWindow } from 'electron'
import { NTClass, NTMethod } from './ntcall'
import { log } from '@/common/utils'
import { randomUUID } from 'node:crypto'
import { Dict } from 'cosmokit'
import { BrowserWindow } from 'electron';
import { getConfigUtil, log } from "../common/utils";
import { NTQQApi, NTQQApiClass, sendMessagePool } from "./ntcall";
import { Group, User } from "./types";
import { RawMessage } from "./types";
import { addHistoryMsg, friends, groups, msgHistory } from "../common/data";
import { v4 as uuidv4 } from 'uuid';
export const hookApiCallbacks: Record<string, (res: any) => void> = {}
export let hookApiCallbacks: Record<string, (apiReturn: any) => void> = {}
export enum ReceiveCmdS {
RECENT_CONTACT = 'nodeIKernelRecentContactListener/onRecentContactListChangedVer2',
UPDATE_MSG = 'nodeIKernelMsgListener/onMsgInfoListUpdate',
UPDATE_ACTIVE_MSG = 'nodeIKernelMsgListener/onActiveMsgInfoUpdate',
NEW_MSG = 'nodeIKernelMsgListener/onRecvMsg',
NEW_ACTIVE_MSG = 'nodeIKernelMsgListener/onRecvActiveMsg',
SELF_SEND_MSG = 'nodeIKernelMsgListener/onAddSendMsg',
USER_INFO = 'nodeIKernelProfileListener/onProfileSimpleChanged',
USER_DETAIL_INFO = 'nodeIKernelProfileListener/onProfileDetailInfoChanged',
GROUPS = 'nodeIKernelGroupListener/onGroupListUpdate',
GROUPS_STORE = 'onGroupListUpdate',
GROUP_MEMBER_INFO_UPDATE = 'nodeIKernelGroupListener/onMemberInfoChange',
FRIENDS = 'onBuddyListChange',
MEDIA_DOWNLOAD_COMPLETE = 'nodeIKernelMsgListener/onRichMediaDownloadComplete',
UNREAD_GROUP_NOTIFY = 'nodeIKernelGroupListener/onGroupNotifiesUnreadCountUpdated',
GROUP_NOTIFY = 'nodeIKernelGroupListener/onGroupSingleScreenNotifies',
FRIEND_REQUEST = 'nodeIKernelBuddyListener/onBuddyReqChange',
SELF_STATUS = 'nodeIKernelProfileListener/onSelfStatusChanged',
CACHE_SCAN_FINISH = 'nodeIKernelStorageCleanListener/onFinishScan',
MEDIA_UPLOAD_COMPLETE = 'nodeIKernelMsgListener/onRichMediaUploadComplete',
export enum ReceiveCmd {
UPDATE_MSG = "nodeIKernelMsgListener/onMsgInfoListUpdate",
NEW_MSG = "nodeIKernelMsgListener/onRecvMsg",
SELF_SEND_MSG = "nodeIKernelMsgListener/onAddSendMsg",
USER_INFO = "nodeIKernelProfileListener/onProfileSimpleChanged",
GROUPS = "nodeIKernelGroupListener/onGroupListUpdate",
GROUPS_UNIX = "onGroupListUpdate",
FRIENDS = "onBuddyListChange"
}
type NTReturnData = [
{
type: 'request'
eventName: NTClass
callbackId?: string
},
{
cmdName: ReceiveCmdS
cmdType: 'event'
payload: unknown
}[]
]
interface NTQQApiReturnData<PayloadType = unknown> extends Array<any> {
0: {
"type": "request",
"eventName": NTQQApiClass,
"callbackId"?: string
},
1:
{
cmdName: ReceiveCmd,
cmdType: "event",
payload: PayloadType
}[]
}
const logHook = false
const receiveHooks: Array<{
method: ReceiveCmdS[]
hookFunc: (payload: any) => void | Promise<void>
id: string
let receiveHooks: Array<{
method: ReceiveCmd,
hookFunc: (payload: any) => void,
id: string
}> = []
const callHooks: Array<{
method: NTMethod[]
hookFunc: (callParams: unknown[]) => void | Promise<void>
}> = []
export function hookNTQQApiReceive(window: BrowserWindow, onlyLog: boolean) {
window.webContents.send = new Proxy(window.webContents.send, {
apply(target, thisArg, args: [channel: string, ...args: NTReturnData]) {
try {
if (logHook && !args[1]?.eventName?.startsWith('ns-LoggerApi')) {
log('received ntqq api message', args)
}
} catch { }
if (!onlyLog) {
if (args[2] instanceof Array) {
for (const receiveData of args[2]) {
const ntMethodName = receiveData.cmdName
for (const hook of receiveHooks) {
if (hook.method.includes(ntMethodName)) {
Promise.resolve(hook.hookFunc(receiveData.payload))
}
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))
if (args?.[1] instanceof Array) {
for (let receiveData of args?.[1]) {
const ntQQApiMethodName = receiveData.cmdName;
// log(`received ntqq api message: ${channel} ${ntQQApiMethodName}`, JSON.stringify(receiveData))
for (let hook of receiveHooks) {
if (hook.method === ntQQApiMethodName) {
new Promise((resolve, reject) => {
try {
hook.hookFunc(receiveData.payload);
} catch (e) {
log("hook error", e, receiveData.payload)
}
}).then()
}
}
}
}
}
if (args[1]?.callbackId) {
const callbackId = args[1].callbackId
if (hookApiCallbacks[callbackId]) {
Promise.resolve(hookApiCallbacks[callbackId](args[2]))
delete hookApiCallbacks[callbackId]
}
if (args[0]?.callbackId) {
// log("hookApiCallback", hookApiCallbacks, args)
const callbackId = args[0].callbackId;
if (hookApiCallbacks[callbackId]) {
// log("callback found")
new Promise((resolve, reject) => {
hookApiCallbacks[callbackId](args[1]);
}).then()
delete hookApiCallbacks[callbackId];
}
}
}
return target.apply(thisArg, args)
},
})
return originalSend.call(window.webContents, channel, ...args);
}
window.webContents.send = patchSend;
}
export function hookNTQQApiCall(window: BrowserWindow, onlyLog: boolean) {
const webContents = window.webContents as Dict
const ipc_message_proxy = webContents._events['-ipc-message']?.[0] || webContents._events['-ipc-message']
const proxyIpcMsg = new Proxy(ipc_message_proxy, {
apply(target, thisArg, args) {
const isLogger = args[3]?.[0]?.eventName?.startsWith('ns-LoggerApi')
if (!isLogger) {
try {
logHook && log('call NTQQ api', args)
} catch (e) { }
if (!onlyLog) {
try {
const _args: unknown[] = args[3][1]
const cmdName = _args[0] as NTMethod
const callParams = _args.slice(1)
callHooks.forEach((hook) => {
if (hook.method.includes(cmdName)) {
Promise.resolve(hook.hookFunc(callParams))
}
})
} catch { }
}
}
return target.apply(thisArg, args)
},
})
if (webContents._events['-ipc-message']?.[0]) {
webContents._events['-ipc-message'][0] = proxyIpcMsg
} else {
webContents._events['-ipc-message'] = proxyIpcMsg
}
/*const ipc_invoke_proxy = webContents._events['-ipc-invoke']?.[0] || webContents._events['-ipc-invoke']
const proxyIpcInvoke = new Proxy(ipc_invoke_proxy, {
apply(target, thisArg, args) {
//HOOK_LOG && log('call NTQQ invoke api', thisArg, args)
args[0]['_replyChannel']['sendReply'] = new Proxy(args[0]['_replyChannel']['sendReply'], {
apply(sendtarget, sendthisArg, sendargs) {
sendtarget.apply(sendthisArg, sendargs)
},
})
const ret = target.apply(thisArg, args)
//HOOK_LOG && log('call NTQQ invoke api return', ret)
return ret
},
})
if (webContents._events['-ipc-invoke']?.[0]) {
webContents._events['-ipc-invoke'][0] = proxyIpcInvoke
} else {
webContents._events['-ipc-invoke'] = proxyIpcInvoke
}*/
}
export function registerReceiveHook<PayloadType>(
method: string | string[],
hookFunc: (payload: PayloadType) => void,
): string {
const id = randomUUID()
if (!Array.isArray(method)) {
method = [method]
}
receiveHooks.push({
method: method as ReceiveCmdS[],
hookFunc,
id,
})
return id
}
export function registerCallHook(
method: NTMethod | NTMethod[],
hookFunc: (callParams: unknown[]) => void | Promise<void>,
): void {
if (!Array.isArray(method)) {
method = [method]
}
callHooks.push({
method,
hookFunc,
})
export function registerReceiveHook<PayloadType>(method: ReceiveCmd, hookFunc: (payload: PayloadType) => void): string {
const id = uuidv4()
receiveHooks.push({
method,
hookFunc,
id
})
return id;
}
export function removeReceiveHook(id: string) {
const index = receiveHooks.findIndex((h) => h.id === id)
receiveHooks.splice(index, 1)
const index = receiveHooks.findIndex(h => h.id === id)
receiveHooks.splice(index, 1);
}
async function updateGroups(_groups: Group[]) {
for (let group of _groups) {
let existGroup = groups.find(g => g.groupCode == group.groupCode)
if (!existGroup) {
NTQQApi.getGroupMembers(group.groupCode).then(members => {
if (members) {
group.members = members
}
})
groups.push(group)
log("update group members", group.members)
} else {
Object.assign(existGroup, group)
}
}
}
registerReceiveHook<{ groupList: Group[] }>(ReceiveCmd.GROUPS, (payload) => updateGroups(payload.groupList).then())
registerReceiveHook<{ groupList: Group[] }>(ReceiveCmd.GROUPS_UNIX, (payload) => updateGroups(payload.groupList).then())
registerReceiveHook<{
data: { categoryId: number, categroyName: string, categroyMbCount: number, buddyList: User[] }[]
}>(ReceiveCmd.FRIENDS, payload => {
for (const fData of payload.data) {
const _friends = fData.buddyList;
for (let friend of _friends) {
let existFriend = friends.find(f => f.uin == friend.uin)
if (!existFriend) {
friends.push(friend)
} else {
Object.assign(existFriend, friend)
}
}
}
})
// registerReceiveHook<any>(ReceiveCmd.USER_INFO, (payload)=>{
// log("user info", payload);
// })
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.UPDATE_MSG, (payload) => {
for (const message of payload.msgList) {
addHistoryMsg(message)
}
})
registerReceiveHook<{ msgList: Array<RawMessage> }>(ReceiveCmd.NEW_MSG, (payload) => {
for (const message of payload.msgList) {
// log("收到新消息push到历史记录", message)
addHistoryMsg(message)
}
const msgIds = Object.keys(msgHistory);
if (msgIds.length > 30000) {
delete msgHistory[msgIds.sort()[0]]
}
})
registerReceiveHook<{ msgRecord: RawMessage }>(ReceiveCmd.SELF_SEND_MSG, ({msgRecord}) => {
const message = msgRecord;
const peerUid = message.peerUid;
// log("收到自己发送成功的消息", Object.keys(sendMessagePool), message);
const sendCallback = sendMessagePool[peerUid];
if (sendCallback) {
try {
sendCallback(message);
} catch (e) {
log("receive self msg error", e.stack)
}
}
})

View File

@@ -1,182 +1,339 @@
import { ipcMain } from 'electron'
import { hookApiCallbacks, registerReceiveHook, removeReceiveHook } from './hook'
import { log } from '../common/utils/legacyLog'
import { randomUUID } from 'node:crypto'
import {
GeneralCallResult,
NodeIKernelBuddyService,
NodeIKernelProfileService,
NodeIKernelGroupService,
NodeIKernelProfileLikeService,
NodeIKernelMsgService,
NodeIKernelMSFService,
NodeIKernelUixConvertService,
NodeIKernelRichMediaService,
NodeIKernelTicketService,
NodeIKernelTipOffService,
NodeIKernelRobotService
} from './services'
import { ipcMain } from "electron";
import { v4 as uuidv4 } from "uuid";
import { ReceiveCmd, hookApiCallbacks, registerReceiveHook, removeReceiveHook } from "./hook";
import { log } from "../common/utils";
import { ChatType, Friend, PicElement, SelfInfo, User } from "./types";
import { Group } from "./types";
import { GroupMember } from "./types";
import { RawMessage } from "./types";
import { SendMessageElement } from "./types";
import * as fs from "fs";
export enum NTClass {
NT_API = 'ns-ntApi',
FS_API = 'ns-FsApi',
OS_API = 'ns-OsApi',
WINDOW_API = 'ns-WindowApi',
HOTUPDATE_API = 'ns-HotUpdateApi',
BUSINESS_API = 'ns-BusinessApi',
GLOBAL_DATA = 'ns-GlobalDataApi',
SKEY_API = 'ns-SkeyApi',
GROUP_HOME_WORK = 'ns-GroupHomeWork',
GROUP_ESSENCE = 'ns-GroupEssence',
NODE_STORE_API = 'ns-NodeStoreApi'
interface IPCReceiveEvent {
eventName: string
callbackId: string
}
export enum NTMethod {
ACTIVE_CHAT_PREVIEW = 'nodeIKernelMsgService/getAioFirstViewLatestMsgsAndAddActiveChat', // 激活聊天窗口,有时候必须这样才能收到消息, 并返回最新预览消息
ACTIVE_CHAT_HISTORY = 'nodeIKernelMsgService/getMsgsIncludeSelfAndAddActiveChat', // 激活聊天窗口,有时候必须这样才能收到消息, 并返回历史消息
HISTORY_MSG = 'nodeIKernelMsgService/getMsgsIncludeSelf',
GET_MULTI_MSG = 'nodeIKernelMsgService/getMultiMsg',
DELETE_ACTIVE_CHAT = 'nodeIKernelMsgService/deleteActiveChatByUid',
MEDIA_FILE_PATH = 'nodeIKernelMsgService/getRichMediaFilePathForGuild',
RECALL_MSG = 'nodeIKernelMsgService/recallMsg',
EMOJI_LIKE = 'nodeIKernelMsgService/setMsgEmojiLikes',
export type IPCReceiveDetail = [
{
cmdName: NTQQApiMethod
payload: unknown
},
]
SELF_INFO = 'fetchAuthData',
FILE_TYPE = 'getFileType',
FILE_MD5 = 'getFileMd5',
FILE_COPY = 'copyFile',
IMAGE_SIZE = 'getImageSizeFromPath',
FILE_SIZE = 'getFileSize',
CACHE_PATH_HOT_UPDATE = 'getHotUpdateCachePath',
CACHE_PATH_DESKTOP_TEMP = 'getDesktopTmpPath',
CACHE_PATH_SESSION = 'getCleanableAppSessionPathList',
OPEN_EXTRA_WINDOW = 'openExternalWindow',
GROUP_MEMBER_SCENE = 'nodeIKernelGroupService/createMemberListScene',
GROUP_MEMBERS = 'nodeIKernelGroupService/getNextMemberList',
HANDLE_GROUP_REQUEST = 'nodeIKernelGroupService/operateSysNotify',
QUIT_GROUP = 'nodeIKernelGroupService/quitGroup',
GROUP_AT_ALL_REMAIN_COUNT = 'nodeIKernelGroupService/getGroupRemainAtTimes',
KICK_MEMBER = 'nodeIKernelGroupService/kickMember',
MUTE_MEMBER = 'nodeIKernelGroupService/setMemberShutUp',
MUTE_GROUP = 'nodeIKernelGroupService/setGroupShutUp',
SET_MEMBER_CARD = 'nodeIKernelGroupService/modifyMemberCardName',
SET_MEMBER_ROLE = 'nodeIKernelGroupService/modifyMemberRole',
SET_GROUP_NAME = 'nodeIKernelGroupService/modifyGroupName',
HANDLE_FRIEND_REQUEST = 'nodeIKernelBuddyService/approvalFriendRequest',
CACHE_SET_SILENCE = 'nodeIKernelStorageCleanService/setSilentScan',
CACHE_ADD_SCANNED_PATH = 'nodeIKernelStorageCleanService/addCacheScanedPaths',
CACHE_SCAN = 'nodeIKernelStorageCleanService/scanCache',
CACHE_CLEAR = 'nodeIKernelStorageCleanService/clearCacheDataByKeys',
CACHE_CHAT_GET = 'nodeIKernelStorageCleanService/getChatCacheInfo',
CACHE_FILE_GET = 'nodeIKernelStorageCleanService/getFileCacheInfo',
CACHE_CHAT_CLEAR = 'nodeIKernelStorageCleanService/clearChatCacheInfo',
export enum NTQQApiClass {
NT_API = "ns-ntApi",
FS_API = "ns-FsApi",
GLOBAL_DATA = "ns-GlobalDataApi"
}
export enum NTChannel {
IPC_UP_1 = 'IPC_UP_1',
IPC_UP_2 = 'IPC_UP_2',
IPC_UP_3 = 'IPC_UP_3',
IPC_UP_4 = 'IPC_UP_4'
export enum NTQQApiMethod {
LIKE_FRIEND = "nodeIKernelProfileLikeService/setBuddyProfileLike",
UPDATE_MSG = "nodeIKernelMsgListener/onMsgInfoListUpdate",
SELF_INFO = "fetchAuthData",
FRIENDS = "nodeIKernelBuddyService/getBuddyList",
GROUPS = "nodeIKernelGroupService/getGroupList",
GROUP_MEMBER_SCENE = "nodeIKernelGroupService/createMemberListScene",
GROUP_MEMBERS = "nodeIKernelGroupService/getNextMemberList",
USER_INFO = "nodeIKernelProfileService/getUserSimpleInfo",
FILE_TYPE = "getFileType",
FILE_MD5 = "getFileMd5",
FILE_COPY = "copyFile",
IMAGE_SIZE = "getImageSizeFromPath",
FILE_SIZE = "getFileSize",
MEDIA_FILE_PATH = "nodeIKernelMsgService/getRichMediaFilePathForGuild",
RECALL_MSG = "nodeIKernelMsgService/recallMsg",
SEND_MSG = "nodeIKernelMsgService/sendMsg",
DOWNLOAD_MEDIA = "nodeIKernelMsgService/downloadRichMedia"
}
interface NTService {
nodeIKernelBuddyService: NodeIKernelBuddyService
nodeIKernelProfileService: NodeIKernelProfileService
nodeIKernelGroupService: NodeIKernelGroupService
nodeIKernelProfileLikeService: NodeIKernelProfileLikeService
nodeIKernelMsgService: NodeIKernelMsgService
nodeIKernelMSFService: NodeIKernelMSFService
nodeIKernelUixConvertService: NodeIKernelUixConvertService
nodeIKernelRichMediaService: NodeIKernelRichMediaService
nodeIKernelTicketService: NodeIKernelTicketService
nodeIKernelTipOffService: NodeIKernelTipOffService
nodeIKernelRobotService: NodeIKernelRobotService
enum NTQQApiChannel {
IPC_UP_2 = "IPC_UP_2",
IPC_UP_3 = "IPC_UP_3",
IPC_UP_1 = "IPC_UP_1",
}
interface InvokeOptions<ReturnType> {
className?: NTClass
channel?: NTChannel
registerEvent?: boolean
cbCmd?: string | string[]
cmdCB?: (payload: ReturnType, result: unknown) => boolean
afterFirstCmd?: boolean // 是否在methodName调用完之后再去hook cbCmd
timeout?: number
export interface Peer {
chatType: ChatType
peerUid: string // 如果是群聊uid为群号私聊uid就是加密的字符串
guildId?: ""
}
export function invoke<
R extends Awaited<ReturnType<Extract<NTService[S][M], (...args: any) => unknown>>>,
S extends keyof NTService = any,
M extends keyof NTService[S] & string = any
>(method: Extract<unknown, `${S}/${M}`> | string, args: unknown[], options: InvokeOptions<R> = {}) {
const className = options.className ?? NTClass.NT_API
const channel = options.channel ?? NTChannel.IPC_UP_2
const timeout = options.timeout ?? 5000
const afterFirstCmd = options.afterFirstCmd ?? true
let eventName = className + '-' + channel[channel.length - 1]
if (options.registerEvent) {
eventName += '-register'
}
return new Promise<R>((resolve, reject) => {
const apiArgs = [method, ...args]
const callbackId = randomUUID()
const timeoutId = setTimeout(() => {
log(`ntqq api timeout ${channel}, ${eventName}, ${method}`, apiArgs)
reject(`ntqq api timeout ${channel}, ${eventName}, ${method}, ${apiArgs}`)
}, timeout)
enum CallBackType {
UUID,
METHOD
}
if (!options.cbCmd) {
// QQ后端会返回结果并且可以根据uuid识别
hookApiCallbacks[callbackId] = res => {
clearTimeout(timeoutId)
resolve(res)
}
}
else {
let result: unknown
// 这里的callback比较特殊QQ后端先返回是否调用成功再返回一条结果数据
const secondCallback = () => {
const hookId = registerReceiveHook<R>(options.cbCmd!, (payload) => {
if (options.cmdCB) {
if (options.cmdCB(payload, result)) {
removeReceiveHook(hookId)
clearTimeout(timeoutId)
resolve(payload)
function callNTQQApi<ReturnType>(channel: NTQQApiChannel, className: NTQQApiClass, methodName: NTQQApiMethod, args: unknown[] = [], cbCmd: ReceiveCmd | null = null, timeout = 5) {
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
if (!cbCmd) {
// QQ后端会返回结果并且可以插根据uuid识别
hookApiCallbacks[uuid] = (r: ReturnType) => {
success = true
resolve(r)
};
} 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);
removeReceiveHook(hookId);
success = true
resolve(payload);
})
} else {
success = true
reject(`ntqq api call failed, ${result.errMsg}`);
}
}
}
else {
removeReceiveHook(hookId)
clearTimeout(timeoutId)
resolve(payload)
}
})
}
!afterFirstCmd && secondCallback()
hookApiCallbacks[callbackId] = (res: GeneralCallResult) => {
result = res
if (res?.result === 0 || ['undefined', 'number'].includes(typeof res)) {
afterFirstCmd && secondCallback()
}
else {
log('ntqq api call failed,', method, args, res)
clearTimeout(timeoutId)
reject(`ntqq api call failed, ${method}, ${res?.errMsg}`)
}
}
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}`)
}
}, _timeout)
ipcMain.emit(
channel,
{},
{type: 'request', callbackId: uuid, eventName: className + "-" + channel[channel.length - 1]},
[methodName, ...args],
)
})
}
export let sendMessagePool: Record<string, ((sendSuccessMsg: RawMessage) => void) | null> = {}// peerUid: callbackFunnc
interface GeneralCallResult {
result: number, // 0: success
errMsg: string
}
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(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.LIKE_FRIEND, [{
doLikeUserInfo: {
friendUid: uid,
sourceId: 71,
doLikeCount: count,
doLikeTollCount: 0
}
},
null])
}
ipcMain.emit(
channel,
{
sender: {
send: () => {
},
},
},
{ type: 'request', callbackId, eventName },
apiArgs,
)
})
}
static getSelfInfo() {
return callNTQQApi<SelfInfo>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.GLOBAL_DATA, NTQQApiMethod.SELF_INFO, [], null, 2)
}
static async getUserInfo(uid: string) {
const result = await callNTQQApi<{
profiles: Map<string, User>
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.USER_INFO,
[{force: true, uids: [uid]}, undefined], ReceiveCmd.USER_INFO)
return result.profiles.get(uid)
}
static async getFriends(forced = false) {
const data = await callNTQQApi<{
data: { categoryId: number, categroyName: string, categroyMbCount: number, buddyList: Friend[] }[]
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.FRIENDS, [{force_update: forced}, undefined], ReceiveCmd.FRIENDS)
let _friends: Friend[] = [];
for (const fData of data.data) {
_friends.push(...fData.buddyList)
}
return _friends
}
static async getGroups(forced = false) {
let cbCmd = ReceiveCmd.GROUPS
if (process.platform != "win32") {
cbCmd = ReceiveCmd.GROUPS_UNIX
}
const result = await callNTQQApi<{
updateType: number,
groupList: Group[]
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.GROUPS, [{force_update: forced}, undefined], cbCmd)
return result.groupList
}
static async getGroupMembers(groupQQ: string, num = 3000) {
const sceneId = await callNTQQApi(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.GROUP_MEMBER_SCENE, [{
groupCode: groupQQ,
scene: "groupMemberList_MainWindow"
}])
// log("get group member sceneId", sceneId);
try {
const result = await callNTQQApi<{
result: { infos: any }
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.GROUP_MEMBERS,
[{
sceneId: sceneId,
num: num
},
null
])
// log("members info", typeof result.result.infos, Object.keys(result.result.infos))
let values = result.result.infos.values()
values = Array.from(values) as GroupMember[]
// log("members info", values);
return values
} catch (e) {
log(`get group ${groupQQ} members failed`, e)
return []
}
}
static getFileType(filePath: string) {
return callNTQQApi<{
ext: string
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.FS_API, NTQQApiMethod.FILE_TYPE, [filePath])
}
static getFileMd5(filePath: string) {
return callNTQQApi<string>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.FS_API, NTQQApiMethod.FILE_MD5, [filePath])
}
static copyFile(filePath: string, destPath: string) {
return callNTQQApi<string>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.FS_API, NTQQApiMethod.FILE_COPY, [{
fromPath: filePath,
toPath: destPath
}])
}
static getImageSize(filePath: string) {
return callNTQQApi<{
width: number,
height: number
}>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.FS_API, NTQQApiMethod.IMAGE_SIZE, [filePath])
}
static getFileSize(filePath: string) {
return callNTQQApi<number>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.FS_API, NTQQApiMethod.FILE_SIZE, [filePath])
}
// 上传文件到QQ的文件夹
static async uploadFile(filePath: string) {
const md5 = await NTQQApi.getFileMd5(filePath);
let ext = (await NTQQApi.getFileType(filePath))?.ext
if (ext) {
ext = "." + ext
}
else{
ext = ""
}
const fileName = `${md5}${ext}`;
const mediaPath = await callNTQQApi<string>(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.MEDIA_FILE_PATH, [{
path_info: {
md5HexStr: md5,
fileName: fileName,
elementType: 2,
elementSubType: 0,
thumbSize: 0,
needCreate: true,
downloadType: 1,
file_uuid: ""
}
}])
log("media path", mediaPath)
await NTQQApi.copyFile(filePath, mediaPath);
const fileSize = await NTQQApi.getFileSize(filePath);
return {
md5,
fileName,
path: mediaPath,
fileSize
}
}
static async downloadMedia(msgId: string, chatType: ChatType, peerUid: string, elementId: string, thumbPath: string, sourcePath: string) {
// 用于下载收到的消息中的图片等
if (fs.existsSync(sourcePath)) {
return sourcePath
}
const apiParams = [
{
getReq: {
msgId: msgId,
chatType: chatType,
peerUid: peerUid,
elementId: elementId,
thumbSize: 0,
downloadType: 1,
filePath: thumbPath,
},
},
undefined,
]
await callNTQQApi(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.DOWNLOAD_MEDIA, apiParams)
return sourcePath
}
static recallMsg(peer: Peer, msgIds: string[]) {
return callNTQQApi(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.RECALL_MSG, [{
peer,
msgIds
}, null])
}
static sendMsg(peer: Peer, msgElements: SendMessageElement[]) {
const sendTimeout = 10 * 1000
return new Promise<RawMessage>((resolve, reject) => {
const peerUid = peer.peerUid;
let usingTime = 0;
let success = false;
const checkSuccess = () => {
if (!success) {
sendMessagePool[peerUid] = null;
reject("发送超时")
}
}
setTimeout(checkSuccess, sendTimeout);
const checkLastSend = () => {
let lastSending = sendMessagePool[peerUid]
if (sendTimeout < usingTime) {
sendMessagePool[peerUid] = null;
reject("发送超时")
}
if (!!lastSending) {
// log("有正在发送的消息,等待中...")
usingTime += 100;
setTimeout(checkLastSend, 100);
} else {
log("可以进行发送消息,设置发送成功回调", sendMessagePool)
sendMessagePool[peerUid] = (rawMessage: RawMessage) => {
success = true;
sendMessagePool[peerUid] = null;
resolve(rawMessage);
}
}
}
checkLastSend()
callNTQQApi(NTQQApiChannel.IPC_UP_2, NTQQApiClass.NT_API, NTQQApiMethod.SEND_MSG, [{
msgId: "0",
peer, msgElements,
msgAttributeInfos: new Map(),
}, null]).then()
})
}
}

View File

@@ -1,469 +0,0 @@
import * as $protobuf from "protobufjs";
import Long = require("long");
/** Namespace SysMsg. */
export namespace SysMsg {
/** Properties of a SystemMessage. */
interface ISystemMessage {
/** SystemMessage header */
header?: (SysMsg.ISystemMessageHeader[]|null);
/** SystemMessage msgSpec */
msgSpec?: (SysMsg.ISystemMessageMsgSpec[]|null);
/** SystemMessage bodyWrapper */
bodyWrapper?: (SysMsg.ISystemMessageBodyWrapper|null);
}
/** Represents a SystemMessage. */
class SystemMessage implements ISystemMessage {
/**
* Constructs a new SystemMessage.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ISystemMessage);
/** SystemMessage header. */
public header: SysMsg.ISystemMessageHeader[];
/** SystemMessage msgSpec. */
public msgSpec: SysMsg.ISystemMessageMsgSpec[];
/** SystemMessage bodyWrapper. */
public bodyWrapper?: (SysMsg.ISystemMessageBodyWrapper|null);
/**
* Decodes a SystemMessage message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns SystemMessage
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.SystemMessage;
/**
* Decodes a SystemMessage message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns SystemMessage
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.SystemMessage;
/**
* Gets the default type url for SystemMessage
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a SystemMessageHeader. */
interface ISystemMessageHeader {
/** SystemMessageHeader peerNumber */
peerNumber?: (number|null);
/** SystemMessageHeader peerString */
peerString?: (string|null);
/** SystemMessageHeader uin */
uin?: (number|null);
/** SystemMessageHeader uid */
uid?: (string|null);
}
/** Represents a SystemMessageHeader. */
class SystemMessageHeader implements ISystemMessageHeader {
/**
* Constructs a new SystemMessageHeader.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ISystemMessageHeader);
/** SystemMessageHeader peerNumber. */
public peerNumber: number;
/** SystemMessageHeader peerString. */
public peerString: string;
/** SystemMessageHeader uin. */
public uin: number;
/** SystemMessageHeader uid. */
public uid?: (string|null);
/**
* Decodes a SystemMessageHeader message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns SystemMessageHeader
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.SystemMessageHeader;
/**
* Decodes a SystemMessageHeader message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns SystemMessageHeader
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.SystemMessageHeader;
/**
* Gets the default type url for SystemMessageHeader
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a SystemMessageMsgSpec. */
interface ISystemMessageMsgSpec {
/** SystemMessageMsgSpec msgType */
msgType?: (number|null);
/** SystemMessageMsgSpec subType */
subType?: (number|null);
/** SystemMessageMsgSpec subSubType */
subSubType?: (number|null);
/** SystemMessageMsgSpec msgSeq */
msgSeq?: (number|null);
/** SystemMessageMsgSpec time */
time?: (number|null);
/** SystemMessageMsgSpec other */
other?: (number|null);
}
/** Represents a SystemMessageMsgSpec. */
class SystemMessageMsgSpec implements ISystemMessageMsgSpec {
/**
* Constructs a new SystemMessageMsgSpec.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ISystemMessageMsgSpec);
/** SystemMessageMsgSpec msgType. */
public msgType: number;
/** SystemMessageMsgSpec subType. */
public subType: number;
/** SystemMessageMsgSpec subSubType. */
public subSubType: number;
/** SystemMessageMsgSpec msgSeq. */
public msgSeq: number;
/** SystemMessageMsgSpec time. */
public time: number;
/** SystemMessageMsgSpec other. */
public other: number;
/**
* Decodes a SystemMessageMsgSpec message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns SystemMessageMsgSpec
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.SystemMessageMsgSpec;
/**
* Decodes a SystemMessageMsgSpec message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns SystemMessageMsgSpec
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.SystemMessageMsgSpec;
/**
* Gets the default type url for SystemMessageMsgSpec
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a SystemMessageBodyWrapper. */
interface ISystemMessageBodyWrapper {
/** SystemMessageBodyWrapper body */
body?: (Uint8Array|null);
}
/** Represents a SystemMessageBodyWrapper. */
class SystemMessageBodyWrapper implements ISystemMessageBodyWrapper {
/**
* Constructs a new SystemMessageBodyWrapper.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ISystemMessageBodyWrapper);
/** SystemMessageBodyWrapper body. */
public body: Uint8Array;
/**
* Decodes a SystemMessageBodyWrapper message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns SystemMessageBodyWrapper
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.SystemMessageBodyWrapper;
/**
* Decodes a SystemMessageBodyWrapper message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns SystemMessageBodyWrapper
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.SystemMessageBodyWrapper;
/**
* Gets the default type url for SystemMessageBodyWrapper
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a LikeDetail. */
interface ILikeDetail {
/** LikeDetail txt */
txt?: (string|null);
/** LikeDetail uin */
uin?: (number|null);
/** LikeDetail nickname */
nickname?: (string|null);
}
/** Represents a LikeDetail. */
class LikeDetail implements ILikeDetail {
/**
* Constructs a new LikeDetail.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ILikeDetail);
/** LikeDetail txt. */
public txt: string;
/** LikeDetail uin. */
public uin: number;
/** LikeDetail nickname. */
public nickname: string;
/**
* Decodes a LikeDetail message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns LikeDetail
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.LikeDetail;
/**
* Decodes a LikeDetail message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns LikeDetail
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.LikeDetail;
/**
* Gets the default type url for LikeDetail
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a LikeMsg. */
interface ILikeMsg {
/** LikeMsg count */
count?: (number|null);
/** LikeMsg time */
time?: (number|null);
/** LikeMsg detail */
detail?: (SysMsg.ILikeDetail|null);
}
/** Represents a LikeMsg. */
class LikeMsg implements ILikeMsg {
/**
* Constructs a new LikeMsg.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.ILikeMsg);
/** LikeMsg count. */
public count: number;
/** LikeMsg time. */
public time: number;
/** LikeMsg detail. */
public detail?: (SysMsg.ILikeDetail|null);
/**
* Decodes a LikeMsg message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns LikeMsg
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.LikeMsg;
/**
* Decodes a LikeMsg message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns LikeMsg
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.LikeMsg;
/**
* Gets the default type url for LikeMsg
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a ProfileLikeSubTip. */
interface IProfileLikeSubTip {
/** ProfileLikeSubTip msg */
msg?: (SysMsg.ILikeMsg|null);
}
/** Represents a ProfileLikeSubTip. */
class ProfileLikeSubTip implements IProfileLikeSubTip {
/**
* Constructs a new ProfileLikeSubTip.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.IProfileLikeSubTip);
/** ProfileLikeSubTip msg. */
public msg?: (SysMsg.ILikeMsg|null);
/**
* Decodes a ProfileLikeSubTip message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns ProfileLikeSubTip
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.ProfileLikeSubTip;
/**
* Decodes a ProfileLikeSubTip message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns ProfileLikeSubTip
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.ProfileLikeSubTip;
/**
* Gets the default type url for ProfileLikeSubTip
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
/** Properties of a ProfileLikeTip. */
interface IProfileLikeTip {
/** ProfileLikeTip msgType */
msgType?: (number|null);
/** ProfileLikeTip subType */
subType?: (number|null);
/** ProfileLikeTip content */
content?: (SysMsg.IProfileLikeSubTip|null);
}
/** Represents a ProfileLikeTip. */
class ProfileLikeTip implements IProfileLikeTip {
/**
* Constructs a new ProfileLikeTip.
* @param [properties] Properties to set
*/
constructor(properties?: SysMsg.IProfileLikeTip);
/** ProfileLikeTip msgType. */
public msgType: number;
/** ProfileLikeTip subType. */
public subType: number;
/** ProfileLikeTip content. */
public content?: (SysMsg.IProfileLikeSubTip|null);
/**
* Decodes a ProfileLikeTip message from the specified reader or buffer.
* @param reader Reader or buffer to decode from
* @param [length] Message length if known beforehand
* @returns ProfileLikeTip
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decode(reader: ($protobuf.Reader|Uint8Array), length?: number): SysMsg.ProfileLikeTip;
/**
* Decodes a ProfileLikeTip message from the specified reader or buffer, length delimited.
* @param reader Reader or buffer to decode from
* @returns ProfileLikeTip
* @throws {Error} If the payload is not a reader or valid buffer
* @throws {$protobuf.util.ProtocolError} If required fields are missing
*/
public static decodeDelimited(reader: ($protobuf.Reader|Uint8Array)): SysMsg.ProfileLikeTip;
/**
* Gets the default type url for ProfileLikeTip
* @param [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
* @returns The default type url
*/
public static getTypeUrl(typeUrlPrefix?: string): string;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
syntax = "proto3";
package SysMsg;
message LikeDetail {
string txt = 1;
uint32 uin = 3;
string nickname = 5;
}
message LikeMsg {
uint32 count = 1;
uint32 time = 2;
LikeDetail detail = 3;
}
message ProfileLikeSubTip {
LikeMsg msg = 14;
}
message ProfileLikeTip {
uint32 msgType = 1;
uint32 subType = 2;
ProfileLikeSubTip content = 203;
}

View File

@@ -1,30 +0,0 @@
syntax = "proto3";
package SysMsg;
message SystemMessage {
repeated SystemMessageHeader header = 1;
repeated SystemMessageMsgSpec msgSpec = 2;
SystemMessageBodyWrapper bodyWrapper = 3;
}
message SystemMessageHeader {
uint32 peerNumber = 1;
string peerString = 2;
uint32 uin = 5;
optional string uid = 6;
}
message SystemMessageMsgSpec {
uint32 msgType = 1;
uint32 subType = 2;
uint32 subSubType = 3;
uint32 msgSeq = 5;
uint32 time = 6;
//uint64 msgId = 12;
uint32 other = 13;
}
message SystemMessageBodyWrapper {
bytes body = 2;
// Find the first [08], or ignore the first 7 bytes?
}

View File

@@ -1,27 +0,0 @@
import { BuddyListReqType } from '@/ntqqapi/types'
import { GeneralCallResult } from './common'
export interface NodeIKernelBuddyService {
getBuddyListV2(callFrom: string, reqType: BuddyListReqType): Promise<GeneralCallResult & {
data: {
categoryId: number
categorySortId: number
categroyName: string
categroyMbCount: number
onlineCount: number
buddyUids: string[]
}[]
}>
setBuddyRemark(uid: number, remark: string): void
isBuddy(uid: string): boolean
approvalFriendRequest(arg: {
friendUid: string
reqTime: string
accept: boolean
}): Promise<void>
getBuddyRecommendContactArkJson(uid: string, phoneNumber: string): Promise<GeneralCallResult & { arkMsg: string }>
}

View File

@@ -1,128 +0,0 @@
import {
GroupMember,
GroupMemberRole,
GroupNotifyType,
GroupRequestOperateTypes,
} from '@/ntqqapi/types'
import { GeneralCallResult } from './common'
export interface NodeIKernelGroupService {
getGroupHonorList(req: { groupCode: number[] }): Promise<{
errCode: number
errMsg: string
groupMemberHonorList: {
honorList: {
groupCode: string
id: number[]
isGray: number
}[]
cacheTs: number
honorInfos: unknown[]
joinTime: number
}
}>
getUinByUids(uins: string[]): Promise<{
errCode: number
errMsg: string
uins: Map<string, string>
}>
getUidByUins(uins: string[]): Promise<{
errCode: number
errMsg: string
uids: Map<string, string>
}>
queryCachedEssenceMsg(req: { groupCode: string, msgRandom: number, msgSeq: number }): Promise<{
items: {
groupCode: string
msgSeq: number
msgRandom: number
msgSenderUin: string
msgSenderNick: string
opType: number
opUin: string
opNick: string
opTime: number
grayTipSeq: string
}[]
}>
setHeader(uid: string, path: string): unknown
createMemberListScene(groupCode: string, scene: string): string
getNextMemberList(sceneId: string, a: undefined, num: number): Promise<{
errCode: number
errMsg: string
result: {
ids: {
uid: string
index: number
}[]
infos: Map<string, GroupMember>
finish: boolean
hasRobot: boolean
}
}>
kickMember(groupCode: string, memberUids: string[], refuseForever: boolean, kickReason: string): Promise<void>
modifyMemberRole(groupCode: string, uid: string, role: GroupMemberRole): void
modifyMemberCardName(groupCode: string, uid: string, cardName: string): void
modifyGroupName(groupCode: string, groupName: string, arg: false): void
quitGroup(groupCode: string): void
getSingleScreenNotifies(force: boolean, startSeq: string, num: number): Promise<GeneralCallResult>
operateSysNotify(
doubt: boolean,
operateMsg: {
operateType: GroupRequestOperateTypes // 2 拒绝
targetMsg: {
seq: string // 通知序列号
type: GroupNotifyType
groupCode: string
postscript: string
}
}
): Promise<void>
publishGroupBulletin(groupCode: string, pskey: string, data: unknown): Promise<GeneralCallResult>
uploadGroupBulletinPic(groupCode: string, pskey: string, imagePath: string): Promise<{
errCode: number
errMsg: string
picInfo?: {
id: string
width: number
height: number
}
}>
getGroupRemainAtTimes(groupCode: string): Promise<GeneralCallResult & {
atInfo: {
canAtAll: boolean
RemainAtAllCountForUin: number
RemainAtAllCountForGroup: number
atTimesMsg: string
canNotAtAllMsg: ''
}
}>
setGroupShutUp(groupCode: string, shutUp: boolean): void
setMemberShutUp(groupCode: string, memberTimes: { uid: string, timeStamp: number }[]): Promise<void>
getGroupRecommendContactArkJson(groupCode: string): Promise<GeneralCallResult & { arkJson: string }>
addGroupEssence(param: { groupCode: string, msgRandom: number, msgSeq: number }): Promise<unknown>
removeGroupEssence(param: { groupCode: string, msgRandom: number, msgSeq: number }): Promise<unknown>
setHeader(args: unknown[]): Promise<GeneralCallResult>
}

View File

@@ -1,3 +0,0 @@
export interface NodeIKernelMSFService {
getServerTime(): string
}

View File

@@ -1,98 +0,0 @@
import { ElementType, MessageElement, Peer, RawMessage, QueryMsgsParams, TmpChatInfoApi } from '@/ntqqapi/types'
import { GeneralCallResult } from './common'
export interface NodeIKernelMsgService {
generateMsgUniqueId(chatType: number, time: string): string
sendMsg(msgId: string, peer: Peer, msgElements: MessageElement[], map: Map<unknown, unknown>): Promise<GeneralCallResult>
recallMsg(peer: Peer, msgIds: string[]): Promise<GeneralCallResult>
setStatus(args: { status: number, extStatus: number, batteryStatus: number }): Promise<GeneralCallResult>
forwardMsg(msgIds: string[], srcContact: Peer, dstContacts: Peer[], commentElements: MessageElement[]): Promise<GeneralCallResult & {
detailErr: Map<unknown, unknown>
}>
forwardMsgWithComment(...args: unknown[]): Promise<GeneralCallResult>
multiForwardMsgWithComment(...args: unknown[]): unknown
getAioFirstViewLatestMsgs(peer: Peer, num: number): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getAioFirstViewLatestMsgsAndAddActiveChat(...args: unknown[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getMsgsIncludeSelfAndAddActiveChat(...args: unknown[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getMsgsIncludeSelf(peer: Peer, msgId: string, count: number, queryOrder: boolean): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getMsgsBySeqAndCount(peer: Peer, seq: string, count: number, desc: boolean, unknownArg: boolean): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getMsgsByMsgId(peer: Peer, ids: string[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getMsgsBySeqList(peer: Peer, seqList: string[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getSingleMsg(peer: Peer, msgSeq: string): Promise<GeneralCallResult & { msgList: RawMessage[] }>
queryMsgsWithFilterEx(msgId: string, msgTime: string, megSeq: string, param: QueryMsgsParams): Promise<GeneralCallResult & {
msgList: RawMessage[]
}>
setMsgRead(peer: Peer): Promise<GeneralCallResult>
getRichMediaFilePathForGuild(arg: {
md5HexStr: string
fileName: string
elementType: ElementType
elementSubType: number
thumbSize: 0
needCreate: true
downloadType: 1
file_uuid: ''
}): string
fetchFavEmojiList(str: string, num: number, uk1: boolean, uk2: boolean): Promise<GeneralCallResult & {
emojiInfoList: {
uin: string
emoId: number
emoPath: string
isExist: boolean
resId: string
url: string
md5: string
emoOriginalPath: string
thumbPath: string
RomaingType: string
isAPNG: false
isMarkFace: false
eId: string
epId: string
ocrWord: string
modifyWord: string
exposeNum: number
clickNum: number
desc: string
}[]
}>
downloadRichMedia(...args: unknown[]): unknown
setMsgEmojiLikes(...args: unknown[]): Promise<GeneralCallResult>
getMsgEmojiLikesList(peer: Peer, msgSeq: string, emojiId: string, emojiType: string, cookie: string, bForward: boolean, number: number): Promise<{
result: number
errMsg: string
emojiLikesList: {
tinyId: string
nickName: string
headUrl: string
}[]
cookie: string
isLastPage: boolean
isFirstPage: boolean
}>
getMultiMsg(...args: unknown[]): Promise<GeneralCallResult & { msgList: RawMessage[] }>
getTempChatInfo(chatType: number, uid: string): Promise<TmpChatInfoApi>
}

View File

@@ -1,25 +0,0 @@
import { BuddyProfileLikeReq } from '../types'
import { GeneralCallResult } from './common'
import { Dict } from 'cosmokit'
export interface NodeIKernelProfileLikeService {
setBuddyProfileLike(...args: unknown[]): GeneralCallResult & { succCounts: number }
getBuddyProfileLike(req: BuddyProfileLikeReq): Promise<GeneralCallResult & {
info: {
userLikeInfos: {
uid: string
time: string
favoriteInfo: {
total_count: number
last_time: number
today_count: number
userInfos: Dict[]
}
voteInfo: Dict
}[]
friendMaxVotes: number
start: number
}
}>
}

View File

@@ -1,18 +0,0 @@
import { SimpleInfo } from '../types'
import { GeneralCallResult } from './common'
export interface NodeIKernelProfileService {
getUidByUin(callfrom: string, uin: Array<string>): Promise<Map<string, string>>
getUinByUid(callfrom: string, uid: Array<string>): Promise<Map<string, string>>
getCoreAndBaseInfo(callfrom: string, uids: string[]): Promise<Map<string, SimpleInfo>>
fetchUserDetailInfo(trace: string, uids: string[], arg2: number, arg3: number[]): Promise<unknown>
setHeader(arg: string): Promise<GeneralCallResult>
getUserDetailInfoWithBizInfo(uid: string, biz: unknown[]): Promise<GeneralCallResult>
getUserDetailInfoByUin(uin: string): Promise<unknown>
}

View File

@@ -1,43 +0,0 @@
import { GetFileListParam, Peer } from '../types'
import { GeneralCallResult } from './common'
export interface NodeIKernelRichMediaService {
getVideoPlayUrlV2(peer: Peer, msgId: string, elemId: string, videoCodecFormat: number, exParams: { downSourceType: number, triggerType: number }): Promise<GeneralCallResult & {
urlResult: {
v4IpUrl: []
v6IpUrl: []
domainUrl: {
url: string
isHttps: boolean
httpsDomain: string
}[]
videoCodecFormat: number
}
}>
deleteGroupFolder(groupCode: string, folderId: string): Promise<GeneralCallResult & { groupFileCommonResult: { retCode: number, retMsg: string, clientWording: string } }>
createGroupFolder(groupCode: string, folderName: string): Promise<GeneralCallResult & { resultWithGroupItem: { result: unknown, groupItem: unknown[] } }>
getGroupFileList(groupCode: string, params: GetFileListParam): Promise<GeneralCallResult & {
groupSpaceResult: {
retCode: number
retMsg: string
clientWording: string
totalSpace: number
usedSpace: number
allUpload: boolean
}
}>
moveGroupFile(arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown, arg5: unknown): unknown
deleteGroupFile(groupCode: string, params: Array<number>, files: Array<string>): Promise<GeneralCallResult & {
transGroupFileResult: {
result: unknown
successFileIdList: Array<unknown>
failFileIdList: Array<unknown>
}
}>
}

View File

@@ -1,13 +0,0 @@
import { GeneralCallResult } from './common'
export interface NodeIKernelRobotService {
getRobotUinRange(req: unknown): Promise<GeneralCallResult & {
response: {
version: number
robotUinRanges: {
minUin: string
maxUin: string
}[]
}
}>
}

View File

@@ -1,10 +0,0 @@
import { GeneralCallResult } from './common'
export interface NodeIKernelTicketService {
forceFetchClientKey(arg: string): Promise<GeneralCallResult & {
url: string
keyIndex: string
clientKey: string
expireTime: string
}>
}

View File

@@ -1,5 +0,0 @@
import { GeneralCallResult } from './common'
export interface NodeIKernelTipOffService {
getPskey(domainList: string[], nocache: boolean): Promise<GeneralCallResult & { domainPskeyMap: Map<string, string> }>
}

View File

@@ -1,5 +0,0 @@
export interface NodeIKernelUixConvertService {
getUin(uid: string[]): Promise<{ uinInfo: Map<string, string> }>
getUid(uin: string[]): Promise<{ uidInfo: Map<string, string> }>
}

View File

@@ -1,8 +0,0 @@
export enum GeneralCallResultStatus {
OK = 0
}
export interface GeneralCallResult {
result: GeneralCallResultStatus
errMsg: string
}

View File

@@ -1,12 +0,0 @@
export * from './common'
export * from './NodeIKernelBuddyService'
export * from './NodeIKernelProfileService'
export * from './NodeIKernelGroupService'
export * from './NodeIKernelProfileLikeService'
export * from './NodeIKernelMsgService'
export * from './NodeIKernelMSFService'
export * from './NodeIKernelUixConvertService'
export * from './NodeIKernelRichMediaService'
export * from './NodeIKernelTicketService'
export * from './NodeIKernelTipOffService'
export * from './NodeIKernelRobotService'

224
src/ntqqapi/types.ts Normal file
View File

@@ -0,0 +1,224 @@
export interface User {
uid: string; // 加密的字符串
uin: string; // QQ号
nick: string;
avatarUrl?: string;
longNick?: string; // 签名
remark?: string
}
export interface SelfInfo extends User{
}
export interface Friend extends User{}
export interface Group{
groupCode: string,
maxMember: number,
memberCount: number,
groupName: string,
groupStatus: 0,
memberRole: 2,
isTop: boolean,
toppedTimestamp: "0",
privilegeFlag: number, //65760
isConf: boolean,
hasModifyConfGroupFace: boolean,
hasModifyConfGroupName: boolean,
remarkName: string,
hasMemo: boolean,
groupShutupExpireTime: string, //"0",
personShutupExpireTime: string, //"0",
discussToGroupUin: string, //"0",
discussToGroupMaxMsgSeq: number,
discussToGroupTime: number,
groupFlagExt: number, //1073938496,
authGroupType: number, //0,
groupCreditLevel: number, //0,
groupFlagExt3: number, //0,
groupOwnerId: {
"memberUin": string, //"0",
"memberUid": string, //"u_fbf8N7aeuZEnUiJAbQ9R8Q"
},
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
}
export interface GroupMember {
avatarPath: string;
cardName: string;
cardType: number;
isDelete: boolean;
nick: string;
qid: string;
remark: string;
role: number; // 群主:4, 管理员:3群员:2
shutUpTime: number; // 禁言时间,单位是什么暂时不清楚
uid: string; // 加密的字符串
uin: string; // QQ号
}
export enum ElementType {
TEXT = 1,
PIC = 2,
PTT = 4,
REPLY = 7,
}
export interface SendTextElement {
elementType: ElementType.TEXT,
elementId: "",
textElement: {
content: string,
atType: number,
atUid: string,
atTinyId: string,
atNtUid: string,
}
}
export interface SendPttElement {
elementType: ElementType.PTT,
elementId: "",
pttElement: {
fileName: string,
filePath: string,
md5HexStr: string,
fileSize: number,
duration: number,
formatType: number,
voiceType: number,
voiceChangeType: number,
canConvert2Text: boolean,
waveAmplitudes: number[],
fileSubId: "",
playState: number,
autoConvertText: number,
}
}
export interface SendPicElement {
elementType: ElementType.PIC,
elementId: "",
picElement: {
md5HexStr: string,
fileSize: number,
picWidth: number,
picHeight: number,
fileName: string,
sourcePath: string,
original: boolean,
picType: number,
picSubType: number,
fileUuid: string,
fileSubId: string,
thumbFileSize: number,
summary: string,
}
}
export interface SendReplyElement {
elementType: ElementType.REPLY,
elementId: "",
replyElement: {
replayMsgSeq: string,
replayMsgId: string,
senderUin: string,
senderUinStr: string,
}
}
export type SendMessageElement = SendTextElement | SendPttElement | SendPicElement | SendReplyElement
export enum AtType {
notAt = 0,
atAll = 1,
atUser = 2
}
export enum ChatType {
friend = 1,
group = 2,
temp = 100
}
export interface PttElement {
canConvert2Text: boolean;
duration: number; // 秒数
fileBizId: null;
fileId: number; // 0
fileName: string; // "e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
filePath: string; // "/Users//Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ/nt_qq_a6b15c9820595d25a56c1633ce19ad40/nt_data/Ptt/2023-11/Ori/e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
fileSize: string; // "4261"
fileSubId: string; // "0"
fileUuid: string; // "90j3z7rmRphDPrdVgP9udFBaYar#oK0TWZIV"
formatType: string; // 1
invalidState: number; // 0
md5HexStr: string; // "e4d09c784d5a2abcb2f9980bdc7acfe6"
playState: number; // 0
progress: number; // 0
text: string; // ""
transferStatus: number; // 0
translateStatus: number; // 0
voiceChangeType: number; // 0
voiceType: number; // 0
waveAmplitudes: number[];
}
export interface ArkElement {
bytesData: string;
}
export const IMAGE_HTTP_HOST = "https://gchat.qpic.cn"
export interface PicElement {
originImageUrl: string; // http url, 没有hosthost是https://gchat.qpic.cn/
sourcePath: string; // 图片本地路径
thumbPath: Map<number, string>;
picWidth: number;
picHeight: number;
fileSize: number;
fileName: string;
fileUuid: string;
}
export interface RawMessage {
msgId: string;
msgShortId?: number; // 自己维护的消息id
msgTime: string;
msgSeq: string;
senderUin: string; // 发送者QQ号
peerUid: string; // 群号 或者 QQ uid
peerUin: string; // 群号 或者 发送者QQ号
sendNickName: string;
sendMemberName?: string; // 发送者群名片
chatType: ChatType;
elements: {
elementId: string,
replyElement: {
senderUid: string; // 原消息发送者QQ号
sourceMsgIsIncPic: boolean; // 原消息是否有图片
sourceMsgText: string;
replayMsgSeq: string; // 源消息的msgSeq可以通过这个找到源消息的msgId
};
textElement: {
atType: AtType;
atUid: string; // QQ号
content: string;
atNtUid: string; // uid号
};
picElement: PicElement;
pttElement: PttElement;
arkElement: ArkElement;
}[];
}
export interface MessageElement {
raw: RawMessage;
peer: any;
sender: {
uid: string; // 一串加密的字符串
memberName: string;
nickname: string;
};
}

View File

@@ -1,66 +0,0 @@
import { ChatType } from './msg'
export interface CacheScanResult {
result: number
size: [
// 单位为字节
string, // 系统总存储空间
string, // 系统可用存储空间
string, // 系统已用存储空间
string, // QQ总大小
string, // 「聊天与文件」大小
string, // 未知
string, // 「缓存数据」大小
string, // 「其他数据」大小
string, // 未知
]
}
export interface ChatCacheList {
pageCount: number
infos: ChatCacheListItem[]
}
export interface ChatCacheListItem {
chatType: ChatType
basicChatCacheInfo: ChatCacheListItemBasic
guildChatCacheInfo: unknown[] // TODO: 没用过频道所以不知道这里边的详细内容
}
export interface ChatCacheListItemBasic {
chatSize: string
chatTime: string
uid: string
uin: string
remarkName: string
nickName: string
chatType?: ChatType
isChecked?: boolean
}
export enum CacheFileType {
IMAGE = 0,
VIDEO = 1,
AUDIO = 2,
DOCUMENT = 3,
OTHER = 4,
}
export interface CacheFileList {
infos: CacheFileListItem[]
}
export interface CacheFileListItem {
fileSize: string
fileTime: string
fileKey: string
elementId: string
elementIdStr: string
fileType: CacheFileType
path: string
fileName: string
senderId: string
previewPath: string
senderName: string
isChecked?: boolean
}

View File

@@ -1,174 +0,0 @@
import { QQLevel, Sex } from './user'
export enum GroupListUpdateType {
REFRESHALL,
GETALL,
MODIFIED,
REMOVE
}
export interface Group {
groupCode: string
maxMember: number
memberCount: number
groupName: string
groupStatus: 0
memberRole: 2
isTop: boolean
toppedTimestamp: '0'
privilegeFlag: number //65760
isConf: boolean
hasModifyConfGroupFace: boolean
hasModifyConfGroupName: boolean
remarkName: string
hasMemo: boolean
groupShutupExpireTime: string
personShutupExpireTime: string
discussToGroupUin: string
discussToGroupMaxMsgSeq: number
discussToGroupTime: number
groupFlagExt: number //1073938496,
authGroupType: number //0,
groupCreditLevel: number //0,
groupFlagExt3: number //0,
groupOwnerId: {
memberUin: string
memberUid: string
}
members: GroupMember[] // 原始数据是没有这个的,为了方便自己加了这个字段
createTime: string
}
export enum GroupMemberRole {
normal = 2,
admin = 3,
owner = 4,
}
export interface GroupMember {
memberSpecialTitle?: string
avatarPath: string
cardName: string
cardType: number
isDelete: boolean
nick: string
qid: string
remark: string
role: GroupMemberRole // 群主:4, 管理员:3群员:2
shutUpTime: number // 禁言时间,单位是什么暂时不清楚
uid: string // 加密的字符串
uin: string // QQ号
isRobot: boolean
sex?: Sex
qqLevel?: QQLevel
isChangeRole: boolean
joinTime: number
lastSpeakTime: number
memberLevel: number
}
export interface PublishGroupBulletinReq {
text: string
picInfo?: {
id: string
width: number
height: number
}
oldFeedsId: ''
pinned: number
confirmRequired: number
}
export interface GroupAllInfo {
groupCode: string
ownerUid: string
groupFlag: number
groupFlagExt: number
maxMemberNum: number
memberNum: number
groupOption: number
classExt: number
groupName: string
fingerMemo: string
groupQuestion: string
certType: number
shutUpAllTimestamp: number
shutUpMeTimestamp: number //解除禁言时间
groupTypeFlag: number
privilegeFlag: number
groupSecLevel: number
groupFlagExt3: number
isConfGroup: number
isModifyConfGroupFace: number
isModifyConfGroupName: number
noFigerOpenFlag: number
noCodeFingerOpenFlag: number
groupFlagExt4: number
groupMemo: string
cmdUinMsgSeq: number
cmdUinJoinTime: number
cmdUinUinFlag: number
cmdUinMsgMask: number
groupSecLevelInfo: number
cmdUinPrivilege: number
cmdUinFlagEx2: number
appealDeadline: number
remarkName: number
isTop: boolean
richFingerMemo: string
groupAnswer: string
joinGroupAuth: string
isAllowModifyConfGroupName: number
}
export interface GroupBulletinListResult {
groupCode: string
srvCode: number
readOnly: number
role: number
inst: unknown[]
feeds: {
uin: string
feedId: string
publishTime: string
msg: {
text: string
textFace: string
pics: {
id: string
width: number
height: number
}[]
title: string
}
type: number
fn: number
cn: number
vn: number
settings: {
isShowEditCard: number
remindTs: number
tipWindowType: number
confirmRequired: number
}
pinned: number
readNum: number
is_read: number
is_all_confirm: number
}[]
groupInfo: {
groupCode: string
classId: number
}
gln: number
tst: number
publisherInfos: {
uin: string
nick: string
avatar: string
}[]
server_time: string
svrt: string
nextIndex: number
jointime: string
}

View File

@@ -1,5 +0,0 @@
export * from './user'
export * from './group'
export * from './msg'
export * from './notify'
export * from './cache'

View File

@@ -1,582 +0,0 @@
import { GroupMemberRole } from './group'
import { GeneralCallResult } from '../services'
export enum ElementType {
Text = 1,
Pic = 2,
File = 3,
Ptt = 4,
Video = 5,
Face = 6,
Reply = 7,
GrayTip = 8,
Ark = 10,
MarketFace = 11,
LiveGift = 12,
StructLongMsg = 13,
Markdown = 14,
Giphy = 15,
MultiForward = 16,
InlineKeyboard = 17,
Calendar = 19,
YoloGameResult = 20,
AvRecord = 21,
TofuRecord = 23,
FaceBubble = 27,
ShareLocation = 28,
TaskTopMsg = 29,
RecommendedMsg = 43,
ActionBar = 44
}
export interface SendTextElement {
elementType: ElementType.Text
elementId: ''
textElement: TextElement
}
export interface SendPttElement {
elementType: ElementType.Ptt
elementId: ''
pttElement: Partial<PttElement>
}
export interface SendPicElement {
elementType: ElementType.Pic
elementId: ''
picElement: Partial<PicElement>
}
export interface SendReplyElement {
elementType: ElementType.Reply
elementId: ''
replyElement: Partial<ReplyElement>
}
export interface SendFaceElement {
elementType: ElementType.Face
elementId: ''
faceElement: FaceElement
}
export interface SendMarketFaceElement {
elementType: ElementType.MarketFace
elementId: ''
marketFaceElement: MarketFaceElement
}
export interface SendFileElement {
elementType: ElementType.File
elementId: ''
fileElement: FileElement
}
export interface SendVideoElement {
elementType: ElementType.Video
elementId: ''
videoElement: VideoElement
}
export interface SendArkElement {
elementType: ElementType.Ark
elementId: ''
arkElement: ArkElement
}
export type SendMessageElement =
| SendTextElement
| SendPttElement
| SendPicElement
| SendReplyElement
| SendFaceElement
| SendMarketFaceElement
| SendFileElement
| SendVideoElement
| SendArkElement
export enum AtType {
Unknown,
All,
One,
}
export interface TextElement {
content: string
atType: AtType
atUid: string
atTinyId: string
atNtUid: string
}
export interface ReplyElement {
replayMsgSeq: string
replayMsgId: string
senderUin: string
senderUinStr: string
sourceMsgIdInRecords: string
senderUid: string
senderUidStr: string
sourceMsgIsIncPic: boolean // 原消息是否有图片
sourceMsgText: string
replyMsgTime: string
}
export interface FileElement {
fileMd5?: string
fileName: string
filePath: string
fileSize: string
picHeight?: number
picWidth?: number
folderId?: string
picThumbPath?: Map<number, string>
file10MMd5?: string
fileSha?: string
fileSha3?: string
fileUuid?: string
fileSubId?: string
thumbFileSize?: number
fileBizId?: number
}
export interface PttElement {
canConvert2Text: boolean
duration: number // 秒数
fileBizId: null
fileId: number // 0
fileName: string // "e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
filePath: string // "/Users//Library/Containers/com.tencent.qq/Data/Library/Application Support/QQ/nt_qq_a6b15c9820595d25a56c1633ce19ad40/nt_data/Ptt/2023-11/Ori/e4d09c784d5a2abcb2f9980bdc7acfe6.amr"
fileSize: string // "4261"
fileSubId: string // "0"
fileUuid: string // "90j3z7rmRphDPrdVgP9udFBaYar#oK0TWZIV"
formatType: number // 1
invalidState: number // 0
md5HexStr: string // "e4d09c784d5a2abcb2f9980bdc7acfe6"
playState: number // 0
progress: number // 0
text: string // ""
transferStatus: number // 0
translateStatus: number // 0
voiceChangeType: number // 0
voiceType: number // 0
waveAmplitudes: number[]
autoConvertText: number
}
export interface ArkElement {
bytesData: string
linkInfo: null
subElementType: null
}
export const IMAGE_HTTP_HOST = 'https://gchat.qpic.cn'
export const IMAGE_HTTP_HOST_NT = 'https://multimedia.nt.qq.com.cn'
export enum PicType {
GIF = 2000,
JPEG = 1000,
}
export enum PicSubType {
Normal = 0, // 普通图片,大图
Face = 1, // 表情包小图
}
export interface PicElement {
picSubType: PicSubType
picType: PicType // 有这玩意儿吗
originImageUrl: string // http url, 没有hosthost是https://gchat.qpic.cn/, 带download参数的是https://multimedia.nt.qq.com.cn
originImageMd5?: string
sourcePath: string // 图片本地路径
thumbPath: Map<number, string>
picWidth: number
picHeight: number
fileSize: string
fileName: string
fileUuid: string
md5HexStr?: string
}
export interface TipAioOpGrayTipElement {
operateType: number
peerUid: string
fromGrpCodeOfTmpChat: string
}
export enum TipGroupElementType {
MemberIncrease = 1,
Kicked = 3, // 被移出群
Ban = 8,
}
export interface TipGroupElement {
type: TipGroupElementType // 1是表示有人加入群, 自己加入群也会收到这个
role: number
groupName: string // 暂时获取不到
memberUid: string
memberNick: string
memberRemark: string
adminUid: string
adminNick: string
adminRemark: string
createGroup: null
memberAdd?: {
showType: number
otherAdd?: {
uid: string
name: string
}
otherAddByOtherQRCode?: unknown
otherAddByYourQRCode?: unknown
youAddByOtherQRCode?: unknown
otherInviteOther?: unknown
otherInviteYou?: unknown
youInviteOther?: unknown
}
shutUp?: {
curTime: string
duration: string // 禁言时间,秒
admin: {
uid: string
card: string
name: string
role: GroupMemberRole
}
member: {
uid: string
card: string
name: string
role: GroupMemberRole
}
}
}
export enum GrayTipElementSubType {
Revoke = 1,
Proclamation = 2,
EmojiReply = 3,
Group = 4,
Buddy = 5,
Feed = 6,
Essence = 7,
GroupNotify = 8,
BuddyNotify = 9,
File = 10,
FeedChannelMsg = 11,
XmlMsg = 12,
LocalMsg = 13,
Block = 14,
AioOp = 15,
Wallet = 16,
JSON = 17,
}
export interface GrayTipElement {
subElementType: GrayTipElementSubType
revokeElement?: {
operatorRole: string
operatorUid: string
operatorNick: string
operatorRemark: string
operatorMemRemark?: string
origMsgSenderUid?: string
isSelfOperate?: boolean
wording: string // 自定义的撤回提示语
}
aioOpGrayTipElement?: TipAioOpGrayTipElement
groupElement?: TipGroupElement
xmlElement?: {
templId: string
content: string
templParam: Map<string, string>
members: Map<string, string> // uid -> remark
}
jsonGrayTipElement?: {
busiId: string
jsonStr: string
}
}
export enum FaceIndex {
Dice = 358,
RPS = 359, // 石头剪刀布
}
export interface FaceElement {
faceIndex: number
faceType: number
faceText?: string
packId?: string
stickerId?: string
sourceType?: number
stickerType?: number
resultId?: string
surpriseId?: string
randomType?: number
pokeType?: number
}
export interface MarketFaceElement {
emojiPackageId: number
faceName?: string
emojiId: string
key: string
imageWidth?: number
imageHeight?: number
supportSize?: {
width: number
height: number
}[]
}
export interface VideoElement {
filePath: string
fileName: string
videoMd5?: string
thumbMd5?: string
fileTime?: number // second
thumbSize?: number // byte
fileFormat?: number // 2表示mp4
fileSize?: string // byte
thumbWidth?: number
thumbHeight?: number
busiType?: 0 // 未知
subBusiType?: 0 // 未知
thumbPath?: Map<number, string>
transferStatus?: 0 // 未知
progress?: 0 // 下载进度?
invalidState?: 0 // 未知
fileUuid?: string // 可以用于下载链接?
fileSubId?: ''
fileBizId?: null
originVideoMd5?: ''
import_rich_media_context?: null
sourceVideoCodecFormat?: number
}
export interface MarkdownElement {
content: string
}
export interface InlineKeyboardElementRowButton {
id: ''
label: string
visitedLabel: string
style: 1 // 未知
type: 2 // 未知
clickLimit: 0 // 未知
unsupportTips: '请升级新版手机QQ'
data: string
atBotShowChannelList: false
permissionType: 2
specifyRoleIds: []
specifyTinyids: []
isReply: false
anchor: 0
enter: false
subscribeDataTemplateIds: []
}
export interface InlineKeyboardElement {
rows: [
{
buttons: InlineKeyboardElementRowButton[]
},
]
}
export interface StructLongMsgElement {
xmlContent: string
resId: string
}
export interface MultiForwardMsgElement {
xmlContent: string // xml格式的消息内容
resId: string
fileName: string
}
export enum ChatType {
C2C = 1,
Group = 2,
TempC2CFromGroup = 100,
}
export interface RawMessage {
msgId: string
msgType: number
subMsgType: number
msgTime: string // 时间戳,秒
msgSeq: string
msgRandom: string
senderUid: string
senderUin: string // 发送者QQ号
peerUid: string // 群号 或者 QQ uid
peerUin: string // 群号 或者 发送者QQ号
guildId: string
sendNickName: string
sendMemberName?: string // 发送者群名片
sendRemarkName?: string // 发送者好友备注
chatType: ChatType
sendStatus?: number // 消息状态别人发的2是已撤回自己发的2是已发送
recallTime: string // 撤回时间, "0"是没有撤回
records: RawMessage[]
elements: MessageElement[]
peerName: string
multiTransInfo?: {
status: number
msgId: number
friendFlag: number
fromFaceUrl: string
}
emojiLikesList: {
emojiId: string
emojiType: string
likesCnt: string
isClicked: boolean
}[]
}
export interface Peer {
chatType: ChatType
peerUid: string // 如果是群聊uid为群号私聊uid就是加密的字符串
guildId?: string
}
export interface MessageElement {
elementType: ElementType
elementId: string
extBufForUI: string //"0x"
textElement?: TextElement
faceElement?: FaceElement
marketFaceElement?: MarketFaceElement
replyElement?: ReplyElement
picElement?: PicElement
pttElement?: PttElement
videoElement?: VideoElement
grayTipElement?: GrayTipElement
arkElement?: ArkElement
fileElement?: FileElement
liveGiftElement?: unknown
markdownElement?: MarkdownElement
structLongMsgElement?: StructLongMsgElement
multiForwardMsgElement?: MultiForwardMsgElement
giphyElement?: unknown
inlineKeyboardElement?: InlineKeyboardElement
textGiftElement?: unknown
calendarElement?: unknown
yoloGameResultElement?: unknown
avRecordElement?: unknown
structMsgElement?: unknown
faceBubbleElement?: unknown
shareLocationElement?: unknown
tofuRecordElement?: unknown
taskTopMsgElement?: unknown
recommendedMsgElement?: unknown
actionBarElement?: unknown
}
export interface RichMediaDownloadCompleteNotify {
fileModelId: string
msgElementId: string
msgId: string
fileId: string
fileProgress: string // '0'
fileSpeed: string // '0'
fileErrCode: string // '0'
fileErrMsg: string
fileDownType: number // 暂时未知
thumbSize: number
filePath: string
totalSize: string
trasferStatus: number
step: number
commonFileInfo: unknown
fileSrvErrCode: string
clientMsg: string
businessId: number
userTotalSpacePerDay: unknown
userUsedSpacePerDay: unknown
}
export interface GroupFileInfo {
retCode: number
retMsg: string
clientWording: string
isEnd: boolean
item: {
peerId: string
type: number
folderInfo?: {
folderId: string
parentFolderId: string
folderName: string
createTime: number
modifyTime: number
createUin: string
creatorName: string
totalFileCount: number
modifyUin: string
modifyName: string
usedSpace: string
}
fileInfo?: {
fileModelId: string
fileId: string
fileName: string
fileSize: string
busId: number
uploadedSize: string
uploadTime: number
deadTime: number
modifyTime: number
downloadTimes: number
sha: string
sha3: string
md5: string
uploaderLocalPath: string
uploaderName: string
uploaderUin: string
parentFolderId: string
localPath: string
transStatus: number
transType: number
elementId: string
isFolder: boolean
}
}[]
allFileCount: number
nextIndex: number
reqId: number
}
export interface QueryMsgsParams {
chatInfo: Peer
filterMsgType: []
filterSendersUid: string[]
filterMsgFromTime: string
filterMsgToTime: string
pageLimit: number
isReverseOrder: boolean
isIncludeCurrent: boolean
}
export interface TmpChatInfoApi extends GeneralCallResult {
tmpChatInfo?: {
chatType: number
fromNick: string
groupCode: string
peerUid: string
sessionType: number
sig: string
}
}
export interface GetFileListParam {
sortType: number
fileCount: number
startIndex: number
sortOrder: number
showOnlinedocFolder: number
folderId?: string
}

View File

@@ -1,81 +0,0 @@
export enum GroupNotifyType {
InvitedByMember = 1,
RefuseInvited,
RefusedByAdminiStrator,
AgreedTojoinDirect, // 有人接受了邀请入群
InvitedNeedAdminiStratorPass, // 有人邀请了别人入群
AgreedToJoinByAdminiStrator,
RequestJoinNeedAdminiStratorPass,
SetAdmin,
KickMemberNotifyAdmin,
KickMemberNotifyKicked,
MemberLeaveNotifyAdmin, // 主动退出
CancelAdminNotifyCanceled, // 我被取消管理员
CancelAdminNotifyAdmin, // 其他人取消管理员
TransferGroupNotifyOldowner,
TransferGroupNotifyAdmin
}
export interface GroupNotifies {
doubt: boolean
nextStartSeq: string
notifies: GroupNotify[]
}
export enum GroupNotifyStatus {
Init, // 初始化
Unhandle, // 未处理
Agreed, // 同意
Refused, // 拒绝
Ignored // 忽略
}
export interface GroupNotify {
time: number // 自己添加的字段,时间戳,毫秒, 用于判断收到短时间内收到重复的notify
seq: string // 唯一标识符转成数字再除以1000应该就是时间戳
type: GroupNotifyType
status: GroupNotifyStatus
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 enum BuddyReqType {
MsgInfo = 12,
MeInitiatorWaitPeerConfirm = 13,
}
export interface FriendRequest {
isInitiator?: boolean
isDecide: boolean
friendUid: string
reqType: BuddyReqType
reqTime: string // 时间戳,秒
extWords: string // 申请人填写的验证消息
isUnread: boolean
friendNick: string
sourceId: number
groupCode: string
}
export interface FriendRequestNotify {
data: {
unreadNums: number
buddyReqs: FriendRequest[]
}
}

View File

@@ -1,359 +0,0 @@
export enum Sex {
male = 0,
female = 2,
unknown = 255,
}
export interface QQLevel {
crownNum: number
sunNum: number
moonNum: number
starNum: number
}
export interface User {
uid: string // 加密的字符串
uin: string // QQ号
nick: string
avatarUrl?: string
longNick?: string // 签名
remark?: string
sex?: Sex
qqLevel?: QQLevel
qid?: string
birthday_year?: number
birthday_month?: number
birthday_day?: number
topTime?: string
constellation?: number
shengXiao?: number
kBloodType?: number
homeTown?: string //"0-0-0",
makeFriendCareer?: number
pos?: string
eMail?: string
phoneNum?: string
college?: string
country?: string
province?: string
city?: string
postCode?: string
address?: string
isBlock?: boolean
isSpecialCareOpen?: boolean
isSpecialCareZone?: boolean
ringId?: string
regTime?: number
interest?: string
labels?: string[]
isHideQQLevel?: number
privilegeIcon?: {
jumpUrl: string
openIconList: unknown[]
closeIconList: unknown[]
}
photoWall?: {
picList: unknown[]
}
vipFlag?: boolean
yearVipFlag?: boolean
svipFlag?: boolean
vipLevel?: number
status?: number
qidianMasterFlag?: number
qidianCrewFlag?: number
qidianCrewFlag2?: number
extStatus?: number
recommendImgFlag?: number
disableEmojiShortCuts?: number
pendantId?: string
}
export interface SelfInfo extends User {
online?: boolean
}
export interface Friend extends User {
}
export interface CategoryFriend {
categoryId: number
categorySortId: number
categroyName: string
categroyMbCount: number
onlineCount: number
buddyList: User[] // V1
buddyUids: string[]
}
export interface CoreInfo {
uid: string
uin: string
nick: string
remark: string
}
export interface BaseInfo {
qid: string
longNick: string
birthday_year: number
birthday_month: number
birthday_day: number
age: number
sex: number
eMail: string
phoneNum: string
categoryId: number
richTime: number
richBuffer: Uint8Array
}
interface MusicInfo {
buf: string
}
interface VideoBizInfo {
cid: string
tvUrl: string
synchType: string
}
interface VideoInfo {
name: string
}
interface ExtOnlineBusinessInfo {
buf: string
customStatus: unknown
videoBizInfo: VideoBizInfo
videoInfo: VideoInfo
}
interface ExtBuffer {
buf: string
}
interface UserStatus {
uid: string
uin: string
status: number
extStatus: number
batteryStatus: number
termType: number
netType: number
iconType: number
customStatus: unknown
setTime: string
specialFlag: number
abiFlag: number
eNetworkType: number
showName: string
termDesc: string
musicInfo: MusicInfo
extOnlineBusinessInfo: ExtOnlineBusinessInfo
extBuffer: ExtBuffer
}
interface PrivilegeIcon {
jumpUrl: string
openIconList: unknown[]
closeIconList: unknown[]
}
interface VasInfo {
vipFlag: boolean
yearVipFlag: boolean
svipFlag: boolean
vipLevel: number
bigClub: boolean
bigClubLevel: number
nameplateVipType: number
grayNameplateFlag: number
superVipTemplateId: number
diyFontId: number
pendantId: number
pendantDiyId: number
faceId: number
vipFont: number
vipFontType: number
magicFont: number
fontEffect: number
newLoverDiamondFlag: number
extendNameplateId: number
diyNameplateIDs: unknown[]
vipStartFlag: number
vipDataFlag: number
gameNameplateId: string
gameLastLoginTime: string
gameRank: number
gameIconShowFlag: boolean
gameCardId: string
vipNameColorId: string
privilegeIcon: PrivilegeIcon
}
export interface SimpleInfo {
uid?: string
uin?: string
coreInfo: CoreInfo
baseInfo: BaseInfo
status: UserStatus | null
vasInfo: VasInfo | null
relationFlags: RelationFlags | null
otherFlags: unknown | null
intimate: unknown | null
}
interface RelationFlags {
topTime: string
isBlock: boolean
isMsgDisturb: boolean
isSpecialCareOpen: boolean
isSpecialCareZone: boolean
ringId: string
isBlocked: boolean
recommendImgFlag: number
disableEmojiShortCuts: number
qidianMasterFlag: number
qidianCrewFlag: number
qidianCrewFlag2: number
isHideQQLevel: number
isHidePrivilegeIcon: number
}
interface CommonExt {
constellation: number
shengXiao: number
kBloodType: number
homeTown: string
makeFriendCareer: number
pos: string
college: string
country: string
province: string
city: string
postCode: string
address: string
regTime: number
interest: string
labels: string[]
qqLevel: QQLevel
}
interface Pic {
picId: string
picTime: number
picUrlMap: Record<string, string>
}
interface PhotoWall {
picList: Pic[]
}
export interface UserDetailInfo {
uid: string
uin: string
simpleInfo: SimpleInfo
commonExt: CommonExt
photoWall: PhotoWall
}
export interface BuddyProfileLikeReq {
friendUids: string[]
basic: number
vote: number
favorite: number
userProfile: number
type: number
start: number
limit: number
}
export interface UserDetailInfoByUinV2 {
result: number
errMsg: string
detail: {
uid: string
uin: string
simpleInfo: SimpleInfo
commonExt: CommonExt
photoWall: null
}
}
export interface UserDetailInfoByUin {
result: number
errMsg: string
info: {
uid: string //这个没办法用
qid: string
uin: string
nick: string
remark: string
longNick: string
avatarUrl: string
birthday_year: number
birthday_month: number
birthday_day: number
sex: number //0
topTime: string
constellation: number
shengXiao: number
kBloodType: number
homeTown: string
makeFriendCareer: number
pos: string
eMail: string
phoneNum: string
college: string
country: string
province: string
city: string
postCode: string
address: string
isBlock: boolean
isSpecialCareOpen: boolean
isSpecialCareZone: boolean
ringId: string
regTime: number
interest: string
termType: number
labels: unknown[]
qqLevel: { crownNum: number, sunNum: number, moonNum: number, starNum: number }
isHideQQLevel: number
privilegeIcon: { jumpUrl: string, openIconList: unknown[], closeIconList: unknown[] }
isHidePrivilegeIcon: number
photoWall: { picList: unknown[] }
vipFlag: boolean
yearVipFlag: boolean
svipFlag: boolean
vipLevel: number
status: number
qidianMasterFlag: number
qidianCrewFlag: number
qidianCrewFlag2: number
extStatus: number
recommendImgFlag: number
disableEmojiShortCuts: number
pendantId: string
vipNameColorId: string
}
}
export enum BuddyListReqType {
KNOMAL,
KLETTER
}
export enum UserDetailSource {
KDB,
KSERVER
}
export enum ProfileBizType {
KALL,
KBASEEXTEND,
KVAS,
KQZONE,
KOTHER
}

View File

@@ -1,51 +0,0 @@
import { ActionName } from './types'
import { OB11Response } from './OB11Response'
import { OB11Return } from '../types'
import { Context, Schema } from 'cordis'
import type Adapter from '../adapter'
abstract class BaseAction<PayloadType, ReturnDataType> {
abstract actionName: ActionName
protected ctx: Context
payloadSchema?: Schema<PayloadType>
constructor(protected adapter: Adapter) {
this.ctx = adapter.ctx
}
public async handle(payload: PayloadType): Promise<OB11Return<ReturnDataType | null>> {
let params: PayloadType
try {
params = this.payloadSchema ? new this.payloadSchema(payload) : payload
} catch (e) {
return OB11Response.error((e as Error).message, 400)
}
try {
const resData = await this._handle(params)
return OB11Response.ok(resData)
} catch (e) {
this.ctx.logger.error('发生错误', e)
return OB11Response.error((e as Error)?.toString() || (e as Error)?.stack?.toString() || '未知错误,可能操作超时', 200)
}
}
public async websocketHandle(payload: PayloadType, echo: unknown): Promise<OB11Return<ReturnDataType | null>> {
let params: PayloadType
try {
params = this.payloadSchema ? new this.payloadSchema(payload) : payload
} catch (e) {
return OB11Response.error((e as Error).message, 1400)
}
try {
const resData = await this._handle(params)
return OB11Response.ok(resData, echo)
} catch (e) {
this.ctx.logger.error('发生错误', e)
return OB11Response.error((e as Error)?.stack?.toString() || String(e), 1200, echo)
}
}
protected abstract _handle(payload: PayloadType): Promise<ReturnDataType>
}
export { BaseAction, Schema }

View File

@@ -1,31 +0,0 @@
import { OB11Return } from '../types'
import { isNullable } from 'cosmokit'
export class OB11Response {
static res<T>(data: T, status: string, retcode: number, message: string = ''): OB11Return<T> {
return {
status: status,
retcode: retcode,
data: data,
message: message,
wording: message,
echo: undefined,
}
}
static ok<T>(data: T, echo?: unknown) {
const res = OB11Response.res<T>(data, 'ok', 0)
if (!isNullable(echo)) {
res.echo = echo
}
return res
}
static error(err: string, retcode: number, echo?: unknown) {
const res = OB11Response.res(null, 'failed', retcode, err)
if (!isNullable(echo)) {
res.echo = echo
}
return res
}
}

View File

@@ -1,90 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { readFile } from 'node:fs/promises'
import { ActionName } from '../types'
import { Peer, ElementType } from '@/ntqqapi/types'
export interface GetFilePayload {
file: string // 文件名或者fileUuid
}
export interface GetFileResponse {
file?: string // path
url?: string
file_size?: string
file_name?: string
base64?: string
}
export abstract class GetFileBase extends BaseAction<GetFilePayload, GetFileResponse> {
payloadSchema = Schema.object({
file: Schema.string().required()
})
protected async _handle(payload: GetFilePayload): Promise<GetFileResponse> {
const { enableLocalFile2Url } = this.adapter.config
let fileCache = await this.ctx.store.getFileCacheById(payload.file)
if (!fileCache?.length) {
fileCache = await this.ctx.store.getFileCacheByName(payload.file)
}
if (fileCache?.length) {
const downloadPath = await this.ctx.ntFileApi.downloadMedia(
fileCache[0].msgId,
fileCache[0].chatType,
fileCache[0].peerUid,
fileCache[0].elementId,
'',
''
)
const res: GetFileResponse = {
file: downloadPath,
url: downloadPath,
file_size: fileCache[0].fileSize,
file_name: fileCache[0].fileName,
}
const peer: Peer = {
chatType: fileCache[0].chatType,
peerUid: fileCache[0].peerUid,
guildId: ''
}
if (fileCache[0].elementType === ElementType.Pic) {
const msgList = await this.ctx.ntMsgApi.getMsgsByMsgId(peer, [fileCache[0].msgId])
if (msgList.msgList.length === 0) {
throw new Error('msg not found')
}
const msg = msgList.msgList[0]
const findEle = msg.elements.find(e => e.elementId === fileCache[0].elementId)
if (!findEle) {
throw new Error('element not found')
}
res.url = await this.ctx.ntFileApi.getImageUrl(findEle.picElement!)
} else if (fileCache[0].elementType === ElementType.Video) {
res.url = await this.ctx.ntFileApi.getVideoUrl(peer, fileCache[0].msgId, fileCache[0].elementId)
}
if (enableLocalFile2Url && downloadPath && (res.file === res.url || res.url === undefined)) {
try {
res.base64 = await readFile(downloadPath, 'base64')
} catch (e) {
throw new Error('文件下载失败. ' + e)
}
}
//不手动删除?文件持久化了
return res
}
throw new Error('file not found')
}
}
export default class GetFile extends GetFileBase {
actionName = ActionName.GetFile
payloadSchema = Schema.object({
file: Schema.string(),
file_id: Schema.string().required()
})
protected async _handle(payload: { file_id: string, file: string }): Promise<GetFileResponse> {
payload.file = payload.file_id
return super._handle(payload)
}
}

View File

@@ -1,6 +0,0 @@
import { GetFileBase } from './GetFile'
import { ActionName } from '../types'
export default class GetImage extends GetFileBase {
actionName = ActionName.GetImage
}

View File

@@ -1,29 +0,0 @@
import path from 'node:path'
import { GetFileBase, GetFilePayload, GetFileResponse } from './GetFile'
import { ActionName } from '../types'
import { decodeSilk } from '@/common/utils/audio'
import { Schema } from '../BaseAction'
import { stat, readFile } from 'node:fs/promises'
interface Payload extends GetFilePayload {
out_format: 'mp3' | 'amr' | 'wma' | 'm4a' | 'spx' | 'ogg' | 'wav' | 'flac'
}
export default class GetRecord extends GetFileBase {
actionName = ActionName.GetRecord
payloadSchema = Schema.object({
file: Schema.string().required(),
out_format: Schema.string().default('mp3')
})
protected async _handle(payload: Payload): Promise<GetFileResponse> {
const res = await super._handle(payload)
res.file = await decodeSilk(this.ctx, res.file!, payload.out_format)
res.file_name = path.basename(res.file)
res.file_size = (await stat(res.file)).size.toString()
if (this.adapter.config.enableLocalFile2Url) {
res.base64 = await readFile(res.file, 'base64')
}
return res
}
}

View File

@@ -1,21 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
group_id: number | string
name: string
parent_id?: '/'
}
export class CreateGroupFileFolder extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_CreateGroupFileFolder
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
name: Schema.string().required(),
})
async _handle(payload: Payload) {
await this.ctx.ntGroupApi.createGroupFileFolder(payload.group_id.toString(), payload.name)
return null
}
}

View File

@@ -1,24 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
message_id: number | string
}
export class DeleteEssenceMsg extends BaseAction<Payload, unknown> {
actionName = ActionName.GoCQHTTP_DelEssenceMsg
payloadSchema = Schema.object({
message_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload) {
const msg = await this.ctx.store.getMsgInfoByShortId(+payload.message_id)
if (!msg) {
throw new Error('msg not found')
}
return await this.ctx.ntGroupApi.removeGroupEssence(
msg.peer.peerUid,
msg.msgId,
)
}
}

View File

@@ -1,22 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
group_id: number | string
file_id: string
busid: number | string
}
export class DelGroupFile extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_DelGroupFile
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
file_id: Schema.string().required(),
busid: Schema.union([Number, String]).default(102)
})
async _handle(payload: Payload) {
await this.ctx.ntGroupApi.deleteGroupFile(payload.group_id.toString(), [payload.file_id], [+payload.busid])
return null
}
}

View File

@@ -1,20 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
group_id: number | string
folder_id: string
}
export class DelGroupFolder extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_DelGroupFolder
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
folder_id: Schema.string().required()
})
async _handle(payload: Payload) {
await this.ctx.ntGroupApi.deleteGroupFileFolder(payload.group_id.toString(), payload.folder_id)
return null
}
}

View File

@@ -1,21 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
user_id: number | string
}
export class DeleteFriend extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_DeleteFriend
payloadSchema = Schema.object({
user_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload) {
const uin = payload.user_id.toString()
const uid = await this.ctx.ntUserApi.getUidByUin(uin)
if (!uid) throw new Error('无法获取用户信息')
await this.ctx.ntFriendApi.delBuddy(uid)
return null
}
}

View File

@@ -1,80 +0,0 @@
import fs from 'fs'
import fsPromise from 'fs/promises'
import path from 'node:path'
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { calculateFileMD5, fetchFile } from '@/common/utils'
import { TEMP_DIR } from '@/common/globalVars'
import { randomUUID } from 'node:crypto'
import { Dict } from 'cosmokit'
interface Payload {
thread_count?: number
url?: string
base64?: string
name?: string
headers?: string | string[]
}
interface FileResponse {
file: string
}
export class DownloadFile extends BaseAction<Payload, FileResponse> {
actionName = ActionName.GoCQHTTP_DownloadFile
payloadSchema = Schema.object({
url: String,
base64: String,
headers: Schema.union([String, Schema.array(String)])
})
protected async _handle(payload: Payload): Promise<FileResponse> {
const isRandomName = !payload.name
const name = payload.name ? path.basename(payload.name) : randomUUID()
const filePath = path.join(TEMP_DIR, name)
if (payload.base64) {
await fsPromise.writeFile(filePath, payload.base64, 'base64')
} else if (payload.url) {
const headers = this.getHeaders(payload.headers)
const res = await fetchFile(payload.url, headers)
await fsPromise.writeFile(filePath, res.data)
} else {
throw new Error('不存在任何文件, 无法下载')
}
if (fs.existsSync(filePath)) {
if (isRandomName) {
// 默认实现要名称未填写时文件名为文件 md5
const md5 = await calculateFileMD5(filePath)
const newPath = path.join(TEMP_DIR, md5)
await fsPromise.rename(filePath, newPath)
return { file: newPath }
}
return { file: filePath }
} else {
throw new Error('文件写入失败, 检查权限')
}
}
getHeaders(headersIn?: string | string[]): Record<string, string> {
const headers: Dict = {}
if (typeof headersIn == 'string') {
headersIn = headersIn.split('[\\r\\n]')
}
if (Array.isArray(headersIn)) {
for (const headerItem of headersIn) {
const spilt = headerItem.indexOf('=')
if (spilt < 0) {
headers[headerItem] = ''
} else {
const key = headerItem.substring(0, spilt)
headers[key] = headerItem.substring(0, spilt + 1)
}
}
}
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/octet-stream'
}
return headers
}
}

View File

@@ -1,56 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { OB11ForwardMessage } from '../../types'
import { OB11Entities } from '../../entities'
import { ActionName } from '../types'
import { filterNullable } from '@/common/utils/misc'
interface Payload {
message_id: string // long msg idgocq
id?: string // long msg id, onebot11
}
interface Response {
messages: OB11ForwardMessage[]
}
export class GetForwardMsg extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetForwardMsg
payloadSchema = Schema.object({
message_id: Schema.string(),
id: Schema.string()
})
protected async _handle(payload: Payload) {
const msgId = payload.id || payload.message_id
if (!msgId) {
throw Error('message_id不能为空')
}
const rootMsgId = await this.ctx.store.getShortIdByMsgId(msgId)
const rootMsg = await this.ctx.store.getMsgInfoByShortId(rootMsgId || +msgId)
if (!rootMsg) {
throw Error('msg not found')
}
const data = await this.ctx.ntMsgApi.getMultiMsg(rootMsg.peer, rootMsg.msgId, rootMsg.msgId)
if (data?.result !== 0) {
throw Error('找不到相关的聊天记录' + data?.errMsg)
}
const messages: (OB11ForwardMessage | undefined)[] = await Promise.all(
data.msgList.map(async (msg) => {
const res = await OB11Entities.message(this.ctx, msg)
if (res) {
return {
content: res.message,
sender: {
nickname: res.sender.nickname,
user_id: res.sender.user_id
},
time: res.time,
message_format: res.message_format,
message_type: res.message_type
}
}
})
)
return { messages: filterNullable(messages) }
}
}

View File

@@ -1,28 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
group_id: number | string
}
interface Response {
can_at_all: boolean
remain_at_all_count_for_group: number
remain_at_all_count_for_uin: number
}
export class GetGroupAtAllRemain extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetGroupAtAllRemain
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required()
})
async _handle(payload: Payload) {
const data = await this.ctx.ntGroupApi.getGroupRemainAtTimes(payload.group_id.toString())
return {
can_at_all: data.atInfo.canAtAll,
remain_at_all_count_for_group: data.atInfo.RemainAtAllCountForGroup,
remain_at_all_count_for_uin: data.atInfo.RemainAtAllCountForUin
}
}
}

View File

@@ -1,50 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { ChatType } from '@/ntqqapi/types'
interface Payload {
group_id: number | string
}
interface EssenceMsg {
sender_id: number
sender_nick: string
sender_time: number
operator_id: number
operator_nick: string
operator_time: number
message_id: number
}
export class GetEssenceMsgList extends BaseAction<Payload, EssenceMsg[]> {
actionName = ActionName.GoCQHTTP_GetEssenceMsgList
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload) {
const groupCode = payload.group_id.toString()
const peer = {
guildId: '',
chatType: ChatType.Group,
peerUid: groupCode
}
const essence = await this.ctx.ntGroupApi.queryCachedEssenceMsg(groupCode)
const data: EssenceMsg[] = []
for (const item of essence.items) {
const { msgList } = await this.ctx.ntMsgApi.queryMsgsWithFilterExBySeq(peer, String(item.msgSeq), '0')
const sourceMsg = msgList.find(e => e.msgRandom === String(item.msgRandom))
if (!sourceMsg) continue
data.push({
sender_id: +item.msgSenderUin,
sender_nick: item.msgSenderNick,
sender_time: +sourceMsg.msgTime,
operator_id: +item.opUin,
operator_nick: item.opNick,
operator_time: item.opTime,
message_id: this.ctx.store.createMsgShortId(peer, sourceMsg.msgId)
})
}
return data
}
}

View File

@@ -1,82 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { pathToFileURL } from 'node:url'
import { ChatType } from '@/ntqqapi/types'
import { GroupFileInfo } from '@/ntqqapi/types'
export interface Payload {
group_id: number | string
file_id: string
busid?: number
}
export interface Response {
url: string
}
export class GetGroupFileUrl extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetGroupFileUrl
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
file_id: Schema.string().required()
})
protected async _handle(payload: Payload) {
const file = await this.ctx.store.getFileCacheById(payload.file_id)
if (file.length > 0) {
const { msgId, chatType, peerUid, elementId } = file[0]
const path = await this.ctx.ntFileApi.downloadMedia(msgId, chatType, peerUid, elementId)
return {
url: pathToFileURL(path).href
}
} else {
const groupId = payload.group_id.toString()
const modelId = await this.search(groupId, payload.file_id)
if (modelId) {
const peer = {
chatType: ChatType.Group,
peerUid: groupId,
guildId: ''
}
const path = await this.ctx.ntFileApi.downloadFileForModelId(peer, modelId)
return {
url: pathToFileURL(path).href
}
}
throw new Error('file not found')
}
}
private async search(groupId: string, fileId: string, folderId?: string) {
let modelId: string | undefined
let nextIndex: number | undefined
const folders: GroupFileInfo['item'] = []
while (nextIndex !== 0) {
const res = await this.ctx.ntGroupApi.getGroupFileList(groupId, {
sortType: 1,
fileCount: 100,
startIndex: nextIndex ?? 0,
sortOrder: 2,
showOnlinedocFolder: 0,
folderId
})
const file = res.item.find(item => item.fileInfo?.fileId === fileId)
if (file) {
modelId = file.fileInfo?.fileModelId
break
}
folders.push(...res.item.filter(item => item.folderInfo?.totalFileCount))
nextIndex = res.nextIndex
}
if (!modelId) {
for (const item of folders) {
const res = await this.search(groupId, fileId, item.folderInfo?.folderId)
if (res) {
modelId = res
break
}
}
}
return modelId
}
}

View File

@@ -1,62 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { OB11GroupFile, OB11GroupFileFolder } from '@/onebot11/types'
import { GroupFileInfo } from '@/ntqqapi/types'
interface Payload {
group_id: number | string
folder_id: string
}
interface Response {
files: OB11GroupFile[]
folders: OB11GroupFileFolder[]
}
export class GetGroupFilesByFolder extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetGroupFilesByFolder
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
folder_id: Schema.string().required()
})
async _handle(payload: Payload) {
const groupId = payload.group_id.toString()
const data: GroupFileInfo['item'] = []
let nextIndex: number | undefined
while (nextIndex !== 0) {
const res = await this.ctx.ntGroupApi.getGroupFileList(groupId, {
sortType: 1,
fileCount: 100,
startIndex: nextIndex ?? 0,
sortOrder: 2,
showOnlinedocFolder: 0,
folderId: payload.folder_id
})
data.push(...res.item)
nextIndex = res.nextIndex
}
return {
files: data.filter(item => item.fileInfo)
.map(item => {
const file = item.fileInfo!
return {
group_id: +item.peerId,
file_id: file.fileId,
file_name: file.fileName,
busid: file.busId,
file_size: +file.fileSize,
upload_time: file.uploadTime,
dead_time: file.deadTime,
modify_time: file.modifyTime,
download_times: file.downloadTimes,
uploader: +file.uploaderUin,
uploader_name: file.uploaderName
}
}),
folders: []
}
}
}

View File

@@ -1,45 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { OB11Message } from '../../types'
import { ActionName } from '../types'
import { ChatType } from '@/ntqqapi/types'
import { OB11Entities } from '../../entities'
import { RawMessage } from '@/ntqqapi/types'
import { filterNullable, parseBool } from '@/common/utils/misc'
interface Payload {
group_id: number | string
message_seq?: number | string
count: number | string
reverseOrder: boolean
}
interface Response {
messages: OB11Message[]
}
export class GetGroupMsgHistory extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetGroupMsgHistory
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required(),
message_seq: Schema.union([Number, String]),
count: Schema.union([Number, String]).default(20),
reverseOrder: Schema.union([Boolean, Schema.transform(String, parseBool)]).default(false)
})
protected async _handle(payload: Payload): Promise<Response> {
const { count, reverseOrder } = payload
const peer = { chatType: ChatType.Group, peerUid: payload.group_id.toString() }
let msgList: RawMessage[]
if (!payload.message_seq || payload.message_seq === '0') {
msgList = (await this.ctx.ntMsgApi.getAioFirstViewLatestMsgs(peer, +count)).msgList
} else {
const startMsgId = (await this.ctx.store.getMsgInfoByShortId(+payload.message_seq))?.msgId
if (!startMsgId) throw new Error(`消息${payload.message_seq}不存在`)
msgList = (await this.ctx.ntMsgApi.getMsgHistory(peer, startMsgId, +count)).msgList
}
if (!msgList?.length) throw new Error('未找到消息')
if (reverseOrder) msgList.reverse()
const ob11MsgList = await Promise.all(msgList.map(msg => OB11Entities.message(this.ctx, msg)))
return { messages: filterNullable(ob11MsgList) }
}
}

View File

@@ -1,48 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
group_id: number | string
}
interface Notice {
sender_id: number
publish_time: number
message: {
text: string
images: {
height: string
width: string
id: string
}[]
}
}
export class GetGroupNotice extends BaseAction<Payload, Notice[]> {
actionName = ActionName.GoCQHTTP_GetGroupNotice
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload) {
const data = await this.ctx.ntGroupApi.getGroupBulletinList(payload.group_id.toString())
const result: Notice[] = []
for (const feed of data.result.feeds) {
result.push({
sender_id: +feed.uin,
publish_time: +feed.publishTime,
message: {
text: feed.msg.text,
images: feed.msg.pics.map(image => {
return {
height: String(image.height),
width: String(image.width),
id: image.id
}
})
}
})
}
return result
}
}

View File

@@ -1,71 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
import { OB11GroupFile, OB11GroupFileFolder } from '../../types'
import { GroupFileInfo } from '@/ntqqapi/types'
interface Payload {
group_id: number | string
}
interface Response {
files: OB11GroupFile[]
folders: OB11GroupFileFolder[]
}
export class GetGroupRootFiles extends BaseAction<Payload, Response> {
actionName = ActionName.GoCQHTTP_GetGroupRootFiles
payloadSchema = Schema.object({
group_id: Schema.union([Number, String]).required()
})
async _handle(payload: Payload) {
const groupId = payload.group_id.toString()
const data: GroupFileInfo['item'] = []
let nextIndex: number | undefined
while (nextIndex !== 0) {
const res = await this.ctx.ntGroupApi.getGroupFileList(groupId, {
sortType: 1,
fileCount: 100,
startIndex: nextIndex ?? 0,
sortOrder: 2,
showOnlinedocFolder: 0,
})
data.push(...res.item)
nextIndex = res.nextIndex
}
return {
files: data.filter(item => item.fileInfo)
.map(item => {
const file = item.fileInfo!
return {
group_id: +item.peerId,
file_id: file.fileId,
file_name: file.fileName,
busid: file.busId,
file_size: +file.fileSize,
upload_time: file.uploadTime,
dead_time: file.deadTime,
modify_time: file.modifyTime,
download_times: file.downloadTimes,
uploader: +file.uploaderUin,
uploader_name: file.uploaderName
}
}),
folders: data.filter(item => item.folderInfo)
.map(item => {
const folder = item.folderInfo!
return {
group_id: +item.peerId,
folder_id: folder.folderId,
folder_name: folder.folderName,
create_time: folder.createTime,
creator: +folder.createUin,
creator_name: folder.creatorName,
total_file_count: folder.totalFileCount
}
})
}
}
}

View File

@@ -1,59 +0,0 @@
import { BaseAction } from '../BaseAction'
import { GroupNotifyStatus } from '@/ntqqapi/types'
import { ActionName } from '../types'
interface Response {
invited_requests: {
request_id: number
invitor_uin: number
invitor_nick: string
group_id: number
group_name: string
checked: boolean
actor: number
}[]
join_requests: {
request_id: number
requester_uin: number
requester_nick: string
message: string
group_id: number
group_name: string
checked: boolean
actor: number
}[]
}
export class GetGroupSystemMsg extends BaseAction<void, Response> {
actionName = ActionName.GoCQHTTP_GetGroupSystemMsg
async _handle() {
const singleScreenNotifies = await this.ctx.ntGroupApi.getSingleScreenNotifies(10)
const data: Response = { invited_requests: [], join_requests: [] }
for (const notify of singleScreenNotifies) {
if (notify.type == 1) {
data.invited_requests.push({
request_id: +notify.seq,
invitor_uin: Number(await this.ctx.ntUserApi.getUinByUid(notify.user1.uid)),
invitor_nick: notify.user1.nickName,
group_id: +notify.group.groupCode,
group_name: notify.group.groupName,
checked: notify.status !== GroupNotifyStatus.Unhandle,
actor: notify.user2?.uid ? Number(await this.ctx.ntUserApi.getUinByUid(notify.user2.uid)) : 0
})
} else if (notify.type == 7) {
data.join_requests.push({
request_id: +notify.seq,
requester_uin: Number(await this.ctx.ntUserApi.getUinByUid(notify.user1.uid)),
requester_nick: notify.user1.nickName,
message: notify.postscript,
group_id: +notify.group.groupCode,
group_name: notify.group.groupName,
checked: notify.status !== GroupNotifyStatus.Unhandle,
actor: notify.user2?.uid ? Number(await this.ctx.ntUserApi.getUinByUid(notify.user2.uid)) : 0
})
}
}
return data
}
}

View File

@@ -1,61 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { OB11User } from '../../types'
import { OB11Entities } from '../../entities'
import { ActionName } from '../types'
import { getBuildVersion } from '@/common/utils'
import { OB11UserSex } from '../../types'
import { calcQQLevel } from '@/common/utils/misc'
interface Payload {
user_id: number | string
}
export class GetStrangerInfo extends BaseAction<Payload, OB11User> {
actionName = ActionName.GoCQHTTP_GetStrangerInfo
payloadSchema = Schema.object({
user_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload): Promise<OB11User> {
if (!(getBuildVersion() >= 26702)) {
const user_id = payload.user_id.toString()
const extendData = await this.ctx.ntUserApi.getUserDetailInfoByUin(user_id)
const uid = (await this.ctx.ntUserApi.getUidByUin(user_id))!
if (!uid || uid.indexOf('*') != -1) {
const ret = {
...extendData,
user_id: parseInt(extendData.info.uin) || 0,
nickname: extendData.info.nick,
sex: OB11UserSex.unknown,
age: (extendData.info.birthday_year == 0) ? 0 : new Date().getFullYear() - extendData.info.birthday_year,
qid: extendData.info.qid,
level: extendData.info.qqLevel && calcQQLevel(extendData.info.qqLevel) || 0,
login_days: 0,
uid: ''
}
return ret
}
const data = { ...extendData, ...(await this.ctx.ntUserApi.getUserDetailInfo(uid)) }
return OB11Entities.stranger(data)
} else {
const user_id = payload.user_id.toString()
const extendData = await this.ctx.ntUserApi.getUserDetailInfoByUinV2(user_id)
const uid = (await this.ctx.ntUserApi.getUidByUin(user_id))!
if (!uid || uid.indexOf('*') != -1) {
const ret = {
...extendData,
user_id: parseInt(extendData.detail.uin) || 0,
nickname: extendData.detail.simpleInfo.coreInfo.nick,
sex: OB11UserSex.unknown,
age: 0,
level: extendData.detail.commonExt.qqLevel && calcQQLevel(extendData.detail.commonExt.qqLevel) || 0,
login_days: 0,
uid: ''
}
return ret
}
const data = { ...extendData, ...(await this.ctx.ntUserApi.getUserDetailInfo(uid)) }
return OB11Entities.stranger(data)
}
}
}

View File

@@ -1,22 +0,0 @@
import { BaseAction, Schema } from '../BaseAction'
import { ActionName } from '../types'
interface Payload {
message_id: number | string
}
export class MarkMsgAsRead extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_MarkMsgAsRead
payloadSchema = Schema.object({
message_id: Schema.union([Number, String]).required()
})
protected async _handle(payload: Payload) {
const msg = await this.ctx.store.getMsgInfoByShortId(+payload.message_id)
if (!msg) {
throw new Error('msg not found')
}
await this.ctx.ntMsgApi.setMsgRead(msg.peer)
return null
}
}

View File

@@ -1,17 +0,0 @@
import { BaseAction } from '../BaseAction'
import { handleQuickOperation, QuickOperation, QuickOperationEvent } from '../../helper/quickOperation'
import { ActionName } from '../types'
interface Payload {
context: QuickOperationEvent,
operation: QuickOperation
}
export class HandleQuickOperation extends BaseAction<Payload, null> {
actionName = ActionName.GoCQHTTP_HandleQuickOperation
protected async _handle(payload: Payload): Promise<null> {
handleQuickOperation(this.ctx, payload.context, payload.operation).catch(e => this.ctx.logger.error(e))
return null
}
}

Some files were not shown because too many files have changed in this diff Show More