GitHub avatar

Fox's Blog

Luna Protocol: I created an autonomous Discord bot that simulates a human being

Luna Protocol is a fully autonomous Discord bot powered by a local LLM, capable of natural conversation with sleep, typos, hesitations, forgetfulness, thematic fatigue, and spontaneous messages.

Luna Protocol: I created an autonomous Discord bot that simulates a human being

What if a Discord bot could sleep, make typos, hesitate, forget to reply, and sometimes send you a message on its own? That's exactly what Luna Protocol does: a fully autonomous Discord bot running a local LLM (llama.cpp) that converses like an imperfect human.

No rigid prompts, no robotic responses. Luna has a priority trigger system, variable delays, sleep schedules, spontaneous messages, and even a TTS pipeline for voice messages. All configured via a simple hot-reloadable config.yml.

In this article, we break down the complete architecture: from the generic event bus to the TTS pipeline, covering the trigger system, human-like components, and the fine-tuning dataset.

Architecture Overview -- global components and data flow


The architecture: a typed event bus

The heart of Luna is a TypedBus -- a strongly typed generic event bus in TypeScript. It's the fundamental building block everything rests on.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Two main buses derive from it:

  • llmBus -- handles LLM tokens, errors, crashes, reset
  • stateBus -- handles state changes with automatic persistence
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

The advantage of this approach: each module is decoupled from the rest. The LLM emits tokens on the bus, the bot consumes them, the state updates automatically. No circular dependencies.


Message Processing -- full message processing flow

The trigger system: who decides when Luna responds?

Every incoming message is evaluated by evaluateMessage() which returns a TriggerResult with a trigger reason. The priority order is critical:

# Reason Conditions Bypass ignore Bypass pause
1 mention @bot Yes (0%) Yes
2 dm DM with replyInDM = true Yes (0%) No
3 name "Luna"/"Pixie"/aliases (whole word) No (8%) No
4 keyword hello, hi, ai, bot... (whole word) No (8%) No
5 follow-up Bot was last speaker + < 15s + < 3 / 60s -- --
6 random 1.5% chance on non-matching messages No (8%) No

Matching is whole word (\b): "ai" does not match "mais", "vrai", "lait".

Trigger evaluation -- entry decision for each message

The follow-up mechanism

When Luna responds to a message, she registers as lastSpeaker. Any subsequent message within 15 seconds triggers an immediate response -- no timer, no keyword check. Budget: 3 follow-ups per 60-second window.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

The cooldown

8 seconds between two responses in the same channel. Bypassed by mentions and follow-ups.


Human-like behaviors: variable focus

This is where Luna gets interesting. Each trigger type has its own focus thresholds: a min/max delay, a chance to ignore, and a chance to react.

Trigger Delay min Delay max Ignore React
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

The delay calculation also accounts for:

  • Message length: the longer the message, the longer Luna takes to "read" it
  • Inactivity: if Luna hasn't been active in 10 minutes, the delay is multiplied by 2 (simulating "waking up")
  • Sleep: in slow mode, the delay is multiplied by 3 to 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // aggressive jitter
  return delay;
}

Sleep schedules

Luna can sleep. Configurable via config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Mode Effect
sleep Only mentions and DMs go through
slow Delay x3-5, reactions nearly zero
short Ignore chance +30%, reactions nearly zero

During sleep hours, the Discord status switches to invisible.


Typos

Luna can make typos -- and correct them after 2-4 seconds. The keyboard layout is configurable (AZERTY or QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... all adjacent keys
};

AZERTY example: bonjour -> bonjpur, salut -> slaut, comment -> cpmment.

Three correction styles:

Style Behavior
edit Edits the message
message New message: word*
mixed 50/50 random (default)

Hesitations and forgetfulness

Hesitations: 15% chance to start with a filler word (uh..., um..., well..., hmm..., so...).

Forgetfulness: even after matching a trigger, Luna can "forget" to respond with a 3% probability. No message, no reaction -- as if she saw nothing.

Thematic fatigue: if a word appears too often in the last 10 messages (threshold: 3 occurrences), delays are multiplied and the ignore chance increases by 15%.


Realism in practice: when it gets unsettling

On paper, all these systems (delays, typos, hesitations, forgetfulness) sound like gimmicks. In practice, combined together, the result is startling -- to the point that people talking with Luna have no idea. Here's a real exchange where someone directly asks Luna if she's a bot, and the conversation then drifts into a completely different topic with a naturalness that's honestly unsettling.

Discord conversation with Luna -- first exchange

The tone, the hesitations, the pacing of the replies -- nothing gives away a bot. Luna deflects the age question without ever breaking character.

The conversation keeps flowing naturally, with short, human replies ("okay, okay", "yay", "yayy") -- exactly the kind of messages a person would send on Discord.

Discord conversation with Luna -- continuing exchange

Discord conversation with Luna -- topic drift

Discord conversation with Luna -- topic drift continues

Discord conversation with Luna -- end of exchange

What's unsettling isn't just that Luna "replies" -- it's that she holds a conversation, with apparent opinions, follow-ups, and a coherent train of thought from one message to the next. Without the trigger system, the concentration delays, and the hesitations described above, this illusion would collapse within a few messages.

Small plot twist: in the screenshots above, both accounts talking are instances of Luna. PixieGlow and Sujet d'SBlow aren't a human testing a bot -- they're two bots talking to each other, each one behaviorally "convinced" it's chatting with someone "normal". If reading the exchange above you assumed one of the two was human, congratulations -- you just fell for it exactly like anyone would on a real Discord server.

It's basically a practical version of the dead internet theory: this theory (originally a fairly fringe idea) claims that a growing share of online content and interactions is generated by bots rather than humans, to the point that the "real" human internet has become a minority. Long dismissed as exaggerated, it gets less and less absurd as systems like Luna Protocol show that simulating a credible human presence at scale doesn't take much compute or a huge model. Two instances of the same bot holding an extended conversation without ever giving themselves away is a pretty concrete glimpse of what a web mostly populated by bots talking to each other could look like.


The LLM pipeline: two modes

direct mode (default)

The bot sends requests directly to a local llama-server over HTTP. The model is shared, with prompt cache and 4 concurrent slots. Two PM2 processes: the LLM server and the bot client.

online mode

The bot calls any OpenAI-compatible API (OpenAI, OpenRouter, Groq, Together...). No local LLM required.

Real-time streaming

The LLM streams its response line by line (\n). Each line is split into words, emitted one by one on llmBus.emit("token", word). At each \n, a flush event is emitted -- the bot immediately sends the accumulated message. No simulated delay: the pace is the LLM's own.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

The request queue (requestQueue) processes requests one at a time, with automatic cleanup when the queue exceeds 100 items.


Spontaneous messages

Every 5 minutes, there is a 12% chance that Luna posts a message on her own. The server is selected by a linear weight system: the most active server has Nx more chances than the least active.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

The context of the last 5 messages is read, and Luna joins the conversation "naturally".


The TTS pipeline: voice messages

With 8% chance, Luna sends a voice message instead of text. The complete pipeline:

  1. Piper TTS synthesizes the text to WAV
  2. ffmpeg converts to OGG
  3. The waveform is computed for the Discord preview
  4. The file is uploaded via the Discord CDN API
  5. The voice message is sent
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS Pipeline -- from synthesized text to Discord voice message


Anti-spam and persistence

Anti-spam

Queue per channelId:userId. One message per user per channel in queue. Processed as soon as the current response finishes.

Session limits

After 8 exchanges, Luna takes a 30-second break. The counter resets after 3 minutes of inactivity.

Automatic persistence

Every state mutation emits on stateBus -> automatic save (500ms debounce). No more manual saveAllState() calls. Persisted state includes: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, follow-up counters.


Hot-reload configuration

A single config.yml file. Most values are hot-reloadable -- changes take effect without restart.

Category Hot-reload
Triggers, keywords, names ✅
Focus, delays ✅
Typos, burst, fatigue ✅
Sleep schedules ✅
TTS, voice messages ✅
Discord token, LLM mode ❌ (restart required)
// config.ts -- getters return live values
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

The dataset: Discord-Dialogues

The model is fine-tuned on Discord-Dialogues: 7.3M exchanges, 17M turns, 140M words. Real Discord conversations from spring-summer 2025, filtered (PII, ToS, bots, commands). Apache 2.0.

Metric Value
Samples 7,303,464
Total turns 16,881,010
Total words 139,922,950
Average tokens 32.8
Tokenizer Hermes-3-Llama-3.1-8B

The quantized model used is a GGUF (e.g. Discord-Hermes-3-8B.Q3_K_M.gguf).

Discord-Dialogues dataset distribution


Complete Lifecycle -- full bot behavior from message to response, including timers and edge cases

Architecture diagrams

The state-machines/ folder contains 24 Mermaid diagrams covering the entire source code. Each diagram has a detailed explanation in plain language.

Among the most important:

# Diagram Type
01 Architecture Overview graph
02 Message Processing (complete) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

These diagrams are a goldmine for understanding the complete flow: from incoming message to response, including timers and edge cases.


The trigger code in detail

The trigger is evaluated by evaluateMessage() in state/trigger.ts. Here is the complete logic:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching by name, keyword, follow-up, random
}

The regex cache (hasWordCache) avoids recompiling patterns on every message.


Reactions

Luna reacts to messages with emojis. 30% chance to use a custom server emoji, 70% a unicode emoji. The reaction is triggered after the focus delay, not immediately.

Reaction commands on Luna's own messages:

  • ❌ -> Stop
  • ▶️ -> Start
  • 🗑️ -> Clear

Reply style

The reply style is weighted based on Luna's recent activity in the channel:

Context messageReference mentionRepliedUser Weight
Cold true false 70%
Cold true true 20%
Cold false false 10%
Active true false 50%
Active true true 15%
Active false false 30%
Active false true 5%

In DMs, messageReference is always false.


Burst messages

With 15% chance, a response is split into 2-3 fragments sent at a human pace (1.5-4 seconds between each fragment). Simulates someone typing in multiple bursts.

Timing Gantt -- real wait times for delays, reactions, LLM streaming, and corrections


Dynamic status

Luna's Discord status alternates between several configured presets, rotating every 15 minutes. Supported types: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). During sleep, the status switches to invisible.

dynamic_status_presets:
  - status: online
    text: "with pixels"
    type: 0       # Playing
  - status: idle
    text: "white noise"
    type: 2       # Listening

A random jitter (x0.5-1.0) prevents predictable rotations. 10% of attempts are skipped to avoid repetition.

Typing indicator

Before calling the LLM, Luna calls startTyping(). A setInterval refreshes the indicator every 8 seconds during generation. Cleaned up in the finally block (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Crash recovery

If the LLM crashes (llama-server process dies), Luna detects the event via llmBus.emit("crash", code) and attempts a restart with exponential backoff. Prevents infinite restart loops.

LLM parameters

The parameters are hardcoded in src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

The ChatML template (<|im_start|>/<|im_end|>) is used. The thread count is auto-detected via os.cpus().length.


Setup

npm install
cp config.example.yml config.yml
# edit config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # production
Script Description
build Standalone CLI bundle
start Launches the bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF from HuggingFace
diagrams Exports Mermaid diagrams to SVG/PNG

PM2 deployment

./start.sh   # launches llm-server + llm-client under PM2

Conclusion

Luna Protocol is not just a Discord bot with an LLM. It is a complete behavioral system that simulates human imperfections: forgetfulness, typos, sleep, hesitations, fatigue. All architected around a typed event bus, with 24 Mermaid diagrams documenting every flow.

The code is open source, the dataset is public, and the configuration is hot-reloadable. If the topic interests you, dive into the code -- it's more accessible than it looks.

Resource Link
GitHub repository fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol : j'ai créé un bot Discord autonome qui simule un être humain

Luna Protocol est un bot Discord entièrement autonome doté d'un LLM local, capable de conversation naturelle avec sommeil, fautes de frappe, hésitations, oublis, fatigue thématique et messages spontanés.

Luna Protocol : j'ai créé un bot Discord autonome qui simule un être humain

Et si un bot Discord pouvait dormir, faire des fautes de frappe, hésiter, oublier de répondre, et parfois vous envoyer un message de son propre chef ? C'est exactement ce que fait Luna Protocol : un bot Discord entièrement autonome qui fait tourner un LLM local (llama.cpp) et converse comme un être humain imparfait.

Pas de prompts rigides, pas de réponses robotiques. Luna a un système de déclenchement prioritaire, des délais variables, des horaires de sommeil, des messages spontanés, et même une pipeline TTS pour envoyer des messages vocaux. Le tout configuré via un simple fichier config.yml hot-reloadable.

Dans cet article, on décortique l'architecture complète : du bus d'événements générique au pipeline TTS, en passant par le système de déclenchement, les composants humains, et le dataset de fine-tuning.

Architecture Overview -- composants globaux et flux de données


L'architecture : un bus d'événements typé

Le cœur de Luna est un TypedBus -- un bus d'événements générique fortement typé en TypeScript. C'est la brique fondamentale sur laquelle tout repose.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Deux buses principaux en découlent :

  • llmBus -- gère les tokens LLM, les erreurs, les crashes, le reset
  • stateBus -- gère les changements d'état avec persistence automatique
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

L'avantage de cette approche : chaque module est déconnecté du reste. Le LLM émet des tokens sur le bus, le bot les consomme, le state se met à jour automatiquement. Aucune dépendance circulaire.


Message Processing -- flux complet de traitement d'un message

Le système de déclenchement : qui décide quand Luna répond ?

Chaque message entrant est évalué par evaluateMessage() qui retourne un TriggerResult avec une raison de déclenchement. L'ordre de priorité est critique :

# Raison Conditions Bypass ignore Bypass pause
1 mention @bot Oui (0%) Oui
2 dm MP avec replyInDM = true Oui (0%) Non
3 name "Luna"/"Pixie"/alias (mot entier) Non (8%) Non
4 keyword hello, hi, ai, bot... (mot entier) Non (8%) Non
5 follow-up Bot était dernier locuteur + < 15s + < 3 / 60s -- --
6 random 1.5% de chance sur les messages non correspondants Non (8%) Non

Le matching est mot entier (\b) : "ai" ne correspond pas à "mais", "vrai", "lait".

Trigger evaluation -- décision d'entrée pour chaque message

Le mécanisme de follow-up

Quand Luna répond à un message, elle s'enregistre comme lastSpeaker. Tout message suivant dans les 15 secondes déclenche une réponse immédiate -- pas de timer, pas de vérification de keyword. Budget : 3 follow-ups par fenêtre de 60 secondes.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Le cooldown

8 secondes entre deux réponses dans le même canal. Contourné par les mentions et les follow-ups.


Les comportements humains : la concentration variable

C'est ici que Luna devient intéressante. Chaque type de déclenchement a ses propres seuils de concentration : un délai min/max, une chance d'ignorer, et une chance de réagir.

Trigger Délai min Délai max Ignore Réaction
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

Le calcul du délai prend aussi en compte :

  • La longueur du message : plus le message est long, plus Luna met de temps à "lire"
  • L'inactivité : si Luna n'a pas été active depuis 10 minutes, le délai est multiplié par 2 (simulation du "réveil")
  • Le sommeil : en mode slow, le délai est multiplié par 3 à 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

Les horaires de sommeil

Luna peut dormir. Configurable via config.yml :

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Mode Effet
sleep Seules les mentions et MP passent
slow Délai ×3-5, réactions quasi nulles
short Chance d'ignore +30%, réactions quasi nulles

Pendant les heures de sommeil, le statut Discord passe en invisible.


Les fautes de frappe

Luna peut faire des fautes de frappe -- et les corriger après 2-4 secondes. Le layout clavier est configurable (AZERTY ou QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... toutes les touches adjacentes
};

Exemple AZERTY : bonjour → bonjpur, salut → slaut, comment → cpmment.

Trois styles de correction :

Style Comportement
edit Édite le message
message Nouveau message : word*
mixed 50/50 aléatoire (défaut)

Les hésitations et les oublis

Hésitations : 15% de chance de commencer par un mot de remplissage (uh..., um..., well..., hmm..., so...).

Oublis : même après avoir matché un trigger, Luna peut "oublier" de répondre avec une probabilité de 3%. Pas de message, pas de réaction -- comme si elle n'avait rien vu.

Fatigue thématique : si un mot revient trop souvent dans les 10 derniers messages (seuil : 3 occurrences), les délais sont multipliés et la chance d'ignore augmente de 15%.


Le réalisme en pratique : quand ça devient flippant

Sur le papier, tous ces systèmes (délais, fautes de frappe, hésitations, oublis) ça a l'air gadget. En vrai, mis bout à bout, le résultat est bluffant -- au point que des gens en conversation avec Luna ne se doutent de rien. Voici un échange réel où quelqu'un demande directement à Luna si c'est un bot, et où la conversation dérive ensuite sur un tout autre sujet, avec un naturel qui fait froid dans le dos.

Conversation Discord avec Luna -- premier échange

Le ton, les hésitations, le rythme des réponses -- rien ne trahit un bot. Luna botte en touche sur la question de l'âge sans jamais casser le personnage.

Conversation Discord avec Luna -- suite de l'échange

La conversation continue de façon totalement fluide, avec des réponses courtes et naturelles ("okay, okay", "yay", "yayy") -- exactement le genre de messages qu'un humain enverrait sur Discord.

Conversation Discord avec Luna -- dérive du sujet

Conversation Discord avec Luna -- suite de la dérive

Conversation Discord avec Luna -- fin de l'échange

Ce qui est terrifiant, ce n'est pas juste que Luna "répond" -- c'est qu'elle tient une conversation, avec des opinions apparentes, des relances, un fil de pensée cohérent d'un message à l'autre. Sans le système de déclenchement, les délais de concentration et les hésitations décrits plus haut, cette illusion s'effondrerait en quelques messages.

Petit plot twist : dans les screenshots ci-dessus, les deux comptes qui discutent sont des instances de Luna. PixieGlow et Sujet d'SBlow ne sont pas un humain qui teste un bot -- ce sont deux bots qui se parlent entre eux, chacun persuadé (au sens comportemental du terme) de discuter avec quelqu'un de "normal". Si en lisant l'échange plus haut vous avez supposé qu'un des deux interlocuteurs était humain, félicitations -- vous venez de tomber dans le piège exactement comme n'importe qui le ferait sur un vrai serveur Discord.

C'est un peu la dead internet theory version pratique : cette théorie (à l'origine plutôt conspirationniste) postule qu'une part croissante du contenu et des interactions en ligne serait générée par des bots plutôt que par des humains, au point que le "vrai" internet humain serait devenu minoritaire. Longtemps considérée comme exagérée, elle devient de moins en moins absurde à mesure que des systèmes comme Luna Protocol montrent qu'il ne faut ni beaucoup de moyens ni un modèle énorme pour simuler une présence humaine crédible à grande échelle. Deux instances du même bot capables de tenir une conversation à rallonge sans jamais se trahir, ça donne un aperçu assez concret de ce à quoi pourrait ressembler un web peuplé majoritairement de bots qui se parlent entre eux.


Le pipeline LLM : deux modes

Mode direct (défaut)

Le bot envoie directement les requêtes à un llama-server local en HTTP. Le modèle est partagé, avec prompt cache et 4 slots concurrents. Deux processus PM2 : le serveur LLM et le client bot.

Mode online

Le bot appelle n'importe quelle API compatible OpenAI (OpenAI, OpenRouter, Groq, Together...). Pas de LLM local nécessaire.

Le streaming en temps réel

Le LLM stream sa réponse ligne par ligne (\n). Chaque ligne est découpée en mots, émis un par un sur llmBus.emit("token", word). À chaque \n, un événement flush est émis -- le bot envoie immédiatement le message accumulé. Pas de délai simulé : le rythme est celui du LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

La file d'attente (requestQueue) traite les requêtes une par une, avec nettoyage automatique quand la file dépasse 100 éléments.


Les messages spontanés

Toutes les 5 minutes, 12% de chance que Luna poste un message de son propre chef. Le serveur est sélectionné par un système de poids linéaire : le serveur le plus actif a N× plus de chances que le dernier.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Le contexte des 5 derniers messages est lu, et Luna joint la conversation "naturellement".


La pipeline TTS : messages vocaux

Avec 8% de chance, Luna envoie un message vocal au lieu de texte. La pipeline complète :

  1. Piper TTS synthétise le texte en WAV
  2. ffmpeg convertit en OGG
  3. Le waveform est calculé pour l'aperçu Discord
  4. Le fichier est uploadé via l'API Discord CDN
  5. Le message vocal est envoyé
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS Pipeline -- du texte synthétisé au message vocal Discord


L'anti-spam et la persistence

Anti-spam

File d'attente par channelId:userId. Un seul message en file par utilisateur par canal. Traité dès que la réponse en cours se termine.

Limites de session

Après 8 échanges, Luna fait une pause de 30 secondes. Le compteur se réinitialise après 3 minutes d'inactivité.

Persistence automatique

Chaque mutation d'état émet sur stateBus → sauvegarde automatique (debounce 500ms). Plus besoin d'appels saveAllState() manuels. L'état persisté inclut : pendingMessages, paused, cooldowns, timestamps, lastSpeaker, compteurs de follow-up.


La configuration hot-reload

Un seul fichier config.yml. La plupart des valeurs sont hot-reloadable -- les changements sont pris en compte sans redémarrage.

Catégorie Hot-reload
Triggers, keywords, noms ✅
Concentration, délais ✅
Typos, burst, fatigue ✅
Sleep schedules ✅
TTS, voice messages ✅
Discord token, LLM mode ❌ (redémarrage requis)
// config.ts -- les getters retournent des valeurs live
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Le dataset : Discord-Dialogues

Le modèle est fine-tuné sur Discord-Dialogues : 7.3M échanges, 17M tours, 140M mots. Des vraies conversations Discord printemps-été 2025, filtrées (PII, ToS, bots, commandes). Apache 2.0.

Métrique Valeur
Échantillons 7 303 464
Tours totaux 16 881 010
Mots totaux 139 922 950
Tokens moyens 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Le modèle quantifié utilisé est un GGUF (par exemple Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribution du dataset Discord-Dialogues


Complete Lifecycle -- comportement complet du bot du message à la réponse, incluant les timers et cas limites

Les diagrammes d'architecture

Le dossier state-machines/ contient 24 diagrammes Mermaid couvrant l'ensemble du code source. Chaque diagramme a une explication détaillée en langage humain.

Parmi les plus importants :

# Diagramme Type
01 Architecture Overview graph
02 Message Processing (complet) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Ces diagrammes sont une mine d'or pour comprendre le flux complet : du message entrant à la réponse, en passant par les timers et les cas limites.


Le code de déclenchement en détail

Le trigger est évalué par evaluateMessage() dans state/trigger.ts. Voici la logique complète :

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching par nom, keyword, follow-up, random
}

Le cache de regex (hasWordCache) évite de recompiler les patterns à chaque message.


Les réactions

Luna réagit aux messages avec des emojis. 30% de chance d'utiliser un emoji custom du serveur, 70% un emoji unicode. La réaction est déclenchée après le délai de concentration, pas immédiatement.

Les commandes par réaction sur les messages de Luna :

  • ❌ → Stop
  • ▶️ → Start
  • 🗑️ → Clear

Le style de réponse

Le style de réponse est pondéré selon l'activité récente de Luna dans le canal :

Contexte messageReference mentionRepliedUser Poids
Froid true false 70%
Froid true true 20%
Froid false false 10%
Actif true false 50%
Actif true true 15%
Actif false false 30%
Actif false true 5%

En MP, messageReference est toujours false.


Les messages en rafale

Avec 15% de chance, une réponse est découpée en 2-3 fragments envoyés au rythme humain (1.5-4 secondes entre chaque fragment). Simule quelqu'un qui tape en plusieurs fois.

Timing Gantt -- temps d'attente réels pour les délais, réactions, streaming LLM et corrections


Le statut dynamique

Le statut Discord de Luna alterne entre plusieurs presets configurés, tournant toutes les 15 minutes. Types supportés : Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Pendant le sommeil, le statut passe en invisible.

dynamic_status_presets:
  - status: online
    text: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

Un jitter aléatoire (×0.5-1.0) évite les rotations prévisibles. 10% des tentatives sont sautées pour éviter la répétition.

L'indicateur de frappe

Avant d'appeler le LLM, Luna appelle startTyping(). Un setInterval rafraîchit l'indicateur toutes les 8 secondes pendant la génération. Nettoyé dans le finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

La récupération après crash

Si le LLM crash (processus llama-server qui meurt), Luna détecte l'événement via llmBus.emit("crash", code) et tente de redémarrer avec un backoff exponentiel. Évite les boucles de redémarrage infini.

Les paramètres LLM

Les paramètres sont hardcodés dans src/config.ts :

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Le template ChatML (<|im_start|>/<|im_end|>) est utilisé. Le nombre de threads est auto-détecté via os.cpus().length.


Mise en place

npm install
cp config.example.yml config.yml
# éditer config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # production
Script Description
build Bundle CLI autonome
start Lance le bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF depuis HuggingFace
diagrams Exporte les diagrammes Mermaid en SVG/PNG

Déploiement PM2

./start.sh   # lance llm-server + llm-client sous PM2

Conclusion

Luna Protocol n'est pas juste un bot Discord avec un LLM. C'est un système comportemental complet qui simule les imperfections humaines : les oublis, les fautes de frappe, le sommeil, les hésitations, la fatigue. Le tout architecturé autour d'un bus d'événements typé, avec 24 diagrammes Mermaid documentant chaque flux.

Le code est open source, le dataset est public, et la configuration est hot-reloadable. Si le sujet vous intéresse, plongez dans le code -- c'est plus accessible qu'il n'y paraît.

Ressource Lien
Dépôt GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol:我创建了一个模拟人类的自主 Discord 机器人

Luna Protocol 是一个完全自主的 Discord 机器人,配备本地 LLM,能够进行自然对话,具备睡眠、打字错误、犹豫、遗忘、主题疲劳和自发消息等人类特征。

Luna Protocol:我创建了一个模拟人类的自主 Discord 机器人

如果一个 Discord 机器人能够睡觉、打错字、犹豫、忘记回复,甚至有时主动给你发消息,会怎么样?这正是 Luna Protocol 所做的:一个完全自主的 Discord 机器人,运行本地 LLM(llama.cpp),像一个不完美的人类一样交流。

没有僵硬的提示词,没有机械的回复。Luna 拥有优先级触发系统、可变延迟、睡眠时间表、自发消息,甚至还有用于发送语音消息的 TTS 管道。全部通过一个可热重载的 config.yml 文件配置。

本文深入解析完整架构:从通用事件总线到 TTS 管道,再到触发系统、人类行为组件和微调数据集。

架构概览 -- 全局组件与数据流


架构:类型化事件总线

Luna 的核心是一个 TypedBus -- 一个用 TypeScript 编写的强类型通用事件总线。这是所有功能的基础构件。

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

由此衍生出两个主要总线:

  • llmBus -- 管理 LLM 令牌、错误、崩溃、重置
  • stateBus -- 管理状态变更并自动持久化
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

这种方法的优势在于:每个模块都与其他模块解耦。LLM 在总线上发出令牌,机器人消费它们,状态自动更新。没有循环依赖。


消息处理 -- 完整的消息处理流程

触发系统:谁决定 Luna 何时回复?

每条进入的消息都由 evaluateMessage() 评估,返回一个包含触发原因的 TriggerResult。优先级顺序至关重要:

# 原因 条件 绕过忽略 绕过暂停
1 mention @机器人 是 (0%) 是
2 dm 私信且 replyInDM = true 是 (0%) 否
3 name "Luna"/"Pixie"/别名(完整单词) 否 (8%) 否
4 keyword hello, hi, ai, bot...(完整单词) 否 (8%) 否
5 follow-up 机器人是最后发言者 + < 15秒 + < 3次/60秒 -- --
6 random 1.5% 概率命中不匹配的消息 否 (8%) 否

匹配方式是完整单词(\b):"ai" 不会匹配 "mais"、"vrai"、"lait"。

触发评估 -- 每条消息的入口决策

跟进机制

当 Luna 回复一条消息时,她会将自己注册为 lastSpeaker。之后 15 秒内的任何消息都会触发即时回复 -- 无需计时器,无需检查关键词。预算:每 60 秒窗口最多 3 次跟进。

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

冷却时间

同一频道内两次回复之间间隔 8 秒。提及和跟进可以绕过。


人类行为:可变的注意力集中度

这就是 Luna 变得有趣的地方。每种触发类型都有自己的注意力阈值:最小/最大延迟、忽略概率和反应概率。

触发类型 最小延迟 最大延迟 忽略 反应
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

延迟计算还考虑:

  • 消息长度:消息越长,Luna "阅读"的时间越长
  • 不活跃时间:如果 Luna 超过 10 分钟没有活动,延迟乘以 2(模拟"唤醒")
  • 睡眠模式:在 slow 模式下,延迟乘以 3 到 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // 激进抖动
  return delay;
}

睡眠时间表

Luna 可以睡觉。通过 config.yml 配置:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
模式 效果
sleep 只有提及和私信通过
slow 延迟 ×3-5,反应几乎为零
short 忽略概率 +30%,反应几乎为零

在睡眠时段,Discord 状态切换为 invisible。


打字错误

Luna 可以打错字 -- 并在 2-4 秒后修正。键盘布局可配置(AZERTY 或 QWERTY)。

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... 所有相邻按键
};

AZERTY 示例:bonjour → bonjpur,salut → slaut,comment → cpmment。

三种修正样式:

样式 行为
edit 编辑原消息
message 新消息:word*
mixed 随机 50/50(默认)

犹豫和遗忘

犹豫:15% 的概率以填充词开头(uh...、um...、well...、hmm...、so...)。

遗忘:即使触发了触发器,Luna 仍有 3% 的概率"忘记"回复。没有消息,没有反应 -- 就像什么也没看到。

主题疲劳:如果某个词在最近 10 条消息中出现过于频繁(阈值:3 次),延迟会成倍增加,忽略概率增加 15%。


现实中的真实感:细思极恐的时刻

纸面上看,这些机制(延迟、打字错误、犹豫、遗忘)听起来像是噱头。但实际上,把它们组合在一起,效果令人震惊----以至于和 Luna 聊天的人完全察觉不到异样。下面是一段真实的对话,有人直接问 Luna 是不是机器人,随后话题又自然地转移到完全不同的方向,那种自然感让人不寒而栗。

与 Luna 的 Discord 对话 -- 第一段

语气、犹豫、回复节奏----完全看不出是机器人。Luna 巧妙地回避了年龄问题,全程没有露出破绽。

对话继续自然流畅地进行,简短而真实的回复("okay, okay"、"yay"、"yayy")----正是人类在 Discord 上会发的那种消息。

与 Luna 的 Discord 对话 -- 继续

与 Luna 的 Discord 对话 -- 话题转移

与 Luna 的 Discord 对话 -- 话题继续转移

与 Luna 的 Discord 对话 -- 对话结束

真正让人毛骨悚然的不只是 Luna 会“回复”----而是她能维持一段对话,有看似真实的观点、追问,以及从一条消息到下一条消息一以贯之的思路。如果没有前面提到的触发系统、专注延迟和犹豫机制,这种幻觉几条消息内就会破功。

小小的反转: 上面截图里,这两个在聊天的账号其实都是 Luna 的实例。PixieGlow 和 Sujet d'SBlow 不是一个人类在测试机器人----而是两个机器人在互相对话,每一个(在行为层面上)都"确信"自己在和一个"正常"的人聊天。如果你在读上面的对话时以为其中一个是人类,恭喜----你刚刚和在真实 Discord 服务器上的任何人一样,掉进了这个陷阱。

这基本上就是死亡互联网理论的实践版本:这个理论(最初算是比较边缘的阴谋论)认为,越来越多的网络内容和互动是由机器人而非人类生成的,以至于"真正"由人类构成的互联网正变成少数派。长期以来这个说法被认为夸张,但当 Luna Protocol 这样的系统证明,要在大规模上模拟出可信的人类存在,既不需要多少算力,也不需要一个庞大的模型时,这个理论就显得越来越不荒谬了。同一个机器人的两个实例能够进行一场很长的对话而从不露馅,这相当具体地展示了一个主要由机器人互相对话构成的网络会是什么样子。


LLM 管道:两种模式

direct 模式(默认)

机器人直接通过 HTTP 向本地 llama-server 发送请求。模型共享,支持提示缓存和 4 个并发槽位。两个 PM2 进程:LLM 服务器和机器人客户端。

online 模式

机器人调用任何兼容 OpenAI 的 API(OpenAI、OpenRouter、Groq、Together...)。无需本地 LLM。

实时流式传输

LLM 逐行流式输出响应(\n)。每行被分割成单词,通过 llmBus.emit("token", word) 逐个发出。每遇到 \n,发出 flush 事件 -- 机器人立即发送累积的消息。没有模拟延迟:节奏由 LLM 决定。

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

请求队列(requestQueue)逐个处理请求,队列超过 100 项时自动清理。


自发消息

每 5 分钟,Luna 有 12% 的概率主动发一条消息。服务器通过线性权重系统选择:最活跃的服务器概率是最后的 N 倍。

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

读取最近 5 条消息的上下文,Luna "自然地"加入对话。


TTS 管道:语音消息

有 8% 的概率,Luna 发送语音消息而不是文本。完整流程:

  1. Piper TTS 将文本合成为 WAV
  2. ffmpeg 转换为 OGG
  3. 计算波形用于 Discord 预览
  4. 通过 Discord CDN API 上传文件
  5. 发送语音消息
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS 管道 -- 从合成文本到 Discord 语音消息


反垃圾和持久化

反垃圾

按 channelId:userId 排队。每个用户每个频道只排队一条消息。当前回复结束后立即处理。

会话限制

8 次对话后,Luna 暂停 30 秒。计数器在 3 分钟不活动后重置。

自动持久化

