Compare commits

..

8 commits

10 changed files with 1289 additions and 85 deletions

9
LICENSE Normal file
View 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.

View file

@ -1,5 +1,6 @@
# lmlfc-bttv # lmlfc-bttv
- install deps `yarn install --production=false` - install deps: `yarn`
- make "dist" dir: `yarn build` - more verbose but same: `yarn install --production=false`
- pack "dist" dir: `yarn build-ext` - compile extension (into "dist" dir): `yarn build`
- pack "dist" dir (runs web-ext): `yarn build-ext`

View file

@ -1,15 +1,19 @@
{ {
"name": "lmlfc-bttv", "name": "lmlfc-bttv",
"version": "0.1", "version": "0.2.0",
"author": "Jörn-Michael Miehe <joern-michael.miehe@lenaisten.de>", "author": "Jörn-Michael Miehe <joern-michael.miehe@lenaisten.de>",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"build": "webpack --config webpack.config.js", "build-debug": "webpack --mode=development",
"build": "webpack --mode=production",
"lint-ext": "web-ext lint --source-dir=dist", "lint-ext": "web-ext lint --source-dir=dist",
"build-ext": "web-ext build --source-dir=dist --artifacts-dir=dist --overwrite-dest" "build-ext": "web-ext build --source-dir=dist --artifacts-dir=dist --overwrite-dest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.7",
"@babel/preset-env": "^7.23.7",
"@types/chrome": "^0.0.254", "@types/chrome": "^0.0.254",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",

View file

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "BTTV für Schnatzbox", "name": "BTTV für Schnatzbox",
"description": "Macht \"bttv:kekw\" und co. in der \"Schnatzbox\" verfügbar.", "description": "Macht \"bttv:kekw\" und co. in der \"Schnatzbox\" verfügbar.",
"version": "0.1", "version": "0.2.0",
"icons": { "icons": {
"16": "img/icon-16.png", "16": "img/icon-16.png",
"32": "img/icon-32.png", "32": "img/icon-32.png",
@ -17,7 +17,7 @@
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["img/sb_button.png"], "resources": ["main.js.map", "img/sb_button.png"],
"matches": ["*://*.lenameyerlandrut-fanclub.de/*"] "matches": ["*://*.lenameyerlandrut-fanclub.de/*"]
} }
], ],

38
src/bttv_api.ts Normal file
View 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
View 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]]),
);
}

View file

@ -1,84 +1,62 @@
import { bttv_get_url } from "./bttv_api";
import { map_concurrent } from "./helpers";
const emote_regex = /bttv:([^\s]+)/gi; const emote_regex = /bttv:([^\s]+)/gi;
type Emote = { /**
code: string; * Process "bttv:*" emotes in a string
url?: string; *
}; * @param text the input string
* @returns new string with existing "bttv:*" emotes replaced by "[not]" BBCodes
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;
}
async function process_text(text: string): Promise<string> { async function process_text(text: string): Promise<string> {
// find "bttv:*" emotes
const matches = [...text.matchAll(emote_regex)]; 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()) { for (const emote of matches.reverse()) {
const [match, code] = emote; 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 =
text.substring(0, index) + text.substring(0, index) +
`[not]${emotes[code]}[/not]` + `[not]${url}[/not]` +
text.substring(index + match.length); text.substring(index + match.length);
} }
text = text.replace(/\[\/not\]\s+\[not\]/g, "[/not][not]"); // remove space between adjacent emotes
// (maybe do this in emote_regex instead?)
return text; return text.replace(/\[\/not\]\s+\[not\]/g, "[/not][not]");
} }
(() => { (() => {
// find chatbox elements
const cb_form = document.querySelector("#mgc_cb_evo_form"); const cb_form = document.querySelector("#mgc_cb_evo_form");
if (!(cb_form instanceof HTMLFormElement)) return;
const cb_send = document.querySelector( const cb_send = document.querySelector(
"#mgc_cb_evo_form > input[type=image]:nth-child(2)", "#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 bttv_btn = (() => {
const btn = document.createElement("a"); const btn = document.createElement("a");
btn.style.setProperty("cursor", "pointer"); 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.setAttribute("title", "bttv:* emotes ersetzen und absenden");
img.style.setProperty("vertical-align", "middle"); img.style.setProperty("vertical-align", "middle");
// ******************
// * business logic *
// ******************
btn.addEventListener("click", async () => { 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.value = await process_text(cb_input.value);
cb_input.focus(); cb_input.focus();
cb_send.click(); cb_send.click();
@ -105,5 +83,5 @@ async function process_text(text: string): Promise<string> {
cb_form.insertBefore(bttv_btn, cb_send); cb_form.insertBefore(bttv_btn, cb_send);
console.log("done."); console.log("[lmlfc-bttv]", `loaded`);
})(); })();

View file

@ -1,13 +1,18 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"module": "commonjs", "module": "CommonJS",
"target": "es6", "target": "es6",
"esModuleInterop": true,
"sourceMap": true, "sourceMap": true,
"rootDir": "src", // flags
"outDir": "dist/js", "alwaysStrict": true,
"strictNullChecks": true,
"noEmitOnError": true, "noEmitOnError": true,
"typeRoots": ["node_modules/@types"] "noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"forceConsistentCasingInFileNames": true
} }
} }

View file

@ -2,7 +2,6 @@ const path = require("path");
const CopyPlugin = require("copy-webpack-plugin"); const CopyPlugin = require("copy-webpack-plugin");
module.exports = { module.exports = {
mode: "production",
entry: { entry: {
main: path.join(__dirname, "src", "main.ts"), main: path.join(__dirname, "src", "main.ts"),
}, },
@ -10,15 +9,26 @@ module.exports = {
path: path.join(__dirname, "dist"), path: path.join(__dirname, "dist"),
filename: "[name].js", filename: "[name].js",
}, },
devtool: "source-map",
resolve: { resolve: {
extensions: [".ts", ".js"], extensions: [".ts", ".js"],
}, },
module: { module: {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.[jt]s$/,
exclude: /(node_modules)/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"],
},
},
{
loader: "ts-loader", loader: "ts-loader",
exclude: /node_modules/, },
],
}, },
], ],
}, },

1150
yarn.lock

File diff suppressed because it is too large Load diff