293 lines
7.5 KiB
JavaScript
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
|
|
};
|