fix(cron): pass agent identity through delivery path (#16218) (#16242)

* fix(cron): pass agent identity through delivery path

Cron delivery messages now include agent identity (name, avatar) in
outbound messages. Identity fields are passed best-effort for Slack
(graceful fallback if chat:write.customize scope is missing).

Fixes #16218

* fix: fix Slack cron delivery identity (#16242) (thanks @robbyczgw-cla)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Robby
2026-02-14 16:08:51 +01:00
committed by GitHub
parent 497b060e49
commit 09e1cbc35d
8 changed files with 222 additions and 23 deletions

View File

@@ -33,8 +33,83 @@ type SlackSendOpts = {
mediaUrl?: string;
client?: WebClient;
threadTs?: string;
username?: string;
icon_url?: string;
icon_emoji?: string;
};
function hasCustomIdentity(opts: SlackSendOpts): boolean {
return Boolean(opts.username || opts.icon_url || opts.icon_emoji);
}
function isSlackCustomizeScopeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const maybeData = err as Error & {
data?: {
error?: string;
needed?: string;
response_metadata?: { scopes?: string[]; acceptedScopes?: string[] };
};
};
const code = maybeData.data?.error?.toLowerCase();
if (code !== "missing_scope") {
return false;
}
const needed = maybeData.data?.needed?.toLowerCase();
if (needed?.includes("chat:write.customize")) {
return true;
}
const scopes = [
...(maybeData.data?.response_metadata?.scopes ?? []),
...(maybeData.data?.response_metadata?.acceptedScopes ?? []),
].map((scope) => scope.toLowerCase());
return scopes.includes("chat:write.customize");
}
async function postSlackMessageBestEffort(params: {
client: WebClient;
channelId: string;
text: string;
threadTs?: string;
opts: SlackSendOpts;
}) {
const basePayload = {
channel: params.channelId,
text: params.text,
thread_ts: params.threadTs,
};
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.opts.icon_url) {
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
icon_url: params.opts.icon_url,
});
}
if (params.opts.icon_emoji) {
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
icon_emoji: params.opts.icon_emoji,
});
}
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
});
} catch (err) {
if (!hasCustomIdentity(params.opts) || !isSlackCustomizeScopeError(err)) {
throw err;
}
logVerbose("slack send: missing chat:write.customize, retrying without custom identity");
return params.client.chat.postMessage(basePayload);
}
}
export type SlackSendResult = {
messageId: string;
channelId: string;
@@ -182,19 +257,23 @@ export async function sendMessageSlack(
maxBytes: mediaMaxBytes,
});
for (const chunk of rest) {
const response = await client.chat.postMessage({
channel: channelId,
const response = await postSlackMessageBestEffort({
client,
channelId,
text: chunk,
thread_ts: opts.threadTs,
threadTs: opts.threadTs,
opts,
});
lastMessageId = response.ts ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const response = await client.chat.postMessage({
channel: channelId,
const response = await postSlackMessageBestEffort({
client,
channelId,
text: chunk,
thread_ts: opts.threadTs,
threadTs: opts.threadTs,
opts,
});
lastMessageId = response.ts ?? lastMessageId;
}