talon-agent 5.28.0 → 5.30.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 +3 -2
- package/prompts/system/agent-brief.md +9 -6
- package/src/backend/claude-sdk/constants.ts +22 -0
- package/src/backend/claude-sdk/models/discovery.ts +3 -0
- package/src/backend/claude-sdk/one-shot.ts +3 -1
- package/src/backend/claude-sdk/options.ts +7 -1
- package/src/core/agents/index.ts +2 -0
- package/src/core/agents/prompt.ts +70 -2
- package/src/core/agents/registry.ts +47 -5
- package/src/core/agents/runner.ts +227 -39
- package/src/core/agents/trail.ts +141 -0
- package/src/core/agents/types.ts +43 -5
- package/src/core/agents/watchdog.ts +70 -0
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/backup/plan.ts +4 -0
- package/src/core/config/index.ts +17 -4
- package/src/core/engine/gateway-actions/agents/control.ts +9 -2
- package/src/core/engine/gateway-actions/agents/preflight.ts +17 -1
- package/src/core/engine/gateway-actions/agents/report.ts +81 -24
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/mcp-hub/guest-scope.ts +3 -1
- package/src/core/mesh/devices/service.ts +7 -0
- package/src/core/mesh/links/bridge-links.ts +20 -0
- package/src/core/secrets/actions.ts +18 -0
- package/src/core/secrets/drop.ts +176 -0
- package/src/core/secrets/index.ts +11 -0
- package/src/core/secrets/service.ts +248 -0
- package/src/core/secrets/store.ts +100 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/agents.ts +30 -12
- package/src/core/tools/ops/secrets.ts +36 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/definitions.ts +21 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/discord/commands/secret.ts +26 -0
- package/src/frontend/discord/handlers/messages.ts +6 -4
- package/src/frontend/native/bridge/routes/host.ts +10 -0
- package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
- package/src/frontend/native/bridge/routes/table.ts +5 -0
- package/src/frontend/native/bridge/server.ts +32 -4
- package/src/frontend/native/commands/definitions.ts +6 -0
- package/src/frontend/native/commands/index.ts +12 -0
- package/src/frontend/native/surface/handlers.ts +9 -0
- package/src/frontend/telegram/actions/chat-info.ts +22 -5
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +3 -0
- package/src/frontend/telegram/commands/secret.ts +24 -0
- package/src/frontend/telegram/handlers/messages.ts +7 -5
- package/src/frontend/whatsapp/commands.ts +16 -1
- 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/src/util/log.ts +1 -0
- package/src/util/paths.ts +6 -0
package/src/core/tools/types.ts
CHANGED
|
@@ -17,6 +17,26 @@ import type { TalonConfig } from "../../../core/config/index.js";
|
|
|
17
17
|
import { log, logError, logWarn } from "../../../util/log.js";
|
|
18
18
|
import { getRepoRoot } from "../../../core/update/self-update.js";
|
|
19
19
|
|
|
20
|
+
/** /secret — one-time paste link for a password (admin, DMs). */
|
|
21
|
+
function secretCommand(): unknown {
|
|
22
|
+
return new SlashCommandBuilder()
|
|
23
|
+
.setName("secret")
|
|
24
|
+
.setDescription("Store a password via a one-time link (admin, DMs)")
|
|
25
|
+
.addStringOption((o) =>
|
|
26
|
+
o
|
|
27
|
+
.setName("name")
|
|
28
|
+
.setDescription("File name under ~/.talon/secrets")
|
|
29
|
+
.setRequired(true),
|
|
30
|
+
)
|
|
31
|
+
.addStringOption((o) =>
|
|
32
|
+
o
|
|
33
|
+
.setName("purpose")
|
|
34
|
+
.setDescription("What it's for (shown on the form)")
|
|
35
|
+
.setRequired(false),
|
|
36
|
+
)
|
|
37
|
+
.toJSON();
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
/**
|
|
21
41
|
* /backup — its own builder: the definitions list is one long literal and
|
|
22
42
|
* this is the only entry with two options and seven choices.
|
|
@@ -167,6 +187,7 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
|
|
|
167
187
|
]
|
|
168
188
|
: []),
|
|
169
189
|
backupCommand(),
|
|
190
|
+
secretCommand(),
|
|
170
191
|
new SlashCommandBuilder()
|
|
171
192
|
.setName("admin")
|
|
172
193
|
.setDescription("Admin operations (admin only)")
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
handleSettings,
|
|
39
39
|
} from "./settings.js";
|
|
40
40
|
import { handleBackup } from "./backup.js";
|
|
41
|
+
import { handleSecret } from "./secret.js";
|
|
41
42
|
import {
|
|
42
43
|
handleRestart,
|
|
43
44
|
handleMetrics,
|
|
@@ -163,6 +164,8 @@ async function routeSlashCommand(
|
|
|
163
164
|
return handleUpdate(interaction, config);
|
|
164
165
|
case "backup":
|
|
165
166
|
return handleBackup(interaction);
|
|
167
|
+
case "secret":
|
|
168
|
+
return handleSecret(interaction, chatId);
|
|
166
169
|
case "admin":
|
|
167
170
|
return handleAdmin(interaction, config, gateway);
|
|
168
171
|
default:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /secret name:<name> [purpose:<text>] — a single-use link to paste a
|
|
3
|
+
* password into instead of the channel. Rules and reply live in
|
|
4
|
+
* core/secrets; the reply is ephemeral either way.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChatInputCommandInteraction } from "discord.js";
|
|
8
|
+
import { secretCommandReply } from "../../../core/secrets/index.js";
|
|
9
|
+
import { isAdmin } from "../handlers/index.js";
|
|
10
|
+
import { reply } from "./interaction.js";
|
|
11
|
+
|
|
12
|
+
export async function handleSecret(
|
|
13
|
+
i: ChatInputCommandInteraction,
|
|
14
|
+
chatId: string,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const name = (i.options.getString("name") ?? "").trim();
|
|
17
|
+
const purpose = (i.options.getString("purpose") ?? "").trim();
|
|
18
|
+
const text = secretCommandReply({
|
|
19
|
+
arg: `${name} ${purpose}`,
|
|
20
|
+
chatKey: chatId,
|
|
21
|
+
frontend: "discord",
|
|
22
|
+
isOperator: isAdmin(i.user.id),
|
|
23
|
+
isGroup: i.inGuild(),
|
|
24
|
+
});
|
|
25
|
+
await reply(i, text, true);
|
|
26
|
+
}
|
|
@@ -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
|
});
|
|
@@ -178,6 +178,16 @@ export type BridgeServerHandlers = {
|
|
|
178
178
|
| Promise<{ script: string; filename: string } | null>;
|
|
179
179
|
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
180
180
|
openNodeBinary(token: string): { path: string; size: number } | null;
|
|
181
|
+
/** The secret-drop form for a live grant (not spent), or null. */
|
|
182
|
+
openSecretDrop(token: string): string | null;
|
|
183
|
+
/** Whether a secret-drop grant is live (checked before reading a body). */
|
|
184
|
+
isLiveSecretDrop(token: string): boolean;
|
|
185
|
+
/** Spend a secret-drop grant and store the value; the page to show. */
|
|
186
|
+
submitSecretDrop(
|
|
187
|
+
token: string,
|
|
188
|
+
body: string,
|
|
189
|
+
contentType: string | undefined,
|
|
190
|
+
): Promise<{ status: number; html: string }>;
|
|
181
191
|
};
|
|
182
192
|
|
|
183
193
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
1
2
|
import type { RouteHost } from "./host.js";
|
|
2
3
|
import type { BridgeRoutes } from "./table.js";
|
|
3
4
|
import { BRIDGE_PROTOCOL_VERSION } from "../../protocol.js";
|
|
@@ -6,7 +7,12 @@ export function preAuthRoutes(
|
|
|
6
7
|
host: RouteHost,
|
|
7
8
|
): Pick<
|
|
8
9
|
BridgeRoutes,
|
|
9
|
-
|
|
10
|
+
| "GET /health"
|
|
11
|
+
| "GET /pair"
|
|
12
|
+
| "GET /node/install"
|
|
13
|
+
| "GET /node/binary"
|
|
14
|
+
| "GET /secret"
|
|
15
|
+
| "POST /secret"
|
|
10
16
|
> {
|
|
11
17
|
const { json, handlers: h } = host;
|
|
12
18
|
return {
|
|
@@ -90,6 +96,37 @@ export function preAuthRoutes(
|
|
|
90
96
|
});
|
|
91
97
|
res.end(install.script);
|
|
92
98
|
},
|
|
99
|
+
// Secret drop. GET shows the paste form and leaves the grant live, so
|
|
100
|
+
// a chat app fetching the link for a preview can't burn it. POST
|
|
101
|
+
// checks the grant BEFORE reading a byte of body — an anonymous caller
|
|
102
|
+
// without one gets nothing read and a closed socket — then reads at
|
|
103
|
+
// most SECRET_BODY_MAX bytes. The value goes to the core service and
|
|
104
|
+
// nowhere else: not a log line, not an error message.
|
|
105
|
+
"GET /secret": ({ res, url }) => {
|
|
106
|
+
const form = h.openSecretDrop(url.searchParams.get("grant") ?? "");
|
|
107
|
+
if (!form) return sendSecretPage(res, 404, notFoundPage());
|
|
108
|
+
sendSecretPage(res, 200, form);
|
|
109
|
+
},
|
|
110
|
+
"POST /secret": async ({ req, res, url }) => {
|
|
111
|
+
const token = url.searchParams.get("grant") ?? "";
|
|
112
|
+
if (!h.isLiveSecretDrop(token)) {
|
|
113
|
+
res.setHeader("Connection", "close");
|
|
114
|
+
return sendSecretPage(res, 404, notFoundPage());
|
|
115
|
+
}
|
|
116
|
+
let body: string;
|
|
117
|
+
try {
|
|
118
|
+
body = await readCappedBody(req, SECRET_BODY_MAX);
|
|
119
|
+
} catch {
|
|
120
|
+
res.setHeader("Connection", "close");
|
|
121
|
+
return sendSecretPage(res, 413, notFoundPage("Too large."));
|
|
122
|
+
}
|
|
123
|
+
const result = await h.submitSecretDrop(
|
|
124
|
+
token,
|
|
125
|
+
body,
|
|
126
|
+
req.headers["content-type"],
|
|
127
|
+
);
|
|
128
|
+
sendSecretPage(res, result.status, result.html);
|
|
129
|
+
},
|
|
93
130
|
"GET /node/binary": ({ res, url }) => {
|
|
94
131
|
const token = url.searchParams.get("provision") ?? "";
|
|
95
132
|
const binary = token ? h.openNodeBinary(token) : null;
|
|
@@ -98,3 +135,47 @@ export function preAuthRoutes(
|
|
|
98
135
|
},
|
|
99
136
|
};
|
|
100
137
|
}
|
|
138
|
+
|
|
139
|
+
/** A form-encoded 64 KB value plus its encoding overhead, and no more. */
|
|
140
|
+
const SECRET_BODY_MAX = 200 * 1024;
|
|
141
|
+
|
|
142
|
+
/** Read a request body as UTF-8, refusing anything over `max` bytes. */
|
|
143
|
+
async function readCappedBody(
|
|
144
|
+
req: IncomingMessage,
|
|
145
|
+
max: number,
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const chunks: Buffer[] = [];
|
|
148
|
+
let total = 0;
|
|
149
|
+
for await (const chunk of req) {
|
|
150
|
+
total += (chunk as Buffer).length;
|
|
151
|
+
if (total > max) throw new Error("too large");
|
|
152
|
+
chunks.push(chunk as Buffer);
|
|
153
|
+
}
|
|
154
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Secret-drop pages: no caching anywhere, no framing, no referrer, and a
|
|
159
|
+
* CSP that allows inline style and a same-origin form post — nothing else.
|
|
160
|
+
*/
|
|
161
|
+
function sendSecretPage(
|
|
162
|
+
res: ServerResponse,
|
|
163
|
+
status: number,
|
|
164
|
+
html: string,
|
|
165
|
+
): void {
|
|
166
|
+
res.writeHead(status, {
|
|
167
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
168
|
+
"Cache-Control": "no-store",
|
|
169
|
+
"Referrer-Policy": "no-referrer",
|
|
170
|
+
"X-Frame-Options": "DENY",
|
|
171
|
+
"X-Content-Type-Options": "nosniff",
|
|
172
|
+
"Content-Security-Policy":
|
|
173
|
+
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
174
|
+
});
|
|
175
|
+
res.end(html);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function notFoundPage(message?: string): string {
|
|
179
|
+
const text = message ?? "This link is unknown, expired, or already used.";
|
|
180
|
+
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not available</title><p style="font:15px system-ui,sans-serif;padding:24px">${text} Ask for a fresh one with /secret <name>.</p>\n`;
|
|
181
|
+
}
|
|
@@ -37,6 +37,11 @@ export const BRIDGE_ROUTE_AUTH = {
|
|
|
37
37
|
"GET /pair": "public",
|
|
38
38
|
"GET /node/install": "public",
|
|
39
39
|
"GET /node/binary": "public",
|
|
40
|
+
// Secret drop: a browser with no credential pastes one value, once. GET
|
|
41
|
+
// shows the form without spending the grant (link previews fetch it);
|
|
42
|
+
// POST spends it, and its body is read only after the grant checks out.
|
|
43
|
+
"GET /secret": "public",
|
|
44
|
+
"POST /secret": "public",
|
|
40
45
|
|
|
41
46
|
// Credential self-service. Any credential may ask who it is, and trade
|
|
42
47
|
// itself (or the shared legacy token) for a fresh per-device credential
|
|
@@ -81,6 +81,25 @@ const SSE_PING_MS = 25_000;
|
|
|
81
81
|
*/
|
|
82
82
|
const SSE_MAX_BACKLOG_BYTES = 16 * 1024 * 1024;
|
|
83
83
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Routes a page served by the bridge itself posts a plain HTML form to
|
|
87
|
+
* (the secret drop). A browser stamps that POST with the bridge's own
|
|
88
|
+
* origin, which is no cross-site request — so these, and only these,
|
|
89
|
+
* accept it without `native.allowedOrigins`. Every other route still
|
|
90
|
+
* refuses any Origin not configured, same-origin included: a page under
|
|
91
|
+
* /media is the bridge's origin too.
|
|
92
|
+
*/
|
|
93
|
+
const SAME_ORIGIN_FORM_ROUTES: ReadonlySet<string> = new Set(["POST /secret"]);
|
|
94
|
+
|
|
95
|
+
/** `host[:port]` of an Origin header, or undefined when it doesn't parse. */
|
|
96
|
+
function originHost(origin: string): string | undefined {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(origin).host;
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
84
103
|
const PORT_FALLBACKS = 5;
|
|
85
104
|
|
|
86
105
|
/**
|
|
@@ -90,7 +109,8 @@ const PORT_FALLBACKS = 5;
|
|
|
90
109
|
* bodies) are unaffected. It does bound request bodies, and every
|
|
91
110
|
* body-reading route sits behind the bearer check (an unauthenticated
|
|
92
111
|
* request is answered 401/429 with `Connection: close` before its body is
|
|
93
|
-
* read)
|
|
112
|
+
* read) — the one pre-auth exception, `POST /secret`, reads a body only
|
|
113
|
+
* for a live single-use grant and caps it at 200 KB — so the budget is sized for the largest authenticated upload
|
|
94
114
|
* (512 MB) on a slow link, not for an attacker.
|
|
95
115
|
*/
|
|
96
116
|
export type BridgeTimeouts = {
|
|
@@ -496,7 +516,10 @@ export class BridgeServer {
|
|
|
496
516
|
// slip the browser is asking for.
|
|
497
517
|
const origin =
|
|
498
518
|
typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
|
499
|
-
const refusal = this.originGuard(
|
|
519
|
+
const refusal = this.originGuard(
|
|
520
|
+
req,
|
|
521
|
+
SAME_ORIGIN_FORM_ROUTES.has(`${method} ${path}`),
|
|
522
|
+
);
|
|
500
523
|
if (refusal !== undefined) {
|
|
501
524
|
res.writeHead(403, {
|
|
502
525
|
...this.corsHeaders(),
|
|
@@ -860,10 +883,15 @@ export class BridgeServer {
|
|
|
860
883
|
*
|
|
861
884
|
* Returns an error string when the request must be refused.
|
|
862
885
|
*/
|
|
863
|
-
private originGuard(
|
|
886
|
+
private originGuard(
|
|
887
|
+
req: IncomingMessage,
|
|
888
|
+
sameOriginAllowed = false,
|
|
889
|
+
): string | undefined {
|
|
864
890
|
const origin = req.headers.origin;
|
|
865
891
|
if (typeof origin === "string" && origin !== "" && origin !== "null") {
|
|
866
|
-
|
|
892
|
+
const sameOrigin =
|
|
893
|
+
sameOriginAllowed && originHost(origin) === req.headers.host;
|
|
894
|
+
if (!sameOrigin && !this.isAllowedOrigin(origin)) {
|
|
867
895
|
return `Origin ${origin} is not allowed. Add it to native.allowedOrigins to permit browser clients.`;
|
|
868
896
|
}
|
|
869
897
|
}
|
|
@@ -96,6 +96,12 @@ export const NATIVE_COMMANDS = [
|
|
|
96
96
|
args: "[status|now|checkpoint <label>|list|show <id>|pin <id>|unpin <id>|restore <id>]",
|
|
97
97
|
admin: true,
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
name: "secret",
|
|
101
|
+
description: "Store a password via a one-time link, not the chat",
|
|
102
|
+
args: "<name> [what it's for]",
|
|
103
|
+
admin: true,
|
|
104
|
+
},
|
|
99
105
|
] as const satisfies readonly ClientCommand[];
|
|
100
106
|
|
|
101
107
|
export type NativeCommandName = (typeof NATIVE_COMMANDS)[number]["name"];
|
|
@@ -18,6 +18,7 @@ import type { NativeRuntime } from "../runtime.js";
|
|
|
18
18
|
import { emitNotice, emitUser } from "../turn/emit.js";
|
|
19
19
|
import { adminCommands } from "./admin.js";
|
|
20
20
|
import { backupCommand } from "./backup.js";
|
|
21
|
+
import { secretCommandReply } from "../../../core/secrets/index.js";
|
|
21
22
|
import { findNativeCommand, type NativeCommandName } from "./definitions.js";
|
|
22
23
|
import { infoCommands } from "./info.js";
|
|
23
24
|
import { sessionCommands } from "./session.js";
|
|
@@ -30,6 +31,17 @@ const HANDLERS: Record<NativeCommandName, NativeCommandHandler> = {
|
|
|
30
31
|
...sessionCommands,
|
|
31
32
|
...adminCommands,
|
|
32
33
|
backup: backupCommand,
|
|
34
|
+
// Native chats are one operator's own surface: never a group.
|
|
35
|
+
secret: async (ctx) =>
|
|
36
|
+
ctx.reply(
|
|
37
|
+
secretCommandReply({
|
|
38
|
+
arg: ctx.arg,
|
|
39
|
+
chatKey: ctx.entry.id,
|
|
40
|
+
frontend: "native",
|
|
41
|
+
isOperator: ctx.operator,
|
|
42
|
+
isGroup: false,
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
33
45
|
};
|
|
34
46
|
|
|
35
47
|
export type ParsedNativeCommand = { name: NativeCommandName; arg: string };
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* anything with logic of its own lives in the module named for it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
isLiveSecretDrop,
|
|
9
|
+
openSecretDropForm,
|
|
10
|
+
submitSecretDrop,
|
|
11
|
+
} from "../../../core/secrets/index.js";
|
|
7
12
|
import { files } from "../../../util/paths.js";
|
|
8
13
|
import { setChatPulse } from "../../../storage/chat-settings.js";
|
|
9
14
|
import { getPooledBackend } from "../../../core/engine/backend-controller/index.js";
|
|
@@ -219,5 +224,9 @@ export function buildBridgeHandlers(
|
|
|
219
224
|
openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
|
|
220
225
|
openNodeInstall: (token, os, arch) => mesh.openNodeInstall(token, os, arch),
|
|
221
226
|
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
227
|
+
openSecretDrop: (token) => openSecretDropForm(token),
|
|
228
|
+
isLiveSecretDrop: (token) => isLiveSecretDrop(token),
|
|
229
|
+
submitSecretDrop: (token, body, contentType) =>
|
|
230
|
+
submitSecretDrop(token, body, contentType),
|
|
222
231
|
};
|
|
223
232
|
}
|
|
@@ -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
|
};
|
|
@@ -52,6 +52,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
|
|
|
52
52
|
command: "backup",
|
|
53
53
|
description: "Snapshots and checkpoints; /backup restore <id> (admin)",
|
|
54
54
|
},
|
|
55
|
+
{
|
|
56
|
+
command: "secret",
|
|
57
|
+
description: "Store a password via a one-time link, not the chat (admin)",
|
|
58
|
+
},
|
|
55
59
|
{ command: "plugins", description: "List loaded plugins" },
|
|
56
60
|
{ command: "help", description: "All commands and features" },
|
|
57
61
|
];
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - `memory` — /memory (read-only view of the typed memory store)
|
|
9
9
|
* - `session` — /reset /status
|
|
10
10
|
* - `settings` — /model /effort /pulse /settings
|
|
11
|
+
* - `secret` — /secret (single-use paste link for a password)
|
|
11
12
|
* - `admin` — /admin /metrics /doctor /dream /restart /update
|
|
12
13
|
* + the unknown-command suggester
|
|
13
14
|
*
|
|
@@ -27,6 +28,7 @@ import { registerAdminCommands } from "./admin.js";
|
|
|
27
28
|
import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
|
|
28
29
|
import { registerAuthCommand } from "./auth.js";
|
|
29
30
|
import { registerBackupCommand } from "./backup.js";
|
|
31
|
+
import { registerSecretCommand } from "./secret.js";
|
|
30
32
|
|
|
31
33
|
export { telegramCommandMenu } from "./definitions.js";
|
|
32
34
|
export { setAdminUserId } from "./state.js";
|
|
@@ -45,6 +47,7 @@ export function registerCommands(
|
|
|
45
47
|
registerWhatsAppPairingCommand(bot);
|
|
46
48
|
registerAuthCommand(bot);
|
|
47
49
|
registerBackupCommand(bot);
|
|
50
|
+
registerSecretCommand(bot);
|
|
48
51
|
// admin LAST: it owns the unknown-command catch-all, which must only
|
|
49
52
|
// be reached after every real command has had its chance to match.
|
|
50
53
|
// Registering anything after it makes that command look unknown
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /secret <name> [purpose] — a single-use link to paste a password into,
|
|
3
|
+
* so it never has to be typed into this chat. The rules and the reply
|
|
4
|
+
* text live in core/secrets; this only adapts Telegram's context.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Bot } from "grammy";
|
|
8
|
+
import { secretCommandReply } from "../../../core/secrets/index.js";
|
|
9
|
+
import { isAuthorizedAdmin } from "./state.js";
|
|
10
|
+
|
|
11
|
+
export function registerSecretCommand(bot: Bot): void {
|
|
12
|
+
bot.command("secret", async (ctx) => {
|
|
13
|
+
const text = secretCommandReply({
|
|
14
|
+
arg: typeof ctx.match === "string" ? ctx.match : "",
|
|
15
|
+
chatKey: String(ctx.chat.id),
|
|
16
|
+
frontend: "telegram",
|
|
17
|
+
isOperator: isAuthorizedAdmin(ctx),
|
|
18
|
+
isGroup: ctx.chat.type !== "private",
|
|
19
|
+
});
|
|
20
|
+
// No preview: a preview fetch is harmless (GET doesn't spend the
|
|
21
|
+
// grant) but there is no reason to hand Telegram the URL early.
|
|
22
|
+
await ctx.reply(text, { link_preview_options: { is_disabled: true } });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -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
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slash commands on WhatsApp — `/model`, `/effort`, `/settings`,
|
|
3
|
-
* `/reset`, `/status`, `/help`.
|
|
3
|
+
* `/reset`, `/status`, `/secret`, `/help`.
|
|
4
4
|
*
|
|
5
5
|
* WhatsApp has no inline buttons, so everything Telegram does with a
|
|
6
6
|
* picker is done here with typed arguments: `/model` prints a numbered
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
settingsCommandReply,
|
|
27
27
|
statusCommandReply,
|
|
28
28
|
} from "../presentation/text-commands.js";
|
|
29
|
+
import { isOperatorPrivateChat } from "../../core/mcp-hub/guest-scope.js";
|
|
30
|
+
import { secretCommandReply } from "../../core/secrets/index.js";
|
|
29
31
|
import { sendText } from "./actions/send.js";
|
|
30
32
|
import { identityAllowed, type Identity } from "./connection/identity.js";
|
|
31
33
|
import type { WhatsAppChatInfo } from "./registry.js";
|
|
@@ -37,6 +39,7 @@ const COMMAND_NAMES = [
|
|
|
37
39
|
"settings",
|
|
38
40
|
"reset",
|
|
39
41
|
"status",
|
|
42
|
+
"secret",
|
|
40
43
|
"help",
|
|
41
44
|
] as const;
|
|
42
45
|
|
|
@@ -96,6 +99,7 @@ const HELP_TEXT = [
|
|
|
96
99
|
"/settings — this chat's model, backend, effort and pulse",
|
|
97
100
|
"/status — session info, context usage and stats",
|
|
98
101
|
"/reset — start a fresh session (chat log kept)",
|
|
102
|
+
"/secret <name> — a one-time link to store a password, so it never goes in a chat (operator, DM)",
|
|
99
103
|
"/help — this message",
|
|
100
104
|
].join("\n");
|
|
101
105
|
|
|
@@ -143,6 +147,17 @@ export async function executeWhatsAppCommand(
|
|
|
143
147
|
return statusCommandReply(chatId, deps);
|
|
144
148
|
case "reset":
|
|
145
149
|
return runResetCommand(chatId, inbound.senderName, deps);
|
|
150
|
+
case "secret":
|
|
151
|
+
return secretCommandReply({
|
|
152
|
+
arg: cmd.arg,
|
|
153
|
+
chatKey: chatId,
|
|
154
|
+
frontend: "whatsapp",
|
|
155
|
+
// The operator's own DM (guest scope's operator chats), never a
|
|
156
|
+
// group and never another allowlisted contact.
|
|
157
|
+
isOperator:
|
|
158
|
+
!inbound.isGroup && isOperatorPrivateChat("whatsapp", chatId),
|
|
159
|
+
isGroup: inbound.isGroup,
|
|
160
|
+
});
|
|
146
161
|
case "help":
|
|
147
162
|
return HELP_TEXT;
|
|
148
163
|
}
|
|
@@ -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",
|