Lumi/src/services/youtube.js
2026-07-19 15:33:20 +02:00

332 lines
9.1 KiB
JavaScript

const { getSetting, setSetting } = require("./settings");
const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users");
const { createLogger } = require("./logger");
const { publishOverlayChatMessage } = require("./overlay-chat");
const youtubeLog = createLogger("platform:youtube", { category: "integration" });
let client = null;
let pollTimer = null;
async function startYouTubeBot({ commandRouter } = {}) {
const clientId = getSetting("youtube_client_id");
const clientSecret = getSetting("youtube_client_secret");
const refreshToken = getSetting("youtube_bot_refresh_token");
if (!clientId || !clientSecret || !refreshToken) {
return null;
}
const state = {
clientId,
clientSecret,
refreshToken,
accessToken: null,
accessTokenExpiresAt: 0,
channelId: getSetting("youtube_bot_channel_id", "") || null,
channelName: null,
channelAvatar: null,
liveChatId: null,
activeVideoId: null,
liveStateUpdatedAt: 0,
nextPageToken: null,
skipHistory: true,
stopped: false,
commandRouter
};
state.sendMessage = async (content) => {
const liveChatId = await ensureLiveChatId(state);
if (!liveChatId) {
throw new Error("No active YouTube live chat is available.");
}
return sendChatMessage(state, liveChatId, content);
};
client = state;
try {
await hydrateBotChannel(state);
} catch (error) {
youtubeLog.error("YouTube bot failed to load channel details", error, { event: "channel_load_failed" });
}
schedulePoll(state, 1000);
return state;
}
async function stopYouTubeBot() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (client) {
client.stopped = true;
}
client = null;
}
function getClient() {
return client;
}
function schedulePoll(state, delayMs) {
if (state.stopped) {
return;
}
if (pollTimer) {
clearTimeout(pollTimer);
}
pollTimer = setTimeout(() => {
pollTimer = null;
pollLiveChat(state).catch((error) => {
youtubeLog.error("YouTube chat poll failed", error, { event: "poll_failed" });
schedulePoll(state, 10000);
});
}, delayMs);
}
async function pollLiveChat(state) {
if (state.stopped) {
return;
}
const liveChatId = await ensureLiveChatId(state);
if (!liveChatId) {
schedulePoll(state, 30000);
return;
}
const response = await listLiveChatMessages(state, liveChatId, state.nextPageToken);
state.nextPageToken = response.nextPageToken || state.nextPageToken;
if (state.skipHistory) {
state.skipHistory = false;
schedulePoll(state, response.pollingIntervalMillis || 5000);
return;
}
const items = Array.isArray(response.items) ? response.items : [];
for (const item of items) {
await handleChatItem(state, liveChatId, item);
}
schedulePoll(state, response.pollingIntervalMillis || 5000);
}
async function handleChatItem(state, liveChatId, item) {
const snippet = item?.snippet;
const author = item?.authorDetails;
if (!snippet || !author) {
return;
}
const messageText = snippet.displayMessage;
if (!messageText) {
return;
}
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const isSelf = Boolean(state.channelId && author.channelId === state.channelId);
const profile = isSelf ? null : ensureUserForIdentity({
provider: "youtube",
providerUserId: author.channelId,
displayName,
avatar
});
publishOverlayChatMessage({
id: item.id,
platform: "youtube",
text: messageText,
timestamp: Date.parse(snippet.publishedAt) || Date.now(),
channel: {
id: liveChatId,
name: state.channelName || state.activeVideoId || "Live chat",
key: state.activeVideoId || liveChatId
},
author: {
id: author.channelId,
name: displayName,
username: displayName,
avatar,
badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean),
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
}
});
if (isSelf) return;
incrementMessages(profile.id);
if (!state.commandRouter) {
return;
}
await state.commandRouter.handleMessage({
platform: "youtube",
raw: messageText,
user: profile,
platformUser: {
id: author.channelId,
displayName,
username: displayName
},
meta: {
liveChatId,
activeVideoId: state.activeVideoId,
broadcasterChannelId: state.channelId,
messageId: item.id,
snippet,
author
},
reply: async (content) => {
try {
await sendChatMessage(state, liveChatId, content);
} catch (error) {
youtubeLog.error("YouTube command reply failed", error, { event: "reply_failed" });
}
}
});
}
async function ensureLiveChatId(state) {
const cacheExpired = Date.now() - (state.liveStateUpdatedAt || 0) >= 60 * 1000;
let liveChatId = state.liveChatId;
if (!liveChatId || cacheExpired) {
const broadcast = await findActiveBroadcast(state);
liveChatId = broadcast?.liveChatId || null;
state.activeVideoId = broadcast?.videoId || null;
state.liveStateUpdatedAt = Date.now();
}
if (liveChatId && liveChatId !== state.liveChatId) {
state.liveChatId = liveChatId;
state.nextPageToken = null;
state.skipHistory = true;
} else if (!liveChatId) {
state.liveChatId = null;
state.nextPageToken = null;
}
return state.liveChatId;
}
async function hydrateBotChannel(state) {
const channel = await fetchMyChannel(state);
if (!channel) {
return;
}
state.channelId = channel.id;
state.channelName = channel.snippet?.title || null;
state.channelAvatar = channel.snippet?.thumbnails?.default?.url || null;
if (state.channelId) {
setSetting("youtube_bot_channel_id", state.channelId);
}
}
async function ensureAccessToken(state) {
const now = Date.now();
if (state.accessToken && now < state.accessTokenExpiresAt) {
return state.accessToken;
}
const token = await refreshAccessToken(state);
state.accessToken = token.access_token;
const expiresIn = Number(token.expires_in || 0);
state.accessTokenExpiresAt = now + Math.max(30, expiresIn - 60) * 1000;
return state.accessToken;
}
async function refreshAccessToken(state) {
const body = new URLSearchParams({
client_id: state.clientId,
client_secret: state.clientSecret,
refresh_token: state.refreshToken,
grant_type: "refresh_token"
});
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
if (!response.ok) {
throw new Error(`YouTube token refresh failed: ${response.status}`);
}
return response.json();
}
async function fetchMyChannel(state) {
const accessToken = await ensureAccessToken(state);
const response = await fetch(
"https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true",
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) {
throw new Error(`YouTube channel fetch failed: ${response.status}`);
}
const data = await response.json();
return data.items?.[0] || null;
}
async function findActiveBroadcast(state) {
const accessToken = await ensureAccessToken(state);
const response = await fetch(
"https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status&broadcastStatus=active&broadcastType=all",
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) {
return null;
}
const data = await response.json();
const broadcast = data.items?.[0];
if (!broadcast) return null;
return {
liveChatId: broadcast.snippet?.liveChatId || null,
videoId: broadcast.id || null
};
}
async function listLiveChatMessages(state, liveChatId, pageToken) {
const accessToken = await ensureAccessToken(state);
const params = new URLSearchParams({
liveChatId,
part: "snippet,authorDetails",
maxResults: "200"
});
if (pageToken) {
params.set("pageToken", pageToken);
}
const response = await fetch(
`https://www.googleapis.com/youtube/v3/liveChatMessages?${params.toString()}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!response.ok) {
throw new Error(`YouTube chat fetch failed: ${response.status}`);
}
return response.json();
}
async function sendChatMessage(state, liveChatId, messageText) {
const accessToken = await ensureAccessToken(state);
const response = await fetch(
"https://www.googleapis.com/youtube/v3/liveChatMessages?part=snippet",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
snippet: {
liveChatId,
type: "textMessageEvent",
textMessageDetails: {
messageText
}
}
})
}
);
if (!response.ok) {
throw new Error(`YouTube chat send failed: ${response.status}`);
}
return response.json();
}
module.exports = {
startYouTubeBot,
stopYouTubeBot,
getClient
};