feat: enhance deployment instructions and exclude spam threads from message capture
This commit is contained in:
@@ -415,6 +415,12 @@ pnpm run db:studio # Open Drizzle Studio
|
|||||||
|
|
||||||
# Install yt-dlp for media download
|
# Install yt-dlp for media download
|
||||||
pnpm run install:yt-dlp
|
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
|
## Configuration
|
||||||
|
|||||||
@@ -169,6 +169,35 @@ Lihat `.env.example` untuk daftar lengkap. Variabel utama:
|
|||||||
- `AI_ANALYSIS_ENABLED` — aktifkan/nonaktifkan analisis AI.
|
- `AI_ANALYSIS_ENABLED` — aktifkan/nonaktifkan analisis AI.
|
||||||
- `AI_LLM_API_KEY`, `AI_LLM_BASE_URL`, `AI_LLM_MODEL` — konfigurasi provider LLM.
|
- `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
|
## Verifikasi Setelah Perubahan
|
||||||
|
|
||||||
Sebelum menjalankan lama atau deploy, jalankan:
|
Sebelum menjalankan lama atau deploy, jalankan:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PageResult } from "@bete/shared";
|
import type { PageResult } from "@bete/shared";
|
||||||
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
import { pgAttachmentsTable, pgMessagesTable } from "@bete/shared";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
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 { getDatabase } from "../../shared/database/index.js";
|
||||||
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -10,6 +10,14 @@ import type {
|
|||||||
MessageUpdate,
|
MessageUpdate,
|
||||||
} from "./messages.schema.js";
|
} 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");
|
const logger = createChildLogger("messages.repository");
|
||||||
|
|
||||||
export interface AttachmentResult {
|
export interface AttachmentResult {
|
||||||
@@ -54,6 +62,16 @@ export class MessagesRepository {
|
|||||||
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
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 where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -96,6 +114,16 @@ export class MessagesRepository {
|
|||||||
conditions.push(lt(pgMessagesTable.created_at, Number(query.cursor)));
|
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
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pgMessagesTable)
|
.from(pgMessagesTable)
|
||||||
@@ -304,6 +332,18 @@ export class MessagesRepository {
|
|||||||
and(
|
and(
|
||||||
eq(pgAttachmentsTable.guild_id, guildId),
|
eq(pgAttachmentsTable.guild_id, guildId),
|
||||||
like(pgAttachmentsTable.type, "image/%"),
|
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))
|
.orderBy(desc(pgAttachmentsTable.created_at))
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ const EXCLUDED_CHANNEL_IDS = new Set([
|
|||||||
"1508059937031589949",
|
"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(
|
function getParentChannelId(
|
||||||
message: MessageLocationInput,
|
message: MessageLocationInput,
|
||||||
): string | null | undefined {
|
): string | null | undefined {
|
||||||
@@ -283,6 +299,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
||||||
if (message.author?.bot) return;
|
if (message.author?.bot) return;
|
||||||
if (isAgeRestrictedMessage(message)) return;
|
if (isAgeRestrictedMessage(message)) return;
|
||||||
|
if (isExcludedThread(message)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await captureMessage(message, "text");
|
await captureMessage(message, "text");
|
||||||
@@ -301,6 +318,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
if (!shouldCaptureForAnyTarget(newMessage, targets)) return;
|
if (!shouldCaptureForAnyTarget(newMessage, targets)) return;
|
||||||
if (newMessage.author?.bot) return;
|
if (newMessage.author?.bot) return;
|
||||||
if (isAgeRestrictedMessage(newMessage as Message)) return;
|
if (isAgeRestrictedMessage(newMessage as Message)) return;
|
||||||
|
if (isExcludedThread(newMessage)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await getMessageById(newMessage.id);
|
const existing = await getMessageById(newMessage.id);
|
||||||
@@ -367,6 +385,7 @@ export function registerMessageCapture(client: Client): void {
|
|||||||
client.on("messageDelete", async (message) => {
|
client.on("messageDelete", async (message) => {
|
||||||
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
if (isExcludedThread(message)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedAt = Date.now();
|
const deletedAt = Date.now();
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ fn render_emojis(content: &str) -> Vec<AnyView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn time_ago(ts: i64) -> String {
|
fn time_ago(ts: i64) -> String {
|
||||||
let now = (js_sys::Date::now() / 1000.0) as i64;
|
let now = js_sys::Date::now() as i64;
|
||||||
let secs = if now > ts { now - ts } else { 0 };
|
// created_at is in milliseconds (from Discord's message.createdTimestamp)
|
||||||
|
let secs = if now > ts { (now - ts) / 1000 } else { 0 };
|
||||||
if secs < 60 {
|
if secs < 60 {
|
||||||
format!("{}s ago", secs)
|
format!("{}s ago", secs)
|
||||||
} else if secs < 3600 {
|
} else if secs < 3600 {
|
||||||
@@ -53,13 +54,13 @@ fn time_ago(ts: i64) -> String {
|
|||||||
} else if secs < 86400 {
|
} else if secs < 86400 {
|
||||||
format!("{}h ago", secs / 3600)
|
format!("{}h ago", secs / 3600)
|
||||||
} else {
|
} 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))
|
format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fmt_time(ts: i64) -> String {
|
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())
|
format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ use shared_types::message::{AiStatus, MessageRecord, PageResult};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wasm_bindgen_futures::spawn_local;
|
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 components;
|
||||||
pub mod hooks;
|
pub mod hooks;
|
||||||
|
|
||||||
@@ -82,6 +93,7 @@ pub fn MessagesPanel() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
format!("{:?}", status).to_lowercase() == filter
|
format!("{:?}", status).to_lowercase() == filter
|
||||||
})
|
})
|
||||||
|
.filter(|m| !is_excluded_thread(m))
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,30 +186,36 @@ pub fn MessagesPanel() -> impl IntoView {
|
|||||||
|
|
||||||
// Fetch image messages when Images tab is selected
|
// Fetch image messages when Images tab is selected
|
||||||
let fetch_images = {
|
let fetch_images = {
|
||||||
|
let image_messages = image_messages.clone();
|
||||||
move || {
|
move || {
|
||||||
spawn_local({
|
let guild_id = use_context::<crate::app::AppConfig>()
|
||||||
async move {
|
.and_then(|c| c.monitor_guild_id.get());
|
||||||
if let Some(config) = use_context::<crate::app::AppConfig>() {
|
if let Some(gid) = guild_id {
|
||||||
if let Some(ref guild_id) = config.monitor_guild_id.get() {
|
spawn_local({
|
||||||
match crate::api::messages::get_images(guild_id, Some(100)).await {
|
let image_messages = image_messages.clone();
|
||||||
Ok(PageResult { data, .. }) => {
|
async move {
|
||||||
web_sys::console::log_2(
|
match crate::api::messages::get_images(&gid, Some(100)).await {
|
||||||
&"[images] fetch OK".into(),
|
Ok(PageResult { data, .. }) => {
|
||||||
&format!("count={}", data.len()).into(),
|
web_sys::console::log_2(
|
||||||
);
|
&"[images] fetch OK".into(),
|
||||||
image_messages.set(data);
|
&format!("count={}", data.len()).into(),
|
||||||
}
|
);
|
||||||
Err(e) => {
|
image_messages.set(
|
||||||
web_sys::console::log_2(
|
data.into_iter()
|
||||||
&"[images] fetch ERROR".into(),
|
.filter(|m| !is_excluded_thread(m))
|
||||||
&format!("{}", e).into(),
|
.collect(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::log_2(
|
||||||
|
&"[images] fetch ERROR".into(),
|
||||||
|
&format!("{}", e).into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Fetch images when tab changes to Images
|
// Fetch images when tab changes to Images
|
||||||
|
|||||||
Reference in New Issue
Block a user