lazy-gravity 0.0.2 → 0.0.3
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 +21 -0
- package/README.md +224 -0
- package/dist/bin/cli.js +79 -0
- package/dist/bin/commands/doctor.js +156 -0
- package/dist/bin/commands/open.js +145 -0
- package/dist/bin/commands/setup.js +366 -0
- package/dist/bin/commands/start.js +15 -0
- package/dist/bot/index.js +914 -0
- package/dist/commands/chatCommandHandler.js +145 -0
- package/dist/commands/cleanupCommandHandler.js +396 -0
- package/dist/commands/messageParser.js +28 -0
- package/dist/commands/registerSlashCommands.js +149 -0
- package/dist/commands/slashCommandHandler.js +104 -0
- package/dist/commands/workspaceCommandHandler.js +230 -0
- package/dist/database/chatSessionRepository.js +88 -0
- package/dist/database/scheduleRepository.js +119 -0
- package/dist/database/templateRepository.js +103 -0
- package/dist/database/workspaceBindingRepository.js +109 -0
- package/dist/events/interactionCreateHandler.js +286 -0
- package/dist/events/messageCreateHandler.js +154 -0
- package/dist/index.js +10 -0
- package/dist/middleware/auth.js +10 -0
- package/dist/middleware/sanitize.js +20 -0
- package/dist/services/antigravityLauncher.js +89 -0
- package/dist/services/approvalDetector.js +384 -0
- package/dist/services/autoAcceptService.js +80 -0
- package/dist/services/cdpBridgeManager.js +204 -0
- package/dist/services/cdpConnectionPool.js +157 -0
- package/dist/services/cdpService.js +1311 -0
- package/dist/services/channelManager.js +118 -0
- package/dist/services/chatSessionService.js +516 -0
- package/dist/services/modeService.js +73 -0
- package/dist/services/modelService.js +63 -0
- package/dist/services/processManager.js +61 -0
- package/dist/services/progressSender.js +61 -0
- package/dist/services/promptDispatcher.js +17 -0
- package/dist/services/quotaService.js +185 -0
- package/dist/services/responseMonitor.js +645 -0
- package/dist/services/scheduleService.js +134 -0
- package/dist/services/screenshotService.js +85 -0
- package/dist/services/titleGeneratorService.js +113 -0
- package/dist/services/workspaceService.js +64 -0
- package/dist/ui/autoAcceptUi.js +34 -0
- package/dist/ui/modeUi.js +34 -0
- package/dist/ui/modelsUi.js +97 -0
- package/dist/ui/screenshotUi.js +51 -0
- package/dist/ui/templateUi.js +67 -0
- package/dist/utils/cdpPorts.js +5 -0
- package/dist/utils/config.js +20 -0
- package/dist/utils/configLoader.js +160 -0
- package/dist/utils/discordFormatter.js +167 -0
- package/dist/utils/i18n.js +77 -0
- package/dist/utils/imageHandler.js +154 -0
- package/dist/utils/lockfile.js +113 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/logo.js +13 -0
- package/dist/utils/metadataExtractor.js +15 -0
- package/dist/utils/processLogBuffer.js +98 -0
- package/dist/utils/streamMessageFormatter.js +90 -0
- package/package.json +73 -5
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChatCommandHandler = void 0;
|
|
4
|
+
const i18n_1 = require("../utils/i18n");
|
|
5
|
+
const discord_js_1 = require("discord.js");
|
|
6
|
+
/**
|
|
7
|
+
* Handler for chat session related commands
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* - /new: Create a new session channel under the category + start a new chat in Antigravity
|
|
11
|
+
* - /chat: Display current session info + list all sessions in the same project (unified)
|
|
12
|
+
*/
|
|
13
|
+
class ChatCommandHandler {
|
|
14
|
+
chatSessionService;
|
|
15
|
+
chatSessionRepo;
|
|
16
|
+
bindingRepo;
|
|
17
|
+
channelManager;
|
|
18
|
+
pool;
|
|
19
|
+
workspaceService;
|
|
20
|
+
constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, workspaceService, pool) {
|
|
21
|
+
this.chatSessionService = chatSessionService;
|
|
22
|
+
this.chatSessionRepo = chatSessionRepo;
|
|
23
|
+
this.bindingRepo = bindingRepo;
|
|
24
|
+
this.channelManager = channelManager;
|
|
25
|
+
this.workspaceService = workspaceService;
|
|
26
|
+
this.pool = pool ?? null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* /new -- Create a new session channel under the category and start a new chat in Antigravity
|
|
30
|
+
*/
|
|
31
|
+
async handleNew(interaction) {
|
|
32
|
+
const guild = interaction.guild;
|
|
33
|
+
if (!guild) {
|
|
34
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const channel = interaction.channel;
|
|
38
|
+
if (!channel || channel.type !== discord_js_1.ChannelType.GuildText) {
|
|
39
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ Please execute in a text channel.') });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Check if the current channel is under a project category
|
|
43
|
+
const parentId = 'parentId' in channel ? channel.parentId : null;
|
|
44
|
+
if (!parentId) {
|
|
45
|
+
await interaction.editReply({
|
|
46
|
+
content: (0, i18n_1.t)('⚠️ Please run in a project category channel.\nUse `/project` to create a project first.'),
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Determine the project path
|
|
51
|
+
const currentSession = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
52
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
53
|
+
const workspaceName = currentSession?.workspacePath ?? binding?.workspacePath;
|
|
54
|
+
if (!workspaceName) {
|
|
55
|
+
await interaction.editReply({
|
|
56
|
+
content: (0, i18n_1.t)('⚠️ Please run in a project category channel.\nUse `/project` to create a project first.'),
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Convert workspace name to full path
|
|
61
|
+
const workspacePath = this.workspaceService.getWorkspacePath(workspaceName);
|
|
62
|
+
// Switch project (connect to the correct workbench page)
|
|
63
|
+
let workspaceCdp;
|
|
64
|
+
if (this.pool) {
|
|
65
|
+
try {
|
|
66
|
+
workspaceCdp = await this.pool.getOrConnect(workspacePath);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
await interaction.editReply({
|
|
70
|
+
content: (0, i18n_1.t)(`⚠️ Failed to switch project: ${e.message}`),
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!workspaceCdp) {
|
|
76
|
+
await interaction.editReply({
|
|
77
|
+
content: (0, i18n_1.t)('⚠️ CDP pool is not initialized or cannot connect to workspace.'),
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Create a new session channel
|
|
82
|
+
const sessionNumber = this.chatSessionRepo.getNextSessionNumber(parentId);
|
|
83
|
+
const channelName = `session-${sessionNumber}`;
|
|
84
|
+
const sessionResult = await this.channelManager.createSessionChannel(guild, parentId, channelName);
|
|
85
|
+
const newChannelId = sessionResult.channelId;
|
|
86
|
+
// Register binding and session
|
|
87
|
+
this.bindingRepo.upsert({
|
|
88
|
+
channelId: newChannelId,
|
|
89
|
+
workspacePath: workspaceName,
|
|
90
|
+
guildId: guild.id,
|
|
91
|
+
});
|
|
92
|
+
this.chatSessionRepo.create({
|
|
93
|
+
channelId: newChannelId,
|
|
94
|
+
categoryId: parentId,
|
|
95
|
+
workspacePath: workspaceName,
|
|
96
|
+
sessionNumber,
|
|
97
|
+
guildId: guild.id,
|
|
98
|
+
});
|
|
99
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
100
|
+
.setTitle((0, i18n_1.t)('💬 Started a new session'))
|
|
101
|
+
.setDescription((0, i18n_1.t)(`Created a new chat session\n→ <#${newChannelId}>`))
|
|
102
|
+
.setColor(0x00CC88)
|
|
103
|
+
.addFields({ name: (0, i18n_1.t)('Session'), value: channelName, inline: true }, { name: (0, i18n_1.t)('Project'), value: workspacePath, inline: true })
|
|
104
|
+
.setTimestamp();
|
|
105
|
+
await interaction.editReply({ embeds: [embed] });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* /chat -- Display current session info + list all sessions in the same project (unified view)
|
|
109
|
+
*/
|
|
110
|
+
async handleChat(interaction) {
|
|
111
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
112
|
+
if (!session) {
|
|
113
|
+
// Channel not managed by session -- get info directly from Antigravity
|
|
114
|
+
const activeNames = this.pool?.getActiveWorkspaceNames() ?? [];
|
|
115
|
+
const anyCdp = activeNames.length > 0 ? this.pool?.getConnected(activeNames[0]) : null;
|
|
116
|
+
const info = anyCdp
|
|
117
|
+
? await this.chatSessionService.getCurrentSessionInfo(anyCdp)
|
|
118
|
+
: { title: (0, i18n_1.t)('(CDP Disconnected)'), hasActiveChat: false };
|
|
119
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
120
|
+
.setTitle((0, i18n_1.t)('💬 Chat Session Info'))
|
|
121
|
+
.setColor(info.hasActiveChat ? 0x00CC88 : 0x888888)
|
|
122
|
+
.addFields({ name: (0, i18n_1.t)('Title'), value: info.title, inline: true }, { name: (0, i18n_1.t)('Status'), value: info.hasActiveChat ? (0, i18n_1.t)('🟢 Active') : (0, i18n_1.t)('⚪ Inactive'), inline: true })
|
|
123
|
+
.setDescription((0, i18n_1.t)('※ Non-session channel.\nUse `/project` to create a project first.'))
|
|
124
|
+
.setTimestamp();
|
|
125
|
+
await interaction.editReply({ embeds: [embed] });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Get all sessions in the same category
|
|
129
|
+
const allSessions = this.chatSessionRepo.findByCategoryId(session.categoryId);
|
|
130
|
+
// Build session list
|
|
131
|
+
const sessionList = allSessions.map((s) => {
|
|
132
|
+
const name = s.displayName ? `${s.displayName}` : `session-${s.sessionNumber}`;
|
|
133
|
+
const current = s.channelId === interaction.channelId ? (0, i18n_1.t)(' ← **Current**') : '';
|
|
134
|
+
return `• <#${s.channelId}> — ${name}${current}`;
|
|
135
|
+
}).join('\n');
|
|
136
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
137
|
+
.setTitle((0, i18n_1.t)('💬 Chat Session Info'))
|
|
138
|
+
.setColor(0x00CC88)
|
|
139
|
+
.addFields({ name: (0, i18n_1.t)('Current session'), value: (0, i18n_1.t)(`#${session.sessionNumber} — ${session.displayName || '(Unset)'}`), inline: false }, { name: (0, i18n_1.t)('Project'), value: session.workspacePath, inline: true }, { name: (0, i18n_1.t)('Total sessions'), value: `${allSessions.length}`, inline: true })
|
|
140
|
+
.setDescription((0, i18n_1.t)(`**Sessions:**\n${sessionList}`))
|
|
141
|
+
.setTimestamp();
|
|
142
|
+
await interaction.editReply({ embeds: [embed] });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.ChatCommandHandler = ChatCommandHandler;
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CleanupCommandHandler = exports.CLEANUP_CANCEL_BTN = exports.CLEANUP_DELETE_BTN = exports.CLEANUP_ARCHIVE_BTN = void 0;
|
|
4
|
+
const i18n_1 = require("../utils/i18n");
|
|
5
|
+
const discord_js_1 = require("discord.js");
|
|
6
|
+
const logger_1 = require("../utils/logger");
|
|
7
|
+
/** Button custom IDs */
|
|
8
|
+
exports.CLEANUP_ARCHIVE_BTN = 'cleanup_archive';
|
|
9
|
+
exports.CLEANUP_DELETE_BTN = 'cleanup_delete';
|
|
10
|
+
exports.CLEANUP_CANCEL_BTN = 'cleanup_cancel';
|
|
11
|
+
/**
|
|
12
|
+
* Handler for the /cleanup command.
|
|
13
|
+
* Detects session channels and categories that have been inactive for the specified days,
|
|
14
|
+
* and presents a confirmation for archiving or deletion.
|
|
15
|
+
*/
|
|
16
|
+
class CleanupCommandHandler {
|
|
17
|
+
chatSessionRepo;
|
|
18
|
+
bindingRepo;
|
|
19
|
+
/** Holds the latest scan result (referenced on button press) */
|
|
20
|
+
lastScanResult = null;
|
|
21
|
+
constructor(chatSessionRepo, bindingRepo) {
|
|
22
|
+
this.chatSessionRepo = chatSessionRepo;
|
|
23
|
+
this.bindingRepo = bindingRepo;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* /cleanup [days] -- Scan unused channels/categories and display confirmation UI
|
|
27
|
+
*/
|
|
28
|
+
async handleCleanup(interaction) {
|
|
29
|
+
const guild = interaction.guild;
|
|
30
|
+
if (!guild) {
|
|
31
|
+
await interaction.editReply({
|
|
32
|
+
content: (0, i18n_1.t)('⚠️ This command can only be used in a server.'),
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const days = interaction.options.getInteger('days') ?? 7;
|
|
37
|
+
if (days < 1 || days > 365) {
|
|
38
|
+
await interaction.editReply({
|
|
39
|
+
content: (0, i18n_1.t)('⚠️ Please specify a number of days between 1 and 365.'),
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Execute scan
|
|
44
|
+
const scanResult = await this.scanInactiveChannels(guild, days);
|
|
45
|
+
this.lastScanResult = scanResult;
|
|
46
|
+
const totalInactive = scanResult.inactiveSessions.length;
|
|
47
|
+
const totalInactiveCategories = scanResult.inactiveCategories.length;
|
|
48
|
+
if (totalInactive === 0 && totalInactiveCategories === 0) {
|
|
49
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
50
|
+
.setTitle((0, i18n_1.t)('🧹 Cleanup Scan Complete'))
|
|
51
|
+
.setDescription((0, i18n_1.t)(`No inactive sessions or categories found (threshold: ${days} days).\n\nScanned ${scanResult.totalScanned} channels total.`))
|
|
52
|
+
.setColor(0x2ECC71)
|
|
53
|
+
.setTimestamp();
|
|
54
|
+
await interaction.editReply({ embeds: [embed] });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Build session list
|
|
58
|
+
const sessionLines = scanResult.inactiveSessions.map((s) => {
|
|
59
|
+
const name = s.channelName;
|
|
60
|
+
const category = s.categoryName ? `📂 ${s.categoryName}` : '(No category)';
|
|
61
|
+
return `• <#${s.channelId}> — ${category} — Last activity: **${s.daysSinceActivity} days ago**`;
|
|
62
|
+
});
|
|
63
|
+
// Build category list
|
|
64
|
+
const categoryLines = scanResult.inactiveCategories.map((c) => {
|
|
65
|
+
return `• 📂 **${c.categoryName}** (${c.sessionCount} sessions) — Last activity: **${c.daysSinceOldestActivity} days ago**`;
|
|
66
|
+
});
|
|
67
|
+
// Build embed (note Discord Embed limit: description is up to 4096 chars)
|
|
68
|
+
let description = '';
|
|
69
|
+
if (categoryLines.length > 0) {
|
|
70
|
+
description += `**🗂️ Inactive Categories (${totalInactiveCategories})**\n`;
|
|
71
|
+
description += `${(0, i18n_1.t)('All sessions within these categories have been inactive.')}\n`;
|
|
72
|
+
description += categoryLines.slice(0, 15).join('\n');
|
|
73
|
+
if (categoryLines.length > 15) {
|
|
74
|
+
description += `\n...and ${categoryLines.length - 15} more`;
|
|
75
|
+
}
|
|
76
|
+
description += '\n\n';
|
|
77
|
+
}
|
|
78
|
+
if (sessionLines.length > 0) {
|
|
79
|
+
description += `**💬 Inactive Sessions (${totalInactive})**\n`;
|
|
80
|
+
description += sessionLines.slice(0, 20).join('\n');
|
|
81
|
+
if (sessionLines.length > 20) {
|
|
82
|
+
description += `\n...and ${sessionLines.length - 20} more`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Truncate to fit within the 4096 character limit
|
|
86
|
+
if (description.length > 4000) {
|
|
87
|
+
description = description.substring(0, 3950) + '\n\n...(truncated)';
|
|
88
|
+
}
|
|
89
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
90
|
+
.setTitle((0, i18n_1.t)('🧹 Cleanup Scan Results'))
|
|
91
|
+
.setDescription(description)
|
|
92
|
+
.setColor(0xF39C12)
|
|
93
|
+
.addFields({
|
|
94
|
+
name: (0, i18n_1.t)('Threshold'),
|
|
95
|
+
value: (0, i18n_1.t)(`${days} days of inactivity`),
|
|
96
|
+
inline: true,
|
|
97
|
+
}, {
|
|
98
|
+
name: (0, i18n_1.t)('Scanned'),
|
|
99
|
+
value: `${scanResult.totalScanned} channels`,
|
|
100
|
+
inline: true,
|
|
101
|
+
}, {
|
|
102
|
+
name: (0, i18n_1.t)('Found'),
|
|
103
|
+
value: `${totalInactive} sessions, ${totalInactiveCategories} categories`,
|
|
104
|
+
inline: true,
|
|
105
|
+
})
|
|
106
|
+
.setFooter({
|
|
107
|
+
text: (0, i18n_1.t)('Choose an action below. Archive hides channels, Delete removes them permanently.'),
|
|
108
|
+
})
|
|
109
|
+
.setTimestamp();
|
|
110
|
+
// Action buttons
|
|
111
|
+
const row = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder()
|
|
112
|
+
.setCustomId(exports.CLEANUP_ARCHIVE_BTN)
|
|
113
|
+
.setLabel((0, i18n_1.t)('📦 Archive All'))
|
|
114
|
+
.setStyle(discord_js_1.ButtonStyle.Primary), new discord_js_1.ButtonBuilder()
|
|
115
|
+
.setCustomId(exports.CLEANUP_DELETE_BTN)
|
|
116
|
+
.setLabel((0, i18n_1.t)('🗑️ Delete All'))
|
|
117
|
+
.setStyle(discord_js_1.ButtonStyle.Danger), new discord_js_1.ButtonBuilder()
|
|
118
|
+
.setCustomId(exports.CLEANUP_CANCEL_BTN)
|
|
119
|
+
.setLabel((0, i18n_1.t)('Cancel'))
|
|
120
|
+
.setStyle(discord_js_1.ButtonStyle.Secondary));
|
|
121
|
+
await interaction.editReply({
|
|
122
|
+
embeds: [embed],
|
|
123
|
+
components: [row],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Button press handler: Archive
|
|
128
|
+
*/
|
|
129
|
+
async handleArchive(interaction) {
|
|
130
|
+
if (!this.lastScanResult) {
|
|
131
|
+
await interaction.update({
|
|
132
|
+
content: (0, i18n_1.t)('⚠️ No scan results found. Please run `/cleanup` again.'),
|
|
133
|
+
embeds: [],
|
|
134
|
+
components: [],
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const guild = interaction.guild;
|
|
139
|
+
if (!guild)
|
|
140
|
+
return;
|
|
141
|
+
await interaction.deferUpdate();
|
|
142
|
+
const result = this.lastScanResult;
|
|
143
|
+
let archivedCount = 0;
|
|
144
|
+
let failedCount = 0;
|
|
145
|
+
// Archive session channels (lock + permission restriction to hide)
|
|
146
|
+
for (const session of result.inactiveSessions) {
|
|
147
|
+
try {
|
|
148
|
+
const channel = guild.channels.cache.get(session.channelId);
|
|
149
|
+
if (channel && channel.type === discord_js_1.ChannelType.GuildText) {
|
|
150
|
+
const textChannel = channel;
|
|
151
|
+
// Add archive prefix to channel name
|
|
152
|
+
const archivedName = `archived-${textChannel.name}`;
|
|
153
|
+
await textChannel.setName(archivedName);
|
|
154
|
+
// Lock channel by denying @everyone's send message permission
|
|
155
|
+
const everyoneRole = guild.roles.everyone;
|
|
156
|
+
await textChannel.permissionOverwrites.create(everyoneRole, {
|
|
157
|
+
SendMessages: false,
|
|
158
|
+
ViewChannel: false,
|
|
159
|
+
});
|
|
160
|
+
archivedCount++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
logger_1.logger.error(`[Cleanup] Failed to archive channel ${session.channelId}:`, e);
|
|
165
|
+
failedCount++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Archive inactive categories
|
|
169
|
+
for (const category of result.inactiveCategories) {
|
|
170
|
+
try {
|
|
171
|
+
const categoryChannel = guild.channels.cache.get(category.categoryId);
|
|
172
|
+
if (categoryChannel && categoryChannel.type === discord_js_1.ChannelType.GuildCategory) {
|
|
173
|
+
const cat = categoryChannel;
|
|
174
|
+
const archivedName = `📦-archived-${cat.name.replace(/^🗂️-/, '')}`;
|
|
175
|
+
await cat.setName(archivedName);
|
|
176
|
+
// Hide entire category
|
|
177
|
+
const everyoneRole = guild.roles.everyone;
|
|
178
|
+
await cat.permissionOverwrites.create(everyoneRole, {
|
|
179
|
+
ViewChannel: false,
|
|
180
|
+
});
|
|
181
|
+
archivedCount++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
logger_1.logger.error(`[Cleanup] Failed to archive category ${category.categoryId}:`, e);
|
|
186
|
+
failedCount++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
this.lastScanResult = null;
|
|
190
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
191
|
+
.setTitle((0, i18n_1.t)('📦 Cleanup Complete — Archived'))
|
|
192
|
+
.setDescription((0, i18n_1.t)(`Successfully archived ${archivedCount} channels/categories.`) +
|
|
193
|
+
(failedCount > 0 ? `\n⚠️ ${failedCount} failed.` : ''))
|
|
194
|
+
.setColor(0x2ECC71)
|
|
195
|
+
.setTimestamp();
|
|
196
|
+
await interaction.editReply({
|
|
197
|
+
embeds: [embed],
|
|
198
|
+
components: [],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Button press handler: Delete
|
|
203
|
+
*/
|
|
204
|
+
async handleDelete(interaction) {
|
|
205
|
+
if (!this.lastScanResult) {
|
|
206
|
+
await interaction.update({
|
|
207
|
+
content: (0, i18n_1.t)('⚠️ No scan results found. Please run `/cleanup` again.'),
|
|
208
|
+
embeds: [],
|
|
209
|
+
components: [],
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const guild = interaction.guild;
|
|
214
|
+
if (!guild)
|
|
215
|
+
return;
|
|
216
|
+
await interaction.deferUpdate();
|
|
217
|
+
const result = this.lastScanResult;
|
|
218
|
+
let deletedCount = 0;
|
|
219
|
+
let failedCount = 0;
|
|
220
|
+
// Delete session channels
|
|
221
|
+
for (const session of result.inactiveSessions) {
|
|
222
|
+
try {
|
|
223
|
+
const channel = guild.channels.cache.get(session.channelId);
|
|
224
|
+
if (channel) {
|
|
225
|
+
await channel.delete(`Cleanup: ${result.thresholdDays} days inactive`);
|
|
226
|
+
// Also delete binding and session info from DB
|
|
227
|
+
this.chatSessionRepo.deleteByChannelId(session.channelId);
|
|
228
|
+
this.bindingRepo.deleteByChannelId(session.channelId);
|
|
229
|
+
deletedCount++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
logger_1.logger.error(`[Cleanup] Failed to delete channel ${session.channelId}:`, e);
|
|
234
|
+
failedCount++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Delete inactive categories (delete category itself only if children are empty)
|
|
238
|
+
for (const category of result.inactiveCategories) {
|
|
239
|
+
try {
|
|
240
|
+
const categoryChannel = guild.channels.cache.get(category.categoryId);
|
|
241
|
+
if (categoryChannel && categoryChannel.type === discord_js_1.ChannelType.GuildCategory) {
|
|
242
|
+
// Check remaining channels under the category
|
|
243
|
+
const children = guild.channels.cache.filter((ch) => 'parentId' in ch && ch.parentId === category.categoryId);
|
|
244
|
+
// Delete child channels as well
|
|
245
|
+
for (const [, child] of children) {
|
|
246
|
+
try {
|
|
247
|
+
// Also delete records from DB
|
|
248
|
+
this.chatSessionRepo.deleteByChannelId(child.id);
|
|
249
|
+
this.bindingRepo.deleteByChannelId(child.id);
|
|
250
|
+
await child.delete(`Cleanup: category ${category.categoryName} removed`);
|
|
251
|
+
deletedCount++;
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
logger_1.logger.error(`[Cleanup] Failed to delete child channel ${child.id} under category:`, e);
|
|
255
|
+
failedCount++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Delete the category itself
|
|
259
|
+
await categoryChannel.delete(`Cleanup: ${result.thresholdDays} days inactive`);
|
|
260
|
+
deletedCount++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
logger_1.logger.error(`[Cleanup] Failed to delete category ${category.categoryId}:`, e);
|
|
265
|
+
failedCount++;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.lastScanResult = null;
|
|
269
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
270
|
+
.setTitle((0, i18n_1.t)('🗑️ Cleanup Complete — Deleted'))
|
|
271
|
+
.setDescription((0, i18n_1.t)(`Successfully deleted ${deletedCount} channels/categories.`) +
|
|
272
|
+
(failedCount > 0 ? `\n⚠️ ${failedCount} failed.` : ''))
|
|
273
|
+
.setColor(0xE74C3C)
|
|
274
|
+
.setTimestamp();
|
|
275
|
+
await interaction.editReply({
|
|
276
|
+
embeds: [embed],
|
|
277
|
+
components: [],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Button press handler: Cancel
|
|
282
|
+
*/
|
|
283
|
+
async handleCancel(interaction) {
|
|
284
|
+
this.lastScanResult = null;
|
|
285
|
+
await interaction.update({
|
|
286
|
+
embeds: [
|
|
287
|
+
new discord_js_1.EmbedBuilder()
|
|
288
|
+
.setTitle((0, i18n_1.t)('🧹 Cleanup Cancelled'))
|
|
289
|
+
.setDescription((0, i18n_1.t)('No changes were made.'))
|
|
290
|
+
.setColor(0x888888)
|
|
291
|
+
.setTimestamp(),
|
|
292
|
+
],
|
|
293
|
+
components: [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Scan for inactive channels/categories
|
|
298
|
+
*/
|
|
299
|
+
async scanInactiveChannels(guild, thresholdDays) {
|
|
300
|
+
const now = new Date();
|
|
301
|
+
const thresholdMs = thresholdDays * 24 * 60 * 60 * 1000;
|
|
302
|
+
// Fetch all channels
|
|
303
|
+
const allChannels = await guild.channels.fetch();
|
|
304
|
+
// Detect bot-managed categories (with 🗂️- prefix)
|
|
305
|
+
const botCategories = allChannels.filter((ch) => ch !== null && ch.type === discord_js_1.ChannelType.GuildCategory && ch.name.startsWith('🗂️-'));
|
|
306
|
+
const inactiveSessions = [];
|
|
307
|
+
const categoryActivityMap = new Map();
|
|
308
|
+
let totalScanned = 0;
|
|
309
|
+
// Scan text channels under each category
|
|
310
|
+
for (const [, category] of botCategories) {
|
|
311
|
+
const children = allChannels.filter((ch) => ch !== null &&
|
|
312
|
+
ch.type === discord_js_1.ChannelType.GuildText &&
|
|
313
|
+
'parentId' in ch &&
|
|
314
|
+
ch.parentId === category.id);
|
|
315
|
+
const sessionsInCategory = [];
|
|
316
|
+
let categoryHasActive = false;
|
|
317
|
+
for (const [, child] of children) {
|
|
318
|
+
totalScanned++;
|
|
319
|
+
// Get the timestamp of the last message
|
|
320
|
+
const lastActivity = await this.getLastActivityDate(child);
|
|
321
|
+
const daysSince = Math.floor((now.getTime() - lastActivity.getTime()) / (24 * 60 * 60 * 1000));
|
|
322
|
+
if (daysSince >= thresholdDays) {
|
|
323
|
+
const session = {
|
|
324
|
+
channelId: child.id,
|
|
325
|
+
channelName: child.name,
|
|
326
|
+
categoryId: category.id,
|
|
327
|
+
categoryName: category.name,
|
|
328
|
+
lastActivityAt: lastActivity,
|
|
329
|
+
daysSinceActivity: daysSince,
|
|
330
|
+
};
|
|
331
|
+
inactiveSessions.push(session);
|
|
332
|
+
sessionsInCategory.push(session);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
categoryHasActive = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
categoryActivityMap.set(category.id, {
|
|
339
|
+
sessions: sessionsInCategory,
|
|
340
|
+
active: categoryHasActive,
|
|
341
|
+
channel: category,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Determine if entire category is inactive
|
|
345
|
+
const inactiveCategories = [];
|
|
346
|
+
for (const [categoryId, data] of categoryActivityMap) {
|
|
347
|
+
// Only if all sessions in the category are inactive (and at least 1 session exists)
|
|
348
|
+
if (!data.active && data.sessions.length > 0) {
|
|
349
|
+
// Get the oldest activity timestamp
|
|
350
|
+
const oldestActivity = data.sessions.reduce((oldest, s) => (s.lastActivityAt < oldest ? s.lastActivityAt : oldest), data.sessions[0].lastActivityAt);
|
|
351
|
+
const daysSince = Math.floor((now.getTime() - oldestActivity.getTime()) / (24 * 60 * 60 * 1000));
|
|
352
|
+
inactiveCategories.push({
|
|
353
|
+
categoryId,
|
|
354
|
+
categoryName: data.channel.name,
|
|
355
|
+
sessionCount: data.sessions.length,
|
|
356
|
+
oldestActivity,
|
|
357
|
+
daysSinceOldestActivity: daysSince,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
inactiveSessions,
|
|
363
|
+
inactiveCategories,
|
|
364
|
+
totalScanned,
|
|
365
|
+
thresholdDays,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Get the last activity date of a channel.
|
|
370
|
+
* Returns the timestamp of the last message, or the channel creation date, whichever is newer.
|
|
371
|
+
*/
|
|
372
|
+
async getLastActivityDate(channel) {
|
|
373
|
+
try {
|
|
374
|
+
// Fetch the most recent message (descending)
|
|
375
|
+
const messages = await channel.messages.fetch({ limit: 1 });
|
|
376
|
+
if (messages.size > 0) {
|
|
377
|
+
const lastMessage = messages.values().next().value;
|
|
378
|
+
if (lastMessage) {
|
|
379
|
+
return lastMessage.createdAt;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
logger_1.logger.warn(`[Cleanup] Failed to fetch messages for channel ${channel.id}:`, e);
|
|
385
|
+
}
|
|
386
|
+
// Use channel creation date if no messages
|
|
387
|
+
return channel.createdAt;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Get the current scan result (for testing)
|
|
391
|
+
*/
|
|
392
|
+
getLastScanResult() {
|
|
393
|
+
return this.lastScanResult;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
exports.CleanupCommandHandler = CleanupCommandHandler;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseMessageContent = parseMessageContent;
|
|
4
|
+
function parseMessageContent(content) {
|
|
5
|
+
const trimmed = content.trim();
|
|
6
|
+
if (!trimmed.startsWith('/') || trimmed === '/') {
|
|
7
|
+
return {
|
|
8
|
+
isCommand: false,
|
|
9
|
+
text: content
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
// Strip leading '/' and split by spaces. Double-quoted segments are kept as one token
|
|
13
|
+
const parts = trimmed.slice(1).match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
14
|
+
if (parts.length === 0) {
|
|
15
|
+
return {
|
|
16
|
+
isCommand: false,
|
|
17
|
+
text: content
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const commandName = parts[0];
|
|
21
|
+
// Strip surrounding double quotes from arguments
|
|
22
|
+
const args = parts.slice(1).map((arg) => arg.replace(/^"(.*)"$/, '$1'));
|
|
23
|
+
return {
|
|
24
|
+
isCommand: true,
|
|
25
|
+
commandName,
|
|
26
|
+
args
|
|
27
|
+
};
|
|
28
|
+
}
|