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.
@@ -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
- if (!channel || !text.trim() || (!directory && !peerId)) {
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({ ok: false, error: "channel, text, and either directory or peerId are required" }));
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 threadTs = typeof event.thread_ts === "string" ? event.thread_ts : null;
86
- const peerId = formatSlackPeerId({ channelId: filtered.channelId, ...(threadTs ? { threadTs } : {}) });
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: filtered.textRaw.trim(),
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 threadTs = typeof event.thread_ts === "string" ? event.thread_ts : null;
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
- if (!text)
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 peer = parseSlackPeerId(peerId);
158
- if (!peer.channelId)
159
- throw new Error("Invalid Slack peerId");
160
- const payload = {
161
- channel: peer.channelId,
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
  }