koishi-plugin-adapter-onebot-multi 0.0.14 → 0.0.16
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/lib/heartbeat.d.ts +2 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +452 -13
- package/lib/load-balancer.d.ts +88 -0
- package/package.json +1 -1
package/lib/heartbeat.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare class HeartbeatMonitor {
|
|
|
11
11
|
private timer;
|
|
12
12
|
private logger;
|
|
13
13
|
private lastOnlineState;
|
|
14
|
+
private consecutiveFailures;
|
|
15
|
+
private readonly maxLoggedFailures;
|
|
14
16
|
constructor(ctx: Context, bot: OneBotBot, interval: number);
|
|
15
17
|
start(): void;
|
|
16
18
|
stop(): void;
|
package/lib/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Context, Schema } from 'koishi';
|
|
|
2
2
|
import { OneBotBot } from './bot';
|
|
3
3
|
import { BaseBot } from './bot/base';
|
|
4
4
|
import { PanelConfig } from './panel';
|
|
5
|
+
import { LoadBalanceConfig } from './load-balancer';
|
|
5
6
|
import * as OneBot from './utils';
|
|
6
7
|
declare module '@koishijs/console' {
|
|
7
8
|
interface Events {
|
|
@@ -19,6 +20,7 @@ export * from './heartbeat';
|
|
|
19
20
|
export * from './status';
|
|
20
21
|
export * from './panel';
|
|
21
22
|
export * from './config-manager';
|
|
23
|
+
export * from './load-balancer';
|
|
22
24
|
export interface BotConfig {
|
|
23
25
|
selfId: string;
|
|
24
26
|
token?: string;
|
|
@@ -34,6 +36,7 @@ export interface Config {
|
|
|
34
36
|
retryLazy?: number;
|
|
35
37
|
advanced?: BaseBot.AdvancedConfig;
|
|
36
38
|
panel?: PanelConfig;
|
|
39
|
+
loadBalance?: LoadBalanceConfig;
|
|
37
40
|
}
|
|
38
41
|
export declare const Config: Schema<Config>;
|
|
39
42
|
export declare const name = "adapter-onebot-multi";
|
package/lib/index.js
CHANGED
|
@@ -35,6 +35,8 @@ __export(src_exports, {
|
|
|
35
35
|
Config: () => Config,
|
|
36
36
|
ConfigManager: () => ConfigManager,
|
|
37
37
|
HeartbeatMonitor: () => HeartbeatMonitor,
|
|
38
|
+
LoadBalanceConfig: () => LoadBalanceConfig,
|
|
39
|
+
LoadBalancer: () => LoadBalancer,
|
|
38
40
|
OneBot: () => utils_exports,
|
|
39
41
|
OneBotBot: () => OneBotBot,
|
|
40
42
|
OneBotMessageEncoder: () => OneBotMessageEncoder,
|
|
@@ -51,7 +53,7 @@ __export(src_exports, {
|
|
|
51
53
|
name: () => name
|
|
52
54
|
});
|
|
53
55
|
module.exports = __toCommonJS(src_exports);
|
|
54
|
-
var
|
|
56
|
+
var import_koishi12 = require("koishi");
|
|
55
57
|
var import_path = require("path");
|
|
56
58
|
|
|
57
59
|
// src/bot/index.ts
|
|
@@ -976,6 +978,7 @@ function accept(socket, bot) {
|
|
|
976
978
|
socket.addEventListener("close", () => {
|
|
977
979
|
delete bot.internal._request;
|
|
978
980
|
bot.offline();
|
|
981
|
+
bot.ctx.emit("onebot-multi/bot-offline", bot);
|
|
979
982
|
});
|
|
980
983
|
bot.internal._request = (action, params) => {
|
|
981
984
|
const data = { action, params, echo: ++counter };
|
|
@@ -996,6 +999,7 @@ __name(accept, "accept");
|
|
|
996
999
|
// src/heartbeat.ts
|
|
997
1000
|
var import_koishi6 = require("koishi");
|
|
998
1001
|
var HeartbeatMonitor = class {
|
|
1002
|
+
// 连续失败超过此次数后降低日志级别
|
|
999
1003
|
constructor(ctx, bot, interval) {
|
|
1000
1004
|
this.ctx = ctx;
|
|
1001
1005
|
this.bot = bot;
|
|
@@ -1008,9 +1012,12 @@ var HeartbeatMonitor = class {
|
|
|
1008
1012
|
timer = null;
|
|
1009
1013
|
logger;
|
|
1010
1014
|
lastOnlineState = null;
|
|
1015
|
+
consecutiveFailures = 0;
|
|
1016
|
+
maxLoggedFailures = 3;
|
|
1011
1017
|
start() {
|
|
1012
1018
|
if (this.timer) return;
|
|
1013
1019
|
this.logger.debug(`Bot ${this.bot.selfId} 心跳检测已启动,间隔 ${this.interval}ms`);
|
|
1020
|
+
this.consecutiveFailures = 0;
|
|
1014
1021
|
this.check();
|
|
1015
1022
|
this.timer = setInterval(() => this.check(), this.interval);
|
|
1016
1023
|
this.ctx.on("dispose", () => this.stop());
|
|
@@ -1023,9 +1030,17 @@ var HeartbeatMonitor = class {
|
|
|
1023
1030
|
}
|
|
1024
1031
|
}
|
|
1025
1032
|
async check() {
|
|
1033
|
+
if (!this.bot.internal._request) {
|
|
1034
|
+
if (this.consecutiveFailures === 0) {
|
|
1035
|
+
this.logger.debug(`Bot ${this.bot.selfId} WebSocket 未连接,跳过心跳检测`);
|
|
1036
|
+
}
|
|
1037
|
+
this.consecutiveFailures++;
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1026
1040
|
try {
|
|
1027
1041
|
const status = await this.bot.internal.getStatus();
|
|
1028
1042
|
const isOnline = status?.online ?? false;
|
|
1043
|
+
this.consecutiveFailures = 0;
|
|
1029
1044
|
if (this.lastOnlineState === isOnline) return;
|
|
1030
1045
|
this.logger.info(`Bot ${this.bot.selfId} 状态变化: ${this.lastOnlineState} -> ${isOnline}`);
|
|
1031
1046
|
this.lastOnlineState = isOnline;
|
|
@@ -1041,7 +1056,12 @@ var HeartbeatMonitor = class {
|
|
|
1041
1056
|
}
|
|
1042
1057
|
}
|
|
1043
1058
|
} catch (error) {
|
|
1044
|
-
this.
|
|
1059
|
+
this.consecutiveFailures++;
|
|
1060
|
+
if (this.consecutiveFailures <= this.maxLoggedFailures) {
|
|
1061
|
+
this.logger.warn(`Bot ${this.bot.selfId} 心跳检测失败 (${this.consecutiveFailures}/${this.maxLoggedFailures}):`, error.message);
|
|
1062
|
+
} else if (this.consecutiveFailures === this.maxLoggedFailures + 1) {
|
|
1063
|
+
this.logger.warn(`Bot ${this.bot.selfId} 心跳检测持续失败,后续失败将不再记录`);
|
|
1064
|
+
}
|
|
1045
1065
|
if (this.lastOnlineState !== false) {
|
|
1046
1066
|
this.lastOnlineState = false;
|
|
1047
1067
|
if (this.bot.status === import_koishi6.Universal.Status.ONLINE) {
|
|
@@ -1530,7 +1550,8 @@ var StatusPanel = class {
|
|
|
1530
1550
|
lastMessageTime: runtime?.lastMessageTime,
|
|
1531
1551
|
startupTime: runtime?.startupTime,
|
|
1532
1552
|
groupCount: runtime?.groupCount,
|
|
1533
|
-
friendCount: runtime?.friendCount
|
|
1553
|
+
friendCount: runtime?.friendCount,
|
|
1554
|
+
lastHeartbeat: runtime?.lastHeartbeat
|
|
1534
1555
|
};
|
|
1535
1556
|
});
|
|
1536
1557
|
ctx.body = { bots, updatedAt: Date.now() };
|
|
@@ -2926,26 +2947,438 @@ var ConfigManager = class {
|
|
|
2926
2947
|
}
|
|
2927
2948
|
};
|
|
2928
2949
|
|
|
2950
|
+
// src/load-balancer.ts
|
|
2951
|
+
var import_koishi11 = require("koishi");
|
|
2952
|
+
var LoadBalanceConfig = import_koishi11.Schema.object({
|
|
2953
|
+
enabled: import_koishi11.Schema.boolean().default(false).description("是否启用负载均衡。"),
|
|
2954
|
+
balanceInterval: import_koishi11.Schema.number().min(60).step(60).default(600).description("负载均衡间隔(秒)。"),
|
|
2955
|
+
channelFilterMode: import_koishi11.Schema.union(["blacklist", "whitelist"]).default("blacklist").description("群过滤模式:blacklist=排除指定群,whitelist=仅管理指定群。"),
|
|
2956
|
+
channelBlacklist: import_koishi11.Schema.array(String).default([]).description("黑名单:排除的群号列表(blacklist 模式生效)。").role("table"),
|
|
2957
|
+
channelWhitelist: import_koishi11.Schema.array(String).default([]).description("白名单:仅管理的群号列表(whitelist 模式生效)。").role("table"),
|
|
2958
|
+
priorityChannels: import_koishi11.Schema.array(String).default([]).description("关键群列表:这些群会被优先分配,确保可用性。").role("table"),
|
|
2959
|
+
defaultMaxLoad: import_koishi11.Schema.number().min(0).default(0).description("默认 Bot 负载上限(0=不限制)。"),
|
|
2960
|
+
botMaxLoad: import_koishi11.Schema.dict(import_koishi11.Schema.number().min(0)).default({}).description('各 Bot 负载上限(格式: "botId": 数量,0=不限制)。'),
|
|
2961
|
+
unassignedValue: import_koishi11.Schema.string().default("").description('未分配群的 assignee 值(留空=不修改,设为"0"=无人负责)。'),
|
|
2962
|
+
channelBotPriority: import_koishi11.Schema.dict(import_koishi11.Schema.array(String)).default({}).description('群Bot优先级配置 (格式: "群号": ["botId1", "botId2"])')
|
|
2963
|
+
}).description("负载均衡");
|
|
2964
|
+
var LoadBalancer = class {
|
|
2965
|
+
constructor(ctx, config, statusManager) {
|
|
2966
|
+
this.ctx = ctx;
|
|
2967
|
+
this.config = config;
|
|
2968
|
+
this.statusManager = statusManager;
|
|
2969
|
+
if (!config.enabled) {
|
|
2970
|
+
this.ctx.logger("load-balancer").info("负载均衡未启用");
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
this.ctx.logger("load-balancer").info("负载均衡模块初始化中...");
|
|
2974
|
+
this.setupEventListeners();
|
|
2975
|
+
this.ctx.on("ready", () => this.initialize());
|
|
2976
|
+
this.ctx.on("dispose", () => this.dispose());
|
|
2977
|
+
}
|
|
2978
|
+
static {
|
|
2979
|
+
__name(this, "LoadBalancer");
|
|
2980
|
+
}
|
|
2981
|
+
// 核心数据结构
|
|
2982
|
+
allChannels = /* @__PURE__ */ new Map();
|
|
2983
|
+
// channelId -> Set<selfId>
|
|
2984
|
+
botChannels = /* @__PURE__ */ new Map();
|
|
2985
|
+
// selfId -> Set<channelId>
|
|
2986
|
+
lastAssignees = /* @__PURE__ */ new Map();
|
|
2987
|
+
// channelId -> 最近分配的 assignee
|
|
2988
|
+
balanceTimer = null;
|
|
2989
|
+
balancePending = null;
|
|
2990
|
+
isBalancing = false;
|
|
2991
|
+
get logger() {
|
|
2992
|
+
return this.ctx.logger("load-balancer");
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* 检查 Bot 是否在线
|
|
2996
|
+
*/
|
|
2997
|
+
isBotOnline(selfId) {
|
|
2998
|
+
const bot = this.ctx.bots.find((b) => b.selfId === selfId && b.platform === "onebot");
|
|
2999
|
+
return bot?.status === import_koishi11.Universal.Status.ONLINE;
|
|
3000
|
+
}
|
|
3001
|
+
/**
|
|
3002
|
+
* 获取所有在线的 OneBot
|
|
3003
|
+
*/
|
|
3004
|
+
getOnlineBots() {
|
|
3005
|
+
return Array.from(this.ctx.bots).filter((b) => b.platform === "onebot" && b.status === import_koishi11.Universal.Status.ONLINE).map((b) => b.selfId);
|
|
3006
|
+
}
|
|
3007
|
+
/**
|
|
3008
|
+
* 检查群是否应该被管理(根据白名单/黑名单模式)
|
|
3009
|
+
*/
|
|
3010
|
+
shouldManageChannel(channelId) {
|
|
3011
|
+
const mode = this.config.channelFilterMode || "blacklist";
|
|
3012
|
+
if (mode === "whitelist") {
|
|
3013
|
+
const whitelist = this.config.channelWhitelist || [];
|
|
3014
|
+
return whitelist.length === 0 || whitelist.includes(channelId);
|
|
3015
|
+
} else {
|
|
3016
|
+
const blacklist = this.config.channelBlacklist || [];
|
|
3017
|
+
return !blacklist.includes(channelId);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* 获取 Bot 的负载上限(0=不限制)
|
|
3022
|
+
*/
|
|
3023
|
+
getBotMaxLoad(botId) {
|
|
3024
|
+
const specific = this.config.botMaxLoad?.[botId];
|
|
3025
|
+
if (specific !== void 0 && specific > 0) {
|
|
3026
|
+
return specific;
|
|
3027
|
+
}
|
|
3028
|
+
return this.config.defaultMaxLoad || 0;
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* 检查 Bot 是否已达到负载上限
|
|
3032
|
+
*/
|
|
3033
|
+
isBotAtCapacity(botId, currentLoad) {
|
|
3034
|
+
const maxLoad = this.getBotMaxLoad(botId);
|
|
3035
|
+
if (maxLoad === 0) return false;
|
|
3036
|
+
return currentLoad >= maxLoad;
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* 设置事件监听
|
|
3040
|
+
*/
|
|
3041
|
+
setupEventListeners() {
|
|
3042
|
+
this.ctx.on("onebot-multi/bot-online", async (bot) => {
|
|
3043
|
+
this.logger.info(`Bot ${bot.selfId} 上线,开始获取群列表...`);
|
|
3044
|
+
await this.fetchBotGuilds(bot);
|
|
3045
|
+
this.scheduleLoadBalancing(5e3);
|
|
3046
|
+
});
|
|
3047
|
+
this.ctx.on("onebot-multi/bot-offline", (bot) => {
|
|
3048
|
+
const channelIds = this.botChannels.get(bot.selfId) || /* @__PURE__ */ new Set();
|
|
3049
|
+
for (const channelId of channelIds) {
|
|
3050
|
+
const bots = this.allChannels.get(channelId);
|
|
3051
|
+
if (bots) {
|
|
3052
|
+
bots.delete(bot.selfId);
|
|
3053
|
+
if (bots.size === 0) {
|
|
3054
|
+
this.allChannels.delete(channelId);
|
|
3055
|
+
this.lastAssignees.delete(channelId);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
this.botChannels.delete(bot.selfId);
|
|
3060
|
+
this.logger.info(`Bot ${bot.selfId} 下线,已清理 ${channelIds.size} 个群的映射`);
|
|
3061
|
+
if (channelIds.size > 0) {
|
|
3062
|
+
this.scheduleLoadBalancing(5e3);
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
3065
|
+
this.ctx.on("guild-added", (session) => {
|
|
3066
|
+
if (session.platform !== "onebot") return;
|
|
3067
|
+
const botChannelSet = this.botChannels.get(session.selfId) || /* @__PURE__ */ new Set();
|
|
3068
|
+
const isNewChannel = !botChannelSet.has(session.guildId);
|
|
3069
|
+
botChannelSet.add(session.guildId);
|
|
3070
|
+
this.botChannels.set(session.selfId, botChannelSet);
|
|
3071
|
+
const channelBots = this.allChannels.get(session.guildId) || /* @__PURE__ */ new Set();
|
|
3072
|
+
channelBots.add(session.selfId);
|
|
3073
|
+
this.allChannels.set(session.guildId, channelBots);
|
|
3074
|
+
this.logger.info(`Bot ${session.selfId} 加入群 ${session.guildId}`);
|
|
3075
|
+
if (isNewChannel) {
|
|
3076
|
+
this.scheduleLoadBalancing(1e4);
|
|
3077
|
+
}
|
|
3078
|
+
});
|
|
3079
|
+
this.ctx.on("guild-removed", (session) => {
|
|
3080
|
+
if (session.platform !== "onebot") return;
|
|
3081
|
+
const botChannelSet = this.botChannels.get(session.selfId);
|
|
3082
|
+
if (botChannelSet) {
|
|
3083
|
+
botChannelSet.delete(session.guildId);
|
|
3084
|
+
}
|
|
3085
|
+
const channelBots = this.allChannels.get(session.guildId);
|
|
3086
|
+
const hadOtherBots = channelBots && channelBots.size > 1;
|
|
3087
|
+
if (channelBots) {
|
|
3088
|
+
channelBots.delete(session.selfId);
|
|
3089
|
+
if (channelBots.size === 0) {
|
|
3090
|
+
this.allChannels.delete(session.guildId);
|
|
3091
|
+
this.lastAssignees.delete(session.guildId);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
this.logger.info(`Bot ${session.selfId} 退出群 ${session.guildId}`);
|
|
3095
|
+
if (hadOtherBots) {
|
|
3096
|
+
this.scheduleLoadBalancing(5e3);
|
|
3097
|
+
}
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* 初始化
|
|
3102
|
+
*/
|
|
3103
|
+
async initialize() {
|
|
3104
|
+
this.logger.info("初始化负载均衡...");
|
|
3105
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3));
|
|
3106
|
+
const allBots = Array.from(this.ctx.bots);
|
|
3107
|
+
this.logger.info(`当前共 ${allBots.length} 个 Bot 实例`);
|
|
3108
|
+
const onlineBots = allBots.filter(
|
|
3109
|
+
(b) => b.platform === "onebot" && b.status === import_koishi11.Universal.Status.ONLINE
|
|
3110
|
+
);
|
|
3111
|
+
this.logger.info(`其中 ${onlineBots.length} 个 OneBot 在线: ${onlineBots.map((b) => b.selfId).join(", ") || "无"}`);
|
|
3112
|
+
const maxConcurrency = 5;
|
|
3113
|
+
for (let i = 0; i < onlineBots.length; i += maxConcurrency) {
|
|
3114
|
+
const batch = onlineBots.slice(i, i + maxConcurrency);
|
|
3115
|
+
await Promise.all(batch.map((bot) => this.fetchBotGuilds(bot)));
|
|
3116
|
+
}
|
|
3117
|
+
this.logger.info(`启动负载均衡定时器,间隔: ${this.config.balanceInterval}秒`);
|
|
3118
|
+
this.balanceTimer = setInterval(
|
|
3119
|
+
() => this.performLoadBalancing(),
|
|
3120
|
+
(this.config.balanceInterval || 600) * 1e3
|
|
3121
|
+
);
|
|
3122
|
+
await this.performLoadBalancing();
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* 获取 Bot 的群列表
|
|
3126
|
+
*/
|
|
3127
|
+
async fetchBotGuilds(bot, retries = 3) {
|
|
3128
|
+
for (let i = 0; i < retries; i++) {
|
|
3129
|
+
try {
|
|
3130
|
+
if (i > 0) {
|
|
3131
|
+
await new Promise((resolve2) => setTimeout(resolve2, 2e3 * i));
|
|
3132
|
+
this.logger.info(`Bot ${bot.selfId} 重试获取群列表 (${i}/${retries})...`);
|
|
3133
|
+
}
|
|
3134
|
+
const guilds = await bot.getGuildList();
|
|
3135
|
+
const channelIds = new Set(
|
|
3136
|
+
Array.from(guilds.data || []).map((g) => g.id)
|
|
3137
|
+
);
|
|
3138
|
+
this.botChannels.set(bot.selfId, channelIds);
|
|
3139
|
+
for (const channelId of channelIds) {
|
|
3140
|
+
const bots = this.allChannels.get(channelId) || /* @__PURE__ */ new Set();
|
|
3141
|
+
bots.add(bot.selfId);
|
|
3142
|
+
this.allChannels.set(channelId, bots);
|
|
3143
|
+
}
|
|
3144
|
+
this.logger.info(`Bot ${bot.selfId} 在 ${channelIds.size} 个群中`);
|
|
3145
|
+
return channelIds;
|
|
3146
|
+
} catch (e) {
|
|
3147
|
+
if (i === retries - 1) {
|
|
3148
|
+
this.logger.error(`Bot ${bot.selfId} 获取群列表失败: ${e.message}`);
|
|
3149
|
+
return null;
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
return null;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* 延迟触发负载均衡(防抖)
|
|
3157
|
+
*/
|
|
3158
|
+
scheduleLoadBalancing(delay = 5e3) {
|
|
3159
|
+
if (this.balancePending) {
|
|
3160
|
+
clearTimeout(this.balancePending);
|
|
3161
|
+
}
|
|
3162
|
+
this.balancePending = setTimeout(() => {
|
|
3163
|
+
this.balancePending = null;
|
|
3164
|
+
this.performLoadBalancing();
|
|
3165
|
+
}, delay);
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* 执行负载均衡 - 动态容量约束的贪心算法
|
|
3169
|
+
*/
|
|
3170
|
+
async performLoadBalancing() {
|
|
3171
|
+
if (this.isBalancing) {
|
|
3172
|
+
this.logger.debug("负载均衡正在执行中,跳过");
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
this.isBalancing = true;
|
|
3176
|
+
try {
|
|
3177
|
+
this.logger.info("执行负载均衡...");
|
|
3178
|
+
const onlineBots = this.getOnlineBots();
|
|
3179
|
+
const onlineBotsSet = new Set(onlineBots);
|
|
3180
|
+
if (onlineBots.length === 0) {
|
|
3181
|
+
this.logger.warn("没有在线的 Bot");
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
const channels = [];
|
|
3185
|
+
const prioritySet = new Set(this.config.priorityChannels || []);
|
|
3186
|
+
for (const [channelId, bots] of this.allChannels.entries()) {
|
|
3187
|
+
channels.push({
|
|
3188
|
+
channelId,
|
|
3189
|
+
availableBots: [...bots],
|
|
3190
|
+
isPriority: prioritySet.has(channelId)
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
channels.sort((a, b) => {
|
|
3194
|
+
if (a.isPriority && !b.isPriority) return -1;
|
|
3195
|
+
if (!a.isPriority && b.isPriority) return 1;
|
|
3196
|
+
return 0;
|
|
3197
|
+
});
|
|
3198
|
+
const botLoad = /* @__PURE__ */ new Map();
|
|
3199
|
+
const botRemainingCapacity = /* @__PURE__ */ new Map();
|
|
3200
|
+
for (const botId of onlineBots) {
|
|
3201
|
+
botLoad.set(botId, 0);
|
|
3202
|
+
botRemainingCapacity.set(botId, 0);
|
|
3203
|
+
}
|
|
3204
|
+
for (const { availableBots } of channels) {
|
|
3205
|
+
for (const botId of availableBots) {
|
|
3206
|
+
if (onlineBotsSet.has(botId)) {
|
|
3207
|
+
botRemainingCapacity.set(botId, (botRemainingCapacity.get(botId) || 0) + 1);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
const assignments = [];
|
|
3212
|
+
for (const channel of channels) {
|
|
3213
|
+
if (!this.shouldManageChannel(channel.channelId)) continue;
|
|
3214
|
+
const validBots = channel.availableBots.filter((botId) => onlineBotsSet.has(botId));
|
|
3215
|
+
if (validBots.length === 0) {
|
|
3216
|
+
this.logger.warn(`群 ${channel.channelId} 没有可用的 Bot`);
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
let selectedBot = null;
|
|
3220
|
+
const priorityList = this.config.channelBotPriority?.[channel.channelId];
|
|
3221
|
+
if (priorityList && priorityList.length > 0) {
|
|
3222
|
+
for (const priorityBotId of priorityList) {
|
|
3223
|
+
if (validBots.includes(priorityBotId)) {
|
|
3224
|
+
const load = botLoad.get(priorityBotId) || 0;
|
|
3225
|
+
if (!this.isBotAtCapacity(priorityBotId, load)) {
|
|
3226
|
+
selectedBot = priorityBotId;
|
|
3227
|
+
break;
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
if (!selectedBot) {
|
|
3232
|
+
selectedBot = this.selectBotByGreedy(validBots, botRemainingCapacity, botLoad);
|
|
3233
|
+
}
|
|
3234
|
+
} else {
|
|
3235
|
+
selectedBot = this.selectBotByGreedy(validBots, botRemainingCapacity, botLoad);
|
|
3236
|
+
}
|
|
3237
|
+
if (!selectedBot) {
|
|
3238
|
+
const unassignedValue = this.config.unassignedValue;
|
|
3239
|
+
if (unassignedValue !== void 0 && unassignedValue !== "") {
|
|
3240
|
+
const lastAssignee2 = this.lastAssignees.get(channel.channelId);
|
|
3241
|
+
if (lastAssignee2 !== unassignedValue) {
|
|
3242
|
+
assignments.push({ channelId: channel.channelId, assignee: unassignedValue });
|
|
3243
|
+
this.logger.info(`群 ${channel.channelId} 所有 Bot 达到上限,设为未分配`);
|
|
3244
|
+
}
|
|
3245
|
+
} else {
|
|
3246
|
+
this.logger.warn(`群 ${channel.channelId} 所有可用 Bot 都已达到负载上限`);
|
|
3247
|
+
}
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
const lastAssignee = this.lastAssignees.get(channel.channelId);
|
|
3251
|
+
if (lastAssignee !== selectedBot) {
|
|
3252
|
+
assignments.push({ channelId: channel.channelId, assignee: selectedBot });
|
|
3253
|
+
}
|
|
3254
|
+
botLoad.set(selectedBot, (botLoad.get(selectedBot) || 0) + 1);
|
|
3255
|
+
for (const botId of validBots) {
|
|
3256
|
+
botRemainingCapacity.set(botId, (botRemainingCapacity.get(botId) || 0) - 1);
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
if (assignments.length > 0) {
|
|
3260
|
+
const batchSize = 100;
|
|
3261
|
+
for (let i = 0; i < assignments.length; i += batchSize) {
|
|
3262
|
+
const slice = assignments.slice(i, i + batchSize);
|
|
3263
|
+
await Promise.all(
|
|
3264
|
+
slice.map(
|
|
3265
|
+
({ channelId, assignee }) => this.ctx.database.setChannel("onebot", channelId, { assignee }).then(() => {
|
|
3266
|
+
this.lastAssignees.set(channelId, assignee);
|
|
3267
|
+
})
|
|
3268
|
+
)
|
|
3269
|
+
);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
const botsWithData = onlineBots.filter((id) => {
|
|
3273
|
+
const channels2 = this.botChannels.get(id);
|
|
3274
|
+
return channels2 && channels2.size > 0;
|
|
3275
|
+
});
|
|
3276
|
+
const priorityCount = channels.filter((c) => c.isPriority && this.shouldManageChannel(c.channelId)).length;
|
|
3277
|
+
if (botsWithData.length > 0) {
|
|
3278
|
+
const loads = botsWithData.map((id) => botLoad.get(id) || 0);
|
|
3279
|
+
const avg = loads.reduce((a, b) => a + b, 0) / loads.length;
|
|
3280
|
+
const variance = loads.reduce((sum, load) => sum + Math.pow(load - avg, 2), 0) / loads.length;
|
|
3281
|
+
const stdDev = Math.sqrt(variance);
|
|
3282
|
+
const loadInfo = botsWithData.map((id) => `${id}:${botLoad.get(id) || 0}`).join(", ");
|
|
3283
|
+
this.logger.info(`负载均衡完成,分配了 ${assignments.length} 个群(关键群: ${priorityCount} 个)`);
|
|
3284
|
+
this.logger.info(`负载分布: [${loadInfo}],平均: ${avg.toFixed(1)},标准差: ${stdDev.toFixed(2)}`);
|
|
3285
|
+
}
|
|
3286
|
+
} catch (e) {
|
|
3287
|
+
this.logger.error(`负载均衡失败: ${e.message}`);
|
|
3288
|
+
} finally {
|
|
3289
|
+
this.isBalancing = false;
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* 贪心选择 bot(考虑负载上限)
|
|
3294
|
+
*/
|
|
3295
|
+
selectBotByGreedy(validBots, capacityMap, loadMap) {
|
|
3296
|
+
const availableBots = validBots.filter((botId) => {
|
|
3297
|
+
const load = loadMap.get(botId) || 0;
|
|
3298
|
+
return !this.isBotAtCapacity(botId, load);
|
|
3299
|
+
});
|
|
3300
|
+
if (availableBots.length === 0) {
|
|
3301
|
+
return null;
|
|
3302
|
+
}
|
|
3303
|
+
let selectedBot = availableBots[0];
|
|
3304
|
+
let minCapacity = capacityMap.get(selectedBot) || 0;
|
|
3305
|
+
let minLoad = loadMap.get(selectedBot) || 0;
|
|
3306
|
+
for (const botId of availableBots) {
|
|
3307
|
+
const capacity = capacityMap.get(botId) || 0;
|
|
3308
|
+
const load = loadMap.get(botId) || 0;
|
|
3309
|
+
if (capacity < minCapacity || capacity === minCapacity && load < minLoad) {
|
|
3310
|
+
minCapacity = capacity;
|
|
3311
|
+
minLoad = load;
|
|
3312
|
+
selectedBot = botId;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
return selectedBot;
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* 销毁
|
|
3319
|
+
*/
|
|
3320
|
+
dispose() {
|
|
3321
|
+
if (this.balanceTimer) {
|
|
3322
|
+
clearInterval(this.balanceTimer);
|
|
3323
|
+
this.balanceTimer = null;
|
|
3324
|
+
}
|
|
3325
|
+
if (this.balancePending) {
|
|
3326
|
+
clearTimeout(this.balancePending);
|
|
3327
|
+
this.balancePending = null;
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* 获取负载均衡状态(供 API 使用)
|
|
3332
|
+
*/
|
|
3333
|
+
getStatus() {
|
|
3334
|
+
const onlineBots = this.getOnlineBots();
|
|
3335
|
+
const botStats = {};
|
|
3336
|
+
for (const botId of onlineBots) {
|
|
3337
|
+
const channels = this.botChannels.get(botId) || /* @__PURE__ */ new Set();
|
|
3338
|
+
let assignedCount = 0;
|
|
3339
|
+
for (const [channelId, assignee] of this.lastAssignees.entries()) {
|
|
3340
|
+
if (assignee === botId) {
|
|
3341
|
+
assignedCount++;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
botStats[botId] = {
|
|
3345
|
+
channelCount: channels.size,
|
|
3346
|
+
assignedCount
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
return {
|
|
3350
|
+
enabled: this.config.enabled,
|
|
3351
|
+
totalChannels: this.allChannels.size,
|
|
3352
|
+
onlineBots: onlineBots.length,
|
|
3353
|
+
botStats
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
};
|
|
3357
|
+
|
|
2929
3358
|
// src/index.ts
|
|
2930
|
-
var Config =
|
|
3359
|
+
var Config = import_koishi12.Schema.intersect([
|
|
2931
3360
|
// 全局连接设置
|
|
2932
|
-
|
|
2933
|
-
responseTimeout:
|
|
2934
|
-
heartbeatInterval:
|
|
3361
|
+
import_koishi12.Schema.object({
|
|
3362
|
+
responseTimeout: import_koishi12.Schema.natural().role("time").default(import_koishi12.Time.minute).description("等待响应的时间(毫秒)。"),
|
|
3363
|
+
heartbeatInterval: import_koishi12.Schema.natural().role("ms").default(30 * import_koishi12.Time.second).description("心跳检测间隔(毫秒),设为 0 禁用。")
|
|
2935
3364
|
}).description("连接设置"),
|
|
2936
3365
|
// 全局重连设置(折叠)
|
|
2937
|
-
|
|
2938
|
-
retryTimes:
|
|
2939
|
-
retryInterval:
|
|
2940
|
-
retryLazy:
|
|
3366
|
+
import_koishi12.Schema.object({
|
|
3367
|
+
retryTimes: import_koishi12.Schema.natural().description("初次连接时的最大重试次数。").default(6),
|
|
3368
|
+
retryInterval: import_koishi12.Schema.natural().role("ms").description("初次连接时的重试时间间隔。").default(5 * import_koishi12.Time.second),
|
|
3369
|
+
retryLazy: import_koishi12.Schema.natural().role("ms").description("连接关闭后的重试时间间隔。").default(import_koishi12.Time.minute)
|
|
2941
3370
|
}).description("重连设置").collapse(),
|
|
2942
3371
|
// 全局高级设置(折叠)
|
|
2943
|
-
|
|
3372
|
+
import_koishi12.Schema.object({
|
|
2944
3373
|
advanced: BaseBot2.AdvancedConfig
|
|
2945
3374
|
}).description("高级设置").collapse(),
|
|
2946
3375
|
// 展示面板设置(折叠)
|
|
2947
|
-
|
|
3376
|
+
import_koishi12.Schema.object({
|
|
2948
3377
|
panel: PanelConfig
|
|
3378
|
+
}).collapse(),
|
|
3379
|
+
// 负载均衡设置(折叠)
|
|
3380
|
+
import_koishi12.Schema.object({
|
|
3381
|
+
loadBalance: LoadBalanceConfig
|
|
2949
3382
|
}).collapse()
|
|
2950
3383
|
]);
|
|
2951
3384
|
var name = "adapter-onebot-multi";
|
|
@@ -2969,6 +3402,10 @@ function apply(ctx, config) {
|
|
|
2969
3402
|
if (config.panel?.enabled) {
|
|
2970
3403
|
new StatusPanel(ctx, config.panel, statusManager, configManager);
|
|
2971
3404
|
}
|
|
3405
|
+
if (config.loadBalance?.enabled) {
|
|
3406
|
+
new LoadBalancer(ctx, config.loadBalance, statusManager);
|
|
3407
|
+
logger.info("负载均衡已启用");
|
|
3408
|
+
}
|
|
2972
3409
|
ctx.inject(["console"], (ctx2) => {
|
|
2973
3410
|
ctx2.console.addEntry({
|
|
2974
3411
|
dev: (0, import_path.resolve)(__dirname, "../client/index.ts"),
|
|
@@ -3034,6 +3471,8 @@ __name(startBot, "startBot");
|
|
|
3034
3471
|
Config,
|
|
3035
3472
|
ConfigManager,
|
|
3036
3473
|
HeartbeatMonitor,
|
|
3474
|
+
LoadBalanceConfig,
|
|
3475
|
+
LoadBalancer,
|
|
3037
3476
|
OneBot,
|
|
3038
3477
|
OneBotBot,
|
|
3039
3478
|
OneBotMessageEncoder,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import { StatusManager } from './status';
|
|
3
|
+
export interface LoadBalanceConfig {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
balanceInterval?: number;
|
|
6
|
+
channelFilterMode?: 'blacklist' | 'whitelist';
|
|
7
|
+
channelBlacklist?: string[];
|
|
8
|
+
channelWhitelist?: string[];
|
|
9
|
+
botMaxLoad?: Record<string, number>;
|
|
10
|
+
defaultMaxLoad?: number;
|
|
11
|
+
unassignedValue?: string;
|
|
12
|
+
priorityChannels?: string[];
|
|
13
|
+
channelBotPriority?: Record<string, string[]>;
|
|
14
|
+
}
|
|
15
|
+
export declare const LoadBalanceConfig: Schema<LoadBalanceConfig>;
|
|
16
|
+
export declare class LoadBalancer {
|
|
17
|
+
private ctx;
|
|
18
|
+
private config;
|
|
19
|
+
private statusManager;
|
|
20
|
+
private allChannels;
|
|
21
|
+
private botChannels;
|
|
22
|
+
private lastAssignees;
|
|
23
|
+
private balanceTimer;
|
|
24
|
+
private balancePending;
|
|
25
|
+
private isBalancing;
|
|
26
|
+
constructor(ctx: Context, config: LoadBalanceConfig, statusManager: StatusManager);
|
|
27
|
+
private get logger();
|
|
28
|
+
/**
|
|
29
|
+
* 检查 Bot 是否在线
|
|
30
|
+
*/
|
|
31
|
+
private isBotOnline;
|
|
32
|
+
/**
|
|
33
|
+
* 获取所有在线的 OneBot
|
|
34
|
+
*/
|
|
35
|
+
private getOnlineBots;
|
|
36
|
+
/**
|
|
37
|
+
* 检查群是否应该被管理(根据白名单/黑名单模式)
|
|
38
|
+
*/
|
|
39
|
+
private shouldManageChannel;
|
|
40
|
+
/**
|
|
41
|
+
* 获取 Bot 的负载上限(0=不限制)
|
|
42
|
+
*/
|
|
43
|
+
private getBotMaxLoad;
|
|
44
|
+
/**
|
|
45
|
+
* 检查 Bot 是否已达到负载上限
|
|
46
|
+
*/
|
|
47
|
+
private isBotAtCapacity;
|
|
48
|
+
/**
|
|
49
|
+
* 设置事件监听
|
|
50
|
+
*/
|
|
51
|
+
private setupEventListeners;
|
|
52
|
+
/**
|
|
53
|
+
* 初始化
|
|
54
|
+
*/
|
|
55
|
+
private initialize;
|
|
56
|
+
/**
|
|
57
|
+
* 获取 Bot 的群列表
|
|
58
|
+
*/
|
|
59
|
+
private fetchBotGuilds;
|
|
60
|
+
/**
|
|
61
|
+
* 延迟触发负载均衡(防抖)
|
|
62
|
+
*/
|
|
63
|
+
private scheduleLoadBalancing;
|
|
64
|
+
/**
|
|
65
|
+
* 执行负载均衡 - 动态容量约束的贪心算法
|
|
66
|
+
*/
|
|
67
|
+
performLoadBalancing(): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* 贪心选择 bot(考虑负载上限)
|
|
70
|
+
*/
|
|
71
|
+
private selectBotByGreedy;
|
|
72
|
+
/**
|
|
73
|
+
* 销毁
|
|
74
|
+
*/
|
|
75
|
+
private dispose;
|
|
76
|
+
/**
|
|
77
|
+
* 获取负载均衡状态(供 API 使用)
|
|
78
|
+
*/
|
|
79
|
+
getStatus(): {
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
totalChannels: number;
|
|
82
|
+
onlineBots: number;
|
|
83
|
+
botStats: Record<string, {
|
|
84
|
+
channelCount: number;
|
|
85
|
+
assignedCount: number;
|
|
86
|
+
}>;
|
|
87
|
+
};
|
|
88
|
+
}
|
package/package.json
CHANGED