lazy-gravity 0.1.0 → 0.2.0
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/README.md +18 -6
- package/dist/bin/cli.js +18 -18
- package/dist/bin/commands/doctor.js +2 -1
- package/dist/bin/commands/start.js +25 -2
- package/dist/bot/index.js +346 -152
- package/dist/commands/joinCommandHandler.js +302 -0
- package/dist/commands/joinDetachCommandHandler.js +285 -0
- package/dist/commands/registerSlashCommands.js +35 -0
- package/dist/database/chatSessionRepository.js +10 -0
- package/dist/database/userPreferenceRepository.js +72 -0
- package/dist/events/interactionCreateHandler.js +58 -36
- package/dist/events/messageCreateHandler.js +158 -53
- package/dist/services/antigravityLauncher.js +4 -3
- package/dist/services/approvalDetector.js +6 -0
- package/dist/services/cdpBridgeManager.js +184 -84
- package/dist/services/cdpConnectionPool.js +79 -51
- package/dist/services/cdpService.js +149 -51
- package/dist/services/chatSessionService.js +229 -8
- package/dist/services/errorPopupDetector.js +6 -0
- package/dist/services/planningDetector.js +6 -0
- package/dist/services/responseMonitor.js +125 -24
- package/dist/services/updateCheckService.js +147 -0
- package/dist/services/userMessageDetector.js +221 -0
- package/dist/ui/modeUi.js +11 -1
- package/dist/ui/outputUi.js +30 -0
- package/dist/ui/sessionPickerUi.js +48 -0
- package/dist/utils/antigravityPaths.js +94 -0
- package/dist/utils/configLoader.js +10 -0
- package/dist/utils/discordButtonUtils.js +33 -0
- package/dist/utils/logBuffer.js +47 -0
- package/dist/utils/logger.js +80 -20
- package/dist/utils/pathUtils.js +57 -0
- package/dist/utils/plainTextFormatter.js +70 -0
- package/package.json +4 -4
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JoinCommandHandler = void 0;
|
|
4
|
+
const i18n_1 = require("../utils/i18n");
|
|
5
|
+
const discord_js_1 = require("discord.js");
|
|
6
|
+
const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
|
|
7
|
+
const responseMonitor_1 = require("../services/responseMonitor");
|
|
8
|
+
const sessionPickerUi_1 = require("../ui/sessionPickerUi");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
/** Maximum embed description length (Discord limit is 4096) */
|
|
11
|
+
const MAX_EMBED_DESC = 4000;
|
|
12
|
+
/**
|
|
13
|
+
* Handler for /join and /mirror commands
|
|
14
|
+
*
|
|
15
|
+
* /join — List Antigravity sessions and connect to one via a select menu.
|
|
16
|
+
* /mirror — Toggle PC-to-Discord message mirroring ON/OFF.
|
|
17
|
+
*/
|
|
18
|
+
class JoinCommandHandler {
|
|
19
|
+
chatSessionService;
|
|
20
|
+
chatSessionRepo;
|
|
21
|
+
bindingRepo;
|
|
22
|
+
channelManager;
|
|
23
|
+
pool;
|
|
24
|
+
workspaceService;
|
|
25
|
+
client;
|
|
26
|
+
/** Active ResponseMonitors per workspace (for AI response mirroring) */
|
|
27
|
+
activeResponseMonitors = new Map();
|
|
28
|
+
constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, pool, workspaceService, client) {
|
|
29
|
+
this.chatSessionService = chatSessionService;
|
|
30
|
+
this.chatSessionRepo = chatSessionRepo;
|
|
31
|
+
this.bindingRepo = bindingRepo;
|
|
32
|
+
this.channelManager = channelManager;
|
|
33
|
+
this.pool = pool;
|
|
34
|
+
this.workspaceService = workspaceService;
|
|
35
|
+
this.client = client;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a project name (from DB) to its full absolute path.
|
|
39
|
+
* The DB stores only the project name; CDP needs the full path for launching.
|
|
40
|
+
*/
|
|
41
|
+
resolveProjectPath(projectName) {
|
|
42
|
+
return this.workspaceService.getWorkspacePath(projectName);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* /join — Show session picker for the workspace bound to this channel.
|
|
46
|
+
*/
|
|
47
|
+
async handleJoin(interaction, bridge) {
|
|
48
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
49
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
50
|
+
const projectName = binding?.workspacePath ?? session?.workspacePath;
|
|
51
|
+
if (!projectName) {
|
|
52
|
+
await interaction.editReply({
|
|
53
|
+
content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const projectPath = this.resolveProjectPath(projectName);
|
|
58
|
+
let cdp;
|
|
59
|
+
try {
|
|
60
|
+
cdp = await this.pool.getOrConnect(projectPath);
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
await interaction.editReply({
|
|
64
|
+
content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const sessions = await this.chatSessionService.listAllSessions(cdp);
|
|
69
|
+
const { embeds, components } = (0, sessionPickerUi_1.buildSessionPickerUI)(sessions);
|
|
70
|
+
await interaction.editReply({ embeds, components });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Handle session selection from the /join picker.
|
|
74
|
+
*
|
|
75
|
+
* Flow:
|
|
76
|
+
* 1. Check if a channel already exists for this session (by displayName)
|
|
77
|
+
* 2. If yes → reply with a link to that channel
|
|
78
|
+
* 3. If no → create a new channel, bind it, activate session, start mirroring
|
|
79
|
+
*/
|
|
80
|
+
async handleJoinSelect(interaction, bridge) {
|
|
81
|
+
const selectedTitle = interaction.values[0];
|
|
82
|
+
const guild = interaction.guild;
|
|
83
|
+
if (!guild) {
|
|
84
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
88
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
89
|
+
const projectName = binding?.workspacePath ?? session?.workspacePath;
|
|
90
|
+
if (!projectName) {
|
|
91
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ No project is bound to this channel.') });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const projectPath = this.resolveProjectPath(projectName);
|
|
95
|
+
// Step 1: Check if a channel already exists for this session
|
|
96
|
+
const existingSession = this.chatSessionRepo.findByDisplayName(projectName, selectedTitle);
|
|
97
|
+
if (existingSession) {
|
|
98
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
99
|
+
.setTitle((0, i18n_1.t)('🔗 Session Already Connected'))
|
|
100
|
+
.setDescription((0, i18n_1.t)(`This session already has a channel:\n→ <#${existingSession.channelId}>`))
|
|
101
|
+
.setColor(0x3498DB)
|
|
102
|
+
.setTimestamp();
|
|
103
|
+
await interaction.editReply({ embeds: [embed], components: [] });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Step 2: Connect to CDP
|
|
107
|
+
let cdp;
|
|
108
|
+
try {
|
|
109
|
+
cdp = await this.pool.getOrConnect(projectPath);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`) });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Step 3: Activate the session in Antigravity
|
|
116
|
+
const activateResult = await this.chatSessionService.activateSessionByTitle(cdp, selectedTitle);
|
|
117
|
+
if (!activateResult.ok) {
|
|
118
|
+
await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to join session: ${activateResult.error}`) });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Step 4: Create a new Discord channel for this session
|
|
122
|
+
const categoryResult = await this.channelManager.ensureCategory(guild, projectName);
|
|
123
|
+
const categoryId = categoryResult.categoryId;
|
|
124
|
+
const sessionNumber = this.chatSessionRepo.getNextSessionNumber(categoryId);
|
|
125
|
+
const channelName = this.channelManager.sanitizeChannelName(`${sessionNumber}-${selectedTitle}`);
|
|
126
|
+
const channelResult = await this.channelManager.createSessionChannel(guild, categoryId, channelName);
|
|
127
|
+
const newChannelId = channelResult.channelId;
|
|
128
|
+
// Step 5: Register binding and session
|
|
129
|
+
this.bindingRepo.upsert({
|
|
130
|
+
channelId: newChannelId,
|
|
131
|
+
workspacePath: projectName,
|
|
132
|
+
guildId: guild.id,
|
|
133
|
+
});
|
|
134
|
+
this.chatSessionRepo.create({
|
|
135
|
+
channelId: newChannelId,
|
|
136
|
+
categoryId,
|
|
137
|
+
workspacePath: projectName,
|
|
138
|
+
sessionNumber,
|
|
139
|
+
guildId: guild.id,
|
|
140
|
+
});
|
|
141
|
+
this.chatSessionRepo.updateDisplayName(newChannelId, selectedTitle);
|
|
142
|
+
// Step 6: Start mirroring (routes dynamically to all bound session channels)
|
|
143
|
+
this.startMirroring(bridge, cdp, projectName);
|
|
144
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
145
|
+
.setTitle((0, i18n_1.t)('🔗 Joined Session'))
|
|
146
|
+
.setDescription((0, i18n_1.t)(`Connected to: **${selectedTitle}**\n→ <#${newChannelId}>\n\n` +
|
|
147
|
+
`📡 Mirroring is **ON** — PC messages will appear in the new channel.\n` +
|
|
148
|
+
`Use \`/mirror\` to toggle.`))
|
|
149
|
+
.setColor(0x2ECC71)
|
|
150
|
+
.setTimestamp();
|
|
151
|
+
await interaction.editReply({ embeds: [embed], components: [] });
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* /mirror — Toggle mirroring ON/OFF for the current channel's workspace.
|
|
155
|
+
*/
|
|
156
|
+
async handleMirror(interaction, bridge) {
|
|
157
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
158
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
159
|
+
const projectName = binding?.workspacePath ?? session?.workspacePath;
|
|
160
|
+
if (!projectName) {
|
|
161
|
+
await interaction.editReply({
|
|
162
|
+
content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const projectPath = this.resolveProjectPath(projectName);
|
|
167
|
+
const detector = this.pool.getUserMessageDetector(projectName);
|
|
168
|
+
if (detector?.isActive()) {
|
|
169
|
+
// Turn OFF — stop user message detector and any active response monitor
|
|
170
|
+
detector.stop();
|
|
171
|
+
const responseMonitor = this.activeResponseMonitors.get(projectName);
|
|
172
|
+
if (responseMonitor?.isActive()) {
|
|
173
|
+
await responseMonitor.stop();
|
|
174
|
+
this.activeResponseMonitors.delete(projectName);
|
|
175
|
+
}
|
|
176
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
177
|
+
.setTitle((0, i18n_1.t)('📡 Mirroring OFF'))
|
|
178
|
+
.setDescription((0, i18n_1.t)('PC-to-Discord message mirroring has been stopped.'))
|
|
179
|
+
.setColor(0x95A5A6)
|
|
180
|
+
.setTimestamp();
|
|
181
|
+
await interaction.editReply({ embeds: [embed] });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// Turn ON
|
|
185
|
+
let cdp;
|
|
186
|
+
try {
|
|
187
|
+
cdp = await this.pool.getOrConnect(projectPath);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
await interaction.editReply({
|
|
191
|
+
content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.startMirroring(bridge, cdp, projectName);
|
|
196
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
197
|
+
.setTitle((0, i18n_1.t)('📡 Mirroring ON'))
|
|
198
|
+
.setDescription((0, i18n_1.t)('PC-to-Discord message mirroring is now active.\n' +
|
|
199
|
+
'Messages typed in Antigravity will appear in the corresponding session channel.'))
|
|
200
|
+
.setColor(0x2ECC71)
|
|
201
|
+
.setTimestamp();
|
|
202
|
+
await interaction.editReply({ embeds: [embed] });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Start user message mirroring for a project.
|
|
207
|
+
*
|
|
208
|
+
* When a PC message is detected, the callback resolves the correct Discord
|
|
209
|
+
* channel via chatSessionRepo.findByDisplayName. Only explicitly joined
|
|
210
|
+
* sessions (with a displayName binding) receive mirrored messages.
|
|
211
|
+
*/
|
|
212
|
+
startMirroring(bridge, cdp, projectName) {
|
|
213
|
+
// Force re-prime: stop existing detector so that ensureUserMessageDetector
|
|
214
|
+
// creates a fresh one. This prevents the detector from treating the
|
|
215
|
+
// new session's last message as a "new" user message after /join.
|
|
216
|
+
const existing = this.pool.getUserMessageDetector(projectName);
|
|
217
|
+
if (existing?.isActive()) {
|
|
218
|
+
existing.stop();
|
|
219
|
+
}
|
|
220
|
+
(0, cdpBridgeManager_1.ensureUserMessageDetector)(bridge, cdp, projectName, (info) => {
|
|
221
|
+
this.routeMirroredMessage(cdp, projectName, info)
|
|
222
|
+
.catch((err) => {
|
|
223
|
+
logger_1.logger.error('[Mirror] Error routing mirrored message:', err);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Route a mirrored PC message to the correct Discord channel and
|
|
229
|
+
* start a passive ResponseMonitor to capture the AI response.
|
|
230
|
+
*
|
|
231
|
+
* Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
|
|
232
|
+
* Sessions without an explicit channel binding are silently skipped.
|
|
233
|
+
*/
|
|
234
|
+
async routeMirroredMessage(cdp, projectName, info) {
|
|
235
|
+
const chatTitle = await (0, cdpBridgeManager_1.getCurrentChatTitle)(cdp);
|
|
236
|
+
if (!chatTitle) {
|
|
237
|
+
logger_1.logger.debug('[Mirror] No chat title detected, skipping');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const session = this.chatSessionRepo.findByDisplayName(projectName, chatTitle);
|
|
241
|
+
if (!session) {
|
|
242
|
+
logger_1.logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const channel = this.client.channels.cache.get(session.channelId);
|
|
246
|
+
if (!channel || !('send' in channel))
|
|
247
|
+
return;
|
|
248
|
+
const sendable = channel;
|
|
249
|
+
// Mirror the user message
|
|
250
|
+
const userEmbed = new discord_js_1.EmbedBuilder()
|
|
251
|
+
.setDescription(`🖥️ ${info.text}`)
|
|
252
|
+
.setColor(0x95A5A6)
|
|
253
|
+
.setFooter({ text: `Typed in Antigravity · ${chatTitle}` })
|
|
254
|
+
.setTimestamp();
|
|
255
|
+
await sendable.send({ embeds: [userEmbed] }).catch((err) => {
|
|
256
|
+
logger_1.logger.error('[Mirror] Failed to send user message:', err);
|
|
257
|
+
});
|
|
258
|
+
// Start passive ResponseMonitor to capture the AI response
|
|
259
|
+
this.startResponseMirror(cdp, projectName, sendable, chatTitle);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Start a passive ResponseMonitor that sends the AI response to Discord
|
|
263
|
+
* when generation completes.
|
|
264
|
+
*/
|
|
265
|
+
startResponseMirror(cdp, projectName, channel, chatTitle) {
|
|
266
|
+
// Stop previous monitor if still running
|
|
267
|
+
const prev = this.activeResponseMonitors.get(projectName);
|
|
268
|
+
if (prev?.isActive()) {
|
|
269
|
+
prev.stop().catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
const monitor = new responseMonitor_1.ResponseMonitor({
|
|
272
|
+
cdpService: cdp,
|
|
273
|
+
pollIntervalMs: 2000,
|
|
274
|
+
maxDurationMs: 300000,
|
|
275
|
+
onComplete: (finalText) => {
|
|
276
|
+
this.activeResponseMonitors.delete(projectName);
|
|
277
|
+
if (!finalText || finalText.trim().length === 0)
|
|
278
|
+
return;
|
|
279
|
+
const text = finalText.length > MAX_EMBED_DESC
|
|
280
|
+
? finalText.slice(0, MAX_EMBED_DESC) + '\n…(truncated)'
|
|
281
|
+
: finalText;
|
|
282
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
283
|
+
.setDescription(text)
|
|
284
|
+
.setColor(0x5865F2)
|
|
285
|
+
.setFooter({ text: `Antigravity response · ${chatTitle}` })
|
|
286
|
+
.setTimestamp();
|
|
287
|
+
channel.send({ embeds: [embed] }).catch((err) => {
|
|
288
|
+
logger_1.logger.error('[Mirror] Failed to send AI response:', err);
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
onTimeout: () => {
|
|
292
|
+
this.activeResponseMonitors.delete(projectName);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
this.activeResponseMonitors.set(projectName, monitor);
|
|
296
|
+
monitor.startPassive().catch((err) => {
|
|
297
|
+
logger_1.logger.error('[Mirror] Failed to start response monitor:', err);
|
|
298
|
+
this.activeResponseMonitors.delete(projectName);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
exports.JoinCommandHandler = JoinCommandHandler;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JoinDetachCommandHandler = void 0;
|
|
4
|
+
const i18n_1 = require("../utils/i18n");
|
|
5
|
+
const discord_js_1 = require("discord.js");
|
|
6
|
+
const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
|
|
7
|
+
const responseMonitor_1 = require("../services/responseMonitor");
|
|
8
|
+
const sessionPickerUi_1 = require("../ui/sessionPickerUi");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
/** Maximum embed description length (Discord limit is 4096) */
|
|
11
|
+
const MAX_EMBED_DESC = 4000;
|
|
12
|
+
/**
|
|
13
|
+
* Handler for /join and /mirror commands.
|
|
14
|
+
*
|
|
15
|
+
* /join — List Antigravity sessions and connect to one via a select menu.
|
|
16
|
+
* /mirror — Toggle PC-to-Discord message mirroring ON/OFF.
|
|
17
|
+
*/
|
|
18
|
+
class JoinDetachCommandHandler {
|
|
19
|
+
chatSessionService;
|
|
20
|
+
chatSessionRepo;
|
|
21
|
+
bindingRepo;
|
|
22
|
+
channelManager;
|
|
23
|
+
pool;
|
|
24
|
+
client;
|
|
25
|
+
/** Active ResponseMonitors per workspace (for AI response mirroring) */
|
|
26
|
+
activeResponseMonitors = new Map();
|
|
27
|
+
constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, pool, client) {
|
|
28
|
+
this.chatSessionService = chatSessionService;
|
|
29
|
+
this.chatSessionRepo = chatSessionRepo;
|
|
30
|
+
this.bindingRepo = bindingRepo;
|
|
31
|
+
this.channelManager = channelManager;
|
|
32
|
+
this.pool = pool;
|
|
33
|
+
this.client = client;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* /join — Show session picker for the workspace bound to this channel.
|
|
37
|
+
*/
|
|
38
|
+
async handleJoin(interaction, bridge) {
|
|
39
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
40
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
41
|
+
const workspaceName = binding?.workspacePath ?? session?.workspacePath;
|
|
42
|
+
if (!workspaceName) {
|
|
43
|
+
await interaction.editReply({
|
|
44
|
+
content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let cdp;
|
|
49
|
+
try {
|
|
50
|
+
cdp = await this.pool.getOrConnect(workspaceName);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
await interaction.editReply({
|
|
54
|
+
content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const sessions = await this.chatSessionService.listAllSessions(cdp);
|
|
59
|
+
const { embeds, components } = (0, sessionPickerUi_1.buildSessionPickerUI)(sessions);
|
|
60
|
+
await interaction.editReply({ embeds, components });
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Handle session selection from the /join picker.
|
|
64
|
+
*
|
|
65
|
+
* Flow:
|
|
66
|
+
* 1. Check if a channel already exists for this session (by displayName)
|
|
67
|
+
* 2. If yes → reply with a link to that channel
|
|
68
|
+
* 3. If no → create a new channel, bind it, activate session, start mirroring
|
|
69
|
+
*/
|
|
70
|
+
async handleJoinSelect(interaction, bridge) {
|
|
71
|
+
const selectedTitle = interaction.values[0];
|
|
72
|
+
const guild = interaction.guild;
|
|
73
|
+
if (!guild) {
|
|
74
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
78
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
79
|
+
const workspaceName = binding?.workspacePath ?? session?.workspacePath;
|
|
80
|
+
if (!workspaceName) {
|
|
81
|
+
await interaction.editReply({ content: (0, i18n_1.t)('⚠️ No project is bound to this channel.') });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Step 1: Check if a channel already exists for this session
|
|
85
|
+
const existingSession = this.chatSessionRepo.findByDisplayName(workspaceName, selectedTitle);
|
|
86
|
+
if (existingSession) {
|
|
87
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
88
|
+
.setTitle((0, i18n_1.t)('🔗 Session Already Connected'))
|
|
89
|
+
.setDescription((0, i18n_1.t)(`This session already has a channel:\n→ <#${existingSession.channelId}>`))
|
|
90
|
+
.setColor(0x3498DB)
|
|
91
|
+
.setTimestamp();
|
|
92
|
+
await interaction.editReply({ embeds: [embed], components: [] });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Step 2: Connect to CDP
|
|
96
|
+
let cdp;
|
|
97
|
+
try {
|
|
98
|
+
cdp = await this.pool.getOrConnect(workspaceName);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`) });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Step 3: Activate the session in Antigravity
|
|
105
|
+
const activateResult = await this.chatSessionService.activateSessionByTitle(cdp, selectedTitle);
|
|
106
|
+
if (!activateResult.ok) {
|
|
107
|
+
await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to join session: ${activateResult.error}`) });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Step 4: Create a new Discord channel for this session
|
|
111
|
+
const categoryResult = await this.channelManager.ensureCategory(guild, workspaceName);
|
|
112
|
+
const categoryId = categoryResult.categoryId;
|
|
113
|
+
const sessionNumber = this.chatSessionRepo.getNextSessionNumber(categoryId);
|
|
114
|
+
const channelName = this.channelManager.sanitizeChannelName(`${sessionNumber}-${selectedTitle}`);
|
|
115
|
+
const channelResult = await this.channelManager.createSessionChannel(guild, categoryId, channelName);
|
|
116
|
+
const newChannelId = channelResult.channelId;
|
|
117
|
+
// Step 5: Register binding and session
|
|
118
|
+
this.bindingRepo.upsert({
|
|
119
|
+
channelId: newChannelId,
|
|
120
|
+
workspacePath: workspaceName,
|
|
121
|
+
guildId: guild.id,
|
|
122
|
+
});
|
|
123
|
+
this.chatSessionRepo.create({
|
|
124
|
+
channelId: newChannelId,
|
|
125
|
+
categoryId,
|
|
126
|
+
workspacePath: workspaceName,
|
|
127
|
+
sessionNumber,
|
|
128
|
+
guildId: guild.id,
|
|
129
|
+
});
|
|
130
|
+
this.chatSessionRepo.updateDisplayName(newChannelId, selectedTitle);
|
|
131
|
+
// Step 6: Start mirroring (routes dynamically to all bound session channels)
|
|
132
|
+
this.startMirroring(bridge, cdp, workspaceName);
|
|
133
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
134
|
+
.setTitle((0, i18n_1.t)('🔗 Joined Session'))
|
|
135
|
+
.setDescription((0, i18n_1.t)(`Connected to: **${selectedTitle}**\n→ <#${newChannelId}>\n\n` +
|
|
136
|
+
`📡 Mirroring is **ON** — PC messages will appear in the new channel.\n` +
|
|
137
|
+
`Use \`/mirror\` to toggle.`))
|
|
138
|
+
.setColor(0x2ECC71)
|
|
139
|
+
.setTimestamp();
|
|
140
|
+
await interaction.editReply({ embeds: [embed], components: [] });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* /mirror — Toggle mirroring ON/OFF for the current channel's workspace.
|
|
144
|
+
*/
|
|
145
|
+
async handleMirror(interaction, bridge) {
|
|
146
|
+
const binding = this.bindingRepo.findByChannelId(interaction.channelId);
|
|
147
|
+
const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
|
|
148
|
+
const workspaceName = binding?.workspacePath ?? session?.workspacePath;
|
|
149
|
+
const dirName = workspaceName ? this.pool.extractDirName(workspaceName) : null;
|
|
150
|
+
if (!dirName || !workspaceName) {
|
|
151
|
+
await interaction.editReply({
|
|
152
|
+
content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const detector = this.pool.getUserMessageDetector(dirName);
|
|
157
|
+
if (detector?.isActive()) {
|
|
158
|
+
// Turn OFF — stop user message detector and any active response monitor
|
|
159
|
+
detector.stop();
|
|
160
|
+
const responseMonitor = this.activeResponseMonitors.get(dirName);
|
|
161
|
+
if (responseMonitor?.isActive()) {
|
|
162
|
+
await responseMonitor.stop();
|
|
163
|
+
this.activeResponseMonitors.delete(dirName);
|
|
164
|
+
}
|
|
165
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
166
|
+
.setTitle((0, i18n_1.t)('📡 Mirroring OFF'))
|
|
167
|
+
.setDescription((0, i18n_1.t)('PC-to-Discord message mirroring has been stopped.'))
|
|
168
|
+
.setColor(0x95A5A6)
|
|
169
|
+
.setTimestamp();
|
|
170
|
+
await interaction.editReply({ embeds: [embed] });
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Turn ON
|
|
174
|
+
let cdp;
|
|
175
|
+
try {
|
|
176
|
+
cdp = await this.pool.getOrConnect(workspaceName);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
await interaction.editReply({
|
|
180
|
+
content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.startMirroring(bridge, cdp, workspaceName);
|
|
185
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
186
|
+
.setTitle((0, i18n_1.t)('📡 Mirroring ON'))
|
|
187
|
+
.setDescription((0, i18n_1.t)('PC-to-Discord message mirroring is now active.\n' +
|
|
188
|
+
'Messages typed in Antigravity will appear in the corresponding session channel.'))
|
|
189
|
+
.setColor(0x2ECC71)
|
|
190
|
+
.setTimestamp();
|
|
191
|
+
await interaction.editReply({ embeds: [embed] });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Start user message mirroring for a workspace.
|
|
196
|
+
*
|
|
197
|
+
* When a PC message is detected, the callback resolves the correct Discord
|
|
198
|
+
* channel via chatSessionRepo.findByDisplayName. Only explicitly joined
|
|
199
|
+
* sessions (with a displayName binding) receive mirrored messages.
|
|
200
|
+
*/
|
|
201
|
+
startMirroring(bridge, cdp, workspaceName) {
|
|
202
|
+
const dirName = this.pool.extractDirName(workspaceName);
|
|
203
|
+
(0, cdpBridgeManager_1.ensureUserMessageDetector)(bridge, cdp, dirName, (info) => {
|
|
204
|
+
this.routeMirroredMessage(cdp, dirName, workspaceName, info)
|
|
205
|
+
.catch((err) => {
|
|
206
|
+
logger_1.logger.error('[Mirror] Error routing mirrored message:', err);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Route a mirrored PC message to the correct Discord channel and
|
|
212
|
+
* start a passive ResponseMonitor to capture the AI response.
|
|
213
|
+
*
|
|
214
|
+
* Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
|
|
215
|
+
* Sessions without an explicit channel binding are silently skipped.
|
|
216
|
+
*/
|
|
217
|
+
async routeMirroredMessage(cdp, dirName, workspaceName, info) {
|
|
218
|
+
const chatTitle = await (0, cdpBridgeManager_1.getCurrentChatTitle)(cdp);
|
|
219
|
+
if (!chatTitle) {
|
|
220
|
+
logger_1.logger.debug('[Mirror] No chat title detected, skipping');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const session = this.chatSessionRepo.findByDisplayName(workspaceName, chatTitle);
|
|
224
|
+
if (!session) {
|
|
225
|
+
logger_1.logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const channel = this.client.channels.cache.get(session.channelId);
|
|
229
|
+
if (!channel || !('send' in channel))
|
|
230
|
+
return;
|
|
231
|
+
const sendable = channel;
|
|
232
|
+
// Mirror the user message
|
|
233
|
+
const userEmbed = new discord_js_1.EmbedBuilder()
|
|
234
|
+
.setDescription(`🖥️ ${info.text}`)
|
|
235
|
+
.setColor(0x95A5A6)
|
|
236
|
+
.setFooter({ text: `Typed in Antigravity · ${chatTitle}` })
|
|
237
|
+
.setTimestamp();
|
|
238
|
+
await sendable.send({ embeds: [userEmbed] }).catch((err) => {
|
|
239
|
+
logger_1.logger.error('[Mirror] Failed to send user message:', err);
|
|
240
|
+
});
|
|
241
|
+
// Start passive ResponseMonitor to capture the AI response
|
|
242
|
+
this.startResponseMirror(cdp, dirName, sendable, chatTitle);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Start a passive ResponseMonitor that sends the AI response to Discord
|
|
246
|
+
* when generation completes.
|
|
247
|
+
*/
|
|
248
|
+
startResponseMirror(cdp, dirName, channel, chatTitle) {
|
|
249
|
+
// Stop previous monitor if still running
|
|
250
|
+
const prev = this.activeResponseMonitors.get(dirName);
|
|
251
|
+
if (prev?.isActive()) {
|
|
252
|
+
prev.stop().catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
const monitor = new responseMonitor_1.ResponseMonitor({
|
|
255
|
+
cdpService: cdp,
|
|
256
|
+
pollIntervalMs: 2000,
|
|
257
|
+
maxDurationMs: 300000,
|
|
258
|
+
onComplete: (finalText) => {
|
|
259
|
+
this.activeResponseMonitors.delete(dirName);
|
|
260
|
+
if (!finalText || finalText.trim().length === 0)
|
|
261
|
+
return;
|
|
262
|
+
const text = finalText.length > MAX_EMBED_DESC
|
|
263
|
+
? finalText.slice(0, MAX_EMBED_DESC) + '\n…(truncated)'
|
|
264
|
+
: finalText;
|
|
265
|
+
const embed = new discord_js_1.EmbedBuilder()
|
|
266
|
+
.setDescription(text)
|
|
267
|
+
.setColor(0x5865F2)
|
|
268
|
+
.setFooter({ text: `Antigravity response · ${chatTitle}` })
|
|
269
|
+
.setTimestamp();
|
|
270
|
+
channel.send({ embeds: [embed] }).catch((err) => {
|
|
271
|
+
logger_1.logger.error('[Mirror] Failed to send AI response:', err);
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
onTimeout: () => {
|
|
275
|
+
this.activeResponseMonitors.delete(dirName);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
this.activeResponseMonitors.set(dirName, monitor);
|
|
279
|
+
monitor.startPassive().catch((err) => {
|
|
280
|
+
logger_1.logger.error('[Mirror] Failed to start response monitor:', err);
|
|
281
|
+
this.activeResponseMonitors.delete(dirName);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
exports.JoinDetachCommandHandler = JoinDetachCommandHandler;
|
|
@@ -102,6 +102,37 @@ const cleanupCommand = new discord_js_1.SlashCommandBuilder()
|
|
|
102
102
|
const helpCommand = new discord_js_1.SlashCommandBuilder()
|
|
103
103
|
.setName('help')
|
|
104
104
|
.setDescription((0, i18n_1.t)('Display list of available commands'));
|
|
105
|
+
/** /join command definition */
|
|
106
|
+
const joinCommand = new discord_js_1.SlashCommandBuilder()
|
|
107
|
+
.setName('join')
|
|
108
|
+
.setDescription((0, i18n_1.t)('Join an existing Antigravity session (shows up to 20 recent sessions)'));
|
|
109
|
+
/** /mirror command definition */
|
|
110
|
+
const mirrorCommand = new discord_js_1.SlashCommandBuilder()
|
|
111
|
+
.setName('mirror')
|
|
112
|
+
.setDescription((0, i18n_1.t)('Toggle PC-to-Discord message mirroring for the current session'));
|
|
113
|
+
/** /output command definition */
|
|
114
|
+
const outputCommand = new discord_js_1.SlashCommandBuilder()
|
|
115
|
+
.setName('output')
|
|
116
|
+
.setDescription((0, i18n_1.t)('Toggle output format between Embed and Plain Text'))
|
|
117
|
+
.addStringOption((option) => option
|
|
118
|
+
.setName('format')
|
|
119
|
+
.setDescription((0, i18n_1.t)('embed / plain (optional direct switch)'))
|
|
120
|
+
.setRequired(false));
|
|
121
|
+
/** /logs command definition */
|
|
122
|
+
const logsCommand = new discord_js_1.SlashCommandBuilder()
|
|
123
|
+
.setName('logs')
|
|
124
|
+
.setDescription((0, i18n_1.t)('View recent bot logs'))
|
|
125
|
+
.addIntegerOption((option) => option
|
|
126
|
+
.setName('lines')
|
|
127
|
+
.setDescription((0, i18n_1.t)('Number of recent log lines (default: 50)'))
|
|
128
|
+
.setRequired(false)
|
|
129
|
+
.setMinValue(1)
|
|
130
|
+
.setMaxValue(100))
|
|
131
|
+
.addStringOption((option) => option
|
|
132
|
+
.setName('level')
|
|
133
|
+
.setDescription((0, i18n_1.t)('Filter by log level'))
|
|
134
|
+
.setRequired(false)
|
|
135
|
+
.addChoices({ name: 'debug', value: 'debug' }, { name: 'info', value: 'info' }, { name: 'warn', value: 'warn' }, { name: 'error', value: 'error' }));
|
|
105
136
|
/** /ping command definition */
|
|
106
137
|
const pingCommand = new discord_js_1.SlashCommandBuilder()
|
|
107
138
|
.setName('ping')
|
|
@@ -120,7 +151,11 @@ exports.slashCommands = [
|
|
|
120
151
|
newCommand,
|
|
121
152
|
chatCommand,
|
|
122
153
|
cleanupCommand,
|
|
154
|
+
joinCommand,
|
|
155
|
+
mirrorCommand,
|
|
156
|
+
outputCommand,
|
|
123
157
|
pingCommand,
|
|
158
|
+
logsCommand,
|
|
124
159
|
];
|
|
125
160
|
/**
|
|
126
161
|
* Register slash commands with Discord
|
|
@@ -67,6 +67,16 @@ class ChatSessionRepository {
|
|
|
67
67
|
const result = this.db.prepare('UPDATE chat_sessions SET display_name = ?, is_renamed = 1 WHERE channel_id = ?').run(displayName, channelId);
|
|
68
68
|
return result.changes > 0;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Find a session by display name within a workspace.
|
|
72
|
+
* Returns the first match (most recent).
|
|
73
|
+
*/
|
|
74
|
+
findByDisplayName(workspacePath, displayName) {
|
|
75
|
+
const row = this.db.prepare('SELECT * FROM chat_sessions WHERE workspace_path = ? AND display_name = ? ORDER BY id DESC LIMIT 1').get(workspacePath, displayName);
|
|
76
|
+
if (!row)
|
|
77
|
+
return undefined;
|
|
78
|
+
return this.mapRow(row);
|
|
79
|
+
}
|
|
70
80
|
deleteByChannelId(channelId) {
|
|
71
81
|
const result = this.db.prepare('DELETE FROM chat_sessions WHERE channel_id = ?').run(channelId);
|
|
72
82
|
return result.changes > 0;
|