Skip to content

Commit

Permalink
Close #14. Add chat bot utility.
Browse files Browse the repository at this point in the history
  • Loading branch information
sebinside committed Jan 8, 2022
1 parent 67514c4 commit 9dc233e
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 63 deletions.
91 changes: 91 additions & 0 deletions skates-utils/ChatBot.ts
@@ -0,0 +1,91 @@
import type { TwitchPrivateMessage } from "@twurple/chat/lib/commands/TwitchPrivateMessage";
import { ServiceProvider } from "nodecg-io-core";
import { NodeCG } from "nodecg-types/types/server";
import { TwitchChatServiceClient } from "nodecg-io-twitch-chat";

/**
* This ChatBot assumes that all commands are listened to from a single bot instance on a single channel.
*/
export class ChatBot {
public static readonly CHANNEL = "#skate702";
public static readonly COMMAND_SYMBOL = "!";

private static chatBot: ChatBot = new ChatBot();

public static getInstance(): ChatBot {
return ChatBot.chatBot;
}

private commandTimeouts: Map<string, number> = new Map<string, number>();

private constructor() {
// private constructor following the singleton pattern
}

/**
* Registers a new command and event handling to the chat bot.
* @param command the command that should be listened to
* @param exactMatch if set to true, the action is only triggered iff the message only contains the command
* @param twitchClient the twitch chat client that shall be used to parse messages
* @param nodecg the current nodecg instance
* @param action the event handling that is triggered if a command is detected
* @param timeoutInSeconds sleep time in seconds until the command can be triggered again
* @returns true if the command was not previously registered and no error happened
*/
public async registerCommand(command: string,
exactMatch: boolean,
twitchClient: ServiceProvider<TwitchChatServiceClient> | undefined,
nodecg: NodeCG,
action: (user: string, message: string, msg: TwitchPrivateMessage) => void,
timeoutInSeconds = 10): Promise<boolean> {

// Internally register command
const normalizedCommand = this.normalizeCommand(command);
if (this.isCommandRegistered(normalizedCommand)) {
return false;
}
this.commandTimeouts.set(normalizedCommand, Date.now());

// Join channel and register event
return twitchClient?.getClient()?.join(ChatBot.CHANNEL)
.then(() => {
nodecg.log.info(`Added chat command "${ChatBot.COMMAND_SYMBOL}${normalizedCommand}" to channel ${ChatBot.CHANNEL}.`);

twitchClient?.getClient()?.onMessage((channel, user, message, msg) => {
if (channel.toLowerCase() === ChatBot.CHANNEL.toLowerCase()) {
if (
(exactMatch && message.toLowerCase() === `${ChatBot.COMMAND_SYMBOL}${normalizedCommand}`)
|| (!exactMatch && message.toLowerCase().startsWith(`${ChatBot.COMMAND_SYMBOL}${normalizedCommand}`))) {

// Handle timeouts
if (Date.now() - (this.commandTimeouts.get(normalizedCommand) ?? Date.now()) > timeoutInSeconds * 1000) {
this.commandTimeouts.set(normalizedCommand, Date.now());

// Trigger client specified event handling (finally!)
action(user, message, msg);
}
}
}
});

return true;
})
.catch((reason) => {
nodecg.log.error(`Couldn't add chat command "${ChatBot.COMMAND_SYMBOL}${normalizedCommand}" to channel ${ChatBot.CHANNEL} because of: ${reason}.`);

return false;
}) ?? false;
}

public getRegisteredCommands(): string[] {
return [...this.commandTimeouts.keys()];
}

public isCommandRegistered(command: string): boolean {
return this.getRegisteredCommands().indexOf(this.normalizeCommand(command)) !== -1;
}

private normalizeCommand(command: string) {
return command.toLowerCase().replace("!", "");
}
}
1 change: 1 addition & 0 deletions skates-utils/README.md
Expand Up @@ -5,6 +5,7 @@ This bundle provides common functionality used by the other bundles.
## Features

* The *Manager*-class provides boilerplate code for initializing and logging nodecg-io service connections, following the template method. This shall help while debugging and reduce code verbosity
* The *ChatBot*-class represents a singleton to manage all chat commands in a single twitch channel. It already contains boilerplate code for handling timeouts, command matching, and so on.

## Service Dependencies

Expand Down
1 change: 1 addition & 0 deletions skates-utils/index.ts
@@ -1 +1,2 @@
export { Manager } from "./Manager";
export { ChatBot } from "./ChatBot";
3 changes: 2 additions & 1 deletion skates-utils/package.json
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"nodecg-types": "^1.8.1",
"nodecg-io-core": "^0.3.0",
"nodecg-io-tsconfig": "^1.0.0"
"nodecg-io-tsconfig": "^1.0.0",
"nodecg-io-twitch-chat": "^0.3.0"
}
}
30 changes: 9 additions & 21 deletions stream-bar/extension/StreamBarManager.ts
@@ -1,5 +1,5 @@
/// <reference types="@types/spotify-api" />
import { Manager } from "skates-utils";
import { ChatBot, Manager } from "skates-utils";
import { SpotifyServiceClient } from "nodecg-io-spotify";
import { StreamElementsServiceClient } from "nodecg-io-streamelements";
import { TwitchChatServiceClient } from "nodecg-io-twitch-chat";
Expand All @@ -25,7 +25,7 @@ export class StreamBarManager extends Manager {
constructor(
private spotifyClient: ServiceProvider<SpotifyServiceClient> | undefined,
private streamelementsClient: ServiceProvider<StreamElementsServiceClient> | undefined,
private twitchClient: ServiceProvider<TwitchChatServiceClient> | undefined,
private twitchClient: ServiceProvider<TwitchChatServiceClient> | undefined,
protected nodecg: NodeCG,
) {
super("StreamBar", nodecg);
Expand All @@ -36,28 +36,16 @@ export class StreamBarManager extends Manager {
});
this.register(this.spotifyClient, "SpotifyClient", () => this.initSpotifyClient());
this.register(this.streamelementsClient, "StreamelementsClient", () => this.initStreamelementsClient());
this.register(this.twitchClient, "TwitchClient", () => this.initTwitchClient());
this.register(this.twitchClient, "TwitchClient", () => this.initTwitchClient());
}