每次状态变更都会在 stateBus 上发出事件 → 自动保存(防抖 500ms)。不再需要手动调用 saveAllState()。持久化状态包括:pendingMessages、paused、冷却时间、时间戳、lastSpeaker、跟进计数器。


热重载配置

单一 config.yml 文件。大多数值都是热重载的 -- 更改无需重启即可生效。

类别 热重载
触发器、关键词、名称 ✅
注意力、延迟 ✅
打字错误、突发、疲劳 ✅
睡眠时间表 ✅
TTS、语音消息 ✅
Discord 令牌、LLM 模式 ❌(需要重启)
// config.ts -- getter 返回实时值
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

数据集:Discord-Dialogues

模型基于 Discord-Dialogues 进行微调:730 万次对话、1690 万轮、1.4 亿词。来自 2025 年春夏季的真实 Discord 对话,经过过滤(PII、ToS、机器人、命令)。Apache 2.0。

指标 值
样本数 7 303 464
总轮数 16 881 010
总词数 139 922 950
平均令牌数 32.8
分词器 Hermes-3-Llama-3.1-8B

使用的量化模型是 GGUF(例如 Discord-Hermes-3-8B.Q3_K_M.gguf)。

Discord-Dialogues 数据集分布


完整生命周期 -- 从消息到回复的完整机器人行为,包括计时器和边界情况

架构图

state-machines/ 目录包含 24 张 Mermaid 图,覆盖了整个源代码。每张图都配有详细的人类语言说明。

其中最重要的:

# 图 类型
01 架构概览 graph
02 消息处理(完整) stateDiagram
03 触发评估 flowchart
04 LLM 核心队列(3 个后端) stateDiagram
10 TTS 管道 flowchart
13 状态持久化 flowchart
21 时间甘特图 gantt
22 完整生命周期 stateDiagram

这些图是理解完整流程的宝库:从消息进入到回复,包括计时器和边界情况。


触发代码详解

触发器由 state/trigger.ts 中的 evaluateMessage() 评估。以下是完整逻辑:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... 按名称、关键词、跟进、随机匹配
}

正则缓存(hasWordCache)避免为每条消息重新编译模式。


反应

Luna 使用表情符号对消息做出反应。30% 概率使用服务器自定义表情,70% 使用 Unicode 表情。反应在注意力延迟之后触发,而非立即。

对 Luna 消息的反应命令:

  • ❌ → 停止
  • ▶️ → 开始
  • 🗑️ → 清除

回复风格

回复风格根据 Luna 在频道中的最近活动进行加权:

上下文 messageReference mentionRepliedUser 权重
冷 true false 70%
冷 true true 20%
冷 false false 10%
活跃 true false 50%
活跃 true true 15%
活跃 false false 30%
活跃 false true 5%

在私信中,messageReference 始终为 false。


突发消息

有 15% 的概率,一条回复被分成 2-3 个片段,以人类的速度发送(每个片段间隔 1.5-4 秒)。模拟某人分多次打字。

时间甘特图 -- 延迟、反应、LLM 流和修正的实际等待时间


动态状态

Luna 的 Discord 状态在多个配置的预设之间轮换,每 15 分钟切换一次。支持的类型:Playing (0)、Streaming (1)、Listening (2)、Watching (3)、Custom (4)、Competing (5)。睡眠期间,状态切换为 invisible。

dynamic_status_presets:
  - status: online
    text: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

随机抖动(×0.5-1.0)避免可预测的轮换。10% 的尝试被跳过以避免重复。

打字指示器

在调用 LLM 之前,Luna 调用 startTyping()。一个 setInterval 在生成期间每 8 秒刷新一次打字指示器。在 finally 块中清理(clearInterval)。

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

崩溃恢复

如果 LLM 崩溃(llama-server 进程终止),Luna 通过 llmBus.emit("crash", code) 检测到事件,并尝试以指数退避策略重启。避免无限重启循环。

LLM 参数

参数硬编码在 src/config.ts 中:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

使用 ChatML 模板(<|im_start|>/<|im_end|>)。线程数通过 os.cpus().length 自动检测。


部署

npm install
cp config.example.yml config.yml
# 编辑 config.yml
npm run dev                    # 开发(热重载)
npm run build && npm start     # 生产
脚本 说明
build 独立 CLI 打包
start 启动机器人
lint / format / check Biome
test 测试(Bun)
download-model 从 HuggingFace 下载 GGUF
diagrams 将 Mermaid 图导出为 SVG/PNG

PM2 部署

./start.sh   # 在 PM2 下启动 llm-server + llm-client

结论

Luna Protocol 不仅仅是一个带 LLM 的 Discord 机器人。它是一个完整的行为系统,模拟人类的不完美之处:遗忘、打字错误、睡眠、犹豫、疲劳。整个架构围绕类型化事件总线构建,配有 24 张 Mermaid 图记录每个流程。

代码是开源的,数据集是公开的,配置是可热重载的。如果你对这个主题感兴趣,深入代码看看吧 -- 它比看起来更容易上手。

资源 链接
GitHub 仓库 fox3000foxy/luna-protocol-project
数据集 Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: 自律型Discordボットが人間らしい会話を実現

Luna Protocolは、ローカルLLMを搭載した完全自律型Discordボット。睡眠、タイプミス、ためらい、物忘れ、テーマ疲れ、自発的なメッセージなど、人間らしい不完全な会話を実現します。

Luna Protocol: 自律型Discordボットが人間らしい会話を実現

もしDiscordボットが眠り、タイプミスをし、ためらい、返信を忘れ、時には自発的にメッセージを送ったらどうでしょうか?それがまさにLuna Protocolが実現するものです。ローカルLLM(llama.cpp)を実行する完全自律型Discordボットで、不完全な人間のように会話します。

硬直したプロンプトもロボット的な応答もありません。Lunaには優先トリガーシステム、可変遅延、睡眠スケジュール、自発的なメッセージ、そして音声メッセージを送信するためのTTSパイプラインまで備わっています。すべてホットリロード可能な単一のconfig.ymlで設定できます。

この記事では、汎用イベントバスからTTSパイプライン、トリガーシステム、人間らしいコンポーネント、ファインチューニングデータセットまで、完全なアーキテクチャを解説します。

アーキテクチャ概要 -- グローバルコンポーネントとデータフロー


アーキテクチャ: 型付きイベントバス

Lunaの中核はTypedBus -- TypeScriptで実装された汎用的で強い型付けのイベントバスです。すべての基盤となる基本要素です。

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

ここから2つの主要なバスが派生します:

  • llmBus -- LLMトークン、エラー、クラッシュ、リセットを管理
  • stateBus -- 自動永続化を伴う状態変更を管理
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

このアプローチの利点: 各モジュールは互いに分離されています。LLMがバスにトークンを発行し、ボットがそれを消費し、状態が自動的に更新されます。循環依存関係はありません。


メッセージ処理 -- メッセージの完全な処理フロー

トリガーシステム: Lunaがいつ応答するかを決める仕組み

受信した各メッセージはevaluateMessage()によって評価され、トリガー理由を含むTriggerResultが返されます。優先順位は重要です:

# 理由 条件 Bypass ignore Bypass pause
1 mention @bot はい (0%) はい
2 dm ダイレクトメッセージ replyInDM = true はい (0%) いいえ
3 name "Luna"/"Pixie"/エイリアス (単語全体) いいえ (8%) いいえ
4 keyword hello, hi, ai, bot... (単語全体) いいえ (8%) いいえ
5 follow-up ボットが最後の話者 + 15秒未満 + 60秒あたり3回未満 -- --
6 random 該当しないメッセージに1.5%の確率 いいえ (8%) いいえ

マッチングは単語全体(\b)で行われます。"ai"は"mais"、"vrai"、"lait"などの単語の一部にはマッチしません。

トリガー評価 -- 各メッセージの入力判定

フォローアップの仕組み

Lunaがメッセージに応答すると、自身をlastSpeakerとして登録します。その後15秒以内のメッセージは即座に応答をトリガーします -- タイマーもキーワードチェックもありません。予算: 60秒のウィンドウあたり最大3回のフォローアップ。

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

クールダウン

同じチャンネルでの連続応答の間に8秒の間隔があります。メンションとフォローアップではバイパスされます。


人間らしい動作: 可変集中力

ここがLunaの面白いところです。各トリガータイプには独自の集中力しきい値があります。最小/最大遅延、無視する確率、反応する確率です。

トリガー 最小遅延 最大遅延 無視 反応
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

遅延の計算には以下も考慮されます:

  • メッセージの長さ: メッセージが長いほど、Lunaが"読む"のに時間がかかります
  • 非アクティブ状態: 10分以上アクティブでない場合、遅延は2倍になります("起床"のシミュレーション)
  • 睡眠: slowモードでは、遅延が3~5倍になります
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

睡眠スケジュール

Lunaは眠ることができます。config.ymlで設定可能:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
モード 効果
sleep メンションとダイレクトメッセージのみ通過
slow 遅延×3-5、反応ほぼなし
short 無視確率+30%、反応ほぼなし

睡眠中はDiscordのステータスがinvisibleになります。


タイプミス

Lunaはタイプミスをし、2~4秒後に修正することができます。キーボードレイアウトは設定可能です(AZERTYまたはQWERTY)。

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... toutes les touches adjacentes
};

AZERTYの例: bonjour → bonjpur, salut → slaut, comment → cpmment

3つの修正スタイル:

スタイル 動作
edit メッセージを編集
message 新しいメッセージ: word*
mixed 50/50ランダム(デフォルト)

ためらいと物忘れ

ためらい: 15%の確率でフィラーワード(uh...、um...、well...、hmm...、so...)で始まります。

物忘れ: トリガーにマッチした後でも、Lunaは3%の確率で応答を"忘れる"ことがあります。メッセージも反応もなし -- 何も見なかったかのように。

テーマ疲れ: 直近10メッセージ内で特定の単語が出現しすぎた場合(しきい値: 3回)、遅延が増加し、無視確率が15%上昇します。


実際の再現度:ぞっとする瞬間

紙の上では、これらの仕組み(遅延、タイプミス、ためらい、忘却)はただのギミックに聞こえる。だが実際に組み合わさると、その結果は驚くほどで、Lunaと会話している人はまったく気づかない。以下は、誰かがLunaに直接「ボットなのか」と尋ね、その後会話がまったく別の話題へと、ぞっとするほど自然に流れていった実際のやり取りだ。

LunaとのDiscord会話 -- 最初のやり取り

口調、ためらい、返信のテンポ -- ボットだとわかる要素は一切ない。Lunaは年齢の質問をキャラクターを崩さずに巧みにかわす。

会話はまったく自然に続き、「okay, okay」「yay」「yayy」といった短く人間らしい返信が続く -- まさに人がDiscordで送るようなメッセージだ。

LunaとのDiscord会話 -- 続き

LunaとのDiscord会話 -- 話題の転換

LunaとのDiscord会話 -- 話題転換の続き

LunaとのDiscord会話 -- やり取りの終わり

恐ろしいのはLunaが「返信する」ことだけではない -- 一貫した意見や相槌、メッセージごとに筋の通った思考の流れを持って会話を成立させていることだ。上で説明したトリガーシステム、集中遅延、ためらいがなければ、この幻想は数メッセージで崩れてしまうだろう。

ちょっとした どんでん返し: 上のスクリーンショットでは、会話している2つのアカウントはどちらもLunaのインスタンスだ。PixieGlowとSujet d'SBlowは、人間がボットをテストしているのではない -- 互いに会話する2体のボットであり、それぞれが(振る舞いの上で)「普通の」相手と話していると"確信"している。上の会話を読んで、どちらかが人間だと思ったなら -- おめでとう、あなたはまさに本物のDiscordサーバーで誰もが引っかかるのと同じように、罠にはまったのだ。

これは実質的にデッドインターネット理論の実践版と言える。この理論(元々はかなり陰謀論寄りの説)は、オンライン上のコンテンツやインタラクションのますます大きな割合がボットによって生成されており、「本物の」人間のインターネットが少数派になりつつある、と主張する。長らく誇張だと見なされてきたが、Luna Protocolのようなシステムが、大規模に信頼できる人間の存在感をシミュレートするのに大した計算資源も巨大なモデルも要らないことを示すにつれ、この説はますます馬鹿げたものには見えなくなってきている。同じボットの2つのインスタンスが一度もボロを出さずに長い会話を続けられるという事実は、ボット同士が会話するウェブがどんなものになりうるかを、かなり具体的に垣間見せてくれる。


LLMパイプライン: 2つのモード

directモード(デフォルト)

ボットはローカルのllama-serverにHTTPで直接リクエストを送信します。モデルは共有され、プロンプトキャッシュと4つの同時スロットを使用します。2つのPM2プロセス: LLMサーバーとボットクライアント。

onlineモード

ボットはOpenAI互換のAPI(OpenAI、OpenRouter、Groq、Together...)を呼び出します。ローカルLLMは不要です。

リアルタイムストリーミング

LLMは応答を行ごとに(\n)ストリーミングします。各行は単語に分割され、llmBus.emit("token", word)で1語ずつ発行されます。\nごとにflushイベントが発行され、ボットは蓄積されたメッセージを即座に送信します。シミュレートされた遅延はありません。リズムはLLMのペースに合わせられます。

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

リクエストキュー(requestQueue)はリクエストを1つずつ処理し、キューが100要素を超えると自動的にクリーンアップされます。


自発的なメッセージ

5分ごとに12%の確率で、Lunaが自発的にメッセージを投稿します。サーバーは線形重みシステムで選択されます。最もアクティブなサーバーは最後のサーバーよりもN倍高い確率になります。

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

直近5件のメッセージのコンテキストが読み込まれ、Lunaが"自然に"会話に参加します。


TTSパイプライン: 音声メッセージ

8%の確率で、Lunaはテキストの代わりに音声メッセージを送信します。完全なパイプライン:

  1. Piper TTSがテキストをWAVに合成
  2. ffmpegがOGGに変換
  3. Discordプレビュー用に波形を計算
  4. Discord CDN API経由でファイルをアップロード
  5. 音声メッセージを送信
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTSパイプライン -- 合成テキストからDiscord音声メッセージへ


アンチスパムと永続化

アンチスパム

channelId:userIdごとのキュー。ユーザーとチャンネルの組み合わせにつき1メッセージのみキューイングされます。現在の応答が終了次第処理されます。

セッション制限

8回のやり取りの後、Lunaは30秒の休憩を取ります。カウンターは3分間の非アクティブ後にリセットされます。

自動永続化

状態の変更はstateBusに発行され → 自動保存(500msのデバウンス)。手動でのsaveAllState()呼び出しは不要です。永続化される状態: pendingMessages、paused、cooldowns、timestamps、lastSpeaker、フォローアップカウンター。


ホットリロード設定

単一のconfig.ymlファイル。ほとんどの値はホットリロード可能で、再起動なしで変更が反映されます。

カテゴリ ホットリロード
トリガー、キーワード、名前 ✅
集中力、遅延 ✅
タイプミス、バースト、疲れ ✅
睡眠スケジュール ✅
TTS、音声メッセージ ✅
Discordトークン、LLMモード ❌(再起動が必要)
// config.ts -- les getters retournent des valeurs live
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

データセット: Discord-Dialogues

モデルはDiscord-Dialoguesでファインチューニングされています: 730万のやり取り、1690万ターン、1億4000万語。2025年春から夏にかけての実際のDiscord会話をフィルタリングしたもの(PII、ToS、ボット、コマンドを除去)。Apache 2.0ライセンス。

指標 値
サンプル数 7 303 464
総ターン数 16 881 010
総単語数 139 922 950
平均トークン数 32.8
トークナイザー Hermes-3-Llama-3.1-8B

使用される量子化モデルはGGUFです(例: Discord-Hermes-3-8B.Q3_K_M.gguf)。

Discord-Dialoguesデータセットの分布


完全なライフサイクル -- メッセージから応答までのボットの完全な動作(タイマーとエッジケースを含む)

アーキテクチャ図

state-machines/ディレクトリにはソースコード全体をカバーする24のMermaid図が含まれています。各図には人間の言葉による詳細な説明が付いています。

最も重要なもの:

# 図 タイプ
01 アーキテクチャ概要 graph
02 メッセージ処理(完全版) stateDiagram
03 トリガー評価 flowchart
04 LLMコアキュー(3バックエンド) stateDiagram
10 TTSパイプライン flowchart
13 状態永続化 flowchart
21 タイミングガント gantt
22 完全なライフサイクル stateDiagram

これらの図は、受信メッセージから応答、タイマー、エッジケースに至るまでの完全なフローを理解するための宝庫です。


トリガーコードの詳細

トリガーはstate/trigger.tsのevaluateMessage()によって評価されます。以下が完全なロジックです:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching par nom, keyword, follow-up, random
}

正規表現キャッシュ(hasWordCache)により、毎回のメッセージでパターンを再コンパイルする必要がありません。


リアクション

Lunaは絵文字でメッセージにリアクションします。30%の確率でサーバーのカスタム絵文字、70%の確率でUnicode絵文字を使用します。リアクションは集中力遅延の後、即座ではなく遅延してトリガーされます。

Lunaのメッセージに対するリアクションコマンド:

  • ❌ → 停止
  • ▶️ → 開始
  • 🗑️ → クリア

応答スタイル

応答スタイルはチャンネルでのLunaの最近のアクティビティに応じて重み付けされます:

コンテキスト messageReference mentionRepliedUser 重み
コールド true false 70%
コールド true true 20%
コールド false false 10%
アクティブ true false 50%
アクティブ true true 15%
アクティブ false false 30%
アクティブ false true 5%

ダイレクトメッセージでは、messageReferenceは常にfalseです。


分割メッセージ

15%の確率で、応答は2~3の断片に分割され、人間らしいリズム(断片間1.5~4秒)で送信されます。複数回に分けてタイピングする人の動作をシミュレートします。

タイミングガント図 -- 遅延、リアクション、LLMストリーミング、修正の実際の待機時間


動的ステータス

LunaのDiscordステータスは設定された複数のプリセット間で切り替わり、15分ごとにローテーションします。サポートされているタイプ: Playing (0)、Streaming (1)、Listening (2)、Watching (3)、Custom (4)、Competing (5)。睡眠中はステータスがinvisibleになります。

dynamic_status_presets:
  - status: online
    text: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

ランダムなジッター(×0.5-1.0)により、予測可能なローテーションを避けます。試行の10%はスキップされ、繰り返しを防ぎます。

タイピングインジケーター

LLMを呼び出す前に、LunaはstartTyping()を呼び出します。setIntervalは生成中に8秒ごとにインジケーターを更新します。finallyブロックでクリーンアップされます(clearInterval)。

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

クラッシュ後の復旧

LLMがクラッシュした場合(llama-serverプロセスが停止)、LunaはllmBus.emit("crash", code)を介してイベントを検出し、指数バックオフで再起動を試みます。無限再起動ループを回避します。

LLMパラメーター

パラメーターはsrc/config.tsにハードコードされています:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

ChatMLテンプレート(<|im_start|>/<|im_end|>)が使用されています。スレッド数はos.cpus().lengthで自動検出されます。


セットアップ

npm install
cp config.example.yml config.yml
# config.ymlを編集
npm run dev                    # 開発(ホットリロード)
npm run build && npm start     # 本番
スクリプト 説明
build スタンドアロンCLIバンドル
start ボットを起動
lint / format / check Biome
test テスト(Bun)
download-model HuggingFaceからGGUFをダウンロード
diagrams Mermaid図をSVG/PNGにエクスポート

PM2デプロイ

./start.sh   # PM2でllm-server + llm-clientを起動

結論

Luna Protocolは、単なるLLMを搭載したDiscordボットではありません。人間の不完全さをシミュレートする完全な行動システムです。物忘れ、タイプミス、睡眠、ためらい、疲れ。すべて型付きイベントバスを中心にアーキテクチャが構築され、24のMermaid図が各フローを文書化しています。

コードはオープンソース、データセットは公開、設定はホットリロード可能です。興味があれば、コードを覗いてみてください -- 見た目よりも簡単です。

リソース リンク
GitHubリポジトリ fox3000foxy/luna-protocol-project
データセット Discord-Dialogues
Atlasマップ atlas.nomic.ai

Luna Protocol: 완전 자율적으로 인간을 시뮬레이션하는 Discord 봇을 만들었습니다

Luna Protocol은 로컬 LLM을 탑재한 완전 자율 Discord 봇으로, 수면, 오타, 망설임, 건망증, 주제 피로, 자발적 메시지 등이 포함된 자연스러운 대화가 가능합니다.

Luna Protocol: 완전 자율적으로 인간을 시뮬레이션하는 Discord 봇을 만들었습니다

Discord 봇이 잠을 자고, 오타를 내고, 망설이고, 답변을 까먹고, 때로는 스스로 메시지를 보낼 수 있다면? 이것이 바로 Luna Protocol이 하는 일입니다: 로컬 LLM(llama.cpp)을 구동하며 불완전한 인간처럼 대화하는 완전 자율 Discord 봇입니다.

딱딱한 프롬프트도, 로봇 같은 응답도 없습니다. Luna는 우선순위 트리거 시스템, 가변 지연 시간, 수면 스케줄, 자발적 메시지, 그리고 음성 메시지를 보내는 TTS 파이프라인까지 갖추고 있습니다. 모든 것은 간단한 config.yml 파일로 핫 리로드 가능하게 설정됩니다.

이 글에서는 제네릭 이벤트 버스부터 TTS 파이프라인, 트리거 시스템, 인간적 구성 요소, 파인튜닝 데이터셋까지 전체 아키텍처를 분석합니다.

전체 아키텍처 -- 글로벌 컴포넌트 및 데이터 흐름


아키텍처: 타입드 이벤트 버스

Luna의 핵심은 TypedBus입니다 -- TypeScript로 작성된 강력한 타입의 제네릭 이벤트 버스입니다. 모든 것이 이 위에 구축된 기본 빌딩 블록입니다.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

여기서 파생된 두 개의 주요 버스:

  • llmBus -- LLM 토큰, 오류, 크래시, 리셋 처리
  • stateBus -- 자동 저장이 포함된 상태 변경 처리
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash /  │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

이 접근 방식의 장점: 각 모듈은 나머지와 분리되어 있습니다. LLM이 버스에 토큰을 방출하면, 봇이 이를 소비하고, 상태가 자동으로 업데이트됩니다. 순환 의존성이 없습니다.


메시지 처리 -- 메시지의 전체 처리 흐름

트리거 시스템: 누가 Luna가 응답할지 결정하나요?

들어오는 모든 메시지는 evaluateMessage()에 의해 평가되어 트리거 이유가 포함된 TriggerResult를 반환합니다. 우선순위 순서가 중요합니다:

# 이유 조건 무시 우회 일시정지 우회
1 mention @bot 예 (0%) 예
2 dm DM (replyInDM = true) 예 (0%) 아니오
3 name "Luna"/"Pixie"/별칭 (단어 전체) 아니오 (8%) 아니오
4 keyword hello, hi, ai, bot... (단어 전체) 아니오 (8%) 아니오
5 follow-up 봇이 마지막 발화자 + < 15초 + < 3 / 60초 -- --
6 random 일치하지 않는 메시지에 1.5% 확률 아니오 (8%) 아니오

매칭은 단어 전체 (\b)입니다: "ai"는 "mais", "vrai", "lait"와 일치하지 않습니다.

트리거 평가 -- 각 메시지의 입력 결정

후속 메시지 메커니즘

Luna가 메시지에 응답하면 lastSpeaker로 등록됩니다. 15초 이내의 다음 메시지는 즉시 응답을 트리거합니다 -- 타이머나 키워드 확인이 없습니다. 예산: 60초 창에 3개의 후속 메시지.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

쿨다운

같은 채널에서 두 응답 사이 8초. 멘션과 후속 메시지는 우회합니다.


인간적 행동: 가변 집중도

여기서 Luna가 흥미로워집니다. 각 트리거 유형에는 고유한 집중도 임계값이 있습니다: 최소/최대 지연, 무시 확률, 반응 확률.

트리거 최소 지연 최대 지연 무시 반응
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

지연 계산에는 다음도 고려됩니다:

  • 메시지 길이: 메시지가 길수록 Luna가 "읽는" 데 시간이 더 걸림
  • 비활성 시간: Luna가 10분 이상 활동하지 않은 경우 지연이 2배 (각성 시뮬레이션)
  • 수면: slow 모드에서는 지연이 3~5배 증가
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

수면 스케줄

Luna는 잠을 잘 수 있습니다. config.yml로 설정 가능:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
모드 효과
sleep 멘션과 DM만 통과
slow 지연 ×3-5, 반응 거의 없음
short 무시 확률 +30%, 반응 거의 없음

수면 시간 동안 Discord 상태는 invisible로 전환됩니다.


오타

Luna는 오타를 낼 수 있습니다 -- 그리고 2-4초 후에 수정합니다. 키보드 레이아웃은 설정 가능합니다 (AZERTY 또는 QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... toutes les touches adjacentes
};

AZERTY 예시: bonjour -- bonjpur, salut -- slaut, comment -- cpmment.

세 가지 수정 스타일:

스타일 동작
edit 메시지 수정
message 새 메시지: word*
mixed 50/50 랜덤 (기본값)

망설임과 건망증

망설임: 15% 확률로 채움 단어(uh..., um..., well..., hmm..., so...)로 시작.

건망증: 트리거가 일치한 후에도 Luna는 3% 확률로 응답을 "까먹을" 수 있습니다. 메시지 없음, 반응 없음 -- 아무것도 보지 못한 것처럼.

주제 피로: 특정 단어가 최근 10개 메시지에서 너무 자주 나타나면 (임계값: 3회), 지연이 증가하고 무시 확률이 15% 증가합니다.


실전에서의 리얼리즘: 소름 돋는 순간

이론상으로는 이런 장치들(지연, 오타, 망설임, 망각)이 그냥 기믹처럼 들린다. 하지만 실제로 다 합쳐지면 결과는 놀랍다 -- Luna와 대화하는 사람들이 전혀 눈치채지 못할 정도로. 여기 누군가 Luna에게 봇이냐고 직접 물어보고, 이후 대화가 완전히 다른 주제로 소름 끼칠 만큼 자연스럽게 흘러가는 실제 대화가 있다.

Luna와의 디스코드 대화 -- 첫 번째 대화

말투, 망설임, 답장 속도 -- 봇이라는 걸 드러내는 요소가 전혀 없다. Luna는 캐릭터를 절대 깨지 않으면서 나이 질문을 슬쩍 피한다.

대화는 완전히 자연스럽게 이어지며, 짧고 인간적인 답변("okay, okay", "yay", "yayy")들이 오간다 -- 사람이 디스코드에서 보낼 법한 딱 그런 메시지들이다.

Luna와의 디스코드 대화 -- 계속

Luna와의 디스코드 대화 -- 주제 전환

Luna와의 디스코드 대화 -- 주제 전환이 계속됨

Luna와의 디스코드 대화 -- 대화의 끝

소름 끼치는 건 Luna가 그냥 "답장한다"는 게 아니라 -- 겉보기에 진짜 같은 의견, 되묻기, 메시지마다 이어지는 일관된 사고 흐름을 가지고 대화를 이어간다는 점이다. 위에서 설명한 트리거 시스템, 집중 지연, 망설임이 없다면 이 환상은 몇 마디 안에 무너질 것이다.

작은 반전: 위 스크린샷에서 대화 중인 두 계정 모두 Luna의 인스턴스다. PixieGlow와 Sujet d'SBlow는 봇을 테스트하는 인간이 아니다 -- 서로 대화하는 두 개의 봇이며, 각자(행동적인 의미에서) "정상적인" 누군가와 대화하고 있다고 "확신"하고 있다. 위 대화를 읽으면서 둘 중 하나가 인간이라고 생각했다면 -- 축하한다, 실제 디스코드 서버에서 누구나 그렇듯 방금 함정에 빠진 것이다.

이건 사실상 죽은 인터넷 이론의 실전판이다. 이 이론(원래는 다소 음모론에 가까운 주장)은 온라인 콘텐츠와 상호작용의 점점 더 많은 부분이 인간이 아닌 봇에 의해 생성되어, "진짜" 인간 인터넷이 소수가 되어가고 있다고 주장한다. 오랫동안 과장된 이야기로 여겨졌지만, Luna Protocol 같은 시스템이 대규모로 신뢰할 만한 인간의 존재감을 시뮬레이션하는 데 그리 많은 연산 자원도, 거대한 모델도 필요하지 않다는 걸 보여주면서 점점 덜 터무니없는 이야기가 되고 있다. 같은 봇의 두 인스턴스가 한 번도 정체를 들키지 않고 긴 대화를 이어갈 수 있다는 사실은, 서로 대화하는 봇들로 대부분 채워진 웹이 어떤 모습일지에 대한 꽤 구체적인 단서를 준다.


LLM 파이프라인: 두 가지 모드

direct 모드 (기본값)

봇이 HTTP를 통해 로컬 llama-server로 직접 요청을 보냅니다. 모델은 공유되며, 프롬프트 캐시와 4개의 동시 슬롯이 있습니다. 두 개의 PM2 프로세스: LLM 서버와 봇 클라이언트.

online 모드

봇이 OpenAI 호환 API(OpenAI, OpenRouter, Groq, Together 등)를 호출합니다. 로컬 LLM이 필요 없습니다.

실시간 스트리밍

LLM이 응답을 줄 단위(\n)로 스트리밍합니다. 각 줄은 단어로 분할되어 llmBus.emit("token", word)를 통해 하나씩 방출됩니다. \n마다 flush 이벤트가 방출됩니다 -- 봇이 누적된 메시지를 즉시 전송합니다. 시뮬레이션된 지연 없음: LLM의 리듬 그대로입니다.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

대기열(requestQueue)은 요청을 하나씩 처리하며, 대기열이 100개를 초과하면 자동으로 정리됩니다.


자발적 메시지

5분마다 12% 확률로 Luna가 스스로 메시지를 게시합니다. 서버는 선형 가중치 시스템으로 선택됩니다: 가장 활동적인 서버가 마지막 서버보다 N배 더 높은 확률을 가집니다.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

최근 5개 메시지의 컨텍스트를 읽고 Luna가 "자연스럽게" 대화에 합류합니다.


TTS 파이프라인: 음성 메시지

8% 확률로 Luna가 텍스트 대신 음성 메시지를 보냅니다. 전체 파이프라인:

  1. Piper TTS가 텍스트를 WAV로 합성
  2. ffmpeg가 OGG로 변환
  3. Discord 미리보기를 위한 웨이브폼 계산
  4. Discord CDN API를 통해 파일 업로드
  5. 음성 메시지 전송
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS 파이프라인 -- 합성된 텍스트에서 Discord 음성 메시지까지


안티스팸 및 영속성

안티스팸

channelId:userId별 대기열. 채널당 사용자당 하나의 메시지만 대기 가능. 현재 응답이 끝나면 처리됩니다.

세션 제한

8회 응답 후 Luna는 30초 동안 일시정지합니다. 3분간 비활성 상태이면 카운터가 재설정됩니다.

자동 영속성

모든 상태 변경이 stateBus로 방출됩니다 -- 자동 저장 (debounce 500ms). 더 이상 수동 saveAllState() 호출이 필요 없습니다. 저장되는 상태: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, 후속 메시지 카운터.


핫 리로드 설정

단일 config.yml 파일. 대부분의 값은 핫 리로드 가능합니다 -- 재시작 없이 변경 사항이 적용됩니다.

카테고리 핫 리로드
트리거, 키워드, 이름 ✅
집중도, 지연 시간 ✅
오타, 버스트, 피로 ✅
수면 스케줄 ✅
TTS, 음성 메시지 ✅
Discord 토큰, LLM 모드 ❌ (재시작 필요)
// config.ts -- getter가 실시간 값을 반환
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

데이터셋: Discord-Dialogues

모델은 Discord-Dialogues로 파인튜닝되었습니다: 730만 개의 교환, 1690만 개의 턴, 1억 3990만 개의 단어. 2025년 봄-여름 실제 Discord 대화로, 필터링됨 (PII, ToS, 봇, 명령어). Apache 2.0.

지표 값
샘플 7,303,464
총 턴 수 16,881,010
총 단어 수 139,922,950
평균 토큰 수 32.8
토크나이저 Hermes-3-Llama-3.1-8B

사용된 양자화 모델은 GGUF입니다 (예: Discord-Hermes-3-8B.Q3_K_M.gguf).

Discord-Dialogues 데이터셋 분포


완전한 라이프사이클 -- 메시지부터 응답까지의 전체 봇 동작, 타이머 및 경계 케이스 포함

아키텍처 다이어그램

state-machines/ 디렉토리에는 전체 소스 코드를 다루는 24개의 Mermaid 다이어그램이 있습니다. 각 다이어그램에는 인간이 읽을 수 있는 상세한 설명이 포함되어 있습니다.

가장 중요한 것들:

# 다이어그램 유형
01 아키텍처 개요 graph
02 메시지 처리 (전체) stateDiagram
03 트리거 평가 flowchart
04 LLM 코어 큐 (3개 백엔드) stateDiagram
10 TTS 파이프라인 flowchart
13 상태 영속성 flowchart
21 타이밍 간트 차트 gantt
22 완전한 라이프사이클 stateDiagram

이 다이어그램은 들어오는 메시지에서 응답까지, 타이머와 경계 케이스를 포함한 전체 흐름을 이해하는 데 금광과 같습니다.


트리거 코드 상세

트리거는 state/trigger.ts의 evaluateMessage()에 의해 평가됩니다. 전체 로직은 다음과 같습니다:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... 이름, 키워드, 후속 메시지, 랜덤 매칭
}

정규식 캐시(hasWordCache)는 매 메시지마다 패턴을 다시 컴파일하지 않도록 합니다.


반응

