initial commit
This commit is contained in:
52
fluxer_api/src/webhook/IWebhookRepository.ts
Normal file
52
fluxer_api/src/webhook/IWebhookRepository.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Webhook} from '~/Models';
|
||||
|
||||
export abstract class IWebhookRepository {
|
||||
abstract findUnique(webhookId: bigint): Promise<Webhook | null>;
|
||||
abstract findByToken(webhookId: bigint, token: string): Promise<Webhook | null>;
|
||||
abstract create(data: {
|
||||
webhookId: bigint;
|
||||
token: string;
|
||||
type: number;
|
||||
guildId: bigint | null;
|
||||
channelId: bigint | null;
|
||||
creatorId: bigint | null;
|
||||
name: string;
|
||||
avatarHash: string | null;
|
||||
}): Promise<Webhook>;
|
||||
abstract update(
|
||||
webhookId: bigint,
|
||||
data: Partial<{
|
||||
token: string;
|
||||
type: number;
|
||||
guildId: bigint | null;
|
||||
channelId: bigint | null;
|
||||
creatorId: bigint | null;
|
||||
name: string;
|
||||
avatarHash: string | null;
|
||||
}>,
|
||||
): Promise<Webhook | null>;
|
||||
abstract delete(webhookId: bigint): Promise<void>;
|
||||
abstract listByGuild(guildId: bigint): Promise<Array<Webhook>>;
|
||||
abstract listByChannel(channelId: bigint): Promise<Array<Webhook>>;
|
||||
abstract countByGuild(guildId: bigint): Promise<number>;
|
||||
abstract countByChannel(channelId: bigint): Promise<number>;
|
||||
}
|
||||
162
fluxer_api/src/webhook/SendGridWebhookService.ts
Normal file
162
fluxer_api/src/webhook/SendGridWebhookService.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {SuspiciousActivityFlags} from '~/Constants';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
|
||||
interface SendGridEvent {
|
||||
email: string;
|
||||
event:
|
||||
| 'bounce'
|
||||
| 'dropped'
|
||||
| 'delivered'
|
||||
| 'processed'
|
||||
| 'deferred'
|
||||
| 'open'
|
||||
| 'click'
|
||||
| 'spamreport'
|
||||
| 'unsubscribe'
|
||||
| 'group_unsubscribe'
|
||||
| 'group_resubscribe';
|
||||
timestamp: number;
|
||||
sg_event_id: string;
|
||||
sg_message_id?: string;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
type?: 'bounce' | 'blocked';
|
||||
bounce_classification?: string;
|
||||
}
|
||||
|
||||
export class SendGridWebhookService {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
verifySignature(payload: string, signature: string, timestamp: string, publicKey: string): boolean {
|
||||
try {
|
||||
const signedPayload = timestamp + payload;
|
||||
|
||||
const verifier = crypto.createVerify('sha256');
|
||||
verifier.update(signedPayload);
|
||||
verifier.end();
|
||||
|
||||
return verifier.verify(publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Error verifying SendGrid webhook signature');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async processEvents(events: Array<SendGridEvent>): Promise<void> {
|
||||
for (const event of events) {
|
||||
try {
|
||||
await this.processEvent(event);
|
||||
} catch (error) {
|
||||
Logger.error({error, event}, 'Error processing SendGrid webhook event');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processEvent(event: SendGridEvent): Promise<void> {
|
||||
if (event.event !== 'bounce' && event.event !== 'dropped') {
|
||||
Logger.debug({event: event.event, email: event.email}, 'SendGrid event received (not bounce/dropped)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === 'bounce') {
|
||||
if (event.type === 'blocked') {
|
||||
Logger.info(
|
||||
{email: event.email, reason: event.reason, type: event.type},
|
||||
'SendGrid soft bounce (blocked) - temporary failure, will retry',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'bounce') {
|
||||
await this.handleHardBounce(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.event === 'dropped') {
|
||||
const reason = event.reason?.toLowerCase() || '';
|
||||
if (reason.includes('bounced') || reason.includes('invalid')) {
|
||||
await this.handleHardBounce(event);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{email: event.email, reason: event.reason},
|
||||
'SendGrid dropped event - not marking as bounced (non-delivery reason)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleHardBounce(event: SendGridEvent): Promise<void> {
|
||||
Logger.warn(
|
||||
{
|
||||
email: event.email,
|
||||
event: event.event,
|
||||
reason: event.reason,
|
||||
bounce_classification: event.bounce_classification,
|
||||
sg_event_id: event.sg_event_id,
|
||||
},
|
||||
'Processing hard bounce - marking email as invalid',
|
||||
);
|
||||
|
||||
const user = await this.userRepository.findByEmail(event.email);
|
||||
if (!user) {
|
||||
Logger.warn({email: event.email}, 'User not found for bounced email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.emailBounced) {
|
||||
Logger.debug({userId: user.id, email: event.email}, 'Email already marked as bounced');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFlags = user.suspiciousActivityFlags || 0;
|
||||
const newFlags = currentFlags | SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL;
|
||||
|
||||
await this.userRepository.patchUpsert(user.id, {
|
||||
email_bounced: true,
|
||||
email_verified: false,
|
||||
suspicious_activity_flags: newFlags,
|
||||
});
|
||||
|
||||
Logger.info(
|
||||
{userId: user.id, email: event.email, reason: event.reason},
|
||||
'User email marked as bounced and requires reverification',
|
||||
);
|
||||
|
||||
const updatedUser = await this.userRepository.findUnique(user.id);
|
||||
if (updatedUser) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: updatedUser.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
348
fluxer_api/src/webhook/WebhookController.ts
Normal file
348
fluxer_api/src/webhook/WebhookController.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {AttachmentDecayService} from '~/attachment/AttachmentDecayService';
|
||||
import {createChannelID, createGuildID, createWebhookID, createWebhookToken} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import {collectMessageAttachments} from '~/channel/services/message/MessageHelpers';
|
||||
import {Logger} from '~/Logger';
|
||||
import {LoginRequired} from '~/middleware/AuthMiddleware';
|
||||
import {BlockAppOriginMiddleware} from '~/middleware/BlockAppOriginMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {GitHubWebhook} from '~/webhook/transformers/GitHubTransformer';
|
||||
import {
|
||||
mapWebhooksToResponse,
|
||||
mapWebhookToResponseWithCache,
|
||||
WebhookCreateRequest,
|
||||
WebhookMessageRequest,
|
||||
WebhookUpdateRequest,
|
||||
} from '~/webhook/WebhookModel';
|
||||
|
||||
export const WebhookController = (app: HonoApp) => {
|
||||
const decayService = new AttachmentDecayService();
|
||||
|
||||
app.get(
|
||||
'/guilds/:guild_id/webhooks',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIST_GUILD),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({guild_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await mapWebhooksToResponse({
|
||||
webhooks: await ctx.get('webhookService').getGuildWebhooks({
|
||||
userId: ctx.get('user').id,
|
||||
guildId: createGuildID(ctx.req.valid('param').guild_id),
|
||||
}),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/channels/:channel_id/webhooks',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIST_CHANNEL),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await mapWebhooksToResponse({
|
||||
webhooks: await ctx.get('webhookService').getChannelWebhooks({
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
}),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/channels/:channel_id/webhooks',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_CREATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({channel_id: Int64Type})),
|
||||
Validator('json', WebhookCreateRequest),
|
||||
async (ctx) => {
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await mapWebhookToResponseWithCache({
|
||||
webhook: await ctx.get('webhookService').createWebhook(
|
||||
{
|
||||
userId: ctx.get('user').id,
|
||||
channelId: createChannelID(ctx.req.valid('param').channel_id),
|
||||
data: ctx.req.valid('json'),
|
||||
},
|
||||
auditLogReason,
|
||||
),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/webhooks/:webhook_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GET),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({webhook_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
await mapWebhookToResponseWithCache({
|
||||
webhook: await ctx.get('webhookService').getWebhook({
|
||||
userId: ctx.get('user').id,
|
||||
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
|
||||
}),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/webhooks/:webhook_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_UPDATE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({webhook_id: Int64Type})),
|
||||
Validator('json', WebhookUpdateRequest),
|
||||
async (ctx) => {
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
return ctx.json(
|
||||
await mapWebhookToResponseWithCache({
|
||||
webhook: await ctx.get('webhookService').updateWebhook(
|
||||
{
|
||||
userId: ctx.get('user').id,
|
||||
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
|
||||
data: ctx.req.valid('json'),
|
||||
},
|
||||
auditLogReason,
|
||||
),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/webhooks/:webhook_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_DELETE),
|
||||
LoginRequired,
|
||||
Validator('param', z.object({webhook_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
await ctx.get('webhookService').deleteWebhook(
|
||||
{
|
||||
userId: ctx.get('user').id,
|
||||
webhookId: createWebhookID(ctx.req.valid('param').webhook_id),
|
||||
},
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/webhooks/:webhook_id/:token',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GET),
|
||||
Validator('param', z.object({webhook_id: Int64Type, token: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {webhook_id: webhookId, token} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await mapWebhookToResponseWithCache({
|
||||
webhook: await ctx
|
||||
.get('webhookService')
|
||||
.getWebhookByToken({webhookId: createWebhookID(webhookId), token: createWebhookToken(token)}),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/webhooks/:webhook_id/:token',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_UPDATE),
|
||||
Validator('param', z.object({webhook_id: Int64Type, token: createStringType()})),
|
||||
Validator('json', WebhookUpdateRequest),
|
||||
async (ctx) => {
|
||||
const {webhook_id: webhookId, token} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await mapWebhookToResponseWithCache({
|
||||
webhook: await ctx.get('webhookService').updateWebhookByToken({
|
||||
webhookId: createWebhookID(webhookId),
|
||||
token: createWebhookToken(token),
|
||||
data: ctx.req.valid('json'),
|
||||
}),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/webhooks/:webhook_id/:token',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_DELETE),
|
||||
Validator('param', z.object({webhook_id: Int64Type, token: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {webhook_id: webhookId, token} = ctx.req.valid('param');
|
||||
await ctx.get('webhookService').deleteWebhookByToken({
|
||||
webhookId: createWebhookID(webhookId),
|
||||
token: createWebhookToken(token),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/webhooks/:webhook_id/:token',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_EXECUTE),
|
||||
BlockAppOriginMiddleware,
|
||||
Validator('param', z.object({webhook_id: Int64Type, token: createStringType()})),
|
||||
Validator('json', WebhookMessageRequest),
|
||||
async (ctx) => {
|
||||
const {webhook_id: webhookId, token} = ctx.req.valid('param');
|
||||
const wait = ctx.req.query('wait') === 'true';
|
||||
|
||||
const message = await ctx.get('webhookService').executeWebhook({
|
||||
webhookId: createWebhookID(webhookId),
|
||||
token: createWebhookToken(token),
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
|
||||
if (wait) {
|
||||
const messageAttachments = collectMessageAttachments(message);
|
||||
const attachmentDecayMap =
|
||||
messageAttachments.length > 0
|
||||
? await decayService.fetchMetadata(messageAttachments.map((att) => ({attachmentId: att.id})))
|
||||
: undefined;
|
||||
return ctx.json(
|
||||
await mapMessageToResponse({
|
||||
message,
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
mediaService: ctx.get('mediaService'),
|
||||
attachmentDecayMap,
|
||||
getReferencedMessage: (channelId, messageId) =>
|
||||
ctx.get('channelRepository').getMessage(channelId, messageId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/webhooks/:webhook_id/:token/github',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GITHUB),
|
||||
Validator('param', z.object({webhook_id: Int64Type, token: createStringType()})),
|
||||
Validator('json', GitHubWebhook),
|
||||
async (ctx) => {
|
||||
const {webhook_id: webhookId, token} = ctx.req.valid('param');
|
||||
await ctx.get('webhookService').executeGitHubWebhook({
|
||||
webhookId: createWebhookID(webhookId),
|
||||
token: createWebhookToken(token),
|
||||
event: ctx.req.header('X-GitHub-Event') ?? '',
|
||||
delivery: ctx.req.header('X-GitHub-Delivery') ?? '',
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/webhooks/livekit', async (ctx) => {
|
||||
if (!Config.voice.enabled) {
|
||||
return ctx.body('Voice not enabled', 404);
|
||||
}
|
||||
|
||||
const liveKitWebhookService = ctx.get('liveKitWebhookService');
|
||||
if (!liveKitWebhookService) {
|
||||
return ctx.body('LiveKit webhook service not available', 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.req.text();
|
||||
const authHeader = ctx.req.header('Authorization') ?? '';
|
||||
const data = await liveKitWebhookService.verifyAndParse(body, authHeader);
|
||||
await liveKitWebhookService.processEvent(data);
|
||||
return ctx.body(null, 200);
|
||||
} catch (error) {
|
||||
Logger.debug({error}, 'Error processing LiveKit webhook');
|
||||
return ctx.body('Invalid webhook', 400);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/webhooks/sendgrid', async (ctx) => {
|
||||
if (!Config.email.enabled) {
|
||||
return ctx.body('Email not enabled', 404);
|
||||
}
|
||||
|
||||
const sendGridWebhookService = ctx.get('sendGridWebhookService');
|
||||
if (!sendGridWebhookService) {
|
||||
return ctx.body('SendGrid webhook service not available', 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await ctx.req.text();
|
||||
|
||||
if (Config.email.webhookPublicKey) {
|
||||
const signature = ctx.req.header('X-Twilio-Email-Event-Webhook-Signature');
|
||||
const timestamp = ctx.req.header('X-Twilio-Email-Event-Webhook-Timestamp');
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
Logger.warn('SendGrid webhook missing signature headers');
|
||||
return ctx.body('Missing signature headers', 401);
|
||||
}
|
||||
|
||||
const isValid = sendGridWebhookService.verifySignature(
|
||||
body,
|
||||
signature,
|
||||
timestamp,
|
||||
Config.email.webhookPublicKey,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
Logger.warn('SendGrid webhook signature verification failed');
|
||||
return ctx.body('Invalid signature', 401);
|
||||
}
|
||||
}
|
||||
|
||||
const events = JSON.parse(body);
|
||||
await sendGridWebhookService.processEvents(events);
|
||||
|
||||
return ctx.body(null, 200);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Error processing SendGrid webhook');
|
||||
return ctx.body('Invalid webhook', 400);
|
||||
}
|
||||
});
|
||||
};
|
||||
106
fluxer_api/src/webhook/WebhookModel.ts
Normal file
106
fluxer_api/src/webhook/WebhookModel.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AVATAR_MAX_SIZE} from '~/Constants';
|
||||
import {MessageRequest} from '~/channel/ChannelModel';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Webhook} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {createBase64StringType, Int64Type, URLType, WebhookNameType, z} from '~/Schema';
|
||||
import {getCachedUserPartialResponse} from '~/user/UserCacheHelpers';
|
||||
import {UserPartialResponse} from '~/user/UserModel';
|
||||
|
||||
export const WebhookResponse = z.object({
|
||||
id: z.string(),
|
||||
guild_id: z.string(),
|
||||
channel_id: z.string(),
|
||||
user: z.lazy(() => UserPartialResponse),
|
||||
name: z.string(),
|
||||
avatar: z.string().nullish(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type WebhookResponse = z.infer<typeof WebhookResponse>;
|
||||
|
||||
export const WebhookCreateRequest = z.object({
|
||||
name: WebhookNameType,
|
||||
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
});
|
||||
|
||||
export type WebhookCreateRequest = z.infer<typeof WebhookCreateRequest>;
|
||||
|
||||
export const WebhookUpdateRequest = z
|
||||
.object({
|
||||
name: WebhookNameType,
|
||||
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33).nullish(),
|
||||
channel_id: Int64Type,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type WebhookUpdateRequest = z.infer<typeof WebhookUpdateRequest>;
|
||||
|
||||
export const WebhookMessageRequest = z.object({
|
||||
...MessageRequest.shape,
|
||||
username: WebhookNameType.nullish(),
|
||||
avatar_url: URLType.nullish(),
|
||||
});
|
||||
|
||||
export type WebhookMessageRequest = z.infer<typeof WebhookMessageRequest>;
|
||||
|
||||
export async function mapWebhookToResponseWithCache({
|
||||
webhook,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<WebhookResponse> {
|
||||
const creatorPartial = await getCachedUserPartialResponse({
|
||||
userId: webhook.creatorId!,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
if (!creatorPartial) {
|
||||
throw new Error(`Creator user ${webhook.creatorId} not found for webhook`);
|
||||
}
|
||||
return {
|
||||
id: webhook.id.toString(),
|
||||
guild_id: webhook.guildId?.toString() || '',
|
||||
channel_id: webhook.channelId?.toString() || '',
|
||||
user: creatorPartial,
|
||||
name: webhook.name || '',
|
||||
avatar: webhook.avatarHash,
|
||||
token: webhook.token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function mapWebhooksToResponse({
|
||||
webhooks,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: {
|
||||
webhooks: Array<Webhook>;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<WebhookResponse>> {
|
||||
return await Promise.all(
|
||||
webhooks.map((webhook) => mapWebhookToResponseWithCache({webhook, userCacheService, requestCache})),
|
||||
);
|
||||
}
|
||||
293
fluxer_api/src/webhook/WebhookRepository.ts
Normal file
293
fluxer_api/src/webhook/WebhookRepository.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ChannelID,
|
||||
createWebhookID,
|
||||
type GuildID,
|
||||
type UserID,
|
||||
type WebhookID,
|
||||
type WebhookToken,
|
||||
} from '~/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
buildPatchFromData,
|
||||
deleteOneOrMany,
|
||||
executeVersionedUpdate,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
} from '~/database/Cassandra';
|
||||
import {WEBHOOK_COLUMNS, type WebhookRow} from '~/database/CassandraTypes';
|
||||
import {Webhook} from '~/Models';
|
||||
import {Webhooks, WebhooksByChannel, WebhooksByGuild} from '~/Tables';
|
||||
import {IWebhookRepository} from './IWebhookRepository';
|
||||
|
||||
const FETCH_WEBHOOK_BY_ID_CQL = Webhooks.selectCql({
|
||||
where: Webhooks.where.eq('webhook_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBHOOK_BY_TOKEN_CQL = Webhooks.selectCql({
|
||||
where: [Webhooks.where.eq('webhook_id'), Webhooks.where.eq('webhook_token')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBHOOK_IDS_BY_GUILD_CQL = WebhooksByGuild.selectCql({
|
||||
columns: ['webhook_id'],
|
||||
where: WebhooksByGuild.where.eq('guild_id'),
|
||||
});
|
||||
|
||||
const FETCH_WEBHOOK_IDS_BY_CHANNEL_CQL = WebhooksByChannel.selectCql({
|
||||
columns: ['webhook_id'],
|
||||
where: WebhooksByChannel.where.eq('channel_id'),
|
||||
});
|
||||
|
||||
export class WebhookRepository extends IWebhookRepository {
|
||||
async findUnique(webhookId: WebhookID): Promise<Webhook | null> {
|
||||
const result = await fetchOne<WebhookRow>(FETCH_WEBHOOK_BY_ID_CQL, {webhook_id: webhookId});
|
||||
return result ? new Webhook(result) : null;
|
||||
}
|
||||
|
||||
async findByToken(webhookId: WebhookID, token: WebhookToken): Promise<Webhook | null> {
|
||||
const result = await fetchOne<WebhookRow>(FETCH_WEBHOOK_BY_TOKEN_CQL, {
|
||||
webhook_id: webhookId,
|
||||
webhook_token: token,
|
||||
});
|
||||
return result ? new Webhook(result) : null;
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
webhookId: WebhookID;
|
||||
token: WebhookToken;
|
||||
type: number;
|
||||
guildId: GuildID | null;
|
||||
channelId: ChannelID | null;
|
||||
creatorId: UserID | null;
|
||||
name: string;
|
||||
avatarHash: string | null;
|
||||
}): Promise<Webhook> {
|
||||
const webhookData: WebhookRow = {
|
||||
webhook_id: data.webhookId,
|
||||
webhook_token: data.token,
|
||||
type: data.type,
|
||||
guild_id: data.guildId,
|
||||
channel_id: data.channelId,
|
||||
creator_id: data.creatorId,
|
||||
name: data.name,
|
||||
avatar_hash: data.avatarHash,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const result = await executeVersionedUpdate<WebhookRow, 'webhook_id' | 'webhook_token'>(
|
||||
async () => {
|
||||
return await fetchOne<WebhookRow>(FETCH_WEBHOOK_BY_ID_CQL, {webhook_id: data.webhookId});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {webhook_id: data.webhookId, webhook_token: data.token},
|
||||
patch: buildPatchFromData(webhookData, current, WEBHOOK_COLUMNS, ['webhook_id', 'webhook_token']),
|
||||
}),
|
||||
Webhooks,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (data.guildId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByGuild.upsertAll({
|
||||
guild_id: data.guildId,
|
||||
webhook_id: data.webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.channelId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByChannel.upsertAll({
|
||||
channel_id: data.channelId,
|
||||
webhook_id: data.webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new Webhook({...webhookData, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async update(
|
||||
webhookId: WebhookID,
|
||||
data: Partial<{
|
||||
token: WebhookToken;
|
||||
type: number;
|
||||
guildId: GuildID | null;
|
||||
channelId: ChannelID | null;
|
||||
creatorId: UserID | null;
|
||||
name: string;
|
||||
avatarHash: string | null;
|
||||
}>,
|
||||
oldData?: WebhookRow | null,
|
||||
): Promise<Webhook | null> {
|
||||
const existing = oldData !== undefined ? (oldData ? new Webhook(oldData) : null) : await this.findUnique(webhookId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updatedData: WebhookRow = {
|
||||
webhook_id: webhookId,
|
||||
webhook_token: data.token ?? existing.token,
|
||||
type: data.type ?? existing.type,
|
||||
guild_id: data.guildId !== undefined ? data.guildId : existing.guildId,
|
||||
channel_id: data.channelId !== undefined ? data.channelId : existing.channelId,
|
||||
creator_id: data.creatorId !== undefined ? data.creatorId : existing.creatorId,
|
||||
name: data.name ?? existing.name,
|
||||
avatar_hash: data.avatarHash !== undefined ? data.avatarHash : existing.avatarHash,
|
||||
version: existing.version,
|
||||
};
|
||||
|
||||
const result = await executeVersionedUpdate<WebhookRow, 'webhook_id' | 'webhook_token'>(
|
||||
async () => {
|
||||
if (oldData !== undefined) return oldData;
|
||||
return await fetchOne<WebhookRow>(FETCH_WEBHOOK_BY_ID_CQL, {webhook_id: webhookId});
|
||||
},
|
||||
(current) => ({
|
||||
pk: {webhook_id: webhookId, webhook_token: updatedData.webhook_token},
|
||||
patch: buildPatchFromData(updatedData, current, WEBHOOK_COLUMNS, ['webhook_id', 'webhook_token']),
|
||||
}),
|
||||
Webhooks,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (existing.guildId !== updatedData.guild_id) {
|
||||
if (existing.guildId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByGuild.deleteByPk({
|
||||
guild_id: existing.guildId,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (updatedData.guild_id) {
|
||||
batch.addPrepared(
|
||||
WebhooksByGuild.upsertAll({
|
||||
guild_id: updatedData.guild_id,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.channelId !== updatedData.channel_id) {
|
||||
if (existing.channelId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByChannel.deleteByPk({
|
||||
channel_id: existing.channelId,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (updatedData.channel_id) {
|
||||
batch.addPrepared(
|
||||
WebhooksByChannel.upsertAll({
|
||||
channel_id: updatedData.channel_id,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new Webhook({...updatedData, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async delete(webhookId: WebhookID): Promise<void> {
|
||||
const webhook = await this.findUnique(webhookId);
|
||||
if (!webhook) return;
|
||||
|
||||
await deleteOneOrMany(
|
||||
Webhooks.deleteByPk({
|
||||
webhook_id: webhookId,
|
||||
webhook_token: webhook.token,
|
||||
}),
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (webhook.guildId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByGuild.deleteByPk({
|
||||
guild_id: webhook.guildId,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (webhook.channelId) {
|
||||
batch.addPrepared(
|
||||
WebhooksByChannel.deleteByPk({
|
||||
channel_id: webhook.channelId,
|
||||
webhook_id: webhookId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async listByGuild(guildId: GuildID): Promise<Array<Webhook>> {
|
||||
const webhookIds = await fetchMany<{webhook_id: bigint}>(FETCH_WEBHOOK_IDS_BY_GUILD_CQL, {guild_id: guildId});
|
||||
|
||||
const webhooks: Array<Webhook> = [];
|
||||
for (const {webhook_id} of webhookIds) {
|
||||
const webhook = await this.findUnique(createWebhookID(webhook_id));
|
||||
if (webhook) {
|
||||
webhooks.push(webhook);
|
||||
}
|
||||
}
|
||||
return webhooks;
|
||||
}
|
||||
|
||||
async listByChannel(channelId: ChannelID): Promise<Array<Webhook>> {
|
||||
const webhookIds = await fetchMany<{webhook_id: bigint}>(FETCH_WEBHOOK_IDS_BY_CHANNEL_CQL, {
|
||||
channel_id: channelId,
|
||||
});
|
||||
|
||||
const webhooks: Array<Webhook> = [];
|
||||
for (const {webhook_id} of webhookIds) {
|
||||
const webhook = await this.findUnique(createWebhookID(webhook_id));
|
||||
if (webhook) {
|
||||
webhooks.push(webhook);
|
||||
}
|
||||
}
|
||||
return webhooks;
|
||||
}
|
||||
|
||||
async countByGuild(guildId: GuildID): Promise<number> {
|
||||
const webhookIds = await fetchMany<{webhook_id: bigint}>(FETCH_WEBHOOK_IDS_BY_GUILD_CQL, {guild_id: guildId});
|
||||
return webhookIds.length;
|
||||
}
|
||||
|
||||
async countByChannel(channelId: ChannelID): Promise<number> {
|
||||
const webhookIds = await fetchMany<{webhook_id: bigint}>(FETCH_WEBHOOK_IDS_BY_CHANNEL_CQL, {
|
||||
channel_id: channelId,
|
||||
});
|
||||
return webhookIds.length;
|
||||
}
|
||||
}
|
||||
471
fluxer_api/src/webhook/WebhookService.ts
Normal file
471
fluxer_api/src/webhook/WebhookService.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createGuildID,
|
||||
createWebhookID,
|
||||
createWebhookToken,
|
||||
type GuildID,
|
||||
type UserID,
|
||||
type WebhookID,
|
||||
type WebhookToken,
|
||||
} from '~/BrandedTypes';
|
||||
import {MAX_WEBHOOKS_PER_CHANNEL, MAX_WEBHOOKS_PER_GUILD, Permissions} from '~/Constants';
|
||||
import type {AllowedMentionsRequest, MessageRequest} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {ChannelService} from '~/channel/services/ChannelService';
|
||||
import {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {MaxWebhooksPerChannelError, MaxWebhooksPerGuildError, UnknownChannelError, UnknownWebhookError} from '~/Errors';
|
||||
import type {GuildAuditLogService} from '~/guild/GuildAuditLogService';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {AvatarService} from '~/infrastructure/AvatarService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {Message, Webhook} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
import * as GitHubTransformer from '~/webhook/transformers/GitHubTransformer';
|
||||
import type {WebhookCreateRequest, WebhookMessageRequest, WebhookUpdateRequest} from '~/webhook/WebhookModel';
|
||||
import type {IWebhookRepository} from './IWebhookRepository';
|
||||
|
||||
export class WebhookService {
|
||||
private static readonly NO_ALLOWED_MENTIONS: AllowedMentionsRequest = {parse: []};
|
||||
constructor(
|
||||
private repository: IWebhookRepository,
|
||||
private guildService: GuildService,
|
||||
private channelService: ChannelService,
|
||||
private channelRepository: IChannelRepository,
|
||||
private cacheService: ICacheService,
|
||||
private gatewayService: IGatewayService,
|
||||
private avatarService: AvatarService,
|
||||
private mediaService: IMediaService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
) {}
|
||||
|
||||
async getWebhook({userId, webhookId}: {userId: UserID; webhookId: WebhookID}): Promise<Webhook> {
|
||||
return await this.getAuthenticatedWebhook({userId, webhookId});
|
||||
}
|
||||
|
||||
async getWebhookByToken({webhookId, token}: {webhookId: WebhookID; token: WebhookToken}): Promise<Webhook> {
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
return webhook;
|
||||
}
|
||||
|
||||
async getGuildWebhooks({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<Array<Webhook>> {
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
return await this.repository.listByGuild(guildId);
|
||||
}
|
||||
|
||||
async getChannelWebhooks({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Array<Webhook>> {
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (!channel.guildId) throw new UnknownChannelError();
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: channel.guildId});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
return await this.repository.listByChannel(channelId);
|
||||
}
|
||||
|
||||
async createWebhook(
|
||||
params: {userId: UserID; channelId: ChannelID; data: WebhookCreateRequest},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<Webhook> {
|
||||
const {userId, channelId, data} = params;
|
||||
const channel = await this.channelService.getChannel({userId, channelId});
|
||||
if (!channel.guildId) throw new UnknownChannelError();
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: channel.guildId});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
const guildWebhookCount = await this.repository.countByGuild(channel.guildId);
|
||||
if (guildWebhookCount >= MAX_WEBHOOKS_PER_GUILD) {
|
||||
throw new MaxWebhooksPerGuildError();
|
||||
}
|
||||
const channelWebhookCount = await this.repository.countByChannel(channelId);
|
||||
if (channelWebhookCount >= MAX_WEBHOOKS_PER_CHANNEL) {
|
||||
throw new MaxWebhooksPerChannelError();
|
||||
}
|
||||
const webhookId = createWebhookID(this.snowflakeService.generate());
|
||||
const webhook = await this.repository.create({
|
||||
webhookId,
|
||||
token: createWebhookToken(RandomUtils.randomString(64)),
|
||||
type: 1,
|
||||
guildId: channel.guildId,
|
||||
channelId,
|
||||
creatorId: userId,
|
||||
name: data.name,
|
||||
avatarHash: data.avatar ? await this.updateAvatar({webhookId, avatar: data.avatar}) : null,
|
||||
});
|
||||
await this.dispatchWebhooksUpdate({guildId: channel.guildId, channelId});
|
||||
await this.recordWebhookAuditLog({
|
||||
guildId: channel.guildId,
|
||||
userId,
|
||||
action: 'create',
|
||||
webhook,
|
||||
auditLogReason,
|
||||
});
|
||||
return webhook;
|
||||
}
|
||||
|
||||
async updateWebhook(
|
||||
params: {userId: UserID; webhookId: WebhookID; data: WebhookUpdateRequest},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<Webhook> {
|
||||
const {userId, webhookId, data} = params;
|
||||
const webhook = await this.getAuthenticatedWebhook({userId, webhookId});
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({
|
||||
userId,
|
||||
guildId: webhook.guildId ? webhook.guildId : createGuildID(0n),
|
||||
});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
if (data.channel_id && data.channel_id !== webhook.channelId) {
|
||||
const targetChannel = await this.channelService.getChannel({userId, channelId: createChannelID(data.channel_id)});
|
||||
if (!targetChannel.guildId || targetChannel.guildId !== webhook.guildId) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
const channelWebhookCount = await this.repository.countByChannel(createChannelID(data.channel_id));
|
||||
if (channelWebhookCount >= MAX_WEBHOOKS_PER_CHANNEL) {
|
||||
throw new MaxWebhooksPerChannelError();
|
||||
}
|
||||
}
|
||||
const updatedData = await this.updateWebhookData({webhook, data});
|
||||
const updatedWebhook = await this.repository.update(webhookId, {
|
||||
name: updatedData.name,
|
||||
avatarHash: updatedData.avatarHash,
|
||||
channelId: updatedData.channelId,
|
||||
});
|
||||
if (!updatedWebhook) throw new UnknownWebhookError();
|
||||
await this.dispatchWebhooksUpdate({
|
||||
guildId: webhook.guildId,
|
||||
channelId: webhook.channelId,
|
||||
});
|
||||
if (webhook.guildId) {
|
||||
const previousSnapshot = this.serializeWebhookForAudit(webhook);
|
||||
await this.recordWebhookAuditLog({
|
||||
guildId: webhook.guildId,
|
||||
userId,
|
||||
action: 'update',
|
||||
webhook: updatedWebhook,
|
||||
previousSnapshot,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
return updatedWebhook;
|
||||
}
|
||||
|
||||
async updateWebhookByToken({
|
||||
webhookId,
|
||||
token,
|
||||
data,
|
||||
}: {
|
||||
webhookId: WebhookID;
|
||||
token: WebhookToken;
|
||||
data: WebhookUpdateRequest;
|
||||
}): Promise<Webhook> {
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
if (data.channel_id && data.channel_id !== webhook.channelId) {
|
||||
const targetChannel = await this.channelRepository.findUnique(createChannelID(data.channel_id));
|
||||
if (!targetChannel || !targetChannel.guildId || targetChannel.guildId !== webhook.guildId) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
}
|
||||
const updatedData = await this.updateWebhookData({webhook, data});
|
||||
const updatedWebhook = await this.repository.update(webhookId, {
|
||||
name: updatedData.name,
|
||||
avatarHash: updatedData.avatarHash,
|
||||
channelId: updatedData.channelId,
|
||||
});
|
||||
if (!updatedWebhook) throw new UnknownWebhookError();
|
||||
await this.dispatchWebhooksUpdate({
|
||||
guildId: webhook.guildId,
|
||||
channelId: webhook.channelId,
|
||||
});
|
||||
return updatedWebhook;
|
||||
}
|
||||
|
||||
async deleteWebhook(
|
||||
{userId, webhookId}: {userId: UserID; webhookId: WebhookID},
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const webhook = await this.getAuthenticatedWebhook({userId, webhookId});
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
await this.repository.delete(webhookId);
|
||||
await this.dispatchWebhooksUpdate({
|
||||
guildId: webhook.guildId,
|
||||
channelId: webhook.channelId,
|
||||
});
|
||||
if (webhook.guildId) {
|
||||
await this.recordWebhookAuditLog({
|
||||
guildId: webhook.guildId,
|
||||
userId,
|
||||
action: 'delete',
|
||||
webhook,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWebhookByToken({webhookId, token}: {webhookId: WebhookID; token: WebhookToken}): Promise<void> {
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
await this.repository.delete(webhookId);
|
||||
await this.dispatchWebhooksUpdate({
|
||||
guildId: webhook.guildId,
|
||||
channelId: webhook.channelId,
|
||||
});
|
||||
}
|
||||
|
||||
async executeWebhook({
|
||||
webhookId,
|
||||
token,
|
||||
data,
|
||||
requestCache,
|
||||
}: {
|
||||
webhookId: WebhookID;
|
||||
token: WebhookToken;
|
||||
data: WebhookMessageRequest;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Message> {
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
const channel = await this.channelRepository.findUnique(webhook.channelId!);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
const message = await this.channelService.sendWebhookMessage({
|
||||
webhook,
|
||||
data: {
|
||||
...data,
|
||||
allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS,
|
||||
} as MessageRequest,
|
||||
username: data.username,
|
||||
avatar: data.avatar_url ? await this.getWebhookAvatar({webhookId: webhook.id, avatarUrl: data.avatar_url}) : null,
|
||||
requestCache,
|
||||
});
|
||||
return message;
|
||||
}
|
||||
|
||||
async executeGitHubWebhook(params: {
|
||||
webhookId: WebhookID;
|
||||
token: WebhookToken;
|
||||
event: string;
|
||||
delivery: string;
|
||||
data: GitHubTransformer.GitHubWebhook;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {webhookId, token, event, delivery, data, requestCache} = params;
|
||||
const webhook = await this.repository.findByToken(webhookId, token);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
const channel = await this.channelRepository.findUnique(webhook.channelId!);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
if (delivery) {
|
||||
const isCached = await this.cacheService.get<number>(`github:${webhookId}:${delivery}`);
|
||||
if (isCached) return;
|
||||
}
|
||||
const embed = await GitHubTransformer.transform(event, data);
|
||||
if (!embed) return;
|
||||
await this.channelService.sendWebhookMessage({
|
||||
webhook,
|
||||
data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS},
|
||||
username: 'GitHub',
|
||||
avatar: await this.getGitHubWebhookAvatar(webhook.id),
|
||||
requestCache,
|
||||
});
|
||||
if (delivery) await this.cacheService.set(`github:${webhookId}:${delivery}`, 1, 60 * 60 * 24);
|
||||
}
|
||||
|
||||
async dispatchWebhooksUpdate({
|
||||
guildId,
|
||||
channelId,
|
||||
}: {
|
||||
guildId: GuildID | null;
|
||||
channelId: ChannelID | null;
|
||||
}): Promise<void> {
|
||||
if (guildId && channelId) {
|
||||
await this.gatewayService.dispatchGuild({
|
||||
guildId: guildId,
|
||||
event: 'WEBHOOKS_UPDATE',
|
||||
data: {channel_id: channelId.toString()},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getAuthenticatedWebhook({userId, webhookId}: {userId: UserID; webhookId: WebhookID}): Promise<Webhook> {
|
||||
const webhook = await this.repository.findUnique(webhookId);
|
||||
if (!webhook) throw new UnknownWebhookError();
|
||||
const {checkPermission} = await this.guildService.getGuildAuthenticated({userId, guildId: webhook.guildId!});
|
||||
await checkPermission(Permissions.MANAGE_WEBHOOKS);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
private async updateWebhookData({
|
||||
webhook,
|
||||
data,
|
||||
}: {
|
||||
webhook: Webhook;
|
||||
data: WebhookUpdateRequest;
|
||||
}): Promise<{name: string; avatarHash: string | null; channelId: ChannelID | null}> {
|
||||
const name = data.name !== undefined ? data.name : webhook.name;
|
||||
const avatarHash =
|
||||
data.avatar !== undefined
|
||||
? await this.updateAvatar({webhookId: webhook.id, avatar: data.avatar})
|
||||
: webhook.avatarHash;
|
||||
let channelId = webhook.channelId;
|
||||
if (data.channel_id !== undefined && data.channel_id !== webhook.channelId) {
|
||||
const channel = await this.channelRepository.findUnique(createChannelID(data.channel_id));
|
||||
if (!channel || !channel.guildId || channel.guildId !== webhook.guildId) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
channelId = channel.id;
|
||||
}
|
||||
return {name: name!, avatarHash, channelId};
|
||||
}
|
||||
|
||||
private async updateAvatar({
|
||||
webhookId,
|
||||
avatar,
|
||||
}: {
|
||||
webhookId: WebhookID;
|
||||
avatar: string | null;
|
||||
}): Promise<string | null> {
|
||||
return this.avatarService.uploadAvatar({
|
||||
prefix: 'avatars',
|
||||
entityId: webhookId,
|
||||
errorPath: 'avatar',
|
||||
base64Image: avatar,
|
||||
});
|
||||
}
|
||||
|
||||
private async getWebhookAvatar({
|
||||
webhookId,
|
||||
avatarUrl,
|
||||
}: {
|
||||
webhookId: WebhookID;
|
||||
avatarUrl: string | null;
|
||||
}): Promise<string | null> {
|
||||
if (!avatarUrl) return null;
|
||||
const avatarCache = await this.cacheService.get<string | null>(`webhook:${webhookId}:avatar:${avatarUrl}`);
|
||||
if (avatarCache) return avatarCache;
|
||||
const metadata = await this.mediaService.getMetadata({
|
||||
type: 'external',
|
||||
url: avatarUrl,
|
||||
with_base64: true,
|
||||
isNSFWAllowed: false,
|
||||
});
|
||||
if (!metadata?.base64) {
|
||||
await this.cacheService.set(`webhook:${webhookId}:avatar:${avatarUrl}`, null, 60 * 5);
|
||||
return null;
|
||||
}
|
||||
const avatar = await this.avatarService.uploadAvatar({
|
||||
prefix: 'avatars',
|
||||
entityId: webhookId,
|
||||
errorPath: 'avatar',
|
||||
base64Image: metadata.base64,
|
||||
});
|
||||
await this.cacheService.set(`webhook:${webhookId}:avatar:${avatarUrl}`, avatar, 60 * 60 * 24);
|
||||
return avatar;
|
||||
}
|
||||
|
||||
private async getGitHubWebhookAvatar(webhookId: WebhookID): Promise<string | null> {
|
||||
const avatarCache = await this.cacheService.get<string | null>(`webhook:${webhookId}:avatar:github`);
|
||||
if (avatarCache) return avatarCache;
|
||||
const avatarFile = await fs.readFile('./src/assets/github.webp');
|
||||
const avatar = await this.avatarService.uploadAvatar({
|
||||
prefix: 'avatars',
|
||||
entityId: webhookId,
|
||||
errorPath: 'avatar',
|
||||
base64Image: avatarFile.toString('base64'),
|
||||
});
|
||||
await this.cacheService.set(`webhook:${webhookId}:avatar:github`, avatar, 60 * 60 * 24);
|
||||
return avatar;
|
||||
}
|
||||
|
||||
private getWebhookMetadata(webhook: Webhook): Record<string, string> | undefined {
|
||||
if (!webhook.channelId) {
|
||||
return undefined;
|
||||
}
|
||||
return {channel_id: webhook.channelId.toString()};
|
||||
}
|
||||
|
||||
private serializeWebhookForAudit(webhook: Webhook): Record<string, unknown> {
|
||||
return {
|
||||
id: webhook.id.toString(),
|
||||
guild_id: webhook.guildId?.toString() ?? null,
|
||||
channel_id: webhook.channelId?.toString() ?? null,
|
||||
name: webhook.name,
|
||||
creator_id: webhook.creatorId?.toString() ?? null,
|
||||
avatar_hash: webhook.avatarHash,
|
||||
type: webhook.type,
|
||||
};
|
||||
}
|
||||
|
||||
private async recordWebhookAuditLog(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
webhook: Webhook;
|
||||
previousSnapshot?: Record<string, unknown> | null;
|
||||
auditLogReason?: string | null;
|
||||
}): Promise<void> {
|
||||
const actionName =
|
||||
params.action === 'create'
|
||||
? 'guild_webhook_create'
|
||||
: params.action === 'update'
|
||||
? 'guild_webhook_update'
|
||||
: 'guild_webhook_delete';
|
||||
|
||||
const previousSnapshot =
|
||||
params.action === 'create' ? null : (params.previousSnapshot ?? this.serializeWebhookForAudit(params.webhook));
|
||||
const nextSnapshot = params.action === 'delete' ? null : this.serializeWebhookForAudit(params.webhook);
|
||||
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, nextSnapshot);
|
||||
|
||||
const actionType =
|
||||
params.action === 'create'
|
||||
? AuditLogActionType.WEBHOOK_CREATE
|
||||
: params.action === 'update'
|
||||
? AuditLogActionType.WEBHOOK_UPDATE
|
||||
: AuditLogActionType.WEBHOOK_DELETE;
|
||||
|
||||
try {
|
||||
await this.guildAuditLogService
|
||||
.createBuilder(params.guildId, params.userId)
|
||||
.withAction(actionType, params.webhook.id.toString())
|
||||
.withReason(params.auditLogReason ?? null)
|
||||
.withMetadata(this.getWebhookMetadata(params.webhook))
|
||||
.withChanges(changes)
|
||||
.commit();
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
guildId: params.guildId.toString(),
|
||||
userId: params.userId.toString(),
|
||||
action: actionName,
|
||||
targetId: params.webhook.id.toString(),
|
||||
},
|
||||
'Failed to record guild webhook audit log',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
120
fluxer_api/src/webhook/transformers/GitHubCheckTransformer.ts
Normal file
120
fluxer_api/src/webhook/transformers/GitHubCheckTransformer.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {parseString} from '~/utils/StringUtils';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transformCheckRun = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.repository || body.action !== 'completed' || !body.check_run) {
|
||||
return null;
|
||||
}
|
||||
const checkRun = body.check_run;
|
||||
if (checkRun.check_suite.conclusion === 'skipped') {
|
||||
return null;
|
||||
}
|
||||
const commitUrl = `${body.repository.html_url}/commit/${checkRun.check_suite.head_sha}`;
|
||||
return {
|
||||
url: commitUrl,
|
||||
title: parseString(
|
||||
`[${body.repository.name}] ${checkRun.name} ${checkRun.conclusion} on ${checkRun.check_suite.head_branch}`,
|
||||
256,
|
||||
),
|
||||
color: checkRun.conclusion === 'success' ? 0x009800 : 0xfc2929,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformCheckSuite = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.repository || body.action !== 'completed' || !body.check_suite) {
|
||||
return null;
|
||||
}
|
||||
const checkSuite = body.check_suite;
|
||||
if (checkSuite.conclusion === 'skipped') {
|
||||
return null;
|
||||
}
|
||||
const commitUrl = `${body.repository.html_url}/commit/${checkSuite.head_sha}`;
|
||||
return {
|
||||
url: commitUrl,
|
||||
title: parseString(
|
||||
`[${body.repository.name}] ${checkSuite.app.name} checks ${checkSuite.conclusion} on ${checkSuite.head_branch}`,
|
||||
256,
|
||||
),
|
||||
color: checkSuite.conclusion === 'success' ? 0x009800 : 0xfc2929,
|
||||
};
|
||||
};
|
||||
|
||||
export const transformDiscussion = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (body.action !== 'created' || !body.discussion || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.discussion.user.avatar_url;
|
||||
const authorName = body.discussion.user.login;
|
||||
const authorUrl = body.discussion.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const discussionNumber = body.discussion.number;
|
||||
const discussionTitle = body.discussion.title;
|
||||
const discussionUrl = body.discussion.html_url;
|
||||
const discussionBody = body.discussion.body;
|
||||
const color = 0xe6c2b0;
|
||||
|
||||
const title = `[${repoName}] New discussion #${discussionNumber}: ${discussionTitle}`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: discussionUrl,
|
||||
color,
|
||||
description: parseString(discussionBody ?? '', 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformDiscussionComment = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (body.action !== 'created' || !body.comment || !body.discussion || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.comment.user.avatar_url;
|
||||
const authorName = body.comment.user.login;
|
||||
const authorUrl = body.comment.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const discussionNumber = body.discussion.number;
|
||||
const discussionTitle = body.discussion.title;
|
||||
const commentUrl = body.comment.html_url;
|
||||
const commentBody = body.comment.body;
|
||||
const color = 0xe6c2b0;
|
||||
|
||||
const title = `[${repoName}] New comment on discussion #${discussionNumber}: ${discussionTitle}`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: commentUrl,
|
||||
color,
|
||||
description: parseString(commentBody, 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
184
fluxer_api/src/webhook/transformers/GitHubCommitTransformer.ts
Normal file
184
fluxer_api/src/webhook/transformers/GitHubCommitTransformer.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {parseString} from '~/utils/StringUtils';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transformCommitComment = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.repository || body.action !== 'created' || !body.comment) {
|
||||
return null;
|
||||
}
|
||||
const comment = body.comment;
|
||||
if (!comment.commit_id) {
|
||||
return null;
|
||||
}
|
||||
const commitId = comment.commit_id.substring(0, 7);
|
||||
return {
|
||||
url: comment.html_url,
|
||||
title: parseString(`[${body.repository.full_name}] New comment on commit \`${commitId}\``, 70),
|
||||
description: parseString(comment.body, 350),
|
||||
author: {
|
||||
name: comment.user.login,
|
||||
url: comment.user.html_url,
|
||||
icon_url: comment.user.avatar_url,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformCreate = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.ref && body.ref_type && body.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const ref = body.ref;
|
||||
const refType = body.ref_type;
|
||||
|
||||
let title: string;
|
||||
if (refType === 'branch') {
|
||||
title = `[${repoName}] New branch created: ${ref}`;
|
||||
} else if (refType === 'tag') {
|
||||
title = `[${repoName}] New tag created: ${ref}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformDelete = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.ref && body.ref_type && body.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const ref = body.ref;
|
||||
const refType = body.ref_type;
|
||||
|
||||
let title: string;
|
||||
if (refType === 'branch') {
|
||||
title = `[${repoName}] Branch deleted: ${ref}`;
|
||||
} else if (refType === 'tag') {
|
||||
title = `[${repoName}] Tag deleted: ${ref}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPush = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.repository && body.ref)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.name;
|
||||
const ref = body.ref;
|
||||
|
||||
let refName: string;
|
||||
let refType: string;
|
||||
if (ref.startsWith('refs/heads/')) {
|
||||
refName = ref.replace('refs/heads/', '');
|
||||
refType = 'Branch';
|
||||
} else if (ref.startsWith('refs/tags/')) {
|
||||
refName = ref.replace('refs/tags/', '');
|
||||
refType = 'Tag';
|
||||
} else {
|
||||
refName = ref;
|
||||
refType = 'Ref';
|
||||
}
|
||||
|
||||
if (body.forced) {
|
||||
if (!(body.head_commit && body.compare)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shortAfterCommitId = body.head_commit.id.substring(0, 7);
|
||||
|
||||
return {
|
||||
url: body.compare,
|
||||
title: parseString(`[${repoName}] ${refType} ${refName} was force-pushed to \`${shortAfterCommitId}\``, 70),
|
||||
color: 0xfcbd1f,
|
||||
description: `[Compare changes](${body.compare})`,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!body.commits || body.commits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commitDescriptions = body.commits
|
||||
.map((commit) => {
|
||||
const shortCommitId = commit.id.substring(0, 7);
|
||||
const commitMessage = commit.message.replace(
|
||||
/This reverts commit (\w{40})\./g,
|
||||
(_, hash) => `This reverts commit [\`${hash.substring(0, 7)}\`](${body.repository?.html_url}/commit/${hash}).`,
|
||||
);
|
||||
return `[\`${shortCommitId}\`](${commit.url}) ${commitMessage} - ${commit.author.name}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
if (!body.compare) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: body.compare,
|
||||
title: parseString(
|
||||
`[${repoName}:${refName}] ${body.commits.length} new commit${body.commits.length > 1 ? 's' : ''}`,
|
||||
256,
|
||||
),
|
||||
color: 0x7289da,
|
||||
description: parseString(commitDescriptions, 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
104
fluxer_api/src/webhook/transformers/GitHubIssueTransformer.ts
Normal file
104
fluxer_api/src/webhook/transformers/GitHubIssueTransformer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {parseString} from '~/utils/StringUtils';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transformIssue = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.issue && body.action && body.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.issue.user.avatar_url;
|
||||
const authorName = body.issue.user.login;
|
||||
const authorUrl = body.issue.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const issueNumber = body.issue.number;
|
||||
const issueTitle = body.issue.title;
|
||||
const issueUrl = body.issue.html_url;
|
||||
const issueDescription = body.issue.body || '';
|
||||
|
||||
let title: string;
|
||||
let color: number;
|
||||
|
||||
switch (body.action) {
|
||||
case 'opened': {
|
||||
title = `[${repoName}] Issue opened: #${issueNumber} ${issueTitle}`;
|
||||
color = 0xeb4841;
|
||||
break;
|
||||
}
|
||||
case 'closed': {
|
||||
title = `[${repoName}] Issue closed: #${issueNumber} ${issueTitle}`;
|
||||
color = 0x000000;
|
||||
break;
|
||||
}
|
||||
case 'reopened': {
|
||||
title = `[${repoName}] Issue reopened: #${issueNumber} ${issueTitle}`;
|
||||
color = 0xfcbd1f;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: issueUrl,
|
||||
color,
|
||||
description: body.action === 'opened' ? parseString(issueDescription, 350) : undefined,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformIssueComment = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.comment || body.action !== 'created' || !body.issue || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.comment.user.avatar_url;
|
||||
const authorName = body.comment.user.login;
|
||||
const authorUrl = body.comment.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const issueNumber = body.issue.number;
|
||||
const issueTitle = body.issue.title;
|
||||
const commentUrl = body.comment.html_url;
|
||||
const commentBody = body.comment.body;
|
||||
const isPullRequest = body.pull_request != null;
|
||||
|
||||
const titlePrefix = isPullRequest ? 'pull request' : 'issue';
|
||||
const title = `[${repoName}] New comment on ${titlePrefix} #${issueNumber}: ${issueTitle}`;
|
||||
const color = 0xc00a7f;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: commentUrl,
|
||||
color,
|
||||
description: parseString(commentBody, 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {parseString} from '~/utils/StringUtils';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transformPullRequest = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.pull_request && body.action && body.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.pull_request.user.avatar_url;
|
||||
const authorName = body.pull_request.user.login;
|
||||
const authorUrl = body.pull_request.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const prNumber = body.pull_request.number;
|
||||
const prTitle = body.pull_request.title;
|
||||
const prUrl = body.pull_request.html_url;
|
||||
const prDescription = body.pull_request.body || '';
|
||||
|
||||
let title: string;
|
||||
let color: number;
|
||||
|
||||
switch (body.action) {
|
||||
case 'opened': {
|
||||
title = `[${repoName}] Pull request opened: #${prNumber} ${prTitle}`;
|
||||
color = 0x098efc;
|
||||
break;
|
||||
}
|
||||
case 'closed': {
|
||||
title = `[${repoName}] Pull request closed: #${prNumber} ${prTitle}`;
|
||||
color = 0x000000;
|
||||
break;
|
||||
}
|
||||
case 'reopened': {
|
||||
title = `[${repoName}] Pull request reopened: #${prNumber} ${prTitle}`;
|
||||
color = 0xfcbd1f;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: prUrl,
|
||||
color,
|
||||
description: body.action === 'opened' ? parseString(prDescription, 350) : undefined,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPullRequestReview = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.review || body.action !== 'submitted' || !body.pull_request || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.review.user.avatar_url;
|
||||
const authorName = body.review.user.login;
|
||||
const authorUrl = body.review.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const prNumber = body.pull_request.number;
|
||||
const prTitle = body.pull_request.title;
|
||||
const reviewUrl = body.review.html_url;
|
||||
const reviewBody = body.review.body || 'No description provided';
|
||||
|
||||
const title = `[${repoName}] Pull request review submitted: #${prNumber} ${prTitle}`;
|
||||
const color = 0x000000;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: reviewUrl,
|
||||
color,
|
||||
description: parseString(reviewBody, 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPullRequestReviewComment = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.comment || body.action !== 'created' || !body.pull_request || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.comment.user.avatar_url;
|
||||
const authorName = body.comment.user.login;
|
||||
const authorUrl = body.comment.user.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const prNumber = body.pull_request.number;
|
||||
const prTitle = body.pull_request.title;
|
||||
const commentUrl = body.comment.html_url;
|
||||
const commentBody = body.comment.body || 'No description provided';
|
||||
|
||||
const title = `[${repoName}] New review comment on pull request #${prNumber}: ${prTitle}`;
|
||||
const color = 0xc00a7f;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: commentUrl,
|
||||
color,
|
||||
description: parseString(commentBody, 350),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {parseString} from '~/utils/StringUtils';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transformFork = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.repository && body.forkee && body.sender)) {
|
||||
return null;
|
||||
}
|
||||
const forkee = body.forkee;
|
||||
const sender = body.sender;
|
||||
return {
|
||||
url: forkee.html_url,
|
||||
title: parseString(`[${body.repository.full_name}] Fork created: ${forkee.full_name}`, 70),
|
||||
author: {
|
||||
name: sender.login,
|
||||
url: sender.html_url,
|
||||
icon_url: sender.avatar_url,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformMember = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (body.action !== 'added' || !body.member || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const memberName = body.member.login;
|
||||
const memberUrl = body.member.html_url;
|
||||
|
||||
const title = `[${repoName}] New collaborator added: ${memberName}`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: memberUrl,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformPublic = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
|
||||
const title = `[${repoName}] Now open sourced!`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformRelease = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (!(body.action === 'published' && body.release && body.repository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const releaseTag = body.release.tag_name;
|
||||
const releaseUrl = body.release.html_url;
|
||||
|
||||
return {
|
||||
title: parseString(`[${repoName}] New release published: ${releaseTag}`, 70),
|
||||
url: releaseUrl,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformWatch = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (body.action !== 'started' || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const repoUrl = body.repository.html_url;
|
||||
|
||||
const title = `[${repoName}] New star added`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: repoUrl,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transformRepository = async (body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
if (body.action !== 'created' || !body.repository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorIconUrl = body.sender.avatar_url;
|
||||
const authorName = body.sender.login;
|
||||
const authorUrl = body.sender.html_url;
|
||||
const repoName = body.repository.full_name;
|
||||
const repoUrl = body.repository.html_url;
|
||||
|
||||
const title = `[${repoName}] Repository created`;
|
||||
|
||||
return {
|
||||
title: parseString(title, 70),
|
||||
url: repoUrl,
|
||||
color: 0x2cbe4e,
|
||||
author: {
|
||||
name: authorName,
|
||||
url: authorUrl,
|
||||
icon_url: authorIconUrl,
|
||||
},
|
||||
};
|
||||
};
|
||||
89
fluxer_api/src/webhook/transformers/GitHubTransformer.ts
Normal file
89
fluxer_api/src/webhook/transformers/GitHubTransformer.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {RichEmbedRequest} from '~/channel/ChannelModel';
|
||||
import {
|
||||
transformCheckRun,
|
||||
transformCheckSuite,
|
||||
transformDiscussion,
|
||||
transformDiscussionComment,
|
||||
} from './GitHubCheckTransformer';
|
||||
import {transformCommitComment, transformCreate, transformDelete, transformPush} from './GitHubCommitTransformer';
|
||||
import {transformIssue, transformIssueComment} from './GitHubIssueTransformer';
|
||||
import {
|
||||
transformPullRequest,
|
||||
transformPullRequestReview,
|
||||
transformPullRequestReviewComment,
|
||||
} from './GitHubPullRequestTransformer';
|
||||
import {
|
||||
transformFork,
|
||||
transformMember,
|
||||
transformPublic,
|
||||
transformRelease,
|
||||
transformRepository,
|
||||
transformWatch,
|
||||
} from './GitHubRepositoryTransformer';
|
||||
import type {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export {GitHubWebhook} from './GitHubTypes';
|
||||
|
||||
export const transform = async (event: string, body: GitHubWebhook): Promise<RichEmbedRequest | null> => {
|
||||
switch (event) {
|
||||
case 'commit_comment':
|
||||
return transformCommitComment(body);
|
||||
case 'create':
|
||||
return transformCreate(body);
|
||||
case 'delete':
|
||||
return transformDelete(body);
|
||||
case 'fork':
|
||||
return transformFork(body);
|
||||
case 'issue_comment':
|
||||
return transformIssueComment(body);
|
||||
case 'issues':
|
||||
return transformIssue(body);
|
||||
case 'member':
|
||||
return transformMember(body);
|
||||
case 'public':
|
||||
return transformPublic(body);
|
||||
case 'pull_request':
|
||||
return transformPullRequest(body);
|
||||
case 'pull_request_review':
|
||||
return transformPullRequestReview(body);
|
||||
case 'pull_request_review_comment':
|
||||
return transformPullRequestReviewComment(body);
|
||||
case 'push':
|
||||
return transformPush(body);
|
||||
case 'release':
|
||||
return transformRelease(body);
|
||||
case 'watch':
|
||||
return transformWatch(body);
|
||||
case 'check_run':
|
||||
return transformCheckRun(body);
|
||||
case 'check_suite':
|
||||
return transformCheckSuite(body);
|
||||
case 'discussion':
|
||||
return transformDiscussion(body);
|
||||
case 'discussion_comment':
|
||||
return transformDiscussionComment(body);
|
||||
case 'repository':
|
||||
return transformRepository(body);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
142
fluxer_api/src/webhook/transformers/GitHubTypes.ts
Normal file
142
fluxer_api/src/webhook/transformers/GitHubTypes.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createStringType, Int32Type, URLType, z} from '~/Schema';
|
||||
|
||||
const GitHubUser = z.object({
|
||||
id: Int32Type,
|
||||
login: createStringType(0, 152133),
|
||||
html_url: URLType,
|
||||
avatar_url: URLType,
|
||||
});
|
||||
|
||||
const GitHubCheckPullRequest = z.object({
|
||||
number: Int32Type,
|
||||
});
|
||||
|
||||
const GitHubCheckApp = z.object({
|
||||
name: createStringType(0, 152133),
|
||||
});
|
||||
|
||||
const GitHubCheckSuite = z.object({
|
||||
conclusion: createStringType(0, 152133).nullish(),
|
||||
head_branch: createStringType(0, 152133).nullish(),
|
||||
head_sha: createStringType(0, 152133),
|
||||
pull_requests: z.array(GitHubCheckPullRequest).nullish(),
|
||||
app: GitHubCheckApp,
|
||||
});
|
||||
|
||||
const GitHubCheckRunOutput = z.object({
|
||||
title: createStringType(0, 152133).nullish(),
|
||||
summary: createStringType(0, 152133).nullish(),
|
||||
});
|
||||
|
||||
const GitHubAuthor = z.object({
|
||||
username: createStringType(0, 152133).nullish(),
|
||||
name: createStringType(0, 152133),
|
||||
});
|
||||
|
||||
const GitHubCheckRun = z.object({
|
||||
conclusion: createStringType(0, 152133).nullish(),
|
||||
name: createStringType(0, 152133),
|
||||
html_url: URLType,
|
||||
check_suite: GitHubCheckSuite,
|
||||
details_url: URLType.nullish(),
|
||||
output: GitHubCheckRunOutput.nullish(),
|
||||
pull_requests: z.array(GitHubCheckPullRequest).nullish(),
|
||||
});
|
||||
|
||||
const GitHubComment = z.object({
|
||||
id: Int32Type,
|
||||
html_url: URLType,
|
||||
user: GitHubUser,
|
||||
commit_id: createStringType(0, 152133).nullish(),
|
||||
body: createStringType(0, 152133),
|
||||
});
|
||||
|
||||
const GitHubCommit = z.object({
|
||||
id: createStringType(0, 152133),
|
||||
url: URLType,
|
||||
message: createStringType(0, 152133),
|
||||
author: GitHubAuthor,
|
||||
});
|
||||
|
||||
const GitHubDiscussion = z.object({
|
||||
title: createStringType(0, 152133),
|
||||
number: Int32Type,
|
||||
html_url: URLType,
|
||||
answer_html_url: URLType.nullish(),
|
||||
body: createStringType(0, 152133).nullish(),
|
||||
user: GitHubUser,
|
||||
});
|
||||
|
||||
const GitHubIssue = z.object({
|
||||
id: Int32Type,
|
||||
number: Int32Type,
|
||||
html_url: URLType,
|
||||
user: GitHubUser,
|
||||
title: createStringType(0, 152133),
|
||||
body: createStringType(0, 152133).nullish(),
|
||||
});
|
||||
|
||||
const GitHubRelease = z.object({
|
||||
id: Int32Type,
|
||||
tag_name: createStringType(0, 152133),
|
||||
html_url: URLType,
|
||||
body: createStringType(0, 152133).nullish(),
|
||||
});
|
||||
|
||||
const GitHubService = z.object({
|
||||
id: Int32Type,
|
||||
html_url: URLType,
|
||||
name: createStringType(0, 152133),
|
||||
full_name: createStringType(0, 152133),
|
||||
});
|
||||
|
||||
const GitHubReview = z.object({
|
||||
user: GitHubUser,
|
||||
body: createStringType(0, 152133).nullish(),
|
||||
html_url: URLType,
|
||||
state: createStringType(0, 152133),
|
||||
});
|
||||
|
||||
export const GitHubWebhook = z.object({
|
||||
action: createStringType(0, 152133).nullish(),
|
||||
answer: GitHubComment.nullish(),
|
||||
check_run: GitHubCheckRun.nullish(),
|
||||
check_suite: GitHubCheckSuite.nullish(),
|
||||
comment: GitHubComment.nullish(),
|
||||
commits: z.array(GitHubCommit).nullish(),
|
||||
compare: URLType.nullish(),
|
||||
discussion: GitHubDiscussion.nullish(),
|
||||
forced: z.boolean().nullish(),
|
||||
forkee: GitHubService.nullish(),
|
||||
head_commit: GitHubCommit.nullish(),
|
||||
issue: GitHubIssue.nullish(),
|
||||
member: GitHubUser.nullish(),
|
||||
pull_request: GitHubIssue.nullish(),
|
||||
ref_type: createStringType(0, 152133).nullish(),
|
||||
ref: createStringType(0, 152133).nullish(),
|
||||
release: GitHubRelease.nullish(),
|
||||
repository: GitHubService.nullish(),
|
||||
review: GitHubReview.nullish(),
|
||||
sender: GitHubUser,
|
||||
});
|
||||
|
||||
export type GitHubWebhook = z.infer<typeof GitHubWebhook>;
|
||||
Reference in New Issue
Block a user