mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
fix: deliver announcements through a scoped Discord webhook (#2737)
* test: reproduce Discord webhook announcement gap * fix: deliver ECC announcements through channel webhook * test: cover webhook replay and least privilege * fix: make webhook delivery durable and least privilege * test: cover trusted receipts and cross-workflow races * fix: serialize and authenticate announcement receipts
This commit is contained in:
@@ -48,3 +48,40 @@ export function findDiscordReceipt(messages, key) {
|
||||
const discussionId = String(key).split(':').at(-1);
|
||||
return messages.find(message => message.embeds?.some(embed => embed.footer?.text === `ecc:${discussionId}`)) || null;
|
||||
}
|
||||
|
||||
export function normalizeDiscordWebhookUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error('invalid Discord webhook URL');
|
||||
}
|
||||
if (parsed.protocol !== 'https:' || parsed.hostname !== 'discord.com' || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new Error('invalid Discord webhook URL');
|
||||
}
|
||||
if (!/^\/api\/webhooks\/\d{10,25}\/[A-Za-z0-9._-]{20,}$/.test(parsed.pathname)) {
|
||||
throw new Error('invalid Discord webhook URL');
|
||||
}
|
||||
parsed.search = '?wait=true';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function discussionReceiptMarker(key) {
|
||||
return `<!-- ecc-discord-receipt:${createHash('sha256').update(String(key)).digest('hex').slice(0, 32)} -->`;
|
||||
}
|
||||
|
||||
export function findDiscussionReceipt(comments, marker) {
|
||||
return comments.find(comment => (
|
||||
comment?.author?.login === 'github-actions[bot]'
|
||||
&& typeof comment.body === 'string'
|
||||
&& comment.body.includes(marker)
|
||||
)) || null;
|
||||
}
|
||||
|
||||
export function discussionReceiptStatus(comment) {
|
||||
const body = String(comment?.body || '');
|
||||
if (body.includes('Discord delivery: complete')) return 'complete';
|
||||
if (body.includes('Discord delivery: pending')) return 'pending';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
import {
|
||||
announcementKey,
|
||||
buildDiscordPayload,
|
||||
discussionReceiptMarker,
|
||||
discussionReceiptStatus,
|
||||
findDiscussionReceipt,
|
||||
findDiscordReceipt,
|
||||
findReleaseDiscussion,
|
||||
normalizeDiscordWebhookUrl,
|
||||
releaseMarker,
|
||||
} from './announcement-core.mjs';
|
||||
|
||||
@@ -87,6 +91,56 @@ function discussionFromEnvironment() {
|
||||
};
|
||||
}
|
||||
|
||||
async function discussionFromGitHub() {
|
||||
if (!/^\d+$/.test(env.DISCUSSION_NUMBER || '')) throw new Error('discussion number is invalid');
|
||||
const response = await request(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/discussions/${env.DISCUSSION_NUMBER}`, {
|
||||
headers: { Authorization: `Bearer ${env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`discussion lookup failed (${response.status})`);
|
||||
const discussion = await response.json();
|
||||
if (discussion.category?.name !== 'Announcements') throw new Error('discussion is not an Announcement');
|
||||
return { id: discussion.node_id, title: discussion.title, body: discussion.body, url: discussion.html_url };
|
||||
}
|
||||
|
||||
async function findReceiptComment(discussionId, marker) {
|
||||
let cursor = null;
|
||||
for (let page = 0; page < 50; page += 1) {
|
||||
const data = await githubGraphql(
|
||||
`query($id:ID!,$after:String){node(id:$id){... on Discussion{comments(first:100,after:$after){nodes{id body author{login}} pageInfo{hasNextPage endCursor}}}}}`,
|
||||
{ id: discussionId, after: cursor },
|
||||
);
|
||||
const comments = data.node?.comments;
|
||||
if (!comments) throw new Error('discussion receipt lookup failed');
|
||||
const receipt = findDiscussionReceipt(comments.nodes, marker);
|
||||
if (receipt) return receipt;
|
||||
if (!comments.pageInfo.hasNextPage) return null;
|
||||
cursor = comments.pageInfo.endCursor;
|
||||
}
|
||||
throw new Error('discussion receipt lookup exceeded page budget');
|
||||
}
|
||||
|
||||
async function addReceiptComment(discussionId, body) {
|
||||
const data = await githubGraphql(
|
||||
`mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{id}}}`,
|
||||
{ id: discussionId, body },
|
||||
);
|
||||
return data.addDiscussionComment.comment.id;
|
||||
}
|
||||
|
||||
async function updateReceiptComment(commentId, body) {
|
||||
await githubGraphql(
|
||||
`mutation($id:ID!,$body:String!){updateDiscussionComment(input:{commentId:$id,body:$body}){comment{id}}}`,
|
||||
{ id: commentId, body },
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteReceiptComment(commentId) {
|
||||
await githubGraphql(
|
||||
`mutation($id:ID!){deleteDiscussionComment(input:{id:$id}){clientMutationId}}`,
|
||||
{ id: commentId },
|
||||
);
|
||||
}
|
||||
|
||||
async function discord(method, path, body) {
|
||||
const response = await request(`https://discord.com/api/v10${path}`, {
|
||||
method,
|
||||
@@ -98,10 +152,40 @@ async function discord(method, path, body) {
|
||||
}
|
||||
|
||||
async function deliver(discussion) {
|
||||
const key = announcementKey({ repository: env.GITHUB_REPOSITORY, discussionId: discussion.id });
|
||||
if (env.DISCORD_ANNOUNCE_WEBHOOK_URL) {
|
||||
if (!env.GITHUB_TOKEN) throw new Error('GitHub receipt configuration is missing');
|
||||
const webhookUrl = normalizeDiscordWebhookUrl(env.DISCORD_ANNOUNCE_WEBHOOK_URL);
|
||||
const marker = discussionReceiptMarker(key);
|
||||
const existingReceipt = await findReceiptComment(discussion.id, marker);
|
||||
if (existingReceipt) {
|
||||
if (discussionReceiptStatus(existingReceipt) === 'complete') {
|
||||
console.log('announcement already delivered');
|
||||
return;
|
||||
}
|
||||
throw new Error('announcement has a pending receipt; inspect Discord before clearing it');
|
||||
}
|
||||
const claimId = await addReceiptComment(discussion.id, `${marker}\n\nDiscord delivery: pending.`);
|
||||
const payload = buildDiscordPayload({ title: discussion.title, body: discussion.body, url: discussion.url, key });
|
||||
delete payload.nonce;
|
||||
delete payload.enforce_nonce;
|
||||
const response = await request(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await deleteReceiptComment(claimId);
|
||||
throw new Error(`Discord webhook request failed (${response.status})`);
|
||||
}
|
||||
const message = await response.json();
|
||||
await updateReceiptComment(claimId, `${marker}\n\nDiscord delivery: complete (message ${message.id}).`);
|
||||
console.log('announcement delivered by channel webhook');
|
||||
return;
|
||||
}
|
||||
if (!env.DISCORD_BOT_TOKEN || !/^\d{10,25}$/.test(env.DISCORD_ANNOUNCE_CHANNEL_ID || '')) {
|
||||
throw new Error('Discord announcement credentials are missing or invalid');
|
||||
}
|
||||
const key = announcementKey({ repository: env.GITHUB_REPOSITORY, discussionId: discussion.id });
|
||||
const recent = await discord('GET', `/channels/${env.DISCORD_ANNOUNCE_CHANNEL_ID}/messages?limit=100`);
|
||||
const receipt = findDiscordReceipt(recent, key);
|
||||
if (receipt) {
|
||||
@@ -121,10 +205,12 @@ async function deliver(discussion) {
|
||||
|
||||
async function main() {
|
||||
if (!env.GITHUB_REPOSITORY) throw new Error('GitHub repository configuration is missing');
|
||||
if (env.ANNOUNCEMENT_KIND === 'release' && !env.GITHUB_TOKEN) throw new Error('GitHub release configuration is missing');
|
||||
if ((env.ANNOUNCEMENT_KIND === 'release' || env.ANNOUNCEMENT_KIND === 'manual') && !env.GITHUB_TOKEN) throw new Error('GitHub configuration is missing');
|
||||
const discussion = env.ANNOUNCEMENT_KIND === 'release'
|
||||
? await createOrFindReleaseDiscussion()
|
||||
: discussionFromEnvironment();
|
||||
: env.ANNOUNCEMENT_KIND === 'manual'
|
||||
? await discussionFromGitHub()
|
||||
: discussionFromEnvironment();
|
||||
await deliver(discussion);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user