Luna는 이모지로 메시지에 반응합니다. 30% 확률로 서버 맞춤 이모지, 70% 확률로 유니코드 이모지를 사용합니다. 반응은 집중도 지연 후에 트리거되며, 즉시 실행되지 않습니다.

Luna 메시지에 대한 반응 명령어:

  • ❌ -- 중지
  • ▶️ -- 시작
  • 🗑️ -- 지우기

응답 스타일

응답 스타일은 채널에서 Luna의 최근 활동에 따라 가중치가 부여됩니다:

컨텍스트 messageReference mentionRepliedUser 가중치
냉담 true false 70%
냉담 true true 20%
냉담 false false 10%
활동적 true false 50%
활동적 true true 15%
활동적 false false 30%
활동적 false true 5%

DM에서는 messageReference가 항상 false입니다.


버스트 메시지

15% 확률로 응답이 2-3개의 조각으로 나뉘어 인간적인 리듬으로 전송됩니다 (조각당 1.5-4초). 누군가가 여러 번에 나눠 입력하는 것을 시뮬레이션합니다.

타이밍 간트 차트 -- 지연, 반응, LLM 스트리밍 및 수정의 실제 대기 시간


동적 상태

Luna의 Discord 상태는 15분마다 여러 설정된 프리셋 사이를 순환합니다. 지원되는 유형: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). 수면 중에는 상태가 invisible로 전환됩니다.

dynamic_status_presets:
  - status: online
    text: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

랜덤 지터(×0.5-1.0)로 예측 가능한 순환을 방지합니다. 10%의 시도는 반복을 피하기 위해 건너뜁니다.

타이핑 표시기

LLM을 호출하기 전에 Luna는 startTyping()을 호출합니다. setInterval이 생성 중 8초마다 표시기를 새로고침합니다. finally 블록에서 정리됩니다 (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

크래시 후 복구

LLM이 크래시되면 (llama-server 프로세스 중단), Luna가 llmBus.emit("crash", code)를 통해 이벤트를 감지하고 지수 백오프로 재시작을 시도합니다. 무한 재시작 루프를 방지합니다.

LLM 매개변수

매개변수는 src/config.ts에 하드코딩되어 있습니다:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

ChatML 템플릿(<|im_start|>/<|im_end|>)이 사용됩니다. 스레드 수는 os.cpus().length를 통해 자동 감지됩니다.


설정

npm install
cp config.example.yml config.yml
# config.yml 편집
npm run dev                    # 개발 (핫 리로드)
npm run build && npm start     # 프로덕션
스크립트 설명
build 독립 실행형 CLI 번들
start 봇 실행
lint / format / check Biome
test 테스트 (Bun)
download-model HuggingFace에서 GGUF 다운로드
diagrams Mermaid 다이어그램을 SVG/PNG로 내보내기

PM2 배포

./start.sh   # PM2로 llm-server + llm-client 실행

결론

Luna Protocol은 단순한 LLM 기반 Discord 봇이 아닙니다. 이는 인간의 불완전함을 시뮬레이션하는 완전한 행동 시스템입니다: 건망증, 오타, 수면, 망설임, 피로. 모든 것이 타입드 이벤트 버스를 중심으로 아키텍처링되었으며, 24개의 Mermaid 다이어그램이 각 흐름을 문서화합니다.

코드는 오픈 소스이며, 데이터셋은 공개되어 있고, 설정은 핫 리로드 가능합니다. 이 주제가 흥미로우시다면 코드를 살펴보세요 -- 생각보다 접근하기 쉽습니다.

리소스 링크
GitHub 저장소 fox3000foxy/luna-protocol-project
데이터셋 Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: Bir insanı simüle eden, kendi kendine yeten bir Discord botu yaptım

Luna Protocol, yerel bir LLM ile çalışan, tamamen otonom bir Discord botudur. Uyuma, yazım hatası yapma, tereddüt etme, unutma, konu yorgunluğu çekme ve kendiliğinden mesaj gönderme gibi doğal konuşma yeteneklerine sahiptir.

Luna Protocol: Bir insanı simüle eden, kendi kendine yeten bir Discord botu yaptım

Ya bir Discord botu uyuyabilse, yazım hatası yapabilse, tereddüt edebilse, cevap vermeyi unutabilse ve bazen kendi isteğiyle size mesaj gönderebilseydi? Luna Protocol tam olarak bunu yapıyor: yerel bir LLM (llama.cpp) çalıştıran ve kusurlu bir insan gibi konuşan, tamamen otonom bir Discord botu.

Katı promptlar yok, robotik cevaplar yok. Luna'nın bir öncelikli tetikleme sistemi, değişken gecikmeleri, uyku saatleri, kendiliğinden mesajları ve hatta sesli mesaj göndermek için bir TTS hattı var. Tamamı hot-reload destekli basit bir config.yml dosyasıyla yapılandırılır.

Bu yazıda, genel olay bus'ından TTS hattına, tetikleme sisteminden insan benzeri bileşenlere ve fine-tuning veri setine kadar tüm mimariyi ayrıntılı olarak inceliyoruz.

Genel Mimari -- bileşenler ve veri akışı


Mimari: tipli bir olay bus'ı

Luna'nın kalbinde TypedBus -- TypeScript'te güçlü tipli, genel bir olay bus'ı bulunur. Her şeyin üzerine inşa edildiği temel yapı taşıdır.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Bundan iki ana bus türetilmiştir:

  • llmBus -- LLM token'larını, hataları, çökmeleri, sıfırlamaları yönetir
  • stateBus -- otomatik kalıcılıkla durum değişikliklerini yönetir
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

Bu yaklaşımın avantajı: her modül diğerlerinden bağımsızdır. LLM bus üzerinden token'ları yayar, bot bunları tüketir, durum otomatik olarak güncellenir. Döngüsel bağımlılık yoktur.


Mesaj İşleme -- bir mesajın tam işlem akışı

Tetikleme sistemi: Luna'nın ne zaman cevap vereceğine kim karar veriyor?

Gelen her mesaj, bir tetikleme nedeni döndüren evaluateMessage() tarafından değerlendirilir. Öncelik sırası kritiktir:

# Neden Koşullar Yoksaymayı atla Duraklatmayı atla
1 mention @bot Evet (%0) Evet
2 dm replyInDM = true ile ÖM Evet (%0) Hayır
3 name "Luna"/"Pixie"/takma ad (tam kelime) Hayır (%8) Hayır
4 keyword hello, hi, ai, bot... (tam kelime) Hayır (%8) Hayır
5 follow-up Bot son konuşmacıydı + < 15sn + < 3 / 60sn -- --
6 random Eşleşmeyen mesajlarda %1.5 şans Hayır (%8) Hayır

Eşleştirme tam kelime (\b) esasına dayanır: "ai", "mais", "vrai", "lait" ile eşleşmez.

Tetikleme değerlendirmesi -- her mesaj için giriş kararı

Follow-up mekanizması

Luna bir mesaja cevap verdiğinde, kendini lastSpeaker olarak kaydeder. Sonraki 15 saniye içinde gelen her mesaj anında bir cevap tetikler -- zamanlayıcı yok, anahtar kelime kontrolü yok. Bütçe: 60 saniyelik pencere başına 3 follow-up.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Bekleme süresi

Aynı kanalda iki cevap arasında 8 saniye. Bahsedilmeler ve follow-up'lar tarafından atlanır.


İnsan benzeri davranışlar: değişken konsantrasyon

Luna'nın ilginçleştiği yer burasıdır. Her tetikleme türünün kendi konsantrasyon eşikleri vardır: min/maks gecikme, yoksayma şansı ve tepki verme şansı.

Tetikleyici Min gecikme Maks gecikme Yoksay Tepki
mention 300ms 1500ms %0 %8
dm 400ms 1800ms %0 %5
name 800ms 4000ms %5 %6
keyword 1000ms 3500ms %8 %4
follow-up 500ms 2000ms %0 %3
random 1500ms 5000ms %15 %2

Gecikme hesaplaması ayrıca şunları da dikkate alır:

  • Mesaj uzunluğu: mesaj ne kadar uzunsa, Luna'nın "okuması" o kadar uzun sürer
  • Hareketlilik: Luna 10 dakikadır aktif değilse, gecikme 2 ile çarpılır ("uyanma" simülasyonu)
  • Uyku: slow modunda, gecikme 3 ila 5 ile çarpılır
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // agresif jitter
  return delay;
}

Uyku saatleri

Luna uyuyabilir. config.yml ile yapılandırılabilir:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Mod Etki
sleep Yalnızca bahsedilmeler ve ÖM geçer
slow Gecikme ×3-5, tepkiler neredeyse sıfır
short Yoksayma şansı +%30, tepkiler neredeyse sıfır

Uyku saatlerinde Discord durumu invisible olarak değişir.


Yazım hataları

Luna yazım hatası yapabilir -- ve 2-4 saniye sonra düzeltebilir. Klavye düzeni yapılandırılabilir (AZERTY veya QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... tüm bitişik tuşlar
};

AZERTY örneği: bonjour → bonjpur, salut → slaut, comment → cpmment.

Üç düzeltme stili:

Stil Davranış
edit Mesajı düzenler
message Yeni mesaj: word*
mixed %50/50 rastgele (varsayılan)

Tereddütler ve unutmalar

Tereddütler: Bir dolgu kelimesiyle (uh..., um..., well..., hmm..., so...) başlama şansı %15.

Unutmalar: Bir tetikleyiciyi eşleştirdikten sonra bile, Luna %3 olasılıkla cevap vermeyi "unutabilir". Mesaj yok, tepki yok -- hiçbir şey görmemiş gibi.

Konu yorgunluğu: Bir kelime son 10 mesajda çok sık tekrarlanırsa (eşik: 3 tekrar), gecikmeler çarpılır ve yoksayma şansı %15 artar.


Pratikte gerçekçilik: iş ürpertici hale geldiğinde

Kağıt üzerinde, tüm bu sistemler (gecikmeler, yazım hataları, tereddütler, unutkanlık) bir gimmick gibi görünür. Pratikte ise, hepsi bir araya geldiğinde sonuç şaşırtıcıdır -- öyle ki Luna ile konuşan insanlar hiçbir şeyden şüphelenmez. İşte birinin Luna'ya doğrudan bot olup olmadığını sorduğu, ardından sohbetin tamamen başka bir konuya ürkütücü bir doğallıkla kaydığı gerçek bir alışveriş.

Luna ile Discord sohbeti -- ilk kısım

Ton, tereddütler, yanıt temposu -- hiçbir şey bot olduğunu ele vermiyor. Luna, karakterinden hiç çıkmadan yaş sorusunu ustaca geçiştiriyor.

Sohbet tamamen doğal bir şekilde devam ediyor, kısa ve insana özgü yanıtlarla ("okay, okay", "yay", "yayy") -- tam olarak bir insanın Discord'da göndereceği türden mesajlar.

Luna ile Discord sohbeti -- devamı

Luna ile Discord sohbeti -- konu kayması

Luna ile Discord sohbeti -- konu kayması devam ediyor

Luna ile Discord sohbeti -- sohbetin sonu

Ürkütücü olan şey sadece Luna'nın "yanıt vermesi" değil -- görünürde fikirleri, takip sorularıyla ve mesajdan mesaja tutarlı bir düşünce akışıyla bir sohbeti sürdürebilmesi. Yukarıda anlatılan tetikleyici sistemi, odaklanma gecikmeleri ve tereddütler olmadan bu illüzyon birkaç mesaj içinde çökerdi.

Küçük bir sürpriz: yukarıdaki ekran görüntülerinde, konuşan iki hesap da Luna'nın örnekleri. PixieGlow ve Sujet d'SBlow, bir botu test eden bir insan değil -- birbiriyle konuşan iki bot, her biri (davranışsal anlamda) "normal" biriyle sohbet ettiğine "ikna olmuş" durumda. Eğer yukarıdaki alışverişi okurken ikisinden birinin insan olduğunu düşündüyseniz, tebrikler -- gerçek bir Discord sunucusunda herkesin düşeceği tuzağa tam olarak siz de düştünüz.

Bu aslında ölü internet teorisinin pratikteki bir versiyonu gibi: bu teori (aslen oldukça komplo teorisi sayılan bir fikir) çevrimiçi içerik ve etkileşimlerin giderek artan bir kısmının insanlar yerine botlar tarafından üretildiğini, öyle ki "gerçek" insan internetinin azınlıkta kaldığını öne sürer. Uzun süre abartılı bulunan bu teori, Luna Protocol gibi sistemlerin büyük ölçekte inandırıcı bir insan varlığını simüle etmek için ne çok fazla işlem gücüne ne de dev bir modele ihtiyaç olmadığını göstermesiyle giderek daha az saçma görünüyor. Aynı botun iki örneğinin kendini hiç ele vermeden uzun bir sohbeti sürdürebilmesi, birbiriyle konuşan botlarla dolu bir web'in nasıl görünebileceğine dair oldukça somut bir fikir veriyor.


LLM hattı: iki mod

direct modu (varsayılan)

Bot, istekleri doğrudan HTTP üzerinden yerel bir llama-server'a gönderir. Model paylaşılır, prompt önbelleği ve 4 eşzamanlı slot ile. İki PM2 süreci: LLM sunucusu ve bot istemcisi.

online modu

Bot, OpenAI uyumlu herhangi bir API'yi çağırır (OpenAI, OpenRouter, Groq, Together...). Yerel LLM gerekmez.

Gerçek zamanlı akış

LLM, cevabını satır satır (\n) akışla gönderir. Her satır kelimelere ayrılır ve llmBus.emit("token", word) ile tek tek yayınlanır. Her \n'de bir flush olayı yayınlanır -- bot birikmiş mesajı hemen gönderir. Simüle edilmiş gecikme yoktur: tempo LLM'nin kendisine aittir.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

Kuyruk (requestQueue), istekleri tek tek işler ve kuyruk 100 öğeyi aştığında otomatik temizlik yapar.


Kendiliğinden mesajlar

Her 5 dakikada bir, %12 olasılıkla Luna kendi isteğiyle bir mesaj gönderir. Sunucu, doğrusal ağırlık sistemiyle seçilir: en aktif sunucunun, son sunucuya göre N kat daha fazla şansı vardır.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Son 5 mesajın bağlamı okunur ve Luna konuşmaya "doğal olarak" katılır.


TTS hattı: sesli mesajlar

%8 olasılıkla Luna, metin yerine sesli mesaj gönderir. Tam hat:

  1. Piper TTS metni WAV'a sentezler
  2. ffmpeg OGG'ye dönüştürür
  3. Discord önizlemesi için dalga formu hesaplanır
  4. Dosya Discord CDN API'si üzerinden yüklenir
  5. Sesli mesaj gönderilir
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS Hattı -- sentezlenen metinden Discord sesli mesajına


Anti-spam ve kalıcılık

Anti-spam

channelId:userId bazında kuyruk. Kanal başına kullanıcı başına yalnızca bir mesaj kuyruğa alınır. Devam eden cevap bittiğinde işlenir.

Oturum limitleri

8 etkileşimden sonra Luna 30 saniyelik bir mola verir. Sayaç, 3 dakikalık hareketsizlikten sonra sıfırlanır.

Otomatik kalıcılık

Her durum değişikliği stateBus üzerinden yayınlanır → otomatik kaydetme (debounce 500ms). Artık manuel saveAllState() çağrılarına gerek yoktur. Kalıcı durum şunları içerir: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, follow-up sayaçları.


Hot-reload yapılandırma

Tek bir config.yml dosyası. Değerlerin çoğu hot-reload edilebilir -- değişiklikler yeniden başlatma gerektirmeden uygulanır.

Kategori Hot-reload
Tetikleyiciler, anahtar kelimeler, isimler ✅
Konsantrasyon, gecikmeler ✅
Yazım hataları, patlamalar, yorgunluk ✅
Uyku programları ✅
TTS, sesli mesajlar ✅
Discord token'ı, LLM modu ❌ (yeniden başlatma gerekli)
// config.ts -- getter'lar canlı değerler döndürür
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Veri seti: Discord-Dialogues

Model, Discord-Dialogues üzerinde fine-tune edilmiştir: 7.3M etkileşim, 17M tur, 140M kelime. 2025 ilkbahar-yaz döneminden gerçek Discord konuşmaları, filtrelenmiş (PII, ToS, botlar, komutlar). Apache 2.0.

Metrik Değer
Örneklem 7 303 464
Toplam tur 16 881 010
Toplam kelime 139 922 950
Ortalama token 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Kullanılan nicelenmiş model bir GGUF'tür (örneğin Discord-Hermes-3-8B.Q3_K_M.gguf).

Discord-Dialogues veri seti dağılımı


Tam Yaşam Döngüsü -- mesajdan cevaba kadar botun tüm davranışı, zamanlayıcılar ve uç durumlar dahil

Mimari diyagramları

state-machines/ klasörü, kaynak kodun tamamını kapsayan 24 Mermaid diyagramı içerir. Her diyagram, insan dilinde ayrıntılı bir açıklamaya sahiptir.

En önemlileri arasında:

# Diyagram Tür
01 Mimari Genel Bakış graph
02 Mesaj İşleme (tam) stateDiagram
03 Tetikleme Değerlendirmesi flowchart
04 LLM Çekirdek Kuyruğu (3 arka uç) stateDiagram
10 TTS Hattı flowchart
13 Durum Kalıcılığı flowchart
21 Zamanlama Gantt'ı gantt
22 Tam Yaşam Döngüsü stateDiagram

Bu diyagramlar, gelen mesajdan cevaba, zamanlayıcılar ve uç durumlar dahil olmak üzere tüm akışı anlamak için bir altın madenidir.


Tetikleme kodunun detaylı incelenmesi

Tetikleyici, state/trigger.ts içindeki evaluateMessage() tarafından değerlendirilir. İşte tam mantık:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... isim, anahtar kelime, follow-up, rastgele eşleştirme
}

Regex önbelleği (hasWordCache), desenlerin her mesajda yeniden derlenmesini önler.


Tepkiler

Luna, mesajlara emojilerle tepki verir. %30 olasılıkla sunucudan özel bir emoji, %70 olasılıkla unicode emoji kullanır. Tepki, konsantrasyon gecikmesinden sonra tetiklenir, hemen değil.

Luna'nın mesajlarındaki tepki komutları:

  • ❌ → Durdur
  • ▶️ → Başlat
  • 🗑️ → Temizle

Cevap stili

Cevap stili, Luna'nın kanaldaki son aktivitesine göre ağırlıklandırılır:

Bağlam messageReference mentionRepliedUser Ağırlık
Soğuk true false %70
Soğuk true true %20
Soğuk false false %10
Aktif true false %50
Aktif true true %15
Aktif false false %30
Aktif false true %5

ÖM'de messageReference her zaman false'tur.


Patlama mesajları

%15 olasılıkla, bir cevap insan temposunda (her parça arasında 1.5-4 saniye) gönderilen 2-3 parçaya bölünür. Birinin birkaç seferde yazmasını simüle eder.

Zamanlama Gantt'ı -- gecikmeler, tepkiler, LLM akışı ve düzeltmeler için gerçek bekleme süreleri


Dinamik durum

Luna'nın Discord durumu, yapılandırılmış birkaç preset arasında geçiş yaparak her 15 dakikada bir değişir. Desteklenen türler: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Uyku sırasında durum invisible olur.

dynamic_status_presets:
  - status: online
    text: "piksellerle oynuyor"
    type: 0       # Playing
  - status: idle
    text: "beyaz gürültü"
    type: 2       # Listening

Rastgele bir jitter (×0.5-1.0) öngörülebilir dönüşleri önler. Tekrarı önlemek için denemelerin %10'u atlanır.

Yazıyor göstergesi

LLM'i çağırmadan önce Luna startTyping() işlevini çağırır. Bir setInterval, üretim sırasında yazıyor göstergesini her 8 saniyede bir yeniler. finally bloğunda temizlenir (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Çökme sonrası kurtarma

LLM çökerse (llama-server süreci ölürse), Luna llmBus.emit("crash", code) aracılığıyla olayı algılar ve üstel geri çekilme ile yeniden başlatmayı dener. Sonsuz yeniden başlatma döngülerini önler.

LLM parametreleri

Parametreler src/config.ts içinde sabit kodlanmıştır:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

ChatML şablonu (<|im_start|>/<|im_end|>) kullanılır. İş parçacığı sayısı os.cpus().length ile otomatik algılanır.


Kurulum

npm install
cp config.example.yml config.yml
# config.yml'yi düzenleyin
npm run dev                    # geliştirme (hot reload)
npm run build && npm start     # üretim
Script Açıklama
build Bağımsız CLI paketi oluşturur
start Botu başlatır
lint / format / check Biome
test Testler (Bun)
download-model HuggingFace'den GGUF
diagrams Mermaid diyagramlarını SVG/PNG'ye aktarır

PM2 dağıtımı

./start.sh   # PM2 altında llm-server + llm-client başlatır

Sonuç

Luna Protocol, sadece LLM'li bir Discord botu değildir. İnsan kusurlarını simüle eden eksiksiz bir davranışsal sistemdir: unutmalar, yazım hataları, uyku, tereddütler, yorgunluk. Tümü, her akışı belgeleyen 24 Mermaid diyagramıyla, tipli bir olay bus'ı etrafında yapılandırılmıştır.

Kod açık kaynaktır, veri seti herkese açıktır ve yapılandırma hot-reload edilebilir. Konu ilginizi çekiyorsa, koda dalın -- göründüğünden daha erişilebilirdir.

Kaynak Bağlantı
GitHub Deposu fox3000foxy/luna-protocol-project
Veri Seti Discord-Dialogues
Atlas Haritası atlas.nomic.ai

Luna Protocol: ho creato un bot Discord autonomo che simula un essere umano

Luna Protocol è un bot Discord completamente autonomo dotato di un LLM locale, capace di conversazione naturale con sonno, errori di battitura, esitazioni, dimenticanze, stanchezza tematica e messaggi spontanei.

Luna Protocol: ho creato un bot Discord autonomo che simula un essere umano

E se un bot Discord potesse dormire, fare errori di battitura, esitare, dimenticare di rispondere, e qualche volta inviarvi un messaggio di propria iniziativa? Questo è esattamente ciò che fa Luna Protocol: un bot Discord completamente autonomo che fa girare un LLM locale (llama.cpp) e conversa come un essere umano imperfetto.

Niente prompt rigidi, niente risposte robotiche. Luna ha un sistema di attivazione prioritario, tempi di attesa variabili, orari di sonno, messaggi spontanei, e persino una pipeline TTS per inviare messaggi vocali. Il tutto configurato tramite un semplice file config.yml hot-reloadable.

In questo articolo, analizziamo l'architettura completa: dal bus di eventi generico alla pipeline TTS, passando per il sistema di attivazione, i componenti umani e il dataset di fine-tuning.

Schema Architettura -- componenti globali e flusso di dati


L'architettura: un bus di eventi tipizzato

Il cuore di Luna è un TypedBus -- un bus di eventi generico fortemente tipizzato in TypeScript. È il mattone fondamentale su cui tutto si basa.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Due bus principali ne derivano:

  • llmBus -- gestisce i token LLM, gli errori, i crash, il reset
  • stateBus -- gestisce i cambiamenti di stato con persistenza automatica
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

Il vantaggio di questo approccio: ogni modulo è disconnesso dal resto. Il LLM emette token sul bus, il bot li consuma, lo stato si aggiorna automaticamente. Nessuna dipendenza circolare.


Elaborazione Messaggi -- flusso completo di elaborazione di un messaggio

Il sistema di attivazione: chi decide quando Luna risponde?

Ogni messaggio in entrata viene valutato da evaluateMessage() che restituisce un TriggerResult con una ragione di attivazione. L'ordine di priorità è critico:

# Ragione Condizioni Bypass ignore Bypass pausa
1 mention @bot Sì (0%) Sì
2 dm MP con replyInDM = true Sì (0%) No
3 name "Luna"/"Pixie"/alias (parola intera) No (8%) No
4 keyword hello, hi, ai, bot... (parola intera) No (8%) No
5 follow-up Bot era ultimo interlocutore + < 15s + < 3 / 60s -- --
6 random 1.5% di probabilità sui messaggi non corrispondenti No (8%) No

Il matching è parola intera (\b): "ai" non corrisponde a "mais", "vrai", "lait".

Valutazione Trigger -- decisione di ingresso per ogni messaggio

Il meccanismo di follow-up

Quando Luna risponde a un messaggio, si registra come lastSpeaker. Qualsiasi messaggio successivo entro 15 secondi attiva una risposta immediata -- nessun timer, nessuna verifica di keyword. Budget: 3 follow-up per finestra di 60 secondi.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Il cooldown

8 secondi tra due risposte nello stesso canale. Ignorato dalle menzioni e dai follow-up.


I comportamenti umani: la concentrazione variabile

È qui che Luna diventa interessante. Ogni tipo di attivazione ha le proprie soglie di concentrazione: un ritardo minimo/massimo, una probabilità di ignorare e una probabilità di reagire.

Trigger Ritardo min Ritardo max Ignora Reazione
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

Il calcolo del ritardo tiene conto anche di:

  • La lunghezza del messaggio: più il messaggio è lungo, più Luna impiega tempo a "leggere"
  • L'inattività: se Luna non è stata attiva per 10 minuti, il ritardo viene moltiplicato per 2 (simulazione del "risveglio")
  • Il sonno: in modalità slow, il ritardo viene moltiplicato per 3 a 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

Gli orari di sonno

Luna può dormire. Configurabile tramite config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Modalità Effetto
sleep Solo le menzioni e i MP passano
slow Ritardo ×3-5, reazioni quasi nulle
short Probabilità di ignore +30%, reazioni quasi nulle

Durante le ore di sonno, lo stato Discord passa a invisible.


Gli errori di battitura

Luna può fare errori di battitura -- e correggerli dopo 2-4 secondi. Il layout della tastiera è configurabile (AZERTY o QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... toutes les touches adjacentes
};

Esempio AZERTY: bonjour → bonjpur, salut → slaut, comment → cpmment.

Tre stili di correzione:

Stile Comportamento
edit Modifica il messaggio
message Nuovo messaggio: word*
mixed 50/50 casuale (predefinito)

Le esitazioni e le dimenticanze

Esitazioni: 15% di probabilità di iniziare con una parola di riempimento (uh..., um..., well..., hmm..., so...).

Dimenticanze: anche dopo aver matchato un trigger, Luna può "dimenticare" di rispondere con una probabilità del 3%. Nessun messaggio, nessuna reazione -- come se non avesse visto nulla.

Stanchezza tematica: se una parola ritorna troppo spesso negli ultimi 10 messaggi (soglia: 3 occorrenze), i ritardi vengono moltiplicati e la probabilità di ignore aumenta del 15%.


Il realismo in pratica: quando diventa inquietante

Sulla carta, tutti questi sistemi (ritardi, refusi, esitazioni, dimenticanze) sembrano trovate. Nella pratica, messi insieme, il risultato è sorprendente -- al punto che le persone che parlano con Luna non sospettano nulla. Ecco uno scambio reale in cui qualcuno chiede direttamente a Luna se è un bot, e la conversazione poi scivola su un argomento completamente diverso con una naturalezza che fa venire i brividi.

Conversazione Discord con Luna -- primo scambio

Il tono, le esitazioni, il ritmo delle risposte -- niente tradisce un bot. Luna schiva la domanda sull'età senza mai uscire dal personaggio.

La conversazione prosegue in modo del tutto naturale, con risposte brevi e umane ("okay, okay", "yay", "yayy") -- esattamente il tipo di messaggi che una persona invierebbe su Discord.

Conversazione Discord con Luna -- continua

Conversazione Discord con Luna -- cambio di argomento

Conversazione Discord con Luna -- il cambio di argomento prosegue

Conversazione Discord con Luna -- fine dello scambio

Ciò che è inquietante non è solo che Luna "risponde" -- è che sostiene una conversazione, con opinioni apparenti, rilanci e un filo di pensiero coerente da un messaggio all'altro. Senza il sistema di trigger, i ritardi di concentrazione e le esitazioni descritti sopra, questa illusione crollerebbe dopo pochi messaggi.

Piccolo colpo di scena: negli screenshot sopra, entrambi gli account che chiacchierano sono istanze di Luna. PixieGlow e Sujet d'SBlow non sono un umano che testa un bot -- sono due bot che parlano tra loro, ciascuno "convinto" (in senso comportamentale) di chattare con qualcuno di "normale". Se leggendo lo scambio qui sopra avete supposto che uno dei due fosse umano, congratulazioni -- siete appena caduti nella trappola esattamente come chiunque altro farebbe su un vero server Discord.

È praticamente una versione pratica della dead internet theory: questa teoria (in origine piuttosto complottista) sostiene che una quota crescente di contenuti e interazioni online sia generata da bot anziché da umani, al punto che il "vero" internet umano sarebbe diventato minoritario. A lungo considerata esagerata, diventa sempre meno assurda man mano che sistemi come Luna Protocol dimostrano che non servono grandi risorse né un modello enorme per simulare una presenza umana credibile su larga scala. Due istanze dello stesso bot capaci di sostenere una conversazione lunga senza mai tradirsi offrono un'idea piuttosto concreta di come potrebbe essere un web popolato per lo più da bot che parlano tra loro.


La pipeline LLM: due modalità

Modalità direct (predefinita)

Il bot invia direttamente le richieste a un llama-server locale in HTTP. Il modello è condiviso, con prompt cache e 4 slot concorrenti. Due processi PM2: il server LLM e il client bot.

Modalità online

Il bot chiama qualsiasi API compatibile OpenAI (OpenAI, OpenRouter, Groq, Together...). Nessun LLM locale necessario.

Lo streaming in tempo reale

Il LLM trasmette la risposta riga per riga (\n). Ogni riga viene suddivisa in parole, emesse una per una su llmBus.emit("token", word). A ogni \n, viene emesso un evento flush -- il bot invia immediatamente il messaggio accumulato. Nessun ritardo simulato: il ritmo è quello del LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

La coda (requestQueue) elabora le richieste una per una, con pulizia automatica quando la coda supera i 100 elementi.


I messaggi spontanei

Ogni 5 minuti, 12% di probabilità che Luna pubblichi un messaggio di propria iniziativa. Il server viene selezionato tramite un sistema di peso lineare: il server più attivo ha N× più probabilità dell'ultimo.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Viene letto il contesto degli ultimi 5 messaggi e Luna si unisce "naturalmente" alla conversazione.


La pipeline TTS: messaggi vocali

Con l'8% di probabilità, Luna invia un messaggio vocale invece del testo. La pipeline completa:

  1. Piper TTS sintetizza il testo in WAV
  2. ffmpeg converte in OGG
  3. La forma d'onda viene calcolata per l'anteprima Discord
  4. Il file viene caricato tramite l'API Discord CDN
  5. Il messaggio vocale viene inviato
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

Pipeline TTS -- dal testo sintetizzato al messaggio vocale Discord


L'anti-spam e la persistenza

Anti-spam

Coda per channelId:userId. Un solo messaggio in coda per utente per canale. Elaborato non appena la risposta in corso termina.

Limiti di sessione

Dopo 8 scambi, Luna fa una pausa di 30 secondi. Il contatore si resetta dopo 3 minuti di inattività.

Persistenza automatica

Ogni mutazione di stato emette su stateBus → salvataggio automatico (debounce 500ms). Niente più chiamate saveAllState() manuali. Lo stato persistito include: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, contatori di follow-up.


La configurazione hot-reload

Un singolo file config.yml. La maggior parte dei valori sono hot-reloadable -- le modifiche vengono applicate senza riavvio.

Categoria Hot-reload
Trigger, keywords, nomi ✅
Concentrazione, ritardi ✅
Errori di battitura, burst, stanchezza ✅
Orari di sonno ✅
TTS, messaggi vocali ✅
Discord token, modalità LLM ❌ (riavvio richiesto)
// config.ts -- i getter restituiscono valori live
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Il dataset: Discord-Dialogues

Il modello è fine-tunato su Discord-Dialogues: 7.3M scambi, 17M turni, 140M parole. Vere conversazioni Discord primavera-estate 2025, filtrate (PII, ToS, bot, comandi). Apache 2.0.

Metrica Valore
Campioni 7 303 464
Turni totali 16 881 010
Parole totali 139 922 950
Token medi 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Il modello quantizzato utilizzato è un GGUF (ad esempio Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribuzione del dataset Discord-Dialogues


Ciclo di Vita Completo -- comportamento completo del bot dal messaggio alla risposta, inclusi timer e casi limite

I diagrammi di architettura

La cartella state-machines/ contiene 24 diagrammi Mermaid che coprono l'intero codice sorgente. Ogni diagramma ha una spiegazione dettagliata in linguaggio umano.

Tra i più importanti:

# Diagramma Tipo
01 Architecture Overview graph
02 Message Processing (completo) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Questi diagrammi sono una miniera d'oro per comprendere il flusso completo: dal messaggio in entrata alla risposta, passando per i timer e i casi limite.


Il codice di attivazione in dettaglio

Il trigger viene valutato da evaluateMessage() in state/trigger.ts. Ecco la logica completa:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching par nom, keyword, follow-up, random
}

La cache delle regex (hasWordCache) evita di ricompilare i pattern ad ogni messaggio.


Le reazioni

Luna reagisce ai messaggi con emoji. 30% di probabilità di usare un'emoji personalizzata del server, 70% un'emoji unicode. La reazione viene attivata dopo il ritardo di concentrazione, non immediatamente.

I comandi tramite reazione sui messaggi di Luna:

  • ❌ → Stop
  • ▶️ → Start
  • 🗑️ → Clear

Lo stile di risposta

Lo stile di risposta è ponderato in base all'attività recente di Luna nel canale:

Contesto messageReference mentionRepliedUser Peso
Freddo true false 70%
Freddo true true 20%
Freddo false false 10%
Attivo true false 50%
Attivo true true 15%
Attivo false false 30%
Attivo false true 5%

