Lumi/src/services/youtube.js
2026-06-10 18:57:38 +02:00

293 lines
7.5 KiB
JavaScript

const { getSetting, setSetting } = require("./settings");
const { incrementMessages } = require("./stats");
const { ensureUserForIdentity } = require("./users");
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,
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) {
console.error("YouTube bot failed to load channel details", error);
}
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) => {
console.error("YouTube chat poll failed", error);
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;
}
if (state.channelId && author.channelId === state.channelId) {
return;
}
const displayName = author.displayName || "YouTube User";
const avatar = author.profileImageUrl || null;
const profile = ensureUserForIdentity({
provider: "youtube",
providerUserId: author.channelId,
displayName,
avatar
});
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,
messageId: item.id,
snippet,
author
},
reply: async (content) => {
try {
await sendChatMessage(state, liveChatId, content);
} catch (error) {
console.error("YouTube command reply failed", error);
}
}
});
}
async function ensureLiveChatId(state) {
const liveChatId = state.liveChatId || (await findActiveLiveChatId(state));
if (liveChatId && liveChatId !== state.liveChatId) {
state.liveChatId = liveChatId;
state.nextPageToken = null;
state.skipHistory = true;
}
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 findActiveLiveChatId(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];
return broadcast?.snippet?.liveChatId || 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
};