Skip to content

Commit

Permalink
feat!: narrow down some response
Browse files Browse the repository at this point in the history
  • Loading branch information
uetchy committed Aug 24, 2021
1 parent 739130f commit 1ad28a0
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 49 deletions.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ A JavaScript library for YouTube Live Chat.
npm i masterchat
```

## Use
## Examples

### Iterate live chats

```js
import { Masterchat, convertRunsToString } from "masterchat";
Expand All @@ -33,6 +35,33 @@ for await (const res of mc.iterateChat({ tokenType: "top" })) {
}
```

### Auto-moderation bot

```js
import { Masterchat, convertRunsToString } from "masterchat";
import { isSpam } from "spamreaper";

// YouTube session cookie
const credentials = {
SAPISID: "<value>",
APISID: "<value>",
HSID: "<value>",
SID: "<value>",
SSID: "<value>",
};
const mc = await Masterchat.init("<videoId>", { credentials });

for await (const { actions } of mc.iterateChat({ tokenType: "all" })) {
for (const action of actions) {
if (action.type !== "addChatItemAction") continue;

if (isSpam(convertRunsToString(action.rawMessage))) {
await mc.remove(action.contextMenuEndpointParams);
}
}
}
```

## CLI

[![npm](https://badgen.net/npm/v/masterchat-cli)](https://npmjs.org/package/masterchat-cli)
Expand Down
11 changes: 8 additions & 3 deletions src/base.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Credentials, buildAuthHeaders } from "./auth";
import { DEFAULT_HEADERS, DEFAULT_ORIGIN } from "./constants";
import fetch from "cross-fetch";
import { LiveChatContext, Metadata } from "./services/context/exports";
import { buildAuthHeaders, Credentials } from "./auth";
import { DEFAULT_HEADERS, DEFAULT_ORIGIN } from "./constants";
import { ReloadContinuationItems } from "./services/chat/exports";
import { LiveChatContext, Metadata } from "./services/context/exports";
import { debugLog } from "./util";

export class Base {
Expand Down Expand Up @@ -40,6 +40,11 @@ export class Base {
});
}

protected async postJson<T>(input: string, init?: RequestInit): Promise<T> {
const res = await this.post(input, init);
return await res.json();
}

protected post(input: string, init?: RequestInit) {
if (!input.startsWith("http")) {
input = DEFAULT_ORIGIN + input;
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export class Masterchat {
return mc;
}

public async setVideoId(videoIdOrUrl: string) {
const videoId = normalizeVideoId(videoIdOrUrl);
this.videoId = videoId;
await this.populateMetadata();
if (this.credentials) await this.populateLiveChatContext();
}

private constructor(
videoId: string,
{ apiKey = DEFAULT_API_KEY, credentials }: MasterchatOptions = {}
Expand Down
32 changes: 22 additions & 10 deletions src/services/chatAction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@

import { Base } from "../../base";
import {
YTActionResponse,
YTAction,
YTGetItemContextMenuResponse,
YTLiveChatServiceEndpointContainer,
} from "../../types/chat";
Expand Down Expand Up @@ -111,35 +111,39 @@ function buildMeta(endpoint: YTLiveChatServiceEndpointContainer) {

export interface ChatActionService extends Base {}
export class ChatActionService {
// TODO: verify request body
// TODO: narrow down return type
async report(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.report;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async block(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.block;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async unblock(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.unblock;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async pin(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.pin;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async unpin(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.unpin;
Expand All @@ -151,61 +155,69 @@ export class ChatActionService {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.remove;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
const res = await this.sendAction(actionInfo);
return res[0].markChatItemAsDeletedAction;
}

// TODO: narrow down return type
async timeout(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.timeout;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async hide(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.hide;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async unhide(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.unhide;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async addModerator(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.addModerator;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

// TODO: narrow down return type
async removeModerator(contextMenuEndpointParams: string) {
const catalog = await this.getActionCatalog(contextMenuEndpointParams);
const actionInfo = catalog?.removeModerator;
if (!actionInfo) return;
return await this.sendAction(actionInfo);
}

private async sendAction<T = YTActionResponse>(
actionInfo: ActionInfo
): Promise<T> {
private async sendAction<T = YTAction[]>(actionInfo: ActionInfo): Promise<T> {
const url = actionInfo.url;
let res;
if (actionInfo.isPost) {
const res = await this.post(url, {
res = await this.post(url, {
body: JSON.stringify(
withContext({
params: actionInfo.params,
})
),
});
return await res.json();
} else {
const res = await this.get(url);
return await res.json();
res = await this.get(url);
}
const json = await res.json();
if (!json.success) {
throw new Error(`Failed to perform action: ` + JSON.stringify(json));
}
return json.actions;
}

/**
Expand Down
16 changes: 3 additions & 13 deletions src/services/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,9 @@ export class ContextService {
}

private async fetchContext(id: string): Promise<Context | undefined> {
const res = await this.get("https://www.youtube.com/watch?v=" + id);
const res = await this.get("/watch?v=" + id);
if (res.status === 429) {
debugLog(
"429",
res.status,
res.statusText,
"https://www.youtube.com/watch?v=" + id
);
debugLog("429", res.status, res.statusText, id);

const err = new Error("BAN detected");
err.name = "EYTBAN";
Expand All @@ -134,12 +129,7 @@ export class ContextService {
const apiKey = findApiKey(watchHtml);

if (!apiKey || !initialData) {
debugLog(
"!apiKey",
res.status,
res.statusText,
"https://www.youtube.com/watch?v=" + id
);
debugLog("!apiKey", res.status, res.statusText, id);
// TODO: when does this happen?
debugLog("!initialData: " + initialData);

Expand Down
32 changes: 25 additions & 7 deletions src/services/message/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { Base } from "../../base";
import {
YTActionResponse,
YTLiveChatTextMessageRenderer,
} from "../../types/chat";
import { withContext } from "../../util";

/**
* returns undefined if unauthorized
*/
export interface MessageService extends Base {}
export class MessageService {
async sendMessage(message: string) {
async sendMessage(
message: string
): Promise<YTLiveChatTextMessageRenderer | undefined> {
const params = this.liveChatContext?.sendMessageParams;
if (!params) return { error: "NO_PARAMS" };
if (!params) return undefined;

const body = withContext({
richMessage: {
Expand All @@ -18,10 +27,19 @@ export class MessageService {
params,
});

const res = await this.post("/youtubei/v1/live_chat/send_message", {
body: JSON.stringify(body),
});
const json = await res.json();
return json;
const res = await this.postJson<YTActionResponse>(
"/youtubei/v1/live_chat/send_message",
{
body: JSON.stringify(body),
}
);
if (!res.success) {
throw new Error(`Failed to send message: ` + JSON.stringify(res));
}
const item = res.actions[0].addChatItemAction?.item;
if (!(item && "liveChatTextMessageRenderer" in item)) {
throw new Error(`Invalid response: ` + item);
}
return item.liveChatTextMessageRenderer;
}
}
2 changes: 1 addition & 1 deletion src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ export interface YTGetItemContextMenuResponse {
// moderate, pin, manage_user
export interface YTActionResponse {
responseContext: YTResponseContext;
actions: YTAction[];
success: boolean;
actions: YTAction[];
}

// Interfaces
Expand Down
24 changes: 10 additions & 14 deletions tests/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,22 @@ describe("send message", () => {
itif("can send message", async () => {
const { completeRecording } = await record("message_send");

const msg = "hello" + Math.random() * 1000;
const msg = "hello world";
const res = await mc.sendMessage(msg);
completeRecording();

expect(res.actions[0]).toMatchObject({
addChatItemAction: {
item: {
liveChatTextMessageRenderer: {
message: { runs: [{ text: msg }] },
},
},
},
if ("error" in res) {
throw res.error;
}

expect(res).toMatchObject({
message: { runs: [{ text: msg }] },
});

// Remove
chatId =
res.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id;
chatId = res.id;
chatParams =
res.actions[0].addChatItemAction.item.liveChatTextMessageRenderer
.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params;
res.contextMenuEndpoint!.liveChatItemContextMenuEndpoint.params;
});
});

Expand All @@ -55,7 +51,7 @@ describe("remove message", () => {
const { completeRecording } = await record("message_remove");
const res = await mc.remove(chatParams);
completeRecording();
const targetId = res?.actions[0].markChatItemAsDeletedAction?.targetItemId;
const targetId = res?.targetItemId;
expect(targetId).toBe(chatId);
});
});

0 comments on commit 1ad28a0

Please sign in to comment.