From 9bfb10738905774740e4ab4e48b5b5d25c264693 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sat, 16 May 2026 02:24:32 -0400 Subject: [PATCH 1/7] API calls --- .../streaming-platforms/twitch/api/index.ts | 7 + .../twitch/api/resource/api-resource-base.ts | 4 + .../twitch/api/resource/chat.ts | 208 +++++++++++++++++- src/types/util-types.d.ts | 12 + 4 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/backend/streaming-platforms/twitch/api/index.ts b/src/backend/streaming-platforms/twitch/api/index.ts index 6c5a2f801..7f1f1bb84 100644 --- a/src/backend/streaming-platforms/twitch/api/index.ts +++ b/src/backend/streaming-platforms/twitch/api/index.ts @@ -169,6 +169,13 @@ class TwitchApi { : this.streamerClient; } + get moderatorId(): string { + const modUser = SettingsManager.getSetting("DefaultModerationUser"); + return modUser === "bot" && this.accounts.bot.loggedIn === true + ? this.accounts.bot.userId + : this.accounts.streamer.userId; + } + get accounts() { return AccountAccess.getAccounts(); } diff --git a/src/backend/streaming-platforms/twitch/api/resource/api-resource-base.ts b/src/backend/streaming-platforms/twitch/api/resource/api-resource-base.ts index 07419c11f..c1c8425ea 100644 --- a/src/backend/streaming-platforms/twitch/api/resource/api-resource-base.ts +++ b/src/backend/streaming-platforms/twitch/api/resource/api-resource-base.ts @@ -23,6 +23,10 @@ export abstract class ApiResourceBase = unknown> return this._apiBase.moderationClient; } + protected get moderatorId(): string { + return this._apiBase.moderatorId; + } + protected get logger() { return this._apiBase.logger; } diff --git a/src/backend/streaming-platforms/twitch/api/resource/chat.ts b/src/backend/streaming-platforms/twitch/api/resource/chat.ts index c63924412..704c62f09 100644 --- a/src/backend/streaming-platforms/twitch/api/resource/chat.ts +++ b/src/backend/streaming-platforms/twitch/api/resource/chat.ts @@ -1,4 +1,4 @@ -import { +import type { HelixChatAnnouncementColor, HelixChatChatter, HelixSendChatAnnouncementParams, @@ -6,9 +6,10 @@ import { HelixUpdateChatSettingsParams, HelixUserEmote } from "@twurple/api"; -import type { SharedChatParticipant } from '../../../../../types'; -import { ApiResourceBase } from "./api-resource-base"; +import type { SharedChatParticipant, SnakeCased, CamelCased } from '../../../../../types'; import type { TwitchApi } from "../"; +import type { EventSubChatMessageData } from "../twurple-private-types"; +import { ApiResourceBase } from "./api-resource-base"; import { TwitchSlashCommandHandler } from "../../chat/twitch-slash-command-handler"; import frontendCommunicator from '../../../../common/frontend-communicator'; @@ -24,6 +25,21 @@ interface ChatMessageRequest { replyToMessageId?: string; } +export type TwitchPinnedChatMessage = { + messageId: string; + broadcasterId: string; + senderUserId: string; + senderUserLogin: string; + senderUserName: string; + pinnedByUserId: string; + pinnedByUserLogin: string; + pinnedByUserName: string; + message: CamelCased; + startsAt: Date; + endsAt?: Date; + updatedAt: Date; +}; + export class TwitchChatApi extends ApiResourceBase { private _slashCommandHandler: TwitchSlashCommandHandler; @@ -450,4 +466,190 @@ export class TwitchChatApi extends ApiResourceBase { this.logger.error(`Failed to get shared chat session`, error.message); } } + + /** + * Gets the currently pinned message in the streamer's chat, if one exists + * @returns The currently pinned chat message, or `null` if no message is pinned + */ + async getPinnedChatMessage(): Promise { + try { + const query: Record = { + // eslint-disable-next-line camelcase + broadcaster_id: this.accounts.streamer.userId, + // eslint-disable-next-line camelcase + moderator_id: this.accounts.streamer.userId + }; + + const response = await this.streamerClient?.callApi<{ + data: SnakeCased[]; + }>({ + type: "helix", + method: "GET", + url: "chat/pins", + query + }); + + const message = response.data[0]; + + return message + ? { + messageId: message.message_id, + broadcasterId: message.broadcaster_id, + senderUserId: message.sender_user_id, + senderUserLogin: message.sender_user_login, + senderUserName: message.sender_user_name, + pinnedByUserId: message.pinned_by_user_id, + pinnedByUserLogin: message.pinned_by_user_login, + pinnedByUserName: message.pinned_by_user_name, + message: { + text: message.message.text, + fragments: message.message.fragments.map((f) => { + return { + type: f.type, + text: f.text, + cheermote: f.type === "cheermote" + ? { + prefix: f.cheermote.prefix, + bits: f.cheermote.bits, + tier: f.cheermote.tier + } + : null, + emote: f.type === "emote" + ? { + id: f.emote.id, + emoteSetId: f.emote.emote_set_id, + ownerId: f.emote.owner_id, + format: f.emote.format + } + : null, + mention: f.type === "mention" + ? { + userId: f.mention.user_id, + userLogin: f.mention.user_login, + userName: f.mention.user_name + } + : null + }; + }) + }, + startsAt: new Date(message.starts_at), + endsAt: message.ends_at + ? new Date(message.ends_at) + : null, + updatedAt: new Date(message.updated_at) + } + : null; + } catch (err) { + const error = err as Error; + this.logger.error(`Failed to get pinned chat message`, error.message); + } + } + + /** + * Pin a chat message to the top of the streamer's chat + * @param messageId ID of the chat message to pin + * @param duration The number of seconds the message should be pinned for. Minimum: 30. Maximum: 1800. If not specified, the message will be pinned until the stream ends. + * @returns `true` if the message was pinned, `false` if it failed + */ + async pinChatMessage(messageId: string, duration?: number): Promise { + try { + const query: Record = { + // eslint-disable-next-line camelcase + broadcaster_id: this.accounts.streamer.userId, + // eslint-disable-next-line camelcase + moderator_id: this.moderatorId, + // eslint-disable-next-line camelcase + message_id: messageId + }; + + if (duration) { + // eslint-disable-next-line camelcase + query.duration_seconds = String(duration); + } + + await this.moderationClient?.callApi({ + type: "helix", + method: "PUT", + url: "chat/pins", + query + }); + + return true; + } catch (err) { + const error = err as Error; + this.logger.error(`Failed to pin chat message`, error.message); + } + + return false; + } + + /** + * Updates the chat message currently pinned to the top of the streamer's chat + * @param messageId ID of the pinned message to update + * @param duration The number of seconds the message should be pinned for. Minimum: 30. Maximum: 1800. If not specified, the message will be pinned until the stream ends. + * @returns `true` if the pinned message was updated, `false` if it failed + */ + async updatePinnedChatMessage(messageId: string, duration?: number): Promise { + try { + const query: Record = { + // eslint-disable-next-line camelcase + broadcaster_id: this.accounts.streamer.userId, + // eslint-disable-next-line camelcase + moderator_id: this.moderatorId, + // eslint-disable-next-line camelcase + message_id: messageId + }; + + if (duration) { + // eslint-disable-next-line camelcase + query.duration_seconds = String(duration); + } + + await this.moderationClient?.callApi({ + type: "helix", + method: "PATCH", + url: "chat/pins", + query + }); + + return true; + } catch (err) { + const error = err as Error; + this.logger.error(`Failed to update pinned chat message`, error.message); + } + + return false; + } + + /** + * Unpins a message from the streamer's chat + * @param messageId ID of the message to unpin + * @returns `true` if the message was unpinned, `false` if it failed + */ + async unpinChatMessage(messageId: string): Promise { + try { + const query: Record = { + // eslint-disable-next-line camelcase + broadcaster_id: this.accounts.streamer.userId, + // eslint-disable-next-line camelcase + moderator_id: this.moderatorId, + // eslint-disable-next-line camelcase + message_id: messageId + }; + + await this.moderationClient?.callApi({ + type: "helix", + method: "DELETE", + url: "chat/pins", + query + }); + + return true; + } catch (err) { + const error = err as Error; + this.logger.error(`Failed to unpin chat message`, error.message); + } + + return false; + } } \ No newline at end of file diff --git a/src/types/util-types.d.ts b/src/types/util-types.d.ts index a3681960a..4614ed51a 100644 --- a/src/types/util-types.d.ts +++ b/src/types/util-types.d.ts @@ -32,4 +32,16 @@ export type SnakeCased = T extends Date ? Array> : T extends object ? { [K in keyof T as K extends string ? CamelToSnake : K]: SnakeCased } + : T; + +type SnakeToCamel = S extends `${infer H}_${infer T}` + ? `${H}${Capitalize>}` + : S; + +export type CamelCased = T extends Date + ? string + : T extends Array + ? Array> + : T extends object + ? { [K in keyof T as K extends string ? SnakeToCamel : K]: CamelCased } : T; \ No newline at end of file From 612e4abc303a5dd2e067e23100166dd053f58aa7 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 27 May 2026 01:32:50 -0400 Subject: [PATCH 2/7] feat(effects): add pin options to Chat effect --- src/backend/effects/builtin/chat.ts | 67 ++++++++++++++++++- .../twitch/api/resource/chat.ts | 32 +++++++-- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/backend/effects/builtin/chat.ts b/src/backend/effects/builtin/chat.ts index 2673179de..be57fda8e 100644 --- a/src/backend/effects/builtin/chat.ts +++ b/src/backend/effects/builtin/chat.ts @@ -1,5 +1,6 @@ -import { EffectType } from '../../../types/effects'; -import { TwitchApi } from '../../streaming-platforms/twitch/api'; +import { EffectType } from "../../../types"; +import { TwitchApi } from "../../streaming-platforms/twitch/api"; +import logger from "../../logwrapper"; const effect: EffectType<{ chatter: string; @@ -7,6 +8,9 @@ const effect: EffectType<{ me: boolean; whisper: string; sendAsReply: boolean; + pin: boolean; + pinUntilEndOfStream: boolean; + pinDuration?: string; }> = { definition: { id: "firebot:chat", @@ -62,6 +66,29 @@ const effect: EffectType<{ + +
+ + +
+ +
+ `, optionsController: ($scope) => { $scope.showWhisperInput = $scope.effect.whisper != null && $scope.effect.whisper !== ''; @@ -71,6 +98,12 @@ const effect: EffectType<{ if (effect.message == null || effect.message === "") { errors.push("Chat message can't be blank."); } + if (effect.pin === true + && effect.pinUntilEndOfStream !== true + && !effect.pinDuration?.length + ) { + errors.push("Must choose pin duration"); + } return errors; }, onTriggerEvent: async ({ effect, trigger }) => { @@ -92,7 +125,35 @@ const effect: EffectType<{ const user = await TwitchApi.users.getUserByName(effect.whisper); await TwitchApi.whispers.sendWhisper(user.id, effect.message, sendAsBot); } else { - await TwitchApi.chat.sendChatMessage(effect.message, effect.sendAsReply ? messageId : null, sendAsBot); + const sendResult = await TwitchApi.chat.sendChatMessage(effect.message, effect.sendAsReply ? messageId : null, sendAsBot); + + if (effect.pin === true) { + if (sendResult.success === true) { + if (sendResult.isSlashCommand !== true) { + let pinDuration: number = undefined; + + if (effect.pinUntilEndOfStream !== true + && !!effect.pinDuration?.length + ) { + pinDuration = Number(effect.pinDuration); + + if (isNaN(pinDuration)) { + pinDuration = undefined; + } else if (pinDuration < 30) { + pinDuration = 30; + } else if (pinDuration > 1800) { + pinDuration = 1800; + } + } + + await TwitchApi.chat.pinChatMessage(sendResult.messageId, pinDuration); + } else { + logger.warn("Chat message not pinned due to being processed as slash command"); + } + } else { + logger.warn("Message failed to send. Unable to pin."); + } + } } return true; diff --git a/src/backend/streaming-platforms/twitch/api/resource/chat.ts b/src/backend/streaming-platforms/twitch/api/resource/chat.ts index 704c62f09..9920f7972 100644 --- a/src/backend/streaming-platforms/twitch/api/resource/chat.ts +++ b/src/backend/streaming-platforms/twitch/api/resource/chat.ts @@ -25,6 +25,13 @@ interface ChatMessageRequest { replyToMessageId?: string; } +interface SendChatMessageResult { + success: boolean; + isSlashCommand: boolean; + messageId?: string; + error?: string; +} + export type TwitchPinnedChatMessage = { messageId: string; broadcasterId: string; @@ -83,11 +90,16 @@ export class TwitchChatApi extends ApiResourceBase { * @param replyToMessageId The ID of the message this should be replying to. Leave as null for non replies. * @param sendAsBot If the chat message should be sent as the bot or not. * If this is set to `false` or the bot account is not logged in, the chat message will be sent as the streamer. - * @returns `true` if sending the chat message was successful or `false` if it failed */ - async sendChatMessage(message: string, replyToMessageId: string = null, sendAsBot = false): Promise { + async sendChatMessage(message: string, replyToMessageId: string = null, sendAsBot = false): Promise { + const sendChatMessageResult: SendChatMessageResult = { + success: false, + isSlashCommand: false + }; + if (!message?.length) { - return false; + sendChatMessageResult.error = "No message specified"; + return sendChatMessageResult; } try { @@ -118,7 +130,9 @@ export class TwitchChatApi extends ApiResourceBase { }); } - return slashCommandResult; + sendChatMessageResult.success = slashCommandResult; + sendChatMessageResult.isSlashCommand = true; + return sendChatMessageResult; } if (slashCommandValidationResult != null @@ -146,16 +160,20 @@ export class TwitchChatApi extends ApiResourceBase { if (result.isSent !== true) { this.logger.error(`Twitch dropped chat message. Reason: ${result.dropReasonMessage}`); - return false; + return sendChatMessageResult; } } - return result.isSent; + sendChatMessageResult.success = result.isSent; + sendChatMessageResult.messageId = result.id; + sendChatMessageResult.error = result.dropReasonMessage; + return sendChatMessageResult; } catch (error) { this.logger.error(`Unable to send ${sendAsBot === true ? "bot" : "steamer"} chat message`, error); + sendChatMessageResult.error = ((error as Error).message ?? error) as string; } - return false; + return sendChatMessageResult; } /** From cf6b21abd3d02f5f2455260fa079a11d88cc2828 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 27 May 2026 10:44:17 -0400 Subject: [PATCH 3/7] feat(effects): Pin/Unpin Chat Message --- src/backend/effects/builtin-effect-loader.js | 4 +- .../effects/builtin/pin-chat-message.ts | 79 +++++++++++++++++++ .../effects/builtin/unpin-chat-message.ts | 32 ++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/backend/effects/builtin/pin-chat-message.ts create mode 100644 src/backend/effects/builtin/unpin-chat-message.ts diff --git a/src/backend/effects/builtin-effect-loader.js b/src/backend/effects/builtin-effect-loader.js index 2dc9b6bee..575c2bdd8 100644 --- a/src/backend/effects/builtin-effect-loader.js +++ b/src/backend/effects/builtin-effect-loader.js @@ -41,6 +41,7 @@ exports.loadEffects = () => { 'moderator-purge', 'moderator-timeout', 'pause-resume-effect-queue', + 'pin-chat-message', 'play-sound', 'play-video', // No migration needed. 'overlay-alert', @@ -69,6 +70,7 @@ exports.loadEffects = () => { 'toggle-scheduled-task', 'toggle-timer', 'trigger-manual-effect-queue', + 'unpin-chat-message', 'update-channel-reward', 'update-counter', 'update-role', @@ -85,7 +87,7 @@ exports.loadEffects = () => { // Deprecated (no remove date) 'deprecated/random-effect', - 'deprecated/sequential-effect', + 'deprecated/sequential-effect' ].forEach((filename) => { const definition = require(`./builtin/${filename}`); EffectManager.registerEffect(definition); diff --git a/src/backend/effects/builtin/pin-chat-message.ts b/src/backend/effects/builtin/pin-chat-message.ts new file mode 100644 index 000000000..917485c27 --- /dev/null +++ b/src/backend/effects/builtin/pin-chat-message.ts @@ -0,0 +1,79 @@ +import type { EffectType } from "../../../types"; +import { TwitchApi } from "../../streaming-platforms/twitch/api"; + +const effect: EffectType<{ + pinUntilEndOfStream: boolean; + pinDuration?: string; +}> = { + definition: { + id: "firebot:pin-chat-message", + name: "Pin Chat Message", + description: "Pin the associated chat message to the top of chat", + icon: "fas fa-thumbtack", + categories: ["chat based", "advanced", "twitch"], + dependencies: ["chat"], + triggers: { + command: true, + event: ["twitch:chat-message"] + } + }, + optionsTemplate: ` + +

This effect pins the associated chat message to the top of chat (for a Command or Chat Message Event)

+
+ + + + + + `, + optionsValidator: (effect) => { + const errors: string[] = []; + if (effect.pinUntilEndOfStream !== true + && !effect.pinDuration?.length + ) { + errors.push("Must choose pin duration"); + } + return errors; + }, + onTriggerEvent: async ({ effect, trigger }) => { + let messageId: string = null; + if (trigger.type === "command") { + messageId = trigger.metadata.chatMessage.id; + } else if (trigger.type === "event") { + // if trigger is event, build chat message from chat event data + messageId = trigger.metadata.eventData.chatMessage.id; + } + + if (messageId) { + let pinDuration: number = undefined; + + if (effect.pinUntilEndOfStream !== true && !!effect.pinDuration?.length) { + pinDuration = Number(effect.pinDuration); + + if (isNaN(pinDuration)) { + pinDuration = undefined; + } else if (pinDuration < 30) { + pinDuration = 30; + } else if (pinDuration > 1800) { + pinDuration = 1800; + } + } + + await TwitchApi.chat.pinChatMessage(messageId, pinDuration); + } + + return true; + } +}; + +export = effect; \ No newline at end of file diff --git a/src/backend/effects/builtin/unpin-chat-message.ts b/src/backend/effects/builtin/unpin-chat-message.ts new file mode 100644 index 000000000..99361fc1f --- /dev/null +++ b/src/backend/effects/builtin/unpin-chat-message.ts @@ -0,0 +1,32 @@ +import type { EffectType } from "../../../types"; +import { TwitchApi } from "../../streaming-platforms/twitch/api"; +import logger from "../../logwrapper"; + +const effect: EffectType = { + definition: { + id: "firebot:unpin-chat-message", + name: "Unpin Chat Message", + description: "Unpin the currently pinned chat message", + icon: "fas fa-comment-slash", + categories: ["chat based", "advanced", "twitch"], + dependencies: ["chat"] + }, + optionsTemplate: ` + +

This effect unpins the currently pinned chat message from the top of chat

+
+ `, + onTriggerEvent: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + + if (pinnedMessage?.messageId) { + await TwitchApi.chat.unpinChatMessage(pinnedMessage.messageId); + } else { + logger.warn("No pinned message to unpin"); + } + + return true; + } +}; + +export = effect; \ No newline at end of file From 5ae35167bfa380eb493dcea3a84cfd544a221f61 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 27 May 2026 11:09:51 -0400 Subject: [PATCH 4/7] feat(dashboard): pin message action --- .../twitch/api/resource/chat.ts | 4 ++ .../chat/feed items/chat-message.js | 57 +++++++++++++------ src/gui/app/services/chat-messages.service.js | 4 ++ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/backend/streaming-platforms/twitch/api/resource/chat.ts b/src/backend/streaming-platforms/twitch/api/resource/chat.ts index 9920f7972..709bf7846 100644 --- a/src/backend/streaming-platforms/twitch/api/resource/chat.ts +++ b/src/backend/streaming-platforms/twitch/api/resource/chat.ts @@ -64,6 +64,10 @@ export class TwitchChatApi extends ApiResourceBase { frontendCommunicator.onAsync("delete-message", async (messageId: string) => { return await this.deleteChatMessage(messageId); }); + + frontendCommunicator.onAsync("chat:pin-message", async (messageId: string) => { + return await this.pinChatMessage(messageId); + }); } /** diff --git a/src/gui/app/directives/chat/feed items/chat-message.js b/src/gui/app/directives/chat/feed items/chat-message.js index 5437708a0..5e5fc620a 100644 --- a/src/gui/app/directives/chat/feed items/chat-message.js +++ b/src/gui/app/directives/chat/feed items/chat-message.js @@ -340,66 +340,84 @@ actions.push({ name: "Details", + actionName: "user:details", icon: "fa-info-circle" }); actions.push({ name: "Delete Message", + actionName: "message:delete", icon: "fa-trash-alt" }); actions.push({ name: "Mention", + actionName: "user:mention", icon: "fa-at" }); actions.push({ name: "Reply To Message", + actionName: "message:reply", icon: "fa-reply" }); actions.push({ name: "Quote Message", + actionName: "message:quote", icon: "fa-quote-right" }); + actions.push({ + name: "Pin Message", + actionName: "message:pin", + icon: "fa-thumbtack" + }); + if (message.username.toLowerCase() !== connectionService.accounts.streamer.username.toLowerCase() && message.username.toLowerCase() !== connectionService.accounts.bot.username.toLowerCase()) { actions.push({ name: "Whisper", + actionName: "user:whisper", icon: "fa-envelope" }); actions.push({ name: "Spotlight Message", + actionName: "message:spotlight", icon: "fa-lightbulb-on" }); actions.push({ name: "Shoutout", + actionName: "user:shoutout", icon: "fa-megaphone" }); if (message.roles.includes("mod")) { actions.push({ name: "Unmod", + actionName: "user:unmod", icon: "fa-user-times" }); } else { actions.push({ name: "Mod", + actionName: "user:mod", icon: "fa-user-plus" }); if (message.roles.includes("vip")) { actions.push({ name: "Remove VIP", + actionName: "user:unvip", icon: "fa-gem" }); } else { actions.push({ name: "Add as VIP", + actionName: "user:vip", icon: "fa-gem" }); } @@ -407,11 +425,13 @@ actions.push({ name: "Timeout", + actionName: "user:timeout", icon: "fa-clock" }); actions.push({ name: "Ban", + actionName: "user:ban", icon: "fa-ban" }); } @@ -426,7 +446,7 @@ }, ...actions.map((a) => { let html = ""; - if (a.name === "Remove VIP") { + if (a.actionName === "user:unvip") { html = `
@@ -447,21 +467,24 @@ return { html: html, click: () => { - $ctrl.messageActionSelected(a.name, message.username, message.userId, message.displayName, message.id, message.rawText, message); + $ctrl.messageActionSelected(a.actionName, message.username, message.userId, message.displayName, message.id, message.rawText, message); } }; })]; }; $ctrl.messageActionSelected = (action, username, userId, displayName, msgId, rawText, message) => { - switch (action.toLowerCase()) { - case "delete message": + switch (action) { + case "message:pin": + chatMessagesService.pinMessage(msgId); + break; + case "message:delete": chatMessagesService.deleteMessage(msgId); break; - case "timeout": + case "user:timeout": updateChatField(`/timeout @${username} 300`); break; - case "ban": + case "user:ban": utilityService .showConfirmationModal({ title: "Ban User", @@ -475,10 +498,10 @@ } }); break; - case "mod": + case "user:mod": viewerRolesService.updateModRoleForUser(username, true); break; - case "unmod": + case "user:unmod": utilityService .showConfirmationModal({ title: "Mod User", @@ -492,33 +515,33 @@ } }); break; - case "add as vip": + case "user:vip": viewerRolesService.updateVipRoleForUser(username, true); break; - case "remove vip": + case "user:unvip": viewerRolesService.updateVipRoleForUser(username, false); break; - case "whisper": + case "user:whisper": updateChatField(`/w @${username} `); break; - case "mention": + case "user:mention": updateChatField(`@${username} `); break; - case "reply to message": + case "message:reply": $ctrl.onReplyClicked({ threadOrReplyMessageId: $ctrl.message.id }); break; - case "quote message": + case "message:quote": updateChatField(`!quote add @${username} ${rawText}`); break; - case "spotlight message": + case "message:spotlight": chatMessagesService.highlightMessage(username, userId, displayName, rawText, message); break; - case "shoutout": + case "user:shoutout": updateChatField(`!so @${username}`); break; - case "details": { + case "user:details": { $ctrl.showUserDetailsModal(userId); break; } diff --git a/src/gui/app/services/chat-messages.service.js b/src/gui/app/services/chat-messages.service.js index c15be20cf..6b3845ac3 100644 --- a/src/gui/app/services/chat-messages.service.js +++ b/src/gui/app/services/chat-messages.service.js @@ -307,6 +307,10 @@ service.hideMessageInChatFeed(data.messageId); }); + service.pinMessage = async (messageId) => { + await backendCommunicator.fireEventAsync("chat:pin-message", messageId); + }; + backendCommunicator.on("twitch:chat:rewardredemption", (redemption) => { if (service.chatQueue && service.chatQueue.length > 0) { const lastQueueItem = service.chatQueue[service.chatQueue.length - 1]; From b77f68215c4ec7c21fcba70707c5b76366939098 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 12 Jun 2026 01:00:39 -0400 Subject: [PATCH 5/7] chore(effects): move pin/unpin effects to Twitch dir --- src/backend/effects/builtin-effect-loader.js | 8 +++++--- .../twitch/effects}/pin-chat-message.ts | 4 ++-- .../twitch/effects}/unpin-chat-message.ts | 8 +++++--- 3 files changed, 12 insertions(+), 8 deletions(-) rename src/backend/{effects/builtin => streaming-platforms/twitch/effects}/pin-chat-message.ts (95%) rename src/backend/{effects/builtin => streaming-platforms/twitch/effects}/unpin-chat-message.ts (81%) diff --git a/src/backend/effects/builtin-effect-loader.js b/src/backend/effects/builtin-effect-loader.js index ffa631046..a055bc266 100644 --- a/src/backend/effects/builtin-effect-loader.js +++ b/src/backend/effects/builtin-effect-loader.js @@ -41,7 +41,6 @@ exports.loadEffects = () => { 'moderator-purge', 'moderator-timeout', 'pause-resume-effect-queue', - 'pin-chat-message', 'play-sound', 'play-video', // No migration needed. 'overlay-alert', @@ -72,7 +71,6 @@ exports.loadEffects = () => { 'toggle-scheduled-task', 'toggle-timer', 'trigger-manual-effect-queue', - 'unpin-chat-message', 'update-channel-reward', 'update-counter', 'update-role', @@ -117,7 +115,11 @@ exports.loadEffects = () => { 'create-prediction', 'lock-prediction', 'resolve-prediction', - 'update-vip-role' + + 'update-vip-role', + + 'pin-chat-message', + 'unpin-chat-message' ].forEach((filename) => { const definition = require(`../streaming-platforms/twitch/effects/${filename}`); EffectManager.registerEffect(definition); diff --git a/src/backend/effects/builtin/pin-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/pin-chat-message.ts similarity index 95% rename from src/backend/effects/builtin/pin-chat-message.ts rename to src/backend/streaming-platforms/twitch/effects/pin-chat-message.ts index 917485c27..6aa1efce1 100644 --- a/src/backend/effects/builtin/pin-chat-message.ts +++ b/src/backend/streaming-platforms/twitch/effects/pin-chat-message.ts @@ -1,5 +1,5 @@ -import type { EffectType } from "../../../types"; -import { TwitchApi } from "../../streaming-platforms/twitch/api"; +import type { EffectType } from "../../../../types"; +import { TwitchApi } from "../api"; const effect: EffectType<{ pinUntilEndOfStream: boolean; diff --git a/src/backend/effects/builtin/unpin-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts similarity index 81% rename from src/backend/effects/builtin/unpin-chat-message.ts rename to src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts index 99361fc1f..c8ad9e1e0 100644 --- a/src/backend/effects/builtin/unpin-chat-message.ts +++ b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts @@ -1,6 +1,8 @@ -import type { EffectType } from "../../../types"; -import { TwitchApi } from "../../streaming-platforms/twitch/api"; -import logger from "../../logwrapper"; +import type { EffectType } from "../../../../types"; +import { TwitchApi } from "../api"; +import { LoggerCache } from "../../../logger-cache"; + +const logger = LoggerCache.getLogger("Effects"); const effect: EffectType = { definition: { From d6f560fd66e9754c99fa2d3dc08261e0ae6f61da Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 12 Jun 2026 02:06:01 -0400 Subject: [PATCH 6/7] feat(vars): add pinned message vars --- .../twitch/variables/chat/index.ts | 12 ++++++---- .../variables/chat/pinned-message/index.ts | 21 +++++++++++++++++ .../pinned-by-user-display-name.ts | 17 ++++++++++++++ .../chat/pinned-message/pinned-by-user-id.ts | 17 ++++++++++++++ .../chat/pinned-message/pinned-by-username.ts | 17 ++++++++++++++ .../pinned-message/pinned-chat-message-id.ts | 17 ++++++++++++++ .../pinned-chat-message-text-only.ts | 23 +++++++++++++++++++ .../pinned-chat-message-user-display-name.ts | 17 ++++++++++++++ .../pinned-chat-message-user-id.ts | 17 ++++++++++++++ .../pinned-chat-message-username.ts | 17 ++++++++++++++ .../pinned-message/pinned-chat-message.ts | 17 ++++++++++++++ 11 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/index.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-display-name.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-id.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-username.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-id.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-text-only.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-display-name.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-id.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-username.ts create mode 100644 src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message.ts diff --git a/src/backend/streaming-platforms/twitch/variables/chat/index.ts b/src/backend/streaming-platforms/twitch/variables/chat/index.ts index 2d12ab3a2..0fd6ab413 100644 --- a/src/backend/streaming-platforms/twitch/variables/chat/index.ts +++ b/src/backend/streaming-platforms/twitch/variables/chat/index.ts @@ -1,13 +1,15 @@ -import chatMessageVariables from './message'; -import chatModeVariables from './mode'; -import moderationVariables from './moderation'; -import sharedChatVariables from './shared-chat'; -import watchStreakVariables from './watch-streak'; +import chatMessageVariables from "./message"; +import chatModeVariables from "./mode"; +import moderationVariables from "./moderation"; +import pinnedMessageVariables from "./pinned-message"; +import sharedChatVariables from "./shared-chat"; +import watchStreakVariables from "./watch-streak"; export default [ ...chatMessageVariables, ...chatModeVariables, ...moderationVariables, + ...pinnedMessageVariables, ...sharedChatVariables, ...watchStreakVariables ]; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/index.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/index.ts new file mode 100644 index 000000000..c43352996 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/index.ts @@ -0,0 +1,21 @@ +import pinnedByUserDisplayName from "./pinned-by-user-display-name"; +import pinnedByUserId from "./pinned-by-user-id"; +import pinnedByUsername from "./pinned-by-username"; +import pinnedChatMessageId from "./pinned-chat-message-id"; +import pinnedChatMessageTextOnly from "./pinned-chat-message-text-only"; +import pinnedChatMessageUserDisplayName from "./pinned-chat-message-user-display-name"; +import pinnedChatMessageUserId from "./pinned-chat-message-user-id"; +import pinnedChatMessageUsername from "./pinned-chat-message-username"; +import pinnedChatMessage from "./pinned-chat-message"; + +export default [ + pinnedByUserDisplayName, + pinnedByUserId, + pinnedByUsername, + pinnedChatMessageId, + pinnedChatMessageTextOnly, + pinnedChatMessageUserDisplayName, + pinnedChatMessageUserId, + pinnedChatMessageUsername, + pinnedChatMessage +]; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-display-name.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-display-name.ts new file mode 100644 index 000000000..07553faf5 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-display-name.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedByUserDisplayName", + description: "The display name of the user who pinned the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.pinnedByUserName ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-id.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-id.ts new file mode 100644 index 000000000..4d8f33939 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-user-id.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedByUserId", + description: "The ID of the user who pinned the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.pinnedByUserId ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-username.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-username.ts new file mode 100644 index 000000000..52556550b --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-by-username.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedByUsername", + description: "The username of the user who pinned the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.pinnedByUserLogin ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-id.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-id.ts new file mode 100644 index 000000000..14a48c1aa --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-id.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessageId", + description: "Outputs the ID of the pinned chat message.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.messageId ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-text-only.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-text-only.ts new file mode 100644 index 000000000..79d0f1c08 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-text-only.ts @@ -0,0 +1,23 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessageTextOnly", + description: "Outputs the pinned chat message text with any emotes, URLs, or cheermotes removed.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + + const textParts = (pinnedMessage?.message?.fragments ?? []) + .filter(mp => mp.type === "text" && mp.text !== null) + .map(mp => mp.text.trim()) + .filter(tp => tp !== ""); + + return textParts.join(" "); + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-display-name.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-display-name.ts new file mode 100644 index 000000000..ad3a059e0 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-display-name.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessageUserDisplayName", + description: "The display name of the user who sent the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.senderUserName ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-id.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-id.ts new file mode 100644 index 000000000..b107faa5f --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-user-id.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessageUserId", + description: "The ID of the user who sent the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.senderUserId ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-username.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-username.ts new file mode 100644 index 000000000..cd2d9c4ed --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message-username.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessageUsername", + description: "The username of the user who sent the currenly pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.senderUserLogin ?? ""; + } +}; + +export default model; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message.ts b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message.ts new file mode 100644 index 000000000..05560d940 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/variables/chat/pinned-message/pinned-chat-message.ts @@ -0,0 +1,17 @@ +import type { ReplaceVariable } from "../../../../../../types"; +import { TwitchApi } from "../../../api"; + +const model : ReplaceVariable = { + definition: { + handle: "pinnedChatMessage", + description: "Outputs the pinned chat message text.", + categories: ["common"], + possibleDataOutput: ["text"] + }, + evaluator: async () => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + return pinnedMessage?.message?.text ?? ""; + } +}; + +export default model; \ No newline at end of file From f43f2fd2ccd95c61a24139b803585fc0a78ca3d3 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 12 Jun 2026 15:24:37 -0400 Subject: [PATCH 7/7] feat(effects): Update Pinned Chat Message --- src/backend/effects/builtin-effect-loader.js | 1 + .../twitch/effects/unpin-chat-message.ts | 1 + .../effects/update-pinned-chat-message.ts | 71 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 src/backend/streaming-platforms/twitch/effects/update-pinned-chat-message.ts diff --git a/src/backend/effects/builtin-effect-loader.js b/src/backend/effects/builtin-effect-loader.js index a055bc266..0f820cc2e 100644 --- a/src/backend/effects/builtin-effect-loader.js +++ b/src/backend/effects/builtin-effect-loader.js @@ -119,6 +119,7 @@ exports.loadEffects = () => { 'update-vip-role', 'pin-chat-message', + 'update-pinned-chat-message', 'unpin-chat-message' ].forEach((filename) => { const definition = require(`../streaming-platforms/twitch/effects/${filename}`); diff --git a/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts index c8ad9e1e0..ab2bc8e50 100644 --- a/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts +++ b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts @@ -25,6 +25,7 @@ const effect: EffectType = { await TwitchApi.chat.unpinChatMessage(pinnedMessage.messageId); } else { logger.warn("No pinned message to unpin"); + return false; } return true; diff --git a/src/backend/streaming-platforms/twitch/effects/update-pinned-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/update-pinned-chat-message.ts new file mode 100644 index 000000000..754e6a580 --- /dev/null +++ b/src/backend/streaming-platforms/twitch/effects/update-pinned-chat-message.ts @@ -0,0 +1,71 @@ +import type { EffectType } from "../../../../types"; +import { TwitchApi } from "../api"; +import { LoggerCache } from "../../../logger-cache"; + +const logger = LoggerCache.getLogger("Effects"); + +const effect: EffectType<{ + pinUntilEndOfStream: boolean; + pinDuration?: string; +}> = { + definition: { + id: "firebot:update-pinned-chat-message", + name: "Update Pinned Chat Message", + description: "Updates the currently pinned chat message", + icon: "fas fa-thumbtack", + categories: ["chat based", "advanced", "twitch"], + dependencies: ["chat"] + }, + optionsTemplate: ` + + + + + `, + optionsValidator: (effect) => { + const errors: string[] = []; + if (effect.pinUntilEndOfStream !== true + && !effect.pinDuration?.length + ) { + errors.push("Must choose pin duration"); + } + return errors; + }, + onTriggerEvent: async ({ effect }) => { + const pinnedMessage = await TwitchApi.chat.getPinnedChatMessage(); + + if (pinnedMessage) { + let pinDuration: number = undefined; + + if (effect.pinUntilEndOfStream !== true && !!effect.pinDuration?.length) { + pinDuration = Number(effect.pinDuration); + + if (isNaN(pinDuration)) { + pinDuration = undefined; + } else if (pinDuration < 30) { + pinDuration = 30; + } else if (pinDuration > 1800) { + pinDuration = 1800; + } + } + + await TwitchApi.chat.updatePinnedChatMessage(pinnedMessage.messageId, pinDuration); + } else { + logger.warn("No pinned message to update"); + return false; + } + + return true; + } +}; + +export = effect; \ No newline at end of file