src/bot/bot.loop.test.ts

import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; import { ClankerBot } from "../bot.ts"; import { buildStreamKey, createGoLiveStreamState } from "../selfbot/streamDiscovery.ts"; import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createTestSettingsPatch } from "../testSettings.ts";

async function withTempStore(run) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-bot-loop-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

try { await run(store); } finally { store.close(); await rmTempDir(dir); } }

async function waitForCondition(check, { timeoutMs = 7000, intervalMs = 20 } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await check()) return; await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error("timed out waiting for condition"); }

test("message/reaction loops cover ingest, read context, reaction, and reply", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "chan-1"; const botUserId = "bot-1"; const incomingMessageId = "msg-100"; const botReplyMessageId = "bot-msg-0";

store.patchSettings(createTestSettingsPatch({
  interaction: {
    activity: {
      minSecondsBetweenMessages: 0,
      replyCoalesceWindowSeconds: 0,
      replyCoalesceMaxMessages: 1
    }
  },
  permissions: {
    replies: {
      allowReplies: true,
      allowUnsolicitedReplies: false,
      allowReactions: true,
      replyChannelIds: [],
      allowedChannelIds: [channelId],
      blockedChannelIds: [],
      blockedUserIds: [],
      maxMessagesPerHour: 100,
      maxReactionsPerHour: 100
    }
  },
  memory: {
    enabled: true,
    promptSlice: {
      maxRecentMessages: 10
    }
  },
  agentStack: {
    runtimeConfig: {
      research: {
        enabled: false,
        maxSearchesPerHour: 0
      }
    }
  },
  media: {
    videoContext: {
      enabled: false,
      maxLookupsPerHour: 0
    }
  },
  initiative: {
    discovery: {
      allowReplyImages: false,
      allowReplyVideos: false,
      allowReplyGifs: false
    }
  }
}));

const memoryIngestCalls = [];
const llmCalls = [];
const reactionCalls = [];
const replyPayloads = [];
const channelSendPayloads = [];
let typingCalls = 0;

const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: {
    async generate(payload) {
      llmCalls.push(payload);
      return {
        text: JSON.stringify({
          text: "bet",
          skip: false,
          reactionEmoji: "🔥",
          media: null,
          webSearchQuery: null,
          memoryLookupQuery: null,
          memoryLine: null,
          automationAction: {
            operation: "none"
          },
          voiceIntent: {
            intent: "none",
            confidence: 0,
            reason: null
          }
        }),
        provider: "test",
        model: "test-model",
        usage: null,
        costUsd: 0
      };
    }
  },
  memory: {
    async ingestMessage(payload) {
      memoryIngestCalls.push(payload);
      return true;
    },
    loadFactProfile() {
      return {
        userFacts: [],
        relevantFacts: [],
        relevantMessages: []
      };
    }
  },
  discovery: null,
  search: null,
  gifs: null,
  video: null
});

bot.client.user = {
  id: botUserId,
  username: "clanky",
  tag: "clanky#0001"
};

const guild = {
  id: guildId,
  emojis: {
    cache: {
      map() {
        return [];
      }
    }
  },
  members: {
    cache: new Map()
  }
};

const channel = {
  id: channelId,
  guildId,
  name: "general",
  guild,
  isTextBased() {
    return true;
  },
  async sendTyping() {
    typingCalls += 1;
  },
  async send(payload) {
    channelSendPayloads.push(payload);
    return {
      id: "bot-msg-standalone",
      createdTimestamp: Date.now(),
      guildId,
      channelId,
      content: String(payload?.content || ""),
      attachments: new Map(),
      embeds: []
    };
  }
};

store.recordMessage({
  messageId: "msg-context-1",
  createdAt: Date.now() - 1200,
  guildId,
  channelId,
  authorId: "user-2",
  authorName: "bob",
  isBot: false,
  content: "older context line",
  referencedMessageId: null
});

const botReplyMessage = {
  id: botReplyMessageId,
  createdTimestamp: Date.now() - 100,
  guildId,
  channelId,
  guild,
  channel,
  author: {
    id: botUserId,
    username: "clanky",
    bot: true
  },
  member: {
    displayName: "clanky"
  },
  content: "bet",
  reference: {
    messageId: incomingMessageId
  },
  attachments: new Map(),
  embeds: [],
  reactions: {
    cache: new Map([
      [
        "fire",
        {
          count: 1,
          emoji: {
            id: null,
            name: "🔥"
          }
        }
      ]
    ])
  }
};

store.recordMessage({
  messageId: botReplyMessageId,
  createdAt: botReplyMessage.createdTimestamp,
  guildId,
  channelId,
  authorId: botUserId,
  authorName: "clanky",
  isBot: true,
  content: "bet",
  referencedMessageId: incomingMessageId
});

