@gajae-code/coding-agent 0.7.1 → 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 +57 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +2 -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/config/settings-schema.d.ts +39 -2
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/lsp/types.d.ts +2 -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/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/telegram-daemon.d.ts +73 -16
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/notifications/topic-registry.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +25 -0
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/notify-cli.ts +152 -5
- package/src/cli.ts +6 -2
- package/src/commands/mcp.ts +117 -0
- package/src/commands/team.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/settings-schema.ts +30 -1
- 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/ralplan/SKILL.md +11 -4
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +17 -3
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -2
- package/src/gjc-runtime/tmux-common.ts +3 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +14 -11
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- 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/event-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +3 -0
- package/src/modes/interactive-mode.ts +48 -3
- 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/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +180 -38
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/telegram-daemon.ts +553 -236
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/notifications/topic-registry.ts +5 -0
- package/src/session/agent-session.ts +82 -51
- 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 +94 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +159 -0
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn as childProcessSpawn } from "node:child_process";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
2
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
3
5
|
import * as path from "node:path";
|
|
4
6
|
import { logger } from "@gajae-code/utils";
|
|
5
7
|
import { withFileLock } from "../config/file-lock";
|
|
@@ -9,6 +11,7 @@ import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
|
9
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
10
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
11
13
|
import { buildButtonGrid, TELEGRAM_PARSE_MODE } from "./html-format";
|
|
14
|
+
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
12
15
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
13
16
|
import {
|
|
14
17
|
type AliasTable,
|
|
@@ -19,7 +22,7 @@ import {
|
|
|
19
22
|
readEndpoint,
|
|
20
23
|
routeInboundUpdate,
|
|
21
24
|
} from "./telegram-reference";
|
|
22
|
-
import { decideThreadedInbound } from "./threaded-inbound";
|
|
25
|
+
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
23
26
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
24
27
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
25
28
|
|
|
@@ -102,6 +105,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
|
|
|
102
105
|
// Native reactions used as a two-stage delivery double-check on inbound thread
|
|
103
106
|
// messages: queued on receipt, consumed once a turn picks the message up.
|
|
104
107
|
const QUEUED_REACTION = "👀";
|
|
108
|
+
const PENDING_TOPIC_FRAME_LIMIT = 20;
|
|
105
109
|
const CONSUMED_REACTION = "✅";
|
|
106
110
|
|
|
107
111
|
/**
|
|
@@ -525,6 +529,154 @@ export interface BotApi {
|
|
|
525
529
|
call(method: string, body: unknown, opts?: { signal?: AbortSignal }): Promise<unknown>;
|
|
526
530
|
}
|
|
527
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
|
+
|
|
528
680
|
/**
|
|
529
681
|
* Cooperative control seam for the daemon run loop. Implemented by the
|
|
530
682
|
* daemon-internal CLI / controller against the owner-scoped control-request
|
|
@@ -573,31 +725,44 @@ interface SessionSocket {
|
|
|
573
725
|
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
574
726
|
}
|
|
575
727
|
|
|
728
|
+
interface PendingThreadedFrame {
|
|
729
|
+
send: ThreadedSend;
|
|
730
|
+
msg: Record<string, unknown>;
|
|
731
|
+
}
|
|
732
|
+
|
|
576
733
|
export class TelegramNotificationDaemon {
|
|
577
734
|
readonly aliasTable: AliasTable;
|
|
578
735
|
readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
|
|
579
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 });
|
|
580
741
|
private running = false;
|
|
581
|
-
private offset = 0;
|
|
582
742
|
private readonly fsImpl: TelegramDaemonFs;
|
|
583
743
|
private readonly botApi: BotApi;
|
|
584
744
|
private readonly topics = new TopicRegistry();
|
|
585
|
-
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId
|
|
586
|
-
private readonly
|
|
587
|
-
private
|
|
588
|
-
|
|
589
|
-
private
|
|
590
|
-
|
|
745
|
+
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
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[]>();
|
|
752
|
+
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
753
|
+
private threadedFallbackNoticeSent = false;
|
|
754
|
+
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
755
|
+
private readonly flatIdentitySent = new Set<string>();
|
|
756
|
+
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
757
|
+
private pairedChatPrivate: boolean | undefined;
|
|
591
758
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
592
|
-
private
|
|
759
|
+
private get busy(): Set<string> {
|
|
760
|
+
return this.dispatchState.busy;
|
|
761
|
+
}
|
|
593
762
|
/** Inbound update id → originating Telegram message, for delivery reactions. */
|
|
594
|
-
private
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
/** Set when a cooperative stop has been requested (signal or control request). */
|
|
598
|
-
private stopRequested = false;
|
|
599
|
-
/** Current bounded backoff after a Telegram getUpdates 409 conflict (0 when healthy). */
|
|
600
|
-
private pollConflictBackoffMs = 0;
|
|
763
|
+
private get inboundReactions(): Map<number, { messageId: number }> {
|
|
764
|
+
return this.dispatchState.inboundReactions;
|
|
765
|
+
}
|
|
601
766
|
|
|
602
767
|
/**
|
|
603
768
|
* Cooperatively stop the daemon: set the stop flag and abort the in-flight
|
|
@@ -605,62 +770,84 @@ export class TelegramNotificationDaemon {
|
|
|
605
770
|
* ~25s getUpdates timeout. Safe to call from a signal handler.
|
|
606
771
|
*/
|
|
607
772
|
requestStop(_reason?: "reload" | "stop" | "signal"): void {
|
|
608
|
-
this.
|
|
773
|
+
this.runtime.requestStop();
|
|
609
774
|
this.running = false;
|
|
610
|
-
this.activePoll?.abort();
|
|
611
775
|
}
|
|
612
776
|
|
|
613
777
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
614
778
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
615
779
|
this.aliasTable = createAliasTable();
|
|
616
|
-
this.botApi =
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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();
|
|
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
|
+
});
|
|
664
851
|
}
|
|
665
852
|
|
|
666
853
|
async loadAliases(): Promise<void> {
|
|
@@ -733,7 +920,7 @@ export class TelegramNotificationDaemon {
|
|
|
733
920
|
void this.handleSessionMessage(session, JSON.parse(String(ev.data))).catch(err => {
|
|
734
921
|
// Surface frame-handling failures (e.g. a rejected ask sendMessage) to
|
|
735
922
|
// the daemon log instead of an invisible unhandled rejection.
|
|
736
|
-
|
|
923
|
+
logger.error("notifications daemon: handleSessionMessage failed", { error: String(err) });
|
|
737
924
|
});
|
|
738
925
|
});
|
|
739
926
|
ws.addEventListener("close", () => {
|
|
@@ -751,7 +938,7 @@ export class TelegramNotificationDaemon {
|
|
|
751
938
|
private startLiveness(session: SessionSocket): void {
|
|
752
939
|
if (session.pingTimer) return;
|
|
753
940
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
754
|
-
const now = () =>
|
|
941
|
+
const now = () => this.runtime.now();
|
|
755
942
|
session.lastPongAt = now();
|
|
756
943
|
session.pingTimer = setIntervalImpl(() => {
|
|
757
944
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
@@ -797,6 +984,7 @@ export class TelegramNotificationDaemon {
|
|
|
797
984
|
"context_update",
|
|
798
985
|
"turn_stream",
|
|
799
986
|
"image_attachment",
|
|
987
|
+
"file_attachment",
|
|
800
988
|
"config_update",
|
|
801
989
|
]);
|
|
802
990
|
|
|
@@ -813,10 +1001,65 @@ export class TelegramNotificationDaemon {
|
|
|
813
1001
|
return `GJC ${sessionId.slice(-6)}`;
|
|
814
1002
|
}
|
|
815
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
|
+
|
|
816
1058
|
/**
|
|
817
1059
|
* Resolve (creating once via `createForumTopic`) the forum topic for a
|
|
818
|
-
* session.
|
|
819
|
-
* `undefined
|
|
1060
|
+
* session. On capability failure (e.g. Threaded Mode off) this returns
|
|
1061
|
+
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
1062
|
+
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
820
1063
|
*/
|
|
821
1064
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
822
1065
|
const existing = this.topics.get(sessionId);
|
|
@@ -857,26 +1100,140 @@ export class TelegramNotificationDaemon {
|
|
|
857
1100
|
if (raw && typeof raw === "object") this.topics.load(raw);
|
|
858
1101
|
}
|
|
859
1102
|
|
|
1103
|
+
/** Download a Telegram file by its file_path (from getFile) into memory. */
|
|
1104
|
+
private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
|
|
1105
|
+
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
1106
|
+
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
1107
|
+
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
1108
|
+
// (traversal/absolute/backslash) and percent-encode each component before
|
|
1109
|
+
// composing the download URL.
|
|
1110
|
+
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
1111
|
+
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
1115
|
+
const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await fetchImpl(url);
|
|
1118
|
+
if (!res.ok) return undefined;
|
|
1119
|
+
return Buffer.from(await res.arrayBuffer());
|
|
1120
|
+
} catch (e) {
|
|
1121
|
+
logger.warn(`notifications: file download failed: ${String(e)}`);
|
|
1122
|
+
return undefined;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Per-session private temp directories (mode 0700) holding inbound non-image
|
|
1128
|
+
* attachments. Keyed by session id and reused across transient reconnects;
|
|
1129
|
+
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
1130
|
+
*/
|
|
1131
|
+
private readonly attachmentDirs = new Map<string, string>();
|
|
1132
|
+
|
|
1133
|
+
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
1134
|
+
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
1135
|
+
const existing = this.attachmentDirs.get(sessionId);
|
|
1136
|
+
if (existing) return existing;
|
|
1137
|
+
// mkdtemp creates a directory with an unguessable suffix and 0700 perms;
|
|
1138
|
+
// chmod defensively in case of an unusual platform/umask.
|
|
1139
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-telegram-"));
|
|
1140
|
+
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
|
|
1141
|
+
this.attachmentDirs.set(sessionId, dir);
|
|
1142
|
+
return dir;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** Remove all per-session attachment directories. Called on daemon shutdown. */
|
|
1146
|
+
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
1147
|
+
const dirs = [...this.attachmentDirs.values()];
|
|
1148
|
+
this.attachmentDirs.clear();
|
|
1149
|
+
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Resolve an inbound attachment to inline image bytes (forwarded as images) or
|
|
1154
|
+
* a securely-saved file path note (non-images). Non-image bytes are written
|
|
1155
|
+
* into a private per-session temp dir (0700) under an unguessable name via an
|
|
1156
|
+
* exclusive 0600 create (`wx`), so the files are not world-readable and the
|
|
1157
|
+
* write never follows a pre-existing symlink. The directory is removed when the
|
|
1158
|
+
* daemon stops. Returns base64 images to inline plus human-readable file notes
|
|
1159
|
+
* to append to the injected text.
|
|
1160
|
+
*/
|
|
1161
|
+
private async resolveInboundAttachment(
|
|
1162
|
+
att: InboundAttachment,
|
|
1163
|
+
sessionId: string,
|
|
1164
|
+
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
1165
|
+
const images: { data: string; mime?: string }[] = [];
|
|
1166
|
+
const fileNotes: string[] = [];
|
|
1167
|
+
const label = att.fileName ?? att.kind;
|
|
1168
|
+
try {
|
|
1169
|
+
const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
|
|
1170
|
+
result?: { file_path?: unknown };
|
|
1171
|
+
};
|
|
1172
|
+
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
1173
|
+
if (!filePath) {
|
|
1174
|
+
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
1175
|
+
return { images, fileNotes };
|
|
1176
|
+
}
|
|
1177
|
+
const bytes = await this.downloadTelegramFile(filePath);
|
|
1178
|
+
if (!bytes) {
|
|
1179
|
+
fileNotes.push(`[attachment download failed: ${label}]`);
|
|
1180
|
+
return { images, fileNotes };
|
|
1181
|
+
}
|
|
1182
|
+
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
1183
|
+
if (isImage) {
|
|
1184
|
+
images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
|
|
1185
|
+
} else {
|
|
1186
|
+
const safeBase =
|
|
1187
|
+
(att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
|
|
1188
|
+
.replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
|
|
1189
|
+
.replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
|
|
1190
|
+
.replace(/^[.-]+/, "_") // no leading dot/hyphen
|
|
1191
|
+
.slice(-128) || "file";
|
|
1192
|
+
const dir = await this.ensureAttachmentDir(sessionId);
|
|
1193
|
+
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
1194
|
+
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
1195
|
+
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
1196
|
+
await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
|
|
1197
|
+
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
1198
|
+
}
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
1201
|
+
fileNotes.push(`[attachment error: ${label}]`);
|
|
1202
|
+
}
|
|
1203
|
+
return { images, fileNotes };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
860
1206
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
861
1207
|
private async flushPool(): Promise<void> {
|
|
862
1208
|
for (const item of this.pool.drain()) {
|
|
863
1209
|
const { send, topicId } = item.payload;
|
|
864
|
-
|
|
1210
|
+
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
1211
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
865
1212
|
try {
|
|
866
1213
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
867
1214
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
868
1215
|
await this.botApi.call("sendPhoto", {
|
|
869
1216
|
chat_id: this.opts.chatId,
|
|
870
|
-
|
|
1217
|
+
...threadField,
|
|
871
1218
|
photo: send.photoBase64,
|
|
872
1219
|
mime: send.mime,
|
|
873
1220
|
caption: send.text,
|
|
874
1221
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
875
1222
|
});
|
|
1223
|
+
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
1224
|
+
await this.botApi.call("sendDocument", {
|
|
1225
|
+
chat_id: this.opts.chatId,
|
|
1226
|
+
...threadField,
|
|
1227
|
+
document: send.documentBase64,
|
|
1228
|
+
mime: send.mime,
|
|
1229
|
+
fileName: send.fileName,
|
|
1230
|
+
caption: send.text,
|
|
1231
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1232
|
+
});
|
|
876
1233
|
} else if (send.text) {
|
|
877
1234
|
await this.botApi.call("sendMessage", {
|
|
878
1235
|
chat_id: this.opts.chatId,
|
|
879
|
-
|
|
1236
|
+
...threadField,
|
|
880
1237
|
text: send.text,
|
|
881
1238
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
882
1239
|
});
|
|
@@ -887,47 +1244,84 @@ export class TelegramNotificationDaemon {
|
|
|
887
1244
|
}
|
|
888
1245
|
}
|
|
889
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* Threaded Mode is unavailable (the bot owner has not enabled forum topics in
|
|
1249
|
+
* @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
|
|
1250
|
+
* the paired chat instead of dropping it, and nudge the user once. Flat delivery
|
|
1251
|
+
* is gated on the paired chat being a private chat: for a group/supergroup/channel
|
|
1252
|
+
* (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
|
|
1253
|
+
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
1254
|
+
* session in flat mode.
|
|
1255
|
+
*/
|
|
1256
|
+
private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
|
|
1257
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1258
|
+
await this.notifyThreadedFallback();
|
|
1259
|
+
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
1260
|
+
this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
|
|
1261
|
+
await this.flushPool();
|
|
1262
|
+
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Resolve once (cached) whether the paired `chatId` is a private chat. Flat
|
|
1267
|
+
* fallback is only safe in a private DM; any non-private chat or an unresolvable
|
|
1268
|
+
* `getChat` is treated as not-private so delivery fails closed.
|
|
1269
|
+
*/
|
|
1270
|
+
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
1271
|
+
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
1272
|
+
try {
|
|
1273
|
+
const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
|
|
1274
|
+
result?: { type?: string };
|
|
1275
|
+
};
|
|
1276
|
+
this.pairedChatPrivate = res.result?.type === "private";
|
|
1277
|
+
} catch {
|
|
1278
|
+
this.pairedChatPrivate = false;
|
|
1279
|
+
}
|
|
1280
|
+
return this.pairedChatPrivate;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
1284
|
+
private async notifyThreadedFallback(): Promise<void> {
|
|
1285
|
+
if (this.threadedFallbackNoticeSent) return;
|
|
1286
|
+
this.threadedFallbackNoticeSent = true;
|
|
1287
|
+
try {
|
|
1288
|
+
await this.botApi.call("sendMessage", {
|
|
1289
|
+
chat_id: this.opts.chatId,
|
|
1290
|
+
text: "turn on threaded mode from botfather miniapp to receive gjc notification!",
|
|
1291
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1292
|
+
});
|
|
1293
|
+
} catch {
|
|
1294
|
+
// Best-effort nudge; never block delivery.
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
890
1298
|
private startFlushTimer(): void {
|
|
891
|
-
|
|
892
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
893
|
-
this.flushTimer = setIntervalImpl(() => {
|
|
1299
|
+
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
894
1300
|
if (!this.running || this.pool.pending === 0) return;
|
|
895
1301
|
void this.flushPool();
|
|
896
|
-
}
|
|
1302
|
+
});
|
|
897
1303
|
}
|
|
898
1304
|
|
|
899
1305
|
private stopFlushTimer(): void {
|
|
900
|
-
|
|
901
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
902
|
-
clearIntervalImpl(this.flushTimer);
|
|
903
|
-
this.flushTimer = undefined;
|
|
1306
|
+
this.runtime.stopInterval("telegram-flush");
|
|
904
1307
|
}
|
|
905
1308
|
|
|
906
1309
|
/** Run a root scan, guarding against overlapping scans from the timer + loop. */
|
|
907
1310
|
private async runScan(): Promise<void> {
|
|
908
|
-
|
|
909
|
-
this.scanning = true;
|
|
910
|
-
try {
|
|
1311
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
911
1312
|
await this.scanRoots();
|
|
912
|
-
}
|
|
913
|
-
this.scanning = false;
|
|
914
|
-
}
|
|
1313
|
+
});
|
|
915
1314
|
}
|
|
916
1315
|
|
|
917
1316
|
private startScanTimer(): void {
|
|
918
|
-
|
|
919
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
920
|
-
this.scanTimer = setIntervalImpl(() => {
|
|
1317
|
+
this.runtime.startInterval("telegram-scan", this.opts.scanIntervalMs ?? SESSION_SCAN_INTERVAL_MS, () => {
|
|
921
1318
|
if (!this.running) return;
|
|
922
1319
|
void this.runScan();
|
|
923
|
-
}
|
|
1320
|
+
});
|
|
924
1321
|
}
|
|
925
1322
|
|
|
926
1323
|
private stopScanTimer(): void {
|
|
927
|
-
|
|
928
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
929
|
-
clearIntervalImpl(this.scanTimer);
|
|
930
|
-
this.scanTimer = undefined;
|
|
1324
|
+
this.runtime.stopInterval("telegram-scan");
|
|
931
1325
|
}
|
|
932
1326
|
|
|
933
1327
|
/** Send a single `typing` chat action into a busy session's topic (best-effort). */
|
|
@@ -959,64 +1353,43 @@ export class TelegramNotificationDaemon {
|
|
|
959
1353
|
}
|
|
960
1354
|
|
|
961
1355
|
private startTypingTimer(): void {
|
|
962
|
-
|
|
963
|
-
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
964
|
-
this.typingTimer = setIntervalImpl(() => {
|
|
1356
|
+
this.runtime.startInterval("telegram-typing", TYPING_REFRESH_INTERVAL_MS, () => {
|
|
965
1357
|
if (!this.running || this.busy.size === 0) return;
|
|
966
1358
|
for (const sessionId of this.busy) void this.sendTyping(sessionId);
|
|
967
|
-
}
|
|
1359
|
+
});
|
|
968
1360
|
}
|
|
969
1361
|
|
|
970
1362
|
private stopTypingTimer(): void {
|
|
971
|
-
|
|
972
|
-
const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
|
|
973
|
-
clearIntervalImpl(this.typingTimer);
|
|
974
|
-
this.typingTimer = undefined;
|
|
1363
|
+
this.runtime.stopInterval("telegram-typing");
|
|
975
1364
|
}
|
|
976
1365
|
|
|
977
1366
|
async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
|
|
978
|
-
if (msg
|
|
979
|
-
const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
980
|
-
if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
|
|
981
|
-
session.capable = true;
|
|
982
|
-
this.startLiveness(session);
|
|
983
|
-
}
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (msg?.type === "pong") {
|
|
987
|
-
if (typeof msg.nonce === "string" && msg.nonce === session.awaitingNonce) {
|
|
988
|
-
session.awaitingNonce = undefined;
|
|
989
|
-
session.lastPongAt = (this.opts.now ?? Date.now)();
|
|
990
|
-
}
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
// Live typing indicator: track busy/idle per session and push an immediate
|
|
994
|
-
// chat action so "typing…" appears without waiting for the refresh tick.
|
|
995
|
-
if (msg?.type === "activity") {
|
|
996
|
-
if (msg.state === "busy") {
|
|
997
|
-
this.busy.add(session.sessionId);
|
|
998
|
-
await this.sendTyping(session.sessionId);
|
|
999
|
-
} else {
|
|
1000
|
-
this.busy.delete(session.sessionId);
|
|
1001
|
-
}
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1004
|
-
// Inbound delivery double-check: flip the queued reaction to the consumed
|
|
1005
|
-
// reaction once the session reports a turn picked the message up.
|
|
1006
|
-
if (msg?.type === "inbound_ack" && typeof msg.updateId === "number") {
|
|
1007
|
-
const target = this.inboundReactions.get(msg.updateId);
|
|
1008
|
-
if (target && msg.state === "consumed") {
|
|
1009
|
-
this.inboundReactions.delete(msg.updateId);
|
|
1010
|
-
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
1011
|
-
}
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1367
|
+
if (await this.sessionRouter.dispatch(session, msg as Record<string, unknown>)) return;
|
|
1014
1368
|
if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
|
|
1015
1369
|
const send = renderThreadedFrame(msg);
|
|
1016
1370
|
if (!send) return;
|
|
1017
|
-
const
|
|
1018
|
-
if (!
|
|
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
|
+
}
|
|
1019
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)));
|
|
1386
|
+
if (!topicId) {
|
|
1387
|
+
await this.deliverFlatFallback(session.sessionId, send);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (send.identity) {
|
|
1391
|
+
const identityKey = this.topicIdentityKey(msg);
|
|
1392
|
+
if (identityKey) this.topicOwnerByIdentity.set(identityKey, session.sessionId);
|
|
1020
1393
|
// Rename the topic if the title changed (e.g. the session title was
|
|
1021
1394
|
// auto-generated after the topic was first created). This runs on
|
|
1022
1395
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
@@ -1034,31 +1407,25 @@ export class TelegramNotificationDaemon {
|
|
|
1034
1407
|
}
|
|
1035
1408
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
1036
1409
|
if (this.topics.needsIdentity(session.sessionId)) {
|
|
1037
|
-
this.
|
|
1038
|
-
sessionId: session.sessionId,
|
|
1039
|
-
lane: send.lane,
|
|
1040
|
-
coalesceKey: send.coalesceKey,
|
|
1041
|
-
payload: { send, topicId },
|
|
1042
|
-
});
|
|
1043
|
-
await this.flushPool();
|
|
1410
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1044
1411
|
this.topics.markIdentitySent(session.sessionId);
|
|
1045
1412
|
}
|
|
1413
|
+
await this.flushPendingThreadedFrames(session.sessionId, topicId);
|
|
1046
1414
|
await this.persistTopics();
|
|
1047
1415
|
return;
|
|
1048
1416
|
}
|
|
1049
|
-
this.
|
|
1050
|
-
sessionId: session.sessionId,
|
|
1051
|
-
lane: send.lane,
|
|
1052
|
-
coalesceKey: send.coalesceKey,
|
|
1053
|
-
payload: { send, topicId },
|
|
1054
|
-
});
|
|
1055
|
-
await this.flushPool();
|
|
1417
|
+
await this.submitThreadedFrame(session.sessionId, send, topicId);
|
|
1056
1418
|
return;
|
|
1057
1419
|
}
|
|
1058
1420
|
if (msg.type === "action_needed" && msg.id) {
|
|
1059
1421
|
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
|
|
1060
1422
|
const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
|
|
1061
|
-
if (!topicId)
|
|
1423
|
+
if (!topicId) {
|
|
1424
|
+
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
1425
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1426
|
+
await this.notifyThreadedFallback();
|
|
1427
|
+
}
|
|
1428
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
1062
1429
|
const rendered = buildActionMessage({
|
|
1063
1430
|
kind: msg.kind ?? "ask",
|
|
1064
1431
|
id: msg.id,
|
|
@@ -1074,7 +1441,7 @@ export class TelegramNotificationDaemon {
|
|
|
1074
1441
|
);
|
|
1075
1442
|
const result = (await this.botApi.call("sendMessage", {
|
|
1076
1443
|
chat_id: this.opts.chatId,
|
|
1077
|
-
|
|
1444
|
+
...threadField,
|
|
1078
1445
|
text: rendered.text,
|
|
1079
1446
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1080
1447
|
...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
@@ -1131,19 +1498,26 @@ export class TelegramNotificationDaemon {
|
|
|
1131
1498
|
const inbound = decideThreadedInbound(update as never, {
|
|
1132
1499
|
pairedChatId: this.opts.chatId,
|
|
1133
1500
|
topicToSession: t => this.topics.sessionForTopic(t),
|
|
1134
|
-
isDuplicate: id => this.seenUpdateIds.has(id),
|
|
1501
|
+
isDuplicate: id => this.dispatchState.seenUpdateIds.has(id),
|
|
1135
1502
|
});
|
|
1136
1503
|
if (inbound.kind === "duplicate") return;
|
|
1137
1504
|
if (inbound.kind === "inject") {
|
|
1138
|
-
this.seenUpdateIds.add(inbound.updateId);
|
|
1505
|
+
this.dispatchState.seenUpdateIds.add(inbound.updateId);
|
|
1139
1506
|
const session = this.sessions.get(inbound.sessionId);
|
|
1140
1507
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1141
|
-
const
|
|
1508
|
+
const attachmentResult = inbound.attachment
|
|
1509
|
+
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
1510
|
+
: undefined;
|
|
1511
|
+
const images = attachmentResult?.images ?? [];
|
|
1512
|
+
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
1513
|
+
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
1514
|
+
const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
1515
|
+
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
1142
1516
|
// A plain (non-config) message while an ask is pending for this session
|
|
1143
1517
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
1144
1518
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
1145
1519
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
1146
|
-
const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
|
|
1520
|
+
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
1147
1521
|
if (pendingAsk) {
|
|
1148
1522
|
session.ws.send(
|
|
1149
1523
|
JSON.stringify({
|
|
@@ -1163,10 +1537,11 @@ export class TelegramNotificationDaemon {
|
|
|
1163
1537
|
: {
|
|
1164
1538
|
type: "user_message",
|
|
1165
1539
|
sessionId: inbound.sessionId,
|
|
1166
|
-
text:
|
|
1540
|
+
text: injectedText,
|
|
1167
1541
|
token: session.token,
|
|
1168
1542
|
updateId: inbound.updateId,
|
|
1169
1543
|
threadId: inbound.threadId,
|
|
1544
|
+
images,
|
|
1170
1545
|
},
|
|
1171
1546
|
),
|
|
1172
1547
|
);
|
|
@@ -1205,67 +1580,7 @@ export class TelegramNotificationDaemon {
|
|
|
1205
1580
|
}
|
|
1206
1581
|
|
|
1207
1582
|
async pollOnce(signal?: AbortSignal): Promise<number> {
|
|
1208
|
-
|
|
1209
|
-
ok?: boolean;
|
|
1210
|
-
error_code?: number;
|
|
1211
|
-
description?: string;
|
|
1212
|
-
result?: Array<{ update_id: number } & Record<string, unknown>>;
|
|
1213
|
-
};
|
|
1214
|
-
try {
|
|
1215
|
-
body = (await this.botApi.call(
|
|
1216
|
-
"getUpdates",
|
|
1217
|
-
{ offset: this.offset, timeout: 25, allowed_updates: ["message", "callback_query"] },
|
|
1218
|
-
{ signal },
|
|
1219
|
-
)) as typeof body;
|
|
1220
|
-
} catch (err) {
|
|
1221
|
-
// A cooperative stop aborts the in-flight long poll; treat as a clean wake.
|
|
1222
|
-
if (isAbortError(err)) return 0;
|
|
1223
|
-
// A transient Telegram API failure (e.g. ECONNRESET on the long-poll) must
|
|
1224
|
-
// never crash the daemon — that silently stops all delivery, including ask
|
|
1225
|
-
// notifications. Log, back off, and let the run loop retry.
|
|
1226
|
-
console.error("notifications daemon: getUpdates failed:", err);
|
|
1227
|
-
await this.sleep(POLL_BACKOFF_MS, signal);
|
|
1228
|
-
return 0;
|
|
1229
|
-
}
|
|
1230
|
-
// Telegram allows only one active getUpdates poller per bot. A 409 means
|
|
1231
|
-
// another poller is live; back off boundedly instead of hot-looping.
|
|
1232
|
-
if (body && body.ok === false && (body.error_code === 409 || /409|conflict/i.test(body.description ?? ""))) {
|
|
1233
|
-
this.pollConflictBackoffMs = Math.min(
|
|
1234
|
-
this.pollConflictBackoffMs ? this.pollConflictBackoffMs * 2 : 500,
|
|
1235
|
-
5_000,
|
|
1236
|
-
);
|
|
1237
|
-
console.error(
|
|
1238
|
-
`notifications daemon: Telegram getUpdates 409 conflict (${body.description ?? "no description"}); backing off ${this.pollConflictBackoffMs}ms`,
|
|
1239
|
-
);
|
|
1240
|
-
await this.sleep(this.pollConflictBackoffMs, signal);
|
|
1241
|
-
return 0;
|
|
1242
|
-
}
|
|
1243
|
-
this.pollConflictBackoffMs = 0;
|
|
1244
|
-
for (const update of body.result ?? []) {
|
|
1245
|
-
this.offset = update.update_id + 1;
|
|
1246
|
-
try {
|
|
1247
|
-
await this.handleTelegramUpdate(update);
|
|
1248
|
-
} catch (err) {
|
|
1249
|
-
console.error("notifications daemon: handleTelegramUpdate failed:", err);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
return body.result?.length ?? 0;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
/** Abortable sleep honoring the injected timer; resolves early on abort. */
|
|
1256
|
-
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1257
|
-
return new Promise<void>(resolve => {
|
|
1258
|
-
if (signal?.aborted) return resolve();
|
|
1259
|
-
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
|
|
1260
|
-
signal?.addEventListener(
|
|
1261
|
-
"abort",
|
|
1262
|
-
() => {
|
|
1263
|
-
(this.opts.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
1264
|
-
resolve();
|
|
1265
|
-
},
|
|
1266
|
-
{ once: true },
|
|
1267
|
-
);
|
|
1268
|
-
});
|
|
1583
|
+
return this.poller.pollOnce(signal);
|
|
1269
1584
|
}
|
|
1270
1585
|
|
|
1271
1586
|
/** Sync the bot's Telegram command menu to what the daemon actually handles. */
|
|
@@ -1292,6 +1607,7 @@ export class TelegramNotificationDaemon {
|
|
|
1292
1607
|
pid: this.opts.pid ?? process.pid,
|
|
1293
1608
|
});
|
|
1294
1609
|
if (!this.running) return;
|
|
1610
|
+
this.runtime.start();
|
|
1295
1611
|
this.startFlushTimer();
|
|
1296
1612
|
this.startScanTimer();
|
|
1297
1613
|
this.startTypingTimer();
|
|
@@ -1300,8 +1616,7 @@ export class TelegramNotificationDaemon {
|
|
|
1300
1616
|
await this.loadAliases();
|
|
1301
1617
|
await this.loadTopics();
|
|
1302
1618
|
await this.runScan();
|
|
1303
|
-
let idleSince =
|
|
1304
|
-
let pollBackoffMs = 0;
|
|
1619
|
+
let idleSince = this.runtime.now();
|
|
1305
1620
|
while (this.running) {
|
|
1306
1621
|
if (await this.controlStopRequested()) break;
|
|
1307
1622
|
if (
|
|
@@ -1317,32 +1632,34 @@ export class TelegramNotificationDaemon {
|
|
|
1317
1632
|
await this.runScan();
|
|
1318
1633
|
if (await this.controlStopRequested()) break;
|
|
1319
1634
|
if (this.sessions.size === 0) {
|
|
1320
|
-
if (
|
|
1635
|
+
if (this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000)) break;
|
|
1321
1636
|
} else {
|
|
1322
|
-
idleSince =
|
|
1323
|
-
|
|
1637
|
+
idleSince = this.runtime.now();
|
|
1638
|
+
const activePoll = this.runtime.createAbortController();
|
|
1324
1639
|
try {
|
|
1325
|
-
await this.pollOnce(
|
|
1326
|
-
|
|
1640
|
+
await this.pollOnce(activePoll.signal);
|
|
1641
|
+
this.loopBackoff.reset();
|
|
1327
1642
|
} catch (e) {
|
|
1328
1643
|
// A transient getUpdates/network failure must not kill the
|
|
1329
1644
|
// daemon. Back off (bounded, below the heartbeat TTL) and keep
|
|
1330
1645
|
// renewing ownership at the loop top.
|
|
1331
|
-
|
|
1332
|
-
logger.warn(`notifications: getUpdates failed, backing off ${
|
|
1333
|
-
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);
|
|
1334
1649
|
continue;
|
|
1335
1650
|
} finally {
|
|
1336
|
-
this.activePoll
|
|
1651
|
+
this.runtime.clearAbortController(activePoll);
|
|
1337
1652
|
}
|
|
1338
1653
|
}
|
|
1339
1654
|
if (await this.controlStopRequested()) break;
|
|
1340
|
-
await
|
|
1655
|
+
await this.runtime.sleep(10);
|
|
1341
1656
|
}
|
|
1342
1657
|
} finally {
|
|
1658
|
+
this.runtime.stop();
|
|
1343
1659
|
this.stopFlushTimer();
|
|
1344
1660
|
this.stopScanTimer();
|
|
1345
1661
|
this.stopTypingTimer();
|
|
1662
|
+
await this.cleanupAllAttachmentDirs();
|
|
1346
1663
|
// Persist durable state before releasing ownership so a fresh daemon
|
|
1347
1664
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
1348
1665
|
await this.persistAliases().catch(() => undefined);
|
|
@@ -1359,7 +1676,7 @@ export class TelegramNotificationDaemon {
|
|
|
1359
1676
|
|
|
1360
1677
|
/** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */
|
|
1361
1678
|
private async controlStopRequested(): Promise<boolean> {
|
|
1362
|
-
if (this.stopRequested) return true;
|
|
1679
|
+
if (this.runtime.stopRequested) return true;
|
|
1363
1680
|
if (!this.opts.control) return false;
|
|
1364
1681
|
try {
|
|
1365
1682
|
return await this.opts.control.shouldStop(this.opts.ownerId);
|