mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(slack): retry transient dns send failures
This commit is contained in:
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh.
|
||||
- Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=<user id>)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina.
|
||||
- Slack/routing: match route bindings written with Slack target syntax such as `channel:C...`, `user:U...`, or `<@U...>`, so bound Slack peers route to the configured agent instead of `main`. Fixes #41608. Thanks @Winnsolutionsadmin.
|
||||
- Slack/delivery: retry Slack Web API writes only when the SDK wraps a DNS request failure such as `EAI_AGAIN`, so transient resolver hiccups can recover without retrying platform errors that may duplicate messages. Fixes #68789. Thanks @sonnyb9.
|
||||
- Slack/mentions: resolve `<!subteam^...>` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack.
|
||||
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
|
||||
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.
|
||||
|
||||
@@ -6,6 +6,17 @@ const { sendMessageSlack } = await import("./send.js");
|
||||
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
|
||||
const SLACK_TEXT_LIMIT = 8000;
|
||||
|
||||
function slackDnsRequestError(): Error {
|
||||
return Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN slack.com"), {
|
||||
code: "slack_webapi_request_error",
|
||||
original: Object.assign(new Error("getaddrinfo EAI_AGAIN slack.com"), {
|
||||
code: "EAI_AGAIN",
|
||||
syscall: "getaddrinfo",
|
||||
hostname: "slack.com",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("sendMessageSlack NO_REPLY guard", () => {
|
||||
it("suppresses NO_REPLY text before any Slack API call", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
@@ -136,6 +147,63 @@ describe("sendMessageSlack blocks", () => {
|
||||
expect(result).toEqual({ messageId: "171234.567", channelId: "U123" });
|
||||
});
|
||||
|
||||
it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
client.chat.postMessage
|
||||
.mockRejectedValueOnce(slackDnsRequestError())
|
||||
.mockResolvedValueOnce({ ts: "171234.999" });
|
||||
|
||||
const result = await sendMessageSlack("channel:C123", "hello", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ messageId: "171234.999", channelId: "C123" });
|
||||
});
|
||||
|
||||
it("retries Slack conversations.open DNS request errors for threaded DMs", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
client.conversations.open
|
||||
.mockRejectedValueOnce(slackDnsRequestError())
|
||||
.mockResolvedValueOnce({ channel: { id: "D123" } });
|
||||
|
||||
const result = await sendMessageSlack("user:U123", "hello", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
threadTs: "171234.100",
|
||||
});
|
||||
|
||||
expect(client.conversations.open).toHaveBeenCalledTimes(2);
|
||||
expect(client.chat.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "D123", thread_ts: "171234.100" }),
|
||||
);
|
||||
expect(result).toEqual({ messageId: "171234.567", channelId: "D123" });
|
||||
});
|
||||
|
||||
it("does not retry Slack platform errors", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
const platformError = Object.assign(
|
||||
new Error("An API error occurred: message_limit_exceeded"),
|
||||
{
|
||||
data: { ok: false, error: "message_limit_exceeded" },
|
||||
},
|
||||
);
|
||||
client.chat.postMessage.mockRejectedValue(platformError);
|
||||
|
||||
await expect(
|
||||
sendMessageSlack("channel:C123", "hello", {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
}),
|
||||
).rejects.toThrow("message_limit_exceeded");
|
||||
|
||||
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("derives fallback text from image blocks", async () => {
|
||||
const client = createSlackSendTestClient();
|
||||
await sendMessageSlack("channel:C123", "", {
|
||||
|
||||
@@ -189,6 +189,7 @@ describe("sendMessageSlack customize-scope fallback", () => {
|
||||
token: "xoxb-test",
|
||||
cfg: SLACK_TEST_CFG,
|
||||
client,
|
||||
threadTs: "171234.100",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"An API error occurred: missing_scope (needed: im:write; granted: chat:write)",
|
||||
|
||||
@@ -32,6 +32,9 @@ const SLACK_UPLOAD_SSRF_POLICY = {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
const SLACK_DM_CHANNEL_CACHE_MAX = 1024;
|
||||
const SLACK_DNS_RETRY_CODES = new Set(["EAI_AGAIN", "ENOTFOUND", "UND_ERR_DNS_RESOLVE_FAILED"]);
|
||||
const SLACK_DNS_RETRY_ATTEMPTS = 2;
|
||||
const SLACK_DNS_RETRY_BASE_DELAY_MS = 250;
|
||||
const slackDmChannelCache = new Map<string, string>();
|
||||
const slackSendQueues = new Map<string, Promise<void>>();
|
||||
|
||||
@@ -147,6 +150,66 @@ function enrichSlackWebApiError(err: unknown): unknown {
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
function readSlackRequestErrorCode(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const code = (value as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code.toUpperCase() : undefined;
|
||||
}
|
||||
|
||||
function readSlackRequestErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function hasSlackDnsRequestSignal(err: unknown): boolean {
|
||||
let current: unknown = err;
|
||||
const seen = new Set<unknown>();
|
||||
for (let depth = 0; current && typeof current === "object" && depth < 6; depth += 1) {
|
||||
if (seen.has(current)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(current);
|
||||
const code = readSlackRequestErrorCode(current);
|
||||
if (code && SLACK_DNS_RETRY_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
const message = readSlackRequestErrorMessage(current);
|
||||
if (/\b(EAI_AGAIN|ENOTFOUND|UND_ERR_DNS_RESOLVE_FAILED)\b/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
current =
|
||||
(current as { original?: unknown; cause?: unknown }).original ??
|
||||
(current as { cause?: unknown }).cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function delaySlackDnsRetry(attempt: number): Promise<void> {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(resolve, SLACK_DNS_RETRY_BASE_DELAY_MS * Math.max(1, attempt)),
|
||||
);
|
||||
}
|
||||
|
||||
async function withSlackDnsRequestRetry<T>(operation: string, fn: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (attempt >= SLACK_DNS_RETRY_ATTEMPTS || !hasSlackDnsRequestSignal(err)) {
|
||||
throw err;
|
||||
}
|
||||
logVerbose(
|
||||
`slack send: retrying ${operation} after transient DNS request error (${attempt + 1}/${SLACK_DNS_RETRY_ATTEMPTS})`,
|
||||
);
|
||||
await delaySlackDnsRetry(attempt + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSlackCustomizeScopeError(err: unknown): boolean {
|
||||
const data = getSlackWebApiErrorData(err);
|
||||
const code = normalizeLowercaseStringOrEmpty(normalizeSlackApiString(data?.error));
|
||||
@@ -182,30 +245,37 @@ async function postSlackMessageBestEffort(params: {
|
||||
try {
|
||||
// Slack Web API types model icon_url and icon_emoji as mutually exclusive.
|
||||
// Build payloads in explicit branches so TS and runtime stay aligned.
|
||||
if (params.identity?.iconUrl) {
|
||||
return await postChatMessage({
|
||||
...basePayload,
|
||||
...(params.identity.username ? { username: params.identity.username } : {}),
|
||||
icon_url: params.identity.iconUrl,
|
||||
});
|
||||
const identity = params.identity;
|
||||
if (identity?.iconUrl) {
|
||||
return await withSlackDnsRequestRetry("chat.postMessage", () =>
|
||||
postChatMessage({
|
||||
...basePayload,
|
||||
...(identity.username ? { username: identity.username } : {}),
|
||||
icon_url: identity.iconUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (params.identity?.iconEmoji) {
|
||||
return await postChatMessage({
|
||||
...basePayload,
|
||||
...(params.identity.username ? { username: params.identity.username } : {}),
|
||||
icon_emoji: params.identity.iconEmoji,
|
||||
});
|
||||
if (identity?.iconEmoji) {
|
||||
return await withSlackDnsRequestRetry("chat.postMessage", () =>
|
||||
postChatMessage({
|
||||
...basePayload,
|
||||
...(identity.username ? { username: identity.username } : {}),
|
||||
icon_emoji: identity.iconEmoji,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return await postChatMessage({
|
||||
...basePayload,
|
||||
...(params.identity?.username ? { username: params.identity.username } : {}),
|
||||
});
|
||||
return await withSlackDnsRequestRetry("chat.postMessage", () =>
|
||||
postChatMessage({
|
||||
...basePayload,
|
||||
...(identity?.username ? { username: identity.username } : {}),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!hasCustomIdentity(params.identity) || !isSlackCustomizeScopeError(err)) {
|
||||
throw err;
|
||||
}
|
||||
logVerbose("slack send: missing chat:write.customize, retrying without custom identity");
|
||||
return postChatMessage(basePayload);
|
||||
return withSlackDnsRequestRetry("chat.postMessage", () => postChatMessage(basePayload));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +408,9 @@ async function resolveChannelId(
|
||||
if (cachedChannelId) {
|
||||
return { channelId: cachedChannelId, isDm: true, cacheHit: true };
|
||||
}
|
||||
const response = await client.conversations.open({ users: recipient.id });
|
||||
const response = await withSlackDnsRequestRetry("conversations.open", () =>
|
||||
client.conversations.open({ users: recipient.id }),
|
||||
);
|
||||
const channelId = response.channel?.id;
|
||||
if (!channelId) {
|
||||
throw new Error("Failed to open Slack DM channel");
|
||||
@@ -382,13 +454,16 @@ async function uploadSlackFile(params: {
|
||||
// Use the 3-step upload flow (getUploadURLExternal -> POST -> completeUploadExternal)
|
||||
// instead of files.uploadV2 which relies on the deprecated files.upload endpoint
|
||||
// and can fail with missing_scope even when files:write is granted.
|
||||
const uploadUrlResp = await params.client.files.getUploadURLExternal({
|
||||
filename: uploadFileName,
|
||||
length: buffer.length,
|
||||
});
|
||||
const uploadUrlResp = await withSlackDnsRequestRetry("files.getUploadURLExternal", () =>
|
||||
params.client.files.getUploadURLExternal({
|
||||
filename: uploadFileName,
|
||||
length: buffer.length,
|
||||
}),
|
||||
);
|
||||
if (!uploadUrlResp.ok || !uploadUrlResp.upload_url || !uploadUrlResp.file_id) {
|
||||
throw new Error(`Failed to get upload URL: ${uploadUrlResp.error ?? "unknown error"}`);
|
||||
}
|
||||
const uploadFileId = uploadUrlResp.file_id;
|
||||
|
||||
// Upload the file content to the presigned URL
|
||||
const uploadBody = new Uint8Array(buffer) as BodyInit;
|
||||
@@ -413,17 +488,19 @@ async function uploadSlackFile(params: {
|
||||
}
|
||||
|
||||
// Complete the upload and share to channel/thread
|
||||
const completeResp = await params.client.files.completeUploadExternal({
|
||||
files: [{ id: uploadUrlResp.file_id, title: uploadTitle }],
|
||||
channel_id: params.channelId,
|
||||
...(params.caption ? { initial_comment: params.caption } : {}),
|
||||
...(params.threadTs ? { thread_ts: params.threadTs } : {}),
|
||||
});
|
||||
const completeResp = await withSlackDnsRequestRetry("files.completeUploadExternal", () =>
|
||||
params.client.files.completeUploadExternal({
|
||||
files: [{ id: uploadFileId, title: uploadTitle }],
|
||||
channel_id: params.channelId,
|
||||
...(params.caption ? { initial_comment: params.caption } : {}),
|
||||
...(params.threadTs ? { thread_ts: params.threadTs } : {}),
|
||||
}),
|
||||
);
|
||||
if (!completeResp.ok) {
|
||||
throw new Error(`Failed to complete upload: ${completeResp.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
return uploadUrlResp.file_id;
|
||||
return uploadFileId;
|
||||
}
|
||||
|
||||
export async function sendMessageSlack(
|
||||
|
||||
Reference in New Issue
Block a user