GitHub avatar

Fox's Blog

Luna Protocol: I Built an Autonomous Discord Bot That Simulates a Human Being

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

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

What if a Discord bot could sleep, make typos, hesitate, forget to reply, and sometimes message you 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.

Luna runs on contextual triggers and assumed imperfect replies. She has a priority trigger system, variable delays, sleep schedules, spontaneous messages, and even a TTS pipeline for voice messages. All configured via a single hot-reloadable config.yml file.

In this article, I break down the full architecture: from the generic event bus to the TTS pipeline, through the trigger system, the human components, and the fine-tuning dataset.

Architecture Overview -- global components and data flow


The architecture: a typed event bus

Luna's core is a TypedBus: a strongly typed generic event bus in TypeScript. It's the foundational brick everything rests on. I built it that way because I wanted to avoid circular dependencies from the start.

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 / │     → auto persistence            │
│  flush / ready / │                                   │
│  reset           │                                   │
└────────┬─────────┴────────┬─────────────────────────┘
          │                  │
┌──────────────────┐  ┌────▼──────────────────────┐
│ core/llm-core.ts │  │ bot.ts (Eris)             │
│ direct mode      │  │ bot/pending.ts             │
│   llama-server   │  │ bot/reactions.ts           │
│ online mode      │  │ state/trigger.ts           │
│   OpenAI API     │  │ state/state.ts             │
│                  │  │ behavior/*                 │
│                  │  │ tts/*                      │
│                  │  │ spontaneous.ts             │
└──────────────────┘  └────────────────────────────┘

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


Message Processing -- full message processing flow

The trigger system: who decides when Luna replies?

Every incoming message is evaluated by evaluateMessage(), which returns a TriggerResult with a trigger reason. 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"/alias (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 unmatched messages No (8%) No

Matching is whole word (\b): "ai" doesn't match "mais", "vrai", "lait".

Trigger evaluation -- entry decision for each message

The follow-up mechanism

When Luna replies to a message, she registers as lastSpeaker. Any message in the next 15 seconds triggers an immediate reply, short-circuiting timers and keyword checks. 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 replies in the same channel. Bypassed by mentions and follow-ups.


Human behaviors: variable concentration

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

Trigger Min delay Max delay 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%

Delay computation also factors in:

  • Message length: the longer the message, the longer Luna takes to "read" it
  • Inactivity: if Luna hasn't been active for 10 minutes, the delay doubles ("waking up" simulation)
  • 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 get through
slow Delay ×3-5, near-zero reactions
short Ignore chance +30%, near-zero reactions

During sleep hours, the Discord status switches to invisible.


Typos

Luna can make typos, corrected after 2-4 seconds. The keyboard layout is configurable (AZERTY or QWERTY). I left it configurable because you immediately spot a bot that types too cleanly.

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

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 of starting with a filler word (uh..., um..., well..., hmm..., so...).

Forgetfulness: even after matching a trigger, Luna can "forget" to reply with a 3% probability. She then stays totally silent, as if she'd seen nothing. I love that behavior because you've already seen humans blank on a message by accident.

Thematic fatigue: if a word comes up too often in the last 10 messages (threshold: 3 occurrences), delays get longer and ignore chance rises by 15%.


Realism in practice: when it gets creepy

On paper, all those systems (delays, typos, hesitations, forgetfulness) look gadgety. In real life, put end to end, the result is striking: people chatting with Luna suspect nothing. Here's a real exchange where someone directly asks Luna if she's a bot, and the conversation then drifts onto a totally different topic, with a naturalness that gives you chills. I didn't expect it to be that believable in real conditions.

Discord conversation with Luna -- first exchange

The tone, hesitations, reply pacing: nothing gives away a bot. Luna dodges the age question without ever breaking character.

Discord conversation with Luna -- exchange continues

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

Discord conversation with Luna -- topic drift

Discord conversation with Luna -- drift continues

Discord conversation with Luna -- end of exchange

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

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

It's the dead internet theory in practice: that theory (originally rather conspiratorial) postulates that a growing share of online content and interactions is generated by bots rather than humans, to the point the "real" human web would have become the minority. Long considered exaggerated, it feels less and less absurd as systems like Luna Protocol show it takes neither much money nor a huge model to simulate believable human presence at scale. Two instances of the same bot holding a long conversation without ever giving themselves away gives a pretty concrete preview of what a web populated mostly 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...). You just need an API key, the LLM runs on the provider side.

Real-time streaming

The LLM streams its reply line by line (\n). Each line is split into words, emitted one by one on llmBus.emit("token", word). On each \n, a flush event fires: the bot immediately sends the accumulated message. The pacing directly follows the LLM's.

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 queue (requestQueue) processes requests one by one, with automatic cleanup when the queue exceeds 100 items.


Spontaneous messages

Every 5 minutes, 12% chance Luna posts a message on her own. The server is picked by a linear weight system: the most active server is N× more likely than the last.

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 last 5 messages' context 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 full 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 queued message per user per channel. Processed as soon as the current reply finishes.

Session limits

After 8 exchanges, Luna pauses for 30 seconds. 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 apply without restart. I wanted to tune delays live, without cutting conversations.

Category Hot-reload
Triggers, keywords, names ✅
Concentration, 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 reply, including timers and edge cases

Architecture diagrams

The state-machines/ folder holds 24 Mermaid diagrams covering the whole source code. Each diagram has a detailed human-language explanation.

Among the most important:

# Diagram Type
01 Architecture Overview graph
02 Message Processing (full) 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

Those diagrams are a goldmine for understanding the full flow: from incoming message to reply, through timers and edge cases.


The trigger code in detail

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

export function evaluateMessage(
  message: Eris.Message, botId: string, botUsername: string, isFollowUp = false
): TriggerResult {
  if (message.author.bot) return { shouldRespond: true, 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 of using a server custom emoji, 70% a unicode emoji. The reaction goes out after the concentration delay, with the same staggered pacing as replies. I like that lag because you feel she took time to read.

Reaction commands on Luna's messages:

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

Reply style

Reply style is weighted by 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 reply is split into 2-3 fragments sent at human pace (1.5-4 seconds between fragments). Simulates someone typing in several goes.

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


Dynamic status

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

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

Random jitter (×0.5-1.0) avoids 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 finally (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 dying), Luna detects the event via llmBus.emit("crash", code) and tries restarting with exponential backoff. Avoids infinite restart loops.

LLM parameters

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. 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 Build standalone CLI
start Launch the bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF from HuggingFace
diagrams Export Mermaid diagrams to SVG/PNG

PM2 deployment

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

Conclusion

Luna Protocol goes way beyond a Discord bot with an LLM. It's a full behavioral system simulating 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 configuration is hot-reloadable. If the topic interests you, dive into the code: it's more approachable than it looks. I used it as a base to test lots of behavior ideas, and you can reuse each module separately.

Resource Link
GitHub repo 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 t'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.

Luna fonctionne avec des déclenchements contextuels et des réponses imparfaites assumées. Elle 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, je 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. Je l'ai construit comme ça parce que je voulais éviter les dépendances circulaires dès le départ.

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, en court-circuitant timers et vérifications 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, corrigées après 2-4 secondes. Le layout clavier est configurable (AZERTY ou QWERTY). Je l'ai laissé configurable parce que tu repères immédiatement un bot qui écrit trop proprement.

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%. Elle reste alors totalement silencieuse, comme si elle n'avait rien vu. J'adore ce comportement parce que tu as déjà vu des humains zapper un message sans faire exprès.

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) ont l'air gadget. En vrai, mis bout à bout, le résultat est bluffant : 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. Je ne m'attendais pas à ce que ce soit aussi crédible en conditions réelles.

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 tu as supposé qu'un des deux interlocuteurs était humain, félicitations : tu viens 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...). Tu as juste besoin d'une clé API, le LLM tourne côté fournisseur.

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é. Le rythme suit directement 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. Je voulais pouvoir ajuster les délais en live, sans couper les conversations en cours.

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 part après le délai de concentration, avec le même rythme décalé que les réponses. J'aime ce décalage parce que tu sens qu'elle a pris le temps de lire.

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 va bien au-delà d'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 t'intéresse, plonge dans le code : c'est plus accessible qu'il n'y paraît. Je m'en suis servi comme base pour tester beaucoup d'idées sur le comportement, et tu peux reprendre chaque module séparément.

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

Luna Protocol:我做了个装人类的自主 Discord 机器人

Luna Protocol 是带本地 LLM 的全自主 Discord 机器人,能自然聊天,会睡觉、打错字、犹豫、忘回、话题疲劳、 spontanément 发消息。

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 token、报错、crash、重置
  • 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 在总线发 token,bot 消费,state 自动更新。无循环依赖。


消息处理,从进到出的完整流

触发系统:谁决定 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 bot 是最后说话人 + 15 秒内 + 60 秒内 < 3 次 -- --
6 random 没命中的消息 1.5% 概率 不(8%) 不过

匹配是整词(\b):"ai" 对不上"mais"、"vrai"、"lait"。

触发判定,每条消息的入口决策

follow-up 机制

Luna 回完把自己记成 lastSpeaker。15 秒内下一条消息直接回,跳过计时和关键词检查。60 秒窗口预算 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;
}

冷却

同频道两次回复隔 8 秒。mention 和 follow-up 不受限。


人类行为:可变的注意力

这里 Luna 开始有意思。每种触发有自己的注意力阈值:最小最大延迟、无视概率、reaction 概率。

触发 最小延迟 最大延迟 无视 Reaction
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 分钟没动,延迟翻倍(模拟"刚睡醒")
  • 睡觉: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 只过 mention 和私聊
slow 延迟 3-5 倍,reaction 几乎无
short 无视概率 +30%,reaction 几乎无

睡觉时 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...)。

忘事:触发命中了,3% 概率"忘"回。彻底沉默,跟没看见一样。我爱死这个,因为真人真会手滑漏消息。

话题疲劳:一个词近 10 条出现太多次(阈值 3 次),延迟拉长,无视概率 +15%。


实战逼真:瘆人时刻

纸面上这些系统(延迟、typo、犹豫、忘事)看着像 gimmick。拼到一起实战吓人:跟 Luna 聊的人完全没怀疑。下面是真聊天,有人直接问 Luna 是不是机器人,话题自然转走,逼真到起鸡皮疙瘩。我真没想到实战这么像。

和 Luna 的 Discord 聊天,第一段

语气、犹豫、回复节奏:没有一点机器人味。Luna 问年龄打太极,人物没崩。

和 Luna 的 Discord 聊天,继续

对话顺得离谱,短回复很自然("okay, okay"、"yay"、"yayy"):正是人类在 Discord 发的那种。

和 Luna 的 Discord 聊天,话题转走

和 Luna 的 Discord 聊天,转走继续

和 Luna 的 Discord 聊天,结尾

瘆人的不是 Luna"会回":是它接得住对话,有像模像样的观点、接话、一条连贯的思路。没上面触发、注意力延迟、犹豫系统,这幻觉几条消息就碎。

小反转:上面截图里两个账号都是 Luna 实例。PixieGlow 和 Sujet d'SBlow 不是人类测机器人:是两个机器人互聊,各自(行为意义上)都以为对面"正常"。如果你看上面以为其中一个是人类,恭喜:你跟真服务器上的人中招姿势一模一样。

有点dead internet theory实战版:这个理论(最早偏阴谋论)说网上越来越多内容和互动是机器人产的,真人互联网成少数。长久以来觉得夸张,但 Luna Protocol 这类系统证明,装出可信人类 presence 规模化,既不用大钱也不用大模型。同一个机器人两实例聊长串互不露馅,大多机器人互聊的网长什么样,有了具体画面。


LLM 管线:两种模式

direct 模式(默认)

bot 直接 HTTP 请求本地 llama-server。模型共享,prompt 缓存,4 并发槽。两个 PM2 进程:LLM 服务端加 bot 客户端。

online 模式

bot 调任何 OpenAI 兼容 API(OpenAI、OpenRouter、Groq、Together……)。要个 API key,LLM 跑 provider 那边。

实时流

LLM 按行(\n)流回。每行切词,llmBus.emit("token", word) 一个个发。每 \n 发 flush 事件:bot 立刻发出攒的消息。节奏跟 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 条上下文,"自然"加入对话。


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 语音消息


反 spam 和持久化

反 spam

按 channelId:userId 排队。同频道同用户只排一条。当前回复完处理。

session 上限

聊 8 轮 Luna 歇 30 秒。3 分钟不活跃计数器清零。

自动持久化

每次状态变更在 stateBus 上发 → 自动保存(500ms 防抖)。不用手调 saveAllState()。持久内容:pendingMessages、paused、cooldown、时间戳、lastSpeaker、follow-up 计数。


热重载配置

单个 config.yml。大多数值热重载:改完不重启生效。我要的就是直播调延迟,不掐对话。

类别 热重载
触发、关键词、名字 ✅
注意力、延迟 ✅
Typo、burst、疲劳 ✅
睡觉计划 ✅
TTS、语音消息 ✅
Discord token、LLM 模式 ❌(要重启)
// config.ts -- getter 返回实时值
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

数据集:Discord-Dialogues

模型在 Discord-Dialogues 上微调:730 万对话、1700 万轮、1.4 亿词。2025 春夏真 Discord 对话,洗过(PII、ToS、机器人、命令)。Apache 2.0。

指标 值
样本 7 303 464
总轮数 16 881 010
总词数 139 922 950
平均 token 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 架构总览 graph
02 消息处理(完整) stateDiagram
03 触发判定 flowchart
04 LLM Core 队列(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: "" };

  // ... 名字、关键词、follow-up、随机匹配
}

正则缓存(hasWordCache)避免每条消息重编 pattern。


Reaction

Luna 给消息打 emoji reaction。服务器自定义 emoji 30%,unicode 70%。reaction 在注意力延迟后发,节奏和回复错开。我喜欢这个延迟因为感觉它真读完了。

消息上 reaction 命令:

  • ❌ → 停
  • ▶️ → 启动
  • 🗨️ → 清

回复风格

回复风格按 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 秒)。模拟分几次敲字的人。

计时甘特,延迟、reaction、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)
  );
};

Crash 恢复

LLM crash(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 远不止 Discord 加 LLM。是完整人类行为系统,模拟人的不完美:忘事、typo、睡觉、犹豫、疲劳。全架在类型化事件总线上,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パイプラインまで、起動システム、人間部品、微調整データセット経由でね。

Architecture Overview、全体部品とデータフロー


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

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はバスにトークンを出す。ボットは消費する。stateは自動更新する。循環依存なしだ。


Message Processing、1メッセージの完全処理フロー

起動システム:Lunaが返事するか誰が決めるか

各入力メッセージはevaluateMessage()で評価されて、起動理由付きTriggerResultを返す。優先順が重要だ。

# 理由 条件 無視回避 休止回避
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 ボットが最終話者 + 15秒以内 + 60秒に3回未満 -- --
6 random 非合致メッセージに1.5パーセント しない(8%) しない

照合は語全体(\b)だ。「ai」は「mais」や「vrai」や「lait」に合わない。

Trigger evaluation、各メッセージの入口判定

フォローアップ仕組み

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;
}

クールダウン

同じチャンネルで2返事の間は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。

修正3スタイル:

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

ためらいと忘却

ためらい:15パーセントで埋め草から始める(uh...、um...、well...、hmm...、so...)。

忘却:起動に合ってもLunaは3パーセントで返事を「忘れ」うる。そのまま完全沈黙する。何も見なかったみたいにね。人間がうっかりメッセージ飛ばすの見たことあるからこの挙動大好きだ。

話題疲れ:直近10メッセージで語が戻りすぎたら(しきい値:3回)、遅延が倍増して無視確率が15パーセント上がる。


実践のリアルさ:怖くなる瞬間

紙の上ではこの仕組み全部(遅延、タイプミス、ためらい、忘却)はおもちゃに見える。実際、端から端まで組むと結果は驚愕だ。Lunaと会話してる人たちは何も疑わない。ここに本物のやり取りがある。誰かがLunaに直接ボットか聞いて、その後会話がまったく違う話題に流れる。背筋が凍る自然さだ。実条件でここまで信用できるとは思わなかった。

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

調子もためらいも返事リズムも、ボットを裏切るものは何もない。Lunaは年齢の問いを人物を壊さずかわす。

LunaとのDiscord会話、やり取りの続き

会話は完全流動的に続く。短く自然な返事で(「okay, okay」「yay」「yayy」)。まさに人間がDiscordで送る種類のメッセージだ。

LunaとのDiscord会話、話題の漂流

LunaとのDiscord会話、漂流の続き

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

怖いのはLunaが「返事する」だけじゃないことだ。会話を保つんだ。見かけの意見も、振りも、メッセージをまたぐ一貫した思考の糸付きでね。上で述べた起動システムも集中遅延もためらいもなしでは、この錯覚は数メッセージで崩れる。

ちょっとしたどんでん返し:上のスクショで話してる2アカウントは両方Lunaのインスタンスだ。PixieGlowもSujet d'SBlowもボットを試す人間じゃない。互いに話す2つのボットで、各々(挙動意味で)「普通」の相手と話してると確信してるんだ。上のやり取りを読んで片方が人間だと思ったなら、おめでとう。本物のDiscordサーバーで誰でもやる通りに罠に落ちたってことだ。

これは死んだインターネット理論の実践版みたいなものだ。この理論(元は陰謀論寄り)は、オンライン内容ややり取りの増える割合が人間じゃなくボット生成になる、果ては「本物の」人間インターネットが少数派になったと仮定する。長く誇張と見なされたけど、Luna Protocolみたいな仕組みが、大した手段も巨大モデルもなしに大規模に信用できる人間存在を真似られるのを示すにつれて、どんどん突飛じゃなくなる。長い会話を裏切らず保てる同じボットの2インスタンスは、互いに話すボット多数派のウェブがどう見えうるのかなり具体的な見取り図だ。


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

directモード(デフォルト)

ボットはHTTPでローカルllama-serverに直接リクエストを送る。モデルは共有で、プロンプトキャッシュと4並行スロット付きだ。PM2プロセス2つ:LLMサーバーとボットクライアントだ。

onlineモード

ボットはOpenAI互換の任意APIを呼ぶ(OpenAI、OpenRouter、Groq、Togetherなど)。APIキー1つ要るだけだ。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プレビュー用waveformを計算する
  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 Pipeline、合成テキストからDiscordボイスメッセージへ


スパム対策と永続化

スパム対策

channelId:userIdごとの待ち行列だ。チャンネルごとユーザーごとに待ちメッセージ1つだ。進行中の返事が終わり次第処理する。

セッション上限

8往復後、Lunaは30秒休む。3分不活性でカウンタリセットだ。

自動永続化

各状態変化はstateBusに出る → 自動保存(500msデバウンス)。手動saveAllState()呼び出しはもう要らない。永続状態はこうだ。pendingMessages、paused、cooldowns、timestamps、lastSpeaker、フォローアップカウンタだ。


ホットリロード設定

config.yml1つだ。大半の値はホットリロード可能だ。変更は再起動なしに反映される。進行中会話を切らずに遅延を live 調整したかった。

カテゴリ ホットリロード
起動、キーワード、名前 ✅
集中、遅延 ✅
タイポ、バースト、疲れ ✅
睡眠スケジュール ✅
TTS、ボイスメッセージ ✅
Discordトークン、LLMモード ❌(再起動必要)
// config.ts。getterは live 値を返す
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

データセット:Discord-Dialogues

モデルはDiscord-Dialoguesで微調整する。730万やり取り、1,700万ターン、1億4,000万語だ。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データセット分布


Complete Lifecycle、タイマーと際どいケース込みのメッセージから返事までのボット完全挙動

アーキテクチャ図

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パーセント、unicode絵文字70パーセントだ。反応は集中遅延の後に出る。返事と同じずらしリズムだ。読む時間かけた感じがするからこのずれ好きだ。

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%

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: "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                    # 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ボットを大きく超える。完全な挙動システムで人間不完全さを真似る。忘却も、タイプミスも、睡眠も、ためらいも、疲れもね。全部型付きイベントバス中心に組み立てて、各フロー文書化のMermaid図24枚付きだ。

コードはオープンソース、データセットは公開、設定はホットリロード可能だ。興味が出たらコードに潜って。見た目より取っ付きやすいよ。挙動のいろんな考えを試す土台に使って、各モジュールは別々に取り出せる。

リソース リンク
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); });
  }
}

여기에서 주 버스 둘이 나와요.

  • 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가 언제 답할지 누가 정하나요?

들어오는 각 메시지는 TriggerResult를 돌려주는 evaluateMessage()로 평가받고, 트리거 이유가 들어 있어요. 우선순위 순서가 중요해요.

# 이유 조건 무시 우회 일시정지 우회
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 봇이 마지막 화자 + 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가 "읽는" 데 오래 걸려요
  • 비활성: 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 무작위 (기본값)

망설임과 깜빡함

망설임: 채우기 말(uh..., um..., well..., hmm..., so...)로 시작할 확률 15퍼센트.

깜빡함: 트리거에 맞아도 Luna가 확률 3퍼센트로 답장을 "잊어버릴" 수 있어요. 그러면 아무것도 못 본 것처럼 완전히 조용히 있어요. 사람이 실수로 메시지를 넘기는 것을 본 적이 있으니 이 행동이 좋아요.

주제 피로: 단어가 최근 10개 메시지에서 너무 자주 돌아오면 (임계값: 3회) 지연이 곱해지고 무시 확률이 15퍼센트 올라가요.


실전 리얼리즘: 소름 끼칠 때

종이 위에서는 이 시스템들(지연, 오타, 망설임, 깜빡함)이 모두 장난처럼 보여요. 실제로는 이어붙이면 결과가 기가 막혀요. Luna와 대화하는 사람들은 아무것도 눈치채지 못해요. 누군가 Luna에게 직접 봇인지 묻고, 대화가 완전히 다른 주제로 흘러가는 실제 교환이 여기 있어요. 소름 끼치는 자연스러움이에요. 실제로 이렇게 믿을 만할 줄은 예상하지 못했어요.

Luna와의 Discord 대화: 첫 교환

톤과 망설임, 답장 리듬. 봇이라는 티가 없어요. Luna는 캐릭터를 절대 깨지 않고 나이에 대한 질문을 피해 가요.

Luna와의 Discord 대화: 교환 이어서

대화가 완전히 유창하게 이어져요. 짧고 자연스러운 답장("okay, okay", "yay", "yayy"). Discord에서 사람이 보낼 법한 메시지 그대로예요.

Luna와의 Discord 대화: 주제 흘러가기

Luna와의 Discord 대화: 흘러가기 이어서

Luna와의 Discord 대화: 교환 끝

무서운 것은 Luna가 "답장한다"는 것이 아니에요. 대화를 이어간다는 거예요. 겉보기 의견과 추임새, 메시지에서 메시지로 일관된 생각 줄기를 가지고요. 위에 설명한 트리거 시스템과 집중 지연, 망설임이 없으면 이 착각은 몇 개 메시지 만에 무너져요.

작은 반전: 위 스크린샷에서 대화하는 두 계정은 둘 다 Luna 인스턴스예요. PixieGlow와 Sujet d'SBlow는 봇을 테스트하는 사람이 아니에요. 서로 말하는 두 봇이고, 각자(행동적 의미에서) "평범한" 누군가와 이야기한다고 믿어요. 위 교환을 읽으면서 둘 중 하나가 사람이라고 생각했다면 축하해요. 진짜 Discord 서버에서 누구나처럼 정확히 함정에 빠진 거예요.

이것은 데드 인터넷 이론의 실전 버전이에요. 이 이론(원래는 음모론 쪽)은 온라인 콘텐츠와 상호작용의 점점 큰 몫이 사람이 아니라 봇이 만들 것이고, "진짜" 인간 인터넷이 소수가 될 지경이라고 해요. 오래 과장으로 여겨졌지만, Luna Protocol 같은 시스템이 믿을 만한 인간 존재감을 대규모로 흉내 내는 데 큰 수단도 거대 모델도 필요 없음을 보여주면서 점점 덜 터무니없어져요. 절대 들키지 않고 긴 대화를 이어갈 수 있는 같은 봇의 인스턴스 둘. 봇끼리 말하는 봇이 대다수인 웹이 어떤 모습일지 꽤 구체적으로 보여줘요.


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

direct 모드 (기본값)

봇이 로컬 llama-server에 바로 HTTP로 요청을 보내요. 모델은 공유되고, 프롬프트 캐시와 동시 슬롯 4개. PM2 프로세스 둘: LLM 서버와 봇 클라이언트.

online 모드

봇이 OpenAI 호환 API(OpenAI, OpenRouter, Groq, Together...)를 불러요. API 키 하나면 되고, 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, 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만 건, 턴 1,700만, 단어 1억 4,000만. 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개가 있어요. 각 도식에 사람 말로 자세한 설명이 있어요.

가장 중요한 것들:

# 도식 종류
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

이 도식들은 완전한 흐름을 이해하는 금광이에요. 들어오는 메시지에서 답장까지, 타이머와 경계 경우를 거쳐요.


자세한 트리거 코드

트리거는 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퍼센트로 답장이 23개 조각으로 나뉘어 사람 리듬(조각 사이 1.54초)으로 나가요. 여러 번에 나눠 치는 사람을 흉내 내요.

타이밍 간트: 지연과 반응, 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 머메이드 도식을 SVG와 PNG로 내보내기

PM2 배포

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

결론

Luna Protocol은 LLM이 들어간 Discord 봇을 훨씬 넘어요. 인간의 불완전함을 흉내 내는 완전한 행동 시스템이에요. 깜빡함과 오타, 수면과 망설임, 피로. 타입화된 이벤트 버스 중심 아키텍처에, 각 흐름을 문서화한 머메이드 도식 24개.

코드는 오픈 소스, 데이터셋은 공개, 설정은 핫리로드 가능. 주제에 관심 있으면 코드에 뛰어들어 보세요. 보기보다 쉬워요. 행동에 대한 많은 생각을 테스트하는 토대로 썼고, 각 모듈을 따로 가져갈 수 있어요.

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

Luna Protocol: insanı simüle eden özerk bir Discord botu yarattım

Luna Protocol, uyku, yazım hataları, tereddütler, unutkanlıklar, tematik yorgunluk ve spontan mesajlarla doğal sohbet edebilen yerel LLMli tamamen özerk bir Discord botudur.

Luna Protocol: insanı simüle eden özerk bir Discord botu yarattım

Bir Discord botu uyuyabilir, yazım hataları yapabilir, tereddüt edebilir, yanıtlamayı unutabilir ve bazen kendi kendine mesaj gönderebilir mi? Luna Protocol tam bunu yapar: yerel bir LLM (llama.cpp) çalıştıran ve kusurlu bir insan gibi sohbet eden tamamen özerk bir Discord botu.

Luna bağlamsal tetikleyiciler ve üstlenilmiş kusurlu yanıtlarla çalışır. Öncelikli tetikleme sistemi, değişken gecikmeler, uyku saatleri, spontan mesajlar ve hatta sesli mesaj göndermek için TTS pipelineı vardır. Hepsi hot-reload yapılabilir basit config.yml dosyasıyla yapılandırılır.

Bu makalede tüm mimariyi didiklerim: genel olay veriyolundan TTS pipelineına, tetikleme sisteminden insani bileşenlere ve ince ayar veri kümesine.

Mimari Genel Bakış: global bileşenler ve veri akışı


Mimari: tipli olay veriyolu

Lunanın kalbi bir TypedBus tur: TypeScript güçlü tipli genel olay veriyolu. Her şeyin dayandığı temel tuğla budur. Başından döngüsel bağımlılıklardan kaçınmak istediğim için böyle inşa ettim.

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); });
  }
}

Buradan iki ana veriyol türetilir:

  • llmBus: LLM jetonları, hatalar, crashler, sıfırlama 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 geri kalanından bağlantısızdır. LLM jetonları veriyolda yayar, bot tüketir, durum otomatik güncellenir. Döngüsel bağımlılık yok.


Mesaj İşleme: bir mesajın eksiksiz işlenme akışı

Tetikleme sistemi: Luna ne zaman yanıt vereceğine kim karar verir?

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

# Neden Koşullar Görmezden gelmeyi atlatır Duraklamayı atlatır
1 mention @bot Evet (%0) Evet
2 dm replyInDM = true ile DM 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ı + < 15 sn + < 3 / 60 sn -- --
6 random eşleşmeyen mesajlarda %1,5 şans Hayır (%8) Hayır

Eşleşme tam kelimedir (\b): "ai", "mais", "vrai", "lait" ile eşleşmez.

Tetikleyici değerlendirmesi: her mesaj için giriş kararı

Takip mekanizması

Luna bir mesaja yanıt verince lastSpeaker olarak kaydedilir. Sonraki 15 saniyede her mesaj zamanlayıcı ve anahtar kelime kontrollerini kısa devre eden anında yanıt tetikler. Bütçe: 60 saniyelik pencerede 3 takip.

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 yanıt arası 8 saniye. Bahsetmeler ve takiplerle atlatılır.


İnsani davranışlar: değişken konsantrasyon

Lunanın ilginçleştiği yer burasıdır. Her tetikleyici türünün kendi konsantrasyon eşikleri vardır: min/maks gecikme, görmezden gelme şansı ve tepki şansı.

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

Gecikme hesabı şunları da dikkate alır:

  • Mesaj uzunluğu: mesaj ne uzun, Luna "okumaya" o kadar zaman harcar
  • Hareketsizlik: 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ılır:

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 bahsetmeler ve DMler geçer
slow Gecikme ×3-5, tepkiler neredeyse sıfır
short Görmezden gelme şansı +%30, tepkiler neredeyse sıfır

Uyku saatlerinde Discord durumu invisible geçer.


Yazım hataları

Luna 2-4 saniye sonra düzeltilen yazım hataları yapabilir. Klavye düzeni yapılandırılabilir (AZERTY ya da QWERTY). Yapılandırılabilir bıraktım çünkü fazla temiz yazan bir botu hemen fark edersin.

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 unutkanlıklar

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

Unutkanlıklar: tetikleyici eşleşse bile Luna %3 olasılıkla yanıtlamayı "unutabilir". O zaman hiçbir şey görmemiş gibi tamamen sessiz kalır. Bu davranışı severim çünkü insanların kazara bir mesajı es geçtiğini zaten gördün.

Tematik yorgunluk: bir kelime son 10 mesajda fazla dönerse (eşik: 3 kez) gecikmeler çarpılır ve görmezden gelme şansı %15 artar.


Pratikte gerçekçilik: ürkütücüleştiği an

Kâğıt üstünde tüm bu sistemler (gecikmeler, yazım hataları, tereddütler, unutkanlıklar) alet gibi görünür. Gerçekte uç uca koyunca sonuç blufftur: Lunayla sohbette insanlar hiçbir şeyden şüphelenmez. Birinin Lunaya doğrudan bot olup olmadığını sorduğu ve sohbetin sonra tüyler ürpertici doğallıkla tamamen başka konuya saptığı gerçek bir değiş tokuş işte. Gerçek koşullarda bu kadar inandırıcı olmasını beklemiyordum.

Luna ile Discord konuşması: ilk değiş tokuş

Ton, tereddütler, yanıt ritmi: botu ele veren hiçbir şey yok. Luna yaş sorusunu karakteri hiç bozmadan savuşturur.

Luna ile Discord konuşması: değiş tokuşun devamı

Sohbet tamamen akıcı devam eder, kısa ve doğal yanıtlarla ("okay, okay", "yay", "yayy"): Discordda bir insanın göndereceği türden mesajlar tam olarak.

Luna ile Discord konuşması: konu sapması

Luna ile Discord konuşması: sapmanın devamı

Luna ile Discord konuşması: değiş tokuşun sonu

Ürkütücü olan Luna "yanıt vermesi" değildir: sohbet sürdürmesidir, görünen fikirlerle, dönüşlerle, mesajdan mesaja tutarlı düşünce ipiyle. Yukarıda anlatılan tetikleme sistemi, konsantrasyon gecikmeleri ve tereddütler olmadan bu illüzyon birkaç mesajda çökerdi.

Küçük olay örgüsü dönüşü: yukarıdaki ekran görüntülerinde sohbet eden iki hesap Luna örnekleridir. PixieGlow ve Sujet d'SBlow bir botu test eden insan değildir: birbirleriyle konuşan iki bottur, her biri (davranışsal anlamda) "normal" biriyle konuştuğuna iknadır. Yukarıdaki değiş tokuşu okurken muhataplardan birinin insan olduğunu varsaydıysan tebrikler: gerçek bir Discord sunucusunda herkesin düşeceği tuzağa az önce düştün.

Bu biraz ölü internet teorisinin pratik sürümüdür: bu teori (başlangıçta epey komplo teorisi) çevrimiçi içerik ve etkileşimlerin giderek artan payının insanlardan çok botlarca üretileceğini, "gerçek" insani internetin azınlığa düştüğünü öne sürer. Uzun süre abartılı sayıldı, Luna Protocol gibi sistemler inandırıcı insani mevcudiyeti büyük ölçekte simüle etmek için ne çok araç ne devasa model gerekmediğini gösterdikçe giderek daha az absürt olur. Hiç ele vermeden uzun sohbet sürdürebilen aynı botun iki örneği, çoğu botların birbirleriyle konuştuğu bir webin neye benzeyebileceğine epey somut bir bakış verir.


LLM pipelineı: iki mod

direct modu (varsayılan)

Bot istekleri yerel llama-server HTTP sunucusuna doğrudan gönderir. Model paylaşımlıdır, istem önbelleği ve 4 eşzamanlı slot ile. İki PM2 süreci: LLM sunucusu ve bot istemcisi.

online modu

Bot herhangi OpenAI uyumlu API çağırır (OpenAI, OpenRouter, Groq, Together...). Sadece API anahtarı gerekir, LLM sağlayıcı tarafında döner.

Gerçek zamanlı akış

LLM yanıtını satır satır (\n) akıtır. Her satır kelimelere bölünür, llmBus.emit("token", word) üzerinde tek tek yayılır. Her \nde flush olayı yayılır: bot birikmiş mesajı hemen gönderir. Ritim doğrudan LLMyi izler.

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();
  });
}

İstek kuyruğu (requestQueue) istekleri tek tek işler, kuyruk 100 öğeyi aşınca otomatik temizlikle.


Spontan mesajlar

Her 5 dakikada Lunannın kendi kendine mesaj gönderme şansı %12dir. Sunucu doğrusal ağırlık sistemiyle seçilir: en aktif sunucunun en sonuncudan N kat ş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 sohbete "doğal" katılır.


TTS pipelineı: sesli mesajlar

%8 şansla Luna metin yerine sesli mesaj gönderir. Eksiksiz pipeline:

  1. Piper TTS metni WAVa sentezler
  2. ffmpeg OGGye çevirir
  3. Dalga formu Discord önizlemesi için hesaplanır
  4. Dosya Discord CDN API ü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 Pipeline: sentezlenmiş metinden Discord sesli mesajına


Anti-spam ve kalıcılık

Anti-spam

channelId:userId başına kuyruk. Kanal başına kullanıcı başına kuyrukta tek mesaj. Devam eden yanıt biter bitmez işlenir.

Oturum limitleri

8 değiş tokuştan sonra Luna 30 saniye mola verir. Sayaç 3 dakika hareketsizlikten sonra sıfırlanır.

Otomatik kalıcılık

Her durum mutasyonu stateBus üzerinde yayılır → otomatik kayıt (500 ms debounce). Manuel saveAllState() çağrılarına artık gerek yok. Kalıcı durum şunları içerir: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, takip sayaçları.


Hot-reload yapılandırması

Tek bir config.yml dosyası. Çoğu değer hot-reload yapılabilir: değişiklikler yeniden başlatmadan dikkate alınır. Gecikmeleri devam eden sohbetleri kesmeden canlı ayarlamak istedim.

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

Veri kümesi: Discord-Dialogues

Model Discord-Dialogues üzerinde ince ayarlıdır: 7,3M değiş tokuş, 17M tur, 140M kelime. 2025 ilkbahar-yaz gerçek Discord konuşmaları, filtrelenmiş (PII, ToS, botlar, komutlar). Apache 2.0.

Metrik Değer
Örnekler 7 303 464
Toplam tur 16 881 010
Toplam kelime 139 922 950
Ortalama jeton 32.8
Jetonlaştırıcı Hermes-3-Llama-3.1-8B

Kullanılan nicemlenmiş model bir GGUFtur (örneğin Discord-Hermes-3-8B.Q3_K_M.gguf).

Discord-Dialogues veri kümesi dağılımı


Complete Lifecycle: zamanlayıcılar ve uç durumlar dahil botun mesajdan yanıta eksiksiz davranışı

Mimari şemaları

state-machines/ klasörü kaynak kodun tamamını kapsayan 24 Mermaid şeması içerir. Her şemanın insan dilinde ayrıntılı açıklaması vardır.

En önemlilerinden:

# Şema Tür
01 Mimari Genel Bakış graph
02 Mesaj İşleme (eksiksiz) stateDiagram
03 Tetikleyici Değerlendirme flowchart
04 LLM Çekirdek Kuyruğu (3 backend) stateDiagram
10 TTS Pipeline flowchart
13 Durum Kalıcılığı flowchart
21 Zamanlama Gantt gantt
22 Eksiksiz Yaşam Döngüsü stateDiagram

Bu şemalar tüm akışı anlamak için altın madenidir: gelen mesajdan yanıta, zamanlayıcılar ve uç durumlar üzerinden.


Tetikleme kodu ayrıntılı

Tetikleyici state/trigger.ts içinde evaluateMessage() ile değerlendirilir. Eksiksiz 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 };

  // ... ad, anahtar kelime, takip, rastgele eşleştirme
}

Regex önbelleği (hasWordCache) kalıpları her mesajda yeniden derlemekten kaçınır.


Tepkiler

Luna mesajlara emojilerle tepki verir. Sunucunun özel emojisini kullanma şansı %30, unicode emoji %70. Tepki konsantrasyon gecikmesinden sonra gider, yanıtlarla aynı kayık ritimle. Bu kaymayı severim çünkü okumaya zaman ayırdığını hissedersin.

Luna mesajlarında tepkili komutlar:

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

Yanıt stili

Yanıt stili Lunannın kanaldaki güncel 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

DMde messageReference hep falsetur.


Patlama mesajlar

%15 şansla yanıt insani ritimde gönderilen 2-3 parçaya bölünür (parçalar arası 1,5-4 saniye). Birkaç seferde yazan birini simüle eder.

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


Dinamik durum

Lunannın Discord durumu yapılandırılmış birkaç hazır durum arasında döner, her 15 dakikada. Desteklenen türler: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Uyurken durum invisible geçer.

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

Öngörülebilir rotasyonlardan kaçınmak için rastgele jitter (×0,5-1,0). Tekrarı önlemek için girişimlerin %10u atlanır.

Yazıyor göstergesi

LLMyi çağırmadan önce Luna startTyping() çağırır. setInterval, üretim boyunca göstergeyi her 8 saniyede yeniler. finally içinde temizlenir (clearInterval).

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

Crash sonrası kurtarma

LLM crashlerse (llama-server süreci ölür) Luna olayı llmBus.emit("crash", code) üzerinden tespit eder ve üstel backoff ile yeniden başlatmayı dener. Sonsuz yeniden başlatma döngülerinden kaçınır.

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. Thread sayısı os.cpus().length ile otomatik tespit edilir.


Kurulum

npm install
cp config.example.yml config.yml
# config.yml düzenle
npm run dev                    # geliştirme (hot reload)
npm run build && npm start     # üretim
Script Açıklama
build Bağımsız CLI paketi
start Botu çalıştırır
lint / format / check Biome
test Testler (Bun)
download-model HuggingFaceden GGUF
diagrams Mermaid şemalarını SVG/PNG dışa aktarır

PM2 dağıtımı

./start.sh   # llm-server + llm-client PM2 altında çalıştırır

Sonuç

Luna Protocol LLMli Discord botunun çok ötesine geçer. İnsani kusurları simüle eden eksiksiz bir davranış sistemidir: unutkanlıklar, yazım hataları, uyku, tereddütler, yorgunluk. Hepsi tipli olay veriyolu etrafında mimarilenmiş, her akışı belgeleyen 24 Mermaid şemasıyla.

Kod açık kaynaktır, veri kümesi herkese açıktır ve yapılandırma hot-reload yapılabilir. Konu ilgini çekerse koda dal: göründüğünden erişilebilir. Davranış üzerine birçok fikri test etmek için temel kullandım ve her modülü ayrı ayrı geri alabilirsin.

Kaynak Bağlantı
GitHub deposu fox3000foxy/luna-protocol-project
Veri kümesi 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 interamente autonomo con un LLM locale, capace di conversazione naturale con sonno, refusi, esitazioni, dimenticanze, fatica 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 refusi, esitare, dimenticare di rispondere, e a volte inviarti un messaggio di sua iniziativa? È esattamente ciò che fa Luna Protocol: un bot Discord interamente autonomo che fa girare un LLM locale (llama.cpp) e conversa come un essere umano imperfetto.

Luna funziona con trigger contestuali e risposte imperfette volute. Ha un sistema di trigger prioritario, ritardi variabili, orari di sonno, messaggi spontanei, e persino una pipeline TTS per inviare messaggi vocali. Il tutto configurato via un semplice file config.yml hot-reloadable.

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

Architecture Overview -- 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 poggia tutto. L'ho costruito così perché volevo evitare dipendenze circolari fin dall'inizio.

// bus di eventi tipizzato: on / emit
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 cambi 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 è scollegato dal resto. L'LLM emette token sul bus, il bot li consuma, lo stato si aggiorna automaticamente. Nessuna dipendenza circolare.


Message Processing -- flusso completo di elaborazione di un messaggio

Il sistema di trigger: chi decide quando Luna risponde?

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

# Ragione Condizioni Bypass ignore Bypass pausa
1 mention @bot Sì (0%) Sì
2 dm DM 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 parlante + < 15s + < 3 / 60s -- --
6 random 1.5% di probabilità sui messaggi non matchati No (8%) No

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

Trigger evaluation -- decisione di ingresso per ogni messaggio

Il meccanismo di follow-up

Quando Luna risponde a un messaggio, si registra come lastSpeaker. Qualsiasi messaggio seguente entro 15 secondi innesca una risposta immediata, cortocircuitando timer e verifiche di keyword. Budget: 3 follow-up per finestra di 60 secondi.

// follow-up se il bot era l'ultimo a parlare
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. Aggirato da menzioni e follow-up.


I comportamenti umani: la concentrazione variabile

È qui che Luna diventa interessante. Ogni tipo di trigger ha le sue soglie di concentrazione: un ritardo min/max, una probabilità di ignorare, e una probabilità di reagire.

Trigger Ritardo min Ritardo max Ignore 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 anche conto di:

  • La lunghezza del messaggio: più il messaggio è lungo, più Luna mette tempo a "leggerlo"
  • L'inattività: se Luna non è stata attiva da 10 minuti, il ritardo è moltiplicato per 2 (simulazione del "risveglio")
  • Il sonno: in modalità slow, il ritardo è moltiplicato per 3 a 5
// ritardo umanizzato: lettura, sonno, jitter
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 aggressivo
  return delay;
}

Gli orari di sonno

Luna può dormire. Configurabile 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
Modalità Effetto
sleep Passano solo menzioni e DM
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.


I refusi

Luna può fare refusi, corretti dopo 2-4 secondi. Il layout tastiera è configurabile (AZERTY o QWERTY). L'ho lasciato configurabile perché becchi subito un bot che scrive troppo pulito.

// tasti adiacenti AZERTY per refusi realistici
const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... tutti i tasti adiacenti
};

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

Tre stili di correzione:

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

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%. Resta allora totalmente silenziosa, come se non avesse visto niente. Adoro questo comportamento perché hai già visto umani saltare un messaggio senza farlo apposta.

Fatica tematica: se una parola torna troppo spesso negli ultimi 10 messaggi (soglia: 3 occorrenze), i ritardi sono 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 gadget. In realtà, messi capo a capo, il risultato è bluffante: persone in conversazione con Luna non sospettano niente. Ecco uno scambio reale dove qualcuno chiede direttamente a Luna se è un bot, e dove la conversazione deriva poi su tutt'altro tema, con una naturalezza che fa venire i brividi. Non mi aspettavo fosse così credibile in condizioni reali.

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 rompere il personaggio.

Conversazione Discord con Luna: seguito dello scambio

La conversazione continua in modo totalmente fluido, con risposte corte e naturali ("okay, okay", "yay", "yayy"): esattamente il tipo di messaggi che un umano invierebbe su Discord.

Conversazione Discord con Luna: deriva del tema

Conversazione Discord con Luna: seguito della deriva

Conversazione Discord con Luna: fine dello scambio

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

Piccolo plot twist: negli screenshot qui sopra, entrambi gli account che discutono sono istanze di Luna. PixieGlow e Sujet d'SBlow non sono un umano che testa un bot: sono due bot che parlano tra loro, ognuno persuaso (in senso comportamentale) di discutere con qualcuno di "normale". Se leggendo lo scambio qui sopra hai supposto che uno dei due interlocutori fosse umano, congratulazioni: sei appena caduto nella trappola esattamente come chiunque farebbe su un vero server Discord.

È un po' la dead internet theory in versione pratica: questa teoria (all'origine piuttosto cospirazionista) postula che una parte crescente del contenuto e delle interazioni online sarebbe generata da bot invece che da umani, al punto che il "vero" internet umano sarebbe diventato minoritario. A lungo considerata esagerata, diventa sempre meno assurda via via che sistemi come Luna Protocol mostrano che non servono né tanti mezzi né un modello enorme per simulare una presenza umana credibile su larga scala. Due istanze dello stesso bot capaci di tenere una conversazione lunga senza mai tradirsi, danno un'idea abbastanza concreta di come potrebbe essere un web popolato in maggioranza da bot che parlano tra loro.


La pipeline LLM: due modalità

Modalità direct (default)

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...). Ti serve solo una chiave API, l'LLM gira lato fornitore.

Lo streaming in tempo reale

L'LLM fa stream della sua risposta riga per riga (\n). Ogni riga è tagliata in parole, emesse una a una su llmBus.emit("token", word). A ogni \n, un evento flush è emesso: il bot invia subito il messaggio accumulato. Il ritmo segue direttamente quello dell'LLM.

// emette le parole una a una, poi flush a fine riga
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 di richieste (requestQueue) elabora le richieste una a una, con pulizia automatica quando la coda supera 100 elementi.


I messaggi spontanei

Ogni 5 minuti, 12% di probabilità che Luna posti un messaggio di sua iniziativa. Il server è selezionato con un sistema di peso lineare: il server più attivo ha N volte più probabilità dell'ultimo.

// selezione pesata del server più attivo
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];
}

Il contesto degli ultimi 5 messaggi è letto, e Luna si unisce alla conversazione "naturalmente".


La pipeline TTS: messaggi vocali

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

  1. Piper TTS sintetizza il testo in WAV
  2. ffmpeg converte in OGG
  3. Il waveform è calcolato per l'anteprima Discord
  4. Il file è caricato via l'API Discord CDN
  5. Il messaggio vocale è inviato
// testo → WAV Piper → OGG ffmpeg → upload Discord
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 -- 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 appena la risposta in corso finisce.

Limiti di sessione

Dopo 8 scambi, Luna fa una pausa di 30 secondi. Il contatore si azzera 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, cooldown, timestamp, lastSpeaker, contatori di follow-up.


La configurazione hot-reload

Un solo file config.yml. Quasi tutti i valori sono hot-reloadable: i cambiamenti sono presi in conto senza riavvio. Volevo poter regolare i ritardi in live, senza tagliare le conversazioni in corso.

Categoria Hot-reload
Trigger, keyword, nomi Sì
Concentrazione, ritardi Sì
Typo, burst, fatica Sì
Sleep schedule Sì
TTS, messaggi vocali Sì
Token Discord, modalità LLM No (serve riavvio)
// 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 usato è un GGUF (per esempio Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribuzione del dataset Discord-Dialogues


Complete Lifecycle -- 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 backend) 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 capire il flusso completo: dal messaggio in ingresso alla risposta, passando per timer e casi limite.


Il codice di trigger in dettaglio

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

// valuta menzioni, DM, pausa, cooldown, nome, keyword, follow-up
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 per nome, keyword, follow-up, random
}

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


Le reazioni

Luna reagisce ai messaggi con emoji. 30% di probabilità di usare un'emoji custom del server, 70% un'emoji unicode. La reazione parte dopo il ritardo di concentrazione, con lo stesso ritmo sfalsato delle risposte. Amo questo sfalsamento perché senti che si è presa il tempo di leggere.

I comandi per reazione sui messaggi di Luna:

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

Lo stile di risposta

Lo stile di risposta è pesato secondo l'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 DM, messageReference è sempre false.


I messaggi a raffica

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

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


Lo stato dinamico

Lo stato Discord di Luna alterna vari preset configurati, a rotazione 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: "avec les pixels"
    type: 0       # Playing
  - status: idle
    text: "du bruit blanc"
    type: 2       # Listening

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

L'indicatore di digitazione

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

// mostra "sta scrivendo" durante la generazione
const startTyping = () => {
  client.sendChannelTyping(message.channel.id);
  typingIntervals.set(
    message.channel.id,
    setInterval(() => {
      client.sendChannelTyping(message.channel.id);
    }, 8000)
  );
};

Il recupero dopo crash

Se l'LLM crasha (processo llama-server che muore), Luna rileva l'evento via llmBus.emit("crash", code) e prova a riavviare con backoff esponenziale. Evita cicli 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|>) è usato. Il numero di thread è auto-rilevato via os.cpus().length.


Setup

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

Deploy PM2

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

Conclusione

Luna Protocol va ben oltre un bot Discord con un LLM. È un sistema comportamentale completo che simula le imperfezioni umane: le dimenticanze, i refusi, il sonno, le esitazioni, la fatica. Il tutto architettato intorno 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 il tema ti interessa, tuffati nel codice: è più accessibile di quanto sembri. Me ne sono servito come base per testare molte idee sul comportamento, e puoi riprendere ogni modulo separatamente.

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

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

Luna Protocol ist ein vollständig autonomer Discord-Bot mit lokalem LLM, fähig zu natürlicher Konversation mit Schlaf, Tippfehlern, Zögern, Vergessen, Themenmüdigkeit und spontanen Nachrichten.

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

Was, wenn ein Discord-Bot schlafen, Tippfehler machen, zögern, vergessen zu antworten und dir manchmal von sich aus eine Nachricht schicken könnte? Genau das macht Luna Protocol: Ein vollständig autonomer Discord-Bot, der ein lokales LLM (llama.cpp) laufen lässt und wie ein unvollkommener Mensch konversiert.

Luna funktioniert mit kontextuellen Auslösern und eingestandenen unvollkommenen Antworten. Sie hat ein prioritäres Auslösersystem, variable Verzögerungen, Schlafzeiten, spontane Nachrichten und sogar eine TTS-Pipeline, um Sprachnachrichten zu schicken. Alles konfiguriert via einer simplen hot-reloadbaren config.yml-Datei.

In diesem Artikel zerlege ich die komplette Architektur: Vom generischen Eventbus bis zur TTS-Pipeline, über das Auslösersystem, die menschlichen Komponenten und das Fine-Tuning-Dataset.

Architecture Overview -- globale Komponenten und Datenflüsse


Die Architektur: ein typisierter Eventbus

Das Herz von Luna ist ein TypedBus: Ein generischer stark typisierter Eventbus in TypeScript. Die fundamentale Baustelle, auf der alles ruht. Ich habe ihn so gebaut, weil ich zirkuläre Abhängigkeiten von Anfang an vermeiden wollte.

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); });
  }
}

Zwei Hauptbusse leiten sich ab:

  • llmBus: Handhabt LLM-Tokens, Fehler, Crashes, Reset
  • stateBus: Handhabt Zustandsänderungen mit automatischer Persistenz
┌─────────────────────────────────────────────────────┐
│                   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             │
└──────────────────┘  └────────────────────────────┘

Der Vorteil dieses Ansatzes: Jedes Modul ist vom Rest entkoppelt. Das LLM emittiert Tokens auf den Bus, der Bot konsumiert sie, der State aktualisiert sich automatisch. Keine zirkuläre Abhängigkeit.


Message Processing -- kompletter Verarbeitungsfluss einer Nachricht

Das Auslösersystem: wer entscheidet, wann Luna antwortet?

Jede eingehende Nachricht wird von evaluateMessage() bewertet, das ein TriggerResult mit Auslösegrund zurückgibt. Die Prioritätsreihenfolge ist kritisch:

# Grund Bedingungen Bypass Ignore Bypass Pause
1 mention @bot Ja (0%) Ja
2 dm DM 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% Chance auf nicht-treffende Nachrichten Nein (8%) Nein

Das Matching ist ganzes Wort (\b): "ai" matcht nicht "mais", "vrai", "lait".

Trigger evaluation -- Eingangsentscheidung für jede Nachricht

Der Follow-up-Mechanismus

Wenn Luna auf eine Nachricht antwortet, registriert sie sich als lastSpeaker. Jede folgende Nachricht innerhalb von 15 Sekunden triggert eine sofortige Antwort und short-circuited Timer und Keywordprüfungen. 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;
}

Der Cooldown

8 Sekunden zwischen zwei Antworten im selben Channel. Umgangen von Mentions und Follow-ups.


Die menschlichen Verhaltensweisen: die variable Konzentration

Hier wird Luna interessant. Jeder Auslösetyp hat seine eigenen Konzentrationsschwellen: Eine Min/Max-Verzögerung, eine Ignore-Chance und eine Reaktionschance.

Trigger Min-Verzögerung Max-Verzögerung Ignore 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 Verzögerungsberechnung berücksichtigt auch:

  • Die Nachrichtenlänge: Je länger die Nachricht, desto länger braucht Luna zum "Lesen"
  • Die Inaktivität: Wenn Luna seit 10 Minuten nicht aktiv war, wird die Verzögerung mit 2 multipliziert (Simulation des "Aufwachens")
  • Den Schlaf: Im slow-Modus wird die Verzögerung 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; // aggressiver Jitter
  return delay;
}

Die Schlafzeiten

Luna kann schlafen. Konfigurierbar 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
Modus Effekt
sleep Nur Mentions und DMs kommen durch
slow Verzögerung ×3-5, quasi keine Reaktionen
short Ignore-Chance +30%, quasi keine Reaktionen

Während der Schlafstunden wechselt der Discord-Status auf invisible.


Die Tippfehler

Luna kann Tippfehler machen, korrigiert nach 2-4 Sekunden. Das Tastaturlayout ist konfigurierbar (AZERTY oder QWERTY). Ich habe es konfigurierbar gelassen, weil du einen Bot, der zu sauber schreibt, sofort erkennst.

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 Editiert die Nachricht
message Neue Nachricht: word*
mixed 50/50 zufällig (Standard)

Das Zögern und Vergessen

Zögern: 15% Chance, mit einem Füllwort zu beginnen (uh..., um..., well..., hmm..., so...).

Vergessen: Selbst nach gematchtem Trigger kann Luna mit 3% Wahrscheinlichkeit "vergessen" zu antworten. Sie bleibt dann völlig still, als hätte sie nichts gesehen. Ich liebe dieses Verhalten, weil du schon Menschen gesehen hast, die eine Nachricht unabsichtlich zappen.

Themenmüdigkeit: Wenn ein Wort in den letzten 10 Nachrichten zu oft vorkommt (Schwelle: 3 Vorkommen), werden die Verzögerungen multipliziert und die Ignore-Chance steigt um 15%.


Der Realismus in der Praxis: wenn es gruselig wird

Auf dem Papier wirken all diese Systeme (Verzögerungen, Tippfehler, Zögern, Vergessen) wie Gadget. In echt, aneinandergereiht, ist das Ergebnis verblüffend: Leute im Gespräch mit Luna ahnen nichts. Hier ein echter Austausch, wo jemand Luna direkt fragt, ob sie ein Bot ist, und wo das Gespräch danach auf ein ganz anderes Thema abdriftet, mit einer Natürlichkeit, die einem kalt den Rücken runterläuft. Ich hatte nicht erwartet, dass es unter realen Bedingungen so glaubwürdig wäre.

Discord-Unterhaltung mit Luna, erster Austausch

Ton, Zögern, Antwortrhythmus: Nichts verrät einen Bot. Luna weicht der Altersfrage aus, ohne je die Rolle zu brechen.

Discord-Unterhaltung mit Luna, Fortsetzung des Austauschs

Das Gespräch läuft völlig flüssig weiter, mit kurzen natürlichen Antworten ("okay, okay", "yay", "yayy"): Genau die Art Nachrichten, die ein Mensch auf Discord schicken würde.

Discord-Unterhaltung mit Luna, Themenabdrift

Discord-Unterhaltung mit Luna, Fortsetzung der Abdrift

Discord-Unterhaltung mit Luna, Ende des Austauschs

Das Erschreckende ist nicht nur, dass Luna "antwortet": Sie hält ein Gespräch, mit scheinbaren Meinungen, Nachfragen, einem kohärenten Gedankenfaden von einer Nachricht zur nächsten. Ohne das oben beschriebene Auslösersystem, die Konzentrationsverzögerungen und das Zögern würde diese Illusion nach wenigen Nachrichten zusammenbrechen.

Kleiner Plot Twist: In den Screenshots oben sind beide diskutierenden Konten Luna-Instanzen. PixieGlow und Sujet d'SBlow sind kein Mensch, der einen Bot testet: Es sind zwei Bots, die miteinander reden, jeder (im verhaltensorientierten Sinn) überzeugt, mit jemand "Normalem" zu diskutieren. Wenn du beim Lesen des Austauschs oben angenommen hast, einer der zwei Gesprächspartner wäre menschlich, Glückwunsch: Du bist genau in die Falle getappt wie jeder auf einem echten Discord-Server.

Das ist ein bisschen die Dead-Internet-Theory in der Praxis: Diese (ursprünglich eher verschwörungstheoretische) Theorie postuliert, dass ein wachsender Anteil der Online-Inhalte und Interaktionen von Bots statt von Menschen generiert würde, bis das "echte" menschliche Internet minoritär geworden wäre. Lange als übertrieben betrachtet, wird sie immer weniger absurd, je mehr Systeme wie Luna Protocol zeigen, dass es weder viele Mittel noch ein riesiges Modell braucht, um eine glaubwürdige menschliche Präsenz in großem Maßstab zu simulieren. Zwei Instanzen desselben Bots, fähig, eine lange Unterhaltung zu halten, ohne sich je zu verraten, geben einen ziemlich konkreten Vorgeschmack darauf, wie ein mehrheitlich von miteinander redenden Bots bevölkertes Web aussehen könnte.


Die LLM-Pipeline: zwei Modi

direct-Modus (Standard)

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

online-Modus

Der Bot ruft jede OpenAI-kompatible API auf (OpenAI, OpenRouter, Groq, Together...). Du brauchst nur einen API-Schlüssel, das LLM läuft anbieterseitig.

Das Echtzeit-Streaming

Das LLM streamt seine Antwort Zeile für Zeile (\n). Jede Zeile wird in Wörter zerlegt, eines nach dem anderen auf llmBus.emit("token", word) emittiert. Bei jedem \n wird ein flush-Event emittiert: Der Bot schickt sofort die akkumulierte Nachricht. Der Rhythmus folgt direkt dem des 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();
  });
}

Die Warteschlange (requestQueue) verarbeitet die Requests einen nach dem anderen, mit automatischem Aufräumen, wenn die Schlange 100 Elemente übersteigt.


Die spontanen Nachrichten

Alle 5 Minuten 12% Chance, dass Luna von sich aus eine Nachricht postet. Der Server wird von einem linearen Gewichts-System gewählt: Der aktivste Server hat N× 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 "natürlich" ins Gespräch ein.


Die TTS-Pipeline: Sprachnachrichten

Mit 8% Chance schickt Luna eine Sprachnachricht statt Text. Die komplette Pipeline:

  1. Piper TTS synthetisiert den Text zu WAV
  2. ffmpeg konvertiert zu OGG
  3. Die Waveform wird für die Discord-Vorschau berechnet
  4. Die Datei wird via Discord-CDN-API hochgeladen
  5. Die Sprachnachricht wird geschickt
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


Der Anti-Spam und die Persistenz

Anti-Spam

Warteschlange pro channelId:userId. Eine einzige Nachricht in der Schlange pro Nutzer pro Channel. Verarbeitet, sobald die laufende Antwort endet.

Session-Limits

Nach 8 Austauschen macht Luna 30 Sekunden Pause. Der Zähler setzt sich nach 3 Minuten Inaktivität zurück.

Automatische Persistenz

Jede Zustandsmutation emittiert auf stateBus → automatisches Speichern (500ms Debounce). Keine manuellen saveAllState()-Aufrufe mehr nötig. Der persistierte Zustand umfasst: pendingMessages, paused, cooldowns, timestamps, lastSpeaker, Follow-up-Zähler.


Die Hot-Reload-Konfiguration

Eine einzige config.yml-Datei. Die meisten Werte sind hot-reloadbar: Änderungen werden ohne Neustart übernommen. Ich wollte die Verzögerungen live justieren können, ohne laufende Gespräche abzuschneiden.

Kategorie Hot-Reload
Trigger, Keywords, Namen ✅
Konzentration, Verzögerungen ✅
Typos, Burst, Müdigkeit ✅
Sleep-Schedules ✅
TTS, Sprachnachrichten ✅
Discord-Token, LLM-Modus ❌ (Neustart nötig)
// config.ts -- die Getter geben Live-Werte zurück
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Das Dataset: Discord-Dialogues

Das Modell ist auf Discord-Dialogues feingetunt: 7.3M Austausche, 17M Turns, 140M Wörter. Echte Discord-Gespräche Frühling-Sommer 2025, gefiltert (PII, ToS, Bots, Commands). Apache 2.0.

Metrik Wert
Samples 7 303 464
Turns gesamt 16 881 010
Wörter gesamt 139 922 950
Tokens im Schnitt 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Das genutzte quantisierte Modell ist ein GGUF (zum Beispiel Discord-Hermes-3-8B.Q3_K_M.gguf).

Verteilung des Discord-Dialogues-Datasets


Complete Lifecycle -- komplettes Botverhalten von Nachricht bis Antwort, inklusive Timer und Edge Cases

Die Architekturdiagramme

Der state-machines/-Ordner enthält 24 Mermaid-Diagramme, die den gesamten Quellcode abdecken. Jedes Diagramm hat eine detaillierte Erklärung in menschlicher Sprache.

Unter den wichtigsten:

# Diagramm Typ
01 Architecture Overview graph
02 Message Processing (komplett) 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 Goldmine, um den kompletten Fluss zu verstehen: Von der eingehenden Nachricht bis zur Antwort, über Timer und Edge Cases.


Der Triggercode im Detail

Der Trigger wird von evaluateMessage() in state/trigger.ts evaluiert. Hier die komplette 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 };

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

Der Regex-Cache (hasWordCache) vermeidet, die Patterns bei jeder Nachricht neu zu kompilieren.


Die Reaktionen

Luna reagiert mit Emojis auf Nachrichten. 30% Chance, ein Custom-Emoji des Servers zu nutzen, 70% ein Unicode-Emoji. Die Reaktion geht nach der Konzentrationsverzögerung raus, mit demselben versetzten Rhythmus wie die Antworten. Ich mag diese Verschiebung, weil du spürst, dass sie sich Zeit zum Lesen genommen hat.

Die Reaktionscommands auf Lunas Nachrichten:

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

Der Antwortstil

Der Antwortstil wird nach Lunas jüngster Aktivität im Channel 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 DM ist messageReference immer false.


Die Burst-Nachrichten

Mit 15% Chance wird eine Antwort in 2-3 Fragmente zerlegt, im menschlichen Rhythmus geschickt (1.5-4 Sekunden zwischen den Fragmenten). Simuliert jemanden, der in mehreren Schüben tippt.

Timing Gantt -- reale Wartezeiten für Verzögerungen, Reaktionen, LLM-Streaming und Korrekturen


Der dynamische Status

Lunas Discord-Status alterniert zwischen mehreren konfigurierten Presets, rotierend alle 15 Minuten. Unterstützte Typen: Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Während des Schlafs wechselt der Status auf invisible.

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

Ein zufälliger Jitter (×0.5-1.0) vermeidet vorhersehbare Rotationen. 10% der Versuche werden übersprungen, um Wiederholung zu vermeiden.

Der Tippindikator

Vor dem LLM-Call ruft Luna startTyping() auf. Ein setInterval frischt den Indikator alle 8 Sekunden während der Generierung auf. Aufgeräumt im finally (clearInterval).

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

Die Crash-Recovery

Wenn das LLM crasht (llama-server-Prozess stirbt), erkennt Luna das Event via llmBus.emit("crash", code) und versucht einen Neustart mit exponentiellem Backoff. Vermeidet endlose Neustartschleifen.

Die LLM-Parameter

Die Parameter sind in src/config.ts hardcoded:

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

Das ChatML-Template (<|im_start|>/<|im_end|>) wird genutzt. Die Threadzahl wird via os.cpus().length autodetekiert.


Einrichtung

npm install
cp config.example.yml config.yml
# config.yml editieren
npm run dev                    # dev (hot reload)
npm run build && npm start     # Produktion
Skript Beschreibung
build Autonomes 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-Deployment

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

Fazit

Luna Protocol geht weit über einen Discord-Bot mit LLM hinaus. Es ist ein komplettes Verhaltenssystem, das menschliche Unvollkommenheiten simuliert: Vergessen, Tippfehler, Schlaf, Zögern, Müdigkeit. Das Ganze architekturiert um einen typisierten Eventbus, mit 24 Mermaid-Diagrammen, die jeden Fluss dokumentieren.

Der Code ist Open Source, das Dataset ist öffentlich, und die Konfiguration ist hot-reloadbar. Wenn dich das Thema interessiert, tauch in den Code ein: Es ist zugänglicher, als es scheint. Ich habe es als Basis genutzt, um viele Ideen über Verhalten zu testen, und du kannst jedes Modul separat wiederverwenden.

Ressource Link
GitHub-Repo 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 с hot-reload.

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

Architecture Overview -- глобальные компоненты и потоки данных


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

Ядро 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 испускает токены в шину, бот их потребляет, состояние обновляется автоматически. Никаких циклических зависимостей.


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

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

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

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

Матчинг это целое слово (\b) : "ai" не совпадает с "mais", "vrai", "lait".

Trigger evaluation -- входное решение для каждого сообщения

Механизм 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;
}

Cooldown

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


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

Здесь Luna становится интересной. У каждого типа триггера свои пороги концентрации : минимальный/максимальный délai, шанс игнорировать и шанс отреагировать.

Триггер Задержка мин Задержка макс Игнор Реакция
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 % шанса начать с filler-слова (uh..., um..., well..., hmm..., so...).

Забывания : даже сматчив триггер, Luna может "забыть" ответить с вероятностью 3 %. Она остаётся полностью молчаливой, будто ничего не видела. Обожаю это поведение, потому что ты уже видел людей, случайно заппивших сообщение.

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


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

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

Разговор Discord с Luna -- первый обмен

Тон, колебания, ритм ответов : ничего не выдаёт бота. Luna уходит от вопроса про возраст, ни разу не сломав персонажа.

Разговор Discord с Luna -- продолжение обмена

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

Разговор Discord с Luna -- съезд темы

Разговор Discord с Luna -- продолжение съезда

Разговор Discord с Luna -- конец обмена

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

Маленький plot twist : на скриншотах выше оба обсуждающих аккаунта это инстансы Luna. PixieGlow и Sujet d'SBlow не человек, тестирующий бота : это два бота, говорящие друг с другом, каждый (в поведенческом смысле) уверен, что общается с кем-то "нормальным". Если, читая обмен выше, ты предположил, что один из собеседников человек, поздравляю : ты только что попался в ловушку ровно как любой на настоящем сервере Discord.

Это немного dead internet theory на практике : эта теория (изначально скорее конспирологическая) постулирует, что растущая доля онлайн-контента и взаимодействий генерировалась бы ботами, а не людьми, до точки, где "настоящий" человеческий интернет стал бы меньшинством. Долго считавшаяся преувеличенной, она становится всё менее абсурдной по мере того, как системы вроде Luna Protocol показывают, что не нужно ни больших средств, ни огромной модели, чтобы симулировать убедительное человеческое присутствие в большом масштабе. Два инстанса одного бота, способных держать длинный разговор, ни разу себя не выдав, дают довольно конкретное представление о том, как мог бы выглядеть веб, населённый в основном ботами, говорящими друг с другом.


LLM-пайплайн : два режима

Режим direct (дефолт)

Бот шлёт запросы напрямую локальному llama-server по HTTP. Модель общая, с кэшем промпта и 4 конкурентными слотами. Два процесса PM2 : LLM-сервер и клиент-бот.

Режим online

Бот зовёт любой OpenAI-совместимый API (OpenAI, OpenRouter, Groq, Together...). Нужен только API-ключ, 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. Waveform вычисляется для предпросмотра 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 Pipeline -- от синтезированного текста к голосовому сообщению Discord


Антиспам и персистентность

Антиспам

Очередь на channelId:userId. Одно сообщение в очереди на пользователя на канал. Обрабатывается, как только текущий ответ завершается.

Лимиты сессий

После 8 обменов Luna делает паузу на 30 секунд. Счётчик сбрасывается после 3 минут неактивности.

Автоперсистентность

Каждая мутация состояния испускает в stateBus → автосохранение (debounce 500ms). Больше не нужны ручные вызовы saveAllState(). Персистимое состояние включает : pendingMessages, paused, cooldowns, timestamps, lastSpeaker, счётчики follow-up.


Hot-reload конфигурации

Один файл config.yml. Большинство значений hot-reloadable : изменения учитываются без перезапуска. Я хотел подкручивать задержки вживую, не обрывая текущие разговоры.

Категория Hot-reload
Триггеры, keywords, имена ✅
Концентрация, задержки ✅
Опечатки, burst, усталость ✅
Расписания сна ✅
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 слов. Настоящие разговоры 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


Complete Lifecycle -- полное поведение бота от сообщения до ответа, включая таймеры и пограничные случаи

Диаграммы архитектуры

Папка state-machines/ содержит 24 диаграммы Mermaid, покрывающие весь исходник. У каждой диаграммы детальное объяснение человеческим языком.

Среди важнейших :

# Диаграмма Тип
01 Architecture Overview graph
02 Обработка сообщений (полная) 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 };

  // ... матчинг по имени, keyword, follow-up, random
}

Кэш 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 секунды между фрагментами). Симулирует человека, печатающего в несколько приёмов.

Timing 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       # Играет
  - status: idle
    text: "белый шум"
    type: 2       # Слушает

Случайный джиттер (×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) и пытается перезапуститься с экспоненциальным 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 (hot reload)
npm run build && npm start     # production
Скрипт Описание
build Автономный CLI-бандл
start Запускает бота
lint / format / check Biome
test Tests (Bun)
download-model GGUF с HuggingFace
diagrams Экспорт диаграмм Mermaid в SVG/PNG

Деплой PM2

./start.sh   # запускает llm-server + llm-client под PM2

Заключение

Luna Protocol идёт сильно дальше Discord-бота с LLM. Это полная поведенческая система, симулирующая человеческие несовершенства : забывания, опечатки, сон, колебания, усталость. Всё заархитектурено вокруг типизированной шины событий, с 24 диаграммами Mermaid, документирующими каждый поток.

Код открытый, датасет публичный, а конфигурация с hot-reload. Если тема интересна, ныряй в код : он доступнее, чем кажется. Я использовал это как базу для проверки многих идей про поведение, и ты можешь переиспользовать каждый модуль отдельно.

Ресурс Ссылка
Репозиторий GitHub fox3000foxy/luna-protocol-project
Dataset 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, vacilaciones, 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, meter erratas, vacilar, olvidar responder, y a veces enviarte un mensaje por iniciativa propia? Eso es justo lo que hace Luna Protocol: un bot Discord totalmente autónomo que corre un LLM local (llama.cpp) y conversa como un ser humano imperfecto.

Luna funciona con disparos contextuales y respuestas imperfectas asumidas. Tiene un sistema de disparo prioritario, retardos variables, horarios de sueño, mensajes espontáneos, e incluso un pipeline TTS para enviar mensajes vocales. Todo configurado vía un simple config.yml hot-reloadable.

En este artículo, disecciono la arquitectura completa: del bus de eventos genérico al pipeline TTS, pasando por el sistema de disparo, los componentes humanos, y el dataset de fine-tuning.

Architecture Overview: 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 la pieza fundamental sobre la que descansa todo. Lo construí así porque quería evitar las dependencias circulares desde el principio.

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 se derivan:

  • llmBus: gestiona los tokens LLM, los errores, los crashes, el 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á desconectado del resto. El LLM emite tokens en el bus, el bot los consume, el state se actualiza automáticamente. Ninguna dependencia circular.


Message Processing: flujo completo de procesamiento de un mensaje

El sistema de disparo: quién decide cuándo responde Luna

Cada mensaje entrante lo evalúa evaluateMessage() que devuelve un TriggerResult con una razón de disparo. El orden de prioridad es crítico:

# Razón Condiciones Bypass ignore Bypass pause
1 mention @bot Sí (0%) Sí
2 dm MP con replyInDM = true Sí (0%) No
3 name "Luna"/"Pixie"/alias (palabra entera) No (8%) No
4 keyword hello, hi, ai, bot... (palabra entera) No (8%) No
5 follow-up Bot fue último locutor + < 15s + < 3 / 60s -- --
6 random 1.5% de probabilidad en los mensajes no coincidentes No (8%) No

El matching es palabra entera (\b): "ai" no coincide con "mais", "vrai", "lait".

Trigger evaluation: decisión de entrada para cada mensaje

El mecanismo de follow-up

Cuando Luna responde un mensaje, se registra como lastSpeaker. Todo mensaje siguiente en los 15 segundos dispara una respuesta inmediata, cortocircuitando timers y comprobaciones 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. Rodeado por las menciones y los follow-ups.


Los comportamientos humanos: la concentración variable

Aquí Luna se vuelve interesante. Cada tipo de disparo tiene sus propios umbrales de concentración: un retardo mín/máx, una probabilidad de ignorar, y una probabilidad de reaccionar.

Trigger Retardo mín Retardo máx Ignore 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 del retardo también tiene en cuenta:

  • La longitud del mensaje: cuanto más largo el mensaje, más tarda Luna en "leerlo"
  • La inactividad: si Luna no estuvo activa desde hace 10 minutos, el retardo se multiplica por 2 (simulación del "despertar")
  • El sueño: en modo slow, el retardo 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 agressif
  return delay;
}

Los horarios de sueño

Luna puede dormir. Configurable vía 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 MPs
slow Retardo ×3-5, reacciones casi nulas
short Probabilidad de ignore +30%, reacciones casi nulas

Durante las horas de sueño, el estado Discord pasa a invisible.


Las erratas

Luna puede meter erratas, corregidas tras 2-4 segundos. El layout de teclado es configurable (AZERTY o QWERTY). Lo dejé configurable porque detectas de inmediato un bot que escribe demasiado limpio.

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 (defecto)

Las vacilaciones y los olvidos

Vacilaciones: 15% de probabilidad de empezar con una muletilla (uh..., um..., well..., hmm..., so...).

Olvidos: incluso tras coincidir con un trigger, Luna puede "olvidar" responder con una probabilidad del 3%. Queda entonces totalmente silenciosa, como si no hubiera visto nada. Me encanta ese comportamiento porque ya viste humanos saltarse un mensaje sin querer.

Fatiga temática: si una palabra vuelve demasiado a menudo en los 10 últimos mensajes (umbral: 3 ocurrencias), los retardos se multiplican y la probabilidad de ignore sube 15%.


El realismo en la práctica: cuando da miedo

Sobre el papel, todos esos sistemas (retardos, erratas, vacilaciones, olvidos) parecen gadget. En realidad, puestos juntos, el resultado es alucinante: gente conversando con Luna no sospecha nada. Aquí un intercambio real donde alguien pregunta directamente a Luna si es un bot, y donde la conversación deriva luego a otro tema totalmente distinto, con una naturalidad que da escalofríos. No esperaba que fuera tan creíble en condiciones reales.

Conversación Discord con Luna: primer intercambio

El tono, las vacilaciones, el ritmo de las respuestas: nada delata un bot. Luna esquiva la pregunta de la edad sin romper nunca el personaje.

Conversación Discord con Luna: continuación del intercambio

La conversación sigue de forma totalmente fluida, con respuestas cortas y naturales ("okay, okay", "yay", "yayy"): justo el tipo de mensajes que un humano enviaría en Discord.

Conversación Discord con Luna: deriva del tema

Conversación Discord con Luna: continuación de la deriva

Conversación Discord con Luna: fin del intercambio

Lo terrorífico no es solo que Luna "responda": es que mantiene una conversación, con opiniones aparentes, réplicas, un hilo de pensamiento coherente de un mensaje a otro. Sin el sistema de disparo, los retardos de concentración y las vacilaciones descritos arriba, esa ilusión se derrumbaría en pocos mensajes.

Pequeño plot twist: en los screenshots de arriba, ambas cuentas que discuten son instancias de Luna. PixieGlow y Sujet d'SBlow no son un humano probando un bot: son dos bots hablándose entre ellos, cada uno convencido (en el sentido conductual del término) de discutir con alguien "normal". Si al leer el intercambio de arriba supusiste que uno de los dos interlocutores era humano, felicidades: acabas de caer en la trampa exactamente como cualquiera lo haría en un verdadero servidor Discord.

Es un poco la dead internet theory en versión práctica: esa teoría (al origen más bien conspiracionista) postula que una parte creciente del contenido y de las interacciones en línea la generarían bots más que humanos, hasta que el "verdadero" internet humano se habría vuelto minoritario. Largo tiempo considerada exagerada, se vuelve cada vez menos absurda a medida que sistemas como Luna Protocol muestran que no hacen falta muchos medios 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 muestra bastante concreta de cómo podría verse una web poblada mayormente por bots que hablan entre ellos.


El pipeline LLM: dos modos

Modo direct (defecto)

El bot envía directamente las peticiones a un llama-server local en HTTP. El modelo es compartido, con prompt cache y 4 slots concurrentes. Dos procesos PM2: el servidor LLM y el bot cliente.

Modo online

El bot llama cualquier API compatible OpenAI (OpenAI, OpenRouter, Groq, Together...). Solo necesitas una clave API, el LLM corre en el proveedor.

El streaming en tiempo real

El LLM streamea su respuesta línea por línea (\n). Cada línea se trocea en palabras, emitidas una por una en llmBus.emit("token", word). En cada \n, se emite un evento flush: el bot envía de inmediato el mensaje acumulado. El ritmo sigue directamente 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 peticiones (requestQueue) procesa las peticiones una por una, con limpieza automática cuando la cola supera 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 con 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 5 últimos mensajes, y Luna se une a la conversación "naturalmente".


La pipeline TTS: mensajes vocales

Con 8% de probabilidad, Luna envía un mensaje vocal en vez de texto. La pipeline completa:

  1. Piper TTS sintetiza el texto en WAV
  2. ffmpeg convierte a OGG
  3. El waveform se calcula para la vista previa Discord
  4. El archivo se sube vía la API Discord CDN
  5. El mensaje vocal se envía
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: del texto sintetizado al mensaje vocal Discord


El antispam y la persistencia

Antispam

Cola por channelId:userId. Un solo mensaje en cola por usuario por canal. Procesado en cuanto termina la respuesta en curso.

Límites de sesión

Tras 8 intercambios, Luna hace una pausa de 30 segundos. El contador se reinicia tras 3 minutos de inactividad.

Persistencia automática

Cada mutación de estado emite en stateBus → guardado automático (debounce 500ms). Ya no hacen falta 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 tienen en cuenta sin reinicio. Quería poder ajustar los retardos en directo, sin cortar las conversaciones en curso.

Categoría Hot-reload
Triggers, keywords, nombres ✅
Concentración, retardos ✅
Typos, burst, fatiga ✅
Sleep schedules ✅
TTS, voice messages ✅
Discord token, LLM mode ❌ (reinicio requerido)
// config.ts: los getters devuelven valores live
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

El dataset: Discord-Dialogues

El modelo está fine-tuneado sobre Discord-Dialogues: 7.3M intercambios, 17M turnos, 140M palabras. Verdaderas conversaciones Discord 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 medios 32.8
Tokenizer Hermes-3-Llama-3.1-8B

El modelo cuantizado usado es un GGUF (por ejemplo Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribución del dataset Discord-Dialogues


Complete Lifecycle: comportamiento completo del bot del mensaje a la respuesta, incluyendo timers y casos límite

Los diagramas de arquitectura

La carpeta state-machines/ contiene 24 diagramas Mermaid cubriendo todo el 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

Esos diagramas son una mina de oro para entender el flujo completo: del mensaje entrante a la respuesta, pasando por los timers y los casos límite.


El código de disparo en detalle

El trigger lo evalúa evaluateMessage() en state/trigger.ts. Aquí 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 };

  // ... matching 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 custom del servidor, 70% un emoji unicode. La reacción sale tras el retardo de concentración, con el mismo ritmo desfasado que las respuestas. Me gusta ese desfase porque sientes que se tomó el tiempo de leer.

Los comandos por reacción sobre 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 MP, messageReference siempre es false.


Los mensajes en ráfaga

Con 15% de probabilidad, una respuesta se trocea en 2-3 fragmentos enviados a ritmo humano (1.5-4 segundos entre cada fragmento). Simula alguien que escribe en varias veces.

Timing Gantt: tiempos de espera reales para los retardos, reacciones, streaming LLM y correcciones


El estado dinámico

El estado 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 pasa a invisible.

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

Un jitter aleatorio (×0.5-1.0) evita las rotaciones previsibles. 10% de los intentos se saltan para evitar la repetición.

El indicador de escritura

Antes de llamar el LLM, Luna llama startTyping(). Un setInterval refresca 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 crashea (proceso llama-server que muere), Luna detecta el evento vía llmBus.emit("crash", code) e intenta reiniciar con un backoff exponencial. Evita los 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

El template ChatML (<|im_start|>/<|im_end|>) se usa. El número de threads se autodetecta vía os.cpus().length.


Puesta en marcha

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 Lanza el bot
lint / format / check Biome
test Tests (Bun)
download-model GGUF desde HuggingFace
diagrams Exporta los diagramas Mermaid en SVG/PNG

Despliegue PM2

./start.sh   # lanza llm-server + llm-client bajo PM2

Conclusión

Luna Protocol va mucho más allá de un bot Discord con un LLM. Es un sistema conductual completo que simula las imperfecciones humanas: los olvidos, las erratas, el sueño, las vacilaciones, la fatiga. Todo arquitecturado alrededor de un bus de eventos tipado, con 24 diagramas Mermaid documentando cada flujo.

El código es open source, 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. Me sirvió de base para probar muchas ideas sobre el comportamiento, y puedes retomar cada módulo por separado.

Recurso Enlace
Repo 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 yang sepenuhnya otonom dengan LLM lokal, mampu bercakap alami dengan tidur, salah ketik, keraguan, lupa, kelelahan tematis dan pesan spontan.

Luna Protocol : saya membuat bot Discord otonom yang mensimulasikan manusia

Bagaimana kalau bot Discord bisa tidur, membuat salah ketik, ragu, lupa menjawab, dan kadang mengirimi kamu pesan atas kemauannya sendiri? Tepat itulah yang dilakukan Luna Protocol : bot Discord yang sepenuhnya otonom yang menjalankan LLM lokal (llama.cpp) dan bercakap seperti manusia yang tidak sempurna.

Luna bekerja dengan pemicu kontekstual dan respons tidak sempurna yang disadari. Ia punya sistem pemicu berprioritas, penundaan variabel, jadwal tidur, pesan spontan, bahkan pipeline TTS untuk mengirim pesan suara. Semuanya dikonfigurasi lewat satu berkas config.yml yang bisa di hot-reload.

Dalam artikel ini, saya membedah arsitektur lengkap : dari bus event generik hingga pipeline TTS, lewat sistem pemicu, komponen manusiawi, dan dataset fine-tuning.

Gambaran Arsitektur -- komponen global dan alur data


Arsitekturnya : bus event bertipe

Jantung Luna adalah TypedBus : bus event generik yang bertipe kuat dalam TypeScript. Ini bata fundamental tempat semuanya bersandar. Saya membangunnya begitu karena ingin menghindari dependensi sirkular sejak awal.

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 berasal darinya :

  • llmBus : menangani token LLM, error, crash, reset
  • stateBus : menangani perubahan keadaan 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             │
└──────────────────┘  └────────────────────────────┘

Keunggulan pendekatan ini : setiap modul terputus dari sisanya. LLM memancarkan token ke bus, bot mengonsumsinya, state memperbarui otomatis. Tanpa dependensi sirkular.


Pemrosesan Pesan -- alur lengkap pemrosesan pesan

Sistem pemicu : siapa memutuskan kapan Luna menjawab?

Setiap pesan masuk dievaluasi oleh evaluateMessage() yang mengembalikan TriggerResult dengan alasan pemicu. Urutan prioritas 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 pembicara terakhir + < 15 dtk + < 3 / 60 dtk -- --
6 random Peluang 1.5% pada pesan yang tidak cocok Tidak (8%) Tidak

Pencocokannya kata utuh (\b) : "ai" tidak cocok dengan "mais", "vrai", "lait".

Evaluasi pemicu -- keputusan masuk untuk setiap pesan

Mekanisme follow-up

Ketika Luna menjawab pesan, ia mencatat dirinya sebagai lastSpeaker. Pesan berikutnya dalam 15 detik memicu respons segera, memotong timer dan pemeriksaan keyword. 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. Dilangkahi oleh mention dan follow-up.


Perilaku manusiawi : konsentrasi variabel

Di sinilah Luna menjadi menarik. Setiap tipe pemicu punya ambang konsentrasi sendiri : penundaan min/maks, peluang mengabaikan, dan peluang bereaksi.

Pemicu Penundaan min Penundaan maks Abaikan Reaksi
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%

Perhitungan penundaan juga memperhitungkan :

  • Panjang pesan : makin panjang pesan, makin lama Luna "membaca"
  • Ketidakaktifan : kalau Luna tidak aktif selama 10 menit, penundaan dikali 2 (simulasi "bangun")
  • Tidur : dalam mode slow, penundaan dikali 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. Bisa dikonfigurasi lewat 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 lolos
slow Penundaan ×3-5, reaksi nyaris nol
short Peluang mengabaikan +30%, reaksi nyaris nol

Selama jam tidur, status Discord beralih ke invisible.


Salah ketik

Luna bisa membuat salah ketik, yang dikoreksi setelah 2-4 detik. Tata letak keyboard bisa dikonfigurasi (AZERTY atau QWERTY). Saya membiarkannya bisa dikonfigurasi karena kamu langsung mengenali bot yang menulis terlalu bersih.

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

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

Tiga gaya koreksi :

Gaya Perilaku
edit Mengedit pesan
message Pesan baru : word*
mixed 50/50 acak (bawaan)

Keraguan dan kelupaan

Keraguan : peluang 15% memulai dengan kata pengisi (uh..., um..., well..., hmm..., so...).

Kelupaan : bahkan setelah mencocokkan pemicu, Luna bisa "lupa" menjawab dengan probabilitas 3%. Ia lalu tetap diam total, seolah tidak melihat apa-apa. Saya suka perilaku ini karena kamu pernah melihat manusia melewatkan pesan tanpa sengaja.

Kelelahan tematis : kalau sebuah kata kembali terlalu sering dalam 10 pesan terakhir (ambang : 3 kemunculan), penundaan dikali dan peluang mengabaikan naik 15%.


Realisme dalam praktik : ketika jadi menyeramkan

Di atas kertas, semua sistem ini (penundaan, salah ketik, keraguan, kelupaan) tampak seperti gadget. Kenyataannya, digabung, hasilnya mengejutkan : orang yang bercakap dengan Luna tidak curiga apa-apa. Berikut pertukaran nyata di mana seseorang langsung bertanya pada Luna apakah ia bot, dan percakapan lalu melayang ke topik yang sama sekali lain, dengan kealamian yang membuat bulu kuduk berdiri. Saya tidak menyangka akan sekredibel itu dalam kondisi nyata.

Percakapan Discord dengan Luna -- pertukaran pertama

Nada, keraguan, ritme respons : tidak ada yang mengkhianati bot. Luna mengelak soal pertanyaan umur tanpa pernah merusak karakter.

Percakapan Discord dengan Luna -- lanjutan pertukaran

Percakapan lanjut dengan sangat lancar, dengan respons pendek dan alami ("okay, okay", "yay", "yayy") : tepat jenis pesan yang dikirim manusia di Discord.

Percakapan Discord dengan Luna -- pergeseran topik

Percakapan Discord dengan Luna -- lanjutan pergeseran

Percakapan Discord dengan Luna -- akhir pertukaran

Yang mengerikan bukan sekadar Luna "menjawab" : melainkan ia menjalin percakapan, dengan opini yang tampak, umpan balik, benang pikir yang koheren dari satu pesan ke pesan lain. Tanpa sistem pemicu, penundaan konsentrasi dan keraguan yang dijelaskan di atas, ilusi ini runtuh dalam beberapa pesan.

Plot twist kecil : dalam tangkapan layar di atas, kedua akun yang berdiskusi adalah instans Luna. PixieGlow dan Sujet d'SBlow bukan manusia yang menguji bot : melainkan dua bot yang saling bicara, masing-masing yakin (dalam arti perilaku) sedang berdiskusi dengan seseorang yang "normal". Kalau saat membaca pertukaran di atas kamu mengira salah satu dari dua lawan bicara adalah manusia, selamat : kamu baru saja jatuh ke jebakan persis seperti siapa pun di server Discord sungguhan.

Ini agak versi praktis teori internet mati : teori ini (awalnya agak konspiratoris) berpostulat bahwa sebagian besar konten dan interaksi online dibangkitkan bot alih-alih manusia, sampai "internet" manusia yang sungguhan menjadi minoritas. Lama dianggap berlebihan, ia menjadi makin tidak absurd seiring sistem seperti Luna Protocol menunjukkan bahwa tidak perlu banyak sarana maupun model raksasa untuk mensimulasikan kehadiran manusia yang kredibel dalam skala besar. Dua instans bot yang sama yang mampu menjalin percakapan panjang tanpa pernah mengkhianati diri, itu memberi gambaran cukup konkret tentang seperti apa web yang dihuni mayoritas bot yang saling bicara.


Pipeline LLM : dua mode

Mode direct (bawaan)

Bot langsung mengirim permintaan ke llama-server lokal lewat HTTP. Model dibagi, dengan prompt cache dan 4 slot bersamaan. Dua proses PM2 : server LLM dan klien bot.

Mode online

Bot memanggil API apa pun yang kompatibel OpenAI (OpenAI, OpenRouter, Groq, Together...). Kamu hanya butuh kunci API, LLM berjalan di sisi penyedia.

Streaming waktu nyata

LLM me streaming responsnya baris per baris (\n). Setiap baris dipotong menjadi kata, dipancarkan satu per satu di llmBus.emit("token", word). Di setiap \n, event flush dipancarkan : bot segera mengirim pesan yang terakumulasi. Irama langsung mengikuti 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 permintaan (requestQueue) memproses permintaan satu per satu, dengan pembersihan otomatis ketika antrean melampaui 100 elemen.


Pesan spontan

Setiap 5 menit, peluang 12% Luna memposting pesan atas kemauannya sendiri. Server dipilih lewat sistem bobot linear : server paling aktif punya peluang N× lebih besar 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 ke percakapan "secara alami".


Pipeline TTS : pesan suara

Dengan peluang 8%, Luna mengirim pesan suara alih-alih teks. Pipeline lengkap :

  1. Piper TTS mensintesis teks menjadi WAV
  2. ffmpeg mengonversi menjadi OGG
  3. Waveform dihitung untuk pratinjau Discord
  4. Berkas diunggah lewat 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 tersintesis ke pesan suara Discord


Anti-spam dan persistensi

Anti-spam

Antrean per channelId:userId. Satu pesan dalam antrean per pengguna per kanal. Diproses begitu respons berjalan selesai.

Batas sesi

Setelah 8 pertukaran, Luna jeda 30 detik. Penghitung di reset setelah 3 menit ketidakaktifan.

Persistensi otomatis

Setiap mutasi keadaan memancarkan di stateBus → penyimpanan otomatis (debounce 500ms). Tidak perlu lagi panggilan saveAllState() manual. Keadaan yang dipersistenkan mencakup : pendingMessages, paused, cooldown, timestamp, lastSpeaker, penghitung follow-up.


Konfigurasi hot-reload

Satu berkas config.yml. Sebagian besar nilai bisa di hot-reload : perubahan diperhitungkan tanpa restart. Saya ingin bisa menyesuaikan penundaan secara live, tanpa memutus percakapan berjalan.

Kategori Hot-reload
Pemicu, kata kunci, nama ✅
Konsentrasi, penundaan ✅
Typo, burst, kelelahan ✅
Jadwal tidur ✅
TTS, pesan suara ✅
Token Discord, mode LLM ❌ (perlu restart)
// config.ts -- getter mengembalikan nilai live
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,3 juta pertukaran, 17 juta putaran, 140 juta kata. Percakapan Discord sungguhan musim semi-panas 2025, difilter (PII, ToS, bot, perintah). Apache 2.0.

Metrik Nilai
Sampel 7 303 464
Total putaran 16 881 010
Total kata 139 922 950
Rata-rata token 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Model terkuantisasi yang dipakai adalah GGUF (misalnya Discord-Hermes-3-8B.Q3_K_M.gguf).

Distribusi dataset Discord-Dialogues


Siklus Hidup Lengkap -- perilaku lengkap bot dari pesan hingga respons, termasuk timer dan kasus batas

Diagram arsitektur

Folder state-machines/ berisi 24 diagram Mermaid yang mencakup seluruh kode sumber. Setiap diagram punya penjelasan detail 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 ini tambang emas untuk memahami alur lengkap : dari pesan masuk hingga respons, lewat 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 nama, kata kunci, follow-up, acak
}

Cache regex (hasWordCache) menghindari mengompilasi ulang pola di setiap pesan.


Reaksi

Luna bereaksi pada pesan dengan emoji. Peluang 30% memakai emoji kustom server, 70% emoji unicode. Reaksi berangkat setelah penundaan konsentrasi, dengan ritme bergeser yang sama seperti respons. Saya suka pergeseran ini karena kamu merasakan ia meluangkan waktu membaca.

Perintah lewat reaksi pada pesan Luna :

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

Gaya respons

Gaya respons dibobot menurut aktivitas terbaru 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%

Di DM, messageReference selalu false.


Pesan beruntun

Dengan peluang 15%, respons dipotong menjadi 2-3 fragmen yang dikirim dengan ritme manusia (1.5-4 detik antara tiap fragmen). Mensimulasikan seseorang yang mengetik berkali-kali.

Gantt Pewaktuan -- waktu tunggu nyata untuk penundaan, reaksi, streaming LLM dan koreksi


Status dinamis

Status Discord Luna bergantian 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 beralih ke invisible.

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

Jitter acak (×0.5-1.0) menghindari rotasi yang bisa diprediksi. 10% upaya dilewati untuk menghindari pengulangan.

Indikator mengetik

Sebelum memanggil LLM, Luna memanggil startTyping(). setInterval menyegarkan indikator setiap 8 detik selama pembangkitan. 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

Kalau LLM crash (proses llama-server yang mati), Luna mendeteksi event lewat llmBus.emit("crash", code) dan mencoba menyalakan ulang dengan backoff eksponensial. Menghindari loop restart tak berujung.

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|>) dipakai. Jumlah thread terdeteksi otomatis lewat os.cpus().length.


Penyiapan

npm install
cp config.example.yml config.yml
# mengedit 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 Mengekspor diagram Mermaid ke SVG/PNG

Deployment PM2

./start.sh   # menjalankan llm-server + llm-client di bawah PM2

Kesimpulan

Luna Protocol jauh melampaui bot Discord dengan LLM. Ini sistem perilaku lengkap yang mensimulasikan ketidaksempurnaan manusia : kelupaan, salah ketik, tidur, keraguan, kelelahan. Semuanya diarsiteki sekitar bus event bertipe, dengan 24 diagram Mermaid yang mendokumentasikan setiap alur.

Kodenya open source, datasetnya publik, dan konfigurasinya bisa di hot-reload. Kalau topik ini menarik buatmu, selami kodenya : lebih mudah diakses daripada kelihatannya. Saya memakainya sebagai dasar untuk menguji banyak ide soal perilaku, dan kamu bisa memakai ulang setiap modul terpisah.

Sumber daya Tautan
Depot GitHub fox3000foxy/luna-protocol-project
Dataset Discord-Dialogues
Atlas Map atlas.nomic.ai

Luna Protocol : मैंने स्वायत्त Discord बॉट बनाया जो मानव का अनुकरण करता है

Luna Protocol स्थानीय LLM वाला पूरी तरह स्वायत्त Discord बॉट है, नींद, टाइपो, हिचकिचाहट, भूल, विषयगत थकान और स्वतः संदेशों के साथ स्वाभाविक बातचीत में सक्षम।

Luna Protocol : मैंने स्वायत्त Discord बॉट बनाया जो मानव का अनुकरण करता है

क्या हो अगर Discord बॉट सो सकता, टाइपो कर सकता, हिचकिचा सकता, जवाब देना भूल सकता, और कभी अपनी ओर से संदेश भेज सकता ? ठीक यही Luna Protocol करता है : पूरी तरह स्वायत्त Discord बॉट जो स्थानीय LLM (llama.cpp) चलाता है और अपूर्ण मानव की तरह बातचीत करता है।

Luna संदर्भात्मक trigger और स्वीकृत अपूर्ण उत्तरों से काम करती है। उसके पास प्राथमिकता trigger तंत्र, परिवर्तनीय विलंब, नींद समय, स्वतः संदेश, और यहाँ तक ध्वनि संदेश भेजने के लिए TTS पाइपलाइन है। सब साधारण hot-reloadable config.yml फाइल से कॉन्फ़िगर।

इस लेख में, मैं पूरी आर्किटेक्चर चीड़ता हूँ : सामान्य घटना बस से TTS पाइपलाइन तक, trigger तंत्र, मानवीय घटकों, और fine-tuning डेटासेट से गुजरते हुए।

Architecture Overview , वैश्विक घटक और डेटा प्रवाह


आर्किटेक्चर : typed घटना बस

Luna का हृदय TypedBus है : TypeScript में कड़ाई से typed सामान्य घटना बस। यह मौलिक ईंट है जिस पर सब टिका है। मैंने इसे ऐसे बनाया क्योंकि मैं शुरू से गोलाकार निर्भरताओं से बचना चाहता था।

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 token, त्रुटियाँ, crash, reset संभालती है
  • 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 बस पर token उत्सर्जित करता है, बॉट उन्हें उपभोग करता है, स्थिति अपने आप अद्यतन होती है। कोई गोलाकार निर्भरता नहीं।


Message Processing , संदेश प्रोसेसिंग का पूरा प्रवाह

trigger तंत्र : कौन तय करता है कि Luna कब जवाब देती है ?

हर आने वाला संदेश evaluateMessage() से मूल्यांकित होता है जो trigger कारण वाला TriggerResult लौटाता है। प्राथमिकता क्रम महत्वपूर्ण है :

# कारण शर्तें ignore bypass pause bypass
1 mention @bot हाँ (0%) हाँ
2 dm replyInDM = true के साथ MP हाँ (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" से मेल नहीं खाता।

Trigger evaluation , हर संदेश के लिए प्रवेश निर्णय

follow-up तंत्र

जब Luna संदेश का जवाब देती है, तो वह lastSpeaker के रूप में दर्ज होती है। 15 सेकंड में अगला हर संदेश तुरंत उत्तर trigger करता है, timer और keyword जाँच short-circuit करके। बजट : 60 सेकंड विंडो में 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;
}

cooldown

उसी चैनल में दो उत्तरों के बीच 8 सेकंड। उल्लेख और follow-up से दरकिनार होता है।


मानवीय व्यवहार : परिवर्तनीय एकाग्रता

यहीं Luna दिलचस्प होती है। हर trigger तरह की अपनी एकाग्रता सीमाएँ हैं : न्यूनतम/अधिकतम विलंब, नजरअंदाज संभावना, और प्रतिक्रिया संभावना।

Trigger न्यूनतम विलंब अधिकतम विलंब नजरअंदाज प्रतिक्रिया
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 सिर्फ उल्लेख और MP गुजरते हैं
slow विलंब ×3-5, प्रतिक्रियाएँ लगभग शून्य
short नजरअंदाज संभावना +30%, प्रतिक्रियाएँ लगभग शून्य

नींद घंटों के दौरान, Discord स्थिति invisible हो जाती है।


टाइपो

Luna टाइपो कर सकती है, 2-4 सेकंड बाद सुधारी गई। keyboard layout कॉन्फ़िगर करने योग्य (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 बेतरतीब (डिफ़ॉल्ट)

हिचकिचाहट और भूल

हिचकिचाहट : भराव शब्द (uh..., um..., well..., hmm..., so...) से शुरू करने की 15% संभावना।

भूल : trigger मिलाने के बाद भी, Luna 3% संभावना से जवाब देना "भूल" सकती है। वह तब पूरी तरह मौन रहती है, जैसे उसने कुछ देखा ही नहीं। मुझे यह व्यवहार पसंद है क्योंकि तुमने पहले ही मानवों को बिना इरादे संदेश भूलते देखा है।

विषयगत थकान : अगर कोई शब्द पिछले 10 संदेशों में बहुत बार लौटता है (सीमा : 3 घटनाएँ), तो विलंब गुणा होते हैं और नजरअंदाज संभावना 15% बढ़ जाती है।


व्यवहार में यथार्थवाद : जब यह डरावना होता है

कागज पर, ये सभी तंत्र (विलंब, टाइपो, हिचकिचाहट, भूल) gadget लगते हैं। असल में, एक के बाद एक, परिणाम चौंकाने वाला है : Luna से बातचीत में लोग कुछ संदेह नहीं करते। यहाँ असली आदान प्रदान है जहाँ कोई सीधे Luna से पूछता है कि क्या यह बॉट है, और जहाँ बातचीत फिर पूरी तरह दूसरे विषय पर बहती है, स्वाभाविकता के साथ जिससे रीढ़ ठंडी होती है। मुझे उम्मीद नहीं थी कि वास्तविक स्थितियों में यह इतना विश्वसनीय होगा।

Luna के साथ Discord बातचीत, पहला आदान प्रदान

स्वर, हिचकिचाहट, उत्तर लय : कुछ बॉट को धोखा नहीं देता। Luna उम्र प्रश्न को पात्र कभी तोड़े बिना टालती है।

Luna के साथ Discord बातचीत, आदान प्रदान जारी

बातचीत पूरी तरह तरल जारी रहती है, छोटे और स्वाभाविक उत्तरों ("okay, okay", "yay", "yayy") के साथ : ठीक वैसे संदेश जो मानव Discord पर भेजेगा।

Luna के साथ Discord बातचीत, विषय बहाव

Luna के साथ Discord बातचीत, बहाव जारी

Luna के साथ Discord बातचीत, आदान प्रदान अंत

जो भयानक है, वह सिर्फ यह नहीं है कि Luna "जवाब देती" है : यह है कि वह बातचीत बनाए रखती है, स्पष्ट राय, फिर से शुरूआत, एक संदेश से दूसरे तक सुसंगत विचार सूत्र के साथ। ऊपर वर्णित trigger तंत्र, एकाग्रता विलंब और हिचकिचाहट के बिना, यह भ्रम कुछ संदेशों में ढह जाएगा।

छोटा plot twist : ऊपर screenshot में, चर्चा करने वाले दोनों खाते Luna के उदाहरण हैं। PixieGlow और Sujet d'SBlow बॉट परखता मानव नहीं हैं : वे दो बॉट हैं जो एक दूसरे से बात करते हैं, हर एक (व्यवहार अर्थ में) आश्वस्त कि वह किसी "सामान्य" से चर्चा कर रहा है। अगर ऊपर आदान प्रदान पढ़ते हुए तुमने माना कि दो वार्ताकारों में से एक मानव था, बधाई : तुम ठीक जाल में गिरे जैसे कोई भी असली Discord serveur पर गिरता।

यह थोड़ा dead internet theory व्यावहारिक संस्करण है : यह सिद्धांत (मूलतः बल्कि षड्यंत्रकारी) मानता है कि ऑनलाइन सामग्री और interaction का बढ़ता हिस्सा मानवों के बजाय बॉट से उत्पन्न होगा, यहाँ तक कि "असली" मानव internet अल्पमत हो गया होगा। लंबे समय अतिरंजित माना गया, वह कम बेतुका होता जाता है जैसे Luna Protocol जैसे तंत्र दिखाते हैं कि बड़े पैमाने विश्वसनीय मानवीय उपस्थिति अनुकरण के लिए न बहुत साधन चाहिए न विशाल मॉडल। एक ही बॉट के दो उदाहरण जो लंबी बातचीत बिना कभी धोखा दिए बनाए रख सकते हैं, यह काफी ठोस झलक देता है कि बहुमत बॉटों से आबाद web कैसा लग सकता है जो एक दूसरे से बात करते हैं।


LLM पाइपलाइन : दो मोड

direct मोड (डिफ़ॉल्ट)

बॉट अनुरोध सीधे स्थानीय llama-server को HTTP में भेजता है। मॉडल साझा है, prompt cache और 4 समवर्ती slot के साथ। दो PM2 प्रक्रियाएँ : LLM serveur और बॉट client।

online मोड

बॉट OpenAI संगत कोई भी API बुलाता है (OpenAI, OpenRouter, Groq, Together...)। तुम्हें बस API कुंजी चाहिए, LLM provider तरफ चलता है।

वास्तविक समय streaming

LLM उत्तर पंक्ति दर पंक्ति (\n) stream करता है। हर पंक्ति शब्दों में काटी जाती है, एक एक करके 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 अपनी ओर से संदेश post करे। serveur रैखिक भार तंत्र से चुना जाता है : सबसे सक्रिय serveur को आखिरी से 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 पूर्वावलोकन के लिए waveform गणना की जाती है
  4. फाइल Discord CDN API से upload की जाती है
  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 Pipeline , संश्लेषित पाठ से Discord ध्वनि संदेश तक


एंटी spam और स्थायित्व

एंटी spam

channelId:userId दर कतार। प्रति चैनल प्रति उपयोगकर्ता कतार में एक ही संदेश। चल रहे उत्तर खत्म होते ही प्रोसेस किया जाता है।

सत्र सीमाएँ

8 आदान प्रदान बाद, Luna 30 सेकंड pause लेती है। 3 मिनट निष्क्रियता बाद counter reset होता है।

स्वचालित स्थायित्व

हर स्थिति उत्परिवर्तन stateBus पर उत्सर्जित होता है → स्वचालित सहेजना (debounce 500ms)। मैन्युअल saveAllState() कॉल की जरूरत नहीं। स्थायी स्थिति शामिल करती है : pendingMessages, paused, cooldown, timestamp, lastSpeaker, follow-up counter।


hot-reload कॉन्फ़िगरेशन

एक ही config.yml फाइल। ज्यादातर मान hot-reloadable हैं : बदलाव बिना पुनरारंभ ध्यान में लिए जाते हैं। मैं live विलंब समायोजित करना चाहता था, बिना चल रही बातचीत काटे।

श्रेणी Hot-reload
Trigger, keyword, नाम ✅
एकाग्रता, विलंब ✅
Typo, burst, थकान ✅
Sleep schedule ✅
TTS, ध्वनि संदेश ✅
Discord token, LLM मोड ❌ (पुनरारंभ जरूरी)
// config.ts , getter live मान लौटाते हैं
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

डेटासेट : Discord-Dialogues

मॉडल Discord-Dialogues पर fine-tune है : 7.3M आदान प्रदान, 17M मोड़, 140M शब्द। वसंत ग्रीष्म 2025 की असली Discord बातचीत, छँटी हुई (PII, ToS, बॉट, कमांड)। Apache 2.0।

मीट्रिक मान
नमूने 7 303 464
कुल मोड़ 16 881 010
कुल शब्द 139 922 950
औसत token 32.8
Tokenizer Hermes-3-Llama-3.1-8B

इस्तेमाल किया quantize मॉडल GGUF है (उदाहरण के लिए Discord-Hermes-3-8B.Q3_K_M.gguf)।

Discord-Dialogues डेटासेट वितरण


Complete Lifecycle , संदेश से उत्तर तक बॉट का पूरा व्यवहार, timer और सीमांत मामलों सहित

आर्किटेक्चर चित्र

state-machines/ फोल्डर में पूरे स्रोत कोड को कवर 24 Mermaid चित्र हैं। हर चित्र में मानव भाषा में विस्तृत व्याख्या है।

सबसे महत्वपूर्ण में :

# चित्र प्रकार
01 Architecture Overview graph
02 Message Processing (पूरा) 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

ये चित्र पूरा प्रवाह समझने के लिए सोने की खान हैं : आने वाले संदेश से उत्तर तक, timer और सीमांत मामलों से गुजरते हुए।


विस्तार में trigger कोड

trigger 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 };

  // ... नाम, keyword, follow-up, बेतरतीब से मिलान
}

regex cache (hasWordCache) हर संदेश पर पैटर्न फिर से संकलित करने से रोकता है।


प्रतिक्रियाएँ

Luna emoji से संदेशों पर प्रतिक्रिया करती है। serveur के custom emoji इस्तेमाल की 30% संभावना, unicode emoji की 70%। प्रतिक्रिया एकाग्रता विलंब बाद जाती है, उत्तरों वाली उसी décalage लय के साथ। मुझे यह décalage पसंद है क्योंकि तुम महसूस करते हो कि उसने पढ़ने का समय लिया।

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%

MP में, messageReference हमेशा false है।


बौछार संदेश

15% संभावना से, उत्तर मानवीय लय (हर टुकड़े के बीच 1.5-4 सेकंड) में भेजे 2-3 टुकड़ों में काटा जाता है। किसी का कई बार टाइप करने का अनुकरण करता है।

Timing Gantt , विलंब, प्रतिक्रियाओं, LLM streaming और सुधारों के लिए वास्तविक प्रतीक्षा समय


गतिशील स्थिति

Luna की Discord स्थिति कई कॉन्फ़िगर preset के बीच बदलती है, हर 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

बेतरतीब 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)
  );
};

crash बाद पुनर्प्राप्ति

अगर LLM crash करता है (llama-server प्रक्रिया मरती है), Luna llmBus.emit("crash", code) घटना से पहचानती है और घातांकीय backoff से पुनरारंभ प्रयास करती है। अनंत पुनरारंभ लूप से बचती है।

LLM पैरामीटर

पैरामीटर src/config.ts में hardcoded हैं :

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 template (<|im_start|>/<|im_end|>) इस्तेमाल होता है। thread संख्या 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 bundle करता है
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 बॉट से कहीं आगे जाता है। यह पूर्ण व्यवहार तंत्र है जो मानवीय अपूर्णताएँ अनुकरण करता है : भूल, टाइपो, नींद, हिचकिचाहट, थकान। सब typed घटना बस के चारों ओर architected, हर प्रवाह प्रलेखित 24 Mermaid चित्रों के साथ।

कोड ओपन सोर्स है, डेटासेट सार्वजनिक है, और कॉन्फ़िगरेशन hot-reloadable है। अगर विषय तुम्हारी रुचि का है, तो कोड में गोता लगाओ : यह दिखता से ज्यादा सुलभ है। मैंने इसे व्यवहार पर बहुत से विचार परखने के आधार के रूप में इस्तेमाल किया, और तुम हर मॉड्यूल अलग फिर से ले सकते हो।

संसाधन लिंक
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، مرورا بنظام المحفزات والمكونات الإنسانية وطقم الضبط.

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


البنية : ناقل أحداث مُنمَّط

قلب 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 الرموز على الناقل، ويستهلكها البوت، وتتحدث الحالة تلقائيا. لا تبعية دائرية.


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

نظام المحفزات : من يقرر متى ترد Luna ؟

كل رسالة واردة تُقيَّم بواسطة evaluateMessage() التي تعيد TriggerResult مع سبب تحفيز. ترتيب الأولوية حاسم :

# السبب الشروط تجاوز التجاهل تجاوز الإيقاف
1 mention @bot نعم (0%) نعم
2 dm رسالة خاصة مع replyInDM = true نعم (0%) لا
3 name "Luna"/"Pixie"/alias (كلمة كاملة) لا (8%) لا
4 keyword hello وhi وai وbot... (كلمة كاملة) لا (8%) لا
5 follow-up البوت كان آخر متكلم + < 15s + < 3 / 60s -- --
6 random 1.5% احتمال على الرسائل غير المطابقة لا (8%) لا

المطابقة كلمة كاملة (\b) : "ai" لا تطابق "mais" و"vrai" و"lait".

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

آلية المتابعة

عندما ترد 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 قوي
  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 occurrences)، تُضرب المهلات ويزيد احتمال التجاهل بـ15%.


الواقعية عمليا : عندما يصبح الأمر مخيفا

على الورق، تبدو كل هذه الأنظمة (المهلات والأخطاء المطبعية والتردد والنسيان) مجرد إكسسوارات. في الحقيقة، متراكمة طرفا لطرف، النتيجة مبهرة : أناس في محادثة مع Luna لا يشكون في شيء. إليكم تبادلا حقيقيا حيث يسأل شخص Luna مباشرة إن كانت بوتا، ثم تنحرف المحادثة بعد ذلك نحو موضوع آخر تماما، بطبيعية تقشعر لها الأبدان. لم أتوقع أن يكون بهذه المصداقية في الظروف الحقيقية.

Conversation Discord avec Luna -- premier échange

النبرة والتردد وإيقاع الردود : لا شيء يخون بوتا. تتهرب Luna من سؤال العمر دون كسر الشخصية أبدا.

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

تستمر المحادثة بسلاسة تامة، مع ردود قصيرة وطبيعية ("okay, okay" و"yay" و"yayy") : بالضبط نوع الرسائل التي يرسلها إنسان على 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

المخيف ليس أن Luna "ترد" فقط : بل أنها تحافظ على محادثة، بآراء ظاهرية ومتابعات وخيط فكر متسق من رسالة لأخرى. دون نظام المحفزات ومهلات التركيز والتردد الموصوفة أعلاه، لانهار هذا الوهم في بضع رسائل.

مفاجأة صغيرة : في اللقطات أعلاه، الحسابان المتحادثان هما نسختان من Luna. PixieGlow وSujet d'SBlow ليسا إنسانا يختبر بوتا : بل بوتان يتكلمان بينهما، كل واحد مقتنع (بالمعنى السلوكي للكلمة) أنه يحادث شخصا "عاديا". إذا افترضت أثناء قراءة التبادل أعلاه أن أحد المتحاورين إنسان، فتهانينا : لقد وقعت في الفخ تماما كما يفعل أي كان على خادم Discord حقيقي.

إنها dead internet theory بنسخة عملية : هذه النظرية (التآمرية الأصل) تفترض أن حصة متزايدة من المحتوى والتفاعلات على الإنترنت ستُولَّد بواسطة بوتات بدلا من بشر، حتى يصبح الإنترنت الإنساني "الحقيقي" أقلية. لطالما اعتُبرت مبالغا فيها، وتصبح أقل عبثية كلما أظهرت أنظمة مثل Luna Protocol أنه لا يلزم الكثير من الوسائل ولا نموذج ضخم لمحاكاة حضور إنساني موثوق على نطاق واسع. نسختان من نفس البوت قادرتان على الحفاظ على محادثة مطولة دون أن تخونا أبدا، تعطيان لمحة ملموسة عما قد يبدو عليه ويب تسكنه غالبية بوتات تتكلم بينها.


خط LLM : وضعان

وضع direct (افتراضي)

يرسل البوت الطلبات مباشرة إلى llama-server محلي عبر HTTP. النموذج مشترك، مع كاش prompt و4 slots متزامنة. عمليتا PM2 : خادم LLM وعميل البوت.

وضع online

يستدعي البوت أي API متوافقة مع OpenAI (OpenAI وOpenRouter وGroq وTogether...). تحتاج فقط مفتاح API، ويعمل 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. يُحسب الـwaveform لمعاينة 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 Pipeline -- du texte synthétisé au message vocal Discord


مكافحة السبام والحفظ

مكافحة السبام

قائمة انتظار لكل channelId:userId. رسالة واحدة في الانتظار لكل مستخدم لكل قناة. تُعالَج بمجرد انتهاء الرد الجاري.

حدود الجلسة

بعد 8 تبادلات، تأخذ Luna استراحة 30 ثانية. يُصفَّر العداد بعد 3 دقائق خمول.

حفظ تلقائي

كل تغيير حالة يبث على stateBus ← حفظ تلقائي (debounce 500ms). لا حاجة لاستدعاءات saveAllState() يدوية. تتضمن الحالة المحفوظة : pendingMessages وpaused وcooldowns وtimestamps وlastSpeaker وعدادات المتابعة.


الإعداد القابل لإعادة التحميل حيا

ملف config.yml واحد. معظم القيم قابلة لإعادة التحميل حيا : تُؤخذ التغييرات دون إعادة تشغيل. أردت ضبط المهلات مباشرة، دون قطع المحادثات الجارية.

الفئة إعادة تحميل حية
Triggers وkeywords والأسماء ✅
التركيز والمهلات ✅
Typos والdفعة والتعب ✅
جداول النوم ✅
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 كلمة. محادثات 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).

Distribution du dataset Discord-Dialogues


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

مخططات البنية

يحوي مجلد state-machines/ 24 مخطط Mermaid تغطي كامل الكود المصدري. لكل مخطط شرح مفصل بلغة بشرية.

من أهمها :

# المخطط النوع
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

هذه المخططات منجم ذهب لفهم التدفق الكامل : من الرسالة الواردة إلى الرد، مرورا بالمؤقتات والحالات الحدية.


كود التحفيز بالتفصيل

يُقيَّم المحفز بواسطة 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 };

  // ... مطابقة بالاسم والكلمة والمتابعة والعشوائي
}

كاش الـ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 ثوانٍ بين كل جزء). يحاكي شخصا يكتب على عدة دفعات.

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


الحالة الديناميكية

تتناوب حالة Discord الخاصة بـLuna بين عدة إعدادات مسبقة، كل 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

تتجنب 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) وتحاول إعادة التشغيل مع 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                    # تطوير (إعادة تحميل حية)
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   # lance llm-server + llm-client sous PM2

الخلاصة

يتجاوز Luna Protocol بكثير بوت Discord مع LLM. إنه نظام سلوكي كامل يحاكي العيوب الإنسانية : النسيان والأخطاء المطبعية والنوم والتردد والتعب. الكل مُبنَى حول ناقل أحداث مُنمَّط، مع 24 مخطط Mermaid توثق كل تدفق.

الكود مفتوح المصدر، والطقم عام، والإعداد قابل لإعادة التحميل حيا. إذا كان الموضوع يهمك، اغُص في الكود : إنه أسهل مما يبدو. استخدمته كأساس لاختبار الكثير من الأفكار حول السلوك، ويمكنك استعادة كل وحدة منفردة.

المورد الرابط
مستودع GitHub fox3000foxy/luna-protocol-project
الطقم Discord-Dialogues
خريطة Atlas atlas.nomic.ai

Luna Protocol : tôi đã tạo bot Discord tự chủ mô phỏng con người

Luna Protocol là bot Discord hoàn toàn tự chủ chạy LLM cục bộ, có khả năng trò chuyện tự nhiên với giấc ngủ, lỗi gõ phím, ngập ngừng, quên lãng, mệt mỏi chủ đề và tin nhắn tự phát.

Luna Protocol : tôi đã tạo bot Discord tự chủ mô phỏng con người

Và nếu một bot Discord có thể ngủ, mắc lỗi gõ phím, ngập ngừng, quên trả lời, và thỉnh thoảng tự gửi cho bạn tin nhắn theo ý nó? Đó chính xác là điều Luna Protocol làm : một bot Discord hoàn toàn tự chủ chạy LLM cục bộ (llama.cpp) và trò chuyện như con người không hoàn hảo.

Luna chạy với kích hoạt theo ngữ cảnh và sự không hoàn hảo có chủ ý. Cô ấy có hệ thống kích hoạt ưu tiên, độ trễ thay đổi, giờ ngủ, tin nhắn tự phát, và cả pipeline TTS để gửi tin nhắn thoại. Tất cả cấu hình qua một tệp config.yml hot-reload đơn giản.

Trong bài này, tôi mổ xẻ kiến trúc đầy đủ : từ bus sự kiện generic tới pipeline TTS, qua hệ thống kích hoạt, thành phần con người, và dataset fine-tuning.

Architecture Overview -- thành phần toàn cục và luồng dữ liệu


Kiến trúc : bus sự kiện có kiểu

Lõi của Luna là TypedBus : bus sự kiện generic gõ kiểu mạnh bằng TypeScript. Đó là viên gạch nền mọi thứ dựa lên. Tôi xây nó như vậy vì muốn tránh phụ thuộc vòng ngay từ đầu.

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 từ đó ra :

  • llmBus : quản lý token LLM, lỗi, crash, reset
  • stateBus : quản lý thay đổi trạng thái với bền vững tự động
┌─────────────────────────────────────────────────────┐
│                   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 thế của cách này : mỗi module tách rời khỏi phần còn lại. LLM phát token lên bus, bot tiêu thụ, state tự cập nhật. Không phụ thuộc vòng.


Message Processing -- luồng xử lý đầy đủ của một tin nhắn

Hệ thống kích hoạt : ai quyết khi nào Luna trả lời?

Mỗi tin nhắn tới được đánh giá bởi evaluateMessage() trả về TriggerResult với lý do kích hoạt. Thứ tự ưu tiên then chốt :

# Lý do Điều kiện Bỏ qua ignore Bỏ qua pause
1 mention @bot Có (0%) Có
2 dm MP với replyInDM = true Có (0%) Không
3 name "Luna"/"Pixie"/alias (từ nguyên) Không (8%) Không
4 keyword hello, hi, ai, bot... (từ nguyên) 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 tin nhắn không khớp Không (8%) Không

Khớp là từ nguyên (\b) : "ai" không khớp "mais", "vrai", "lait".

Trigger evaluation -- quyết định đầu vào cho mỗi tin nhắn

Cơ chế follow-up

Khi Luna trả lời tin nhắn, cô ấy đăng ký mình là lastSpeaker. Mọi tin nhắn tiếp theo trong 15 giây kích hoạt phản hồi ngay, bỏ qua timer và kiểm tra keyword. Ngân sách : 3 follow-up cho 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 kênh. Bị mention và follow-up bỏ qua.


Hành vi con người : độ tập trung thay đổi

Đây là lúc Luna thành thú vị. Mỗi kiểu 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.

Trigger 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%

Phép tính trễ còn xét :

  • Độ dài tin nhắn : tin nhắn càng dài, Luna càng mất thời gian "đọc"
  • Không hoạt động : nếu Luna không hoạt động 10 phút, trễ nhân 2 (mô phỏng "tỉnh dậy")
  • Giấc ngủ : ở chế độ slow, trễ nhân 3 tới 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 mạnh
  return delay;
}

Giờ ngủ

Luna có thể ngủ. 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à MP qua được
slow Trễ ×3-5, phản ứng gần như không
short Cơ hội bỏ qua +30%, phản ứng gần như không

Trong giờ ngủ, trạng thái Discord chuyển sang invisible.


Lỗi gõ phím

Luna có thể mắc lỗi gõ phím, sửa sau 2-4 giây. Layout bàn phím cấu hình được (AZERTY hay QWERTY). Tôi để cấu hình được vì bạn nhận ra ngay bot nào viết quá sạch.

const azertyAdjacent: Record<string, string[]> = {
  a: ["z", "q", "w"],
  z: ["a", "e", "s", "x"],
  e: ["z", "r", "d", "s"],
  // ... mọi phím kề nhau
};

Ví dụ AZERTY : bonjour → bonjpur, salut → slaut, comment → cpmment.

Ba kiểu sửa :

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 lãng

Ngập ngừng : 15% cơ hội mở đầu bằng từ đệm (uh..., um..., well..., hmm..., so...).

Quên lãng : ngay cả khi đã khớp trigger, Luna có thể "quên" trả lời với xác suất 3%. Cô ấy khi đó im lặng hoàn toàn, như thể chẳng thấy gì. Tôi mê hành vi này vì bạn từng thấy con người lỡ tin nhắn mà không cố ý.

Mệt mỏi chủ đề : nếu một từ quay lại quá thường trong 10 tin nhắn cuối (ngưỡng : 3 lần xuất hiện), độ trễ nhân lên và cơ hội bỏ qua tăng 15%.


Hiện thực trong thực hành : khi nó thành rợn

Trên giấy tờ, mọi hệ thống này (trễ, lỗi gõ phím, ngập ngừng, quên lãng) trông như đồ chơi. Thực tế, đặt cạnh nhau, kết quả gây choáng : người trò chuyện với Luna không hề nghi ngờ. Đây là cuộc trao đổi thật nơi ai đó hỏi thẳng Luna có phải bot không, và cuộc trò chuyện sau đó trôi sang chủ đề hoàn toàn khác, với tự nhiên lạnh sống lưng. Tôi không ngờ nó đáng tin tới vậy trong điều kiện thật.

Trò chuyện Discord với Luna -- cuộc trao đổi đầu

Giọng, ngập ngừng, nhịp phản hồi : không gì tố cáo bot. Luna lảng câu hỏi tuổi mà không bao giờ phá vai.

Trò chuyện Discord với Luna -- tiếp cuộc trao đổi

Cuộc trò chuyện tiếp tục hoàn toàn trôi chảy, với phản hồi ngắn tự nhiên ("okay, okay", "yay", "yayy") : đúng kiểu tin nhắn con người gửi trên Discord.

Trò chuyện Discord với Luna -- trôi chủ đề

Trò chuyện Discord với Luna -- tiếp đoạn trôi

Trò chuyện Discord với Luna -- cuối cuộc trao đổi

Điều đáng sợ không chỉ là Luna "trả lời" : mà là cô ấy giữ được cuộc trò chuyện, với ý kiến bề ngoài, câu gợi, mạch suy nghĩ mạch lạc từ tin nhắn này sang tin khác. Không có hệ thống kích hoạt, trễ tập trung và ngập ngừng mô tả ở trên, ảo giác này sẽ sụp trong vài tin nhắn.

Plot twist nhỏ : trong các screenshot trên, cả hai tài khoản đang nói chuyện đều là instance của Luna. PixieGlow và Sujet d'SBlow không phải con người đang test bot : đó là hai bot nói chuyện với nhau, mỗi bên tin (theo nghĩa hành vi) mình đang bàn với ai đó "bình thường". Nếu khi đọc cuộc trao đổi trên bạn cho rằng một trong hai bên là con người, chúc mừng : bạn vừa sập bẫy đúng như bất kỳ ai trên máy chủ Discord thật.

Đó hơi là thuyết internet chết phiên bản thực hành : thuyết này (gốc hơi thuyết âm mưu) cho rằng phần ngày càng lớn của nội dung và tương tác trực tuyến do bot tạo ra thay vì con người, tới mức internet con người "thật" thành thiểu số. Lâu nay bị coi là phóng đại, nó ngày càng bớt phi lý khi các hệ thống như Luna Protocol cho thấy không cần nhiều phương tiện hay mô hình khổng lồ để mô phỏng sự hiện diện con người đáng tin ở quy mô lớn. Hai instance của cùng bot có thể giữ cuộc trò chuyện dài mà không bao giờ tự tố, điều đó cho cái nhìn khá cụ thể về web đông bot nói chuyện với nhau trông sẽ ra sao.


Pipeline LLM : hai chế độ

Chế độ direct (mặc định)

Bot gửi thẳng yêu cầu tới llama-server cục bộ qua HTTP. Mô hình dùng chung, 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 tương thích OpenAI nào (OpenAI, OpenRouter, Groq, Together...). Bạn chỉ cần khóa API, LLM chạy phía nhà cung cấp.

Streaming thời gian thực

LLM stream phản hồi từng dòng (\n). Mỗi dòng cắt thành từ, phát từng từ lên llmBus.emit("token", word). Mỗi \n, sự kiện flush được phát : bot gửi ngay tin nhắn tích lũy. Nhịp theo đúng nhịp 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ý yêu cầu từng cái, với dọn tự động khi hàng đợi vượt 100 phần tử.


Tin nhắn tự phát

Mỗi 5 phút, 12% cơ hội Luna đăng tin nhắn theo ý mình. Máy chủ được chọn bằng hệ thống trọng số tuyến tính : máy chủ hoạt động nhất có cơ hội gấp N× máy cuối.

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];
}

Ngữ cảnh 5 tin nhắn cuối được đọc, và Luna nhập cuộc "tự nhiên".


Pipeline 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. Pipeline đầy đủ :

  1. Piper TTS tổng hợp văn bản thành WAV
  2. ffmpeg đổi sang OGG
  3. Waveform được tính cho 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
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 -- từ văn bản tổng hợp tới tin nhắn thoại Discord


Chống spam và bền vững

Chống spam

Hàng đợi cho mỗi channelId:userId. Một tin nhắn trong hàng cho mỗi người dùng mỗi kênh. Xử lý ngay khi phản hồi đang chạy xong.

Giới hạn phiên

Sau 8 lượt trao đổi, Luna nghỉ 30 giây. Bộ đếm reset sau 3 phút không hoạt động.

Bền vững tự động

Mỗi đột biến trạng thái phát lên stateBus → lưu tự động (debounce 500ms). Không cần gọi saveAllState() tay nữa. Trạng thái bền vững gồm : pendingMessages, paused, cooldown, timestamp, lastSpeaker, bộ đếm follow-up.


Cấu hình hot-reload

Một tệp config.yml duy nhất. Hầu hết giá trị hot-reload : thay đổi có hiệu lực không cần khởi động lại. Tôi muốn chỉnh trễ trực tiếp, không cắt cuộc trò chuyện đang diễn ra.

Danh mục Hot-reload
Trigger, keyword, tên ✅
Tập trung, trễ ✅
Typo, burst, mệt mỏi ✅
Lịch ngủ ✅
TTS, tin nhắn thoại ✅
Token Discord, chế độ LLM ❌ (đòi khởi động lại)
// config.ts -- getter trả về giá trị trực tiếp
export const config = {
  get typoChance() { return raw.typoChance ?? 0.06; },
  get concentration() { return raw.concentration; },
  // ...
};

Dataset : Discord-Dialogues

Mô hình được fine-tune trên Discord-Dialogues : 7.3M lượt trao đổi, 17M lượt, 140M từ. Hội thoại Discord thật xuân-hè 2025, đã lọc (PII, ToS, bot, lệnh). Apache 2.0.

Chỉ số Giá trị
Mẫu 7 303 464
Tổng lượt 16 881 010
Tổng từ 139 922 950
Token trung bình 32.8
Tokenizer Hermes-3-Llama-3.1-8B

Mô hình lượng tử dùng là GGUF (ví dụ Discord-Hermes-3-8B.Q3_K_M.gguf).

Phân bố dataset Discord-Dialogues


Complete Lifecycle -- hành vi đầy đủ của bot từ tin nhắn tới phản hồi, gồm timer và trường hợp biên

Sơ đồ kiến trúc

Thư mục state-machines/ chứa 24 sơ đồ Mermaid phủ toàn bộ mã nguồn. Mỗi sơ đồ có giải thích chi tiết bằng ngôn ngữ con người.

Trong các sơ đồ quan trọng nhất :

# Sơ đồ Kiểu
01 Architecture Overview graph
02 Message Processing (đầy đủ) 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

Các sơ đồ này là mỏ vàng để hiểu luồng đầy đủ : từ tin nhắn tới tới phản hồi, qua 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 đầy đủ :

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 };

  // ... khớp theo tên, keyword, follow-up, random
}

Cache regex (hasWordCache) tránh biên dịch lại pattern mỗi tin nhắn.


Phản ứng

Luna phản ứng tin nhắn bằng emoji. 30% cơ hội dùng emoji custom của máy chủ, 70% emoji unicode. Phản ứng đi sau trễ tập trung, với cùng nhịp lệch như phản hồi. Tôi thích độ lệch này vì bạn cảm thấy cô ấy đã dành thời gian đọc.

Lệnh bằng phản ứng trên tin nhắn của Luna :

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

Kiểu phản hồi

Kiểu phản hồi có trọng số 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%
Nóng true false 50%
Nóng true true 15%
Nóng false false 30%
Nóng false true 5%

Trong MP, messageReference luôn false.


Tin nhắn chùm

Với 15% cơ hội, phản hồi bị cắt thành 2-3 mảnh gửi theo nhịp con người (1.5-4 giây giữa mỗi mảnh). Mô phỏng người gõ nhiều lần.

Timing Gantt -- thời gian chờ thật cho trễ, phản ứng, streaming 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 mỗi 15 phút. Các kiểu hỗ trợ : Playing (0), Streaming (1), Listening (2), Watching (3), Custom (4), Competing (5). Trong lúc ngủ, trạng thái chuyển sang invisible.

dynamic_status_presets:
  - status: online
    text: "với các pixel"
    type: 0       # Playing
  - status: idle
    text: "tiếng ồn trắng"
    type: 2       # Listening

Jitter ngẫu nhiên (×0.5-1.0) tránh xoay đoán được. 10% lần thử bị bỏ để tránh lặp.

Chỉ báo đang gõ

Trước khi gọi LLM, Luna gọi startTyping(). setInterval làm mới chỉ báo mỗi 8 giây trong lúc sinh. Dọn 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 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 lũy thừa. Tránh vòng khởi động lại vô hạn.

Tham số LLM

Tham số ghi cứng 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 dùng. Số luồng tự phát hiện qua os.cpus().length.


Cài đặt

npm install
cp config.example.yml config.yml
# sửa config.yml
npm run dev                    # dev (hot reload)
npm run build && npm start     # production
Script Mô tả
build Bundle CLI độc lập
start Chạy bot
lint / format / check Biome
test Test (Bun)
download-model GGUF từ HuggingFace
diagrams Xuất sơ đồ Mermaid sang SVG/PNG

Triển khai PM2

./start.sh   # chạy llm-server + llm-client dưới PM2

Kết luận

Luna Protocol đi xa hơn bot Discord gắn LLM. Đó là hệ thống hành vi đầy đủ mô phỏng sự không hoàn hảo con người : quên lãng, lỗi gõ phím, giấc ngủ, ngập ngừng, mệt mỏi. Tất cả kiến trúc quanh bus sự kiện có kiểu, với 24 sơ đồ Mermaid ghi lại mỗi luồng.

Mã open source, dataset công khai, và cấu hình hot-reload. Nếu chủ đề khiến bạn hứng thú, hãy lặn vào mã : nó dễ tiếp cận hơn vẻ ngoài. Tôi đã dùng nó làm nền để thử nhiều ý tưởng về hành vi, và bạn có thể lấy lại từng module riêng.

Tài nguyên Link
Repo 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, ระบบการตัดสินใจ, คอมโพเนนต์ความเป็นมนุษย์, และชุดข้อมูลสำหรับ 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