implement bots

This commit is contained in:
2019-11-12 20:13:56 +03:00
parent bf76e3c3ed
commit 58806f2103
47 changed files with 10251 additions and 305 deletions

0
bots/__init__.py Normal file
View File

3
bots/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
bots/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class BotsConfig(AppConfig):
name = 'bots'

19
bots/forms.py Normal file
View File

@@ -0,0 +1,19 @@
from django.forms import ModelForm
from bots.models import TelegramBot
class BotForm(ModelForm):
prefix = 'bot'
def __init__(self, *args, module, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(module, 'rpc_dispatcher'):
self.fields.pop('rpc_name')
if not hasattr(module, 'periodic_task'):
self.fields.pop('periodic_interval')
self.fields.pop('periodic_last_run')
class Meta:
model = TelegramBot
exclude = 'owner', 'config_type', 'config_id',

View File

View File

View File

@@ -0,0 +1,53 @@
import logging
import traceback
import sentry_sdk
from django.core.cache import cache
from django.core.management import BaseCommand
from telegram import TelegramError
from telegram.error import TimedOut
from bots.models import TelegramBot
class Command(BaseCommand):
def handle(self, *args, **options):
dispatchers = []
while True:
try:
if not dispatchers or cache.get('bots_reset'):
logging.warning('Reloading dispatchers')
dispatchers = []
for bot in TelegramBot.objects.filter(active=True):
try:
dispatcher = bot.build_dispatcher()
dispatcher.last_update_id = 0
dispatchers.append(dispatcher)
except TelegramError:
pass
cache.delete('bots_reset')
for dispatcher in dispatchers:
try:
updates = dispatcher.bot.get_updates(dispatcher.last_update_id)
except TimedOut:
continue
except TelegramError as e:
sentry_sdk.capture_exception(e)
traceback.print_exc()
updates = []
for update in updates:
try:
dispatcher.process_update(update)
except KeyboardInterrupt:
return
except Exception as e:
sentry_sdk.capture_exception(e)
traceback.print_exc()
dispatcher.last_update_id = update.update_id + 1
except KeyboardInterrupt:
return
except Exception as e:
sentry_sdk.capture_exception(e)
traceback.print_exc()

View File

@@ -0,0 +1,33 @@
# Generated by Django 2.1.5 on 2019-11-12 15:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TelegramBot',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=32)),
('bot_token', models.CharField(max_length=256)),
('config_id', models.PositiveIntegerField()),
('config_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AlterUniqueTogether(
name='telegrambot',
unique_together={('config_type', 'config_id')},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.1.5 on 2019-11-12 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bots', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EchoBotModuleConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('prefix', models.TextField()),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.1.5 on 2019-11-12 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bots', '0002_echobotmoduleconfig'),
]
operations = [
migrations.AddField(
model_name='telegrambot',
name='active',
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 2.1.5 on 2019-11-12 16:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bots', '0003_telegrambot_active'),
]
operations = [
migrations.AddField(
model_name='telegrambot',
name='periodic_interval',
field=models.DurationField(blank=True, null=True),
),
migrations.AddField(
model_name='telegrambot',
name='periodic_last_run',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='telegrambot',
name='rpc_name',
field=models.CharField(blank=True, max_length=32, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.1.5 on 2019-11-12 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bots', '0004_auto_20191112_1908'),
]
operations = [
migrations.CreateModel(
name='ChannelHelperBotModuleConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('chat_id', models.CharField(max_length=32)),
],
options={
'abstract': False,
},
),
]

View File

57
bots/models.py Normal file
View File

@@ -0,0 +1,57 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from telegram import Bot
from telegram.ext import Dispatcher
class TelegramBot(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
active = models.BooleanField(default=True)
title = models.CharField(max_length=32)
bot_token = models.CharField(max_length=256)
rpc_name = models.CharField(max_length=32, blank=True, null=True)
periodic_interval = models.DurationField(blank=True, null=True)
periodic_last_run = models.DateTimeField(blank=True, null=True)
config_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
config_id = models.PositiveIntegerField()
config = GenericForeignKey('config_type', 'config_id')
def get_bot(self):
return Bot(self.bot_token)
def build_dispatcher(self):
bot = self.get_bot()
bot.get_me()
dispatcher = Dispatcher(bot, None, workers=0, use_context=True)
self.config.build_dispatcher(dispatcher)
return dispatcher
def __str__(self):
return f'#{self.pk} {self.title}'
class Meta:
unique_together = ('config_type', 'config_id')
class TelegramBotModuleConfig(models.Model):
_bot = GenericRelation(TelegramBot, content_type_field='config_type', object_id_field='config_id')
MODULE_NAME = '<Not set>'
@property
def bot(self):
return self._bot.get()
@property
def content_type(self):
return ContentType.objects.get_for_model(self.__class__)
def build_dispatcher(self, dispatcher: Dispatcher):
raise NotImplementedError()
class Meta:
abstract = True

4
bots/modules/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .channel_helper import ChannelHelperBotModuleConfig
from .echo import EchoBotModuleConfig
BOT_MODULES = [EchoBotModuleConfig, ChannelHelperBotModuleConfig]

View File

@@ -0,0 +1,87 @@
import base64
import os
import tempfile
from io import BytesIO
from uuid import uuid4
import requests
from PIL import Image
from django.db import models
from telegram import Update
from telegram.ext import Dispatcher, CallbackContext, MessageHandler, Filters
from jsonrpc import Dispatcher as RPCDispatcher
from bots.models import TelegramBotModuleConfig
class ChannelHelperBotModuleConfig(TelegramBotModuleConfig):
chat_id = models.CharField(max_length=32)
MODULE_NAME = 'Channel helper'
rpc_dispatcher = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.rpc_dispatcher = RPCDispatcher()
self.rpc_dispatcher['post_photo'] = self.rpc_post_photo
def rpc_post_photo(self, photo, is_base64=False):
try:
if is_base64:
f = BytesIO(base64.b64decode(photo))
else:
resp = requests.get(photo)
resp.raise_for_status()
f = BytesIO(resp.content)
except:
raise RuntimeError('Could not load image')
im = Image.open(f) # type: Image.Image
width, height = im.size
if width > 2000 or height > 2000:
im.thumbnail((2000, 2000))
with tempfile.TemporaryDirectory() as d:
fpath = os.path.join(d, '{}.jpg'.format(uuid4()))
im.save(fpath)
self.bot.get_bot().send_photo(self.chat_id, open(fpath, 'rb'))
return True
def handle_message(self, update: Update, ctx: CallbackContext):
m = update.effective_message
bot = ctx.bot
if hasattr(m, 'audio') and m.audio:
a = m.audio
r = bot.send_audio(self.chat_id, a.file_id, a.duration, a.performer, a.title)
elif hasattr(m, 'document') and m.document:
d = m.document
r = bot.send_document(self.chat_id, d.file_id, d.file_name)
elif hasattr(m, 'photo') and m.photo:
p = m.photo
r = bot.send_photo(self.chat_id, p[-1].file_id)
elif hasattr(m, 'sticker') and m.sticker:
s = m.sticker
r = bot.send_sticker(self.chat_id, s.file_id)
elif hasattr(m, 'video') and m.video:
v = m.video
r = bot.send_video(self.chat_id, v.file_id, v.duration)
elif hasattr(m, 'voice') and m.voice:
v = m.voice
r = bot.send_voice(self.chat_id, v.file_id, v.duration)
elif hasattr(m, 'video_note') and m.video_note:
vn = m.video_note
r = bot.send_video_note(self.chat_id, vn.file_id, vn.duration, vn.length)
elif hasattr(m, 'contact') and m.contact:
c = m.contact
r = bot.send_contact(self.chat_id, c.phone_number, c.first_name, c.last_name)
elif hasattr(m, 'location') and m.location:
l = m.location
r = bot.send_location(self.chat_id, l.latitude, l.longitude)
elif hasattr(m, 'venue') and m.venue:
v = m.venue
r = bot.send_venue(self.chat_id, v.location.latitude, v.location.longitude, v.title, v.address, v.foursquare_id)
elif hasattr(m, 'text') and m.text:
r = bot.send_message(self.chat_id, m.text_html, 'html')
def build_dispatcher(self, dispatcher: Dispatcher):
dispatcher.add_handler(MessageHandler(Filters.all, self.handle_message))
return dispatcher

18
bots/modules/echo.py Normal file
View File

@@ -0,0 +1,18 @@
from django.db import models
from telegram import Update
from telegram.ext import Dispatcher, CallbackContext, MessageHandler, Filters
from bots.models import TelegramBotModuleConfig
class EchoBotModuleConfig(TelegramBotModuleConfig):
prefix = models.TextField()
MODULE_NAME = 'Echo'
def message_handler(self, update: Update, ctx: CallbackContext):
update.effective_message.reply_text(self.prefix + update.effective_message.text)
def build_dispatcher(self, dispatcher: Dispatcher):
dispatcher.add_handler(MessageHandler(Filters.text, self.message_handler))
return dispatcher

View File

@@ -0,0 +1,55 @@
{% extends 'cabinet/_internal_base.html' %}
{% load bootstrap4 %}
{% block breadcrumbs %}
<li><a href="{% url 'cabinet:bots:index' %}">Bot list</a></li>
<li><span>{{ title }}</span></li>
{% endblock %}
{% block content %}
<form action="" method="post" class="card">
{% csrf_token %}
<header class="card-header">
<h2 class="card-title">{% if feed %}Bot "{{ feed.title }}" configuration{% else %}New bot{% endif %}</h2>
</header>
<div class="card-body">
<h4>General options</h4>
{% bootstrap_form bot_form layout='horizontal' %}
<hr>
<h4>Module options</h4>
{% bootstrap_form config_form layout='horizontal' %}
</div>
<footer class="card-footer text-right">
{% if feed %}<a href="#delete-modal" class="modal-basic btn btn-danger">Delete</a>{% endif %}
<button type="submit" class="btn btn-primary">Save</button>
</footer>
</form>
{% if feed %}
<div id="delete-modal" class="modal-block modal-full-color modal-block-danger mfp-hide">
<section class="card">
<header class="card-header">
<h2 class="card-title">Delete bot "{{ feed.title }}"</h2>
</header>
<div class="card-body">
<div class="modal-wrapper">
<div class="modal-icon"><i class="fas fa-times-circle"></i></div>
<div class="modal-text">
<h4>Are you sure?</h4>
<p>This action cannot be undone.</p>
</div>
</div>
</div>
<footer class="card-footer">
<div class="row">
<form action="{% url 'cabinet:bots:delete' pk=feed.pk %}" method="post" class="col-md-12 text-right">
{% csrf_token %}
<button type="button" class="btn btn-default modal-dismiss">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</footer>
</section>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'cabinet/_internal_base.html' %}
{% block breadcrumbs %}
<li><span>Bot list</span></li>
{% endblock %}
{% block content %}
<section class="card">
<header class="card-header">
<div class="card-button-actions">
<div class="dropdown">
<button class="btn btn-primary btn-sm dropdown-toggle" type="button" data-toggle="dropdown">
Add new bot
</button>
<div class="dropdown-menu dropdown-menu-right">
{% for module in bot_modules %}
<a class="dropdown-item"
href="{% url 'cabinet:bots:new' content_type=module.content_type.model %}">{{ module.MODULE_NAME }}</a>
{% endfor %}
</div>
</div>
</div>
<h2 class="card-title">Your bots</h2>
</header>
<div class="card-body">
<table class="table table-hover table-responsive-md mb-0">
<thead>
<tr>
<th>Title</th>
<th>Module</th>
</tr>
</thead>
{% for bot in object_list %}
<tr class="clickable-row" data-href="{% url 'cabinet:bots:edit' pk=bot.pk %}">
<td>{{ bot.title }}</td>
<td>{{ bot.config.MODULE_NAME }}</td>
</tr>
{% empty %}
<tfoot>
<tr>
<td colspan="4">No bots added</td>
</tr>
</tfoot>
{% endfor %}
</table>
</div>
</section>
{% endblock %}

3
bots/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
bots/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from bots.views import BotListView, BotConfigEditView, BotConfigDeleteView, BotConfigCreateView
app_name = 'bots'
urlpatterns = [
path('', BotListView.as_view(), name='index'),
path('<int:pk>/', BotConfigEditView.as_view(), name='edit'),
path('new/<content_type>/', BotConfigCreateView.as_view(), name='new'),
path('delete/<int:pk>/', BotConfigDeleteView.as_view(), name='delete'),
]

39
bots/utils.py Normal file
View File

@@ -0,0 +1,39 @@
from django.views.generic import TemplateView
from bots.forms import BotForm
from bots.models import TelegramBot
from cabinet.utils import CabinetViewMixin
from config.utils import get_config_form
class BaseBotConfigView(CabinetViewMixin, TemplateView):
template_name = 'cabinet/bots/bot_form.html'
context_object_name = 'feed'
def get_queryset(self):
return TelegramBot.objects.filter(owner=self.request.user)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return self.render_to_response(self.get_context_data())
def post(self, request, *args, **kwargs):
self.object = self.get_object()
bot_form, config_form = self.get_forms()
if bot_form.is_valid() and config_form.is_valid():
return self.form_valid(bot_form, config_form)
else:
context = self.get_context_data(forms=(bot_form, config_form))
return self.render_to_response(context)
def get_forms(self):
bot = self.get_object()
data = self.request.POST if self.request.method == 'POST' else None
return BotForm(data=data, instance=bot, module=self.get_content_type().model_class()), \
get_config_form(self.get_content_type().model_class())(data=data, instance=bot.config if bot else None)
def get_context_data(self, forms=None, **kwargs):
ctx = super(BaseBotConfigView, self).get_context_data(**kwargs)
ctx['bot_form'], ctx['config_form'] = self.get_forms() if forms is None else forms
ctx['bot_module'] = self.get_content_type().model_class()
return ctx

96
bots/views.py Normal file
View File

@@ -0,0 +1,96 @@
from braces.views import AjaxResponseMixin, JSONResponseMixin
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect, HttpRequest
from django.shortcuts import redirect, get_object_or_404
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import ListView
from django.views.generic.detail import SingleObjectMixin
from jsonrpc import JSONRPCResponseManager
from bots.models import TelegramBot
from bots.modules import BOT_MODULES
from bots.utils import BaseBotConfigView
from cabinet.utils import CabinetViewMixin
from config.utils import AllowCORSMixin
class BotListView(CabinetViewMixin, ListView):
template_name = 'cabinet/bots/bot_list.html'
title = 'Bot list'
sidebar_section = 'bots'
def get_queryset(self):
return TelegramBot.objects.filter(owner=self.request.user)
def get_context_data(self, *args, **kwargs):
ctx = super(BotListView, self).get_context_data(*args, **kwargs)
ctx['bot_modules'] = BOT_MODULES
return ctx
class BotConfigEditView(BaseBotConfigView, SingleObjectMixin):
title = 'Configure bot'
sidebar_section = 'bots'
def form_valid(self, bot_form, config_form):
bot_form.save()
config_form.save()
cache.set('bots_reset', '1')
messages.success(self.request, 'Config was successfully saved')
return HttpResponseRedirect('')
def get_content_type(self):
return self.get_object().config_type
class BotConfigCreateView(BaseBotConfigView):
title = 'Create bot'
sidebar_section = 'bots'
def get_object(self):
return None
def form_valid(self, bots_form, config_form):
config_form.save()
bots_form.instance.owner = self.request.user
bots_form.instance.config_type = self.get_content_type()
bots_form.instance.config_id = config_form.instance.pk
bots_form.save()
cache.set('bots_reset', '1')
messages.success(self.request, 'Config was successfully saved')
return redirect('cabinet:bots:edit', pk=bots_form.instance.pk)
def get_content_type(self):
return get_object_or_404(ContentType, model=self.kwargs['content_type'])
class BotConfigDeleteView(LoginRequiredMixin, SingleObjectMixin, View):
def get_queryset(self):
return TelegramBot.objects.filter(owner=self.request.user)
def post(self, request, *args, **kwargs):
feed = self.get_object()
messages.success(self.request, 'Bot "{}" was successfully deleted'.format(feed.title))
feed.delete()
return redirect('cabinet:bots:index')
class BotRPCView(JSONResponseMixin, AjaxResponseMixin, AllowCORSMixin, View):
@method_decorator(csrf_exempt)
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs):
bot = get_object_or_404(TelegramBot, rpc_name=self.kwargs.get('endpoint'))
if not hasattr(bot.config, 'rpc_dispatcher'):
raise PermissionDenied()
rpc_response = JSONRPCResponseManager.handle(request.body, bot.config.rpc_dispatcher)
response = self.render_json_response(rpc_response.data)
self.add_access_control_headers(response)
return response