initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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>;
}

View 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),
});
}
}
}

View 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);
}
});
};

View 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})),
);
}

View 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;
}
}

View 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',
);
}
}
}

View 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,
},
};
};

View 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,
},
};
};

View 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,
},
};
};

View File

@@ -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,
},
};
};

View File

@@ -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,
},
};
};

View 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;
}
};

View 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>;