talon-agent 5.27.0 → 5.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app.ts +26 -10
- package/src/core/agents/runner.ts +59 -9
- package/src/core/agents/types.ts +14 -2
- package/src/core/backup/{restore-guard.ts → restore/guard.ts} +5 -5
- package/src/core/backup/restore/notice.ts +164 -0
- package/src/core/backup/restore.ts +17 -5
- package/src/core/config/index.ts +4 -0
- package/src/core/mesh/credentials/store.ts +9 -1
- package/src/core/mesh/persist.ts +9 -0
- package/src/core/tools/ops/agents.ts +4 -4
- package/src/frontend/discord/actions/channels.ts +6 -3
- package/src/frontend/discord/actions/index.ts +24 -0
- package/src/frontend/discord/commands/backup.ts +1 -0
- package/src/frontend/discord/handlers/messages.ts +6 -4
- package/src/frontend/discord/handlers/registry.ts +35 -0
- package/src/frontend/native/commands/backup.ts +1 -0
- package/src/frontend/native/turn/actions.ts +29 -1
- package/src/frontend/telegram/actions/chat-info.ts +22 -5
- package/src/frontend/telegram/commands/backup.ts +1 -0
- package/src/frontend/telegram/handlers/messages.ts +7 -5
- package/src/frontend/whatsapp/messages/media-store.ts +4 -2
- package/src/storage/media-index.ts +43 -11
- package/src/storage/repositories/media-index-repo.ts +12 -1
- package/src/storage/sql/media-index.sql +4 -0
- package/src/storage/sql/statements.generated.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.29.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "The Falconry",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"@kilocode/sdk": "^7.2.22",
|
|
112
112
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
113
113
|
"@openai/agents": "^0.18.0",
|
|
114
|
-
"@openai/codex-sdk": "^0.
|
|
114
|
+
"@openai/codex-sdk": "^0.156.1",
|
|
115
115
|
"@opencode-ai/sdk": "^1.17.4",
|
|
116
116
|
"@playwright/mcp": "0.0.56",
|
|
117
117
|
"@types/cross-spawn": "^6.0.6",
|
package/src/app.ts
CHANGED
|
@@ -128,10 +128,18 @@ stampDaemonOwner();
|
|
|
128
128
|
*
|
|
129
129
|
* Never throws: a failed restore still boots the daemon (with the reason
|
|
130
130
|
* in the log and the request deleted, so the next boot is normal).
|
|
131
|
+
* Returns the confirmation line and who asked for it, so it can be sent
|
|
132
|
+
* back to that chat once the frontends are up.
|
|
131
133
|
*/
|
|
132
|
-
async function applyStagedRestore(): Promise<
|
|
134
|
+
async function applyStagedRestore(): Promise<{
|
|
135
|
+
text: string;
|
|
136
|
+
requestedBy?: string;
|
|
137
|
+
frontend?: string;
|
|
138
|
+
} | null> {
|
|
133
139
|
const { applyPendingRestore, readRestorePending } =
|
|
134
140
|
await import("./core/backup/index.js");
|
|
141
|
+
const { formatRestoreNotice } =
|
|
142
|
+
await import("./core/backup/restore/notice.js");
|
|
135
143
|
if (!(await readRestorePending())) return null;
|
|
136
144
|
const { loadConfig } = await import("./core/config/index.js");
|
|
137
145
|
const { resolveBackupSettings } = await import("./core/backup/plan.js");
|
|
@@ -141,12 +149,11 @@ async function applyStagedRestore(): Promise<string | null> {
|
|
|
141
149
|
beforeApply: closeDatabase,
|
|
142
150
|
});
|
|
143
151
|
if (!report) return null;
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
);
|
|
152
|
+
return {
|
|
153
|
+
text: formatRestoreNotice(report),
|
|
154
|
+
requestedBy: report.requestedBy,
|
|
155
|
+
frontend: report.frontend,
|
|
156
|
+
};
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
/**
|
|
@@ -546,12 +553,21 @@ async function main(): Promise<void> {
|
|
|
546
553
|
// Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
|
|
547
554
|
// moment the frontends are listening, so the totals are folded into the
|
|
548
555
|
// metrics store here, from the same uptime figure the log line prints.
|
|
549
|
-
// A restore applied at boot happened before any frontend existed, so
|
|
550
|
-
//
|
|
556
|
+
// A restore applied at boot happened before any frontend existed, so it
|
|
557
|
+
// is reported here, on the first channels that can carry it: the chat
|
|
558
|
+
// that asked for it, or the admin's primary chat when that one can't be
|
|
559
|
+
// reached. Not awaited — delivery may retry while a frontend finishes
|
|
560
|
+
// connecting, and the boot shouldn't wait on a courtesy message.
|
|
551
561
|
if (restoreReport) {
|
|
552
562
|
const { notifyAdmin } =
|
|
553
563
|
await import("./core/frontend-runtime/admin-notify.js");
|
|
554
|
-
|
|
564
|
+
const { deliverRestoreNotice } =
|
|
565
|
+
await import("./core/backup/restore/notice.js");
|
|
566
|
+
void deliverRestoreNotice({
|
|
567
|
+
text: restoreReport.text,
|
|
568
|
+
requester: restoreReport,
|
|
569
|
+
notifyAdmin,
|
|
570
|
+
});
|
|
555
571
|
}
|
|
556
572
|
// Same reasoning for a crash: the process that died couldn't say so,
|
|
557
573
|
// so the marker it left is announced now. The probes start here too —
|
|
@@ -112,7 +112,10 @@ export function initAgents(
|
|
|
112
112
|
"agents",
|
|
113
113
|
`Initialized — maxConcurrent=${capsHolder.caps.maxConcurrent} ` +
|
|
114
114
|
`maxDepth=${capsHolder.caps.maxDepth} ` +
|
|
115
|
-
`timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s
|
|
115
|
+
`timeout=${Math.round(capsHolder.caps.defaultTimeoutMs / 1000)}s` +
|
|
116
|
+
(capsHolder.caps.allowedBackends?.length
|
|
117
|
+
? ` allowedBackends=${capsHolder.caps.allowedBackends.join(",")}`
|
|
118
|
+
: ""),
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -137,19 +140,37 @@ function inheritedBackendId(parent: AgentParent): string | null {
|
|
|
137
140
|
return agentRegistry.get(parent.agentId)?.backendId ?? null;
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/** Where a spawn lands: backend, the model it inherits, and why. */
|
|
144
|
+
interface SpawnTarget {
|
|
145
|
+
readonly backendId: string | null;
|
|
146
|
+
/** The parent agent's model, inherited by an unpinned child. */
|
|
147
|
+
readonly inheritedModel?: string;
|
|
148
|
+
readonly routing?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
/**
|
|
141
152
|
* Which backend this agent runs on, and why.
|
|
142
153
|
*
|
|
143
154
|
* An explicit backend (or model — a model id is backend-specific, so naming
|
|
144
|
-
* one pins its backend) is honoured as written.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
155
|
+
* one pins its backend) is honoured as written. A child of another agent
|
|
156
|
+
* with neither inherits its parent's backend *and* model: a tree of agents
|
|
157
|
+
* stays on the backend its root was put on, so a parent that chose (or was
|
|
158
|
+
* told to use) a backend does not see its children wander off to another
|
|
159
|
+
* subscription. A top-level spawn with neither is a routing decision:
|
|
160
|
+
* sub-agents are isolated one-shots with no session to keep warm, so they
|
|
161
|
+
* are the cheapest work to move onto whichever subscription has room.
|
|
148
162
|
*/
|
|
149
|
-
async function resolveSpawnBackend(
|
|
150
|
-
spec: AgentSpawnSpec,
|
|
151
|
-
): Promise<{ backendId: string | null; routing?: string }> {
|
|
163
|
+
async function resolveSpawnBackend(spec: AgentSpawnSpec): Promise<SpawnTarget> {
|
|
152
164
|
if (spec.backendId) return { backendId: spec.backendId };
|
|
165
|
+
if (spec.parent.kind === "agent" && !spec.model) {
|
|
166
|
+
const parent = agentRegistry.get(spec.parent.agentId);
|
|
167
|
+
if (parent) {
|
|
168
|
+
return {
|
|
169
|
+
backendId: parent.backendId,
|
|
170
|
+
...(parent.model ? { inheritedModel: parent.model } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
153
174
|
const inherited = inheritedBackendId(spec.parent);
|
|
154
175
|
if (!inherited) return { backendId: null };
|
|
155
176
|
const taskClass = taskClassForEffort(spec.reasoningEffort);
|
|
@@ -166,12 +187,37 @@ async function resolveSpawnBackend(
|
|
|
166
187
|
}
|
|
167
188
|
: {}),
|
|
168
189
|
});
|
|
190
|
+
// A routed pick outside the allowlist falls back to the inherited
|
|
191
|
+
// backend rather than refusing a spawn the caller never pinned.
|
|
192
|
+
if (
|
|
193
|
+
decision.routed &&
|
|
194
|
+
!isBackendAllowed(decision.backendId) &&
|
|
195
|
+
isBackendAllowed(inherited)
|
|
196
|
+
) {
|
|
197
|
+
return { backendId: inherited };
|
|
198
|
+
}
|
|
169
199
|
return {
|
|
170
200
|
backendId: decision.backendId,
|
|
171
201
|
...(decision.routed ? { routing: decision.reason } : {}),
|
|
172
202
|
};
|
|
173
203
|
}
|
|
174
204
|
|
|
205
|
+
/** Whether `agents.allowedBackends` (when set) lets an agent run here. */
|
|
206
|
+
function isBackendAllowed(backendId: string): boolean {
|
|
207
|
+
const allowed = capsHolder.caps.allowedBackends;
|
|
208
|
+
return !allowed || allowed.length === 0 || allowed.includes(backendId);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The tool error for a backend outside `agents.allowedBackends`. */
|
|
212
|
+
function disallowedBackendError(backendId: string): string {
|
|
213
|
+
const allowed = capsHolder.caps.allowedBackends ?? [];
|
|
214
|
+
return (
|
|
215
|
+
`Backend "${backendId}" is not allowed for sub-agents ` +
|
|
216
|
+
`(agents.allowedBackends: ${allowed.join(", ")}). Pass one of those ` +
|
|
217
|
+
`as backend, or leave it unset to inherit.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
175
221
|
/** The chat a run's task belongs to, for `talon ps`. */
|
|
176
222
|
function taskChatId(parent: AgentParent): string | undefined {
|
|
177
223
|
if (parent.kind === "chat") return parent.chatId;
|
|
@@ -262,6 +308,10 @@ export async function spawnAgent(
|
|
|
262
308
|
"Could not resolve a backend for this agent — pass one explicitly.",
|
|
263
309
|
};
|
|
264
310
|
}
|
|
311
|
+
if (!isBackendAllowed(backendId)) {
|
|
312
|
+
return { ok: false, error: disallowedBackendError(backendId) };
|
|
313
|
+
}
|
|
314
|
+
const model = spec.model ?? routed.inheritedModel;
|
|
265
315
|
|
|
266
316
|
// Register first: the slot and the depth are claimed synchronously, so two
|
|
267
317
|
// concurrent spawns can never both slip past maxConcurrent while awaiting
|
|
@@ -296,7 +346,7 @@ export async function spawnAgent(
|
|
|
296
346
|
};
|
|
297
347
|
}
|
|
298
348
|
|
|
299
|
-
const resolved = await resolveRun(acquired.backend, backendId,
|
|
349
|
+
const resolved = await resolveRun(acquired.backend, backendId, model);
|
|
300
350
|
if (!resolved.ok) {
|
|
301
351
|
agentRegistry.discard(record.id);
|
|
302
352
|
await acquired.release();
|
package/src/core/agents/types.ts
CHANGED
|
@@ -94,9 +94,16 @@ export interface AgentSpawnSpec {
|
|
|
94
94
|
readonly brief: string;
|
|
95
95
|
readonly label: string;
|
|
96
96
|
readonly parent: AgentParent;
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Unset: a child of another agent inherits its parent's backend (and,
|
|
99
|
+
* with no model either, its model); a top-level spawn starts from the
|
|
100
|
+
* chat's backend and may be routed.
|
|
101
|
+
*/
|
|
98
102
|
readonly backendId?: string;
|
|
99
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Defaults to the parent agent's model when the backend is inherited from
|
|
105
|
+
* one, else the resolved backend's own default model.
|
|
106
|
+
*/
|
|
100
107
|
readonly model?: string;
|
|
101
108
|
readonly reasoningEffort?: ReasoningEffortLevel;
|
|
102
109
|
/** Hard wall-clock cap. Defaults to `agents.defaultTimeoutMs`. */
|
|
@@ -132,4 +139,9 @@ export interface AgentCaps {
|
|
|
132
139
|
readonly maxDepth: number;
|
|
133
140
|
/** Default hard timeout for one run. */
|
|
134
141
|
readonly defaultTimeoutMs: number;
|
|
142
|
+
/**
|
|
143
|
+
* Backends a sub-agent may run on. Unset or empty = any backend with a
|
|
144
|
+
* background capability. Enforced by `spawnAgent` on the final choice.
|
|
145
|
+
*/
|
|
146
|
+
readonly allowedBackends?: readonly string[];
|
|
135
147
|
}
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { chmod } from "node:fs/promises";
|
|
25
|
-
import { logWarn } from "
|
|
26
|
-
import { TalonError } from "
|
|
27
|
-
import { verifyManifest } from "
|
|
28
|
-
import { resolvePassphrase } from "
|
|
29
|
-
import type { BackupSettings, Manifest } from "
|
|
25
|
+
import { logWarn } from "../../../util/log.js";
|
|
26
|
+
import { TalonError } from "../../errors.js";
|
|
27
|
+
import { verifyManifest } from "../archive/manifest-auth.js";
|
|
28
|
+
import { resolvePassphrase } from "../passphrase.js";
|
|
29
|
+
import type { BackupSettings, Manifest } from "../types.js";
|
|
30
30
|
|
|
31
31
|
export type ManifestTrust = {
|
|
32
32
|
/** Operator override for unauthenticated manifests. */
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting a staged restore back to the chat that asked for it.
|
|
3
|
+
*
|
|
4
|
+
* `/backup restore <id>` stages the request and restarts; the restore runs
|
|
5
|
+
* in the next boot before any frontend exists (see `applyStagedRestore` in
|
|
6
|
+
* app.ts). Once the frontends are up, the "♻️ Restored snapshot …" line is
|
|
7
|
+
* delivered here: to the requesting chat, on the frontend it came from,
|
|
8
|
+
* and — when that chat can't be reached (frontend disabled, delivery
|
|
9
|
+
* failing) or the request never said who asked — to the operator's
|
|
10
|
+
* primary chat through the admin notifier, which is where it always went
|
|
11
|
+
* before.
|
|
12
|
+
*
|
|
13
|
+
* Core never imports src/frontend, so delivery goes through the same
|
|
14
|
+
* cross-send broker `send_via` uses: each enabled frontend's action
|
|
15
|
+
* handler, keyed by frontend name.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { log, logWarn } from "../../../util/log.js";
|
|
19
|
+
import {
|
|
20
|
+
isNativeChatId,
|
|
21
|
+
isTelegramChatId,
|
|
22
|
+
numericChatIdFor,
|
|
23
|
+
} from "../../frontend-runtime/chat-id.js";
|
|
24
|
+
import { crossSendTarget } from "../../engine/gateway-actions/cross-send.js";
|
|
25
|
+
import type { RestoreReport } from "../restore.js";
|
|
26
|
+
|
|
27
|
+
/** Who asked for a staged restore, as recorded in restore-pending.json. */
|
|
28
|
+
export type RestoreRequester = {
|
|
29
|
+
/** The requesting chat's key (Telegram id, `d_…`, `discord_…`). */
|
|
30
|
+
requestedBy?: string;
|
|
31
|
+
/** The frontend the request came from. Absent in files staged before it existed. */
|
|
32
|
+
frontend?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The frontend a requester belongs to: the recorded one when the request
|
|
37
|
+
* carries it, else inferred from the shape of the chat key — Telegram's
|
|
38
|
+
* ids are numeric, native's start `d_`, Discord's `discord_`. Undefined
|
|
39
|
+
* when neither says.
|
|
40
|
+
*/
|
|
41
|
+
export function requesterFrontend(
|
|
42
|
+
requester: RestoreRequester,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const explicit =
|
|
45
|
+
typeof requester.frontend === "string"
|
|
46
|
+
? requester.frontend.trim().toLowerCase()
|
|
47
|
+
: "";
|
|
48
|
+
if (explicit) return explicit;
|
|
49
|
+
const key =
|
|
50
|
+
typeof requester.requestedBy === "string" ? requester.requestedBy : "";
|
|
51
|
+
if (!key) return undefined;
|
|
52
|
+
if (isTelegramChatId(key)) return "telegram";
|
|
53
|
+
if (isNativeChatId(key)) return "native";
|
|
54
|
+
if (key.startsWith("discord_")) return "discord";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The confirmation line a successful staged restore reports. */
|
|
59
|
+
export function formatRestoreNotice(
|
|
60
|
+
report: Pick<RestoreReport, "id" | "checkpointId">,
|
|
61
|
+
): string {
|
|
62
|
+
return (
|
|
63
|
+
`♻️ Restored snapshot ${report.id}` +
|
|
64
|
+
(report.checkpointId
|
|
65
|
+
? ` (previous state saved as checkpoint ${report.checkpointId})`
|
|
66
|
+
: "")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Send `text` to one chat through its frontend's registered action
|
|
72
|
+
* handler. The chat key rides in `target` so a frontend that has not
|
|
73
|
+
* seen the chat since the restart (a Discord channel nobody has spoken
|
|
74
|
+
* in yet, a native chat the restored database doesn't list) can adopt it.
|
|
75
|
+
* Resolves true only when the frontend reports the message delivered.
|
|
76
|
+
*/
|
|
77
|
+
async function sendToRequester(
|
|
78
|
+
frontend: string,
|
|
79
|
+
chatKey: string,
|
|
80
|
+
text: string,
|
|
81
|
+
): Promise<boolean> {
|
|
82
|
+
const handler = crossSendTarget(frontend);
|
|
83
|
+
if (!handler) return false;
|
|
84
|
+
const result = await handler(
|
|
85
|
+
{ action: "send_message", text, target: chatKey },
|
|
86
|
+
numericChatIdFor(chatKey),
|
|
87
|
+
);
|
|
88
|
+
return Boolean(result && result.ok === true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const RESTORE_NOTICE_ATTEMPTS = 6;
|
|
92
|
+
const RESTORE_NOTICE_DELAY_MS = 5_000;
|
|
93
|
+
|
|
94
|
+
export type RestoreNoticeOptions = {
|
|
95
|
+
text: string;
|
|
96
|
+
requester: RestoreRequester;
|
|
97
|
+
/** The operator's primary chat — the fallback. */
|
|
98
|
+
notifyAdmin: (text: string) => Promise<unknown>;
|
|
99
|
+
send?: (frontend: string, chatKey: string, text: string) => Promise<boolean>;
|
|
100
|
+
/** Whether a frontend is enabled at all (no point retrying one that isn't). */
|
|
101
|
+
isEnabled?: (frontend: string) => boolean;
|
|
102
|
+
attempts?: number;
|
|
103
|
+
sleep?: (ms: number) => Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Deliver the restore notice to the requesting chat, falling back to the
|
|
108
|
+
* admin's primary chat. Retries a while when the requester's frontend is
|
|
109
|
+
* enabled but can't deliver yet — the frontends have just started, and a
|
|
110
|
+
* Discord client may still be logging in. Never throws; resolves with
|
|
111
|
+
* where the notice went.
|
|
112
|
+
*/
|
|
113
|
+
export async function deliverRestoreNotice(
|
|
114
|
+
options: RestoreNoticeOptions,
|
|
115
|
+
): Promise<"requester" | "admin"> {
|
|
116
|
+
const {
|
|
117
|
+
text,
|
|
118
|
+
requester,
|
|
119
|
+
notifyAdmin,
|
|
120
|
+
send = sendToRequester,
|
|
121
|
+
isEnabled = (name) => crossSendTarget(name) !== undefined,
|
|
122
|
+
attempts = RESTORE_NOTICE_ATTEMPTS,
|
|
123
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref?.()),
|
|
124
|
+
} = options;
|
|
125
|
+
const frontend = requesterFrontend(requester);
|
|
126
|
+
const chatKey =
|
|
127
|
+
typeof requester.requestedBy === "string" ? requester.requestedBy : "";
|
|
128
|
+
|
|
129
|
+
if (frontend && chatKey && isEnabled(frontend)) {
|
|
130
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
if (await send(frontend, chatKey, text)) {
|
|
133
|
+
log("backup", `Restore reported to ${frontend} chat ${chatKey}`);
|
|
134
|
+
return "requester";
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logWarn(
|
|
138
|
+
"backup",
|
|
139
|
+
`Restore report to ${frontend} chat ${chatKey} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (attempt < attempts) await sleep(RESTORE_NOTICE_DELAY_MS);
|
|
143
|
+
}
|
|
144
|
+
logWarn(
|
|
145
|
+
"backup",
|
|
146
|
+
`Could not reach ${frontend} chat ${chatKey}; reporting the restore to the admin chat instead`,
|
|
147
|
+
);
|
|
148
|
+
} else if (chatKey || frontend) {
|
|
149
|
+
logWarn(
|
|
150
|
+
"backup",
|
|
151
|
+
`Restore requester ${frontend ?? "?"}:${chatKey || "?"} is not reachable here; reporting to the admin chat`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await notifyAdmin(text);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logWarn(
|
|
159
|
+
"backup",
|
|
160
|
+
`Admin restore report failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return "admin";
|
|
164
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The rules that make this safe to run on a live home directory:
|
|
5
5
|
*
|
|
6
6
|
* 1. Verify before you touch anything. The manifest's signature is
|
|
7
|
-
* checked (restore
|
|
7
|
+
* checked (restore/guard.ts), then every part's sha256 and — for
|
|
8
8
|
* encrypted parts — every record's tag; a part that fails is a
|
|
9
9
|
* stopped restore, not a half-applied one.
|
|
10
10
|
* 2. Stage, then swap. The archive is extracted into a staging
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
authenticateManifest,
|
|
56
56
|
makePrivate,
|
|
57
57
|
type ManifestTrust,
|
|
58
|
-
} from "./restore
|
|
58
|
+
} from "./restore/guard.js";
|
|
59
59
|
import { buildSnapshot } from "./snapshot.js";
|
|
60
60
|
import {
|
|
61
61
|
relocateRoot,
|
|
@@ -84,6 +84,12 @@ export type RestorePending = {
|
|
|
84
84
|
requestedAt: number;
|
|
85
85
|
/** Chat key that asked, so the boot can report back. */
|
|
86
86
|
requestedBy?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Frontend the request came from ("telegram", "discord", "native"), so
|
|
89
|
+
* the report goes back the way it came. Absent in files staged before
|
|
90
|
+
* it was recorded — the boot then infers it from `requestedBy`.
|
|
91
|
+
*/
|
|
92
|
+
frontend?: string;
|
|
87
93
|
};
|
|
88
94
|
|
|
89
95
|
export type RestoreReport = {
|
|
@@ -507,7 +513,7 @@ export type RestoreOptions = {
|
|
|
507
513
|
beforeApply?: () => void | Promise<void>;
|
|
508
514
|
/** Skip the automatic pre-restore checkpoint (it has already been taken). */
|
|
509
515
|
skipCheckpoint?: boolean;
|
|
510
|
-
/** Restore a manifest that carries no signature (see restore
|
|
516
|
+
/** Restore a manifest that carries no signature (see restore/guard.ts). */
|
|
511
517
|
allowUnauthenticated?: ManifestTrust["allowUnauthenticated"];
|
|
512
518
|
/**
|
|
513
519
|
* Restoring onto a different machine: relocate the session stores and
|
|
@@ -641,7 +647,9 @@ export async function applyPendingRestore(options: {
|
|
|
641
647
|
settings: BackupSettings;
|
|
642
648
|
home?: string;
|
|
643
649
|
beforeApply?: () => void | Promise<void>;
|
|
644
|
-
}): Promise<
|
|
650
|
+
}): Promise<
|
|
651
|
+
(RestoreReport & { requestedBy?: string; frontend?: string }) | null
|
|
652
|
+
> {
|
|
645
653
|
const home = options.home ?? dirs.root;
|
|
646
654
|
const pending = await readRestorePending(home);
|
|
647
655
|
if (!pending) return null;
|
|
@@ -654,7 +662,11 @@ export async function applyPendingRestore(options: {
|
|
|
654
662
|
beforeApply: options.beforeApply,
|
|
655
663
|
});
|
|
656
664
|
await clearRestorePending(home);
|
|
657
|
-
return {
|
|
665
|
+
return {
|
|
666
|
+
...report,
|
|
667
|
+
requestedBy: pending.requestedBy,
|
|
668
|
+
frontend: pending.frontend,
|
|
669
|
+
};
|
|
658
670
|
} catch (err) {
|
|
659
671
|
logWarn("backup", `Staged restore of ${pending.id} failed: ${String(err)}`);
|
|
660
672
|
await clearRestorePending(home);
|
package/src/core/config/index.ts
CHANGED
|
@@ -643,6 +643,9 @@ const configSchema = z.object({
|
|
|
643
643
|
* default) = chat → agent → agent.
|
|
644
644
|
* - `defaultTimeoutMs` — hard wall-clock cap when a spawn doesn't pass
|
|
645
645
|
* its own. Per-spawn values are clamped to [30s, 60min].
|
|
646
|
+
* - `allowedBackends` — optional allowlist of backend ids sub-agents
|
|
647
|
+
* may run on. Unset = any backend with a background capability. A
|
|
648
|
+
* spawn that names (or inherits) a backend outside it is refused.
|
|
646
649
|
*/
|
|
647
650
|
agents: z
|
|
648
651
|
.object({
|
|
@@ -654,6 +657,7 @@ const configSchema = z.object({
|
|
|
654
657
|
.min(30_000)
|
|
655
658
|
.max(3_600_000)
|
|
656
659
|
.default(15 * 60 * 1000),
|
|
660
|
+
allowedBackends: z.array(z.string().min(1)).optional(),
|
|
657
661
|
})
|
|
658
662
|
.optional(),
|
|
659
663
|
/**
|
|
@@ -23,7 +23,7 @@ import { resolve } from "node:path";
|
|
|
23
23
|
import { dirs } from "../../../util/paths.js";
|
|
24
24
|
import { log, logWarn } from "../../../util/log.js";
|
|
25
25
|
import { TalonError } from "../../errors.js";
|
|
26
|
-
import { readArray, writePrivateJson } from "../persist.js";
|
|
26
|
+
import { readArray, writePrivateJson, writesSettled } from "../persist.js";
|
|
27
27
|
import { faultText } from "../../engine/fault-text.js";
|
|
28
28
|
import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
|
|
29
29
|
import {
|
|
@@ -506,6 +506,14 @@ export class DeviceCredentialStore {
|
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Wait for every background write queued so far (mintNow, bind, re-pair
|
|
511
|
+
* revocation, lastUsedAt touches) to reach disk or fail.
|
|
512
|
+
*/
|
|
513
|
+
flush(): Promise<void> {
|
|
514
|
+
return writesSettled(this.file);
|
|
515
|
+
}
|
|
516
|
+
|
|
509
517
|
private persistSoon(): void {
|
|
510
518
|
void this.persist().catch((err: unknown) =>
|
|
511
519
|
logWarn("mesh", `Could not persist mesh credentials: ${err}`),
|
package/src/core/mesh/persist.ts
CHANGED
|
@@ -25,6 +25,15 @@ export async function readArray<T>(path: string): Promise<T[]> {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Resolve once every write queued for `path` so far has finished (settled,
|
|
30
|
+
* success or failure) — for callers that persisted fire-and-forget and now
|
|
31
|
+
* need the file on disk.
|
|
32
|
+
*/
|
|
33
|
+
export function writesSettled(path: string): Promise<void> {
|
|
34
|
+
return writeQueues.get(path) ?? Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/** Persist JSON atomically with 0600 perms, serialized per path. */
|
|
29
38
|
export async function writePrivateJson(
|
|
30
39
|
path: string,
|
|
@@ -35,8 +35,8 @@ Bad uses: anything needing a back-and-forth with the user, trivial work you
|
|
|
35
35
|
could do in one tool call, or work whose result you need in the next second
|
|
36
36
|
(spawning costs a model cold-start).
|
|
37
37
|
|
|
38
|
-
Backend and model default to
|
|
39
|
-
somewhere else (e.g. a cheap model for a mechanical sweep, or a backend with
|
|
38
|
+
Backend and model default to the caller's (a sub-agent's own children
|
|
39
|
+
inherit its backend and model); pass them to put the agent somewhere else (e.g. a cheap model for a mechanical sweep, or a backend with
|
|
40
40
|
a bigger context window). Returns the agent id immediately.`;
|
|
41
41
|
|
|
42
42
|
export const agentTools: ToolDefinition[] = [
|
|
@@ -61,13 +61,13 @@ export const agentTools: ToolDefinition[] = [
|
|
|
61
61
|
.string()
|
|
62
62
|
.optional()
|
|
63
63
|
.describe(
|
|
64
|
-
"Backend id to run on. Unset =
|
|
64
|
+
"Backend id to run on. Unset = inherit: an agent's children run on its own backend and model; a chat's spawns start from the chat's backend (and may be routed to one with more headroom). Must have a background capability and, when the deployment sets agents.allowedBackends, be on that list.",
|
|
65
65
|
),
|
|
66
66
|
model: z
|
|
67
67
|
.string()
|
|
68
68
|
.optional()
|
|
69
69
|
.describe(
|
|
70
|
-
"Model id on the chosen backend. Unset = that backend's default model. Call list_models for valid ids.",
|
|
70
|
+
"Model id on the chosen backend. Unset = the parent agent's model when the backend is inherited from one, else that backend's default model. Call list_models for valid ids.",
|
|
71
71
|
),
|
|
72
72
|
effort: z
|
|
73
73
|
.enum(["minimal", "low", "medium", "high", "xhigh"])
|
|
@@ -14,9 +14,12 @@ export async function resolveChannel(
|
|
|
14
14
|
const info = lookupDiscordChat(numericChatId);
|
|
15
15
|
if (!info) return null;
|
|
16
16
|
try {
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
17
|
+
// A DM rebuilt from its chat key has no channel id — open it by user.
|
|
18
|
+
if (info.channelId) {
|
|
19
|
+
const ch = await client.channels.fetch(info.channelId);
|
|
20
|
+
if (ch && "send" in ch && (ch as TextBasedChannel).isSendable?.()) {
|
|
21
|
+
return ch as TextBasedChannel;
|
|
22
|
+
}
|
|
20
23
|
}
|
|
21
24
|
if (info.userId) {
|
|
22
25
|
const user = await client.users.fetch(info.userId);
|
|
@@ -24,6 +24,11 @@ import type { Client } from "discord.js";
|
|
|
24
24
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
25
25
|
import type { ActionResult } from "../../../core/types.js";
|
|
26
26
|
import { resolveChannel } from "./channels.js";
|
|
27
|
+
import {
|
|
28
|
+
lookupDiscordChat,
|
|
29
|
+
parseDiscordChatKey,
|
|
30
|
+
registerDiscordChat,
|
|
31
|
+
} from "../handlers/registry.js";
|
|
27
32
|
import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
|
|
28
33
|
import { mediaHandlers } from "./media.js";
|
|
29
34
|
import { chatInfoHandlers } from "./chat-info.js";
|
|
@@ -43,6 +48,24 @@ const handlers: DiscordActionHandlers = Object.assign(Object.create(null), {
|
|
|
43
48
|
...chatInfoHandlers,
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A plain send addressed to a chat this process hasn't registered — the
|
|
53
|
+
* channel that asked for a staged restore, say, before anyone has spoken
|
|
54
|
+
* in it since the restart. The sender names the chat by key in
|
|
55
|
+
* `body.target`; when that key is the one the numeric id was derived
|
|
56
|
+
* from, register it so the channel resolves. send_message only.
|
|
57
|
+
*/
|
|
58
|
+
function adoptAddressedChat(
|
|
59
|
+
action: string,
|
|
60
|
+
body: Record<string, unknown>,
|
|
61
|
+
chatId: number,
|
|
62
|
+
): void {
|
|
63
|
+
if (action !== "send_message" || lookupDiscordChat(chatId)) return;
|
|
64
|
+
const key = typeof body.target === "string" ? body.target : "";
|
|
65
|
+
const info = key ? parseDiscordChatKey(key) : undefined;
|
|
66
|
+
if (info && info.numericChatId === chatId) registerDiscordChat(info);
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
export function createDiscordActionHandler(client: Client, gateway: Gateway) {
|
|
47
70
|
const scheduledMessages = new Map<string, ReturnType<typeof setTimeout>>();
|
|
48
71
|
|
|
@@ -58,6 +81,7 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
|
|
|
58
81
|
const handler = handlers[action];
|
|
59
82
|
if (!handler) return null; // not a Discord action
|
|
60
83
|
|
|
84
|
+
adoptAddressedChat(action, body, chatId);
|
|
61
85
|
const channel = await resolveChannel(client, chatId);
|
|
62
86
|
|
|
63
87
|
// For non-channel actions (e.g. cancel_scheduled) that don't need a
|
|
@@ -66,15 +66,17 @@ export async function handleMessage(
|
|
|
66
66
|
if (msg.attachments.size > 0) {
|
|
67
67
|
for (const att of msg.attachments.values()) {
|
|
68
68
|
try {
|
|
69
|
-
const
|
|
69
|
+
const downloadedPath = await downloadAttachment(att, config.workspace);
|
|
70
70
|
const cls = classifyAttachment(att);
|
|
71
|
-
setMessageFilePath(chatId, numericMessageId,
|
|
72
|
-
addMedia
|
|
71
|
+
setMessageFilePath(chatId, numericMessageId, downloadedPath);
|
|
72
|
+
// addMedia may dedupe onto an existing identical file and delete
|
|
73
|
+
// the fresh download — prompt with the path it resolves to.
|
|
74
|
+
const savedPath = await addMedia({
|
|
73
75
|
chatId,
|
|
74
76
|
msgId: numericMessageId,
|
|
75
77
|
senderName: sender,
|
|
76
78
|
type: cls.type,
|
|
77
|
-
filePath:
|
|
79
|
+
filePath: downloadedPath,
|
|
78
80
|
caption: cleanedContent || undefined,
|
|
79
81
|
timestamp: Date.now(),
|
|
80
82
|
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Discord channel info the action handler needs to post back.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { deriveNumericChatId } from "../../../core/frontend-runtime/chat-id.js";
|
|
6
7
|
import {
|
|
7
8
|
chatRegistry,
|
|
8
9
|
chatRegistryByString,
|
|
@@ -21,3 +22,37 @@ export function lookupDiscordChat(
|
|
|
21
22
|
): DiscordChatInfo | undefined {
|
|
22
23
|
return chatRegistry.get(numericChatId);
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild a chat's registry entry from its string key alone
|
|
28
|
+
* (`discord_guild_<guild>_<channel>` or `discord_dm_<user>`), for a chat
|
|
29
|
+
* this process hasn't seen a message from yet — the registry is in-memory,
|
|
30
|
+
* so after a restart only the allowed users' DMs are known until someone
|
|
31
|
+
* speaks. A DM entry carries no channel id; resolveChannel opens the DM
|
|
32
|
+
* from the user id. Undefined for anything that isn't a Discord chat key.
|
|
33
|
+
*/
|
|
34
|
+
export function parseDiscordChatKey(
|
|
35
|
+
chatKey: string,
|
|
36
|
+
): DiscordChatInfo | undefined {
|
|
37
|
+
const guild = /^discord_guild_(\d+)_(\d+)$/.exec(chatKey);
|
|
38
|
+
if (guild) {
|
|
39
|
+
return {
|
|
40
|
+
channelId: guild[2]!,
|
|
41
|
+
guildId: guild[1]!,
|
|
42
|
+
userId: null,
|
|
43
|
+
numericChatId: deriveNumericChatId(chatKey),
|
|
44
|
+
chatId: chatKey,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const dm = /^discord_dm_(\d+)$/.exec(chatKey);
|
|
48
|
+
if (dm) {
|
|
49
|
+
return {
|
|
50
|
+
channelId: "",
|
|
51
|
+
guildId: null,
|
|
52
|
+
userId: dm[1]!,
|
|
53
|
+
numericChatId: deriveNumericChatId(chatKey),
|
|
54
|
+
chatId: chatKey,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
@@ -15,6 +15,10 @@ import type {
|
|
|
15
15
|
import type { Gateway } from "../../../core/engine/gateway.js";
|
|
16
16
|
import type { NativeChats, ChatEntry } from "../chats/chats.js";
|
|
17
17
|
import type { BridgeEvent, ClientButton } from "../protocol.js";
|
|
18
|
+
import {
|
|
19
|
+
deriveNumericChatId,
|
|
20
|
+
isNativeChatId,
|
|
21
|
+
} from "../../../core/frontend-runtime/chat-id.js";
|
|
18
22
|
|
|
19
23
|
export type NativeActionDeps = {
|
|
20
24
|
chats: NativeChats;
|
|
@@ -69,6 +73,28 @@ const NATIVE_ACTIONS = new Set([
|
|
|
69
73
|
"send_chat_action",
|
|
70
74
|
]);
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A plain send addressed to a native chat this daemon doesn't list — the
|
|
78
|
+
* chat that asked for a staged restore, say, when the restored database
|
|
79
|
+
* predates it. The sender names the chat by key in `body.target`; the
|
|
80
|
+
* chat is adopted (as a deep link would) so the message lands in its
|
|
81
|
+
* history and the app shows it. Only when the key really is the chat the
|
|
82
|
+
* numeric id was derived from, and only for send_message: edits,
|
|
83
|
+
* reactions and deletes address messages, which an unknown chat has none of.
|
|
84
|
+
*/
|
|
85
|
+
function adoptAddressedChat(
|
|
86
|
+
chats: NativeChats,
|
|
87
|
+
action: string,
|
|
88
|
+
body: Record<string, unknown>,
|
|
89
|
+
chatId: number,
|
|
90
|
+
): ChatEntry | undefined {
|
|
91
|
+
if (action !== "send_message") return undefined;
|
|
92
|
+
const key = typeof body.target === "string" ? body.target : "";
|
|
93
|
+
if (!key || !isNativeChatId(key) || deriveNumericChatId(key) !== chatId)
|
|
94
|
+
return undefined;
|
|
95
|
+
return chats.ensure(key);
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
export function createNativeActionHandler(
|
|
73
99
|
deps: NativeActionDeps,
|
|
74
100
|
): FrontendActionHandler {
|
|
@@ -77,7 +103,9 @@ export function createNativeActionHandler(
|
|
|
77
103
|
return async (body, chatId): Promise<ActionResult | null> => {
|
|
78
104
|
const action = typeof body.action === "string" ? body.action : "";
|
|
79
105
|
if (!NATIVE_ACTIONS.has(action)) return null;
|
|
80
|
-
const entry =
|
|
106
|
+
const entry =
|
|
107
|
+
chats.byNumeric(chatId) ??
|
|
108
|
+
adoptAddressedChat(chats, action, body, chatId);
|
|
81
109
|
if (!entry) return { ok: false, error: "No active native chat" };
|
|
82
110
|
|
|
83
111
|
switch (action) {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getPinnedMessages as userbotPinnedMessages,
|
|
22
22
|
getOnlineCount as userbotOnlineCount,
|
|
23
23
|
} from "../userbot.js";
|
|
24
|
+
import { getMediaForMessage } from "../../../storage/media-index.js";
|
|
24
25
|
import { savePackToLibrary } from "../sticker-library.js";
|
|
25
26
|
import { toPositiveId } from "./coerce.js";
|
|
26
27
|
import type { TelegramActionHandlers } from "./types.js";
|
|
@@ -308,16 +309,32 @@ export const chatInfoHandlers: TelegramActionHandlers = {
|
|
|
308
309
|
},
|
|
309
310
|
|
|
310
311
|
download_media: async (body, chatId) => {
|
|
312
|
+
const msgId = toPositiveId(body.message_id);
|
|
313
|
+
if (!msgId) return { ok: false, error: "Required: message_id" };
|
|
314
|
+
// Media the bot received is already on disk and indexed. Answer from
|
|
315
|
+
// the index first: the userbot can't see messages in the bot's own
|
|
316
|
+
// DMs (message ids there are per-account), so asking it for an
|
|
317
|
+
// inbound photo by id reports "not found".
|
|
318
|
+
const indexed = getMediaForMessage(String(chatId), msgId);
|
|
319
|
+
if (indexed && existsSync(indexed.filePath)) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
text: `Saved at: ${indexed.filePath} (${indexed.type}). Use the Read tool on this path to view the content.`,
|
|
323
|
+
file_path: indexed.filePath,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
311
326
|
if (isUserClientReady()) {
|
|
312
327
|
const { downloadMessageMedia } = await import("../userbot.js");
|
|
313
328
|
return {
|
|
314
329
|
ok: true,
|
|
315
|
-
text: await downloadMessageMedia({
|
|
316
|
-
chatId,
|
|
317
|
-
messageId: Number(body.message_id),
|
|
318
|
-
}),
|
|
330
|
+
text: await downloadMessageMedia({ chatId, messageId: msgId }),
|
|
319
331
|
};
|
|
320
332
|
}
|
|
321
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: indexed
|
|
336
|
+
? `Media for message ${msgId} is no longer on disk and the user client is not connected to re-download it.`
|
|
337
|
+
: "User client not connected.",
|
|
338
|
+
};
|
|
322
339
|
},
|
|
323
340
|
};
|
|
@@ -72,16 +72,18 @@ async function handleMediaMessage(
|
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const
|
|
75
|
+
const downloadedPath = await downloadTelegramFile(
|
|
76
76
|
bot,
|
|
77
77
|
config,
|
|
78
78
|
media.fileId,
|
|
79
79
|
media.fileName,
|
|
80
80
|
);
|
|
81
81
|
|
|
82
|
-
// Store file path in history + media index
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
// Store file path in history + media index. addMedia may dedupe the
|
|
83
|
+
// download onto an identical file already on disk (and delete the
|
|
84
|
+
// fresh one), so the prompt must use the path it resolves to.
|
|
85
|
+
setMessageFilePath(chatId, ctx.message.message_id, downloadedPath);
|
|
86
|
+
const savedPath = await addMedia({
|
|
85
87
|
chatId,
|
|
86
88
|
msgId: ctx.message.message_id,
|
|
87
89
|
senderName: sender,
|
|
@@ -93,7 +95,7 @@ async function handleMediaMessage(
|
|
|
93
95
|
| "animation"
|
|
94
96
|
| "audio"
|
|
95
97
|
| "sticker",
|
|
96
|
-
filePath:
|
|
98
|
+
filePath: downloadedPath,
|
|
97
99
|
caption: media.caption,
|
|
98
100
|
timestamp: Date.now(),
|
|
99
101
|
});
|
|
@@ -96,7 +96,9 @@ export async function saveInboundMedia(
|
|
|
96
96
|
writeFileSync(filePath, buffer);
|
|
97
97
|
|
|
98
98
|
const caption = content?.caption ?? undefined;
|
|
99
|
-
addMedia
|
|
99
|
+
// addMedia may dedupe onto an existing identical file and delete
|
|
100
|
+
// this download — report the path it resolves to.
|
|
101
|
+
const savedPath = await addMedia({
|
|
100
102
|
chatId,
|
|
101
103
|
msgId,
|
|
102
104
|
senderName,
|
|
@@ -105,7 +107,7 @@ export async function saveInboundMedia(
|
|
|
105
107
|
timestamp: Date.now(),
|
|
106
108
|
...(caption ? { caption } : {}),
|
|
107
109
|
});
|
|
108
|
-
return { filePath, type, ...(caption ? { caption } : {}) };
|
|
110
|
+
return { filePath: savedPath, type, ...(caption ? { caption } : {}) };
|
|
109
111
|
} catch (err) {
|
|
110
112
|
logWarn(
|
|
111
113
|
"whatsapp",
|
|
@@ -73,7 +73,19 @@ function importLegacyMediaIndex(): void {
|
|
|
73
73
|
|
|
74
74
|
// ── CRUD ────────────────────────────────────────────────────────────────────
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Index a downloaded file and dedupe it against identical content.
|
|
78
|
+
*
|
|
79
|
+
* Resolves to the path the message's media now lives at — the
|
|
80
|
+
* canonical copy when the download duplicated one already on disk
|
|
81
|
+
* (the fresh file is then deleted), otherwise `entry.filePath`.
|
|
82
|
+
* Callers MUST use the resolved path for anything they show the
|
|
83
|
+
* model: the original path may no longer exist once this settles.
|
|
84
|
+
*
|
|
85
|
+
* The row is written synchronously, so it is queryable before the
|
|
86
|
+
* hash finishes.
|
|
87
|
+
*/
|
|
88
|
+
export async function addMedia(entry: Omit<MediaEntry, "id">): Promise<string> {
|
|
77
89
|
try {
|
|
78
90
|
repo.upsert(entry);
|
|
79
91
|
} catch (err) {
|
|
@@ -85,13 +97,32 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
|
|
|
85
97
|
recordError(
|
|
86
98
|
`Media index write failed: ${err instanceof Error ? err.message : err}`,
|
|
87
99
|
);
|
|
88
|
-
return;
|
|
100
|
+
return entry.filePath;
|
|
89
101
|
}
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
// Serialize dedupe: two identical files hashed concurrently (e.g. an
|
|
103
|
+
// album) could otherwise each pick the other as canonical and both
|
|
104
|
+
// get unlinked.
|
|
105
|
+
const run = dedupeChain.then(() => hashAndDedupe(entry));
|
|
106
|
+
dedupeChain = run.then(
|
|
107
|
+
() => undefined,
|
|
108
|
+
() => undefined,
|
|
94
109
|
);
|
|
110
|
+
try {
|
|
111
|
+
return await run;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logError("media", `Media content hash failed for ${entry.filePath}`, err);
|
|
114
|
+
return entry.filePath;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let dedupeChain: Promise<void> = Promise.resolve();
|
|
119
|
+
|
|
120
|
+
/** The indexed entry for a message, if any. */
|
|
121
|
+
export function getMediaForMessage(
|
|
122
|
+
chatId: string,
|
|
123
|
+
msgId: number,
|
|
124
|
+
): MediaEntry | undefined {
|
|
125
|
+
return repo.byMessage(chatId, msgId);
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
/**
|
|
@@ -101,15 +132,15 @@ export function addMedia(entry: Omit<MediaEntry, "id">): void {
|
|
|
101
132
|
* canonical copy and drop the duplicate file, so re-posted media costs
|
|
102
133
|
* one copy on disk no matter how many messages carry it.
|
|
103
134
|
*/
|
|
104
|
-
async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<
|
|
105
|
-
if (!existsSync(entry.filePath)) return; // gone already (expiry, tests)
|
|
135
|
+
async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<string> {
|
|
136
|
+
if (!existsSync(entry.filePath)) return entry.filePath; // gone already (expiry, tests)
|
|
106
137
|
const hash = await blake3HexFile(entry.filePath);
|
|
107
138
|
repo.setContentHash(entry.chatId, entry.msgId, hash);
|
|
108
139
|
|
|
109
140
|
const canonical = repo.firstByContentHash(hash, entry.chatId, entry.msgId);
|
|
110
|
-
if (!canonical) return;
|
|
111
|
-
if (canonical.filePath === entry.filePath) return; // re-download of the same path
|
|
112
|
-
if (!existsSync(canonical.filePath)) return; // canonical copy lost — keep ours
|
|
141
|
+
if (!canonical) return entry.filePath;
|
|
142
|
+
if (canonical.filePath === entry.filePath) return entry.filePath; // re-download of the same path
|
|
143
|
+
if (!existsSync(canonical.filePath)) return entry.filePath; // canonical copy lost — keep ours
|
|
113
144
|
|
|
114
145
|
repo.setFilePath(entry.chatId, entry.msgId, canonical.filePath);
|
|
115
146
|
setMessageFilePath(entry.chatId, entry.msgId, canonical.filePath);
|
|
@@ -131,6 +162,7 @@ async function hashAndDedupe(entry: Omit<MediaEntry, "id">): Promise<void> {
|
|
|
131
162
|
);
|
|
132
163
|
}
|
|
133
164
|
}
|
|
165
|
+
return canonical.filePath;
|
|
134
166
|
}
|
|
135
167
|
|
|
136
168
|
/** Get recent media for a chat, newest first. */
|
|
@@ -33,7 +33,7 @@ export type MediaEntry = {
|
|
|
33
33
|
timestamp: number;
|
|
34
34
|
/**
|
|
35
35
|
* BLAKE3 hex digest of the file contents (native/blake3-wasm).
|
|
36
|
-
* Filled in
|
|
36
|
+
* Filled in by addMedia before it resolves; undefined until hashed.
|
|
37
37
|
*/
|
|
38
38
|
contentHash?: string;
|
|
39
39
|
};
|
|
@@ -112,6 +112,17 @@ export function firstByContentHash(
|
|
|
112
112
|
return row ? rowToEntry(row) : undefined;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** The entry for one message, if indexed. */
|
|
116
|
+
export function byMessage(
|
|
117
|
+
chatId: string,
|
|
118
|
+
msgId: number,
|
|
119
|
+
): MediaEntry | undefined {
|
|
120
|
+
const row = getDatabase()
|
|
121
|
+
.prepare(mediaIndexSql.byMessage)
|
|
122
|
+
.get(chatId, msgId) as Row | undefined;
|
|
123
|
+
return row ? rowToEntry(row) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
115
126
|
/** Entries currently pointing at a file — dedupe makes this > 1. */
|
|
116
127
|
export function countByFilePath(filePath: string): number {
|
|
117
128
|
const row = getDatabase()
|
|
@@ -43,3 +43,7 @@ ORDER BY timestamp ASC, rowid ASC LIMIT 1
|
|
|
43
43
|
|
|
44
44
|
-- name: countByFilePath
|
|
45
45
|
SELECT COUNT(*) AS n FROM media_index WHERE file_path = ?
|
|
46
|
+
|
|
47
|
+
-- name: byMessage
|
|
48
|
+
SELECT chat_id, msg_id, sender_name, type, file_path, caption, timestamp, content_hash
|
|
49
|
+
FROM media_index WHERE chat_id = ? AND msg_id = ?
|
|
@@ -767,6 +767,8 @@ FROM media_index
|
|
|
767
767
|
WHERE content_hash = ? AND NOT (chat_id = ? AND msg_id = ?)
|
|
768
768
|
ORDER BY timestamp ASC, rowid ASC LIMIT 1`,
|
|
769
769
|
countByFilePath: `SELECT COUNT(*) AS n FROM media_index WHERE file_path = ?`,
|
|
770
|
+
byMessage: `SELECT chat_id, msg_id, sender_name, type, file_path, caption, timestamp, content_hash
|
|
771
|
+
FROM media_index WHERE chat_id = ? AND msg_id = ?`,
|
|
770
772
|
} as const;
|
|
771
773
|
|
|
772
774
|
export const memorySql = {
|