From 20749860ada1b0e8b3b00be8683d4e61530b2ae3 Mon Sep 17 00:00:00 2001 From: Danil Nikolaev Date: Sun, 18 Jan 2026 16:28:53 +0300 Subject: [PATCH] add transliteration command (en <-> ru) --- src/commands/transliteration.ts | 113 ++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/util/utils.ts | 6 ++ 3 files changed, 121 insertions(+) create mode 100644 src/commands/transliteration.ts diff --git a/src/commands/transliteration.ts b/src/commands/transliteration.ts new file mode 100644 index 0000000..c4e937c --- /dev/null +++ b/src/commands/transliteration.ts @@ -0,0 +1,113 @@ +import {ChatCommand} from "../base/chat-command"; +import {Message} from "typescript-telegram-bot-api"; +import {logError, replyToMessage} from "../util/utils"; + +const EN = + "`qwertyuiop[]asdfghjkl;'zxcvbnm,./" + + "~QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?" + + "1234567890-=" + + "!@#$%^&*()_+"; + +const RU = + "ёйцукенгшщзхъфывапролджэячсмитьбю." + + "ЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ," + + "1234567890-=" + + "!\"№;%:?*()_+"; + +function makeMap(from: string, to: string): Map { + if (from.length !== to.length) { + throw new Error(`Layout maps must be same length: ${from.length} vs ${to.length}`); + } + const m = new Map(); + for (let i = 0; i < from.length; i++) m.set(from[i], to[i]); + return m; +} + +const enToRu = makeMap(EN, RU); +const ruToEn = makeMap(RU, EN); + +function swapLayout(text: string, map: Map): string { + let out = ""; + for (const ch of text) out += map.get(ch) ?? ch; + return out; +} + +export const toRuLayout = (text: string) => swapLayout(text, enToRu); +export const toEnLayout = (text: string) => swapLayout(text, ruToEn); + +const reCyr = /\p{Script=Cyrillic}/u; +const reLat = /\p{Script=Latin}/u; + +export type ScriptGuess = "ru" | "en" | "mixed" | "unknown"; + +export function detectScript(text: string): ScriptGuess { + let cyr = 0, lat = 0; + + for (const ch of text) { + if (reCyr.test(ch)) cyr++; + else if (reLat.test(ch)) lat++; + } + + if (cyr === 0 && lat === 0) return "unknown"; + if (cyr > 0 && lat > 0) return "mixed"; + return cyr > 0 ? "ru" : "en"; +} + +const EN_VOWELS = /[aeiouy]/i; +const RU_VOWELS = /[аеёиоуыэюя]/i; + +function vowelRatio(text: string, reLetter: RegExp, reVowel: RegExp): number { + let letters = 0, vowels = 0; + for (const ch of text) { + if (reLetter.test(ch)) { + letters++; + if (reVowel.test(ch)) vowels++; + } + } + return letters === 0 ? 0 : vowels / letters; +} + +function looksLikeEnglish(text: string): boolean { + const ratio = vowelRatio(text, /\p{Script=Latin}/u, EN_VOWELS); + return ratio >= 0.20; +} + +function looksLikeRussian(text: string): boolean { + const ratio = vowelRatio(text, /\p{Script=Cyrillic}/u, RU_VOWELS); + return ratio >= 0.18; +} + +export function fixLayoutAuto( + text: string, + toRuLayout: (s: string) => string, + toEnLayout: (s: string) => string, +): string { + const guess = detectScript(text); + + if (guess === "en") { + if (looksLikeEnglish(text)) return text; + return toRuLayout(text); + } + + if (guess === "ru") { + if (looksLikeRussian(text)) return text; + return toEnLayout(text); + } + + return text; +} + +export class Transliteration extends ChatCommand { + regexp = /^\/tr\s([^]+)/i; + + async execute(msg: Message, match?: RegExpExecArray): Promise { + const text = (match ? match[1] : "").trim(); + if (text.length === 0) { + return; + } + + const newText = fixLayoutAuto(text, toRuLayout, toEnLayout); + + await replyToMessage(msg, newText).catch(logError); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c6863c1..531479a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ import {OllamaRequest} from "./model/ollama-request"; import {CallbackCommand} from "./base/callback-command"; import {OllamaCancel} from "./callback_commands/ollama-cancel"; import {MistralChat} from "./commands/mistral-chat"; +import {Transliteration} from "./commands/transliteration"; process.setUncaughtExceptionCaptureCallback(console.error); @@ -133,6 +134,7 @@ export const chatCommands: ChatCommand[] = [ new Distort(), new Dice(), new Title(), + new Transliteration(), new AdminsAdd(), new AdminsRemove(), diff --git a/src/util/utils.ts b/src/util/utils.ts index 90b2557..0a927fe 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -798,4 +798,10 @@ export async function imageToBase64(filePath: string): Promise { resolve(dataUrl); }); }); +} + +export function ifTrue(exp?: never): boolean { + if (!exp) return false; + + return ["true", "t", "y", 1, "1"].includes(exp); } \ No newline at end of file