async initTwitchClient(): Promise<void> {

const channel = "#skate702";

this.twitchClient?.getClient()?.join(channel)
.then(() => {
this.nodecg.log.info(`Connected stream-bar bot to twitch channel "${channel}"`);

this.twitchClient?.getClient()?.onMessage(async (chan, user, message, _) => {
if (chan === channel.toLowerCase() && message.match("!song")) {
await this.retrieveCurrentSong();
this.twitchClient?.getClient()?.say(channel, `Der aktuelle Song ist "${this.streamBarInfo.value.songName}" von ${this.streamBarInfo.value.artistName}`, {replyTo: user});
}
async initTwitchClient(): Promise<void> {
ChatBot.getInstance().registerCommand("song", true, this.twitchClient, this.nodecg,
async (_: string, __: string, msg) => {
await this.retrieveCurrentSong();
this.twitchClient?.getClient()?.say(ChatBot.CHANNEL, `Der aktuelle Song ist "${this.streamBarInfo.value.songName}" von ${this.streamBarInfo.value.artistName}`, { replyTo: msg });
});
})
.catch((reason) => {
this.nodecg.log.error(`Couldn't connect to twitch: ${reason}.`);
});
}
}

initStreamelementsClient(): void {
this.streamelementsClient?.getClient()?.onSubscriber(data => {
Expand Down
52 changes: 11 additions & 41 deletions was/extension/WasCommandManager.ts
@@ -1,7 +1,7 @@
import { ServiceProvider } from "nodecg-io-core";
import { TwitchChatServiceClient } from "nodecg-io-twitch-chat";
import { NodeCG } from "nodecg-types/types/server";
import { Manager } from "skates-utils";
import { ChatBot, Manager } from "skates-utils";
import { TwitchApiServiceClient } from "nodecg-io-twitch-api";
import { MessageController } from "./MessageController";
import { SQLClient } from "nodecg-io-sql";
Expand All @@ -23,17 +23,21 @@ export class WasCommandManager extends Manager {
this.initReadyListener(this.chatClient);
}

public static readonly CHANNEL = "#skate702";
public static readonly COMMAND = /^!was(\s.*|$)/;
public static readonly TIMEOUT_IN_SECONDS = 10;
public static readonly DB_REFRESH_INTERVAL = 10;

private lastMessage = Date.now();

private messageController = new MessageController(this.nodecg);

async initChat(): Promise<void> {
this.addListener(WasCommandManager.CHANNEL);
ChatBot.getInstance().registerCommand("was", true, this.chatClient, this.nodecg,
async (_: string, __: string, msg) => {
const game = (await this.retrieveCurrentGame()) || "";

if (this.messageController.hasMessage(game)) {
this.chatClient?.getClient()?.say(ChatBot.CHANNEL, this.messageController.getMessage(game)?.toString() || "", { replyTo: msg });
} else {
this.nodecg.log.info(`Unable to find !was output for game: ${game}`);
}
});
}

async initApiClient(): Promise<void> {
Expand All @@ -57,40 +61,6 @@ export class WasCommandManager extends Manager {
}, WasCommandManager.DB_REFRESH_INTERVAL * 1000);
}

private addListener(channel: string) {
this.chatClient
?.getClient()
?.join(channel)
.then(() => {
this.nodecg.log.info(`Connected !was-manager to twitch channel "${channel}"`);

this.chatClient?.getClient()?.onMessage((chan, _, message, _msg) => {
if (chan === channel.toLowerCase() && message.match(WasCommandManager.COMMAND)) {
this.postMessage();
}
});
})
.catch((reason) => {
this.nodecg.log.error(`Couldn't connect to twitch: ${reason}.`);
});
}

private async postMessage() {
if (Date.now() - this.lastMessage > WasCommandManager.TIMEOUT_IN_SECONDS * 1000) {
this.lastMessage = Date.now();

const game = (await this.retrieveCurrentGame()) || "";

if (this.messageController.hasMessage(game)) {
this.chatClient
?.getClient()
?.say(WasCommandManager.CHANNEL, this.messageController.getMessage(game)?.toString() || "");
} else {
this.nodecg.log.info(`Unable to find !was output for game: ${game}`);
}
}
}

private async retrieveCurrentGame() {
const user = await this.twitchApiClient?.getClient()?.helix.users.getMe();
const stream = await user?.getStream();
Expand Down

0 comments on commit 9dc233e

Please sign in to comment.