botschat 0.1.15 → 0.1.16
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 +1 -1
- package/packages/api/src/do/connection-do.ts +106 -18
- package/packages/api/src/index.ts +49 -1
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +75 -6
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +1 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/{index-DsWBWQD6.js → index--A7c71kf.js} +1 -1
- package/packages/web/dist/assets/{index-CvbTpaza.js → index-CEStVU9o.js} +135 -135
- package/packages/web/dist/assets/{index-cm_3YFsA.css → index-CLKSdbmx.css} +1 -1
- package/packages/web/dist/assets/{index-dMn_npR3.js → index-Cd5GHqU6.js} +1 -1
- package/packages/web/dist/assets/index-D3Vfl8Ll.js +2 -0
- package/packages/web/dist/assets/index-DUpmW4Ay.js +2 -0
- package/packages/web/dist/assets/index-DfBArjKG.js +1 -0
- package/packages/web/dist/assets/{index.esm-DdTIpXjl.js → index.esm-Dc_1yrX1.js} +1 -1
- package/packages/web/dist/assets/{web-Dft_LGIH.js → web-CDcVasbM.js} +1 -1
- package/packages/web/dist/assets/{web-DIeOUVhn.js → web-D_QoLpUi.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/src/App.tsx +24 -4
- package/packages/web/src/api.ts +2 -1
- package/packages/web/src/components/ChatWindow.tsx +134 -73
- package/packages/web/src/components/ScheduleEditor.tsx +120 -47
- package/packages/web/src/components/ThreadPanel.tsx +21 -2
- package/packages/web/src/foreground.ts +40 -10
- package/packages/web/src/store.ts +4 -0
- package/packages/web/dist/assets/index-CbCpFrA9.js +0 -2
- package/packages/web/dist/assets/index-Ct0m11C8.js +0 -2
- package/packages/web/dist/assets/index-GwprVhDP.js +0 -1
package/package.json
CHANGED
|
@@ -5,6 +5,20 @@ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
|
|
|
5
5
|
import { generateId as generateIdUtil } from "../utils/id.js";
|
|
6
6
|
import { randomUUID } from "../utils/uuid.js";
|
|
7
7
|
|
|
8
|
+
/** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
|
|
9
|
+
interface BrowserAttachment {
|
|
10
|
+
authenticated: boolean;
|
|
11
|
+
tag: string;
|
|
12
|
+
foreground: boolean;
|
|
13
|
+
sessionKey: string | null;
|
|
14
|
+
/** Timestamp (ms) when the session last went to background. */
|
|
15
|
+
backgroundAt: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Grace period constants for push notification suppression. */
|
|
19
|
+
const BG_GRACE_MS = 15_000; // 15 s after going background
|
|
20
|
+
const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
|
|
21
|
+
|
|
8
22
|
/**
|
|
9
23
|
* ConnectionDO — one Durable Object instance per BotsChat user.
|
|
10
24
|
*
|
|
@@ -29,8 +43,12 @@ export class ConnectionDO implements DurableObject {
|
|
|
29
43
|
/** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
|
|
30
44
|
private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
|
|
31
45
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Recently disconnected browser sessions — provides a grace period so that
|
|
48
|
+
* brief network blips don't immediately trigger push notifications.
|
|
49
|
+
* In-memory only; if the DO hibernates, the grace period has expired anyway.
|
|
50
|
+
*/
|
|
51
|
+
private recentDisconnects = new Map<string, number>();
|
|
34
52
|
|
|
35
53
|
/** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
|
|
36
54
|
private lastOpenClawAcceptedAt = 0;
|
|
@@ -150,9 +168,13 @@ export class ConnectionDO implements DurableObject {
|
|
|
150
168
|
JSON.stringify({ type: "openclaw.disconnected" }),
|
|
151
169
|
);
|
|
152
170
|
}
|
|
153
|
-
//
|
|
171
|
+
// Disconnect grace: remember recently-disconnected browser sessions so
|
|
172
|
+
// push notifications are still suppressed during brief network blips.
|
|
154
173
|
if (tag?.startsWith("browser:")) {
|
|
155
|
-
|
|
174
|
+
const att = ws.deserializeAttachment() as BrowserAttachment | null;
|
|
175
|
+
if (att?.foreground) {
|
|
176
|
+
this.recentDisconnects.set(tag, Date.now());
|
|
177
|
+
}
|
|
156
178
|
}
|
|
157
179
|
}
|
|
158
180
|
|
|
@@ -217,7 +239,17 @@ export class ConnectionDO implements DurableObject {
|
|
|
217
239
|
|
|
218
240
|
const tag = `browser:${sessionId}`;
|
|
219
241
|
this.state.acceptWebSocket(server, [tag]);
|
|
220
|
-
|
|
242
|
+
const att: BrowserAttachment = {
|
|
243
|
+
authenticated: false,
|
|
244
|
+
tag,
|
|
245
|
+
foreground: false,
|
|
246
|
+
sessionKey: null,
|
|
247
|
+
backgroundAt: null,
|
|
248
|
+
};
|
|
249
|
+
server.serializeAttachment(att);
|
|
250
|
+
|
|
251
|
+
// Clear disconnect grace entry — this session is back.
|
|
252
|
+
this.recentDisconnects.delete(tag);
|
|
221
253
|
|
|
222
254
|
return new Response(null, { status: 101, webSocket: client });
|
|
223
255
|
}
|
|
@@ -296,16 +328,19 @@ export class ConnectionDO implements DurableObject {
|
|
|
296
328
|
|
|
297
329
|
// For agent.media, cache external images to R2 so they remain accessible
|
|
298
330
|
// even after the original URL expires (e.g. DALL-E temporary URLs).
|
|
331
|
+
// Skip caching if the plugin already uploaded E2E-encrypted media.
|
|
299
332
|
let persistedMediaUrl = msg.mediaUrl as string | undefined;
|
|
300
|
-
if (msg.type === "agent.media" && persistedMediaUrl) {
|
|
333
|
+
if (msg.type === "agent.media" && persistedMediaUrl && !msg.mediaEncrypted) {
|
|
301
334
|
const cachedUrl = await this.cacheExternalMedia(persistedMediaUrl);
|
|
302
335
|
if (cachedUrl) {
|
|
303
336
|
persistedMediaUrl = cachedUrl;
|
|
304
|
-
// Update the message object so browsers get the cached URL
|
|
305
337
|
msg.mediaUrl = cachedUrl;
|
|
306
338
|
}
|
|
307
339
|
}
|
|
308
340
|
|
|
341
|
+
// Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
|
|
342
|
+
const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
|
|
343
|
+
|
|
309
344
|
await this.persistMessage({
|
|
310
345
|
id: msg.messageId as string | undefined,
|
|
311
346
|
sender: "agent",
|
|
@@ -314,7 +349,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
314
349
|
text: (msg.text ?? msg.caption ?? "") as string,
|
|
315
350
|
mediaUrl: persistedMediaUrl,
|
|
316
351
|
a2ui: msg.jsonl as string | undefined,
|
|
317
|
-
encrypted:
|
|
352
|
+
encrypted: encryptedBits,
|
|
318
353
|
});
|
|
319
354
|
}
|
|
320
355
|
|
|
@@ -377,10 +412,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
377
412
|
const { notifyPreview: _stripped, ...msgForBrowser } = msg;
|
|
378
413
|
this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
|
|
379
414
|
|
|
380
|
-
// Send push notification
|
|
415
|
+
// Send push notification unless a device is (or was recently) in the foreground
|
|
381
416
|
if (
|
|
382
417
|
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
|
|
383
|
-
this.
|
|
418
|
+
!this.shouldSuppressPush() &&
|
|
384
419
|
(this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
|
|
385
420
|
) {
|
|
386
421
|
this.sendPushNotifications(msg).catch((err) => {
|
|
@@ -393,7 +428,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
393
428
|
ws: WebSocket,
|
|
394
429
|
msg: Record<string, unknown>,
|
|
395
430
|
): Promise<void> {
|
|
396
|
-
const attachment = ws.deserializeAttachment() as
|
|
431
|
+
const attachment = ws.deserializeAttachment() as BrowserAttachment | null;
|
|
397
432
|
|
|
398
433
|
// Handle browser auth — verify JWT token
|
|
399
434
|
if (msg.type === "auth") {
|
|
@@ -444,15 +479,29 @@ export class ConnectionDO implements DurableObject {
|
|
|
444
479
|
return;
|
|
445
480
|
}
|
|
446
481
|
|
|
447
|
-
//
|
|
482
|
+
// ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
|
|
448
483
|
if (msg.type === "foreground.enter") {
|
|
449
|
-
|
|
450
|
-
|
|
484
|
+
ws.serializeAttachment({
|
|
485
|
+
...attachment,
|
|
486
|
+
foreground: true,
|
|
487
|
+
sessionKey: (msg.sessionKey as string) ?? attachment.sessionKey ?? null,
|
|
488
|
+
backgroundAt: null,
|
|
489
|
+
} satisfies BrowserAttachment);
|
|
451
490
|
return;
|
|
452
491
|
}
|
|
453
492
|
if (msg.type === "foreground.leave") {
|
|
454
|
-
|
|
455
|
-
|
|
493
|
+
ws.serializeAttachment({
|
|
494
|
+
...attachment,
|
|
495
|
+
foreground: false,
|
|
496
|
+
backgroundAt: Date.now(),
|
|
497
|
+
} satisfies BrowserAttachment);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (msg.type === "focus.update") {
|
|
501
|
+
ws.serializeAttachment({
|
|
502
|
+
...attachment,
|
|
503
|
+
sessionKey: (msg.sessionKey as string) ?? null,
|
|
504
|
+
} satisfies BrowserAttachment);
|
|
456
505
|
return;
|
|
457
506
|
}
|
|
458
507
|
|
|
@@ -668,6 +717,39 @@ export class ConnectionDO implements DurableObject {
|
|
|
668
717
|
}
|
|
669
718
|
}
|
|
670
719
|
|
|
720
|
+
// ---- Presence helpers ----
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Determine whether push notifications should be suppressed because a device
|
|
724
|
+
* is (or was very recently) in the foreground.
|
|
725
|
+
*
|
|
726
|
+
* Checks three layers:
|
|
727
|
+
* 1. Any connected browser socket with `foreground === true`
|
|
728
|
+
* 2. Background grace: socket went background < BG_GRACE_MS ago
|
|
729
|
+
* 3. Disconnect grace: socket disconnected < DC_GRACE_MS ago
|
|
730
|
+
*/
|
|
731
|
+
private shouldSuppressPush(): boolean {
|
|
732
|
+
const now = Date.now();
|
|
733
|
+
|
|
734
|
+
// 1 + 2: scan connected browser sockets
|
|
735
|
+
const sockets = this.state.getWebSockets();
|
|
736
|
+
for (const s of sockets) {
|
|
737
|
+
const att = s.deserializeAttachment() as BrowserAttachment | null;
|
|
738
|
+
if (!att || !att.tag?.startsWith("browser:") || !att.authenticated) continue;
|
|
739
|
+
if (att.foreground) return true;
|
|
740
|
+
if (att.backgroundAt && now - att.backgroundAt < BG_GRACE_MS) return true;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 3: recently disconnected sessions
|
|
744
|
+
for (const [tag, disconnectedAt] of this.recentDisconnects) {
|
|
745
|
+
if (now - disconnectedAt < DC_GRACE_MS) return true;
|
|
746
|
+
// Expired — prune
|
|
747
|
+
this.recentDisconnects.delete(tag);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
671
753
|
// ---- Push notifications ----
|
|
672
754
|
|
|
673
755
|
/**
|
|
@@ -1035,15 +1117,21 @@ export class ConnectionDO implements DurableObject {
|
|
|
1035
1117
|
if (mediaUrl) {
|
|
1036
1118
|
mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
|
|
1037
1119
|
}
|
|
1120
|
+
const encBits = (row.encrypted as number) ?? 0;
|
|
1121
|
+
// Derive mediaEncrypted:
|
|
1122
|
+
// - User messages with encrypted=1: media was always encrypted by browser
|
|
1123
|
+
// - Any message with bit 1 set (encrypted >= 2): media was E2E encrypted
|
|
1124
|
+
const mediaEncrypted = (row.sender === "user" && encBits >= 1) || (encBits & 2) !== 0;
|
|
1038
1125
|
return {
|
|
1039
1126
|
id: row.id,
|
|
1040
1127
|
sender: row.sender,
|
|
1041
1128
|
text: row.text ?? "",
|
|
1042
|
-
timestamp: ((row.created_at as number) ?? 0) * 1000,
|
|
1129
|
+
timestamp: ((row.created_at as number) ?? 0) * 1000,
|
|
1043
1130
|
mediaUrl,
|
|
1044
1131
|
a2ui: row.a2ui ?? undefined,
|
|
1045
1132
|
threadId: row.thread_id ?? undefined,
|
|
1046
|
-
encrypted:
|
|
1133
|
+
encrypted: encBits,
|
|
1134
|
+
mediaEncrypted,
|
|
1047
1135
|
};
|
|
1048
1136
|
}),
|
|
1049
1137
|
);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import type { Env } from "./env.js";
|
|
4
|
-
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
|
|
4
|
+
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature, signMediaUrl } from "./utils/auth.js";
|
|
5
|
+
import { randomUUID } from "./utils/uuid.js";
|
|
5
6
|
import { auth } from "./routes/auth.js";
|
|
6
7
|
import { agents } from "./routes/agents.js";
|
|
7
8
|
import { channels } from "./routes/channels.js";
|
|
@@ -477,6 +478,53 @@ app.get("/api/messages/:userId", async (c) => {
|
|
|
477
478
|
return stub.fetch(new Request(url.toString()));
|
|
478
479
|
});
|
|
479
480
|
|
|
481
|
+
// ---- Plugin upload (pairing token auth) ----
|
|
482
|
+
app.post("/api/plugin-upload", async (c) => {
|
|
483
|
+
const token = c.req.header("X-Pairing-Token");
|
|
484
|
+
if (!token) {
|
|
485
|
+
return c.json({ error: "Missing X-Pairing-Token header" }, 401);
|
|
486
|
+
}
|
|
487
|
+
const row = await c.env.DB.prepare(
|
|
488
|
+
"SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
|
|
489
|
+
)
|
|
490
|
+
.bind(token)
|
|
491
|
+
.first<{ user_id: string }>();
|
|
492
|
+
if (!row) {
|
|
493
|
+
return c.json({ error: "Invalid pairing token" }, 401);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const userId = row.user_id;
|
|
497
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
498
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
499
|
+
return c.json({ error: "Expected multipart/form-data" }, 400);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const formData = await c.req.formData();
|
|
503
|
+
const file = formData.get("file") as File | null;
|
|
504
|
+
if (!file) {
|
|
505
|
+
return c.json({ error: "No file provided" }, 400);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const MAX_SIZE = 20 * 1024 * 1024;
|
|
509
|
+
if (file.size > MAX_SIZE) {
|
|
510
|
+
return c.json({ error: "File too large (max 20 MB)" }, 413);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const fileType = file.type || "application/octet-stream";
|
|
514
|
+
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
|
515
|
+
const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
|
|
516
|
+
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
|
|
517
|
+
const key = `media/${userId}/${filename}`;
|
|
518
|
+
|
|
519
|
+
await c.env.MEDIA.put(key, file.stream(), {
|
|
520
|
+
httpMetadata: { contentType: fileType },
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const secret = getJwtSecret(c.env);
|
|
524
|
+
const url = await signMediaUrl(userId, filename, secret, 3600);
|
|
525
|
+
return c.json({ url, key });
|
|
526
|
+
});
|
|
527
|
+
|
|
480
528
|
// ---- Protected routes (require Bearer token) — AFTER ws routes ----
|
|
481
529
|
app.route("/api", protectedApp);
|
|
482
530
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAuC,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC/F,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { deleteBotsChatAccount, listBotsChatAccountIds, resolveBotsChatAccount,
|
|
|
2
2
|
import { getBotsChatRuntime } from "./runtime.js";
|
|
3
3
|
import { BotsChatCloudClient } from "./ws-client.js";
|
|
4
4
|
import crypto from "crypto";
|
|
5
|
-
import { encryptText, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
|
|
5
|
+
import { encryptText, encryptBytes, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// A2UI message-tool hints — injected via agentPrompt.messageToolHints so
|
|
8
8
|
// the agent knows it can output interactive UI components. These strings
|
|
@@ -42,6 +42,8 @@ function readAgentModel(_agentId) {
|
|
|
42
42
|
const cloudClients = new Map();
|
|
43
43
|
/** Maps accountId → cloudUrl so handleCloudMessage can resolve relative URLs */
|
|
44
44
|
const cloudUrls = new Map();
|
|
45
|
+
/** Maps accountId → pairingToken for plugin HTTP uploads */
|
|
46
|
+
const pairingTokens = new Map();
|
|
45
47
|
function getCloudClient(accountId) {
|
|
46
48
|
return cloudClients.get(accountId);
|
|
47
49
|
}
|
|
@@ -141,16 +143,17 @@ export const botschatPlugin = {
|
|
|
141
143
|
return { ok: true };
|
|
142
144
|
},
|
|
143
145
|
sendMedia: async (ctx) => {
|
|
144
|
-
const
|
|
146
|
+
const accountId = ctx.accountId ?? "default";
|
|
147
|
+
const client = getCloudClient(accountId);
|
|
145
148
|
if (!client?.connected) {
|
|
146
149
|
return { ok: false, error: new Error("Not connected to BotsChat cloud") };
|
|
147
150
|
}
|
|
148
151
|
const messageId = crypto.randomUUID();
|
|
149
152
|
let text = ctx.text;
|
|
150
153
|
let encrypted = false;
|
|
151
|
-
|
|
154
|
+
let mediaEncrypted = false;
|
|
155
|
+
if (client.e2eKey && text) {
|
|
152
156
|
try {
|
|
153
|
-
// Encrypt caption using messageId as contextId
|
|
154
157
|
const ciphertext = await encryptText(client.e2eKey, text, messageId);
|
|
155
158
|
text = toBase64(ciphertext);
|
|
156
159
|
encrypted = true;
|
|
@@ -159,17 +162,60 @@ export const botschatPlugin = {
|
|
|
159
162
|
return { ok: false, error: new Error(`Encryption failed: ${err}`) };
|
|
160
163
|
}
|
|
161
164
|
}
|
|
165
|
+
let finalMediaUrl = ctx.mediaUrl;
|
|
166
|
+
if (client.e2eKey && ctx.mediaUrl && !ctx.mediaUrl.startsWith("/api/media/")) {
|
|
167
|
+
try {
|
|
168
|
+
const baseUrl = cloudUrls.get(accountId);
|
|
169
|
+
const token = pairingTokens.get(accountId);
|
|
170
|
+
if (baseUrl && token) {
|
|
171
|
+
const resp = await fetch(ctx.mediaUrl, { signal: AbortSignal.timeout(15_000) });
|
|
172
|
+
if (resp.ok) {
|
|
173
|
+
const rawBytes = new Uint8Array(await resp.arrayBuffer());
|
|
174
|
+
const encBytes = await encryptBytes(client.e2eKey, rawBytes, `${messageId}:media`);
|
|
175
|
+
const contentType = resp.headers.get("Content-Type") ?? "application/octet-stream";
|
|
176
|
+
const extMap = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
|
|
177
|
+
const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
|
|
178
|
+
const formData = new FormData();
|
|
179
|
+
const blob = new Blob([encBytes], { type: contentType });
|
|
180
|
+
formData.append("file", blob, `encrypted.${ext}`);
|
|
181
|
+
const uploadUrl = `${baseUrl.replace(/\/$/, "")}/api/plugin-upload`;
|
|
182
|
+
const uploadResp = await fetch(uploadUrl, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "X-Pairing-Token": token },
|
|
185
|
+
body: formData,
|
|
186
|
+
signal: AbortSignal.timeout(30_000),
|
|
187
|
+
});
|
|
188
|
+
if (uploadResp.ok) {
|
|
189
|
+
const result = await uploadResp.json();
|
|
190
|
+
finalMediaUrl = result.url;
|
|
191
|
+
mediaEncrypted = true;
|
|
192
|
+
console.log(`[botschat][sendMedia] E2E encrypted media uploaded (${rawBytes.length} → ${encBytes.length} bytes)`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
console.error(`[botschat][sendMedia] Plugin upload failed: HTTP ${uploadResp.status}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.error(`[botschat][sendMedia] Failed to download media: HTTP ${resp.status}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.error(`[botschat][sendMedia] E2E media encryption failed, sending unencrypted:`, err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
162
207
|
const notifyPreview = (encrypted && client.notifyPreview && ctx.text)
|
|
163
208
|
? (ctx.text.length > 100 ? ctx.text.slice(0, 100) + "…" : ctx.text)
|
|
164
209
|
: undefined;
|
|
165
|
-
if (
|
|
210
|
+
if (finalMediaUrl) {
|
|
166
211
|
client.send({
|
|
167
212
|
type: "agent.media",
|
|
168
213
|
sessionKey: ctx.to,
|
|
169
|
-
mediaUrl:
|
|
214
|
+
mediaUrl: finalMediaUrl,
|
|
170
215
|
caption: text || undefined,
|
|
171
216
|
messageId,
|
|
172
217
|
encrypted,
|
|
218
|
+
mediaEncrypted,
|
|
173
219
|
...(notifyPreview ? { notifyPreview } : {}),
|
|
174
220
|
});
|
|
175
221
|
}
|
|
@@ -193,6 +239,18 @@ export const botschatPlugin = {
|
|
|
193
239
|
log?.warn(`[${accountId}] BotsChat not configured — skipping`);
|
|
194
240
|
return;
|
|
195
241
|
}
|
|
242
|
+
const existingClient = cloudClients.get(accountId);
|
|
243
|
+
if (existingClient?.connected) {
|
|
244
|
+
log?.info(`[${accountId}] Already connected — skipping restart`);
|
|
245
|
+
return existingClient;
|
|
246
|
+
}
|
|
247
|
+
if (existingClient) {
|
|
248
|
+
log?.info(`[${accountId}] Disconnecting stale client before reconnect`);
|
|
249
|
+
existingClient.disconnect();
|
|
250
|
+
cloudClients.delete(accountId);
|
|
251
|
+
cloudUrls.delete(accountId);
|
|
252
|
+
pairingTokens.delete(accountId);
|
|
253
|
+
}
|
|
196
254
|
ctx.setStatus({
|
|
197
255
|
...ctx.getStatus(),
|
|
198
256
|
accountId,
|
|
@@ -223,11 +281,13 @@ export const botschatPlugin = {
|
|
|
223
281
|
});
|
|
224
282
|
cloudClients.set(accountId, client);
|
|
225
283
|
cloudUrls.set(accountId, account.cloudUrl);
|
|
284
|
+
pairingTokens.set(accountId, account.pairingToken);
|
|
226
285
|
client.connect();
|
|
227
286
|
ctx.abortSignal.addEventListener("abort", () => {
|
|
228
287
|
client.disconnect();
|
|
229
288
|
cloudClients.delete(accountId);
|
|
230
289
|
cloudUrls.delete(accountId);
|
|
290
|
+
pairingTokens.delete(accountId);
|
|
231
291
|
});
|
|
232
292
|
return client;
|
|
233
293
|
},
|
|
@@ -800,6 +860,9 @@ async function openclawCronAdd(msg, log) {
|
|
|
800
860
|
if (/^at\s+/i.test(s)) {
|
|
801
861
|
args.push("--at", s.replace(/^at\s+/i, ""));
|
|
802
862
|
}
|
|
863
|
+
else if (/^cron\s+/i.test(s)) {
|
|
864
|
+
args.push("--cron", s.replace(/^cron\s+/i, ""));
|
|
865
|
+
}
|
|
803
866
|
else if (s) {
|
|
804
867
|
args.push("--every", s.replace(/^every\s+/i, ""));
|
|
805
868
|
}
|
|
@@ -869,6 +932,9 @@ async function handleTaskSchedule(msg, ctx) {
|
|
|
869
932
|
if (/^at\s+/i.test(s)) {
|
|
870
933
|
args.push("--at", s.replace(/^at\s+/i, ""));
|
|
871
934
|
}
|
|
935
|
+
else if (/^cron\s+/i.test(s)) {
|
|
936
|
+
args.push("--cron", s.replace(/^cron\s+/i, ""));
|
|
937
|
+
}
|
|
872
938
|
else {
|
|
873
939
|
args.push("--every", s.replace(/^every\s+/i, ""));
|
|
874
940
|
}
|
|
@@ -1399,6 +1465,9 @@ async function handleTaskScanRequest(ctx) {
|
|
|
1399
1465
|
else if (job.schedule.kind === "at" && job.schedule.at) {
|
|
1400
1466
|
scheduleStr = `at ${job.schedule.at}`;
|
|
1401
1467
|
}
|
|
1468
|
+
else if (job.schedule.kind === "cron" && job.schedule.expr) {
|
|
1469
|
+
scheduleStr = `cron ${job.schedule.expr}`;
|
|
1470
|
+
}
|
|
1402
1471
|
}
|
|
1403
1472
|
let lastRun;
|
|
1404
1473
|
// Model: OpenClaw stores it in job.payload.model (agentTurn), not at job top level
|