switch to uv and add cors headers
This commit is contained in:
		
							
								
								
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.python-version
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
3.9
 | 
			
		||||
							
								
								
									
										53
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										53
									
								
								Pipfile
									
									
									
									
									
								
							@@ -1,53 +0,0 @@
 | 
			
		||||
[[source]]
 | 
			
		||||
url = "https://pypi.org/simple"
 | 
			
		||||
verify_ssl = true
 | 
			
		||||
name = "pypi"
 | 
			
		||||
 | 
			
		||||
[packages]
 | 
			
		||||
django = "*"
 | 
			
		||||
pillow = "*"
 | 
			
		||||
pyrogram = "*"
 | 
			
		||||
python-telegram-bot = "==13.14"
 | 
			
		||||
django-yamlfield = "*"
 | 
			
		||||
django-timezone-field = "*"
 | 
			
		||||
django-redis-cache = "*"
 | 
			
		||||
django-jsoneditor = "*"
 | 
			
		||||
django-jet = "*"
 | 
			
		||||
django-extensions = "*"
 | 
			
		||||
django-environ = "*"
 | 
			
		||||
django-djconfig = "*"
 | 
			
		||||
django-crispy-forms = "*"
 | 
			
		||||
django-bootstrap4 = "*"
 | 
			
		||||
daphne = "*"
 | 
			
		||||
markovify = "*"
 | 
			
		||||
python-anticaptcha = "*"
 | 
			
		||||
vk-api = "11.9.7"
 | 
			
		||||
sentry-sdk = "*"
 | 
			
		||||
python-twitter = "*"
 | 
			
		||||
pytelegrambotapi = "*"
 | 
			
		||||
celery = "*"
 | 
			
		||||
celery-once = "*"
 | 
			
		||||
feedparser = "*"
 | 
			
		||||
json-rpc = "*"
 | 
			
		||||
humanize = "*"
 | 
			
		||||
jsonfield2 = "*"
 | 
			
		||||
tgcrypto = "*"
 | 
			
		||||
psycopg2-binary = "*"
 | 
			
		||||
hiredis = "*"
 | 
			
		||||
twisted = {extras = ["http2", "tls"], version = "*"}
 | 
			
		||||
whitenoise = "*"
 | 
			
		||||
django-picklefield = "*"
 | 
			
		||||
boto3 = "*"
 | 
			
		||||
filetype = "*"
 | 
			
		||||
qrtools = "*"
 | 
			
		||||
vkwave = "*"
 | 
			
		||||
imagehash = "*"
 | 
			
		||||
tqdm = "*"
 | 
			
		||||
tiktokapi = "*"
 | 
			
		||||
ffmpeg-python = "*"
 | 
			
		||||
crispy-bootstrap4 = "*"
 | 
			
		||||
 | 
			
		||||
[dev-packages]
 | 
			
		||||
 | 
			
		||||
[requires]
 | 
			
		||||
python_version = "3.9"
 | 
			
		||||
							
								
								
									
										2148
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2148
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,8 +0,0 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from .models import AggregationSource, Chat, Message
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
admin.site.register(AggregationSource)
 | 
			
		||||
admin.site.register(Chat)
 | 
			
		||||
admin.site.register(Message)
 | 
			
		||||
@@ -1,23 +0,0 @@
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.db.models.signals import post_delete
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregatorConfig(AppConfig):
 | 
			
		||||
    name = 'aggregator'
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        self.register_config()
 | 
			
		||||
        self.register_signals()
 | 
			
		||||
 | 
			
		||||
    def register_config(self):
 | 
			
		||||
        import djconfig
 | 
			
		||||
        from .forms import AggregatorAppConfigForm
 | 
			
		||||
 | 
			
		||||
        djconfig.register(AggregatorAppConfigForm)
 | 
			
		||||
 | 
			
		||||
    def register_signals(self):
 | 
			
		||||
        from .models import AggregationSource, Chat
 | 
			
		||||
        from .signals import aggregation_source_deleted, chat_deleted
 | 
			
		||||
 | 
			
		||||
        post_delete.connect(aggregation_source_deleted, sender=AggregationSource)
 | 
			
		||||
        post_delete.connect(chat_deleted, sender=Chat)
 | 
			
		||||
@@ -1,93 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
from typing import List
 | 
			
		||||
 | 
			
		||||
from django.core.files.storage import default_storage
 | 
			
		||||
from djconfig import config
 | 
			
		||||
from pyrogram import Client
 | 
			
		||||
