From c0edb0fa40847c8fc395b473eca12e0087beb762 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 5 Jul 2026 06:58:17 +0700 Subject: [PATCH] feat: enhance deployment instructions and exclude spam threads from message capture --- CLAUDE.md | 6 ++ README.md | 29 ++++++++++ .../modules/messages/messages.repository.ts | 42 +++++++++++++- .../modules/message-capture/messageCapture.ts | 19 ++++++ .../messages/components/message_card.rs | 9 +-- .../frontend/src/features/messages/mod.rs | 58 ++++++++++++------- 6 files changed, 138 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ca1f4db..83ee5f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -415,6 +415,12 @@ pnpm run db:studio # Open Drizzle Studio # Install yt-dlp for media download pnpm run install:yt-dlp + +# Deploy to VPS (build + hot-patch running containers) +./deploy.sh # Build + deploy all services +./deploy.sh --frontend # Frontend WASM only +./deploy.sh --backend # Backend TypeScript only +./deploy.sh --no-build # Skip build, just copy files ``` ## Configuration diff --git a/README.md b/README.md index 875316c..eae5b09 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,35 @@ Lihat `.env.example` untuk daftar lengkap. Variabel utama: - `AI_ANALYSIS_ENABLED` — aktifkan/nonaktifkan analisis AI. - `AI_LLM_API_KEY`, `AI_LLM_BASE_URL`, `AI_LLM_MODEL` — konfigurasi provider LLM. +## Deploy ke VPS + +Project menggunakan `deploy.sh` untuk build + deploy manual ke VPS. Script ini +membangun backend (TypeScript) dan frontend (WASM Leptos) lalu menyalinnya ke +dalam container Docker yang sudah berjalan di VPS. + +```bash +# Build + deploy semua service +./deploy.sh + +# Deploy frontend saja (setelah perubahan UI) +./deploy.sh --frontend + +# Deploy backend saja +./deploy.sh --backend + +# Deploy tanpa rebuild (file sudah terbuild sebelumnya) +./deploy.sh --no-build +``` + +Credentials diambil otomatis dari GitLab CI variables via `glab`. Atau set manual: + +```bash +export VPS_HOST="your-vps-ip" +export VPS_USER="root" +export VPS_SSH_KEY="~/.ssh/id_ed25519" +./deploy.sh +``` + ## Verifikasi Setelah Perubahan Sebelum menjalankan lama atau deploy, jalankan: diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 088f2ec..16772c4 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -1,7 +1,7 @@ import type { PageResult } from "@bete/shared"; import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared"; import { createChildLogger } from "@bete/shared/logger"; -import { and, desc, eq, inArray, like, lt, ne, type SQL } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, like, lt, ne, notInArray, or, type SQL } from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; import { mapMessageRow } from "../../shared/utils/messageMapper.js"; import type { @@ -10,6 +10,14 @@ import type { MessageUpdate, } from "./messages.schema.js"; +/** + * Thread IDs to exclude from all message queries. + * Messages in these threads (e.g. bot/selfbot spam) are skipped + * both at capture time (discord-gateway) and when serving data + * (backend API). + */ +const EXCLUDED_THREAD_IDS = ["1522077685508083893"]; + const logger = createChildLogger("messages.repository"); export interface AttachmentResult { @@ -54,6 +62,16 @@ export class MessagesRepository { conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor))); } + // Exclude spam threads (NULL-safe: non-thread messages are kept) + if (EXCLUDED_THREAD_IDS.length > 0) { + conditions.push( + or( + isNull(pgMessagesTable.thread_id), + notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS), + )!, + ); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; const rows = await db .select() @@ -96,6 +114,16 @@ export class MessagesRepository { conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor))); } + // Exclude spam threads (NULL-safe) + if (EXCLUDED_THREAD_IDS.length > 0) { + conditions.push( + or( + isNull(pgMessagesTable.thread_id), + notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS), + )!, + ); + } + const rows = await db .select() .from(pgMessagesTable) @@ -304,6 +332,18 @@ export class MessagesRepository { and( eq(pgAttachmentsTable.guild_id, guildId), like(pgAttachmentsTable.type, "image/%"), + // Exclude spam threads (NULL-safe for non-thread messages) + ...(EXCLUDED_THREAD_IDS.length > 0 + ? [ + or( + isNull(pgAttachmentsTable.thread_id), + notInArray( + pgAttachmentsTable.thread_id, + EXCLUDED_THREAD_IDS, + ), + )!, + ] + : []), ), ) .orderBy(desc(pgAttachmentsTable.created_at)) diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts index 5cc902e..f64b893 100644 --- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts +++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts @@ -49,6 +49,22 @@ const EXCLUDED_CHANNEL_IDS = new Set([ "1508059937031589949", ]); +/** + * Threads whose messages should be entirely ignored. + * Useful when a bot or selfbot is spamming inside a thread and + * you only want to ignore that one conversation, not the whole + * parent channel. + */ +const EXCLUDED_THREAD_IDS = new Set(["1522077685508083893"]); + +function isExcludedThread(message: { + channel?: { isThread?: () => boolean; id?: string }; +}): boolean { + return message.channel?.isThread?.() === true + && typeof message.channel.id === "string" + && EXCLUDED_THREAD_IDS.has(message.channel.id); +} + function getParentChannelId( message: MessageLocationInput, ): string | null | undefined { @@ -283,6 +299,7 @@ export function registerMessageCapture(client: Client): void { if (!shouldCaptureForAnyTarget(message, targets)) return; if (message.author?.bot) return; if (isAgeRestrictedMessage(message)) return; + if (isExcludedThread(message)) return; try { await captureMessage(message, "text"); @@ -301,6 +318,7 @@ export function registerMessageCapture(client: Client): void { if (!shouldCaptureForAnyTarget(newMessage, targets)) return; if (newMessage.author?.bot) return; if (isAgeRestrictedMessage(newMessage as Message)) return; + if (isExcludedThread(newMessage)) return; try { const existing = await getMessageById(newMessage.id); @@ -367,6 +385,7 @@ export function registerMessageCapture(client: Client): void { client.on("messageDelete", async (message) => { if (!shouldCaptureForAnyTarget(message, targets)) return; if (!message.author) return; + if (isExcludedThread(message)) return; try { const deletedAt = Date.now(); diff --git a/services/frontend/frontend/src/features/messages/components/message_card.rs b/services/frontend/frontend/src/features/messages/components/message_card.rs index 88c1f07..5655c8d 100644 --- a/services/frontend/frontend/src/features/messages/components/message_card.rs +++ b/services/frontend/frontend/src/features/messages/components/message_card.rs @@ -44,8 +44,9 @@ fn render_emojis(content: &str) -> Vec { } fn time_ago(ts: i64) -> String { - let now = (js_sys::Date::now() / 1000.0) as i64; - let secs = if now > ts { now - ts } else { 0 }; + let now = js_sys::Date::now() as i64; + // created_at is in milliseconds (from Discord's message.createdTimestamp) + let secs = if now > ts { (now - ts) / 1000 } else { 0 }; if secs < 60 { format!("{}s ago", secs) } else if secs < 3600 { @@ -53,13 +54,13 @@ fn time_ago(ts: i64) -> String { } else if secs < 86400 { format!("{}h ago", secs / 3600) } else { - let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + let d = js_sys::Date::new(&JsValue::from_f64(ts as f64)); format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED)) } } fn fmt_time(ts: i64) -> String { - let d = js_sys::Date::new(&JsValue::from_f64((ts as f64) * 1000.0)); + let d = js_sys::Date::new(&JsValue::from_f64(ts as f64)); format!("{:02}:{:02}", d.get_hours(), d.get_minutes()) } diff --git a/services/frontend/frontend/src/features/messages/mod.rs b/services/frontend/frontend/src/features/messages/mod.rs index 870192a..d10a40d 100644 --- a/services/frontend/frontend/src/features/messages/mod.rs +++ b/services/frontend/frontend/src/features/messages/mod.rs @@ -3,6 +3,17 @@ use shared_types::message::{AiStatus, MessageRecord, PageResult}; use std::sync::Arc; use wasm_bindgen_futures::spawn_local; +/// Threads whose messages should be hidden from both the feed and the +/// Images tab. A bot or selfbot may be spamming in a thread, polluting +/// the dashboard — add its thread ID here to keep the view clean. +const EXCLUDED_THREAD_IDS: &[&str] = &["1522077685508083893"]; + +fn is_excluded_thread(m: &MessageRecord) -> bool { + m.thread_id + .as_deref() + .is_some_and(|tid| EXCLUDED_THREAD_IDS.contains(&tid)) +} + pub mod components; pub mod hooks; @@ -82,6 +93,7 @@ pub fn MessagesPanel() -> impl IntoView { } format!("{:?}", status).to_lowercase() == filter }) + .filter(|m| !is_excluded_thread(m)) .collect() }); @@ -174,30 +186,36 @@ pub fn MessagesPanel() -> impl IntoView { // Fetch image messages when Images tab is selected let fetch_images = { + let image_messages = image_messages.clone(); move || { - spawn_local({ - async move { - if let Some(config) = use_context::() { - if let Some(ref guild_id) = config.monitor_guild_id.get() { - match crate::api::messages::get_images(guild_id, Some(100)).await { - Ok(PageResult { data, .. }) => { - web_sys::console::log_2( - &"[images] fetch OK".into(), - &format!("count={}", data.len()).into(), - ); - image_messages.set(data); - } - Err(e) => { - web_sys::console::log_2( - &"[images] fetch ERROR".into(), - &format!("{}", e).into(), - ); - } + let guild_id = use_context::() + .and_then(|c| c.monitor_guild_id.get()); + if let Some(gid) = guild_id { + spawn_local({ + let image_messages = image_messages.clone(); + async move { + match crate::api::messages::get_images(&gid, Some(100)).await { + Ok(PageResult { data, .. }) => { + web_sys::console::log_2( + &"[images] fetch OK".into(), + &format!("count={}", data.len()).into(), + ); + image_messages.set( + data.into_iter() + .filter(|m| !is_excluded_thread(m)) + .collect(), + ); + } + Err(e) => { + web_sys::console::log_2( + &"[images] fetch ERROR".into(), + &format!("{}", e).into(), + ); } } } - } - }); + }); + } } }; // Fetch images when tab changes to Images