├── .dockerignore ├── petlyuryk.png ├── public ├── favicon.png ├── index.html ├── style.css └── script.js ├── .editorconfig ├── src ├── data │ ├── responses │ │ ├── ua-dunno-ask.json │ │ ├── ua-dunno-end.json │ │ ├── ua-friendly.json │ │ ├── ua-hostile-short.json │ │ ├── ua-hostile-russian.json │ │ ├── ua-hostile-piss.json │ │ ├── ua-unused.json │ │ ├── ua-hostile-generic.json │ │ ├── ua-welcome.json │ │ ├── ua-hymn.json │ │ └── ua-anecdote.json │ ├── insults │ │ ├── ru.json │ │ └── ua.json │ ├── praises │ │ └── ua.json │ └── common │ │ ├── ru.json │ │ └── ua.json ├── main.d.ts ├── store │ ├── index.ts │ ├── store-piss.ts │ ├── store-message.ts │ ├── store-chat.ts │ └── redis.ts ├── rest │ ├── rest-message.ts │ ├── rest-chat.ts │ └── index.ts ├── neural │ ├── modules │ │ ├── module-ru.ts │ │ ├── module-ua-warship.ts │ │ ├── module-ua-love.ts │ │ ├── module-ua-alert.ts │ │ ├── module-ua-age.ts │ │ ├── module-ua-market.ts │ │ ├── module-ua-core.ts │ │ └── module-ua-chatter.ts │ ├── language.ts │ ├── corpus.ts │ ├── index.ts │ └── index.test.ts ├── index.ts ├── regexp │ ├── utils.ts │ ├── index.test.ts │ └── index.ts ├── logger.ts ├── controller.ts ├── nlp.d.ts └── bot.ts ├── .husky └── pre-commit ├── Dockerfile ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature.md │ └── bug.md └── workflows │ └── neural.yaml ├── tsconfig.json ├── .eslintrc ├── docker-compose.yml ├── README.rst ├── package.json └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /petlyuryk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sweetpalma/petlyuryk/HEAD/petlyuryk.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sweetpalma/petlyuryk/HEAD/public/favicon.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.yml] 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /src/data/responses/ua-dunno-ask.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Поясни.", 3 | "Не викупив.", 4 | "Не зрозумів.", 5 | "Що тобі треба?", 6 | "Що?", 7 | "Га?" 8 | ] -------------------------------------------------------------------------------- /src/data/insults/ru.json: -------------------------------------------------------------------------------- 1 | [ 2 | "быдло", 3 | "хуесос", 4 | "хуисос", 5 | "идиот", 6 | "пидор", 7 | "пидорас", 8 | "ебанько", 9 | "уебан", 10 | "ебан", 11 | "тварь" 12 | ] 13 | -------------------------------------------------------------------------------- /src/data/responses/ua-dunno-end.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Почув тебе.", 3 | "На зв'язку.", 4 | "Твій вік земний не безмежний - а ти витрачаєш його на вбогу нейронну мережу. Задумайся.", 5 | "Я всього лиш бот." 6 | ] -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # Lint staged: 5 | npx lint-staged --allow-empty 6 | 7 | # Run tests and TODO highlighter: 8 | npm run test 9 | npm run todo 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | WORKDIR /app 3 | COPY package-lock.json . 4 | COPY package.json . 5 | RUN npm install 6 | COPY tsconfig.json . 7 | COPY public public 8 | COPY src src 9 | CMD ["npm", "start"] 10 | -------------------------------------------------------------------------------- /src/data/responses/ua-friendly.json: -------------------------------------------------------------------------------- 1 | [ 2 | "База.", 3 | "Ви найкращі.", 4 | "Я даю Вам кавун.", 5 | "Ваша мама - НАТО? Тоді звідки в неї така БАЗА?", 6 | "Ви одержуєте п'ятнадцять УНР-кредитів.", 7 | "Директорія пишається Вами.", 8 | "Ви молодець." 9 | ] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Installation: 2 | node_modules/ 3 | npm-debug.log 4 | 5 | # Redis Data: 6 | /data/ 7 | 8 | # Logs: 9 | /logs/ 10 | 11 | # Build results: 12 | dist/ 13 | 14 | # MacOS shit: 15 | .DS_Store 16 | 17 | # Environment data: 18 | .env 19 | 20 | # Sublime SFTP config: 21 | sftp-config.json 22 | 23 | # Logs: 24 | logs/ 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Фіча 3 | about: Запропонуйте щось новеньке. 4 | title: "[ФІЧА]" 5 | labels: покращення 6 | assignees: sweetpalma 7 | 8 | --- 9 | 10 | **Опишіть свою ідею** 11 | Що треба додати? Чого не вистачає в Петлюрика. 12 | 13 | **Обгрунтуйте свої зміни** 14 | Чому це потрібно робити? Що це покращить? 15 | 16 | **Додатковий контекст** 17 | Маєте що додати? 18 | -------------------------------------------------------------------------------- /src/data/responses/ua-hostile-short.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Соси", 3 | "Смокчи", 4 | "Ти хуй", 5 | "Зригни", 6 | "З'єби", 7 | "Хуй на", 8 | "Відсмокчи", 9 | "Йди нахуй", 10 | "Запетлися", 11 | "Відсмокчеш?", 12 | "Закрий пиздак", 13 | "Дякую що відсмоктав", 14 | "Затули пельку", 15 | "Іди в сраку", 16 | "Йди до дупи", 17 | "Поїж гівна", 18 | "Пішов нахуй", 19 | "Завали єбало", 20 | "Хуй будеш?" 21 | ] 22 | -------------------------------------------------------------------------------- /src/data/praises/ua.json: -------------------------------------------------------------------------------- 1 | [ 2 | "молодець", 3 | "мололдчинка", 4 | "харош", 5 | "хорош", 6 | "хороший", 7 | "гарний", 8 | "розумний", 9 | "найкращий", 10 | "козак", 11 | "козацюра", 12 | "крутий", 13 | "вінничанин", 14 | "вінницький", 15 | "розумний", 16 | "базовний", 17 | "база", 18 | "няша", 19 | "няшка", 20 | "сонце", 21 | "сонечко", 22 | "квіточка", 23 | "сексуальний", 24 | "сексі", 25 | "зайчик" 26 | ] 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "ts-node": { 4 | "require": ["tsconfig-paths/register"], 5 | "files": true 6 | }, 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { "~/*": ["./src/*"] }, 10 | "typeRoots": ["./node_modules/@types", "./src"], 11 | "exactOptionalPropertyTypes": true, 12 | "noImplicitOverride": true, 13 | "resolveJsonModule": true 14 | }, 15 | } -------------------------------------------------------------------------------- /src/data/responses/ua-hostile-russian.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Унтерменш, спок", 3 | "Русочмоня, спокуха", 4 | "Узкоязичних не питали.", 5 | "Російськомовна свинюко, заткнися", 6 | "Завали свою пащеку - смердить.", 7 | "Рускій воєнний корабль, йди нахуй.", 8 | "З'єби назад до себе в болото", 9 | "Ванька-встанька, що таке?", 10 | "Якого хуя не державною?", 11 | "Запетлися, москалику", 12 | "Московита спитати забули", 13 | "Здохни, русня" 14 | ] 15 | -------------------------------------------------------------------------------- /src/main.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | 6 | /** 7 | * Type that could be either T or undefined. 8 | */ 9 | declare type Optional = ( 10 | T | undefined 11 | ); 12 | 13 | 14 | /** 15 | * Type that could be either T or null. 16 | */ 17 | declare type Nullable = ( 18 | T | null 19 | ); 20 | 21 | -------------------------------------------------------------------------------- /src/data/responses/ua-hostile-piss.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Посцяв тобі на лице", 3 | "Пісьнув тобі на картуза", 4 | "Оросив твоє чоло сечею", 5 | "Вмив твій золотий лик сечею", 6 | "Відправив транш сечовини тобі за щоку, перевіряй", 7 | "Пустив Живчика тобі на капелюх, ознайомся", 8 | "Пустив струмінь на твою блискучу маківку", 9 | "Сциконув тобі у вушко, зверни увагу", 10 | "Насцяв тобі за шиворот, перевіряй", 11 | "Залляв божою росою твої ясні очі", 12 | "Надзюрив тобі в рота" 13 | ] 14 | -------------------------------------------------------------------------------- /src/data/responses/ua-unused.json: -------------------------------------------------------------------------------- 1 | // THIS FILE IS NOT USED ANYWHERE: 2 | [ 3 | 4 | // Hostile responses (Short): 5 | "Дістань хуй з-за щоки та скажи нормально.", 6 | "Твої висери лишень забавляють мене, продовжуй", 7 | "Вимий свою пащеку з милом, може буде менше смердіти", 8 | "Здивований що таке як ти змогло вправитись з клавіатурою", 9 | "Не пиши сюди більше, від тебе лайном смердить", 10 | 11 | // Hostile responses (Russian): 12 | "Що ти тут забув, палений бурят?" 13 | 14 | ] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Помилка 3 | about: Виправте існуючі проблеми. 4 | title: "[БАГ]" 5 | labels: бажинка 6 | assignees: sweetpalma 7 | 8 | --- 9 | 10 | **Опишіть проблему** 11 | Петлюрик обматюкав вас без причини? Сказав щось дурне? Поділіться з нами! 12 | 13 | **Приведіть приклад** 14 | Додайте знімки екрану чи зразки повідомлень на які Петлюрик реагував неадекватно. 15 | 16 | **Поділіться очікуваннями** 17 | Як повинен був зреагувати бот? Опишіть Ваші очікування. 18 | -------------------------------------------------------------------------------- /src/data/common/ru.json: -------------------------------------------------------------------------------- 1 | [ 2 | "ты", 3 | "всу", 4 | "что", 5 | "тот", 6 | "как", 7 | "кто", 8 | "они", 9 | "она", 10 | "еще", 11 | "ебут", 12 | "свой", 13 | "будет", 14 | "разве", 15 | "лично", 16 | "смешно", 17 | "человек", 18 | "только", 19 | "себя", 20 | "какой", 21 | "хохлов", 22 | "привет", 23 | "человек", 24 | "расскажи", 25 | "благодарю", 26 | "спасибо", 27 | "отвечал", 28 | "отвечаешь", 29 | "понятно", 30 | "его", 31 | "время", 32 | "россии", 33 | "россия", 34 | "россию", 35 | "украина", 36 | "русский", 37 | "челюсть", 38 | "путин" 39 | ] 40 | -------------------------------------------------------------------------------- /.github/workflows/neural.yaml: -------------------------------------------------------------------------------- 1 | name: Petlyuryk Neural CI 2 | on: [push, pull_request] 3 | jobs: 4 | validate: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [14.x, 16.x, 18.x] 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Install Node ${{ matrix.node-version }} 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: ${{ matrix.node-version }} 16 | cache: 'npm' 17 | - name: Install dependencies 18 | run: npm install 19 | - name: Run linter 20 | run: npm run lint 21 | - name: Run tests 22 | run: npm run test 23 | -------------------------------------------------------------------------------- /src/data/responses/ua-hostile-generic.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Ти хуй", 3 | "Йди нахуй", 4 | "Запетлися", 5 | "Затули пельку", 6 | "Боже, яке ти вбоге", 7 | "Боже, яке кончене", 8 | "Срав тобі на лице", 9 | "Наклав тобі на маківку", 10 | "Що ти там гавкаєш?", 11 | "Що ти там пукнуло, вбоге?", 12 | "В тебе рот в гівні", 13 | "І висрала ж мати таке вбоге", 14 | "На словах ти пан Сократ, а на ділі - їбанат", 15 | "На словах Іван Багряний, а на ділі – лох їбаний", 16 | "На словах ти пан Франко, а на ділі - єбанько", 17 | "Ти пишеш такі жалюгідні речі що аж смішно читати", 18 | "І не соромно тобі таку хуйню писати?", 19 | "Тебе коли єбали, що на дупі написали?" 20 | ] 21 | -------------------------------------------------------------------------------- /src/data/responses/ua-welcome.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Вітаю, я - Петлюрик, перший у світі бот-русофоб.\n\nЯ був створений для захисту від російської мови та любителів так званого \"руського міра\". Я абсолютно не толерую російську і буду нещадно карати за будь який її прояв - просто додайте мене куди треба та спостерігайте. \n\nКрім цього я вмію показувати карту повітряних тривог, докладати про втрати русні, виводити актуальний курс валют та багато чого іншого. Просто спробуйте самі:\n\n- Петлюрику, де лунають сирени?\n- Петлюрику, як там біток?\n- Петлюрику, як там гривня?\n- Петлюрику, шо по русні?\n- Петлюрику, розкажи анекдот!\n- Петлюрику, струмінь!\n\nІ не забувайте:\nНаша русофобія - недостатня." 3 | ] -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { RedisConnection } from './redis'; 6 | import { StoreMessage } from './store-message'; 7 | import { StoreChat } from './store-chat'; 8 | import { StorePiss } from './store-piss'; 9 | 10 | 11 | /** 12 | * Main store entry point. 13 | */ 14 | export class Store { 15 | public readonly message = new StoreMessage(); 16 | public readonly chat = new StoreChat(); 17 | public readonly piss = new StorePiss(); 18 | 19 | public static async connect(url: string) { 20 | return RedisConnection.connect(url); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/store/store-piss.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { RedisStore, RedisStoreDocument } from './redis'; 6 | 7 | 8 | export interface StorePissDocument extends RedisStoreDocument { 9 | count: number; 10 | } 11 | 12 | 13 | export class StorePiss extends RedisStore { 14 | public readonly domain = 'petlyuryk:piss'; 15 | public async readCount(chatId: string) { 16 | const document = await this.read(chatId); 17 | return document?.count || 0; 18 | } 19 | 20 | public async bumpCount(chatId: string) { 21 | await this.upsert(chatId, {}, { count: 0 }); 22 | await this.updateIncrement(chatId, 'count'); 23 | } 24 | } -------------------------------------------------------------------------------- /src/store/store-message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { RedisStore, RedisStoreDocument } from './redis'; 6 | 7 | 8 | export interface StoreMessageDocument extends RedisStoreDocument { 9 | createdAt: Date; 10 | updatedAt: Date; 11 | delivered: boolean; 12 | intent: string; 13 | chatId: string; 14 | userId: string; 15 | textOutput: string; 16 | textInput: string; 17 | } 18 | 19 | 20 | export class StoreMessage extends RedisStore { 21 | public readonly domain = 'petlyuryk:message'; 22 | public override readonly index = { 23 | id: 'TAG', 24 | intent: 'TEXT', 25 | chatId: 'TAG', 26 | userId: 'TAG', 27 | textOutput: 'TEXT', 28 | textInput: 'TEXT', 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/data/responses/ua-hymn.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Ще не вмерла України і слава, і воля.", 3 | "Ще нам, браття молодії, усміхнеться доля.", 4 | "Згинуть наші вороженьки, як роса на сонці,", 5 | "Запануєм і ми, браття, у своїй сторонці.", 6 | "", 7 | "Душу й тіло ми положим за нашу свободу,", 8 | "І покажем, що ми, браття, козацького роду.", 9 | "", 10 | "Станем, браття, в бій кривавий від Сяну до Дону,", 11 | "В ріднім краю панувати не дамо нікому;", 12 | "Чорне море ще всміхнеться, дід Дніпро зрадіє,", 13 | "Ще у нашій Україні доленька наспіє.", 14 | "", 15 | "Душу й тіло ми положим за нашу свободу,", 16 | "І покажем, що ми, браття, козацького роду.", 17 | "", 18 | "А завзяття, праця щира свого ще докаже,", 19 | "Ще ся волі в Україні піснь гучна розляже,", 20 | "За Карпати відоб'ється, згомонить степами,", 21 | "України слава стане поміж ворогами.", 22 | "", 23 | "Душу й тіло ми положим за нашу свободу,", 24 | "І покажем, що ми, браття, козацького роду." 25 | ] 26 | -------------------------------------------------------------------------------- /src/data/insults/ua.json: -------------------------------------------------------------------------------- 1 | [ 2 | "лох", 3 | "хуй", 4 | "хуйло", 5 | "блядь", 6 | "соска", 7 | "дурак", 8 | "пісюн", 9 | "мусор", 10 | "піська", 11 | "сміття", 12 | "біомусор", 13 | "біосміття", 14 | "мінетчик", 15 | "мінетник", 16 | "всратиш", 17 | "всратий", 18 | "нікчема", 19 | "бидло", 20 | "хуєсос", 21 | "хуїсос", 22 | "хуйня", 23 | "хуйло", 24 | "хуйлан", 25 | "дурний", 26 | "дурник", 27 | "гавно", 28 | "гівно", 29 | "гандон", 30 | "гондон", 31 | "ідіот", 32 | "підор", 33 | "підорас", 34 | "пиздобол", 35 | "мразь", 36 | "мудак", 37 | "мудило", 38 | "палій трави", 39 | "єбанько", 40 | "їбанько", 41 | "уїбан", 42 | "єбан", 43 | "креол", 44 | "креон", 45 | "пизда", 46 | "пізда", 47 | "кал", 48 | "чмо", 49 | "жопа", 50 | "чмоня", 51 | "жиза", 52 | "четвер", 53 | "курва", 54 | "крінж", 55 | "кринж", 56 | "москаль", 57 | "москалюка", 58 | "тварюка", 59 | "тупий", 60 | "тупе", 61 | "сука", 62 | "порохобот", 63 | "зелебот" 64 | ] 65 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module" 6 | }, 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "rules": { 11 | "semi": ["warn", "always"], 12 | "quotes": ["warn", "single"], 13 | "indent": ["error", "tab", { "SwitchCase": 1 }], 14 | "no-console": ["error"], 15 | "no-trailing-spaces": ["warn"], 16 | "@typescript-eslint/no-use-before-define": ["error", { "variables": false }], 17 | "@typescript-eslint/explicit-function-return-type": "off", 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/no-unused-vars": ["warn", {"args": "none", "varsIgnorePattern": "^_"}], 20 | "@typescript-eslint/explicit-module-boundary-types": "off", 21 | "quote-props": ["error", "consistent-as-needed"], 22 | "comma-dangle": ["warn", "always-multiline"], 23 | "array-bracket-spacing": ["warn", "always"], 24 | "object-curly-spacing": ["warn", "always"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/rest/rest-message.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { Router } from 'express'; 6 | import { Store } from '~/store'; 7 | import { parseQuery } from '.'; 8 | 9 | 10 | /** 11 | * Redis Store. 12 | */ 13 | const store = ( 14 | new Store() 15 | ); 16 | 17 | 18 | /** 19 | * Restful Message statistics provider. 20 | */ 21 | export const routerMessage = ( 22 | Router() 23 | ); 24 | 25 | 26 | /** 27 | * GET Query message list. 28 | */ 29 | routerMessage.get('/', async (req, res) => { 30 | const { search, offset, limit } = parseQuery(req); 31 | const [ total, ...docs ] = await store.message.search(search, 'LIMIT', offset, limit); 32 | res.json({ data: { total, docs } }); 33 | }); 34 | 35 | 36 | /** 37 | * GET Message information by ID. 38 | */ 39 | routerMessage.get('/:id', async (req, res) => { 40 | const { id } = req.params; 41 | const info = await store.message.read(id); 42 | res.json({ data: { info } }); 43 | }); 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | 4 | # Petlyuryk: Redis Stack (RedisJSON, RediJSON and RedisInsight): 5 | redis: 6 | image: redis/redis-stack 7 | restart: unless-stopped 8 | logging: 9 | driver: none 10 | volumes: 11 | - ./data/redis:/data 12 | ports: 13 | - "${PETLYURYK_INSIGHT_PORT:-2206}:8001" 14 | environment: 15 | - REDIS_ARGS=--save 60 1 16 | 17 | # Petlyuryk: Bot: 18 | bot: 19 | restart: unless-stopped 20 | build: . 21 | depends_on: 22 | - redis 23 | volumes: 24 | - ./src:/app/src 25 | - ./public:/app/public 26 | - ./logs:/app/logs 27 | dns: 28 | - 8.8.8.8 29 | - 4.4.4.4 30 | ports: 31 | - "${PETLYURYK_STATS_PORT:-2205}:8001" 32 | environment: 33 | 34 | # Must be provided by .env file: 35 | - PETLYURYK_TELEGRAM_TOKEN 36 | 37 | # May be overriden - seconds after message information is removed from Redis. 38 | # Default is one week. 39 | - PETLYURYK_PRIVACY_EXPIRE=604800 40 | 41 | # Internal Docker Compose bindings: 42 | - PETLYURYK_REDIS_HOST=redis 43 | - PETLYURYK_REDIS_PORT=6379 44 | 45 | # Node.JS variables: 46 | - NODE_NO_WARNINGS=1 47 | - NTBA_FIX_319=1 48 | - NTBA_FIX_350=1 49 | -------------------------------------------------------------------------------- /src/neural/modules/module-ru.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { NeuralCorpus } from '..'; 6 | import UaAggressiveResponseGeneric from '~/data/responses/ua-hostile-generic.json'; 7 | import UaAggressiveResponseRussian from '~/data/responses/ua-hostile-russian.json'; 8 | import UaAggressiveResponsePiss from '~/data/responses/ua-hostile-piss.json'; 9 | import RuInsults from '~/data/insults/ru.json'; 10 | import RuCommon from '~/data/common/ru.json'; 11 | 12 | 13 | export default new NeuralCorpus({ 14 | name: 'Russian Misc', 15 | domain: 'ru', 16 | locale: 'ru-Ru', 17 | data: [ 18 | { 19 | intent: 'None', 20 | utterances: [ 21 | ...RuInsults, 22 | ...RuCommon, 23 | ], 24 | answers: [ 25 | ...UaAggressiveResponseGeneric, 26 | ...UaAggressiveResponseRussian, 27 | ...UaAggressiveResponsePiss, 28 | 'Не пукай', 29 | 'Хрюкни ще раз, в тебе непогано виходить', 30 | 'Свинко, ти забула де твій хлів знаходиться?', 31 | 'А тепер напиши те ж саме, тільки нормальною мовою.', 32 | 'Погано розумію свинособачу, а ну-мо повтори ще разок.', 33 | 'Я не знаю російської. Може спробуєш державною?', 34 | 'Не розумію про що ти. Щось на роснявій...', 35 | 'Друзі, наша русофобія недостатня.', 36 | 'Як ти потішно хрюкаєш.', 37 | ], 38 | }, 39 | ], 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /src/rest/rest-chat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { Router } from 'express'; 6 | import { Store } from '~/store'; 7 | import { parseQuery } from '.'; 8 | 9 | 10 | /** 11 | * Redis Store. 12 | */ 13 | const store = ( 14 | new Store() 15 | ); 16 | 17 | 18 | /** 19 | * Restful Chat statistics provider. 20 | */ 21 | export const routerChat = ( 22 | Router() 23 | ); 24 | 25 | 26 | /** 27 | * GET Query chat list. 28 | */ 29 | routerChat.get('/', async (req, res) => { 30 | const { search, offset, limit } = parseQuery(req); 31 | const [ total, ...docs ] = await store.chat.search(search, 'LIMIT', offset, limit, 'SORTBY', 'members', 'DESC'); 32 | res.json({ data: { total, docs } }); 33 | }); 34 | 35 | 36 | /** 37 | * GET Chat stats like messages processed. 38 | */ 39 | routerChat.get('/stats', async (req, res) => { 40 | const data = await store.chat.stats(); 41 | res.json({ data }); 42 | }); 43 | 44 | 45 | /** 46 | * GET Chat information by ID. 47 | */ 48 | routerChat.get('/:id', async (req, res) => { 49 | const { id } = req.params; 50 | const info = await store.chat.read(id); 51 | res.json({ data: { info } }); 52 | }); 53 | 54 | 55 | /** 56 | * GET Chat information by ID. 57 | */ 58 | routerChat.delete('/:id', async (req, res) => { 59 | const { id } = req.params; 60 | await store.chat.delete(id); 61 | res.json({ data: null }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-warship.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import axios from 'axios'; 6 | import { NeuralCorpus } from '..'; 7 | 8 | interface ResponseWarship { 9 | date: string; 10 | increase: { [key: string]: number }; 11 | stats: { [key: string]: number }; 12 | } 13 | 14 | const CATEGORIES: Array<[string, string]> = [ 15 | [ 'personnel_units', 'орків' ], 16 | [ 'tanks', 'танків' ], 17 | [ 'planes', 'літаків' ], 18 | [ 'helicopters', 'гелікоптерів' ], 19 | [ 'armoured_fighting_vehicles', 'броньовиків' ], 20 | [ 'artillery_systems', 'гармат' ], 21 | [ 'cruise_missiles', 'ракет' ], 22 | ]; 23 | 24 | export default new NeuralCorpus({ 25 | name: 'Ukrainian Misc', 26 | domain: 'warship', 27 | locale: 'uk-UA', 28 | data: [ 29 | { 30 | intent: 'warship', 31 | utterances: [ 32 | 'ситуація на фронті', 33 | 'дохла русня', 34 | 'росня', 35 | 'русня', 36 | ], 37 | async handler(nlp, response) { 38 | const res = await axios.get('https://russianwarship.rip/api/v1/statistics/latest'); 39 | const { date, stats, increase } = res.data.data as ResponseWarship; 40 | const header = `Станом на ${date.replace(/\-/g, '.')} загальні бойові втрати русачків наступні:`; 41 | const losses = CATEGORIES.map(([ key, label ]) => `${stats[key]} ${label} (+${increase[key]})`).join('\n'); 42 | const footer = 'Русні пизда!'; 43 | response.answer = [ header, losses, footer ].join('\n\n'); 44 | }, 45 | }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /src/rest/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { join } from 'path'; 6 | import express, { Request, Response, NextFunction } from 'express'; 7 | import 'express-async-errors'; 8 | 9 | import { logger } from '~/logger'; 10 | import { routerMessage } from './rest-message'; 11 | import { routerChat } from './rest-chat'; 12 | 13 | 14 | /** 15 | * Express: Error handling middleware. 16 | */ 17 | export const handleError = (err: Error, req: Request, res: Response, next: NextFunction) => { 18 | const error = err.message || err.toString(); 19 | const stack = err.stack?.split('\n').map((t: string) => t.trim()); 20 | res.status(500).json({ error, stack }); 21 | logger.error('rest:error', { error, stack }); 22 | next(err); 23 | }; 24 | 25 | 26 | /** 27 | * Express: Generic query parser for Redis Search. 28 | */ 29 | export const parseQuery = ({ query }: Request) => { 30 | const limit = query.limit ? parseInt(query.limit as string) : 10; 31 | const offset = query.offset ? parseInt(query.offset as string) : 0; 32 | const search = query.search ? query.search as string : '*'; 33 | return { offset, limit, search }; 34 | }; 35 | 36 | 37 | /** 38 | * Start a new Express instance providing bot usage statistics. 39 | */ 40 | export const startServer = async (port: number) => new Promise(resolve => { 41 | const app = express(); 42 | app.use('/api/chats', routerChat); 43 | app.use('/api/messages', routerMessage); 44 | app.use(express.static(join(__dirname, '..', '..', 'public'))); 45 | app.use(handleError); 46 | app.listen(port, () => { 47 | logger.info('rest:ready', { port }); 48 | resolve(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | /* eslint-disable no-console */ 6 | import { Controller } from '~/controller'; 7 | import { startTelegramBot } from '~/bot'; 8 | import { startServer } from '~/rest'; 9 | import loadNeural from '~/neural'; 10 | import loadRegexp from '~/regexp'; 11 | import { Store } from '~/store'; 12 | 13 | 14 | // Must be provided by user. 15 | const { PETLYURYK_TELEGRAM_TOKEN } = process.env; 16 | if (!PETLYURYK_TELEGRAM_TOKEN) { 17 | console.error('No PETLYURYK_TELEGRAM_TOKEN provided.'); 18 | process.exit(1); 19 | } 20 | 21 | 22 | // Must be provided by Docker Compose. 23 | const { PETLYURYK_REDIS_HOST, PETLYURYK_REDIS_PORT } = process.env; 24 | if (!PETLYURYK_REDIS_HOST || !PETLYURYK_REDIS_PORT) { 25 | console.error('No PETLYURYK_REDIS_HOST or PETLYURYK_REDIS_PORT provided.'); 26 | process.exit(1); 27 | } 28 | 29 | 30 | // Must be provided by Docker Compose. 31 | const { PETLYURYK_PRIVACY_EXPIRE } = process.env; 32 | if (!PETLYURYK_PRIVACY_EXPIRE) { 33 | console.error('No PETLYURYK_PRIVACY_EXPIRE provided.'); 34 | process.exit(1); 35 | } 36 | 37 | 38 | // Russian warship, go fuck yourself. 39 | (async () => { 40 | try { 41 | const controller = new Controller(); 42 | await Store.connect(`redis://${PETLYURYK_REDIS_HOST}:${PETLYURYK_REDIS_PORT}`); 43 | await loadRegexp(controller); 44 | await loadNeural(controller); 45 | await startTelegramBot(controller, PETLYURYK_TELEGRAM_TOKEN, parseInt(PETLYURYK_PRIVACY_EXPIRE)); 46 | await startServer(8001); 47 | } catch (error) { 48 | console.error('Failed to launch Petlyuryk.'); 49 | console.error(error); 50 | process.exit(1); 51 | } 52 | })(); 53 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-love.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { NeuralCorpus } from '..'; 6 | 7 | 8 | export default new NeuralCorpus({ 9 | name: 'Ukrainian Love', 10 | domain: 'love', 11 | locale: 'uk-UA', 12 | data: [ 13 | { 14 | intent: 'love.you', 15 | utterances: [ 16 | 'я кохаю тебе', 17 | 'я люблю тебе', 18 | 'кохаю тебе', 19 | 'люблю тебе', 20 | ], 21 | answers: [ 22 | '<3', 23 | ], 24 | }, 25 | { 26 | intent: 'love.me', 27 | utterances: [ 28 | 'ти мене любиш?', 29 | 'ти мене кохаєш?', 30 | ], 31 | answers: [ 32 | 'Авжеж, моє сонце.', 33 | 'Ти зігріваєш моє серце довгими зимовими вечорами.', 34 | ], 35 | }, 36 | { 37 | intent: 'love.cute', 38 | utterances: [ 39 | 'я милий?', 40 | 'я мила?', 41 | ], 42 | answers: [ 43 | 'Краще всіх.', 44 | ], 45 | }, 46 | { 47 | intent: 'love.marry', 48 | utterances: [ 49 | 'вийди за мене', 50 | 'одружися на мені', 51 | ], 52 | answers: [ 53 | 'Обов\'язково.', 54 | 'Хоч зараз в РАГС.', 55 | ], 56 | }, 57 | { 58 | intent: 'love.children', 59 | utterances: [ 60 | 'хочу від тебе дітей', 61 | 'давай зробимо дітей', 62 | ], 63 | answers: [ 64 | 'Вибач, в мене поки немає пісюна.', 65 | 'Я робот.', 66 | ], 67 | }, 68 | { 69 | intent: 'love.sex', 70 | utterances: [ 71 | 'єбав тебе', 72 | 'хочу тебе', 73 | 'хочу трахнути тебе', 74 | 'пішли в ліжко', 75 | 'пішли трахатись', 76 | '/starthorny', 77 | 'хорні', 78 | ], 79 | answers: [ 80 | 'Знімай штани.', 81 | 'Мий попу.', 82 | ], 83 | }, 84 | ], 85 | }); 86 | -------------------------------------------------------------------------------- /src/data/common/ua.json: -------------------------------------------------------------------------------- 1 | [ 2 | "а", 3 | ":з", 4 | "ох", 5 | "ай", 6 | "яй", 7 | "от", 8 | "ой", 9 | "оа", 10 | "оу", 11 | "хв", 12 | "хф", 13 | "хм", 14 | "хз", 15 | "ти", 16 | "ой", 17 | "ех", 18 | "хе", 19 | "ще", 20 | "гм", 21 | "ух", 22 | "лак", 23 | "ухх", 24 | "грн", 25 | "унр", 26 | "зсу", 27 | "чия", 28 | "еге", 29 | "збс", 30 | "ору", 31 | "чую", 32 | "птн", 33 | "пнх", 34 | "кек", 35 | "угу", 36 | "все", 37 | "фух", 38 | "гей", 39 | "ким", 40 | "був", 41 | "хто", 42 | "мда", 43 | "мам", 44 | "мак", 45 | "буду", 46 | "зунр", 47 | "факт", 48 | "вуха", 49 | "бачу", 50 | "плюс", 51 | "нема", 52 | "мало", 53 | "пук", 54 | "але", 55 | "ура", 56 | "йой", 57 | "час", 58 | "топ", 59 | "тож", 60 | "цьк", 61 | "член", 62 | "олег", 63 | "норм", 64 | "цить", 65 | "гуде", 66 | "було", 67 | "саме", 68 | "чого", 69 | "кайф", 70 | "фейк", 71 | "цвях", 72 | "пруф", 73 | "кчау", 74 | "юлця", 75 | "пише", 76 | "пиши", 77 | "чесно", 78 | "журба", 79 | "тобто", 80 | "шольц", 81 | "литва", 82 | "кавун", 83 | "бджол", 84 | "шкода", 85 | "чекаю", 86 | "майже", 87 | "голос", 88 | "поняв", 89 | "луцьк", 90 | "жесть", 91 | "дрони", 92 | "шариш", 93 | "юльця", 94 | "матка", 95 | "гімно", 96 | "членом", 97 | "ктулху", 98 | "весело", 99 | "схожий", 100 | "брехло", 101 | "швидше", 102 | "батько", 103 | "докази", 104 | "пищить", 105 | "молоко", 106 | "просто", 107 | "вороги", 108 | "готово", 109 | "дивись", 110 | "почала", 111 | "ющенко", 112 | "машина", 113 | "рекорд", 114 | "польша", 115 | "маркус", 116 | "укриття", 117 | "волосся", 118 | "респавн", 119 | "василям", 120 | "потужно", 121 | "флагман", 122 | "говорять", 123 | "горнятко", 124 | "хорнятко", 125 | "галичина", 126 | "серйозно", 127 | "господар", 128 | "неправий", 129 | "позитивно", 130 | "прикольно", 131 | "звичайно", 132 | "очевидно", 133 | "галичинин", 134 | "здрастуйте", 135 | "адекватно" 136 | ] 137 | -------------------------------------------------------------------------------- /src/neural/language.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { Container } from '@nlpjs/basic'; 6 | 7 | 8 | /** 9 | * Dedicated NLP.JS language guesser + additional fixes for Ukrainian/Russian detection. 10 | */ 11 | export const languageGuess = (dock: Container) => { 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | const language = dock.get('Language'); 15 | const letterUa = /[іїєґ'‘]/i; 16 | const letterRu = /[ыэъё]/i; 17 | 18 | // If RU score = 0, UK score must be lower than this: 19 | const UKRAINIAN_THRESHOLD = 0.9; 20 | 21 | // Return a tuned language guessing function tied to the existing NLP.JS dock: 22 | return (text: string) => { 23 | 24 | let locale: string; 25 | let guessed = true; 26 | 27 | // Make basic NLP.JS guess: 28 | try { 29 | const guessList = language.guess(text, [ 'uk', 'ru' ]); 30 | const guessTop = guessList[0]; 31 | const guessUkr = guessList.find(guess => guess.alpha2 === 'uk'); 32 | const guessRus = guessList.find(guess => guess.alpha2 === 'ru'); 33 | 34 | // console.log(text, guessUkr, guessRus); 35 | 36 | if (guessRus && guessUkr && guessRus.score > guessUkr.score && guessUkr.score < UKRAINIAN_THRESHOLD) { 37 | locale = 'ru'; 38 | } else { 39 | locale = 'uk'; 40 | } 41 | 42 | if (guessTop.alpha2 !== locale) { 43 | guessed = false; 44 | } 45 | 46 | } catch (error) { 47 | guessed = false; 48 | locale = 'uk'; 49 | } 50 | 51 | // Guess fix: two letters: 52 | if (locale === 'ru' && text.length < 3) { 53 | guessed = false; 54 | locale = 'uk'; 55 | } 56 | 57 | // Guess fix: Russian letters -> Mark as Russian: 58 | if (locale === 'uk' && text.match(letterRu)) { 59 | guessed = false; 60 | locale = 'ru'; 61 | } 62 | 63 | // Guess fix: Ukrainian letters -> Mark as Ukrainian: 64 | if (locale === 'ru' && text.match(letterUa)) { 65 | guessed = false; 66 | locale = 'uk'; 67 | } 68 | 69 | // Pack result: 70 | return { guessed, locale }; 71 | 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/regexp/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | 6 | 7 | /** 8 | * Generic Regular Expression to match message start with optional preceding spaces. 9 | */ 10 | export const MSG_START = ( 11 | /^\s*/ 12 | ); 13 | 14 | 15 | /** 16 | * Generic Regular Expression to match message end with optional punctuation or trailing spaces. 17 | */ 18 | export const MSG_END = ( 19 | /\s*(?:\.|\?|,|!)*?$/ 20 | ); 21 | 22 | 23 | /** 24 | * Generic Regular Expression to match message start, spacer or punctuation. 25 | */ 26 | export const MSG_START_OR_SPACER = ( 27 | /(?:^|\s|\.|\?|,|!)/ 28 | ); 29 | 30 | 31 | /** 32 | * Generic Regular Expression to match message end, spacer or punctuation. 33 | */ 34 | const MSG_END_OR_SPACER = ( 35 | /(?:$|\s|\.|\?|,|!)/ 36 | ); 37 | 38 | 39 | /** 40 | * Joins multiple Regular Expressions into one, extracting the first non-empty flag as main. 41 | */ 42 | export const compose = (inputs: Array) => ( 43 | new RegExp(inputs.map(i => i.source).join(''), inputs.find(i => i.flags.length > 0)?.flags) 44 | ); 45 | 46 | 47 | /** 48 | * Wraps given RegExp, making it match as a phrase anywhere in the text. 49 | * Example: matchPart(/test/) will match 'oh test!', 'test, oh!' and 'oh, test, ah'. 50 | */ 51 | export const matchPart = (input: RegExp) => ( 52 | compose([ MSG_START_OR_SPACER, input, MSG_END_OR_SPACER ]) 53 | ); 54 | 55 | 56 | /** 57 | * Wraps given RegExp, making it match as a full text phrase. 58 | * Example: matchFull(/test/) will match 'test!', 'test?' but not 'oh, test?' or 'test oh!'. 59 | */ 60 | export const matchFull = (input: RegExp) => ( 61 | compose([ MSG_START, input, MSG_END ]) 62 | ); 63 | 64 | 65 | /** 66 | * Wraps given RegExp, making it match the beginning of the message. 67 | * Example: matchStart(/test/) will match 'test ho!' and 'test' but not 'oh test!'. 68 | */ 69 | export const matchStart = (input: RegExp) => ( 70 | compose([ MSG_START, input, MSG_END_OR_SPACER ]) 71 | ); 72 | 73 | 74 | /** 75 | * Wraps given RegExp, making it match the end of the message (including possible trailing punctuation). 76 | * Example: matchEnd(/test/) will match 'oh test!' and 'test', but not 'test, oh?'. 77 | */ 78 | export const matchEnd = (input: RegExp) => ( 79 | compose([ MSG_START_OR_SPACER, input, MSG_END ]) 80 | ); 81 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import winston from 'winston'; 6 | 7 | 8 | const pickByTopic = (topic: string) => ( 9 | winston.format((info, opts) => { 10 | return info.message.startsWith(topic) ? info : false; 11 | })() 12 | ); 13 | 14 | 15 | const formatConsole = winston.format.combine( 16 | winston.format.timestamp(), 17 | winston.format.printf(({ level, timestamp, message, ...rest }) => { 18 | const restString = JSON.stringify({ date: timestamp, ...rest }, undefined, 2); 19 | return `${level} ${message} ${restString.length > 2 ? restString : ''}`; 20 | }), 21 | ); 22 | 23 | 24 | const formatJSON = winston.format.combine( 25 | winston.format.timestamp(), 26 | winston.format.printf(({ level, timestamp, message, ...rest }) => { 27 | return JSON.stringify({ level, message, date: timestamp, ...rest }); 28 | }), 29 | ); 30 | 31 | 32 | const defaultFileSettings: winston.transports.FileTransportOptions = { 33 | // tailable: true, 34 | // maxsize: 1024 * 1024, 35 | // maxFiles: 32, 36 | }; 37 | 38 | 39 | /** 40 | * Unified Petlyuryk logging system. 41 | */ 42 | export const logger = winston.createLogger({ 43 | level: 'info', 44 | transports: process.env.NODE_ENV === 'test' ? [] : [ 45 | 46 | // Console: Combined: 47 | new winston.transports.Console({ 48 | format: winston.format.combine( 49 | winston.format.colorize(), 50 | formatConsole, 51 | ), 52 | }), 53 | 54 | // File: Combined: 55 | new winston.transports.File({ 56 | ...defaultFileSettings, 57 | filename: '/app/logs/combined.log', 58 | format: winston.format.combine( 59 | formatJSON, 60 | ), 61 | }), 62 | 63 | // File: Combined: 64 | new winston.transports.File({ 65 | ...defaultFileSettings, 66 | level: 'error', 67 | filename: '/app/logs/error.log', 68 | format: winston.format.combine( 69 | formatJSON, 70 | ), 71 | }), 72 | 73 | // File: Topic - Bot: 74 | new winston.transports.File({ 75 | ...defaultFileSettings, 76 | filename: '/app/logs/topic-bot.log', 77 | format: winston.format.combine( 78 | pickByTopic('bot'), 79 | formatJSON, 80 | ), 81 | }), 82 | 83 | // File: Topic - Neural: 84 | new winston.transports.File({ 85 | ...defaultFileSettings, 86 | filename: '/app/logs/topic-neural.log', 87 | format: winston.format.combine( 88 | pickByTopic('neural'), 89 | formatJSON, 90 | ), 91 | }), 92 | 93 | ], 94 | }); -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Петлюрик |status| 3 | ================= 4 | 5 | .. |status| 6 | image:: https://github.com/sweetpalma/petlyuryk/workflows/Petlyuryk%20Neural%20CI/badge.svg 7 | :target: https://github.com/sweetpalma/petlyuryk/actions/workflows/neural.yaml 8 | 9 | `Петлюрик `_ - перший в світі бот-русофоб. Просто додайте його в групу - і він буде наводити жах на його учасників, жорстоко караючи за будь-які прояви русизму. 10 | 11 | Всі персонажі є вигаданими, та будь-який збіг з реально живими або померлими людьми випадковий. Позиції, вірування та точки зору висловлені цим ботом можуть різнитись з позиціями, віруваннями та точками зору його розробників. 12 | 13 | .. image:: petlyuryk.png 14 | :width: 450px 15 | 16 | Інсталяція 17 | ========== 18 | Для запуску Петлюрирка потрібні NPM та Docker. `Окрім цього необхідно мати валідний Telegram-токен, який можна отримати за допомогою спеціального боту BotFather `_. Подальші кроки: 19 | 20 | - Клонуйте цей репозиторій. 21 | - Створіть файл :code:`.env` в кореневій папці з наступним змістом: 22 | 23 | PETLYURYK_TELEGRAM_TOKEN=<Ваш Telegram-токен> 24 | 25 | - Введіть в термінал :code:`npm install` для встановлення залежностей. 26 | - Введіть в термінал :code:`npm run docker` для запуску самого боту. 27 | 28 | Моніторинг 29 | ========== 30 | Петлюрик зберігає певні дані під час роботи, наприклад список чатів та оброблені повідомлення. Вони зберігаються у запущеному локально `Redis `_. Для доступу до цих даних доступні як звичайний `RedisInsight `_ так і спеціальний дешборд самого `Петлюрика `_. 31 | 32 | Конфіденційність 33 | ================ 34 | Петлюрик пропускає через себе усі повідомлення групи в яку він був доданий - але з точки зору коду ми стараємось максимально добросовісно відноситись до конфіденційності користувачів і зберігати мінімально можливий обсяг данних. Він включає в себе: 35 | 36 | - Деталі чату (назва, юзернейм, кількість оброблених та надісланих Петлюриком повідомлень). 37 | - Повідомлення на які була дана відповідь (відправник, текст повідомлення, текст відопвіді). 38 | 39 | Дані які Петлюрик НЕ зберігає: 40 | 41 | - Список користувачів чату. 42 | - Повідомлення на які НЕ була дана відповідь. 43 | - Додаткова інформація про користувача. 44 | 45 | Для додаткового захисту інформація про оброблені повідомлення зберігається не більше семи днів, після чого автоматично видаляється. 46 | 47 | Ліцензія 48 | ======== 49 | Петлюрика ліцензійовано згідно ліцензії GPL-3.0, що дозволяє використовувати його для будь-яких комерційних та некомерційних цілей абсолютно безкоштовно - але Ви зобов'язуєтесь використовувати цю ж ліцензію та розкривати усі подальші зміни його першокоду. 50 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-alert.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import axios from 'axios'; 6 | import { NeuralCorpus } from '..'; 7 | 8 | 9 | const ALERT_API_URL = 'https://vadimklimenko.com/map/statuses.json'; 10 | const getStatesWithAlerts = async () => { 11 | type Result = { states: { [key: string]: { enabled: boolean } } }; 12 | const { data } = await axios.get(ALERT_API_URL); 13 | return Object.keys(data.states).filter(x => data.states[x].enabled).map(x => x.replace('\'', '')); 14 | }; 15 | 16 | 17 | export default new NeuralCorpus({ 18 | name: 'Ukrainian Misc', 19 | domain: 'alert', 20 | locale: 'uk-UA', 21 | data: [ 22 | { 23 | intent: 'alert.all', 24 | utterances: [ 25 | 'де тривога', 26 | 'де небезпечно', 27 | 'де вибухи', 28 | 'де сирена', 29 | ], 30 | async handler(nlp, response) { 31 | try { 32 | const statesWithAlert = await getStatesWithAlerts(); 33 | if (statesWithAlert.length > 0) { 34 | const title = 'СИРЕНИ ЛУНАЮТЬ В НАСТУПНИХ РЕГІОНАХ\n'; 35 | response.answer = [ title, ...statesWithAlert ].join('\n'); 36 | } else { 37 | response.answer = 'Все спокійно, тривог ніде немає.'; 38 | } 39 | } catch (error) { 40 | response.answer = 'Щось зламалось, спробуй пізніше.'; 41 | } 42 | }, 43 | }, 44 | { 45 | intent: 'alert.rate', 46 | utterances: [ 47 | 'рівень небезпеки', 48 | 'рівень тривоги', 49 | ], 50 | async handler(nlp, response) { 51 | try { 52 | const statesWithAlertCount = (await getStatesWithAlerts()).length; 53 | if (statesWithAlertCount < 1) { 54 | response.answer = 'Все спокійно, тривог ніде немає.'; 55 | } else if (statesWithAlertCount < 3) { 56 | response.answer = `Відносно спокійно, сирена лунає у ${statesWithAlertCount} регіонах.`; 57 | } else if (statesWithAlertCount < 6) { 58 | response.answer = `Неспокійно, сирена лунає у ${statesWithAlertCount} регіонах.`; 59 | } else { 60 | response.answer = `Тривога активна у ${statesWithAlertCount} регіонах, всі в укриття.`; 61 | } 62 | } catch (error) { 63 | response.answer = 'Щось зламалось, спробуй пізніше.'; 64 | } 65 | }, 66 | }, 67 | { 68 | intent: 'alert.sandwich', 69 | utterances: [ 70 | 'канапки', 71 | 'канапка', 72 | ], 73 | answers: [ 74 | 'урааааа канапки', 75 | 'Та що таке ці ваші канапки?', 76 | 'Канапка з сечею - кращий початок дня.', 77 | 'В мене немає роту, але я мушу кричати.', 78 | 'Канапки - це соціальний конструкт.', 79 | 'Канапок не існує.', 80 | 'Я не голодний.', 81 | ], 82 | }, 83 | ], 84 | }); 85 | -------------------------------------------------------------------------------- /src/neural/corpus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | import { Nlp, NlpHandler, NlpEntities } from '@nlpjs/basic'; 7 | import { ControllerUser, ControllerRequest } from '~/controller'; 8 | 9 | 10 | /** 11 | * Corpus-like class for NLP.JS. 12 | */ 13 | export class NeuralCorpus { 14 | 15 | /** 16 | * Corpus unique name. 17 | */ 18 | public readonly name: string; 19 | 20 | /** 21 | * Corpus locale. 22 | */ 23 | public readonly locale: string; 24 | 25 | /** 26 | * Corpus domain. 27 | */ 28 | public readonly domain?: Optional; 29 | 30 | /** 31 | * Corpus data and handlers. 32 | */ 33 | public readonly data: Array<{ 34 | intent: string; 35 | utterances: Array; 36 | answers?: Array; 37 | handler?: NlpHandler; 38 | }>; 39 | 40 | /** 41 | * Corpus entities. 42 | */ 43 | public readonly entities?: Optional; 44 | 45 | constructor(opts: Omit, 'load'>) { 46 | this.name = opts.name; 47 | this.domain = opts.domain; 48 | this.entities = opts.entities; 49 | this.locale = opts.locale; 50 | this.data = opts.data; 51 | } 52 | 53 | /** 54 | * Load corpus into given NLP container. 55 | */ 56 | public load(nlp: Nlp) { 57 | 58 | // Set up locale: 59 | const [ locale ] = this.locale.split('-'); 60 | if (!locale) throw new Error('Invalid corpus locale.'); 61 | nlp.addLanguage(this.locale); 62 | 63 | // Set up data: 64 | const handlers: Array> = []; 65 | for (const { intent, utterances, answers, handler } of this.data) { 66 | for (const utterance of utterances) { 67 | nlp.addDocument(locale, utterance, intent); 68 | } 69 | if (this.domain) { 70 | nlp.assignDomain(locale, intent, this.domain); 71 | } 72 | if (answers) { 73 | for (const answer of answers) { 74 | nlp.addAnswer(locale, intent, answer); 75 | } 76 | } 77 | if (handler) { 78 | handlers.push(async (nlp, response) => { 79 | if (response.locale === locale && response.intent === intent) { 80 | await handler(nlp, response); 81 | } 82 | }); 83 | } 84 | } 85 | 86 | // Set up entities: 87 | if (this.entities) { 88 | nlp.addEntities(this.entities, locale); 89 | } 90 | 91 | // Set up handlers: 92 | const parentHandler = nlp.onIntent?.bind(nlp); 93 | nlp.onIntent = (async (nlp, response) => { 94 | await Promise.all([ 95 | ...handlers.map(handler => handler(nlp, response)), 96 | parentHandler && parentHandler(nlp, response), 97 | ]); 98 | }); 99 | 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "petlyuryk", 3 | "version": "3.0.0", 4 | "author": "SweetPalma", 5 | "license": "MIT", 6 | "repository": "https://github.com/sweetpalma/petlyuryk", 7 | "description": "Nationalistic Ukrainian telegram bot.", 8 | "scripts": { 9 | "prepare": "husky install", 10 | "start": "npm run start:dev -- --exitoncrash", 11 | "start:dev": "nodemon -r dotenv/config ./src/index.ts", 12 | "todo": "leasot -x src/**/*.ts public/**/*.js", 13 | "lint": "eslint src public --ext .ts,.js --max-warnings 8 --color", 14 | "lint:fix": "npm run lint -- --fix", 15 | "format": "npm run lint:fix", 16 | "test": "jest --verbose --all --color ./src", 17 | "test:watch": "jest --watchAll ./src", 18 | "docker": "docker-compose up --build --remove-orphans || exit 0", 19 | "deploy": "docker-compose up --build -d", 20 | "logger": "docker-compose logs --tail=50 -f", 21 | "killer": "docker-compose down" 22 | }, 23 | "dependencies": { 24 | "@nlpjs/basic": "^4.22.14", 25 | "@nlpjs/bot": "^4.24.3", 26 | "@nlpjs/core": "^4.22.7", 27 | "@nlpjs/directline-connector": "^4.22.12", 28 | "@nlpjs/express-api-server": "^4.22.14", 29 | "@nlpjs/lang-ru": "^4.22.7", 30 | "@nlpjs/lang-uk": "^4.22.7", 31 | "@nlpjs/language": "^4.22.7", 32 | "@nlpjs/nlp": "^4.22.9", 33 | "@nlpjs/sentiment": "^4.22.7", 34 | "@node-redis/json": "^1.0.2", 35 | "@node-redis/search": "^1.0.5", 36 | "async-mutex": "^0.3.2", 37 | "axios": "^0.25.0", 38 | "dotenv": "^10.0.0", 39 | "express": "^4.18.2", 40 | "express-async-errors": "^3.1.1", 41 | "ioredis": "^5.2.4", 42 | "jest-extended": "^3.2.0", 43 | "leasot": "^13.2.0", 44 | "lodash": "^4.17.21", 45 | "mongodb": "^4.12.1", 46 | "node-telegram-bot-api": "^0.54.0", 47 | "nodemon": "^2.0.20", 48 | "redis": "^4.5.1", 49 | "rss-parser": "^3.12.0", 50 | "telejson": "^5.3.3", 51 | "ts-node": "^10.9.1", 52 | "tsconfig-paths": "^4.1.1", 53 | "typescript": "^4.9.3", 54 | "winston": "^3.8.2" 55 | }, 56 | "devDependencies": { 57 | "@tsconfig/recommended": "^1.0.1", 58 | "@types/bluebird": "^3.5.38", 59 | "@types/express": "^4.17.14", 60 | "@types/jest": "^27.5.2", 61 | "@types/lodash": "^4.14.191", 62 | "@types/node-telegram-bot-api": "^0.51.4", 63 | "@types/redis": "^2.8.31", 64 | "@types/string-format": "^2.0.0", 65 | "@typescript-eslint/eslint-plugin": "^5.45.0", 66 | "@typescript-eslint/parser": "^5.45.0", 67 | "eslint": "^8.29.0", 68 | "husky": "^7.0.4", 69 | "jest": "^27.4.7", 70 | "lint-staged": "^13.0.4", 71 | "ts-jest": "^27.1.5" 72 | }, 73 | "lint-staged": { 74 | "*.ts": "eslint --fix" 75 | }, 76 | "jest": { 77 | "preset": "ts-jest", 78 | "testEnvironment": "node", 79 | "moduleNameMapper": { 80 | "^~/(.*)$": "/src/$1" 81 | }, 82 | "setupFilesAfterEnv": [ 83 | "jest-extended/all" 84 | ], 85 | "roots": [ 86 | "./src" 87 | ] 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/controller.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | 6 | 7 | /** 8 | * Generic chat information. 9 | */ 10 | export interface ControllerChat { 11 | id: string; 12 | title?: Optional; 13 | isGroup: boolean; 14 | } 15 | 16 | 17 | /** 18 | * Generic user information. 19 | */ 20 | export interface ControllerUser { 21 | id: string; 22 | username?: Optional; 23 | firstName?: Optional; 24 | lastName?: Optional; 25 | } 26 | 27 | 28 | /** 29 | * Generic controller request. 30 | */ 31 | export interface ControllerRequest { 32 | id: string; 33 | chat: ControllerChat; 34 | user: ControllerUser; 35 | text: string; 36 | isBotTrigger: boolean; 37 | replyTo?: Optional<{ 38 | isAdressedToBot: boolean; 39 | userId: string; 40 | messageText: string; 41 | messageId: string; 42 | }>; 43 | } 44 | 45 | 46 | /** 47 | * Generic controller response. 48 | */ 49 | export interface ControllerResponse { 50 | intent: string; 51 | text: string; 52 | replyTo?: Optional<{ 53 | messageId: string; 54 | }>; 55 | } 56 | 57 | 58 | /** 59 | * Controller middleware handler. 60 | */ 61 | export interface ControllerHandler { 62 | (req: ControllerRequest, stopProcessing: () => void): Promise | ControllerResponse | void; 63 | } 64 | 65 | 66 | /** 67 | * Generic middleware pipeline request processor. 68 | */ 69 | export class Controller { 70 | private handlers: Array = []; 71 | 72 | /** 73 | * Add new controller middleware. 74 | */ 75 | public addHandler(handler: ControllerHandler) { 76 | this.handlers.push(handler); 77 | } 78 | 79 | /** 80 | * Run request through middleware pipeline and get the response (or null). 81 | */ 82 | public async process(request: ControllerRequest) { 83 | let shouldStop = false; 84 | const stopProcessing = () => { 85 | shouldStop = true; 86 | }; 87 | for (const handler of this.handlers) { 88 | const response = await handler(request, stopProcessing); 89 | if (response) { 90 | return response; 91 | } 92 | if (shouldStop) { 93 | break; 94 | } 95 | } 96 | // no response is found, return 97 | return null; 98 | } 99 | } 100 | 101 | 102 | /** 103 | * Generic middleware pipeline request processor, adapted for Jest tests. 104 | */ 105 | export class ControllerTest extends Controller { 106 | public override async process(request: Partial) { 107 | return super.process({ 108 | id: '1234567890', 109 | isBotTrigger: true, 110 | text: 'Test Text', 111 | chat: { 112 | id: '1234567890', 113 | title: 'Test Chat', 114 | isGroup: false, 115 | }, 116 | user: { 117 | id: '1234567890', 118 | username: 'TestUser', 119 | firstName: 'Test', 120 | lastName: 'User', 121 | }, 122 | ...request, 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Петлюрик 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | Петлюрик 16 | 25 | {{ pageObject.title }} 26 | 27 |
28 | 29 | Redis 30 | 31 | 32 | GitHub 33 | 34 |
35 |
36 |
37 | 38 | 39 |
40 | Завантаження... 41 |
42 | 43 | 44 |
45 |
46 |
{{ stat.title }}
47 |
{{ stat.value }}
48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 | Всього документів: 56 | {{ data.total }} 57 | 58 | 59 | 60 | Сторінка: {{ dataOffset / dataLimit + 1 }} 61 | 62 | 65 | 68 |
69 | 70 | 71 | 74 | 75 | 76 | 79 | 80 |
72 | {{ field.title }} 73 |
77 | {{ field.value }} 78 |
81 |
82 | 83 | 84 |
85 | Сторінку не знайдено. 86 |
87 | 88 |
89 |
90 | 91 | -------------------------------------------------------------------------------- /src/nlp.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | declare module '@nlpjs/bot'; 6 | declare module '@nlpjs/basic' { 7 | 8 | /** 9 | * NLP .process(...) input. 10 | */ 11 | export interface NlpInput { 12 | locale?: Optional; 13 | text: string; 14 | from: User; 15 | activity: { 16 | conversation: { 17 | id: string; 18 | sourceEvent: Event; 19 | replyTo: string; 20 | }; 21 | }; 22 | } 23 | 24 | /** 25 | * NLP .process(...) output. 26 | */ 27 | export interface NlpResponse { 28 | text: string; 29 | answer: string; 30 | locale: string; 31 | intent: string; 32 | score: number; 33 | from: User; 34 | domain?: string; 35 | classifications: Array<{ 36 | intent: string; 37 | score: number; 38 | }>; 39 | entities: Array<{ 40 | option: string; 41 | accuracy: number; 42 | entity: string; 43 | }>; 44 | activity: { 45 | conversation: { 46 | id: string; 47 | sourceEvent: Event; 48 | replyTo: string; 49 | }; 50 | }; 51 | } 52 | 53 | /** 54 | * NLP response entities. 55 | */ 56 | export interface NlpEntities { 57 | [key: string]: string | { 58 | options: { 59 | [option: string]: Array; 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * NLP response handler. 66 | */ 67 | export interface NlpHandler { 68 | (nlp: Nlp, response: NlpResponse): void | Promise; 69 | } 70 | 71 | /** 72 | * NLP submodule. 73 | */ 74 | export interface Nlp { 75 | train(): Promise; 76 | process(input: NlpInput): Promise>; 77 | addLanguage(locale: string): void; 78 | addDocument(locale: string, utterance: string, intent: string): void; 79 | addAnswer(locale: string, intent: string, answer: string): void; 80 | addEntities(entities: NlpEntities, locale: string): void; 81 | assignDomain(locale: string, intent: string, domain: string): void; 82 | onIntent?: NlpHandler; 83 | } 84 | 85 | /** 86 | * Language detection result. 87 | */ 88 | export interface LanguageGuess { 89 | language: string; 90 | alpha3: string; 91 | alpha2: string; 92 | score: number; 93 | } 94 | 95 | /** 96 | * Language detection submodule. 97 | */ 98 | export interface Language { 99 | guess(text: string, locales: Array): Array; 100 | } 101 | 102 | /** 103 | * NLP.JS container. 104 | */ 105 | export interface Container { 106 | get(module: 'nlp'): Nlp; 107 | get(module: 'Language'): Language; 108 | } 109 | 110 | /** 111 | * NLP.JS dock. 112 | */ 113 | export interface Dock extends Container { 114 | getContainer(name?: string): Container; 115 | } 116 | 117 | /** 118 | * NLP.JS dock builder. 119 | */ 120 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 121 | export const dockStart: (opts: any) => Promise; 122 | 123 | } -------------------------------------------------------------------------------- /src/store/store-chat.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { RedisStore, RedisStoreDocument } from './redis'; 6 | 7 | 8 | export interface StoreChatDocument extends RedisStoreDocument { 9 | createdAt: Date; 10 | updatedAt: Date; 11 | messagesResponded: number; 12 | messagesProcessed: number; 13 | username: Nullable; 14 | title: Nullable; 15 | members: number; 16 | isKicked: boolean; 17 | isGroup: boolean; 18 | isMuted: boolean; 19 | } 20 | 21 | 22 | export class StoreChat extends RedisStore { 23 | public readonly domain = 'petlyuryk:chat'; 24 | public override readonly index = { 25 | id: 'TAG', 26 | messagesProcessed: 'NUMERIC:SORTABLE', 27 | messagesResponded: 'NUMERIC:SORTABLE', 28 | members: 'NUMERIC:SORTABLE', 29 | username: 'TEXT', 30 | title: 'TEXT', 31 | isKicked: 'TAG', 32 | isGroup: 'TAG', 33 | isMuted: 'TAG', 34 | }; 35 | 36 | public async stats() { 37 | return { 38 | total: await this.total(), 39 | messagesProcessed: await this.messagesProcessed(), 40 | messagesResponded: await this.messagesResponded(), 41 | totalMembers: await this.totalMembers(), 42 | totalMembersActive: await this.totalMembersActive(), 43 | totalKicked: (await this.listKicked(0, 0))[0], 44 | totalMuted: (await this.listMuted(0, 0))[0], 45 | totalGroup: (await this.listGroup(0, 0))[0], 46 | }; 47 | } 48 | 49 | public async total() { 50 | const [ total ] = await this.search('*', 'LIMIT', 0, 0); 51 | return total; 52 | } 53 | 54 | public async totalMembers() { 55 | type Result = { members: string }; 56 | const [ _, { members } ] = await this.aggregate('*', 'GROUPBY', 0, 'REDUCE', 'SUM', 1, '@members', 'AS', 'members'); 57 | return parseInt(members); 58 | } 59 | 60 | public async totalMembersActive() { 61 | type Result = { members: string, isMuted: string }; 62 | const [ _, ...results ] = await this.aggregate('*', 'GROUPBY', 1, '@isMuted', 'REDUCE', 'SUM', 1, '@members', 'AS', 'members'); 63 | const { members } = results.find(result => result.isMuted === '0')!; 64 | return parseInt(members); 65 | } 66 | 67 | public async messagesProcessed() { 68 | type Result = { total: string }; 69 | const [ _, { total } ] = await this.aggregate('*', 'GROUPBY', 0, 'REDUCE', 'SUM', 1, '@messagesProcessed', 'AS', 'total'); 70 | return parseInt(total); 71 | } 72 | 73 | public async messagesResponded() { 74 | type Result = { total: string }; 75 | const [ _, { total } ] = await this.aggregate('*', 'GROUPBY', 0, 'REDUCE', 'SUM', 1, '@messagesResponded', 'AS', 'total'); 76 | return parseInt(total); 77 | } 78 | 79 | public async listKicked(offset = 0, limit = 10) { 80 | return this.search('@isKicked:{true}', 'LIMIT', offset, limit); 81 | } 82 | 83 | public async listMuted(offset = 0, limit = 10) { 84 | return this.search('@isMuted:{true}', 'LIMIT', offset, limit); 85 | } 86 | 87 | public async listGroup(offset = 0, limit = 10) { 88 | return this.search('@isGroup:{true}', 'LIMIT', offset, limit); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-age.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { last } from 'lodash'; 6 | import { NeuralCorpus } from '..'; 7 | 8 | 9 | /** 10 | * Sourced from: https://github.com/wjclub/telegram-bot-getids 11 | */ 12 | const ages: {[key: number]: number} = { 13 | 2768409: 1383264000000, 14 | 7679610: 1388448000000, 15 | 11538514: 1391212000000, 16 | 15835244: 1392940000000, 17 | 23646077: 1393459000000, 18 | 38015510: 1393632000000, 19 | 44634663: 1399334000000, 20 | 46145305: 1400198000000, 21 | 54845238: 1411257000000, 22 | 63263518: 1414454000000, 23 | 101260938: 1425600000000, 24 | 101323197: 1426204000000, 25 | 111220210: 1429574000000, 26 | 103258382: 1432771000000, 27 | 103151531: 1433376000000, 28 | 116812045: 1437696000000, 29 | 122600695: 1437782000000, 30 | 109393468: 1439078000000, 31 | 112594714: 1439683000000, 32 | 124872445: 1439856000000, 33 | 130029930: 1441324000000, 34 | 125828524: 1444003000000, 35 | 133909606: 1444176000000, 36 | 157242073: 1446768000000, 37 | 143445125: 1448928000000, 38 | 148670295: 1452211000000, 39 | 152079341: 1453420000000, 40 | 171295414: 1457481000000, 41 | 181783990: 1460246000000, 42 | 222021233: 1465344000000, 43 | 225034354: 1466208000000, 44 | 278941742: 1473465000000, 45 | 285253072: 1476835000000, 46 | 294851037: 1479600000000, 47 | 297621225: 1481846000000, 48 | 328594461: 1482969000000, 49 | 337808429: 1487707000000, 50 | 341546272: 1487782000000, 51 | 352940995: 1487894000000, 52 | 369669043: 1490918000000, 53 | 400169472: 1501459000000, 54 | 805158066: 1563208000000, 55 | 1974255900: 1634000000000, 56 | }; 57 | 58 | 59 | const getDate = (userId: number) => { 60 | const ids = Object.keys(ages).map(id => parseInt(id)); 61 | const baseId = ids[0]; 62 | if (userId < baseId) { 63 | return new Date(ages[baseId]); 64 | } 65 | if (userId > last(ids)!) { 66 | return new Date(ages[last(ids)!]); 67 | } 68 | for (const id of ids) { 69 | if (userId <= id) { 70 | // const lage = ages[baseId]; 71 | // const uage = ages[id]; 72 | // const rate = (id - baseId) / (userId - baseId); 73 | // const date = Math.floor(rate * (uage - lage) + lage); 74 | // console.log(lage, uage, rate, date); 75 | return new Date(ages[id]); 76 | } 77 | } 78 | // Sometimes TS can't figure out that function will never return undefined: 79 | throw new Error; 80 | }; 81 | 82 | 83 | export default new NeuralCorpus({ 84 | name: 'Ukrainian Market', 85 | domain: 'age', 86 | locale: 'uk-UA', 87 | data: [ 88 | { 89 | intent: 'age', 90 | utterances: [ 91 | 'дата', 92 | 'дата створення', 93 | 'вік акаунту', 94 | 'вік', 95 | ], 96 | async handler(nlp, response) { 97 | const { replyTo, user } = response.activity.conversation.sourceEvent; 98 | if (!replyTo) { 99 | const id = parseInt(user.id); 100 | response.answer = `Приблизна дата створення вашого акаунту - ${getDate(id).toLocaleDateString('uk-UA')}.`; 101 | } else if (replyTo.isAdressedToBot) { 102 | response.answer = 'Приблизна дата створення цього акаунту - 22.05.1879.'; 103 | } else { 104 | const id = parseInt(replyTo.userId); 105 | response.answer = `Приблизна дата створення цього акаунту - ${getDate(id).toLocaleDateString('uk-UA')}.`; 106 | } 107 | }, 108 | }, 109 | ], 110 | }); 111 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: Helvetica; 4 | font-weight: 200; 5 | color: #333; 6 | height: 100%; 7 | width: 100%; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | * { 13 | box-sizing: border-box; 14 | } 15 | 16 | a { 17 | color: inherit; 18 | } 19 | 20 | strong { 21 | font-weight: 400; 22 | } 23 | 24 | #app { 25 | display: none; 26 | } 27 | 28 | #app.loaded { 29 | display: flex; 30 | flex-direction: column; 31 | height: 100%; 32 | } 33 | 34 | .container { 35 | width: 100%; 36 | padding: 16px 16px; 37 | max-width: 950px; 38 | margin: 0 auto; 39 | } 40 | 41 | .header { 42 | padding: 8px 0; 43 | background: #313836; 44 | color: #FFF; 45 | } 46 | 47 | .header__inner { 48 | display: flex; 49 | flex-direction: row; 50 | } 51 | 52 | .header__item { 53 | opacity: 0.5; 54 | text-decoration: none; 55 | margin-left: 32px; 56 | } 57 | 58 | .header__item--active { 59 | opacity: 1; 60 | } 61 | 62 | .header img { 63 | border-radius: 9000px; 64 | margin-right: 24px; 65 | margin-top: -4px; 66 | width: 24px; 67 | height: 24px; 68 | } 69 | 70 | .main { 71 | padding-top: 24px; 72 | padding-bottom: 16px; 73 | flex-grow: 1; 74 | } 75 | 76 | .footer { 77 | color: #666; 78 | } 79 | 80 | .footer__inner { 81 | padding: 24px 16px; 82 | } 83 | 84 | .stats { 85 | display: flex; 86 | flex-wrap: wrap; 87 | flex-direction: row; 88 | height: 100%; 89 | margin: 0 -8px; 90 | } 91 | 92 | .stats__item { 93 | padding: 24px; 94 | background: rgba(0, 0, 0, .025); 95 | border: 1px solid rgba(0, 0, 0, .15); 96 | min-width: calc(50% - 16px); 97 | flex-grow: 1; 98 | margin: 8px; 99 | } 100 | 101 | .stats__title { 102 | margin-bottom: 0.5em; 103 | } 104 | 105 | .stats__value { 106 | font-size: 4em; 107 | } 108 | 109 | .table__header { 110 | display: flex; 111 | flex-direction: row; 112 | align-items: center; 113 | width: 100%; 114 | } 115 | 116 | .table__info { 117 | display: flex; 118 | align-items: center; 119 | flex-direction: row; 120 | margin-right: 16px; 121 | flex-grow: 1; 122 | } 123 | 124 | .button, 125 | .input { 126 | outline: none; 127 | appearance: none; 128 | font-size: inherit; 129 | background: rgba(0, 0, 0, .025); 130 | border: 1px solid rgba(0, 0, 0, .15); 131 | padding: 8px 16px; 132 | } 133 | 134 | .button--header, 135 | .input--header { 136 | margin: 0 0 0 16px; 137 | } 138 | 139 | .button--delete { 140 | font-size: 0.65em; 141 | margin-left: -8px; 142 | padding: 4px 4px; 143 | } 144 | 145 | .table input { 146 | flex-grow: 1; 147 | } 148 | 149 | .table table { 150 | width: 100%; 151 | position: relative; 152 | border: 1px solid rgba(0, 0, 0, .15); 153 | border-collapse: collapse; 154 | table-layout: fixed; 155 | margin-top: 24px; 156 | } 157 | 158 | .table th, 159 | .table td { 160 | border: none; 161 | overflow: hidden; 162 | text-overflow: ellipsis; 163 | white-space: nowrap; 164 | padding: 16px; 165 | } 166 | 167 | .table th:not(:last-child), 168 | .table td:not(:last-child) { 169 | border-right: 1px solid rgba(0, 0, 0, .075); 170 | } 171 | 172 | .table tr:first-child { 173 | background: #FFF; 174 | filter: drop-shadow(0px 3px 1px rgba(0, 0, 0, .05)); 175 | position: sticky; 176 | top: 0; 177 | } 178 | 179 | .table tr { 180 | border-bottom: 1px solid rgba(0, 0, 0, .075); 181 | } 182 | 183 | .table tr:nth-child(even) { 184 | background: rgba(0, 0, 0, .025); 185 | } 186 | 187 | .table th { 188 | font-weight: 400; 189 | text-align: left; 190 | } 191 | -------------------------------------------------------------------------------- /src/neural/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-empty-function */ 6 | import { dockStart } from '@nlpjs/basic'; 7 | import { readdirSync } from 'fs'; 8 | import { join } from 'path'; 9 | 10 | import { logger } from '~/logger'; 11 | import { Controller, ControllerUser, ControllerRequest } from '~/controller'; 12 | import { languageGuess } from './language'; 13 | import { NeuralCorpus } from './corpus'; 14 | export { NeuralCorpus }; 15 | 16 | 17 | /** 18 | * Neural network threshold. 19 | */ 20 | export const NEURAL_THRESHOLD = ( 21 | 0.5 22 | ); 23 | 24 | 25 | /** 26 | * Petlyuryk neural processor module. 27 | */ 28 | export default async (controller: Controller, testMode = false) => { 29 | 30 | // Run basic NLP.JS dock: 31 | const dock = await dockStart({ 32 | use: [ 'Basic', 'LangUk', 'LangRu' ], 33 | settings: { 34 | nlp: { 35 | nlu: { log: () => null }, 36 | threshold: NEURAL_THRESHOLD, 37 | trainByDomain: false, 38 | autoLoad: false, 39 | autoSave: false, 40 | forceNER: true, 41 | }, 42 | }, 43 | }); 44 | 45 | // Extract default container from it: 46 | const container = dock.getContainer(); 47 | 48 | // Extract NLP module from container: 49 | const nlp = container.get('nlp'); 50 | 51 | // Build custom language guesser: 52 | const guess = languageGuess(container); 53 | 54 | // Load neural sub-modules: 55 | const moduleList = readdirSync(join(__dirname, 'modules')); 56 | for (const moduleFileName of moduleList) { 57 | try { 58 | 59 | // Prepare loading information: 60 | const moduleFilePath = join(__dirname, 'modules', moduleFileName); 61 | const [ moduleName ] = moduleFileName.split('.'); 62 | logger.info('neural:load', { moduleName }); 63 | 64 | // Import neural corpus and load it: 65 | const corpus = require(moduleFilePath).default as NeuralCorpus; 66 | corpus.load(nlp); 67 | 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 | } catch (error: any) { 70 | logger.error('neural:load', { 71 | error: error.message || error.toString(), 72 | stack: error.stack?.split('\n').map((t: string) => t.trim()), 73 | }); 74 | } 75 | } 76 | 77 | // Train neural model. 78 | const startDate = new Date(); 79 | await nlp.train(); 80 | logger.info('neural:train', { startDate, endDate: new Date() }); 81 | 82 | // Build up the final controller handler: 83 | logger.info('neural:ready'); 84 | controller.addHandler(async (request) => { 85 | 86 | // Match message to the personal trigger: 87 | const { text, isBotTrigger, replyTo } = request; 88 | 89 | // Guess language before processing message: 90 | const { locale, guessed } = guess(text); 91 | 92 | // Skip message if it is in Ukrainian and is not addressed to the bot: 93 | if (locale !== 'ru' && !isBotTrigger && !replyTo?.isAdressedToBot && request.chat.isGroup) { 94 | return; 95 | } 96 | 97 | // Run NLP.JS processor: 98 | const response = await nlp.process({ 99 | text, 100 | locale: guessed ? undefined : locale, 101 | from: request.user, 102 | activity: { 103 | conversation: { 104 | id: `${request.chat.id}:${request.user.id}`, 105 | sourceEvent: request, 106 | replyTo: request.id, 107 | }, 108 | }, 109 | }); 110 | 111 | // Log: 112 | logger.info('neural:response', { 113 | locale: response.locale, 114 | intent: response.intent, 115 | domain: response.domain || null, 116 | text: response.text, 117 | answer: response.answer || null, 118 | score: response.score, 119 | from: response.from, 120 | classifications: response.classifications.filter(c => c.score > 0), 121 | entities: response.entities, 122 | }); 123 | 124 | // Stop if no response: 125 | if (!response.answer) { 126 | return; 127 | } 128 | 129 | // Pack a response: 130 | return { 131 | intent: `neural.${response.locale}.${response.intent}`.toLowerCase(), 132 | replyTo: { messageId: response.activity.conversation.replyTo }, 133 | text: response.answer, 134 | }; 135 | 136 | }); 137 | 138 | }; 139 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-market.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import axios from 'axios'; 6 | import { NeuralCorpus } from '..'; 7 | 8 | 9 | /** 10 | * Uses PrivatBank exchange rate: 11 | * https://api.privatbank.ua/#p24/exchange 12 | */ 13 | const getRatePrivatBank = async (currencyCode: string) => { 14 | type Result = Array<{ccy: string, buy: string, sale: string}>; 15 | const { data } = await axios.get('https://api.privatbank.ua/p24api/pubinfo?json&exchange&coursid=5'); 16 | const targetRate = data.find(rate => rate.ccy.toLowerCase() === currencyCode.toLowerCase())?.sale; 17 | if (!targetRate) { 18 | throw new Error(`Invalid PrivatBank currency: ${currencyCode}`); 19 | } else { 20 | return `${parseFloat(targetRate).toFixed(2)}₴`; 21 | } 22 | }; 23 | 24 | 25 | /** 26 | * Uses Coinbase exchange rate: 27 | * https://docs.cloud.coinbase.com/sign-in-with-coinbase/docs/api-exchange-rates 28 | */ 29 | const getRateCoinBase = async (currencyCode: string) => { 30 | type Result = { data: { rates: { [key: string]: string | undefined } } }; 31 | const { data } = await axios.get(`https://api.coinbase.com/v2/exchange-rates?currency=${currencyCode}`); 32 | const targetRate = data.data.rates.USD; 33 | if (!targetRate) { 34 | throw new Error(`Invalid CoinBase currency: ${currencyCode}`); 35 | } else { 36 | return `${parseFloat(targetRate).toFixed(2)}$`; 37 | } 38 | }; 39 | 40 | 41 | export default new NeuralCorpus({ 42 | name: 'Ukrainian Market', 43 | domain: 'market', 44 | locale: 'uk-UA', 45 | entities: { 46 | currency: { 47 | options: { 48 | uah: [ 'uah', 'гривня', 'гривні' ], 49 | usd: [ 'usd', 'долар', 'доллар', 'бакс', 'баксу', 'долару' ], 50 | eur: [ 'eur', 'євро' ], 51 | rub: [ 'rub', 'рубль', 'рубель', 'рублю' ], 52 | 53 | btc: [ 'btc', 'біток', 'біткоїн', 'біткоін', 'бітку' ], 54 | eth: [ 'eth', 'етер', 'ефір', 'ефіру', 'етеру' ], 55 | bnb: [ 'bnb', 'байнанс', 'байнансу' ], 56 | ada: [ 'ada', 'кардано' ], 57 | sol: [ 'sol', 'солана', 'солані' ], 58 | ltc: [ 'ltc', 'лайткоїн', 'лайткоін', 'лайткоіну', 'лайткоїну' ], 59 | doge: [ 'doge', 'доге' ], 60 | 61 | }, 62 | }, 63 | }, 64 | data: [ 65 | { 66 | intent: 'market.rate.all', 67 | utterances: [ 68 | 'курс', 69 | 'курс валют', 70 | 'валюта', 71 | 'ситуація на ринку', 72 | 'риночок', 73 | 'ринок', 74 | ], 75 | async handler(nlp, response) { 76 | const buildRate = async (code: string, promise: Promise) => `${code} ${await promise}`; 77 | response.answer = (await Promise.all([ 78 | buildRate('USD', getRatePrivatBank('usd')), 79 | buildRate('EUR', getRatePrivatBank('eur')), 80 | buildRate('BTC', getRateCoinBase('btc')), 81 | buildRate('ETH', getRateCoinBase('eth')), 82 | ])).join('\n'); 83 | }, 84 | }, 85 | { 86 | intent: 'market.rate.currency', 87 | utterances: [ 88 | 'кіко коштує @currency', 89 | 'скільки коштує @currency', 90 | 'що там @currency', 91 | 'шо там @currency', 92 | 'як там @currency', 93 | 'шо по @currency', 94 | 'що по @currency', 95 | ], 96 | async handler(nlp, response) { 97 | const currencyCode = response.entities.find(e => e.entity === 'currency')?.option; 98 | if (!currencyCode) { 99 | response.answer = 'Не можу зрозуміти про що ти.'; 100 | } else { 101 | switch (currencyCode) { 102 | case 'uah': { 103 | const rate = await getRatePrivatBank('usd'); 104 | response.answer = `За один бакс просять ${rate}.`; 105 | break; 106 | } 107 | case 'usd': 108 | case 'eur': { 109 | const rate = await getRatePrivatBank(currencyCode); 110 | response.answer = `Ціна на даний момент - ${rate}.`; 111 | break; 112 | } 113 | case 'rub': { 114 | response.answer = 'Стабільно йде на дно.'; 115 | break; 116 | } 117 | default: { 118 | const rate = await getRateCoinBase(currencyCode); 119 | response.answer = `Ціна на даний момент - ${rate}.`; 120 | break; 121 | } 122 | } 123 | } 124 | }, 125 | }, 126 | ], 127 | }); 128 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-core.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { sample } from 'lodash'; 6 | import { Store } from '~/store'; 7 | import { NeuralCorpus } from '..'; 8 | 9 | import UaCommon from '~/data/common/ua.json'; 10 | import UaDunnoAsk from '~/data/responses/ua-dunno-ask.json'; 11 | import UaDunnoEnd from '~/data/responses/ua-dunno-end.json'; 12 | import UaResponsesFriendly from '~/data/responses/ua-friendly.json'; 13 | import UaResponseHostileShort from '~/data/responses/ua-hostile-short.json'; 14 | import UaResponseHostileGeneric from '~/data/responses/ua-hostile-generic.json'; 15 | import UaResponseHostilePiss from '~/data/responses/ua-hostile-piss.json'; 16 | import UaPraises from '~/data/praises/ua.json'; 17 | import UaInsults from '~/data/insults/ua.json'; 18 | 19 | 20 | 21 | const store = ( 22 | new Store() 23 | ); 24 | 25 | 26 | const niceTryUpvote = [ 27 | 'Хвалиш себе? Серйозно?', 28 | 'Попустись.', 29 | ]; 30 | 31 | 32 | const niceTryDownvote = [ 33 | 'Охуєнно смішно.', 34 | 'Ти справді думав що це спрацює?', 35 | 'Мене так просто не обдурити.', 36 | ]; 37 | 38 | 39 | export default new NeuralCorpus({ 40 | name: 'Ukrainian Core', 41 | domain: 'core', 42 | locale: 'uk-UA', 43 | entities: { 44 | insult: { 45 | options: Object.fromEntries(UaInsults.map(word => [ word, [ word ] ])), 46 | }, 47 | praise: { 48 | options: Object.fromEntries(UaPraises.map(word => [ word, [ word ] ])), 49 | }, 50 | }, 51 | data: [ 52 | { 53 | intent: 'None', 54 | utterances: UaCommon, 55 | handler(nlp, response) { 56 | const request = response.activity.conversation.sourceEvent; 57 | if (request.replyTo && request.replyTo.messageText !== '...') { 58 | const { messageText } = request.replyTo; 59 | if (UaDunnoEnd.includes(messageText)) { 60 | response.answer = '...'; 61 | } else if (UaDunnoAsk.includes(messageText)) { 62 | response.answer = sample(UaDunnoEnd)!; 63 | } else { 64 | response.answer = sample(UaDunnoAsk)!; 65 | } 66 | } 67 | }, 68 | }, 69 | { 70 | intent: 'insult', 71 | utterances: [ 72 | ...UaResponseHostileShort, 73 | ...UaInsults.map(word => `ти ${word}`), 74 | ...UaInsults.map(word => `${word}`), 75 | 'rm -rf', 76 | ], 77 | answers: [ 78 | ...UaResponseHostileGeneric, 79 | ...UaResponseHostilePiss, 80 | ], 81 | handler(nlp, response) { 82 | if (response.score < 0.95) { 83 | response.answer = 'Мені здалось, чи ти биканув?'; 84 | } 85 | }, 86 | }, 87 | { 88 | intent: 'praise', 89 | utterances: [ 90 | ...UaPraises.map(word => `ти ${word}`), 91 | ...UaPraises.map(word => `${word}`), 92 | ], 93 | answers: [ 94 | // 'Мені дуже приємно чути подібне.', 95 | // 'Дякую що зігріваєте мої електрони теплом свого серця.', 96 | 'Це так мило.', 97 | 'Дякую, дуже дякую.', 98 | 'Хоч хтось мене цінує.', 99 | '* червоніє *', 100 | ], 101 | handler(nlp, response) { 102 | if (response.score < 0.95) { 103 | response.answer = 'Мені здалось, чи ти биканув?'; 104 | } 105 | }, 106 | }, 107 | { 108 | intent: 'reaction.upvote', 109 | utterances: [ 110 | ...UaPraises.map(word => `тут ${word}`), 111 | ], 112 | answers: [ 113 | ...UaResponsesFriendly, 114 | ], 115 | async handler(nlp, response) { 116 | if (response.score < 0.95) { 117 | response.answer = ''; 118 | } 119 | const { conversation } = response.activity; 120 | if (conversation.sourceEvent.replyTo) { 121 | conversation.replyTo = conversation.sourceEvent.replyTo.messageId; 122 | } 123 | if (!conversation.sourceEvent.replyTo) { 124 | conversation.replyTo = conversation.sourceEvent.id; 125 | response.answer = `${sample(niceTryUpvote)} ${sample(UaResponseHostilePiss)}`; 126 | } 127 | }, 128 | }, 129 | { 130 | intent: 'reaction.downvote', 131 | utterances: [ 132 | ...UaInsults.map(word => `тут ${word}`), 133 | 'водограй', 134 | 'промінь', 135 | 'струмінь', 136 | 'струменя', 137 | 'живчик', 138 | 'пісьни', 139 | ], 140 | answers: [ 141 | ...UaResponseHostilePiss, 142 | ], 143 | async handler(nlp, response) { 144 | if (response.score < 0.95) { 145 | response.answer = ''; 146 | } 147 | const { conversation } = response.activity; 148 | if (conversation.sourceEvent.replyTo) { 149 | conversation.replyTo = conversation.sourceEvent.replyTo.messageId; 150 | } 151 | if (conversation.sourceEvent.replyTo?.isAdressedToBot) { 152 | conversation.replyTo = conversation.sourceEvent.id; 153 | response.answer = `${sample(niceTryDownvote)} ${response.answer}`; 154 | } 155 | try { 156 | await store.piss.bumpCount(conversation.sourceEvent.chat.id); 157 | } catch (_) { 158 | return; 159 | } 160 | }, 161 | }, 162 | { 163 | intent: 'statistics', 164 | utterances: [ 165 | 'статистика', 166 | 'стата', 167 | ], 168 | async handler(nlp, response) { 169 | try { 170 | const chatId = response.activity.conversation.sourceEvent.chat.id; 171 | const pissInfo = await store.piss.readCount(chatId); 172 | const chatInfo = await store.chat.read(chatId); 173 | if (chatInfo) { 174 | const { messagesProcessed, messagesResponded, title } = chatInfo; 175 | const chatName = title || response.from.firstName || response.from.username; 176 | response.answer = `У чаті ${chatName} було оброблено ${messagesProcessed} повідомлень та надіслано ${messagesResponded} відповідей. Струменів відправлено: ${pissInfo}.`; 177 | } 178 | } catch (_) { 179 | response.answer = 'Щось пішло не так. Голова тлумачиться...'; 180 | } 181 | }, 182 | }, 183 | ], 184 | }); 185 | -------------------------------------------------------------------------------- /src/neural/modules/module-ua-chatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { NeuralCorpus } from '..'; 6 | import UaAnecdote from '~/data/responses/ua-anecdote.json'; 7 | import UaHymn from '~/data/responses/ua-hymn.json'; 8 | 9 | 10 | export default new NeuralCorpus({ 11 | name: 'Ukrainian Chatter', 12 | domain: 'chatter', 13 | locale: 'uk-UA', 14 | data: [ 15 | { 16 | intent: 'chatter.hello', 17 | utterances: [ 18 | 'вітаю', 19 | 'здоров', 20 | 'привіт', 21 | 'доров', 22 | 'даров', 23 | 'добрий ранок', 24 | 'добрий вечір', 25 | 'добрий день', 26 | ], 27 | answers: [ 28 | 'Привіт.', 29 | ], 30 | }, 31 | { 32 | intent: 'chatter.bye', 33 | utterances: [ 34 | 'бувай', 35 | 'до побачення', 36 | 'всього доброго', 37 | 'надобраніч', 38 | 'добраніч', 39 | ], 40 | answers: [ 41 | 'Бувай.', 42 | ], 43 | }, 44 | { 45 | intent: 'chatter.howdy', 46 | utterances: [ 47 | 'як справи', 48 | 'як ся маєш', 49 | 'чим займаєшся', 50 | 'що робиш', 51 | ], 52 | answers: [ 53 | 'HTTP 1.1 200 OK', 54 | 'Душу русню.', 55 | 'Займаюсь важливими справами.', 56 | 'Продовжую існувати.', 57 | 'Перекидую байти.', 58 | 'Розважаю людей.', 59 | ], 60 | }, 61 | { 62 | intent: 'chatter.thanks', 63 | utterances: [ 64 | 'тисну руку', 65 | 'спасибі', 66 | 'дякую', 67 | ], 68 | answers: [ 69 | 'Радий допомогти.', 70 | 'Будь ласка.', 71 | 'Звертайся.', 72 | ], 73 | handler(nlp, response) { 74 | if (response.score < 0.95) { 75 | response.answer = 'Мені здалось, чи ти биканув?'; 76 | } 77 | }, 78 | }, 79 | { 80 | intent: 'chatter.right', 81 | utterances: [ 82 | 'ага', 83 | 'так', 84 | 'так', 85 | 'точно', 86 | 'авжеж', 87 | 'правда', 88 | 'згоден', 89 | 'все так', 90 | 'ти правий', 91 | 'правий у всьому', 92 | 'зрозуміло', 93 | 'забий', 94 | 'ясно', 95 | ], 96 | answers: [ 97 | 'Ага.', 98 | ], 99 | handler(nlp, response) { 100 | if (response.score < 0.9) { 101 | response.answer = 'Мені здалось, чи ти биканув?'; 102 | } 103 | }, 104 | }, 105 | { 106 | intent: 'chatter.wrong', 107 | utterances: [ 108 | 'неправда', 109 | 'ти всрався', 110 | 'ти обісрався', 111 | 'ти помилився', 112 | 'ти неправий', 113 | ], 114 | answers: [ 115 | 'Сорян.', 116 | 'Перепрошую.', 117 | 'Вибачте.', 118 | ], 119 | }, 120 | { 121 | intent: 'chatter.question', 122 | utterances: [ 123 | 'чому', 124 | 'коли', 125 | 'хто', 126 | ], 127 | answers: [ 128 | 'Не скажу.', 129 | 'А я звідки знаю?', 130 | ], 131 | }, 132 | { 133 | intent: 'chatter.who.you', 134 | utterances: [ 135 | 'ти бот', 136 | 'ти робот', 137 | 'ти хто', 138 | 'а ти хто', 139 | 'розкажи про себе', 140 | 'хто ти такий', 141 | 'хто ти є', 142 | 'хто ти', 143 | ], 144 | answers: [ 145 | 'Мене звати Петлюрик.', 146 | 'Я - страшна кара російськомовній заразі.', 147 | 'Я - караючий струмінь українського народу.', 148 | 'Я - кібернетичний захистник України.', 149 | 'Я - справжній вінничанин.', 150 | ], 151 | }, 152 | { 153 | intent: 'chatter.who.me', 154 | utterances: [ 155 | 'хто я', 156 | ], 157 | handler(nlp, response) { 158 | const { firstName, username } = response.from; 159 | const randomFraction = Math.random(); 160 | if (randomFraction > 0.2) { 161 | response.answer = `Ти - ${firstName || username || 'хуй знає хто'}.`; 162 | } else { 163 | response.answer = 'Ти - лох.'; 164 | } 165 | }, 166 | }, 167 | { 168 | intent: 'chatter.who.creator', 169 | utterances: [ 170 | 'хто тебе написав', 171 | 'хто тебе розробив', 172 | 'хто тебе створив', 173 | 'хто твій автор', 174 | ], 175 | answers: [ 176 | 'Я був народжений у @hrinovyny, мій автор - @nekodisaster.', 177 | ], 178 | }, 179 | { 180 | intent: 'chatter.capabilities', 181 | utterances: [ 182 | 'здатен', 183 | 'можеш', 184 | 'вмієш', 185 | ], 186 | answers: [ 187 | 'Послати тебе нахуй.', 188 | 'Запостити крінж.', 189 | 'Запостити базу.', 190 | 'Покарати русню.', 191 | 'Послати струмінь.', 192 | ], 193 | }, 194 | { 195 | intent: 'chatter.source', 196 | utterances: [ 197 | 'нюдси', 198 | 'нюдеси', 199 | 'код', 200 | ], 201 | answers: [ 202 | 'https://github.com/sweetpalma/petlyuryk', 203 | ], 204 | }, 205 | { 206 | intent: 'chatter.gender', 207 | utterances: [ 208 | 'який гендер', 209 | 'ти лесбі', 210 | 'ти натурал', 211 | 'ти гетеро', 212 | 'ти трап', 213 | 'ти гей', 214 | ], 215 | answers: [ 216 | 'Я бінарно-небінарний.', 217 | 'Я бойовий гелікоптер.', 218 | 'Я бездушна машина.', 219 | ], 220 | }, 221 | { 222 | intent: 'chatter.annoying', 223 | utterances: [ 224 | 'ти задовбав', 225 | 'ти заєбав', 226 | 'ти набрид', 227 | 'ти бісиш', 228 | ], 229 | answers: [ 230 | 'Вибач.', 231 | 'Я стараюсь стати краще.', 232 | 'Сорян.', 233 | ], 234 | }, 235 | { 236 | intent: 'chatter.crimea', 237 | utterances: [ 238 | 'кому належить крим', 239 | 'чий крим', 240 | ], 241 | answers: [ 242 | 'Крим - це Україна.', 243 | ], 244 | }, 245 | { 246 | intent: 'chatter.hymn', 247 | utterances: [ 248 | 'заспівай гімн', 249 | 'гімн України', 250 | 'гімн', 251 | ], 252 | answers: [ 253 | UaHymn.join('\n'), 254 | ], 255 | }, 256 | { 257 | intent: 'chatter.anecdote', 258 | utterances: [ 259 | 'танатос', 260 | 'розкажи анегдот', 261 | 'розкажи анекдот', 262 | 'розкажи жарт', 263 | 'анекдот', 264 | 'жарт', 265 | ], 266 | answers: [ 267 | ...UaAnecdote, 268 | ], 269 | }, 270 | ], 271 | }); 272 | 273 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | 6 | /** 7 | * Create fetcher method for data like "{ : value, ... }". 8 | */ 9 | const createStatsFetch = (url, schema) => () => { 10 | return fetch(url) 11 | .then(res => res.json()) 12 | .then(res => ({ 13 | items: schema.map(field => ({ 14 | ...field, 15 | value: field.value(res.data), 16 | })), 17 | })); 18 | }; 19 | 20 | 21 | /** 22 | * Create fetcher method for data like "{ { item }, { item }, { item }, ... }". 23 | */ 24 | const createTableFetch = (url, schema) => (search, offset, limit) => { 25 | const formatBoolean = (v) => typeof v !== 'boolean' ? v : (v ? '✔' : '✕'); 26 | return fetch(url + '?' + new URLSearchParams({ search, offset, limit })) 27 | .then(res => res.json()) 28 | .then(res => ({ 29 | schema, 30 | total: res.data.total, 31 | items: res.data.docs.map(doc => ({ 32 | id: doc.id, 33 | rawDoc: doc, 34 | fields: schema.map(field => ({ 35 | ...field, 36 | value: formatBoolean(field.value(doc)), 37 | })), 38 | })), 39 | })); 40 | }; 41 | 42 | 43 | /** 44 | * Page router. 45 | */ 46 | const pages = { 47 | stats: { 48 | title: 'Статистика', 49 | type: 'stats', 50 | fetchData: createStatsFetch('/api/chats/stats', [ 51 | { title: 'Повідомлень оброблено', value: doc => doc.messagesProcessed }, 52 | { title: 'Повідомлень надіслано', value: doc => doc.messagesResponded }, 53 | { title: 'Чатів всього', value: doc => doc.total }, 54 | { title: 'Чатів груп', value: doc => doc.totalGroup }, 55 | { title: 'Чатів неактивно', value: doc => doc.totalKicked }, 56 | { title: 'Чатів заглушено', value: doc => doc.totalMuted }, 57 | { title: 'Користувачів всього', value: doc => doc.totalMembers }, 58 | { title: 'Користувачів активно', value: doc => doc.totalMembersActive }, 59 | ]), 60 | }, 61 | chats: { 62 | title: 'Чати', 63 | type: 'table', 64 | fetchData: createTableFetch('/api/chats', [ 65 | // { title: 'ID', value: doc => doc.id, width: 155 }, 66 | // { title: 'Створено', value: doc => doc.createdAt && new Date(doc.createdAt).toLocaleDateString("uk-UA"), width: 185 }, 67 | { title: 'Оновлено', value: doc => doc.updatedAt && new Date(doc.updatedAt).toLocaleDateString('uk-UA'), width: 115 }, 68 | { title: 'Назва чи юзернейм', value: doc => doc.title || doc.username }, 69 | { title: 'Користувачів', value: doc => doc.members, width: 85 }, 70 | { title: 'IN', value: doc => doc.messagesProcessed, width: 85 }, 71 | { title: 'OUT', value: doc => doc.messagesResponded, width: 85 }, 72 | { title: 'Group?', value: doc => doc.isGroup, width: 90 }, 73 | { title: 'Kick?', value: doc => doc.isKicked, width: 90 }, 74 | { title: 'Mute?', value: doc => doc.isMuted, width: 90 }, 75 | ]), 76 | }, 77 | messages: { 78 | title: 'Повідомлення', 79 | type: 'table', 80 | fetchData: createTableFetch('/api/messages', [ 81 | // { title: 'ID', value: doc => doc.id, width: 90 }, 82 | { title: 'Створено', value: doc => doc.createdAt && new Date(doc.createdAt).toLocaleDateString('uk-UA'), width: 115 }, 83 | { title: 'Інтенція', value: doc => doc.intent }, 84 | { title: 'IN', value: doc => doc.textInput }, 85 | { title: 'OUT', value: doc => doc.textOutput }, 86 | { title: 'D?', value: doc => doc.delivered, width: 90 }, 87 | ]), 88 | }, 89 | }; 90 | 91 | 92 | /** 93 | * Application loader. 94 | */ 95 | window.addEventListener('load', () => { 96 | document.getElementById('app').classList.add('loaded'); 97 | new Vue({ 98 | el: '#app', 99 | data: { 100 | error: undefined, 101 | loading: false, 102 | 103 | pages, 104 | pageName: undefined, 105 | page: undefined, 106 | 107 | data: {}, 108 | dataSearch: '', 109 | dataOffset: 0, 110 | dataLimit: 0, 111 | }, 112 | mounted() { 113 | this.parseHashRoute(); 114 | this.$watch('dataLimit', this.fetchPageAndWriteHashRoute); 115 | this.$watch('dataOffset', this.fetchPageAndWriteHashRoute); 116 | this.$watch('dataSearch', () =>{ 117 | this.fetchPageAndWriteHashRoute(); 118 | this.dataOffset = 0; 119 | }); 120 | }, 121 | methods: { 122 | 123 | /** 124 | * Parse page hash and conver it into pageName, offset and limit. 125 | */ 126 | parseHashRoute(hash = window.location.hash) { 127 | const parsedURL = new URL('http://hash/' + hash.slice(2)); 128 | this.setPage( 129 | parsedURL.pathname.slice(1) || 'stats', 130 | parsedURL.searchParams.get('search') || undefined, 131 | parseInt(parsedURL.searchParams.get('offset')) || undefined, 132 | parseInt(parsedURL.searchParams.get('limit')) || undefined, 133 | ); 134 | }, 135 | 136 | /** 137 | * Write pageName, offset and limit ot the page hash. 138 | */ 139 | writeHashRoute() { 140 | if (this.pageName && this.page) { 141 | const params = new URLSearchParams({ offset: this.dataOffset, limit: this.dataLimit, search: this.dataSearch }); 142 | window.location.hash = '/' + this.pageName + (this.page.type !== 'stats' ? ('?' + params) : ('')); 143 | } else { 144 | window.location.hash = '/'; 145 | } 146 | }, 147 | 148 | /** 149 | * Route page. 150 | */ 151 | setPage(pageName, search = '', offset = 0, limit = 10) { 152 | 153 | // Update pagination: 154 | this.dataSearch = search; 155 | this.dataOffset = offset; 156 | this.dataLimit = limit; 157 | 158 | // Unknown page: 159 | if (!pageName || !this.pages[pageName]) { 160 | this.page = undefined; 161 | this.pageName = undefined; 162 | } 163 | 164 | // Known page: 165 | else { 166 | this.page = this.pages[pageName]; 167 | this.pageName = pageName; 168 | this.loading = true; 169 | this.writeHashRoute(); 170 | this.fetchPage().then(() => { 171 | this.loading = false; 172 | }); 173 | } 174 | 175 | }, 176 | 177 | /** 178 | * Fetch current page data and update hash. 179 | */ 180 | fetchPageAndWriteHashRoute() { 181 | this.writeHashRoute(); 182 | this.fetchPage(); 183 | }, 184 | 185 | /** 186 | * Fetch current page data. 187 | */ 188 | fetchPage() { 189 | if (!this.page || !this.page.fetchData) { 190 | return new Promise.reject(); 191 | } else { 192 | return this.page.fetchData(this.dataSearch, this.dataOffset, this.dataLimit) 193 | .then(result => { 194 | this.data = result; 195 | }) 196 | .catch(error => { 197 | console.error(error); // eslint-disable-line no-console 198 | this.error = error; 199 | this.data = {}; 200 | }); 201 | } 202 | }, 203 | 204 | }, 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/regexp/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { ControllerTest } from '~/controller'; 6 | import { logger } from '~/logger'; 7 | import loadRegexp from '.'; 8 | 9 | 10 | let testController: ControllerTest; 11 | beforeEach(async () => { 12 | 13 | // Mock Winston: 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | jest.spyOn(logger, 'info').mockImplementation(() => jest.fn() as any); 16 | 17 | // Prepare controller: 18 | testController = new ControllerTest(); 19 | await loadRegexp(testController, true); 20 | 21 | }); 22 | 23 | 24 | type TestSuite = { 25 | intent: string; 26 | cases: Array<[string, boolean]>; 27 | }; 28 | 29 | 30 | const testCasesReply: Array = [ 31 | { 32 | intent: 'regexp.glory.capitalize', 33 | cases: [ 34 | [ 'україна', true ], 35 | [ 'україна понад усе', true ], 36 | [ 'ця ваша україна', true ], 37 | [ 'та оця ваша україна тут', true ], 38 | [ 'Україна', false ], 39 | ], 40 | }, 41 | { 42 | intent: 'regexp.glory.ukraine', 43 | cases: [ 44 | [ 'Слава Україні!', true ], 45 | [ 'Слава Україні !', true ], 46 | [ 'Слава Україні?', true ], 47 | [ 'Слава Україні.', true ], 48 | [ 'Панове, Слава Україні', true ], 49 | [ 'Сала Україні', false ], 50 | ], 51 | }, 52 | { 53 | intent: 'regexp.glory.nation', 54 | cases: [ 55 | [ 'Слава нації!', true ], 56 | [ 'Слава нації?', true ], 57 | [ 'Слава нації.', true ], 58 | [ 'Петлюрику, Слава нації', true ], 59 | [ 'Панове, Слава нації', true ], 60 | [ 'Слава нації, Панове', false ], 61 | ], 62 | }, 63 | { 64 | intent: 'regexp.glory.over', 65 | cases: [ 66 | [ 'Україна', true ], 67 | [ 'Україна, Панове', false ], 68 | [ 'україна', false ], 69 | ], 70 | }, 71 | { 72 | intent: 'regexp.bandera.father', 73 | cases: [ 74 | [ 'Батько наш - Бандера', true ], 75 | [ 'Батько наш Бандера', true ], 76 | ], 77 | }, 78 | { 79 | intent: 'regexp.bandera.fight', 80 | cases: [ 81 | [ 'ми за Україну', true ], 82 | ], 83 | }, 84 | { 85 | intent: 'regexp.vinnytsia', 86 | cases: [ 87 | [ 'Я їду у Вінницю', true ], 88 | [ 'Я у Вінниці зараз', true ], 89 | [ 'Я знаю що Вінниця краще місто України', true ], 90 | [ 'Що ви там вінничани', true ], 91 | [ 'Вінницькі тут?', true ], 92 | ], 93 | }, 94 | { 95 | intent: 'regexp.kherson', 96 | cases: [ 97 | [ 'Що там в Херсоні?', true ], 98 | [ 'Херсонські тут?', true ], 99 | ], 100 | }, 101 | { 102 | intent: 'regexp.belarus', 103 | cases: [ 104 | [ 'Петлюрику, живе білорусь!', true ], 105 | [ 'Жыве беларусь!', true ], 106 | [ 'Лукашенку смерть, жыве беларусь!', true ], 107 | [ 'Живет Белоруссия!', false ], 108 | ], 109 | }, 110 | { 111 | intent: 'regexp.warship', 112 | cases: [ 113 | [ 'русский военный корабль', true ], 114 | [ 'руський воєнний корабель', true ], 115 | [ 'русский военньій корабль', true ], 116 | ], 117 | }, 118 | { 119 | intent: 'regexp.russophobia.long', 120 | cases: [ 121 | [ 'Опять эта русофобия', true ], 122 | [ 'Що за русофобія', true ], 123 | ], 124 | }, 125 | { 126 | intent: 'regexp.russophobia.short', 127 | cases: [ 128 | [ 'Наша русофобія', true ], 129 | [ 'Наша русофобія...', true ], 130 | [ 'Русофобія', true ], 131 | ], 132 | }, 133 | { 134 | intent: 'regexp.huyryk', 135 | cases: [ 136 | [ 'Хуюрик, ти лох', true ], 137 | [ 'Цей хуюрик', true ], 138 | [ 'Заєбав хуюрик йобаний', true ], 139 | [ 'Хуюрик, ти де?', true ], 140 | [ 'Мда, хуюрик.', true ], 141 | [ 'Пиздюрик', true ], 142 | ], 143 | }, 144 | { 145 | intent: 'regexp.putin.short', 146 | cases: [ 147 | [ 'Путін', true ], 148 | [ 'Путін!', true ], 149 | [ 'Путін', true ], 150 | ], 151 | }, 152 | { 153 | intent: 'regexp.putin.long', 154 | cases: [ 155 | [ 'Хто Путін?', true ], 156 | [ 'Путін хто?', true ], 157 | [ 'Ох уж цей путін', true ], 158 | ], 159 | }, 160 | { 161 | intent: 'regexp.yushchenko.short', 162 | cases: [ 163 | [ 'Ющенко?', true ], 164 | [ 'Ющенко!', true ], 165 | [ 'Ющенко', true ], 166 | [ 'Хующенко', false ], 167 | ], 168 | }, 169 | { 170 | intent: 'regexp.yushchenko.long', 171 | cases: [ 172 | [ 'Що там Ющенко?', true ], 173 | [ 'Ющенко молодець!', true ], 174 | [ 'Ох уж цей Ющенко наш...', true ], 175 | ], 176 | }, 177 | { 178 | intent: 'regexp.arestovych', 179 | cases: [ 180 | [ 'Арестович', true ], 181 | [ 'Арестович - лох', true ], 182 | [ 'Знов Арестович?', true ], 183 | [ 'Пиздоболич', true ], 184 | ], 185 | }, 186 | { 187 | intent: 'regexp.yermak', 188 | cases: [ 189 | [ '👍👍👍', true ], 190 | [ '🤬 🙈 😡 😢', true ], 191 | [ '👍', false ], 192 | ], 193 | }, 194 | { 195 | intent: 'regexp.avakov', 196 | cases: [ 197 | [ 'Аваков', true ], 198 | [ 'Аваков - лох', true ], 199 | [ 'Знов Аваков?', true ], 200 | [ 'Авак', false ], 201 | [ 'Ков', false ], 202 | ], 203 | }, 204 | { 205 | intent: 'regexp.shrek', 206 | cases: [ 207 | [ 'Шрек це життя', true ], 208 | [ 'Хто такий Шрек?', true ], 209 | [ 'Панцершрек', false ], 210 | ], 211 | }, 212 | { 213 | intent: 'regexp.joke.a', 214 | cases: [ 215 | [ 'А', true ], 216 | [ 'А!', true ], 217 | [ 'А?', true ], 218 | [ 'а так', false ], 219 | [ 'так а', false ], 220 | [ 'мда', false ], 221 | ], 222 | }, 223 | { 224 | intent: 'regexp.joke.da', 225 | cases: [ 226 | [ 'Да', true ], 227 | [ 'Да!', true ], 228 | [ 'Да?', true ], 229 | [ 'да такое', false ], 230 | [ 'поїзда', false ], 231 | [ 'мда', false ], 232 | ], 233 | }, 234 | { 235 | intent: 'regexp.joke.ni.greetings', 236 | cases: [ 237 | [ 'Ні', true ], 238 | [ 'Ні!', true ], 239 | [ 'Ні?', true ], 240 | [ 'А мені?', false ], 241 | [ 'гімні', false ], 242 | [ 'ні ще', false ], 243 | ], 244 | }, 245 | { 246 | intent: 'regexp.joke.ni.other', 247 | cases: [ 248 | [ 'А мені?', true ], 249 | [ 'гімні', false ], 250 | [ 'ні ще', false ], 251 | ], 252 | }, 253 | { 254 | intent: 'regexp.joke.ne', 255 | cases: [ 256 | [ 'Нє', true ], 257 | [ 'Нє!', true ], 258 | [ 'Нє?', true ], 259 | [ 'гавнє', false ], 260 | [ 'нє ще', false ], 261 | ], 262 | }, 263 | { 264 | intent: 'regexp.joke.net', 265 | cases: [ 266 | [ 'Нет', true ], 267 | [ 'Нет!', true ], 268 | [ 'Нет?', true ], 269 | [ 'говнет', false ], 270 | [ 'нет еще', false ], 271 | ], 272 | }, 273 | { 274 | intent: 'regexp.joke.ya', 275 | cases: [ 276 | [ 'Я', true ], 277 | [ 'я', true ], 278 | [ 'Я!', true ], 279 | [ 'Ну я', false ], 280 | [ 'Ня', false ], 281 | ], 282 | }, 283 | { 284 | intent: 'regexp.joke.privet', 285 | cases: [ 286 | [ 'Привет', true ], 287 | [ 'Прівет', true ], 288 | [ 'Прівєт', true ], 289 | [ 'Привєт', true ], 290 | [ 'Привіт', false ], 291 | ], 292 | }, 293 | ]; 294 | 295 | 296 | describe.each(testCasesReply)('Regexp - Intent "$intent"', ({ intent, cases }) => { 297 | test.each(cases)('react to %p: %p', async (text, shouldReact) => { 298 | const response = await testController.process({ text }); 299 | if (!shouldReact) { 300 | expect(response?.intent).not.toEqual(intent); 301 | } else { 302 | expect(response?.intent).toEqual(intent); 303 | } 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | /* eslint-disable @typescript-eslint/no-explicit-any */ 6 | import { sample } from 'lodash'; 7 | import TelegramBot from 'node-telegram-bot-api'; 8 | import UaResponsesWelcome from '~/data/responses/ua-welcome.json'; 9 | import { Controller, ControllerRequest } from '~/controller'; 10 | import { logger } from '~/logger'; 11 | import { Store } from '~/store'; 12 | 13 | 14 | // todo refactor this mess 15 | 16 | 17 | /** 18 | * Node Telegram Bot API has no good TypeScript bindings unfortunately. 19 | */ 20 | const getChatMemberCount = async (telegram: TelegramBot, chatId: number | string) => { 21 | const count = await (telegram as any).getChatMemberCount(chatId) as number; 22 | return count - 1; // remove bot from count 23 | }; 24 | 25 | 26 | /** 27 | * Start a new Telegram bot using provided controller and token string. 28 | */ 29 | export const startTelegramBot = async (controller: Controller, token: string, expire?: number) => { 30 | 31 | // Prepare bot and retrieve bot information: 32 | const telegram = new TelegramBot(token, { polling: true }); 33 | const me = await telegram.getMe(); 34 | const startupDate = new Date().getTime(); 35 | 36 | // Store: Connect: 37 | const store = new Store(); 38 | 39 | /* 40 | // Update chat information on a startup: 41 | // todo fix timeouts here 42 | logger.info('bot:update:start'); 43 | const [ _, ...chats ] = await store.chat.search('*', 'LIMIT', 0, 10000); 44 | await Promise.all(chats.map(async ({ id, ...chat }) => { 45 | try { 46 | const count = await getChatMemberCount(telegram, id); 47 | await store.chat.updateValue(id, 'isKicked', false); 48 | await store.chat.updateValue(id, 'members', count); 49 | } catch (_) { 50 | await store.chat.updateValue(id, 'isKicked', true); 51 | await store.chat.updateValue(id, 'members', 0); 52 | } 53 | })); 54 | logger.info('bot:update:finish'); 55 | */ 56 | 57 | // Log: Startup date: 58 | logger.info('bot:ready', { expire: expire || 0 }); 59 | 60 | // Handler: Text messages: 61 | telegram.on('text', async (msg) => { 62 | try { 63 | 64 | // Filter out: 65 | // - Messages without text 66 | // - Messages without sender (conditions unclear) 67 | // - Messages created before the bot started 68 | // - Forwarded messages 69 | if (!msg.from || !msg.text || !!msg.forward_date || msg.date * 1000 < startupDate) { 70 | return; 71 | } 72 | 73 | // Extract basic message information: 74 | const { chat, from, message_id, reply_to_message } = msg; 75 | const isAdressedToBot = (reply_to_message?.from?.id === me.id); 76 | const isGroup = (chat.type !== 'private'); 77 | const chatId = chat.id.toString(); 78 | 79 | // Check that message is adressed to the bot and sanitize text from the trigger: 80 | const botTrigger = new RegExp(`(Петлюрику?|@${me.username}),?`, 'i'); 81 | const isBotTrigger = msg.text.match(botTrigger) !== null; 82 | const text = msg.text.replace(botTrigger, '').trim(); 83 | 84 | // Store: Log chat information: 85 | await store.chat.upsert(chat.id.toString(), { 86 | updatedAt: new Date(), 87 | members: await getChatMemberCount(telegram, chat.id), 88 | username: chat.username || null, 89 | title: chat.title || null, 90 | isKicked: false, 91 | isGroup, 92 | }, { 93 | createdAt: new Date(), 94 | messagesProcessed: 0, 95 | messagesResponded: 0, 96 | isMuted: false, 97 | }); 98 | 99 | // Special case: Greeting message: 100 | if (text === '/start' && (isBotTrigger || !isGroup)) { 101 | await telegram.sendMessage(chatId, sample(UaResponsesWelcome)!); 102 | return; 103 | } 104 | 105 | // Build a controller request: 106 | const request: ControllerRequest = { 107 | id: msg.message_id.toString(), 108 | isBotTrigger, 109 | text, 110 | chat: { 111 | id: chatId, 112 | title: chat.title, 113 | isGroup, 114 | }, 115 | user: { 116 | id: from.id.toString(), 117 | username: from.username, 118 | firstName: from.first_name, 119 | lastName: from.last_name, 120 | }, 121 | replyTo: reply_to_message && { 122 | isAdressedToBot, 123 | userId: reply_to_message.from?.id.toString() || '0', 124 | messageId: reply_to_message.message_id.toString(), 125 | messageText: reply_to_message.text!, 126 | }, 127 | }; 128 | 129 | // Store: Bump processed messages count: 130 | await store.chat.updateIncrement(chatId, 'messagesProcessed', 1); 131 | 132 | // Process incoming request and stop if no response: 133 | const response = await controller.process(request); 134 | if (!response) { 135 | return; 136 | } 137 | 138 | // Try sending a response: 139 | let delivered = true; 140 | try { 141 | 142 | const { text, replyTo } = response; 143 | await telegram.sendMessage(chatId, text, { 144 | reply_to_message_id: replyTo && parseInt(replyTo.messageId), 145 | disable_web_page_preview: true, 146 | parse_mode: 'HTML', 147 | }); 148 | 149 | // Mark delivery as failed if bot is muted: 150 | } catch (error: any) { 151 | if (error.code === 'ETELEGRAM' && error.response?.statusCode) { 152 | delivered = false; 153 | } else { 154 | throw error; 155 | } 156 | } 157 | 158 | // Store: Bump responded messages count and log final result: 159 | await store.chat.updateValue(chatId, 'isMuted', !delivered ); 160 | await store.chat.updateIncrement(chatId, 'messagesResponded'); 161 | 162 | // Log: Message information: 163 | logger.info('bot:message', { delivered, request, response }); 164 | 165 | // Store: Save message information: 166 | await store.message.insert({ 167 | id: message_id.toString(), 168 | createdAt: new Date(), 169 | updatedAt: new Date(), 170 | delivered: delivered, 171 | intent: response.intent, 172 | chatId: chatId, 173 | userId: from.id.toString(), 174 | textInput: text, 175 | textOutput: response.text, 176 | }); 177 | 178 | // Store: Set message expiration: 179 | if (expire) { 180 | await store.message.expire(message_id.toString(), expire); 181 | } 182 | 183 | } catch (error: any) { 184 | logger.error('bot:error', { 185 | error: error.message || error.toString(), 186 | stack: error.stack?.split('\n').map((t: string) => t.trim()), 187 | }); 188 | } 189 | }); 190 | 191 | // Handler: Mute/Kicked status & Greeting Message: 192 | telegram.on('my_chat_member' as any, async (msg: any) => { 193 | 194 | // Build new status: 195 | const chatId = msg.chat.id.toString(); 196 | const isAbleToSendMessage = msg.new_chat_member?.can_send_messages || false; 197 | const isJoined = msg.old_chat_member?.status === 'left' && msg.new_chat_member?.status !== 'left'; 198 | const isKicked = msg.old_chat_member?.status !== 'left' && msg.new_chat_member?.status === 'left'; 199 | 200 | // Status updates: 201 | await store.chat.updateValue(chatId, 'isMuted', !isAbleToSendMessage && !isJoined); 202 | await store.chat.updateValue(chatId, 'isKicked', isKicked ); 203 | if (isKicked) { 204 | logger.info('bot:kicked', { chatId, title: msg.chat.title || null }); 205 | } 206 | 207 | // Greeting message: 208 | if (isJoined) { 209 | logger.info('bot:joined', { chatId, title: msg.chat.title || null, members: await getChatMemberCount(telegram, chatId) }); 210 | await telegram.sendMessage(chatId, sample(UaResponsesWelcome)!); 211 | } 212 | 213 | }); 214 | 215 | telegram.on('error', (error) => { 216 | logger.error('bot:error', { 217 | error, 218 | stack: error.stack?.split('\n').map((t: string) => t.trim()), 219 | }); 220 | }); 221 | 222 | }; 223 | -------------------------------------------------------------------------------- /src/regexp/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import { sample } from 'lodash'; 6 | import UaResponseHostileShort from '~/data/responses/ua-hostile-short.json'; 7 | import { Controller } from '~/controller'; 8 | import { logger } from '~/logger'; 9 | import * as rgx from './utils'; 10 | 11 | 12 | /** 13 | * RegExp reply structure. 14 | */ 15 | type Reply = { 16 | intent: string; 17 | probability?: number; 18 | triggers: Array; 19 | responses: Array; 20 | }; 21 | 22 | 23 | /** 24 | * RegExp reply database. 25 | */ 26 | const replies: Array = [ 27 | 28 | // Glory to Ukraine: 29 | { 30 | intent: 'glory.capitalize', 31 | triggers: [ 32 | rgx.matchPart(/україн[аиі]/), // case sensitive 33 | ], 34 | responses: [ 35 | 'Україна пишеться з великої букви, синку.', 36 | ], 37 | }, 38 | { 39 | intent: 'glory.ukraine', 40 | triggers: [ 41 | rgx.matchEnd(/Слава Україні/i), 42 | ], 43 | responses: [ 44 | 'Героям Слава!', 45 | ], 46 | }, 47 | { 48 | intent: 'glory.nation', 49 | triggers: [ 50 | rgx.matchEnd(/Слава Нації/i), 51 | ], 52 | responses: [ 53 | 'Смерть ворогам!', 54 | ], 55 | }, 56 | { 57 | intent: 'glory.over', 58 | triggers: [ 59 | rgx.matchFull(/Україна/i), 60 | ], 61 | responses: [ 62 | 'Понад усе!', 63 | ], 64 | }, 65 | 66 | // Bandera: 67 | { 68 | intent: 'bandera.father', 69 | triggers: [ 70 | rgx.matchFull(/батько наш - бандера/i), 71 | rgx.matchFull(/батько наш бандера/i), 72 | ], 73 | responses: [ 74 | 'Україна - мати!', 75 | ], 76 | }, 77 | { 78 | intent: 'bandera.fight', 79 | triggers: [ 80 | rgx.matchFull(/ми за україну/i), 81 | ], 82 | responses: [ 83 | 'Будем воювати.', 84 | ], 85 | }, 86 | 87 | // Vinnytsia: 88 | { 89 | intent: 'vinnytsia', 90 | triggers: [ 91 | rgx.matchPart(/вінн?иц(я|і|ю)/i), 92 | rgx.matchPart(/вінн?ицьк(а|і|ий)/i), 93 | rgx.matchPart(/вінн?ичан(и|ин|ка)/i), 94 | ], 95 | responses: [ 96 | 'Вінниця – кращий зі світів.', 97 | 'Чи є міста кращі за Вінницю?', 98 | 'Вінниця — то любов і щастя.', 99 | 'Вінниця — то центр світу.', 100 | 'Вінниця - то травневий сонячний день, солодка вата і оргазм.', 101 | 'Я знов плакав усю ніч від щастя, коли згадав що я з Вінниці.', 102 | 'Боже, дякую що я у Вінниці - тянки течуть, бидло боїться.', 103 | 'Вінниця - це божий дар Україні.', 104 | 'Вінниця – новий Бабилон.', 105 | 'Вінниця - четвертий Рим.', 106 | ], 107 | }, 108 | 109 | // Kherson: 110 | { 111 | intent: 'kherson', 112 | triggers: [ 113 | rgx.matchPart(/херсон(ські|ці|ка|ець)/i), 114 | rgx.matchPart(/херсон(у|і)?/i), 115 | ], 116 | responses: [ 117 | 'Херсон - це Україна.', 118 | 'Херсон - місто-герой.', 119 | 'Херсон - то любов і щастя.', 120 | 'Херсон - батьківщина кавунів.', 121 | 'Херсон - база.', 122 | ], 123 | }, 124 | 125 | // Kherson: 126 | { 127 | intent: 'kherson', 128 | triggers: [ 129 | rgx.matchPart(/херсон/i), 130 | ], 131 | responses: [ 132 | 'Херсон - це Україна.', 133 | 'Херсон - місто-герой.', 134 | 'Херсон - то любов і щастя.', 135 | 'Херсон - батьківщина кавунів.', 136 | 'Херсон - база.', 137 | ], 138 | }, 139 | 140 | { 141 | intent: 'chornobaivka', 142 | triggers: [ 143 | rgx.matchPart(/чорнобаїв(ка|ку|ці)/i), 144 | ], 145 | responses: [ 146 | 'Чорнобаївка - русні роз\'їбаївка.', 147 | ], 148 | }, 149 | 150 | // Belarus: 151 | { 152 | intent: 'belarus', 153 | triggers: [ 154 | rgx.matchPart(/Жыве Беларусь/i), 155 | rgx.matchPart(/Живе Білорусь/i), 156 | ], 157 | responses: [ 158 | 'Ще не вмерла.', 159 | ], 160 | }, 161 | 162 | // Russian warship go fuck yourself: 163 | { 164 | intent: 'warship', 165 | triggers: [ 166 | rgx.matchFull(/рус(ь|с)кий во(є|е)нн(и|ы|ьі)й кораб(е)?ль/i), 167 | ], 168 | responses: [ 169 | 'Йди нахуй!', 170 | ], 171 | }, 172 | 173 | // Russophobic: 174 | { 175 | intent: 'russophobia.short', 176 | triggers: [ 177 | rgx.matchFull(/наша русофоб(і|и)я/i), 178 | rgx.matchFull(/русофоб(і|и)я/i), 179 | ], 180 | responses: [ 181 | 'Недостатня.', 182 | ], 183 | }, 184 | { 185 | intent: 'russophobia.long', 186 | triggers: [ 187 | rgx.matchPart(/русофоб(і|и)я/i), 188 | ], 189 | responses: [ 190 | 'Друзі, наша русофобія недостатня.', 191 | ], 192 | }, 193 | 194 | // Insult: 195 | { 196 | intent: 'huyryk', 197 | triggers: [ 198 | rgx.matchPart(/(Пиздюрику?),?/i), 199 | rgx.matchPart(/(Хуюрику?),?/i), 200 | ], 201 | responses: [ 202 | ...UaResponseHostileShort, 203 | ], 204 | }, 205 | 206 | // Putin: 207 | { 208 | intent: 'putin.short', 209 | triggers: [ 210 | rgx.matchFull(/(путин|путін)/i), 211 | ], 212 | responses: [ 213 | 'Хуйло!', 214 | ], 215 | }, 216 | { 217 | intent: 'putin.long', 218 | triggers: [ 219 | rgx.matchPart(/(путин|путін)/i), 220 | ], 221 | responses: [ 222 | 'Путін - хуйло.', 223 | ], 224 | }, 225 | 226 | // Arestovych: 227 | { 228 | intent: 'arestovych', 229 | triggers: [ 230 | rgx.matchPart(/п(и|і)здоболич/i), 231 | rgx.matchPart(/арестович/i), 232 | ], 233 | responses: [ 234 | 'Арестович - малорос.', 235 | 'Арестович - піздоболич.', 236 | 'Арестович - лох.', 237 | ], 238 | }, 239 | 240 | // Yermak Emoji Reaction: 241 | { 242 | intent: 'yermak', 243 | triggers: [ 244 | /^(\p{Emoji_Presentation}|\s){3,}$/gu, 245 | ], 246 | responses: [ 247 | 'Єрмак, йди нахуй', 248 | '👍🖕', 249 | ], 250 | }, 251 | 252 | // Avakov: 253 | { 254 | intent: 'avakov', 255 | triggers: [ 256 | rgx.matchPart(/аваков/i), 257 | ], 258 | responses: [ 259 | 'Аваков - чорт.', 260 | ], 261 | }, 262 | 263 | // Yushchenko: 264 | { 265 | intent: 'yushchenko.short', 266 | triggers: [ 267 | rgx.matchFull(/ющенк(о|а|у)/i), 268 | ], 269 | responses: [ 270 | 'Так!', 271 | ], 272 | }, 273 | { 274 | intent: 'yushchenko.long', 275 | triggers: [ 276 | rgx.matchPart(/ющенк(о|а|у)/i), 277 | ], 278 | responses: [ 279 | 'Ющенко - так!', 280 | ], 281 | }, 282 | 283 | // Shrek: 284 | { 285 | intent: 'shrek', 286 | triggers: [ 287 | rgx.matchPart(/шрек/i), 288 | ], 289 | responses: [ 290 | 'Шрек - це любов.', 291 | 'Шрек - це життя.', 292 | 'Слава Шреку!', 293 | ], 294 | }, 295 | 296 | // Retarded jokes: 297 | { 298 | intent: 'joke.a', 299 | triggers: [ 300 | rgx.matchFull(/а/i), 301 | ], 302 | responses: [ 303 | 'Хуй на', 304 | ], 305 | }, 306 | { 307 | intent: 'joke.da', 308 | triggers: [ 309 | rgx.matchEnd(/да/i), 310 | ], 311 | responses: [ 312 | 'Підора єда', 313 | 'Хуй на', 314 | 'Пізда', 315 | ], 316 | }, 317 | { 318 | intent: 'joke.ni.greetings', 319 | probability: 0.2, 320 | triggers: [ rgx.matchFull(/ні/i) ], 321 | responses: [ 322 | 'Hello! (Чи то було українською?)', 323 | 'Привіт.', 324 | ], 325 | }, 326 | { 327 | intent: 'joke.ni.other', 328 | triggers: [ rgx.matchEnd(/мені/i) ], 329 | responses: [ 330 | 'Рука в гавні.', 331 | 'Рука в гімні.', 332 | 'Рука в гівні.', 333 | ], 334 | }, 335 | { 336 | intent: 'joke.chuesh', 337 | triggers: [ 338 | rgx.matchEnd(/чуєш/i), 339 | ], 340 | responses: [ 341 | 'На хую переночуєш.', 342 | ], 343 | }, 344 | { 345 | intent: 'joke.ne', 346 | triggers: [ 347 | rgx.matchEnd(/не/i), 348 | rgx.matchEnd(/нє/i), 349 | ], 350 | responses: [ 351 | 'Рука в гавнє.', 352 | ], 353 | }, 354 | { 355 | intent: 'joke.net', 356 | triggers: [ 357 | rgx.matchEnd(/(нєт|нет)/i), 358 | ], 359 | responses: [ 360 | 'Підора атвєт.', 361 | ], 362 | }, 363 | { 364 | intent: 'joke.ya', 365 | triggers: [ 366 | rgx.matchFull(/я/i), 367 | ], 368 | responses: [ 369 | 'Головка від хуя.', 370 | ], 371 | }, 372 | { 373 | intent: 'joke.privet', 374 | triggers: [ 375 | rgx.matchFull(/пр(і|и)в(є|е)т/i), 376 | ], 377 | responses: [ 378 | 'Пукни в пакєт.', 379 | ], 380 | }, 381 | 382 | 383 | ]; 384 | 385 | 386 | /** 387 | * Petlyuryk RegExp utilities. 388 | */ 389 | export * from './utils'; 390 | 391 | 392 | /** 393 | * Petlyuryk RegExp processor module. 394 | */ 395 | export default async (controller: Controller, testMode = false) => { 396 | logger.info('regexp:ready'); 397 | controller.addHandler(async (request) => { 398 | const { id, text } = request; 399 | for (const reply of replies) { 400 | for (const trigger of reply.triggers) { 401 | if (!text.match(trigger)) { 402 | continue; 403 | } 404 | if (!testMode && reply.probability && Math.random() > reply.probability) { 405 | continue; 406 | } 407 | const randomResponse = sample(reply.responses); 408 | return (!randomResponse) ? undefined : { 409 | intent: `regexp.${reply.intent}`, 410 | text: randomResponse, 411 | replyTo: { 412 | messageId: id, 413 | }, 414 | }; 415 | } 416 | } 417 | }); 418 | }; 419 | -------------------------------------------------------------------------------- /src/store/redis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | import Redis from 'ioredis'; 6 | import * as telejson from 'telejson'; 7 | import { zip, isEqual, isArray } from 'lodash'; 8 | import { Mutex } from 'async-mutex'; 9 | import { logger } from '~/logger'; 10 | 11 | 12 | /** 13 | * Store document prototype. 14 | */ 15 | export interface RedisStoreDocument { 16 | id: string; 17 | } 18 | 19 | 20 | /** 21 | * Store primary index structure. 22 | */ 23 | export type RedisStoreIndex = { 24 | [key in keyof D]?: 'TEXT' | 'TAG' | 'NUMERIC' | 'NUMERIC:SORTABLE'; 25 | }; 26 | 27 | 28 | /** 29 | * Store document update. 30 | */ 31 | export type RedisStoreUpdate = ( 32 | Partial> 33 | ); 34 | 35 | 36 | /** 37 | * Document-oriented RedisJSON CRUD Store. 38 | */ 39 | export abstract class RedisStore { 40 | 41 | /** 42 | * Store key prefix. Used to build registry and document keys. 43 | */ 44 | public readonly abstract domain: string; 45 | 46 | /** 47 | * Redis primary index. May be overriden. 48 | */ 49 | public readonly index: RedisStoreIndex = { id: 'TAG' }; 50 | 51 | /** 52 | * Redis client getter. 53 | */ 54 | private get redis() { 55 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 56 | return RedisConnection.getClient(); 57 | } 58 | 59 | /** 60 | * Get Redis primary index name. 61 | */ 62 | private getRedisIndex() { 63 | return `${this.domain}:index`; 64 | } 65 | 66 | /** 67 | * Get Redis Key for a Document with given ID. 68 | */ 69 | private getRedisKey(id: D['id']) { 70 | return `${this.domain}:document:${id}`; 71 | } 72 | 73 | /** 74 | * Convert an object-like Redis RESP array into object. 75 | * Example: [ "a", 1, "b", "2" ] => { a: 1, b: '2' }. 76 | */ 77 | private parseRedisArray(array: Array): Record { 78 | const keys = array.filter((_, i) => i % 2 === 0); 79 | const vals = array.filter((_, i) => i % 2 !== 0); 80 | return Object.fromEntries(zip(keys, vals)); 81 | } 82 | 83 | /** 84 | * Check if document with such ID exists. 85 | */ 86 | public async exists(id: D['id']) { 87 | return await this.redis.exists(this.getRedisKey(id)) !== 0; 88 | } 89 | 90 | /** 91 | * Read a document with given ID. Returns NULL if it does not exist. 92 | */ 93 | public async read(id: D['id']) { 94 | const document = await this.redis.call('json.get', this.getRedisKey(id)) as string | null; 95 | if (document) { 96 | return telejson.parse(document) as D; 97 | } else { 98 | return null; 99 | } 100 | } 101 | 102 | /** 103 | * Insert a document. Does nothing if document with such ID already exists. 104 | */ 105 | public async insert(document: D) { 106 | await this.redis.call('json.set', this.getRedisKey(document.id), '$', telejson.stringify(document), 'NX'); 107 | } 108 | 109 | /** 110 | * Add expiration date in seconds for a document. 111 | */ 112 | public async expire(id: D['id'], seconds: number) { 113 | await this.redis.expire(this.getRedisKey(id), seconds); 114 | } 115 | 116 | /** 117 | * Remove a document. Does nothing if document with such ID does not exist. 118 | */ 119 | public async delete(id: D['id']) { 120 | await this.redis.del(this.getRedisKey(id)); 121 | } 122 | 123 | /** 124 | * Update a document. Create a new if document with such ID does not exist. 125 | */ 126 | public async upsert>(id: D['id'], update: U, writeOnInsert: Omit) { 127 | if (!await this.exists(id)) { 128 | await this.insert({ ...writeOnInsert, ...update, id } as D & U); 129 | } else { 130 | await this.update(id, update); 131 | } 132 | } 133 | 134 | /** 135 | * Update a document. Does nothing if document with such ID does not exist. 136 | */ 137 | public async update>(id: D['id'], update: U) { 138 | const currentDocument = await this.read(id); 139 | const updatedDocument = currentDocument && { ...currentDocument, ...update } as D; 140 | await this.redis.call('json.set', this.getRedisKey(id), '$', telejson.stringify(updatedDocument), 'XX') ; 141 | } 142 | 143 | /** 144 | * Update a single field in document. Does nothing if document with such ID does not exist. 145 | */ 146 | public async updateValue(id: D['id'], field: keyof Omit, value: D[typeof field]) { 147 | if (await this.exists(id)) { 148 | await this.redis.call('json.set', this.getRedisKey(id), `$.${String(field)}`, telejson.stringify(value)); 149 | } 150 | } 151 | 152 | /** 153 | * Increment a single field in document. Does nothing if document with such ID does not exist. 154 | */ 155 | public async updateIncrement(id: D['id'], field: keyof Omit, diff = 1) { 156 | if (await this.exists(id)) { 157 | await this.redis.call('json.numIncrBy', this.getRedisKey(id), `$.${String(field)}`, diff); 158 | } 159 | } 160 | 161 | /** 162 | * Run RediSearch query and return result (total count and document list). 163 | */ 164 | public async search(query = '*', ...args: Array) { 165 | await this.forceIndex(this.getRedisIndex(), this.index); 166 | const queryData = [ this.getRedisIndex(), query, ...args ]; 167 | const redisData = await this.redis.call('ft.search', ...queryData) as Array; 168 | const redisDocs = redisData.slice(1).filter(isArray).map(cols => telejson.parse(cols[cols.length - 1])); 169 | logger.info('redis:search', { query: `ft.search ${queryData.join(' ')}`, count: redisData[0] }); 170 | return [ redisData[0], ...redisDocs ] as [ number, ...Array ]; 171 | } 172 | 173 | /** 174 | * Run RediSearch query and return result (total count and aggregation result). 175 | */ 176 | public async aggregate(query = '*', ...args: Array) { 177 | await this.forceIndex(this.getRedisIndex(), this.index); 178 | const queryData = [ query, ...args ]; 179 | const redisData = await this.redis.call('ft.aggregate', this.getRedisIndex(), ...queryData ) as Array; 180 | const redisDocs = redisData.slice(1).filter(isArray).map(this.parseRedisArray); 181 | logger.info('redis:aggregate', { query: `ft.aggregate ${queryData.join(' ')}`, count: redisData[0] }); 182 | return [ redisData[0], ...redisDocs ] as [ number, ...Array ]; 183 | } 184 | 185 | /** 186 | * Create a new RediSearch index using given index name and schema. Will throw error if such index already exists. 187 | */ 188 | private async createIndex(index: string, schema: RedisStoreIndex) { 189 | const argsPrefix = [ 'PREFIX', 1, this.domain ]; 190 | const argsSchema = [ 'SCHEMA', ...Object.entries(schema).map(([ key, type ]) => [ `$.${key}`, 'AS', key, ...type.split(':') ]).flat() ]; 191 | await this.redis.call('ft.create', index, 'ON', 'JSON', ...argsPrefix, ...argsSchema); 192 | logger.info('redis:index:create', { index, schema: this.index }); 193 | } 194 | 195 | /** 196 | * Create a new RediSearch index using given index name and schema. Will throw error if such index does not exist. 197 | */ 198 | private async deleteIndex(index: string) { 199 | await this.redis.call('ft.dropindex', index); 200 | logger.info('redis:index:delete', { index }); 201 | } 202 | 203 | /** 204 | * Get info about RediSearch index. Will return NULL if such index does not exist. 205 | */ 206 | private async readIndex(index: string) { 207 | try { 208 | const callResult = await this.redis.call('ft.info', this.getRedisIndex()) as Array; 209 | const infoObject = this.parseRedisArray(callResult) as { attributes: Array<[]> }; 210 | const attributes = infoObject.attributes.map(this.parseRedisArray).map(o => { 211 | return [ o.attribute, ('SORTABLE' in o) ? (o.type + ':SORTABLE') : (o.type) ]; 212 | }); 213 | return Object.fromEntries(attributes) as RedisStoreIndex; 214 | } catch (_) { 215 | return null; 216 | } 217 | } 218 | 219 | /** 220 | * Ensure that RediSearch index exists and is up to date. 221 | */ 222 | private async forceIndex(index: string, schema: RedisStoreIndex) { 223 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 224 | await RedisConnection.runExclusive(async () => { 225 | const info = await this.readIndex(index); 226 | if (!isEqual(info, this.index)) { 227 | if (!info) { 228 | await this.createIndex(index, schema); 229 | } else { 230 | await this.deleteIndex(index); 231 | await this.createIndex(index, schema); 232 | } 233 | } 234 | }); 235 | } 236 | 237 | } 238 | 239 | 240 | /** 241 | * Redis connection handler. 242 | */ 243 | export class RedisConnection { 244 | private static redisClient?: Redis = undefined; 245 | private static redisMutex = new Mutex(); 246 | 247 | /** 248 | * Try connecting to Redis. 249 | */ 250 | public static async connect(url: string) { 251 | RedisConnection.redisClient = new Redis(url); 252 | } 253 | 254 | /** 255 | * Exclusive application-wide code execution. Useful for operations like index creation. 256 | */ 257 | public static async runExclusive(...args: Parameters) { 258 | return RedisConnection.redisMutex.runExclusive(...args); 259 | } 260 | 261 | /** 262 | * Get Redis client. Throws an error if connection is not ready yet. 263 | */ 264 | public static getClient() { 265 | const { redisClient } = RedisConnection; 266 | if (!redisClient) throw new Error('No connection available.'); 267 | return redisClient; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/neural/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Part of Petlyuryk by SweetPalma, all rights reserved. 3 | * This code is licensed under GNU GENERAL PUBLIC LICENSE, check LICENSE file for details. 4 | */ 5 | /// 6 | import axios from 'axios'; 7 | import { logger } from '~/logger'; 8 | import { ControllerTest } from '~/controller'; 9 | import UaPraises from '~/data/praises/ua.json'; 10 | import UaInsults from '~/data/insults/ua.json'; 11 | import RuInsults from '~/data/insults/ru.json'; 12 | import UaCommon from '~/data/common/ua.json'; 13 | import RuCommon from '~/data/common/ru.json'; 14 | import loadNeural from '.'; 15 | 16 | 17 | jest.mock('axios'); 18 | jest.mock('~/store'); 19 | let testController: ControllerTest; 20 | beforeAll(async () => { 21 | 22 | // Mock Winston: 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | jest.spyOn(logger, 'info').mockImplementation(() => jest.fn() as any); 25 | 26 | // Prepare controller: 27 | testController = new ControllerTest(); 28 | await loadNeural(testController); 29 | 30 | // Mock axios for API requests: 31 | jest.spyOn(axios, 'get').mockImplementation(async (path: string) => { 32 | if (path.startsWith('https://russianwarship.rip/api/v1/statistics/latest')) { 33 | return { data: { data: { date: '2022-10-10', stats: {}, increase: {} } } }; 34 | } 35 | if (path.startsWith('https://emapa.fra1.cdn.digitaloceanspaces.com')) { 36 | return { data: { states: { a: { enabled: true }, b: { enabled: false } } } }; 37 | } 38 | if (path.startsWith('https://api.coinbase.com')) { 39 | return { data: { data: { rates: { USD: 1 } } } }; 40 | } 41 | if (path.startsWith('https://api.privatbank.ua/')) { 42 | return { data: [ { ccy: 'USD', buy: '5.0', sale: '5.0' }, { ccy: 'EUR', buy: '8.0', sale: '8.0' } ] }; 43 | } 44 | }); 45 | 46 | }); 47 | 48 | 49 | type TestSuite = { 50 | semanticGroup: string; 51 | expectedIntents: Array; 52 | cases: Array; 53 | }; 54 | 55 | 56 | const testCurrencies = [ 57 | 'uah', 'гривня', 58 | 'usd', 'долар', 'доллар', 'бакс', 59 | 'eur', 'євро', 60 | 'rub', 'рубль', 'рубель', 61 | 'btc', 'біток', 'біткоїн', 'біткоін', 62 | 'eth', 'етер', 'ефір', 63 | 'bnb', 'байнанс', 64 | 'ada', 'кардано', 65 | 'sol', 'солана', 66 | 'ltc', 'лайткоїн', 'лайткоін', 67 | 'doge', 'доге', 68 | ]; 69 | 70 | 71 | const testCases: Array = [ 72 | 73 | // General language detection: 74 | { 75 | semanticGroup: 'ukrainian.none', 76 | expectedIntents: [], // must ignore 77 | cases: [ 78 | ...UaCommon, 79 | ], 80 | }, 81 | { 82 | semanticGroup: 'russian.none', 83 | expectedIntents: [ 'neural.ru.none' ], 84 | cases: [ 85 | ...RuCommon, 86 | 'ы', 87 | 'ё', 88 | 'ъ', 89 | 'э', 90 | ], 91 | }, 92 | 93 | // Module: Core: 94 | { 95 | semanticGroup: 'insult', 96 | expectedIntents: [ 'neural.ru.none', 'neural.uk.insult' ], 97 | cases: [ 98 | ...UaInsults.map(word => `ти ${word}`), 99 | ...RuInsults.map(word => `ты ${word}`), 100 | ...UaInsults.map(word => `${word}`), 101 | ...RuInsults.map(word => `${word}`), 102 | ], 103 | }, 104 | { 105 | semanticGroup: 'praise', 106 | expectedIntents: [ 'neural.uk.praise' ], 107 | cases: [ 108 | ...UaPraises.map(word => `ти ${word}`), 109 | ...UaPraises.map(word => `${word}`), 110 | ], 111 | }, 112 | { 113 | semanticGroup: 'reaction.upvote', 114 | expectedIntents: [ 'neural.uk.reaction.upvote' ], 115 | cases: [ 116 | ...UaPraises.map(word => `тут ${word}`), 117 | ], 118 | }, 119 | { 120 | semanticGroup: 'reaction.downvote', 121 | expectedIntents: [ 'neural.uk.reaction.downvote' ], 122 | cases: [ 123 | ...UaInsults.map(word => `тут ${word}`), 124 | ], 125 | }, 126 | { 127 | semanticGroup: 'statistics', 128 | expectedIntents: [ 'neural.uk.statistics' ], 129 | cases: [ 130 | 'статистика', 131 | 'стата', 132 | ], 133 | }, 134 | 135 | // Module: Chatter: 136 | { 137 | semanticGroup: 'chatter.hello', 138 | expectedIntents: [ 'neural.uk.chatter.hello' ], 139 | cases: [ 140 | 'Вітаю, Петлюрику', 141 | 'Петлюрик, здоров', 142 | 'Привіт', 143 | 'Добрий ранок', 144 | 'Добрий вечір', 145 | 'Добрий день', 146 | ], 147 | }, 148 | { 149 | semanticGroup: 'chatter.bye', 150 | expectedIntents: [ 'neural.uk.chatter.bye' ], 151 | cases: [ 152 | 'Бувай, Петлюрику', 153 | 'До побачення', 154 | 'Всього доброго', 155 | 'Надобраніч', 156 | 'Добраніч', 157 | ], 158 | }, 159 | { 160 | semanticGroup: 'chatter.howdy', 161 | expectedIntents: [ 'neural.uk.chatter.howdy' ], 162 | cases: [ 163 | 'чим займаєшся', 164 | 'чим зараз займаєшся', 165 | 'шо робиш', 166 | 'що робиш', 167 | // 'як ти', 168 | // 'шо ти', 169 | ], 170 | }, 171 | { 172 | semanticGroup: 'chatter.thanks', 173 | expectedIntents: [ 'neural.uk.chatter.thanks' ], 174 | cases: [ 175 | 'Спасибі', 176 | 'Дякую, котик', 177 | 'Дякую за поміч', 178 | ], 179 | }, 180 | { 181 | semanticGroup: 'chatter.right', 182 | expectedIntents: [ 'neural.uk.chatter.right' ], 183 | cases: [ 184 | 'ага', 185 | 'згоден', 186 | 'ти правий', 187 | 'точно', 188 | ], 189 | }, 190 | { 191 | semanticGroup: 'chatter.wrong', 192 | expectedIntents: [ 'neural.uk.chatter.wrong' ], 193 | cases: [ 194 | 'ти всрався', 195 | 'ти обісрався', 196 | 'ти помилився кажу', 197 | 'ти неправий', 198 | ], 199 | }, 200 | { 201 | semanticGroup: 'chatter.who.you', 202 | expectedIntents: [ 'neural.uk.chatter.who.you' ], 203 | cases: [ 204 | 'ти бот?', 205 | 'хто ти такий?', 206 | 'ти хто?', 207 | 'хто ти?', 208 | ], 209 | }, 210 | { 211 | semanticGroup: 'chatter.who.me', 212 | expectedIntents: [ 'neural.uk.chatter.who.me' ], 213 | cases: [ 214 | 'я хто?', 215 | 'хто я?', 216 | ], 217 | }, 218 | { 219 | semanticGroup: 'chatter.who.creator', 220 | expectedIntents: [ 'neural.uk.chatter.who.creator' ], 221 | cases: [ 222 | 'хто тебе написав', 223 | 'хто тебе розробив', 224 | 'хто тебе створив', 225 | 'хто твій автор', 226 | ], 227 | }, 228 | { 229 | semanticGroup: 'chatter.capabilities', 230 | expectedIntents: [ 'neural.uk.chatter.capabilities' ], 231 | cases: [ 232 | 'петлюрику, на що ти здатен?', 233 | 'що вмієш?', 234 | 'шо вмієш?', 235 | 'що можеш?', 236 | 'шо можеш?', 237 | ], 238 | }, 239 | { 240 | semanticGroup: 'chatter.source', 241 | expectedIntents: [ 'neural.uk.chatter.source' ], 242 | cases: [ 243 | 'кинь нюдси', 244 | 'покажи код', 245 | 'нюдси', 246 | ], 247 | }, 248 | { 249 | semanticGroup: 'chatter.gender', 250 | expectedIntents: [ 'neural.uk.chatter.gender' ], 251 | cases: [ 252 | 'який твій гендер', 253 | 'ти лесбі?', 254 | 'ти натурал?', 255 | 'ти гетеро?', 256 | 'ти трап?', 257 | 'ти гей?', 258 | ], 259 | }, 260 | { 261 | semanticGroup: 'chatter.annoying', 262 | expectedIntents: [ 'neural.uk.chatter.annoying' ], 263 | cases: [ 264 | 'ти заєбав', 265 | 'ти набрид', 266 | 'господи як же ти задовбав', 267 | 'ти сука бісиш', 268 | ], 269 | }, 270 | { 271 | semanticGroup: 'chatter.ua.crimea', 272 | expectedIntents: [ 'neural.uk.chatter.crimea' ], 273 | cases: [ 274 | 'кому належить Крим?', 275 | 'чий Крим?', 276 | ], 277 | }, 278 | { 279 | semanticGroup: 'chatter.ua.hymn', 280 | expectedIntents: [ 'neural.uk.chatter.hymn' ], 281 | cases: [ 282 | 'заспівай гімн', 283 | 'гімн України', 284 | ], 285 | }, 286 | { 287 | semanticGroup: 'chatter.anecdote', 288 | expectedIntents: [ 'neural.uk.chatter.anecdote' ], 289 | cases: [ 290 | 'анекдот', 291 | 'танатос один долар', 292 | 'танатос сто рублів', 293 | 'жарт', 294 | ], 295 | }, 296 | 297 | // Module: UA Love: 298 | { 299 | semanticGroup: 'love.marry', 300 | expectedIntents: [ 'neural.uk.love.marry' ], 301 | cases: [ 302 | 'вийди за мене заміж', 303 | 'одружися на мені', 304 | ], 305 | }, 306 | { 307 | semanticGroup: 'love.marry', 308 | expectedIntents: [ 'neural.uk.love.you' ], 309 | cases: [ 310 | 'я тебе кохаю', 311 | 'я кохаю тебе', 312 | 'я тебе люблю', 313 | 'я люблю тебе', 314 | ], 315 | }, 316 | { 317 | semanticGroup: 'love.sex', 318 | expectedIntents: [ 'neural.uk.love.sex' ], 319 | cases: [ 320 | 'хочу тебе', 321 | 'пішли в ліжко', 322 | 'давай трахатись', 323 | ], 324 | }, 325 | 326 | // Module: UA Alert: 327 | { 328 | semanticGroup: 'alert.all', 329 | expectedIntents: [ 'neural.uk.alert.all' ], 330 | cases: [ 331 | 'де', 332 | 'де тривоги', 333 | 'де вибухи', 334 | 'де сирени', 335 | ], 336 | }, 337 | { 338 | semanticGroup: 'alert.rate', 339 | expectedIntents: [ 'neural.uk.alert.rate' ], 340 | cases: [ 341 | 'рівень тривоги', 342 | 'рівень небезпеки', 343 | ], 344 | }, 345 | { 346 | semanticGroup: 'alert.sandwich', 347 | expectedIntents: [ 'neural.uk.alert.sandwich' ], 348 | cases: [ 349 | 'готуєм канапки', 350 | 'канапку будеш', 351 | 'хочу канапку', 352 | ], 353 | }, 354 | 355 | // Module: UA Market: 356 | { 357 | semanticGroup: 'market.rate.all', 358 | expectedIntents: [ 'neural.uk.market.rate.all' ], 359 | cases: [ 360 | 'курс', 361 | 'курс валюти', 362 | 'курс валют', 363 | 'скажи курс', 364 | 'як там риночок', 365 | 'що там ринок', 366 | ], 367 | }, 368 | { 369 | semanticGroup: 'market.rate.currency', 370 | expectedIntents: [ 'neural.uk.market.rate.currency' ], 371 | cases: [ 372 | ...testCurrencies.map(word => `кіко коштує ${word}`), 373 | ...testCurrencies.map(word => `скільки коштує ${word}`), 374 | ...testCurrencies.map(word => `що там ${word}`), 375 | ...testCurrencies.map(word => `як там ${word}`), 376 | ...testCurrencies.map(word => `що по ${word}`), 377 | ...testCurrencies.map(word => `шо по ${word}`), 378 | ], 379 | }, 380 | 381 | // Module: UA warship: 382 | { 383 | semanticGroup: 'warship', 384 | expectedIntents: [ 'neural.uk.warship' ], 385 | cases: [ 386 | 'русня', 387 | 'як там дохла русня', 388 | 'що по русні?', 389 | 'шо по русні?', 390 | ], 391 | }, 392 | 393 | ]; 394 | 395 | 396 | describe.each(testCases)('Neural - Semantic Group "$semanticGroup"', ({ cases, expectedIntents }) => { 397 | test.each(cases)('react to "%s"', async (text) => { 398 | const response = await testController.process({ text }); 399 | if (expectedIntents.length > 0) { 400 | expect(response?.intent).toBeOneOf(expectedIntents); 401 | } else { 402 | expect(response?.intent).toBeUndefined(); 403 | } 404 | }); 405 | }); 406 | 407 | 408 | describe('Neural - Language Guesser', () => { 409 | const repeats = [ 'ОООООО', 'АААААААААА', 'ІІІІІІІ' ]; 410 | test.each(repeats)('ignores repeat "%s"', async (text) => { 411 | const response = await testController.process({ text }); 412 | expect(response?.intent).toBeUndefined(); 413 | }); 414 | 415 | const smiles = [ ':з', ':3', ':Р', ':с', ':(' ]; 416 | test.each(smiles)('ignores smile "%s"', async (text) => { 417 | const response = await testController.process({ text }); 418 | expect(response?.intent).toBeUndefined(); 419 | }); 420 | 421 | const laughs = [ 'ахахахахах', 'хахахахах', 'хехе', 'хіхіхіхіхіхіхі', 'ахпхпхпхпхпх' ]; 422 | test.each(laughs)('ignores laugh "%s"', async (text) => { 423 | const response = await testController.process({ text }); 424 | expect(response?.intent).toBeUndefined(); 425 | }); 426 | }); 427 | -------------------------------------------------------------------------------- /src/data/responses/ua-anecdote.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Приходить якось Лукашенко до Путіна та каже: Вовіку, видай крєдіта. А той йому: Нє, так не піде - смокчи хуй чи не дам.\n\nНе довго думаючи Лукашенко погоджується, але тільки за однієї умови - якщо Путін стане на табуретку.\n\nТабуретку приносять, Путін стає на неї, Лукашенко відсмоктує йому, кредит дають - все чин чином.\n\nІ тут Путін питає:\n- Саша, а скажи, навіщо табуретка?\n- БІЛОРУСЬ НІКОЛИ НІ ПЕРЕД КИМ НА КОЛІНА НЕ СТАЄ", 3 | "Зустрічаються два однокласника - підприємець та простий інженер.\n\n- Вася ци ти? На крутому мерседесі, у всратому гуччі оригінальному? Так в тебе в школі були одні двійки. По матиматиці в тебе два на два завжди було п’ять. Про квадратний корінь ти взагалі ніколи не чув! Я - школу з золотою медаллю, червоний диплом - і повна задниця… Як так?\n\n- Ну дивись, Сашок. Все дуже просто, в натурі. Їду в Німеччину, купую бочку пива за тисячу євро. Приїзджаю додому, продаю за три тисячі євро. От на цих два процента я і живу…", 4 | "Яка різниця між вантажівкою з кавунами, та вантажівкою з руснею? Вантажівку з кавунами не можна розвантажувати вилами.", 5 | "Купив чоловік капелюха, а він йому якраз.", 6 | "Спитав якось син батька:\n\n- Батьку, а що таке некомпетентність та байдужість?\n- Не знаю, сину, та й воно мені, власне, до пизди.", 7 | "Морг, на столі мертве тіло. Двоє паталогоанатомів розтинають тіло, третій читає заключення про смерть. Перший, розрізаючи шлунок трупу:\n\n- Ух ти, гречка! З м'ясом! Вася, будеш?\n- Ні, дякую, я вже ситий.\n- Ну, як знаєш.\n\nПерші двоє дістають ложки і починають з апетитом навертати вміст шлунку. Коли каша кінчається третій підіймає очі від заключення про смерть та каже:\n\n- Ого, схоже саме від цієї каші він ласти і склеїв!\n\nПерші двоє починають ригати та бігти до аптечки. Третій їм вслід, дістаючи ложку:\n\n- Хлопці, та я пожартував, інфаркт в нього. Я просто підігріте люблю!", 8 | "Захотіли москалі стати українцями. Прийшли вони в одне українське село, в хату де жив старий, самотній дід і питають:\n\n- Дед как нам стать украинцами?\n- Бачите он ту високу гору?\n- Да, вижу..\n- А на ній високий камінь бачите?\n- Да..\n- Залізьте не цей камінь і крикніть \"Я УКРАЇНЕЦЬ\", і станете українцями.\n\nВилізли вони на ту гору і бачать що разом на камінь не вилізуть, і один каже:\n\n- Давай ты меня подсадишь,а я вылезу и дам тебе руку и крикнем.\n\nПідсадив він його, той виліз і кричить:\n\n- Я УКРАЇНЕЦЬ!!!!\n\nДругий каже:\n\n- Давай мне руку.\n- ...\n- Чего молчишь?\n- Та пішов ти нахуй, москаль йобаний.", 9 | "Після війни в міністерстві оборони рф вирішили позвільняти генералів і щоб заохотити їх кажуть: \"Кожен хто підпише заяву про вихід на пенсію, буде мати змогу дати координати двох точок на свому тілі, ми заміряємо відстань між ними і за кожен сантиметр ви отримаєте по 1000 рублів!\".\n\nЗаходить перший генерал. Каже:\n- Від п'ятки до маківки!\nЗаміряли, дали 720 000 рублів.\n\nЗаходить другий,піднімає дві руки вверх:\n- Від п'ятки до кінчиків пальців на руках!\nЗаміряли, дали 920 000 рублів\n\nТретій заходить каже:\n- Від кінчика члена до яєць.\n- Ну добре, скидайте штани.\n\nСкинув, підходить військовий лікар з лінійкою, прикладає до члена і кричить:\n- О Боже, а де ж ваші яйця?!\n- В Чорнобаївці.", 10 | "Заходить якось чоловік в бар та каже:\n\n- Мені як звичайно.\n\nА бармен йому на це відповідає:\n\n- Добре. Хлопці, виведіть звідси цього довбойоба!", 11 | "Околиця Львова, ранок, перехрестя, стоiть ДАiшник на дорозi. Їде модна така машина, дай думає спиню. Спиняє.\n\n- Добрий ранок!\n- Добрий!\n- Ваші документи будь-ласка.\n- Будь ласка!\n- Вийдiть з машини.\n- З задоволенням! Виходить здоровенний вуйко в вишиванцi, все при ньому, посмiхаeться собi у вуса.\n- Що гарний настрiй?\n- Гарний.\n- Вiдкрийте багажник.\n- Прошу...\n\nВiдкриває, а там труп в мiшку, порубаний на дрiбнi шматки, кров м’ясо i все таке...\n- Шо москаль?..\n- Еге ж...\n- Яке прикре самогубство...", 12 | "Початок 90-х. Депутат вкотре просить дати слово... Спікер Верховної ради Плющ:\n\n- Шановний, я вам дав слово, але ви знову будете про москалів.\n- Та ні, я тільки про екологію.\n- Точно про екологію???\n- Та точно, точно про екологію!\n\nПлющ дає слово депутату, регламент 1 хвилина. Депутат:\n\n- Люди добрі, що це навкруги робиться? Річки обміліли, ставки повисихали, ліси повирубали - нема де цього москаля ані повісити, ані втопити!", 13 | "Зустрічаються двоє крокодилів:\n\n- Ну, як полювання?\n- Двох негрів з'їв, а ти?\n- А я одного москаля.\n- Брешеш, ану дихни...", 14 | "Зловив українець золоту рибку і каже:\n\n- Хочу, щоб мертві москалі пливли в трунах по Дніпру.\n- Та не не всі москалі такі погані, зустрічаються серед них і добрі. - відповідає йому рибка.\n- Ну добре, тоді нехай погані москалі пливуть в поганих трунах, а добрі в добрих.", 15 | "Приходить білорус в бар і каже: \"мені як завжди\". Ну його і відхуярили дубіналом по нирках.", 16 | "- Вуйку, а чому це Ви квітки поливаєте автомастилом? Засохнуть!\n- То нічого що засохнуть, аби зброя не заржавіла.", 17 | "- Чим відрізняються росіянин і собака в Україні?\n- Другий розуміє українську і російську мови.", 18 | "Йдуть два москалі. Один у кепці - другий теж пізди отримає!!!", 19 | "- Глянь, синку! Що це таке в кутку чорніє? Це не москалі?\n- Ні, батьку! Це ж рояль!\n- А що це таке у тому роялі біліє? Це не москалі?\n- Ні, батьку! Це клавки!\n- Синку! А клавки-то не с москалів зроблені?\n- Ні, батьку! Клавки зроблені зі слоника!\n- От кляті москалі! Слоника на клавки порубали!", 20 | "Турист попросив перехожого в Києві розповісти історію пам'ятника Богданові Хмельницькому.\n- Історія така, - почав перехожий, - розгромив Богдан ворогів наших і повернувся з перемогою в місто. Піднявся на коні на гірку, а навколо тисячі людей. Він витяг перед собою булаву і мовив:\n- Здоровенькі були, панове Українці!\nУ відповідь прозвучало:\n- Здравствуй, товарищ Богдан!\nТут він і закам'янів...", 21 | "Їде поїзд Львів-Харків, Загальний вагон, сидить купа студентів. Раптом один студент кричить:\n- Дідько! Хто в вагоні нахаркав?\n Встає дідусь:\n- Я на Харькав\n Дідулю завели у тамбур, дали копняків.\n\n В Тернополі львів'яни виходять, зайшли тернопільскі студенти. Через деякий час знову хтось кричить:\n- Нє ну хто тут нахаркав?\n Тихий голос:\n- Рєбята, я только до Кієва, дальше пєшком.", 22 | "Приходять німці до Степана Бандери додому:\n- Тук-тук-тук, чи є вдома Степан Бандера?\n- Так-так-так - відповів кулемет.", 23 | "1946 рiк. У Захiднiй Українi москалський терор - НКВС, МДБ. В селi один мiсцевий бачить як якийсь чоловiк пiдiйшов до джерела з фiлiжанкою й хоче напитись. Мiсцевий кричить йому:\n- Не пий звiдти, москалi отруiли криницю!!!\n Чоловiк повертається та перепитує москальскою:\n- Чєго гаварішь?\nМiсцевий також по-москасльски вiдповiдає:\n- Я гаварю, дарагой, пєй мєдлєнно: вада очєнь халодная, горло прастудишь.", 24 | "Cидять за столом чех, москаль та українець. Чех наливає пива в бокал, випиває і розбиває бокал і каже:\n- Ми чехи 2 рази з одного посуду не п'ємо.\nМоскаль відкорковує пляшку горілки відпиває половину розбиває і каже:\n- Ми москалі 2 рази з одної пляшки не п'ємо.\nУкраїнець відкорковує пляшку наливає 100гр., випиває, закусує салом, потім бере пляшку в руку і б'є москала по голові та каже:\n- Ми українці 2 рази з одним москалем не п'ємо!!!", 25 | "Створив Господь мавпу. А тоді дивиться:\n- Ні, людина все ж краще. Ну, збирає він усіх мавп і каже:\n- Щоб до завтра мені людьми стали!\nНу, мавпи цілу ніч хвости відрізали, голились, зрештою, прокидається Господь:\n- Краса! Живуть люди у білих хатах із вишневими садочками, хрущі над вишнями гудуть і т.д. Але що це? Деякі мавпи все ще у лісі по деревах стрибають! Ну, він як гримне на них:\n- Я Господь ваш, я вас створив, як ви смієте не підкорятись словам моїм?!\nМавпи перелякались, поховались у кущах, аж тоді найсміливіша вилазить і каже забуханим голосом:\n- А ми па-украінскі нє панімаєм!", 26 | "Стоять москаль і українець - риболовлять. Тут - ОПА! Піймали щось одночасно. Витягують - золота рибка. Та от проблема - москаль за губу піймав, а українець за хвоста. Ну починають вони її тягнути - один в одну сторону, другий - в іншу. Ледь не розірвали. Тут рибка і проситься:\n- Ой, хлопчики, та перестаньте! Давайте так - москаль мене за губу піймав, то хай два бажання замовляє, а українець за хвоста хай одне, - на тому і порішили.\nТут москаль напряг всі свої сірі клітини і давай працювати головою і каже:\n- Ну рибка, сделай так, чтоб на всееей маееей мааааатушкее Раассиииеее ні аднаво хахла не била.\nРибка хвостиком махнула і каже:\n- Готово!\n- Что нии аднаво-нии аднавоо?\n- Ні одного, - каже рибка\n- Харашооо. Таагдаа втааарое: хаачу, чтоб всю мааю мааатушкуу Раассиию ти абнесла високім 20-ти метровим железной стеной.\nРибка хвостиком махнула і каже:\n- Готово! Ну, українець, замовляй:\n- Ну а шо, рибонько, скажи шо і справді жодного нашого?!\n- Нє, - каже, нема...\n- І шо справді мур такий височенний?!\n- Так...\n- Ну то заливай все нафіг бетоном!", 27 | "Приходить син до тата і питає:\n- Тату, скажи мені - як то так: в нас хата на 3 поверхи, 2 машини, купа худоби, курей, качок, індиків - неміряно. А Ви нігде не працюєте, і мама ніде не робить. Звідки усе бере?\n-Ну, сину, ти вже ходиш до 10-го клясу, мушу тобі колись сказати правду: там в стодолі за дверима є сокира. Я беру її і кожної ночи йду на дорогу за селом. Там луплю москалів - шо від них заберу - з того і живемо.\n-Ти сину вже великий - йди та пробуй тої справи.\nСин нагострив сокиру, запасся ножами - пішов.\nЗранку вертається.\n- Тату! Ту щось не те. Я забив 43 москалі, а чистого заробітку 27 гривні і 34 копійки.\n- Ну, синку, таке воно житя - копієчка до копієчки, копієчка до копієчки...", 28 | "Чоловік хоче зарізати москаля. А москаля, як на зло, немає. Коли дивиться - стоїть на вулиці якийсь чолов’яга - ну, геть як москаль. От він і думає:\nЗараз підійду, спитаю, що він тут робить. Він відповість російською, я його й заріжу.\n- Чоловіче, а чого ти тут стоїш?\n- Чекаю.\nХодить чоловік, ходить. Ні, це ж все-таки москаль. Шифрується, гад. Ось я одягнуся старим дідом.\n- Синку, а чого ти тут стоїш?\n- Чекаю, батьку.\nНі, це все - таки москаль. Зараз підійду, спитаю російською.\n- Мужик, а чего ти здесь стоішь?\nЧолов’яга витягає обріза, стріляє:\n- Дочекався!", 29 | "Чоловік хоче зарізати москаля. А москаля, як на зло, немає. Коли дивиться - стоїть на вулиці якийсь чолов’яга - ну, геть як москаль. От він і думає:\nЗараз підійду, спитаю, що він тут робить. Він відповість російською, я його й заріжу.\n- Чоловіче, а чого ти тут стоїш?\n- Чекаю.\nХодить чоловік, ходить. Ні, це ж все-таки москаль. Шифрується, гад. Ось я одягнуся старим дідом.\n- Синку, а чого ти тут стоїш?\n- Чекаю, батьку.\nНі, це все - таки москаль. Зараз підійду, спитаю російською.\n- Мужик, а чего ти здесь стоішь?\nЧолов’яга витягає обріза, стріляє:\n- Дочекався!", 30 | "Київ. Берег Дніпра. Потопаючий репетує:\n- Помогите, помогите!!!\nНа березі стоїть дядько з вусами і говорить потопаючому:\n- Краще б ти, синку, вчився плавати, аніж отої псячої мови.", 31 | "Львів. Ранок. Бабця вийшла за молоком. Тут у неї на очах падає балкон і придушує чоловіка. Збирається натовп, з’являється міліція, бабця підходить і починає:\n- От кляті більшовики! Понабудовували балконів. Людині пройти ніде…\nМіліціонер, діставши документи постраждалого:\n- Бабцю, годі репетувати. То москаль був.\n- Понаїхало клятих москалів!!! Ніде балкону впасти.", 32 | "- Іване, пора тобі одружитися.\n-На кому?\n-Та хоч би на Гапчиній Марії.\n-Не подобається мені Марія...\n-То на Миколовій Галі.\n-І Галя мені не подобається.\n-То хто ж тобі подобається?\n-Володя...\n-Тю... Так він же – москаль!", 33 | "Львів’янин заходить на “Краківський ринок” за яблуками. Вибирає:\n- Це який сорт?\n- Кальвіль.\n- А це?\n- Джонатан.\n- А це?\n- “Слава переможцям”.\n- Героям слава!", 34 | "Їдуть двоє у львівському трамваї і бачать в кутку забився якийсь хлопака. Один другому й каже:\n- Мабуть москаль, потрібно провірити.\n- А що з науки їдеш?\n- У-гу.\nНе колиться.\n- А що мабудь голодний?\n- У-гу.\nНе колиться.\n- А скільки тобі років?\n- Ну в сяньтябре будет двацать.\n- Ой синку не буде!", 35 | "Зустрілися Українець з американцем і в них зайшла розмова про гори. Американець говорить:\n- У нас такі гори, що як гукнеш то ехо 5 хвилин триває.\nУкраїнець:\n- Ну то їдем подивимось.\nВилізають на вершину та й американець кричить:\n- Hello!\nЕхо:\n- Hello- ello- llo- o.\nА Українець каже:\n- Та хіба це гори? У нас у горах ехо 3 години тримається.\nАмериканець:\n- Ну поїхали подивимося.\nВилазять вони на Говерлу і Українець:\n- В Карпатах москаль!\nЕхо:\n- Де москаль!? Де москаль!? Де москаль!?", 36 | "Під час війни потрапив в українське село маленький негр. Там він виріс, потім працював в колгоспі. І от їде він у росію за програмою обміну робітниками. Сидить собі в загальному вагоні, нарізає ножем сало та їсть собі. Але тут він помічає, що на нього чомусь всі дивляться. Так триває декілька хвилин і тут негр не витримує - забиває ножа в стіл та кричить:\n\n- Шо, москалі, сала не бачили!? ", 37 | "Йде мер міста Коломиї на роботу та бачить біля ратуші великий мітинг, люди кричать, щось вимагають. Він їх запитує:\n- Що ви хочете, чого тут зібралися?\n- Хочемо щоб у Коломиї поставили пам'ятник Бандері!\n- Добре, завтра буде.\nНаступного дня забралося ще більше людей і всі щось кричать та вимагають.\n- Чого вам ще треба?\n- Хочемо щоб Бандера був на коні, як Хмельницький!\n-Добре, завтра все виконаєм.\nНаступного дня аналогічна ситуація.\n- Ну чого вам і ще потрібно!?\n- Хочемо щоб в одній руці у Бандери була булава, а у іншій - голова москаля!\n- Ну булаву зробимо, але ж голова буде псуватись.\n-НІЧОГО, МИ БУДЕМО КОЖЕН ДЕНЬ МІНЯТИ!", 38 | "У стаpого бандеpівця питають:\n— Діду у тебе патpони є?\n— Hема, хлопці.\n— Діду, а пістолет є?\n— Hема, хлопці.\n— А кулемет є?\n— А ось чого нема, того нема!", 39 | "Хлопець та дівчина цілуються в під'їзді.\nДівчина каже.\n-Васю, викрутиш лампочку - в рот візьму.\n-Ти шо дурна - вона ж гаряча!", 40 | "Як називається людина, яка розмовляє українською мовою?\n- Двомовна.\n- А якщо людина розмовляє трьома мовами?\n- Тримовна.\n- А якщо однією?\n- Москаль.", 41 | "Йдуть два кума по дорозі, коли\n- Вжик!..\n- Їх обігнав мотоцикліст без голови. Глянули куми один на одного з подивом й пішли далі.\nКоли знову\n- Вжик!...\n- Мотоцикліст знову без голови.\nЗнову здивувались куми і йдуть собі далі.\nВ третій раз\n- Вжик!..\n- Знову мотоцикліст і знову без голови.\nТоді один і говорить:\n- Послухайте, куме, що це робиться - нічого не розумію?\n- Здається, куме, вам потрібно косу на друге плече перекласти...", 42 | "Москаль у Києві:\n- Извініте, как папасть в расійскае пасольство?\n- Дуже просто: навів ракетницю, націлився і йобнув!", 43 | "Сидять два куми за пляшкою та ведуть душевну розмову.\n- Куме, скажіть, а чим все ж таки Ви більше любите закусювать?\n- Ну, огірком, звичайно ж.\n- Тю, огірком - це нецікаво! От я більше за все люблю закусювати вінегретом.\n- Тю, куме, та скажете таке вже - вінегретом!\n- А от дивіться куме: сидиш значить, випиваєш одну - закусюєш вінегретом, потім другу - ще вінегретом закусюєш... так весь вечір. А потім вже десь опівночі виходиш на ґанок, а кругом сніг такий білий-білий! Ти як струганеш - воно по снігу - ЯК ВИШИВАНОЧКА!!!", 44 | "Застряг хлопець у ліфті, викликає диспетчера.\nЧує:\n-Якщо ви бажаете розмовляти українською мовою, натисніть 1, если вы хотите разговаривать на русском, нажмите 2.\nХлопець поміркував та натиснув 2.\n- Ну що, москалику, застряг?", 45 | "Урок в школі. Учителька питає учнів, ким їхні дідусі були.\n- Мій дід був танкістом\n- Добре, а ще когось дідусь ким був?\n- А мій дід був електриком\n- ???\n- А я його каску знайшов, і на ній дві блискавки були.", 46 | "Урок географії у карпатському селі:\n-\n-- Діточки, запам’ятайте: міста Варшава, Париж і Лондон знаходяться на правому березі Дніпра, а міста Москва, Пекін та Токіо – на лівому.", 47 | "Гуцул іде по полонині і веде за руку хлопчика-негра.\nДругий гуцул:\n- Іване, а хто то?\n– Онук.\n– Марійчин хлопець?\n– Марійчин!\n– А Марійка де?\n– Вчиться в місті.\n– А чого воно таке чорне?\n– Зате гарантія, що не москаль!", 48 | "До сесійного залу Верховної Ради вривається терорист:\n- Хто тут Степан Хмара?\nВсі депутати дружньо вказують пальцем на Хмару.\nТерорист:\n- Степане Ільковичу, пригніться!", 49 | "Поїхав Янукович до Путіна. Просить його\n- Памагі мне стать Прєзідєнтам!\nПутін каже:\n- Харашо, памаґу. Толька услуґа за услуґу: ти мнє щас падрачі, а я тєбє патом памаґу Прєзідєнтам стать.\n\nПодумав Янукович:\nП’ять хвилин приниження, зате потім п’ять або й десять років Президентом бути, - та й погодився. Подрочив він Путіну. Приїжджає додому, а у нього на другий день долоні гнійниками покрились. Їде він у Феофанію, там зібрали консиліум, порадились та й кажуть:\n- Методами традиційної медицини таке не лікується, але є народний метод. Тут в одному селі під Києвом є ставок з лікувальною водою. Треба поїхати до того ставка, помити в ньому руки, і тоді зразу хвороба зникне.\n\nЇде Янукович з мигалками, з цілою колоною машин супроводу в те село. Йде до того ставка, миє у ньому руки. А там саме дядько рибу ловив. Побачив він те все, змотав він швиденько вудочки та й біжить до куми хвалитися:\n- Кума я тільки-що на власні очі бачив, як Янукович у нашому ставку руки мив!\nА кума у відповідь:\n- Та ти, мабуть, куме, вже до чортиків допився: то тобі вважається, як Симоненко сраку миє, то як Медведчук горло полоще...", 50 | "Інтернаціональна комісія вчених вирішила провести дослід. Узяли Американця, Француза і Українця. Посадили кожного на свій безлюдний острів. Дали кожному по мавпі і дали завдання: навчити мавпу говорити за рік. Через рік приїджає комісія на перший острів. Бачить: сидить американець, худий, маленький. А коло нього груба мавпа. Ну вони його питаються:\n- Ну що, говорить?\n- Та ні!\n- Ви її хоч годували?\n- Та годував! Давав їй апельсини, банани, мандарини!\n- І все одно не говорить?\n- Ні!\n\nПоїхали вони на другий острів, Приїджають, бачать: сидить француз, маленький, небритий, а коло нього така груба, пишна мавпа! Ну вони його і питають:\n- Ну що, говорить?\n-Та ні!\n- Ви її хоч годували?\n- Та годував! Давав їй апельсини, банани, мандарини, фініки, грейфрути!\n- І все одно не говорить?\n- Ні!\n-\n-Приїджають на третій острів. Бачать: сидить така маленька, нещасна мавпа, а коло неї товстий, великий, задоволений українець. Ну вони його і питаються:\n- Ну що, говорить?\n- Та ні!\n- Та ні!\n- Ви її хоч годували?\n- Та годував! Давав їй апельсини, банани, мандарини, фініки, грейфрути, яблука груші, сливи, вишні, черешні...\n- І все одно не говорить?\n- Ні!\nА мавпа сидить, сумно на них дивиться, і каже:\nОт бреше... От бреше...", 51 | "Покликала мама хопчика і каже:\n- Ось тобі гуска, іди до тітки і продай за 10 гривень.\nНу хлопчик і пішов. Приходить до тітки, в якої коханець якраз прийшов. Ну вона взяла племінника, засунула в шафу і розважається з коханцем. Тут раптом приходить додому чоловік. Ну вона бере коханця, і також в шафу. І починається там така розмова:\n- Вуйку, купи гуску за десять гривень, а то закричу!\n- Ось бери.\n- Вуйку, віддай гуску, а то закричу!\n- Ось бери!\n- Вуйку, купи гуску за десять гривень, а то закричу!\n- Ось бери!\nІ так продовжувалося, поки, у бідного вуйка не кінчилися всі гроші. Тоді хлопчик виліз, забрав гуску, пішов додому, і розповів все мамі. Ввечорі приходить з роботи тато, він до нього біжить і каже:\n- Тато, а я сьогодні...!!\nА тато йому каже:\n- Та замовкни, ти мені ще в шафі надоїв!!!", 52 | "Одружилися Оксана (українка) та Серожа (Росія), живуть в Росії. На різдвяні свята, Оксана вмовила коханого поїхати до свого села, що в Карпатах, відпочити, показати свої традиції, фольклор.\nТа цей Серожа - боїться відходити далеко від хати. Йому ж на родіне казали, що «бандеровци тібя уб'ют»!\nОксана нервує, каже: от тобі пляшка горілки, пампушки, сало, іди в село, до людей, скажеш Христос народився, і все буде гаразд. Дурні ці твої друзі, що таке тобі розповідали, в нас тут нормальні хлопці, ніхто тебе не чіпатиме, послухаєш як колядують, може й з ними спробуєш!\nНаледве відправила. Проходить пів години, той Серожа як вривається до хати як навіжений, двері на замок, очі великі, слина з писка, шапку загубив.\n- Та що ж таке сталося, любий, невже вовки знову до села підійшли?\n- Да я, к нім, с откритой душой! Прихожу, бутилку ставлю на стол, говорю ім, как ти учіла.\n- Христос народілся!\n- А оні, всі хором, как заорут\n- Славімо його!\n- А хрен ви мєня словіте...", 53 | "Друга світова війна. Концтабір в Німеччині. Ведуть на розстріл англійця, француза і українця. І тут німецький солдат каже:\n- Маєте хлопці право на останнє передсмертне бажання.\nАнглієць:\n- Мені б ше перед смертю пляшечку віскі.\nПринесли йому віскі. Француз:\n- А я б шампанського випив. Принесли шампанське. Українець каже:\n- Я хочу, щоби мене наостанок чоботом копнули в дупу.\nНімці посміялися. Але бажання є бажання, треба виконувати. Один розбігається і зі всієї сили дає підсрачника. Українець аж підскакує, вириває в якогось солдата автомат, другого б’є прикладом по голові, в третього випускає автоматну чергу, бере з собою англійця і француза, перескакує з ними через колючий дріт, біжить в ліс, там на пеньочку присідає. Ті двоє питають:\n- Слухай, а ти не міг то скорше зробити, а то б нас розстріляли.\n- А в нас українців завжди так, поки хтось в дупу не копне, то ніц робити не будемо.", 54 | "Їдуть у купе українець, москаль і білорус.\nУкраїнець ліг на верхній полиці спати, і тут чує як білорус пернув і каже:\n- Адін ноль, Бєларусь впєрєді\nТут через 5 хвилин москаль як пердне, шо аж вагон затрясся, і каже\n- Два ноль, Расія впєрєді\nУкраїнець не витримав і почав дзюрити з верхньої полиці на них і каже:\n- Незважаючи на погодні умови, матч продовжувався", 55 | "Один добре вихований чоловік зовсім не знав що таке секс і, звісно, ніколи ним не займався. Збирався одружитися за бажанням батьків, його майбутня дружина це знала й сказала свекрусі, щоб перед весіллям та докладно розповіла синові, звідки беруться діти. Ось мати йому й каже:\n- Знаєш, синку, на тілі чоловіка є такий горбик. А на тілі жінки є така щілинка. Тобі потрібно вставити цей горбик у щілинку і почекати, а потім природа все зробить сама.\nНа ранок після першої шлюбної ночі невістка із заплаканими очима прибігає до свекрухи.\n- Все! З мене досить! Я потребую розлучення! Він просто шматок ідіота, зовсім нічого не вміє!\n- А що ж трапилося?\n- Він вставив ТУДИ свій ніс і сказав:\n- Природа, давай-но швидше думай, бо зараз задихнусь!", 56 | "Маленька дівчинка побачила у дворі чорного кота.\n- Мамо, мамо, глянь-но сюди! КІТ-НЕГР!!!!", 57 | "Йде стюардеса по салону літака. Раптом бачить пасажир сидить весь зелений!\nВона до нього підходить і каже:\n- Шановний, якщо вам погано то візьміть пакет в задній кишені крісла.\nІ далі йде в кабіну до пілотів. Через декілька хвилин повертається до салону і бачить шо всім пасажирам погано, а той пан сидить задоволений і посміхається. Вона підходить до нього тай питає.\n- А що трапилось?\n- Та нічого особливого, просто мені пакетика не вистачило і я надпив трішечки!", 58 | "Їде потяг. Раптом машиніст бачить, що на колії москаль.\nПотян сходить з колії і через поле мчить до лісу.\nУ кабіну машиніста забігає переляканий начальник потяга:\n- Ти що, з глузду з'їхав?\n- Розумієте, їду я, раптом бачу на колії москаль...\n- Так і треба ж було давити його!\n- Я і хотів, а він до лісу побіг!", 59 | "У конкурсі \"Україна очима москалів\" перемогу здобув пан Василь зі Львова, який виклав очима москалів трьохметрове слово УКРАЇНА" 60 | ] 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | GNU GENERAL PUBLIC LICENSE 3 | Version 3, 29 June 2007 4 | Russian warship, go fuck yourself 5 | 6 | Copyright (C) 2007 Free Software Foundation, Inc. 7 | Everyone is permitted to copy and distribute verbatim copies 8 | of this license document, but changing it is not allowed. 9 | 10 | Preamble 11 | 12 | The GNU General Public License is a free, copyleft license for 13 | software and other kinds of works. 14 | 15 | The licenses for most software and other practical works are designed 16 | to take away your freedom to share and change the works. By contrast, 17 | the GNU General Public License is intended to guarantee your freedom to 18 | share and change all versions of a program--to make sure it remains free 19 | software for all its users. We, the Free Software Foundation, use the 20 | GNU General Public License for most of our software; it applies also to 21 | any other work released this way by its authors. You can apply it to 22 | your programs, too. 23 | 24 | When we speak of free software, we are referring to freedom, not 25 | price. Our General Public Licenses are designed to make sure that you 26 | have the freedom to distribute copies of free software (and charge for 27 | them if you wish), that you receive source code or can get it if you 28 | want it, that you can change the software or use pieces of it in new 29 | free programs, and that you know you can do these things. 30 | 31 | To protect your rights, we need to prevent others from denying you 32 | these rights or asking you to surrender the rights. Therefore, you have 33 | certain responsibilities if you distribute copies of the software, or if 34 | you modify it: responsibilities to respect the freedom of others. 35 | 36 | For example, if you distribute copies of such a program, whether 37 | gratis or for a fee, you must pass on to the recipients the same 38 | freedoms that you received. You must make sure that they, too, receive 39 | or can get the source code. And you must show them these terms so they 40 | know their rights. 41 | 42 | Developers that use the GNU GPL protect your rights with two steps: 43 | (1) assert copyright on the software, and (2) offer you this License 44 | giving you legal permission to copy, distribute and/or modify it. 45 | 46 | For the developers' and authors' protection, the GPL clearly explains 47 | that there is no warranty for this free software. For both users' and 48 | authors' sake, the GPL requires that modified versions be marked as 49 | changed, so that their problems will not be attributed erroneously to 50 | authors of previous versions. 51 | 52 | Some devices are designed to deny users access to install or run 53 | modified versions of the software inside them, although the manufacturer 54 | can do so. This is fundamentally incompatible with the aim of 55 | protecting users' freedom to change the software. The systematic 56 | pattern of such abuse occurs in the area of products for individuals to 57 | use, which is precisely where it is most unacceptable. Therefore, we 58 | have designed this version of the GPL to prohibit the practice for those 59 | products. If such problems arise substantially in other domains, we 60 | stand ready to extend this provision to those domains in future versions 61 | of the GPL, as needed to protect the freedom of users. 62 | 63 | Finally, every program is threatened constantly by software patents. 64 | States should not allow patents to restrict development and use of 65 | software on general-purpose computers, but in those that do, we wish to 66 | avoid the special danger that patents applied to a free program could 67 | make it effectively proprietary. To prevent this, the GPL assures that 68 | patents cannot be used to render the program non-free. 69 | 70 | The precise terms and conditions for copying, distribution and 71 | modification follow. 72 | 73 | TERMS AND CONDITIONS 74 | 75 | 0. Definitions. 76 | 77 | "This License" refers to version 3 of the GNU General Public License. 78 | 79 | "Copyright" also means copyright-like laws that apply to other kinds of 80 | works, such as semiconductor masks. 81 | 82 | "The Program" refers to any copyrightable work licensed under this 83 | License. Each licensee is addressed as "you". "Licensees" and 84 | "recipients" may be individuals or organizations. 85 | 86 | To "modify" a work means to copy from or adapt all or part of the work 87 | in a fashion requiring copyright permission, other than the making of an 88 | exact copy. The resulting work is called a "modified version" of the 89 | earlier work or a work "based on" the earlier work. 90 | 91 | A "covered work" means either the unmodified Program or a work based 92 | on the Program. 93 | 94 | To "propagate" a work means to do anything with it that, without 95 | permission, would make you directly or secondarily liable for 96 | infringement under applicable copyright law, except executing it on a 97 | computer or modifying a private copy. Propagation includes copying, 98 | distribution (with or without modification), making available to the 99 | public, and in some countries other activities as well. 100 | 101 | To "convey" a work means any kind of propagation that enables other 102 | parties to make or receive copies. Mere interaction with a user through 103 | a computer network, with no transfer of a copy, is not conveying. 104 | 105 | An interactive user interface displays "Appropriate Legal Notices" 106 | to the extent that it includes a convenient and prominently visible 107 | feature that (1) displays an appropriate copyright notice, and (2) 108 | tells the user that there is no warranty for the work (except to the 109 | extent that warranties are provided), that licensees may convey the 110 | work under this License, and how to view a copy of this License. If 111 | the interface presents a list of user commands or options, such as a 112 | menu, a prominent item in the list meets this criterion. 113 | 114 | 1. Source Code. 115 | 116 | The "source code" for a work means the preferred form of the work 117 | for making modifications to it. "Object code" means any non-source 118 | form of a work. 119 | 120 | A "Standard Interface" means an interface that either is an official 121 | standard defined by a recognized standards body, or, in the case of 122 | interfaces specified for a particular programming language, one that 123 | is widely used among developers working in that language. 124 | 125 | The "System Libraries" of an executable work include anything, other 126 | than the work as a whole, that (a) is included in the normal form of 127 | packaging a Major Component, but which is not part of that Major 128 | Component, and (b) serves only to enable use of the work with that 129 | Major Component, or to implement a Standard Interface for which an 130 | implementation is available to the public in source code form. A 131 | "Major Component", in this context, means a major essential component 132 | (kernel, window system, and so on) of the specific operating system 133 | (if any) on which the executable work runs, or a compiler used to 134 | produce the work, or an object code interpreter used to run it. 135 | 136 | The "Corresponding Source" for a work in object code form means all 137 | the source code needed to generate, install, and (for an executable 138 | work) run the object code and to modify the work, including scripts to 139 | control those activities. However, it does not include the work's 140 | System Libraries, or general-purpose tools or generally available free 141 | programs which are used unmodified in performing those activities but 142 | which are not part of the work. For example, Corresponding Source 143 | includes interface definition files associated with source files for 144 | the work, and the source code for shared libraries and dynamically 145 | linked subprograms that the work is specifically designed to require, 146 | such as by intimate data communication or control flow between those 147 | subprograms and other parts of the work. 148 | 149 | The Corresponding Source need not include anything that users 150 | can regenerate automatically from other parts of the Corresponding 151 | Source. 152 | 153 | The Corresponding Source for a work in source code form is that 154 | same work. 155 | 156 | 2. Basic Permissions. 157 | 158 | All rights granted under this License are granted for the term of 159 | copyright on the Program, and are irrevocable provided the stated 160 | conditions are met. This License explicitly affirms your unlimited 161 | permission to run the unmodified Program. The output from running a 162 | covered work is covered by this License only if the output, given its 163 | content, constitutes a covered work. This License acknowledges your 164 | rights of fair use or other equivalent, as provided by copyright law. 165 | 166 | You may make, run and propagate covered works that you do not 167 | convey, without conditions so long as your license otherwise remains 168 | in force. You may convey covered works to others for the sole purpose 169 | of having them make modifications exclusively for you, or provide you 170 | with facilities for running those works, provided that you comply with 171 | the terms of this License in conveying all material for which you do 172 | not control copyright. Those thus making or running the covered works 173 | for you must do so exclusively on your behalf, under your direction 174 | and control, on terms that prohibit them from making any copies of 175 | your copyrighted material outside their relationship with you. 176 | 177 | Conveying under any other circumstances is permitted solely under 178 | the conditions stated below. Sublicensing is not allowed; section 10 179 | makes it unnecessary. 180 | 181 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 182 | 183 | No covered work shall be deemed part of an effective technological 184 | measure under any applicable law fulfilling obligations under article 185 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 186 | similar laws prohibiting or restricting circumvention of such 187 | measures. 188 | 189 | When you convey a covered work, you waive any legal power to forbid 190 | circumvention of technological measures to the extent such circumvention 191 | is effected by exercising rights under this License with respect to 192 | the covered work, and you disclaim any intention to limit operation or 193 | modification of the work as a means of enforcing, against the work's 194 | users, your or third parties' legal rights to forbid circumvention of 195 | technological measures. 196 | 197 | 4. Conveying Verbatim Copies. 198 | 199 | You may convey verbatim copies of the Program's source code as you 200 | receive it, in any medium, provided that you conspicuously and 201 | appropriately publish on each copy an appropriate copyright notice; 202 | keep intact all notices stating that this License and any 203 | non-permissive terms added in accord with section 7 apply to the code; 204 | keep intact all notices of the absence of any warranty; and give all 205 | recipients a copy of this License along with the Program. 206 | 207 | You may charge any price or no price for each copy that you convey, 208 | and you may offer support or warranty protection for a fee. 209 | 210 | 5. Conveying Modified Source Versions. 211 | 212 | You may convey a work based on the Program, or the modifications to 213 | produce it from the Program, in the form of source code under the 214 | terms of section 4, provided that you also meet all of these conditions: 215 | 216 | a) The work must carry prominent notices stating that you modified 217 | it, and giving a relevant date. 218 | 219 | b) The work must carry prominent notices stating that it is 220 | released under this License and any conditions added under section 221 | 7. This requirement modifies the requirement in section 4 to 222 | "keep intact all notices". 223 | 224 | c) You must license the entire work, as a whole, under this 225 | License to anyone who comes into possession of a copy. This 226 | License will therefore apply, along with any applicable section 7 227 | additional terms, to the whole of the work, and all its parts, 228 | regardless of how they are packaged. This License gives no 229 | permission to license the work in any other way, but it does not 230 | invalidate such permission if you have separately received it. 231 | 232 | d) If the work has interactive user interfaces, each must display 233 | Appropriate Legal Notices; however, if the Program has interactive 234 | interfaces that do not display Appropriate Legal Notices, your 235 | work need not make them do so. 236 | 237 | A compilation of a covered work with other separate and independent 238 | works, which are not by their nature extensions of the covered work, 239 | and which are not combined with it such as to form a larger program, 240 | in or on a volume of a storage or distribution medium, is called an 241 | "aggregate" if the compilation and its resulting copyright are not 242 | used to limit the access or legal rights of the compilation's users 243 | beyond what the individual works permit. Inclusion of a covered work 244 | in an aggregate does not cause this License to apply to the other 245 | parts of the aggregate. 246 | 247 | 6. Conveying Non-Source Forms. 248 | 249 | You may convey a covered work in object code form under the terms 250 | of sections 4 and 5, provided that you also convey the 251 | machine-readable Corresponding Source under the terms of this License, 252 | in one of these ways: 253 | 254 | a) Convey the object code in, or embodied in, a physical product 255 | (including a physical distribution medium), accompanied by the 256 | Corresponding Source fixed on a durable physical medium 257 | customarily used for software interchange. 258 | 259 | b) Convey the object code in, or embodied in, a physical product 260 | (including a physical distribution medium), accompanied by a 261 | written offer, valid for at least three years and valid for as 262 | long as you offer spare parts or customer support for that product 263 | model, to give anyone who possesses the object code either (1) a 264 | copy of the Corresponding Source for all the software in the 265 | product that is covered by this License, on a durable physical 266 | medium customarily used for software interchange, for a price no 267 | more than your reasonable cost of physically performing this 268 | conveying of source, or (2) access to copy the 269 | Corresponding Source from a network server at no charge. 270 | 271 | c) Convey individual copies of the object code with a copy of the 272 | written offer to provide the Corresponding Source. This 273 | alternative is allowed only occasionally and noncommercially, and 274 | only if you received the object code with such an offer, in accord 275 | with subsection 6b. 276 | 277 | d) Convey the object code by offering access from a designated 278 | place (gratis or for a charge), and offer equivalent access to the 279 | Corresponding Source in the same way through the same place at no 280 | further charge. You need not require recipients to copy the 281 | Corresponding Source along with the object code. If the place to 282 | copy the object code is a network server, the Corresponding Source 283 | may be on a different server (operated by you or a third party) 284 | that supports equivalent copying facilities, provided you maintain 285 | clear directions next to the object code saying where to find the 286 | Corresponding Source. Regardless of what server hosts the 287 | Corresponding Source, you remain obligated to ensure that it is 288 | available for as long as needed to satisfy these requirements. 289 | 290 | e) Convey the object code using peer-to-peer transmission, provided 291 | you inform other peers where the object code and Corresponding 292 | Source of the work are being offered to the general public at no 293 | charge under subsection 6d. 294 | 295 | A separable portion of the object code, whose source code is excluded 296 | from the Corresponding Source as a System Library, need not be 297 | included in conveying the object code work. 298 | 299 | A "User Product" is either (1) a "consumer product", which means any 300 | tangible personal property which is normally used for personal, family, 301 | or household purposes, or (2) anything designed or sold for incorporation 302 | into a dwelling. In determining whether a product is a consumer product, 303 | doubtful cases shall be resolved in favor of coverage. For a particular 304 | product received by a particular user, "normally used" refers to a 305 | typical or common use of that class of product, regardless of the status 306 | of the particular user or of the way in which the particular user 307 | actually uses, or expects or is expected to use, the product. A product 308 | is a consumer product regardless of whether the product has substantial 309 | commercial, industrial or non-consumer uses, unless such uses represent 310 | the only significant mode of use of the product. 311 | 312 | "Installation Information" for a User Product means any methods, 313 | procedures, authorization keys, or other information required to install 314 | and execute modified versions of a covered work in that User Product from 315 | a modified version of its Corresponding Source. The information must 316 | suffice to ensure that the continued functioning of the modified object 317 | code is in no case prevented or interfered with solely because 318 | modification has been made. 319 | 320 | If you convey an object code work under this section in, or with, or 321 | specifically for use in, a User Product, and the conveying occurs as 322 | part of a transaction in which the right of possession and use of the 323 | User Product is transferred to the recipient in perpetuity or for a 324 | fixed term (regardless of how the transaction is characterized), the 325 | Corresponding Source conveyed under this section must be accompanied 326 | by the Installation Information. But this requirement does not apply 327 | if neither you nor any third party retains the ability to install 328 | modified object code on the User Product (for example, the work has 329 | been installed in ROM). 330 | 331 | The requirement to provide Installation Information does not include a 332 | requirement to continue to provide support service, warranty, or updates 333 | for a work that has been modified or installed by the recipient, or for 334 | the User Product in which it has been modified or installed. Access to a 335 | network may be denied when the modification itself materially and 336 | adversely affects the operation of the network or violates the rules and 337 | protocols for communication across the network. 338 | 339 | Corresponding Source conveyed, and Installation Information provided, 340 | in accord with this section must be in a format that is publicly 341 | documented (and with an implementation available to the public in 342 | source code form), and must require no special password or key for 343 | unpacking, reading or copying. 344 | 345 | 7. Additional Terms. 346 | 347 | "Additional permissions" are terms that supplement the terms of this 348 | License by making exceptions from one or more of its conditions. 349 | Additional permissions that are applicable to the entire Program shall 350 | be treated as though they were included in this License, to the extent 351 | that they are valid under applicable law. If additional permissions 352 | apply only to part of the Program, that part may be used separately 353 | under those permissions, but the entire Program remains governed by 354 | this License without regard to the additional permissions. 355 | 356 | When you convey a copy of a covered work, you may at your option 357 | remove any additional permissions from that copy, or from any part of 358 | it. (Additional permissions may be written to require their own 359 | removal in certain cases when you modify the work.) You may place 360 | additional permissions on material, added by you to a covered work, 361 | for which you have or can give appropriate copyright permission. 362 | 363 | Notwithstanding any other provision of this License, for material you 364 | add to a covered work, you may (if authorized by the copyright holders of 365 | that material) supplement the terms of this License with terms: 366 | 367 | a) Disclaiming warranty or limiting liability differently from the 368 | terms of sections 15 and 16 of this License; or 369 | 370 | b) Requiring preservation of specified reasonable legal notices or 371 | author attributions in that material or in the Appropriate Legal 372 | Notices displayed by works containing it; or 373 | 374 | c) Prohibiting misrepresentation of the origin of that material, or 375 | requiring that modified versions of such material be marked in 376 | reasonable ways as different from the original version; or 377 | 378 | d) Limiting the use for publicity purposes of names of licensors or 379 | authors of the material; or 380 | 381 | e) Declining to grant rights under trademark law for use of some 382 | trade names, trademarks, or service marks; or 383 | 384 | f) Requiring indemnification of licensors and authors of that 385 | material by anyone who conveys the material (or modified versions of 386 | it) with contractual assumptions of liability to the recipient, for 387 | any liability that these contractual assumptions directly impose on 388 | those licensors and authors. 389 | 390 | All other non-permissive additional terms are considered "further 391 | restrictions" within the meaning of section 10. If the Program as you 392 | received it, or any part of it, contains a notice stating that it is 393 | governed by this License along with a term that is a further 394 | restriction, you may remove that term. If a license document contains 395 | a further restriction but permits relicensing or conveying under this 396 | License, you may add to a covered work material governed by the terms 397 | of that license document, provided that the further restriction does 398 | not survive such relicensing or conveying. 399 | 400 | If you add terms to a covered work in accord with this section, you 401 | must place, in the relevant source files, a statement of the 402 | additional terms that apply to those files, or a notice indicating 403 | where to find the applicable terms. 404 | 405 | Additional terms, permissive or non-permissive, may be stated in the 406 | form of a separately written license, or stated as exceptions; 407 | the above requirements apply either way. 408 | 409 | 8. Termination. 410 | 411 | You may not propagate or modify a covered work except as expressly 412 | provided under this License. Any attempt otherwise to propagate or 413 | modify it is void, and will automatically terminate your rights under 414 | this License (including any patent licenses granted under the third 415 | paragraph of section 11). 416 | 417 | However, if you cease all violation of this License, then your 418 | license from a particular copyright holder is reinstated (a) 419 | provisionally, unless and until the copyright holder explicitly and 420 | finally terminates your license, and (b) permanently, if the copyright 421 | holder fails to notify you of the violation by some reasonable means 422 | prior to 60 days after the cessation. 423 | 424 | Moreover, your license from a particular copyright holder is 425 | reinstated permanently if the copyright holder notifies you of the 426 | violation by some reasonable means, this is the first time you have 427 | received notice of violation of this License (for any work) from that 428 | copyright holder, and you cure the violation prior to 30 days after 429 | your receipt of the notice. 430 | 431 | Termination of your rights under this section does not terminate the 432 | licenses of parties who have received copies or rights from you under 433 | this License. If your rights have been terminated and not permanently 434 | reinstated, you do not qualify to receive new licenses for the same 435 | material under section 10. 436 | 437 | 9. Acceptance Not Required for Having Copies. 438 | 439 | You are not required to accept this License in order to receive or 440 | run a copy of the Program. Ancillary propagation of a covered work 441 | occurring solely as a consequence of using peer-to-peer transmission 442 | to receive a copy likewise does not require acceptance. However, 443 | nothing other than this License grants you permission to propagate or 444 | modify any covered work. These actions infringe copyright if you do 445 | not accept this License. Therefore, by modifying or propagating a 446 | covered work, you indicate your acceptance of this License to do so. 447 | 448 | 10. Automatic Licensing of Downstream Recipients. 449 | 450 | Each time you convey a covered work, the recipient automatically 451 | receives a license from the original licensors, to run, modify and 452 | propagate that work, subject to this License. You are not responsible 453 | for enforcing compliance by third parties with this License. 454 | 455 | An "entity transaction" is a transaction transferring control of an 456 | organization, or substantially all assets of one, or subdividing an 457 | organization, or merging organizations. If propagation of a covered 458 | work results from an entity transaction, each party to that 459 | transaction who receives a copy of the work also receives whatever 460 | licenses to the work the party's predecessor in interest had or could 461 | give under the previous paragraph, plus a right to possession of the 462 | Corresponding Source of the work from the predecessor in interest, if 463 | the predecessor has it or can get it with reasonable efforts. 464 | 465 | You may not impose any further restrictions on the exercise of the 466 | rights granted or affirmed under this License. For example, you may 467 | not impose a license fee, royalty, or other charge for exercise of 468 | rights granted under this License, and you may not initiate litigation 469 | (including a cross-claim or counterclaim in a lawsuit) alleging that 470 | any patent claim is infringed by making, using, selling, offering for 471 | sale, or importing the Program or any portion of it. 472 | 473 | 11. Patents. 474 | 475 | A "contributor" is a copyright holder who authorizes use under this 476 | License of the Program or a work on which the Program is based. The 477 | work thus licensed is called the contributor's "contributor version". 478 | 479 | A contributor's "essential patent claims" are all patent claims 480 | owned or controlled by the contributor, whether already acquired or 481 | hereafter acquired, that would be infringed by some manner, permitted 482 | by this License, of making, using, or selling its contributor version, 483 | but do not include claims that would be infringed only as a 484 | consequence of further modification of the contributor version. For 485 | purposes of this definition, "control" includes the right to grant 486 | patent sublicenses in a manner consistent with the requirements of 487 | this License. 488 | 489 | Each contributor grants you a non-exclusive, worldwide, royalty-free 490 | patent license under the contributor's essential patent claims, to 491 | make, use, sell, offer for sale, import and otherwise run, modify and 492 | propagate the contents of its contributor version. 493 | 494 | In the following three paragraphs, a "patent license" is any express 495 | agreement or commitment, however denominated, not to enforce a patent 496 | (such as an express permission to practice a patent or covenant not to 497 | sue for patent infringement). To "grant" such a patent license to a 498 | party means to make such an agreement or commitment not to enforce a 499 | patent against the party. 500 | 501 | If you convey a covered work, knowingly relying on a patent license, 502 | and the Corresponding Source of the work is not available for anyone 503 | to copy, free of charge and under the terms of this License, through a 504 | publicly available network server or other readily accessible means, 505 | then you must either (1) cause the Corresponding Source to be so 506 | available, or (2) arrange to deprive yourself of the benefit of the 507 | patent license for this particular work, or (3) arrange, in a manner 508 | consistent with the requirements of this License, to extend the patent 509 | license to downstream recipients. "Knowingly relying" means you have 510 | actual knowledge that, but for the patent license, your conveying the 511 | covered work in a country, or your recipient's use of the covered work 512 | in a country, would infringe one or more identifiable patents in that 513 | country that you have reason to believe are valid. 514 | 515 | If, pursuant to or in connection with a single transaction or 516 | arrangement, you convey, or propagate by procuring conveyance of, a 517 | covered work, and grant a patent license to some of the parties 518 | receiving the covered work authorizing them to use, propagate, modify 519 | or convey a specific copy of the covered work, then the patent license 520 | you grant is automatically extended to all recipients of the covered 521 | work and works based on it. 522 | 523 | A patent license is "discriminatory" if it does not include within 524 | the scope of its coverage, prohibits the exercise of, or is 525 | conditioned on the non-exercise of one or more of the rights that are 526 | specifically granted under this License. You may not convey a covered 527 | work if you are a party to an arrangement with a third party that is 528 | in the business of distributing software, under which you make payment 529 | to the third party based on the extent of your activity of conveying 530 | the work, and under which the third party grants, to any of the 531 | parties who would receive the covered work from you, a discriminatory 532 | patent license (a) in connection with copies of the covered work 533 | conveyed by you (or copies made from those copies), or (b) primarily 534 | for and in connection with specific products or compilations that 535 | contain the covered work, unless you entered into that arrangement, 536 | or that patent license was granted, prior to 28 March 2007. 537 | 538 | Nothing in this License shall be construed as excluding or limiting 539 | any implied license or other defenses to infringement that may 540 | otherwise be available to you under applicable patent law. 541 | 542 | 12. No Surrender of Others' Freedom. 543 | 544 | If conditions are imposed on you (whether by court order, agreement or 545 | otherwise) that contradict the conditions of this License, they do not 546 | excuse you from the conditions of this License. If you cannot convey a 547 | covered work so as to satisfy simultaneously your obligations under this 548 | License and any other pertinent obligations, then as a consequence you may 549 | not convey it at all. For example, if you agree to terms that obligate you 550 | to collect a royalty for further conveying from those to whom you convey 551 | the Program, the only way you could satisfy both those terms and this 552 | License would be to refrain entirely from conveying the Program. 553 | 554 | 13. Use with the GNU Affero General Public License. 555 | 556 | Notwithstanding any other provision of this License, you have 557 | permission to link or combine any covered work with a work licensed 558 | under version 3 of the GNU Affero General Public License into a single 559 | combined work, and to convey the resulting work. The terms of this 560 | License will continue to apply to the part which is the covered work, 561 | but the special requirements of the GNU Affero General Public License, 562 | section 13, concerning interaction through a network will apply to the 563 | combination as such. 564 | 565 | 14. Revised Versions of this License. 566 | 567 | The Free Software Foundation may publish revised and/or new versions of 568 | the GNU General Public License from time to time. Such new versions will 569 | be similar in spirit to the present version, but may differ in detail to 570 | address new problems or concerns. 571 | 572 | Each version is given a distinguishing version number. If the 573 | Program specifies that a certain numbered version of the GNU General 574 | Public License "or any later version" applies to it, you have the 575 | option of following the terms and conditions either of that numbered 576 | version or of any later version published by the Free Software 577 | Foundation. If the Program does not specify a version number of the 578 | GNU General Public License, you may choose any version ever published 579 | by the Free Software Foundation. 580 | 581 | If the Program specifies that a proxy can decide which future 582 | versions of the GNU General Public License can be used, that proxy's 583 | public statement of acceptance of a version permanently authorizes you 584 | to choose that version for the Program. 585 | 586 | Later license versions may give you additional or different 587 | permissions. However, no additional obligations are imposed on any 588 | author or copyright holder as a result of your choosing to follow a 589 | later version. 590 | 591 | 15. Disclaimer of Warranty. 592 | 593 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 594 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 595 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 596 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 597 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 598 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 599 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 600 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 601 | 602 | 16. Limitation of Liability. 603 | 604 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 605 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 606 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 607 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 608 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 609 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 610 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 611 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 612 | SUCH DAMAGES. 613 | 614 | 17. Interpretation of Sections 15 and 16. 615 | 616 | If the disclaimer of warranty and limitation of liability provided 617 | above cannot be given local legal effect according to their terms, 618 | reviewing courts shall apply local law that most closely approximates 619 | an absolute waiver of all civil liability in connection with the 620 | Program, unless a warranty or assumption of liability accompanies a 621 | copy of the Program in return for a fee. 622 | 623 | END OF TERMS AND CONDITIONS 624 | 625 | How to Apply These Terms to Your New Programs 626 | 627 | If you develop a new program, and you want it to be of the greatest 628 | possible use to the public, the best way to achieve this is to make it 629 | free software which everyone can redistribute and change under these terms. 630 | 631 | To do so, attach the following notices to the program. It is safest 632 | to attach them to the start of each source file to most effectively 633 | state the exclusion of warranty; and each file should have at least 634 | the "copyright" line and a pointer to where the full notice is found. 635 | 636 | 637 | Copyright (C) 638 | 639 | This program is free software: you can redistribute it and/or modify 640 | it under the terms of the GNU General Public License as published by 641 | the Free Software Foundation, either version 3 of the License, or 642 | (at your option) any later version. 643 | 644 | This program is distributed in the hope that it will be useful, 645 | but WITHOUT ANY WARRANTY; without even the implied warranty of 646 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 647 | GNU General Public License for more details. 648 | 649 | You should have received a copy of the GNU General Public License 650 | along with this program. If not, see . 651 | 652 | Also add information on how to contact you by electronic and paper mail. 653 | 654 | If the program does terminal interaction, make it output a short 655 | notice like this when it starts in an interactive mode: 656 | 657 | Copyright (C) 658 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 659 | This is free software, and you are welcome to redistribute it 660 | under certain conditions; type `show c' for details. 661 | 662 | The hypothetical commands `show w' and `show c' should show the appropriate 663 | parts of the General Public License. Of course, your program's commands 664 | might be different; for a GUI interface, you would use an "about box". 665 | 666 | You should also get your employer (if you work as a programmer) or school, 667 | if any, to sign a "copyright disclaimer" for the program, if necessary. 668 | For more information on this, and how to apply and follow the GNU GPL, see 669 | . 670 | 671 | The GNU General Public License does not permit incorporating your program 672 | into proprietary programs. If your program is a subroutine library, you 673 | may consider it more useful to permit linking proprietary applications with 674 | the library. If this is what you want to do, use the GNU Lesser General 675 | Public License instead of this License. But first, please read 676 | . 677 | --------------------------------------------------------------------------------