diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b4498..d61b0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## v1.3.0 + +### New + +- Add `ErrorAction`, `UnknownAction` +- Add `BotError` + +### Improvements + +- Update from [stu43005/masterchat](https://github.com/stu43005/masterchat) + - Commit: [5e30cf71ee0c9b7739e59cdcf92402f7794b7ee5](https://github.com/stu43005/masterchat/commit/5e30cf71ee0c9b7739e59cdcf92402f7794b7ee5) + - `addBannerToLiveChatCommand` + - `addChatItemAction` +- #1 Updated version of timeoutParams() (@FlashlightXi) +- Update interfaces + - `YTAnyText` + - `YTPlayabilityStatus` +- `MembersOnlyError` should have additional data +- `parseMetadataFromWatch` should handle more edge cases + - Chat can be available even when `playabilityStatus` not exist + - `MembersOnly` chat should be available when stream ended + +## v1.2.0 + +### New + +- Add `isUpcoming` +- Add `isMembersOnly` +- Add `videoMetadata` following format [VideoObject](./src/interfaces/yt/metadata.ts) + +### Improvements + +- Add more field on `Credentials` for members-only live stream + + - `__Secure-1PAPISID` + - `__Secure-1PSID` + - `__Secure-1PSIDTS` + - `__Secure-1PSIDCC` + +- Error parsing action `parseAction` should not crash +- Change access modifier of `get`, `post`, `postWithRetry` to `protected` + +### Fixes + +- Milestone message duration parsing error +- Question text from `parseShowLiveChatActionPanelAction` realted to poll + ## v1.1.0 ### New diff --git a/MANUAL.md b/MANUAL.md index 07bc759..62b8b5f 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -113,7 +113,7 @@ await mc .iter() .filter((action) => action.type === "addChatItemAction") // only chat events .map((chat) => JSON.stringify(chat) + "\n") // convert to JSONL - .forEach((jsonl) => appendFile("./chats.jsonl", jsonl)) // append to the file + .forEach((jsonl) => appendFile("./chats.jsonl", jsonl)); // append to the file ``` ### Chat moderation bot @@ -248,7 +248,7 @@ const mc = await Masterchat.init("", { axiosInstance }); ## Reference -[API Documentation](https://holodata.github.io/masterchat) +[API Documentation](https://sigvt.github.io/masterchat) ### Action type diff --git a/README.md b/README.md index 6fa656f..42794b2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Masterchat -[![npm](https://badgen.net/npm/v/masterchat)](https://npmjs.org/package/masterchat) -[![npm: total downloads](https://badgen.net/npm/dt/masterchat)](https://npmjs.org/package/masterchat) -[![npm: publish size](https://badgen.net/packagephobia/publish/masterchat)](https://npmjs.org/package/masterchat) -[![typedoc](https://badgen.net/badge/docs/typedoc/purple)](https://holodata.github.io/masterchat/) +[![npm](https://badgen.net/npm/v/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) +[![npm: total downloads](https://badgen.net/npm/dt/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) +[![npm: publish size](https://badgen.net/packagephobia/publish/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) +[![typedoc](https://badgen.net/badge/docs/typedoc/purple)](https://sigvt.github.io/masterchat/) Masterchat is the most powerful library for YouTube Live Chat, supporting parsing 20+ actions, video comments and transcripts, as well as sending messages and moderating chats. ## Install ``` -npm install masterchat +npm install @hitomaru/masterchat ``` ```js -import { Masterchat, stringify } from "masterchat"; +import { Masterchat, stringify } from "@hitomaru/masterchat"; const mc = await Masterchat.init("oyxvhJW1Cf8"); diff --git a/package.json b/package.json index 551fed5..0f91caf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "masterchat", + "name": "@hitomaru/masterchat", "description": "JavaScript library for YouTube Live Chat", - "version": "1.1.0", + "version": "1.3.0", "author": "Yasuaki Uechi (https://uechi.io/)", "scripts": { "build": "cross-env NODE_ENV=production rollup -c && shx rm -rf lib/lib lib/*.map && patch lib/masterchat.d.ts patches/fix-dts.patch", @@ -30,6 +30,7 @@ ], "dependencies": { "axios": "^0.27.2", + "cheerio": "^1.0.0-rc.12", "debug": "^4.3.2", "iterator-helpers-polyfill": "^2.2.8", "sha1": "^1.1.1" @@ -64,13 +65,13 @@ "typedoc": "^0.22.16", "typescript": "^4.7.2" }, - "homepage": "https://github.com/holodata/masterchat", + "homepage": "https://github.com/HitomaruKonpaku/masterchat", "repository": { "type": "git", - "url": "https://github.com/holodata/masterchat.git" + "url": "git+https://github.com/HitomaruKonpaku/masterchat.git" }, "bugs": { - "url": "https://github.com/holodata/masterchat/issues" + "url": "https://github.com/HitomaruKonpaku/masterchat/issues" }, "license": "Apache-2.0", "keywords": [ diff --git a/src/chat/actions/addBannerToLiveChatCommand.ts b/src/chat/actions/addBannerToLiveChatCommand.ts index a6c7b41..86d4a53 100644 --- a/src/chat/actions/addBannerToLiveChatCommand.ts +++ b/src/chat/actions/addBannerToLiveChatCommand.ts @@ -1,9 +1,14 @@ +import { toUnknownAction } from ".."; import { AddBannerAction, - AddRedirectBannerAction, + AddIncomingRaidBannerAction, + AddOutgoingRaidBannerAction, + AddProductBannerAction, + AddChatSummaryBannerAction, + AddCallForQuestionsBannerAction, } from "../../interfaces/actions"; import { YTAddBannerToLiveChatCommand } from "../../interfaces/yt/chat"; -import { debugLog, stringify, tsToDate } from "../../utils"; +import { debugLog, endpointToUrl, stringify, tsToDate } from "../../utils"; import { parseBadges } from "../badge"; import { pickThumbUrl } from "../utils"; @@ -13,20 +18,12 @@ export function parseAddBannerToLiveChatCommand( // add pinned item const bannerRdr = payload["bannerRenderer"]["liveChatBannerRenderer"]; - if ( - bannerRdr.header && - bannerRdr.header.liveChatBannerHeaderRenderer.icon.iconType !== "KEEP" - ) { - debugLog( - "[action required] Unknown icon type (addBannerToLiveChatCommand)", - JSON.stringify(bannerRdr.header) - ); - } - // banner const actionId = bannerRdr.actionId; const targetId = bannerRdr.targetId; const viewerIsCreator = bannerRdr.viewerIsCreator; + const isStackable = bannerRdr.isStackable; + const bannerType = bannerRdr.bannerType; // contents const contents = bannerRdr.contents; @@ -40,7 +37,7 @@ export function parseAddBannerToLiveChatCommand( const authorName = stringify(rdr.authorName); const authorPhoto = pickThumbUrl(rdr.authorPhoto); const authorChannelId = rdr.authorExternalChannelId; - const { isVerified, isOwner, isModerator, membership } = parseBadges(rdr); + const badges = parseBadges(rdr); // header const header = bannerRdr.header!.liveChatBannerHeaderRenderer; @@ -65,33 +62,137 @@ export function parseAddBannerToLiveChatCommand( authorName, authorPhoto, authorChannelId, - isVerified, - isOwner, - isModerator, - membership, + ...badges, viewerIsCreator, contextMenuEndpointParams: rdr.contextMenuEndpoint?.liveChatItemContextMenuEndpoint.params, }; return parsed; - } else if ("liveChatBannerRedirectRenderer" in contents) { - // TODO: + } + + if ("liveChatBannerRedirectRenderer" in contents) { const rdr = contents.liveChatBannerRedirectRenderer; - const authorName = rdr.bannerMessage.runs[0].text; + const targetVideoId = + "watchEndpoint" in rdr.inlineActionButton.buttonRenderer.command + ? rdr.inlineActionButton.buttonRenderer.command.watchEndpoint.videoId + : undefined; + + const photo = pickThumbUrl(rdr.authorPhoto); + + if (targetVideoId) { + // Outgoing + const targetName = rdr.bannerMessage.runs[1].text; + const payload: AddOutgoingRaidBannerAction = { + type: "addOutgoingRaidBannerAction", + actionId, + targetId, + targetName, + targetPhoto: photo, + targetVideoId, + }; + return payload; + } else { + // Incoming + const sourceName = rdr.bannerMessage.runs[0].text; + const payload: AddIncomingRaidBannerAction = { + type: "addIncomingRaidBannerAction", + actionId, + targetId, + sourceName, + sourcePhoto: photo, + }; + return payload; + } + } + + if ("liveChatProductItemRenderer" in contents) { + const rdr = contents.liveChatProductItemRenderer; + const title = rdr.title; + const description = rdr.accessibilityTitle; + const thumbnail = rdr.thumbnail.thumbnails[0].url; + const price = rdr.price; + const vendorName = rdr.vendorName; + const creatorMessage = rdr.creatorMessage; + const creatorName = rdr.creatorName; const authorPhoto = pickThumbUrl(rdr.authorPhoto); - const payload: AddRedirectBannerAction = { - type: "addRedirectBannerAction", + const url = endpointToUrl(rdr.onClickCommand)!; + + if (!url) { + debugLog( + `Empty url at liveChatProductItemRenderer: ${JSON.stringify(rdr)}` + ); + } + + const dialogMessage = + rdr.informationDialog.liveChatDialogRenderer.dialogMessages; + const isVerified = rdr.isVerified; + + const payload: AddProductBannerAction = { + type: "addProductBannerAction", actionId, targetId, - authorName, + viewerIsCreator, + isStackable, + title, + description, + thumbnail, + price, + vendorName, + creatorMessage, + creatorName, authorPhoto, + url, + dialogMessage, + isVerified, }; return payload; - } else { - throw new Error( - `[action required] Unrecognized content type found in parseAddBannerToLiveChatCommand: ${JSON.stringify( - payload - )}` - ); } + + if ("liveChatCallForQuestionsRenderer" in contents) { + const rdr = contents.liveChatCallForQuestionsRenderer; + const creatorAvatar = rdr.creatorAvatar.thumbnails[0].url; + const creatorAuthorName = stringify(rdr.creatorAuthorName); + const questionMessage = rdr.questionMessage.runs; + + const parsed: AddCallForQuestionsBannerAction = { + type: "addCallForQuestionsBannerAction", + actionId, + targetId, + isStackable, + bannerType, + creatorAvatar, + creatorAuthorName, + questionMessage, + }; + return parsed; + } + + if ("liveChatBannerChatSummaryRenderer" in contents) { + const rdr = contents.liveChatBannerChatSummaryRenderer; + const id = rdr.liveChatSummaryId; + const timestampUsec = id.split("_").at(-1)!; + const timestamp = tsToDate(timestampUsec); + const chatSummary = rdr.chatSummary.runs; + + const parsed: AddChatSummaryBannerAction = { + type: "addChatSummaryBannerAction", + id, + actionId, + targetId, + isStackable, + bannerType, + timestampUsec, + timestamp, + chatSummary, + }; + return parsed; + } + + debugLog( + `[action required] Unrecognized content type found in parseAddBannerToLiveChatCommand: ${JSON.stringify( + payload + )}` + ); + + return toUnknownAction(payload); } diff --git a/src/chat/actions/addChatItemAction.ts b/src/chat/actions/addChatItemAction.ts index 728ae46..790c1ed 100644 --- a/src/chat/actions/addChatItemAction.ts +++ b/src/chat/actions/addChatItemAction.ts @@ -1,19 +1,24 @@ +import { toUnknownAction } from ".."; import { AddChatItemAction, AddMembershipItemAction, AddMembershipMilestoneItemAction, AddPlaceholderItemAction, + AddPollResultAction, AddSuperChatItemAction, AddSuperStickerItemAction, AddViewerEngagementMessageAction, LiveChatMode, - ModeChangeAction, - AddPollResultAction, MembershipGiftPurchaseAction, - MembershipGiftRedemptionAction, MembershipGiftPurchaseTickerContent, + MembershipGiftRedemptionAction, + ModeChangeAction, ModerationMessageAction, -} from "../../interfaces/actions"; + YTLiveChatTextMessageRenderer, + YTRun, + YTRunContainer, + YTTextRun, +} from "../../interfaces"; import { YTAddChatItemAction, YTLiveChatMembershipItemRenderer, @@ -24,11 +29,7 @@ import { YTLiveChatPlaceholderItemRenderer, YTLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer, YTLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer, - YTLiveChatTextMessageRenderer, YTLiveChatViewerEngagementMessageRenderer, - YTRun, - YTRunContainer, - YTTextRun, } from "../../interfaces/yt/chat"; import { debugLog, @@ -37,64 +38,73 @@ import { stringify, tsToDate, } from "../../utils"; -import { parseBadges, parseMembership } from "../badge"; -import { parseAmountText, parseSuperChat } from "../superchat"; -import { parseColorCode, pickThumbUrl } from "../utils"; +import { parseBadges } from "../badge"; +import { parseSuperChat } from "../superchat"; +import { pickThumbUrl, unitsToNumber } from "../utils"; export function parseAddChatItemAction(payload: YTAddChatItemAction) { const { item } = payload; - if ("liveChatTextMessageRenderer" in item) { - // Chat - const renderer = item["liveChatTextMessageRenderer"]!; - return parseLiveChatTextMessageRenderer(renderer); - } else if ("liveChatPaidMessageRenderer" in item) { - // Super Chat - const renderer = item["liveChatPaidMessageRenderer"]!; - return parseLiveChatPaidMessageRenderer(renderer); - } else if ("liveChatPaidStickerRenderer" in item) { - // Super Sticker - const renderer = item["liveChatPaidStickerRenderer"]!; - return parseLiveChatPaidStickerRenderer(renderer); - } else if ("liveChatMembershipItemRenderer" in item) { - // Membership updates - const renderer = item["liveChatMembershipItemRenderer"]!; - return parseLiveChatMembershipItemRenderer(renderer); - } else if ("liveChatViewerEngagementMessageRenderer" in item) { - // Engagement message - const renderer = item["liveChatViewerEngagementMessageRenderer"]!; - return parseLiveChatViewerEngagementMessageRenderer(renderer); - } else if ("liveChatPlaceholderItemRenderer" in item) { - // Placeholder chat - const renderer = item["liveChatPlaceholderItemRenderer"]!; - return parseLiveChatPlaceholderItemRenderer(renderer); - } else if ("liveChatModeChangeMessageRenderer" in item) { - // Mode change message (e.g. toggle members-only) - const renderer = item["liveChatModeChangeMessageRenderer"]!; - return parseLiveChatModeChangeMessageRenderer(renderer); - } else if ("liveChatSponsorshipsGiftPurchaseAnnouncementRenderer" in item) { - // Sponsorships gift purchase announcement - const renderer = - item["liveChatSponsorshipsGiftPurchaseAnnouncementRenderer"]; - return parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( - renderer - ) as MembershipGiftPurchaseAction; - } else if ("liveChatSponsorshipsGiftRedemptionAnnouncementRenderer" in item) { - // Sponsorships gift purchase announcement - const renderer = - item["liveChatSponsorshipsGiftRedemptionAnnouncementRenderer"]; - return parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( - renderer + const parsedAction = (() => { + if ("liveChatTextMessageRenderer" in item) { + // Chat + const renderer = item["liveChatTextMessageRenderer"]!; + return parseLiveChatTextMessageRenderer(renderer); + } else if ("liveChatPaidMessageRenderer" in item) { + // Super Chat + const renderer = item["liveChatPaidMessageRenderer"]!; + return parseLiveChatPaidMessageRenderer(renderer); + } else if ("liveChatPaidStickerRenderer" in item) { + // Super Sticker + const renderer = item["liveChatPaidStickerRenderer"]!; + return parseLiveChatPaidStickerRenderer(renderer); + } else if ("liveChatMembershipItemRenderer" in item) { + // Membership updates + const renderer = item["liveChatMembershipItemRenderer"]!; + return parseLiveChatMembershipItemRenderer(renderer); + } else if ("liveChatViewerEngagementMessageRenderer" in item) { + // Engagement message + const renderer = item["liveChatViewerEngagementMessageRenderer"]!; + return parseLiveChatViewerEngagementMessageRenderer(renderer); + } else if ("liveChatPlaceholderItemRenderer" in item) { + // Placeholder chat + const renderer = item["liveChatPlaceholderItemRenderer"]!; + return parseLiveChatPlaceholderItemRenderer(renderer); + } else if ("liveChatModeChangeMessageRenderer" in item) { + // Mode change message (e.g. toggle members-only) + const renderer = item["liveChatModeChangeMessageRenderer"]!; + return parseLiveChatModeChangeMessageRenderer(renderer); + } else if ("liveChatSponsorshipsGiftPurchaseAnnouncementRenderer" in item) { + // Sponsorships gift purchase announcement + const renderer = + item["liveChatSponsorshipsGiftPurchaseAnnouncementRenderer"]; + return parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( + renderer + ) as MembershipGiftPurchaseAction; + } else if ( + "liveChatSponsorshipsGiftRedemptionAnnouncementRenderer" in item + ) { + // Sponsorships gift purchase announcement + const renderer = + item["liveChatSponsorshipsGiftRedemptionAnnouncementRenderer"]; + return parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( + renderer + ); + } else if ("liveChatModerationMessageRenderer" in item) { + const renderer = item["liveChatModerationMessageRenderer"]; + return parseLiveChatModerationMessageRenderer(renderer); + } + })(); + + if (!parsedAction) { + debugLog( + "[action required] Unrecognized chat item renderer type:", + JSON.stringify(item) ); - } else if ("liveChatModerationMessageRenderer" in item) { - const renderer = item["liveChatModerationMessageRenderer"]; - return parseLiveChatModerationMessageRenderer(renderer); + return toUnknownAction(payload); } - debugLog( - "[action required] Unrecognized chat item renderer type:", - JSON.stringify(item) - ); + return parsedAction; } // Chat @@ -116,8 +126,7 @@ export function parseLiveChatTextMessageRenderer( renderer.authorPhoto.thumbnails[renderer.authorPhoto.thumbnails.length - 1] .url; - const { isVerified, isOwner, isModerator, membership } = - parseBadges(renderer); + const badges = parseBadges(renderer); const contextMenuEndpointParams = renderer.contextMenuEndpoint!.liveChatItemContextMenuEndpoint.params; @@ -141,10 +150,7 @@ export function parseLiveChatTextMessageRenderer( authorChannelId, authorPhoto, message, - membership, - isVerified, - isOwner, - isModerator, + ...badges, contextMenuEndpointParams, rawMessage: message, // deprecated }; @@ -172,6 +178,7 @@ export function parseLiveChatPaidMessageRenderer( const message = renderer.message?.runs ?? null; const superchat = parseSuperChat(renderer); + const badges = parseBadges(renderer); const parsed: AddSuperChatItemAction = { type: "addSuperChatItemAction", @@ -183,6 +190,7 @@ export function parseLiveChatPaidMessageRenderer( authorPhoto, message, ...superchat, + ...badges, superchat, // deprecated rawMessage: renderer.message?.runs, // deprecated }; @@ -191,31 +199,30 @@ export function parseLiveChatPaidMessageRenderer( // Super Sticker export function parseLiveChatPaidStickerRenderer( - rdr: YTLiveChatPaidStickerRenderer + renderer: YTLiveChatPaidStickerRenderer ): AddSuperStickerItemAction { - const { timestampUsec, authorExternalChannelId: authorChannelId } = rdr; + const { timestampUsec, authorExternalChannelId: authorChannelId } = renderer; const timestamp = tsToDate(timestampUsec); - const authorName = stringify(rdr.authorName); - const authorPhoto = pickThumbUrl(rdr.authorPhoto); + const authorName = stringify(renderer.authorName); + const authorPhoto = pickThumbUrl(renderer.authorPhoto); if (!authorName) { debugLog( "[action required] empty authorName (super sticker)", - JSON.stringify(rdr) + JSON.stringify(renderer) ); } - const stickerUrl = "https:" + pickThumbUrl(rdr.sticker); - const stickerText = rdr.sticker.accessibility!.accessibilityData.label; - const { amount, currency } = parseAmountText( - rdr.purchaseAmountText.simpleText - ); + const stickerUrl = "https:" + pickThumbUrl(renderer.sticker); + const stickerText = renderer.sticker.accessibility!.accessibilityData.label; + const superchat = parseSuperChat(renderer); + const badges = parseBadges(renderer); const parsed: AddSuperStickerItemAction = { type: "addSuperStickerItemAction", - id: rdr.id, + id: renderer.id, timestamp, timestampUsec, authorName, @@ -223,14 +230,10 @@ export function parseLiveChatPaidStickerRenderer( authorPhoto, stickerUrl, stickerText, - amount, - currency, - stickerDisplayWidth: rdr.stickerDisplayWidth, - stickerDisplayHeight: rdr.stickerDisplayHeight, - moneyChipBackgroundColor: parseColorCode(rdr.moneyChipBackgroundColor), - moneyChipTextColor: parseColorCode(rdr.moneyChipTextColor), - backgroundColor: parseColorCode(rdr.backgroundColor), - authorNameTextColor: parseColorCode(rdr.authorNameTextColor), + stickerDisplayWidth: renderer.stickerDisplayWidth, + stickerDisplayHeight: renderer.stickerDisplayHeight, + ...superchat, + ...badges, }; return parsed; @@ -249,27 +252,17 @@ export function parseLiveChatMembershipItemRenderer( const authorChannelId = renderer.authorExternalChannelId; const authorPhoto = pickThumbUrl(renderer.authorPhoto); - // observed, MODERATOR - // observed, undefined renderer.authorBadges - const membership = renderer.authorBadges - ? parseMembership(renderer.authorBadges[renderer.authorBadges.length - 1]) - : undefined; - if (!membership) { - debugLog( - `missing membership information while parsing neww membership action: ${JSON.stringify( - renderer - )}` - ); - } + const badges = parseBadges(renderer); const isMilestoneMessage = "empty" in renderer || "message" in renderer; if (isMilestoneMessage) { const message = renderer.message ? renderer.message.runs : null; const durationText = renderer - .headerPrimaryText!.runs.slice(1) - .map((r) => r.text) - .join(""); + .headerPrimaryText!.runs.map((r) => r.text) + .join("") + .replace("Member for", "") + .trim(); // duration > membership.since // e.g. 12 months > 6 months const duration = durationToSeconds(durationText); @@ -286,7 +279,7 @@ export function parseLiveChatMembershipItemRenderer( authorName, authorChannelId, authorPhoto, - membership, + ...badges, level, message, duration, @@ -309,7 +302,7 @@ export function parseLiveChatMembershipItemRenderer( authorName, authorChannelId, authorPhoto, - membership, + ...badges, level, }; return parsed; @@ -325,11 +318,8 @@ export function parseLiveChatViewerEngagementMessageRenderer( * POLL: poll result message */ - const { - id, - timestampUsec, - icon: { iconType }, - } = renderer; + const { id, timestampUsec, icon } = renderer; + const { iconType } = icon ?? {}; if ("simpleText" in renderer.message) { debugLog( "[action required] message is simpleText (engagement):", @@ -382,14 +372,19 @@ export function parseLiveChatViewerEngagementMessageRenderer( } else { text.pop(); } - return { text, votePercentage }; + return { + text, + voteRatio: parseFloat(votePercentage) / 100, + votePercentage, + }; }); const parsed: AddPollResultAction = { type: "addPollResultAction", id, question, - total, + total, // deprecated + voteCount: unitsToNumber(total), choices, }; return parsed; @@ -467,6 +462,7 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( const channelName = header.primaryText.runs[3].text; const amount = parseInt(header.primaryText.runs[1].text, 10); const image = header.image.thumbnails[0].url; + const badges = parseBadges(header); if (!authorName) { debugLog( @@ -475,23 +471,13 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( ); } - const membership = parseMembership( - header.authorBadges[header.authorBadges.length - 1] - )!; - - if (!membership) { - debugLog( - "[action required] empty membership (gift purchase)", - JSON.stringify(renderer) - ); - } - if (!timestampUsec || !timestamp) { const tickerContent: MembershipGiftPurchaseTickerContent = { + type: "membershipGiftPurchaseAction", id, channelName, amount, - membership, + ...badges, authorName, authorChannelId, authorPhoto, @@ -507,7 +493,7 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( timestampUsec, channelName, amount, - membership, + ...badges, authorName, authorChannelId, authorPhoto, @@ -528,6 +514,7 @@ export function parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( const authorName = stringify(renderer.authorName); const authorPhoto = pickThumbUrl(renderer.authorPhoto); const senderName = renderer.message.runs[1].text; + const badges = parseBadges(renderer); if (!authorName) { debugLog( @@ -545,6 +532,7 @@ export function parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( authorName, authorChannelId, authorPhoto, + ...badges, }; return parsed; } diff --git a/src/chat/actions/showLiveChatActionPanelAction.ts b/src/chat/actions/showLiveChatActionPanelAction.ts index df9b094..41fa8cb 100644 --- a/src/chat/actions/showLiveChatActionPanelAction.ts +++ b/src/chat/actions/showLiveChatActionPanelAction.ts @@ -3,7 +3,7 @@ import { YTLiveChatPollRenderer, YTShowLiveChatActionPanelAction, } from "../../interfaces/yt/chat"; -import { debugLog } from "../../utils"; +import { debugLog, stringify } from "../../utils"; import { pickThumbUrl } from "../utils"; export function parseShowLiveChatActionPanelAction( @@ -16,13 +16,16 @@ export function parseShowLiveChatActionPanelAction( const rdr = panelRdr.contents.pollRenderer as YTLiveChatPollRenderer; const authorName = rdr.header.pollHeaderRenderer.metadataText.runs[0].text; + const question = + rdr.header.pollHeaderRenderer.pollQuestion?.simpleText || + stringify(rdr.header.pollHeaderRenderer.pollQuestion?.runs || ""); const parsed: ShowPollPanelAction = { type: "showPollPanelAction", targetId: panelRdr.targetId, id: panelRdr.id, choices: rdr.choices, - question: rdr.header.pollHeaderRenderer.pollQuestion?.simpleText, + question, authorName, authorPhoto: pickThumbUrl(rdr.header.pollHeaderRenderer.thumbnail), pollType: rdr.header.pollHeaderRenderer.liveChatPollType, diff --git a/src/chat/actions/updateLiveChatPollAction.ts b/src/chat/actions/updateLiveChatPollAction.ts index 12542bf..9a42c5b 100644 --- a/src/chat/actions/updateLiveChatPollAction.ts +++ b/src/chat/actions/updateLiveChatPollAction.ts @@ -1,5 +1,6 @@ import { UpdatePollAction } from "../../interfaces/actions"; import { YTUpdateLiveChatPollAction } from "../../interfaces/yt/chat"; +import { stringify } from "../../utils"; import { pickThumbUrl } from "../utils"; export function parseUpdateLiveChatPollAction( @@ -19,13 +20,16 @@ export function parseUpdateLiveChatPollAction( const authorName = meta[0].text; const elapsedText = meta[2].text; const voteCount = parseInt(meta[4].text, 10); + const question = + header.pollQuestion?.simpleText || + stringify(header.pollQuestion?.runs || ""); const parsed: UpdatePollAction = { type: "updatePollAction", id: rdr.liveChatPollId, authorName, authorPhoto: pickThumbUrl(header.thumbnail), - question: header.pollQuestion?.simpleText, + question, choices: rdr.choices, elapsedText, voteCount, diff --git a/src/chat/badge.ts b/src/chat/badge.ts index 2fad345..05d73c7 100644 --- a/src/chat/badge.ts +++ b/src/chat/badge.ts @@ -1,9 +1,6 @@ +import { Badges, Membership } from "../interfaces/misc"; +import { YTAuthorBadge } from "../interfaces/yt/chat"; import { debugLog } from "../utils"; -import { - YTAuthorBadge, - YTLiveChatTextMessageRenderer, -} from "../interfaces/yt/chat"; -import { Membership } from "../interfaces/misc"; export function parseMembership(badge: YTAuthorBadge): Membership | undefined { const renderer = badge.liveChatAuthorBadgeRenderer; @@ -24,7 +21,9 @@ export function parseMembership(badge: YTAuthorBadge): Membership | undefined { } } -export function parseBadges(renderer: YTLiveChatTextMessageRenderer) { +export function parseBadges(renderer: { + authorBadges?: YTAuthorBadge[]; +}): Badges { let isVerified = false, isOwner = false, isModerator = false, @@ -54,7 +53,6 @@ export function parseBadges(renderer: YTLiveChatTextMessageRenderer) { iconType, JSON.stringify(renderer) ); - throw new Error("Unrecognized iconType: " + iconType); } } } diff --git a/src/chat/index.ts b/src/chat/index.ts index 6152bf2..f0cfe4d 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -1,4 +1,4 @@ -import { Action, UnknownAction } from "../interfaces/actions"; +import { Action, ErrorAction, UnknownAction } from "../interfaces/actions"; import { YTAction } from "../interfaces/yt/chat"; import { debugLog, omitTrackingParams } from "../utils"; import { parseAddBannerToLiveChatCommand } from "./actions/addBannerToLiveChatCommand"; @@ -16,62 +16,86 @@ import { parseUpdateLiveChatPollAction } from "./actions/updateLiveChatPollActio /** * Parse raw action object and returns Action */ -export function parseAction(action: YTAction): Action | UnknownAction { - const filteredActions = omitTrackingParams(action); - const type = Object.keys(filteredActions)[0] as keyof typeof filteredActions; - - switch (type) { - case "addChatItemAction": { - const parsed = parseAddChatItemAction(action[type]!); - if (parsed) return parsed; - break; - } +export function parseAction(action: YTAction): Action { + try { + const filteredActions = omitTrackingParams(action); + const type = Object.keys( + filteredActions + )[0] as keyof typeof filteredActions; - case "markChatItemsByAuthorAsDeletedAction": - return parseMarkChatItemsByAuthorAsDeletedAction(action[type]!); + switch (type) { + case "addChatItemAction": { + const parsed = parseAddChatItemAction(action[type]!); + if (parsed) return parsed; + break; + } - case "markChatItemAsDeletedAction": - return parseMarkChatItemAsDeletedAction(action[type]!); + case "markChatItemsByAuthorAsDeletedAction": + return parseMarkChatItemsByAuthorAsDeletedAction(action[type]!); - case "addLiveChatTickerItemAction": { - const parsed = parseAddLiveChatTickerItemAction(action[type]!); - if (parsed) return parsed; - break; - } + case "markChatItemAsDeletedAction": + return parseMarkChatItemAsDeletedAction(action[type]!); + + case "addLiveChatTickerItemAction": { + const parsed = parseAddLiveChatTickerItemAction(action[type]!); + if (parsed) return parsed; + break; + } - case "replaceChatItemAction": - return parseReplaceChatItemAction(action[type]!); + case "replaceChatItemAction": + return parseReplaceChatItemAction(action[type]!); - case "addBannerToLiveChatCommand": - return parseAddBannerToLiveChatCommand(action[type]!); + case "addBannerToLiveChatCommand": + return parseAddBannerToLiveChatCommand(action[type]!); - case "removeBannerForLiveChatCommand": - return parseRemoveBannerForLiveChatCommand(action[type]!); + case "removeBannerForLiveChatCommand": + return parseRemoveBannerForLiveChatCommand(action[type]!); - case "showLiveChatTooltipCommand": - return parseShowLiveChatTooltipCommand(action[type]!); + case "showLiveChatTooltipCommand": + return parseShowLiveChatTooltipCommand(action[type]!); - case "showLiveChatActionPanelAction": - const parsed = parseShowLiveChatActionPanelAction(action[type]!); - return parsed; + case "showLiveChatActionPanelAction": + return parseShowLiveChatActionPanelAction(action[type]!); - case "updateLiveChatPollAction": - return parseUpdateLiveChatPollAction(action[type]!); + case "updateLiveChatPollAction": + return parseUpdateLiveChatPollAction(action[type]!); - case "closeLiveChatActionPanelAction": - return parseCloseLiveChatActionPanelAction(action[type]!); + case "closeLiveChatActionPanelAction": + return parseCloseLiveChatActionPanelAction(action[type]!); - default: { - const _: never = type; - debugLog( - "[action required] Unrecognized action type:", - JSON.stringify(action) - ); + default: { + debugLog( + "[action required] Unrecognized action type:", + JSON.stringify(action) + ); + } } + + return toUnknownAction(action); + } catch (error: any) { + debugLog( + "[action required] Error occurred while parsing action:", + error.message || error, + JSON.stringify(action) + ); + return toErrorAction(action, error); } +} +/** + * Unknown action used for unexpected payloads. You should implement an appropriate action parser as soon as you discover this action in the production. + */ +export function toUnknownAction(payload: unknown): UnknownAction { return { type: "unknown", - payload: action, - } as UnknownAction; + payload, + }; +} + +export function toErrorAction(payload: unknown, error: unknown): ErrorAction { + return { + type: "error", + error, + payload, + }; } diff --git a/src/chat/superchat.ts b/src/chat/superchat.ts index 9eef993..b40bb25 100644 --- a/src/chat/superchat.ts +++ b/src/chat/superchat.ts @@ -1,10 +1,14 @@ -import { stringify } from "../utils"; -import { YTLiveChatPaidMessageRenderer, YTText } from "../interfaces/yt/chat"; import { SuperChat, SUPERCHAT_COLOR_MAP, SUPERCHAT_SIGNIFICANCE_MAP, + SuperChatColorFields, } from "../interfaces/misc"; +import { + YTLiveChatPaidMessageRenderer, + YTLiveChatPaidStickerRenderer, +} from "../interfaces/yt/chat"; +import { debugLog, stringify } from "../utils"; import { parseColorCode } from "./utils"; const AMOUNT_REGEXP = /[\d.,]+/; @@ -41,29 +45,39 @@ export function parseAmountText(purchaseAmountText: string) { return { amount, currency }; } -export function parseSuperChat( - renderer: YTLiveChatPaidMessageRenderer -): SuperChat { +export function parseSuperChat< + T extends YTLiveChatPaidMessageRenderer | YTLiveChatPaidStickerRenderer +>(renderer: T): SuperChat { const { amount, currency } = parseAmountText( - renderer.purchaseAmountText.simpleText + stringify(renderer.purchaseAmountText) ); + const originalColor = + "headerBackgroundColor" in renderer + ? renderer.headerBackgroundColor.toString() + : renderer.backgroundColor.toString(); const color = - SUPERCHAT_COLOR_MAP[ - renderer.headerBackgroundColor.toString() as keyof typeof SUPERCHAT_COLOR_MAP - ]; + SUPERCHAT_COLOR_MAP[originalColor as keyof typeof SUPERCHAT_COLOR_MAP]; const significance = SUPERCHAT_SIGNIFICANCE_MAP[color]; + if (!color) { + debugLog( + "[action required] Can't find the color:", + JSON.stringify(renderer) + ); + } + + const colorFields = Object.fromEntries( + Object.entries(renderer) + .filter(([key]) => key.endsWith("Color")) + .map(([key, value]) => [key, parseColorCode(value)]) + ) as SuperChatColorFields; + return { amount, currency, color, significance, - authorNameTextColor: parseColorCode(renderer.authorNameTextColor), - timestampColor: parseColorCode(renderer.timestampColor), - headerBackgroundColor: parseColorCode(renderer.headerBackgroundColor), - headerTextColor: parseColorCode(renderer.headerTextColor), - bodyBackgroundColor: parseColorCode(renderer.bodyBackgroundColor), - bodyTextColor: parseColorCode(renderer.bodyTextColor), + ...colorFields, }; } diff --git a/src/chat/utils.ts b/src/chat/utils.ts index c8bf2ab..9e3f1ac 100644 --- a/src/chat/utils.ts +++ b/src/chat/utils.ts @@ -1,5 +1,5 @@ -import { YTThumbnailList } from "../interfaces/yt/chat"; import { Color } from "../interfaces/misc"; +import { YTThumbnailList } from "../interfaces/yt/chat"; export function pickThumbUrl(thumbList: YTThumbnailList): string { return thumbList.thumbnails[thumbList.thumbnails.length - 1].url; @@ -17,3 +17,36 @@ export function parseColorCode(code: number): Color { return { r, g, b, opacity }; } + +const magnitudes = new Map([ + ["K", 1000 ** 1], + ["M", 1000 ** 2], + ["G", 1000 ** 3], + ["T", 1000 ** 4], + ["P", 1000 ** 5], + ["E", 1000 ** 6], +] as const); + +const unitRegex = /(?[0-9]+(\.[0-9]*)?)(?([KMGTPE]))?/; + +export function unitsToNumber(text: string) { + const unitsMatch = text.match(unitRegex); + + if (!unitsMatch?.groups) { + return NaN; + } + + const parsedValue = parseFloat(unitsMatch.groups.value); + + if (!unitsMatch.groups?.suffix) { + return parsedValue; + } + + const magnitude = magnitudes.get(unitsMatch.groups.suffix as never); + + if (!magnitude) { + throw new Error("UnitRegex is wrong some how"); + } + + return parseInt((parsedValue * magnitude).toFixed(1)); +} diff --git a/src/context/index.ts b/src/context/index.ts index abea17d..398eb9e 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,31 +1,50 @@ +import * as cheerio from "cheerio"; import { + BotError, MembersOnlyError, NoPermissionError, NoStreamRecordingError, UnavailableError, } from "../errors"; import { runsToString } from "../utils"; -import { YTInitialData, YTPlayabilityStatus } from "../interfaces/yt/context"; +import { + PurpleStyle, + YTInitialData, + YTPlayabilityStatus, +} from "../interfaces/yt/context"; +import { VideoObject } from "../interfaces/yt/metadata"; // OK duration=">0" => Archived (replay chat may be available) // OK duration="0" => Live (chat may be available) // LIVE_STREAM_OFFLINE => Offline (chat may be available) -function assertPlayability(playabilityStatus: YTPlayabilityStatus | undefined) { +function assertPlayability( + playabilityStatus: YTPlayabilityStatus | undefined, + data?: any +) { if (!playabilityStatus) { throw new Error("playabilityStatus missing"); } + + const msg = playabilityStatus.reason || playabilityStatus.messages?.join(" "); switch (playabilityStatus.status) { case "ERROR": - throw new UnavailableError(playabilityStatus.reason!); + throw new UnavailableError(msg!); case "LOGIN_REQUIRED": - throw new NoPermissionError(playabilityStatus.reason!); + if ( + playabilityStatus.reason === "Sign in to confirm you’re not a bot" || + playabilityStatus.skip?.playabilityErrorSkipConfig + ?.skipOnPlayabilityError === false + ) { + throw new BotError(msg!); + } + throw new NoPermissionError(msg!); case "UNPLAYABLE": { if ( "playerLegacyDesktopYpcOfferRenderer" in playabilityStatus.errorScreen! ) { - throw new MembersOnlyError(playabilityStatus.reason!); + throw new MembersOnlyError(msg!, data); } - throw new NoStreamRecordingError(playabilityStatus.reason!); + throw new NoStreamRecordingError(msg!); } case "LIVE_STREAM_OFFLINE": case "OK": @@ -104,28 +123,117 @@ export async function parseMetadataFromEmbed(html: string) { } export function parseMetadataFromWatch(html: string) { + const metadata = parseVideoMetadataFromHtml(html); const initialData = findInitialData(html)!; - const playabilityStatus = findPlayabilityStatus(html); - assertPlayability(playabilityStatus); - // TODO: initialData.contents.twoColumnWatchNextResults.conversationBar.conversationBarRenderer.availabilityMessage.messageRenderer.text.runs[0].text === 'Chat is disabled for this live stream.' const results = initialData.contents?.twoColumnWatchNextResults?.results.results!; - const primaryInfo = results.contents[0].videoPrimaryInfoRenderer; - const videoOwner = - results.contents[1].videoSecondaryInfoRenderer.owner.videoOwnerRenderer; + const primaryInfo = results.contents?.find( + (v) => v.videoPrimaryInfoRenderer + )?.videoPrimaryInfoRenderer; + const secondaryInfo = results.contents?.find( + (v) => v.videoSecondaryInfoRenderer + )?.videoSecondaryInfoRenderer; + const videoOwner = secondaryInfo?.owner?.videoOwnerRenderer; + const badges = primaryInfo?.badges || []; + + const channelId = videoOwner?.navigationEndpoint?.browseEndpoint?.browseId; + const channelName = + runsToString(videoOwner?.title?.runs || []) || metadata.author?.name; + const title = runsToString(primaryInfo?.title?.runs || []) || metadata.name; + const isLive = !metadata.publication?.endDate || false; + const isUpcoming = + primaryInfo?.dateText?.simpleText?.includes("Scheduled for") || false; + const isMembersOnly = + badges.some( + (v) => + v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly + ) || false; + + try { + const playabilityStatus = findPlayabilityStatus(html); + // even if playabilityStatus missing you can still have chat + if (playabilityStatus) { + assertPlayability(playabilityStatus, { channelId, meta: metadata }); + } + } catch (error) { + // If members-only video is ended it should be able to get chat normally + if (!(error instanceof MembersOnlyError && metadata.publication?.endDate)) { + throw error; + } + } - const title = runsToString(primaryInfo.title.runs); - const channelId = videoOwner.navigationEndpoint.browseEndpoint.browseId; - const channelName = runsToString(videoOwner.title.runs); - const isLive = primaryInfo.viewCount!.videoViewCountRenderer.isLive ?? false; + if (!channelId) { + throw new Error("CHANNEL_ID_NOT_FOUND"); + } return { title, channelId, channelName, isLive, + isUpcoming, + isMembersOnly, + metadata, }; } + +/** + * @see http://schema.org/VideoObject + */ +function parseVideoMetadataFromHtml(html: string): VideoObject { + const $ = cheerio.load(html); + const meta = parseVideoMetadataFromElement( + $("[itemtype=http://schema.org/VideoObject]")?.[0] + ) as VideoObject; + return meta; +} + +function parseVideoMetadataFromElement( + root: any, + meta: Record = {} +) { + root?.children?.forEach((child: any) => { + const attributes = child?.attribs; + const key = attributes?.itemprop; + if (!key) { + return; + } + + if (child.children.length) { + const value = parseVideoMetadataFromElement(child); + if (meta[key]) { + meta[key] = [meta[key], value]; + } else { + meta[key] = value; + } + return; + } + + const value = parseVideoMetaValueByKey( + key, + attributes?.href || attributes?.content + ); + meta[key] = value; + }); + + return meta; +} + +function parseVideoMetaValueByKey(key: string, value: string) { + switch (key) { + case "paid": + case "unlisted": + case "requiresSubscription": + case "isFamilyFriendly": + case "isLiveBroadcast": + return /true/i.test(value); + case "width": + case "height": + case "userInteractionCount": + return Number(value); + } + return value; +} diff --git a/src/errors.ts b/src/errors.ts index 55be872..61736d2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import { VideoObject } from "./interfaces"; + export type EndReason = | "privated" // Privated by streamer | "deleted" // Deleted by streamer @@ -9,17 +11,25 @@ export type ErrorCode = | "unavailable" // Deleted video OR wrong video id | "disabled" // Live chat is disabled | "private" // No permission (private) + | "bot" // Bot detected | "membersOnly" // No permission (members-only) | "unarchived" // Live stream recording is not available | "denied" // Access denied (429) | "invalid"; // Invalid request -export class MasterchatError extends Error { +export interface MembersOnlyErrorData { + channelId?: string; + meta?: VideoObject; +} + +export class MasterchatError extends Error { public code: ErrorCode; + public data?: T; - constructor(code: ErrorCode, msg: string) { + constructor(code: ErrorCode, msg: string, data?: T) { super(msg); this.code = code; + this.data = data; Object.setPrototypeOf(this, MasterchatError.prototype); } @@ -46,9 +56,16 @@ export class NoPermissionError extends MasterchatError { } } -export class MembersOnlyError extends MasterchatError { +export class BotError extends MasterchatError { constructor(msg: string) { - super("membersOnly", msg); + super("bot", msg); + Object.setPrototypeOf(this, BotError.prototype); + } +} + +export class MembersOnlyError extends MasterchatError { + constructor(msg: string, data?: MembersOnlyErrorData) { + super("membersOnly", msg, data); Object.setPrototypeOf(this, MembersOnlyError.prototype); } } diff --git a/src/interfaces/actions.ts b/src/interfaces/actions.ts index 868cc78..8ef1276 100644 --- a/src/interfaces/actions.ts +++ b/src/interfaces/actions.ts @@ -1,15 +1,12 @@ +import { Badges, SuperChat, Color } from "./misc"; import { - Color, - Membership, - SuperChat, - SuperChatColor, - SuperChatSignificance, -} from "./misc"; -import { - YTLiveChatPollChoice, + YTLiveChatPaidMessageRenderer, + YTLiveChatPaidStickerRenderer, + YTText, + YTSimpleTextContainer, YTLiveChatPollType, + YTLiveChatPollChoice, YTRun, - YTText, } from "./yt/chat"; /** @@ -32,6 +29,11 @@ export type Action = | AddBannerAction | RemoveBannerAction | AddRedirectBannerAction + | AddIncomingRaidBannerAction + | AddOutgoingRaidBannerAction + | AddProductBannerAction + | AddCallForQuestionsBannerAction + | AddChatSummaryBannerAction | AddViewerEngagementMessageAction | ShowPanelAction | ShowPollPanelAction @@ -42,9 +44,13 @@ export type Action = | ModeChangeAction | MembershipGiftPurchaseAction | MembershipGiftRedemptionAction - | ModerationMessageAction; + | ModerationMessageAction + | RemoveChatItemAction + | RemoveChatItemByAuthorAction + | UnknownAction + | ErrorAction; -export interface AddChatItemAction { +export interface AddChatItemAction extends Badges { type: "addChatItemAction"; id: string; timestamp: Date; @@ -59,17 +65,15 @@ export interface AddChatItemAction { authorName?: string; authorChannelId: string; authorPhoto: string; - membership?: Membership; - isOwner: boolean; - isModerator: boolean; - isVerified: boolean; contextMenuEndpointParams: string; /** @deprecated use `message` */ rawMessage?: YTRun[]; } -export interface AddSuperChatItemAction { +export interface AddSuperChatItemAction + extends SuperChat, + Badges { type: "addSuperChatItemAction"; id: string; timestamp: Date; @@ -79,25 +83,17 @@ export interface AddSuperChatItemAction { authorChannelId: string; authorPhoto: string; message: YTRun[] | null; - amount: number; - currency: string; - color: SuperChatColor; - significance: SuperChatSignificance; - authorNameTextColor: Color; - timestampColor: Color; - headerBackgroundColor: Color; - headerTextColor: Color; - bodyBackgroundColor: Color; - bodyTextColor: Color; /** @deprecated use `message` */ rawMessage: YTRun[] | undefined; /** @deprecated flattened */ - superchat: SuperChat; + superchat: SuperChat; } -export interface AddSuperStickerItemAction { +export interface AddSuperStickerItemAction + extends SuperChat, + Badges { type: "addSuperStickerItemAction"; id: string; timestamp: Date; @@ -107,17 +103,11 @@ export interface AddSuperStickerItemAction { authorPhoto: string; stickerUrl: string; stickerText: string; - amount: number; - currency: string; stickerDisplayWidth: number; stickerDisplayHeight: number; - moneyChipBackgroundColor: Color; - moneyChipTextColor: Color; - backgroundColor: Color; - authorNameTextColor: Color; } -export interface AddMembershipItemAction { +export interface AddMembershipItemAction extends Badges { type: "addMembershipItemAction"; id: string; timestamp: Date; @@ -126,16 +116,13 @@ export interface AddMembershipItemAction { // `level` is only shown when there's multiple levels available level?: string; - /** Sometimes customThumbnail is not available */ - membership?: Membership; - /** rare but can be undefined */ authorName?: string; authorChannelId: string; authorPhoto: string; } -export interface AddMembershipMilestoneItemAction { +export interface AddMembershipMilestoneItemAction extends Badges { type: "addMembershipMilestoneItemAction"; id: string; timestamp: Date; @@ -144,9 +131,6 @@ export interface AddMembershipMilestoneItemAction { /** `level` is only shown when there's multiple levels available */ level?: string; - /** Sometimes customThumbnail is not available */ - membership?: Membership; - authorName?: string; authorChannelId: string; authorPhoto: string; @@ -245,7 +229,7 @@ export interface AddMembershipTickerAction { endBackgroundColor: Color; } -export interface AddBannerAction { +export interface AddBannerAction extends Badges { type: "addBannerAction"; actionId: string; targetId: string; @@ -257,10 +241,6 @@ export interface AddBannerAction { authorName: string; authorChannelId: string; authorPhoto: string; - membership?: Membership; - isOwner: boolean; - isModerator: boolean; - isVerified: boolean; viewerIsCreator: boolean; contextMenuEndpointParams?: string; } @@ -278,6 +258,65 @@ export interface AddRedirectBannerAction { authorPhoto: string; } +export interface AddIncomingRaidBannerAction { + type: "addIncomingRaidBannerAction"; + actionId: string; + targetId: string; + sourceName: string; + sourcePhoto: string; +} + +export interface AddOutgoingRaidBannerAction { + type: "addOutgoingRaidBannerAction"; + actionId: string; + targetId: string; + targetName: string; + targetPhoto: string; + targetVideoId: string; +} + +export interface AddProductBannerAction { + type: "addProductBannerAction"; + actionId: string; + targetId: string; + viewerIsCreator: boolean; + isStackable?: boolean; + title: string; + description: string; + thumbnail: string; + price: string; + vendorName: string; + creatorMessage: string; + creatorName: string; + authorPhoto: string; + url: string; + dialogMessage: YTSimpleTextContainer[]; + isVerified: boolean; +} + +export interface AddCallForQuestionsBannerAction { + type: "addCallForQuestionsBannerAction"; + actionId: string; + targetId: string; + isStackable?: boolean; + bannerType?: string; + creatorAvatar: string; + creatorAuthorName: string; + questionMessage: YTRun[]; +} + +export interface AddChatSummaryBannerAction { + type: "addChatSummaryBannerAction"; + id: string; + actionId: string; + targetId: string; + isStackable?: boolean; + bannerType?: string; + timestamp: Date; + timestampUsec: string; + chatSummary: YTRun[]; +} + export interface ShowTooltipAction { type: "showTooltipAction"; targetId: string; @@ -336,12 +375,15 @@ export interface AddPollResultAction { type: "addPollResultAction"; id: string; question?: YTRun[]; + /** @deprecated use `voteCount` */ total: string; + voteCount: number; choices: PollChoice[]; } export interface PollChoice { text: YTRun[]; + voteRatio: number; votePercentage: string; } @@ -359,14 +401,13 @@ export interface ModeChangeAction { description: string; } -export interface MembershipGiftPurchaseAction { +export interface MembershipGiftPurchaseAction extends Badges { type: "membershipGiftPurchaseAction"; id: string; timestamp: Date; timestampUsec: string; channelName: string; // MEMO: is it limited for ¥500 membership? amount: number; // 5, 10, 20 - membership: Membership; authorName: string; authorChannelId: string; authorPhoto: string; @@ -375,10 +416,10 @@ export interface MembershipGiftPurchaseAction { export type MembershipGiftPurchaseTickerContent = Omit< MembershipGiftPurchaseAction, - "timestamp" | "timestampUsec" | "type" + "timestamp" | "timestampUsec" >; -export interface MembershipGiftRedemptionAction { +export interface MembershipGiftRedemptionAction extends Badges { type: "membershipGiftRedemptionAction"; id: string; timestamp: Date; @@ -397,7 +438,25 @@ export interface ModerationMessageAction { message: YTRun[]; } +export interface RemoveChatItemAction { + type: "removeChatItemAction"; + targetId: string; + timestamp: Date; +} + +export interface RemoveChatItemByAuthorAction { + type: "removeChatItemByAuthorAction"; + channelId: string; + timestamp: Date; +} + export interface UnknownAction { type: "unknown"; payload: unknown; } + +export interface ErrorAction { + type: "error"; + error: unknown; + payload: unknown; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index b7f3f3b..3ee9176 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -38,4 +38,9 @@ export interface Credentials { * Delegated session id for brand account */ DELEGATED_SESSION_ID?: string; + + "__Secure-1PAPISID"?: string; + "__Secure-1PSID"?: string; + "__Secure-1PSIDTS"?: string; + "__Secure-1PSIDCC"?: string; } diff --git a/src/interfaces/misc.ts b/src/interfaces/misc.ts index 28f8f5e..6b11323 100644 --- a/src/interfaces/misc.ts +++ b/src/interfaces/misc.ts @@ -13,12 +13,19 @@ export const SUPERCHAT_SIGNIFICANCE_MAP = { export const SUPERCHAT_COLOR_MAP = { "4279592384": "blue", + "4280191205": "blue", "4278237396": "lightblue", + "4278248959": "lightblue", "4278239141": "green", + "4280150454": "green", "4294947584": "yellow", + "4294953512": "yellow", "4293284096": "orange", + "4294278144": "orange", "4290910299": "magenta", + "4293467747": "magenta", "4291821568": "red", + "4293271831": "red", } as const; /** * Components @@ -30,24 +37,30 @@ export type SuperChatSignificance = export type SuperChatColor = typeof SUPERCHAT_COLOR_MAP[keyof typeof SUPERCHAT_COLOR_MAP]; -export interface SuperChat { +export type SuperChatColorFields = { + [K in keyof T as K extends `${string}Color` ? K : never]: Color; +}; + +export type SuperChat = { amount: number; currency: string; color: SuperChatColor; significance: SuperChatSignificance; - authorNameTextColor: Color; - timestampColor: Color; - headerBackgroundColor: Color; - headerTextColor: Color; - bodyBackgroundColor: Color; - bodyTextColor: Color; -} +} & SuperChatColorFields; export interface Membership { status: string; since?: string; thumbnail: string; } + +export interface Badges { + isOwner: boolean; + isModerator: boolean; + isVerified: boolean; + membership?: Membership; +} + /** * 0 - 255 */ diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index 16007d4..87b95f5 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -1,9 +1,10 @@ import { - YTAccessibilityLabel, + FrameworkUpdates, YTAccessibilityData, + YTAccessibilityLabel, + YTBrowseEndpointContainer, YTReloadContinuation, YTResponseContext, - YTBrowseEndpointContainer, } from "./context"; // -------------------- @@ -12,6 +13,13 @@ import { export type YTText = YTSimpleTextContainer | YTRunContainer; +/** + * Actually same {@link YTText} but better for IntelliSense (?) + * + * It can have `simpleText` or `runs` not both + */ +export type YTAnyText = Partial; + export interface YTSimpleTextContainer { simpleText: string; accessibility?: YTAccessibilityData; @@ -27,12 +35,18 @@ export interface YTTextRun { text: string; bold?: boolean; italics?: boolean; + fontFace?: YTFontFace | string; navigationEndpoint?: | YTUrlEndpointContainer | YTBrowseEndpointContainer | YTWatchEndpointContainer; } +export enum YTFontFace { + RobotoRegular = "FONT_FACE_ROBOTO_REGULAR", + RobotoMedium = "FONT_FACE_ROBOTO_MEDIUM", +} + export interface YTEmojiRun { emoji: YTEmoji; } @@ -92,6 +106,7 @@ export interface YTChatResponse { continuationContents?: YTContinuationContents; error?: YTChatError; trackingParams: string; + frameworkUpdates?: FrameworkUpdates; } export interface YTGetItemContextMenuResponse { @@ -232,6 +247,8 @@ export interface YTAction { addChatItemAction?: YTAddChatItemAction; markChatItemsByAuthorAsDeletedAction?: YTMarkChatItemsByAuthorAsDeletedAction; markChatItemAsDeletedAction?: YTMarkChatItemAsDeletedAction; + removeChatItemAction?: YTRemoveChatItemAction; + removeChatItemByAuthorAction?: YTRemoveChatItemByAuthorAction; // Ticker addLiveChatTickerItemAction?: YTAddLiveChatTickerItemAction; @@ -291,6 +308,14 @@ export interface YTMarkChatItemsByAuthorAsDeletedAction { externalChannelId: string; } +export interface YTRemoveChatItemAction { + targetItemId: string; +} + +export interface YTRemoveChatItemByAuthorAction { + externalChannelId: string; +} + export interface YTAddBannerToLiveChatCommand { bannerRenderer: YTLiveChatBannerRendererContainer; } @@ -375,6 +400,18 @@ export interface YTLiveChatBannerRedirectRendererContainer { liveChatBannerRedirectRenderer: YTLiveChatBannerRedirectRenderer; } +export interface YTLiveChatProductItemRendererContainer { + liveChatProductItemRenderer: YTLiveChatProductItemRenderer; +} + +export interface YTLiveChatCallForQuestionsRendererContainer { + liveChatCallForQuestionsRenderer: YTLiveChatCallForQuestionsRenderer; +} + +export interface YTLiveChatBannerChatSummaryRendererContainer { + liveChatBannerChatSummaryRenderer: YTLiveChatBannerChatSummaryRenderer; +} + // LiveChat Renderers export interface YTLiveChatTextMessageRenderer { @@ -401,6 +438,7 @@ export interface YTLiveChatPaidMessageRenderer { authorExternalChannelId: string; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; + authorBadges?: YTAuthorBadge[]; purchaseAmountText: YTSimpleTextContainer; timestampColor: number; @@ -409,6 +447,7 @@ export interface YTLiveChatPaidMessageRenderer { headerTextColor: number; bodyBackgroundColor: number; bodyTextColor: number; + textInputBackgroundColor: number; trackingParams: string; } @@ -421,6 +460,7 @@ export interface YTLiveChatPaidStickerRenderer { authorName: YTText; authorExternalChannelId: string; sticker: YTThumbnailList; // with accessibility + authorBadges?: YTAuthorBadge[]; moneyChipBackgroundColor: number; moneyChipTextColor: number; purchaseAmountText: YTSimpleTextContainer; @@ -464,21 +504,47 @@ export interface YTLiveChatPlaceholderItemRenderer { export interface YTLiveChatBannerRenderer { actionId: string; - targetId: string; // live-chat-banner + targetId: "live-chat-banner" | string; contents: | YTLiveChatTextMessageRendererContainer - | YTLiveChatBannerRedirectRendererContainer; - header?: YTLiveChatBannerRendererHeader; + | YTLiveChatBannerRedirectRendererContainer + | YTLiveChatProductItemRendererContainer + | YTLiveChatCallForQuestionsRendererContainer + | YTLiveChatBannerChatSummaryRendererContainer; viewerIsCreator: boolean; + header?: YTLiveChatBannerRendererHeader; + isStackable?: boolean; + backgroundType?: "LIVE_CHAT_BANNER_BACKGROUND_TYPE_STATIC" | string; + bannerType?: YTLiveChatBannerType | string; + onCollapseCommand?: YTElementsCommandContainer; + onExpandCommand?: YTElementsCommandContainer; +} + +export enum YTLiveChatBannerType { + ChatSummary = "LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY", + CallForQuestions = "LIVE_CHAT_BANNER_TYPE_QNA_START", +} + +export interface YTElementsCommandContainer { + clickTrackingParams: string; + elementsCommand: YTSetEntityCommandContainer; +} + +export interface YTSetEntityCommandContainer { + setEntityCommand: { + identifier: string; + entity: string; + }; } export interface YTLiveChatViewerEngagementMessageRenderer { id: string; timestampUsec?: string; - icon: YTIcon; + icon?: YTIcon; message: YTText; actionButton?: YTActionButtonRendererContainer; contextMenuEndpoint?: YTLiveChatItemContextMenuEndpointContainer; + trackingParams?: string; } export interface YTTooltipRenderer { @@ -501,7 +567,7 @@ export interface YTLiveChatPollRenderer { liveChatPollId: string; header: { pollHeaderRenderer: { - pollQuestion?: YTSimpleTextContainer; + pollQuestion?: YTAnyText; thumbnail: YTThumbnailList; metadataText: YTRunContainer; liveChatPollType: YTLiveChatPollType; @@ -534,10 +600,65 @@ export interface YTLiveChatBannerRedirectRenderer { */ bannerMessage: YTRunContainer; authorPhoto: YTThumbnailList; - inlineActionButton: YTActionButtonRendererContainer; + inlineActionButton: YTContextMenuButtonRendererContainer< + YTUrlEndpointContainer | YTWatchEndpointContainer + >; contextMenuButton: YTContextMenuButtonRendererContainer; } +export interface YTLiveChatProductItemRenderer { + title: string; + accessibilityTitle: string; + thumbnail: YTThumbnailList; + price: string; + vendorName: string; + fromVendorText: string; + informationButton: InformationButton; + onClickCommand: YTUrlEndpointContainer; + trackingParams: string; + creatorMessage: string; + creatorName: string; + authorPhoto: YTThumbnailList; + informationDialog: InformationDialog; + isVerified: boolean; +} + +export interface InformationButton { + buttonRenderer: InformationButtonButtonRenderer; +} + +export interface InformationButtonButtonRenderer { + icon: YTIcon; + accessibility: YTAccessibilityLabel; + trackingParams: string; +} + +export interface InformationDialog { + liveChatDialogRenderer: LiveChatDialogRenderer; +} + +export interface LiveChatDialogRenderer { + trackingParams: string; + dialogMessages: YTSimpleTextContainer[]; + confirmButton: CollapseButton; +} + +export interface YTLiveChatCallForQuestionsRenderer { + creatorAvatar: YTThumbnailList; + featureLabel: YTSimpleTextContainer; + contentSeparator: YTSimpleTextContainer; + overflowMenuButton: YTContextMenuButtonRendererContainer; + creatorAuthorName: YTSimpleTextContainer; + questionMessage: YTRunContainer; +} + +export interface YTLiveChatBannerChatSummaryRenderer { + liveChatSummaryId: string; + chatSummary: YTRunContainer; + icon: YTIcon; + trackingParams: string; +} + export interface YTLiveChatPollChoice { text: YTText; selected: boolean; @@ -612,7 +733,7 @@ export interface YTLiveChatSponsorshipsHeaderRenderer { { text: " memberships"; bold: true } ]; }; - authorBadges: YTLiveChatAuthorBadgeRendererContainer[]; + authorBadges?: YTLiveChatAuthorBadgeRendererContainer[]; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; image: YTThumbnailList; // https://www.gstatic.com/youtube/img/sponsorships/sponsorships_gift_purchase_announcement_artwork.png @@ -631,6 +752,7 @@ export interface YTLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer { { text: string; bold: true; italics: true } // text: "User" ]; }; + authorBadges?: YTLiveChatAuthorBadgeRendererContainer[]; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; trackingParams: string; @@ -854,6 +976,7 @@ export enum YTIconType { TabSubscriptions = "TAB_SUBSCRIPTIONS", BlockUser = "BLOCK_USER", ErrorOutline = "ERROR_OUTLINE", + Spark = "SPARK", } export interface YTPicker { @@ -919,7 +1042,8 @@ export interface YTContextMenuButtonRendererContainer< buttonRenderer: { icon: YTIcon; style?: string; - command?: Command; + command: Command; + accessibility?: YTAccessibilityLabel; accessibilityData: YTAccessibilityData; trackingParams: string; }; @@ -936,12 +1060,16 @@ export interface YTServiceButtonRenderer { trackingParams: string; } +// Generic type +export interface YTButton {} + export interface YTButtonRenderer { - size: string; - style: string; - isDisabled: boolean; + icon?: YTIcon; + text?: YTText; + size?: string; + style?: string; + isDisabled?: boolean; accessibility: YTAccessibilityLabel; - trackingParams: string; } export interface YTIconButtonRenderer { diff --git a/src/interfaces/yt/context.ts b/src/interfaces/yt/context.ts index 4dcd24f..06bf4a2 100644 --- a/src/interfaces/yt/context.ts +++ b/src/interfaces/yt/context.ts @@ -1,5 +1,6 @@ import { UIActions, + YTAnyText, YTApiEndpointMetadataContainer, YTClientMessages, YTIcon, @@ -23,9 +24,12 @@ export interface YTPlayabilityStatus { contextParams: string; // if not OK reason?: string; + messages?: string[]; errorScreen?: { playerErrorMessageRenderer?: { reason: YTSimpleTextContainer; + subreason?: YTAnyText; + proceedButton?: YTDismissButtonClass; thumbnail: YTThumbnailList; icon: YTIcon; }; @@ -219,7 +223,7 @@ export interface YTCollapseButtonButtonRenderer { isDisabled: boolean; accessibility?: YTAccessibilityLabel; trackingParams: string; - text?: YTRunContainer; + text?: YTAnyText; } export interface YTAccessibilityData { @@ -748,6 +752,7 @@ export interface OwnerBadgeMetadataBadgeRenderer { export enum PurpleStyle { BadgeStyleTypeVerified = "BADGE_STYLE_TYPE_VERIFIED", + BadgeStyleTypeMembersOnly = "BADGE_STYLE_TYPE_MEMBERS_ONLY", } export interface MembershipButton { diff --git a/src/interfaces/yt/index.ts b/src/interfaces/yt/index.ts index 511ea8b..2348ba3 100644 --- a/src/interfaces/yt/index.ts +++ b/src/interfaces/yt/index.ts @@ -16,3 +16,4 @@ export { YTAccessibilityData, YTReloadContinuation } from "./context"; // export * from "./context"; export * from "./comments"; // export * from "./transcript"; +export * from "./metadata"; diff --git a/src/interfaces/yt/metadata.ts b/src/interfaces/yt/metadata.ts new file mode 100644 index 0000000..afd9d16 --- /dev/null +++ b/src/interfaces/yt/metadata.ts @@ -0,0 +1,45 @@ +export interface Person { + url: string; + name: string; +} + +export interface Thumbnail { + url: string; + width: number; + height: number; +} + +export interface InteractionCounter { + interactionType: string; + userInteractionCount: number; +} + +export interface PublicationEvent { + isLiveBroadcast: boolean; + startDate?: string; + endDate?: string; +} + +export interface VideoObject { + url: string; + name: string; + description: string; + requiresSubscription: boolean; + identifier: string; + duration: string; + author: Person; + thumbnailUrl: string; + thumbnail: Thumbnail; + embedUrl: string; + playerType: string; + width: number; + height: number; + isFamilyFriendly: boolean; + regionsAllowed: string; + interactionStatistic?: InteractionCounter | InteractionCounter[]; + keywords?: string; + datePublished: string; + uploadDate: string; + genre?: string; + publication?: PublicationEvent; +} diff --git a/src/masterchat.ts b/src/masterchat.ts index dabe02f..a7a088b 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -1,6 +1,5 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import { EventEmitter } from "events"; -import { AsyncIterator } from "iterator-helpers-polyfill"; import { buildMeta } from "./api"; import { buildAuthHeaders } from "./auth"; import { parseAction } from "./chat"; @@ -23,6 +22,7 @@ import { ChatResponse, Credentials, RenderingPriority, + VideoObject, YTCommentThreadRenderer, YTContinuationItem, } from "./interfaces"; @@ -143,20 +143,23 @@ export class Masterchat extends EventEmitter { public channelId!: string; public isLive?: boolean; + public isUpcoming?: boolean; + public isMembersOnly?: boolean; public channelName?: string; public title?: string; + public videoMetadata?: VideoObject; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; private listenerAbortion: AbortController = new AbortController(); - private credentials?: Credentials; + protected credentials?: Credentials; /* * Private API */ - private async postWithRetry( + protected async postWithRetry( input: string, body: any, options?: RetryOptions @@ -192,7 +195,7 @@ export class Masterchat extends EventEmitter { } } - private async post( + protected async post( input: string, body: any, config: AxiosRequestConfig = {} @@ -201,24 +204,26 @@ export class Masterchat extends EventEmitter { input = Constants.DO + input; } + const headers = { + "Content-Type": "application/json", + ...Constants.DH, + ...(this.credentials && buildAuthHeaders(this.credentials)), + ...config.headers, + }; + const res = await this.axiosInstance.request({ ...config, url: input, signal: this.listenerAbortion.signal, method: "POST", - headers: { - ...config.headers, - "Content-Type": "application/json", - ...(this.credentials && buildAuthHeaders(this.credentials)), - ...Constants.DH, - }, + headers, data: body, }); return res.data; } - private async get( + protected async get( input: string, config: AxiosRequestConfig = {} ): Promise { @@ -226,15 +231,17 @@ export class Masterchat extends EventEmitter { input = Constants.DO + input; } + const headers = { + ...Constants.DH, + ...(this.credentials && buildAuthHeaders(this.credentials)), + ...config.headers, + }; + const res = await this.axiosInstance.request({ ...config, url: input, signal: this.listenerAbortion.signal, - headers: { - ...config.headers, - ...(this.credentials && buildAuthHeaders(this.credentials)), - ...Constants.DH, - }, + headers, }); return res.data; @@ -432,7 +439,10 @@ export class Masterchat extends EventEmitter { this.title = metadata.title; this.channelId = metadata.channelId; this.channelName = metadata.channelName; - this.isLive ??= metadata.isLive; + this.isLive = metadata.isLive; + this.isUpcoming = metadata.isUpcoming; + this.isMembersOnly = metadata.isMembersOnly; + this.videoMetadata = metadata.metadata; } public async fetchMetadataFromWatch(id: string) { @@ -465,6 +475,9 @@ export class Masterchat extends EventEmitter { channelName: this.channelName, title: this.title, isLive: this.isLive, + isUpcoming: this.isUpcoming, + isMembersOnly: this.isMembersOnly, + videoMetadata: this.videoMetadata, }; } @@ -586,10 +599,10 @@ export class Masterchat extends EventEmitter { /** * AsyncIterator API */ - public iter(options?: IterateChatOptions): AsyncIterator { - return AsyncIterator.from( - this.iterate(options) - ).flatMap((action) => action.actions); + public async *iter(options?: IterateChatOptions): AsyncIterator { + for await (const response of this.iterate(options)) { + yield* response.actions; + } } /** @@ -852,8 +865,15 @@ export class Masterchat extends EventEmitter { } const actions = rawActions - .map(parseAction) - .filter((a): a is Action => a !== undefined); + .map((action) => { + try { + return parseAction(action); + } catch (error: any) { + this.log("parseAction", error.message, { action }); + return null; + } + }) + .filter((a): a is Action => !!a); const chat: ChatResponse = { actions, @@ -975,10 +995,17 @@ export class Masterchat extends EventEmitter { } /** - * Put user in timeout for 300 seconds + * Put user in timeout for *timeoutLength* seconds (default set to 300 seconds) */ - public async timeout(channelId: string): Promise { - const params = timeoutParams(channelId, this.cvPair()); + public async timeout( + channelId: string, + timeoutLength: number = 300 + ): Promise { + const params = timeoutParams( + channelId, + this.cvPair(), + Math.min(Math.max(timeoutLength, 10), 86400) + ); const res = await this.post( Constants.EP_MOD, diff --git a/src/pool.ts b/src/pool.ts index f998f86..937170b 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -57,7 +57,7 @@ export class StreamPool extends EventEmitter { fn: (agent: Masterchat, videoId: string, index: number) => void ) { return Promise.allSettled( - this.entries.map(([videoId, instance], i) => + this.entries.map(([videoId, instance]: any, i) => Promise.resolve(fn(instance, videoId, i)) ) ); diff --git a/src/protobuf/assembler.ts b/src/protobuf/assembler.ts index 9e6e376..ea288d2 100644 --- a/src/protobuf/assembler.ts +++ b/src/protobuf/assembler.ts @@ -129,13 +129,21 @@ export function removeMessageParams( ); } -export function timeoutParams(channelId: string, origin: CVPair): string { +export function timeoutParams( + channelId: string, + origin: CVPair, + timeoutLength: number +): string { return b64e( cc([ ld(1, cvToken(origin)), - ld(6, ld(1, truc(channelId))), - vt(10, 2), + ld(6, [ + ld(1, truc(channelId)), + ld(2, [vt(1, encv(BigInt(timeoutLength)))]), + ]), + vt(10, 1), vt(11, 1), + vt(14, 4), ]), B64Type.B2 ); diff --git a/src/utils.ts b/src/utils.ts index b3eb488..dcc6d12 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -287,7 +287,9 @@ export function durationToSeconds(durationText: string): number { const match = /^(a|\d+)\s(year|month|week|day|hour|minute|second)s?$/.exec( durationText ); - if (!match) throw new Error(`Invalid duration: ${durationText}`); + if (!match) { + throw new Error(`Invalid duration: ${durationText}`); + } const [_, duration, unit] = match; const durationInt = parseInt(duration) || 1; @@ -300,7 +302,9 @@ export function durationToSeconds(durationText: string): number { minute: 60, second: 1, }[unit]; - if (!multiplier) throw new Error(`Invalid duration unit: ${unit}`); + if (!multiplier) { + throw new Error(`Invalid duration unit: ${unit}`); + } return durationInt * multiplier; } @@ -309,7 +313,9 @@ export function durationToISO8601(durationText: string): string { const match = /^(a|\d+)\s(year|month|week|day|hour|minute|second)s?$/.exec( durationText ); - if (!match) throw new Error(`Invalid duration: ${durationText}`); + if (!match) { + throw new Error(`Invalid duration: ${durationText}`); + } const [_, duration, unit] = match; const durationInt = parseInt(duration) || 1; @@ -322,7 +328,9 @@ export function durationToISO8601(durationText: string): string { minute: "TM", second: "TS", }[unit]; - if (!durationUnit) throw new Error(`Invalid duration unit: ${unit}`); + if (!durationUnit) { + throw new Error(`Invalid duration unit: ${unit}`); + } return `P${durationInt}${durationUnit}`; }