@omnizap-system/omnizap 2.6.1 → 2.6.2
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/.env.example +54 -9
- package/.github/workflows/ci.yml +3 -3
- package/.github/workflows/security-runner-hardening.yml +1 -1
- package/.github/workflows/security-zap-full-scan.yml +1 -0
- package/app/config/index.js +2 -0
- package/app/configParts/adminIdentity.js +5 -5
- package/app/configParts/baileysConfig.js +226 -55
- package/app/configParts/groupUtils.js +5 -0
- package/app/configParts/messagePersistenceService.js +143 -3
- package/app/configParts/sessionConfig.js +157 -0
- package/app/connection/baileysCompatibility.test.js +1 -1
- package/app/connection/groupOwnerWriteStateResolver.js +109 -0
- package/app/connection/socketController.js +625 -124
- package/app/connection/socketController.multiSession.test.js +108 -0
- package/app/controllers/messageController.js +1 -1
- package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
- package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
- package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +80 -2
- package/app/controllers/messageProcessingPipeline.js +88 -9
- package/app/controllers/messageProcessingPipeline.test.js +200 -0
- package/app/modules/adminModule/AGENT.md +1 -1
- package/app/modules/adminModule/commandConfig.json +3318 -1347
- package/app/modules/adminModule/groupCommandHandlers.js +856 -14
- package/app/modules/adminModule/groupCommandHandlers.test.js +375 -9
- package/app/modules/adminModule/groupWarningRepository.js +152 -0
- package/app/modules/aiModule/AGENT.md +47 -30
- package/app/modules/aiModule/aiConfigRuntime.js +1 -0
- package/app/modules/aiModule/catCommand.js +132 -25
- package/app/modules/aiModule/commandConfig.json +114 -28
- package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
- package/app/modules/gameModule/AGENT.md +1 -1
- package/app/modules/gameModule/commandConfig.json +29 -0
- package/app/modules/menuModule/AGENT.md +1 -1
- package/app/modules/menuModule/commandConfig.json +45 -10
- package/app/modules/menuModule/menuCatalogService.js +190 -0
- package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
- package/app/modules/menuModule/menuDynamicService.js +511 -0
- package/app/modules/menuModule/menuDynamicService.test.js +141 -0
- package/app/modules/menuModule/menus.js +36 -5
- package/app/modules/playModule/AGENT.md +10 -5
- package/app/modules/playModule/commandConfig.json +74 -16
- package/app/modules/playModule/playCommandConstants.js +13 -7
- package/app/modules/playModule/playCommandCore.js +4 -6
- package/app/modules/playModule/{playCommandYtDlpClient.js → playCommandMediaClient.js} +684 -332
- package/app/modules/playModule/playConfigRuntime.js +5 -6
- package/app/modules/playModule/playModuleCriticalFlows.test.js +44 -59
- package/app/modules/quoteModule/AGENT.md +1 -1
- package/app/modules/quoteModule/commandConfig.json +29 -0
- package/app/modules/rpgPokemonModule/AGENT.md +1 -1
- package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
- package/app/modules/statsModule/AGENT.md +1 -1
- package/app/modules/statsModule/commandConfig.json +58 -0
- package/app/modules/stickerModule/AGENT.md +1 -1
- package/app/modules/stickerModule/commandConfig.json +145 -0
- package/app/modules/stickerPackModule/AGENT.md +1 -1
- package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
- package/app/modules/stickerPackModule/commandConfig.json +29 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +1 -1
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +78 -57
- package/app/modules/stickerPackModule/stickerPackService.js +13 -6
- package/app/modules/systemMetricsModule/AGENT.md +1 -1
- package/app/modules/systemMetricsModule/commandConfig.json +29 -0
- package/app/modules/tiktokModule/AGENT.md +1 -1
- package/app/modules/tiktokModule/commandConfig.json +29 -0
- package/app/modules/userModule/AGENT.md +1 -1
- package/app/modules/userModule/commandConfig.json +29 -0
- package/app/modules/waifuPicsModule/AGENT.md +57 -27
- package/app/modules/waifuPicsModule/commandConfig.json +87 -0
- package/app/observability/metrics.js +136 -0
- package/app/services/ai/commandConfigEnrichmentService.js +229 -47
- package/app/services/ai/geminiService.js +131 -7
- package/app/services/ai/geminiService.test.js +59 -2
- package/app/services/ai/moduleAiHelpCoreService.js +33 -4
- package/app/services/group/groupMetadataService.js +24 -1
- package/app/services/infra/dbWriteQueue.js +51 -21
- package/app/services/messaging/newsBroadcastService.js +843 -27
- package/app/services/multiSession/assignmentBalancerService.js +457 -0
- package/app/services/multiSession/groupOwnershipRepository.js +381 -0
- package/app/services/multiSession/groupOwnershipService.js +890 -0
- package/app/services/multiSession/groupOwnershipService.test.js +309 -0
- package/app/services/multiSession/sessionRegistryService.js +293 -0
- package/app/store/aiPromptStore.js +36 -19
- package/app/store/groupConfigStore.js +41 -5
- package/app/store/premiumUserStore.js +21 -7
- package/app/utils/antiLink/antiLinkModule.js +352 -16
- package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
- package/database/index.js +6 -0
- package/database/migrations/20260307_d0_hardening_down.sql +1 -1
- package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
- package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
- package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
- package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
- package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
- package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
- package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
- package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
- package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
- package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
- package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
- package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
- package/database/schema.sql +102 -1
- package/docker-compose.yml +4 -1
- package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
- package/docs/compliance/privacy-policy-2026-03-07.md +2 -2
- package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
- package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
- package/docs/security/omnizap-static-security-headers.conf +25 -0
- package/ecosystem.prod.config.cjs +31 -11
- package/index.js +52 -18
- package/observability/alert-rules.yml +20 -0
- package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
- package/observability/mysql-setup.sql +4 -4
- package/observability/system-admin-observability.md +26 -0
- package/package.json +12 -5
- package/public/comandos/commands-catalog.json +2253 -78
- package/public/js/apps/commandsReactApp.js +267 -87
- package/public/js/apps/createPackApp.js +3 -3
- package/public/js/apps/stickersApp.js +255 -103
- package/public/js/apps/termsReactApp.js +57 -8
- package/public/js/apps/userPasswordResetReactApp.js +406 -0
- package/public/js/apps/userReactApp.js +96 -47
- package/public/js/apps/userSystemAdmReactApp.js +1506 -0
- package/public/pages/politica-de-privacidade.html +1 -1
- package/public/pages/stickers.html +5 -5
- package/public/pages/termos-de-uso-texto-integral.html +1 -1
- package/public/pages/termos-de-uso.html +1 -1
- package/public/pages/user-password-reset.html +3 -4
- package/public/pages/user-systemadm.html +8 -462
- package/public/pages/user.html +1 -1
- package/scripts/clear-whatsapp-session.sh +123 -0
- package/scripts/core-ai-mode.mjs +163 -0
- package/scripts/deploy.sh +10 -0
- package/scripts/enrich-command-config-ux-openai.mjs +492 -0
- package/scripts/generate-commands-catalog.mjs +155 -0
- package/scripts/new-whatsapp-session.sh +317 -0
- package/scripts/security-web-surface-check.mjs +218 -0
- package/server/controllers/admin/adminPanelHandlers.js +253 -3
- package/server/controllers/admin/systemAdminController.js +267 -0
- package/server/controllers/sticker/stickerCatalogController.js +9 -23
- package/server/controllers/system/contactController.js +9 -17
- package/server/controllers/system/stickerCatalogSystemContext.js +27 -6
- package/server/controllers/system/systemController.js +254 -1
- package/server/controllers/userController.js +6 -0
- package/server/email/emailTemplateService.js +3 -2
- package/server/http/httpServer.js +8 -4
- package/server/middleware/securityHeaders.js +20 -1
- package/server/routes/admin/systemAdminRouter.js +6 -0
- package/server/routes/indexRouter.js +30 -6
- package/server/routes/observability/grafanaProxyRouter.js +254 -0
- package/server/routes/static/staticPageRouter.js +27 -1
- package/server/utils/publicContact.js +31 -0
- package/utils/whatsapp/contactEnv.js +39 -0
- package/vite.config.mjs +2 -1
- package/app/modules/playModule/local/installYtDlp.js +0 -25
- package/app/modules/playModule/local/ytDlpInstaller.js +0 -28
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import logger from '#logger';
|
|
2
|
+
import { executeQuery, TABLES, withTransaction } from '../../../database/index.js';
|
|
3
|
+
import { getActiveSocketsBySession, getMultiSessionRuntimeConfig, isSocketOpen, parseEnvInt } from '../../config/index.js';
|
|
4
|
+
import groupOwnershipService, { recordHistory as recordGroupOwnerHistory } from './groupOwnershipService.js';
|
|
5
|
+
|
|
6
|
+
const runtimeConfig = getMultiSessionRuntimeConfig();
|
|
7
|
+
const SESSION_IDS = Array.isArray(runtimeConfig?.sessionIds) && runtimeConfig.sessionIds.length > 0 ? runtimeConfig.sessionIds : ['default'];
|
|
8
|
+
const PRIMARY_SESSION_ID = String(runtimeConfig?.primarySessionId || SESSION_IDS[0] || 'default').trim() || 'default';
|
|
9
|
+
|
|
10
|
+
const GROUP_BALANCER_ENABLED = runtimeConfig?.balancerEnabled === true;
|
|
11
|
+
const GROUP_OWNER_LEASE_MS = Math.max(5_000, Number(runtimeConfig?.ownerLeaseMs) || 120_000);
|
|
12
|
+
|
|
13
|
+
const BALANCER_START_DELAY_MS = parseEnvInt(process.env.GROUP_BALANCER_START_DELAY_MS, 20_000, 1_000, 5 * 60 * 1000);
|
|
14
|
+
const BALANCER_INTERVAL_MS = parseEnvInt(process.env.GROUP_BALANCER_INTERVAL_MS, 60_000, 10_000, 15 * 60 * 1000);
|
|
15
|
+
const BALANCER_MESSAGES_WINDOW_SECONDS = parseEnvInt(process.env.GROUP_BALANCER_MESSAGES_WINDOW_SECONDS, 60, 30, 30 * 60);
|
|
16
|
+
const BALANCER_ERRORS_WINDOW_SECONDS = parseEnvInt(process.env.GROUP_BALANCER_ERRORS_WINDOW_SECONDS, 300, 60, 2 * 60 * 60);
|
|
17
|
+
const BALANCER_MAX_MOVES_PER_CYCLE = parseEnvInt(process.env.GROUP_BALANCER_MAX_MOVES_PER_CYCLE, Math.max(1, Math.ceil(SESSION_IDS.length / 2)), 1, 500);
|
|
18
|
+
const BALANCER_GROUP_COOLDOWN_MS = parseEnvInt(process.env.GROUP_BALANCER_GROUP_COOLDOWN_MS, 5 * 60 * 1000, 10_000, 24 * 60 * 60 * 1000);
|
|
19
|
+
|
|
20
|
+
const parseNumber = (value, fallback) => {
|
|
21
|
+
const parsed = Number(value);
|
|
22
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SCORE_WEIGHT_ACTIVE_GROUPS = parseNumber(process.env.GROUP_BALANCER_SCORE_GROUPS_WEIGHT, 2.0);
|
|
26
|
+
const SCORE_WEIGHT_MESSAGES_PER_MIN = parseNumber(process.env.GROUP_BALANCER_SCORE_MESSAGES_WEIGHT, 1.0);
|
|
27
|
+
const SCORE_WEIGHT_ERRORS = parseNumber(process.env.GROUP_BALANCER_SCORE_ERRORS_WEIGHT, 3.5);
|
|
28
|
+
const SCORE_STICKINESS_BONUS = parseNumber(process.env.GROUP_BALANCER_STICKINESS_BONUS, 1.5);
|
|
29
|
+
const SCORE_MIN_IMPROVEMENT = parseNumber(process.env.GROUP_BALANCER_MIN_IMPROVEMENT, 1.0);
|
|
30
|
+
|
|
31
|
+
const clampNumber = (value, fallback, min, max) => {
|
|
32
|
+
const parsed = Number(value);
|
|
33
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
34
|
+
return Math.max(min, Math.min(max, parsed));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const normalizeSessionId = (value) => {
|
|
38
|
+
const normalized = String(value || '').trim();
|
|
39
|
+
if (!normalized) return PRIMARY_SESSION_ID;
|
|
40
|
+
return SESSION_IDS.includes(normalized) ? normalized : PRIMARY_SESSION_ID;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const buildInClause = (items = []) => items.map(() => '?').join(', ');
|
|
44
|
+
|
|
45
|
+
const toMs = (value) => {
|
|
46
|
+
const parsed = new Date(value).getTime();
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const computeSessionScore = (stats = {}) => {
|
|
51
|
+
const groupsOwned = clampNumber(stats.groupsOwned, 0, 0, 100_000);
|
|
52
|
+
const messagesPerMin = clampNumber(stats.messagesPerMin, 0, 0, 1_000_000);
|
|
53
|
+
const errorsRecent = clampNumber(stats.errorsRecent, 0, 0, 1_000_000);
|
|
54
|
+
const sessionWeight = Math.max(1, clampNumber(stats.sessionWeight, 1, 1, 10_000));
|
|
55
|
+
|
|
56
|
+
const load = groupsOwned * SCORE_WEIGHT_ACTIVE_GROUPS + messagesPerMin * SCORE_WEIGHT_MESSAGES_PER_MIN + errorsRecent * SCORE_WEIGHT_ERRORS;
|
|
57
|
+
return Number((load / sessionWeight).toFixed(4));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const listOnlineSessions = () => {
|
|
61
|
+
const socketsBySession = getActiveSocketsBySession();
|
|
62
|
+
const onlineSessionIds = [];
|
|
63
|
+
|
|
64
|
+
for (const sessionId of SESSION_IDS) {
|
|
65
|
+
const socket = socketsBySession.get(sessionId);
|
|
66
|
+
if (isSocketOpen(socket)) {
|
|
67
|
+
onlineSessionIds.push(sessionId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return onlineSessionIds;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const fetchGroupCountsBySession = async (sessionIds) => {
|
|
75
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return new Map();
|
|
76
|
+
const placeholders = buildInClause(sessionIds);
|
|
77
|
+
const rows = await executeQuery(
|
|
78
|
+
`SELECT owner_session_id AS session_id, COUNT(*) AS total
|
|
79
|
+
FROM ${TABLES.GROUP_ASSIGNMENT}
|
|
80
|
+
WHERE owner_session_id IN (${placeholders})
|
|
81
|
+
AND lease_expires_at > UTC_TIMESTAMP()
|
|
82
|
+
GROUP BY owner_session_id`,
|
|
83
|
+
sessionIds,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const counts = new Map();
|
|
87
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
88
|
+
const sessionId = normalizeSessionId(row?.session_id);
|
|
89
|
+
counts.set(sessionId, Number(row?.total || 0));
|
|
90
|
+
}
|
|
91
|
+
return counts;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const fetchMessagesPerMinuteBySession = async (sessionIds) => {
|
|
95
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return new Map();
|
|
96
|
+
const placeholders = buildInClause(sessionIds);
|
|
97
|
+
const sinceDate = new Date(Date.now() - BALANCER_MESSAGES_WINDOW_SECONDS * 1000);
|
|
98
|
+
const rows = await executeQuery(
|
|
99
|
+
`SELECT session_id, COUNT(*) AS total
|
|
100
|
+
FROM ${TABLES.MESSAGES}
|
|
101
|
+
WHERE session_id IN (${placeholders})
|
|
102
|
+
AND timestamp >= ?
|
|
103
|
+
GROUP BY session_id`,
|
|
104
|
+
[...sessionIds, sinceDate],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
109
|
+
const sessionId = normalizeSessionId(row?.session_id);
|
|
110
|
+
counts.set(sessionId, Number(row?.total || 0));
|
|
111
|
+
}
|
|
112
|
+
return counts;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const fetchRecentErrorsBySession = async (sessionIds) => {
|
|
116
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return new Map();
|
|
117
|
+
const placeholders = buildInClause(sessionIds);
|
|
118
|
+
const sinceDate = new Date(Date.now() - BALANCER_ERRORS_WINDOW_SECONDS * 1000);
|
|
119
|
+
const rows = await executeQuery(
|
|
120
|
+
`SELECT session_id, COUNT(*) AS total
|
|
121
|
+
FROM ${TABLES.MESSAGE_ANALYSIS_EVENT}
|
|
122
|
+
WHERE session_id IN (${placeholders})
|
|
123
|
+
AND created_at >= ?
|
|
124
|
+
AND processing_result = 'error'
|
|
125
|
+
GROUP BY session_id`,
|
|
126
|
+
[...sessionIds, sinceDate],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
131
|
+
const sessionId = normalizeSessionId(row?.session_id);
|
|
132
|
+
counts.set(sessionId, Number(row?.total || 0));
|
|
133
|
+
}
|
|
134
|
+
return counts;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const fetchCandidateAssignments = async (sessionIds) => {
|
|
138
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return [];
|
|
139
|
+
const placeholders = buildInClause(sessionIds);
|
|
140
|
+
const rows = await executeQuery(
|
|
141
|
+
`SELECT group_jid, owner_session_id, lease_expires_at, cooldown_until, assignment_version, pinned
|
|
142
|
+
FROM ${TABLES.GROUP_ASSIGNMENT}
|
|
143
|
+
WHERE owner_session_id IN (${placeholders})
|
|
144
|
+
AND lease_expires_at > UTC_TIMESTAMP()
|
|
145
|
+
ORDER BY lease_expires_at DESC`,
|
|
146
|
+
sessionIds,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return (Array.isArray(rows) ? rows : []).map((row) => ({
|
|
150
|
+
groupJid: String(row?.group_jid || '').trim(),
|
|
151
|
+
ownerSessionId: normalizeSessionId(row?.owner_session_id),
|
|
152
|
+
leaseExpiresAt: row?.lease_expires_at ? new Date(row.lease_expires_at) : null,
|
|
153
|
+
cooldownUntil: row?.cooldown_until ? new Date(row.cooldown_until) : null,
|
|
154
|
+
assignmentVersion: Number(row?.assignment_version || 1),
|
|
155
|
+
pinned: Number(row?.pinned || 0) === 1,
|
|
156
|
+
}));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const pickBestTargetSession = (sessionIds, scoreBySession, currentOwnerSessionId) => {
|
|
160
|
+
const candidates = sessionIds
|
|
161
|
+
.filter((sessionId) => sessionId !== currentOwnerSessionId)
|
|
162
|
+
.map((sessionId) => ({
|
|
163
|
+
sessionId,
|
|
164
|
+
score: Number(scoreBySession.get(sessionId) || 0),
|
|
165
|
+
}))
|
|
166
|
+
.sort((a, b) => (a.score === b.score ? a.sessionId.localeCompare(b.sessionId) : a.score - b.score));
|
|
167
|
+
|
|
168
|
+
return candidates[0] || null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const rebalanceAssignment = async ({ groupJid, fromSessionId, toSessionId, cycleId, fromScore, toScore }) =>
|
|
172
|
+
withTransaction(async (connection) => {
|
|
173
|
+
const rows = await executeQuery(
|
|
174
|
+
`SELECT group_jid, owner_session_id, assignment_version, pinned, cooldown_until
|
|
175
|
+
FROM ${TABLES.GROUP_ASSIGNMENT}
|
|
176
|
+
WHERE group_jid = ?
|
|
177
|
+
LIMIT 1
|
|
178
|
+
FOR UPDATE`,
|
|
179
|
+
[groupJid],
|
|
180
|
+
connection,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const current = rows?.[0];
|
|
184
|
+
if (!current) {
|
|
185
|
+
return { moved: false, reason: 'assignment_not_found' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const currentOwnerSessionId = normalizeSessionId(current.owner_session_id);
|
|
189
|
+
if (currentOwnerSessionId !== fromSessionId) {
|
|
190
|
+
return { moved: false, reason: 'owner_changed' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (Number(current.pinned || 0) === 1) {
|
|
194
|
+
return { moved: false, reason: 'pinned_assignment' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const nowMs = Date.now();
|
|
198
|
+
const cooldownUntilMs = toMs(current.cooldown_until);
|
|
199
|
+
if (cooldownUntilMs > nowMs) {
|
|
200
|
+
return { moved: false, reason: 'cooldown_active' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const leaseExpiresAt = new Date(nowMs + GROUP_OWNER_LEASE_MS);
|
|
204
|
+
const cooldownUntil = new Date(nowMs + BALANCER_GROUP_COOLDOWN_MS);
|
|
205
|
+
const nextAssignmentVersion = Number(current.assignment_version || 1) + 1;
|
|
206
|
+
|
|
207
|
+
const updateResult = await executeQuery(
|
|
208
|
+
`UPDATE ${TABLES.GROUP_ASSIGNMENT}
|
|
209
|
+
SET owner_session_id = ?,
|
|
210
|
+
lease_expires_at = ?,
|
|
211
|
+
cooldown_until = ?,
|
|
212
|
+
assignment_version = assignment_version + 1,
|
|
213
|
+
last_reason = ?
|
|
214
|
+
WHERE group_jid = ?
|
|
215
|
+
AND owner_session_id = ?`,
|
|
216
|
+
[toSessionId, leaseExpiresAt, cooldownUntil, 'balancer_rebalance', groupJid, fromSessionId],
|
|
217
|
+
connection,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (Number(updateResult?.affectedRows || 0) < 1) {
|
|
221
|
+
return { moved: false, reason: 'update_noop' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await recordGroupOwnerHistory(
|
|
225
|
+
{
|
|
226
|
+
groupJid,
|
|
227
|
+
previousSessionId: fromSessionId,
|
|
228
|
+
newSessionId: toSessionId,
|
|
229
|
+
reason: 'balancer_rebalance',
|
|
230
|
+
changedBy: 'group_balancer',
|
|
231
|
+
assignmentVersion: nextAssignmentVersion,
|
|
232
|
+
metadata: {
|
|
233
|
+
cycleId,
|
|
234
|
+
fromScore,
|
|
235
|
+
toScore,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
connection,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
moved: true,
|
|
243
|
+
reason: 'rebalanced',
|
|
244
|
+
groupJid,
|
|
245
|
+
fromSessionId,
|
|
246
|
+
toSessionId,
|
|
247
|
+
assignmentVersion: nextAssignmentVersion,
|
|
248
|
+
cooldownUntil,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
let schedulerTimeout = null;
|
|
253
|
+
let schedulerInterval = null;
|
|
254
|
+
let cycleInProgress = false;
|
|
255
|
+
let missingTablesLogged = false;
|
|
256
|
+
|
|
257
|
+
export const runGroupAssignmentBalancerCycle = async () => {
|
|
258
|
+
if (!GROUP_BALANCER_ENABLED) {
|
|
259
|
+
return {
|
|
260
|
+
moved: 0,
|
|
261
|
+
reason: 'balancer_disabled',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (cycleInProgress) {
|
|
266
|
+
return {
|
|
267
|
+
moved: 0,
|
|
268
|
+
reason: 'cycle_in_progress',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
cycleInProgress = true;
|
|
273
|
+
const cycleId = `${Date.now()}:${Math.floor(Math.random() * 10_000)}`;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const onlineSessionIds = listOnlineSessions();
|
|
277
|
+
if (onlineSessionIds.length < 2) {
|
|
278
|
+
return {
|
|
279
|
+
moved: 0,
|
|
280
|
+
reason: 'insufficient_online_sessions',
|
|
281
|
+
onlineSessionIds,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const [groupCounts, messageRates, errorCounts, assignments] = await Promise.all([
|
|
286
|
+
fetchGroupCountsBySession(onlineSessionIds),
|
|
287
|
+
fetchMessagesPerMinuteBySession(onlineSessionIds),
|
|
288
|
+
fetchRecentErrorsBySession(onlineSessionIds),
|
|
289
|
+
fetchCandidateAssignments(onlineSessionIds),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
const nowMs = Date.now();
|
|
293
|
+
const sessionStats = new Map();
|
|
294
|
+
const scoreBySession = new Map();
|
|
295
|
+
for (const sessionId of onlineSessionIds) {
|
|
296
|
+
const sessionWeight = Math.max(1, Number(runtimeConfig?.sessionWeights?.[sessionId] || 1));
|
|
297
|
+
const stats = {
|
|
298
|
+
sessionId,
|
|
299
|
+
groupsOwned: Number(groupCounts.get(sessionId) || 0),
|
|
300
|
+
messagesPerMin: Number(messageRates.get(sessionId) || 0),
|
|
301
|
+
errorsRecent: Number(errorCounts.get(sessionId) || 0),
|
|
302
|
+
sessionWeight,
|
|
303
|
+
};
|
|
304
|
+
sessionStats.set(sessionId, stats);
|
|
305
|
+
scoreBySession.set(sessionId, computeSessionScore(stats));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const movableAssignments = assignments
|
|
309
|
+
.filter((assignment) => assignment.groupJid && onlineSessionIds.includes(assignment.ownerSessionId))
|
|
310
|
+
.filter((assignment) => assignment.pinned !== true)
|
|
311
|
+
.filter((assignment) => toMs(assignment.cooldownUntil) <= nowMs)
|
|
312
|
+
.sort((a, b) => Number(scoreBySession.get(b.ownerSessionId) || 0) - Number(scoreBySession.get(a.ownerSessionId) || 0));
|
|
313
|
+
|
|
314
|
+
if (movableAssignments.length === 0) {
|
|
315
|
+
return {
|
|
316
|
+
moved: 0,
|
|
317
|
+
reason: 'no_movable_assignments',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const maxMoves = Math.max(1, BALANCER_MAX_MOVES_PER_CYCLE);
|
|
322
|
+
const movedGroups = [];
|
|
323
|
+
|
|
324
|
+
for (const assignment of movableAssignments) {
|
|
325
|
+
if (movedGroups.length >= maxMoves) break;
|
|
326
|
+
|
|
327
|
+
const fromSessionId = assignment.ownerSessionId;
|
|
328
|
+
const target = pickBestTargetSession(onlineSessionIds, scoreBySession, fromSessionId);
|
|
329
|
+
if (!target) continue;
|
|
330
|
+
|
|
331
|
+
const fromScore = Number(scoreBySession.get(fromSessionId) || 0);
|
|
332
|
+
const toScore = Number(target.score || 0);
|
|
333
|
+
const improvement = fromScore - toScore;
|
|
334
|
+
if (improvement <= SCORE_MIN_IMPROVEMENT + SCORE_STICKINESS_BONUS) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const rebalanceResult = await rebalanceAssignment({
|
|
339
|
+
groupJid: assignment.groupJid,
|
|
340
|
+
fromSessionId,
|
|
341
|
+
toSessionId: target.sessionId,
|
|
342
|
+
cycleId,
|
|
343
|
+
fromScore,
|
|
344
|
+
toScore,
|
|
345
|
+
});
|
|
346
|
+
if (!rebalanceResult?.moved) continue;
|
|
347
|
+
|
|
348
|
+
groupOwnershipService.invalidateCache(assignment.groupJid);
|
|
349
|
+
movedGroups.push({
|
|
350
|
+
groupJid: assignment.groupJid,
|
|
351
|
+
fromSessionId,
|
|
352
|
+
toSessionId: target.sessionId,
|
|
353
|
+
fromScore,
|
|
354
|
+
toScore,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const fromStats = sessionStats.get(fromSessionId);
|
|
358
|
+
const toStats = sessionStats.get(target.sessionId);
|
|
359
|
+
if (fromStats) {
|
|
360
|
+
fromStats.groupsOwned = Math.max(0, Number(fromStats.groupsOwned || 0) - 1);
|
|
361
|
+
scoreBySession.set(fromSessionId, computeSessionScore(fromStats));
|
|
362
|
+
}
|
|
363
|
+
if (toStats) {
|
|
364
|
+
toStats.groupsOwned = Number(toStats.groupsOwned || 0) + 1;
|
|
365
|
+
scoreBySession.set(target.sessionId, computeSessionScore(toStats));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (movedGroups.length > 0) {
|
|
370
|
+
logger.info('Balanceador multi-sessão executou rebalance de owners.', {
|
|
371
|
+
action: 'group_balancer_cycle_rebalanced',
|
|
372
|
+
cycleId,
|
|
373
|
+
moved: movedGroups.length,
|
|
374
|
+
movedGroups,
|
|
375
|
+
onlineSessionIds,
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
logger.debug('Balanceador multi-sessão sem movimentos neste ciclo.', {
|
|
379
|
+
action: 'group_balancer_cycle_noop',
|
|
380
|
+
cycleId,
|
|
381
|
+
onlineSessionIds,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
moved: movedGroups.length,
|
|
387
|
+
reason: movedGroups.length > 0 ? 'rebalanced' : 'no_better_target',
|
|
388
|
+
movedGroups,
|
|
389
|
+
};
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error?.code === 'ER_NO_SUCH_TABLE') {
|
|
392
|
+
if (!missingTablesLogged) {
|
|
393
|
+
missingTablesLogged = true;
|
|
394
|
+
logger.warn('Balanceador de grupos indisponível: tabelas de multi-sessão ausentes.', {
|
|
395
|
+
action: 'group_balancer_tables_missing',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
moved: 0,
|
|
400
|
+
reason: 'tables_missing',
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
logger.error('Falha ao executar ciclo do balanceador multi-sessão.', {
|
|
405
|
+
action: 'group_balancer_cycle_failed',
|
|
406
|
+
error: error?.message,
|
|
407
|
+
});
|
|
408
|
+
throw error;
|
|
409
|
+
} finally {
|
|
410
|
+
cycleInProgress = false;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const startGroupAssignmentBalancer = () => {
|
|
415
|
+
if (!GROUP_BALANCER_ENABLED) {
|
|
416
|
+
logger.info('Balanceador de ownership por grupo desativado.', {
|
|
417
|
+
action: 'group_balancer_disabled',
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (schedulerTimeout || schedulerInterval) return;
|
|
423
|
+
|
|
424
|
+
logger.info('Iniciando balanceador de ownership por grupo.', {
|
|
425
|
+
action: 'group_balancer_start',
|
|
426
|
+
startDelayMs: BALANCER_START_DELAY_MS,
|
|
427
|
+
intervalMs: BALANCER_INTERVAL_MS,
|
|
428
|
+
maxMovesPerCycle: BALANCER_MAX_MOVES_PER_CYCLE,
|
|
429
|
+
cooldownMs: BALANCER_GROUP_COOLDOWN_MS,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
schedulerTimeout = setTimeout(() => {
|
|
433
|
+
schedulerTimeout = null;
|
|
434
|
+
void runGroupAssignmentBalancerCycle();
|
|
435
|
+
|
|
436
|
+
schedulerInterval = setInterval(() => {
|
|
437
|
+
void runGroupAssignmentBalancerCycle();
|
|
438
|
+
}, BALANCER_INTERVAL_MS);
|
|
439
|
+
|
|
440
|
+
if (typeof schedulerInterval.unref === 'function') schedulerInterval.unref();
|
|
441
|
+
}, BALANCER_START_DELAY_MS);
|
|
442
|
+
|
|
443
|
+
if (typeof schedulerTimeout.unref === 'function') schedulerTimeout.unref();
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
export const stopGroupAssignmentBalancer = () => {
|
|
447
|
+
if (schedulerTimeout) {
|
|
448
|
+
clearTimeout(schedulerTimeout);
|
|
449
|
+
schedulerTimeout = null;
|
|
450
|
+
}
|
|
451
|
+
if (schedulerInterval) {
|
|
452
|
+
clearInterval(schedulerInterval);
|
|
453
|
+
schedulerInterval = null;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
export const isGroupAssignmentBalancerEnabled = () => GROUP_BALANCER_ENABLED;
|