Skip to content

Commit

Permalink
Merge pull request #96 from HydroGest/dev
Browse files Browse the repository at this point in the history
[feat] QQ 扩展管理功能
  • Loading branch information
HydroGest authored Jan 27, 2025
2 parents 84629e3 + f6a2276 commit 600e617
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 10 deletions.
252 changes: 252 additions & 0 deletions packages/core/src/commands/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { Context } from "koishi";
import path from 'path';
import fs from 'fs/promises';
import { downloadFile, readMetadata } from "../utils";

// 文件名标准化函数
function normalizeFilename(original: string): string {
// 移除已有扩展名前缀(如果有的话)
const baseName = original.startsWith('ext_')
? original.slice(4)
: original;

// 添加统一前缀
return `ext_${baseName}`;
}

// 扩展信息类型
interface ExtensionInfo {
fileName: string
name: string
version: string
author: string
description?: string
}

// 获取扩展目录路径
function getExtensionPath(ctx: Context) {
const isDevMode = process.env.NODE_ENV === 'development'
return path.join(
ctx.baseDir,
isDevMode
? 'external/yesimbot/packages/core/lib/extensions'
: 'node_modules/koishi-plugin-yesimbot/lib/extensions'
)
}

// 获取有效扩展文件列表
async function getExtensionFiles(ctx: Context): Promise<string[]> {
const extensionPath = getExtensionPath(ctx)
try {
const files = await fs.readdir(extensionPath)
return files.filter(file =>
file.startsWith('ext_') &&
file.endsWith('.js') &&
!file.endsWith('.map') &&
!file.endsWith('.d.js')
)
} catch (error) {
ctx.logger.error('读取扩展目录失败:', error)
return []
}
}

