opencode-router 0.11.128 → 0.11.130
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/README.md +33 -0
- package/dist/bridge.js +240 -68
- package/dist/cli.js +67 -11
- package/dist/delivery.js +108 -0
- package/dist/health.js +10 -3
- package/dist/media-store.js +125 -0
- package/dist/media.js +111 -0
- package/dist/slack.js +204 -22
- package/dist/telegram.js +247 -14
- package/package.json +1 -1
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
2
|
+
"ECONNRESET",
|
|
3
|
+
"ECONNREFUSED",
|
|
4
|
+
"EAI_AGAIN",
|
|
5
|
+
"ENOTFOUND",
|
|
6
|
+
"ETIMEDOUT",
|
|
7
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
8
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
9
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
10
|
+
]);
|
|
11
|
+
function coerceStatus(error) {
|
|
12
|
+
if (!error || typeof error !== "object")
|
|
13
|
+
return undefined;
|
|
14
|
+
const record = error;
|
|
15
|
+
const maybe = [
|
|
16
|
+
record.status,
|
|
17
|
+
record.statusCode,
|
|
18
|
+
record.error_code,
|
|
19
|
+
record.response?.status,
|
|
20
|
+
record.data?.status,
|
|
21
|
+
];
|
|
22
|
+
for (const value of maybe) {
|
|
23
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
function coerceCode(error) {
|
|
30
|
+
if (!error || typeof error !== "object")
|
|
31
|
+
return "";
|
|
32
|
+
const code = error.code;
|
|
33
|
+
return typeof code === "string" ? code.trim().toUpperCase() : "";
|
|
34
|
+
}
|
|
35
|
+
function coerceMessage(error) {
|
|
36
|
+
if (error instanceof Error)
|
|
37
|
+
return error.message;
|
|
38
|
+
if (typeof error === "string")
|
|
39
|
+
return error;
|
|
40
|
+
return "Unknown delivery error";
|
|
41
|
+
}
|
|
42
|
+
export function classifyDeliveryError(error) {
|
|
43
|
+
const status = coerceStatus(error);
|
|
44
|
+
const code = coerceCode(error);
|
|
45
|
+
const message = coerceMessage(error);
|
|
46
|
+
const lower = message.toLowerCase();
|
|
47
|
+
if (status === 401 || lower.includes("invalid_auth") || lower.includes("unauthorized")) {
|
|
48
|
+
return { code: "auth", message, retryable: false, status };
|
|
49
|
+
}
|
|
50
|
+
if (status === 403 || lower.includes("forbidden") || lower.includes("not_in_channel")) {
|
|
51
|
+
return { code: "forbidden", message, retryable: false, status };
|
|
52
|
+
}
|
|
53
|
+
if (status === 404 || lower.includes("chat not found") || lower.includes("channel_not_found")) {
|
|
54
|
+
return { code: "not_found", message, retryable: false, status };
|
|
55
|
+
}
|
|
56
|
+
if (status === 400 && (lower.includes("chat_id") || lower.includes("invalid peer") || lower.includes("invalid slack peer"))) {
|
|
57
|
+
return { code: "invalid_target", message, retryable: false, status };
|
|
58
|
+
}
|
|
59
|
+
if (status === 429 || lower.includes("rate limit") || lower.includes("too many requests")) {
|
|
60
|
+
return { code: "rate_limited", message, retryable: true, status };
|
|
61
|
+
}
|
|
62
|
+
if (status === 413 || lower.includes("too large") || lower.includes("file_too_large")) {
|
|
63
|
+
return { code: "payload_too_large", message, retryable: false, status };
|
|
64
|
+
}
|
|
65
|
+
if (lower.includes("unsupported") || lower.includes("cannot upload") || lower.includes("not allowed for this message type")) {
|
|
66
|
+
return { code: "unsupported_media", message, retryable: false, status };
|
|
67
|
+
}
|
|
68
|
+
if (status === 408 || lower.includes("timeout") || lower.includes("timed out")) {
|
|
69
|
+
return { code: "timeout", message, retryable: true, status };
|
|
70
|
+
}
|
|
71
|
+
if ((status !== undefined && status >= 500) || RETRYABLE_NETWORK_CODES.has(code) || lower.includes("fetch failed")) {
|
|
72
|
+
return { code: "network", message, retryable: true, status };
|
|
73
|
+
}
|
|
74
|
+
return { code: "unknown", message, retryable: false, status };
|
|
75
|
+
}
|
|
76
|
+
function withJitter(baseMs) {
|
|
77
|
+
const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(baseMs * 0.2)));
|
|
78
|
+
return baseMs + jitter;
|
|
79
|
+
}
|
|
80
|
+
export async function withDeliveryRetry(operation, run, options = {}) {
|
|
81
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
|
82
|
+
const baseDelayMs = Math.max(50, options.baseDelayMs ?? 250);
|
|
83
|
+
const maxDelayMs = Math.max(baseDelayMs, options.maxDelayMs ?? 4_000);
|
|
84
|
+
let attempt = 0;
|
|
85
|
+
for (;;) {
|
|
86
|
+
attempt += 1;
|
|
87
|
+
try {
|
|
88
|
+
return await run();
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
const classified = classifyDeliveryError(error);
|
|
92
|
+
if (!classified.retryable || attempt >= maxAttempts) {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
const delayMs = Math.min(maxDelayMs, withJitter(baseDelayMs * 2 ** (attempt - 1)));
|
|
96
|
+
options.logger?.warn?.({
|
|
97
|
+
operation,
|
|
98
|
+
attempt,
|
|
99
|
+
maxAttempts,
|
|
100
|
+
delayMs,
|
|
101
|
+
code: classified.code,
|
|
102
|
+
status: classified.status,
|
|
103
|
+
message: classified.message,
|
|
104
|
+
}, "delivery operation failed; retrying");
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/health.js
CHANGED
|
@@ -439,9 +439,15 @@ export async function startHealthServer(port, getStatus, logger, handlers = {})
|
|
|
439
439
|
const peerId = typeof payload.peerId === "string" ? payload.peerId.trim() : "";
|
|
440
440
|
const autoBind = payload.autoBind === true;
|
|
441
441
|
const text = typeof payload.text === "string" ? payload.text : "";
|
|
442
|
-
|
|
442
|
+
const parts = Array.isArray(payload.parts) ? payload.parts : undefined;
|
|
443
|
+
const hasText = text.trim().length > 0;
|
|
444
|
+
const hasParts = Array.isArray(parts) && parts.length > 0;
|
|
445
|
+
if (!channel || (!hasText && !hasParts) || (!directory && !peerId)) {
|
|
443
446
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
444
|
-
res.end(JSON.stringify({
|
|
447
|
+
res.end(JSON.stringify({
|
|
448
|
+
ok: false,
|
|
449
|
+
error: "channel, at least one of text/parts, and either directory or peerId are required",
|
|
450
|
+
}));
|
|
445
451
|
return;
|
|
446
452
|
}
|
|
447
453
|
const result = await handlers.sendMessage({
|
|
@@ -450,7 +456,8 @@ export async function startHealthServer(port, getStatus, logger, handlers = {})
|
|
|
450
456
|
...(directory ? { directory } : {}),
|
|
451
457
|
...(peerId ? { peerId } : {}),
|
|
452
458
|
...(autoBind ? { autoBind: true } : {}),
|
|
453
|
-
text,
|
|
459
|
+
...(hasText ? { text } : {}),
|
|
460
|
+
...(hasParts ? { parts } : {}),
|
|
454
461
|
});
|
|
455
462
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
456
463
|
res.end(JSON.stringify({ ok: true, ...result }));
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
function sanitizeSegment(value) {
|
|
5
|
+
const safe = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
6
|
+
return safe || "unknown";
|
|
7
|
+
}
|
|
8
|
+
function extensionFromMime(mimeType, kind) {
|
|
9
|
+
const value = (mimeType ?? "").toLowerCase();
|
|
10
|
+
if (value === "image/jpeg")
|
|
11
|
+
return ".jpg";
|
|
12
|
+
if (value === "image/png")
|
|
13
|
+
return ".png";
|
|
14
|
+
if (value === "image/webp")
|
|
15
|
+
return ".webp";
|
|
16
|
+
if (value === "audio/ogg")
|
|
17
|
+
return ".ogg";
|
|
18
|
+
if (value === "audio/mpeg")
|
|
19
|
+
return ".mp3";
|
|
20
|
+
if (value === "audio/mp4")
|
|
21
|
+
return ".m4a";
|
|
22
|
+
if (value === "application/pdf")
|
|
23
|
+
return ".pdf";
|
|
24
|
+
if (kind === "image")
|
|
25
|
+
return ".jpg";
|
|
26
|
+
if (kind === "audio")
|
|
27
|
+
return ".ogg";
|
|
28
|
+
return ".bin";
|
|
29
|
+
}
|
|
30
|
+
function sanitizeFilename(filename, fallbackPrefix, fallbackExt) {
|
|
31
|
+
const trimmed = filename.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return `${fallbackPrefix}${fallbackExt}`;
|
|
34
|
+
const base = basename(trimmed)
|
|
35
|
+
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "");
|
|
37
|
+
if (!base)
|
|
38
|
+
return `${fallbackPrefix}${fallbackExt}`;
|
|
39
|
+
if (extname(base))
|
|
40
|
+
return base;
|
|
41
|
+
return `${base}${fallbackExt}`;
|
|
42
|
+
}
|
|
43
|
+
export class MediaStore {
|
|
44
|
+
rootDir;
|
|
45
|
+
constructor(rootDir) {
|
|
46
|
+
this.rootDir = rootDir;
|
|
47
|
+
}
|
|
48
|
+
async ensureReady() {
|
|
49
|
+
await mkdir(this.rootDir, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
inboundDir(channel, identityId, peerId) {
|
|
52
|
+
const now = new Date();
|
|
53
|
+
const day = now.toISOString().slice(0, 10);
|
|
54
|
+
return join(this.rootDir, "inbound", day, sanitizeSegment(channel), sanitizeSegment(identityId), sanitizeSegment(peerId));
|
|
55
|
+
}
|
|
56
|
+
async saveInboundBuffer(input) {
|
|
57
|
+
const dir = this.inboundDir(input.channel, input.identityId, input.peerId);
|
|
58
|
+
await mkdir(dir, { recursive: true });
|
|
59
|
+
const defaultExt = extensionFromMime(input.mimeType, input.kind);
|
|
60
|
+
const safeFilename = sanitizeFilename(input.filename ?? "", `${input.kind}-${Date.now()}-${randomUUID().slice(0, 8)}`, defaultExt);
|
|
61
|
+
const filePath = join(dir, safeFilename);
|
|
62
|
+
await writeFile(filePath, input.buffer);
|
|
63
|
+
return {
|
|
64
|
+
filePath,
|
|
65
|
+
filename: safeFilename,
|
|
66
|
+
sizeBytes: input.buffer.byteLength,
|
|
67
|
+
...(input.mimeType ? { mimeType: input.mimeType } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async downloadInbound(input) {
|
|
71
|
+
const response = await fetch(input.url, {
|
|
72
|
+
headers: input.headers,
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const error = new Error(`Failed to download media (${response.status})`);
|
|
76
|
+
error.status = response.status;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const mimeType = input.mimeType || response.headers.get("content-type") || undefined;
|
|
80
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
81
|
+
return this.saveInboundBuffer({
|
|
82
|
+
channel: input.channel,
|
|
83
|
+
identityId: input.identityId,
|
|
84
|
+
peerId: input.peerId,
|
|
85
|
+
kind: input.kind,
|
|
86
|
+
buffer: new Uint8Array(arrayBuffer),
|
|
87
|
+
...(input.filename ? { filename: input.filename } : {}),
|
|
88
|
+
...(mimeType ? { mimeType } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async resolveOutboundFile(input) {
|
|
92
|
+
const raw = input.filePath.trim();
|
|
93
|
+
if (!raw) {
|
|
94
|
+
const error = new Error("filePath is required");
|
|
95
|
+
error.status = 400;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
const resolved = isAbsolute(raw) ? resolve(raw) : resolve(input.baseDirectory, raw);
|
|
99
|
+
let info;
|
|
100
|
+
try {
|
|
101
|
+
info = await stat(resolved);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const wrapped = new Error(`File not found: ${resolved}`);
|
|
105
|
+
wrapped.status = 404;
|
|
106
|
+
wrapped.cause = error;
|
|
107
|
+
throw wrapped;
|
|
108
|
+
}
|
|
109
|
+
if (!info.isFile()) {
|
|
110
|
+
const error = new Error(`Not a file: ${resolved}`);
|
|
111
|
+
error.status = 400;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (typeof input.maxBytes === "number" && Number.isFinite(input.maxBytes) && info.size > input.maxBytes) {
|
|
115
|
+
const error = new Error(`File exceeds maximum allowed size (${info.size} > ${Math.floor(input.maxBytes)} bytes): ${resolved}`);
|
|
116
|
+
error.status = 413;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
filePath: resolved,
|
|
121
|
+
filename: basename(resolved),
|
|
122
|
+
sizeBytes: info.size,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/media.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
function asTrimmedString(value) {
|
|
3
|
+
return typeof value === "string" ? value.trim() : "";
|
|
4
|
+
}
|
|
5
|
+
function parseTextPart(value) {
|
|
6
|
+
if (!value || typeof value !== "object")
|
|
7
|
+
return null;
|
|
8
|
+
const record = value;
|
|
9
|
+
if (record.type !== "text")
|
|
10
|
+
return null;
|
|
11
|
+
const text = typeof record.text === "string" ? record.text : "";
|
|
12
|
+
if (!text.trim())
|
|
13
|
+
return null;
|
|
14
|
+
return { type: "text", text };
|
|
15
|
+
}
|
|
16
|
+
function parseMediaPart(value) {
|
|
17
|
+
if (!value || typeof value !== "object")
|
|
18
|
+
return null;
|
|
19
|
+
const record = value;
|
|
20
|
+
if (record.type !== "image" && record.type !== "audio" && record.type !== "file")
|
|
21
|
+
return null;
|
|
22
|
+
const filePath = asTrimmedString(record.filePath);
|
|
23
|
+
if (!filePath)
|
|
24
|
+
return null;
|
|
25
|
+
const caption = typeof record.caption === "string" ? record.caption : undefined;
|
|
26
|
+
const filename = asTrimmedString(record.filename) || undefined;
|
|
27
|
+
const mimeType = asTrimmedString(record.mimeType) || undefined;
|
|
28
|
+
return {
|
|
29
|
+
type: record.type,
|
|
30
|
+
filePath,
|
|
31
|
+
...(caption ? { caption } : {}),
|
|
32
|
+
...(filename ? { filename } : {}),
|
|
33
|
+
...(mimeType ? { mimeType } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function normalizeOutboundParts(input) {
|
|
37
|
+
const normalized = [];
|
|
38
|
+
const text = typeof input.text === "string" ? input.text : "";
|
|
39
|
+
if (text.trim()) {
|
|
40
|
+
normalized.push({ type: "text", text });
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(input.parts)) {
|
|
43
|
+
for (const item of input.parts) {
|
|
44
|
+
const textPart = parseTextPart(item);
|
|
45
|
+
if (textPart) {
|
|
46
|
+
normalized.push(textPart);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const mediaPart = parseMediaPart(item);
|
|
50
|
+
if (mediaPart) {
|
|
51
|
+
normalized.push(mediaPart);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
export function textFromInboundParts(parts, fallbackText = "") {
|
|
58
|
+
if (!Array.isArray(parts) || parts.length === 0)
|
|
59
|
+
return fallbackText;
|
|
60
|
+
const texts = parts
|
|
61
|
+
.filter((part) => part.type === "text")
|
|
62
|
+
.map((part) => part.text)
|
|
63
|
+
.filter((value) => value.trim().length > 0);
|
|
64
|
+
if (texts.length === 0)
|
|
65
|
+
return fallbackText;
|
|
66
|
+
return texts.join("\n");
|
|
67
|
+
}
|
|
68
|
+
function formatBytes(sizeBytes) {
|
|
69
|
+
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes < 0)
|
|
70
|
+
return "";
|
|
71
|
+
if (sizeBytes < 1024)
|
|
72
|
+
return `${sizeBytes}B`;
|
|
73
|
+
if (sizeBytes < 1024 * 1024)
|
|
74
|
+
return `${(sizeBytes / 1024).toFixed(1)}KB`;
|
|
75
|
+
return `${(sizeBytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
76
|
+
}
|
|
77
|
+
export function summarizeInboundPartsForPrompt(parts) {
|
|
78
|
+
if (!Array.isArray(parts) || parts.length === 0)
|
|
79
|
+
return [];
|
|
80
|
+
const mediaParts = parts.filter((part) => part.type === "media");
|
|
81
|
+
if (mediaParts.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
return mediaParts.map((part, index) => {
|
|
84
|
+
const media = part.media;
|
|
85
|
+
const label = `[${media.kind}]`;
|
|
86
|
+
const filename = media.filename || (media.filePath ? basename(media.filePath) : "(unnamed)");
|
|
87
|
+
const details = [];
|
|
88
|
+
if (media.mimeType)
|
|
89
|
+
details.push(media.mimeType);
|
|
90
|
+
const prettySize = formatBytes(media.sizeBytes);
|
|
91
|
+
if (prettySize)
|
|
92
|
+
details.push(prettySize);
|
|
93
|
+
if (media.status === "ready") {
|
|
94
|
+
const pathLabel = media.filePath ? `path=${media.filePath}` : "path=(missing)";
|
|
95
|
+
if (part.caption?.trim())
|
|
96
|
+
details.push(`caption=${JSON.stringify(part.caption.trim())}`);
|
|
97
|
+
details.push(pathLabel);
|
|
98
|
+
return `${index + 1}. ${label} ${filename}${details.length ? ` (${details.join(", ")})` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
const reason = media.error?.trim() || "download failed";
|
|
101
|
+
return `${index + 1}. ${label} ${filename} (failed: ${reason})`;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function summarizeInboundPartsForReporter(parts) {
|
|
105
|
+
if (!Array.isArray(parts) || parts.length === 0)
|
|
106
|
+
return "";
|
|
107
|
+
const mediaCount = parts.filter((part) => part.type === "media").length;
|
|
108
|
+
if (!mediaCount)
|
|
109
|
+
return "";
|
|
110
|
+
return mediaCount === 1 ? "[1 media attachment]" : `[${mediaCount} media attachments]`;
|
|
111
|
+
}
|
package/dist/slack.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
1
3
|
import { SocketModeClient } from "@slack/socket-mode";
|
|
2
4
|
import { WebClient } from "@slack/web-api";
|
|
5
|
+
import { classifyDeliveryError, withDeliveryRetry } from "./delivery.js";
|
|
3
6
|
// `peerId` encoding:
|
|
4
7
|
// - DMs: D12345678
|
|
5
8
|
// - Threads in channels: C12345678|1700000000.000100
|
|
@@ -28,7 +31,7 @@ export function stripSlackMention(text, botUserId) {
|
|
|
28
31
|
return next.trim();
|
|
29
32
|
}
|
|
30
33
|
const MAX_TEXT_LENGTH = 39_000;
|
|
31
|
-
export function createSlackAdapter(identity, config, logger, onMessage, deps = { WebClient, SocketModeClient }) {
|
|
34
|
+
export function createSlackAdapter(identity, config, logger, onMessage, deps = { WebClient, SocketModeClient }, mediaStore) {
|
|
32
35
|
const botToken = identity.botToken?.trim() ?? "";
|
|
33
36
|
const appToken = identity.appToken?.trim() ?? "";
|
|
34
37
|
if (!botToken) {
|
|
@@ -42,6 +45,105 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
42
45
|
const socket = new deps.SocketModeClient({ appToken });
|
|
43
46
|
let botUserId = null;
|
|
44
47
|
let started = false;
|
|
48
|
+
const parseSlackFiles = (value) => {
|
|
49
|
+
if (!Array.isArray(value))
|
|
50
|
+
return [];
|
|
51
|
+
const files = [];
|
|
52
|
+
for (const item of value) {
|
|
53
|
+
if (!item || typeof item !== "object")
|
|
54
|
+
continue;
|
|
55
|
+
const record = item;
|
|
56
|
+
const id = typeof record.id === "string" ? record.id : "";
|
|
57
|
+
const url = typeof record.url_private_download === "string"
|
|
58
|
+
? record.url_private_download
|
|
59
|
+
: typeof record.url_private === "string"
|
|
60
|
+
? record.url_private
|
|
61
|
+
: "";
|
|
62
|
+
if (!id || !url)
|
|
63
|
+
continue;
|
|
64
|
+
const mimeType = typeof record.mimetype === "string" ? record.mimetype : undefined;
|
|
65
|
+
const kind = typeof mimeType === "string" && mimeType.startsWith("image/")
|
|
66
|
+
? "image"
|
|
67
|
+
: typeof mimeType === "string" && mimeType.startsWith("audio/")
|
|
68
|
+
? "audio"
|
|
69
|
+
: "file";
|
|
70
|
+
files.push({
|
|
71
|
+
id,
|
|
72
|
+
url,
|
|
73
|
+
kind,
|
|
74
|
+
...(typeof record.name === "string" ? { filename: record.name } : {}),
|
|
75
|
+
...(mimeType ? { mimeType } : {}),
|
|
76
|
+
...(typeof record.size === "number" ? { sizeBytes: record.size } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
};
|
|
81
|
+
const downloadSlackFile = async (peerId, candidate) => {
|
|
82
|
+
if (!mediaStore) {
|
|
83
|
+
return {
|
|
84
|
+
type: "media",
|
|
85
|
+
media: {
|
|
86
|
+
id: candidate.id,
|
|
87
|
+
kind: candidate.kind,
|
|
88
|
+
source: "slack",
|
|
89
|
+
status: "failed",
|
|
90
|
+
...(candidate.filename ? { filename: candidate.filename } : {}),
|
|
91
|
+
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
92
|
+
...(typeof candidate.sizeBytes === "number" ? { sizeBytes: candidate.sizeBytes } : {}),
|
|
93
|
+
providerFileId: candidate.id,
|
|
94
|
+
providerUrl: candidate.url,
|
|
95
|
+
error: "media store unavailable",
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const stored = await withDeliveryRetry("slack.download", () => mediaStore.downloadInbound({
|
|
101
|
+
channel: "slack",
|
|
102
|
+
identityId: identity.id,
|
|
103
|
+
peerId,
|
|
104
|
+
kind: candidate.kind,
|
|
105
|
+
url: candidate.url,
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${botToken}`,
|
|
108
|
+
},
|
|
109
|
+
...(candidate.filename ? { filename: candidate.filename } : {}),
|
|
110
|
+
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
111
|
+
}), { logger: log });
|
|
112
|
+
return {
|
|
113
|
+
type: "media",
|
|
114
|
+
media: {
|
|
115
|
+
id: candidate.id,
|
|
116
|
+
kind: candidate.kind,
|
|
117
|
+
source: "slack",
|
|
118
|
+
status: "ready",
|
|
119
|
+
filePath: stored.filePath,
|
|
120
|
+
filename: stored.filename,
|
|
121
|
+
...(stored.mimeType ? { mimeType: stored.mimeType } : {}),
|
|
122
|
+
sizeBytes: stored.sizeBytes,
|
|
123
|
+
providerFileId: candidate.id,
|
|
124
|
+
providerUrl: candidate.url,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const classified = classifyDeliveryError(error);
|
|
130
|
+
return {
|
|
131
|
+
type: "media",
|
|
132
|
+
media: {
|
|
133
|
+
id: candidate.id,
|
|
134
|
+
kind: candidate.kind,
|
|
135
|
+
source: "slack",
|
|
136
|
+
status: "failed",
|
|
137
|
+
...(candidate.filename ? { filename: candidate.filename } : {}),
|
|
138
|
+
...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
|
|
139
|
+
...(typeof candidate.sizeBytes === "number" ? { sizeBytes: candidate.sizeBytes } : {}),
|
|
140
|
+
providerFileId: candidate.id,
|
|
141
|
+
providerUrl: candidate.url,
|
|
142
|
+
error: `${classified.code}: ${classified.message}`,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
};
|
|
45
147
|
const safeAck = async (ack) => {
|
|
46
148
|
if (typeof ack !== "function")
|
|
47
149
|
return;
|
|
@@ -58,16 +160,20 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
58
160
|
const userId = typeof event.user === "string" ? event.user : null;
|
|
59
161
|
const botId = typeof event.bot_id === "string" ? event.bot_id : null;
|
|
60
162
|
const subtype = typeof event.subtype === "string" ? event.subtype : null;
|
|
163
|
+
const files = parseSlackFiles(event.files);
|
|
164
|
+
const hasFiles = files.length > 0;
|
|
165
|
+
const threadTs = typeof event.thread_ts === "string" ? event.thread_ts : null;
|
|
166
|
+
const ts = typeof event.ts === "string" ? event.ts : null;
|
|
61
167
|
// Avoid loops / non-user messages.
|
|
62
168
|
if (botId)
|
|
63
169
|
return { ok: true };
|
|
64
|
-
if (subtype && subtype !== "")
|
|
170
|
+
if (subtype && subtype !== "" && subtype !== "file_share")
|
|
65
171
|
return { ok: true };
|
|
66
172
|
if (userId && botUserId && userId === botUserId)
|
|
67
173
|
return { ok: true };
|
|
68
|
-
if (!channelId || !textRaw.trim())
|
|
174
|
+
if (!channelId || (!textRaw.trim() && !hasFiles))
|
|
69
175
|
return { ok: true };
|
|
70
|
-
return { ok: false, channelId, textRaw, userId };
|
|
176
|
+
return { ok: false, channelId, textRaw, userId, files, threadTs, ts };
|
|
71
177
|
};
|
|
72
178
|
socket.on("message", async (args) => {
|
|
73
179
|
const ack = args?.ack;
|
|
@@ -82,14 +188,28 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
82
188
|
const isDm = filtered.channelId.startsWith("D");
|
|
83
189
|
if (!isDm)
|
|
84
190
|
return;
|
|
85
|
-
const
|
|
86
|
-
const
|
|
191
|
+
const peerId = formatSlackPeerId({ channelId: filtered.channelId, ...(filtered.threadTs ? { threadTs: filtered.threadTs } : {}) });
|
|
192
|
+
const parts = [];
|
|
193
|
+
if (filtered.textRaw.trim()) {
|
|
194
|
+
parts.push({ type: "text", text: filtered.textRaw.trim() });
|
|
195
|
+
}
|
|
196
|
+
for (const file of filtered.files) {
|
|
197
|
+
parts.push(await downloadSlackFile(peerId, file));
|
|
198
|
+
}
|
|
199
|
+
if (parts.length === 0)
|
|
200
|
+
return;
|
|
201
|
+
const text = parts
|
|
202
|
+
.filter((part) => part.type === "text")
|
|
203
|
+
.map((part) => part.text)
|
|
204
|
+
.join("\n")
|
|
205
|
+
.trim();
|
|
87
206
|
try {
|
|
88
207
|
await onMessage({
|
|
89
208
|
channel: "slack",
|
|
90
209
|
identityId: identity.id,
|
|
91
210
|
peerId,
|
|
92
|
-
text
|
|
211
|
+
text,
|
|
212
|
+
parts,
|
|
93
213
|
raw: event,
|
|
94
214
|
});
|
|
95
215
|
}
|
|
@@ -106,19 +226,30 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
106
226
|
const filtered = shouldIgnore(event);
|
|
107
227
|
if (filtered.ok)
|
|
108
228
|
return;
|
|
109
|
-
const
|
|
110
|
-
const ts = typeof event.ts === "string" ? event.ts : null;
|
|
111
|
-
const rootThread = threadTs || ts;
|
|
229
|
+
const rootThread = filtered.threadTs || filtered.ts;
|
|
112
230
|
const peerId = formatSlackPeerId({ channelId: filtered.channelId, ...(rootThread ? { threadTs: rootThread } : {}) });
|
|
113
231
|
const text = stripSlackMention(filtered.textRaw, botUserId);
|
|
114
|
-
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (text) {
|
|
234
|
+
parts.push({ type: "text", text });
|
|
235
|
+
}
|
|
236
|
+
for (const file of filtered.files) {
|
|
237
|
+
parts.push(await downloadSlackFile(peerId, file));
|
|
238
|
+
}
|
|
239
|
+
if (parts.length === 0)
|
|
115
240
|
return;
|
|
241
|
+
const promptText = parts
|
|
242
|
+
.filter((part) => part.type === "text")
|
|
243
|
+
.map((part) => part.text)
|
|
244
|
+
.join("\n")
|
|
245
|
+
.trim();
|
|
116
246
|
try {
|
|
117
247
|
await onMessage({
|
|
118
248
|
channel: "slack",
|
|
119
249
|
identityId: identity.id,
|
|
120
250
|
peerId,
|
|
121
|
-
text,
|
|
251
|
+
text: promptText,
|
|
252
|
+
parts,
|
|
122
253
|
raw: event,
|
|
123
254
|
});
|
|
124
255
|
}
|
|
@@ -126,6 +257,57 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
126
257
|
log.error({ error, peerId }, "slack inbound handler failed");
|
|
127
258
|
}
|
|
128
259
|
});
|
|
260
|
+
const sendMessageInternal = async (peerId, message) => {
|
|
261
|
+
const peer = parseSlackPeerId(peerId);
|
|
262
|
+
if (!peer.channelId) {
|
|
263
|
+
const error = new Error("Invalid Slack peerId");
|
|
264
|
+
error.status = 400;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
const partResults = [];
|
|
268
|
+
let sentParts = 0;
|
|
269
|
+
for (let index = 0; index < message.parts.length; index += 1) {
|
|
270
|
+
const part = message.parts[index];
|
|
271
|
+
try {
|
|
272
|
+
if (part.type === "text") {
|
|
273
|
+
await withDeliveryRetry("slack.postMessage", () => web.chat.postMessage({
|
|
274
|
+
channel: peer.channelId,
|
|
275
|
+
text: part.text,
|
|
276
|
+
...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
|
|
277
|
+
}), { logger: log });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const fileData = await readFile(part.filePath);
|
|
281
|
+
const filename = part.filename || basename(part.filePath);
|
|
282
|
+
await withDeliveryRetry("slack.uploadFile", () => web.files.uploadV2({
|
|
283
|
+
channel_id: peer.channelId,
|
|
284
|
+
file: fileData,
|
|
285
|
+
filename,
|
|
286
|
+
...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
|
|
287
|
+
...(part.caption?.trim() ? { initial_comment: part.caption.trim() } : {}),
|
|
288
|
+
}), { logger: log });
|
|
289
|
+
}
|
|
290
|
+
sentParts += 1;
|
|
291
|
+
partResults.push({ index, type: part.type, sent: true });
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
const classified = classifyDeliveryError(error);
|
|
295
|
+
partResults.push({
|
|
296
|
+
index,
|
|
297
|
+
type: part.type,
|
|
298
|
+
sent: false,
|
|
299
|
+
error: classified.message,
|
|
300
|
+
code: classified.code,
|
|
301
|
+
retryable: classified.retryable,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
attemptedParts: message.parts.length,
|
|
307
|
+
sentParts,
|
|
308
|
+
partResults,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
129
311
|
return {
|
|
130
312
|
name: "slack",
|
|
131
313
|
identityId: identity.id,
|
|
@@ -153,17 +335,17 @@ export function createSlackAdapter(identity, config, logger, onMessage, deps = {
|
|
|
153
335
|
}
|
|
154
336
|
log.info("slack adapter stopped");
|
|
155
337
|
},
|
|
338
|
+
async sendMessage(peerId, message) {
|
|
339
|
+
return sendMessageInternal(peerId, message);
|
|
340
|
+
},
|
|
156
341
|
async sendText(peerId, text) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
text
|
|
163
|
-
}
|
|
164
|
-
if (peer.threadTs)
|
|
165
|
-
payload.thread_ts = peer.threadTs;
|
|
166
|
-
await web.chat.postMessage(payload);
|
|
342
|
+
const result = await sendMessageInternal(peerId, {
|
|
343
|
+
parts: [{ type: "text", text }],
|
|
344
|
+
});
|
|
345
|
+
if (result.sentParts === 0) {
|
|
346
|
+
const firstError = result.partResults.find((part) => !part.sent)?.error;
|
|
347
|
+
throw new Error(firstError || "Failed to deliver Slack text message");
|
|
348
|
+
}
|
|
167
349
|
},
|
|
168
350
|
};
|
|
169
351
|
}
|