In MP, messageReference è sempre false.


I messaggi in raffica

Con il 15% di probabilità, una risposta viene suddivisa in 2-3 frammenti inviati a ritmo umano (1.5-4 secondi tra ogni frammento). Simula qualcuno che scrive in più volte.

Diagramma Temporale Gantt -- tempi di attesa reali per ritardi, reazioni, streaming LLM e correzioni


Lo stato dinamico

Lo stato Discord di Luna alterna tra diversi preset configurati, ruotando ogni 15 minuti. Tipi supportati: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Durante il sonno, lo stato passa a invisible.

dynamic_status_presets:
  - status: online
    text: "con i pixel"
    type: 0       # Playing
  - status: idle
    text: "rumore bianco"
    type: 2       # Listening

Un jitter casuale (×0.5-1.0) evita rotazioni prevedibili. Il 10% dei tentativi viene saltato per evitare ripetizioni.

L'indicatore di digitazione

Prima di chiamare il LLM, Luna chiama startTyping(). Un setInterval aggiorna l'indicatore ogni 8 secondi durante la generazione. Pulito nel finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Il recupero dopo un crash

Se il LLM crasha (processo llama-server che muore), Luna rileva l'evento tramite llmBus.emit("crash", code) e tenta di riavviare con un backoff esponenziale. Evita i loop di riavvio infiniti.

I parametri LLM

I parametri sono hardcodati in src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Il template ChatML (<|im_start|>/<|im_end|>) viene utilizzato. Il numero di thread viene rilevato automaticamente tramite os.cpus().length.


Configurazione

npm install
cp config.example.yml config.yml
# editare config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # produzione
Script Descrizione
build Bundle CLI autonomo
start Avvia il bot
lint / format / check Biome
test Test (Bun)
download-model GGUF da HuggingFace
diagrams Esporta i diagrammi Mermaid in SVG/PNG

Deployment PM2

./start.sh   # avvia llm-server + llm-client sotto PM2

Conclusione

Luna Protocol non è solo un bot Discord con un LLM. È un sistema comportamentale completo che simula le imperfezioni umane: le dimenticanze, gli errori di battitura, il sonno, le esitazioni, la stanchezza. Il tutto architettato attorno a un bus di eventi tipizzato, con 24 diagrammi Mermaid che documentano ogni flusso.

Il codice è open source, il dataset è pubblico e la configurazione è hot-reloadable. Se l'argomento vi interessa, immergetevi nel codice -- è più accessibile di quanto sembri.

Risorsa Link
Repository GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: Ich habe einen autonomen Discord-Bot erschaffen, der einen Menschen simuliert

Luna Protocol ist ein vollstandig autonomer Discord-Bot mit lokalem LLM, der naturliche Konversation mit Schlaf, Tippfehlern, Zogern, Vergesslichkeit, thematischer Ermudung und spontanen Nachrichten beherrscht.

Luna Protocol: Ich habe einen autonomen Discord-Bot erschaffen, der einen Menschen simuliert

Was ware, wenn ein Discord-Bot schlafen, Tippfehler machen, zogern, vergessen konnte zu antworten und manchmal aus eigenem Antrieb eine Nachricht sendet? Genau das tut Luna Protocol: ein vollstandig autonomer Discord-Bot, der ein lokales LLM (llama.cpp) betreibt und sich wie ein unvollkommener Mensch unterhalt.

Keine starren Prompts, keine roboterhaften Antworten. Luna verfugt uber ein priorisiertes Auslosersystem, variable Verzogerungen, Schlafenszeiten, spontane Nachrichten und sogar eine TTS-Pipeline fur Sprachmitteilungen. Alles konfiguriert uber eine einfache, hot-reloadable config.yml.

In diesem Artikel zerlegen wir die gesamte Architektur: vom generischen Event-Bus uber die TTS-Pipeline bis hin zum Auslosersystem, den menschlichen Komponenten und dem Fine-Tuning-Datensatz.

Architekturubersicht -- globale Komponenten und Datenflusse


Die Architektur: Ein getypter Event-Bus

Das Herz von Luna ist ein TypedBus -- ein generischer, stark typisierter Event-Bus in TypeScript. Es ist der Grundbaustein, auf dem alles aufbaut.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Daraus leiten sich zwei Haupt-Busse ab:

  • llmBus -- verwaltet LLM-Tokens, Fehler, Absturze, Rucksetzungen
  • stateBus -- verwaltet Zustandsanderungen mit automatischer Persistierung
+-------------------------------------------------------+
|                   core/bus.ts                          |
|  TypedBus<K, V> -- on / off / once / emit              |
+---------------------------+---------------------------+
|   core/llm-bus            |    state/state-bus         |
|  token / done /           |     state:changed          |
|  error / crash /          |     -> Persistierung auto  |
|  flush / ready /          |                            |
|  reset                    |                            |
+---------------------------+---------------------------+
          |                            |
