feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config

Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
This commit is contained in:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
+327
View File
@@ -0,0 +1,327 @@
# Frontend UI Guidelines — The Glass Facade
> *"The details are not the details. They make the design."*
> — Charles Eames
---
## 🎯 Scope
Dokumen ini mengkhususkan implementasi **design system** untuk frontend web BETE (React + Tailwind + Vite). Fokus: konfigurasi Tailwind, CSS architecture, dan integration patterns.
---
## ⚛️ Stack Implementation
| Tool | Version | Purpose |
|------|---------|---------|
| React | 19.x | UI library |
| TypeScript | 5.x | Type safety |
| Vite | 6.x | Bundler |
| Tailwind CSS | 4.x | Utility-first CSS |
| Radix UI | — | Headless primitives |
| TanStack Query | 5.x | Server state |
| Zustand | 5.x | Client state |
| Framer Motion | 11.x | Animations |
| GSAP | 3.x | Page transitions |
| Recharts | 2.x | Charts |
| Three.js | 0.170+ | Particle background |
---
## 🎨 Tailwind Config (Extended)
```js
// tailwind.config.js
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
colors: {
// Semantic colors — map to CSS variables
border: 'oklch(var(--clr-border) / <alpha-value>)',
input: 'oklch(var(--clr-border) / <alpha-value>)',
ring: 'oklch(var(--clr-primary-400) / <alpha-value>)',
background: 'oklch(var(--clr-surface-base) / <alpha-value>)',
foreground: 'oklch(var(--clr-text) / <alpha-value>)',
primary: {
DEFAULT: 'oklch(var(--clr-primary) / <alpha-value>)',
foreground: 'oklch(var(--clr-text-on-primary) / <alpha-value>)',
soft: 'oklch(var(--clr-primary-bg) / <alpha-value>)',
50: 'oklch(var(--clr-primary-50) / <alpha-value>)',
100: 'oklch(var(--clr-primary-100) / <alpha-value>)',
500: 'oklch(var(--clr-primary-500) / <alpha-value>)',
600: 'oklch(var(--clr-primary-600) / <alpha-value>)',
},
muted: {
DEFAULT: 'oklch(var(--clr-surface-elevated) / <alpha-value>)',
foreground: 'oklch(var(--clr-text-secondary) / <alpha-value>)',
},
destructive: {
DEFAULT: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
foreground: 'white',
},
// Severity colors
severity: {
safe: 'oklch(var(--clr-severity-safe) / <alpha-value>)',
low: 'oklch(var(--clr-severity-low) / <alpha-value>)',
medium: 'oklch(var(--clr-severity-medium) / <alpha-value>)',
high: 'oklch(var(--clr-severity-high) / <alpha-value>)',
critical: 'oklch(var(--clr-severity-critical) / <alpha-value>)',
},
// Glass effects
glass: {
bg: 'oklch(var(--glass-bg) / <alpha-value>)',
border: 'oklch(var(--glass-border) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--rd-lg)',
md: 'var(--rd-md)',
sm: 'var(--rd-sm)',
xl: 'var(--rd-xl)',
full: 'var(--rd-full)',
},
spacing: {
0.5: 'var(--sp-0-5)',
1: 'var(--sp-1)',
2: 'var(--sp-2)',
3: 'var(--sp-3)',
4: 'var(--sp-4)',
5: 'var(--sp-5)',
6: 'var(--sp-6)',
7: 'var(--sp-7)',
8: 'var(--sp-8)',
},
zIndex: {
header: 'var(--z-header)',
sidebar: 'var(--z-sidebar)',
overlay: 'var(--z-overlay)',
modal: 'var(--z-modal)',
toast: 'var(--z-toast)',
mascot: 'var(--z-mascot)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'fade-in-up': 'fadeInUp 0.5s ease-out',
'shimmer': 'shimmer 1.5s ease-in-out infinite',
'bar-pulse': 'bar-pulse 0.4s ease-in-out infinite',
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
'scale-in': 'scaleIn 0.3s ease-out',
'slide-up': 'slideUp 0.35s ease-out',
'slide-down': 'slideDown 0.25s ease-out',
},
keyframes: {
fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
fadeInUp: { '0%': { opacity: '0', transform: 'translateY(20px)' }, '100%': { opacity: '1', transform: 'translateY(0)' } },
shimmer: { '0%': { backgroundPosition: '200% 0' }, '100%': { backgroundPosition: '-200% 0' } },
'bar-pulse': { '0%, 100%': { transform: 'scaleY(0.8)' }, '50%': { transform: 'scaleY(1.2)' } },
glowPulse: { '0%, 100%': { opacity: '0.4' }, '50%': { opacity: '0.8' } },
scaleIn: { '0%': { transform: 'scale(0.95)', opacity: '0' }, '100%': { transform: 'scale(1)', opacity: '1' } },
slideUp: { '0%': { transform: 'translateY(10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
slideDown: { '0%': { transform: 'translateY(-10px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' } },
},
backdropBlur: {
glass: '16px',
strong: '24px',
subtle: '8px',
},
},
},
plugins: [],
};
```
---
## 📁 Source Structure (Feature-Sliced)
```
src/
├── main.tsx # Entry + QueryClient + Providers
├── styles.css # Tailwind + CSS custom properties + keyframes
├── App.tsx # Layout shell + routing
├── entities/ # Domain types (pure, no logic)
│ ├── message/
│ ├── guild/
│ ├── voice/
│ ├── media/
│ └── ui/
├── shared/ # Cross-cutting
│ ├── api/ # HTTP client + typed endpoints
│ ├── ws/ # WebSocket manager
│ ├── hooks/ # Shared hooks (useReducedMotion, etc.)
│ ├── ui/ # UI primitives (button, card, badge, etc.)
│ └── lib/ # Utils (cn, logger, formatters)
├── features/ # Feature modules
│ ├── live/ # Voice + media controls
│ ├── messages/ # Message feed + moderation
│ ├── admin/ # Admin panel
│ ├── settings/ # Settings
│ └── auth/ # Login/overlay
└── widgets/ # Layout composites
├── DashboardLayout.tsx
├── Header.tsx
├── Sidebar.tsx
├── mascot/
└── particles/
```
---
## 🎭 Glassmorphism Implementation
```css
/* styles.css — Glass utility classes */
@layer utilities {
.glass {
background: oklch(from var(--clr-surface-elevated) l c h / 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid oklch(from var(--clr-border) l c h / 0.2);
}
.glass-strong {
background: oklch(from var(--clr-surface-overlay) l c h / 0.85);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.glass-subtle {
background: oklch(from var(--clr-surface-base) l c h / 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.gradient-text {
background: linear-gradient(135deg,
oklch(var(--clr-primary-500)),
oklch(var(--clr-primary-300))
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
```
---
## 🌐 WebSocket Integration
```tsx
// shared/ws/socket.ts
class SocketManager {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<(data: unknown) => void>>();
private reconnectAttempts = 0;
private maxReconnectDelay = 30000;
connect(url: string): void {
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
this.listeners.get(type)?.forEach(fn => fn(data));
};
this.ws.onclose = () => this.scheduleReconnect();
}
on<T>(event: string, callback: (data: T) => void): () => void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(callback as (data: unknown) => void);
return () => this.listeners.get(event)?.delete(callback as (data: unknown) => void);
}
private scheduleReconnect(): void {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
setTimeout(() => { this.reconnectAttempts++; this.connect(this.ws!.url); }, delay);
}
}
export const socket = new SocketManager();
```
---
## 🎯 Key Integration Rules
| Concern | Implementation | Location |
|---------|---------------|----------|
| CSS Variables | Defined in `styles.css` on `:root` | Root stylesheet |
| Tailwind Colors | Map to CSS variables with `<alpha-value>` | tailwind.config.js |
| Component Library | shadcn/ui patterns with custom variants | shared/ui/ |
| Server State | TanStack Query in feature hooks | features/*/hooks/ |
| Client State | Zustand stores for UI state | shared/stores/ |
| WebSocket | Singleton SocketManager | shared/ws/socket.ts |
| Animations | Framer Motion for component, GSAP for page | In components |
| Particles | Three.js via @react-three/fiber | widgets/particles/ |
---
## ⚠️ Frontend Anti-Patterns
### ❌ Server state di state lokal
```tsx
// ❌ JANGAN — API data disimpan di useState
const [messages, setMessages] = useState([]);
useEffect(() => { fetchMessages().then(setMessages); }, []);
// ✅ Gunakan TanStack Query
const { data: messages } = useQuery({ queryKey: ['messages'], queryFn: fetchMessages });
```
### ❌ Inline styles untuk dynamic values
```tsx
// ❌ JANGAN — tidak theme-aware, tidak bisa dark mode
<div style={{ backgroundColor: isActive ? '#3b82f6' : '#6b7280' }} />
// ✅ CSS class dengan state
<div className={isActive ? 'bg-primary' : 'bg-muted'} />
```
### ❌ Mengimpor langsung dari library tanpa wrapper
```tsx
// ❌ JANGAN — susah diganti library nanti
import { motion } from 'framer-motion';
// ✅ Wrapper pattern
import { AnimatedDiv } from '@/shared/ui';
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Tailwind CSS Docs](https://tailwindcss.com/docs) | Utility-first CSS |
| [shadcn/ui](https://ui.shadcn.com/) | Component primitives |
| [TanStack Query](https://tanstack.com/query) | Server state |
| [Zustand](https://github.com/pmndrs/zustand) | Client state |
---
*"Fasad kaca yang menari — di balik setiap piksel ada cerita."* ❄️🩵
@@ -0,0 +1,271 @@
# Backend API Guidelines — The Nerve Center
> *"APIs are contracts. Design them with the same care as legal documents."*
> — Unknown
---
## 🎯 Filosofi API
Backend API BETE adalah **fasilitator antara data dan tampilan**:
1. **RESTful by design** — Sumber daya, bukan aksi
2. **Type-safe** — Zod schemas di setiap endpoint
3. **Consistent pagination** — Tidak ada kejutan format
4. **Error as structure** — Setiap error punya kode dan resolusi
---
## 📐 API Design Principles
### URL Structure
```
GET /api/v1/messages # List messages
GET /api/v1/messages/:id # Single message
GET /api/v1/channels # List channels
GET /api/v1/analytics/overview # Analytics
GET /api/v1/voice/connections # Voice connections
POST /api/v1/voice/connect # Connect to voice
POST /api/v1/voice/disconnect # Disconnect
```
### Response Envelope
```typescript
// Success
{
"success": true,
"data": T,
"meta"?: {
"page": 1,
"limit": 50,
"total": 1234,
"hasMore": true
}
}
// Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid channelId format",
"details": {
"field": "channelId",
"constraint": "numeric_string"
},
"requestId": "req_abc123"
}
}
```
### Pagination
```typescript
interface PaginationParams {
page?: number; // Default: 1
limit?: number; // Default: 50, Max: 200
cursor?: string; // For cursor-based pagination
}
interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}
```
### Filtering
```typescript
interface FilterParams {
search?: string;
channelId?: string;
userId?: string;
severity?: 'safe' | 'low' | 'medium' | 'high' | 'critical';
dateFrom?: string; // ISO 8601
dateTo?: string; // ISO 8601
sortBy?: string; // Field name
sortOrder?: 'asc' | 'desc';
}
```
---
## 🏗️ Module Structure (Backend)
```
services/backend/src/modules/
├── messages/
│ ├── messages.schema.ts # Zod schemas
│ ├── messages.repository.ts # Database queries
│ ├── messages.service.ts # Business logic
│ ├── messages.controller.ts # Request handlers
│ └── routes/
│ └── index.ts # Express router
├── analytics/
├── voice/
├── media/
└── health/
```
### Layer Rules
```
Controller (parse + validate) → Service (business logic) → Repository (DB queries)
Shared Infrastructure
(config, logger, errors)
```
---
## ⚡ WebSocket Events
### Event Format
```typescript
interface WsEvent<T = unknown> {
type: string; // e.g., "message:created"
data: T;
timestamp: number;
requestId?: string;
}
// Server → Client events
{
"type": "message:created",
"data": {
"id": "msg_123",
"content": "...",
"author": { "id": "user_1", "name": "User" }
},
"timestamp": 1750000000000
}
// Client → Server events
{
"type": "voice:connect",
"data": {
"guildId": "123456789",
"channelId": "987654321"
}
}
```
### Event Catalog
| Type | Direction | Description |
|------|-----------|-------------|
| `message:created` | Server → Client | New message captured |
| `message:updated` | Server → Client | Message edited |
| `message:deleted` | Server → Client | Message removed |
| `message:analyzed` | Server → Client | AI analysis complete |
| `voice:state` | Server → Client | Voice connection state |
| `voice:speaker` | Server → Client | Speaker activity |
| `attachment:uploaded` | Server → Client | Attachment uploaded |
| `analytics:update` | Server → Client | Analytics data refresh |
---
## 🔒 Authentication & Authorization
```typescript
// Admin auth via header
Authorization: Bearer <admin-password-hash>
// Rate limiting
RateLimit: 100/minute per IP
Retry-After: 60
```
### Error Codes
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION_ERROR` | 400 | Invalid input |
| `UNAUTHORIZED` | 401 | Invalid/missing auth |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `RATE_LIMITED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Unexpected error |
| `SERVICE_UNAVAILABLE` | 503 | Downstream failure |
---
## 🧪 Testing Strategy
```typescript
describe('GET /api/v1/messages', () => {
it('returns paginated messages', async () => {
const res = await request(app).get('/api/v1/messages?page=1&limit=10');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.meta.hasMore).toBeDefined();
});
it('rejects invalid severity filter', async () => {
const res = await request(app).get('/api/v1/messages?severity=invalid');
expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});
```
---
## ⚠️ API Anti-Patterns
### ❌ Nested resources terlalu dalam
```
// ❌ JANGAN
GET /api/v1/guilds/123/channels/456/messages/789
// ✅ Flat dengan query params
GET /api/v1/messages?channelId=456
```
### ❌ Inconsistent error format
```typescript
// ❌ JANGAN — kadang string, kadang object
if (err) return res.status(400).send('Bad request');
if (err) return res.status(400).json({ message: 'Bad request' });
// ✅ Consistent envelope
if (err) return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'Bad request' }
});
```
### ❌ No type safety
```typescript
// ❌ JANGAN — any, tidak ada validasi
app.get('/api/messages', async (req, res) => {
const messages = await db.query('SELECT * FROM messages');
res.json(messages);
});
// ✅ Zod schema + typed handler
app.get('/api/v1/messages', asyncHandler(async (req, res) => {
const query = messageQuerySchema.parse(req.query);
const messages = await messagesService.list(query);
res.json({ success: true, data: messages });
}));
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [JSON:API](https://jsonapi.org/) | Response format spec |
| [Express.js](https://expressjs.com/) | Server framework |
| [Zod](https://zod.dev/) | Schema validation |
---
*"API adalah jembatan ingatan — setiap request adalah percakapan."* ❄️🩵
+310
View File
@@ -0,0 +1,310 @@
# Gateway Event Design — The Pulse of Discord
> *"Events are the heartbeat of a distributed system."*
> — Martin Fowler
---
## 🎯 Filosofi Gateway Events
Discord Gateway adalah **jantung event-driven** BETE:
1. **Single source of truth** — Events adalah satu-satunya cara data bergerak antar service
2. **At-least-once delivery** — Event bisa terkirim lebih dari sekali (idempotent consumers)
3. **Schema evolution** — Events punya versioning untuk backward compatibility
4. **Observable** — Setiap event tercatat untuk debugging dan audit
---
## 📦 Event Schema
### Envelope
```typescript
interface GatewayEvent<T = unknown> {
/** Event type identifier — lowercase, colon-separated */
type: string;
/** Event payload */
data: T;
/** ISO 8601 timestamp of when the event was created */
timestamp: string;
/** Unique event ID for deduplication */
eventId: string;
/** Source service name */
source: 'discord-gateway';
/** Event schema version */
version: number;
/** Optional correlation ID for tracing request flows */
correlationId?: string;
}
```
### Event Size Limits
| Limit | Value | Notes |
|-------|-------|-------|
| Max payload size | 256KB | Larger payloads → reference via URL |
| Max nesting depth | 5 levels | Prevent billion laughs attack |
| String max length | 100KB | Truncate with `... (truncated)` suffix |
---
## 📋 Event Catalog
### Message Events
```typescript
// discord:message:created
interface MessageCreatedEvent {
id: string;
channelId: string;
guildId: string;
author: {
id: string;
name: string;
discriminator: string;
avatar: string | null;
isBot: boolean;
};
content: string;
timestamp: string; // ISO 8601
editedTimestamp: string | null;
attachments: AttachmentInfo[];
replyTo?: string; // Parent message ID
}
// discord:message:updated
interface MessageUpdatedEvent {
id: string;
channelId: string;
content: string;
editedTimestamp: string;
}
// discord:message:deleted
interface MessageDeletedEvent {
id: string;
channelId: string;
guildId: string;
}
```
### Analysis Events
```typescript
// discord:message:analyzed
interface MessageAnalyzedEvent {
messageId: string;
status: 'pending' | 'complete' | 'error';
severity: 'safe' | 'low' | 'medium' | 'high' | 'critical';
categories: string[];
confidence: number; // 01
summary: string;
analyzedAt: string;
processingTimeMs: number;
}
```
### Voice Events
```typescript
// discord:voice:started
interface VoiceStartedEvent {
guildId: string;
channelId: string;
channelName: string;
startedAt: string;
participants: Array<{
userId: string;
userName: string;
}>;
}
// discord:voice:stopped
interface VoiceStoppedEvent {
guildId: string;
channelId: string;
duration: number; // seconds
segmentsCount: number;
}
// discord:voice:uploaded
interface VoiceUploadedEvent {
segmentId: string;
guildId: string;
channelId: string;
userId: string;
userName: string;
duration: number;
fileUrl: string;
fileSize: number;
timestamp: string;
}
```
### Attachment Events
```typescript
// discord:attachment:created
interface AttachmentCreatedEvent {
id: string;
messageId: string;
channelId: string;
url: string;
filename: string;
contentType: string;
size: number;
width?: number;
height?: number;
}
// discord:attachment:uploaded
interface AttachmentUploadedEvent {
id: string;
messageId: string;
storageUrl: string;
thumbnailUrl?: string;
fileSize: number;
processingTimeMs: number;
}
```
---
## 🔄 Event Lifecycle
```
┌──────────┐
│ Discord │ (messageCreate, voiceStateUpdate, etc.)
└────┬─────┘
┌──────────────┐
│ Discord.js │ (client events)
└────┬─────────┘
┌─────────────────────────────┐
│ Message Capture Controller │ (messageCapture.ts)
│ - Parse event │
│ - Store in database │
│ - Publish to Redis │
└────┬───────────────────────┘
┌─────────────────────┐
│ Redis Pub/Sub │ (channel: discord:message:created)
└────┬────────────────┘
├──────────────────────────────┐
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ Backend Service │ │ AI Moderation │
│ - Index message │ │ - Analyze text │
│ - Store in DB │ │ - Update status │
│ - Broadcast WS │ │ - Publish result │
└──────────────────┘ └──────────────────┘
```
---
## 🧪 Event Testing
```typescript
// Helper untuk generate test events
function createTestEvent<T>(type: string, data: T): GatewayEvent<T> {
return {
type,
data,
timestamp: new Date().toISOString(),
eventId: crypto.randomUUID(),
source: 'discord-gateway',
version: 1,
};
}
describe('MessageCreatedEvent', () => {
it('is properly formatted', () => {
const event = createTestEvent('discord:message:created', {
id: 'msg_1',
channelId: 'ch_1',
guildId: 'guild_1',
author: { id: 'user_1', name: 'Test', discriminator: '0000', avatar: null, isBot: false },
content: 'Hello world',
timestamp: new Date().toISOString(),
editedTimestamp: null,
attachments: [],
});
expect(event.type).toBe('discord:message:created');
expect(event.data.content).toBe('Hello world');
expect(event.source).toBe('discord-gateway');
expect(event.version).toBe(1);
});
});
```
---
## 📊 Event Performance Metrics
| Metric | Target | Alert |
|--------|--------|-------|
| Processing latency | <50ms p99 | >200ms |
| Event throughput | >1000/s | <100/s (unusual) |
| Redis publish latency | <5ms | >20ms |
| Event loss rate | 0% | >0.01% |
| Queue depth | <100 | >1000 |
---
## ⚠️ Anti-Patterns Events
### ❌ Processing-heavy event handlers
```typescript
// ❌ JANGAN — blocking event loop
eventBus.on('message:created', async (event) => {
const result = await expensiveAnalysis(event.data.content);
await db.save(result);
// Event handler for 100 msg/s = bottleneck
});
// ✅ Queue heavy work
eventBus.on('message:created', async (event) => {
await analysisQueue.add(event); // Worker processes async
});
```
### ❌ Missing idempotency
```typescript
// ❌ JANGAN — duplicate events create duplicate records
async function handleMessageCreated(event) {
await db.insert({ id: event.data.id, content: event.data.content });
// If event arrives twice → duplicate key error
}
// ✅ Idempotent: UPSERT
async function handleMessageCreated(event) {
await db.upsert({ id: event.data.id }, { content: event.data.content });
}
```
---
## 🔗 Referensi
| Sumber | Konsep |
|--------|--------|
| [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/) | Event backbone |
| [CloudEvents](https://cloudevents.io/) | Event schema standard |
| [Discord Gateway](https://discord.com/developers/docs/topics/gateway) | Discord events |
---
*"Setiap event adalah denyut nadi — tanda bahwa sistem masih hidup dan berbicara."* ❄️🩵