r2-relay-channel 0.3.5

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/dist/channel.d.ts +16 -0
  4. package/dist/channel.js +271 -0
  5. package/dist/checkpoint-store.d.ts +4 -0
  6. package/dist/checkpoint-store.js +16 -0
  7. package/dist/commands.d.ts +2 -0
  8. package/dist/commands.js +54 -0
  9. package/dist/config.d.ts +76 -0
  10. package/dist/config.js +123 -0
  11. package/dist/conversation-targets.d.ts +32 -0
  12. package/dist/conversation-targets.js +120 -0
  13. package/dist/inbound.d.ts +38 -0
  14. package/dist/inbound.js +440 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +26 -0
  17. package/dist/lifecycle.d.ts +64 -0
  18. package/dist/lifecycle.js +296 -0
  19. package/dist/media.d.ts +30 -0
  20. package/dist/media.js +361 -0
  21. package/dist/message-adapter.d.ts +27 -0
  22. package/dist/message-adapter.js +53 -0
  23. package/dist/openclaw.plugin.json +41 -0
  24. package/dist/outbound.d.ts +28 -0
  25. package/dist/outbound.js +141 -0
  26. package/dist/protocol.d.ts +2 -0
  27. package/dist/protocol.js +1 -0
  28. package/dist/r2-relay.d.ts +1 -0
  29. package/dist/r2-relay.js +1 -0
  30. package/dist/r2client.d.ts +33 -0
  31. package/dist/r2client.js +137 -0
  32. package/dist/relay-core/checkpoint.d.ts +31 -0
  33. package/dist/relay-core/checkpoint.js +95 -0
  34. package/dist/relay-core/errors.d.ts +9 -0
  35. package/dist/relay-core/errors.js +18 -0
  36. package/dist/relay-core/index.d.ts +6 -0
  37. package/dist/relay-core/index.js +6 -0
  38. package/dist/relay-core/keyspace.d.ts +24 -0
  39. package/dist/relay-core/keyspace.js +60 -0
  40. package/dist/relay-core/object-store.d.ts +20 -0
  41. package/dist/relay-core/object-store.js +1 -0
  42. package/dist/relay-core/transport.d.ts +48 -0
  43. package/dist/relay-core/transport.js +326 -0
  44. package/dist/relay-core/types.d.ts +220 -0
  45. package/dist/relay-core/types.js +1 -0
  46. package/dist/runtime.d.ts +9 -0
  47. package/dist/runtime.js +54 -0
  48. package/dist/service.d.ts +51 -0
  49. package/dist/service.js +186 -0
  50. package/dist/service.test.d.ts +1 -0
  51. package/dist/service.test.js +27 -0
  52. package/dist/session-publication.d.ts +7 -0
  53. package/dist/session-publication.js +365 -0
  54. package/dist/setup-entry.d.ts +6 -0
  55. package/dist/setup-entry.js +49 -0
  56. package/dist/setup.d.ts +3 -0
  57. package/dist/setup.js +291 -0
  58. package/dist/target.d.ts +6 -0
  59. package/dist/target.js +53 -0
  60. package/dist/webhook.d.ts +18 -0
  61. package/dist/webhook.js +225 -0
  62. package/openclaw.plugin.json +41 -0
  63. package/package.json +62 -0
