520 lines
20 KiB
Diff
520 lines
20 KiB
Diff
diff --git a/dist/index.js b/dist/index.js
|
|
index b80888b6d87e8ad429d18cc955c4c5048a55722a..081e3b1664d3e65d1decf82bbf314909a36c143d 100644
|
|
--- a/dist/index.js
|
|
+++ b/dist/index.js
|
|
@@ -2154,6 +2154,7 @@
|
|
const command = params.get("command") || "";
|
|
const text = params.get("text") || "";
|
|
const userId = params.get("user_id") || "";
|
|
+ const userName = params.get("user_name") || userId;
|
|
const channelId = params.get("channel_id") || "";
|
|
const triggerId = params.get("trigger_id") || void 0;
|
|
this.logger.debug("Processing Slack slash command", {
|
|
@@ -2163,14 +2164,13 @@
|
|
channelId,
|
|
triggerId
|
|
});
|
|
- const userInfo = await this.lookupUser(userId);
|
|
const event = {
|
|
command,
|
|
text,
|
|
user: {
|
|
userId,
|
|
- userName: userInfo?.displayName ?? userId,
|
|
- fullName: userInfo?.realName ?? userId,
|
|
+ userName,
|
|
+ fullName: userName,
|
|
isBot: false,
|
|
isMe: false
|
|
},
|
|
@@ -2179,8 +2179,11 @@
|
|
triggerId,
|
|
channelId: channelId ? `slack:${channelId}` : ""
|
|
};
|
|
- this.chat.processSlashCommand(event, options);
|
|
- return new Response("", { status: 200 });
|
|
+ await this.chat.processSlashCommand(event, options);
|
|
+ return Response.json({
|
|
+ response_type: "ephemeral",
|
|
+ text: "Paperclip received this command."
|
|
+ });
|
|
}
|
|
/**
|
|
* Handle block_actions payload (button clicks in Block Kit).
|
|
@@ -2706,7 +2709,8 @@
|
|
"Content-Type": "application/json",
|
|
"x-slack-socket-token": this.socketForwardingSecret
|
|
},
|
|
- body: JSON.stringify(event)
|
|
+ body: JSON.stringify(event),
|
|
+ signal: AbortSignal.timeout(45e3)
|
|
});
|
|
if (response.ok) {
|
|
this.logger.debug("Socket event forwarded successfully", {
|
|
@@ -2894,8 +2898,19 @@
|
|
}
|
|
}
|
|
const previousMessage = event.previous_message;
|
|
+ // File edits can retain both text and edited.ts. Compare only attachment
|
|
+ // identity and consumed metadata, not expiring URLs or unfurl metadata.
|
|
+ const fileRevision = (files) => JSON.stringify(
|
|
+ (Array.isArray(files) ? files : []).map((value) => {
|
|
+ const file = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
+ return [
|
|
+ ...[file.id, file.name, file.mimetype].map((field) => typeof field === "string" ? field : null),
|
|
+ ...[file.size, file.original_w, file.original_h].map((field) => typeof field === "number" && Number.isFinite(field) ? field : null)
|
|
+ ];
|
|
+ })
|
|
+ );
|
|
const isHiddenMessageEdit = Boolean(
|
|
- previousMessage && (inner.edited?.ts !== previousMessage.edited?.ts || inner.text !== previousMessage.text)
|
|
+ previousMessage && (inner.edited?.ts !== previousMessage.edited?.ts || inner.text !== previousMessage.text || fileRevision(inner.files) !== fileRevision(previousMessage.files))
|
|
);
|
|
if (event.hidden === true && !isHiddenMessageEdit) {
|
|
return;
|
|
@@ -3900,17 +3915,22 @@
|
|
const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId);
|
|
const threadTs = rawThreadTs || void 0;
|
|
try {
|
|
- let uploadedFileIds;
|
|
+ let uploadedFiles;
|
|
const files = extractFiles(message);
|
|
if (files.length > 0) {
|
|
- uploadedFileIds = await this.uploadFiles(files, channel, threadTs);
|
|
+ uploadedFiles = await this.uploadFiles(
|
|
+ files,
|
|
+ channel,
|
|
+ threadTs,
|
|
+ this.paperclipFileUploadReceiptContext?.getStore?.()
|
|
+ );
|
|
const hasText = typeof message === "string" || typeof message === "object" && message !== null && ("raw" in message && message.raw || "markdown" in message && message.markdown || "ast" in message && message.ast);
|
|
const card2 = extractCard(message);
|
|
if (!(hasText || card2)) {
|
|
return {
|
|
- id: `file-${Date.now()}`,
|
|
+ id: uploadedFiles.messageId,
|
|
threadId,
|
|
- raw: { files, uploadedFileIds }
|
|
+ raw: { files, uploadedFileIds: uploadedFiles.fileIds }
|
|
};
|
|
}
|
|
}
|
|
@@ -3946,7 +3966,7 @@
|
|
return {
|
|
id: result2.ts,
|
|
threadId,
|
|
- raw: uploadedFileIds === void 0 ? result2 : { ...result2, uploadedFileIds }
|
|
+ raw: uploadedFiles === void 0 ? result2 : { ...result2, uploadedFileIds: uploadedFiles.fileIds }
|
|
};
|
|
}
|
|
const payload = this.formatConverter.toSlackPayload(message);
|
|
@@ -3971,7 +3991,7 @@
|
|
return {
|
|
id: result.ts,
|
|
threadId,
|
|
- raw: uploadedFileIds === void 0 ? result : { ...result, uploadedFileIds }
|
|
+ raw: uploadedFiles === void 0 ? result : { ...result, uploadedFileIds: uploadedFiles.fileIds }
|
|
};
|
|
} catch (error) {
|
|
this.handleSlackError(error);
|
|
@@ -4183,7 +4203,7 @@
|
|
* Upload files to Slack and share them to a channel.
|
|
* Returns the file IDs of uploaded files.
|
|
*/
|
|
- async uploadFiles(files, channel, threadTs) {
|
|
+ async uploadFiles(files, channel, threadTs, onFileUploadAccepted) {
|
|
const bufferResults = await Promise.all(
|
|
files.map(async (file) => {
|
|
try {
|
|
@@ -4205,7 +4225,9 @@
|
|
(result2) => result2 !== null
|
|
);
|
|
if (fileUploads.length === 0) {
|
|
- return [];
|
|
+ const error = new Error("Slack file upload could not prepare any files");
|
|
+ error.name = "ValidationError";
|
|
+ throw error;
|
|
}
|
|
this.logger.debug("Slack API: files.uploadV2 (batch)", {
|
|
fileCount: fileUploads.length,
|
|
@@ -4219,15 +4241,158 @@
|
|
const result = await this._client.files.uploadV2(uploadArgs);
|
|
this.logger.debug("Slack API: files.uploadV2 response", { ok: result.ok });
|
|
const fileIds = [];
|
|
+ const completedFiles = [];
|
|
if (result.files?.[0]?.files) {
|
|
- for (const uploadedFile of result.files[0].files) {
|
|
- if (uploadedFile.id) {
|
|
- fileIds.push(uploadedFile.id);
|
|
+ for (const completion of result.files) {
|
|
+ if (Array.isArray(completion.files)) {
|
|
+ for (const uploadedFile of completion.files) {
|
|
+ completedFiles.push(uploadedFile);
|
|
+ if (uploadedFile.id) {
|
|
+ fileIds.push(uploadedFile.id);
|
|
+ }
|
|
+ }
|
|
}
|
|
}
|
|
}
|
|
- return fileIds;
|
|
+ if (fileIds.length !== fileUploads.length || new Set(fileIds).size !== fileIds.length || fileIds.some((fileId) => typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId))) {
|
|
+ throw new Error(
|
|
+ "Slack file upload completed, but its message identity could not be confirmed"
|
|
+ );
|
|
+ }
|
|
+ if (typeof onFileUploadAccepted === "function") {
|
|
+ await onFileUploadAccepted({
|
|
+ version: 1,
|
|
+ fileIds: [...fileIds],
|
|
+ channelId: channel,
|
|
+ threadTs: threadTs ?? null
|
|
+ });
|
|
+ }
|
|
+ let messageId = this.slackFileShareMessageId(
|
|
+ completedFiles,
|
|
+ channel,
|
|
+ threadTs
|
|
+ );
|
|
+ if (!messageId) {
|
|
+ try {
|
|
+ messageId = await this.paperclipResolveFileUploadMessageId(fileIds, channel, threadTs);
|
|
+ } catch {
|
|
+ throw new Error(
|
|
+ "Slack file upload completed, but its message identity could not be confirmed"
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+ if (!messageId) {
|
|
+ throw new Error(
|
|
+ "Slack file upload completed, but its message identity could not be confirmed"
|
|
+ );
|
|
+ }
|
|
+ return { fileIds, messageId };
|
|
}
|
|
+ async paperclipResolveFileUploadReceipt(fileIds, threadId) {
|
|
+ if (!Array.isArray(fileIds) || fileIds.length === 0 || fileIds.length > 20 || new Set(fileIds).size !== fileIds.length || fileIds.some((fileId) => typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId))) {
|
|
+ const error = new Error("Slack file upload receipt is invalid");
|
|
+ error.name = "ValidationError";
|
|
+ throw error;
|
|
+ }
|
|
+ const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId);
|
|
+ try {
|
|
+ return await this.paperclipResolveFileUploadMessageId(fileIds, channel, rawThreadTs || void 0);
|
|
+ } catch (error) {
|
|
+ this.handleSlackError(error);
|
|
+ }
|
|
+ }
|
|
+ async paperclipResolveFileUploadMessageId(fileIds, channel, threadTs) {
|
|
+ const deadline = Date.now() + 5e3;
|
|
+ const retryDelays = [100, 250, 500, 1e3];
|
|
+ let lookupAttempt = 0;
|
|
+ while (Date.now() < deadline) {
|
|
+ let timeout;
|
|
+ let messageId = null;
|
|
+ try {
|
|
+ const lookup = Promise.all(
|
|
+ fileIds.map(async (file) => {
|
|
+ const args = await this.withToken({ file });
|
|
+ if (Date.now() >= deadline) {
|
|
+ throw new Error("Slack file identity lookup timed out");
|
|
+ }
|
|
+ return this._client.files.info(args);
|
|
+ })
|
|
+ );
|
|
+ const infos = await Promise.race([
|
|
+ lookup,
|
|
+ new Promise((_, reject) => {
|
|
+ timeout = setTimeout(
|
|
+ () => reject(new Error("Slack file identity lookup timed out")),
|
|
+ Math.max(1, deadline - Date.now())
|
|
+ );
|
|
+ timeout.unref?.();
|
|
+ })
|
|
+ ]);
|
|
+ const infoFiles = infos.map((info) => info.file);
|
|
+ if (infoFiles.some((file, index) => !file || file.id !== fileIds[index])) {
|
|
+ const error = new Error("Slack file identity lookup did not match the uploaded files");
|
|
+ error.name = "ValidationError";
|
|
+ throw error;
|
|
+ }
|
|
+ messageId = this.slackFileShareMessageId(infoFiles, channel, threadTs);
|
|
+ } finally {
|
|
+ clearTimeout(timeout);
|
|
+ }
|
|
+ if (messageId) return messageId;
|
|
+ const remaining = deadline - Date.now();
|
|
+ if (remaining <= 0) break;
|
|
+ const delay = Math.min(
|
|
+ retryDelays[Math.min(lookupAttempt, retryDelays.length - 1)],
|
|
+ remaining
|
|
+ );
|
|
+ lookupAttempt += 1;
|
|
+ await new Promise((resolve) => {
|
|
+ const retryTimer = setTimeout(resolve, delay);
|
|
+ retryTimer.unref?.();
|
|
+ });
|
|
+ }
|
|
+ return null;
|
|
+ }
|
|
+ slackFileShareMessageId(files, channel, threadTs) {
|
|
+ if (!Array.isArray(files) || files.length === 0) {
|
|
+ return null;
|
|
+ }
|
|
+ const messageIdsByFile = files.map((file) => {
|
|
+ const messageIds = /* @__PURE__ */ new Set();
|
|
+ const shares = file?.shares;
|
|
+ if (!shares || typeof shares !== "object") {
|
|
+ return messageIds;
|
|
+ }
|
|
+ for (const group of Object.values(shares)) {
|
|
+ if (!group || typeof group !== "object") {
|
|
+ continue;
|
|
+ }
|
|
+ const channelShares = group[channel];
|
|
+ if (!Array.isArray(channelShares)) {
|
|
+ continue;
|
|
+ }
|
|
+ for (const share of channelShares) {
|
|
+ if (!share || typeof share !== "object" || typeof share.ts !== "string" || !/^\d+\.\d+$/.test(share.ts)) {
|
|
+ continue;
|
|
+ }
|
|
+ const shareThreadTs = typeof share.thread_ts === "string" && share.thread_ts ? share.thread_ts : void 0;
|
|
+ if (threadTs ? shareThreadTs !== threadTs : shareThreadTs !== void 0) {
|
|
+ continue;
|
|
+ }
|
|
+ messageIds.add(share.ts);
|
|
+ }
|
|
+ }
|
|
+ return messageIds;
|
|
+ });
|
|
+ if (messageIdsByFile.some((messageIds) => messageIds.size === 0)) {
|
|
+ return null;
|
|
+ }
|
|
+ const [first, ...rest] = messageIdsByFile;
|
|
+ const sharedMessageIds = [...first].filter(
|
|
+ (messageId) => rest.every((messageIds) => messageIds.has(messageId))
|
|
+ );
|
|
+ return sharedMessageIds.length === 1 ? sharedMessageIds[0] : null;
|
|
+ }
|
|
async editMessage(threadId, messageId, _message) {
|
|
const message = await this.resolveMessageMentions(_message, threadId);
|
|
const ephemeral = this.decodeEphemeralMessageId(messageId);
|
|
@@ -4622,6 +4787,8 @@
|
|
this.logger.debug("Slack: starting stream", { channel, threadTs });
|
|
const token = await this.getToken();
|
|
const streamer = this._client.chatStream({
|
|
+ // Keep the Web API's batching, but account for its pending tail below.
|
|
+ buffer_size: 256,
|
|
channel,
|
|
thread_ts: threadTs,
|
|
...options?.recipientUserId && {
|
|
@@ -4667,6 +4834,72 @@
|
|
}
|
|
};
|
|
const fallback = { message: null, mode: "native", nativeRendered: false };
|
|
+ let nativePendingLength = 0;
|
|
+ let nativeMessageTs = null;
|
|
+ const nativeDeliveryUnknown = () => new NetworkError(
|
|
+ "slack",
|
|
+ "Slack native stream delivery could not be confirmed"
|
|
+ );
|
|
+ const confirmNativeResponse = (response) => {
|
|
+ if (response?.ok !== true || typeof response.ts !== "string" ||
|
|
+ response.ts.length > 128 || !/^\d+\.\d+$/.test(response.ts) ||
|
|
+ response.channel !== void 0 && response.channel !== channel ||
|
|
+ nativeMessageTs !== null && response.ts !== nativeMessageTs ||
|
|
+ response.message?.ts !== void 0 && response.message.ts !== response.ts) {
|
|
+ throw nativeDeliveryUnknown();
|
|
+ }
|
|
+ nativeMessageTs = response.ts;
|
|
+ fallback.nativeRendered = true;
|
|
+ nativePendingLength = 0;
|
|
+ };
|
|
+ const appendNative = async (args) => {
|
|
+ const response = await streamer.append({ ...args, token });
|
|
+ if (response === null) {
|
|
+ nativePendingLength += args.markdown_text?.length ?? 0;
|
|
+ } else {
|
|
+ // Record each awaited receipt before another fragment can fail.
|
|
+ confirmNativeResponse(response);
|
|
+ }
|
|
+ };
|
|
+ const appendRendered = async (delta) => {
|
|
+ let part = "";
|
|
+ // Preserve complete Slack links/mentions, escaped entities and Unicode
|
|
+ // scalars. Never silently truncate or split an oversized opaque token.
|
|
+ for (const [atom] of delta.matchAll(/<[^<>\r\n]*>|&(?:amp|lt|gt);|[\s\S]/gu)) {
|
|
+ if (atom.length > 12e3) {
|
|
+ throw new ValidationError("slack", "Rendered Slack token exceeds the native stream limit");
|
|
+ }
|
|
+ if (nativePendingLength + part.length + atom.length > 12e3) {
|
|
+ if (part.length > 0) {
|
|
+ await appendNative({ markdown_text: part });
|
|
+ part = "";
|
|
+ }
|
|
+ if (nativePendingLength + atom.length > 12e3) {
|
|
+ // A whole token fits, but not alongside the Web API's <256 tail.
|
|
+ await appendNative({ chunks: [] });
|
|
+ }
|
|
+ }
|
|
+ part += atom;
|
|
+ }
|
|
+ if (part.length > 0) {
|
|
+ await appendNative({ markdown_text: part });
|
|
+ }
|
|
+ };
|
|
+ const definiteNativeUnsupported = (error) => {
|
|
+ const code = slackPlatformErrorCode(error);
|
|
+ return error?.data?.ok === false && code !== void 0 && NATIVE_STREAMING_UNSUPPORTED_ERRORS.has(code);
|
|
+ };
|
|
+ const rethrowNativeFailure = (error) => {
|
|
+ // A later explicit rejection does not undo an already accepted prefix.
|
|
+ // Do not expose its retryable/definite code as the whole send's outcome.
|
|
+ const code = slackPlatformErrorCode(error);
|
|
+ if (fallback.nativeRendered || fallback.message !== null ||
|
|
+ error?.code === "slack_webapi_platform_error" && error.data?.ok !== false ||
|
|
+ code === "internal_error" || code === "fatal_error") {
|
|
+ throw nativeDeliveryUnknown();
|
|
+ }
|
|
+ throw error;
|
|
+ };
|
|
const updateIntervalMs = options?.updateIntervalMs ?? 1e3;
|
|
let fallbackSent = "";
|
|
let lastFallbackEditAt = 0;
|
|
@@ -4709,17 +4942,11 @@
|
|
return;
|
|
}
|
|
try {
|
|
- const response = await streamer.append({
|
|
- markdown_text: delta,
|
|
- token
|
|
- });
|
|
- if (response) {
|
|
- fallback.nativeRendered = true;
|
|
- }
|
|
+ await appendRendered(delta);
|
|
lastAppended = resolvedCommitted;
|
|
} catch (error) {
|
|
- if (fallback.nativeRendered) {
|
|
- throw error;
|
|
+ if (fallback.nativeRendered || !definiteNativeUnsupported(error)) {
|
|
+ rethrowNativeFailure(error);
|
|
}
|
|
switchToFallback(error);
|
|
await flushFallback(force);
|
|
@@ -4736,12 +4963,15 @@
|
|
return;
|
|
}
|
|
try {
|
|
- await streamer.append({
|
|
- chunks: [chunk],
|
|
- token
|
|
- });
|
|
- fallback.nativeRendered = true;
|
|
+ await appendNative({ chunks: [chunk] });
|
|
} catch (error) {
|
|
+ const code = slackPlatformErrorCode(error);
|
|
+ if (fallback.nativeRendered || error?.data?.ok !== false || ![
|
|
+ "invalid_chunks", "invalid_blocks", "missing_scope",
|
|
+ ...NATIVE_STREAMING_UNSUPPORTED_ERRORS
|
|
+ ].includes(code)) {
|
|
+ rethrowNativeFailure(error);
|
|
+ }
|
|
structuredChunksSupported = false;
|
|
this.logger.warn(
|
|
"Structured streaming chunk failed, falling back to text-only streaming. Ensure your Slack app manifest includes the agent/assistant feature and the assistant:write scope",
|
|
@@ -4749,22 +4979,28 @@
|
|
);
|
|
}
|
|
};
|
|
- for await (const chunk of textStream) {
|
|
- if (options?.signal?.aborted) {
|
|
- break;
|
|
+ try {
|
|
+ for await (const chunk of textStream) {
|
|
+ if (options?.signal?.aborted) {
|
|
+ break;
|
|
+ }
|
|
+ if (typeof chunk === "string") {
|
|
+ renderer.push(chunk);
|
|
+ await flushCommitted();
|
|
+ } else if (chunk.type === "markdown_text") {
|
|
+ renderer.push(chunk.text);
|
|
+ await flushCommitted();
|
|
+ } else {
|
|
+ await sendStructuredChunk(chunk);
|
|
+ }
|
|
}
|
|
- if (typeof chunk === "string") {
|
|
- renderer.push(chunk);
|
|
- await flushCommitted();
|
|
- } else if (chunk.type === "markdown_text") {
|
|
- renderer.push(chunk.text);
|
|
- await flushCommitted();
|
|
- } else {
|
|
- await sendStructuredChunk(chunk);
|
|
- }
|
|
+ renderer.finish();
|
|
+ await flushCommitted(true);
|
|
+ } catch (error) {
|
|
+ // Iterator/renderer/mention lookup failures also follow an irreversible
|
|
+ // accepted native prefix; never classify them as safe whole-send retry.
|
|
+ rethrowNativeFailure(error);
|
|
}
|
|
- renderer.finish();
|
|
- await flushCommitted(true);
|
|
if (fallback.mode === "fallback") {
|
|
if (options?.stopBlocks || this.feedbackButtons) {
|
|
this.logger.warn(
|
|
@@ -4775,7 +5011,7 @@
|
|
this.logger.debug("Slack: fallback stream complete", {
|
|
messageId: fallback.message?.id
|
|
});
|
|
- await this.endTyping(threadId, options?.sessionStatus ?? "active");
|
|
+ await this.endTyping(threadId, options?.sessionStatus ?? "active").catch(rethrowNativeFailure);
|
|
return fallback.message;
|
|
}
|
|
const stopBlocks = [
|
|
@@ -4784,21 +5020,27 @@
|
|
];
|
|
let result;
|
|
try {
|
|
+ if (!fallback.nativeRendered) {
|
|
+ // Observe the initial receipt even when all text is still buffered;
|
|
+ // stop() otherwise hides its internal startStream response.
|
|
+ await appendNative({ chunks: [] });
|
|
+ }
|
|
result = await streamer.stop({
|
|
token,
|
|
...this.agentView ? { session_status: options?.sessionStatus ?? "active" } : {},
|
|
...stopBlocks.length > 0 ? { blocks: stopBlocks } : {}
|
|
});
|
|
+ confirmNativeResponse(result);
|
|
} catch (error) {
|
|
- if (fallback.nativeRendered) {
|
|
- throw error;
|
|
+ if (fallback.nativeRendered || !definiteNativeUnsupported(error)) {
|
|
+ rethrowNativeFailure(error);
|
|
}
|
|
switchToFallback(error);
|
|
await flushFallback(true);
|
|
this.logger.debug("Slack: fallback stream complete", {
|
|
messageId: fallback.message?.id
|
|
});
|
|
- await this.endTyping(threadId, options?.sessionStatus ?? "active");
|
|
+ await this.endTyping(threadId, options?.sessionStatus ?? "active").catch(rethrowNativeFailure);
|
|
return fallback.message;
|
|
}
|
|
const messageTs = result.message?.ts ?? result.ts;
|
|
@@ -5636,7 +5878,8 @@
|
|
const response = await fetch(responseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
- body: JSON.stringify(payload)
|
|
+ body: JSON.stringify(payload),
|
|
+ signal: AbortSignal.timeout(45e3)
|
|
});
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|