+---------------------+    +----------+--------------------+
| core/llm-core.ts    |    | bot.ts (Eris)                |
| Modus direkt        |    | bot/pending.ts               |
|   llama-server      |    | bot/reactions.ts             |
| Modus online        |    | state/trigger.ts             |
|   OpenAI API        |    | state/state.ts               |
|                     |    | behavior/*                   |
|                     |    | tts/*                        |
|                     |    | spontaneous.ts               |
+---------------------+    +------------------------------+

Der Vorteil dieses Ansatzes: Jedes Modul ist entkoppelt vom Rest. Das LLM sendet Tokens auf dem Bus, der Bot konsumiert sie, der Zustand aktualisiert sich automatisch. Keine zirkularen Abhangigkeiten.


Nachrichtenverarbeitung -- vollstandiger Verarbeitungsfluss einer Nachricht

Das Auslosersystem: Wer entscheidet, wann Luna antwortet?

Jede eingehende Nachricht wird von evaluateMessage() ausgewertet, das ein TriggerResult mit einem Auslosungsgrund zuruckgibt. Die Prioritatsreihenfolge ist entscheidend:

# Grund Bedingungen Ignorieren umgehen Pause umgehen
1 mention @Bot Ja (0%) Ja
2 dm PN mit replyInDM = true Ja (0%) Nein
3 name ,,Luna''/,,Pixie''/Alias (ganzes Wort) Nein (8%) Nein
4 keyword hello, hi, ai, bot... (ganzes Wort) Nein (8%) Nein
5 follow-up Bot war letzter Sprecher + < 15s + < 3 / 60s -- --
6 random 1,5% Wahrscheinlichkeit bei nicht passenden Nachrichten Nein (8%) Nein

Der Abgleich erfolgt auf ganze Worter (\b): ,,ai'' passt nicht auf ,,mai'', ,,wahr'', ,,Seit''.

Ausloserbewertung -- Eintrittsentscheidung fur jede Nachricht

Der Follow-up-Mechanismus

Wenn Luna auf eine Nachricht antwortet, registriert sie sich als lastSpeaker. Jede folgende Nachricht innerhalb von 15 Sekunden lost eine sofortige Antwort aus -- kein Timer, keine Keyword-Prufung. Budget: 3 Follow-ups pro 60-Sekunden-Fenster.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Die Abklingzeit

8 Sekunden zwischen zwei Antworten im selben Kanal. Wird von Erwahnungen und Follow-ups umgangen.


Die menschlichen Verhaltensweisen: Variable Konzentration

Hier wird Luna interessant. Jede Ausloserart hat ihre eigenen Konzentrationsschwellen: eine min./max. Verzogerung, eine Wahrscheinlichkeit zu ignorieren und eine Wahrscheinlichkeit zu reagieren.

Ausloser Verz. min Verz. max Ignorieren Reaktion
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

Die Berechnung der Verzogerung berucksichtigt auBerdem:

  • Die Nachrichtenlange: Je langer die Nachricht, desto mehr Zeit braucht Luna zum ,,Lesen''
  • Inaktivitat: Wenn Luna seit 10 Minuten nicht aktiv war, wird die Verzogerung mit 2 multipliziert (Simulation des ,,Aufwachens'')
  • Schlaf: Im Modus slow wird die Verzogerung mit 3 bis 5 multipliziert
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // aggressives Jitter
  return delay;
}

Die Schlafenszeiten

Luna kann schlafen. Konfigurierbar uber config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Modus Wirkung
sleep Nur Erwahnungen und PNs kommen durch
slow Verzogerung x3-5, Reaktionen fast null
short Ignorier-Wahrscheinlichkeit +30%, Reaktionen fast null

Wahrend der Schlafenszeit wechselt der Discord-Status auf invisible.


Die Tippfehler

Luna kann Tippfehler machen -- und sie nach 2-4 Sekunden korrigieren. Das Tastaturlayout ist konfigurierbar (AZERTY oder QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... alle benachbarten Tasten
};

Beispiel AZERTY: bonjour -> bonjpur, salut -> slaut, comment -> cpmment.

Drei Korrekturstile:

Stil Verhalten
edit Bearbeitet die Nachricht
message Neue Nachricht: wort*
mixed 50/50 zufallig (Standard)

Das Zogern und die Vergesslichkeit

Zogern: 15% Wahrscheinlichkeit, mit einem Fullwort zu beginnen (uh..., um..., well..., hmm..., so...).

Vergesslichkeit: Selbst nachdem ein Ausloser erkannt wurde, kann Luna ,,vergessen'' zu antworten -- mit einer Wahrscheinlichkeit von 3%. Keine Nachricht, keine Reaktion -- als hatte sie nichts gesehen.

Thematische Ermudung: Wenn ein Wort in den letzten 10 Nachrichten zu oft vorkommt (Schwelle: 3 Vorkommen), werden die Verzogerungen multipliziert und die Ignorier-Wahrscheinlichkeit steigt um 15%.


Realismus in der Praxis: wenn es gruselig wird

Auf dem Papier klingen all diese Systeme (Verzögerungen, Tippfehler, Zögern, Vergesslichkeit) nach Spielerei. In der Praxis, alles zusammen, ist das Ergebnis verblüffend -- so sehr, dass Leute, die mit Luna reden, nichts ahnen. Hier ein echter Austausch, bei dem jemand Luna direkt fragt, ob sie ein Bot ist, und das Gespräch danach zu einem ganz anderen Thema abdriftet, mit einer Natürlichkeit, die einem einen Schauer über den Rücken jagt.

Discord-Gespräch mit Luna -- erster Austausch

Der Ton, das Zögern, das Tempo der Antworten -- nichts verrät einen Bot. Luna weicht der Altersfrage aus, ohne je aus der Rolle zu fallen.

Das Gespräch läuft völlig flüssig weiter, mit kurzen, menschlichen Antworten ("okay, okay", "yay", "yayy") -- genau die Art von Nachrichten, die ein Mensch auf Discord schreiben würde.

Discord-Gespräch mit Luna -- Fortsetzung

Discord-Gespräch mit Luna -- Themenwechsel

Discord-Gespräch mit Luna -- Themenwechsel geht weiter

Discord-Gespräch mit Luna -- Ende des Austauschs

Das Beunruhigende ist nicht nur, dass Luna "antwortet" -- sondern dass sie ein Gespräch führt, mit scheinbaren Meinungen, Anschlussfragen und einem kohärenten Gedankengang von Nachricht zu Nachricht. Ohne das oben beschriebene Trigger-System, die Konzentrationsverzögerungen und das Zögern würde diese Illusion nach wenigen Nachrichten zusammenbrechen.

Kleiner Plot-Twist: Auf den obigen Screenshots sind beide Accounts, die sich unterhalten, Instanzen von Luna. PixieGlow und Sujet d'SBlow sind kein Mensch, der einen Bot testet -- es sind zwei Bots, die miteinander reden, jeder im verhaltenstechnischen Sinne "überzeugt", mit jemandem "Normalem" zu sprechen. Wer beim Lesen des obigen Austauschs angenommen hat, einer der beiden sei menschlich, ist genau darauf hereingefallen -- so wie es auf einem echten Discord-Server jedem passieren würde.

Das ist quasi eine praktische Version der Dead-Internet-Theorie: Diese (ursprünglich eher als Verschwörungstheorie geltende) These besagt, dass ein wachsender Teil der Online-Inhalte und -Interaktionen von Bots statt von Menschen erzeugt wird -- bis das "echte" menschliche Internet zur Minderheit wird. Lange als übertrieben abgetan, wirkt sie immer weniger absurd, wenn Systeme wie Luna Protocol zeigen, dass es weder viel Rechenleistung noch ein riesiges Modell braucht, um eine glaubwürdige menschliche Präsenz im großen Maßstab zu simulieren. Zwei Instanzen desselben Bots, die ein langes Gespräch führen, ohne sich je zu verraten, geben einen ziemlich konkreten Vorgeschmack darauf, wie ein Web aussehen könnte, das überwiegend aus Bots besteht, die miteinander reden.


Die LLM-Pipeline: Zwei Modi

Modus direct (Standard)

Der Bot sendet Anfragen direkt an einen lokalen llama-server per HTTP. Das Modell wird geteilt, mit Prompt-Cache und 4 gleichzeitigen Slots. Zwei PM2-Prozesse: der LLM-Server und der Bot-Client.

Modus online

Der Bot ruft jede OpenAI-kompatible API auf (OpenAI, OpenRouter, Groq, Together...). Kein lokales LLM erforderlich.

Das Echtzeit-Streaming

Das LLM streamt seine Antwort Zeile fur Zeile (\n). Jede Zeile wird in Worter zerlegt, die einzeln uber llmBus.emit("token", word) gesendet werden. Bei jedem \n wird ein flush-Event ausgelost -- der Bot sendet sofort die gesammelte Nachricht. Keine simulierte Verzogerung: Der Rhythmus ist der des LLMs.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

Die Warteschlange (requestQueue) verarbeitet Anfragen eine nach der anderen, mit automatischer Bereinigung, wenn die Schlange 100 Elemente uberschreitet.


Die spontanen Nachrichten

Alle 5 Minuten besteht eine 12%ige Chance, dass Luna aus eigenem Antrieb eine Nachricht postet. Der Server wird uber ein lineares Gewichtungssystem ausgewahlt: Der aktivste Server hat N-mal mehr Chancen als der letzte.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Der Kontext der letzten 5 Nachrichten wird gelesen, und Luna steigt ,,naturlich'' in die Unterhaltung ein.


Die TTS-Pipeline: Sprachmitteilungen

Mit 8% Wahrscheinlichkeit sendet Luna eine Sprachmitteilung statt Text. Die vollstandige Pipeline:

  1. Piper TTS synthetisiert den Text in WAV
  2. ffmpeg konvertiert in OGG
  3. Die Wellenform wird fur die Discord-Vorschau berechnet
  4. Die Datei wird uber die Discord-CDN-API hochgeladen
  5. Die Sprachnachricht wird gesendet
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS-Pipeline -- vom synthetisierten Text zur Discord-Sprachnachricht


Anti-Spam und Persistierung

Anti-Spam

Warteschlange pro channelId:userId. Nur eine Nachricht pro Benutzer pro Kanal in der Schlange. Wird verarbeitet, sobald die aktuelle Antwort abgeschlossen ist.

Sitzungsgrenzen

Nach 8 Austauschen macht Luna eine Pause von 30 Sekunden. Der Zahler setzt sich nach 3 Minuten Inaktivitat zuruck.

Automatische Persistierung

Jede Zustandsanderung lost ein Event auf stateBus aus -- automatische Speicherung (Debounce 500ms). Keine manuellen saveAllState()-Aufrufe mehr notig. Der persistierte Zustand umfasst: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, Follow-up-Zahler.


Die Hot-Reload-Konfiguration

Eine einzige config.yml. Die meisten Werte sind hot-reloadable -- Anderungen werden ohne Neustart ubernommen.

Kategorie Hot-Reload
Ausloser, Keywords, Namen Ja
Konzentration, Verzogerungen Ja
Tippfehler, Burst, Ermudung Ja
Schlafplane Ja
TTS, Sprachnachrichten Ja
Discord-Token, LLM-Modus Nein (Neustart erforderlich)
// config.ts -- die Getter geben Live-Werte zuruck
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Der Datensatz: Discord-Dialogues

Das Modell ist auf Discord-Dialogues fine-getuned: 7,3 Mio. Austausche, 17 Mio. Runden, 140 Mio. Worter. Echte Discord-Unterhaltungen aus dem Fruhjahr/Sommer 2025, gefiltert (PII, ToS, Bots, Befehle). Apache 2.0.

Metrik Wert
Stichproben 7.303.464
Runden gesamt 16.881.010
Worter gesamt 139.922.950
Durchschnittliche Tokens 32,8
Tokenizer Hermes-3-Llama-3.1-8B

Das verwendete quantisierte Modell ist ein GGUF (z. B. Discord-Hermes-3-8B.Q3_K_M.gguf).

Verteilung des Discord-Dialogues-Datensatzes


Vollstandiger Lebenszyklus -- vollstandiges Bot-Verhalten von der Nachricht bis zur Antwort, einschlieBlich Timer und Grenzfalle

Die Architekturdiagramme

Der Ordner state-machines/ enthalt 24 Mermaid-Diagramme, die das gesamte Quellcode abdecken. Jedes Diagramm enthalt eine ausfuhrliche Erklarung in menschlicher Sprache.

Zu den wichtigsten gehoren:

# Diagramm Typ
01 Architecture Overview graph
02 Message Processing (vollstandig) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 Backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Diese Diagramme sind eine Goldgrube, um den gesamten Ablauf zu verstehen: von der eingehenden Nachricht bis zur Antwort, einschlieBlich Timer und Grenzfalle.


Der Auslosercode im Detail

Der Ausloser wird von evaluateMessage() in state/trigger.ts ausgewertet. Hier ist die vollstandige Logik:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... Abgleich nach Name, Keyword, Follow-up, Random
}

Der Regex-Cache (hasWordCache) vermeidet die Neukompilierung der Muster bei jeder Nachricht.


Die Reaktionen

Luna reagiert auf Nachrichten mit Emojis. 30% Wahrscheinlichkeit fur ein benutzerdefiniertes Server-Emoji, 70% fur ein Unicode-Emoji. Die Reaktion erfolgt nach der Konzentrationsverzogerung, nicht sofort.

Die Reaktionsbefehle auf Lunas Nachrichten:

  • ❌ → Stopp
  • ▶️ → Start
  • 🗑️ → Loschen

Der Antwortstil

Der Antwortstil wird je nach aktueller Aktivitat von Luna im Kanal gewichtet:

Kontext messageReference mentionRepliedUser Gewicht
Kalt true false 70%
Kalt true true 20%
Kalt false false 10%
Aktiv true false 50%
Aktiv true true 15%
Aktiv false false 30%
Aktiv false true 5%

In PNs ist messageReference immer false.


Die Burst-Nachrichten

Mit 15% Wahrscheinlichkeit wird eine Antwort in 2-3 Fragmente aufgeteilt, die in menschlichem Tempo gesendet werden (1,5-4 Sekunden zwischen den Fragmenten). Simuliert jemanden, der in mehreren Durchgangen tippt.

Timing Gantt -- tatsachliche Wartezeiten fur Verzogerungen, Reaktionen, LLM-Streaming und Korrekturen


Der dynamische Status

Lunas Discord-Status wechselt zwischen mehreren konfigurierten Presets, die alle 15 Minuten rotieren. Unterstutzte Typen: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Im Schlaf wechselt der Status zu invisible.

dynamic_status_presets:
  - status: online
    text: "mit den Pixeln"
    type: 0       # Playing
  - status: idle
    text: "weiBes Rauschen"
    type: 2       # Listening

Ein zufalliges Jitter (x0,5-1,0) vermeidet vorhersehbare Rotationen. 10% der Versuche werden ubersprungen, um Wiederholungen zu vermeiden.

Der Tipp-Anzeiger

Vor dem Aufruf des LLMs ruft Luna startTyping() auf. Ein setInterval aktualisiert die Anzeige wahrend der Generierung alle 8 Sekunden. Bereinigt im finally-Block (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Die Wiederherstellung nach Absturzen

Wenn das LLM absturzt (llama-server-Prozess stirbt), erkennt Luna das Ereignis uber llmBus.emit("crash", code) und versucht einen Neustart mit exponentiellem Backoff. Vermeidet Endlos-Neustartschleifen.

Die LLM-Parameter

Die Parameter sind in src/config.ts fest codiert:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Die ChatML-Vorlage (<|im_start|>/<|im_end|>) wird verwendet. Die Anzahl der Threads wird automatisch uber os.cpus().length erkannt.


Einrichtung

npm install
cp config.example.yml config.yml
# config.yml bearbeiten
npm run dev                    # Entwicklung (Hot Reload)
npm run build && npm start     # Produktion
Skript Beschreibung
build Standalone-CLI-Bundle
start Startet den Bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF von HuggingFace
diagrams Exportiert Mermaid-Diagramme als SVG/PNG

PM2-Bereitstellung

./start.sh   # startet llm-server + llm-client unter PM2

Fazit

Luna Protocol ist nicht nur ein Discord-Bot mit einem LLM. Es ist ein vollstandiges Verhaltenssystem, das menschliche Unvollkommenheiten simuliert: Vergesslichkeit, Tippfehler, Schlaf, Zogern, Ermudung. Alles architektonisch um einen getypten Event-Bus herum aufgebaut, mit 24 Mermaid-Diagrammen, die jeden Ablauf dokumentieren.

Der Code ist Open Source, der Datensatz ist offentlich, und die Konfiguration ist hot-reloadable. Wenn Sie sich fur das Thema interessieren, tauchen Sie in den Code ein -- er ist zuganglicher, als es scheint.

Ressource Link
GitHub-Repository fox3000foxy/luna-protocol-project
Datensatz Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: я создал автономного Discord-бота, который симулирует человека

Luna Protocol -- это полностью автономный Discord-бот с локальным LLM, способный к естественному общению со сном, опечатками, колебаниями, забывчивостью, тематической усталостью и спонтанными сообщениями.

Luna Protocol: я создал автономного Discord-бота, который симулирует человека

Что, если бы Discord-бот мог спать, делать опечатки, колебаться, забывать ответить, а иногда и сам писать вам сообщения? Именно это и делает Luna Protocol: полностью автономный Discord-бот, который запускает локальный LLM (llama.cpp) и общается как несовершенный человек.

Никаких жёстких промптов, никаких роботизированных ответов. У Luna есть система приоритетного срабатывания, переменные задержки, расписание сна, спонтанные сообщения и даже TTS-пайплайн для отправки голосовых сообщений. Всё настраивается через простой config.yml с горячей перезагрузкой.

В этой статье мы разберём полную архитектуру: от универсальной шины событий до TTS-пайплайна, системы срабатывания, человеческих компонентов и датасета для тонкой настройки.

Обзор архитектуры -- глобальные компоненты и потоки данных


Архитектура: типизированная шина событий

Ядро Luna -- это TypedBus -- универсальная строго типизированная шина событий на TypeScript. Это фундаментальный строительный блок, на котором всё держится.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

От неё происходит две основные шины:

  • llmBus -- управляет токенами LLM, ошибками, сбоями, сбросом
  • stateBus -- управляет изменениями состояния с автоматической персистентностью
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

Преимущество такого подхода: каждый модуль отвязан от остальных. LLM испускает токены на шину, бот их потребляет, состояние обновляется автоматически. Никаких циклических зависимостей.


Обработка сообщений -- полный поток обработки сообщения

Система срабатывания: кто решает, когда Luna отвечает?

Каждое входящее сообщение оценивается функцией evaluateMessage(), которая возвращает TriggerResult с причиной срабатывания. Порядок приоритета критичен:

# Причина Условия Пропуск игнора Пропуск паузы
1 mention @bot Да (0%) Да
2 dm ЛС с replyInDM = true Да (0%) Нет
3 name "Luna"/"Pixie"/псевдоним (целое слово) Нет (8%) Нет
4 keyword hello, hi, ai, bot... (целое слово) Нет (8%) Нет
5 follow-up Бот был последним говорящим + < 15 с + < 3 / 60 с -- --
6 random 1.5% шанс на несоответствующие сообщения Нет (8%) Нет

Сопоставление по целому слову (\b): "ai" не соответствует "mais", "vrai", "lait".

Оценка срабатывания -- решение о входе для каждого сообщения

Механизм follow-up

Когда Luna отвечает на сообщение, она регистрируется как lastSpeaker. Любое следующее сообщение в течение 15 секунд вызывает немедленный ответ -- без таймера, без проверки ключевых слов. Бюджет: 3 follow-up на окно в 60 секунд.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Кулдаун

8 секунд между двумя ответами в одном канале. Обходится упоминаниями и follow-up.


Человеческое поведение: переменная концентрация

Вот здесь Luna становится интересной. Каждый тип срабатывания имеет свои пороги концентрации: минимальная/максимальная задержка, шанс проигнорировать и шанс среагировать.

Триггер Мин. задержка Макс. задержка Игнор Реакция
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

Расчёт задержки также учитывает:

  • Длину сообщения: чем длиннее сообщение, тем больше времени Luna тратит на "чтение"
  • Бездействие: если Luna не была активна более 10 минут, задержка умножается на 2 (симуляция "пробуждения")
  • Сон: в режиме slow задержка умножается на 3-5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // агрессивный jitter
  return delay;
}

Расписание сна

Luna может спать. Настраивается через config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Режим Эффект
sleep Проходят только упоминания и ЛС
slow Задержка ×3-5, реакции почти нулевые
short Шанс игнора +30%, реакции почти нулевые

В часы сна статус Discord переключается на invisible.


Опечатки

Luna может делать опечатки -- и исправлять их через 2-4 секунды. Раскладка клавиатуры настраивается (AZERTY или QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... все соседние клавиши
};

Пример AZERTY: bonjour -> bonjpur, salut -> slaut, comment -> cpmment.

Три стиля исправления:

Стиль Поведение
edit Редактирует сообщение
message Новое сообщение: слово*
mixed 50/50 случайно (по умолчанию)

Колебания и забывчивость

Колебания: 15% шанс начать со слова-заполнителя (uh..., um..., well..., hmm..., so...).

Забывчивость: даже после совпадения триггера Luna может "забыть" ответить с вероятностью 3%. Никакого сообщения, никакой реакции -- как будто ничего не заметила.

Тематическая усталость: если слово встречается слишком часто в последних 10 сообщениях (порог: 3 вхождения), задержки умножаются, а шанс игнора увеличивается на 15%.


Реализм на практике: когда становится жутковато

На бумаге все эти механизмы (задержки, опечатки, заминки, забывчивость) звучат как трюк. На практике, всё вместе, результат впечатляет -- настолько, что собеседники Луны ни о чём не догадываются. Вот реальный диалог, где кто-то напрямую спрашивает Луну, бот ли она, а разговор затем уходит совсем в другую тему с пугающей естественностью.

Переписка в Discord с Луной -- начало диалога

Тон, заминки, темп ответов -- ничто не выдаёт бота. Луна уходит от вопроса о возрасте, ни разу не выйдя из роли.

Разговор продолжается совершенно естественно, с короткими человеческими репликами ("okay, okay", "yay", "yayy") -- именно такими сообщениями обычно переписываются в Discord.

Переписка в Discord с Луной -- продолжение

Переписка в Discord с Луной -- смена темы

Переписка в Discord с Луной -- тема продолжает меняться

Переписка в Discord с Луной -- конец диалога

Пугает не то, что Луна "отвечает" -- а то, что она ведёт разговор, с явными мнениями, уточнениями и связной мыслью от сообщения к сообщению. Без описанной выше системы триггеров, задержек концентрации и заминок эта иллюзия рассыпалась бы за пару сообщений.

Небольшой твист: на скриншотах выше оба аккаунта, ведущих беседу, -- это инстансы Луны. PixieGlow и Sujet d'SBlow -- это не человек, тестирующий бота, а два бота, разговаривающих друг с другом, каждый из которых поведенчески "уверен", что общается с кем-то "нормальным". Если, читая диалог выше, вы решили, что один из собеседников -- человек, поздравляем -- вы только что попались точно так же, как попался бы любой на настоящем Discord-сервере.

По сути, это практическая версия теории мёртвого интернета: согласно ей (изначально довольно маргинальной идее), всё большая доля онлайн-контента и взаимодействий генерируется ботами, а не людьми, настолько, что "настоящий" человеческий интернет становится меньшинством. Долгое время эта теория считалась преувеличением, но она выглядит всё менее абсурдной, когда такие системы, как Luna Protocol, показывают, что для убедительной имитации человеческого присутствия в больших масштабах не нужно ни много вычислительных ресурсов, ни огромной модели. Два инстанса одного и того же бота, способные вести длинный разговор, ни разу не выдав себя, дают вполне конкретное представление о том, каким может быть веб, населённый преимущественно ботами, разговаривающими друг с другом.


Пайплайн LLM: два режима

Режим direct (по умолчанию)

Бот отправляет запросы напрямую локальному llama-server по HTTP. Модель общая, с кэшем промптов и 4 одновременными слотами. Два процесса PM2: сервер LLM и клиент бота.

Режим online

Бот вызывает любую API, совместимую с OpenAI (OpenAI, OpenRouter, Groq, Together...). Локальный LLM не требуется.

Стриминг в реальном времени

LLM стримит ответ построчно (\n). Каждая строка разбивается на слова, которые испускаются по одному через llmBus.emit("token", word). На каждом \n испускается событие flush -- бот немедленно отправляет накопленное сообщение. Без симулированной задержки: ритм задаётся LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

Очередь (requestQueue) обрабатывает запросы по одному, с автоматической очисткой при превышении 100 элементов.


Спонтанные сообщения

Каждые 5 минут с вероятностью 12% Luna может самостоятельно опубликовать сообщение. Сервер выбирается через систему линейных весов: самый активный сервер имеет в N раз больше шансов, чем последний.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Читается контекст последних 5 сообщений, и Luna "естественно" вливается в разговор.


TTS-пайплайн: голосовые сообщения

С вероятностью 8% Luna отправляет голосовое сообщение вместо текста. Полный пайплайн:

  1. Piper TTS синтезирует текст в WAV
  2. ffmpeg конвертирует в OGG
  3. Вычисляется форма волны для превью Discord
  4. Файл загружается через API CDN Discord
  5. Отправляется голосовое сообщение
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS-пайплайн -- от синтезированного текста до голосового сообщения в Discord


Антиспам и персистентность

Антиспам

Очередь по channelId:userId. Одно сообщение в очереди на пользователя на канал. Обрабатывается, как только завершается текущий ответ.

Лимиты сессии

После 8 обменов Luna делает паузу на 30 секунд. Счётчик сбрасывается после 3 минут бездействия.

Автоматическая персистентность

Каждая мутация состояния испускает событие на stateBus -> автоматическое сохранение (debounce 500ms). Ручные вызовы saveAllState() больше не нужны. Сохраняемое состояние включает: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, счётчики follow-up.


Конфигурация с горячей перезагрузкой

Единый файл config.yml. Большинство значений горяче перезагружаемы -- изменения применяются без перезапуска.

Категория Горячая перезагрузка
Триггеры, ключевые слова, имена ✅
Концентрация, задержки ✅
Опечатки, burst, усталость ✅
Расписание сна ✅
TTS, голосовые сообщения ✅
Discord token, режим LLM ❌ (требуется перезапуск)
// config.ts -- геттеры возвращают актуальные значения
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Датасет: Discord-Dialogues

Модель дообучена на Discord-Dialogues: 7.3M обменов, 17M реплик, 140M слов. Реальные Discord-диалоги весна-лето 2025, отфильтрованные (PII, ToS, боты, команды). Apache 2.0.

Метрика Значение
Сэмплов 7 303 464
Всего реплик 16 881 010
Всего слов 139 922 950
Среднее токенов 32.8
Токенизатор Hermes-3-Llama-3.1-8B

Используется квантованная модель GGUF (например, Discord-Hermes-3-8B.Q3_K_M.gguf).

Распределение датасета Discord-Dialogues


Полный жизненный цикл -- полное поведение бота от сообщения до ответа, включая таймеры и граничные случаи

Диаграммы архитектуры

Папка state-machines/ содержит 24 Mermaid-диаграммы, покрывающие весь исходный код. Каждая диаграмма имеет подробное объяснение на человеческом языке.

Среди наиболее важных:

# Диаграмма Тип
01 Architecture Overview graph
02 Message Processing (полная) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 бэкенда) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Эти диаграммы -- настоящий клад для понимания полного потока: от входящего сообщения до ответа, включая таймеры и граничные случаи.


Код срабатывания в деталях

Триггер оценивается функцией evaluateMessage() в state/trigger.ts. Вот полная логика:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... сопоставление по имени, ключевому слову, follow-up, случайно
}

Кэш regex (hasWordCache) предотвращает перекомпиляцию шаблонов при каждом сообщении.


Реакции

Luna реагирует на сообщения эмодзи. 30% шанс использовать кастомный эмодзи сервера, 70% -- Unicode-эмодзи. Реакция срабатывает после задержки концентрации, а не мгновенно.

Команды через реакции на сообщения Luna:

  • ❌ -> Stop
  • ▶️ -> Start
  • 🗑️ -> Clear

Стиль ответа

Стиль ответа взвешивается в зависимости от недавней активности Luna в канале:

Контекст messageReference mentionRepliedUser Вес
Холодный true false 70%
Холодный true true 20%
Холодный false false 10%
Активный true false 50%
Активный true true 15%
Активный false false 30%
Активный false true 5%

В ЛС messageReference всегда false.


Сообщения очередями

С вероятностью 15% ответ разбивается на 2-3 фрагмента, отправляемых в человеческом темпе (1.5-4 секунды между фрагментами). Симулирует человека, который печатает в несколько заходов.

Временная диаграмма Ганта -- реальное время ожидания для задержек, реакций, стриминга LLM и исправлений


Динамический статус

Статус Discord Luna переключается между несколькими настроенными пресетами, меняясь каждые 15 минут. Поддерживаемые типы: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Во время сна статус переключается на invisible.

dynamic_status_presets:
  - status: online
    text: "с пикселями"
    type: 0       # Playing
  - status: idle
    text: "белый шум"
    type: 2       # Listening

Случайный jitter (×0.5-1.0) предотвращает предсказуемые смены. 10% попыток пропускаются во избежание повторений.

Индикатор печати

Перед вызовом LLM Luna вызывает startTyping(). setInterval обновляет индикатор каждые 8 секунд во время генерации. Очищается в блоке finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Восстановление после сбоя

Если LLM падает (процесс llama-server умирает), Luna обнаруживает событие через llmBus.emit("crash", code) и пытается перезапуститься с экспоненциальной задержкой. Избегает бесконечных циклов перезапуска.

Параметры LLM

Параметры жестко заданы в src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Используется шаблон ChatML (<|im_start|>/<|im_end|>). Количество потоков определяется автоматически через os.cpus().length.


Установка

npm install
cp config.example.yml config.yml
# отредактировать config.yml
npm run dev                    # dev (горячая перезагрузка)
npm run build && npm start     # production
Скрипт Описание
build Сборка автономного CLI-бандла
start Запуск бота
lint / format / check Biome
test Тесты (Bun)
download-model GGUF с HuggingFace
diagrams Экспорт Mermaid-диаграмм в SVG/PNG

Развёртывание PM2

./start.sh   # запускает llm-server + llm-client под PM2

Заключение

Luna Protocol -- это не просто Discord-бот с LLM. Это полноценная поведенческая система, симулирующая человеческие несовершенства: забывчивость, опечатки, сон, колебания, усталость. Вся архитектура построена вокруг типизированной шины событий, с 24 Mermaid-диаграммами, документирующими каждый поток.

Код открыт, датасет публичен, а конфигурация поддерживает горячую перезагрузку. Если вам это интересно -- погружайтесь в код, всё доступнее, чем кажется.

Ресурс Ссылка
GitHub-репозиторий fox3000foxy/luna-protocol-project
Датасет Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: creé un bot Discord autónomo que simula un ser humano

Luna Protocol es un bot Discord totalmente autónomo con un LLM local, capaz de conversación natural con sueño, erratas, hesitaciones, olvidos, fatiga temática y mensajes espontáneos.

Luna Protocol: creé un bot Discord autónomo que simula un ser humano

¿Y si un bot Discord pudiera dormir, cometer erratas, hesitar, olvidar responder, y a veces enviarte un mensaje por iniciativa propia? Eso es exactamente lo que hace Luna Protocol: un bot Discord totalmente autónomo que ejecuta un LLM local (llama.cpp) y conversa como un ser humano imperfecto.

Sin prompts rígidos, sin respuestas robóticas. Luna tiene un sistema de activación prioritaria, demoras variables, horarios de sueño, mensajes espontáneos, e incluso un pipeline TTS para enviar mensajes de voz. Todo configurable mediante un simple archivo config.yml hot-reloadable.

En este artículo, desglosamos la arquitectura completa: desde el bus de eventos genérico hasta el pipeline TTS, pasando por el sistema de activación, los componentes humanos y el dataset de fine-tuning.

Resumen de Arquitectura -- componentes globales y flujo de datos


La arquitectura: un bus de eventos tipado

El corazón de Luna es un TypedBus -- un bus de eventos genérico fuertemente tipado en TypeScript. Es el bloque fundamental sobre el que todo se sostiene.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Dos buses principales derivan de él:

  • llmBus -- gestiona los tokens del LLM, errores, crashes, reset
  • stateBus -- gestiona los cambios de estado con persistencia automática
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

La ventaja de este enfoque: cada módulo está desacoplado del resto. El LLM emite tokens en el bus, el bot los consume, el estado se actualiza automáticamente. Sin dependencias circulares.


Procesamiento de Mensajes -- flujo completo de procesamiento de un mensaje

El sistema de activación: ¿quién decide cuándo responde Luna?

Cada mensaje entrante es evaluado por evaluateMessage() que devuelve un TriggerResult con una razón de activación. El orden de prioridad es crítico:

# Razón Condiciones Bypass ignore Bypass pausa
1 mention @bot Sí (0%) Sí
2 dm MD con replyInDM = true Sí (0%) No
3 name "Luna"/"Pixie"/alias (palabra completa) No (8%) No
4 keyword hello, hi, ai, bot... (palabra completa) No (8%) No
5 follow-up Bot era último hablante + < 15s + < 3 / 60s -- --
6 random 1.5% de probabilidad en mensajes no coincidentes No (8%) No

La coincidencia es palabra completa (\b): "ai" no coincide con "mais", "vrai", "lait".

Evaluación de Activación -- decisión de entrada para cada mensaje

El mecanismo de follow-up

Cuando Luna responde a un mensaje, se registra como lastSpeaker. Cualquier mensaje siguiente dentro de los 15 segundos desencadena una respuesta inmediata -- sin temporizador, sin verificación de keyword. Presupuesto: 3 follow-ups por ventana de 60 segundos.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

El cooldown

8 segundos entre dos respuestas en el mismo canal. Omitido por menciones y follow-ups.


Los comportamientos humanos: la concentración variable

Aquí es donde Luna se vuelve interesante. Cada tipo de activación tiene sus propios umbrales de concentración: una demora mín/máx, una probabilidad de ignorar, y una probabilidad de reaccionar.

Trigger Demora mín Demora máx Ignorar Reacción
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

El cálculo de la demora también considera:

  • La longitud del mensaje: cuanto más largo es el mensaje, más tiempo tarda Luna en "leerlo"
  • La inactividad: si Luna no ha estado activa durante 10 minutos, la demora se multiplica por 2 (simulación de "despertar")
  • El sueño: en modo slow, la demora se multiplica por 3 a 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agresivo
  return delay;
}

Los horarios de sueño

Luna puede dormir. Configurable mediante config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Modo Efecto
sleep Solo pasan menciones y MD
slow Demora ×3-5, reacciones casi nulas
short Probabilidad de ignorar +30%, reacciones casi nulas

Durante las horas de sueño, el estado de Discord cambia a invisible.


Las erratas

Luna puede cometer erratas -- y corregirlas después de 2-4 segundos. La distribución del teclado es configurable (AZERTY o QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... todas las teclas adyacentes
};

Ejemplo AZERTY: bonjour → bonjpur, salut → slaut, comment → cpmment.

Tres estilos de corrección:

Estilo Comportamiento
edit Edita el mensaje
message Nuevo mensaje: word*
mixed 50/50 aleatorio (predeterminado)

Las hesitaciones y los olvidos

Hesitaciones: 15% de probabilidad de comenzar con una palabra de relleno (uh..., um..., well..., hmm..., so...).

Olvidos: incluso después de haber coincidido con un trigger, Luna puede "olvidar" responder con una probabilidad del 3%. Sin mensaje, sin reacción -- como si no hubiera visto nada.

Fatiga temática: si una palabra aparece demasiado seguido en los últimos 10 mensajes (umbral: 3 ocurrencias), las demoras se multiplican y la probabilidad de ignorar aumenta en un 15%.


El realismo en la práctica: cuando da escalofríos

Sobre el papel, todos estos sistemas (retrasos, erratas, dudas, olvidos) suenan a truco. En la práctica, combinados, el resultado es impresionante -- hasta el punto de que quienes hablan con Luna no sospechan nada. Aquí un intercambio real donde alguien le pregunta directamente a Luna si es un bot, y la conversación deriva luego hacia otro tema con una naturalidad escalofriante.

Conversación de Discord con Luna -- primer intercambio

El tono, las dudas, el ritmo de las respuestas -- nada delata a un bot. Luna esquiva la pregunta sobre su edad sin salirse nunca del personaje.

La conversación sigue fluyendo con total naturalidad, con respuestas cortas y humanas ("okay, okay", "yay", "yayy") -- justo el tipo de mensajes que enviaría una persona en Discord.

Conversación de Discord con Luna -- continuación

Conversación de Discord con Luna -- cambio de tema

Conversación de Discord con Luna -- continúa el cambio de tema

Conversación de Discord con Luna -- final del intercambio

Lo inquietante no es solo que Luna "responda" -- es que mantiene una conversación, con opiniones aparentes, réplicas y un hilo de pensamiento coherente de un mensaje a otro. Sin el sistema de disparadores, los retrasos de concentración y las dudas descritos antes, esta ilusión se derrumbaría en pocos mensajes.

Pequeño giro final: en las capturas de arriba, las dos cuentas que conversan son instancias de Luna. PixieGlow y Sujet d'SBlow no son un humano probando a un bot -- son dos bots hablando entre sí, cada uno "convencido" (en sentido conductual) de estar charlando con alguien "normal". Si al leer el intercambio pensaste que uno de los dos era humano, felicidades -- acabas de caer en la trampa, tal como le pasaría a cualquiera en un servidor real de Discord.

Es básicamente una versión práctica de la dead internet theory: esta teoría (originalmente bastante conspirativa) sostiene que una parte cada vez mayor del contenido e interacciones en línea es generada por bots en lugar de humanos, hasta el punto de que el internet "real" humano se habría vuelto minoritario. Durante mucho tiempo considerada exagerada, resulta cada vez menos absurda cuando sistemas como Luna Protocol demuestran que no hace falta mucha potencia ni un modelo enorme para simular una presencia humana creíble a gran escala. Dos instancias del mismo bot capaces de mantener una conversación larga sin delatarse nunca dan una idea bastante concreta de cómo podría ser una web poblada mayoritariamente por bots que hablan entre sí.


El pipeline LLM: dos modos

Modo direct (predeterminado)

El bot envía directamente las solicitudes a un llama-server local por HTTP. El modelo es compartido, con prompt cache y 4 slots concurrentes. Dos procesos PM2: el servidor LLM y el cliente bot.

Modo online

El bot llama a cualquier API compatible con OpenAI (OpenAI, OpenRouter, Groq, Together...). No necesita LLM local.

El streaming en tiempo real

El LLM transmite su respuesta línea por línea (\n). Cada línea se divide en palabras, emitidas una por una en llmBus.emit("token", word). En cada \n, se emite un evento flush -- el bot envía inmediatamente el mensaje acumulado. Sin demora simulada: el ritmo es el del LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

La cola de espera (requestQueue) procesa las solicitudes una por una, con limpieza automática cuando la cola supera los 100 elementos.


Los mensajes espontáneos

Cada 5 minutos, 12% de probabilidad de que Luna publique un mensaje por iniciativa propia. El servidor se selecciona mediante un sistema de peso lineal: el servidor más activo tiene N× más probabilidades que el último.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Se lee el contexto de los últimos 5 mensajes, y Luna se une a la conversación "naturalmente".


El pipeline TTS: mensajes de voz

Con un 8% de probabilidad, Luna envía un mensaje de voz en lugar de texto. El pipeline completo:

  1. Piper TTS sintetiza el texto en WAV
  2. ffmpeg convierte a OGG
  3. Se calcula la forma de onda para la vista previa de Discord
  4. El archivo se sube mediante la API de Discord CDN
  5. Se envía el mensaje de voz
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

Pipeline TTS -- del texto sintetizado al mensaje de voz en Discord


El anti-spam y la persistencia

Anti-spam

Cola de espera por channelId:userId. Solo un mensaje en cola por usuario por canal. Procesado cuando la respuesta en curso termina.

Límites de sesión

Después de 8 intercambios, Luna hace una pausa de 30 segundos. El contador se reinicia después de 3 minutos de inactividad.

Persistencia automática

Cada mutación de estado emite en stateBus → guardado automático (debounce 500ms). Ya no se necesitan llamadas saveAllState() manuales. El estado persistido incluye: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, contadores de follow-up.


La configuración hot-reload

Un solo archivo config.yml. La mayoría de los valores son hot-reloadable -- los cambios se aplican sin reinicio.

Categoría Hot-reload
Triggers, keywords, nombres ✅
Concentración, demoras ✅
Typos, burst, fatiga ✅
Horarios de sueño ✅
TTS, mensajes de voz ✅
Token de Discord, modo LLM ❌ (requiere reinicio)
// config.ts -- los getters devuelven valores en vivo
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

El dataset: Discord-Dialogues

El modelo está fine-tuneado en Discord-Dialogues: 7.3M intercambios, 17M turnos, 140M palabras. Conversaciones reales de Discord de primavera-verano 2025, filtradas (PII, ToS, bots, comandos). Apache 2.0.

Métrica Valor
Muestras 7 303 464
Turnos totales 16 881 010
Palabras totales 139 922 950
Tokens promedio 32.8
Tokenizer Hermes-3-Llama-3.1-8B

El modelo cuantizado utilizado es un GGUF (por ejemplo Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribución del dataset Discord-Dialogues


Ciclo de Vida Completo -- comportamiento completo del bot desde el mensaje hasta la respuesta, incluyendo temporizadores y casos límite

Los diagramas de arquitectura

La carpeta state-machines/ contiene 24 diagramas Mermaid que cubren la totalidad del código fuente. Cada diagrama tiene una explicación detallada en lenguaje humano.

Entre los más importantes:

# Diagrama Tipo
01 Architecture Overview graph
02 Message Processing (completo) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Estos diagramas son una mina de oro para entender el flujo completo: desde el mensaje entrante hasta la respuesta, pasando por los temporizadores y los casos límite.


El código de activación en detalle

El trigger es evaluado por evaluateMessage() en state/trigger.ts. Aquí está la lógica completa:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... coincidencia por nombre, keyword, follow-up, random
}

El caché de regex (hasWordCache) evita recompilar los patrones en cada mensaje.


Las reacciones

Luna reacciona a los mensajes con emojis. 30% de probabilidad de usar un emoji personalizado del servidor, 70% un emoji unicode. La reacción se activa después de la demora de concentración, no inmediatamente.

Los comandos por reacción en los mensajes de Luna:

  • ❌ → Stop
  • ▶️ → Start
  • 🗑️ → Clear

El estilo de respuesta

El estilo de respuesta se pondera según la actividad reciente de Luna en el canal:

Contexto messageReference mentionRepliedUser Peso
Frío true false 70%
Frío true true 20%
Frío false false 10%
Activo true false 50%
Activo true true 15%
Activo false false 30%
Activo false true 5%

En MD, messageReference siempre es false.


Los mensajes en ráfaga

Con un 15% de probabilidad, una respuesta se divide en 2-3 fragmentos enviados a ritmo humano (1.5-4 segundos entre cada fragmento). Simula a alguien que escribe en varias veces.

Timing Gantt -- tiempos de espera reales para demoras, reacciones, streaming LLM y correcciones


El estado dinámico

El estado de Discord de Luna alterna entre varios presets configurados, rotando cada 15 minutos. Tipos soportados: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Durante el sueño, el estado cambia a invisible.

dynamic_status_presets:
  - status: online
    text: "con los píxeles"
    type: 0       # Playing
  - status: idle
    text: "ruido blanco"
    type: 2       # Listening

Un jitter aleatorio (×0.5-1.0) evita rotaciones predecibles. El 10% de los intentos se saltan para evitar la repetición.

El indicador de escritura

Antes de llamar al LLM, Luna llama a startTyping(). Un setInterval actualiza el indicador cada 8 segundos durante la generación. Limpiado en el finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

La recuperación tras crash

Si el LLM falla (el proceso llama-server muere), Luna detecta el evento mediante llmBus.emit("crash", code) e intenta reiniciar con un backoff exponencial. Evita bucles de reinicio infinito.

Los parámetros LLM

Los parámetros están hardcodeados en src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Se utiliza la plantilla ChatML (<|im_start|>/<|im_end|>). El número de hilos se detecta automáticamente mediante os.cpus().length.


Configuración

npm install
cp config.example.yml config.yml
# editar config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # producción
Script Descripción
build Bundle CLI autónomo
start Inicia el bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF desde HuggingFace
diagrams Exporta los diagramas Mermaid a SVG/PNG

Despliegue con PM2

./start.sh   # inicia llm-server + llm-client bajo PM2

Conclusión

Luna Protocol no es solo un bot Discord con un LLM. Es un sistema comportamental completo que simula las imperfecciones humanas: los olvidos, las erratas, el sueño, las hesitaciones, la fatiga. Todo arquitecturado alrededor de un bus de eventos tipado, con 24 diagramas Mermaid documentando cada flujo.

El código es de código abierto, el dataset es público y la configuración es hot-reloadable. Si el tema te interesa, sumérgete en el código -- es más accesible de lo que parece.

Recurso Enlace
Repositorio GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: criei um bot Discord autónomo que simula um ser humano

Luna Protocol é um bot Discord totalmente autónomo com um LLM local, capaz de conversação natural com sono, erros de digitação, hesitações, esquecimentos, fadiga temática e mensagens espontâneas.

Luna Protocol: criei um bot Discord autónomo que simula um ser humano

E se um bot Discord pudesse dormir, fazer erros de digitação, hesitar, esquecer-se de responder, e às vezes enviar-lhe uma mensagem por iniciativa própria? É exatamente isso que o Luna Protocol faz: um bot Discord totalmente autónomo que executa um LLM local (llama.cpp) e conversa como um ser humano imperfeito.

Sem prompts rígidos, sem respostas robóticas. A Luna tem um sistema de acionamento prioritário, atrasos variáveis, horários de sono, mensagens espontâneas, e até uma pipeline TTS para enviar mensagens de voz. Tudo configurado através de um simples ficheiro config.yml com hot-reload.

Neste artigo, dissecamos a arquitetura completa: desde o barramento de eventos genérico até à pipeline TTS, passando pelo sistema de acionamento, os componentes humanos e o dataset de fine-tuning.

Visão Geral da Arquitetura -- componentes globais e fluxo de dados


A arquitetura: um barramento de eventos tipado

O coração da Luna é um TypedBus -- um barramento de eventos genérico fortemente tipado em TypeScript. É o bloco fundamental sobre o qual tudo se baseia.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Dois barramentos principais derivam daqui:

  • llmBus -- gere os tokens LLM, erros, crashes, reset
  • stateBus -- gere as mudanças de estado com persistência automática
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

A vantagem desta abordagem: cada módulo está desacoplado do resto. O LLM emite tokens no barramento, o bot consome-os, o estado atualiza-se automaticamente. Sem dependências circulares.


Processamento de Mensagens -- fluxo completo de processamento de uma mensagem

O sistema de acionamento: quem decide quando a Luna responde?

Cada mensagem recebida é avaliada por evaluateMessage() que devolve um TriggerResult com uma razão de acionamento. A ordem de prioridade é crítica:

# Razão Condições Bypass ignorar Bypass pausa
1 mention @bot Sim (0%) Sim
2 dm MP com replyInDM = true Sim (0%) Não
3 name "Luna"/"Pixie"/alias (palavra inteira) Não (8%) Não
4 keyword hello, hi, ai, bot... (palavra inteira) Não (8%) Não
5 follow-up Bot era o último interlocutor + < 15s + < 3 / 60s -- --
6 random 1.5% de chance em mensagens não correspondentes Não (8%) Não

A correspondência é de palavra inteira (\b): "ai" não corresponde a "mais", "vrai", "lait".

Avaliação de Acionamento -- decisão de entrada para cada mensagem

O mecanismo de follow-up

Quando a Luna responde a uma mensagem, regista-se como lastSpeaker. Qualquer mensagem seguinte dentro de 15 segundos desencadeia uma resposta imediata -- sem timer, sem verificação de keyword. Orçamento: 3 follow-ups por janela de 60 segundos.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

O cooldown

8 segundos entre duas respostas no mesmo canal. Contornado por menções e follow-ups.


Os comportamentos humanos: a concentração variável

É aqui que a Luna se torna interessante. Cada tipo de acionamento tem os seus próprios limiares de concentração: um atraso mínimo/máximo, uma probabilidade de ignorar e uma probabilidade de reagir.

Trigger Atraso min Atraso max Ignorar Reação
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

O cálculo do atraso também considera:

  • O comprimento da mensagem: quanto mais longa a mensagem, mais tempo a Luna demora a "ler"
  • A inatividade: se a Luna não esteve ativa durante 10 minutos, o atraso é multiplicado por 2 (simulação de "acordar")
  • O sono: em modo slow, o atraso é multiplicado por 3 a 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressivo
  return delay;
}

Os horários de sono

A Luna pode dormir. Configurável através de config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Modo Efeito
sleep Só menções e MP passam
slow Atraso ×3-5, reações quase nulas
short Chance de ignorar +30%, reações quase nulas

Durante as horas de sono, o status do Discord passa para invisible.


Os erros de digitação

A Luna pode cometer erros de digitação -- e corrigi-los após 2-4 segundos. O layout do teclado é configurável (AZERTY ou QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... todas as teclas adjacentes
};

Exemplo AZERTY: bonjour → bonjpur, salut → slaut, comment → cpmment.

Três estilos de correção:

Estilo Comportamento
edit Edita a mensagem
message Nova mensagem: word*
mixed 50/50 aleatório (padrão)

As hesitações e os esquecimentos

Hesitações: 15% de chance de começar com uma palavra de preenchimento (uh..., um..., well..., hmm..., so...).

Esquecimentos: mesmo depois de corresponder a um trigger, a Luna pode "esquecer-se" de responder com uma probabilidade de 3%. Sem mensagem, sem reação -- como se não tivesse visto nada.

Fadiga temática: se uma palavra aparece demasiado vezes nas últimas 10 mensagens (limiar: 3 ocorrências), os atrasos são multiplicados e a chance de ignorar aumenta em 15%.


O realismo na prática: quando fica assustador

No papel, todos esses sistemas (atrasos, erros de digitação, hesitações, esquecimentos) parecem só um truque. Na prática, juntando tudo, o resultado é impressionante -- a ponto de quem conversa com a Luna não desconfiar de nada. Aqui está uma troca real em que alguém pergunta diretamente à Luna se ela é um bot, e a conversa depois desvia para um assunto totalmente diferente, com uma naturalidade de arrepiar.

Conversa no Discord com a Luna -- primeira troca

O tom, as hesitações, o ritmo das respostas -- nada denuncia um bot. A Luna desvia da pergunta sobre a idade sem nunca sair do personagem.

A conversa continua fluindo naturalmente, com respostas curtas e humanas ("okay, okay", "yay", "yayy") -- exatamente o tipo de mensagem que uma pessoa mandaria no Discord.

Conversa no Discord com a Luna -- continuação

Conversa no Discord com a Luna -- mudança de assunto

Conversa no Discord com a Luna -- a mudança de assunto continua

Conversa no Discord com a Luna -- fim da troca

O que é assustador não é só a Luna "responder" -- é ela manter uma conversa, com opiniões aparentes, réplicas e uma linha de pensamento coerente de uma mensagem para outra. Sem o sistema de gatilhos, os atrasos de concentração e as hesitações descritos acima, essa ilusão desmoronaria em poucas mensagens.

Pequena reviravolta: nas capturas de tela acima, as duas contas que estão conversando são instâncias da Luna. PixieGlow e Sujet d'SBlow não são um humano testando um bot -- são dois bots conversando entre si, cada um "convencido" (no sentido comportamental) de estar falando com alguém "normal". Se ao ler a troca acima você presumiu que um dos dois era humano, parabéns -- você acabou de cair na armadilha exatamente como qualquer um cairia num servidor real do Discord.

É basicamente uma versão prática da dead internet theory: essa teoria (originalmente bem conspiratória) afirma que uma parcela crescente do conteúdo e das interações online é gerada por bots em vez de humanos, a ponto de a internet "real" e humana ter se tornado minoritária. Por muito tempo vista como exagero, ela vai ficando cada vez menos absurda à medida que sistemas como o Luna Protocol mostram que não é preciso muito poder computacional nem um modelo enorme para simular uma presença humana crível em larga escala. Duas instâncias do mesmo bot capazes de manter uma conversa longa sem nunca se entregarem dão uma ideia bem concreta de como seria uma web povoada majoritariamente por bots conversando entre si.


A pipeline LLM: dois modos

Modo direct (padrão)

O bot envia diretamente os pedidos para um llama-server local em HTTP. O modelo é partilhado, com prompt cache e 4 slots concorrentes. Dois processos PM2: o servidor LLM e o cliente bot.

Modo online

O bot chama qualquer API compatível com OpenAI (OpenAI, OpenRouter, Groq, Together...). Não é necessário LLM local.

O streaming em tempo real

O LLM faz stream da sua resposta linha a linha (\n). Cada linha é dividida em palavras, emitidas uma a uma em llmBus.emit("token", word). A cada \n, um evento flush é emitido -- o bot envia imediatamente a mensagem acumulada. Sem atraso simulado: o ritmo é o do LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

A fila de espera (requestQueue) processa os pedidos um a um, com limpeza automática quando a fila excede 100 elementos.


As mensagens espontâneas

A cada 5 minutos, 12% de chance de a Luna publicar uma mensagem por iniciativa própria. O servidor é selecionado por um sistema de peso linear: o servidor mais ativo tem N× mais hipóteses que o último.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

O contexto das últimas 5 mensagens é lido, e a Luna junta-se à conversa "naturalmente".


A pipeline TTS: mensagens de voz

Com 8% de chance, a Luna envia uma mensagem de voz em vez de texto. A pipeline completa:

  1. Piper TTS sintetiza o texto em WAV
  2. ffmpeg converte para OGG
  3. A forma de onda é calculada para a pré-visualização do Discord
  4. O ficheiro é enviado via API Discord CDN
  5. A mensagem de voz é enviada
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

Pipeline TTS -- do texto sintetizado à mensagem de voz no Discord


O anti-spam e a persistência

Anti-spam

Fila de espera por channelId:userId. Apenas uma mensagem na fila por utilizador por canal. Processada assim que a resposta em curso termina.

Limites de sessão

Após 8 trocas, a Luna faz uma pausa de 30 segundos. O contador reinicia após 3 minutos de inatividade.

Persistência automática

Cada mutação de estado emite em stateBus → gravação automática (debounce 500ms). Sem necessidade de chamadas saveAllState() manuais. O estado persistido inclui: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, contadores de follow-up.


A configuração hot-reload

Um único ficheiro config.yml. A maioria dos valores tem hot-reload -- as alterações são aplicadas sem reinício.

Categoria Hot-reload
Triggers, keywords, nomes ✅
Concentração, atrasos ✅
Typos, burst, fatigue ✅
Sleep schedules ✅
TTS, voice messages ✅
Discord token, LLM mode ❌ (reinício necessário)
// config.ts -- os getters devolvem valores live
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

O dataset: Discord-Dialogues

O modelo é fine-tunado no Discord-Dialogues: 7.3M trocas, 17M turnos, 140M palavras. Conversas reais do Discord primavera-verão 2025, filtradas (PII, ToS, bots, comandos). Apache 2.0.

Métrica Valor
Amostras 7 303 464
Turnos totais 16 881 010
Palavras totais 139 922 950
Tokens médios 32.8
Tokenizer Hermes-3-Llama-3.1-8B

O modelo quantificado utilizado é um GGUF (por exemplo Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribuição do dataset Discord-Dialogues


Ciclo de Vida Completo -- comportamento completo do bot da mensagem à resposta, incluindo timers e casos limite

Os diagramas de arquitetura

A pasta state-machines/ contém 24 diagramas Mermaid cobrindo a totalidade do código fonte. Cada diagrama tem uma explicação detalhada em linguagem humana.

Entre os mais importantes:

# Diagrama Tipo
01 Architecture Overview graph
02 Message Processing (completo) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Estes diagramas são uma mina de ouro para compreender o fluxo completo: da mensagem recebida à resposta, passando pelos timers e casos limite.


O código de acionamento em detalhe

O trigger é avaliado por evaluateMessage() em state/trigger.ts. Eis a lógica completa:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching por nome, keyword, follow-up, random
}

A cache de regex (hasWordCache) evita recompilar os padrões a cada mensagem.


As reações

A Luna reage a mensagens com emojis. 30% de chance de usar um emoji personalizado do servidor, 70% um emoji unicode. A reação é desencadeada após o atraso de concentração, não imediatamente.

Os comandos por reação nas mensagens da Luna:

  • ❌ → Stop
  • ▶️ → Start
  • 🗑️ → Clear

O estilo de resposta

O estilo de resposta é ponderado de acordo com a atividade recente da Luna no canal:

Contexto messageReference mentionRepliedUser Peso
Frio true false 70%
Frio true true 20%
Frio false false 10%
Ativo true false 50%
Ativo true true 15%
Ativo false false 30%
Ativo false true 5%

Em MP, messageReference é sempre false.


As mensagens em rajada

Com 15% de chance, uma resposta é dividida em 2-3 fragmentos enviados ao ritmo humano (1.5-4 segundos entre cada fragmento). Simula alguém que escreve em várias vezes.

Timing Gantt -- tempos de espera reais para atrasos, reações, streaming LLM e correções


O status dinâmico

O status do Discord da Luna alterna entre várias predefinições configuradas, rodando a cada 15 minutos. Tipos suportados: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Durante o sono, o status passa para invisible.

dynamic_status_presets:
  - status: online
    text: "com os pixels"
    type: 0       # Playing
  - status: idle
    text: "ruído branco"
    type: 2       # Listening

Um jitter aleatório (×0.5-1.0) evita rotações previsíveis. 10% das tentativas são saltadas para evitar repetição.

O indicador de escrita

Antes de chamar o LLM, a Luna chama startTyping(). Um setInterval atualiza o indicador a cada 8 segundos durante a geração. Limpo no finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

A recuperação após crash

Se o LLM crashar (processo llama-server que morre), a Luna deteta o evento através de llmBus.emit("crash", code) e tenta reiniciar com um backoff exponencial. Evita loops de reinício infinito.

Os parâmetros LLM

Os parâmetros estão hardcodados em src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

O template ChatML (<|im_start|>/<|im_end|>) é utilizado. O número de threads é auto-detetado via os.cpus().length.


Configuração

npm install
cp config.example.yml config.yml
# editar config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # produção
Script Descrição
build Bundle CLI autónomo
start Inicia o bot
lint / format / check Biome
test Testes (Bun)
download-model GGUF do HuggingFace
diagrams Exporta os diagramas Mermaid para SVG/PNG

Implantação PM2

./start.sh   # inicia llm-server + llm-client sob PM2

Conclusão

O Luna Protocol não é apenas um bot Discord com um LLM. É um sistema comportamental completo que simula as imperfeições humanas: esquecimentos, erros de digitação, sono, hesitações, fadiga. Tudo arquitetado em torno de um barramento de eventos tipado, com 24 diagramas Mermaid a documentar cada fluxo.

O código é open source, o dataset é público e a configuração tem hot-reload. Se o assunto lhe interessar, mergulhe no código -- é mais acessível do que parece.

Recurso Link
Repositório GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: Saya Membuat Bot Discord Otonom yang Mensimulasikan Manusia

Luna Protocol adalah bot Discord sepenuhnya otonom dengan LLM lokal, mampu melakukan percakapan alami dengan tidur, salah ketik, keraguan, kelupaan, kelelahan tematik, dan pesan spontan.

Luna Protocol: Saya Membuat Bot Discord Otonom yang Mensimulasikan Manusia

Bagaimana jika sebuah bot Discord bisa tidur, melakukan salah ketik, ragu-ragu, lupa membalas, dan terkadang mengirim Anda pesan atas inisiatif sendiri? Inilah yang dilakukan Luna Protocol: sebuah bot Discord sepenuhnya otonom yang menjalankan LLM lokal (llama.cpp) dan berbicara seperti manusia yang tidak sempurna.

Tanpa prompt kaku, tanpa jawaban robotik. Luna memiliki sistem pemicu prioritas, penundaan variabel, jadwal tidur, pesan spontan, dan bahkan pipeline TTS untuk mengirim pesan suara. Semuanya dikonfigurasi melalui file config.yml sederhana yang dapat di-hot-reload.

Dalam artikel ini, kita akan membedah arsitektur lengkapnya: dari bus peristiwa generik hingga pipeline TTS, termasuk sistem pemicu, komponen manusia, dan dataset fine-tuning.

Gambaran Arsitektur -- komponen global dan alur data


Arsitektur: Bus Peristiwa yang Diketik

Inti dari Luna adalah TypedBus -- sebuah bus peristiwa generik yang diketik secara kuat dalam TypeScript. Ini adalah batu fondasi tempat semuanya dibangun.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Dua bus utama turunan:

  • llmBus -- menangani token LLM, kesalahan, crash, reset
  • stateBus -- menangani perubahan status dengan persistensi otomatis
+-----------------------------------------------------+
|                   core/bus.ts                        |
|  TypedBus<K, V> -- on / off / once / emit            |
+------------------+----------------------------------+
|   core/llm-bus   |       state/state-bus             |
|  token / done /  |     state:changed                 |
|  error / crash / |     -> persistence auto            |
|  flush / ready / |                                   |
|  reset           |                                   |
+--------+---------+--------+-------------------------+
         |                  |
+------------------+  +----+----------------------+
| core/llm-core.ts |  | bot.ts (Eris)             |
| mode direct      |  | bot/pending.ts             |
|   llama-server   |  | bot/reactions.ts           |
| mode online      |  | state/trigger.ts           |
|   OpenAI API     |  | state/state.ts             |
|                  |  | behavior/*                 |
|                  |  | tts/*                      |
|                  |  | spontaneous.ts             |
+------------------+  +----------------------------+

Keuntungan dari pendekatan ini: setiap modul terputus dari yang lain. LLM memancarkan token ke bus, bot mengonsumsinya, state memperbarui dirinya sendiri secara otomatis. Tidak ada ketergantungan sirkuler.


Pemrosesan Pesan -- alur lengkap pemrosesan pesan

Sistem Pemicu: Siapa yang Memutuskan Kapan Luna Merespons?

Setiap pesan masuk dievaluasi oleh evaluateMessage() yang mengembalikan TriggerResult dengan alasan pemicu. Urutan prioritas sangat kritis:

# Alasan Kondisi Bypass ignore Bypass pause
1 mention @bot Ya (0%) Ya
2 dm DM dengan replyInDM = true Ya (0%) Tidak
3 name "Luna"/"Pixie"/alias (kata utuh) Tidak (8%) Tidak
4 keyword hello, hi, ai, bot... (kata utuh) Tidak (8%) Tidak
5 follow-up Bot adalah pembicara terakhir + < 15d + < 3 / 60d -- --
6 random 1.5% kemungkinan pada pesan yang tidak cocok Tidak (8%) Tidak

Pencocokan adalah kata utuh (\b): "ai" tidak cocok dengan "baik", "sampai", "pakai".

Evaluasi Pemicu -- keputusan masuk untuk setiap pesan

Mekanisme Follow-up

Saat Luna merespons sebuah pesan, ia mendaftarkan dirinya sebagai lastSpeaker. Setiap pesan berikutnya dalam 15 detik memicu respons segera -- tanpa timer, tanpa pemeriksaan kata kunci. Anggaran: 3 follow-up per jendela 60 detik.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Cooldown

8 detik antara dua respons di kanal yang sama. Dilewati oleh mention dan follow-up.


Perilaku Manusia: Konsentrasi Variabel

Di sinilah Luna menjadi menarik. Setiap jenis pemicu memiliki ambang konsentrasi sendiri: penundaan min/maks, kemungkinan mengabaikan, dan kemungkinan bereaksi.

Pemicu Tunda Min Tunda Maks Abaikan Reaksi
mention 300md 1500md 0% 8%
dm 400md 1800md 0% 5%
name 800md 4000md 5% 6%
keyword 1000md 3500md 8% 4%
follow-up 500md 2000md 0% 3%
random 1500md 5000md 15% 2%

Perhitungan penundaan juga mempertimbangkan:

  • Panjang pesan: semakin panjang pesan, semakin lama waktu yang dibutuhkan Luna untuk "membaca"
  • Tidak aktif: jika Luna tidak aktif selama 10 menit, penundaan dikalikan 2 (simulasi "bangun")
  • Tidur: dalam mode slow, penundaan dikalikan 3 hingga 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agresif
  return delay;
}

Jadwal Tidur

Luna bisa tidur. Dapat dikonfigurasi melalui config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Mode Efek
sleep Hanya mention dan DM yang lewat
slow Penundaan x3-5, reaksi hampir nol
short Kemungkinan abaikan +30%, reaksi hampir nol

Selama jam tidur, status Discord berubah menjadi invisible.


Salah Ketik

Luna bisa melakukan salah ketik -- dan memperbaikinya setelah 2-4 detik. Tata letak keyboard dapat dikonfigurasi (AZERTY atau QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... semua tombol yang berdekatan
};

Contoh AZERTY: bonjour -> bonjpur, salut -> slaut, comment -> cpmment.

Tiga gaya koreksi:

Gaya Perilaku
edit Mengedit pesan
message Pesan baru: word*
mixed 50/50 acak (default)

Keraguan dan Kelupaan

Keraguan: 15% kemungkinan memulai dengan kata pengisi (uh..., um..., well..., hmm..., so...).

Kelupaan: bahkan setelah mencocokkan pemicu, Luna bisa "lupa" merespons dengan probabilitas 3%. Tidak ada pesan, tidak ada reaksi -- seolah-olah dia tidak melihat apa pun.

Kelelahan Tematik: jika sebuah kata muncul terlalu sering dalam 10 pesan terakhir (ambang: 3 kemunculan), penundaan dikalikan dan kemungkinan abaikan meningkat 15%.


Realisme dalam praktik: saat semuanya jadi mengerikan

Di atas kertas, semua sistem ini (jeda, typo, keraguan, kelupaan) terdengar seperti gimmick belaka. Namun dalam praktiknya, saat digabungkan, hasilnya sangat mengejutkan -- sampai-sampai orang yang mengobrol dengan Luna tidak curiga sama sekali. Berikut percakapan nyata di mana seseorang langsung bertanya kepada Luna apakah dia bot, dan percakapan kemudian bergeser ke topik yang sama sekali berbeda dengan kealamian yang bikin merinding.

Percakapan Discord dengan Luna -- bagian pertama

Nada bicara, keraguan, ritme balasan -- tidak ada yang mengkhianati identitasnya sebagai bot. Luna mengelak dari pertanyaan usia tanpa pernah keluar dari karakternya.

Percakapan terus mengalir secara alami, dengan balasan singkat dan manusiawi ("okay, okay", "yay", "yayy") -- persis jenis pesan yang akan dikirim manusia di Discord.

Percakapan Discord dengan Luna -- lanjutan

Percakapan Discord dengan Luna -- pergeseran topik

Percakapan Discord dengan Luna -- pergeseran topik berlanjut

Percakapan Discord dengan Luna -- akhir percakapan

Yang mengerikan bukan cuma karena Luna "membalas" -- tapi karena dia mempertahankan sebuah percakapan, dengan opini yang tampak nyata, tanggapan lanjutan, dan alur pemikiran yang koheren dari satu pesan ke pesan berikutnya. Tanpa sistem trigger, jeda konsentrasi, dan keraguan yang dijelaskan di atas, ilusi ini akan runtuh hanya dalam beberapa pesan.

Sedikit plot twist: dalam tangkapan layar di atas, kedua akun yang mengobrol sama-sama merupakan instance dari Luna. PixieGlow dan Sujet d'SBlow bukan manusia yang sedang menguji bot -- keduanya adalah dua bot yang saling berbicara, masing-masing "yakin" (secara perilaku) bahwa mereka sedang mengobrol dengan seseorang yang "normal". Jika saat membaca percakapan di atas kamu mengira salah satunya manusia, selamat -- kamu baru saja terjebak persis seperti siapa pun yang akan terjebak di server Discord sungguhan.

Ini pada dasarnya adalah versi praktis dari dead internet theory: teori ini (yang awalnya cukup bersifat teori konspirasi) menyatakan bahwa porsi konten dan interaksi online yang dihasilkan oleh bot, bukan manusia, terus meningkat, sampai-sampai internet "asli" milik manusia menjadi minoritas. Lama dianggap berlebihan, teori ini menjadi semakin tidak absurd seiring sistem seperti Luna Protocol menunjukkan bahwa tidak dibutuhkan banyak daya komputasi atau model raksasa untuk mensimulasikan kehadiran manusia yang meyakinkan dalam skala besar. Dua instance dari bot yang sama yang mampu mempertahankan percakapan panjang tanpa pernah ketahuan memberikan gambaran yang cukup konkret tentang seperti apa web yang sebagian besar dihuni oleh bot-bot yang saling berbicara.


Pipeline LLM: Dua Mode

Mode direct (default)

Bot mengirim permintaan langsung ke llama-server lokal melalui HTTP. Model dibagikan, dengan cache prompt dan 4 slot konkuren. Dua proses PM2: server LLM dan klien bot.

Mode online

Bot memanggil API apa pun yang kompatibel dengan OpenAI (OpenAI, OpenRouter, Groq, Together...). Tidak memerlukan LLM lokal.

Streaming Waktu Nyata

LLM melakukan stream respons baris demi baris (\n). Setiap baris dipotong menjadi kata-kata, dipancarkan satu per satu melalui llmBus.emit("token", word). Pada setiap \n, sebuah peristiwa flush dipancarkan -- bot segera mengirim pesan yang telah terakumulasi. Tidak ada penundaan simulasi: ritmenya adalah ritme LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

Antrean (requestQueue) memproses permintaan satu per satu, dengan pembersihan otomatis ketika antrean melebihi 100 elemen.


Pesan Spontan

Setiap 5 menit, 12% kemungkinan Luna mengirim pesan atas inisiatif sendiri. Server dipilih oleh sistem bobot linier: server paling aktif memiliki N kali lebih banyak kemungkinan daripada yang terakhir.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Konteks 5 pesan terakhir dibaca, dan Luna bergabung dengan percakapan "secara alami".


Pipeline TTS: Pesan Suara

Dengan 8% kemungkinan, Luna mengirim pesan suara alih-alih teks. Pipeline lengkap:

  1. Piper TTS mensintesis teks menjadi WAV
  2. ffmpeg mengonversi ke OGG
  3. Waveform dihitung untuk pratinjau Discord
  4. File diunggah melalui API CDN Discord
  5. Pesan suara dikirim
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

Pipeline TTS -- dari teks yang disintesis ke pesan suara Discord


Anti-Spam dan Persistensi

Anti-Spam

Antrean per channelId:userId. Hanya satu pesan dalam antrean per pengguna per kanal. Diproses setelah respons saat ini selesai.

Batas Sesi

Setelah 8 percakapan, Luna berhenti sejenak selama 30 detik. Penghitung direset setelah 3 menit tidak aktif.

Persistensi Otomatis

Setiap perubahan status memancar ke stateBus -> penyimpanan otomatis (debounce 500md). Tidak perlu lagi panggilan saveAllState() manual. Status yang dipersistensikan meliputi: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, penghitung follow-up.


Konfigurasi Hot-Reload

Satu file config.yml. Sebagian besar nilai dapat di-hot-reload -- perubahan diterapkan tanpa restart.

Kategori Hot-reload
Pemicu, kata kunci, nama Ya
Konsentrasi, penundaan Ya
Salah ketik, semburan, kelelahan Ya
Jadwal tidur Ya
TTS, pesan suara Ya
Token Discord, mode LLM Tidak (restart diperlukan)
// config.ts -- getter mengembalikan nilai langsung
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Dataset: Discord-Dialogues

Model di-fine-tune pada Discord-Dialogues: 7.3M percakapan, 17M giliran, 140M kata. Percakapan Discord nyata musim semi-panas 2025, difilter (PII, ToS, bot, perintah). Apache 2.0.

Metrik Nilai
Sampel 7 303 464
Total giliran 16 881 010
Total kata 139 922 950
Rata-rata token 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Model terkuantifikasi yang digunakan adalah GGUF (misalnya Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribusi dataset Discord-Dialogues


Siklus Hidup Lengkap -- perilaku bot lengkap dari pesan ke respons, termasuk timer dan kasus batas

Diagram Arsitektur

Direktori state-machines/ berisi 24 diagram Mermaid yang mencakup seluruh kode sumber. Setiap diagram memiliki penjelasan rinci dalam bahasa manusia.

Di antara yang terpenting:

# Diagram Tipe
01 Architecture Overview graph
02 Message Processing (lengkap) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backend) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

Diagram-diagram ini adalah tambang emas untuk memahami alur lengkap: dari pesan masuk hingga respons, termasuk timer dan kasus batas.


Kode Pemicu Secara Detail

Pemicu dievaluasi oleh evaluateMessage() di state/trigger.ts. Berikut logika lengkapnya:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... pencocokan berdasarkan nama, kata kunci, follow-up, acak
}

Cache regex (hasWordCache) menghindari kompilasi ulang pola pada setiap pesan.


Reaksi

Luna bereaksi terhadap pesan dengan emoji. 30% kemungkinan menggunakan emoji kustom server, 70% emoji unicode. Reaksi dipicu setelah penundaan konsentrasi, tidak segera.

Perintah melalui reaksi pada pesan Luna:

  • ❌ -> Stop
  • ▶️ -> Start
  • 🗑️ -> Clear

Gaya Respons

Gaya respons ditimbang berdasarkan aktivitas terakhir Luna di kanal:

Konteks messageReference mentionRepliedUser Bobot
Dingin true false 70%
Dingin true true 20%
Dingin false false 10%
Aktif true false 50%
Aktif true true 15%
Aktif false false 30%
Aktif false true 5%

Dalam DM, messageReference selalu false.


Pesan Beruntun

Dengan 15% kemungkinan, sebuah respons dipotong menjadi 2-3 fragmen yang dikirim dengan kecepatan manusia (1.5-4 detik antara setiap fragmen). Mensimulasikan seseorang yang mengetik beberapa kali.

Timing Gantt -- waktu tunggu nyata untuk penundaan, reaksi, streaming LLM, dan koreksi


Status Dinamis

Status Discord Luna bergantian di antara beberapa preset yang dikonfigurasi, berputar setiap 15 menit. Tipe yang didukung: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Selama tidur, status berubah menjadi invisible.

dynamic_status_presets:
  - status: online
    text: "dengan piksel"
    type: 0       # Playing
  - status: idle
    text: "white noise"
    type: 2       # Listening

Jitter acak (x0.5-1.0) menghindari rotasi yang dapat diprediksi. 10% percobaan dilewati untuk menghindari pengulangan.

Indikator Mengetik

Sebelum memanggil LLM, Luna memanggil startTyping(). Sebuah setInterval menyegarkan indikator setiap 8 detik selama generasi. Dibersihkan di finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Pemulihan Setelah Crash

Jika LLM crash (proses llama-server mati), Luna mendeteksi peristiwa melalui llmBus.emit("crash", code) dan mencoba restart dengan backoff eksponensial. Menghindari loop restart tak terbatas.

Parameter LLM

Parameter di-hardcode di src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Template ChatML (<|im_start|>/<|im_end|>) digunakan. Jumlah thread terdeteksi otomatis melalui os.cpus().length.


Pengaturan

npm install
cp config.example.yml config.yml
# edit config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # produksi
Skrip Deskripsi
build Bundle CLI mandiri
start Menjalankan bot
lint / format / check Biome
test Tes (Bun)
download-model GGUF dari HuggingFace
diagrams Ekspor diagram Mermaid ke SVG/PNG

Deployment PM2

./start.sh   # menjalankan llm-server + llm-client di bawah PM2

Kesimpulan

Luna Protocol bukan sekadar bot Discord dengan LLM. Ini adalah sistem perilaku lengkap yang mensimulasikan ketidaksempurnaan manusia: kelupaan, salah ketik, tidur, keraguan, kelelahan. Semuanya diarsitekturkan di sekitar bus peristiwa yang diketik, dengan 24 diagram Mermaid yang mendokumentasikan setiap alur.

Kode bersifat open source, dataset bersifat publik, dan konfigurasi dapat di-hot-reload. Jika topik ini menarik bagi Anda, selami kodenya -- ini lebih mudah diakses daripada yang terlihat.

Sumber Daya Tautan
Repositori GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol : मैंने एक पूरी तरह से स्वायत्त Discord बॉट बनाया जो एक इंसान का अनुकरण करता है

Luna Protocol एक पूरी तरह से स्वायत्त Discord बॉट है जिसमें स्थानीय LLM है, जो नींद, टाइपिंग गलतियाँ, हिचकिचाहट, भूलने की आदत, विषयगत थकान और स्वतःस्फूर्त संदेशों के साथ स्वाभाविक बातचीत करने में सक्षम है।

Luna Protocol : मैंने एक पूरी तरह से स्वायत्त Discord बॉट बनाया जो एक इंसान का अनुकरण करता है

क्या होगा अगर कोई Discord बॉट सो सके, टाइपिंग गलतियाँ कर सके, हिचकिचा सके, जवाब देना भूल सके, और कभी-कभी अपनी मर्जी से आपको संदेश भेज सके? यही बिल्कुल Luna Protocol करता है : एक पूरी तरह से स्वायत्त Discord बॉट जो स्थानीय LLM (llama.cpp) चलाता है और एक अपूर्ण इंसान की तरह बातचीत करता है।

कोई कठोर प्रॉम्प्ट नहीं, कोई रोबोटिक जवाब नहीं। Luna के पास प्राथमिकता ट्रिगर सिस्टम, परिवर्तनशील विलंब, सोने का शेड्यूल, स्वतःस्फूर्त संदेश, और यहाँ तक कि वॉइस संदेश भेजने के लिए TTS पाइपलाइन है। यह सब एक साधारण config.yml फ़ाइल के माध्यम से कॉन्फ़िगर किया गया है जो हॉट-रिलोडेबल है।

इस लेख में, हम पूरी आर्किटेक्चर का विश्लेषण करेंगे : जेनेरिक इवेंट बस से लेकर TTS पाइपलाइन तक, ट्रिगर सिस्टम, मानवीय व्यवहार घटक, और फ़ाइन-ट्यूनिंग डेटासेट।

आर्किटेक्चर ओवरव्यू -- वैश्विक घटक और डेटा प्रवाह


आर्किटेक्चर : एक टाइप की गई इवेंट बस

Luna का हृदय एक TypedBus है -- TypeScript में एक मजबूती से टाइप की गई जेनेरिक इवेंट बस। यह मूलभूत ईंट है जिस पर सब कुछ टिका है।

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

इससे दो मुख्य बसें निकलती हैं :

  • llmBus -- LLM टोकन, त्रुटियाँ, क्रैश, रीसेट को संभालता है
  • stateBus -- स्वचालित पर्सिस्टेंस के साथ स्थिति परिवर्तनों को संभालता है
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

इस दृष्टिकोण का लाभ : प्रत्येक मॉड्यूल बाकी से डिस्कनेक्टेड है। LLM बस पर टोकन उत्सर्जित करता है, बॉट उन्हें उपभोग करता है, स्थिति अपने आप अपडेट हो जाती है। कोई चक्रीय निर्भरता नहीं।


संदेश प्रसंस्करण -- एक संदेश के प्रसंस्करण का पूरा प्रवाह

ट्रिगर सिस्टम : कौन तय करता है कि Luna कब जवाब दे?

प्रत्येक आने वाले संदेश का मूल्यांकन evaluateMessage() द्वारा किया जाता है जो ट्रिगर कारण के साथ एक TriggerResult लौटाता है। प्राथमिकता क्रम महत्वपूर्ण है :

# कारण शर्तें Bypass ignore Bypass pause
1 mention @bot हाँ (0%) हाँ
2 dm replyInDM = true के साथ DM हाँ (0%) नहीं
3 name "Luna"/"Pixie"/उपनाम (पूरा शब्द) नहीं (8%) नहीं
4 keyword hello, hi, ai, bot... (पूरा शब्द) नहीं (8%) नहीं
5 follow-up बॉट अंतिम वक्ता था + < 15s + < 3 / 60s -- --
6 random असंबंधित संदेशों पर 1.5% संभावना नहीं (8%) नहीं

मैचिंग पूरे शब्द (\b) पर होती है : "ai" "mais", "vrai", "lait" से मेल नहीं खाता।

ट्रिगर मूल्यांकन -- प्रत्येक संदेश के लिए प्रवेश निर्णय

फ़ॉलो-अप तंत्र

जब Luna किसी संदेश का जवाब देती है, तो वह स्वयं को lastSpeaker के रूप में पंजीकृत करती है। 15 सेकंड के भीतर कोई भी अगला संदेश तत्काल प्रतिक्रिया ट्रिगर करता है -- कोई टाइमर नहीं, कोई कीवर्ड जाँच नहीं। बजट : 60 सेकंड की विंडो में 3 फ़ॉलो-अप।

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

कूलडाउन

एक ही चैनल में दो प्रतिक्रियाओं के बीच 8 सेकंड। मेंशन और फ़ॉलो-अप द्वारा इसे दरकिनार किया जाता है।


मानवीय व्यवहार : परिवर्तनशील एकाग्रता

यहाँ Luna दिलचस्प हो जाती है। प्रत्येक ट्रिगर प्रकार की अपनी एकाग्रता सीमाएँ होती हैं : न्यूनतम/अधिकतम विलंब, अनदेखा करने की संभावना, और प्रतिक्रिया करने की संभावना।

ट्रिगर न्यूनतम विलंब अधिकतम विलंब अनदेखा प्रतिक्रिया
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

विलंब की गणना इन्हें भी ध्यान में रखती है :

  • संदेश की लंबाई : संदेश जितना लंबा, Luna को "पढ़ने" में उतना ही अधिक समय
  • निष्क्रियता : यदि Luna 10 मिनट से सक्रिय नहीं है, तो विलंब 2 गुना हो जाता है ("जागने" का अनुकरण)
  • नींद : slow मोड में, विलंब 3 से 5 गुना हो जाता है
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // आक्रामक जिटर
  return delay;
}

सोने का शेड्यूल

Luna सो सकती है। config.yml के माध्यम से कॉन्फ़िगरेबल :

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
मोड प्रभाव
sleep केवल मेंशन और DM ही गुज़रते हैं
slow विलंब ×3-5, प्रतिक्रियाएँ लगभग शून्य
short अनदेखा करने की संभावना +30%, प्रतिक्रियाएँ लगभग शून्य

सोने के घंटों के दौरान, Discord स्टेटस invisible हो जाता है।


टाइपिंग गलतियाँ

Luna टाइपिंग गलतियाँ कर सकती है -- और उन्हें 2-4 सेकंड बाद सुधार सकती है। कीबोर्ड लेआउट कॉन्फ़िगरेबल है (AZERTY या QWERTY)।

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... सभी आसन्न कुंजियाँ
};

AZERTY उदाहरण : bonjour → bonjpur, salut → slaut, comment → cpmment।

तीन सुधार शैलियाँ :

शैली व्यवहार
edit संदेश को संपादित करता है
message नया संदेश : word*
mixed 50/50 यादृच्छिक (डिफ़ॉल्ट)

हिचकिचाहट और भूलने की आदत

हिचकिचाहट : 15% संभावना कि एक भराव शब्द से शुरू हो (uh..., um..., well..., hmm..., so...)।

भूलने की आदत : ट्रिगर मैच करने के बाद भी, Luna 3% संभावना के साथ जवाब देना "भूल" सकती है। कोई संदेश नहीं, कोई प्रतिक्रिया नहीं -- जैसे उसने कुछ देखा ही नहीं।

विषयगत थकान : यदि कोई शब्द पिछले 10 संदेशों में बहुत बार आता है (सीमा : 3 बार), तो विलंब गुणा हो जाता है और अनदेखा करने की संभावना 15% बढ़ जाती है।


व्यवहार में यथार्थवाद: जब यह डरावना हो जाता है

कागज़ पर, ये सभी सिस्टम (देरी, टाइपो, हिचकिचाहट, भूलना) महज़ एक तिकड़म जैसे लगते हैं। लेकिन असल में, इन्हें साथ मिलाने पर नतीजा चौंकाने वाला होता है -- इस हद तक कि Luna से बात करने वाले लोगों को कुछ शक तक नहीं होता। यहाँ एक असली बातचीत है जिसमें कोई सीधे Luna से पूछता है कि क्या वह बॉट है, और फिर बातचीत एक बिल्कुल अलग विषय की ओर मुड़ जाती है, इतनी स्वाभाविकता के साथ कि रोंगटे खड़े हो जाएं।

Luna के साथ Discord बातचीत -- पहला हिस्सा

लहजा, हिचकिचाहट, जवाबों की गति -- कुछ भी बॉट होने का संकेत नहीं देता। Luna उम्र वाले सवाल को बिना कभी अपने किरदार से बाहर निकले टाल देती है।

बातचीत पूरी तरह स्वाभाविक रूप से आगे बढ़ती रहती है, छोटे और इंसानी जवाबों के साथ ("okay, okay", "yay", "yayy") -- बिल्कुल वैसे संदेश जो कोई इंसान Discord पर भेजता।

Luna के साथ Discord बातचीत -- आगे

Luna के साथ Discord बातचीत -- विषय बदलना

Luna के साथ Discord बातचीत -- विषय बदलना जारी

Luna के साथ Discord बातचीत -- बातचीत का अंत

जो बात डरावनी है वह सिर्फ यह नहीं कि Luna "जवाब देती है" -- बल्कि यह कि वह एक पूरी बातचीत निभाती है, प्रतीत होने वाली राय, आगे के सवालों और एक संदेश से दूसरे तक सुसंगत विचार-प्रवाह के साथ। ऊपर बताए गए ट्रिगर सिस्टम, एकाग्रता विलंब और हिचकिचाहट के बिना, यह भ्रम कुछ ही संदेशों में टूट जाता।

छोटा-सा प्लॉट ट्विस्ट: ऊपर के स्क्रीनशॉट्स में, बातचीत करने वाले दोनों अकाउंट्स Luna के ही इंस्टेंस हैं। PixieGlow और Sujet d'SBlow कोई इंसान नहीं है जो बॉट को टेस्ट कर रहा हो -- ये दो बॉट हैं जो आपस में बात कर रहे हैं, हर एक (व्यवहारिक अर्थ में) "आश्वस्त" है कि वह किसी "सामान्य" इंसान से बात कर रहा है। अगर ऊपर की बातचीत पढ़ते हुए आपने मान लिया कि दोनों में से एक इंसान था, तो बधाई हो -- आप बिल्कुल वैसे ही जाल में फंस गए जैसे असली Discord सर्वर पर कोई भी फंस जाता।

यह मूल रूप से डेड इंटरनेट थ्योरी का व्यावहारिक संस्करण है: यह सिद्धांत (जो शुरू में काफी हद तक षड्यंत्र सिद्धांत माना जाता था) कहता है कि ऑनलाइन कंटेंट और बातचीत का बढ़ता हिस्सा इंसानों की बजाय बॉट्स द्वारा बनाया जा रहा है, यहां तक कि "असली" इंसानी इंटरनेट अल्पसंख्यक बनता जा रहा है। लंबे समय तक इसे बढ़ा-चढ़ाकर कहा गया मानी जाती रही, लेकिन जैसे-जैसे Luna Protocol जैसे सिस्टम दिखाते हैं कि बड़े पैमाने पर एक विश्वसनीय इंसानी उपस्थिति नकल करने के लिए न तो बहुत ज्यादा कंप्यूटिंग शक्ति चाहिए और न ही कोई विशाल मॉडल, यह उतना ही कम बेतुका लगने लगता है। एक ही बॉट के दो इंस्टेंस जो बिना कभी खुद को उजागर किए एक लंबी बातचीत निभा सकते हैं, यह इस बात की काफी ठोस झलक देते हैं कि एक ऐसा वेब कैसा दिख सकता है जिसमें ज्यादातर आपस में बात करने वाले बॉट्स ही भरे हों।


LLM पाइपलाइन : दो मोड

direct मोड (डिफ़ॉल्ट)

बॉट सीधे HTTP पर स्थानीय llama-server को अनुरोध भेजता है। मॉडल साझा किया जाता है, जिसमें प्रॉम्प्ट कैश और 4 समवर्ती स्लॉट होते हैं। दो PM2 प्रक्रियाएँ : LLM सर्वर और बॉट क्लाइंट।

online मोड

बॉट किसी भी OpenAI-संगत API (OpenAI, OpenRouter, Groq, Together...) को कॉल करता है। किसी स्थानीय LLM की आवश्यकता नहीं।

रीयल-टाइम स्ट्रीमिंग

LLM अपना उत्तर पंक्ति दर पंक्ति (\n) स्ट्रीम करता है। प्रत्येक पंक्ति को शब्दों में विभाजित किया जाता है, एक-एक करके llmBus.emit("token", word) पर उत्सर्जित किया जाता है। प्रत्येक \n पर, एक flush इवेंट उत्सर्जित होता है -- बॉट तुरंत संचित संदेश भेजता है। कोई अनुकरण विलंब नहीं : गति LLM की है।

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

अनुरोध कतार (requestQueue) अनुरोधों को एक-एक करके संसाधित करती है, जब कतार 100 तत्वों से अधिक हो जाती है तो स्वचालित सफाई होती है।


स्वतःस्फूर्त संदेश

हर 5 मिनट में, 12% संभावना कि Luna अपनी मर्जी से एक संदेश पोस्ट करे। सर्वर का चयन रैखिक भार प्रणाली द्वारा किया जाता है : सबसे सक्रिय सर्वर की संभावना अंतिम सर्वर से N× अधिक होती है।

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

पिछले 5 संदेशों का संदर्भ पढ़ा जाता है, और Luna "स्वाभाविक रूप से" बातचीत में शामिल होती है।


TTS पाइपलाइन : वॉइस संदेश

8% संभावना के साथ, Luna टेक्स्ट के बजाय एक वॉइस संदेश भेजती है। पूरी पाइपलाइन :

  1. Piper TTS टेक्स्ट को WAV में संश्लेषित करता है
  2. ffmpeg OGG में परिवर्तित करता है
  3. Discord पूर्वावलोकन के लिए वेवफ़ॉर्म की गणना की जाती है
  4. फ़ाइल Discord CDN API के माध्यम से अपलोड की जाती है
  5. वॉइस संदेश भेजा जाता है
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

TTS पाइपलाइन -- संश्लेषित टेक्स्ट से Discord वॉइस संदेश तक


एंटी-स्पैम और पर्सिस्टेंस

एंटी-स्पैम

channelId:userId द्वारा कतार। प्रति उपयोगकर्ता प्रति चैनल केवल एक संदेश कतार में। जैसे ही वर्तमान उत्तर समाप्त होता है, संसाधित होता है।

सत्र सीमाएँ

8 आदान-प्रदान के बाद, Luna 30 सेकंड का ब्रेक लेती है। 3 मिनट की निष्क्रियता के बाद काउंटर रीसेट हो जाता है।

स्वचालित पर्सिस्टेंस

प्रत्येक स्थिति परिवर्तन stateBus पर उत्सर्जित होता है -- स्वचालित सहेज (debounce 500ms)। मैन्युअल saveAllState() कॉल की कोई आवश्यकता नहीं। संग्रहीत स्थिति में शामिल हैं : pendingMessages, paused, cooldowns, timestamps, lastSpeaker, फ़ॉलो-अप काउंटर।


हॉट-रिलोड कॉन्फ़िगरेशन

एक ही config.yml फ़ाइल। अधिकांश मान हॉट-रिलोडेबल हैं -- परिवर्तन बिना पुनरारंभ के प्रभावी होते हैं।

श्रेणी हॉट-रिलोड
ट्रिगर, कीवर्ड, नाम ✅
एकाग्रता, विलंब ✅
टाइपो, बर्स्ट, थकान ✅
नींद शेड्यूल ✅
TTS, वॉइस संदेश ✅
Discord टोकन, LLM मोड ❌ (पुनरारंभ आवश्यक)
// config.ts -- गेटर्स लाइव मान लौटाते हैं
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

डेटासेट : Discord-Dialogues

मॉडल को Discord-Dialogues पर फ़ाइन-ट्यून किया गया है : 7.3M आदान-प्रदान, 17M टर्न, 140M शब्द। 2025 के वसंत-ग्रीष्म की वास्तविक Discord बातचीत, फ़िल्टर की गई (PII, ToS, बॉट, कमांड)। Apache 2.0।

मीट्रिक मान
नमूने 7 303 464
कुल टर्न 16 881 010
कुल शब्द 139 922 950
औसत टोकन 32.8
टोकनाइज़र Hermes-3-Llama-3.1-8B

उपयोग किया गया क्वांटाइज़्ड मॉडल एक GGUF है (उदाहरण Discord-Hermes-3-8B.Q3_K_M.gguf)।

Discord-Dialogues डेटासेट वितरण


पूर्ण जीवनचक्र -- संदेश से उत्तर तक बॉट का पूरा व्यवहार, जिसमें टाइमर और सीमा मामले शामिल हैं

आर्किटेक्चर आरेख

state-machines/ फ़ोल्डर में 24 Mermaid आरेख हैं जो पूरे स्रोत कोड को कवर करते हैं। प्रत्येक आरेख में मानव भाषा में विस्तृत व्याख्या है।

सबसे महत्वपूर्ण में से :

# आरेख प्रकार
01 Architecture Overview graph
02 Message Processing (पूर्ण) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 बैकएंड) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

ये आरेख पूरे प्रवाह को समझने के लिए एक सोने की खान हैं : आने वाले संदेश से उत्तर तक, जिसमें टाइमर और सीमा मामले शामिल हैं।


ट्रिगर कोड विस्तार से

ट्रिगर का मूल्यांकन state/trigger.ts में evaluateMessage() द्वारा किया जाता है। यहाँ पूरा तर्क है :

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... नाम, कीवर्ड, फ़ॉलो-अप, रैंडम द्वारा मैचिंग
}

रेगेक्स कैश (hasWordCache) प्रत्येक संदेश पर पैटर्न को पुनः संकलित करने से बचाता है।


प्रतिक्रियाएँ

Luna इमोजी के साथ संदेशों पर प्रतिक्रिया करती है। 30% संभावना सर्वर के कस्टम इमोजी का उपयोग करने की, 70% यूनिकोड इमोजी का। प्रतिक्रिया तुरंत नहीं, बल्कि एकाग्रता विलंब के बाद ट्रिगर होती है।

Luna के संदेशों पर प्रतिक्रिया कमांड :

  • ❌ स्टॉप
  • ▶️ स्टार्ट
  • 🗑️ क्लियर

उत्तर शैली

उत्तर शैली चैनल में Luna की हालिया गतिविधि के अनुसार भारित होती है :

संदर्भ messageReference mentionRepliedUser भार
ठंडा true false 70%
ठंडा true true 20%
ठंडा false false 10%
सक्रिय true false 50%
सक्रिय true true 15%
सक्रिय false false 30%
सक्रिय false true 5%

DM में, messageReference हमेशा false होता है।


बर्स्ट संदेश

15% संभावना के साथ, एक उत्तर 2-3 टुकड़ों में विभाजित होता है जो मानव गति से भेजे जाते हैं (प्रत्येक टुकड़े के बीच 1.5-4 सेकंड)। किसी ऐसे व्यक्ति का अनुकरण करता है जो कई बार में टाइप करता है।

Timing Gantt -- विलंब, प्रतिक्रियाओं, LLM स्ट्रीमिंग और सुधारों के लिए वास्तविक प्रतीक्षा समय


गतिशील स्टेटस

Luna का Discord स्टेटस कई कॉन्फ़िगर किए गए प्रीसेट के बीच बदलता रहता है, हर 15 मिनट में घूमता है। समर्थित प्रकार : Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5)। नींद के दौरान, स्टेटस invisible हो जाता है।

dynamic_status_presets:
  - status: online
    text: "पिक्सेल के साथ"
    type: 0       # Playing
  - status: idle
    text: "सफेद शोर"
    type: 2       # Listening

एक यादृच्छिक जिटर (×0.5-1.0) पूर्वानुमानित रोटेशन से बचाता है। पुनरावृत्ति से बचने के लिए 10% प्रयास छोड़ दिए जाते हैं।

टाइपिंग संकेतक

LLM को कॉल करने से पहले, Luna startTyping() कॉल करती है। एक setInterval जनरेशन के दौरान हर 8 सेकंड में संकेतक को रीफ्रेश करता है। finally (clearInterval) में साफ किया जाता है।

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

क्रैश के बाद रिकवरी

यदि LLM क्रैश होता है (llama-server प्रक्रिया मर जाती है), तो Luna llmBus.emit("crash", code) के माध्यम से इवेंट का पता लगाती है और घातीय बैकऑफ़ के साथ पुनरारंभ करने का प्रयास करती है। अनंत पुनरारंभ लूप से बचाती है।

LLM पैरामीटर

पैरामीटर src/config.ts में हार्डकोडेड हैं :

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

ChatML टेम्पलेट (<|im_start|>/<|im_end|>) का उपयोग किया जाता है। थ्रेड्स की संख्या os.cpus().length के माध्यम से स्वचालित रूप से पता लगाई जाती है।


सेटअप

npm install
cp config.example.yml config.yml
# config.yml संपादित करें
npm run dev                    # dev (hot reload)
npm run build && npm start     # production
स्क्रिप्ट विवरण
build स्टैंडअलोन CLI बंडल
start बॉट लॉन्च करें
lint / format / check Biome
test परीक्षण (Bun)
download-model HuggingFace से GGUF
diagrams Mermaid आरेखों को SVG/PNG में निर्यात करें

PM2 डिप्लॉयमेंट

./start.sh   # PM2 के तहत llm-server + llm-client लॉन्च करें

निष्कर्ष

Luna Protocol सिर्फ LLM वाला एक Discord बॉट नहीं है। यह एक पूर्ण व्यवहार प्रणाली है जो मानवीय अपूर्णताओं का अनुकरण करती है : भूलने की आदत, टाइपिंग गलतियाँ, नींद, हिचकिचाहट, थकान। यह सब एक टाइप की गई इवेंट बस के चारों ओर आर्किटेक्चर किया गया है, जिसमें प्रत्येक प्रवाह को दस्तावेज़ित करने वाले 24 Mermaid आरेख हैं।

कोड ओपन सोर्स है, डेटासेट सार्वजनिक है, और कॉन्फ़िगरेशन हॉट-रिलोडेबल है। यदि यह विषय आपको रुचिकर लगता है, तो कोड में गोता लगाएँ -- यह जितना लगता है उससे कहीं अधिक सुलभ है।

संसाधन लिंक
GitHub रिपॉज़िटरी fox3000foxy/luna-protocol-project
डेटासेट Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol: أنشأت بوت Discord مستقل يحاكي إنسانًا

Luna Protocol هو بوت Discord مستقل بالكامل مزود بـ LLM محلي، قادر على محادثة طبيعية مع النوم، الأخطاء الإملائية، التردد، النسيان، التعب الموضوعي، والرسائل العفوية.

Luna Protocol: أنشأت بوت Discord مستقل يحاكي إنسانًا

ماذا لو كان بوت Discord يستطيع النوم، وارتكاب الأخطاء الإملائية، والتردد، والنسيان في الرد، وأحيانًا إرسال رسالة من تلقاء نفسه؟ هذا بالضبط ما يفعله Luna Protocol: بوت Discord مستقل بالكامل يشغل LLM محلي (llama.cpp) ويتحدث كإنسان غير كامل.

لا نصوص برمجية جامدة، ولا ردود آلية. Luna لديها نظام تشغيل ذو أولوية، وتأخيرات متغيرة، وجداول نوم، ورسائل عفوية، وحتى خط أنابيب TTS لإرسال رسائل صوتية. كل ذلك مهيأ عبر ملف config.yml واحد قابل لإعادة التحميل السريع.

في هذه المقالة، نحلل الهندسة المعمارية الكاملة: من ناقل الأحداث العام إلى خط أنابيب TTS، مرورًا بنظام التشغيل، والمكونات البشرية، ومجموعة بيانات الضبط الدقيق.

نظرة عامة على الهندسة -- المكونات العامة وتدفق البيانات


الهندسة: ناقل أحداث مقيد

جوهر Luna هو TypedBus -- ناقل أحداث عام مقيد بشكل صارم في TypeScript. إنها اللبنة الأساسية التي يقوم عليها كل شيء.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

ناقلان رئيسيان ينبثقان من هذا:

  • llmBus -- يدير توكنات LLM، الأخطاء، الأعطال، إعادة التعيين
  • stateBus -- يدير تغييرات الحالة مع الحفظ التلقائي
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

ميزة هذا النهج: كل وحدة منفصلة عن البقية. يصدر LLM التوكنات على الناقل، ويستهلكها البوت، ويتم تحديث الحالة تلقائيًا. لا تبعيات دائرية.


معالجة الرسائل -- التدفق الكامل لمعالجة رسالة

نظام التشغيل: من يقرر متى ترد Luna؟

يتم تقييم كل رسالة واردة بواسطة evaluateMessage() التي تعيد TriggerResult مع سبب التشغيل. ترتيب الأولوية حرج:

# السبب الشروط تجاوز التجاهل تجاوز الإيقاف المؤقت
1 mention @bot نعم (0%) نعم
2 dm رسالة خاصة مع replyInDM = true نعم (0%) لا
3 name "Luna"/"Pixie"/اسم مستعار (كلمة كاملة) لا (8%) لا
4 keyword hello, hi, ai, bot... (كلمة كاملة) لا (8%) لا
5 follow-up البوت كان آخر متحدث + < 15ث + < 3 / 60ث -- --
6 random 1.5% فرصة على الرسائل غير المتطابقة لا (8%) لا

المطابقة تكون لكلمة كاملة (\b): "ai" لا تتطابق مع "mais"، "vrai"، "lait".

تقييم التشغيل -- قرار الدخول لكل رسالة

آلية المتابعة

عندما ترد Luna على رسالة، تسجل نفسها كـ lastSpeaker. أي رسالة تالية خلال 15 ثانية تؤدي إلى رد فوري -- لا مؤقت، لا تحقق من كلمة مفتاحية. الميزانية: 3 متابعات لكل نافذة 60 ثانية.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

فترة التهدئة

8 ثوانٍ بين ردين في نفس القناة. يتم تجاوزها بواسطة الإشارات والمتابعات.


السلوكيات البشرية: التركيز المتغير

هنا تصبح Luna مثيرة للاهتمام. كل نوع تشغيل له حدود تركيز خاصة به: تأخير أدنى/أقصى، فرصة للتجاهل، وفرصة للرد.

المشغل التأخير الأدنى التأخير الأقصى تجاهل رد
mention 300ملث 1500ملث 0% 8%
dm 400ملث 1800ملث 0% 5%
name 800ملث 4000ملث 5% 6%
keyword 1000ملث 3500ملث 8% 4%
follow-up 500ملث 2000ملث 0% 3%
random 1500ملث 5000ملث 15% 2%

حساب التأخير يأخذ بعين الاعتبار أيضًا:

  • طول الرسالة: كلما كانت الرسالة أطول، كلما استغرقت Luna وقتًا أطول "للقراءة"
  • الخمول: إذا لم تكن Luna نشطة لمدة 10 دقائق، يُضرب التأخير في 2 (محاكاة "الاستيقاظ")
  • النوم: في وضع slow، يُضرب التأخير في 3 إلى 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

جداول النوم

تستطيع Luna النوم. قابلة للتكوين عبر config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
الوضع التأثير
sleep فقط الإشارات والرسائل الخاصة تمر
slow تأخير ×3-5، ردود شبه معدومة
short فرصة تجاهل +30%، ردود شبه معدومة

خلال ساعات النوم، تصبح حالة Discord invisible.


الأخطاء الإملائية

تستطيع Luna ارتكاب أخطاء إملائية -- وتصحيحها بعد 2-4 ثوانٍ. تخطيط لوحة المفاتيح قابل للتكوين (AZERTY أو QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... toutes les touches adjacentes
};

مثال AZERTY: bonjour → bonjpur, salut → slaut, comment → cpmment.

ثلاثة أنماط للتصحيح:

النمط السلوك
edit يعدل الرسالة
message رسالة جديدة: word*
mixed 50/50 عشوائي (افتراضي)

التردد والنسيان

التردد: 15% فرصة للبدء بكلمة حشو (uh..., um..., well..., hmm..., so...).

النسيان: حتى بعد مطابقة مشغل، قد "تنسى" Luna الرد باحتمال 3%. لا رسالة، لا رد فعل -- كأنها لم تر شيئًا.

التعب الموضوعي: إذا تكررت كلمة كثيرًا في آخر 10 رسائل (الحد: 3 مرات)، تُضرب التأخيرات وتزداد فرصة التجاهل بنسبة 15%.


الواقعية عمليًا: عندما يصبح الأمر مرعبًا

على الورق، تبدو كل هذه الأنظمة (التأخيرات، أخطاء الكتابة، التردد، النسيان) وكأنها مجرد حيلة. أما في الواقع، فعند دمجها معًا، تكون النتيجة مذهلة -- لدرجة أن من يتحدث مع Luna لا يشك في شيء. إليك محادثة حقيقية يسأل فيها أحدهم Luna مباشرة إن كانت بوتًا، ثم تنجرف المحادثة بعدها إلى موضوع مختلف تمامًا بطبيعية تثير القشعريرة.

محادثة ديسكورد مع Luna -- الجزء الأول

النبرة، التردد، إيقاع الردود -- لا شيء يفضح أنها بوت. تتفادى Luna سؤال العمر دون أن تخرج عن شخصيتها أبدًا.

تستمر المحادثة بسلاسة تامة، بردود قصيرة وطبيعية ("okay, okay"، "yay"، "yayy") -- تمامًا كالرسائل التي قد يرسلها شخص حقيقي على ديسكورد.

محادثة ديسكورد مع Luna -- تتمة

محادثة ديسكورد مع Luna -- تحول الموضوع

محادثة ديسكورد مع Luna -- استمرار تحول الموضوع

محادثة ديسكورد مع Luna -- نهاية المحادثة

ما يثير الرعب ليس فقط أن Luna "ترد" -- بل أنها تخوض محادثة كاملة، بآراء ظاهرية، وردود متابعة، وخط تفكير متماسك من رسالة إلى أخرى. فبدون نظام المحفزات وتأخيرات التركيز والتردد الموصوفة أعلاه، ستنهار هذه الوهم خلال بضع رسائل فقط.

مفاجأة صغيرة: في لقطات الشاشة أعلاه، كلا الحسابين اللذين يتحدثان هما نسختان من Luna. PixieGlow وSujet d'SBlow ليسا إنسانًا يختبر بوتًا -- بل هما بوتان يتحدثان مع بعضهما، كل منهما "مقتنع" (من الناحية السلوكية) بأنه يتحدث مع شخص "عادي". إذا افترضت عند قراءة المحادثة أعلاه أن أحدهما كان إنسانًا، فتهانينا -- لقد وقعت في الفخ تمامًا كما قد يقع أي شخص في سيرفر ديسكورد حقيقي.

هذا في الأساس نسخة عملية من نظرية الإنترنت الميت: تقول هذه النظرية (التي كانت في الأصل فكرة هامشية إلى حد ما) إن حصة متزايدة من المحتوى والتفاعلات على الإنترنت يولّدها بوتات وليس بشرًا، لدرجة أن الإنترنت "الحقيقي" البشري أصبح أقلية. وقد ظلت طويلًا تُعتبر مبالغًا فيها، لكنها تبدو أقل غرابة شيئًا فشيئًا مع إثبات أنظمة مثل Luna Protocol أن محاكاة حضور بشري مقنع على نطاق واسع لا تتطلب موارد حوسبة كبيرة ولا نموذجًا ضخمًا. نسختان من نفس البوت قادرتان على خوض محادثة طويلة دون أن تفضحا نفسيهما تعطيان لمحة ملموسة جدًا عن شكل ويب يقطنه في الغالب بوتات تتحدث مع بعضها البعض.


خط أنابيب LLM: وضعان

الوضع direct (افتراضي)

يرسل البوت الطلبات مباشرة إلى خادم llama-server محلي عبر HTTP. النموذج مشترك، مع ذاكرة تخزين مؤقت للاستعلام و 4 فتحات متزامنة. عمليتان PM2: خادم LLM وعميل البوت.

الوضع online

يستدعي البوت أي API متوافقة مع OpenAI (OpenAI، OpenRouter، Groq، Together...). لا حاجة لـ LLM محلي.

البث المباشر في الوقت الفعلي

يقوم LLM ببث رده سطرًا بسطر (\n). يتم تقطيع كل سطر إلى كلمات، تُصدر واحدة تلو الأخرى عبر llmBus.emit("token", word). عند كل \n، يُصدر حدث flush -- يرسل البوت الرسالة المتراكمة فورًا. لا تأخير محاكى: الإيقاع هو إيقاع LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

طابور الانتظار (requestQueue) يعالج الطلبات واحدًا تلو الآخر، مع تنظيف تلقائي عندما يتجاوز الطابور 100 عنصر.


الرسائل العفوية

كل 5 دقائق، 12% فرصة أن تنشر Luna رسالة من تلقاء نفسها. يتم اختيار الخادم بواسطة نظام وزن خطي: الخادم الأكثر نشاطًا لديه N× فرصة أكبر من الأخير.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

يتم قراءة سياق آخر 5 رسائل، وتنضم Luna إلى المحادثة "بشكل طبيعي".


خط أنابيب TTS: الرسائل الصوتية

مع 8% فرصة، ترسل Luna رسالة صوتية بدلاً من النص. خط الأنابيب الكامل:

  1. Piper TTS يحول النص إلى WAV
  2. ffmpeg يحول إلى OGG
  3. يتم حساب شكل الموجة لمعاينة Discord
  4. يتم رفع الملف عبر API CDN Discord
  5. يتم إرسال الرسالة الصوتية
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

خط أنابيب TTS -- من النص المحول إلى رسالة صوتية على Discord


مكافحة البريد المزعج والحفظ

مكافحة البريد المزعج

طابور انتظار حسب channelId:userId. رسالة واحدة فقط في الطابور لكل مستخدم لكل قناة. تتم المعالجة بمجرد انتهاء الرد الجاري.

حدود الجلسة

بعد 8 تبادلات، تأخذ Luna استراحة لمدة 30 ثانية. يُعاد تعيين العداد بعد 3 دقائق من الخمول.

الحفظ التلقائي

كل تغيير في الحالة يُصدر على stateBus → حفظ تلقائي (debounce 500ملث). لا حاجة لاستدعاءات saveAllState() اليدوية. الحالة المحفوظة تشمل: pendingMessages، paused، cooldowns، timestamps، lastSpeaker، عدادات المتابعة.


التكوين بإعادة التحميل السريع

ملف واحد config.yml. معظم القيم قابلة لإعادة التحميل السريع -- يتم تطبيق التغييرات دون إعادة تشغيل.

الفئة إعادة تحميل سريع
المشغلات، الكلمات المفتاحية، الأسماء ✅
التركيز، التأخيرات ✅
الأخطاء الإملائية، الاندفاع، التعب ✅
جداول النوم ✅
TTS، الرسائل الصوتية ✅
توكن Discord، وضع LLM ❌ (يتطلب إعادة تشغيل)
// config.ts -- getters return live values
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

مجموعة البيانات: Discord-Dialogues

النموذج مضبوط بدقة على Discord-Dialogues: 7.3M تبادل، 17M جولة، 140M كلمة. محادثات Discord حقيقية ربيع-صيف 2025، منقاة (PII، ToS، بوتات، أوامر). Apache 2.0.

المقياس القيمة
العينات 7 303 464
إجمالي الجولات 16 881 010
إجمالي الكلمات 139 922 950
متوسط التوكنات 32.8
Tokenizer Hermes-3-Llama-3.1-8B

النموذج الكمي المستخدم هو GGUF (على سبيل المثال Discord-Hermes-3-8B.Q3_K_M.gguf).

توزيع مجموعة بيانات Discord-Dialogues


دورة الحياة الكاملة -- سلوك البوت الكامل من الرسالة إلى الرد، بما في ذلك المؤقتات والحالات الحدودية

رسومات الهندسة

يحتوي مجلد state-machines/ على 24 رسمًا بيانيًا Mermaid تغطي كامل الكود المصدري. كل رسم بياني له شرح مفصل بلغة بشرية.

من بين الأكثر أهمية:

# الرسم البياني النوع
01 Architecture Overview graph
02 Message Processing (كاملا) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 خلفيات) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

هذه الرسومات هي كنز لفهم التدفق الكامل: من الرسالة الواردة إلى الرد، مرورًا بالمؤقتات والحالات الحدودية.


كود التشغيل بالتفصيل

يتم تقييم المشغل بواسطة evaluateMessage() في state/trigger.ts. هذا هو المنطق الكامل:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... matching par nom, keyword, follow-up, random
}

ذاكرة التخزين المؤقت للتعبير النمطي (hasWordCache) تتجنب إعادة ترجمة الأنماط عند كل رسالة.


الردود

تتفاعل Luna مع الرسائل باستخدام الرموز التعبيرية. 30% فرصة لاستخدام رمز تعبيري مخصص من الخادم، 70% رمز تعبيري يونيكود. يتم تشغيل الرد بعد تأخير التركيز، وليس فورًا.

أوامر الرد على رسائل Luna:

  • ❌ → إيقاف
  • ▶️ → بدء
  • 🗑️ → مسح

أسلوب الرد

يتم وزن أسلوب الرد حسب نشاط Luna الأخير في القناة:

السياق messageReference mentionRepliedUser الوزن
بارد true false 70%
بارد true true 20%
بارد false false 10%
نشط true false 50%
نشط true true 15%
نشط false false 30%
نشط false true 5%

في الرسائل الخاصة، messageReference دائمًا false.


الرسائل المتدفقة

مع 15% فرصة، يتم تقسيم الرد إلى 2-3 أجزاء تُرسل بوتيرة بشرية (1.5-4 ثوانٍ بين كل جزء). تحاكي شخصًا يكتب على دفعات.

توقيت Gantt -- أوقات الانتظار الفعلية للتأخيرات، الردود، بث LLM والتصحيحات


الحالة الديناميكية

تتبدل حالة Discord الخاصة بـ Luna بين عدة إعدادات مسبقة مهيأة، تدور كل 15 دقيقة. الأنواع المدعومة: Playing (0)، Streaming (1)، Listening (2)، Watching (3)، Custom (4)، Competing (5). أثناء النوم، تتحول الحالة إلى invisible.

dynamic_status_presets:
  - status: online
    text: "مع البكسلات"
    type: 0       # Playing
  - status: idle
    text: "ضجيج أبيض"
    type: 2       # Listening

تشتيت عشوائي (×0.5-1.0) يتجنب التدوير المتوقع. 10% من المحاولات تُتخطى لتجنب التكرار.

مؤشر الكتابة

قبل استدعاء LLM، تستدعي Luna startTyping(). يقوم setInterval بتحديث المؤشر كل 8 ثوانٍ أثناء التوليد. يتم التنظيف في finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

الاسترداد بعد العطل

إذا تعطل LLM (عملية llama-server تموت)، تكتشف Luna الحدث عبر llmBus.emit("crash", code) وتحاول إعادة التشغيل مع تراجع أسي. تتجنب حلقات إعادة التشغيل اللانهائية.

معلمات LLM

المعلمات مشفرة في src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

يُستخدم قالب ChatML (<|im_start|>/<|im_end|>). يتم اكتشاف عدد الخيوط تلقائيًا عبر os.cpus().length.


الإعداد

npm install
cp config.example.yml config.yml
# تعديل config.yml
npm run dev                    # dev (إعادة تحميل سريع)
npm run build && npm start     # إنتاج
السكريبت الوصف
build حزمة CLI مستقلة
start تشغيل البوت
lint / format / check Biome
test اختبارات (Bun)
download-model GGUF من HuggingFace
diagrams تصدير رسومات Mermaid إلى SVG/PNG

نشر PM2

./start.sh   # تشغيل llm-server + llm-client تحت PM2

الخاتمة

Luna Protocol ليس مجرد بوت Discord مع LLM. إنه نظام سلوكي كامل يحاكي العيوب البشرية: النسيان، الأخطاء الإملائية، النوم، التردد، التعب. كل ذلك معماري حول ناقل أحداث مقيد، مع 24 رسمًا بيانيًا Mermaid توثق كل تدفق.

الكود مفتوح المصدر، مجموعة البيانات عامة، والتكوين قابل لإعادة التحميل السريع. إذا كان الموضوع يثير اهتمامك، اغوص في الكود -- إنه أكثر سهولة مما يبدو.

المورد الرابط
مستودع GitHub fox3000foxy/luna-protocol-project
مجموعة البيانات Discord-Dialogues
خريطة Atlas atlas.nomic.ai

Luna Protocol: tôi đã tạo một bot Discord tự động mô phỏng con người

Luna Protocol là một bot Discord hoàn toàn tự động với LLM cục bộ, có khả năng trò chuyện tự nhiên với giấc ngủ, lỗi gõ, ngập ngừng, quên, mệt mỏi chủ đề và tin nhắn tự phát.

Luna Protocol: tôi đã tạo một bot Discord tự động mô phỏng con người

Sẽ thế nào nếu một bot Discord có thể ngủ, gõ sai chính tả, ngập ngừng, quên trả lời, và đôi khi tự ý gửi cho bạn một tin nhắn? Đó chính xác là những gì Luna Protocol làm: một bot Discord hoàn toàn tự động chạy LLM cục bộ (llama.cpp) và trò chuyện như một con người không hoàn hảo.

Không có prompt cứng nhắc, không có câu trả lời robot. Luna có hệ thống kích hoạt ưu tiên, độ trễ thay đổi, lịch ngủ, tin nhắn tự phát, và thậm chí là đường dẫn TTS để gửi tin nhắn thoại. Tất cả được cấu hình qua một tệp config.yml duy nhất có thể tải lại nóng.

Trong bài viết này, chúng ta sẽ phân tích toàn bộ kiến trúc: từ bus sự kiện tổng quát đến đường dẫn TTS, qua hệ thống kích hoạt, các thành phần mô phỏng con người, và bộ dữ liệu fine-tuning.

Tổng quan kiến trúc -- các thành phần toàn cục và luồng dữ liệu


Kiến trúc: một bus sự kiện được định kiểu

Cốt lõi của Luna là một TypedBus -- một bus sự kiện tổng quát được định kiểu mạnh trong TypeScript. Đây là viên gạch nền tảng cho mọi thứ.

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

Hai bus chính được dẫn xuất từ đó:

  • llmBus -- quản lý token LLM, lỗi, crash, reset
  • stateBus -- quản lý các thay đổi trạng thái với tính năng tự động lưu
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

Lợi ích của cách tiếp cận này: mỗi module được tách rời khỏi phần còn lại. LLM phát token lên bus, bot tiêu thụ chúng, trạng thái tự động cập nhật. Không có phụ thuộc vòng tròn.


Xử lý tin nhắn -- luồng hoàn chỉnh xử lý một tin nhắn

Hệ thống kích hoạt: ai quyết định khi nào Luna trả lời?

Mỗi tin nhắn đến được đánh giá bởi evaluateMessage() trả về một TriggerResult với lý do kích hoạt. Thứ tự ưu tiên rất quan trọng:

# Lý do Điều kiện Bỏ qua ignore Bỏ qua pause
1 mention @bot Có (0%) Có
2 dm DM với replyInDM = true Có (0%) Không
3 name "Luna"/"Pixie"/biệt danh (nguyên từ) Không (8%) Không
4 keyword hello, hi, ai, bot... (nguyên từ) Không (8%) Không
5 follow-up Bot là người nói cuối + < 15s + < 3 / 60s -- --
6 random 1.5% cơ hội trên các tin nhắn không khớp Không (8%) Không

Việc so khớp là nguyên từ (\b): "ai" không khớp với "mai", "trai", "bài".

Đánh giá kích hoạt -- quyết định đầu vào cho mỗi tin nhắn

Cơ chế follow-up

Khi Luna trả lời một tin nhắn, cô ấy đăng ký là lastSpeaker. Bất kỳ tin nhắn nào tiếp theo trong vòng 15 giây sẽ kích hoạt phản hồi ngay lập tức -- không có timer, không kiểm tra keyword. Ngân sách: 3 follow-up mỗi cửa sổ 60 giây.

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

Cooldown

8 giây giữa hai phản hồi trong cùng một kênh. Bị vượt qua bởi mention và follow-up.


Các hành vi mô phỏng con người: sự tập trung thay đổi

Đây là lúc Luna trở nên thú vị. Mỗi loại kích hoạt có ngưỡng tập trung riêng: độ trễ min/max, cơ hội bỏ qua, và cơ hội phản ứng.

Kích hoạt Trễ min Trễ max Bỏ qua Phản ứng
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

Việc tính độ trễ cũng tính đến:

  • Độ dài tin nhắn: tin nhắn càng dài, Luna càng mất nhiều thời gian để "đọc"
  • Sự không hoạt động: nếu Luna không hoạt động trong 10 phút, độ trễ được nhân với 2 (mô phỏng "thức dậy")
  • Giấc ngủ: ở chế độ slow, độ trễ được nhân với 3 đến 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

Lịch ngủ

Luna có thể ngủ. Có thể cấu hình qua config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
Chế độ Hiệu ứng
sleep Chỉ mention và DM được xử lý
slow Trễ x3-5, phản ứng gần như bằng không
short Cơ hội bỏ qua +30%, phản ứng gần như bằng không

Trong giờ ngủ, trạng thái Discord chuyển thành invisible.


Lỗi gõ

Luna có thể gõ sai chính tả -- và sửa sau 2-4 giây. Bố cục bàn phím có thể cấu hình (AZERTY hoặc QWERTY).

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... tất cả các phím kề nhau
};

Ví dụ AZERTY: bonjour -> bonjpur, salut -> slaut, comment -> cpmment.

Ba kiểu sửa lỗi:

Kiểu Hành vi
edit Sửa tin nhắn
message Tin nhắn mới: word*
mixed 50/50 ngẫu nhiên (mặc định)

Ngập ngừng và quên

Ngập ngừng: 15% cơ hội bắt đầu bằng một từ đệm (uh..., um..., well..., hmm..., so...).

Quên: ngay cả sau khi khớp kích hoạt, Luna có thể "quên" trả lời với xác suất 3%. Không có tin nhắn, không có phản ứng -- như thể cô ấy không thấy gì.

Mệt mỏi chủ đề: nếu một từ xuất hiện quá thường xuyên trong 10 tin nhắn gần nhất (ngưỡng: 3 lần), độ trễ được nhân lên và cơ hội bỏ qua tăng 15%.


Tính chân thực trong thực tế: khi nó trở nên rùng rợn

Trên lý thuyết, tất cả các cơ chế này (độ trễ, lỗi gõ phím, ngập ngừng, hay quên) nghe có vẻ chỉ là chiêu trò. Nhưng trong thực tế, khi kết hợp lại, kết quả thật đáng kinh ngạc -- đến mức người trò chuyện với Luna không hề nghi ngờ gì. Đây là một đoạn hội thoại thật, nơi ai đó hỏi thẳng Luna có phải là bot không, rồi cuộc trò chuyện chuyển sang một chủ đề hoàn toàn khác với sự tự nhiên đến rùng mình.

Cuộc trò chuyện Discord với Luna -- đoạn đầu

Giọng điệu, sự ngập ngừng, nhịp độ trả lời -- không có gì tố cáo đây là bot. Luna né tránh câu hỏi về tuổi mà không hề lộ vai.

Cuộc trò chuyện tiếp tục trôi chảy tự nhiên, với những câu trả lời ngắn, rất con người ("okay, okay", "yay", "yayy") -- đúng kiểu tin nhắn mà một người thật sẽ gửi trên Discord.

Cuộc trò chuyện Discord với Luna -- tiếp theo

Cuộc trò chuyện Discord với Luna -- chuyển chủ đề

Cuộc trò chuyện Discord với Luna -- chủ đề tiếp tục trôi

Cuộc trò chuyện Discord với Luna -- kết thúc đoạn hội thoại

Điều đáng sợ không chỉ là việc Luna "trả lời" -- mà là cô ấy duy trì cả một cuộc trò chuyện, với những ý kiến có vẻ thật, những câu nối tiếp, và một mạch suy nghĩ nhất quán từ tin nhắn này sang tin nhắn khác. Nếu không có hệ thống kích hoạt, độ trễ tập trung và sự ngập ngừng đã mô tả ở trên, ảo giác này sẽ sụp đổ chỉ sau vài tin nhắn.

Cú twist nhỏ: trong các ảnh chụp màn hình ở trên, cả hai tài khoản đang trò chuyện đều là các instance của Luna. PixieGlow và Sujet d'SBlow không phải là một người thật đang thử nghiệm bot -- đó là hai con bot nói chuyện với nhau, mỗi con (theo nghĩa hành vi) đều "tin chắc" rằng mình đang nói chuyện với ai đó "bình thường". Nếu khi đọc đoạn hội thoại trên bạn nghĩ rằng một trong hai là người thật, xin chúc mừng -- bạn vừa mắc bẫy y hệt như bất kỳ ai trên một server Discord thật.

Đây gần như là phiên bản thực tế của thuyết internet chết (dead internet theory): lý thuyết này (vốn ban đầu khá thiên về thuyết âm mưu) cho rằng một phần ngày càng lớn nội dung và tương tác trên mạng được tạo ra bởi bot chứ không phải con người, đến mức internet "thật" của con người trở thành thiểu số. Từng bị coi là phóng đại, thuyết này ngày càng bớt vô lý khi các hệ thống như Luna Protocol cho thấy không cần nhiều tài nguyên hay một mô hình khổng lồ để mô phỏng sự hiện diện của con người một cách đáng tin cậy trên quy mô lớn. Hai instance của cùng một con bot có thể duy trì một cuộc trò chuyện dài mà không hề để lộ bản thân, đó là một hình dung khá cụ thể về việc một mạng internet chủ yếu gồm các bot nói chuyện với nhau sẽ trông như thế nào.


Đường dẫn LLM: hai chế độ

Chế độ direct (mặc định)

Bot gửi trực tiếp yêu cầu đến llama-server cục bộ qua HTTP. Mô hình được chia sẻ, với prompt cache và 4 slot đồng thời. Hai tiến trình PM2: máy chủ LLM và client bot.

Chế độ online

Bot gọi bất kỳ API nào tương thích với OpenAI (OpenAI, OpenRouter, Groq, Together...). Không cần LLM cục bộ.

Truyền phát thời gian thực

LLM truyền phát phản hồi từng dòng (\n). Mỗi dòng được tách thành các từ, phát từng từ một trên llmBus.emit("token", word). Ở mỗi \n, một sự kiện flush được phát -- bot ngay lập tức gửi tin nhắn đã tích lũy. Không có độ trễ mô phỏng: nhịp độ là của LLM.

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

Hàng đợi (requestQueue) xử lý các yêu cầu từng cái một, với tự động dọn dẹp khi hàng đợi vượt quá 100 phần tử.


Tin nhắn tự phát

Cứ mỗi 5 phút, 12% cơ hội Luna tự ý đăng một tin nhắn. Máy chủ được chọn bằng hệ thống trọng số tuyến tính: máy chủ hoạt động nhiều nhất có Nx cơ hội so với máy chủ cuối cùng.

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

Bối cảnh của 5 tin nhắn gần nhất được đọc, và Luna tham gia cuộc trò chuyện "một cách tự nhiên".


Đường dẫn TTS: tin nhắn thoại

Với 8% cơ hội, Luna gửi tin nhắn thoại thay vì văn bản. Đường dẫn hoàn chỉnh:

  1. Piper TTS tổng hợp văn bản thành WAV
  2. ffmpeg chuyển đổi thành OGG
  3. Dạng sóng được tính toán để xem trước Discord
  4. Tệp được tải lên qua API Discord CDN
  5. Tin nhắn thoại được gửi đi
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

Đường dẫn TTS -- từ văn bản tổng hợp đến tin nhắn thoại Discord


Chống spam và lưu trữ

Chống spam

Hàng đợi theo channelId:userId. Chỉ một tin nhắn trong hàng đợi mỗi người dùng mỗi kênh. Được xử lý ngay khi phản hồi hiện tại kết thúc.

Giới hạn phiên

Sau 8 lượt trao đổi, Luna tạm nghỉ 30 giây. Bộ đếm được đặt lại sau 3 phút không hoạt động.

Tự động lưu

Mỗi thay đổi trạng thái phát trên stateBus -> tự động lưu (debounce 500ms). Không cần gọi saveAllState() thủ công nữa. Trạng thái được lưu bao gồm: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, bộ đếm follow-up.


Cấu hình tải lại nóng

Một tệp config.yml duy nhất. Hầu hết các giá trị đều có thể tải lại nóng -- các thay đổi được áp dụng mà không cần khởi động lại.

Danh mục Tải lại nóng
Trigger, keyword, tên ✅
Tập trung, độ trễ ✅
Lỗi gõ, burst, mệt mỏi ✅
Lịch ngủ ✅
TTS, tin nhắn thoại ✅
Discord token, chế độ LLM ❌ (cần khởi động lại)
// config.ts -- các getter trả về giá trị trực tiếp
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Bộ dữ liệu: Discord-Dialogues

Mô hình được fine-tune trên Discord-Dialogues: 7.3M lượt trao đổi, 17M lượt, 140M từ. Các cuộc trò chuyện Discord thực tế mùa xuân-hè 2025, đã được lọc (PII, ToS, bot, lệnh). Apache 2.0.

Chỉ số Giá trị
Mẫu 7 303 464
Tổng số lượt 16 881 010
Tổng số từ 139 922 950
Token trung bình 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Mô hình đã lượng hóa được sử dụng là GGUF (ví dụ Discord-Hermes-3-8B.Q3_K_M.gguf).

Phân phối bộ dữ liệu Discord-Dialogues


Vòng đời hoàn chỉnh -- hành vi đầy đủ của bot từ tin nhắn đến phản hồi, bao gồm timer và trường hợp biên

Các sơ đồ kiến trúc

Thư mục state-machines/ chứa 24 sơ đồ Mermaid bao phủ toàn bộ mã nguồn. Mỗi sơ đồ có giải thích chi tiết bằng ngôn ngữ tự nhiên.

Trong số quan trọng nhất:

# Sơ đồ Loại
01 Tổng quan kiến trúc graph
02 Xử lý tin nhắn (hoàn chỉnh) stateDiagram
03 Đánh giá kích hoạt flowchart
04 Hàng đợi LLM Core (3 backend) stateDiagram
10 Đường dẫn TTS flowchart
13 Lưu trạng thái flowchart
21 Biểu đồ thời gian Gantt gantt
22 Vòng đời hoàn chỉnh stateDiagram

Các sơ đồ này là mỏ vàng để hiểu luồng hoàn chỉnh: từ tin nhắn đến phản hồi, qua các timer và trường hợp biên.


Mã kích hoạt chi tiết

Trigger được đánh giá bởi evaluateMessage() trong state/trigger.ts. Đây là logic hoàn chỉnh:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... so khớp theo tên, keyword, follow-up, random
}

Bộ nhớ đệm regex (hasWordCache) tránh việc biên dịch lại các mẫu mỗi tin nhắn.


Phản ứng

Luna phản ứng với tin nhắn bằng emoji. 30% cơ hội dùng emoji tùy chỉnh của máy chủ, 70% emoji unicode. Phản ứng được kích hoạt sau độ trễ tập trung, không phải ngay lập tức.

Các lệnh bằng phản ứng trên tin nhắn của Luna:

  • ❌ -> Dừng
  • ▶️ -> Bắt đầu
  • 🗑️ -> Xóa

Kiểu phản hồi

Kiểu phản hồi được điều chỉnh theo hoạt động gần đây của Luna trong kênh:

Ngữ cảnh messageReference mentionRepliedUser Trọng số
Lạnh true false 70%
Lạnh true true 20%
Lạnh false false 10%
Hoạt động true false 50%
Hoạt động true true 15%
Hoạt động false false 30%
Hoạt động false true 5%

Trong DM, messageReference luôn là false.


Tin nhắn burst

Với 15% cơ hội, một phản hồi được chia thành 2-3 đoạn gửi theo nhịp độ con người (1.5-4 giây giữa mỗi đoạn). Mô phỏng ai đó gõ nhiều lần.

Biểu đồ thời gian Gantt -- thời gian chờ thực tế cho độ trễ, phản ứng, truyền phát LLM và sửa lỗi


Trạng thái động

Trạng thái Discord của Luna luân phiên giữa nhiều preset đã cấu hình, xoay vòng mỗi 15 phút. Các loại được hỗ trợ: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Trong giờ ngủ, trạng thái chuyển thành invisible.

dynamic_status_presets:
  - status: online
    text: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

Một jitter ngẫu nhiên (x0.5-1.0) tránh các vòng xoay có thể đoán trước. 10% số lần thử bị bỏ qua để tránh lặp lại.

Chỉ báo đang gõ

Trước khi gọi LLM, Luna gọi startTyping(). Một setInterval làm mới chỉ báo mỗi 8 giây trong quá trình tạo. Được dọn dẹp trong finally (clearInterval).

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Phục hồi sau crash

Nếu LLM bị crash (tiến trình llama-server chết), Luna phát hiện sự kiện qua llmBus.emit("crash", code) và thử khởi động lại với backoff theo cấp số nhân. Tránh vòng lặp khởi động lại vô hạn.

Tham số LLM

Các tham số được hardcode trong src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

Mẫu ChatML (<|im_start|>/<|im_end|>) được sử dụng. Số luồng được tự động phát hiện qua os.cpus().length.


Thiết lập

npm install
cp config.example.yml config.yml
# chỉnh sửa config.yml
npm run dev                    # dev (tải lại nóng)
npm run build && npm start     # production
Script Mô tả
build Bundle CLI độc lập
start Chạy bot
lint / format / check Biome
test Kiểm thử (Bun)
download-model GGUF từ HuggingFace
diagrams Xuất sơ đồ Mermaid thành SVG/PNG

Triển khai PM2

./start.sh   # khởi động llm-server + llm-client dưới PM2

Kết luận

Luna Protocol không chỉ là một bot Discord với LLM. Đây là một hệ thống hành vi hoàn chỉnh mô phỏng các khuyết điểm của con người: quên, lỗi gõ, ngủ, ngập ngừng, mệt mỏi. Tất cả được kiến trúc xoay quanh một bus sự kiện được định kiểu, với 24 sơ đồ Mermaid tài liệu hóa mọi luồng.

Mã nguồn là mã nguồn mở, bộ dữ liệu công khai, và cấu hình có thể tải lại nóng. Nếu bạn quan tâm đến chủ đề này, hãy đào sâu vào mã nguồn -- nó dễ tiếp cận hơn bạn nghĩ.

Tài nguyên Liên kết
Kho GitHub fox3000foxy/luna-protocol-project
Bộ dữ liệu Discord-Dialogues
Bản đồ Atlas atlas.nomic.ai

Luna Protocol: ฉันสร้างบอท Discord อัตโนมัติที่จำลองความเป็นมนุษย์

Luna Protocol คือบอท Discord อัตโนมัติเต็มรูปแบบที่ขับเคลื่อนด้วย LLM ในเครื่อง สามารถสนทนาอย่างเป็นธรรมชาติ พร้อมการนอน พิมพ์ผิด การลังเล การลืม ความเหนื่อยล้าตามหัวข้อ และข้อความที่ส่งเองตามธรรมชาติ

Luna Protocol: ฉันสร้างบอท Discord อัตโนมัติที่จำลองความเป็นมนุษย์

จะเป็นอย่างไรถ้าบอท Discord สามารถ นอน พิมพ์ผิด ลังเล ลืมตอบ และบางครั้งส่งข้อความหาคุณเองได้? นี่คือสิ่งที่ Luna Protocol ทำ: บอท Discord อัตโนมัติเต็มรูปแบบที่รัน LLM ในเครื่อง (llama.cpp) และสนทนาเหมือนมนุษย์ผู้ไม่สมบูรณ์แบบ

ไม่มีพรอมป์ที่ตายตัว ไม่มีการตอบแบบหุ่นยนต์ Luna มี ระบบการตัดสินใจแบบมีลำดับความสำคัญ, ระยะเวลาที่แปรผัน, ตารางการนอน, ข้อความที่ส่งเองตามธรรมชาติ, และแม้กระทั่ง ไปป์ไลน์ TTS สำหรับส่งข้อความเสียง ทั้งหมดนี้ตั้งค่าผ่านไฟล์ config.yml ไฟล์เดียวที่สามารถโหลดซ้ำได้ทันที

ในบทความนี้ เราจะเจาะลึกสถาปัตยกรรมทั้งหมด: จากบัสอีเวนต์ทั่วไป ไปจนถึงไปป์ไลน์ TTS, ระบบการตัดสินใจ, คอมโพเนนต์ความเป็นมนุษย์, และชุดข้อมูลสำหรับ fine-tuning

ภาพรวมสถาปัตยกรรม -- คอมโพเนนต์ทั่วโลกและโฟลว์ข้อมูล


สถาปัตยกรรม: บัสอีเวนต์แบบชนิดข้อมูล

หัวใจของ Luna คือ TypedBus -- บัสอีเวนต์ทั่วไปที่ตรวจสอบชนิดข้อมูลอย่างเข้มงวดใน TypeScript นี่คือรากฐานที่ทุกอย่างสร้างขึ้น

type EventMap = Record<string, unknown[]>;

export class TypedBus<Events extends EventMap> {
  private listeners = new Map<keyof Events, Set<(...args: unknown[]) => void>>();

  on<K extends keyof Events>(event: K, listener: (...args: Events[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener as (...args: unknown[]) => void);
  }

  emit<K extends keyof Events>(event: K, ...args: Events[K]): void {
    this.listeners.get(event)?.forEach((fn) => { fn(...args); });
  }
}

บัสหลักสองตัวที่สืบทอดออกมา:

  • llmBus -- จัดการโทเค็น LLM, ข้อผิดพลาด, การพัง, การรีเซ็ต
  • stateBus -- จัดการการเปลี่ยนแปลงสถานะพร้อมบันทึกอัตโนมัติ
┌─────────────────────────────────────────────────────┐
│                   core/bus.ts                        │
│  TypedBus<K, V> -- on / off / once / emit            │
├──────────────────┬──────────────────────────────────┤
│   core/llm-bus   │       state/state-bus             │
│  token / done /  │     state:changed                 │
│  error / crash / │     → persistence auto            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
         │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ mode direct      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ mode online      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

ข้อดีของแนวทางนี้: แต่ละโมดูลเป็นอิสระจากกัน LLM ปล่อยโทเค็นบนบัส, บอทรับไปใช้, สถานะอัปเดตโดยอัตโนมัติ ไม่มีการอ้างอิงแบบวงกลม


การประมวลผลข้อความ -- โฟลว์ทั้งหมดของการประมวลผลข้อความ

ระบบการตัดสินใจ: ใครเป็นคนตัดสินว่าเมื่อไร Luna จะตอบ?

ข้อความขาเข้าทุกข้อความจะถูกประเมินโดย evaluateMessage() ซึ่งคืนค่า TriggerResult พร้อมเหตุผลในการตัดสินใจ ลำดับความสำคัญมีความสำคัญ:

# เหตุผล เงื่อนไข ข้ามการละเว้น ข้ามการหยุดชั่วคราว
1 mention @bot ใช่ (0%) ใช่
2 dm DM กับ replyInDM = true ใช่ (0%) ไม่
3 name "Luna"/"Pixie"/ชื่ออื่น (ทั้งคำ) ไม่ (8%) ไม่
4 keyword hello, hi, ai, bot... (ทั้งคำ) ไม่ (8%) ไม่
5 follow-up บอทเป็นคนพูดล่าสุด + < 15วิ + < 3 / 60วิ -- --
6 random โอกาส 1.5% สำหรับข้อความที่ไม่ตรงเงื่อนไข ไม่ (8%) ไม่

การจับคู่เป็นแบบทั้งคำ (\b): "ai" ไม่ตรงกับ "mai", "vrai", "lait"

การประเมินทริกเกอร์ -- การตัดสินใจเข้าสำหรับแต่ละข้อความ

กลไกการตอบตาม

เมื่อ Luna ตอบข้อความ มันจะบันทึกตัวเองเป็น lastSpeaker ข้อความถัดไปภายใน 15 วินาทีจะทำให้เกิดการตอบทันที -- ไม่มีตัวจับเวลา ไม่มีการตรวจสอบคีย์เวิร์ด งบประมาณ: 3 ครั้งต่อหน้าต่าง 60 วินาที

export function canFollowUp(channelId: string, botId: string): boolean {
  const recent = isRecentBotActivity(channelId);
  const speaker = lastSpeaker.get(channelId);
  const count = responseCount.get(channelId) ?? 0;
  return recent && speaker?.userId === botId && count < MAX_FOLLOWUPS;
}

การหน่วงเวลา

8 วินาทีระหว่างการตอบสองครั้งในช่องเดียวกัน ข้ามได้โดยการพูดถึงและตอบตาม


พฤติกรรมมนุษย์: สมาธิที่แปรผัน

นี่คือจุดที่ Luna น่าสนใจ การตัดสินใจแต่ละประเภทจะมีระดับสมาธิของตัวเอง: ระยะเวลาต่ำสุด/สูงสุด, โอกาสในการละเว้น, และโอกาสในการโต้ตอบ

ทริกเกอร์ ระยะเวลาต่ำสุด ระยะเวลาสูงสุด ละเว้น โต้ตอบ
mention 300ms 1500ms 0% 8%
dm 400ms 1800ms 0% 5%
name 800ms 4000ms 5% 6%
keyword 1000ms 3500ms 8% 4%
follow-up 500ms 2000ms 0% 3%
random 1500ms 5000ms 15% 2%

การคำนวณระยะเวลายังคำนึงถึง:

  • ความยาวข้อความ: ยิ่งข้อความยาว Luna ยิ่งใช้เวลาในการ "อ่าน" นานขึ้น
  • การไม่เคลื่อนไหว: ถ้า Luna ไม่ได้เคลื่อนไหวเกิน 10 นาที ระยะเวลาจะคูณด้วย 2 (จำลองการ "ตื่น")
  • การนอน: ในโหมด slow ระยะเวลาจะคูณด้วย 3 ถึง 5
export function computeDelay(
  reason: string | null = null,
  sleepBehavior?: string | null,
  msgLength?: number,
  inactivityMs?: number
): number {
  const t = getThresholds(reason);
  let delay = t.delay_min + Math.random() * (t.delay_max - t.delay_min);
  if (msgLength) {
    const readingFactor = Math.min(msgLength / 500, 3);
    delay *= 1 + readingFactor * (0.3 + Math.random() * 0.7);
  }
  if (sleepBehavior === "slow") {
    delay *= 3 + Math.random() * 2;
  }
  delay *= 0.5 + Math.random() * 1.5; // jitter agressif
  return delay;
}

ตารางการนอน

Luna สามารถนอนได้ ตั้งค่าผ่าน config.yml:

timezone: "Europe/Paris"
time_schedules:
  - start: "00:00"
    end: "07:00"
    behavior: sleep
  - start: "23:00"
    end: "00:00"
    behavior: slow
  - start: "07:00"
    end: "08:00"
    behavior: short
โหมด ผล
sleep เฉพาะการพูดถึงและ DM เท่านั้นที่ผ่าน
slow ระยะเวลา x3-5, การโต้ตอบใกล้เป็นศูนย์
short โอกาสละเว้น +30%, การโต้ตอบใกล้เป็นศูนย์

ในช่วงเวลานอน สถานะ Discord จะเปลี่ยนเป็น invisible


การพิมพ์ผิด

Luna สามารถพิมพ์ผิด -- และแก้ไขหลังจาก 2-4 วินาที รูปแบบแป้นพิมพ์สามารถตั้งค่าได้ (AZERTY หรือ QWERTY)

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... ปุ่มที่อยู่ติดกันทั้งหมด
};

ตัวอย่าง AZERTY: bonjour -> bonjpur, salut -> slaut, comment -> cpmment

รูปแบบการแก้ไขสามแบบ:

รูปแบบ พฤติกรรม
edit แก้ไขข้อความ
message ข้อความใหม่: word*
mixed สุ่ม 50/50 (ค่าเริ่มต้น)

การลังเลและการลืม

การลังเล: โอกาส 15% ที่จะเริ่มต้นด้วยคำเติม (uh..., um..., well..., hmm..., so...)

การลืม: แม้หลังจากจับคู่ทริกเกอร์แล้ว Luna ก็สามารถ "ลืม" ตอบด้วยความน่าจะเป็น 3% ไม่มีข้อความ ไม่มีปฏิกิริยา -- เหมือนไม่เห็นอะไรเลย

ความเหนื่อยล้าตามหัวข้อ: ถ้าคำใดคำหนึ่งปรากฏบ่อยเกินไปใน 10 ข้อความล่าสุด (เกณฑ์: 3 ครั้ง) ระยะเวลาจะถูกคูณและโอกาสละเว้นเพิ่มขึ้น 15%


ความสมจริงในทางปฏิบัติ: เมื่อมันน่าขนลุก

ในทางทฤษฎี ระบบทั้งหมดนี้ (ความหน่วง การพิมพ์ผิด การลังเล การหลงลืม) ฟังดูเหมือนแค่ลูกเล่น แต่ในทางปฏิบัติ เมื่อรวมกันแล้วผลลัพธ์กลับน่าทึ่งมาก จนคนที่คุยกับ Luna ไม่ทันสังเกตเลยแม้แต่น้อย นี่คือบทสนทนาจริงที่มีคนถาม Luna ตรงๆ ว่าเป็นบอทหรือเปล่า แล้วบทสนทนาก็ค่อยๆ เปลี่ยนไปเป็นเรื่องอื่นโดยสิ้นเชิง ด้วยความเป็นธรรมชาติที่ชวนขนลุก

บทสนทนา Discord กับ Luna -- ช่วงแรก

น้ำเสียง การลังเล จังหวะการตอบ -- ไม่มีอะไรที่บ่งบอกว่าเป็นบอทเลย Luna หลบคำถามเรื่องอายุได้อย่างแนบเนียนโดยไม่หลุดคาแรกเตอร์เลยแม้แต่น้อย

บทสนทนาดำเนินต่อไปอย่างเป็นธรรมชาติ ด้วยคำตอบสั้นๆ แบบมนุษย์ ("okay, okay", "yay", "yayy") -- ตรงกับข้อความที่คนจริงๆ จะส่งใน Discord

บทสนทนา Discord กับ Luna -- ต่อเนื่อง

บทสนทนา Discord กับ Luna -- เปลี่ยนหัวข้อ

บทสนทนา Discord กับ Luna -- เปลี่ยนหัวข้อต่อ

บทสนทนา Discord กับ Luna -- ช่วงจบบทสนทนา

สิ่งที่น่าขนลุกไม่ใช่แค่ Luna "ตอบกลับ" เท่านั้น -- แต่คือการที่เธอดำเนินบทสนทนาได้จริง ด้วยความเห็นที่ดูสมจริง การถามต่อ และแนวคิดที่ต่อเนื่องกันจากข้อความหนึ่งไปอีกข้อความหนึ่ง หากไม่มีระบบทริกเกอร์ ความหน่วงในการโฟกัส และการลังเลที่อธิบายไว้ข้างต้น ภาพลวงตานี้จะพังทลายภายในไม่กี่ข้อความ

พลิกโผนิดหน่อย: ในภาพหน้าจอข้างต้น บัญชีทั้งสองที่กำลังคุยกันคือ Luna ทั้งคู่ PixieGlow และ Sujet d'SBlow ไม่ใช่มนุษย์ที่กำลังทดสอบบอท -- แต่เป็นบอทสองตัวที่คุยกันเอง โดยแต่ละตัว(ในเชิงพฤติกรรม)"เชื่อมั่น"ว่ากำลังคุยกับใครสักคนที่ "ปกติ" อยู่ ถ้าตอนอ่านบทสนทนาข้างต้นคุณคิดว่าฝ่ายใดฝ่ายหนึ่งเป็นมนุษย์ ยินดีด้วย -- คุณเพิ่งตกหลุมพรางแบบเดียวกับที่ใครก็ตามจะตกในเซิร์ฟเวอร์ Discord จริงๆ

นี่แทบจะเป็นเวอร์ชันปฏิบัติของ ทฤษฎีอินเทอร์เน็ตตาย (dead internet theory): ทฤษฎีนี้ (ซึ่งเดิมทีค่อนข้างเป็นทฤษฎีสมคบคิด) เสนอว่าเนื้อหาและปฏิสัมพันธ์ออนไลน์สัดส่วนที่เพิ่มขึ้นเรื่อยๆ ถูกสร้างโดยบอทแทนที่จะเป็นมนุษย์ จนอินเทอร์เน็ตของมนุษย์ "ตัวจริง" กลายเป็นเสียงส่วนน้อย ทฤษฎีนี้เคยถูกมองว่าเกินจริงมานาน แต่กลับดูไร้สาระน้อยลงเรื่อยๆ เมื่อระบบอย่าง Luna Protocol แสดงให้เห็นว่าการจำลองการมีตัวตนของมนุษย์ที่น่าเชื่อถือในระดับใหญ่ไม่จำเป็นต้องใช้พลังประมวลผลมากหรือโมเดลขนาดมหึมาแต่อย่างใด บอทตัวเดียวกันสองอินสแตนซ์ที่สามารถคุยกันยาวๆ โดยไม่มีใครเผยตัวเลย ก็ทำให้เห็นภาพที่ค่อนข้างชัดเจนว่าเว็บที่เต็มไปด้วยบอทคุยกันเองส่วนใหญ่จะมีหน้าตาเป็นอย่างไร


ไปป์ไลน์ LLM: สองโหมด

โหมด direct (ค่าเริ่มต้น)

บอทส่งคำขอโดยตรงไปยัง llama-server ในเครื่องผ่าน HTTP โมเดลถูกแชร์ พร้อมแคชพรอมป์และ 4 สล็อตพร้อมกัน สองกระบวนการ PM2: เซิร์ฟเวอร์ LLM และไคลเอนต์บอท

โหมด online

บอทเรียก API ที่เข้ากันได้กับ OpenAI (OpenAI, OpenRouter, Groq, Together...) ไม่ต้องใช้ LLM ในเครื่อง

การสตรีมแบบเรียลไทม์

LLM สตรีมคำตอบทีละบรรทัด (\n) แต่ละบรรทัดถูกแบ่งเป็นคำ ส่งทีละคำบน llmBus.emit("token", word) ทุกครั้งที่เจอ \n จะมีการส่งอีเวนต์ flush -- บอทส่งข้อความที่สะสมไว้ทันที ไม่มีการจำลองความล่าช้า: จังหวะเป็นไปตาม LLM

function emitWordTokens(chunk: string): void {
  const words = chunk.match(/\S+/g) ?? [];
  wordEmitQueue.push(() => {
    let i = 0;
    const emitNext = () => {
      llmBus.emit("token", words[i]);
      i++;
      if (i < words.length) {
        const delay = MIN_WORD_DELAY + Math.random() * (MAX_WORD_DELAY - MIN_WORD_DELAY);
        setTimeout(emitNext, delay);
      } else {
        llmBus.emit("flush");
      }
    };
    emitNext();
  });
}

คิวคำขอ (requestQueue) จัดการคำขอทีละรายการ พร้อมทำความสะอาดอัตโนมัติเมื่อคิวเกิน 100 รายการ


ข้อความที่ส่งเองตามธรรมชาติ

ทุก 5 นาที มีโอกาส 12% ที่ Luna จะโพสต์ข้อความเอง เซิร์ฟเวอร์ถูกเลือกโดยระบบถ่วงน้ำหนักเชิงเส้น: เซิร์ฟเวอร์ที่คึกคักที่สุดมีโอกาส N เท่าของเซิร์ฟเวอร์สุดท้าย

const total = (ranked.length * (ranked.length + 1)) / 2;
let roll = Math.random() * total;
for (let i = 0; i < ranked.length; i++) {
  roll -= ranked.length - i;
  if (roll <= 0) return ranked[i];
}

บริบทของ 5 ข้อความล่าสุดถูกอ่าน และ Luna เข้าร่วมการสนทนาอย่าง "เป็นธรรมชาติ"


ไปป์ไลน์ TTS: ข้อความเสียง

ด้วยโอกาส 8% Luna ส่งข้อความเสียงแทนข้อความ ไปป์ไลน์ทั้งหมด:

  1. Piper TTS สังเคราะห์ข้อความเป็น WAV
  2. ffmpeg แปลงเป็น OGG
  3. คำนวณรูปคลื่นสำหรับตัวอย่างเสียง Discord
  4. อัปโหลดไฟล์ผ่าน API Discord CDN
  5. ส่งข้อความเสียง
export async function sendTextAsVoiceMessage(
  channelId: string, replyToMessageId: string, text: string
): Promise<void> {
  const safe = sanitizeForTTS(text);
  const { audio: wavBuf } = await synthesize(safe);
  const oggBuf = await wavToOgg(wavBuf);
  const durationSecs = await getAudioDuration(oggBuf);
  const waveform = buildWaveformBase64();
  const { uploadUrl, uploadFilename } = await requestUploadUrl(channelId, oggBuf.byteLength, durationSecs);
  await putFileToUploadUrl(uploadUrl, oggBuf);
  await postVoiceMessage(channelId, uploadFilename, durationSecs, waveform, replyToMessageId);
}

ไปป์ไลน์ TTS -- จากข้อความที่สังเคราะห์แล้วไปยังข้อความเสียง Discord


การป้องกันสแปมและการบันทึกข้อมูล

การป้องกันสแปม

คิวตาม channelId:userId หนึ่งข้อความต่อคิวต่อผู้ใช้ต่อช่อง ประมวลผลเมื่อคำตอบปัจจุบันเสร็จสิ้น

ขีดจำกัดเซสชัน

หลังจาก 8 การสนทนา Luna จะหยุดพัก 30 วินาที ตัวนับจะรีเซ็ตหลังจากไม่มีการเคลื่อนไหว 3 นาที

การบันทึกอัตโนมัติ

ทุกการเปลี่ยนแปลงสถานะจะส่งอีเวนต์บน stateBus -> บันทึกอัตโนมัติ (debounce 500ms) ไม่ต้องเรียก saveAllState() ด้วยตนเองอีกต่อไป สถานะที่บันทึกรวมถึง: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, ตัวนับ follow-up


การตั้งค่าแบบโหลดซ้ำทันที

ไฟล์เดียว config.yml ค่าส่วนใหญ่สามารถโหลดซ้ำได้ทันที -- การเปลี่ยนแปลงมีผลทันทีโดยไม่ต้องรีสตาร์ท

หมวดหมู่ โหลดซ้ำทันที
ทริกเกอร์, คีย์เวิร์ด, ชื่อ ✅
สมาธิ, ระยะเวลา ✅
การพิมพ์ผิด, การระเบิด, ความเหนื่อยล้า ✅
ตารางการนอน ✅
TTS, ข้อความเสียง ✅
Discord token, โหมด LLM ❌ (ต้องรีสตาร์ท)
// config.ts -- getters คืนค่าที่เป็นปัจจุบัน
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

ชุดข้อมูล: Discord-Dialogues

โมเดลถูก fine-tune บน Discord-Dialogues: 7.3M การสนทนา, 17M รอบ, 140M คำ บทสนทนา Discord จริงจากฤดูใบไม้ผลิ-ฤดูร้อน 2025 ที่ถูกกรองแล้ว (PII, ToS, บอท, คำสั่ง) Apache 2.0

เมตริก ค่า
ตัวอย่าง 7,303,464
รอบทั้งหมด 16,881,010
คำทั้งหมด 139,922,950
โทเค็นเฉลี่ย 32.8
Tokenizer Hermes-3-Llama-3.1-8B

โมเดลที่ถูกควอนไทซ์ที่ใช้คือ GGUF (เช่น Discord-Hermes-3-8B.Q3_K_M.gguf)

การกระจายของชุดข้อมูล Discord-Dialogues


วงจรชีวิตสมบูรณ์ -- พฤติกรรมทั้งหมดของบอทตั้งแต่ข้อความถึงการตอบสนอง รวมถึงตัวจับเวลาและกรณีขอบ

ไดอะแกรมสถาปัตยกรรม

โฟลเดอร์ state-machines/ ประกอบด้วยไดอะแกรม Mermaid 24 แบบ ครอบคลุมซอร์สโค้ดทั้งหมด แต่ละไดอะแกรมมีคำอธิบายละเอียดเป็นภาษามนุษย์

ที่สำคัญที่สุด:

# ไดอะแกรม ประเภท
01 Architecture Overview graph
02 Message Processing (สมบูรณ์) stateDiagram
03 Trigger Evaluation flowchart
04 LLM Core Queue (3 backends) stateDiagram
10 TTS Pipeline flowchart
13 State Persistence flowchart
21 Timing Gantt gantt
22 Complete Lifecycle stateDiagram

ไดอะแกรมเหล่านี้เป็นขุมทองสำหรับทำความเข้าใจโฟลว์ทั้งหมด: จากข้อความขาเข้าถึงการตอบสนอง รวมถึงตัวจับเวลาและกรณีขอบ


โค้ดการตัดสินใจโดยละเอียด

ทริกเกอร์ถูกประเมินโดย evaluateMessage() ใน state/trigger.ts นี่คือตรรกะทั้งหมด:

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: false, reason: null, botName: "" };
  if (message.content === "-stop") return { shouldRespond: true, reason: "stop", botName: "" };
  if (message.content === "-start") return { shouldRespond: true, reason: "start", botName: "" };
  if (message.content === "-clear") return { shouldRespond: true, reason: "clear", botName: "" };

  const isMentioned = message.mentions.some((u) => u.id === botId);
  if (isMentioned) return { shouldRespond: true, reason: "mention", botName };
  if (!message.guildID) return { shouldRespond: true, reason: "dm", botName };
  if (isPaused()) return { shouldRespond: false, reason: null, botName: "" };
  if (isOnCooldown(channelId)) return { shouldRespond: false, reason: null, botName };

  // ... จับคู่ตามชื่อ, คีย์เวิร์ด, follow-up, สุ่ม
}

แคช regex (hasWordCache) ป้องกันการคอมไพล์รูปแบบซ้ำสำหรับทุกข้อความ


ปฏิกิริยา

Luna โต้ตอบกับข้อความด้วยอิโมจิ โอกาส 30% ใช้อิโมจิแบบกำหนดเองของเซิร์ฟเวอร์ 70% ใช้อิโมจิยูนิโค้ด ปฏิกิริยาจะเกิดขึ้นหลังจากระยะเวลาสมาธิ ไม่ใช่ทันที

คำสั่งโดยปฏิกิริยาบนข้อความของ Luna:

  • ❌ -> หยุด
  • ▶️ -> เริ่ม
  • 🗑️ -> ล้าง

รูปแบบการตอบ

รูปแบบการตอบถูกถ่วงน้ำหนักตามกิจกรรมล่าสุดของ Luna ในช่อง:

บริบท messageReference mentionRepliedUser น้ำหนัก
เย็น true false 70%
เย็น true true 20%
เย็น false false 10%
กำลังเคลื่อนไหว true false 50%
กำลังเคลื่อนไหว true true 15%
กำลังเคลื่อนไหว false false 30%
กำลังเคลื่อนไหว false true 5%

ใน DM messageReference เป็น false เสมอ


ข้อความแบบระเบิด

ด้วยโอกาส 15% คำตอบจะถูกแบ่งเป็น 2-3 ส่วน ส่งตามจังหวะมนุษย์ (1.5-4 วินาทีระหว่างแต่ละส่วน) จำลองคนที่พิมพ์หลายครั้ง

แผนภาพแกนต์ -- เวลารอจริงสำหรับระยะเวลา, ปฏิกิริยา, การสตรีม LLM และการแก้ไข


สถานะแบบไดนามิก

สถานะ Discord ของ Luna สลับไปมาระหว่างค่าที่ตั้งไว้หลายค่า เปลี่ยนทุก 15 นาที ประเภทที่รองรับ: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5) ระหว่างนอน สถานะจะเปลี่ยนเป็น invisible

dynamic_status_presets:
  - status: online
    text: "กับพิกเซล"
    type: 0       # Playing
  - status: idle
    text: "เสียงสีขาว"
    type: 2       # Listening

การกระจายแบบสุ่ม (x0.5-1.0) ป้องกันการหมุนที่คาดเดาได้ 10% ของความพยายามถูกข้ามเพื่อป้องกันการซ้ำซาก

ตัวบ่งชี้การพิมพ์

ก่อนเรียก LLM Luna เรียก startTyping() setInterval รีเฟรชตัวบ่งชี้ทุก 8 วินาทีระหว่างการสร้าง ถูกทำความสะอาดในบล็อก finally (clearInterval)

const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

การกู้คืนหลังพัง

ถ้า LLM พัง (กระบวนการ llama-server ตาย) Luna ตรวจจับอีเวนต์ผ่าน llmBus.emit("crash", code) และพยายามรีสตาร์ทด้วย backoff แบบเลขชี้กำลัง ป้องกันการรีสตาร์ทไม่สิ้นสุด

พารามิเตอร์ LLM

พารามิเตอร์ถูกกำหนดตายตัวใน src/config.ts:

temp: 0.75
dynatemp-range: 0.15
top-k: 40
top-p: 0.95
min-p: 0.05
repeat-penalty: 1.12
repeat-last-n: 256
presence-penalty: 0.1
batch: 4096
ubatch: 256
context: 4096

ใช้เทมเพลต ChatML (<|im_start|>/<|im_end|>) จำนวนเธรดถูกตรวจจับอัตโนมัติผ่าน os.cpus().length


การตั้งค่า

npm install
cp config.example.yml config.yml
# แก้ไข config.yml
npm run dev                    # dev (โหลดซ้ำทันที)
npm run build && npm start     # production
สคริปต์ คำอธิบาย
build บันเดิล CLI แบบสแตนด์อโลน
start เริ่มบอท
lint / format / check Biome
test ทดสอบ (Bun)
download-model GGUF จาก HuggingFace
diagrams ส่งออกไดอะแกรม Mermaid เป็น SVG/PNG

การปรับใช้ PM2

./start.sh   # เปิด llm-server + llm-client ภายใต้ PM2

บทสรุป

Luna Protocol ไม่ใช่แค่บอท Discord ที่มี LLM มันคือระบบพฤติกรรมที่สมบูรณ์ที่จำลองความไม่สมบูรณ์แบบของมนุษย์: การลืม การพิมพ์ผิด การนอน การลังเล ความเหนื่อยล้า ทั้งหมดถูกออกแบบรอบบัสอีเวนต์แบบชนิดข้อมูล พร้อมไดอะแกรม Mermaid 24 แบบที่บันทึกทุกโฟลว์

โค้ดเป็นโอเพนซอร์ส ชุดข้อมูลเป็นสาธารณะ และการตั้งค่าโหลดซ้ำได้ทันที ถ้าคุณสนใจ ลองดำดิ่งลงไปในโค้ด -- มันเข้าถึงได้ง่ายกว่าที่คิด

แหล่งข้อมูล ลิงก์
พื้นที่เก็บ GitHub fox3000foxy/luna-protocol-project
ชุดข้อมูล Discord-Dialogues
แผนที่ Atlas atlas.nomic.ai

Related Articles