@dsh-plugin/dsh-synapse 0.0.0-stage → 0.1.0-dev.41044818627
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/NOTICE.md +18 -0
- package/README.md +136 -2
- package/cordis.patch.yml +3 -0
- package/lib/bot.cjs +8314 -0
- package/lib/bot.cjs.map +7 -0
- package/lib/build.json +1 -0
- package/lib/client.js +46 -0
- package/lib/host/agent.d.ts +21 -0
- package/lib/host/agent.js +151 -0
- package/lib/host/agent.js.map +1 -0
- package/lib/host/bot.d.ts +17 -0
- package/lib/host/bot.js +158 -0
- package/lib/host/bot.js.map +1 -0
- package/lib/host/client.d.ts +2 -0
- package/lib/host/client.js +19 -0
- package/lib/host/client.js.map +1 -0
- package/lib/host/config.d.ts +24 -0
- package/lib/host/config.js +192 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/files.d.ts +29 -0
- package/lib/host/files.js +188 -0
- package/lib/host/files.js.map +1 -0
- package/lib/host/gateway.d.ts +33 -0
- package/lib/host/gateway.js +237 -0
- package/lib/host/gateway.js.map +1 -0
- package/lib/host/github.d.ts +47 -0
- package/lib/host/github.js +219 -0
- package/lib/host/github.js.map +1 -0
- package/lib/host/http.d.ts +4 -0
- package/lib/host/http.js +41 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.d.ts +32 -0
- package/lib/host/index.js +192 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/inference-worker.d.ts +1 -0
- package/lib/host/inference-worker.js +72 -0
- package/lib/host/inference-worker.js.map +1 -0
- package/lib/host/mcp.d.ts +138 -0
- package/lib/host/mcp.js +104 -0
- package/lib/host/mcp.js.map +1 -0
- package/lib/host/memory.d.ts +26 -0
- package/lib/host/memory.js +155 -0
- package/lib/host/memory.js.map +1 -0
- package/lib/host/panel.d.ts +1 -0
- package/lib/host/panel.html +174 -0
- package/lib/host/panel.js +388 -0
- package/lib/host/panel.js.map +1 -0
- package/lib/host/runtime.d.ts +21 -0
- package/lib/host/runtime.js +145 -0
- package/lib/host/runtime.js.map +1 -0
- package/lib/host/types.d.ts +112 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/package.json +94 -4
- package/scripts/build.mjs +90 -0
- package/scripts/check.mjs +13 -0
- package/scripts/dump-npm-latest.mjs +49 -0
- package/scripts/model-smoke.mjs +25 -0
- package/src/agent.ts +203 -0
- package/src/bot.ts +173 -0
- package/src/client.ts +20 -0
- package/src/config.ts +217 -0
- package/src/files.ts +206 -0
- package/src/gateway.ts +276 -0
- package/src/github.ts +263 -0
- package/src/http.ts +41 -0
- package/src/index.ts +227 -0
- package/src/inference-worker.ts +89 -0
- package/src/mcp.ts +112 -0
- package/src/memory.ts +178 -0
- package/src/panel.html +174 -0
- package/src/panel.ts +440 -0
- package/src/runtime.ts +164 -0
- package/src/types.ts +106 -0
- package/test/core.test.mjs +279 -0
- package/test/fixtures/mcp-server.mjs +14 -0
- package/test/integration.test.mjs +253 -0
- package/test/panel.test.mjs +73 -0
- package/tsconfig.json +18 -0
- package/vendor/gugle-chat-bot/LICENSE +661 -0
- package/vendor/gugle-chat-bot/package.json +57 -0
- package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -0
- package/vendor/gugle-chat-bot/src/api.json +4285 -0
- package/vendor/gugle-chat-bot/src/command/index.ts +141 -0
- package/vendor/gugle-chat-bot/src/config/features.ts +548 -0
- package/vendor/gugle-chat-bot/src/config/index.ts +184 -0
- package/vendor/gugle-chat-bot/src/config/manager.ts +357 -0
- package/vendor/gugle-chat-bot/src/constants/index.ts +13 -0
- package/vendor/gugle-chat-bot/src/custom.ts +548 -0
- package/vendor/gugle-chat-bot/src/event/index.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -0
- package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -0
- package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -0
- package/vendor/gugle-chat-bot/src/features/bili/template/video.html +57 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -0
- package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -0
- package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -0
- package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -0
- package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -0
- package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -0
- package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -0
- package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -0
- package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -0
- package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +469 -0
- package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -0
- package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -0
- package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -0
- package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -0
- package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -0
- package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -0
- package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -0
- package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -0
- package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -0
- package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -0
- package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -0
- package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -0
- package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -0
- package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -0
- package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -0
- package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -0
- package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +452 -0
- package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -0
- package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +78 -0
- package/vendor/gugle-chat-bot/src/features/welcome/index.ts +58 -0
- package/vendor/gugle-chat-bot/src/github-ip.json +5814 -0
- package/vendor/gugle-chat-bot/src/image/index.ts +278 -0
- package/vendor/gugle-chat-bot/src/index.ts +695 -0
- package/vendor/gugle-chat-bot/src/logger/index.ts +54 -0
- package/vendor/gugle-chat-bot/src/result.json +359 -0
- package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -0
- package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -0
- package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -0
- package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -0
- package/vendor/gugle-chat-bot/tsconfig.json +46 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
// @ts-ignore markdown-it-emoji 未提供默认导出类型声明(full 预设,同时支持 :name: 与 Unicode emoji)
|
|
3
|
+
import emoji from 'markdown-it-emoji';
|
|
4
|
+
import { Message } from 'discord.js';
|
|
5
|
+
import { Template, tryGenerateImage } from '@/image';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Discord markdown 工具:
|
|
9
|
+
* - isDiscordMarkdown:启发式判断一段文本是否使用了 Discord markdown 语法
|
|
10
|
+
* - escapeDiscord:发送纯文本前转义 markdown 特殊字符,避免被 Discord 误渲染
|
|
11
|
+
* - renderDiscordMessageToImage:把 Discord 消息内容渲染为图片(base64),转发到 QQ 用
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 启发式判定文本是否包含 Discord markdown 语法 */
|
|
15
|
+
const DISCORD_MARKDOWN_PATTERNS: RegExp[] = [
|
|
16
|
+
/\*\*[^*\n]+\*\*/, // **粗体**
|
|
17
|
+
/(^|[\s((>])\*[^*\s][^*\n]*\*(?![*\w])/, // *斜体*(避免把 a*b*c 误判为 markdown)
|
|
18
|
+
/__[^_\n]+__/, // __下划线__(CommonMark 会渲染为粗体,属已知偏差)
|
|
19
|
+
/(^|[\s((>])_[^_\s][^_\n]*_(?![_\w])/, // _斜体_
|
|
20
|
+
/~~[^~\n]+~~/, // ~~删除线~~
|
|
21
|
+
/\|\|[^|\n]+\|\|/, // ||剧透||
|
|
22
|
+
/```[\s\S]*?```/, // 代码块
|
|
23
|
+
/`[^`\n]+`/, // 行内代码
|
|
24
|
+
/^#{1,3}\s+\S/m, // # 标题(行首)
|
|
25
|
+
/^>\s+\S/m, // > 引用(行首)
|
|
26
|
+
/^\s*[-*+]\s+\S/m, // 无序列表
|
|
27
|
+
/^\s*\d+\.\s+\S/m, // 有序列表
|
|
28
|
+
/\[[^\]\n]+\]\([^)\n]+\)/, // [文字](链接)
|
|
29
|
+
/<[@#][!&]?\d+>/, // <@id> / <@!id> / <#id>
|
|
30
|
+
/<a?:\w{2,32}:\d+>/, // <a?:name:id> 自定义表情
|
|
31
|
+
/^-{3,}\s*$/m // 分割线
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export function isDiscordMarkdown(text: string): boolean {
|
|
35
|
+
return DISCORD_MARKDOWN_PATTERNS.some(pattern => pattern.test(text));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 转义 Discord markdown 特殊字符,使文本按原样(plain text)显示 */
|
|
39
|
+
export function escapeDiscord(text: string): string {
|
|
40
|
+
return text.replace(/([\\*_~`|>#-])/g, '\\$1');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** HTML 转义(注入模板/渲染前处理用户文本) */
|
|
44
|
+
export function escapeHtml(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.replace(/&/g, '&')
|
|
47
|
+
.replace(/</g, '<')
|
|
48
|
+
.replace(/>/g, '>')
|
|
49
|
+
.replace(/"/g, '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const md: MarkdownIt = new MarkdownIt({
|
|
53
|
+
html: false,
|
|
54
|
+
linkify: true,
|
|
55
|
+
breaks: true
|
|
56
|
+
}).use(emoji.full ?? emoji);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 把 Discord 消息文本渲染为 HTML 片段。
|
|
60
|
+
* 先 HTML 转义,再把 Discord 特有语法(提及/频道/自定义表情/剧透)替换为等价 HTML,
|
|
61
|
+
* 最后交给 markdown-it 渲染公共 markdown 部分。
|
|
62
|
+
*/
|
|
63
|
+
export function renderDiscordMarkdownToHtml(content: string, message?: Message): string {
|
|
64
|
+
let text = escapeHtml(content);
|
|
65
|
+
// 剧透 ||x|| → 黑块(markdown-it 不认识该语法,先替换为 HTML)
|
|
66
|
+
text = text.replace(/\|\|([\s\S]+?)\|\|/g, '<span class="spoiler">$1</span>');
|
|
67
|
+
// 自定义表情 <a?:name:id>
|
|
68
|
+
text = text.replace(/<a?:(\w{2,32}):\d+>/g, ':$1:');
|
|
69
|
+
// 频道提及 <#id> → #频道名
|
|
70
|
+
text = text.replace(/<#(\d+)>/g, (_match, id: string) => {
|
|
71
|
+
const name = message?.guild?.channels.cache.get(id)?.name;
|
|
72
|
+
return `<span class="mention">#${escapeHtml(name ?? id)}</span>`;
|
|
73
|
+
});
|
|
74
|
+
// 用户提及 <@id> / <@!id> → @昵称
|
|
75
|
+
text = text.replace(/<@!?(\d+)>/g, (_match, id: string) => {
|
|
76
|
+
const name =
|
|
77
|
+
message?.mentions.members?.get(id)?.displayName ?? message?.mentions.users.get(id)?.username;
|
|
78
|
+
return `<span class="mention">@${escapeHtml(name ?? id)}</span>`;
|
|
79
|
+
});
|
|
80
|
+
// 角色提及 <@&id> → @角色名
|
|
81
|
+
text = text.replace(/<@&(\d+)>/g, (_match, id: string) => {
|
|
82
|
+
const name = message?.guild?.roles.cache.get(id)?.name;
|
|
83
|
+
return `<span class="mention">@${escapeHtml(name ?? id)}</span>`;
|
|
84
|
+
});
|
|
85
|
+
return md.render(text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 把 Discord markdown 消息渲染成 PNG 图片(base64)。
|
|
90
|
+
* header 为图片顶部来源行(【服务器|频道】昵称(用户名)),content 为消息正文。
|
|
91
|
+
* 复用 src/image/index.ts 的共享 Chrome 截图队列。
|
|
92
|
+
*/
|
|
93
|
+
export function renderDiscordMessageToImage(header: string, content: string, message?: Message): Promise<string> {
|
|
94
|
+
return new Promise<string>((resolve, reject) => {
|
|
95
|
+
const htmlFragment = renderDiscordMarkdownToHtml(content, message);
|
|
96
|
+
const template = Template.load('message', 'src/features/discord-bridge/template')
|
|
97
|
+
.arg('header', escapeHtml(header))
|
|
98
|
+
.arg('content', htmlFragment);
|
|
99
|
+
const templateFile = template.file();
|
|
100
|
+
template
|
|
101
|
+
.handler()
|
|
102
|
+
.then(html => tryGenerateImage(resolve, reject, html, 640, templateFile))
|
|
103
|
+
.catch(reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApplicationCommandOptionType,
|
|
3
|
+
ChatInputCommandInteraction,
|
|
4
|
+
Client,
|
|
5
|
+
PermissionFlagsBits,
|
|
6
|
+
SlashCommandBuilder,
|
|
7
|
+
SlashCommandSubcommandBuilder
|
|
8
|
+
} from 'discord.js';
|
|
9
|
+
import { Arguments, CommandManager, CommandNode } from 'gugle-command';
|
|
10
|
+
import { BotCommandSource, executeCommandTokens } from '@/command';
|
|
11
|
+
import { isDiscordOperator } from '@/config/features';
|
|
12
|
+
import { QQBot } from '@/index';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Discord 原生斜杠命令:把 gugle-command 已注册的命令树转换为 Discord application
|
|
16
|
+
* commands(guild 级注册,即时生效),交互经 token 精确回放交给同一套命令实现。
|
|
17
|
+
*
|
|
18
|
+
* 映射规则:
|
|
19
|
+
* - 根字面量(help/mcv/server/...)→ 顶命令;其二层字面量(github→bind 等)→ 子命令;
|
|
20
|
+
* - 参数节点 → options,类型按 decode 识别(NUMBER→Number、BOOLEAN→Boolean、其余 String);
|
|
21
|
+
* - 必填规则:参数节点的祖先(到父节点为止)都没有 exec 则必填,否则选填
|
|
22
|
+
* (/server <ip> [port]:ip 必填、port 选填,天然保证必填在选填之前);
|
|
23
|
+
* - 字面量与参数混排、三层以上字面量嵌套不支持(当前命令树无此情况,出现时告警跳过)。
|
|
24
|
+
*
|
|
25
|
+
* 前置条件:bot 邀请链接需包含 applications.commands 权限范围(否则注册报错并告警)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** option 规格(交互时按声明顺序回放 token 用) */
|
|
29
|
+
export interface SlashOptionSpec {
|
|
30
|
+
/** 小写 option 名 */
|
|
31
|
+
name: string;
|
|
32
|
+
type: ApplicationCommandOptionType.String | ApplicationCommandOptionType.Number | ApplicationCommandOptionType.Boolean;
|
|
33
|
+
required: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 命令规格:一个顶命令或一个子命令 */
|
|
37
|
+
export interface SlashCommandSpec {
|
|
38
|
+
name: string;
|
|
39
|
+
subcommand?: string;
|
|
40
|
+
options: SlashOptionSpec[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BuiltSlashCommands {
|
|
44
|
+
builders: SlashCommandBuilder[];
|
|
45
|
+
specs: SlashCommandSpec[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 命令/参数描述(Discord 必填项;未收录时回退通用文案) */
|
|
49
|
+
const DESCRIPTIONS: Record<string, string> = {
|
|
50
|
+
help: '帮助',
|
|
51
|
+
mcv: '获取 Minecraft 版本信息',
|
|
52
|
+
server: '获取 Minecraft 服务器状态',
|
|
53
|
+
wiki: '搜索 Minecraft 维基',
|
|
54
|
+
github: 'GitHub 绑定/订阅/授权',
|
|
55
|
+
pardon: '把用户移出黑名单(管理员)',
|
|
56
|
+
reload: '重载配置与数据文件(管理员)',
|
|
57
|
+
reboot: '重启应用(管理员)',
|
|
58
|
+
shutdown: '关闭应用(管理员)',
|
|
59
|
+
pvtime: '查询当前是梁文峰时间还是梁文谷时间',
|
|
60
|
+
bind: '绑定 GitHub 用户名',
|
|
61
|
+
subscribe: '订阅仓库消息推送',
|
|
62
|
+
allow: '授权仓库访问(管理员)',
|
|
63
|
+
ip: '服务器地址',
|
|
64
|
+
port: '端口',
|
|
65
|
+
query: '搜索词',
|
|
66
|
+
username: 'GitHub 用户名',
|
|
67
|
+
repository: 'owner/repo',
|
|
68
|
+
target: 'owner 或 owner/repo',
|
|
69
|
+
userid: 'QQ 号'
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function describe(name: string): string {
|
|
73
|
+
return DESCRIPTIONS[name] ?? `参数 ${name}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Discord chat-input 名称约束:小写字母数字 _ -,1-32 字符 */
|
|
77
|
+
const DISCORD_NAME_RE = /^[a-z0-9_-]{1,32}$/;
|
|
78
|
+
|
|
79
|
+
/** 参数节点 → option 名(`<userId>` → `userid`) */
|
|
80
|
+
function optionName(node: CommandNode): string {
|
|
81
|
+
const raw = node.toString().replace(/^<(.+)>$/, '$1');
|
|
82
|
+
return raw.toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 参数节点 → option 类型(按构造时存入的 decorator 身份识别) */
|
|
86
|
+
function optionType(node: CommandNode): SlashOptionSpec['type'] {
|
|
87
|
+
const decorator = (node as unknown as { decorator?: unknown }).decorator;
|
|
88
|
+
if (decorator === Arguments.NUMBER) return ApplicationCommandOptionType.Number;
|
|
89
|
+
if (decorator === Arguments.BOOLEAN) return ApplicationCommandOptionType.Boolean;
|
|
90
|
+
return ApplicationCommandOptionType.String;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 收集节点的参数链(沿第一个参数子节点下行)。
|
|
95
|
+
* 必填规则:祖先(rootLiteral 到父节点)都没有 exec 则必填。
|
|
96
|
+
*/
|
|
97
|
+
function collectOptions(rootLiteral: CommandNode, warn: (msg: string) => void): SlashOptionSpec[] {
|
|
98
|
+
const options: SlashOptionSpec[] = [];
|
|
99
|
+
let ancestorHasExec = rootLiteral.exec !== undefined;
|
|
100
|
+
let current: CommandNode = rootLiteral;
|
|
101
|
+
for (;;) {
|
|
102
|
+
const literals = current.children.filter(child => child.isLiteral());
|
|
103
|
+
const args = current.children.filter(child => !child.isLiteral());
|
|
104
|
+
if (args.length === 0) return options;
|
|
105
|
+
if (literals.length > 0) {
|
|
106
|
+
warn(`参数链中出现字面量分支(${rootLiteral.toString()}),斜杠命令不支持,已截断`);
|
|
107
|
+
return options;
|
|
108
|
+
}
|
|
109
|
+
if (args.length > 1) {
|
|
110
|
+
warn(`同一层级有多个参数节点(${rootLiteral.toString()}),只取第一个`);
|
|
111
|
+
}
|
|
112
|
+
const arg = args[0]!;
|
|
113
|
+
const required = !ancestorHasExec;
|
|
114
|
+
options.push({ name: optionName(arg), type: optionType(arg), required });
|
|
115
|
+
ancestorHasExec = ancestorHasExec || arg.exec !== undefined;
|
|
116
|
+
current = arg;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function applyOptions(
|
|
121
|
+
target: SlashCommandBuilder | SlashCommandSubcommandBuilder,
|
|
122
|
+
options: SlashOptionSpec[]
|
|
123
|
+
): void {
|
|
124
|
+
for (const option of options) {
|
|
125
|
+
if (option.type === ApplicationCommandOptionType.Number) {
|
|
126
|
+
target.addNumberOption(builder =>
|
|
127
|
+
builder.setName(option.name).setDescription(describe(option.name)).setRequired(option.required)
|
|
128
|
+
);
|
|
129
|
+
} else if (option.type === ApplicationCommandOptionType.Boolean) {
|
|
130
|
+
target.addBooleanOption(builder =>
|
|
131
|
+
builder.setName(option.name).setDescription(describe(option.name)).setRequired(option.required)
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
target.addStringOption(builder =>
|
|
135
|
+
builder.setName(option.name).setDescription(describe(option.name)).setRequired(option.required)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let cachedSpecs: SlashCommandSpec[] = [];
|
|
142
|
+
|
|
143
|
+
/** 把 gugle-command 命令树转换为 Discord 斜杠命令构建器与回放规格 */
|
|
144
|
+
export function buildSlashCommands(manager: CommandManager, warn: (msg: string) => void = () => undefined): BuiltSlashCommands {
|
|
145
|
+
const builders: SlashCommandBuilder[] = [];
|
|
146
|
+
const specs: SlashCommandSpec[] = [];
|
|
147
|
+
|
|
148
|
+
for (const root of manager.roots.values()) {
|
|
149
|
+
for (const literal of root.children) {
|
|
150
|
+
if (!literal.isLiteral()) continue;
|
|
151
|
+
const name = literal.toString();
|
|
152
|
+
if (!DISCORD_NAME_RE.test(name)) {
|
|
153
|
+
warn(`命令名 ${name} 不符合 Discord 命名约束,已跳过`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const literalChildren = literal.children.filter(child => child.isLiteral());
|
|
158
|
+
const argumentChildren = literal.children.filter(child => !child.isLiteral());
|
|
159
|
+
const builder = new SlashCommandBuilder().setName(name).setDescription(describe(name));
|
|
160
|
+
|
|
161
|
+
if (literalChildren.length > 0 && argumentChildren.length > 0) {
|
|
162
|
+
warn(`命令 ${name} 字面量与参数混排,斜杠命令不支持,已跳过`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (literalChildren.length > 0) {
|
|
167
|
+
// 二层字面量 → 子命令
|
|
168
|
+
for (const sub of literalChildren) {
|
|
169
|
+
const subName = sub.toString();
|
|
170
|
+
if (!DISCORD_NAME_RE.test(subName)) {
|
|
171
|
+
warn(`子命令名 ${name} ${subName} 不符合 Discord 命名约束,已跳过`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (sub.children.some(child => child.isLiteral())) {
|
|
175
|
+
warn(`命令 ${name} ${subName} 存在三层嵌套,斜杠命令不支持,已跳过`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const options = collectOptions(sub, warn);
|
|
179
|
+
builder.addSubcommand(subBuilder => {
|
|
180
|
+
subBuilder.setName(subName).setDescription(describe(subName));
|
|
181
|
+
applyOptions(subBuilder, options);
|
|
182
|
+
return subBuilder;
|
|
183
|
+
});
|
|
184
|
+
specs.push({ name, subcommand: subName, options });
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const options = collectOptions(literal, warn);
|
|
188
|
+
applyOptions(builder, options);
|
|
189
|
+
specs.push({ name, options });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
builders.push(builder);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
cachedSpecs = specs;
|
|
197
|
+
return { builders, specs };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 对 bridges 配置去重后的每个 guild 注册斜杠命令(guild 级,即时生效)。
|
|
202
|
+
* 配置热重载新增的 guild 需重启后才会注册。
|
|
203
|
+
*/
|
|
204
|
+
export async function registerSlashCommands(client: Client<true>, guildIds: string[], bot: QQBot): Promise<void> {
|
|
205
|
+
const { builders } = buildSlashCommands(bot.getCommandManager(), msg => bot.logger?.warn(`[DiscordBridge] ${msg}`));
|
|
206
|
+
if (builders.length === 0) return;
|
|
207
|
+
for (const guildId of guildIds) {
|
|
208
|
+
const guild = client.guilds.cache.get(guildId);
|
|
209
|
+
if (!guild) {
|
|
210
|
+
bot.logger?.warn(`[DiscordBridge] 注册斜杠命令:未找到服务器 ${guildId}`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await guild.commands.set(builders);
|
|
215
|
+
bot.logger?.info(`[DiscordBridge] 已在 ${guild.name} 注册 ${builders.length} 个斜杠命令`);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
bot.logger?.error(
|
|
218
|
+
`[DiscordBridge] 注册斜杠命令失败(${guild.name}): ${error instanceof Error ? error.message : String(error)}` +
|
|
219
|
+
`。请确认 bot 邀请链接包含 applications.commands 权限范围(需在开发者后台重新生成邀请并授权)`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Discord 交互命令源:回复经 interaction 的 deferReply → editReply/followUp 完成。
|
|
227
|
+
* 身份与权限映射与 DiscordMsgCommandSource 一致(dc: 前缀用户标识、服务器权限即管理员)。
|
|
228
|
+
*/
|
|
229
|
+
export class DiscordInteractionCommandSource implements BotCommandSource {
|
|
230
|
+
private readonly bot: QQBot;
|
|
231
|
+
private readonly interaction: ChatInputCommandInteraction;
|
|
232
|
+
private readonly groupId: number | undefined;
|
|
233
|
+
private responded = false;
|
|
234
|
+
|
|
235
|
+
public constructor(bot: QQBot, interaction: ChatInputCommandInteraction, groupId?: number) {
|
|
236
|
+
this.bot = bot;
|
|
237
|
+
this.interaction = interaction;
|
|
238
|
+
this.groupId = groupId;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
public getUserId(): string {
|
|
242
|
+
return `dc:${this.interaction.user.id}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public getGroupId(): number | undefined {
|
|
246
|
+
return this.groupId;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
public isAdmin(): boolean {
|
|
250
|
+
if (isDiscordOperator(this.interaction.user.id)) return true;
|
|
251
|
+
if (this.interaction.guild?.ownerId === this.interaction.user.id) return true;
|
|
252
|
+
const permissions = this.interaction.memberPermissions;
|
|
253
|
+
if (!permissions) return false;
|
|
254
|
+
return permissions.has(PermissionFlagsBits.Administrator) || permissions.has(PermissionFlagsBits.ManageGuild);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 立即 deferReply(交互 3 秒时限),后续 success/fail 用 editReply/followUp */
|
|
258
|
+
public async defer(): Promise<void> {
|
|
259
|
+
await this.interaction.deferReply().catch(error => {
|
|
260
|
+
this.bot.logger?.error(
|
|
261
|
+
`[DiscordBridge] 交互 deferReply 失败: ${error instanceof Error ? error.message : String(error)}`
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public success(message: string): void {
|
|
267
|
+
this.reply(message);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
public fail(message: string): void {
|
|
271
|
+
this.reply(message);
|
|
272
|
+
this.bot.logger?.error(
|
|
273
|
+
`[Discord:${this.interaction.user.username}] ${message}: /${this.interaction.commandName}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public getName(): string {
|
|
278
|
+
return this.interaction.user.displayName ?? this.interaction.user.username;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
public hasPermission(permission: string): boolean {
|
|
282
|
+
if (!permission) return true;
|
|
283
|
+
const getPermissionLevel = (text: string): number => {
|
|
284
|
+
const level = Number.parseInt(text);
|
|
285
|
+
if (Number.isNaN(level)) {
|
|
286
|
+
return text === 'owner' ? 2 : text === 'admin' ? 1 : 0;
|
|
287
|
+
}
|
|
288
|
+
return level;
|
|
289
|
+
};
|
|
290
|
+
const isOwner = this.interaction.guild?.ownerId === this.interaction.user.id;
|
|
291
|
+
const permissionLevel = isOwner ? 2 : this.isAdmin() ? 1 : 0;
|
|
292
|
+
return permissionLevel >= getPermissionLevel(permission);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 首次回复 editReply,其后 followUp;2000 字截断 */
|
|
296
|
+
private reply(message: string): void {
|
|
297
|
+
const content = message.length <= 2000 ? message : `${message.slice(0, 1984)}\n…(消息过长已截断)`;
|
|
298
|
+
if (!this.responded) {
|
|
299
|
+
this.responded = true;
|
|
300
|
+
this.interaction.editReply({ content }).catch(error => {
|
|
301
|
+
this.bot.logger?.error(
|
|
302
|
+
`[DiscordBridge] 交互回复失败: ${error instanceof Error ? error.message : String(error)}`
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.interaction.followUp({ content }).catch(error => {
|
|
308
|
+
this.bot.logger?.error(
|
|
309
|
+
`[DiscordBridge] 交互 followUp 失败: ${error instanceof Error ? error.message : String(error)}`
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 斜杠命令交互分发:把 option 值按声明顺序回放为精确 token(不经字符串切分,
|
|
317
|
+
* 带空格的字符串参数保持完整),交给 gugle-command 执行。
|
|
318
|
+
*
|
|
319
|
+
* @param resolveGroupId 由 DiscordBridge 注入的频道 → 桥接 QQ 群解析
|
|
320
|
+
*/
|
|
321
|
+
export async function handleSlashInteraction(
|
|
322
|
+
bot: QQBot,
|
|
323
|
+
interaction: ChatInputCommandInteraction,
|
|
324
|
+
resolveGroupId: (guildId: string, channelName: string | undefined) => number | undefined
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
if (!interaction.guildId) return;
|
|
327
|
+
const specs = cachedSpecs.length > 0 ? cachedSpecs : buildSlashCommands(bot.getCommandManager()).specs;
|
|
328
|
+
const subcommand = interaction.options.getSubcommand(false) ?? undefined;
|
|
329
|
+
const spec = specs.find(item => item.name === interaction.commandName && item.subcommand === subcommand);
|
|
330
|
+
if (!spec) {
|
|
331
|
+
await interaction.reply({ content: '未知命令(可能刚更新,请稍后重试)', ephemeral: true }).catch(() => undefined);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 按声明顺序回放 token;可选参数缺失即截断(位置参数必须连续)
|
|
336
|
+
const tokens = [spec.name];
|
|
337
|
+
if (spec.subcommand) tokens.push(spec.subcommand);
|
|
338
|
+
for (const option of spec.options) {
|
|
339
|
+
const value = interaction.options.get(option.name)?.value;
|
|
340
|
+
if (value === undefined || value === null) break;
|
|
341
|
+
tokens.push(String(value));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const channel = interaction.channel;
|
|
345
|
+
const channelName = channel && 'name' in channel ? (channel.name ?? undefined) : undefined;
|
|
346
|
+
const source = new DiscordInteractionCommandSource(bot, interaction, resolveGroupId(interaction.guildId, channelName));
|
|
347
|
+
await source.defer();
|
|
348
|
+
try {
|
|
349
|
+
executeCommandTokens(bot.getCommandManager(), source, tokens);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
bot.logger?.error(
|
|
352
|
+
`[DiscordBridge] 斜杠命令执行失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Message as DiscordMessage, PermissionFlagsBits } from 'discord.js';
|
|
2
|
+
import { BotCommandSource } from '@/command';
|
|
3
|
+
import { isDiscordOperator } from '@/config/features';
|
|
4
|
+
import { QQBot } from '@/index';
|
|
5
|
+
|
|
6
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Discord 侧管理员判定:服务器拥有者、拥有「管理服务器」/「管理员」权限的成员,
|
|
10
|
+
* 或 management.json 里 `discordOperators` 白名单中的用户。
|
|
11
|
+
* 命令源(DiscordMsgCommandSource)与 Hermes AI(审批/技能门控)共用同一口径。
|
|
12
|
+
*/
|
|
13
|
+
export function isDiscordAdmin(message: DiscordMessage): boolean {
|
|
14
|
+
if (isDiscordOperator(message.author.id)) return true;
|
|
15
|
+
if (message.guild?.ownerId === message.author.id) return true;
|
|
16
|
+
const permissions = message.member?.permissions;
|
|
17
|
+
if (!permissions) return false;
|
|
18
|
+
return permissions.has(PermissionFlagsBits.Administrator) || permissions.has(PermissionFlagsBits.ManageGuild);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discord 频道命令源:让 gugle-command 已注册的全部命令都能在 Discord 频道中执行,
|
|
23
|
+
* 命令回复(success/fail)以 Discord 回复的形式发回原频道。
|
|
24
|
+
*
|
|
25
|
+
* 身份与权限映射:
|
|
26
|
+
* - getUserId() 返回 `dc:<Discord用户ID>`(`dc:` 前缀保证与纯数字 QQ 号不冲突,
|
|
27
|
+
* GitHub 绑定等按用户持久化的数据可安全共用同一套存储);
|
|
28
|
+
* - getGroupId() 返回该频道桥接配置里的 QQ 群号(订阅类命令作用于对应 QQ 群);
|
|
29
|
+
* - isAdmin() / hasPermission():服务器拥有者视为 owner(2),
|
|
30
|
+
* 拥有「管理服务器」或「管理员」权限的成员视为 admin(1),其余为 0。
|
|
31
|
+
*/
|
|
32
|
+
export class DiscordMsgCommandSource implements BotCommandSource {
|
|
33
|
+
private readonly bot: QQBot;
|
|
34
|
+
private readonly message: DiscordMessage;
|
|
35
|
+
private readonly groupId: number | undefined;
|
|
36
|
+
|
|
37
|
+
public constructor(bot: QQBot, message: DiscordMessage, groupId?: number) {
|
|
38
|
+
this.bot = bot;
|
|
39
|
+
this.message = message;
|
|
40
|
+
this.groupId = groupId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public getUserId(): string {
|
|
44
|
+
return `dc:${this.message.author.id}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public getGroupId(): number | undefined {
|
|
48
|
+
return this.groupId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public isAdmin(): boolean {
|
|
52
|
+
return isDiscordAdmin(this.message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public success(message: string): void {
|
|
56
|
+
this.reply(message);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public fail(message: string): void {
|
|
60
|
+
this.reply(message);
|
|
61
|
+
this.bot.logger?.error(`[Discord:${this.message.author.username}] ${message}: ${this.message.content}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public getName(): string {
|
|
65
|
+
return this.message.member?.displayName ?? this.message.author.username;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public hasPermission(permission: string): boolean {
|
|
69
|
+
if (!permission) return true;
|
|
70
|
+
const getPermissionLevel = (text: string): number => {
|
|
71
|
+
const level = Number.parseInt(text);
|
|
72
|
+
if (Number.isNaN(level)) {
|
|
73
|
+
return text === 'owner' ? 2 : text === 'admin' ? 1 : 0;
|
|
74
|
+
}
|
|
75
|
+
return level;
|
|
76
|
+
};
|
|
77
|
+
const isOwner = this.message.guild?.ownerId === this.message.author.id;
|
|
78
|
+
const permissionLevel = isOwner ? 2 : this.isAdmin() ? 1 : 0;
|
|
79
|
+
return permissionLevel >= getPermissionLevel(permission);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 命令回复发在 Discord 频道(回复原消息,不 @ 人,超长截断) */
|
|
83
|
+
private reply(content: string): void {
|
|
84
|
+
const text =
|
|
85
|
+
content.length <= DISCORD_MESSAGE_LIMIT
|
|
86
|
+
? content
|
|
87
|
+
: `${content.slice(0, DISCORD_MESSAGE_LIMIT - 16)}\n…(消息过长已截断)`;
|
|
88
|
+
this.message
|
|
89
|
+
.reply({ content: text, allowedMentions: { parse: [], repliedUser: false } })
|
|
90
|
+
.catch(error => {
|
|
91
|
+
this.bot.logger?.error(
|
|
92
|
+
`[DiscordBridge] Discord 命令回复失败: ${error instanceof Error ? error.message : String(error)}`
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>{{header}}</title>
|
|
6
|
+
<link rel="stylesheet" href="./style/message.css">
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div class="container">
|
|
10
|
+
<div class="header">{{header}}</div>
|
|
11
|
+
<div class="content">{{content}}</div>
|
|
12
|
+
</div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|