This commit is contained in:
Jörn-Michael Miehe 2023-12-30 15:46:06 +00:00
parent 795a481aaa
commit ffb5a3b497
3 changed files with 61 additions and 12 deletions

View file

@ -1,3 +1,6 @@
/**
* An "Emote" as returned by the BTTV API
*/
type BTTV_Emote = { type BTTV_Emote = {
id: string; id: string;
code: string; code: string;
@ -6,21 +9,34 @@ type BTTV_Emote = {
user: any; 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 `[code, url]` tuple where `url` is the URL to a BTTV emote graphic
*/
export async function bttv_get_url( export async function bttv_get_url(
code: string, code: string,
): Promise<[string, string] | null> { ): Promise<[string, string] | null> {
// search for emotes
const res = await fetch( const res = await fetch(
`https://api.betterttv.net/3/emotes/shared/search?query=${code}&offset=0&limit=50`, `https://api.betterttv.net/3/emotes/shared/search?query=${code}&offset=0&limit=50`,
); );
const content = await res.json(); const content = await res.json();
// sanity check
if (!Array.isArray(content)) return null; if (!Array.isArray(content)) return null;
// find exact matches (disregard case)
const matches = (content as BTTV_Emote[]).filter( const matches = (content as BTTV_Emote[]).filter(
(be) => be.code.toLowerCase() == code.toLowerCase(), (be) => be.code.toLowerCase() == code.toLowerCase(),
); );
// ensure there's emotes left
if (matches.length <= 0) return null; if (matches.length <= 0) return null;
// return first emote's URL
return [ return [
code, code,
`//cdn.betterttv.net/emote/${matches[0].id}/1x.${matches[0].imageType}`, `//cdn.betterttv.net/emote/${matches[0].id}/1x.${matches[0].imageType}`,

View file

@ -1,11 +1,23 @@
/**
* Async function mapping some `input` to an `[input, output]` tuple or a `null` if no output exists
*/
type MapFunction<In, Out> = (input: In) => Promise<[In, Out] | null>; type MapFunction<In, Out> = (input: In) => Promise<[In, Out] | null>;
export async function map_parallel<In, Out>( /**
* 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>, inputs: Iterable<In>,
map_fn: MapFunction<In, Out>, map_fn: MapFunction<In, Out>,
): Promise<Map<In, Out>> { ): Promise<Map<In, Out>> {
// apply map_fn to inputs
const queue = [...inputs].map((input) => map_fn(input)); const queue = [...inputs].map((input) => map_fn(input));
// wait for calls to finish
return new Map<In, Out>( return new Map<In, Out>(
(await Promise.all(queue)).filter((job): job is [In, Out] => job !== null), (await Promise.all(queue)).filter((job): job is [In, Out] => job !== null),
); );

View file

@ -1,44 +1,62 @@
import { bttv_get_url } from "./bttv_api"; import { bttv_get_url } from "./bttv_api";
import { map_parallel } from "./helpers"; import { map_concurrent } from "./helpers";
const emote_regex = /bttv:([^\s]+)/gi; const emote_regex = /bttv:([^\s]+)/gi;
/**
* 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> { async function process_text(text: string): Promise<string> {
// find "bttv:*" emotes
const matches = [...text.matchAll(emote_regex)]; const matches = [...text.matchAll(emote_regex)];
const codes = new Set(matches.map(([_, code]) => code));
console.log("[lmlfc-bttv]", `searching ${[...codes].length} emotes`);
const emotes = await map_parallel(codes, bttv_get_url);
// 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;
if (emote.index == undefined) continue;
const index = emote.index;
if (!emotes.has(code)) continue; if (!emotes.has(code)) continue;
// emote exists
const url = emotes.get(code); const url = emotes.get(code);
if (emote.index == undefined) continue;
// position in input string is known
const index = emote.index;
text = text =
text.substring(0, index) + text.substring(0, index) +
`[not]${url}[/not]` + `[not]${url}[/not]` +
text.substring(index + match.length); text.substring(index + match.length);
} }
// remove space between adjacent emotes
// (maybe do this in emote_regex instead?)
return text.replace(/\[\/not\]\s+\[not\]/g, "[/not][not]"); 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"); 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; 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");
@ -50,6 +68,9 @@ 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 () => {
cb_input.value = await process_text(cb_input.value); cb_input.value = await process_text(cb_input.value);
cb_input.focus(); cb_input.focus();