From 2e14b5e4ed37b328317a6a8a1051c2cde8c93325 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 13 Jun 2026 13:35:11 +0700 Subject: [PATCH] chore(auto): task completed - unknown --- .../0010_add_reactions_and_edit_history.sql | 30 +++ .../drizzle/migrations/meta/_journal.json | 7 + services/discord-gateway/src/app/bootstrap.ts | 27 +++ services/discord-gateway/src/app/shutdown.ts | 6 + .../channel-topic/channelTopicCapture.ts | 7 +- .../event-broadcaster/eventBroadcaster.ts | 100 ++++++++++ .../modules/event-broadcaster/eventTypes.ts | 20 ++ .../src/modules/gateway-metrics/metrics.ts | 7 +- .../guild-member-events/memberEvents.ts | 48 +++-- .../modules/message-capture/messageCapture.ts | 23 ++- .../modules/message-capture/messageStore.ts | 9 +- .../modules/message-capture/messages.crud.ts | 16 +- .../modules/message-capture/messages.db.ts | 6 +- .../reaction-tracking/reactionCapture.ts | 171 +++++++++++------- .../modules/thread-tracking/threadCapture.ts | 38 ++-- .../modules/user-presence/presenceCapture.ts | 74 ++++---- .../modules/voice-recording/mediaSource.ts | 8 +- .../src/modules/voice-recording/muxer.ts | 24 +-- .../modules/webhook-notifications/index.ts | 2 +- .../webhook-notifications/webhookNotifier.ts | 18 +- .../src/shared/config/config.ts | 4 + .../src/shared/database/schema.ts | 43 +++++ 22 files changed, 516 insertions(+), 172 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0010_add_reactions_and_edit_history.sql diff --git a/services/discord-gateway/drizzle/migrations/0010_add_reactions_and_edit_history.sql b/services/discord-gateway/drizzle/migrations/0010_add_reactions_and_edit_history.sql new file mode 100644 index 0000000..31ed544 --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0010_add_reactions_and_edit_history.sql @@ -0,0 +1,30 @@ +-- Migration 0010: Add message_reactions and message_edits tables +-- Generated from schema: pgReactionsTable, pgMessageEditsTable + +CREATE TABLE IF NOT EXISTS "message_reactions" ( + "id" text PRIMARY KEY, + "message_id" text NOT NULL, + "channel_id" text NOT NULL, + "guild_id" text NOT NULL, + "user_id" text NOT NULL, + "username" text NOT NULL, + "emoji" text NOT NULL, + "emoji_id" text, + "animated" boolean NOT NULL DEFAULT false, + "reaction_type" text NOT NULL CHECK ("reaction_type" IN ('add', 'remove')), + "created_at" bigint NOT NULL +); + +CREATE INDEX IF NOT EXISTS "idx_reactions_message_id" ON "message_reactions" ("message_id"); +CREATE INDEX IF NOT EXISTS "idx_reactions_user_id" ON "message_reactions" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_reactions_guild_created" ON "message_reactions" ("guild_id", "created_at"); + +CREATE TABLE IF NOT EXISTS "message_edits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "message_id" text NOT NULL, + "old_content" text NOT NULL, + "edited_at" bigint NOT NULL +); + +CREATE INDEX IF NOT EXISTS "idx_message_edits_message_id" ON "message_edits" ("message_id"); +CREATE INDEX IF NOT EXISTS "idx_message_edits_edited_at" ON "message_edits" ("edited_at"); diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index 6508081..366ae1e 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1781316000000, "tag": "0009_add_reply_forward_crosspost", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1781390000000, + "tag": "0010_add_reactions_and_edit_history", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 2dd5895..6cb5539 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -4,16 +4,29 @@ import { Client } from "discord.js-selfbot-v13"; import { inArray, lt } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js"; +import { registerChannelTopicCapture } from "../modules/channel-topic/index.js"; import { CommandHandler } from "../modules/command-handler/commandHandler.js"; import { EventBroadcaster, RedisEventPublisher, } from "../modules/event-broadcaster/index.js"; +import { + startMetricsServer, + stopMetricsServer, +} from "../modules/gateway-metrics/index.js"; +import { registerGuildMemberEvents } from "../modules/guild-member-events/index.js"; import { registerMessageCapture, setEventBroadcaster as setMessageCaptureEventBroadcaster, } from "../modules/message-capture/messageCapture.js"; import { getExpiredMessages } from "../modules/message-capture/messageStore.js"; +import { registerReactionCapture } from "../modules/reaction-tracking/index.js"; +import { registerThreadCapture } from "../modules/thread-tracking/index.js"; +import { registerPresenceCapture } from "../modules/user-presence/index.js"; +import { + startMuxerWorker, + stopMuxerWorker, +} from "../modules/voice-recording/muxer.js"; import { setEventBroadcaster as setRecorderEventBroadcaster } from "../modules/voice-recording/recorder.js"; import { VoiceController } from "../modules/voice-recording/voiceController.js"; import { config } from "../shared/config/config.js"; @@ -212,6 +225,7 @@ export async function initializeDiscordGateway() { client, eventBroadcaster, commandHandler, + stopMetricsServer, }); try { @@ -252,6 +266,16 @@ export async function initializeDiscordGateway() { registerMessageCapture(client); startPendingAIAnalysisWorker(client, eventBroadcaster); + // Register new event captures + registerReactionCapture(client, eventBroadcaster); + registerThreadCapture(client, eventBroadcaster); + registerPresenceCapture(client, eventBroadcaster); + registerChannelTopicCapture(client, eventBroadcaster); + registerGuildMemberEvents(client, eventBroadcaster); + + // Start background workers + startMuxerWorker(); + // Start command handler after Discord is ready commandHandler.start(client, voiceController); logger.info("Command handler started"); @@ -282,6 +306,9 @@ export async function initializeDiscordGateway() { gracefulShutdown("unhandledRejection"); }); + // Start metrics server + startMetricsServer(); + logger.info("Calling Discord client.login"); client .login(token) diff --git a/services/discord-gateway/src/app/shutdown.ts b/services/discord-gateway/src/app/shutdown.ts index 1d94e1f..67df418 100644 --- a/services/discord-gateway/src/app/shutdown.ts +++ b/services/discord-gateway/src/app/shutdown.ts @@ -2,11 +2,14 @@ import type { createChildLogger } from "@bete/shared/logger"; import type { Client } from "discord.js-selfbot-v13"; import type { CommandHandler } from "../modules/command-handler/commandHandler.js"; import type { EventBroadcaster } from "../modules/event-broadcaster/index.js"; +import { stopMetricsServer } from "../modules/gateway-metrics/index.js"; +import { stopMuxerWorker } from "../modules/voice-recording/muxer.js"; import type { VoiceController } from "../modules/voice-recording/voiceController.js"; import type { closeDatabase } from "../shared/database/drizzle.js"; type Logger = ReturnType; type CloseDatabase = typeof closeDatabase; +type StopMetricsServer = typeof stopMetricsServer; export interface GracefulShutdownOptions { logger: Logger; @@ -15,6 +18,7 @@ export interface GracefulShutdownOptions { client: Client; eventBroadcaster: EventBroadcaster; commandHandler: CommandHandler; + stopMetricsServer?: StopMetricsServer; } export function createGracefulShutdown(options: GracefulShutdownOptions) { @@ -30,6 +34,8 @@ export function createGracefulShutdown(options: GracefulShutdownOptions) { options.logger.info({ signal }, "Graceful shutdown initiated"); try { + options.stopMetricsServer?.(); + stopMuxerWorker(); options.logger.info("Closing database..."); await options.closeDatabase(); options.logger.info("Database closed"); diff --git a/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts b/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts index 62169b1..d5746ea 100644 --- a/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts +++ b/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts @@ -7,8 +7,11 @@ const logger = createChildLogger("channel-topic"); function isMonitoredGuild(guildId: string | null | undefined): boolean { if (!guildId) return false; - const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as string[] | undefined; - if (!guildIds || guildIds.length === 0) return config.MONITOR_GUILD_ID === guildId; + const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as + | string[] + | undefined; + if (!guildIds || guildIds.length === 0) + return config.MONITOR_GUILD_ID === guildId; return guildIds.includes(guildId); } diff --git a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts index a1e634f..ea8d15b 100644 --- a/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts +++ b/services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts @@ -184,6 +184,106 @@ export class EventBroadcaster { }); } + async reactionAdded(data: unknown): Promise { + this.logger.debug({ data }, "Publishing reaction_added"); + await this.publisher.publish(EventChannels.REACTION_ADDED, { + type: "reaction_added", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async reactionRemoved(data: unknown): Promise { + this.logger.debug({ data }, "Publishing reaction_removed"); + await this.publisher.publish(EventChannels.REACTION_REMOVED, { + type: "reaction_removed", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async threadCreated(data: unknown): Promise { + this.logger.debug({ data }, "Publishing thread_created"); + await this.publisher.publish(EventChannels.THREAD_CREATED, { + type: "thread_created", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async threadDeleted(data: unknown): Promise { + this.logger.debug({ data }, "Publishing thread_deleted"); + await this.publisher.publish(EventChannels.THREAD_DELETED, { + type: "thread_deleted", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async threadUpdated(data: unknown): Promise { + this.logger.debug({ data }, "Publishing thread_updated"); + await this.publisher.publish(EventChannels.THREAD_UPDATED, { + type: "thread_updated", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async channelTopicUpdated(data: unknown): Promise { + this.logger.debug({ data }, "Publishing channel_topic_updated"); + await this.publisher.publish(EventChannels.CHANNEL_TOPIC_UPDATED, { + type: "channel_topic_updated", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async presenceUpdated(data: unknown): Promise { + this.logger.debug({ data }, "Publishing presence_updated"); + await this.publisher.publish(EventChannels.PRESENCE_UPDATED, { + type: "presence_updated", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async guildMemberAdded(data: unknown): Promise { + this.logger.debug({ data }, "Publishing guild_member_added"); + await this.publisher.publish(EventChannels.GUILD_MEMBER_ADDED, { + type: "guild_member_added", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async guildMemberRemoved(data: unknown): Promise { + this.logger.debug({ data }, "Publishing guild_member_removed"); + await this.publisher.publish(EventChannels.GUILD_MEMBER_REMOVED, { + type: "guild_member_removed", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + + async voiceAnalyzed(data: unknown): Promise { + this.logger.debug({ data }, "Publishing voice_analyzed"); + await this.publisher.publish(EventChannels.VOICE_ANALYZED, { + type: "voice_analyzed", + data, + timestamp: Date.now(), + source: "discord-gateway", + }); + } + async analysisQueueStatus(data: unknown): Promise { this.logger.debug({ data }, "Publishing analysis_queue_status"); await this.publisher.publish(EventChannels.ANALYSIS_QUEUE_STATUS, { diff --git a/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts b/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts index 31266ba..adbeb4f 100644 --- a/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts +++ b/services/discord-gateway/src/modules/event-broadcaster/eventTypes.ts @@ -2,11 +2,21 @@ import { DISCORD_ANALYSIS_QUEUE_STATUS, DISCORD_ATTACHMENT_CREATED, DISCORD_ATTACHMENT_UPLOADED, + DISCORD_CHANNEL_TOPIC_UPDATED, + DISCORD_GUILD_MEMBER_ADDED, + DISCORD_GUILD_MEMBER_REMOVED, DISCORD_MESSAGE_ANALYZED, DISCORD_MESSAGE_CREATED, DISCORD_MESSAGE_DELETED, DISCORD_MESSAGE_UPDATED, + DISCORD_PRESENCE_UPDATED, + DISCORD_REACTION_ADDED, + DISCORD_REACTION_REMOVED, + DISCORD_THREAD_CREATED, + DISCORD_THREAD_DELETED, + DISCORD_THREAD_UPDATED, DISCORD_VOICE_ACTIVE_USER, + DISCORD_VOICE_ANALYZED, DISCORD_VOICE_PCM, DISCORD_VOICE_STARTED, DISCORD_VOICE_STOPPED, @@ -30,6 +40,16 @@ export const EventChannels = { VOICE_ACTIVE_USER: DISCORD_VOICE_ACTIVE_USER, // Active speaker state updates VOICE_PCM: DISCORD_VOICE_PCM, // Live PCM audio data stream ANALYSIS_QUEUE_STATUS: DISCORD_ANALYSIS_QUEUE_STATUS, + REACTION_ADDED: DISCORD_REACTION_ADDED, + REACTION_REMOVED: DISCORD_REACTION_REMOVED, + THREAD_CREATED: DISCORD_THREAD_CREATED, + THREAD_DELETED: DISCORD_THREAD_DELETED, + THREAD_UPDATED: DISCORD_THREAD_UPDATED, + CHANNEL_TOPIC_UPDATED: DISCORD_CHANNEL_TOPIC_UPDATED, + PRESENCE_UPDATED: DISCORD_PRESENCE_UPDATED, + GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED, + GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED, + VOICE_ANALYZED: DISCORD_VOICE_ANALYZED, } as const; export type EventChannelType = diff --git a/services/discord-gateway/src/modules/gateway-metrics/metrics.ts b/services/discord-gateway/src/modules/gateway-metrics/metrics.ts index 58f1a89..6e3b072 100644 --- a/services/discord-gateway/src/modules/gateway-metrics/metrics.ts +++ b/services/discord-gateway/src/modules/gateway-metrics/metrics.ts @@ -1,5 +1,6 @@ import http from "node:http"; import { createChildLogger } from "@bete/shared/logger"; +import type { AppConfig as GatewayConfig } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js"; const logger = createChildLogger("gateway-metrics"); @@ -73,7 +74,9 @@ function formatMetrics(): string { const lines: string[] = []; for (const [fullName, metric] of metrics) { - const baseName = fullName.includes("{") ? fullName.slice(0, fullName.indexOf("{")) : fullName; + const baseName = fullName.includes("{") + ? fullName.slice(0, fullName.indexOf("{")) + : fullName; lines.push(`# HELP ${baseName} ${metric.help}`); lines.push(`# TYPE ${baseName} ${metric.type}`); lines.push(`${fullName} ${metric.value}`); @@ -85,7 +88,7 @@ function formatMetrics(): string { export function startMetricsServer(): void { if (server) return; - const port = config.METRICS_PORT; + const port = (config as any).METRICS_PORT ?? 9090; logger.info({ port }, "Starting metrics HTTP server"); server = http.createServer((req, res) => { diff --git a/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts b/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts index 73c26fc..6bc9152 100644 --- a/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts +++ b/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts @@ -1,5 +1,9 @@ import { createChildLogger } from "@bete/shared/logger"; -import type { Client, GuildMember } from "discord.js-selfbot-v13"; +import type { + Client, + GuildMember, + PartialGuildMember, +} from "discord.js-selfbot-v13"; import { config } from "../../shared/config/config.js"; import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; @@ -7,8 +11,11 @@ const logger = createChildLogger("guild-member-events"); function isMonitoredGuild(guildId: string | null | undefined): boolean { if (!guildId) return false; - const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as string[] | undefined; - if (!guildIds || guildIds.length === 0) return config.MONITOR_GUILD_ID === guildId; + const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as + | string[] + | undefined; + if (!guildIds || guildIds.length === 0) + return config.MONITOR_GUILD_ID === guildId; return guildIds.includes(guildId); } @@ -38,22 +45,25 @@ export function registerGuildMemberEvents( await eventBroadcaster.guildMemberAdded(data).catch(() => {}); }); - client.on("guildMemberRemove", async (member: GuildMember) => { - if (!isMonitoredGuild(member.guild.id)) return; + client.on( + "guildMemberRemove", + async (member: GuildMember | PartialGuildMember) => { + if (!isMonitoredGuild(member.guild.id)) return; - const data = { - user_id: member.id, - username: member.user?.username ?? "unknown", - tag: member.user?.tag ?? null, - guild_id: member.guild.id, - member_count: member.guild.memberCount, - removed_at: Date.now(), - }; + const data = { + user_id: member.id, + username: (member.user as any)?.username ?? "unknown", + tag: (member.user as any)?.tag ?? null, + guild_id: member.guild.id, + member_count: member.guild.memberCount, + removed_at: Date.now(), + }; - logger.info( - { userId: member.id, username: member.user?.username }, - "Guild member removed", - ); - await eventBroadcaster.guildMemberRemoved(data).catch(() => {}); - }); + logger.info( + { userId: member.id, username: member.user?.username }, + "Guild member removed", + ); + await eventBroadcaster.guildMemberRemoved(data).catch(() => {}); + }, + ); } diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts index c58f7c2..4256d82 100644 --- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts +++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts @@ -85,7 +85,10 @@ function getTextCaptureTargets(): TextCaptureTarget[] { const { EFFECTIVE_MONITOR_GUILD_IDS, TEXT_CHANNEL_ID } = config as any; if (EFFECTIVE_MONITOR_GUILD_IDS?.length) { if (TEXT_CHANNEL_ID) { - return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({ guildId, channelId: TEXT_CHANNEL_ID })); + return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({ + guildId, + channelId: TEXT_CHANNEL_ID, + })); } return EFFECTIVE_MONITOR_GUILD_IDS.map((guildId: string) => ({ guildId })); } @@ -99,7 +102,9 @@ function shouldCaptureForAnyTarget( targets: TextCaptureTarget[], ): boolean { if (targets.length === 0) return false; - return targets.some((target) => shouldCaptureMessageLocation(message, target)); + return targets.some((target) => + shouldCaptureMessageLocation(message, target), + ); } function requireMessageGuildId(message: Message): string { @@ -292,8 +297,7 @@ export function registerMessageCapture(client: Client): void { }); client.on("messageUpdate", async (_oldMessage, newMessage) => { - if (!shouldCaptureForAnyTarget(newMessage, targets)) - return; + if (!shouldCaptureForAnyTarget(newMessage, targets)) return; if (newMessage.author?.bot) return; if (isAgeRestrictedMessage(newMessage as Message)) return; @@ -321,9 +325,14 @@ export function registerMessageCapture(client: Client): void { // Save edit history snapshot before overwriting if (oldContent) { - insertMessageEdit(newMessage.id, oldContent, editedAt).catch((err: unknown) => { - logger.error({ messageId: newMessage.id, error: err }, "Failed to save edit history"); - }); + insertMessageEdit(newMessage.id, oldContent, editedAt).catch( + (err: unknown) => { + logger.error( + { messageId: newMessage.id, error: err }, + "Failed to save edit history", + ); + }, + ); } await updateMessageAsEdited( diff --git a/services/discord-gateway/src/modules/message-capture/messageStore.ts b/services/discord-gateway/src/modules/message-capture/messageStore.ts index d37af4b..a88179b 100644 --- a/services/discord-gateway/src/modules/message-capture/messageStore.ts +++ b/services/discord-gateway/src/modules/message-capture/messageStore.ts @@ -53,7 +53,11 @@ export class MessageStore { // ── Edit History ──────────────────────────────────────────────────────── - insertMessageEdit(messageId: string, oldContent: string, editedAt: number): Promise { + insertMessageEdit( + messageId: string, + oldContent: string, + editedAt: number, + ): Promise { return this.messages.insertMessageEdit(messageId, oldContent, editedAt); } @@ -313,7 +317,8 @@ export const insertMessageEdit = ( messageId: string, oldContent: string, editedAt: number, -): Promise => getInstance().insertMessageEdit(messageId, oldContent, editedAt); +): Promise => + getInstance().insertMessageEdit(messageId, oldContent, editedAt); // Messages export const insertMessage = (message: MessageRecord): Promise => diff --git a/services/discord-gateway/src/modules/message-capture/messages.crud.ts b/services/discord-gateway/src/modules/message-capture/messages.crud.ts index e1ba6e8..1b23762 100644 --- a/services/discord-gateway/src/modules/message-capture/messages.crud.ts +++ b/services/discord-gateway/src/modules/message-capture/messages.crud.ts @@ -2,7 +2,10 @@ import { createChildLogger, type Logger } from "@bete/shared/logger"; import { and, desc, eq, or, type SQL } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type * as schema from "../../shared/database/schema.js"; -import { messagesTable, messageEditsTable } from "../../shared/database/schema.js"; +import { + messageEditsTable, + messagesTable, +} from "../../shared/database/schema.js"; import type { MessageRecord } from "../message-capture/types.js"; // ─── Shared Helpers ────────────────────────────────────────────────────────── @@ -152,11 +155,18 @@ export class MessagesCrud { try { await this.db .insert(messageEditsTable) - .values({ message_id: messageId, old_content: oldContent, edited_at: editedAt }) + .values({ + message_id: messageId, + old_content: oldContent, + edited_at: editedAt, + }) .onConflictDoNothing(); } catch (error) { this.logger.error( - { messageId, error: error instanceof Error ? error.message : String(error) }, + { + messageId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to insert message edit", ); throw error; diff --git a/services/discord-gateway/src/modules/message-capture/messages.db.ts b/services/discord-gateway/src/modules/message-capture/messages.db.ts index 2b4ba2d..6666df2 100644 --- a/services/discord-gateway/src/modules/message-capture/messages.db.ts +++ b/services/discord-gateway/src/modules/message-capture/messages.db.ts @@ -128,7 +128,11 @@ export class MessagesDb { // ── Edit History ───────────────────────────────────────────────────────── - insertMessageEdit(messageId: string, oldContent: string, editedAt: number): Promise { + insertMessageEdit( + messageId: string, + oldContent: string, + editedAt: number, + ): Promise { return this.crud.insertMessageEdit(messageId, oldContent, editedAt); } diff --git a/services/discord-gateway/src/modules/reaction-tracking/reactionCapture.ts b/services/discord-gateway/src/modules/reaction-tracking/reactionCapture.ts index 6546f31..11feec0 100644 --- a/services/discord-gateway/src/modules/reaction-tracking/reactionCapture.ts +++ b/services/discord-gateway/src/modules/reaction-tracking/reactionCapture.ts @@ -1,8 +1,14 @@ import { createChildLogger } from "@bete/shared/logger"; -import type { Client, MessageReaction, User } from "discord.js-selfbot-v13"; +import type { + Client, + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from "discord.js-selfbot-v13"; +import { config } from "../../shared/config/config.js"; import { getDatabase } from "../../shared/database/drizzle.js"; import { reactionsTable } from "../../shared/database/schema.js"; -import { config } from "../../shared/config/config.js"; import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; const logger = createChildLogger("reaction-tracking"); @@ -11,12 +17,17 @@ const logger = createChildLogger("reaction-tracking"); function isMonitoredGuild(guildId: string | null | undefined): boolean { if (!guildId) return false; - const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as string[] | undefined; - if (!guildIds || guildIds.length === 0) return config.MONITOR_GUILD_ID === guildId; + const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as + | string[] + | undefined; + if (!guildIds || guildIds.length === 0) + return config.MONITOR_GUILD_ID === guildId; return guildIds.includes(guildId); } -function getEmojiIdentifier(reaction: MessageReaction): { +function getEmojiIdentifier( + reaction: MessageReaction | PartialMessageReaction, +): { emoji: string; emojiId: string | null; animated: boolean; @@ -39,8 +50,8 @@ function getEmojiIdentifier(reaction: MessageReaction): { // ─── Event Handlers ────────────────────────────────────────────────────── async function handleReactionAdd( - reaction: MessageReaction, - user: User, + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, ): Promise { const guildId = reaction.message.guildId; if (!isMonitoredGuild(guildId)) return; @@ -52,19 +63,22 @@ async function handleReactionAdd( try { const db = getDatabase(); - await (db as any).insert(reactionsTable).values({ - id, - message_id: reaction.message.id, - channel_id: reaction.message.channelId, - guild_id: guildId, - user_id: user.id, - username: user.username, - emoji, - emoji_id: emojiId, - animated, - reaction_type: "add", - created_at: now, - }).onConflictDoNothing(); + await (db as any) + .insert(reactionsTable) + .values({ + id, + message_id: reaction.message.id, + channel_id: reaction.message.channelId, + guild_id: guildId, + user_id: user.id, + username: user.username, + emoji, + emoji_id: emojiId, + animated, + reaction_type: "add", + created_at: now, + }) + .onConflictDoNothing(); logger.debug( { messageId: reaction.message.id, emoji, userId: user.id }, @@ -79,8 +93,8 @@ async function handleReactionAdd( } async function handleReactionRemove( - reaction: MessageReaction, - user: User, + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, ): Promise { const guildId = reaction.message.guildId; if (!isMonitoredGuild(guildId)) return; @@ -92,19 +106,22 @@ async function handleReactionRemove( try { const db = getDatabase(); - await (db as any).insert(reactionsTable).values({ - id, - message_id: reaction.message.id, - channel_id: reaction.message.channelId, - guild_id: guildId, - user_id: user.id, - username: user.username, - emoji, - emoji_id: emojiId, - animated, - reaction_type: "remove", - created_at: now, - }).onConflictDoNothing(); + await (db as any) + .insert(reactionsTable) + .values({ + id, + message_id: reaction.message.id, + channel_id: reaction.message.channelId, + guild_id: guildId, + user_id: user.id, + username: user.username, + emoji, + emoji_id: emojiId, + animated, + reaction_type: "remove", + created_at: now, + }) + .onConflictDoNothing(); logger.debug( { messageId: reaction.message.id, emoji, userId: user.id }, @@ -126,45 +143,61 @@ export function registerReactionCapture( ): void { logger.info("Registering reaction capture"); - client.on("messageReactionAdd", async (reaction, user) => { - await handleReactionAdd(reaction, user); + client.on( + "messageReactionAdd", + async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + await handleReactionAdd(reaction, user); - const guildId = reaction.message.guildId; - if (!isMonitoredGuild(guildId)) return; + const guildId = reaction.message.guildId; + if (!isMonitoredGuild(guildId)) return; - const { emoji, emojiId, animated } = getEmojiIdentifier(reaction); + const { emoji, emojiId, animated } = getEmojiIdentifier(reaction); - eventBroadcaster.reactionAdded({ - message_id: reaction.message.id, - channel_id: reaction.message.channelId, - guild_id: guildId, - user_id: user.id, - username: user.username, - emoji, - emoji_id: emojiId, - animated, - created_at: Date.now(), - }).catch(() => {}); - }); + eventBroadcaster + .reactionAdded({ + message_id: reaction.message.id, + channel_id: reaction.message.channelId, + guild_id: guildId, + user_id: user.id, + username: user.username, + emoji, + emoji_id: emojiId, + animated, + created_at: Date.now(), + }) + .catch(() => {}); + }, + ); - client.on("messageReactionRemove", async (reaction, user) => { - await handleReactionRemove(reaction, user); + client.on( + "messageReactionRemove", + async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + await handleReactionRemove(reaction, user); - const guildId = reaction.message.guildId; - if (!isMonitoredGuild(guildId)) return; + const guildId = reaction.message.guildId; + if (!isMonitoredGuild(guildId)) return; - const { emoji, emojiId, animated } = getEmojiIdentifier(reaction); + const { emoji, emojiId, animated } = getEmojiIdentifier(reaction); - eventBroadcaster.reactionRemoved({ - message_id: reaction.message.id, - channel_id: reaction.message.channelId, - guild_id: guildId, - user_id: user.id, - username: user.username, - emoji, - emoji_id: emojiId, - animated, - created_at: Date.now(), - }).catch(() => {}); - }); + eventBroadcaster + .reactionRemoved({ + message_id: reaction.message.id, + channel_id: reaction.message.channelId, + guild_id: guildId, + user_id: user.id, + username: user.username, + emoji, + emoji_id: emojiId, + animated, + created_at: Date.now(), + }) + .catch(() => {}); + }, + ); } diff --git a/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts b/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts index de90866..2dca10d 100644 --- a/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts +++ b/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts @@ -7,8 +7,11 @@ const logger = createChildLogger("thread-tracking"); function isMonitoredGuild(guildId: string | null | undefined): boolean { if (!guildId) return false; - const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as string[] | undefined; - if (!guildIds || guildIds.length === 0) return config.MONITOR_GUILD_ID === guildId; + const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as + | string[] + | undefined; + if (!guildIds || guildIds.length === 0) + return config.MONITOR_GUILD_ID === guildId; return guildIds.includes(guildId); } @@ -51,20 +54,23 @@ export function registerThreadCapture( await eventBroadcaster.threadDeleted(data).catch(() => {}); }); - client.on("threadUpdate", async (_oldThread: ThreadChannel, newThread: ThreadChannel) => { - if (!isMonitoredGuild(newThread.guildId)) return; + client.on( + "threadUpdate", + async (_oldThread: ThreadChannel, newThread: ThreadChannel) => { + if (!isMonitoredGuild(newThread.guildId)) return; - const data = { - id: newThread.id, - guild_id: newThread.guildId, - channel_id: newThread.parentId ?? newThread.guildId, - name: newThread.name, - archived: (newThread as any).archived ?? false, - rate_limit_per_user: (newThread as any).rateLimitPerUser ?? null, - updated_at: Date.now(), - }; + const data = { + id: newThread.id, + guild_id: newThread.guildId, + channel_id: newThread.parentId ?? newThread.guildId, + name: newThread.name, + archived: (newThread as any).archived ?? false, + rate_limit_per_user: (newThread as any).rateLimitPerUser ?? null, + updated_at: Date.now(), + }; - logger.debug({ threadId: newThread.id }, "Thread updated"); - await eventBroadcaster.threadUpdated(data).catch(() => {}); - }); + logger.debug({ threadId: newThread.id }, "Thread updated"); + await eventBroadcaster.threadUpdated(data).catch(() => {}); + }, + ); } diff --git a/services/discord-gateway/src/modules/user-presence/presenceCapture.ts b/services/discord-gateway/src/modules/user-presence/presenceCapture.ts index 2e203ba..437caba 100644 --- a/services/discord-gateway/src/modules/user-presence/presenceCapture.ts +++ b/services/discord-gateway/src/modules/user-presence/presenceCapture.ts @@ -11,19 +11,25 @@ const PRESENCE_COOLDOWN_MS = 30_000; function isMonitoredGuild(guildId: string | null | undefined): boolean { if (!guildId) return false; - const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as string[] | undefined; - if (!guildIds || guildIds.length === 0) return config.MONITOR_GUILD_ID === guildId; + const guildIds = (config as any).EFFECTIVE_MONITOR_GUILD_IDS as + | string[] + | undefined; + if (!guildIds || guildIds.length === 0) + return config.MONITOR_GUILD_ID === guildId; return guildIds.includes(guildId); } function getStatus(presence: Presence): string { if (!presence) return "offline"; const status = presence.status; - if (status === "online" || status === "idle" || status === "dnd") return status; + if (status === "online" || status === "idle" || status === "dnd") + return status; return "offline"; } -function getActivities(presence: Presence): Array<{ name: string; type: string }> { +function getActivities( + presence: Presence, +): Array<{ name: string; type: string }> { if (!presence?.activities) return []; return presence.activities.map((a) => ({ name: a.name ?? "unknown", @@ -47,38 +53,42 @@ export function registerPresenceCapture( ): void { logger.info("Registering presence capture"); - client.on("presenceUpdate", async (_oldPresence: Presence | null, newPresence: Presence) => { - if (!newPresence?.guildId) return; - if (!isMonitoredGuild(newPresence.guildId)) return; + client.on( + "presenceUpdate", + async (_oldPresence: Presence | null, newPresence: Presence) => { + const guildId = newPresence.guild?.id ?? null; + if (!guildId) return; + if (!isMonitoredGuild(guildId)) return; - const userId = newPresence.userId ?? newPresence.user?.id; - if (!userId) return; + const userId = newPresence.userId ?? newPresence.user?.id; + if (!userId) return; - // Cooldown check - const now = Date.now(); - const lastUpdate = presenceCooldowns.get(userId); - if (lastUpdate && now - lastUpdate < PRESENCE_COOLDOWN_MS) return; - presenceCooldowns.set(userId, now); + // Cooldown check + const now = Date.now(); + const lastUpdate = presenceCooldowns.get(userId); + if (lastUpdate && now - lastUpdate < PRESENCE_COOLDOWN_MS) return; + presenceCooldowns.set(userId, now); - const data = { - user_id: userId, - username: newPresence.user?.username ?? "unknown", - status: getStatus(newPresence), - activities: getActivities(newPresence), - client_status: getClientStatus(newPresence), - guild_id: newPresence.guildId, - last_changed: now, - }; + const data = { + user_id: userId, + username: newPresence.user?.username ?? "unknown", + status: getStatus(newPresence), + activities: getActivities(newPresence), + client_status: getClientStatus(newPresence), + guild_id: guildId, + last_changed: now, + }; - logger.debug({ userId, status: data.status }, "Presence updated"); - await eventBroadcaster.presenceUpdated(data).catch(() => {}); + logger.debug({ userId, status: data.status }, "Presence updated"); + await eventBroadcaster.presenceUpdated(data).catch(() => {}); - // Periodic cleanup of stale cooldown entries - if (presenceCooldowns.size > 1000) { - const threshold = now - PRESENCE_COOLDOWN_MS * 10; - for (const [uid, ts] of presenceCooldowns) { - if (ts < threshold) presenceCooldowns.delete(uid); + // Periodic cleanup of stale cooldown entries + if (presenceCooldowns.size > 1000) { + const threshold = now - PRESENCE_COOLDOWN_MS * 10; + for (const [uid, ts] of presenceCooldowns) { + if (ts < threshold) presenceCooldowns.delete(uid); + } } - } - }); + }, + ); } diff --git a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts index 7442d89..4bd2ced 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts @@ -213,8 +213,9 @@ export function resolveMediaUrl( } }); - // -- stderr (capture for diagnostics) ---------------------------------- + // -- stderr (capture for diagnostics, capped at 4KB) ---------------------------------- + const MAX_STDERR = 4096; if (proc.stderr) { proc.stderr.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString("utf8"); @@ -295,6 +296,7 @@ export async function extractMediaInfo(url: string): Promise { let stdoutBuf = ""; let stderrBuf = ""; + const MAX_STDERR = 4096; if (proc.stdout) { proc.stdout.on("data", (chunk: Buffer) => { @@ -304,7 +306,9 @@ export async function extractMediaInfo(url: string): Promise { if (proc.stderr) { proc.stderr.on("data", (chunk: Buffer) => { - stderrBuf += chunk.toString("utf8"); + if (stderrBuf.length < MAX_STDERR) { + stderrBuf += chunk.toString("utf8").slice(0, MAX_STDERR - stderrBuf.length); + } }); } diff --git a/services/discord-gateway/src/modules/voice-recording/muxer.ts b/services/discord-gateway/src/modules/voice-recording/muxer.ts index 8a0d6e3..1ba55c3 100644 --- a/services/discord-gateway/src/modules/voice-recording/muxer.ts +++ b/services/discord-gateway/src/modules/voice-recording/muxer.ts @@ -1,9 +1,10 @@ import { createChildLogger } from "@bete/shared/logger"; import { eq } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { getDatabase } from "../../shared/database/drizzle.js"; -import { muxerJobsTable } from "../../shared/database/schema.js"; import { config } from "../../shared/config/config.js"; +import { getDatabase } from "../../shared/database/drizzle.js"; +import type * as schema from "../../shared/database/schema.js"; +import { muxerJobsTable } from "../../shared/database/schema.js"; import { buildMuxFfmpegArgs, runFfmpeg } from "./ffmpegProcess.js"; const logger = createChildLogger("muxer"); @@ -59,10 +60,7 @@ export function startMuxerWorker(): void { pollTimer = setInterval(() => { processNextJobs().catch((err: unknown) => { - logger.error( - { error: String(err) }, - "Muxer worker tick failed", - ); + logger.error({ error: String(err) }, "Muxer worker tick failed"); }); }, 10_000); } @@ -126,7 +124,9 @@ async function processJob( const data = JSON.parse(job.data) as MuxerJobData; if (!data.inputs || data.inputs.length < 2) { - throw new Error(`Muxer job ${job.id} needs at least 2 inputs, got ${data.inputs?.length ?? 0}`); + throw new Error( + `Muxer job ${job.id} needs at least 2 inputs, got ${data.inputs?.length ?? 0}`, + ); } logger.info( @@ -156,10 +156,7 @@ async function processJob( .set({ status: "completed", updatedAt: Date.now() }) .where(eq(muxerJobsTable.id, job.id)); - logger.info( - { jobId: job.id, output: data.output }, - "Muxer job completed", - ); + logger.info({ jobId: job.id, output: data.output }, "Muxer job completed"); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); const newAttempts = job.attempts + 1; @@ -174,7 +171,10 @@ async function processJob( updatedAt: Date.now(), }) .where(eq(muxerJobsTable.id, job.id)); - logger.error({ jobId: job.id, error: errMsg }, "Muxer job failed permanently"); + logger.error( + { jobId: job.id, error: errMsg }, + "Muxer job failed permanently", + ); } else { await db .update(muxerJobsTable) diff --git a/services/discord-gateway/src/modules/webhook-notifications/index.ts b/services/discord-gateway/src/modules/webhook-notifications/index.ts index a1509f3..9d62b90 100644 --- a/services/discord-gateway/src/modules/webhook-notifications/index.ts +++ b/services/discord-gateway/src/modules/webhook-notifications/index.ts @@ -1,2 +1,2 @@ -export { triggerWebhook } from "./webhookNotifier.js"; export type { WebhookPayload } from "./webhookNotifier.js"; +export { triggerWebhook } from "./webhookNotifier.js"; diff --git a/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts b/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts index 936fdc6..90c282a 100644 --- a/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts +++ b/services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts @@ -1,4 +1,5 @@ import { createChildLogger } from "@bete/shared/logger"; +import type { AppConfig as GatewayConfig } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js"; const logger = createChildLogger("webhook-notifier"); @@ -29,11 +30,16 @@ export async function triggerWebhook( eventType: string, payload: WebhookPayload, ): Promise { - const urls = config.WEBHOOK_URLS; + const urls = (config as any).WEBHOOK_URLS as string[] | undefined; if (!urls || urls.length === 0) return; - const enabledEvents = config.WEBHOOK_EVENTS; - if (enabledEvents.length > 0 && !enabledEvents.includes(eventType)) return; + const enabledEvents = (config as any).WEBHOOK_EVENTS as string[] | undefined; + if ( + enabledEvents && + enabledEvents.length > 0 && + !enabledEvents.includes(eventType) + ) + return; const body = JSON.stringify({ ...payload, @@ -59,7 +65,11 @@ export async function triggerWebhook( // ─── Internal ──────────────────────────────────────────────────────────── -async function sendWebhook(url: string, body: string): Promise { +async function sendWebhook( + url: string | undefined, + body: string, +): Promise { + if (!url) return; let lastErr: Error | null = null; for (let attempt = 0; attempt < 3; attempt++) { try { diff --git a/services/discord-gateway/src/shared/config/config.ts b/services/discord-gateway/src/shared/config/config.ts index 3596692..c8ac6d6 100644 --- a/services/discord-gateway/src/shared/config/config.ts +++ b/services/discord-gateway/src/shared/config/config.ts @@ -6,6 +6,7 @@ import { loadConfig as sharedLoadConfig } from "@bete/shared/config"; export type AppConfig = SharedAppConfig & { EFFECTIVE_TEXT_GUILD_ID?: string; EFFECTIVE_VOICE_GUILD_ID?: string; + EFFECTIVE_MONITOR_GUILD_IDS: string[]; }; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { @@ -14,6 +15,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { ...parsed, EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID, + EFFECTIVE_MONITOR_GUILD_IDS: + (parsed as any).EFFECTIVE_MONITOR_GUILD_IDS ?? + (parsed.MONITOR_GUILD_ID ? [parsed.MONITOR_GUILD_ID] : []), }; } diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 525263c..48e4a5a 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -390,6 +390,47 @@ export const pgUserProfilesTable = pgTable( * Mascot Chat Messages Table (PostgreSQL) * Stores AI mascot chat conversation history */ +export const pgReactionsTable = pgTable( + "message_reactions", + { + id: pgText("id").primaryKey(), + message_id: pgText("message_id").notNull(), + channel_id: pgText("channel_id").notNull(), + guild_id: pgText("guild_id").notNull(), + user_id: pgText("user_id").notNull(), + username: pgText("username").notNull(), + emoji: pgText("emoji").notNull(), + emoji_id: pgText("emoji_id"), + animated: pgBoolean("animated").notNull().default(false), + reaction_type: pgText("reaction_type", { + enum: ["add", "remove"], + }).notNull(), + created_at: pgBigint("created_at", { mode: "number" }).notNull(), + }, + (table) => ({ + messageIdIdx: pgIndex("idx_reactions_message_id").on(table.message_id), + userIdIdx: pgIndex("idx_reactions_user_id").on(table.user_id), + guildCreatedIdx: pgIndex("idx_reactions_guild_created").on( + table.guild_id, + table.created_at, + ), + }), +); + +export const pgMessageEditsTable = pgTable( + "message_edits", + { + id: pgUuid("id").defaultRandom().primaryKey(), + message_id: pgText("message_id").notNull(), + old_content: pgText("old_content").notNull(), + edited_at: pgBigint("edited_at", { mode: "number" }).notNull(), + }, + (table) => ({ + messageIdIdx: pgIndex("idx_message_edits_message_id").on(table.message_id), + editedAtIdx: pgIndex("idx_message_edits_edited_at").on(table.edited_at), + }), +); + export const pgMascotChatMessagesTable = pgTable( "mascot_chat_messages", { @@ -428,6 +469,8 @@ export const correctedModerationsTable = pgCorrectedModerationsTable; export const userReputationsTable = pgUserReputationsTable; export const channelCulturesTable = pgChannelCulturesTable; export const userProfilesTable = pgUserProfilesTable; +export const reactionsTable = pgReactionsTable; +export const messageEditsTable = pgMessageEditsTable; export const mascotChatMessagesTable = pgMascotChatMessagesTable; // Export table types for use in queries