@@ -0,0 +1,296 @@
1
+ import { createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
2
+ import { hasSeenMessage, rememberMessage, saveCheckpointState } from "./checkpoint-store.js";
3
+ import { Service } from "./service.js";
4
+ const activeServices = new Map();
5
+ const runtimeSnapshots = new Map();
6
+ export function createDefaultRuntimeState(accountId) {
7
+ return createDefaultChannelRuntimeState(accountId, {
8
+ serverId: null,
9
+ endpoint: null,
10
+ bucket: null,
11
+ lastPollAt: null,
12
+ checkpointHeadKey: null,
13
+ lastInboundAt: null,
14
+ lastOutboundAt: null,
15
+ });
16
+ }
17
+ export function getRuntimeSnapshot(accountId) {
18
+ return runtimeSnapshots.get(accountId);
19
+ }
20
+ export function setRuntimeSnapshot(accountId, snapshot) {
21
+ runtimeSnapshots.set(accountId, snapshot);
22
+ }
23
+ export function touchRuntime(accountId, patch) {
24
+ const current = runtimeSnapshots.get(accountId) ?? createDefaultRuntimeState(accountId);
25
+ runtimeSnapshots.set(accountId, {
26
+ ...current,
27
+ ...patch,
28
+ });
29
+ }
30
+ export function getOrCreateService(account) {
31
+ const existing = activeServices.get(account.accountId);
32
+ if (existing) {
33
+ return existing;
34
+ }
35
+ const created = new Service({
36
+ endpoint: account.endpoint,
37
+ bucket: account.bucket,
38
+ region: account.region,
39
+ accessKeyId: account.accessKeyId,
40
+ secretAccessKey: account.secretAccessKey,
41
+ forcePathStyle: account.forcePathStyle,
42
+ peerId: account.serverId,
43
+ });
44
+ activeServices.set(account.accountId, created);
45
+ return created;
46
+ }
47
+ export function clearService(accountId) {
48
+ activeServices.delete(accountId);
49
+ }
50
+ export function redactEndpoint(endpoint) {
51
+ try {
52
+ const url = new URL(endpoint);
53
+ return `${url.protocol}//${url.host}`;
54
+ }
55
+ catch {
56
+ return endpoint;
57
+ }
58
+ }
59
+ export function emitRelayDebug(log, message, meta) {
60
+ if (log?.debug) {
61
+ log.debug(message, meta);
62
+ return;
63
+ }
64
+ if (log?.info) {
65
+ log.info(message, meta);
66
+ return;
67
+ }
68
+ if (meta) {
69
+ console.log(message, JSON.stringify(meta));
70
+ return;
71
+ }
72
+ console.log(message);
73
+ }
74
+ function summarizeRelayMessageForLog(msg) {
75
+ return {
76
+ msgId: msg.msg_id ?? null,
77
+ contentType: msg.content?.type ?? "text",
78
+ from: msg.from ?? null,
79
+ to: msg.to ?? null,
80
+ agentId: msg.route?.agent_id ?? null,
81
+ conversationId: msg.route?.conversation_id ?? null,
82
+ statusState: msg.status?.state ?? null,
83
+ };
84
+ }
85
+ export async function pollRelayInbox(params) {
86
+ const { cfg, account, service, abortSignal, log, setStatus, syncPublishedIdentity, dispatchInboundMessage, formatInboundRelayBody, classifyInboundRelayMessageFailure, } = params;
87
+ let interval = account.pollIntervalMs;
88
+ let state = params.state;
89
+ while (!abortSignal?.aborted) {
90
+ try {
91
+ const pollStartedAt = Date.now();
92
+ const wakeGapThresholdMs = Math.max(120_000, interval * 5);
93
+ if (state.lastPollAt && pollStartedAt - state.lastPollAt >= wakeGapThresholdMs) {
94
+ log?.info?.(`[${account.accountId}] detected long poll gap; refreshing published identity`, {
95
+ lastPollAt: state.lastPollAt,
96
+ pollStartedAt,
97
+ gapMs: pollStartedAt - state.lastPollAt,
98
+ wakeGapThresholdMs,
99
+ });
100
+ await syncPublishedIdentity(service, account, true);
101
+ }
102
+ const batch = await service.collectInboxMessages(account.serverId, state.lastHeadKey);
103
+ const now = Date.now();
104
+ state = { ...state, lastPollAt: now };
105
+ touchRuntime(account.accountId, {
106
+ lastPollAt: now,
107
+ checkpointHeadKey: state.lastHeadKey,
108
+ });
109
+ setStatus({
110
+ accountId: account.accountId,
111
+ lastPollAt: now,
112
+ checkpointHeadKey: state.lastHeadKey,
113
+ });
114
+ if (batch.messages.length > 0) {
115
+ let batchFailed = false;
116
+ for (const item of batch.messages) {
117
+ if (abortSignal?.aborted) {
118
+ break;
119
+ }
120
+ const msg = item.message;
121
+ try {
122
+ emitRelayDebug(log, `[${account.accountId}] evaluating inbound relay message`, {
123
+ objectKey: item.key,
124
+ ...summarizeRelayMessageForLog(msg),
125
+ });
126
+ if (msg.from === account.serverId) {
127
+ emitRelayDebug(log, `[${account.accountId}] skipping self-authored relay message`, {
128
+ objectKey: item.key,
129
+ ...summarizeRelayMessageForLog(msg),
130
+ });
131
+ await service.markMessageProcessed(item.key, {
132
+ state: "done",
133
+ processedBy: account.serverId,
134
+ });
135
+ state = rememberMessage(state, {
136
+ msgId: msg.msg_id,
137
+ objectKey: item.key,
138
+ at: msg.ts_sent,
139
+ });
140
+ state = { ...state, lastHeadKey: item.key };
141
+ continue;
142
+ }
143
+ if (msg.to !== account.serverId) {
144
+ emitRelayDebug(log, `[${account.accountId}] skipping relay message addressed to different peer`, {
145
+ objectKey: item.key,
146
+ expectedTo: account.serverId,
147
+ ...summarizeRelayMessageForLog(msg),
148
+ });
149
+ state = { ...state, lastHeadKey: item.key };
150
+ continue;
151
+ }
152
+ if (hasSeenMessage(state, { msgId: msg.msg_id, objectKey: item.key })) {
153
+ emitRelayDebug(log, `[${account.accountId}] skipping already-seen relay message`, {
154
+ objectKey: item.key,
155
+ ...summarizeRelayMessageForLog(msg),
156
+ });
157
+ state = { ...state, lastHeadKey: batch.checkpointHeadKey };
158
+ continue;
159
+ }
160
+ let processedState = "done";
161
+ try {
162
+ await dispatchInboundMessage({
163
+ cfg,
164
+ account,
165
+ service,
166
+ log,
167
+ text: formatInboundRelayBody(msg),
168
+ senderId: msg.from,
169
+ timestamp: msg.ts_sent || Date.now(),
170
+ messageId: msg.msg_id,
171
+ route: msg.route,
172
+ attachments: (msg.content?.type === "text" ? msg.content.attachments : null) ?? [],
173
+ });
174
+ emitRelayDebug(log, `[${account.accountId}] dispatched inbound relay message to gateway`, {
175
+ objectKey: item.key,
176
+ ...summarizeRelayMessageForLog(msg),
177
+ });
178
+ }
179
+ catch (messageErr) {
180
+ const message = messageErr instanceof Error ? messageErr.message : String(messageErr);
181
+ processedState = classifyInboundRelayMessageFailure(messageErr);
182
+ touchRuntime(account.accountId, { lastError: message });
183
+ setStatus({ accountId: account.accountId, lastError: message });
184
+ log?.error?.(`[${account.accountId}] relay inbound handling failed for ${item.key}: ${message}`, {
185
+ ...summarizeRelayMessageForLog(msg),
186
+ objectKey: item.key,
187
+ processedState,
188
+ });
189
+ }
190
+ try {
191
+ await service.markMessageProcessed(item.key, {
192
+ state: processedState,
193
+ processedBy: account.serverId,
194
+ });
195
+ }
196
+ catch (markErr) {
197
+ log?.warn?.(`[${account.accountId}] failed to persist processed state for ${item.key}: ${markErr instanceof Error ? markErr.message : String(markErr)}`);
198
+ }
199
+ state = rememberMessage(state, {
200
+ msgId: msg.msg_id,
201
+ objectKey: item.key,
202
+ at: msg.ts_sent,
203
+ });
204
+ state = { ...state, lastHeadKey: item.key };
205
+ touchRuntime(account.accountId, {
206
+ lastInboundAt: msg.ts_sent || Date.now(),
207
+ checkpointHeadKey: state.lastHeadKey,
208
+ lastError: processedState === "done" ? null : getRuntimeSnapshot(account.accountId)?.lastError ?? null,
209
+ });
210
+ setStatus({
211
+ accountId: account.accountId,
212
+ lastInboundAt: msg.ts_sent || Date.now(),
213
+ checkpointHeadKey: state.lastHeadKey,
214
+ lastError: processedState === "done" ? null : getRuntimeSnapshot(account.accountId)?.lastError ?? null,
215
+ });
216
+ }
217
+ catch (messageErr) {
218
+ const message = messageErr instanceof Error ? messageErr.message : String(messageErr);
219
+ touchRuntime(account.accountId, { lastError: message });
220
+ setStatus({ accountId: account.accountId, lastError: message });
221
+ log?.error?.(`[${account.accountId}] relay inbound handling failed for ${item.key}: ${message}`);
222
+ batchFailed = true;
223
+ break;
224
+ }
225
+ }
226
+ if (!batchFailed) {
227
+ state = {
228
+ ...state,
229
+ lastHeadKey: batch.checkpointHeadKey ?? state.lastHeadKey,
230
+ };
231
+ }
232
+ await saveCheckpointState(state, account.accountId);
233
+ touchRuntime(account.accountId, {
234
+ checkpointHeadKey: state.lastHeadKey,
235
+ lastPollAt: state.lastPollAt,
236
+ lastInboundAt: state.lastInboundAt,
237
+ });
238
+ setStatus({
239
+ accountId: account.accountId,
240
+ checkpointHeadKey: state.lastHeadKey,
241
+ lastPollAt: state.lastPollAt,
242
+ lastInboundAt: state.lastInboundAt,
243
+ });
244
+ if (!batchFailed) {
245
+ interval = account.pollIntervalMs;
246
+ }
247
+ }
248
+ await syncPublishedIdentity(service, account);
249
+ await sleep(interval, abortSignal);
250
+ if (!batch.head?.head_key) {
251
+ interval = Math.min(interval * 2, account.backoffMaxMs);
252
+ }
253
+ }
254
+ catch (err) {
255
+ const message = err instanceof Error ? err.message : String(err);
256
+ touchRuntime(account.accountId, { lastError: message });
257
+ setStatus({ accountId: account.accountId, lastError: message });
258
+ log?.error?.(`[${account.accountId}] relay poll failed: ${message}`);
259
+ await saveCheckpointState(state, account.accountId);
260
+ await sleep(interval, abortSignal);
261
+ interval = Math.min(interval * 2, account.backoffMaxMs);
262
+ }
263
+ }
264
+ await saveCheckpointState(state, account.accountId);
265
+ }
266
+ export async function runSweeperLoop(params) {
267
+ await sleep(60_000, params.abortSignal);
268
+ while (!params.abortSignal?.aborted) {
269
+ try {
270
+ const summaries = await params.service.sweepRetention(params.account.ttl, params.abortSignal);
271
+ const summaryText = summaries.map((item) => `${item.prefix} scanned=${item.scanned} deleted=${item.deleted}`).join("; ");
272
+ params.log?.info?.(`[${params.account.accountId}] relay sweeper complete${summaryText ? `: ${summaryText}` : ""}`);
273
+ }
274
+ catch (err) {
275
+ params.log?.warn?.(`[${params.account.accountId}] relay sweeper failed: ${err instanceof Error ? err.message : String(err)}`);
276
+ }
277
+ await sleep(24 * 60 * 60 * 1000, params.abortSignal);
278
+ }
279
+ }
280
+ function sleep(ms, abortSignal) {
281
+ if (ms <= 0) {
282
+ return Promise.resolve();
283
+ }
284
+ return new Promise((resolve) => {
285
+ const timer = setTimeout(() => {
286
+ abortSignal?.removeEventListener("abort", onAbort);
287
+ resolve();
288
+ }, ms);
289
+ const onAbort = () => {
290
+ clearTimeout(timer);
291
+ abortSignal?.removeEventListener("abort", onAbort);
292
+ resolve();
293
+ };
294
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
295
+ });
296
+ }
@@ -0,0 +1,30 @@
1
+ import { type OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
2
+ import type { ResolvedR2RelayAccount } from "./config.js";
3
+ import type { AttachmentRef } from "./protocol.js";
4
+ import type { Service } from "./service.js";
5
+ import { type RelayPollLog } from "./lifecycle.js";
6
+ export declare function resolveRelayPayloadMediaUrls(payload: {
7
+ mediaUrl?: string | null;
8
+ mediaUrls?: string[] | null;
9
+ }): string[];
10
+ export declare function buildRelayAttachments(params: {
11
+ account: ResolvedR2RelayAccount;
12
+ service: Service;
13
+ targetPeer: string;
14
+ mediaUrls: string[];
15
+ workspaceDir?: string | null;
16
+ mediaAccess?: OutboundMediaAccess;
17
+ mediaLocalRoots?: readonly string[];
18
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
19
+ log?: RelayPollLog;
20
+ source?: string;
21
+ }): Promise<{
22
+ attachments: AttachmentRef[];
23
+ failures: string[];
24
+ }>;
25
+ export declare function resolveExistingLocalMediaReference(value: string, workspaceDir: string): string | URL;
26
+ export declare function describeLocalMediaReference(value: string): string;
27
+ export declare function normalizeAttachmentContentType(primary?: string | null, fallback?: string | null, fileName?: string | null): string;
28
+ export declare function resolveInboundAttachmentBestEffortMaxBytes(attachment: AttachmentRef, defaultMaxBytes: number): number;
29
+ export declare function resolveInboundAttachmentMaxBytes(attachment: AttachmentRef): number;
30
+ export declare function formatAttachmentContext(attachments?: AttachmentRef[]): string | null;
package/dist/media.js ADDED
@@ -0,0 +1,361 @@
1
+ import fs from "node:fs";
2
+ import { kindFromMime, maxBytesForKind, } from "openclaw/plugin-sdk/media-runtime";
3
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
4
+ import { emitRelayDebug } from "./lifecycle.js";
5
+ const MAX_IMAGE_BYTES = maxBytesForKind("image");
6
+ const MAX_VIDEO_BYTES = maxBytesForKind("video");
7
+ const MAX_AUDIO_BYTES = maxBytesForKind("audio");
8
+ const MAX_DOCUMENT_BYTES = maxBytesForKind("document");
9
+ export function resolveRelayPayloadMediaUrls(payload) {
10
+ const urls = [
11
+ ...(payload.mediaUrls ?? []),
12
+ ...(payload.mediaUrl ? [payload.mediaUrl] : []),
13
+ ];
14
+ const seen = new Set();
15
+ return urls
16
+ .map((url) => url?.trim())
17
+ .filter((url) => Boolean(url))
18
+ .filter((url) => {
19
+ if (seen.has(url)) {
20
+ return false;
21
+ }
22
+ seen.add(url);
23
+ return true;
24
+ });
25
+ }
26
+ export async function buildRelayAttachments(params) {
27
+ const attachments = [];
28
+ const failures = [];
29
+ if (params.mediaUrls.length === 0) {
30
+ return { attachments, failures };
31
+ }
32
+ const relay = params.service.relay;
33
+ const messageId = relay.shortUuid();
34
+ const nowMs = Date.now();
35
+ const maxBytes = Math.max(MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, MAX_AUDIO_BYTES, MAX_DOCUMENT_BYTES);
36
+ for (let i = 0; i < params.mediaUrls.length; i++) {
37
+ const rawUrl = params.mediaUrls[i];
38
+ const attKey = relay.makeAttKey(params.targetPeer, messageId, i + 1, undefined, nowMs);
39
+ try {
40
+ const resolvedUrl = isLocalMediaPath(rawUrl)
41
+ ? resolveExistingLocalMediaReference(rawUrl, params.workspaceDir?.trim() || process.cwd())
42
+ : rawUrl.trim();
43
+ if (isLocalMediaPath(rawUrl)) {
44
+ emitRelayDebug(params.log, `[${params.account.accountId}] resolving local outbound relay media`, {
45
+ source: params.source ?? "unknown",
46
+ rawUrl,
47
+ resolvedPath: resolvedUrl instanceof URL ? resolvedUrl.toString() : resolvedUrl,
48
+ workspaceDir: params.workspaceDir?.trim() || process.cwd(),
49
+ cwd: process.cwd(),
50
+ });
51
+ }
52
+ else {
53
+ emitRelayDebug(params.log, `[${params.account.accountId}] fetching remote outbound relay media`, {
54
+ source: params.source ?? "unknown",
55
+ mediaUrl: rawUrl,
56
+ });
57
+ }
58
+ const media = await loadOutboundMediaFromUrl(resolvedUrl instanceof URL ? resolvedUrl.toString() : resolvedUrl, {
59
+ maxBytes,
60
+ mediaAccess: params.mediaAccess,
61
+ mediaLocalRoots: params.mediaLocalRoots,
62
+ mediaReadFile: params.mediaReadFile,
63
+ });
64
+ const buffer = media.buffer;
65
+ const fileName = media.fileName ?? null;
66
+ const contentType = normalizeAttachmentContentType(media.contentType, inferContentTypeFromFileName(fileName), fileName);
67
+ const dimensions = inferImageDimensions(buffer, contentType);
68
+ emitRelayDebug(params.log, `[${params.account.accountId}] uploading outbound relay attachment`, {
69
+ source: params.source ?? "unknown",
70
+ attKey,
71
+ fileName,
72
+ contentType,
73
+ size: buffer.length,
74
+ width: dimensions?.width ?? null,
75
+ height: dimensions?.height ?? null,
76
+ });
77
+ await params.service.client.putObject(attKey, buffer, contentType, undefined, undefined, "*");
78
+ attachments.push({
79
+ id: `att-${messageId}-${i + 1}`,
80
+ key: attKey,
81
+ file_name: fileName,
82
+ content_type: contentType,
83
+ size: buffer.length,
84
+ sha256: null,
85
+ kind: inferKindFromMediaType(contentType),
86
+ width: dimensions?.width ?? null,
87
+ height: dimensions?.height ?? null,
88
+ duration_ms: null,
89
+ preview_image_key: null,
90
+ preview_image_type: null,
91
+ preview_size: null,
92
+ });
93
+ emitRelayDebug(params.log, `[${params.account.accountId}] outbound relay attachment uploaded`, {
94
+ source: params.source ?? "unknown",
95
+ attKey,
96
+ fileName,
97
+ });
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ failures.push(`${rawUrl}: ${message}`);
102
+ params.log?.error?.(`[${params.account.accountId}] failed preparing outbound relay attachment`, {
103
+ source: params.source ?? "unknown",
104
+ mediaUrl: rawUrl,
105
+ error: message,
106
+ });
107
+ }
108
+ }
109
+ return { attachments, failures };
110
+ }
111
+ export function resolveExistingLocalMediaReference(value, workspaceDir) {
112
+ const trimmed = value.trim();
113
+ if (trimmed.toLowerCase().startsWith("file://")) {
114
+ const url = new URL(trimmed);
115
+ return new URL(`file://${resolveExistingFilePrefix(url.pathname)}`);
116
+ }
117
+ const absolute = isAbsolutePath(trimmed)
118
+ ? trimmed
119
+ : resolvePath(workspaceDir, trimmed);
120
+ return resolveExistingFilePrefix(absolute);
121
+ }
122
+ function resolveExistingFilePrefix(value) {
123
+ const original = value.trim();
124
+ let candidate = original;
125
+ while (candidate.length > 1) {
126
+ try {
127
+ const stat = fs.statSync(candidate);
128
+ if (stat.isFile()) {
129
+ return candidate;
130
+ }
131
+ }
132
+ catch {
133
+ // keep trimming at whitespace boundaries only
134
+ }
135
+ const next = candidate.replace(/\s+\S+\s*$/, "").trimEnd();
136
+ if (!next || next === candidate) {
137
+ break;
138
+ }
139
+ candidate = next;
140
+ }
141
+ return original;
142
+ }
143
+ export function describeLocalMediaReference(value) {
144
+ const trimmed = value.trim();
145
+ return pathBasename(trimmed) || trimmed;
146
+ }
147
+ function inferImageDimensions(buffer, contentType) {
148
+ const lower = contentType?.toLowerCase() ?? "";
149
+ try {
150
+ if (lower === "image/png" && buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47) {
151
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
152
+ }
153
+ if ((lower === "image/jpeg" || lower === "image/jpg") && buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
154
+ let offset = 2;
155
+ while (offset + 9 < buffer.length) {
156
+ if (buffer[offset] !== 0xff) {
157
+ offset += 1;
158
+ continue;
159
+ }
160
+ const marker = buffer[offset + 1];
161
+ const length = buffer.readUInt16BE(offset + 2);
162
+ if (length < 2) {
163
+ break;
164
+ }
165
+ const isStartOfFrame = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf);
166
+ if (isStartOfFrame && offset + 8 < buffer.length) {
167
+ return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) };
168
+ }
169
+ offset += 2 + length;
170
+ }
171
+ return null;
172
+ }
173
+ if (lower === "image/gif" && buffer.length >= 10 && buffer.toString("ascii", 0, 3) === "GIF") {
174
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
175
+ }
176
+ if (lower === "image/webp" && buffer.length >= 30 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
177
+ const chunk = buffer.toString("ascii", 12, 16);
178
+ if (chunk === "VP8X" && buffer.length >= 30) {
179
+ return {
180
+ width: 1 + buffer.readUIntLE(24, 3),
181
+ height: 1 + buffer.readUIntLE(27, 3),
182
+ };
183
+ }
184
+ if (chunk === "VP8 " && buffer.length >= 30) {
185
+ return {
186
+ width: buffer.readUInt16LE(26) & 0x3fff,
187
+ height: buffer.readUInt16LE(28) & 0x3fff,
188
+ };
189
+ }
190
+ if (chunk === "VP8L" && buffer.length >= 25) {
191
+ const bits = buffer.readUInt32LE(21);
192
+ return {
193
+ width: (bits & 0x3fff) + 1,
194
+ height: ((bits >> 14) & 0x3fff) + 1,
195
+ };
196
+ }
197
+ }
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ return null;
203
+ }
204
+ function inferKindFromMediaType(type) {
205
+ const kind = kindFromMime(type);
206
+ switch (kind) {
207
+ case "image":
208
+ return "image";
209
+ case "video":
210
+ return "video";
211
+ case "audio":
212
+ return "audio";
213
+ default:
214
+ return "file";
215
+ }
216
+ }
217
+ function isLocalMediaPath(value) {
218
+ const lower = value.toLowerCase();
219
+ if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:")) {
220
+ return false;
221
+ }
222
+ if (lower.startsWith("media:")) {
223
+ return false;
224
+ }
225
+ if (lower.startsWith("file://")) {
226
+ return true;
227
+ }
228
+ return isAbsolutePath(value) || value.startsWith("./") || value.startsWith("../");
229
+ }
230
+ function inferContentTypeFromFileName(fileName) {
231
+ const ext = pathExtname(fileName ?? "").toLowerCase();
232
+ switch (ext) {
233
+ case ".jpg":
234
+ case ".jpeg":
235
+ return "image/jpeg";
236
+ case ".png":
237
+ return "image/png";
238
+ case ".gif":
239
+ return "image/gif";
240
+ case ".webp":
241
+ return "image/webp";
242
+ case ".heic":
243
+ return "image/heic";
244
+ case ".heif":
245
+ return "image/heif";
246
+ case ".mp4":
247
+ return "video/mp4";
248
+ case ".mov":
249
+ return "video/quicktime";
250
+ case ".webm":
251
+ return "video/webm";
252
+ case ".mp3":
253
+ return "audio/mpeg";
254
+ case ".m4a":
255
+ return "audio/mp4";
256
+ case ".wav":
257
+ return "audio/wav";
258
+ case ".ogg":
259
+ return "audio/ogg";
260
+ case ".pdf":
261
+ return "application/pdf";
262
+ case ".txt":
263
+ return "text/plain";
264
+ case ".json":
265
+ return "application/json";
266
+ case ".zip":
267
+ return "application/zip";
268
+ default:
269
+ return "application/octet-stream";
270
+ }
271
+ }
272
+ export function normalizeAttachmentContentType(primary, fallback, fileName) {
273
+ const primaryTrimmed = primary?.trim();
274
+ if (primaryTrimmed)
275
+ return primaryTrimmed;
276
+ const fallbackTrimmed = fallback?.trim();
277
+ if (fallbackTrimmed)
278
+ return fallbackTrimmed;
279
+ return inferContentTypeFromFileName(fileName);
280
+ }
281
+ export function resolveInboundAttachmentBestEffortMaxBytes(attachment, defaultMaxBytes) {
282
+ if (typeof attachment.size === "number" && Number.isFinite(attachment.size) && attachment.size > 0) {
283
+ return Math.max(defaultMaxBytes, Math.min(attachment.size + 1024 * 1024, 64 * 1024 * 1024));
284
+ }
285
+ return Math.max(defaultMaxBytes, 64 * 1024 * 1024);
286
+ }
287
+ function isImageAttachment(attachment) {
288
+ if (attachment.kind === "image") {
289
+ return true;
290
+ }
291
+ return Boolean(attachment.content_type?.toLowerCase().startsWith("image/"));
292
+ }
293
+ function isVideoAttachment(attachment) {
294
+ if (attachment.kind === "video") {
295
+ return true;
296
+ }
297
+ return Boolean(attachment.content_type?.toLowerCase().startsWith("video/"));
298
+ }
299
+ function isAudioAttachment(attachment) {
300
+ if (attachment.kind === "audio") {
301
+ return true;
302
+ }
303
+ return Boolean(attachment.content_type?.toLowerCase().startsWith("audio/"));
304
+ }
305
+ export function resolveInboundAttachmentMaxBytes(attachment) {
306
+ if (isImageAttachment(attachment))
307
+ return MAX_IMAGE_BYTES;
308
+ if (isVideoAttachment(attachment))
309
+ return MAX_VIDEO_BYTES;
310
+ if (isAudioAttachment(attachment))
311
+ return MAX_AUDIO_BYTES;
312
+ return MAX_DOCUMENT_BYTES;
313
+ }
314
+ export function formatAttachmentContext(attachments) {
315
+ if (!attachments || attachments.length === 0)
316
+ return null;
317
+ const lines = attachments.map((att, i) => {
318
+ const parts = [`[Attachment ${i + 1}]`];
319
+ if (att.file_name)
320
+ parts.push(`name: ${att.file_name}`);
321
+ if (att.content_type)
322
+ parts.push(`type: ${att.content_type}`);
323
+ if (att.kind && att.kind !== "unknown")
324
+ parts.push(`kind: ${att.kind}`);
325
+ if (att.size)
326
+ parts.push(`size: ${att.size} bytes`);
327
+ if (att.width && att.height)
328
+ parts.push(`dimensions: ${att.width}x${att.height}`);
329
+ if (att.duration_ms)
330
+ parts.push(`duration: ${(att.duration_ms / 1000).toFixed(1)}s`);
331
+ return parts.join(", ");
332
+ });
333
+ return lines.join("\n");
334
+ }
335
+ function pathBasename(value) {
336
+ return value.split(/[\\/]/).pop() ?? value;
337
+ }
338
+ function pathExtname(value) {
339
+ const base = pathBasename(value);
340
+ const index = base.lastIndexOf(".");
341
+ return index >= 0 ? base.slice(index) : "";
342
+ }
343
+ function isAbsolutePath(value) {
344
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
345
+ }
346
+ function resolvePath(base, relative) {
347
+ const normalizedBase = base.replace(/[\\/]+$/, "");
348
+ const parts = `${normalizedBase}/${relative}`.split(/[\\/]+/);
349
+ const resolved = [];
350
+ for (const part of parts) {
351
+ if (!part || part === ".") {
352
+ continue;
353
+ }
354
+ if (part === "..") {
355
+ resolved.pop();
356
+ continue;
357
+ }
358
+ resolved.push(part);
359
+ }
360
+ return `${normalizedBase.startsWith("/") ? "/" : ""}${resolved.join("/")}`;
361
+ }