const incomingMessage = {
  id: incomingMessageId,
  createdTimestamp: Date.now(),
  guildId,
  channelId,
  guild,
  channel,
  author: {
    id: "user-1",
    username: "alice",
    bot: false
  },
  member: {
    displayName: "alice"
  },
  content: "clanky, weigh in on this",
  mentions: {
    users: {
      has(userId) {
        return String(userId || "") === botUserId;
      }
    },
    repliedUser: null
  },
  reference: null,
  attachments: new Map(),
  embeds: [],
  reactions: {
    cache: new Map([
      [
        "fire",
        {
          count: 2,
          emoji: {
            id: null,
            name: "🔥"
          }
        }
      ]
    ])
  },
  async react(emoji) {
    reactionCalls.push(emoji);
  },
  async reply(payload) {
    replyPayloads.push(payload);
    return {
      id: "bot-reply-1",
      createdTimestamp: Date.now(),
      guildId,
      channelId,
      content: String(payload?.content || ""),
      attachments: new Map(),
      embeds: []
    };
  }
};

const reactionEvent = {
  partial: false,
  message: botReplyMessage,
  emoji: {
    id: null,
    name: "🔥"
  }
};

const reactingUser = {
  id: "user-1",
  username: "alice",
  globalName: null,
  bot: false
};

try {
  bot.client.emit("messageReactionAdd", reactionEvent, reactingUser);
  await waitForCondition(() => {
    const rows = store.getRecentMessages(channelId, 20);
    const reactionRow = rows.find((item) => String(item.message_id).startsWith("reaction:"));
    return Boolean(reactionRow?.content?.includes("alice reacted with 🔥 to clanky's message"));
  });

  bot.client.emit("messageCreate", incomingMessage);
  await waitForCondition(() => (replyPayloads.length + channelSendPayloads.length) === 1 && bot.getReplyQueuePendingCount() === 0);

  assert.equal(memoryIngestCalls.length, 2);
  const userIngest = memoryIngestCalls.find((entry) => entry?.messageId === incomingMessageId);
  const botIngest = memoryIngestCalls.find((entry) => entry?.isBot === true);
  assert.equal(userIngest?.messageId, incomingMessageId);
  assert.equal(botIngest?.authorId, botUserId);
  assert.equal(botIngest?.isBot, true);
  assert.equal(botIngest?.trace?.source, "text_reply_memory_ingest");
  assert.match(String(llmCalls[0]?.userPrompt || ""), /alice reacted with 🔥 to clanky's message: "bet"/);
  assert.equal(reactionCalls.length, 1);
  assert.equal(reactionCalls[0], "🔥");
  assert.equal(replyPayloads.length + channelSendPayloads.length, 1);
  assert.equal(typingCalls > 0, true);
  assert.equal(store.hasTriggeredResponse(incomingMessageId), true);

  const since = new Date(Date.now() - 60 * 60 * 1000).toISOString();
  assert.equal(store.countActionsSince("reacted", since), 1);
  assert.equal(store.countActionsSince("sent_reply", since) + store.countActionsSince("sent_message", since), 1);
} finally {
  await bot.stop();
}

}); }, 15_000);

test("stream discovery seeds session Go Live target before credentials arrive", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const voiceChannelId = "voice-1"; const targetUserId = "user-1"; const streamKey = guild:${guildId}:${voiceChannelId}:${targetUserId};

const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: null,
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});

bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const session = {
  id: "session-1",
  guildId,
  textChannelId: "text-1",
  voiceChannelId,
  mode: "openai_realtime",
  ending: false,
  goLiveStream: createGoLiveStreamState()
};
bot.voiceSessionManager.sessions.set(guildId, session as never);

try {
  bot.client.emit("clientReady", bot.client);
  bot.client.emit("raw", {
    t: "STREAM_CREATE",
    d: {
      stream_key: streamKey,
      rtc_server_id: "rtc-1",
      region: "us-east"
    }
  });

  await waitForCondition(() => session.goLiveStream.targetUserId === targetUserId);

  assert.equal(session.goLiveStream.active, false);
  assert.equal(session.goLiveStream.targetUserId, targetUserId);
  assert.equal(session.goLiveStream.streamKey, streamKey);
  assert.equal(session.goLiveStream.channelId, voiceChannelId);
  assert.equal(session.goLiveStream.guildId, guildId);
  assert.equal(session.goLiveStream.rtcServerId, "rtc-1");
} finally {
  await bot.stop();
}

}); });

test("stream discovery seeds provisional session Go Live target from self_stream before stream creation arrives", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const voiceChannelId = "voice-1"; const targetUserId = "user-1"; const streamKey = buildStreamKey(guildId, voiceChannelId, targetUserId);

const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: null,
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});

bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const session = {
  id: "session-1",
  guildId,
  textChannelId: "text-1",
  voiceChannelId,
  mode: "openai_realtime",
  ending: false,
  goLiveStream: createGoLiveStreamState()
};
bot.voiceSessionManager.sessions.set(guildId, session as never);

