Skip to content

Commit af06f5c

Browse files
committed
feat: webhooks
1 parent 617930c commit af06f5c

25 files changed

+2211
-1305
lines changed

locales/en/messages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,14 @@
77
},
88
"notificationTitle_newBountyAvailable": {
99
"message": "New Bounty Available"
10+
},
11+
"bountyWebhook_messageContent": {
12+
"message": "$1 has received a new Twitch Bounty and should be available on your Bounty Board if you're eligible!"
13+
},
14+
"bountyWebhook_buttonLabel": {
15+
"message": "Open Bounty Board"
16+
},
17+
"testWebhook_messageContent": {
18+
"message": "This is a test webhook sent by $1. If you see this message, the next Twitch Bounties you'll receive will also be shared here."
1019
}
1120
}

package-lock.json

Lines changed: 1641 additions & 1062 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,40 @@
1010
"test": "tsc --noEmit && eslint --ext=tsx,ts ."
1111
},
1212
"dependencies": {
13-
"@tabler/icons-react": "^3.10.0",
14-
"preact": "^10.22.0",
13+
"@tabler/icons-react": "^3.31.0",
14+
"preact": "^10.26.4",
1515
"webextension-polyfill": "^0.12.0",
16-
"wouter": "^3.3.1"
16+
"wouter": "^3.6.0"
1717
},
1818
"devDependencies": {
19-
"@commitlint/cli": "^19.3.0",
20-
"@commitlint/config-conventional": "^19.2.2",
19+
"@commitlint/cli": "^19.8.0",
20+
"@commitlint/config-conventional": "^19.8.0",
2121
"@csstools/postcss-cascade-layers": "^4.0.6",
2222
"@pandacss/dev": "^0.41.0",
2323
"@seldszar/yael": "^2.2.0",
24-
"@swc/core": "^1.4.16",
25-
"@types/react": "^18.3.3",
26-
"@types/react-dom": "^18.3.0",
24+
"@swc/core": "^1.11.8",
25+
"@types/react": "^18.3.18",
26+
"@types/react-dom": "^18.3.5",
2727
"@types/webextension-polyfill": "^0.10.7",
28-
"@types/webpack-env": "^1.18.4",
29-
"@typescript-eslint/eslint-plugin": "^6.2.1",
30-
"@typescript-eslint/parser": "^6.2.1",
28+
"@types/webpack-env": "^1.18.8",
29+
"@typescript-eslint/eslint-plugin": "^6.21.0",
30+
"@typescript-eslint/parser": "^6.21.0",
3131
"copy-webpack-plugin": "^12.0.2",
3232
"css-loader": "^7.1.2",
33-
"eslint": "^8.46.0",
33+
"eslint": "^8.57.1",
3434
"eslint-config-prettier": "^9.1.0",
35-
"eslint-plugin-prettier": "^5.1.3",
36-
"graphql": "^16.9.0",
37-
"html-webpack-plugin": "^5.6.0",
38-
"husky": "^9.0.11",
39-
"lint-staged": "^15.2.2",
40-
"mini-css-extract-plugin": "^2.9.0",
41-
"postcss": "^8.4.39",
35+
"eslint-plugin-prettier": "^5.2.3",
36+
"graphql": "^16.10.0",
37+
"html-webpack-plugin": "^5.6.3",
38+
"husky": "^9.1.7",
39+
"lint-staged": "^15.4.3",
40+
"mini-css-extract-plugin": "^2.9.2",
41+
"postcss": "^8.5.3",
4242
"postcss-loader": "^8.1.1",
43-
"prettier": "^3.2.5",
43+
"prettier": "^3.5.3",
4444
"swc-loader": "^0.2.6",
45-
"typescript": "^5.4.5",
46-
"webpack": "^5.91.0",
45+
"typescript": "^5.8.2",
46+
"webpack": "^5.98.0",
4747
"webpack-cli": "^5.1.4",
4848
"webpack-merge": "^5.10.0"
4949
},

src/background/index.ts

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { BountyBoardStatus, BountyStatus, WebhookType } from "~/common/constants";
12
import { countBounties } from "~/common/helpers";
2-
import { Bounty } from "~/common/types";
3+
import { Bounty, Webhook } from "~/common/types";
34

4-
import { getBounties, getBountyBoardSettings } from "./twitch";
5+
import { applyMigrations } from "./migrations";
6+
import { getBounties, getBountyBoardSettings, getLogin } from "./twitch";
57

