@grinev/opencode-telegram-bot 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bot/commands/commands.js +1 -1
- package/dist/bot/handlers/prompt.js +1 -1
- package/dist/bot/index.js +4 -0
- package/dist/bot/utils/busy-reconciliation.js +113 -0
- package/dist/opencode/events.js +95 -5
- package/dist/scheduled-task/foreground-state.js +22 -12
- package/dist/summary/aggregator.js +4 -0
- package/package.json +1 -1
|
@@ -292,7 +292,7 @@ async function executeCommand(ctx, deps, params) {
|
|
|
292
292
|
const model = storedModel.providerID && storedModel.modelID
|
|
293
293
|
? `${storedModel.providerID}/${storedModel.modelID}`
|
|
294
294
|
: undefined;
|
|
295
|
-
foregroundSessionState.markBusy(session.id);
|
|
295
|
+
foregroundSessionState.markBusy(session.id, session.directory);
|
|
296
296
|
await markAttachedSessionBusy(session.id);
|
|
297
297
|
assistantRunState.startRun(session.id, {
|
|
298
298
|
startedAt: Date.now(),
|
|
@@ -198,7 +198,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
198
198
|
fileCount: fileParts.length,
|
|
199
199
|
};
|
|
200
200
|
logger.info(`[Bot] Calling session.promptAsync (start-only) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
201
|
-
foregroundSessionState.markBusy(currentSession.id);
|
|
201
|
+
foregroundSessionState.markBusy(currentSession.id, currentSession.directory);
|
|
202
202
|
await markAttachedSessionBusy(currentSession.id);
|
|
203
203
|
assistantRunState.startRun(currentSession.id, {
|
|
204
204
|
startedAt: Date.now(),
|
package/dist/bot/index.js
CHANGED
|
@@ -59,6 +59,7 @@ import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js
|
|
|
59
59
|
import { handleVoiceMessage } from "./handlers/voice.js";
|
|
60
60
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
61
61
|
import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
|
|
62
|
+
import { reconcileBusyState } from "./utils/busy-reconciliation.js";
|
|
62
63
|
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
|
|
63
64
|
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
|
|
64
65
|
import { deliverThinkingMessage } from "./utils/thinking-message.js";
|
|
@@ -800,6 +801,9 @@ async function ensureEventSubscription(directory) {
|
|
|
800
801
|
});
|
|
801
802
|
logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
|
|
802
803
|
subscribeToEvents(directory, (event) => {
|
|
804
|
+
if (event.type === "server.heartbeat") {
|
|
805
|
+
void reconcileBusyState(directory);
|
|
806
|
+
}
|
|
803
807
|
const attached = attachManager.getSnapshot();
|
|
804
808
|
const eventSessionId = getEventSessionId(event);
|
|
805
809
|
if (attached &&
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
2
|
+
import { foregroundSessionState, } from "../../scheduled-task/foreground-state.js";
|
|
3
|
+
import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
|
|
4
|
+
import { attachManager } from "../../attach/manager.js";
|
|
5
|
+
import { markAttachedSessionBusy, markAttachedSessionIdle } from "../../attach/service.js";
|
|
6
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
7
|
+
import { clearPromptResponseMode } from "../handlers/prompt.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
const RECONCILE_MIN_INTERVAL_MS = 10_000;
|
|
10
|
+
const FOREGROUND_BUSY_RECONCILE_GRACE_MS = 2_000;
|
|
11
|
+
const inFlightDirectories = new Set();
|
|
12
|
+
const lastReconcileAtByDirectory = new Map();
|
|
13
|
+
function getReconciliationTargets(directory) {
|
|
14
|
+
const foregroundBusySessions = foregroundSessionState
|
|
15
|
+
.getBusySessions()
|
|
16
|
+
.filter((session) => session.directory === directory);
|
|
17
|
+
const attachedSession = attachManager.getSnapshot();
|
|
18
|
+
const attachedSessionForDirectory = attachedSession?.directory === directory ? attachedSession : null;
|
|
19
|
+
return { foregroundBusySessions, attachedSessionForDirectory };
|
|
20
|
+
}
|
|
21
|
+
function getSessionStatus(statuses, sessionId) {
|
|
22
|
+
if (!statuses || typeof statuses !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const status = statuses[sessionId];
|
|
26
|
+
return status ?? null;
|
|
27
|
+
}
|
|
28
|
+
function isTerminalStatus(status) {
|
|
29
|
+
return !status || status.type === "idle" || status.type === "error";
|
|
30
|
+
}
|
|
31
|
+
function isWithinForegroundBusyGracePeriod(session, now) {
|
|
32
|
+
return now - session.markedAt < FOREGROUND_BUSY_RECONCILE_GRACE_MS;
|
|
33
|
+
}
|
|
34
|
+
async function clearForegroundBusySession(sessionId, reason) {
|
|
35
|
+
foregroundSessionState.markIdle(sessionId);
|
|
36
|
+
assistantRunState.clearRun(sessionId, reason);
|
|
37
|
+
clearPromptResponseMode(sessionId);
|
|
38
|
+
}
|
|
39
|
+
export async function reconcileBusyStateNow(directory, now = Date.now()) {
|
|
40
|
+
if (!directory) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const { foregroundBusySessions, attachedSessionForDirectory } = getReconciliationTargets(directory);
|
|
44
|
+
if (foregroundBusySessions.length === 0 && !attachedSessionForDirectory) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const { data: statuses, error } = await opencodeClient.session.status({ directory });
|
|
48
|
+
if (error || !statuses) {
|
|
49
|
+
logger.warn("[BusyReconciliation] Failed to load session status", error);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const freshForegroundSessionIds = new Set(foregroundBusySessions
|
|
53
|
+
.filter((session) => isWithinForegroundBusyGracePeriod(session, now))
|
|
54
|
+
.map((session) => session.sessionId));
|
|
55
|
+
if (attachedSessionForDirectory) {
|
|
56
|
+
const attachedStatus = getSessionStatus(statuses, attachedSessionForDirectory.sessionId);
|
|
57
|
+
if (attachedStatus?.type === "busy") {
|
|
58
|
+
await markAttachedSessionBusy(attachedSessionForDirectory.sessionId);
|
|
59
|
+
}
|
|
60
|
+
else if (isTerminalStatus(attachedStatus) &&
|
|
61
|
+
!freshForegroundSessionIds.has(attachedSessionForDirectory.sessionId)) {
|
|
62
|
+
await markAttachedSessionIdle(attachedSessionForDirectory.sessionId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let clearedForegroundSession = false;
|
|
66
|
+
for (const session of foregroundBusySessions) {
|
|
67
|
+
const status = getSessionStatus(statuses, session.sessionId);
|
|
68
|
+
if (!isTerminalStatus(status)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (freshForegroundSessionIds.has(session.sessionId)) {
|
|
72
|
+
logger.debug(`[BusyReconciliation] Skipping fresh foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
logger.info(`[BusyReconciliation] Clearing stale foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
|
|
76
|
+
if (attachedSessionForDirectory?.sessionId !== session.sessionId) {
|
|
77
|
+
await markAttachedSessionIdle(session.sessionId);
|
|
78
|
+
}
|
|
79
|
+
await clearForegroundBusySession(session.sessionId, "status_reconcile_idle");
|
|
80
|
+
clearedForegroundSession = true;
|
|
81
|
+
}
|
|
82
|
+
if (clearedForegroundSession) {
|
|
83
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function reconcileBusyState(directory, now = Date.now()) {
|
|
87
|
+
if (!directory || inFlightDirectories.has(directory)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { foregroundBusySessions, attachedSessionForDirectory } = getReconciliationTargets(directory);
|
|
91
|
+
if (foregroundBusySessions.length === 0 && !attachedSessionForDirectory) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const lastReconcileAt = lastReconcileAtByDirectory.get(directory);
|
|
95
|
+
if (lastReconcileAt !== undefined && now - lastReconcileAt < RECONCILE_MIN_INTERVAL_MS) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
lastReconcileAtByDirectory.set(directory, now);
|
|
99
|
+
inFlightDirectories.add(directory);
|
|
100
|
+
try {
|
|
101
|
+
await reconcileBusyStateNow(directory, now);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
logger.warn("[BusyReconciliation] Failed to reconcile busy state", error);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
inFlightDirectories.delete(directory);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function __resetBusyReconciliationForTests() {
|
|
111
|
+
inFlightDirectories.clear();
|
|
112
|
+
lastReconcileAtByDirectory.clear();
|
|
113
|
+
}
|
package/dist/opencode/events.js
CHANGED
|
@@ -32,6 +32,65 @@ function waitWithAbort(ms, signal) {
|
|
|
32
32
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null;
|
|
37
|
+
}
|
|
38
|
+
function isEventLike(value) {
|
|
39
|
+
return isRecord(value) && typeof value.type === "string" && isRecord(value.properties);
|
|
40
|
+
}
|
|
41
|
+
function normalizeDirectoryForComparison(directory) {
|
|
42
|
+
const normalized = directory.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
43
|
+
return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized;
|
|
44
|
+
}
|
|
45
|
+
function isSameDirectory(left, right) {
|
|
46
|
+
return normalizeDirectoryForComparison(left) === normalizeDirectoryForComparison(right);
|
|
47
|
+
}
|
|
48
|
+
function normalizeGlobalEvent(rawEvent, directory) {
|
|
49
|
+
if (isEventLike(rawEvent)) {
|
|
50
|
+
return rawEvent;
|
|
51
|
+
}
|
|
52
|
+
if (!isRecord(rawEvent) || !("payload" in rawEvent)) {
|
|
53
|
+
logger.debug("[Events] Ignoring global event with unknown shape");
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const eventDirectory = typeof rawEvent.directory === "string" ? rawEvent.directory : null;
|
|
57
|
+
if (eventDirectory && !isSameDirectory(eventDirectory, directory)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!isEventLike(rawEvent.payload)) {
|
|
61
|
+
logger.debug("[Events] Ignoring global event with unknown payload shape");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return rawEvent.payload;
|
|
65
|
+
}
|
|
66
|
+
function normalizeEvent(rawEvent, source, directory) {
|
|
67
|
+
if (source === "global") {
|
|
68
|
+
return normalizeGlobalEvent(rawEvent, directory);
|
|
69
|
+
}
|
|
70
|
+
if (!isEventLike(rawEvent)) {
|
|
71
|
+
logger.debug("[Events] Ignoring legacy event with unknown shape");
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return rawEvent;
|
|
75
|
+
}
|
|
76
|
+
async function subscribeToGlobalEventStream(signal) {
|
|
77
|
+
const globalEvents = opencodeClient.global;
|
|
78
|
+
if (!globalEvents?.event) {
|
|
79
|
+
throw new Error("Global event subscription is not available");
|
|
80
|
+
}
|
|
81
|
+
const result = await globalEvents.event({ signal });
|
|
82
|
+
if (!result.stream) {
|
|
83
|
+
throw new Error(FATAL_NO_STREAM_ERROR);
|
|
84
|
+
}
|
|
85
|
+
return { source: "global", stream: result.stream };
|
|
86
|
+
}
|
|
87
|
+
async function subscribeToLegacyEventStream(directory, signal) {
|
|
88
|
+
const result = await opencodeClient.event.subscribe({ directory }, { signal });
|
|
89
|
+
if (!result.stream) {
|
|
90
|
+
throw new Error(FATAL_NO_STREAM_ERROR);
|
|
91
|
+
}
|
|
92
|
+
return { source: "legacy", stream: result.stream };
|
|
93
|
+
}
|
|
35
94
|
export async function subscribeToEvents(directory, callback) {
|
|
36
95
|
if (isListening && activeDirectory === directory) {
|
|
37
96
|
eventCallback = callback;
|
|
@@ -53,14 +112,33 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
53
112
|
streamAbortController = controller;
|
|
54
113
|
try {
|
|
55
114
|
let reconnectAttempt = 0;
|
|
115
|
+
let useLegacyEventsOnce = false;
|
|
56
116
|
while (isListening && activeDirectory === directory && !controller.signal.aborted) {
|
|
57
117
|
try {
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
118
|
+
let subscription;
|
|
119
|
+
if (useLegacyEventsOnce) {
|
|
120
|
+
useLegacyEventsOnce = false;
|
|
121
|
+
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
try {
|
|
125
|
+
subscription = await subscribeToGlobalEventStream(controller.signal);
|
|
126
|
+
logger.debug(`Using global OpenCode event stream for ${directory}`);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
logger.warn(`Global event stream unavailable for ${directory}, falling back to project event stream`, error);
|
|
136
|
+
subscription = await subscribeToLegacyEventStream(directory, controller.signal);
|
|
137
|
+
}
|
|
61
138
|
}
|
|
62
139
|
reconnectAttempt = 0;
|
|
63
|
-
eventStream =
|
|
140
|
+
eventStream = subscription.stream;
|
|
141
|
+
let usefulEventCount = 0;
|
|
64
142
|
for await (const event of eventStream) {
|
|
65
143
|
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
66
144
|
logger.debug(`Event listener stopped or changed directory, breaking loop`);
|
|
@@ -69,6 +147,13 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
69
147
|
// CRITICAL: Explicitly yield to the event loop BEFORE processing the event
|
|
70
148
|
// This allows grammY to handle getUpdates between SSE events
|
|
71
149
|
await new Promise((resolve) => setImmediate(resolve));
|
|
150
|
+
const normalizedEvent = normalizeEvent(event, subscription.source, directory);
|
|
151
|
+
if (!normalizedEvent) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (normalizedEvent.type !== "server.connected") {
|
|
155
|
+
usefulEventCount++;
|
|
156
|
+
}
|
|
72
157
|
if (eventCallback) {
|
|
73
158
|
// Use setImmediate to avoid blocking the event loop
|
|
74
159
|
// and let grammY process incoming Telegram updates
|
|
@@ -81,7 +166,7 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
81
166
|
listenerGeneration !== generation) {
|
|
82
167
|
return;
|
|
83
168
|
}
|
|
84
|
-
callbackSnapshot(
|
|
169
|
+
callbackSnapshot(normalizedEvent);
|
|
85
170
|
});
|
|
86
171
|
}
|
|
87
172
|
}
|
|
@@ -89,6 +174,11 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
89
174
|
if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
|
|
90
175
|
break;
|
|
91
176
|
}
|
|
177
|
+
if (subscription.source === "global" && usefulEventCount === 0) {
|
|
178
|
+
useLegacyEventsOnce = true;
|
|
179
|
+
logger.warn(`Global event stream ended without project events for ${directory}, falling back to project event stream`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
92
182
|
reconnectAttempt++;
|
|
93
183
|
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
94
184
|
logger.warn(`Event stream ended for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
@@ -1,32 +1,42 @@
|
|
|
1
1
|
import { logger } from "../utils/logger.js";
|
|
2
2
|
class ForegroundSessionState {
|
|
3
|
-
|
|
4
|
-
markBusy(sessionId) {
|
|
5
|
-
if (!sessionId) {
|
|
3
|
+
activeSessions = new Map();
|
|
4
|
+
markBusy(sessionId, directory) {
|
|
5
|
+
if (!sessionId || !directory) {
|
|
6
6
|
return;
|
|
7
7
|
}
|
|
8
|
-
this.
|
|
9
|
-
logger.debug(`[ScheduledTaskForeground] Marked session busy: session=${sessionId}, count=${this.
|
|
8
|
+
this.activeSessions.set(sessionId, { sessionId, directory, markedAt: Date.now() });
|
|
9
|
+
logger.debug(`[ScheduledTaskForeground] Marked session busy: session=${sessionId}, directory=${directory}, count=${this.activeSessions.size}`);
|
|
10
10
|
}
|
|
11
11
|
markIdle(sessionId) {
|
|
12
12
|
if (!sessionId) {
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
|
-
this.
|
|
16
|
-
logger.debug(`[ScheduledTaskForeground] Marked session idle: session=${sessionId}, count=${this.
|
|
15
|
+
this.activeSessions.delete(sessionId);
|
|
16
|
+
logger.debug(`[ScheduledTaskForeground] Marked session idle: session=${sessionId}, count=${this.activeSessions.size}`);
|
|
17
|
+
}
|
|
18
|
+
getBusySessions() {
|
|
19
|
+
return Array.from(this.activeSessions.values(), (session) => ({ ...session }));
|
|
17
20
|
}
|
|
18
21
|
isBusy() {
|
|
19
|
-
return this.
|
|
22
|
+
return this.activeSessions.size > 0;
|
|
20
23
|
}
|
|
21
24
|
clearAll(reason) {
|
|
22
|
-
if (this.
|
|
25
|
+
if (this.activeSessions.size === 0) {
|
|
23
26
|
return;
|
|
24
27
|
}
|
|
25
|
-
logger.info(`[ScheduledTaskForeground] Cleared foreground busy state: reason=${reason}, count=${this.
|
|
26
|
-
this.
|
|
28
|
+
logger.info(`[ScheduledTaskForeground] Cleared foreground busy state: reason=${reason}, count=${this.activeSessions.size}`);
|
|
29
|
+
this.activeSessions.clear();
|
|
27
30
|
}
|
|
28
31
|
__resetForTests() {
|
|
29
|
-
this.
|
|
32
|
+
this.activeSessions.clear();
|
|
33
|
+
}
|
|
34
|
+
__setMarkedAtForTests(sessionId, markedAt) {
|
|
35
|
+
const session = this.activeSessions.get(sessionId);
|
|
36
|
+
if (!session) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.activeSessions.set(sessionId, { ...session, markedAt });
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
export const foregroundSessionState = new ForegroundSessionState();
|
|
@@ -173,6 +173,10 @@ class SummaryAggregator {
|
|
|
173
173
|
this.handleMessagePartDelta(event);
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
+
if (eventType === "server.heartbeat") {
|
|
177
|
+
logger.debug("[Aggregator] Heartbeat received");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
176
180
|
// Log all question-related events for debugging
|
|
177
181
|
if (event.type.startsWith("question.")) {
|
|
178
182
|
logger.info(`[Aggregator] Question event: ${event.type}`, JSON.stringify(event.properties, null, 2));
|