try {
  bot.client.emit("clientReady", bot.client);
  bot.client.emit("raw", {
    t: "VOICE_STATE_UPDATE",
    d: {
      user_id: targetUserId,
      guild_id: guildId,
      channel_id: voiceChannelId,
      self_stream: true
    }
  });

  await waitForCondition(() => session.goLiveStream.targetUserId === targetUserId);

  assert.equal(session.goLiveStream.active, false);
  assert.equal(session.goLiveStream.targetUserId, targetUserId);
  assert.equal(session.goLiveStream.streamKey, streamKey);
  assert.equal(session.goLiveStream.channelId, voiceChannelId);
  assert.equal(session.goLiveStream.guildId, guildId);
  assert.equal(session.goLiveStream.rtcServerId, null);

  await waitForCondition(() => store.getRecentActions(20, { kinds: ["stream_discovery"] }).some((entry) =>
    String(entry.content || "").includes("stream_discovery_go_live_bootstrap_seeded")
  ));

  const seededLog = store.getRecentActions(20, { kinds: ["stream_discovery"] }).find((entry) =>
    String(entry.content || "").includes("stream_discovery_go_live_bootstrap_seeded")
  );
  assert.equal(seededLog?.metadata?.streamKey, streamKey);
} finally {
  await bot.stop();
}

}); });

test("stream discovery clears provisional Go Live target when self_stream ends before credentials arrive", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const voiceChannelId = "voice-1"; const targetUserId = "user-1"; const streamKey = buildStreamKey(guildId, voiceChannelId, targetUserId);

const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: null,
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});

bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const session = {
  id: "session-1",
  guildId,
  textChannelId: "text-1",
  voiceChannelId,
  mode: "openai_realtime",
  ending: false,
  goLiveStream: createGoLiveStreamState()
};
bot.voiceSessionManager.sessions.set(guildId, session as never);

try {
  bot.client.emit("clientReady", bot.client);
  bot.client.emit("raw", {
    t: "VOICE_STATE_UPDATE",
    d: {
      user_id: targetUserId,
      guild_id: guildId,
      channel_id: voiceChannelId,
      self_stream: true
    }
  });
  await waitForCondition(() => session.goLiveStream.targetUserId === targetUserId);

  bot.client.emit("raw", {
    t: "VOICE_STATE_UPDATE",
    d: {
      user_id: targetUserId,
      guild_id: guildId,
      channel_id: voiceChannelId,
      self_stream: false
    }
  });

  await waitForCondition(() => session.goLiveStream.targetUserId === null);

  assert.equal(session.goLiveStream.active, false);
  assert.equal(session.goLiveStream.streamKey, null);
  assert.equal(session.goLiveStream.guildId, null);
  assert.equal(session.goLiveStream.channelId, null);

  await waitForCondition(() => store.getRecentActions(20, { kinds: ["stream_discovery"] }).some((entry) =>
    String(entry.content || "").includes("stream_discovery_go_live_bootstrap_cleared")
  ));

  const clearedLog = store.getRecentActions(20, { kinds: ["stream_discovery"] }).find((entry) =>
    String(entry.content || "").includes("stream_discovery_go_live_bootstrap_cleared")
  );
  assert.equal(clearedLog?.metadata?.streamKey, streamKey);
  assert.equal(clearedLog?.metadata?.reason, "voice_state_self_stream_false");
} finally {
  await bot.stop();
}

}); });

test("stream discovery preserves multiple Go Live users instead of overwriting the first one", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const voiceChannelId = "voice-1"; const firstUserId = "user-1"; const secondUserId = "user-2";

const bot = new ClankerBot({
  appConfig: {},
  store,
  llm: null,
  memory: null,
  discovery: null,
  search: null,
  gifs: null,
  video: null
});

bot.client.user = {
  id: "bot-1",
  username: "clanky",
  tag: "clanky#0001"
};

const session = {
  id: "session-1",
  guildId,
  textChannelId: "text-1",
  voiceChannelId,
  mode: "openai_realtime",
  ending: false,
  goLiveStream: createGoLiveStreamState(),
  goLiveStreams: new Map()
};
bot.voiceSessionManager.sessions.set(guildId, session as never);

try {
  bot.client.emit("clientReady", bot.client);
  for (const userId of [firstUserId, secondUserId]) {
    bot.client.emit("raw", {
      t: "VOICE_STATE_UPDATE",
      d: {
        user_id: userId,
        guild_id: guildId,
        channel_id: voiceChannelId,
        self_stream: true
      }
    });
  }

  await waitForCondition(() => session.goLiveStreams.size === 2);

  assert.equal(session.goLiveStreams.size, 2);
  assert.equal(session.goLiveStreams.has(buildStreamKey(guildId, voiceChannelId, firstUserId)), true);
  assert.equal(session.goLiveStreams.has(buildStreamKey(guildId, voiceChannelId, secondUserId)), true);
} finally {
  await bot.stop();
}

}); });