68
function getIconUrl(color: string, size: number) {
79
return browser.runtime.getURL(`icon-${color}-${size}.png`);
@@ -21,7 +23,7 @@ async function fetchStatus() {
2123
const [{ data }] = await getBountyBoardSettings();
2224

2325
if (data == null) {
24-
return "NONE";
26+
return BountyBoardStatus.None;
2527
}
2628

2729
const {
@@ -58,10 +60,10 @@ async function fetchBounties() {
5860

5961
get date() {
6062
switch (node.status) {
61-
case "COMPLETED":
63+
case BountyStatus.Completed:
6264
return Date.parse(node.trackingStoppedAt);
6365

64-
case "LIVE":
66+
case BountyStatus.Live:
6567
return Date.parse(node.expiresAt);
6668
}
6769

@@ -70,7 +72,7 @@ async function fetchBounties() {
7072

7173
get amount() {
7274
switch (node.status) {
73-
case "COMPLETED":
75+
case BountyStatus.Completed:
7476
return node.payoutCents / 100;
7577
}
7678

@@ -80,27 +82,29 @@ async function fetchBounties() {
8082
campaign: {
8183
id: campaign.id,
8284
title: campaign.title,
85+
sponsor: campaign.sponsor,
86+
displayName: campaign.displayName || campaign.game.displayName,
8387
boxArtUrl: campaign.boxArtURL || campaign.game.boxArtURL,
8488
},
8589
};
8690
});
8791
});
8892
}
8993

90-
async function refreshBounties() {
94+
async function refresh() {
9195
let bounties = new Array<Bounty>();
9296
let active = false;
9397

9498
try {
9599
const status = await fetchStatus();
96100

97-
if (status === "ACCEPTED") {
101+
if (status === BountyBoardStatus.Accepted) {
98102
bounties = await fetchBounties();
99103
active = true;
100104
}
101105
} catch {} // eslint-disable-line no-empty
102106

103-
const badgeCount = countBounties(bounties, "AVAILABLE");
107+
const badgeCount = countBounties(bounties, BountyStatus.Available);
104108
const color = active ? "purple" : "gray";
105109

106110
browser.storage.session.set({
@@ -127,21 +131,122 @@ async function refreshBounties() {
127131
});
128132
}
129133

134+
async function formatTestWebhook(webhook: Webhook) {
135+
const login = await getLogin();
136+
137+
switch (webhook.type) {
138+
case WebhookType.Discord:
139+
return {
140+
username: browser.i18n.getMessage("extensionName"),
141+
avatar_url: "https://github.com/Seldszar/Coco/raw/main/public/icon-purple-96.png",
142+
content: browser.i18n.getMessage("testWebhook_messageContent", login),
143+
};
144+
145+
case WebhookType.Slack:
146+
return {
147+
text: browser.i18n.getMessage("testWebhook_messageContent", login),
148+
};
149+
}
150+
151+
throw new RangeError("Webhook type not supported");
152+
}
153+
154+
async function formatBountyWebhook(webhook: Webhook, bounty: Bounty) {
155+
const login = await getLogin();
156+
157+
switch (webhook.type) {
158+
case WebhookType.Discord:
159+
return {
160+
username: browser.i18n.getMessage("extensionName"),
161+
avatar_url: "https://github.com/Seldszar/Coco/raw/main/public/icon-purple-96.png",
162+
content: browser.i18n.getMessage("bountyWebhook_messageContent", login),
163+
embeds: [
164+
{
165+
title: bounty.campaign.sponsor,
166+
description: bounty.campaign.title,
167+
url: "https://dashboard.twitch.tv/bounties",
168+
color: "11032055",
169+
thumbnail: {
170+
url: bounty.campaign.boxArtUrl,
171+
},
172+
footer: {
173+
text: browser.i18n.getMessage("extensionName"),
174+
icon_url: "https://github.com/Seldszar/Coco/raw/main/public/icon-purple-32.png",
175+
},
176+
},
177+
],
178+
};
179+
180+
case WebhookType.Slack:
181+
return {
182+
text: browser.i18n.getMessage("bountyWebhook_messageContent", login),
183+
blocks: [
184+
{
185+
type: "actions",
186+
elements: [
187+
{
188+
type: "section",
189+
text: {
190+
type: "mrkdwn",
191+
text: `*<https://dashboard.twitch.tv/bounties|${bounty.campaign.sponsor}>*\n${bounty.campaign.title}`,
192+
},
193+
accessory: {
194+
type: "image",
195+
image_url: bounty.campaign.boxArtUrl,
196+
alt_text: bounty.campaign.title,
197+
},
198+
},
199+
{
200+
type: "button",
201+
url: "https://dashboard.twitch.tv/bounties",
202+
text: {
203+
type: "plain_text",
204+
text: browser.i18n.getMessage("bountyWebhook_buttonLabel"),
205+
},
206+
},
207+
],
208+
},
209+
],
210+
};
211+
}
212+
213+
throw new RangeError("Webhook type not supported");
214+
}
215+
216+
async function executeWebhook(webhook: Webhook, body: any) {
217+
return fetch(webhook.url, {
218+
headers: [["Content-Type", "application/json"]],
219+
body: JSON.stringify(body),
220+
method: "POST",
221+
});
222+
}
223+
224+
async function executeTestWebhook(webhook: Webhook) {
225+
return executeWebhook(webhook, await formatTestWebhook(webhook));
226+
}
227+
228+
async function executeBountyWebhook(webhook: Webhook, bounty: Bounty) {
229+
return executeWebhook(webhook, await formatBountyWebhook(webhook, bounty));
230+
}
231+
130232
async function checkAlarm() {
131233
if (await browser.alarms.get()) {
132234
return;
133235
}
134236

135-
refreshBounties();
237+
refresh();
136238
}
137239

138240
browser.runtime.onMessage.addListener(async (message) => {
139241
switch (message.type) {
242+
case "executeTestWebhook":
243+
return executeTestWebhook(message.data);
244+
140245
case "openBountyBoard":
141246
return openBountyBoard();
142247

143-
case "refreshBounties":
144-
return refreshBounties();
248+
case "refresh":
249+
return refresh();
145250
}
146251

147252
throw new RangeError("Unknown message type");
@@ -153,7 +258,8 @@ browser.storage.onChanged.addListener(async (changes) => {
153258
if (bounties == null) {
154259
return;
155260
}
156-
const { newValue = [], oldValue } = bounties;
261+
262+
const { newValue, oldValue } = bounties;
157263

158264
if (oldValue == null) {
159265
return;
@@ -162,20 +268,21 @@ browser.storage.onChanged.addListener(async (changes) => {
162268
const { settings } = await browser.storage.local.get({
163269
settings: {
164270
notifications: false,
271+
webhooks: [],
165272
},
166273
});
167274

168-
if (settings.notifications) {
169-
const newBounties = newValue.filter(
170-
(newItem) =>
171-
newItem.status === "AVAILABLE" &&
172-
oldValue.every((oldItem) => newItem.campaign.id !== oldItem.campaign.id),
173-
);
174-
175-
if (newBounties.length === 0) {
176-
return;
177-
}
275+
const newBounties = newValue.filter(
276+
(newItem: Bounty) =>
277+
newItem.status === BountyStatus.Available &&
278+
oldValue.every((oldItem: Bounty) => newItem.campaign.id !== oldItem.campaign.id),
279+
);
178280

281+
if (newBounties.length === 0) {
282+
return;
283+
}
284+
285+
if (settings.notifications) {
179286
newBounties.forEach((bounty: Bounty) => {
180287
browser.notifications.create({
181288
iconUrl: getIconUrl("purple", 96),
@@ -185,11 +292,19 @@ browser.storage.onChanged.addListener(async (changes) => {
185292
});
186293
});
187294
}
295+
296+
settings.webhooks.forEach((webhook: Webhook) => {
297+
newBounties.forEach((bounty: Bounty) => executeBountyWebhook(webhook, bounty));
298+
});
299+
});
300+
301+
browser.runtime.onInstalled.addListener(async () => {
302+
await applyMigrations();
303+
await refresh();
188304
});
189305

190-
browser.runtime.onInstalled.addListener(refreshBounties);
191-
browser.runtime.onStartup.addListener(refreshBounties);
192-
browser.alarms.onAlarm.addListener(refreshBounties);
306+
browser.runtime.onStartup.addListener(refresh);
307+
browser.alarms.onAlarm.addListener(refresh);
193308

194309
browser.action.onClicked.addListener(openBountyBoard);
195310
browser.notifications.onClicked.addListener(openBountyBoard);

src/background/migrations.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const migrations = new Map([
2+
[
3+
"20250306201000_initial",
4+
async () => {
5+
const { settings } = await browser.storage.local.get("settings");
6+
7+
await browser.storage.local.set({
8+
settings: {
9+
theme: "light",
10+
notifications: true,
11+
webhooks: [],
12+
13+
...settings,
14+
},
15+
});
16+
},
17+
],
18+
]);
19+
20+
export async function applyMigrations() {
21+
const { appliedMigrations } = await browser.storage.local.get({
22+
appliedMigrations: [],
23+
});
24+
25+
for (const [name, migration] of migrations) {
26+
const [version] = name.split("_");
27+
28+
if (appliedMigrations.includes(version)) {
29+
continue;
30+
}
31+
32+
await migration();
33+
34+
await browser.storage.local.set({
35+
appliedMigrations: appliedMigrations.concat(version),
36+
});
37+
}
38+
}

0 commit comments

Comments
 (0)