Lumi/src/services/twitch.js
2026-07-19 22:14:29 +02:00

128 lines
3.7 KiB
JavaScript

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