= {
+ // 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/backend/streaming-platforms/twitch/effects/pin-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/pin-chat-message.ts
new file mode 100644
index 000000000..6aa1efce1
--- /dev/null
+++ b/src/backend/streaming-platforms/twitch/effects/pin-chat-message.ts
@@ -0,0 +1,79 @@
+import type { EffectType } from "../../../../types";
+import { TwitchApi } from "../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/streaming-platforms/twitch/effects/unpin-chat-message.ts b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts
new file mode 100644
index 000000000..ab2bc8e50
--- /dev/null
+++ b/src/backend/streaming-platforms/twitch/effects/unpin-chat-message.ts
@@ -0,0 +1,35 @@
+import type { EffectType } from "../../../../types";
+import { TwitchApi } from "../api";
+import { LoggerCache } from "../../../logger-cache";
+
+const logger = LoggerCache.getLogger("Effects");
+
+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 false;
+ }
+
+ return true;
+ }
+};
+
+export = effect;
\ No newline at end of file
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
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
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];
diff --git a/src/types/util-types.ts b/src/types/util-types.ts
index 0819c1478..2d045a743 100644
--- a/src/types/util-types.ts
+++ b/src/types/util-types.ts
@@ -49,4 +49,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