const tmi = require("tmi.js"); const { getSetting } = require("./settings"); const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); const { publishOverlayChatMessage } = require("./overlay-chat"); const { resolveBetterTtvEmotes, resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets"); const twitchLog = createLogger("platform:twitch", { category: "integration" }); let client = null; function parseChannels(raw) { return (raw || "") .split(/[,\s]+/) .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => (entry.startsWith("#") ? entry : `#${entry}`)); } async function startTwitchBot({ commandRouter } = {}) { const username = getSetting("twitch_bot_username"); const oauth = getSetting("twitch_bot_oauth"); const channels = parseChannels(getSetting("twitch_channels")); if (!username || !oauth || !channels.length) { return null; } const password = oauth.startsWith("oauth:") ? oauth : `oauth:${oauth}`; client = new tmi.Client({ options: { debug: false }, identity: { username, password }, channels }); client.on("connected", (address, port) => { twitchLog.info("Twitch bot connected", { address, port, channels }, { event: "platform_ready" }); }); client.on("message", async (channel, tags, message, self) => { const userId = tags["user-id"] || (self ? `lumi:${username.toLowerCase()}` : ""); if (!userId) { return; } const displayName = tags["display-name"] || tags.username || username; const [badges, avatar, betterTtvEmotes] = await Promise.all([ resolveTwitchBadges(tags["room-id"], tags.badges || {}), resolveTwitchAvatar(tags["user-id"], tags.username || username), resolveBetterTtvEmotes(tags["room-id"], message) ]); const profile = self ? null : ensureUserForIdentity({ provider: "twitch", providerUserId: userId, displayName, avatar }); publishOverlayChatMessage({ id: tags.id, platform: "twitch", text: message, timestamp: Number(tags["tmi-sent-ts"]) || Date.now(), channel: { id: String(tags["room-id"] || ""), name: channel, key: channel }, author: { id: userId, name: displayName, username: tags.username, avatar, color: tags.color, badges, lumi: profile ? { id: profile.id, username: profile.internal_username } : null }, emotes: [ ...Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => { const [start, end] = String(position).split("-").map(Number); return { start, end, image: `https://static-cdn.jtvnw.net/emoticons/v2/${encodeURIComponent(id)}/default/dark/3.0` }; })), ...betterTtvEmotes ] }); if (self) return; incrementMessages(profile.id); if (commandRouter) { await commandRouter.handleMessage({ platform: "twitch", raw: message, user: profile, platformUser: { id: userId, displayName, username: tags.username }, meta: { channel, tags, client }, reply: async (content) => { try { await client.say(channel, content); } catch (error) { twitchLog.error("Twitch command reply failed", error, { event: "reply_failed" }); } } }); } }); await client.connect(); return client; } async function stopTwitchBot() { if (client) { await client.disconnect(); client = null; } } function getClient() { return client; } module.exports = { startTwitchBot, stopTwitchBot, getClient };