Compare commits
8 commits
fd04f4024f
...
ba0fa94eff
Author | SHA1 | Date | |
---|---|---|---|
ba0fa94eff | |||
685fd1763d | |||
49adf851b1 | |||
1f3d6cdda6 | |||
ffb5a3b497 | |||
795a481aaa | |||
dce6fc007a | |||
ed1d500c23 |
10 changed files with 1289 additions and 85 deletions
9
LICENSE
Normal file
9
LICENSE
Normal file
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 Lenaisten.de
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -1,5 +1,6 @@
|
|||
# lmlfc-bttv
|
||||
|
||||
- install deps `yarn install --production=false`
|
||||
- make "dist" dir: `yarn build`
|
||||
- pack "dist" dir: `yarn build-ext`
|
||||
- install deps: `yarn`
|
||||
- more verbose but same: `yarn install --production=false`
|
||||
- compile extension (into "dist" dir): `yarn build`
|
||||
- pack "dist" dir (runs web-ext): `yarn build-ext`
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
{
|
||||
"name": "lmlfc-bttv",
|
||||
"version": "0.1",
|
||||
"version": "0.2.0",
|
||||
"author": "Jörn-Michael Miehe <joern-michael.miehe@lenaisten.de>",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack.config.js",
|
||||
"build-debug": "webpack --mode=development",
|
||||
"build": "webpack --mode=production",
|
||||
"lint-ext": "web-ext lint --source-dir=dist",
|
||||
"build-ext": "web-ext build --source-dir=dist --artifacts-dir=dist --overwrite-dest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.7",
|
||||
"@types/chrome": "^0.0.254",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"manifest_version": 3,
|
||||
"name": "BTTV für Schnatzbox",
|
||||
"description": "Macht \"bttv:kekw\" und co. in der \"Schnatzbox\" verfügbar.",
|
||||
"version": "0.1",
|
||||
"version": "0.2.0",
|
||||
"icons": {
|
||||
"16": "img/icon-16.png",
|
||||
"32": "img/icon-32.png",
|
||||
|
@ -17,7 +17,7 @@
|
|||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["img/sb_button.png"],
|
||||
"resources": ["main.js.map", "img/sb_button.png"],
|
||||
"matches": ["*://*.lenameyerlandrut-fanclub.de/*"]
|
||||
}
|
||||
],
|
||||
|
|
38
src/bttv_api.ts
Normal file
38
src/bttv_api.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* An "Emote" as returned by the BTTV API
|
||||
*/
|
||||
type BTTV_Emote = {
|
||||
id: string;
|
||||
code: string;
|
||||
imageType: string;
|
||||
animated: boolean;
|
||||
user: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Query BTTV API for an emote graphic
|
||||
*
|
||||
* @param code string associated with the emote graphic (often called "emote" itself)
|
||||
* @returns `null` iff no emote found or error, else URL to a BTTV emote graphic
|
||||
*/
|
||||
export async function bttv_get_url(code: string): Promise<string | null> {
|
||||
// search for emotes
|
||||
const res = await fetch(
|
||||
`https://api.betterttv.net/3/emotes/shared/search?query=${code}&offset=0&limit=50`,
|
||||
);
|
||||
const content = await res.json();
|
||||
|
||||
// sanity check
|
||||
if (!Array.isArray(content)) return null;
|
||||
|
||||
// find exact matches (disregard case)
|
||||
const matches = (content as BTTV_Emote[]).filter(
|
||||
(be) => be.code.toLowerCase() == code.toLowerCase(),
|
||||
);
|
||||
|
||||
// ensure there's emotes left
|
||||
if (matches.length <= 0) return null;
|
||||
|
||||
// return first emote's URL
|
||||
return `//cdn.betterttv.net/emote/${matches[0].id}/1x.${matches[0].imageType}`;
|
||||
}
|
19
src/helpers.ts
Normal file
19
src/helpers.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Concurrently create some mapping of input to output elements.
|
||||
*
|
||||
* @param inputs collection of input elements
|
||||
* @param map_fn function mapping inputs to outputs
|
||||
* @returns ES6 Map object with inputs mapped to outputs where possible
|
||||
*/
|
||||
export async function map_concurrent<In, Out>(
|
||||
inputs: Iterable<In>,
|
||||
map_fn: (input: In) => Promise<Out | null>,
|
||||
): Promise<Map<In, Out | null>> {
|
||||
// apply map_fn to inputs
|
||||
const outputs = await Promise.all([...inputs].map((input) => map_fn(input)));
|
||||
|
||||
// create map object
|
||||
return new Map<In, Out | null>(
|
||||
[...inputs].map((input, index) => [input, outputs[index]]),
|
||||
);
|
||||
}
|
106
src/main.ts
106
src/main.ts
|
@ -1,84 +1,62 @@
|
|||
import { bttv_get_url } from "./bttv_api";
|
||||
import { map_concurrent } from "./helpers";
|
||||
|
||||
const emote_regex = /bttv:([^\s]+)/gi;
|
||||
|
||||
type Emote = {
|
||||
code: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type BTTV_Emote = {
|
||||
id: string;
|
||||
code: string;
|
||||
imageType: string;
|
||||
animated: boolean;
|
||||
user: any;
|
||||
};
|
||||
|
||||
async function bttv_get_url(code: string): Promise<Emote> {
|
||||
const res = await fetch(
|
||||
`https://api.betterttv.net/3/emotes/shared/search?query=${code}&offset=0&limit=50`,
|
||||
);
|
||||
const content = await res.json();
|
||||
|
||||
if (!Array.isArray(content)) return { code: code };
|
||||
const matches = (content as BTTV_Emote[]).filter(
|
||||
(be) => be.code.toLowerCase() == code.toLowerCase(),
|
||||
);
|
||||
|
||||
if (matches.length <= 0) return { code: code };
|
||||
|
||||
return {
|
||||
code: code,
|
||||
url: `//cdn.betterttv.net/emote/${matches[0].id}/1x.${matches[0].imageType}`,
|
||||
};
|
||||
}
|
||||
|
||||
type EmoteMap = { [code: string]: string };
|
||||
|
||||
async function get_emotes(codes: Iterable<string>): Promise<EmoteMap> {
|
||||
const codes_set = new Set(codes);
|
||||
|
||||
// queue emote jobs
|
||||
const queue = [...codes_set].map((code) => bttv_get_url(code));
|
||||
console.log("[lmlfc-bttv] queued", queue.length, "jobs");
|
||||
|
||||
// run
|
||||
const result: EmoteMap = {};
|
||||
for (const job of await Promise.all(queue)) {
|
||||
if (job.url) result[job.code] = job.url;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process "bttv:*" emotes in a string
|
||||
*
|
||||
* @param text the input string
|
||||
* @returns new string with existing "bttv:*" emotes replaced by "[not]" BBCodes
|
||||
*/
|
||||
async function process_text(text: string): Promise<string> {
|
||||
// find "bttv:*" emotes
|
||||
const matches = [...text.matchAll(emote_regex)];
|
||||
const emotes = await get_emotes(matches.map(([_, code]) => code));
|
||||
|
||||
// unique emote codes (the "*" parts)
|
||||
const codes = new Set(matches.map(([_, code]) => code));
|
||||
|
||||
// find emote pics using BTTV API
|
||||
console.log("[lmlfc-bttv]", `searching ${[...codes].length} emotes`);
|
||||
const emotes = await map_concurrent(codes, bttv_get_url);
|
||||
|
||||
// replace emotes with BBCodes
|
||||
for (const emote of matches.reverse()) {
|
||||
const [match, code] = emote;
|
||||
const index = emote.index ?? 0;
|
||||
if (!(code in emotes)) continue;
|
||||
|
||||
// ensure emote exists
|
||||
const url = emotes.get(code);
|
||||
if (url == null) continue;
|
||||
|
||||
// ensure position in input string is known
|
||||
const index = emote.index;
|
||||
if (index == undefined) continue;
|
||||
|
||||
text =
|
||||
text.substring(0, index) +
|
||||
`[not]${emotes[code]}[/not]` +
|
||||
`[not]${url}[/not]` +
|
||||
text.substring(index + match.length);
|
||||
}
|
||||
|
||||
text = text.replace(/\[\/not\]\s+\[not\]/g, "[/not][not]");
|
||||
|
||||
return text;
|
||||
// remove space between adjacent emotes
|
||||
// (maybe do this in emote_regex instead?)
|
||||
return text.replace(/\[\/not\]\s+\[not\]/g, "[/not][not]");
|
||||
}
|
||||
|
||||
(() => {
|
||||
// find chatbox elements
|
||||
const cb_form = document.querySelector("#mgc_cb_evo_form");
|
||||
if (!(cb_form instanceof HTMLFormElement)) return;
|
||||
|
||||
const cb_send = document.querySelector(
|
||||
"#mgc_cb_evo_form > input[type=image]:nth-child(2)",
|
||||
);
|
||||
if (!(cb_send instanceof HTMLInputElement)) return;
|
||||
const cb_input = document.querySelector("#mgc_cb_evo_input");
|
||||
|
||||
// check chatbox elements
|
||||
if (!(cb_form instanceof HTMLFormElement)) return;
|
||||
if (!(cb_send instanceof HTMLInputElement)) return;
|
||||
if (!(cb_input instanceof HTMLInputElement)) return;
|
||||
|
||||
// create "bttv" button
|
||||
const bttv_btn = (() => {
|
||||
const btn = document.createElement("a");
|
||||
btn.style.setProperty("cursor", "pointer");
|
||||
|
@ -90,10 +68,10 @@ async function process_text(text: string): Promise<string> {
|
|||
img.setAttribute("title", "bttv:* emotes ersetzen und absenden");
|
||||
img.style.setProperty("vertical-align", "middle");
|
||||
|
||||
// ******************
|
||||
// * business logic *
|
||||
// ******************
|
||||
btn.addEventListener("click", async () => {
|
||||
const cb_input = document.querySelector("#mgc_cb_evo_input");
|
||||
if (!(cb_input instanceof HTMLInputElement)) return;
|
||||
|
||||
cb_input.value = await process_text(cb_input.value);
|
||||
cb_input.focus();
|
||||
cb_send.click();
|
||||
|
@ -105,5 +83,5 @@ async function process_text(text: string): Promise<string> {
|
|||
|
||||
cb_form.insertBefore(bttv_btn, cb_send);
|
||||
|
||||
console.log("done.");
|
||||
console.log("[lmlfc-bttv]", `loaded`);
|
||||
})();
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"module": "commonjs",
|
||||
"module": "CommonJS",
|
||||
"target": "es6",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist/js",
|
||||
// flags
|
||||
"alwaysStrict": true,
|
||||
"strictNullChecks": true,
|
||||
"noEmitOnError": true,
|
||||
"typeRoots": ["node_modules/@types"]
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ const path = require("path");
|
|||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
|
||||
module.exports = {
|
||||
mode: "production",
|
||||
entry: {
|
||||
main: path.join(__dirname, "src", "main.ts"),
|
||||
},
|
||||
|
@ -10,15 +9,26 @@ module.exports = {
|
|||
path: path.join(__dirname, "dist"),
|
||||
filename: "[name].js",
|
||||
},
|
||||
devtool: "source-map",
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
test: /\.[jt]s$/,
|
||||
exclude: /(node_modules)/,
|
||||
use: [
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
presets: ["@babel/preset-env"],
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: "ts-loader",
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue