From a37000e9a7eea5a7aaaee509a40723ad7a382153 Mon Sep 17 00:00:00 2001 From: Adler Neves Date: Sat, 15 Feb 2020 22:48:06 -0300 Subject: [PATCH] Initial commit --- .gitignore | 13 + .gitlab-ci.yml | 4 + Makefile | 44 ++++ README.md | 17 ++ manage.py | 11 + propergrammar/__init__.py | 9 + propergrammar/__main__.py | 9 + propergrammar/djangosetup.py | 13 + propergrammar/migrations/0001_initial.py | 47 ++++ propergrammar/migrations/__init__.py | 0 propergrammar/models.py | 66 +++++ propergrammar/mwt.py | 42 ++++ propergrammar/settings.py | 19 ++ propergrammar/telebot.py | 308 +++++++++++++++++++++++ requirements.frozen.txt | 14 ++ requirements.txt | 3 + spelling_libreoffice_colibri_cc0.svg | 1 + spelling_libreoffice_colibri_cc0.svg.png | Bin 0 -> 13759 bytes spelling_libreoffice_colibri_cc0_48.svg | 85 +++++++ telegram-bot-use-proper-grammar.service | 13 + 20 files changed, 718 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Makefile create mode 100644 README.md create mode 100755 manage.py create mode 100644 propergrammar/__init__.py create mode 100644 propergrammar/__main__.py create mode 100644 propergrammar/djangosetup.py create mode 100644 propergrammar/migrations/0001_initial.py create mode 100644 propergrammar/migrations/__init__.py create mode 100644 propergrammar/models.py create mode 100644 propergrammar/mwt.py create mode 100644 propergrammar/settings.py create mode 100644 propergrammar/telebot.py create mode 100644 requirements.frozen.txt create mode 100644 requirements.txt create mode 100644 spelling_libreoffice_colibri_cc0.svg create mode 100644 spelling_libreoffice_colibri_cc0.svg.png create mode 100644 spelling_libreoffice_colibri_cc0_48.svg create mode 100644 telegram-bot-use-proper-grammar.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a018fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +**/*.pyc +**/__pycache__ +**/__pycache__/** +virtual_env +virtual_env/** +.vscode +.vscode/** +.idea +.idea/** +.atom +.atom/** +/telegrambot.txt +/db.sqlite3 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5db9fa9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,4 @@ +deploy: + stage: deploy + script: + make deploy diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e9b0ab --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +help: + @echo -n + +deploy: + -sudo systemctl stop telegram-bot-use-proper-grammar.service + -sudo rm /etc/systemd/system/telegram-bot-use-proper-grammar.service + -sudo install telegram-bot-use-proper-grammar.service /etc/systemd/system + -sudo mkdir -p /var/www/telegram-bot-use-proper-grammar + -sudo rm -rf /var/www/telegram-bot-use-proper-grammar/propergrammar + -sudo rm -rf /var/www/telegram-bot-use-proper-grammar/Makefile + -sudo rm -rf /var/www/telegram-bot-use-proper-grammar/manage.py + -sudo cp -R propergrammar /var/www/telegram-bot-use-proper-grammar/propergrammar + -sudo install manage.py /var/www/telegram-bot-use-proper-grammar + -sudo install Makefile /var/www/telegram-bot-use-proper-grammar + -sudo install requirements.frozen.txt /var/www/telegram-bot-use-proper-grammar + sudo make depends -C /var/www/telegram-bot-use-proper-grammar + sudo make migrate -C /var/www/telegram-bot-use-proper-grammar + cd /var/www/telegram-bot-use-proper-grammar; sudo chown http:http -R . + sudo systemctl daemon-reload + sudo systemctl enable telegram-bot-use-proper-grammar.service + sudo systemctl restart telegram-bot-use-proper-grammar.service + +devmigrate: virtual_env + . virtual_env/bin/activate; python manage.py makemigrations + . virtual_env/bin/activate; python manage.py migrate + +migrate: virtual_env + . virtual_env/bin/activate; python manage.py migrate + +serve: virtual_env + . virtual_env/bin/activate; python -m propergrammar + +depends: virtual_env + . virtual_env/bin/activate; pip install -U -r requirements.frozen.txt + +depends-latest: virtual_env + . virtual_env/bin/activate; pip install -U -r requirements.txt + +freeze: virtual_env + . virtual_env/bin/activate; python -m pip freeze > requirements.frozen.txt + +virtual_env: + python3 -m virtualenv virtual_env + make depends diff --git a/README.md b/README.md new file mode 100644 index 0000000..07f94df --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +Telegram - Use Proper Grammar, Please! +=========================== + +Sometimes we need a bot that repeats a set of messages over and over... even more than the frequency you're willing to copy-paste. + +Because of those people who need to be reminded more than you're able to remind them, this bot exists. + +## Running locally + +- Create a bot +- Create the file `telegrambot.txt` containing the API key +- Run `make serve` on a terminal + +## Deploying + +- Copy `telegrambot.txt` to `/var/www/telegram-bot-use-proper-grammar` +- Run `make deploy` diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..acbcd7e --- /dev/null +++ b/manage.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +import sys + +from propergrammar.djangosetup import setup_django + +if __name__ == '__main__': + setup_django() + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/propergrammar/__init__.py b/propergrammar/__init__.py new file mode 100644 index 0000000..da98752 --- /dev/null +++ b/propergrammar/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +from pathlib import Path +from .telebot import start_bot + + +def main(): + start_bot(Path('telegrambot.txt').read_text().strip()) diff --git a/propergrammar/__main__.py b/propergrammar/__main__.py new file mode 100644 index 0000000..92a91cd --- /dev/null +++ b/propergrammar/__main__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +from . import main +from .djangosetup import setup_django + +if __name__ == "__main__": + setup_django() + main() diff --git a/propergrammar/djangosetup.py b/propergrammar/djangosetup.py new file mode 100644 index 0000000..deb7e1c --- /dev/null +++ b/propergrammar/djangosetup.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +import os, sys + +def setup_django(): + # Setup environ + sys.path.append(os.getcwd()) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "propergrammar.settings") + + # Setup django + import django + django.setup() \ No newline at end of file diff --git a/propergrammar/migrations/0001_initial.py b/propergrammar/migrations/0001_initial.py new file mode 100644 index 0000000..7344916 --- /dev/null +++ b/propergrammar/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.0.3 on 2020-02-15 23:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chat_id', models.IntegerField(default=0, unique=True)), + ('name', models.CharField(default='', max_length=255)), + ], + ), + migrations.CreateModel( + name='GroupDictionaryEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('word', models.TextField(default='')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='words', to='propergrammar.Group')), + ], + ), + migrations.AddIndex( + model_name='group', + index=models.Index(fields=['id'], name='propergramm_id_f9b41c_idx'), + ), + migrations.AddIndex( + model_name='group', + index=models.Index(fields=['chat_id'], name='propergramm_chat_id_c6909f_idx'), + ), + migrations.AddIndex( + model_name='groupdictionaryentry', + index=models.Index(fields=['id'], name='propergramm_id_1fdb42_idx'), + ), + migrations.AddIndex( + model_name='groupdictionaryentry', + index=models.Index(fields=['group_id'], name='propergramm_group_i_fd6c71_idx'), + ), + ] diff --git a/propergrammar/migrations/__init__.py b/propergrammar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/propergrammar/models.py b/propergrammar/models.py new file mode 100644 index 0000000..3f1460a --- /dev/null +++ b/propergrammar/models.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +from django.db import models + + +VALID_LANGUAGES_LST = 'en,es,de,fr,pt'.split(',') +VALID_LANGUAGES = tuple(zip( + *(VALID_LANGUAGES_LST, 'English,Español,Deutsch,Français,Português'.split(',')) +)) +VALID_LANGUAGES_DICT = dict(VALID_LANGUAGES) +LANGUAGES_SCOLDING = ''' +I guessed you were writing in "{0}" and I have the following suggestions: +Supuse que estabas escribiendo en "{0}" y tengo las siguientes sugerencias: +Ich vermute, Sie haben auf "{0}" geschrieben und ich habe die folgenden Vorschläge: +J'ai deviné que vous écriviez en "{0}" et j'ai les suggestions suivantes: +Imaginei que você estivesse escrevendo em "{0}" e tenho as seguintes sugestões:'''.splitlines()[1:] +LANGUAGES_SCOLDING_DICT = dict(tuple(zip( + *(VALID_LANGUAGES_LST, LANGUAGES_SCOLDING) +))) + + +class Group(models.Model): + chat_id = models.IntegerField( + default=0, + blank=False, + null=False, + unique=True + ) + name = models.CharField( + default="", + blank=False, + null=False, + max_length=255 + ) + + def __str__(self): + return f'{self.pk} - {self.name}' + + class Meta: + indexes = [ + models.Index(fields=['id']), + models.Index(fields=['chat_id']), + ] + + +class GroupDictionaryEntry(models.Model): + group = models.ForeignKey( + Group, + on_delete=models.CASCADE, + related_name='words' + ) + word = models.TextField( + default="", + blank=False, + null=False + ) + + def __str__(self): + return f'{self.pk} - {self.word}' + + class Meta: + indexes = [ + models.Index(fields=['id']), + models.Index(fields=['group_id']), + ] diff --git a/propergrammar/mwt.py b/propergrammar/mwt.py new file mode 100644 index 0000000..8e52c8c --- /dev/null +++ b/propergrammar/mwt.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +import time + + +class MWT(object): + """Memoize With Timeout""" + _caches = {} + _timeouts = {} + + def __init__(self, timeout=2): + self.timeout = timeout + + def collect(self): + """Clear cache of results which have timed out""" + for func in self._caches: + cache = {} + for key in self._caches[func]: + if (time.time() - self._caches[func][key][1]) < self._timeouts[func]: + cache[key] = self._caches[func][key] + self._caches[func] = cache + + def __call__(self, f): + self.cache = self._caches[f] = {} + self._timeouts[f] = self.timeout + + def func(*args, **kwargs): + kw = sorted(kwargs.items()) + key = (args, tuple(kw)) + try: + v = self.cache[key] + # print("cache") + if (time.time() - v[1]) > self.timeout: + raise KeyError + except KeyError: + # print("new") + v = self.cache[key] = f(*args, **kwargs), time.time() + return v[0] + func.func_name = f.__name__ + + return func diff --git a/propergrammar/settings.py b/propergrammar/settings.py new file mode 100644 index 0000000..96a927a --- /dev/null +++ b/propergrammar/settings.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +SECRET_KEY = "plz, stop complaining" + +INSTALLED_APPS = [ + 'propergrammar' +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'db.sqlite3', + } +} + +USE_TZ = True + +TIME_ZONE = "UTC" diff --git a/propergrammar/telebot.py b/propergrammar/telebot.py new file mode 100644 index 0000000..7454d7d --- /dev/null +++ b/propergrammar/telebot.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- + +from .mwt import MWT +import sys +import telegram +import telegram.ext +from telegram.ext import Updater +from telegram.ext import CommandHandler +from telegram.ext import MessageHandler +from telegram.ext.filters import Filters +from spellchecker import SpellChecker +import logging +from django.utils import timezone +import random +import datetime +from io import BytesIO, StringIO +from telegram.constants import MAX_MESSAGE_LENGTH + +CHECKERS = dict() +Group: 'propergrammar.models.Group' = None +GroupDictionaryEntry: 'propergrammar.models.GroupDictionaryEntry' = None +logger = None +models = None + + +@MWT(timeout=60*5) +def get_admin_ids(bot, chat_id): + """Returns a list of admin IDs for a given chat. Results are cached for 1 hour.""" + return [admin.user.id for admin in bot.get_chat_administrators(chat_id)] + + +def start(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['private']: + context.bot.send_message( + chat_id=update.message.chat_id, + text="Invite me to your server!" + ) + elif chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + is_group_admin(update, context) + send_help_message(update, context) + + +def send_help_message(update: telegram.Update, context: telegram.ext.CallbackContext): + context.bot.send_message( + chat_id=update.message.chat_id, + text='''Try using my services with the commands: +/add_group_dictionary +/show_group_dictionary +/remove_group_dictionary +/export_group_dictionary +(there is a clear_group_dictionary, but you'll have to type it yourself)''' + ) + + +def split_long_messages(message: str) -> list: + if len(message) <= MAX_MESSAGE_LENGTH: + return [message] + else: + lst = [] + lines = message.splitlines() + size = 0 + buffer = '' + for line in lines: + if len(buffer) + len(line) + 1 <= MAX_MESSAGE_LENGTH: + buffer += line + '\n' + else: + lst.append(buffer) + buffer = '' + if len(line) + 1 <= MAX_MESSAGE_LENGTH: + buffer += line + '\n' + else: + remainder = line + while len(remainder) + 1 >= MAX_MESSAGE_LENGTH: + humongous, remainder = ( + remainder[:MAX_MESSAGE_LENGTH], remainder[MAX_MESSAGE_LENGTH:]) + lst.append(humongous) + lst.append(remainder) + buffer = '' + if len(buffer) > 0: + lst.append(buffer) + return lst + + +def get_group(chat): + group = Group.objects.filter(chat_id=chat.id).first() + title = chat.title + if title is None: + title = f'{chat.first_name} {chat.last_name}' + if group is None: + group = Group(chat_id=chat.id, name=title) + group.save() + elif group.name != title: + group.name = title + group.save() + return group + + +def cmd_null(update: telegram.Update, context: telegram.ext.CallbackContext): + return + + +def remove_links(text, entities): + if isinstance(text, str): + text = list(text) + for entity in entities: + if entity.type in ['url', 'email', 'bot_command']: + for i in range(entity.offset, entity.offset+entity.length): + text[i] = '' + return ''.join(text) + else: + return text + + +def handle_message(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup', 'private']: + to_check = ' '.join( + list(filter(lambda x: x is not None, [ + remove_links(update.message.text, update.message.entities), + remove_links(update.message.caption, + update.message.caption_entities) + ])) + ).strip() + if len(to_check) > 0: + group = get_group(update.message.chat) + languages = models.VALID_LANGUAGES_LST + ignored = [x.word for x in group.words.all()] + language_rank = list() + for language in languages: + chk = CHECKERS[language] + words = chk.split_words(to_check) + if len(words) > 0: + language_rank.append( + (len(chk.known(words))/len(words), language) + ) + language_rank.sort() + language_rank.reverse() + language_confidence, language_main = language_rank[0] + if language_confidence == 0: + language_main = 'en' + checker_main = CHECKERS[language_main] + words = checker_main.split_words(to_check) + unknown = checker_main.unknown(words) + for lng in languages: + unknown = CHECKERS[lng].unknown(unknown) + unknown = set(unknown) + for unknown_word in list(unknown): + for ignored_word in ignored: + if unknown_word.lower() == ignored_word.lower(): + unknown.difference_update(unknown_word) + unknown = sorted(list(unknown)) + formatted_suggestions = [] + for typo in unknown: + typo_fixed = checker_main.correction(typo) + typo_fixes = checker_main.candidates(typo) + typo_fixes -= {typo_fixed} + formatted_suggestions.append( + f'{typo} → {typo_fixed}; {", ".join(typo_fixes)}' + ) + if len(unknown) > 0: + lecture = models.LANGUAGES_SCOLDING_DICT[language_main].format( + models.VALID_LANGUAGES_DICT[language_main] + )+'\n'+"\n".join(formatted_suggestions) + for message_segments in split_long_messages(lecture): + context.bot.send_message( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + text=message_segments + ) + + +def cmd_agd(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + group = get_group(update.message.chat) + wordlist = sorted( + list(set([w.word.lower() for w in group.words.all()]))) + new_words = ' '.join(remove_links( + update.message.text, update.message.entities).splitlines()).split() + status = f'Adding {len(new_words)} new words...\n' + for nword in new_words: + status += f'{nword}... ' + if nword.lower() in wordlist: + status += 'already on list\n' + else: + GroupDictionaryEntry(group=group, word=nword).save() + wordlist.append(nword.lower()) + status += 'OK\n' + status += f'New word count: {len(group.words.all())}' + for segment in split_long_messages(status): + context.bot.send_message( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + text=segment + ) + + +def cmd_rgd(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + group = get_group(update.message.chat) + old_words = ' '.join(remove_links( + update.message.text, update.message.entities).splitlines()).split() + status = f'Removing {len(old_words)} words...\n' + for oword in old_words: + status += f'{oword}... ' + entry = group.words.all().filter(word__iexact=oword).first() + if entry is None: + status += 'not found\n' + else: + entry.delete() + status += 'REMOVED\n' + status += f'New word count: {len(group.words.all())}' + for segment in split_long_messages(status): + context.bot.send_message( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + text=segment + ) + + +def cmd_sgd(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + group = get_group(update.message.chat) + wordlist = sorted([w.word for w in group.words.all()]) + wordlist_str = '\n'.join(wordlist) + msg = 'Here are all your %d entries you have on your group\'s dictionary:\n%s' % ( + len(wordlist), wordlist_str + ) + for fragment in split_long_messages(msg): + context.bot.send_message( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + text=fragment + ) + + +def cmd_egd(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + group = get_group(update.message.chat) + wordlist = sorted([w.word for w in group.words.all()]) + wordlist_str = '\n'.join(wordlist) + wordlist_str += '\n' + bio = BytesIO(wordlist_str.encode('UTF-8')) + bio.name = f'DictExport_{update.message.chat_id}_{str(update.message.date).replace(" ", "_").replace(":", "-")}.txt' + context.bot.send_document( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + document=bio + ) + + +def cmd_cgd(update: telegram.Update, context: telegram.ext.CallbackContext): + chattype = update.message.chat.type + if chattype in ['group', 'supergroup']: + admins = get_admin_ids(context.bot, update.message.chat_id) + if update.message.from_user.id in admins: + group = get_group(update.message.chat) + group.words.all().delete() + context.bot.send_message( + chat_id=update.message.chat_id, + reply_to_message_id=update.message.message_id, + text='Erased all entries from local dictionary.' + ) + + +def start_bot(token): + global Group + global GroupDictionaryEntry + global logger + global CHECKERS + global models + from .models import Group + from .models import GroupDictionaryEntry + from . import models + for lng in models.VALID_LANGUAGES_LST: + CHECKERS[lng] = SpellChecker(lng) + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + logger = logging.getLogger() + logger.setLevel(logging.INFO) + u = Updater(token, use_context=True) + d = u.dispatcher + d.add_handler(CommandHandler('start', start)) + d.add_handler(CommandHandler('add_group_dictionary', cmd_agd)) + d.add_handler(CommandHandler('remove_group_dictionary', cmd_rgd)) + d.add_handler(CommandHandler('show_group_dictionary', cmd_sgd)) + d.add_handler(CommandHandler('export_group_dictionary', cmd_egd)) + d.add_handler(CommandHandler('clear_group_dictionary', cmd_cgd)) + d.add_handler(CommandHandler('help', send_help_message)) + d.add_handler(MessageHandler(Filters.all, handle_message)) + u.start_polling() diff --git a/requirements.frozen.txt b/requirements.frozen.txt new file mode 100644 index 0000000..0fd1a5e --- /dev/null +++ b/requirements.frozen.txt @@ -0,0 +1,14 @@ +asgiref==3.2.3 +certifi==2019.11.28 +cffi==1.14.0 +cryptography==2.8 +decorator==4.4.1 +Django==3.0.3 +future==0.18.2 +pycparser==2.19 +pyspellchecker==0.5.3 +python-telegram-bot==12.4.2 +pytz==2019.3 +six==1.14.0 +sqlparse==0.3.0 +tornado==6.0.3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa88f8b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +django +python-telegram-bot +pyspellchecker \ No newline at end of file diff --git a/spelling_libreoffice_colibri_cc0.svg b/spelling_libreoffice_colibri_cc0.svg new file mode 100644 index 0000000..8ab3678 --- /dev/null +++ b/spelling_libreoffice_colibri_cc0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spelling_libreoffice_colibri_cc0.svg.png b/spelling_libreoffice_colibri_cc0.svg.png new file mode 100644 index 0000000000000000000000000000000000000000..2b8e3f323a2e6922d0aec2706df0d668fde9df1d GIT binary patch literal 13759 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 V|FTbM17Eg+NK43zmx${7{2%bfo(li~ literal 0 HcmV?d00001 diff --git a/spelling_libreoffice_colibri_cc0_48.svg b/spelling_libreoffice_colibri_cc0_48.svg new file mode 100644 index 0000000..be3e7d8 --- /dev/null +++ b/spelling_libreoffice_colibri_cc0_48.svg @@ -0,0 +1,85 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/telegram-bot-use-proper-grammar.service b/telegram-bot-use-proper-grammar.service new file mode 100644 index 0000000..dff3287 --- /dev/null +++ b/telegram-bot-use-proper-grammar.service @@ -0,0 +1,13 @@ +[Unit] +Description=Use Proper Grammar Bot telegram service +After=network.target + +[Service] +User=http +Group=http +WorkingDirectory=/var/www/telegram_use_proper_grammar +ExecStart=/usr/bin/make serve +KillSignal=SIGINT + +[Install] +WantedBy=multi-user.target