diff --git a/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts b/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts new file mode 100644 index 0000000..62169b1 --- /dev/null +++ b/services/discord-gateway/src/modules/channel-topic/channelTopicCapture.ts @@ -0,0 +1,49 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Client, TextChannel } from "discord.js-selfbot-v13"; +import { config } from "../../shared/config/config.js"; +import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; + +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; + return guildIds.includes(guildId); +} + +export function registerChannelTopicCapture( + client: Client, + eventBroadcaster: EventBroadcaster, +): void { + logger.info("Registering channel topic capture"); + + client.on("channelUpdate", async (oldChannel, newChannel) => { + // Only care about text channels + if (newChannel.type !== "GUILD_TEXT") return; + if (!isMonitoredGuild(newChannel.guildId)) return; + + const oldText = oldChannel as TextChannel; + const newText = newChannel as TextChannel; + + const oldTopic = oldText.topic ?? ""; + const newTopic = newText.topic ?? ""; + + if (oldTopic === newTopic) return; + + const data = { + channel_id: newText.id, + guild_id: newText.guildId, + channel_name: newText.name, + old_topic: oldTopic || null, + new_topic: newTopic || null, + updated_at: Date.now(), + }; + + logger.info( + { channelId: newText.id, channelName: newText.name }, + "Channel topic updated", + ); + await eventBroadcaster.channelTopicUpdated(data).catch(() => {}); + }); +} diff --git a/services/discord-gateway/src/modules/channel-topic/index.ts b/services/discord-gateway/src/modules/channel-topic/index.ts new file mode 100644 index 0000000..10785fc --- /dev/null +++ b/services/discord-gateway/src/modules/channel-topic/index.ts @@ -0,0 +1 @@ +export { registerChannelTopicCapture } from "./channelTopicCapture.js"; diff --git a/services/discord-gateway/src/modules/guild-member-events/index.ts b/services/discord-gateway/src/modules/guild-member-events/index.ts new file mode 100644 index 0000000..19b8568 --- /dev/null +++ b/services/discord-gateway/src/modules/guild-member-events/index.ts @@ -0,0 +1 @@ +export { registerGuildMemberEvents } from "./memberEvents.js"; diff --git a/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts b/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts new file mode 100644 index 0000000..73c26fc --- /dev/null +++ b/services/discord-gateway/src/modules/guild-member-events/memberEvents.ts @@ -0,0 +1,59 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Client, GuildMember } from "discord.js-selfbot-v13"; +import { config } from "../../shared/config/config.js"; +import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; + +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; + return guildIds.includes(guildId); +} + +export function registerGuildMemberEvents( + client: Client, + eventBroadcaster: EventBroadcaster, +): void { + logger.info("Registering guild member events"); + + client.on("guildMemberAdd", async (member: GuildMember) => { + if (!isMonitoredGuild(member.guild.id)) return; + + const data = { + user_id: member.id, + username: member.user.username, + tag: member.user.tag ?? null, + avatar_url: member.user.avatarURL() ?? null, + guild_id: member.guild.id, + member_count: member.guild.memberCount, + joined_at: Date.now(), + }; + + logger.info( + { userId: member.id, username: member.user.username }, + "Guild member added", + ); + await eventBroadcaster.guildMemberAdded(data).catch(() => {}); + }); + + client.on("guildMemberRemove", async (member: GuildMember) => { + 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(), + }; + + 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/thread-tracking/index.ts b/services/discord-gateway/src/modules/thread-tracking/index.ts new file mode 100644 index 0000000..e51233c --- /dev/null +++ b/services/discord-gateway/src/modules/thread-tracking/index.ts @@ -0,0 +1 @@ +export { registerThreadCapture } from "./threadCapture.js"; diff --git a/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts b/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts new file mode 100644 index 0000000..de90866 --- /dev/null +++ b/services/discord-gateway/src/modules/thread-tracking/threadCapture.ts @@ -0,0 +1,70 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Client, ThreadChannel } from "discord.js-selfbot-v13"; +import { config } from "../../shared/config/config.js"; +import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; + +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; + return guildIds.includes(guildId); +} + +export function registerThreadCapture( + client: Client, + eventBroadcaster: EventBroadcaster, +): void { + logger.info("Registering thread capture"); + + client.on("threadCreate", async (thread: ThreadChannel) => { + if (!isMonitoredGuild(thread.guildId)) return; + + const data = { + id: thread.id, + guild_id: thread.guildId, + channel_id: thread.parentId ?? thread.guildId, + name: thread.name, + owner_id: thread.ownerId ?? null, + type: thread.type, + archived: (thread as any).archived ?? false, + created_at: Date.now(), + }; + + logger.debug({ threadId: thread.id, name: thread.name }, "Thread created"); + await eventBroadcaster.threadCreated(data).catch(() => {}); + }); + + client.on("threadDelete", async (thread: ThreadChannel) => { + if (!isMonitoredGuild(thread.guildId)) return; + + const data = { + id: thread.id, + guild_id: thread.guildId, + channel_id: thread.parentId ?? thread.guildId, + name: thread.name, + deleted_at: Date.now(), + }; + + logger.debug({ threadId: thread.id }, "Thread deleted"); + await eventBroadcaster.threadDeleted(data).catch(() => {}); + }); + + 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(), + }; + + logger.debug({ threadId: newThread.id }, "Thread updated"); + await eventBroadcaster.threadUpdated(data).catch(() => {}); + }); +} diff --git a/services/discord-gateway/src/modules/user-presence/index.ts b/services/discord-gateway/src/modules/user-presence/index.ts new file mode 100644 index 0000000..6dfab00 --- /dev/null +++ b/services/discord-gateway/src/modules/user-presence/index.ts @@ -0,0 +1 @@ +export { registerPresenceCapture } from "./presenceCapture.js"; diff --git a/services/discord-gateway/src/modules/user-presence/presenceCapture.ts b/services/discord-gateway/src/modules/user-presence/presenceCapture.ts new file mode 100644 index 0000000..2e203ba --- /dev/null +++ b/services/discord-gateway/src/modules/user-presence/presenceCapture.ts @@ -0,0 +1,84 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Client, Presence } from "discord.js-selfbot-v13"; +import { config } from "../../shared/config/config.js"; +import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; + +const logger = createChildLogger("presence-tracking"); + +// ─── Cooldown per user (30s) ───────────────────────────────────────────── +const presenceCooldowns = new Map(); +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; + 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; + return "offline"; +} + +function getActivities(presence: Presence): Array<{ name: string; type: string }> { + if (!presence?.activities) return []; + return presence.activities.map((a) => ({ + name: a.name ?? "unknown", + type: String(a.type ?? "custom"), + })); +} + +function getClientStatus(presence: Presence): Record | null { + const cs = (presence as any).clientStatus ?? (presence as any).client_status; + if (!cs) return null; + const result: Record = {}; + for (const [platform, status] of Object.entries(cs)) { + result[platform] = String(status); + } + return result; +} + +export function registerPresenceCapture( + client: Client, + eventBroadcaster: EventBroadcaster, +): void { + logger.info("Registering presence capture"); + + client.on("presenceUpdate", async (_oldPresence: Presence | null, newPresence: Presence) => { + if (!newPresence?.guildId) return; + if (!isMonitoredGuild(newPresence.guildId)) 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); + + 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, + }; + + 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); + } + } + }); +}