lazy-gravity 0.9.2 → 0.10.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/dist/bot/index.js +232 -41
- package/dist/bot/telegramMessageHandler.js +1 -0
- package/dist/commands/chatCommandHandler.js +71 -1
- package/dist/commands/registerSlashCommands.js +13 -1
- package/dist/events/interactionCreateHandler.js +324 -26
- package/dist/events/messageCreateHandler.js +53 -4
- package/dist/handlers/__tests__/fileChangeButtonAction.test.js +85 -0
- package/dist/handlers/approvalButtonAction.js +2 -1
- package/dist/handlers/errorPopupButtonAction.js +2 -1
- package/dist/handlers/fileChangeButtonAction.js +69 -0
- package/dist/handlers/genericActionButtonAction.js +74 -0
- package/dist/handlers/planningButtonAction.js +24 -9
- package/dist/handlers/questionSelectAction.js +69 -0
- package/dist/handlers/questionSkipAction.js +67 -0
- package/dist/handlers/runCommandButtonAction.js +2 -1
- package/dist/platform/discord/discordResponseRenderer.js +164 -0
- package/dist/services/approvalDetector.js +70 -24
- package/dist/services/artifactService.js +57 -31
- package/dist/services/assistantDomExtractor.js +174 -3
- package/dist/services/cdpBridgeManager.js +114 -6
- package/dist/services/cdpConnectionPool.js +15 -0
- package/dist/services/cdpService.js +21 -7
- package/dist/services/chatSessionService.js +133 -33
- package/dist/services/errorPopupDetector.js +23 -11
- package/dist/services/notificationSender.js +70 -3
- package/dist/services/planningDetector.js +69 -25
- package/dist/services/promptDispatcher.js +7 -1
- package/dist/services/questionDetector.js +376 -0
- package/dist/services/responseMonitor.js +28 -2
- package/dist/services/runCommandDetector.js +14 -7
- package/dist/ui/artifactsUi.js +4 -3
- package/dist/utils/consecutiveEmptyPollGate.js +21 -0
- package/dist/utils/fileOpenCache.js +33 -0
- package/dist/utils/htmlToDiscordMarkdown.js +5 -2
- package/dist/utils/pathUtils.js +12 -0
- package/dist/utils/projectResolver.js +10 -0
- package/dist/utils/questionActionUtils.js +25 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* They return a `MessagePayload` that any platform adapter can render.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.QUESTION_SKIP_ACTION_PREFIX = exports.QUESTION_SELECT_ACTION_PREFIX = exports.FEEDBACK_ACTION_PREFIX = void 0;
|
|
9
10
|
exports.buildApprovalNotification = buildApprovalNotification;
|
|
10
11
|
exports.buildPlanningNotification = buildPlanningNotification;
|
|
11
12
|
exports.buildErrorPopupNotification = buildErrorPopupNotification;
|
|
@@ -13,6 +14,7 @@ exports.buildRunCommandNotification = buildRunCommandNotification;
|
|
|
13
14
|
exports.buildAutoApprovedNotification = buildAutoApprovedNotification;
|
|
14
15
|
exports.buildResolvedOverlay = buildResolvedOverlay;
|
|
15
16
|
exports.buildStatusNotification = buildStatusNotification;
|
|
17
|
+
exports.buildQuestionNotification = buildQuestionNotification;
|
|
16
18
|
exports.buildProgressNotification = buildProgressNotification;
|
|
17
19
|
const richContentBuilder_1 = require("../platform/richContentBuilder");
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -28,6 +30,9 @@ const ERROR_POPUP_COPY_DEBUG_ACTION_PREFIX = 'error_popup_copy_debug_action';
|
|
|
28
30
|
const ERROR_POPUP_RETRY_ACTION_PREFIX = 'error_popup_retry_action';
|
|
29
31
|
const RUN_COMMAND_RUN_ACTION_PREFIX = 'run_command_run_action';
|
|
30
32
|
const RUN_COMMAND_REJECT_ACTION_PREFIX = 'run_command_reject_action';
|
|
33
|
+
exports.FEEDBACK_ACTION_PREFIX = 'feedback_action';
|
|
34
|
+
exports.QUESTION_SELECT_ACTION_PREFIX = 'question_select_action';
|
|
35
|
+
exports.QUESTION_SKIP_ACTION_PREFIX = 'question_skip_action';
|
|
31
36
|
// ---------------------------------------------------------------------------
|
|
32
37
|
// Notification colours
|
|
33
38
|
// ---------------------------------------------------------------------------
|
|
@@ -77,14 +82,21 @@ function customId(prefix, projectName, channelId) {
|
|
|
77
82
|
// ---------------------------------------------------------------------------
|
|
78
83
|
/** Build the approval notification message. */
|
|
79
84
|
function buildApprovalNotification(opts) {
|
|
80
|
-
const { title, description, projectName, channelId, toolNames, extraFields } = opts;
|
|
85
|
+
const { title, description, projectName, channelId, toolNames, extraFields, hasAlwaysAllow, alwaysAllowText } = opts;
|
|
81
86
|
const richContent = (0, richContentBuilder_1.pipe)((0, richContentBuilder_1.createRichContent)(), (rc) => (0, richContentBuilder_1.withTitle)(rc, title), (rc) => (0, richContentBuilder_1.withDescription)(rc, description), (rc) => (0, richContentBuilder_1.withColor)(rc, COLOR_APPROVAL), (rc) => (0, richContentBuilder_1.addField)(rc, 'Project', projectName, true), (rc) => toolNames && toolNames.length > 0
|
|
82
87
|
? (0, richContentBuilder_1.addField)(rc, 'Tools', toolNames.join(', '), true)
|
|
83
88
|
: rc, (rc) => extraFields
|
|
84
89
|
? extraFields.reduce((acc, f) => (0, richContentBuilder_1.addField)(acc, f.name, f.value, f.inline), rc)
|
|
85
90
|
: rc, (rc) => (0, richContentBuilder_1.withFooter)(rc, 'Approval required'), (rc) => (0, richContentBuilder_1.withTimestamp)(rc));
|
|
91
|
+
const buttons = [
|
|
92
|
+
button(customId(APPROVE_ACTION_PREFIX, projectName, channelId), 'Allow', 'success'),
|
|
93
|
+
];
|
|
94
|
+
if (hasAlwaysAllow) {
|
|
95
|
+
buttons.push(button(customId(ALWAYS_ALLOW_ACTION_PREFIX, projectName, channelId), alwaysAllowText || 'Allow Chat', 'primary'));
|
|
96
|
+
}
|
|
97
|
+
buttons.push(button(customId(DENY_ACTION_PREFIX, projectName, channelId), 'Deny', 'danger'));
|
|
86
98
|
const components = [
|
|
87
|
-
buttonRow(
|
|
99
|
+
buttonRow(...buttons),
|
|
88
100
|
];
|
|
89
101
|
return { richContent, components };
|
|
90
102
|
}
|
|
@@ -94,8 +106,15 @@ function buildPlanningNotification(opts) {
|
|
|
94
106
|
const richContent = (0, richContentBuilder_1.pipe)((0, richContentBuilder_1.createRichContent)(), (rc) => (0, richContentBuilder_1.withTitle)(rc, title), (rc) => (0, richContentBuilder_1.withDescription)(rc, description), (rc) => (0, richContentBuilder_1.withColor)(rc, COLOR_PLANNING), (rc) => extraFields
|
|
95
107
|
? extraFields.reduce((acc, f) => (0, richContentBuilder_1.addField)(acc, f.name, f.value, f.inline), rc)
|
|
96
108
|
: rc, (rc) => (0, richContentBuilder_1.withFooter)(rc, 'Planning mode detected'), (rc) => (0, richContentBuilder_1.withTimestamp)(rc));
|
|
109
|
+
const buttons = [];
|
|
110
|
+
const openLabel = opts.openText || 'Open';
|
|
111
|
+
if (opts.hasOpenButton !== false) {
|
|
112
|
+
buttons.push(button(customId(PLANNING_OPEN_ACTION_PREFIX, projectName, channelId), openLabel, 'primary'));
|
|
113
|
+
}
|
|
114
|
+
const buttonLabel = opts.proceedText || 'Proceed';
|
|
115
|
+
buttons.push(button(customId(PLANNING_PROCEED_ACTION_PREFIX, projectName, channelId), buttonLabel, 'success'));
|
|
97
116
|
const components = [
|
|
98
|
-
buttonRow(
|
|
117
|
+
buttonRow(...buttons),
|
|
99
118
|
];
|
|
100
119
|
return { richContent, components };
|
|
101
120
|
}
|
|
@@ -158,6 +177,54 @@ function buildStatusNotification(opts) {
|
|
|
158
177
|
: rc);
|
|
159
178
|
return { richContent };
|
|
160
179
|
}
|
|
180
|
+
function buildQuestionNotification(params) {
|
|
181
|
+
const { title, description, projectName, channelId, options } = params;
|
|
182
|
+
const baseContent = (0, richContentBuilder_1.pipe)((0, richContentBuilder_1.createRichContent)(), (rc) => (0, richContentBuilder_1.withTitle)(rc, title), (rc) => (0, richContentBuilder_1.withColor)(rc, COLOR_APPROVAL));
|
|
183
|
+
const embed = description ? (0, richContentBuilder_1.withDescription)(baseContent, description) : baseContent;
|
|
184
|
+
const selectMenuOptions = options.map((opt, i) => ({
|
|
185
|
+
label: opt.text.substring(0, 100), // Discord max length for label is 100
|
|
186
|
+
value: i.toString(),
|
|
187
|
+
})).slice(0, 25); // Discord max options in a select menu is 25
|
|
188
|
+
// Ensure customIds fit within 100 chars without losing channelId
|
|
189
|
+
const encodeSafe = (str, maxLen) => {
|
|
190
|
+
let encoded = encodeURIComponent(str);
|
|
191
|
+
if (encoded.length <= maxLen)
|
|
192
|
+
return encoded;
|
|
193
|
+
return encoded.substring(0, maxLen).replace(/(%[0-9A-F]?)$/i, '');
|
|
194
|
+
};
|
|
195
|
+
const encodedChannel = encodeURIComponent(channelId);
|
|
196
|
+
const selectOverhead = exports.QUESTION_SELECT_ACTION_PREFIX.length + 2 + encodedChannel.length;
|
|
197
|
+
const safeSelectProjectName = encodeSafe(projectName, 100 - selectOverhead);
|
|
198
|
+
const encodedCustomId = `${exports.QUESTION_SELECT_ACTION_PREFIX}:${safeSelectProjectName}:${encodedChannel}`;
|
|
199
|
+
const skipOverhead = exports.QUESTION_SKIP_ACTION_PREFIX.length + 2 + encodedChannel.length;
|
|
200
|
+
const safeSkipProjectName = encodeSafe(projectName, 100 - skipOverhead);
|
|
201
|
+
const encodedSkipCustomId = `${exports.QUESTION_SKIP_ACTION_PREFIX}:${safeSkipProjectName}:${encodedChannel}`;
|
|
202
|
+
return {
|
|
203
|
+
richContent: embed,
|
|
204
|
+
components: [
|
|
205
|
+
{
|
|
206
|
+
components: [
|
|
207
|
+
{
|
|
208
|
+
type: 'selectMenu',
|
|
209
|
+
customId: encodedCustomId,
|
|
210
|
+
placeholder: 'Select an option...',
|
|
211
|
+
options: selectMenuOptions,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
components: [
|
|
217
|
+
{
|
|
218
|
+
type: 'button',
|
|
219
|
+
customId: encodedSkipCustomId,
|
|
220
|
+
label: 'Skip',
|
|
221
|
+
style: 'secondary',
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
161
228
|
/** Build a progress / phase notification (e.g. "Thinking...", "Generating..."). */
|
|
162
229
|
function buildProgressNotification(opts) {
|
|
163
230
|
const { phase, projectName, detail } = opts;
|
|
@@ -10,33 +10,59 @@ const approvalDetector_1 = require("./approvalDetector");
|
|
|
10
10
|
* and extracts plan metadata from the surrounding DOM elements.
|
|
11
11
|
*/
|
|
12
12
|
const DETECT_PLANNING_SCRIPT = `(() => {
|
|
13
|
-
const OPEN_PATTERNS = ['open'];
|
|
13
|
+
const OPEN_PATTERNS = ['open', 'review'];
|
|
14
14
|
const PROCEED_PATTERNS = ['proceed'];
|
|
15
|
+
const REJECT_PATTERNS = ['reject'];
|
|
16
|
+
const ACCEPT_PATTERNS = ['accept'];
|
|
15
17
|
|
|
16
18
|
const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const allButtons = Array.from(document.querySelectorAll('button, a'))
|
|
21
|
+
.filter(btn => btn.offsetParent !== null)
|
|
22
|
+
.reverse();
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
let proceedBtn = null;
|
|
25
|
+
let openBtn = null;
|
|
26
|
+
let rejectBtn = null;
|
|
24
27
|
|
|
25
|
-
const
|
|
28
|
+
for (const btn of allButtons) {
|
|
26
29
|
const t = normalize(btn.textContent || '');
|
|
27
|
-
|
|
28
|
-
}) || null;
|
|
30
|
+
if (t.includes('review changes')) continue; // Ignore file change review button
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// If we hit an accept or reject button before finding a proceed button,
|
|
33
|
+
// it means the most recent state is an Approval, not Planning.
|
|
34
|
+
if (!proceedBtn && (ACCEPT_PATTERNS.some(p => t === p || t.includes(p)) || REJECT_PATTERNS.some(p => t === p || t.includes(p)))) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!proceedBtn && PROCEED_PATTERNS.some(p => t === p || t.includes(p))) {
|
|
39
|
+
proceedBtn = btn;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!openBtn && OPEN_PATTERNS.some(p => t === p || t.includes(p))) {
|
|
44
|
+
openBtn = btn;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!rejectBtn && REJECT_PATTERNS.some(p => t === p || t.includes(p))) {
|
|
48
|
+
rejectBtn = btn;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (proceedBtn && openBtn) break;
|
|
52
|
+
}
|
|
34
53
|
|
|
35
|
-
//
|
|
36
|
-
if (!
|
|
54
|
+
// Proceed button must exist for this to be a planning UI
|
|
55
|
+
if (!proceedBtn) return null;
|
|
56
|
+
|
|
57
|
+
const container = proceedBtn.closest('.notify-user-container') || proceedBtn.closest('div[class*="rounded-lg"]') || proceedBtn.parentElement?.parentElement;
|
|
58
|
+
if (!container) return null;
|
|
37
59
|
|
|
38
|
-
|
|
60
|
+
// openBtn was already extracted in the loop above
|
|
61
|
+
|
|
62
|
+
const hasOpenButton = openBtn !== null;
|
|
63
|
+
const openText = openBtn ? (openBtn.textContent || '').trim() : 'Open';
|
|
39
64
|
const proceedText = (proceedBtn.textContent || '').trim();
|
|
65
|
+
const rejectText = rejectBtn ? (rejectBtn.textContent || '').trim() : '';
|
|
40
66
|
|
|
41
67
|
// Extract plan title from .inline-flex.break-all
|
|
42
68
|
const titleEl = container.querySelector('span.inline-flex.break-all, .inline-flex.break-all');
|
|
@@ -67,7 +93,7 @@ const DETECT_PLANNING_SCRIPT = `(() => {
|
|
|
67
93
|
description = parts.join(' ').slice(0, 500);
|
|
68
94
|
}
|
|
69
95
|
|
|
70
|
-
return { openText, proceedText, planTitle, planSummary, description };
|
|
96
|
+
return { openText, proceedText, planTitle, planSummary, description, rejectText, hasOpenButton };
|
|
71
97
|
})()`;
|
|
72
98
|
/**
|
|
73
99
|
* Extract plan content displayed after clicking Open.
|
|
@@ -158,8 +184,12 @@ class PlanningDetector {
|
|
|
158
184
|
lastDetectedInfo = null;
|
|
159
185
|
/** Timestamp of last notification (for cooldown-based dedup) */
|
|
160
186
|
lastNotifiedAt = 0;
|
|
187
|
+
/** Number of consecutive polls without detecting buttons */
|
|
188
|
+
emptyPollCount = 0;
|
|
161
189
|
/** Cooldown period in ms to suppress duplicate notifications */
|
|
162
190
|
static COOLDOWN_MS = 5000;
|
|
191
|
+
/** Number of consecutive empty polls required before resetting state */
|
|
192
|
+
static REQUIRED_EMPTY_POLLS = 3;
|
|
163
193
|
constructor(options) {
|
|
164
194
|
this.cdpService = options.cdpService;
|
|
165
195
|
this.pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
@@ -174,6 +204,7 @@ class PlanningDetector {
|
|
|
174
204
|
this.lastDetectedKey = null;
|
|
175
205
|
this.lastDetectedInfo = null;
|
|
176
206
|
this.lastNotifiedAt = 0;
|
|
207
|
+
this.emptyPollCount = 0;
|
|
177
208
|
this.schedulePoll();
|
|
178
209
|
}
|
|
179
210
|
/** Stop monitoring. */
|
|
@@ -210,6 +241,15 @@ class PlanningDetector {
|
|
|
210
241
|
const text = buttonText ?? this.lastDetectedInfo?.proceedText ?? 'Proceed';
|
|
211
242
|
return this.clickButton(text);
|
|
212
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Click the Reject button via CDP.
|
|
246
|
+
* @param buttonText Text of the button to click (default: detected rejectText or "Reject All")
|
|
247
|
+
* @returns true if click succeeded
|
|
248
|
+
*/
|
|
249
|
+
async clickRejectButton(buttonText) {
|
|
250
|
+
const text = buttonText ?? this.lastDetectedInfo?.rejectText ?? 'Reject All';
|
|
251
|
+
return this.clickButton(text);
|
|
252
|
+
}
|
|
213
253
|
/**
|
|
214
254
|
* Extract plan content from the DOM after Open has been clicked.
|
|
215
255
|
* @returns Plan content text or null if not found
|
|
@@ -255,8 +295,9 @@ class PlanningDetector {
|
|
|
255
295
|
const result = await this.cdpService.call('Runtime.evaluate', callParams);
|
|
256
296
|
const info = result?.result?.value ?? null;
|
|
257
297
|
if (info) {
|
|
258
|
-
|
|
259
|
-
|
|
298
|
+
this.emptyPollCount = 0;
|
|
299
|
+
// Duplicate prevention: use plan title and content as key (stable across DOM redraws and button text updates)
|
|
300
|
+
const key = `${info.planTitle}::${info.planSummary}::${(info.description || '').substring(0, 50)}`;
|
|
260
301
|
const now = Date.now();
|
|
261
302
|
const withinCooldown = (now - this.lastNotifiedAt) < PlanningDetector.COOLDOWN_MS;
|
|
262
303
|
if (key !== this.lastDetectedKey && !withinCooldown) {
|
|
@@ -271,12 +312,15 @@ class PlanningDetector {
|
|
|
271
312
|
}
|
|
272
313
|
}
|
|
273
314
|
else {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
this.
|
|
315
|
+
this.emptyPollCount++;
|
|
316
|
+
if (this.emptyPollCount >= PlanningDetector.REQUIRED_EMPTY_POLLS) {
|
|
317
|
+
// Reset when buttons disappear for consecutive polls
|
|
318
|
+
const wasDetected = this.lastDetectedKey !== null;
|
|
319
|
+
this.lastDetectedKey = null;
|
|
320
|
+
this.lastDetectedInfo = null;
|
|
321
|
+
if (wasDetected && this.onResolved) {
|
|
322
|
+
this.onResolved();
|
|
323
|
+
}
|
|
280
324
|
}
|
|
281
325
|
}
|
|
282
326
|
}
|
|
@@ -11,7 +11,13 @@ class PromptDispatcher {
|
|
|
11
11
|
this.deps = deps;
|
|
12
12
|
}
|
|
13
13
|
async send(req) {
|
|
14
|
-
await this.
|
|
14
|
+
await this._dispatch(req, req.options);
|
|
15
|
+
}
|
|
16
|
+
async resume(req) {
|
|
17
|
+
await this._dispatch(req, { ...req.options, resumeOnly: true });
|
|
18
|
+
}
|
|
19
|
+
async _dispatch(req, options) {
|
|
20
|
+
await this.deps.sendPromptImpl(this.deps.bridge, req.message, req.prompt, req.cdp, this.deps.modeService, this.deps.modelService, req.inboundImages ?? [], options);
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
23
|
exports.PromptDispatcher = PromptDispatcher;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.QuestionDetector = void 0;
|
|
4
|
+
const events_1 = require("events");
|
|
5
|
+
const logger_1 = require("../utils/logger");
|
|
6
|
+
class QuestionDetector extends events_1.EventEmitter {
|
|
7
|
+
cdp;
|
|
8
|
+
pollIntervalMs;
|
|
9
|
+
intervalId = null;
|
|
10
|
+
logger = logger_1.logger;
|
|
11
|
+
lastQuestionDetected = false;
|
|
12
|
+
emptyPollCount = 0;
|
|
13
|
+
static REQUIRED_EMPTY_POLLS = 3;
|
|
14
|
+
_isStarted = false;
|
|
15
|
+
onQuestionRequired;
|
|
16
|
+
onResolved;
|
|
17
|
+
projectName = 'unknown';
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super();
|
|
20
|
+
this.cdp = options.cdpService;
|
|
21
|
+
this.pollIntervalMs = options.pollIntervalMs || 2000;
|
|
22
|
+
this.onQuestionRequired = options.onQuestionRequired;
|
|
23
|
+
this.onResolved = options.onResolved;
|
|
24
|
+
// Listen to own events to call options callbacks (for compat)
|
|
25
|
+
this.on('question', (info) => this.onQuestionRequired(info));
|
|
26
|
+
this.on('resolved', () => {
|
|
27
|
+
if (this.onResolved)
|
|
28
|
+
this.onResolved();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
setProjectName(name) {
|
|
32
|
+
this.projectName = name;
|
|
33
|
+
}
|
|
34
|
+
get isActive() {
|
|
35
|
+
return this._isStarted;
|
|
36
|
+
}
|
|
37
|
+
start() {
|
|
38
|
+
if (this._isStarted)
|
|
39
|
+
return;
|
|
40
|
+
this._isStarted = true;
|
|
41
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Starting polling`);
|
|
42
|
+
this.lastQuestionDetected = false;
|
|
43
|
+
this.emptyPollCount = 0;
|
|
44
|
+
this.intervalId = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
45
|
+
this.poll();
|
|
46
|
+
}
|
|
47
|
+
stop() {
|
|
48
|
+
if (!this._isStarted)
|
|
49
|
+
return;
|
|
50
|
+
this._isStarted = false;
|
|
51
|
+
if (this.intervalId) {
|
|
52
|
+
clearInterval(this.intervalId);
|
|
53
|
+
this.intervalId = null;
|
|
54
|
+
}
|
|
55
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Stopped polling`);
|
|
56
|
+
}
|
|
57
|
+
async submitOption(index) {
|
|
58
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Submitting option ${index}`);
|
|
59
|
+
try {
|
|
60
|
+
const contextId = this.cdp.getPrimaryContextId();
|
|
61
|
+
const callParams = {
|
|
62
|
+
expression: `
|
|
63
|
+
(() => {
|
|
64
|
+
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
65
|
+
let targetList = null;
|
|
66
|
+
let submitBtn = null;
|
|
67
|
+
|
|
68
|
+
for (const container of containers) {
|
|
69
|
+
const buttons = Array.from(container.querySelectorAll('button'));
|
|
70
|
+
let possibleSubmitBtn = null;
|
|
71
|
+
for (const btn of buttons) {
|
|
72
|
+
const text = btn.textContent?.toLowerCase() || '';
|
|
73
|
+
if (text.includes('submit')) {
|
|
74
|
+
possibleSubmitBtn = btn;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (possibleSubmitBtn) {
|
|
80
|
+
const items = Array.from(container.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
81
|
+
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
82
|
+
|
|
83
|
+
if (items.length > 1) {
|
|
84
|
+
targetList = container;
|
|
85
|
+
submitBtn = possibleSubmitBtn;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!targetList || !submitBtn) return { found: false };
|
|
92
|
+
|
|
93
|
+
const items = Array.from(targetList.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
94
|
+
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
95
|
+
if (items.length <= ${index}) return { found: false };
|
|
96
|
+
|
|
97
|
+
const targetOption = items[${index}];
|
|
98
|
+
|
|
99
|
+
const optionRect = targetOption.getBoundingClientRect();
|
|
100
|
+
const btnRect = submitBtn.getBoundingClientRect();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
found: true,
|
|
104
|
+
option: {
|
|
105
|
+
x: Math.round(optionRect.left + optionRect.width / 2),
|
|
106
|
+
y: Math.round(optionRect.top + optionRect.height / 2)
|
|
107
|
+
},
|
|
108
|
+
button: {
|
|
109
|
+
x: Math.round(btnRect.left + btnRect.width / 2),
|
|
110
|
+
y: Math.round(btnRect.top + btnRect.height / 2)
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
})()
|
|
114
|
+
`,
|
|
115
|
+
returnByValue: true,
|
|
116
|
+
awaitPromise: false,
|
|
117
|
+
};
|
|
118
|
+
if (contextId !== null) {
|
|
119
|
+
callParams.contextId = contextId;
|
|
120
|
+
}
|
|
121
|
+
const response = await this.cdp.call('Runtime.evaluate', callParams);
|
|
122
|
+
const result = response?.result?.value;
|
|
123
|
+
if (!result || !result.found) {
|
|
124
|
+
this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find question modal elements during submission.`);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// Click the option
|
|
128
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
129
|
+
type: 'mousePressed',
|
|
130
|
+
x: result.option.x,
|
|
131
|
+
y: result.option.y,
|
|
132
|
+
button: 'left',
|
|
133
|
+
clickCount: 1,
|
|
134
|
+
});
|
|
135
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
136
|
+
type: 'mouseReleased',
|
|
137
|
+
x: result.option.x,
|
|
138
|
+
y: result.option.y,
|
|
139
|
+
button: 'left',
|
|
140
|
+
clickCount: 1,
|
|
141
|
+
});
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
143
|
+
// Click the submit button
|
|
144
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
145
|
+
type: 'mousePressed',
|
|
146
|
+
x: result.button.x,
|
|
147
|
+
y: result.button.y,
|
|
148
|
+
button: 'left',
|
|
149
|
+
clickCount: 1,
|
|
150
|
+
});
|
|
151
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
152
|
+
type: 'mouseReleased',
|
|
153
|
+
x: result.button.x,
|
|
154
|
+
y: result.button.y,
|
|
155
|
+
button: 'left',
|
|
156
|
+
clickCount: 1,
|
|
157
|
+
});
|
|
158
|
+
this.lastQuestionDetected = false;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
this.logger.error(`[QuestionDetector:${this.projectName}] submitOption error:`, e.message);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async skipQuestion() {
|
|
167
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Skipping question`);
|
|
168
|
+
try {
|
|
169
|
+
const contextId = this.cdp.getPrimaryContextId();
|
|
170
|
+
const callParams = {
|
|
171
|
+
expression: `
|
|
172
|
+
(() => {
|
|
173
|
+
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
174
|
+
let skipBtn = null;
|
|
175
|
+
|
|
176
|
+
for (const container of containers) {
|
|
177
|
+
const items = Array.from(container.querySelectorAll('li, [role="radio"], [role="option"], .cursor-pointer'))
|
|
178
|
+
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
179
|
+
const hasList = items.length > 1;
|
|
180
|
+
|
|
181
|
+
const buttons = Array.from(container.querySelectorAll('button'));
|
|
182
|
+
for (const btn of buttons) {
|
|
183
|
+
const text = btn.textContent?.toLowerCase() || '';
|
|
184
|
+
if (text.includes('skip') && hasList) {
|
|
185
|
+
skipBtn = btn;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (skipBtn) break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!skipBtn) return { found: false };
|
|
193
|
+
|
|
194
|
+
const btnRect = skipBtn.getBoundingClientRect();
|
|
195
|
+
return {
|
|
196
|
+
found: true,
|
|
197
|
+
button: {
|
|
198
|
+
x: Math.round(btnRect.left + btnRect.width / 2),
|
|
199
|
+
y: Math.round(btnRect.top + btnRect.height / 2)
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
})()
|
|
203
|
+
`,
|
|
204
|
+
returnByValue: true,
|
|
205
|
+
awaitPromise: false,
|
|
206
|
+
};
|
|
207
|
+
if (contextId !== null)
|
|
208
|
+
callParams.contextId = contextId;
|
|
209
|
+
const response = await this.cdp.call('Runtime.evaluate', callParams);
|
|
210
|
+
const result = response?.result?.value;
|
|
211
|
+
if (!result || !result.found) {
|
|
212
|
+
this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find skip button.`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
216
|
+
type: 'mousePressed',
|
|
217
|
+
x: result.button.x,
|
|
218
|
+
y: result.button.y,
|
|
219
|
+
button: 'left',
|
|
220
|
+
clickCount: 1,
|
|
221
|
+
});
|
|
222
|
+
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
223
|
+
type: 'mouseReleased',
|
|
224
|
+
x: result.button.x,
|
|
225
|
+
y: result.button.y,
|
|
226
|
+
button: 'left',
|
|
227
|
+
clickCount: 1,
|
|
228
|
+
});
|
|
229
|
+
this.lastQuestionDetected = false;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
this.logger.error(`[QuestionDetector:${this.projectName}] skipQuestion error:`, e.message);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async poll() {
|
|
238
|
+
if (!this.cdp.isConnected())
|
|
239
|
+
return;
|
|
240
|
+
try {
|
|
241
|
+
const callParams = {
|
|
242
|
+
expression: `
|
|
243
|
+
(() => {
|
|
244
|
+
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
245
|
+
let targetList = null;
|
|
246
|
+
let submitBtn = null;
|
|
247
|
+
|
|
248
|
+
for (const container of containers) {
|
|
249
|
+
const buttons = Array.from(container.querySelectorAll('button'));
|
|
250
|
+
let possibleSubmitBtn = null;
|
|
251
|
+
for (const btn of buttons) {
|
|
252
|
+
const text = btn.textContent?.toLowerCase() || '';
|
|
253
|
+
if (text.includes('submit')) {
|
|
254
|
+
possibleSubmitBtn = btn;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (possibleSubmitBtn) {
|
|
260
|
+
const items = Array.from(container.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
261
|
+
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
262
|
+
|
|
263
|
+
if (items.length > 1) {
|
|
264
|
+
targetList = container;
|
|
265
|
+
submitBtn = possibleSubmitBtn;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!targetList || !submitBtn) return { detected: false, reason: "No targetList or submitBtn found" };
|
|
271
|
+
|
|
272
|
+
let titleEl = targetList.querySelector('h1, h2, h3, [role="heading"], .text-lg');
|
|
273
|
+
if (!titleEl) {
|
|
274
|
+
let p = targetList;
|
|
275
|
+
while (p && p.tagName !== 'BODY') {
|
|
276
|
+
titleEl = p.querySelector('h1, h2, h3, [role="heading"], .text-lg, p');
|
|
277
|
+
if (titleEl && titleEl !== p && (titleEl.innerText || titleEl.textContent || '').trim().length > 0) break;
|
|
278
|
+
p = p.parentElement;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const title = titleEl ? (titleEl.innerText || titleEl.textContent || '').trim() : 'Question';
|
|
282
|
+
|
|
283
|
+
const items = Array.from(targetList.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
284
|
+
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
285
|
+
const options = items.map(n => {
|
|
286
|
+
const rect = n.getBoundingClientRect();
|
|
287
|
+
const finalLabel = n.innerText || n.textContent || 'Option';
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
text: finalLabel.replace(/\\n/g, ' ').replace(/\\s+/g, ' ').trim().substring(0, 100),
|
|
291
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
292
|
+
y: Math.round(rect.top + rect.height / 2)
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (options.length === 0) return { detected: false, reason: "options length 0" };
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
detected: true,
|
|
300
|
+
title,
|
|
301
|
+
options
|
|
302
|
+
};
|
|
303
|
+
})()
|
|
304
|
+
`,
|
|
305
|
+
returnByValue: true,
|
|
306
|
+
awaitPromise: false,
|
|
307
|
+
};
|
|
308
|
+
const contexts = this.cdp.getContexts();
|
|
309
|
+
const contextIds = [
|
|
310
|
+
this.cdp.getPrimaryContextId(),
|
|
311
|
+
...contexts.map((ctx) => ctx.id),
|
|
312
|
+
].filter((value, index, arr) => typeof value === 'number' && arr.indexOf(value) === index);
|
|
313
|
+
const targets = contextIds.length > 0 ? contextIds : [null];
|
|
314
|
+
let detectedResult = null;
|
|
315
|
+
let lastReason = null;
|
|
316
|
+
for (const contextId of targets) {
|
|
317
|
+
if (contextId !== null) {
|
|
318
|
+
callParams.contextId = contextId;
|
|
319
|
+
}
|
|
320
|
+
const response = await this.cdp.call('Runtime.evaluate', callParams).catch(e => {
|
|
321
|
+
this.logger.debug(`[QuestionDetector] Error evaluating on ctx ${contextId}: ${e.message}`);
|
|
322
|
+
return null;
|
|
323
|
+
});
|
|
324
|
+
if (response?.exceptionDetails) {
|
|
325
|
+
this.logger.debug(`[QuestionDetector] Exception on ctx ${contextId}: ${response.exceptionDetails.exception?.description || response.exceptionDetails.text}`);
|
|
326
|
+
}
|
|
327
|
+
const result = response?.result?.value;
|
|
328
|
+
if (result) {
|
|
329
|
+
this.logger.debug(`[QuestionDetector] Context ${contextId} evaluated successfully, detected: ${result.detected}, reason: ${result.reason}`);
|
|
330
|
+
}
|
|
331
|
+
if (result && result.detected) {
|
|
332
|
+
detectedResult = result;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
else if (result && result.reason) {
|
|
336
|
+
lastReason = result.reason;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const result = detectedResult;
|
|
340
|
+
if (result && result.detected) {
|
|
341
|
+
this.emptyPollCount = 0;
|
|
342
|
+
if (!this.lastQuestionDetected) {
|
|
343
|
+
this.lastQuestionDetected = true;
|
|
344
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Question modal detected`);
|
|
345
|
+
this.emit('question', {
|
|
346
|
+
title: result.title || 'Question',
|
|
347
|
+
description: 'Please answer the question below.',
|
|
348
|
+
options: result.options,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
if (!result && lastReason) {
|
|
354
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Evaluate returned false: ${lastReason}`);
|
|
355
|
+
}
|
|
356
|
+
this.emptyPollCount++;
|
|
357
|
+
if (this.emptyPollCount >= QuestionDetector.REQUIRED_EMPTY_POLLS) {
|
|
358
|
+
if (this.lastQuestionDetected) {
|
|
359
|
+
this.logger.debug(`[QuestionDetector:${this.projectName}] Question modal disappeared`);
|
|
360
|
+
this.lastQuestionDetected = false;
|
|
361
|
+
this.emit('resolved');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
if (e.message?.includes('Target closed') || e.message?.includes('Session closed')) {
|
|
368
|
+
// Ignore disconnect errors
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
this.logger.error(`[QuestionDetector:${this.projectName}] Error:`, e.message);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
exports.QuestionDetector = QuestionDetector;
|