@gajae-code/coding-agent 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +2 -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/telegram-daemon.d.ts +19 -0
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +2 -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 +22 -0
- 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/notify-cli.ts +152 -5
- package/src/cli.ts +1 -3
- package/src/commands/team.ts +1 -1
- package/src/config/settings-schema.ts +30 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
- package/src/edit/modes/replace.ts +1 -1
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +27 -5
- 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 +8 -0
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/hashline/hash.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/interactive-mode.ts +46 -2
- 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 +224 -45
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/telegram-daemon.ts +235 -14
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/session/agent-session.ts +82 -51
- package/src/tools/ask.ts +3 -2
- package/src/tools/fetch.ts +78 -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 +155 -0
- 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";
|
|
@@ -19,7 +21,7 @@ import {
|
|
|
19
21
|
readEndpoint,
|
|
20
22
|
routeInboundUpdate,
|
|
21
23
|
} from "./telegram-reference";
|
|
22
|
-
import { decideThreadedInbound } from "./threaded-inbound";
|
|
24
|
+
import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound";
|
|
23
25
|
import { renderThreadedFrame, type ThreadedSend } from "./threaded-render";
|
|
24
26
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
25
27
|
|
|
@@ -582,8 +584,14 @@ export class TelegramNotificationDaemon {
|
|
|
582
584
|
private readonly fsImpl: TelegramDaemonFs;
|
|
583
585
|
private readonly botApi: BotApi;
|
|
584
586
|
private readonly topics = new TopicRegistry();
|
|
585
|
-
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId
|
|
587
|
+
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
586
588
|
private readonly seenUpdateIds = new Set<number>();
|
|
589
|
+
/** True once the daemon has nudged the user to enable Threaded Mode. */
|
|
590
|
+
private threadedFallbackNoticeSent = false;
|
|
591
|
+
/** Sessions whose identity header was already sent flat (Threaded Mode off). */
|
|
592
|
+
private readonly flatIdentitySent = new Set<string>();
|
|
593
|
+
/** Cached result of whether the paired chat is a private chat (flat-fallback gate). */
|
|
594
|
+
private pairedChatPrivate: boolean | undefined;
|
|
587
595
|
private flushTimer: ReturnType<typeof setInterval> | undefined;
|
|
588
596
|
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
589
597
|
private scanning = false;
|
|
@@ -646,6 +654,35 @@ export class TelegramNotificationDaemon {
|
|
|
646
654
|
);
|
|
647
655
|
return res.json();
|
|
648
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
|
+
}
|
|
649
686
|
const res = await fetchWithRetry(
|
|
650
687
|
fetchImpl,
|
|
651
688
|
url,
|
|
@@ -660,7 +697,7 @@ export class TelegramNotificationDaemon {
|
|
|
660
697
|
return res.json();
|
|
661
698
|
},
|
|
662
699
|
};
|
|
663
|
-
this.pool = new RateLimitPool<{ send: ThreadedSend; topicId
|
|
700
|
+
this.pool = new RateLimitPool<{ send: ThreadedSend; topicId?: string }>({ now: opts.now });
|
|
664
701
|
}
|
|
665
702
|
|
|
666
703
|
async loadAliases(): Promise<void> {
|
|
@@ -797,6 +834,7 @@ export class TelegramNotificationDaemon {
|
|
|
797
834
|
"context_update",
|
|
798
835
|
"turn_stream",
|
|
799
836
|
"image_attachment",
|
|
837
|
+
"file_attachment",
|
|
800
838
|
"config_update",
|
|
801
839
|
]);
|
|
802
840
|
|
|
@@ -815,8 +853,9 @@ export class TelegramNotificationDaemon {
|
|
|
815
853
|
|
|
816
854
|
/**
|
|
817
855
|
* Resolve (creating once via `createForumTopic`) the forum topic for a
|
|
818
|
-
* session.
|
|
819
|
-
* `undefined
|
|
856
|
+
* session. On capability failure (e.g. Threaded Mode off) this returns
|
|
857
|
+
* `undefined`; callers then flat-deliver to a private paired chat (with a
|
|
858
|
+
* one-time nudge) or drop fail-closed for a non-private chat.
|
|
820
859
|
*/
|
|
821
860
|
private async ensureTopic(sessionId: string, name: string): Promise<string | undefined> {
|
|
822
861
|
const existing = this.topics.get(sessionId);
|
|
@@ -857,26 +896,140 @@ export class TelegramNotificationDaemon {
|
|
|
857
896
|
if (raw && typeof raw === "object") this.topics.load(raw);
|
|
858
897
|
}
|
|
859
898
|
|
|
899
|
+
/** Download a Telegram file by its file_path (from getFile) into memory. */
|
|
900
|
+
private async downloadTelegramFile(filePath: string): Promise<Buffer | undefined> {
|
|
901
|
+
const apiBase = this.opts.apiBase ?? "https://api.telegram.org";
|
|
902
|
+
const fetchImpl = this.opts.fetchImpl ?? fetch;
|
|
903
|
+
// `filePath` is remote metadata from getFile; reject suspicious segments
|
|
904
|
+
// (traversal/absolute/backslash) and percent-encode each component before
|
|
905
|
+
// composing the download URL.
|
|
906
|
+
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) {
|
|
907
|
+
logger.warn("notifications: rejecting suspicious Telegram file_path");
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
911
|
+
const url = `${apiBase}/file/bot${this.opts.botToken}/${encodedPath}`;
|
|
912
|
+
try {
|
|
913
|
+
const res = await fetchImpl(url);
|
|
914
|
+
if (!res.ok) return undefined;
|
|
915
|
+
return Buffer.from(await res.arrayBuffer());
|
|
916
|
+
} catch (e) {
|
|
917
|
+
logger.warn(`notifications: file download failed: ${String(e)}`);
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Per-session private temp directories (mode 0700) holding inbound non-image
|
|
924
|
+
* attachments. Keyed by session id and reused across transient reconnects;
|
|
925
|
+
* removed when the daemon stops (see {@link cleanupAllAttachmentDirs}).
|
|
926
|
+
*/
|
|
927
|
+
private readonly attachmentDirs = new Map<string, string>();
|
|
928
|
+
|
|
929
|
+
/** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */
|
|
930
|
+
private async ensureAttachmentDir(sessionId: string): Promise<string> {
|
|
931
|
+
const existing = this.attachmentDirs.get(sessionId);
|
|
932
|
+
if (existing) return existing;
|
|
933
|
+
// mkdtemp creates a directory with an unguessable suffix and 0700 perms;
|
|
934
|
+
// chmod defensively in case of an unusual platform/umask.
|
|
935
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gjc-telegram-"));
|
|
936
|
+
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
|
|
937
|
+
this.attachmentDirs.set(sessionId, dir);
|
|
938
|
+
return dir;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** Remove all per-session attachment directories. Called on daemon shutdown. */
|
|
942
|
+
private async cleanupAllAttachmentDirs(): Promise<void> {
|
|
943
|
+
const dirs = [...this.attachmentDirs.values()];
|
|
944
|
+
this.attachmentDirs.clear();
|
|
945
|
+
await Promise.all(dirs.map(dir => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Resolve an inbound attachment to inline image bytes (forwarded as images) or
|
|
950
|
+
* a securely-saved file path note (non-images). Non-image bytes are written
|
|
951
|
+
* into a private per-session temp dir (0700) under an unguessable name via an
|
|
952
|
+
* exclusive 0600 create (`wx`), so the files are not world-readable and the
|
|
953
|
+
* write never follows a pre-existing symlink. The directory is removed when the
|
|
954
|
+
* daemon stops. Returns base64 images to inline plus human-readable file notes
|
|
955
|
+
* to append to the injected text.
|
|
956
|
+
*/
|
|
957
|
+
private async resolveInboundAttachment(
|
|
958
|
+
att: InboundAttachment,
|
|
959
|
+
sessionId: string,
|
|
960
|
+
): Promise<{ images: { data: string; mime?: string }[]; fileNotes: string[] }> {
|
|
961
|
+
const images: { data: string; mime?: string }[] = [];
|
|
962
|
+
const fileNotes: string[] = [];
|
|
963
|
+
const label = att.fileName ?? att.kind;
|
|
964
|
+
try {
|
|
965
|
+
const got = (await this.botApi.call("getFile", { file_id: att.fileId })) as {
|
|
966
|
+
result?: { file_path?: unknown };
|
|
967
|
+
};
|
|
968
|
+
const filePath = typeof got?.result?.file_path === "string" ? got.result.file_path : undefined;
|
|
969
|
+
if (!filePath) {
|
|
970
|
+
fileNotes.push(`[attachment unavailable: ${label}]`);
|
|
971
|
+
return { images, fileNotes };
|
|
972
|
+
}
|
|
973
|
+
const bytes = await this.downloadTelegramFile(filePath);
|
|
974
|
+
if (!bytes) {
|
|
975
|
+
fileNotes.push(`[attachment download failed: ${label}]`);
|
|
976
|
+
return { images, fileNotes };
|
|
977
|
+
}
|
|
978
|
+
const isImage = att.kind === "photo" || (typeof att.mime === "string" && att.mime.startsWith("image/"));
|
|
979
|
+
if (isImage) {
|
|
980
|
+
images.push({ data: bytes.toString("base64"), mime: att.mime ?? "image/jpeg" });
|
|
981
|
+
} else {
|
|
982
|
+
const safeBase =
|
|
983
|
+
(att.fileName?.trim() || path.basename(filePath) || `${att.kind}-${att.fileId}`)
|
|
984
|
+
.replace(/[^\w.-]+/g, "_") // drop path separators and unusual chars
|
|
985
|
+
.replace(/\.\.+/g, "_") // neutralize any ".." traversal-looking runs
|
|
986
|
+
.replace(/^[.-]+/, "_") // no leading dot/hyphen
|
|
987
|
+
.slice(-128) || "file";
|
|
988
|
+
const dir = await this.ensureAttachmentDir(sessionId);
|
|
989
|
+
// Unguessable, non-colliding name inside the private 0700 dir; the
|
|
990
|
+
// exclusive 0600 create (`wx`) refuses to follow a pre-existing file/symlink.
|
|
991
|
+
const dest = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safeBase}`);
|
|
992
|
+
await fs.promises.writeFile(dest, bytes, { flag: "wx", mode: 0o600 });
|
|
993
|
+
fileNotes.push(`[user attached a file, saved to ${dest}${att.mime ? ` (${att.mime})` : ""}]`);
|
|
994
|
+
}
|
|
995
|
+
} catch (e) {
|
|
996
|
+
logger.warn(`notifications: inbound attachment failed: ${String(e)}`);
|
|
997
|
+
fileNotes.push(`[attachment error: ${label}]`);
|
|
998
|
+
}
|
|
999
|
+
return { images, fileNotes };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
860
1002
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
861
1003
|
private async flushPool(): Promise<void> {
|
|
862
1004
|
for (const item of this.pool.drain()) {
|
|
863
1005
|
const { send, topicId } = item.payload;
|
|
864
|
-
|
|
1006
|
+
// Threaded topic when available; otherwise deliver flat to the paired chat.
|
|
1007
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
865
1008
|
try {
|
|
866
1009
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
867
1010
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
868
1011
|
await this.botApi.call("sendPhoto", {
|
|
869
1012
|
chat_id: this.opts.chatId,
|
|
870
|
-
|
|
1013
|
+
...threadField,
|
|
871
1014
|
photo: send.photoBase64,
|
|
872
1015
|
mime: send.mime,
|
|
873
1016
|
caption: send.text,
|
|
874
1017
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
875
1018
|
});
|
|
1019
|
+
} else if (send.method === "sendDocument" && send.documentBase64) {
|
|
1020
|
+
await this.botApi.call("sendDocument", {
|
|
1021
|
+
chat_id: this.opts.chatId,
|
|
1022
|
+
...threadField,
|
|
1023
|
+
document: send.documentBase64,
|
|
1024
|
+
mime: send.mime,
|
|
1025
|
+
fileName: send.fileName,
|
|
1026
|
+
caption: send.text,
|
|
1027
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1028
|
+
});
|
|
876
1029
|
} else if (send.text) {
|
|
877
1030
|
await this.botApi.call("sendMessage", {
|
|
878
1031
|
chat_id: this.opts.chatId,
|
|
879
|
-
|
|
1032
|
+
...threadField,
|
|
880
1033
|
text: send.text,
|
|
881
1034
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
882
1035
|
});
|
|
@@ -887,6 +1040,57 @@ export class TelegramNotificationDaemon {
|
|
|
887
1040
|
}
|
|
888
1041
|
}
|
|
889
1042
|
|
|
1043
|
+
/**
|
|
1044
|
+
* Threaded Mode is unavailable (the bot owner has not enabled forum topics in
|
|
1045
|
+
* @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to
|
|
1046
|
+
* the paired chat instead of dropping it, and nudge the user once. Flat delivery
|
|
1047
|
+
* is gated on the paired chat being a private chat: for a group/supergroup/channel
|
|
1048
|
+
* (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session
|
|
1049
|
+
* content never lands in a shared chat. Identity headers are sent at most once per
|
|
1050
|
+
* session in flat mode.
|
|
1051
|
+
*/
|
|
1052
|
+
private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
|
|
1053
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1054
|
+
await this.notifyThreadedFallback();
|
|
1055
|
+
if (send.identity && this.flatIdentitySent.has(sessionId)) return;
|
|
1056
|
+
this.pool.submit({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
|
|
1057
|
+
await this.flushPool();
|
|
1058
|
+
if (send.identity) this.flatIdentitySent.add(sessionId);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Resolve once (cached) whether the paired `chatId` is a private chat. Flat
|
|
1063
|
+
* fallback is only safe in a private DM; any non-private chat or an unresolvable
|
|
1064
|
+
* `getChat` is treated as not-private so delivery fails closed.
|
|
1065
|
+
*/
|
|
1066
|
+
private async pairedChatIsPrivate(): Promise<boolean> {
|
|
1067
|
+
if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate;
|
|
1068
|
+
try {
|
|
1069
|
+
const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as {
|
|
1070
|
+
result?: { type?: string };
|
|
1071
|
+
};
|
|
1072
|
+
this.pairedChatPrivate = res.result?.type === "private";
|
|
1073
|
+
} catch {
|
|
1074
|
+
this.pairedChatPrivate = false;
|
|
1075
|
+
}
|
|
1076
|
+
return this.pairedChatPrivate;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Tell the user once (per daemon run) how to enable Threaded Mode. */
|
|
1080
|
+
private async notifyThreadedFallback(): Promise<void> {
|
|
1081
|
+
if (this.threadedFallbackNoticeSent) return;
|
|
1082
|
+
this.threadedFallbackNoticeSent = true;
|
|
1083
|
+
try {
|
|
1084
|
+
await this.botApi.call("sendMessage", {
|
|
1085
|
+
chat_id: this.opts.chatId,
|
|
1086
|
+
text: "turn on threaded mode from botfather miniapp to receive gjc notification!",
|
|
1087
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1088
|
+
});
|
|
1089
|
+
} catch {
|
|
1090
|
+
// Best-effort nudge; never block delivery.
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
890
1094
|
private startFlushTimer(): void {
|
|
891
1095
|
if (this.flushTimer) return;
|
|
892
1096
|
const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval;
|
|
@@ -1015,7 +1219,10 @@ export class TelegramNotificationDaemon {
|
|
|
1015
1219
|
const send = renderThreadedFrame(msg);
|
|
1016
1220
|
if (!send) return;
|
|
1017
1221
|
const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
|
|
1018
|
-
if (!topicId)
|
|
1222
|
+
if (!topicId) {
|
|
1223
|
+
await this.deliverFlatFallback(session.sessionId, send);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1019
1226
|
if (send.identity) {
|
|
1020
1227
|
// Rename the topic if the title changed (e.g. the session title was
|
|
1021
1228
|
// auto-generated after the topic was first created). This runs on
|
|
@@ -1058,7 +1265,12 @@ export class TelegramNotificationDaemon {
|
|
|
1058
1265
|
if (msg.type === "action_needed" && msg.id) {
|
|
1059
1266
|
if (msg.kind === "ask") session.pending.set(msg.id, { sessionId: session.sessionId, actionId: msg.id });
|
|
1060
1267
|
const topicId = await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg));
|
|
1061
|
-
if (!topicId)
|
|
1268
|
+
if (!topicId) {
|
|
1269
|
+
// Fail closed for non-private chats; only nudge + flat-deliver in a private DM.
|
|
1270
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
1271
|
+
await this.notifyThreadedFallback();
|
|
1272
|
+
}
|
|
1273
|
+
const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
|
|
1062
1274
|
const rendered = buildActionMessage({
|
|
1063
1275
|
kind: msg.kind ?? "ask",
|
|
1064
1276
|
id: msg.id,
|
|
@@ -1074,7 +1286,7 @@ export class TelegramNotificationDaemon {
|
|
|
1074
1286
|
);
|
|
1075
1287
|
const result = (await this.botApi.call("sendMessage", {
|
|
1076
1288
|
chat_id: this.opts.chatId,
|
|
1077
|
-
|
|
1289
|
+
...threadField,
|
|
1078
1290
|
text: rendered.text,
|
|
1079
1291
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1080
1292
|
...(inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
@@ -1138,12 +1350,19 @@ export class TelegramNotificationDaemon {
|
|
|
1138
1350
|
this.seenUpdateIds.add(inbound.updateId);
|
|
1139
1351
|
const session = this.sessions.get(inbound.sessionId);
|
|
1140
1352
|
if (session?.ws.readyState === WebSocket.OPEN) {
|
|
1141
|
-
const
|
|
1353
|
+
const attachmentResult = inbound.attachment
|
|
1354
|
+
? await this.resolveInboundAttachment(inbound.attachment, inbound.sessionId)
|
|
1355
|
+
: undefined;
|
|
1356
|
+
const images = attachmentResult?.images ?? [];
|
|
1357
|
+
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
1358
|
+
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
1359
|
+
const injectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
1360
|
+
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
1142
1361
|
// A plain (non-config) message while an ask is pending for this session
|
|
1143
1362
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
1144
1363
|
// Telegram asks always accept custom text (the SDK maps a string answer
|
|
1145
1364
|
// to the ask's custom-input slot), so route the latest pending ask here.
|
|
1146
|
-
const pendingAsk = cfg ? undefined : [...session.pending.values()].at(-1);
|
|
1365
|
+
const pendingAsk = cfg || hasMedia ? undefined : [...session.pending.values()].at(-1);
|
|
1147
1366
|
if (pendingAsk) {
|
|
1148
1367
|
session.ws.send(
|
|
1149
1368
|
JSON.stringify({
|
|
@@ -1163,10 +1382,11 @@ export class TelegramNotificationDaemon {
|
|
|
1163
1382
|
: {
|
|
1164
1383
|
type: "user_message",
|
|
1165
1384
|
sessionId: inbound.sessionId,
|
|
1166
|
-
text:
|
|
1385
|
+
text: injectedText,
|
|
1167
1386
|
token: session.token,
|
|
1168
1387
|
updateId: inbound.updateId,
|
|
1169
1388
|
threadId: inbound.threadId,
|
|
1389
|
+
images,
|
|
1170
1390
|
},
|
|
1171
1391
|
),
|
|
1172
1392
|
);
|
|
@@ -1343,6 +1563,7 @@ export class TelegramNotificationDaemon {
|
|
|
1343
1563
|
this.stopFlushTimer();
|
|
1344
1564
|
this.stopScanTimer();
|
|
1345
1565
|
this.stopTypingTimer();
|
|
1566
|
+
await this.cleanupAllAttachmentDirs();
|
|
1346
1567
|
// Persist durable state before releasing ownership so a fresh daemon
|
|
1347
1568
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
1348
1569
|
await this.persistAliases().catch(() => undefined);
|
|
@@ -21,11 +21,30 @@ export interface InboundUpdate {
|
|
|
21
21
|
message?: {
|
|
22
22
|
message_id?: unknown;
|
|
23
23
|
text?: unknown;
|
|
24
|
+
caption?: unknown;
|
|
25
|
+
photo?: unknown;
|
|
26
|
+
document?: unknown;
|
|
27
|
+
video?: unknown;
|
|
28
|
+
audio?: unknown;
|
|
29
|
+
voice?: unknown;
|
|
30
|
+
animation?: unknown;
|
|
24
31
|
chat?: { id?: unknown };
|
|
25
32
|
message_thread_id?: unknown;
|
|
26
33
|
};
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** A downloadable media attachment referenced by an inbound message. */
|
|
37
|
+
export interface InboundAttachment {
|
|
38
|
+
/** Telegram file_id to resolve via getFile. */
|
|
39
|
+
fileId: string;
|
|
40
|
+
/** Source media kind; "photo" is always an image. */
|
|
41
|
+
kind: "photo" | "document" | "video" | "audio" | "voice" | "animation";
|
|
42
|
+
/** MIME type when Telegram provides one. */
|
|
43
|
+
mime?: string;
|
|
44
|
+
/** Original file name when provided. */
|
|
45
|
+
fileName?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** Context for {@link decideThreadedInbound}. All lookups are injected. */
|
|
30
49
|
export interface ThreadedInboundCtx {
|
|
31
50
|
/** The single paired chat id (string-compared). */
|
|
@@ -38,7 +57,15 @@ export interface ThreadedInboundCtx {
|
|
|
38
57
|
|
|
39
58
|
/** Outcome of routing an inbound update. */
|
|
40
59
|
export type ThreadedInboundDecision =
|
|
41
|
-
| {
|
|
60
|
+
| {
|
|
61
|
+
kind: "inject";
|
|
62
|
+
sessionId: string;
|
|
63
|
+
text: string;
|
|
64
|
+
updateId: number;
|
|
65
|
+
threadId: string;
|
|
66
|
+
messageId?: number;
|
|
67
|
+
attachment?: InboundAttachment;
|
|
68
|
+
}
|
|
42
69
|
| { kind: "duplicate"; updateId: number }
|
|
43
70
|
| { kind: "ignore"; reason: string };
|
|
44
71
|
|
|
@@ -48,6 +75,29 @@ function asString(value: unknown): string | undefined {
|
|
|
48
75
|
return undefined;
|
|
49
76
|
}
|
|
50
77
|
|
|
78
|
+
function extractAttachment(message: NonNullable<InboundUpdate["message"]>): InboundAttachment | undefined {
|
|
79
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
80
|
+
const photo = message.photo[message.photo.length - 1];
|
|
81
|
+
if (photo && typeof photo === "object" && "file_id" in photo && typeof photo.file_id === "string") {
|
|
82
|
+
return { fileId: photo.file_id, kind: "photo", mime: "image/jpeg" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const kind of ["document", "video", "audio", "voice", "animation"] as const) {
|
|
87
|
+
const file = message[kind];
|
|
88
|
+
if (file && typeof file === "object" && "file_id" in file && typeof file.file_id === "string") {
|
|
89
|
+
return {
|
|
90
|
+
fileId: file.file_id,
|
|
91
|
+
kind,
|
|
92
|
+
mime: "mime_type" in file && typeof file.mime_type === "string" ? file.mime_type : undefined,
|
|
93
|
+
fileName: "file_name" in file && typeof file.file_name === "string" ? file.file_name : undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
51
101
|
/**
|
|
52
102
|
* Decide whether an inbound update should inject a user turn. Fail-closed:
|
|
53
103
|
* returns `ignore` (with a reason) or `duplicate` for anything that is not an
|
|
@@ -72,9 +122,15 @@ export function decideThreadedInbound(update: InboundUpdate, ctx: ThreadedInboun
|
|
|
72
122
|
const updateId = update.update_id;
|
|
73
123
|
if (ctx.isDuplicate(updateId)) return { kind: "duplicate", updateId };
|
|
74
124
|
|
|
75
|
-
const text =
|
|
76
|
-
|
|
125
|
+
const text =
|
|
126
|
+
typeof message.text === "string"
|
|
127
|
+
? message.text.trim()
|
|
128
|
+
: typeof message.caption === "string"
|
|
129
|
+
? message.caption.trim()
|
|
130
|
+
: "";
|
|
131
|
+
const attachment = extractAttachment(message);
|
|
132
|
+
if (!text && attachment === undefined) return { kind: "ignore", reason: "empty_text" };
|
|
77
133
|
|
|
78
134
|
const messageId = typeof message.message_id === "number" ? message.message_id : undefined;
|
|
79
|
-
return { kind: "inject", sessionId, text, updateId, threadId, messageId };
|
|
135
|
+
return { kind: "inject", sessionId, text, updateId, threadId, messageId, attachment };
|
|
80
136
|
}
|
|
@@ -15,15 +15,19 @@ import type { RateLimitLane } from "./rate-limit-pool";
|
|
|
15
15
|
|
|
16
16
|
/** A Telegram send derived from a threaded frame (topic id is applied by the daemon). */
|
|
17
17
|
export interface ThreadedSend {
|
|
18
|
-
method: "sendMessage" | "sendPhoto";
|
|
18
|
+
method: "sendMessage" | "sendPhoto" | "sendDocument";
|
|
19
19
|
/** Rate-limit lane for prioritisation/fairness. */
|
|
20
20
|
lane: RateLimitLane;
|
|
21
21
|
/** Message text (sendMessage) or photo caption (sendPhoto). */
|
|
22
22
|
text?: string;
|
|
23
23
|
/** Base64 image bytes for sendPhoto. */
|
|
24
24
|
photoBase64?: string;
|
|
25
|
+
/** Base64 file bytes for sendDocument. */
|
|
26
|
+
documentBase64?: string;
|
|
25
27
|
/** Image MIME type for sendPhoto. */
|
|
26
28
|
mime?: string;
|
|
29
|
+
/** Suggested document filename. */
|
|
30
|
+
fileName?: string;
|
|
27
31
|
/** Coalesce key for live edits (same key collapses to the latest). */
|
|
28
32
|
coalesceKey?: string;
|
|
29
33
|
/** True for the one-time identity header (the daemon pins it once). */
|
|
@@ -49,11 +53,12 @@ interface ThreadedFrame {
|
|
|
49
53
|
phase?: unknown;
|
|
50
54
|
text?: unknown;
|
|
51
55
|
messageRef?: unknown;
|
|
52
|
-
// image_attachment
|
|
56
|
+
// image_attachment / file_attachment
|
|
53
57
|
source?: unknown;
|
|
54
58
|
data?: unknown;
|
|
55
59
|
mime?: unknown;
|
|
56
60
|
caption?: unknown;
|
|
61
|
+
name?: unknown;
|
|
57
62
|
// config_update
|
|
58
63
|
verbosity?: unknown;
|
|
59
64
|
redact?: unknown;
|
|
@@ -141,6 +146,19 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
|
|
|
141
146
|
text: finalizeTelegramHtml(caption === undefined ? undefined : escapeHtml(caption)),
|
|
142
147
|
};
|
|
143
148
|
}
|
|
149
|
+
case "file_attachment": {
|
|
150
|
+
const data = str(frame.data);
|
|
151
|
+
if (!data) return undefined;
|
|
152
|
+
const caption = str(frame.caption);
|
|
153
|
+
return {
|
|
154
|
+
method: "sendDocument",
|
|
155
|
+
lane: "finalized",
|
|
156
|
+
documentBase64: data,
|
|
157
|
+
mime: str(frame.mime),
|
|
158
|
+
fileName: str(frame.name),
|
|
159
|
+
text: finalizeTelegramHtml(caption === undefined ? undefined : escapeHtml(caption)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
144
162
|
case "config_update": {
|
|
145
163
|
const verbosity = str(frame.verbosity);
|
|
146
164
|
const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;
|