export function apply(ctx: Context) {

// 扩展列表指令
ctx.command('扩展列表', '显示已安装的扩展列表', { authority: 3 })
.action(async ({ session }) => {
try {
const extFiles = await getExtensionFiles(ctx)
if (extFiles.length === 0) {
return '当前没有安装任何扩展。'
}

const extensions: ExtensionInfo[] = []
for (const file of extFiles) {
try {
const filePath = path.join(getExtensionPath(ctx), file)
const metadata = readMetadata(filePath)
if (!metadata) continue

extensions.push({
fileName: file,
name: metadata.name || '未命名扩展',
version: metadata.version || '0.0.0',
author: metadata.author || '未知作者',
description: metadata.description
})
} catch (error) {
ctx.logger.warn(`[${file}] 元数据读取失败:`, error)
}
}

if (extensions.length === 0) {
return '没有找到有效的扩展。'
}

// 格式化输出
let message = '📦 已安装扩展列表:\n\n'
message += extensions.map((ext, index) =>
`【${index + 1}${ext.name}
- 文件:${ext.fileName}
- 版本:v${ext.version}
- 作者:${ext.author}
${ext.description ? `- 描述:${ext.description}` : '- 请联系扩展作者添加详细信息。'}`
).join('\n\n')

return message;
} catch (error) {
ctx.logger.error('扩展列表获取失败:', error)
return '❌ 获取扩展列表失败,请查看日志。'
}
})

// 删除扩展指令
ctx.command('删除扩展 <fileName>', '删除指定扩展文件', { authority: 3 })
.option('force', '-f 强制删除(跳过确认)')
.usage([
'注意:',
'1. 文件名不需要输入 ext_ 前缀和 .js 后缀',
'2. 实际删除时会自动补全前缀和后缀',
'示例:删除扩展 example → 实际删除 ext_example.js'
].join('\n'))
.example('删除扩展 example -f')
.action(async ({ session, options }, fileName) => {
try {
if (!fileName) return '请输入要删除的扩展名称。'

// 文件名标准化处理
let processedName = fileName.trim()
// 补充扩展名
if (!processedName.endsWith('.js')) processedName += '.js'
// 强制前缀处理
processedName = normalizeFilename(processedName)

const filePath = path.join(getExtensionPath(ctx), processedName)

try {
await fs.access(filePath)
} catch {
return `❌ 扩展文件 ${processedName} 不存在。`
}

if (!options.force) {
await session?.send(`⚠️ 确认要删除扩展 ${processedName} 吗?(y/N)`)
const confirm = await session?.prompt(5000)
if (!confirm || !confirm.toLowerCase().startsWith('y')) {
return '🗑️ 删除操作已取消。'
}
}

await fs.unlink(filePath)
ctx.logger.success(`扩展删除成功: ${processedName}`)

return `✅ 扩展 ${processedName} 已删除。\n` +
'请使用 "重载插件" 命令使更改生效。'
} catch (error) {
ctx.logger.error('扩展删除失败:', error)
return `❌ 删除失败:${error.message}`
}
})

ctx.command("重载插件", { authority: 3 })
.usage("重载 Athena,用于生效扩展变更。")
.action(({ session }) => {
session.send("✅ 已进行重载操作。")
ctx.scope.restart();
})

ctx
.command("安装扩展 <url>", { authority: 3 })
.usage("安装 Athena 扩展文件")
.example(
[
"安装扩展 https://example.com/plugin.js",
"安装扩展 https://example.com/plugin.js -f custom"
].join("\n")
)
.option("file", "-f <filename> 指定保存的文件名", { type: "string" })
.action(async ({ session, options }, url) => {
try {
ctx.logger.info(`[扩展安装] 开始从 ${url} 安装扩展...`);

const isDevMode = process.env.NODE_ENV === 'development';
ctx.logger.info(`[环境模式] ${isDevMode ? '开发环境 🛠️' : '生产环境 🚀'}`);

const extensionPath = path.join(
ctx.baseDir,
isDevMode
? 'external/yesimbot/packages/core/lib/extensions'
: 'node_modules/koishi-plugin-yesimbot/lib/extensions'
);
ctx.logger.info(`[路径配置] 扩展存储目录:${extensionPath}`);
await fs.mkdir(extensionPath, { recursive: true });

// 文件名处理流程
let filename: string;
if (options.file) {
// 处理用户指定文件名
filename = options.file.endsWith('.js')
? options.file
: `${options.file}.js`;
} else {
// 从 URL 提取文件名
filename = path.basename(url);
if (!filename.endsWith('.js')) {
throw new Error('URL 必须指向 .js 文件');
}
}

// 强制添加前缀(不影响已有 ext_ 开头的情况)
filename = normalizeFilename(filename);

// 安全校验(二次防御)
if (!/^ext_[\w\-]+\.js$/.test(filename)) {
throw new Error('文件名格式无效,应为 ext_开头 + 字母数字 + .js');
}

const filePath = path.join(extensionPath, filename);

// 交互式覆盖确认
try {
await fs.access(filePath);
ctx.logger.warn("[文件下载] 文件已存在,等待用户操作");
await session?.send(`文件 ${ filename } 已存在,是否覆盖?(y / N)`);
const confirm = await session?.prompt();
if (!confirm?.toLowerCase().startsWith('y')) {
return '❌ 用户取消操作';
}
} catch {
// 文件不存在时忽略错误
}

// 下载文件
await downloadFile(url, filePath, true);
ctx.logger.success(`[文件下载] 扩展文件已保存至:${filePath}`);

// 读取元数据
const metadata = readMetadata(filePath);
if (!metadata) {
throw new Error('无法读取扩展元数据');
}

ctx.logger.info(`[扩展信息] 安装详情:
- 文件名称:${filename}
- 显示名称:${metadata.name || '未命名扩展'}
- 版本号:${metadata.version || '0.0.0'}
- 作者:${metadata.author || '匿名'}`);

return `✅ 扩展 ${metadata.name || filename} 安装完成。输入 "重载插件" 以生效。
详情:
- 文件名称:${ filename }
- 显示名称:${ metadata.name || '未命名扩展' }
- 版本号:${ metadata.version || '0.0.0' }
- 作者:${ metadata.author || '匿名' }`;

} catch (error) {
ctx.logger.error('[扩展安装] 失败原因:', error);
return `❌ 安装失败:${error.message}`;
}
});
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { convertUrltoBase64 } from "./utils/imageUtils";
import { Bot, FailedResponse, SkipResponse, SuccessResponse } from "./bot";
import { apply as applyMemoryCommands } from "./commands/memory";
import { apply as applySendQueneCommands } from "./commands/sendQuene";
import { apply as applyExtensionCommands } from "./commands/extension";

export const name = "yesimbot";

Expand Down Expand Up @@ -126,6 +127,7 @@ export function apply(ctx: Context, config: Config) {
try {
//applyMemoryCommands(ctx, bot);
applySendQueneCommands(ctx, sendQueue);
applyExtensionCommands(ctx);
} catch (error) {

}
Expand Down
32 changes: 22 additions & 10 deletions packages/core/src/utils/metaReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,48 @@ import path from 'path';
* 从指定的 TypeScript 文件中读取元数据。
*
* 该函数读取文件内容,查找以 `// ==Extension==` 开始和 `// ==/Extension==` 结束的部分,并提取其中以 `// @` 开头的元数据信息。
* 元数据信息存储在一个对象中,键是 `@` 后面的标识符,值是标识符后面的描述信息。
* 元数据信息存储在一个对象中,键是 `@` 后面的标识符(去除 `@`),值是标识符后面的描述信息。
*
* @param filePath - 要读取元数据的 TypeScript 文件的路径。
* @returns 一个包含元数据的对象,键是元数据标识符(如 `name`、`version`、`description` 等),值是相应的元数据信息。如果不是预期格式则返回空对象。
*
* @returns 一个包含元数据的对象,键是元数据标识符(如 `name`、`version` 等),值是对应信息。若格式错误或读取失败返回空对象。
*/
export function readMetadata(filePath: string): { [key: string]: string } {
try {
const content = fs.readFileSync(path.resolve(filePath), 'utf-8');
const metadata: { [key: string]: string } = {};
const lines = content.split('\n');
let capturing = false;

for (const line of lines) {
if (line.trim() === '// ==Extension==') {
const trimmedLine = line.trim();

if (trimmedLine === '// ==Extension==') {
capturing = true;
continue;
}
if (line.trim() === '// ==/Extension==') {
if (trimmedLine === '// ==/Extension==') {
capturing = false;
continue;
}
if (capturing) {
if (line.trim().startsWith('// @')) {
const parts = line.trim().substring(3).split(' ');
const key = parts[0];
const value = parts.slice(1).join(' ');

if (capturing && trimmedLine.startsWith('// @')) {
// 移除注释符号和空格,分割键值
const metaLine = trimmedLine.substring(3).trim(); // 去掉 '// ' 得到 '@key value'
const firstSpaceIndex = metaLine.indexOf(' ');

if (firstSpaceIndex === -1) {
// 只有键没有值的情况(如 '// @key')
const key = metaLine.substring(1); // 去掉 '@'
metadata[key] = '';
} else {
// 分割键和值
const key = metaLine.substring(1, firstSpaceIndex); // 取 '@' 后到第一个空格前的部分
const value = metaLine.substring(firstSpaceIndex + 1).trim();
metadata[key] = value;
}
}
}

return metadata;
} catch (error) {
console.error(`Error reading file: ${error}`);
Expand Down

0 comments on commit 600e617

Please sign in to comment.