Skip to content

Commit 564a5c8

Browse files
committed
fix(slack): keep buttons + link to renewal docs on action-required errors
For expired refresh token and 403 not-owner failures, show ":warning: Action required, see thread…" with Approve/Reject still visible so the admin can retry, and post a thread message with a clickable link to the README renewal section.
1 parent 1dfd9f9 commit 564a5c8

2 files changed

Lines changed: 87 additions & 66 deletions

File tree

src/graph.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
* Pure fetch, no SDK.
44
*/
55

6+
const RENEW_TOKEN_URL = 'https://github.com/adobe-rnd/teams-admin-agent#renewing-the-delegated-refresh-token';
7+
8+
/** Error subclass used to signal "the admin must fix something before the retry will succeed". */
9+
export class ActionRequiredError extends Error {
10+
constructor(message) {
11+
super(message);
12+
this.name = 'ActionRequiredError';
13+
this.actionRequired = true;
14+
}
15+
}
16+
617
let _graphToken = { value: null, expiresAt: 0 };
718

819
async function getGraphToken(env) {
@@ -45,8 +56,8 @@ async function graphApiWithToken(accessToken, path, method = 'GET', body = null)
4556
const resText = await res.text();
4657
if (!res.ok) {
4758
if (res.status === 403 && path.includes('/members')) {
48-
throw new Error(
49-
'403 Forbidden — the account from /auth/microsoft must be an owner of this team. If the invitee is a guest, your tenant may block adding guests via Graph; add them manually in Teams if needed.',
59+
throw new ActionRequiredError(
60+
`The account associated with the delegated refresh token is not an owner of this team. Sign in as a team owner and renew the token by following the steps in <${RENEW_TOKEN_URL}|the README>, then click Approve again. If the invitee is a guest, your tenant may also block adding guests via Graphadd them manually in Teams if needed.`,
5061
);
5162
}
5263
if (res.status === 403 && path.includes('/invitations')) {
@@ -152,8 +163,8 @@ export async function resolveUser(env, email) {
152163
export async function sendInvitation(env, email, options = {}) {
153164
const token = await getDelegatedToken(env);
154165
if (!token) {
155-
throw new Error(
156-
'DELEGATED_REFRESH_TOKEN is required to send invitations. Visit GET /auth/microsoft, sign in, then set the refresh token.',
166+
throw new ActionRequiredError(
167+
`The delegated refresh token is missing or expired. Renew it by following the steps in <${RENEW_TOKEN_URL}|the README>, then click Approve again.`,
157168
);
158169
}
159170
const body = {
@@ -180,8 +191,8 @@ export async function addTeamMember(env, teamId, email, options = {}) {
180191
if (!err.message?.includes('User not found')) throw err;
181192
const delegatedToken = await getDelegatedToken(env);
182193
if (!delegatedToken) {
183-
throw new Error(
184-
'DELEGATED_REFRESH_TOKEN is required. Visit GET /auth/microsoft, sign in as a team owner, then set the returned refresh token as DELEGATED_REFRESH_TOKEN.',
194+
throw new ActionRequiredError(
195+
`The delegated refresh token is missing or expired. Renew it by following the steps in <${RENEW_TOKEN_URL}|the README>, then click Approve again.`,
185196
);
186197
}
187198
const invitation = await sendInvitation(env, email, { displayName: options.displayName });

src/slack.js

Lines changed: 70 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,55 @@ function spinnerBlocks(request, env, opts) {
7373
];
7474
}
7575

76+
/** Approve/Reject actions block — reused so the buttons can be re-rendered after recoverable errors. */
77+
function approvalActionsBlock(request) {
78+
return {
79+
type: 'actions',
80+
block_id: 'approval_actions',
81+
elements: [
82+
{
83+
type: 'button',
84+
text: { type: 'plain_text', text: 'Approve' },
85+
style: 'primary',
86+
action_id: 'approve_request',
87+
value: String(request.id),
88+
},
89+
{
90+
type: 'button',
91+
text: { type: 'plain_text', text: 'Reject' },
92+
style: 'danger',
93+
action_id: 'reject_request',
94+
value: String(request.id),
95+
},
96+
],
97+
};
98+
}
99+
100+
/** Blocks shown when the approval failed with a fixable cause (expired token, not owner, …). Keeps the buttons so the admin can retry after fixing the issue. */
101+
function actionRequiredBlocks(request, env, opts) {
102+
return [
103+
...cardBodyBlocks(request, env, opts),
104+
{ type: 'section', text: { type: 'mrkdwn', text: ':warning: Action required, see thread…' } },
105+
approvalActionsBlock(request),
106+
];
107+
}
108+
109+
/** Post the failure detail as a threaded reply. Action-required errors are rendered as mrkdwn so the README link in the message is clickable; other errors stay in a code block. */
110+
async function postErrorThread(env, channelId, messageTs, err, actionRequired) {
111+
const message = String(err.message ?? err);
112+
const text = actionRequired
113+
? message
114+
: '```\n' + message.slice(0, 2900).replace(/```/g, '`​``') + '\n```';
115+
await slack(env, 'chat.postMessage', {
116+
channel: channelId,
117+
thread_ts: messageTs,
118+
text: actionRequired ? message : 'Error response',
119+
blocks: [
120+
{ type: 'section', text: { type: 'mrkdwn', text } },
121+
],
122+
});
123+
}
124+
76125
// ── Post one approval card per email ────────────────────────────
77126

78127
export async function postApprovalCard(env, request) {
@@ -100,26 +149,7 @@ export async function postApprovalCard(env, request) {
100149
text: `${requester} requested to invite ${request.member_email} to ${request.team_name}`,
101150
blocks: [
102151
...cardBodyBlocks(request, env, opts),
103-
{
104-
type: 'actions',
105-
block_id: 'approval_actions',
106-
elements: [
107-
{
108-
type: 'button',
109-
text: { type: 'plain_text', text: 'Approve' },
110-
style: 'primary',
111-
action_id: 'approve_request',
112-
value: String(request.id),
113-
},
114-
{
115-
type: 'button',
116-
text: { type: 'plain_text', text: 'Reject' },
117-
style: 'danger',
118-
action_id: 'reject_request',
119-
value: String(request.id),
120-
},
121-
],
122-
},
152+
approvalActionsBlock(request),
123153
],
124154
});
125155

@@ -365,34 +395,24 @@ async function handleApprove(payload, action, env) {
365395
console.error('Approve failed:', err);
366396
const channelId = payload.channel?.id ?? payload.container?.channel_id ?? env.SLACK_ADMIN_CHANNEL_ID;
367397
const messageTs = payload.message?.ts ?? payload.container?.message_ts ?? request.slack_message_ts;
368-
const errorCardText = ':warning: An error occurred…';
398+
const actionRequired = err.actionRequired === true;
399+
const errorCardText = actionRequired ? ':warning: Action required, see thread…' : ':warning: An error occurred…';
369400
if (messageTs) {
370401
try {
371402
const fallbackText = `${request.requester_email ?? request.requester_name} requested to invite ${request.member_email} to ${request.team_name}\n${errorCardText}`;
403+
const blocks = actionRequired
404+
? actionRequiredBlocks(request, env, { displayName: resolvedDisplayName })
405+
: [
406+
...cardBodyBlocks(request, env, { displayName: resolvedDisplayName }),
407+
{ type: 'section', text: { type: 'mrkdwn', text: errorCardText } },
408+
];
372409
await slack(env, 'chat.update', {
373410
channel: channelId,
374411
ts: messageTs,
375412
text: fallbackText,
376-
blocks: [
377-
...cardBodyBlocks(request, env, { displayName: resolvedDisplayName }),
378-
{ type: 'section', text: { type: 'mrkdwn', text: errorCardText } },
379-
],
380-
});
381-
const errorSnippet = String(err.message ?? err).slice(0, 2900);
382-
await slack(env, 'chat.postMessage', {
383-
channel: channelId,
384-
thread_ts: messageTs,
385-
text: 'Error response',
386-
blocks: [
387-
{
388-
type: 'section',
389-
text: {
390-
type: 'mrkdwn',
391-
text: '```\n' + errorSnippet.replace(/```/g, '`\u200b``') + '\n```',
392-
},
393-
},
394-
],
413+
blocks,
395414
});
415+
await postErrorThread(env, channelId, messageTs, err, actionRequired);
396416
} catch (updateErr) {
397417
console.error('chat.update (approve error) failed:', updateErr);
398418
const errorText = `Failed to add member: ${err.message}`;
@@ -678,31 +698,21 @@ async function handleApproveDisplayNameSubmission(payload, env, displayName) {
678698
console.error('Approve (display name) failed:', err);
679699
if (ts) {
680700
try {
681-
const errorCardText = ':warning: An error occurred…';
701+
const actionRequired = err.actionRequired === true;
702+
const errorCardText = actionRequired ? ':warning: Action required, see thread…' : ':warning: An error occurred…';
703+
const blocks = actionRequired
704+
? actionRequiredBlocks(request, env, { displayName })
705+
: [
706+
...cardBodyBlocks(request, env, { displayName }),
707+
{ type: 'section', text: { type: 'mrkdwn', text: errorCardText } },
708+
];
682709
await slack(env, 'chat.update', {
683710
channel,
684711
ts,
685712
text: `${request.requester_email ?? request.requester_name} requested to invite ${request.member_email} to ${request.team_name}\n${errorCardText}`,
686-
blocks: [
687-
...cardBodyBlocks(request, env, { displayName }),
688-
{ type: 'section', text: { type: 'mrkdwn', text: errorCardText } },
689-
],
690-
});
691-
const errorSnippet = String(err.message ?? err).slice(0, 2900);
692-
await slack(env, 'chat.postMessage', {
693-
channel,
694-
thread_ts: ts,
695-
text: 'Error response',
696-
blocks: [
697-
{
698-
type: 'section',
699-
text: {
700-
type: 'mrkdwn',
701-
text: '```\n' + errorSnippet.replace(/```/g, '`\u200b``') + '\n```',
702-
},
703-
},
704-
],
713+
blocks,
705714
});
715+
await postErrorThread(env, channel, ts, err, actionRequired);
706716
} catch (updateErr) {
707717
console.error('chat.update (approve error) failed:', updateErr);
708718
}

0 commit comments

Comments
 (0)