from pyrogram.handlers import MessageHandler, DeletedMessagesHandler
 | 
			
		||||
from pyrogram.types import Message as PyrogramMessage
 | 
			
		||||
from pyrogram.errors import ChannelPrivate
 | 
			
		||||
from pyrogram.session import Session
 | 
			
		||||
 | 
			
		||||
from aggregator.models import AggregationSource, Message, Chat
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Session.notice_displayed = True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_client(takeout=False):
 | 
			
		||||
    config._reload_maybe()
 | 
			
		||||
    if not config.pyrogram_session or not config.pyrogram_app_id or not config.pyrogram_app_hash:
 | 
			
		||||
        raise RuntimeError('Pyrogram is not configured')
 | 
			
		||||
 | 
			
		||||
    session_path = os.path.relpath(default_storage.path(config.pyrogram_session.replace('.session', '')))
 | 
			
		||||
 | 
			
		||||
    return Client(session_path, config.pyrogram_app_id, config.pyrogram_app_hash, config.pyrogram_app_version or None,
 | 
			
		||||
                  config.pyrogram_device_model or None, config.pyrogram_system_version or None, takeout=takeout)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_message(client, message: PyrogramMessage):
 | 
			
		||||
    if not AggregationSource.objects.filter(chat_id=message.chat.id).exists():
 | 
			
		||||
        return
 | 
			
		||||
    Message.from_obj(message, client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def delete_messages(client, messages: List[PyrogramMessage]):
 | 
			
		||||
    for message in messages:
 | 
			
		||||
        Message.objects.filter(chat__chat_id=message.chat.id, message_id=message.message_id).update(deleted=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def collect_new_messages(client, chat):
 | 
			
		||||
    # Collecting new messages
 | 
			
		||||
    last_message = chat.messages.order_by('-message_id').first()
 | 
			
		||||
    if last_message:
 | 
			
		||||
        itr = client.iter_history(chat.chat_id, reverse=True, offset_id=last_message.message_id + 1)
 | 
			
		||||
    else:
 | 
			
		||||
        itr = client.iter_history(chat.chat_id, reverse=True, limit=10)
 | 
			
		||||
    for message in itr:
 | 
			
		||||
        Message.from_obj(message, client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def startup_collect(client: Client):
 | 
			
		||||
    for chat in Chat.objects.all():
 | 
			
		||||
        try:
 | 
			
		||||
            client.get_chat(chat.chat_id)
 | 
			
		||||
        except ChannelPrivate:
 | 
			
		||||
            print('I was banned in chat id="{}"'.format(chat.chat_id))
 | 
			
		||||
            continue
 | 
			
		||||
        # Collecting edited & deleted messages
 | 
			
		||||
        offset = 0
 | 
			
		||||
        qs = Message.objects.active_messages().filter(chat__chat_id=chat.chat_id)
 | 
			
		||||
        while True:
 | 
			
		||||
            lst = qs[200*offset:200*(offset+1)]
 | 
			
		||||
            if not lst:
 | 
			
		||||
                break
 | 
			
		||||
            messages = client.get_messages(chat.chat_id, [m.message_id for m in lst])
 | 
			
		||||
            for message in messages.messages:
 | 
			
		||||
                m_qs = Message.objects.active_messages() \
 | 
			
		||||
                    .filter(chat__chat_id=chat.chat_id, message_id=message.message_id)
 | 
			
		||||
                if message.empty:
 | 
			
		||||
                     m_qs.update(deleted=True)
 | 
			
		||||
                elif message.edit_date:
 | 
			
		||||
                    m = m_qs.first()  # type: Message
 | 
			
		||||
                    if m and (not m.edit_date or m.edit_date.timestamp() != message.edit_date):
 | 
			
		||||
                        Message.from_obj(message, client)
 | 
			
		||||
            offset += 1
 | 
			
		||||
 | 
			
		||||
        collect_new_messages(client, chat)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def start_client():
 | 
			
		||||
    app = get_client()
 | 
			
		||||
 | 
			
		||||
    app.add_handler(MessageHandler(save_message))
 | 
			
		||||
    app.add_handler(DeletedMessagesHandler(delete_messages))
 | 
			
		||||
 | 
			
		||||
    print('Starting pyrogram client...')
 | 
			
		||||
    app.start()
 | 
			
		||||
 | 
			
		||||
    print('Loading updates that happened while I was offline...')
 | 
			
		||||
    startup_collect(app)
 | 
			
		||||
 | 
			
		||||
    print('Idling...')
 | 
			
		||||
    app.idle()
 | 
			
		||||
@@ -1,58 +0,0 @@
 | 
			
		||||
from django import forms
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.files.storage import default_storage
 | 
			
		||||
from django.forms import ModelForm
 | 
			
		||||
from djconfig.forms import ConfigForm
 | 
			
		||||
from pyrogram.errors import ChannelPrivate
 | 
			
		||||
 | 
			
		||||
from config.utils import parse_mtproto_chat
 | 
			
		||||
from .client import get_client
 | 
			
		||||
from .models import AggregationSource, Chat
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceForm(ModelForm):
 | 
			
		||||
    invite_link = forms.CharField(help_text='Invite link (with joinchat) or username')
 | 
			
		||||
 | 
			
		||||
    def clean(self):
 | 
			
		||||
        cleaned_data = super(AggregationSourceForm, self).clean()
 | 
			
		||||
        invite_link = cleaned_data.pop('invite_link')
 | 
			
		||||
        if invite_link:
 | 
			
		||||
            with get_client() as app:
 | 
			
		||||
                try:
 | 
			
		||||
                    upd = app.join_chat(invite_link)
 | 
			
		||||
                except ChannelPrivate:
 | 
			
		||||
                    raise ValidationError('I was banned in this chat')
 | 
			
		||||
                chat = parse_mtproto_chat(app, upd.chats[0])
 | 
			
		||||
                cleaned_data['chat_id'] = chat.id
 | 
			
		||||
                Chat.from_obj(chat, app)
 | 
			
		||||
        return cleaned_data
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = AggregationSource
 | 
			
		||||
        fields = 'title', 'invite_link',
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceEditForm(AggregationSourceForm):
 | 
			
		||||
    invite_link = forms.CharField(help_text='Invite link (with joinchat) or username; '
 | 
			
		||||
                                            'leave empty if change is not needed', required=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregatorAppConfigForm(ConfigForm):
 | 
			
		||||
    slug = 'aggregator'
 | 
			
		||||
    title = 'Aggregator'
 | 
			
		||||
 | 
			
		||||
    pyrogram_app_id = forms.CharField()
 | 
			
		||||
    pyrogram_app_hash = forms.CharField()
 | 
			
		||||
    pyrogram_app_version = forms.CharField(required=False)
 | 
			
		||||
    pyrogram_device_model = forms.CharField(required=False)
 | 
			
		||||
    pyrogram_system_version = forms.CharField(required=False)
 | 
			
		||||
    pyrogram_session = forms.FileField()
 | 
			
		||||
 | 
			
		||||
    def save_session(self):
 | 
			
		||||
        session = self.cleaned_data.get('pyrogram_session')
 | 
			
		||||
        if session:
 | 
			
		||||
            session.name = default_storage.save(session.name, session)
 | 
			
		||||
 | 
			
		||||
    def save(self):
 | 
			
		||||
        self.save_session()
 | 
			
		||||
        super(AggregatorAppConfigForm, self).save()
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
from django.core.management import BaseCommand
 | 
			
		||||
 | 
			
		||||
from aggregator.client import start_client
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Command(BaseCommand):
 | 
			
		||||
    def handle(self, *args, **options):
 | 
			
		||||
        start_client()
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
# Generated by Django 2.1.5 on 2019-01-27 12:20
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='AggregationSource',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('title', models.CharField(max_length=64)),
 | 
			
		||||
                ('chat_id', models.IntegerField(db_index=True)),
 | 
			
		||||
                ('last_id', models.PositiveIntegerField()),
 | 
			
		||||
                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Chat',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('chat_id', models.IntegerField(db_index=True)),
 | 
			
		||||
                ('title', models.TextField()),
 | 
			
		||||
                ('username', models.CharField(blank=True, max_length=64, null=True)),
 | 
			
		||||
                ('photo', models.ImageField(blank=True, null=True, upload_to='')),
 | 
			
		||||
                ('photo_id', models.CharField(blank=True, max_length=64, null=True)),
 | 
			
		||||
            ],
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name='Message',
 | 
			
		||||
            fields=[
 | 
			
		||||
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
 | 
			
		||||
                ('message_id', models.PositiveIntegerField(db_index=True)),
 | 
			
		||||
                ('text', models.TextField(blank=True)),
 | 
			
		||||
                ('date', models.DateTimeField()),
 | 
			
		||||
                ('edit_date', models.DateTimeField(blank=True, null=True)),
 | 
			
		||||
                ('deleted', models.BooleanField(default=False)),
 | 
			
		||||
                ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='aggregator.Chat')),
 | 
			
		||||
                ('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='aggregator.Message')),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                'ordering': ('message_id', 'edit_date'),
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
# Generated by Django 2.1.5 on 2019-01-27 13:42
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('aggregator', '0001_initial'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RemoveField(
 | 
			
		||||
            model_name='aggregationsource',
 | 
			
		||||
            name='last_id',
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,23 +0,0 @@
 | 
			
		||||
# Generated by Django 2.1.5 on 2019-01-27 21:48
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('aggregator', '0002_remove_aggregationsource_last_id'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='aggregationsource',
 | 
			
		||||
            name='chat_id',
 | 
			
		||||
            field=models.CharField(db_index=True, max_length=64),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='chat',
 | 
			
		||||
            name='chat_id',
 | 
			
		||||
            field=models.CharField(db_index=True, max_length=64),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,23 +0,0 @@
 | 
			
		||||
# Generated by Django 2.1.5 on 2019-01-30 20:21
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('aggregator', '0003_auto_20190128_0048'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='aggregationsource',
 | 
			
		||||
            name='chat_id',
 | 
			
		||||
            field=models.BigIntegerField(db_index=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='chat',
 | 
			
		||||
            name='chat_id',
 | 
			
		||||
            field=models.BigIntegerField(db_index=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -1,99 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from tempfile import TemporaryDirectory
 | 
			
		||||
 | 
			
		||||
import pytz
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from pyrogram.types import Chat as PyrogramChat, Message as PyrogramMessage
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSource(models.Model):
 | 
			
		||||
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
 | 
			
		||||
    title = models.CharField(max_length=64)
 | 
			
		||||
    chat_id = models.BigIntegerField(db_index=True)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.title
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Chat(models.Model):
 | 
			
		||||
    chat_id = models.BigIntegerField(db_index=True)
 | 
			
		||||
    title = models.TextField()
 | 
			
		||||
    username = models.CharField(max_length=64, null=True, blank=True)
 | 
			
		||||
    photo = models.ImageField(null=True, blank=True)
 | 
			
		||||
    photo_id = models.CharField(max_length=64, null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_obj(cls, chat: PyrogramChat, client):
 | 
			
		||||
        from aggregator.tasks import collect_new_messages
 | 
			
		||||
 | 
			
		||||
        obj, created = Chat.objects.update_or_create(
 | 
			
		||||
            chat_id=chat.id,
 | 
			
		||||
            defaults={
 | 
			
		||||
                'title': chat.title or '{} {}'.format(chat.first_name, chat.last_name).rstrip(),
 | 
			
		||||
                'username': chat.username,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        if chat.photo is None:
 | 
			
		||||
            if obj.photo_id is not None:
 | 
			
		||||
                obj.photo = obj.photo_id = None
 | 
			
		||||
                obj.save()
 | 
			
		||||
        else:
 | 
			
		||||
            photo_file_id = chat.photo.small_file_id
 | 
			
		||||
            if photo_file_id != obj.photo_id:
 | 
			
		||||
                with TemporaryDirectory() as d:
 | 
			
		||||
                    path = client.download_media(photo_file_id, os.path.join(d, ''), block=True)
 | 
			
		||||
                    with open(path, 'rb') as f:
 | 
			
		||||
                        obj.photo.save(os.path.basename(path), f, save=True)
 | 
			
		||||
                obj.photo_id = chat.photo.small_file_id
 | 
			
		||||
                obj.save()
 | 
			
		||||
        if created:
 | 
			
		||||
            transaction.on_commit(lambda: collect_new_messages.delay(obj.pk))
 | 
			
		||||
        return obj
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return '{} (chat_id="{}")'.format(self.title, self.chat_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MessageManager(models.Manager):
 | 
			
		||||
    def active_messages(self):
 | 
			
		||||
        return self.get_queryset().filter(deleted=False, replaced_by__isnull=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Message(models.Model):
 | 
			
		||||
    chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='messages')
 | 
			
		||||
    message_id = models.PositiveIntegerField(db_index=True)
 | 
			
		||||
    text = models.TextField(blank=True)
 | 
			
		||||
    date = models.DateTimeField()
 | 
			
		||||
    edit_date = models.DateTimeField(null=True, blank=True)
 | 
			
		||||
    deleted = models.BooleanField(default=False)
 | 
			
		||||
    replaced_by = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
 | 
			
		||||
 | 
			
		||||
    objects = MessageManager()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def from_obj(cls, message: PyrogramMessage, client):
 | 
			
		||||
        tz = pytz.timezone('UTC')
 | 
			
		||||
        chat = Chat.from_obj(message.chat, client)
 | 
			
		||||
        try:
 | 
			
		||||
            old = Message.objects.active_messages().get(chat=chat, message_id=message.message_id)
 | 
			
		||||
        except Message.DoesNotExist:
 | 
			
		||||
            old = None
 | 
			
		||||
        obj = Message.objects.create(
 | 
			
		||||
            chat=chat,
 | 
			
		||||
            message_id=message.message_id,
 | 
			
		||||
            text=message.text.html if message.text else '',
 | 
			
		||||
            date=tz.localize(datetime.utcfromtimestamp(message.date)),
 | 
			
		||||
            edit_date=tz.localize(datetime.utcfromtimestamp(message.edit_date)) if message.edit_date else None,
 | 
			
		||||
        )
 | 
			
		||||
        if old is not None:
 | 
			
		||||
            old.replaced_by = obj
 | 
			
		||||
            old.save()
 | 
			
		||||
        return obj
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return 'id="{}" (chat_id="{}")'.format(self.message_id, self.chat.chat_id)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        ordering = 'message_id', 'edit_date',
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
from pyrogram.errors import RPCError
 | 
			
		||||
 | 
			
		||||
from .client import get_client
 | 
			
		||||
from .models import Chat, AggregationSource
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def aggregation_source_deleted(sender, instance: AggregationSource, **kwargs):
 | 
			
		||||
    if not AggregationSource.objects.filter(chat_id=instance.chat_id):
 | 
			
		||||
        Chat.objects.filter(chat_id=instance.chat_id).delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def chat_deleted(sender, instance: Chat, **kwargs):
 | 
			
		||||
    with get_client() as client:
 | 
			
		||||
        try:
 | 
			
		||||
            client.leave_chat(instance.chat_id)
 | 
			
		||||
        except RPCError:
 | 
			
		||||
            pass
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
from celery_once import QueueOnce
 | 
			
		||||
 | 
			
		||||
from aggregator.client import get_client
 | 
			
		||||
from config.celery import app
 | 
			
		||||
 | 
			
		||||
from .client import collect_new_messages as _collect_new_messages
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.task(base=QueueOnce, once={'keys': ['chat_id'], 'graceful': True})
 | 
			
		||||
def collect_new_messages(chat_id):
 | 
			
		||||
    from .models import Chat
 | 
			
		||||
 | 
			
		||||
    chat = Chat.objects.get(pk=chat_id)
 | 
			
		||||
    client = get_client(takeout=True)
 | 
			
		||||
    client.start()
 | 
			
		||||
    _collect_new_messages(client, chat)
 | 
			
		||||
    client.stop()
 | 
			
		||||
@@ -1,59 +0,0 @@
 | 
			
		||||
{% extends 'cabinet/_internal_base.html' %}
 | 
			
		||||
{% load bootstrap4 %}
 | 
			
		||||
 | 
			
		||||
{% block breadcrumbs %}
 | 
			
		||||
    <li><a href="{% url 'cabinet:aggregator:index' %}">Aggregation source 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 source %}Source "{{ source.title }}" configuration{% else %}New source{% endif %}</h2>
 | 
			
		||||
        </header>
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
            {% bootstrap_form form layout='horizontal' %}
 | 
			
		||||
            {% if source %}
 | 
			
		||||
                <div class="form-group row">
 | 
			
		||||
                    <label class="col-md-3 col-form-label" for="id_feed-last_check">Chat ID</label>
 | 
			
		||||
                    <div class="col-md-9">
 | 
			
		||||
                        <input type="text" value="{{ source.chat_id }}" class="form-control" placeholder="Chat ID" title="" id="id_chat_id" disabled>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
        <footer class="card-footer text-right">
 | 
			
		||||
            {% if source %}<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 source %}
 | 
			
		||||
        <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 source "{{ source.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:aggregator:delete' pk=source.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 %}
 | 
			
		||||
@@ -1,40 +0,0 @@
 | 
			
		||||
{% extends 'cabinet/_internal_base.html' %}
 | 
			
		||||
 | 
			
		||||
{% block breadcrumbs %}
 | 
			
		||||
    <li><span>Aggregator source list</span></li>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <section class="card">
 | 
			
		||||
        <header class="card-header">
 | 
			
		||||
            <div class="card-button-actions">
 | 
			
		||||
                <a href="{% url 'cabinet:aggregator:new' %}" class="btn btn-primary btn-sm" type="button">
 | 
			
		||||
                    Add new source
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
            <h2 class="card-title">Your sources</h2>
 | 
			
		||||
        </header>
 | 
			
		||||
        <div class="card-body">
 | 
			
		||||
            <table class="table table-hover table-responsive-md mb-0">
 | 
			
		||||
                <thead>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <th>Title</th>
 | 
			
		||||
                    <th>Chat ID</th>
 | 
			
		||||
                </tr>
 | 
			
		||||
                </thead>
 | 
			
		||||
                {% for source in object_list %}
 | 
			
		||||
                <tr class="clickable-row" data-href="{% url 'cabinet:aggregator:edit' pk=source.pk %}">
 | 
			
		||||
                    <td>{{ source.title }}</td>
 | 
			
		||||
                    <td>{{ source.chat_id }}</td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                {% empty %}
 | 
			
		||||
                <tfoot>
 | 
			
		||||
                <tr>
 | 
			
		||||
                    <td colspan="2">No sources added</td>
 | 
			
		||||
                </tr>
 | 
			
		||||
                </tfoot>
 | 
			
		||||
                {% endfor %}
 | 
			
		||||
            </table>
 | 
			
		||||
        </div>
 | 
			
		||||
    </section>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
# Create your tests here.
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
from django.urls import path
 | 
			
		||||
 | 
			
		||||
from .views import AggregationSourceListView, AggregationSourceCreateView, AggregationSourceUpdateView, \
 | 
			
		||||
                   AggregationSourceDeleteView
 | 
			
		||||
 | 
			
		||||
app_name = 'aggregator'
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path('', AggregationSourceListView.as_view(), name='index'),
 | 
			
		||||
    path('<int:pk>/', AggregationSourceUpdateView.as_view(), name='edit'),
 | 
			
		||||
    path('new/', AggregationSourceCreateView.as_view(), name='new'),
 | 
			
		||||
    path('delete/<int:pk>/', AggregationSourceDeleteView.as_view(), name='delete'),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@@ -1,63 +0,0 @@
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.contrib.auth.mixins import LoginRequiredMixin
 | 
			
		||||
from django.shortcuts import redirect
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.generic import ListView, UpdateView, CreateView
 | 
			
		||||
from django.views.generic.detail import SingleObjectMixin
 | 
			
		||||
 | 
			
		||||
from aggregator.forms import AggregationSourceForm, AggregationSourceEditForm
 | 
			
		||||
from aggregator.models import AggregationSource
 | 
			
		||||
from cabinet.utils import CabinetViewMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceListView(CabinetViewMixin, ListView):
 | 
			
		||||
    template_name = 'cabinet/aggregator/source_list.html'
 | 
			
		||||
    title = 'Aggregation source list'
 | 
			
		||||
    sidebar_section = 'aggregator'
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return AggregationSource.objects.filter(owner=self.request.user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceCreateView(CabinetViewMixin, CreateView):
 | 
			
		||||
    template_name = 'cabinet/aggregator/source_form.html'
 | 
			
		||||
    title = 'Create aggregation source'
 | 
			
		||||
    sidebar_section = 'aggregator'
 | 
			
		||||
    form_class = AggregationSourceForm
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        form.instance.owner = self.request.user
 | 
			
		||||
        form.instance.chat_id = form.cleaned_data['chat_id']
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect('cabinet:aggregator:edit', pk=form.instance.pk)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceUpdateView(CabinetViewMixin, UpdateView):
 | 
			
		||||
    template_name = 'cabinet/aggregator/source_form.html'
 | 
			
		||||
    title = 'Configure aggregation source'
 | 
			
		||||
    sidebar_section = 'aggregator'
 | 
			
		||||
    form_class = AggregationSourceEditForm
 | 
			
		||||
    context_object_name = 'source'
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return AggregationSource.objects.filter(owner=self.request.user)
 | 
			
		||||
 | 
			
		||||
    def form_valid(self, form):
 | 
			
		||||
        if 'chat_id' in form.cleaned_data:
 | 
			
		||||
            form.instance.chat_id = form.cleaned_data['chat_id']
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect('cabinet:aggregator:edit', pk=form.instance.pk)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AggregationSourceDeleteView(LoginRequiredMixin, SingleObjectMixin, View):
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return AggregationSource.objects.filter(owner=self.request.user)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
        source = self.get_object()
 | 
			
		||||
        messages.success(self.request, 'Source "{}" was successfully deleted'.format(source.title))
 | 
			
		||||
        source.delete()
 | 
			
		||||
        return redirect('cabinet:aggregator:index')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -16,7 +16,6 @@ from bots.models import TelegramBot
 | 
			
		||||
from bots.modules import BOT_MODULES
 | 
			
		||||
from bots.utils import BaseBotConfigView, JSONResponseMixin, AjaxResponseMixin
 | 
			
		||||
from cabinet.utils import CabinetViewMixin
 | 
			
		||||
from config.utils import AllowCORSMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BotListView(CabinetViewMixin, ListView):
 | 
			
		||||
@@ -84,7 +83,7 @@ class BotConfigDeleteView(LoginRequiredMixin, SingleObjectMixin, View):
 | 
			
		||||
        return redirect('cabinet:bots:index')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BotRPCView(JSONResponseMixin, AjaxResponseMixin, AllowCORSMixin, View):
 | 
			
		||||
class BotRPCView(JSONResponseMixin, AjaxResponseMixin, View):
 | 
			
		||||
    @method_decorator(csrf_exempt)
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
@@ -95,5 +94,4 @@ class BotRPCView(JSONResponseMixin, AjaxResponseMixin, AllowCORSMixin, View):
 | 
			
		||||
            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
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ urlpatterns = [
 | 
			
		||||
    path('login/', LoginView.as_view(), name='login'),
 | 
			
		||||
    path('logout/', LogoutView.as_view(), name='logout'),
 | 
			
		||||
    path('feeds/', include('feeds.urls', namespace='feeds')),
 | 
			
		||||
    path('aggregator/', include('aggregator.urls', namespace='aggregator')),
 | 
			
		||||
    path('bots/', include('bots.urls', namespace='bots')),
 | 
			
		||||
    path('admin/config/<slug>/', AdminConfigView.as_view(), name='admin_config'),
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
 | 
			
		||||
    'crispy_bootstrap4',
 | 
			
		||||
    'djconfig',
 | 
			
		||||
    'jsoneditor',
 | 
			
		||||
    'corsheaders',
 | 
			
		||||
 | 
			
		||||
    'django.contrib.admin',
 | 
			
		||||
    'django.contrib.auth',
 | 
			
		||||
@@ -34,7 +35,6 @@ INSTALLED_APPS = [
 | 
			
		||||
 | 
			
		||||
    'cabinet.apps.CabinetConfig',
 | 
			
		||||
    'feeds.apps.FeedsConfig',
 | 
			
		||||
    'aggregator.apps.AggregatorConfig',
 | 
			
		||||
    'bots.apps.BotsConfig',
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@@ -43,6 +43,7 @@ MIDDLEWARE = [
 | 
			
		||||
    'django.middleware.security.SecurityMiddleware',
 | 
			
		||||
    'whitenoise.middleware.WhiteNoiseMiddleware',
 | 
			
		||||
    'django.contrib.sessions.middleware.SessionMiddleware',
 | 
			
		||||
    "corsheaders.middleware.CorsMiddleware",
 | 
			
		||||
    'django.middleware.common.CommonMiddleware',
 | 
			
		||||
    # 'django.middleware.csrf.CsrfViewMiddleware',
 | 
			
		||||
    'django.contrib.auth.middleware.AuthenticationMiddleware',
 | 
			
		||||
@@ -131,6 +132,8 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4'
 | 
			
		||||
JSON_EDITOR_JS = 'https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/7.0.4/jsoneditor.min.js'
 | 
			
		||||
JSON_EDITOR_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/7.0.4/jsoneditor.min.css'
 | 
			
		||||
 | 
			
		||||
CORS_ALLOW_ALL_ORIGINS = True
 | 
			
		||||
 | 
			
		||||
sentry_sdk.init(
 | 
			
		||||
    dsn=env.str('SENTRY_DSN', None),
 | 
			
		||||
    integrations=[DjangoIntegration()],
 | 
			
		||||
 
 | 
			
		||||
@@ -2,18 +2,6 @@ from urllib.parse import urlparse
 | 
			
		||||
 | 
			
		||||
from django.forms import ModelForm
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, HttpResponseForbidden
 | 
			
		||||
from pyrogram.types import Chat as PyrogramChat
 | 
			
		||||
from pyrogram.raw.types.chat import Chat as MTProtoChat
 | 
			
		||||
from pyrogram.raw.types.user import User as MTProtoUser
 | 
			
		||||
from pyrogram.raw.types.channel import Channel as MTProtoChannel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_mtproto_chat(client, chat):
 | 
			
		||||
    if isinstance(chat, MTProtoChat):
 | 
			
		||||
        return PyrogramChat._parse_chat_chat(client, chat)
 | 
			
		||||
    elif isinstance(chat, MTProtoUser):
 | 
			
		||||
        return PyrogramChat._parse_user_chat(client, chat)
 | 
			
		||||
    return PyrogramChat._parse_channel_chat(client, chat)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def same_origin(current_uri, redirect_uri):
 | 
			
		||||
@@ -66,16 +54,3 @@ def get_config_form(mdl):
 | 
			
		||||
            exclude = mdl.EXCLUDE_FIELDS if hasattr(mdl, 'EXCLUDE_FIELDS') else ()
 | 
			
		||||
            widgets = mdl.CUSTOM_WIDGETS if hasattr(mdl, 'CUSTOM_WIDGETS') else {}
 | 
			
		||||
    return ConfigForm
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AllowCORSMixin(object):
 | 
			
		||||
    def add_access_control_headers(self, response):
 | 
			
		||||
        response["Access-Control-Allow-Origin"] = "*"
 | 
			
		||||
        response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
 | 
			
		||||
        response["Access-Control-Max-Age"] = "1000"
 | 
			
		||||
        response["Access-Control-Allow-Headers"] = "X-Requested-With, Content-Type"
 | 
			
		||||
 | 
			
		||||
    def options(self, request, *args, **kwargs):
 | 
			
		||||
        response = HttpResponse()
 | 
			
		||||
        self.add_access_control_headers(response)
 | 
			
		||||
        return response
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
[project]
 | 
			
		||||
name = "telegram-bots"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
description = "Add your description here"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
requires-python = ">=3.9"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "boto3>=1.39.13",
 | 
			
		||||
    "celery>=5.4.0",
 | 
			
		||||
    "celery-once>=3.0.1",
 | 
			
		||||
    "crispy-bootstrap4>=2024.1",
 | 
			
		||||
    "daphne>=4.2.1",
 | 
			
		||||
    "django==4.2.16",
 | 
			
		||||
    "django-bootstrap4>=24.1",
 | 
			
		||||
    "django-cors-headers>=4.7.0",
 | 
			
		||||
    "django-crispy-forms>=2.3",
 | 
			
		||||
    "django-djconfig>=0.11.0",
 | 
			
		||||
    "django-environ>=0.11.3",
 | 
			
		||||
    "django-extensions>=4.1",
 | 
			
		||||
    "django-jsoneditor>=0.2.4",
 | 
			
		||||
    "django-picklefield>=3.3",
 | 
			
		||||
    "django-redis-cache>=3.0.1",
 | 
			
		||||
    "django-timezone-field>=7.1",
 | 
			
		||||
    "django-yamlfield>=1.2.2",
 | 
			
		||||
    "feedparser>=6.0.11",
 | 
			
		||||
    "ffmpeg-python>=0.2.0",
 | 
			
		||||
    "filetype>=1.2.0",
 | 
			
		||||
    "hiredis>=3.2.1",
 | 
			
		||||
    "humanize>=4.12.3",
 | 
			
		||||
    "imagehash>=4.3.2",
 | 
			
		||||
    "json-rpc>=1.15.0",
 | 
			
		||||
    "jsonfield2>=4.0.0.post0",
 | 
			
		||||
    "markovify>=0.9.4",
 | 
			
		||||
    "pillow>=11.3.0",
 | 
			
		||||
    "psycopg2-binary>=2.9.10",
 | 
			
		||||
    "python-anticaptcha>=1.0.0",
 | 
			
		||||
    "python-telegram-bot==13.14",
 | 
			
		||||
    "python-twitter>=3.5",
 | 
			
		||||
    "qrtools>=0.0.2",
 | 
			
		||||
    "sentry-sdk>=2.33.2",
 | 
			
		||||
    "tgcrypto>=1.2.5",
 | 
			
		||||
    "tiktokapi>=7.1.0",
 | 
			
		||||
    "tqdm>=4.67.1",
 | 
			
		||||
    "twisted[http2,tls]>=23.8.0",
 | 
			
		||||
    "vk-api>=11.10.0",
 | 
			
		||||
    "vkwave>=0.2.17",
 | 
			
		||||
    "werkzeug>=3.1.3",
 | 
			
		||||
    "whitenoise>=6.9.0",
 | 
			
		||||
]
 | 
			
		||||
		Reference in New Issue
	
	Block a user