@gajae-code/coding-agent 0.7.2 → 0.7.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/CHANGELOG.md +38 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/telegram-daemon.d.ts +54 -16
- package/dist/types/notifications/topic-registry.d.ts +2 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/web/insane/url-guard.d.ts +6 -3
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/package.json +7 -7
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli.ts +6 -2
- package/src/commands/mcp.ts +117 -0
- package/src/config/keybindings.ts +2 -2
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/gjc-runtime/tmux-common.ts +3 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +10 -7
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/model-selector.ts +12 -0
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/selector-controller.ts +3 -0
- package/src/modes/interactive-mode.ts +2 -1
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/telegram-daemon.ts +347 -251
- package/src/notifications/topic-registry.ts +5 -0
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/fetch.ts +18 -2
- package/src/web/insane/url-guard.ts +18 -14
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
|
@@ -11,6 +11,7 @@ import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
|
11
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
12
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
13
13
|
import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
|
|
14
|
+
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
14
15
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
15
16
|
import {
|
|
16
17
|
type AliasTable,
|
|
@@ -104,6 +105,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
104
105
|
// Native reactions used as a two-stage delivery double-check on inbound thread
|
|
105
106
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
106
107
|
const QUEUED_REACTION = "👀";
|
|
108
|
+
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
107
109
|
const CONSUMED_REACTION = "✅";
|
|
108
110
|
|
|
109
111
|
/**
|
|
@@ -527,6 +529,154 @@ export interface BotApi {
|
|
|
527
529
|
call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
|
|
528
530
|
}
|
|
529
531
|
|
|
532
|
+
export interface TelegramTransportOptions {
|
|
533
|
+
botToken: string;
|
|
534
|
+
apiBase?: string;
|
|
535
|
+
fetchImpl?: typeof fetch;
|
|
536
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */
|
|
540
|
+
export class TelegramBotTransport implements BotApi {
|
|
541
|
+
#opts: TelegramTransportOptions;
|
|
542
|
+
|
|
543
|
+
constructor(opts: TelegramTransportOptions) {
|
|
544
|
+
this.#opts = opts;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown> {
|
|
548
|
+
const apiBase = this.#opts.apiBase ?? "https://api.telegram.org";
|
|
549
|
+
const url = `${apiBase}/bot${this.#opts.botToken}/${method}`;
|
|
550
|
+
const fetchImpl = this.#opts.fetchImpl ?? fetch;
|
|
551
|
+
const setTimeoutImpl = this.#opts.setTimeoutImpl ?? setTimeout;
|
|
552
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeoutImpl(resolve, ms));
|
|
553
|
+
// sendPhoto with base64 bytes must be a multipart upload (Telegram does
|
|
554
|
+
// not accept base64 in JSON). Other methods stay JSON.
|
|
555
|
+
const photoBody = body as { photo?: unknown; mime?: unknown } | null;
|
|
556
|
+
if (method === "sendPhoto" && photoBody && typeof photoBody.photo === "string") {
|
|
557
|
+
const b = body as {
|
|
558
|
+
chat_id: unknown;
|
|
559
|
+
message_thread_id?: unknown;
|
|
560
|
+
photo: string;
|
|
561
|
+
mime?: string;
|
|
562
|
+
caption?: string;
|
|
563
|
+
parse_mode?: string;
|
|
564
|
+
};
|
|
565
|
+
const form = new FormData();
|
|
566
|
+
form.set("chat_id", String(b.chat_id));
|
|
567
|
+
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
568
|
+
if (b.caption) form.set("caption", b.caption);
|
|
569
|
+
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
570
|
+
form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
|
|
571
|
+
const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
|
|
572
|
+
return res.json();
|
|
573
|
+
}
|
|
574
|
+
const docBody = body as { document?: unknown } | null;
|
|
575
|
+
if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
|
|
576
|
+
const b = body as {
|
|
577
|
+
chat_id: unknown;
|
|
578
|
+
message_thread_id?: unknown;
|
|
579
|
+
document: string;
|
|
580
|
+
mime?: string;
|
|
581
|
+
fileName?: string;
|
|
582
|
+
caption?: string;
|
|
583
|
+
parse_mode?: string;
|
|
584
|
+
};
|
|
585
|
+
const form = new FormData();
|
|
586
|
+
form.set("chat_id", String(b.chat_id));
|
|
587
|
+
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
588
|
+
if (b.caption) form.set("caption", b.caption);
|
|
589
|
+
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
590
|
+
form.set(
|
|
591
|
+
"document",
|
|
592
|
+
new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
|
|
593
|
+
b.fileName ?? "file",
|
|
594
|
+
);
|
|
595
|
+
const res = await fetchWithRetry(fetchImpl, url, { method: "POST", body: form, signal: opts?.signal }, sleep);
|
|
596
|
+
return res.json();
|
|
597
|
+
}
|
|
598
|
+
const res = await fetchWithRetry(
|
|
599
|
+
fetchImpl,
|
|
600
|
+
url,
|
|
601
|
+
{
|
|
602
|
+
method: "POST",
|
|
603
|
+
headers: { "content-type": "application/json" },
|
|
604
|
+
body: JSON.stringify(body),
|
|
605
|
+
signal: opts?.signal,
|
|
606
|
+
},
|
|
607
|
+
sleep,
|
|
608
|
+
);
|
|
609
|
+
return res.json();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export interface TelegramUpdatePollerOptions {
|
|
614
|
+
botApi: BotApi;
|
|
615
|
+
runtime: NotificationOperatorRuntime;
|
|
616
|
+
backoff: OperatorBackoffPolicy;
|
|
617
|
+
processUpdate: (update: unknown) => Promise<void>;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
|
|
621
|
+
export class TelegramUpdatePoller {
|
|
622
|
+
#offset = 0;
|
|
623
|
+
#opts: TelegramUpdatePollerOptions;
|
|
624
|
+
|
|
625
|
+
constructor(opts: TelegramUpdatePollerOptions) {
|
|
626
|
+
this.#opts = opts;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
630
|
+
let body: {
|
|
631
|
+
ok?: boolean;
|
|
632
|
+
error_code?: number;
|
|
633
|
+
description?: string;
|
|
634
|
+
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
635
|
+
};
|
|
636
|
+
try {
|
|
637
|
+
body = (await this.#opts.botApi.call(
|
|
638
|
+
"getUpdates",
|
|
639
|
+
{ offset: this.#offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
640
|
+
{ signal },
|
|
641
|
+
)) as typeof body;
|
|
642
|
+
} catch (err) {
|
|
643
|
+
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
644
|
+
if (isAbortError(err)) return 0;
|
|
645
|
+
// A transient Telegram API failure must never crash the daemon.
|
|
646
|
+
logger.error("notifications daemon: getUpdates failed", { error: String(err) });
|
|
647
|
+
await this.#opts.runtime.sleep(POLL_BACKOFF_MS, signal);
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
651
|
+
// another poller is live; back off boundedly instead of hot-looping.
|
|
652
|
+
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
653
|
+
const backoffMs = this.#opts.backoff.next();
|
|
654
|
+
logger.error(
|
|
655
|
+
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${backoffMs}ms`,
|
|
656
|
+
);
|
|
657
|
+
await this.#opts.runtime.sleep(backoffMs, signal);
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
this.#opts.backoff.reset();
|
|
661
|
+
for (const update of body.result ?? []) {
|
|
662
|
+
this.#offset = update.update_id + 1;
|
|
663
|
+
try {
|
|
664
|
+
await this.#opts.processUpdate(update);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
logger.error("notifications daemon: handleTelegramUpdate failed", { error: String(err) });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return body.result?.length ?? 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Mutable dispatch state shared by session frames and inbound Telegram updates. */
|
|
674
|
+
export class TelegramEventDispatchState {
|
|
675
|
+
readonly busy = new Set<string>();
|
|
676
|
+
readonly inboundReactions = new Map<number, { messageId: number }>();
|
|
677
|
+
readonly seenUpdateIds = new Set<number>();
|
|
678
|
+
}
|
|
679
|
+
|
|
530
680
|
/**
|
|
531
681
|
* Cooperative control seam for the daemon run loop. Implemented by the
|
|
532
682
|
* daemon-internal CLI / controller against the owner-scoped control-request
|
|
@@ -575,37 +725,44 @@ interface SessionSocket {
|
|
|
575
725
|
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
576
726
|
}
|
|
577
727
|
|
|
728
|
+
interface PendingThreadedFrame {
|
|
729
|
+
send: ThreadedSend;
|
|
730
|
+
msg: Record<string, unknown>;
|
|
731
|
+
}
|
|
732
|
+
|
|
578
733
|
export class TelegramNotificationDaemon {
|
|
579
734
|
readonly aliasTable: AliasTable;
|
|
580
735
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
581
736
|
readonly sessions = new Map<string, SessionSocket>();
|
|
737
|
+
private readonly runtime: NotificationOperatorRuntime;
|
|
738
|
+
private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
|
|
739
|
+
private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
|
|
740
|
+
private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
|
|
582
741
|
private running = false;
|
|
583
|
-
private offset = 0;
|
|
584
742
|
private readonly fsImpl: TelegramDaemonFs;
|
|
585
743
|
private readonly botApi: BotApi;
|
|
586
744
|
private readonly topics = new TopicRegistry();
|
|
587
745
|
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
588
|
-
private readonly
|
|
746
|
+
private readonly poller: TelegramUpdatePoller;
|
|
747
|
+
private readonly dispatchState = new TelegramEventDispatchState();
|
|
748
|
+
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
749
|
+
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
750
|
+
/** Non-identity frames held until identity creates the correct thread. */
|
|
751
|
+
private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
|
|
589
752
|
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
590
753
|
private threadedFallbackNoticeSent = false;
|
|
591
754
|
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
592
755
|
private readonly flatIdentitySent = new Set<string>();
|
|
593
756
|
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
594
757
|
private pairedChatPrivate: boolean | undefined;
|
|
595
|
-
private flushTimer: ReturnType<typeof setInterval> | undefined;
|
|
596
|
-
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
597
|
-
private scanning = false;
|
|
598
|
-
private typingTimer: ReturnType<typeof setInterval> | undefined;
|
|
599
758
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
600
|
-
private
|
|
759
|
+
private get busy(): Set<string> {
|
|
760
|
+
return this.dispatchState.busy;
|
|
761
|
+
}
|
|
601
762
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
602
|
-
private
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
/** Set when a cooperative stop has been requested (signal or control request). */
|
|
606
|
-
private stopRequested = false;
|
|
607
|
-
/** Current bounded backoff after a Telegram getUpdates 409 conflict (0 when healthy). */
|
|
608
|
-
private pollConflictBackoffMs = 0;
|
|
763
|
+
private get inboundReactions(): Map<number, { messageId: number }> {
|
|
764
|
+
return this.dispatchState.inboundReactions;
|
|
765
|
+
}
|
|
609
766
|
|
|
610
767
|
/**
|
|
611
768
|
* Cooperatively stop the daemon: set the stop flag and abort the in-flight
|
|
@@ -613,91 +770,84 @@ export class TelegramNotificationDaemon {
|
|
|
613
770
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
614
771
|
*/
|
|
615
772
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
616
|
-
this.
|
|
773
|
+
this.runtime.requestStop();
|
|
617
774
|
this.running = false;
|
|
618
|
-
this.activePoll?.abort();
|
|
619
775
|
}
|
|
620
776
|
|
|
621
777
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
622
778
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
623
779
|
this.aliasTable = createAliasTable();
|
|
624
|
-
this.botApi =
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
caption?: string;
|
|
641
|
-
parse_mode?: string;
|
|
642
|
-
};
|
|
643
|
-
const form = new FormData();
|
|
644
|
-
form.set("chat_id", String(b.chat_id));
|
|
645
|
-
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
646
|
-
if (b.caption) form.set("caption", b.caption);
|
|
647
|
-
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
648
|
-
form.set("photo", new Blob([Buffer.from(b.photo, "base64")], { type: b.mime ?? "image/png" }), "image");
|
|
649
|
-
const res = await fetchWithRetry(
|
|
650
|
-
fetchImpl,
|
|
651
|
-
url,
|
|
652
|
-
{ method: "POST", body: form, signal: callOpts?.signal },
|
|
653
|
-
sleep,
|
|
654
|
-
);
|
|
655
|
-
return res.json();
|
|
656
|
-
}
|
|
657
|
-
const docBody = body as { document?: unknown } | null;
|
|
658
|
-
if (method === "sendDocument" && docBody && typeof docBody.document === "string") {
|
|
659
|
-
const b = body as {
|
|
660
|
-
chat_id: unknown;
|
|
661
|
-
message_thread_id?: unknown;
|
|
662
|
-
document: string;
|
|
663
|
-
mime?: string;
|
|
664
|
-
fileName?: string;
|
|
665
|
-
caption?: string;
|
|
666
|
-
parse_mode?: string;
|
|
667
|
-
};
|
|
668
|
-
const form = new FormData();
|
|
669
|
-
form.set("chat_id", String(b.chat_id));
|
|
670
|
-
if (b.message_thread_id !== undefined) form.set("message_thread_id", String(b.message_thread_id));
|
|
671
|
-
if (b.caption) form.set("caption", b.caption);
|
|
672
|
-
if (b.parse_mode) form.set("parse_mode", String(b.parse_mode));
|
|
673
|
-
form.set(
|
|
674
|
-
"document",
|
|
675
|
-
new Blob([Buffer.from(b.document, "base64")], { type: b.mime ?? "application/octet-stream" }),
|
|
676
|
-
b.fileName ?? "file",
|
|
677
|
-
);
|
|
678
|
-
const res = await fetchWithRetry(
|
|
679
|
-
fetchImpl,
|
|
680
|
-
url,
|
|
681
|
-
{ method: "POST", body: form, signal: callOpts?.signal },
|
|
682
|
-
sleep,
|
|
683
|
-
);
|
|
684
|
-
return res.json();
|
|
685
|
-
}
|
|
686
|
-
const res = await fetchWithRetry(
|
|
687
|
-
fetchImpl,
|
|
688
|
-
url,
|
|
689
|
-
{
|
|
690
|
-
method: "POST",
|
|
691
|
-
headers: { "content-type": "application/json" },
|
|
692
|
-
body: JSON.stringify(body),
|
|
693
|
-
signal: callOpts?.signal,
|
|
694
|
-
},
|
|
695
|
-
sleep,
|
|
696
|
-
);
|
|
697
|
-
return res.json();
|
|
698
|
-
},
|
|
699
|
-
};
|
|
780
|
+
this.botApi =
|
|
781
|
+
opts.botApi ??
|
|
782
|
+
new TelegramBotTransport({
|
|
783
|
+
botToken: opts.botToken,
|
|
784
|
+
apiBase: opts.apiBase,
|
|
785
|
+
fetchImpl: opts.fetchImpl,
|
|
786
|
+
setTimeoutImpl: opts.setTimeoutImpl,
|
|
787
|
+
});
|
|
788
|
+
this.runtime = new NotificationOperatorRuntime({
|
|
789
|
+
now: opts.now,
|
|
790
|
+
setTimeoutImpl: opts.setTimeoutImpl,
|
|
791
|
+
clearTimeoutImpl: opts.clearTimeoutImpl,
|
|
792
|
+
setIntervalImpl: opts.setIntervalImpl,
|
|
793
|
+
clearIntervalImpl: opts.clearIntervalImpl,
|
|
794
|
+
});
|
|
795
|
+
this.sessionRouter = this.createSessionRouter();
|
|
700
796
|
this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
|
|
797
|
+
this.poller = new TelegramUpdatePoller({
|
|
798
|
+
botApi: this.botApi,
|
|
799
|
+
runtime: this.runtime,
|
|
800
|
+
backoff: this.pollConflictBackoff,
|
|
801
|
+
processUpdate: update => this.handleTelegramUpdate(update),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private createSessionRouter(): OperatorEventRouter<SessionSocket> {
|
|
806
|
+
return new OperatorEventRouter<SessionSocket>()
|
|
807
|
+
.add({
|
|
808
|
+
name: "hello",
|
|
809
|
+
matches: msg => msg.type === "hello",
|
|
810
|
+
handle: (session, msg) => {
|
|
811
|
+
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
812
|
+
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
813
|
+
session.capable = true;
|
|
814
|
+
this.startLiveness(session);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
.add({
|
|
819
|
+
name: "pong",
|
|
820
|
+
matches: msg => msg.type === "pong",
|
|
821
|
+
handle: (session, msg) => {
|
|
822
|
+
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
823
|
+
session.awaitingNonce = undefined;
|
|
824
|
+
session.lastPongAt = this.runtime.now();
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
})
|
|
828
|
+
.add({
|
|
829
|
+
name: "activity",
|
|
830
|
+
matches: msg => msg.type === "activity",
|
|
831
|
+
handle: async (session, msg) => {
|
|
832
|
+
if (msg.state === "busy") {
|
|
833
|
+
this.busy.add(session.sessionId);
|
|
834
|
+
await this.sendTyping(session.sessionId);
|
|
835
|
+
} else {
|
|
836
|
+
this.busy.delete(session.sessionId);
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
})
|
|
840
|
+
.add({
|
|
841
|
+
name: "inbound_ack",
|
|
842
|
+
matches: msg => msg.type === "inbound_ack" && typeof msg.updateId === "number",
|
|
843
|
+
handle: async (_session, msg) => {
|
|
844
|
+
const target = this.inboundReactions.get(msg.updateId as number);
|
|
845
|
+
if (target && msg.state === "consumed") {
|
|
846
|
+
this.inboundReactions.delete(msg.updateId as number);
|
|
847
|
+
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
});
|
|
701
851
|
}
|
|
702
852
|
|
|
703
853
|
async loadAliases(): Promise<void> {
|
|
@@ -770,7 +920,7 @@ export class TelegramNotificationDaemon {
|
|
|
770
920
|
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
|
|
771
921
|
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
772
922
|
// the daemon log instead of an invisible unhandled rejection.
|
|
773
|
-
|
|
923
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
774
924
|
});
|
|
775
925
|
});
|
|
776
926
|
ws.addEventListener("close", () => {
|
|
@@ -788,7 +938,7 @@ export class TelegramNotificationDaemon {
|
|
|
788
938
|
private startLiveness(session: SessionSocket): void {
|
|
789
939
|
if (session.pingTimer) return;
|
|
790
940
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
791
|
-
const now = () =>
|
|
941
|
+
const now = () => this.runtime.now();
|
|
792
942
|
session.lastPongAt = now();
|
|
793
943
|
session.pingTimer = setIntervalImpl(() => {
|
|
794
944
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
@@ -851,6 +1001,60 @@ export class TelegramNotificationDaemon {
|
|
|
851
1001
|
return `GJC ${sessionId.slice(-6)}`;
|
|
852
1002
|
}
|
|
853
1003
|
|
|
1004
|
+
private topicIdentityKey(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1005
|
+
const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
|
|
1006
|
+
if (!repo) return undefined;
|
|
1007
|
+
const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : "";
|
|
1008
|
+
return `${repo}\0${branch}`;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private topicIdentityBase(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1012
|
+
const repo = typeof msg?.repo === "string" && msg.repo.trim() ? msg.repo.trim() : undefined;
|
|
1013
|
+
if (!repo) return undefined;
|
|
1014
|
+
const branch = typeof msg?.branch === "string" && msg.branch.trim() ? msg.branch.trim() : undefined;
|
|
1015
|
+
return branch ? `${repo}/${branch}` : repo;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private topicOwnerForIdentity(msg: { repo?: unknown; branch?: unknown }): string | undefined {
|
|
1019
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1020
|
+
const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined;
|
|
1021
|
+
if (remembered && this.topics.get(remembered)) return remembered;
|
|
1022
|
+
const base = this.topicIdentityBase(msg);
|
|
1023
|
+
if (!identityKey || !base) return undefined;
|
|
1024
|
+
for (const sessionId of this.topics.sessionIds()) {
|
|
1025
|
+
const name = this.topics.get(sessionId)?.name;
|
|
1026
|
+
if (name === base || name?.startsWith(`${base} - `)) {
|
|
1027
|
+
this.topicOwnerByIdentity.set(identityKey, sessionId);
|
|
1028
|
+
return sessionId;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return undefined;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private async submitThreadedFrame(sessionId: string, send: ThreadedSend, topicId: string): Promise<void> {
|
|
1035
|
+
this.pool.submit({
|
|
1036
|
+
sessionId,
|
|
1037
|
+
lane: send.lane,
|
|
1038
|
+
coalesceKey: send.coalesceKey,
|
|
1039
|
+
payload: { send, topicId },
|
|
1040
|
+
});
|
|
1041
|
+
await this.flushPool();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
|
|
1045
|
+
const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
|
|
1046
|
+
frames.push({ send, msg });
|
|
1047
|
+
if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
|
|
1048
|
+
this.pendingThreadedFrames.set(sessionId, frames);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private async flushPendingThreadedFrames(sessionId: string, topicId: string): Promise<void> {
|
|
1052
|
+
const frames = this.pendingThreadedFrames.get(sessionId);
|
|
1053
|
+
if (!frames || frames.length === 0) return;
|
|
1054
|
+
this.pendingThreadedFrames.delete(sessionId);
|
|
1055
|
+
for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicId);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
854
1058
|
/**
|
|
855
1059
|
* Resolve (creating once via `createForumTopic`) the forum topic for a
|
|
856
1060
|
* session. On capability failure (e.g. Threaded Mode off) this returns
|
|
@@ -1092,46 +1296,32 @@ export class TelegramNotificationDaemon {
|
|
|
1092
1296
|
}
|
|
1093
1297
|
|
|
1094
1298
|
private startFlushTimer(): void {
|
|
1095
|
-
|
|
1096
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1097
|
-
this.flushTimer = setIntervalImpl(() => {
|
|
1299
|
+
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
1098
1300
|
if (!this.running || this.pool.pending === 0) return;
|
|
1099
1301
|
void this.flushPool();
|
|
1100
|
-
}
|
|
1302
|
+
});
|
|
1101
1303
|
}
|
|
1102
1304
|
|
|
1103
1305
|
private stopFlushTimer(): void {
|
|
1104
|
-
|
|
1105
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1106
|
-
clearIntervalImpl(this.flushTimer);
|
|
1107
|
-
this.flushTimer = undefined;
|
|
1306
|
+
this.runtime.stopInterval("telegram-flush");
|
|
1108
1307
|
}
|
|
1109
1308
|
|
|
1110
1309
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
1111
1310
|
private async runScan(): Promise<void> {
|
|
1112
|
-
|
|
1113
|
-
this.scanning = true;
|
|
1114
|
-
try {
|
|
1311
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
1115
1312
|
await this.scanRoots();
|
|
1116
|
-
}
|
|
1117
|
-
this.scanning = false;
|
|
1118
|
-
}
|
|
1313
|
+
});
|
|
1119
1314
|
}
|
|
1120
1315
|
|
|
1121
1316
|
private startScanTimer(): void {
|
|
1122
|
-
|
|
1123
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1124
|
-
this.scanTimer = setIntervalImpl(() => {
|
|
1317
|
+
this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
|
|
1125
1318
|
if (!this.running) return;
|
|
1126
1319
|
void this.runScan();
|
|
1127
|
-
}
|
|
1320
|
+
});
|
|
1128
1321
|
}
|
|
1129
1322
|
|
|
1130
1323
|
private stopScanTimer(): void {
|
|
1131
|
-
|
|
1132
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1133
|
-
clearIntervalImpl(this.scanTimer);
|
|
1134
|
-
this.scanTimer = undefined;
|
|
1324
|
+
this.runtime.stopInterval("telegram-scan");
|
|
1135
1325
|
}
|
|
1136
1326
|
|
|
1137
1327
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
@@ -1163,67 +1353,43 @@ export class TelegramNotificationDaemon {
|
|
|
1163
1353
|
}
|
|
1164
1354
|
|
|
1165
1355
|
private startTypingTimer(): void {
|
|
1166
|
-
|
|
1167
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
1168
|
-
this.typingTimer = setIntervalImpl(() => {
|
|
1356
|
+
this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
|
|
1169
1357
|
if (!this.running || this.busy.size === 0) return;
|
|
1170
1358
|
for (const sessionId of this.busy) void this.sendTyping(sessionId);
|
|
1171
|
-
}
|
|
1359
|
+
});
|
|
1172
1360
|
}
|
|
1173
1361
|
|
|
1174
1362
|
private stopTypingTimer(): void {
|
|
1175
|
-
|
|
1176
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
1177
|
-
clearIntervalImpl(this.typingTimer);
|
|
1178
|
-
this.typingTimer = undefined;
|
|
1363
|
+
this.runtime.stopInterval("telegram-typing");
|
|
1179
1364
|
}
|
|
1180
1365
|
|
|
1181
1366
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
1182
|
-
if (msg
|
|
1183
|
-
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
1184
|
-
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
1185
|
-
session.capable = true;
|
|
1186
|
-
this.startLiveness(session);
|
|
1187
|
-
}
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
if (msg?.type === "pong") {
|
|
1191
|
-
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
1192
|
-
session.awaitingNonce = undefined;
|
|
1193
|
-
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
1194
|
-
}
|
|
1195
|
-
return;
|
|
1196
|
-
}
|
|
1197
|
-
// Live typing indicator: track busy/idle per session and push an immediate
|
|
1198
|
-
// chat action so "typing…" appears without waiting for the refresh tick.
|
|
1199
|
-
if (msg?.type === "activity") {
|
|
1200
|
-
if (msg.state === "busy") {
|
|
1201
|
-
this.busy.add(session.sessionId);
|
|
1202
|
-
await this.sendTyping(session.sessionId);
|
|
1203
|
-
} else {
|
|
1204
|
-
this.busy.delete(session.sessionId);
|
|
1205
|
-
}
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
// Inbound delivery double-check: flip the queued reaction to the consumed
|
|
1209
|
-
// reaction once the session reports a turn picked the message up.
|
|
1210
|
-
if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
|
|
1211
|
-
const target = this.inboundReactions.get(msg.updateId);
|
|
1212
|
-
if (target && msg.state === "consumed") {
|
|
1213
|
-
this.inboundReactions.delete(msg.updateId);
|
|
1214
|
-
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
1215
|
-
}
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1367
|
+
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
1218
1368
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1219
1369
|
const send = renderThreadedFrame(msg);
|
|
1220
1370
|
if (!send) return;
|
|
1221
|
-
const
|
|
1371
|
+
const existingTopic = this.topics.get(session.sessionId)?.topicId;
|
|
1372
|
+
if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
|
|
1373
|
+
this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (send.identity) {
|
|
1377
|
+
const ownerId = this.topicOwnerForIdentity(msg);
|
|
1378
|
+
const ownerTopic = ownerId ? this.topics.get(ownerId) : undefined;
|
|
1379
|
+
if (ownerId && ownerId !== session.sessionId && ownerTopic) {
|
|
1380
|
+
await this.flushPendingThreadedFrames(session.sessionId, ownerTopic.topicId);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
const topicId =
|
|
1385
|
+
existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
|
|
1222
1386
|
if (!topicId) {
|
|
1223
1387
|
await this.deliverFlatFallback(session.sessionId, send);
|
|
1224
1388
|
return;
|
|
1225
1389
|
}
|
|
1226
1390
|
if (send.identity) {
|
|
1391
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1392
|
+
if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
|
|
1227
1393
|
// Rename the topic if the title changed (e.g. the session title was
|
|
1228
1394
|
// auto-generated after the topic was first created). This runs on
|
|
1229
1395
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
@@ -1241,25 +1407,14 @@ export class TelegramNotificationDaemon {
|
|
|
1241
1407
|
}
|
|
1242
1408
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
1243
1409
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
1244
|
-
this.
|
|
1245
|
-
sessionId: session.sessionId,
|
|
1246
|
-
lane: send.lane,
|
|
1247
|
-
coalesceKey: send.coalesceKey,
|
|
1248
|
-
payload: { send, topicId },
|
|
1249
|
-
});
|
|
1250
|
-
await this.flushPool();
|
|
1410
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1251
1411
|
this.topics.markIdentitySent(session.sessionId);
|
|
1252
1412
|
}
|
|
1413
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicId);
|
|
1253
1414
|
await this.persistTopics();
|
|
1254
1415
|
return;
|
|
1255
1416
|
}
|
|
1256
|
-
this.
|
|
1257
|
-
sessionId: session.sessionId,
|
|
1258
|
-
lane: send.lane,
|
|
1259
|
-
coalesceKey: send.coalesceKey,
|
|
1260
|
-
payload: { send, topicId },
|
|
1261
|
-
});
|
|
1262
|
-
await this.flushPool();
|
|
1417
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1263
1418
|
return;
|
|
1264
1419
|
}
|
|
1265
1420
|
if (msg.type === "action_needed" && msg.id) {
|
|
@@ -1343,11 +1498,11 @@ export class TelegramNotificationDaemon {
|
|
|
1343
1498
|
const inbound = decideThreadedInbound(update as never, {
|
|
1344
1499
|
pairedChatId: this.opts.chatId,
|
|
1345
1500
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
1346
|
-
isDuplicate: id => this.seenUpdateIds.has(id),
|
|
1501
|
+
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
1347
1502
|
});
|
|
1348
1503
|
if (inbound.kind === "duplicate") return;
|
|
1349
1504
|
if (inbound.kind === "inject") {
|
|
1350
|
-
this.seenUpdateIds.add(inbound.updateId);
|
|
1505
|
+
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1351
1506
|
const session = this.sessions.get(inbound.sessionId);
|
|
1352
1507
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1353
1508
|
const attachmentResult = inbound.attachment
|
|
@@ -1425,67 +1580,7 @@ export class TelegramNotificationDaemon {
|
|
|
1425
1580
|
}
|
|
1426
1581
|
|
|
1427
1582
|
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
1428
|
-
|
|
1429
|
-
ok?: boolean;
|
|
1430
|
-
error_code?: number;
|
|
1431
|
-
description?: string;
|
|
1432
|
-
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
1433
|
-
};
|
|
1434
|
-
try {
|
|
1435
|
-
body = (await this.botApi.call(
|
|
1436
|
-
"getUpdates",
|
|
1437
|
-
{ offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
1438
|
-
{ signal },
|
|
1439
|
-
)) as typeof body;
|
|
1440
|
-
} catch (err) {
|
|
1441
|
-
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
1442
|
-
if (isAbortError(err)) return 0;
|
|
1443
|
-
// A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
|
|
1444
|
-
// never crash the daemon — that silently stops all delivery, including ask
|
|
1445
|
-
// notifications. Log, back off, and let the run loop retry.
|
|
1446
|
-
console.error("notifications daemon: getUpdates failed:", err);
|
|
1447
|
-
await this.sleep(POLL_BACKOFF_MS, signal);
|
|
1448
|
-
return 0;
|
|
1449
|
-
}
|
|
1450
|
-
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
1451
|
-
// another poller is live; back off boundedly instead of hot-looping.
|
|
1452
|
-
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
1453
|
-
this.pollConflictBackoffMs = Math.min(
|
|
1454
|
-
this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
|
|
1455
|
-
5_000,
|
|
1456
|
-
);
|
|
1457
|
-
console.error(
|
|
1458
|
-
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
|
|
1459
|
-
);
|
|
1460
|
-
await this.sleep(this.pollConflictBackoffMs, signal);
|
|
1461
|
-
return 0;
|
|
1462
|
-
}
|
|
1463
|
-
this.pollConflictBackoffMs = 0;
|
|
1464
|
-
for (const update of body.result ?? []) {
|
|
1465
|
-
this.offset = update.update_id + 1;
|
|
1466
|
-
try {
|
|
1467
|
-
await this.handleTelegramUpdate(update);
|
|
1468
|
-
} catch (err) {
|
|
1469
|
-
console.error("notifications daemon: handleTelegramUpdate failed:", err);
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
return body.result?.length ?? 0;
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
/** Abortable sleep honoring the injected timer; resolves early on abort. */
|
|
1476
|
-
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1477
|
-
return new Promise<void>(resolve => {
|
|
1478
|
-
if (signal?.aborted) return resolve();
|
|
1479
|
-
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
|
|
1480
|
-
signal?.addEventListener(
|
|
1481
|
-
"abort",
|
|
1482
|
-
() => {
|
|
1483
|
-
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
1484
|
-
resolve();
|
|
1485
|
-
},
|
|
1486
|
-
{ once: true },
|
|
1487
|
-
);
|
|
1488
|
-
});
|
|
1583
|
+
return this.poller.pollOnce(signal);
|
|
1489
1584
|
}
|
|
1490
1585
|
|
|
1491
1586
|
/** Sync the bot's Telegram command menu to what the daemon actually handles. */
|
|
@@ -1512,6 +1607,7 @@ export class TelegramNotificationDaemon {
|
|
|
1512
1607
|
pid: this.opts.pid ?? process.pid,
|
|
1513
1608
|
});
|
|
1514
1609
|
if (!this.running) return;
|
|
1610
|
+
this.runtime.start();
|
|
1515
1611
|
this.startFlushTimer();
|
|
1516
1612
|
this.startScanTimer();
|
|
1517
1613
|
this.startTypingTimer();
|
|
@@ -1520,8 +1616,7 @@ export class TelegramNotificationDaemon {
|
|
|
1520
1616
|
await this.loadAliases();
|
|
1521
1617
|
await this.loadTopics();
|
|
1522
1618
|
await this.runScan();
|
|
1523
|
-
let idleSince =
|
|
1524
|
-
let pollBackoffMs = 0;
|
|
1619
|
+
let idleSince = this.runtime.now();
|
|
1525
1620
|
while (this.running) {
|
|
1526
1621
|
if (await this.controlStopRequested()) break;
|
|
1527
1622
|
if (
|
|
@@ -1537,29 +1632,30 @@ export class TelegramNotificationDaemon {
|
|
|
1537
1632
|
await this.runScan();
|
|
1538
1633
|
if (await this.controlStopRequested()) break;
|
|
1539
1634
|
if (this.sessions.size === 0) {
|
|
1540
|
-
if (
|
|
1635
|
+
if (this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
|
|
1541
1636
|
} else {
|
|
1542
|
-
idleSince =
|
|
1543
|
-
|
|
1637
|
+
idleSince = this.runtime.now();
|
|
1638
|
+
const activePoll = this.runtime.createAbortController();
|
|
1544
1639
|
try {
|
|
1545
|
-
await this.pollOnce(
|
|
1546
|
-
|
|
1640
|
+
await this.pollOnce(activePoll.signal);
|
|
1641
|
+
this.loopBackoff.reset();
|
|
1547
1642
|
} catch (e) {
|
|
1548
1643
|
// A transient getUpdates/network failure must not kill the
|
|
1549
1644
|
// daemon. Back off (bounded, below the heartbeat TTL) and keep
|
|
1550
1645
|
// renewing ownership at the loop top.
|
|
1551
|
-
|
|
1552
|
-
logger.warn(`notifications: getUpdates failed, backing off ${
|
|
1553
|
-
await
|
|
1646
|
+
const backoffMs = this.loopBackoff.next();
|
|
1647
|
+
logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
|
|
1648
|
+
await this.runtime.sleep(backoffMs);
|
|
1554
1649
|
continue;
|
|
1555
1650
|
} finally {
|
|
1556
|
-
this.activePoll
|
|
1651
|
+
this.runtime.clearAbortController(activePoll);
|
|
1557
1652
|
}
|
|
1558
1653
|
}
|
|
1559
1654
|
if (await this.controlStopRequested()) break;
|
|
1560
|
-
await
|
|
1655
|
+
await this.runtime.sleep(10);
|
|
1561
1656
|
}
|
|
1562
1657
|
} finally {
|
|
1658
|
+
this.runtime.stop();
|
|
1563
1659
|
this.stopFlushTimer();
|
|
1564
1660
|
this.stopScanTimer();
|
|
1565
1661
|
this.stopTypingTimer();
|
|
@@ -1580,7 +1676,7 @@ export class TelegramNotificationDaemon {
|
|
|
1580
1676
|
|
|
1581
1677
|
/** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
|
|
1582
1678
|
private async controlStopRequested(): Promise<boolean> {
|
|
1583
|
-
if (this.stopRequested) return true;
|
|
1679
|
+
if (this.runtime.stopRequested) return true;
|
|
1584
1680
|
if (!this.opts.control) return false;
|
|
1585
1681
|
try {
|
|
1586
1682
|
return await this.opts.control.shouldStop(this.opts.ownerId);
|