Initial commit
This commit is contained in:
parent
fd28449133
commit
d21542a9ad
|
@ -160,3 +160,7 @@ cython_debug/
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
*.db
|
||||||
|
*.session
|
||||||
|
.vscode/settings.json
|
||||||
|
*session_journal
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: Flask",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "flask",
|
||||||
|
"env": {
|
||||||
|
"FLASK_APP": "app",
|
||||||
|
"FLASK_DEBUG": "1"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--no-debugger",
|
||||||
|
"--no-reload",
|
||||||
|
],
|
||||||
|
"jinja": true,
|
||||||
|
"justMyCode": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
FROM python:3.11
|
||||||
|
|
||||||
|
COPY app/ /app/
|
||||||
|
COPY migrations/ /migrations/
|
||||||
|
COPY paper /paper/
|
||||||
|
|
||||||
|
COPY config.py config.py
|
||||||
|
COPY make_celery.py make_celery.py
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
|
||||||
|
RUN pip install -r requirements.txt
|
|
@ -0,0 +1,20 @@
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from app.blueprints import blueprints
|
||||||
|
from app.extensions import db, migrate
|
||||||
|
from app.celery import celery_init_app
|
||||||
|
|
||||||
|
def create_app(config_class=Config):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app)
|
||||||
|
|
||||||
|
for blueprint in blueprints:
|
||||||
|
app.register_blueprint(blueprint)
|
||||||
|
|
||||||
|
celery_init_app(app)
|
||||||
|
|
||||||
|
return app
|
|
@ -0,0 +1,7 @@
|
||||||
|
from .api.routes import api
|
||||||
|
from .frontend.routes import frontend
|
||||||
|
|
||||||
|
blueprints = (
|
||||||
|
api,
|
||||||
|
frontend,
|
||||||
|
)
|
|
@ -0,0 +1,11 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from .tasks.routes import tasks
|
||||||
|
from .sessions.routes import sessions
|
||||||
|
from .users.routes import users
|
||||||
|
|
||||||
|
api = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
api.register_blueprint(users)
|
||||||
|
api.register_blueprint(tasks)
|
||||||
|
api.register_blueprint(sessions)
|
|
@ -0,0 +1,81 @@
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.task import Task
|
||||||
|
from paper.errors import NeedPasswordException
|
||||||
|
from paper.parser import PaperParser
|
||||||
|
|
||||||
|
sessions = Blueprint("sessions", __name__, url_prefix="/sessions")
|
||||||
|
|
||||||
|
@sessions.route("/", methods=["POST"])
|
||||||
|
async def create_session(**kwargs):
|
||||||
|
data = kwargs or request.json or request.form
|
||||||
|
data["name"] = data["name"].replace("/", "").replace("\\", "")
|
||||||
|
|
||||||
|
session = Session.query.filter_by(name=data.get("name")).first()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
session = Session(
|
||||||
|
name=data.get("name"),
|
||||||
|
authorized=False
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(session)
|
||||||
|
|
||||||
|
response = {"status": "ok"}
|
||||||
|
|
||||||
|
async with PaperParser(data.get("name")) as parser:
|
||||||
|
try:
|
||||||
|
result = await parser.sign_in(**data)
|
||||||
|
|
||||||
|
if hasattr(result, "phone_code_hash"):
|
||||||
|
response = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Need secret code from telegram",
|
||||||
|
"action": "code",
|
||||||
|
"phone_hash": result.phone_code_hash,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
session.authorized = True
|
||||||
|
|
||||||
|
except NeedPasswordException:
|
||||||
|
response = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "Need 2FA Password",
|
||||||
|
"action": "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
response = {
|
||||||
|
"status": "exception",
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
@sessions.route("/", methods=["GET"])
|
||||||
|
def get_sessions(**kwargs):
|
||||||
|
return Session.query.filter_by(authorized=True).all()
|
||||||
|
|
||||||
|
@sessions.route("/<int:id>", methods=["DELETE"])
|
||||||
|
async def remove_session(id: int):
|
||||||
|
session: Session = Session.query.get_or_404(id)
|
||||||
|
|
||||||
|
if session.has_depending_tasks():
|
||||||
|
return 'Есть зависимые задачи', 409
|
||||||
|
|
||||||
|
async with PaperParser(session.name) as parser:
|
||||||
|
try:
|
||||||
|
await parser.client.log_out()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(e, 500)
|
||||||
|
|
||||||
|
db.session.delete(session)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return Response('ok', status=200)
|
|
@ -0,0 +1,27 @@
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.task import Task
|
||||||
|
|
||||||
|
def success_state(task_id):
|
||||||
|
task = Task.query.filter_by(task_id=task_id).one()
|
||||||
|
|
||||||
|
task.status = "SUCCESS"
|
||||||
|
task.status_message = "Задача выполнена успешно"
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def failure_state(task_id, exception):
|
||||||
|
task = Task.query.filter_by(task_id=task_id).one()
|
||||||
|
|
||||||
|
task.status = "FAILURE"
|
||||||
|
task.status_message = f"Произошла ошибка: {str(exception)}"
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def run_state(task_id, task_record_id):
|
||||||
|
task = Task.query.get(task_record_id)
|
||||||
|
|
||||||
|
task.task_id = task_id
|
||||||
|
task.status = "RUNNING"
|
||||||
|
task.status_message = "Задача запущена"
|
||||||
|
|
||||||
|
db.session.commit()
|
|
@ -0,0 +1,83 @@
|
||||||
|
import uuid
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.session import Session
|
||||||
|
|
||||||
|
from .tasks import send_messages_task, add_to_group_task
|
||||||
|
|
||||||
|
tasks = Blueprint("tasks", __name__, url_prefix="/tasks", template_folder="templates")
|
||||||
|
|
||||||
|
@tasks.route("/", methods=["GET"])
|
||||||
|
def get_tasks():
|
||||||
|
tasks = Task.query.all()
|
||||||
|
|
||||||
|
return render_template("tasks_cards.j2", tasks=tasks)
|
||||||
|
|
||||||
|
@tasks.route("/<int:task_id>", methods=["GET"])
|
||||||
|
def get_task(task_id: int):
|
||||||
|
...
|
||||||
|
|
||||||
|
@tasks.route("/session/<int:session_id>", methods=["POST"])
|
||||||
|
def create_task(session_id: int):
|
||||||
|
info = request.form
|
||||||
|
session = Session.query.get_or_404(session_id)
|
||||||
|
task = Task(
|
||||||
|
name = str(uuid.uuid4()),
|
||||||
|
session = session,
|
||||||
|
status = "CREATED",
|
||||||
|
status_message = "Задача создана",
|
||||||
|
type = info.get('task'),
|
||||||
|
url = info.get('url'),
|
||||||
|
message = info.get('message'),
|
||||||
|
file = None
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
start_task(task.id)
|
||||||
|
|
||||||
|
return 'Created', 200
|
||||||
|
|
||||||
|
@tasks.route("/<int:task_id>", methods=["DELETE"])
|
||||||
|
def delete_task(task_id: int):
|
||||||
|
task: Task = Task.query.get_or_404(task_id)
|
||||||
|
stop_task(task.id)
|
||||||
|
db.session.delete(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return 'Deleted', 200
|
||||||
|
|
||||||
|
@tasks.route("/<int:task_id>/stop", methods=["PUT"])
|
||||||
|
def stop_task(task_id: int):
|
||||||
|
task = Task.query.get_or_404(task_id)
|
||||||
|
fn = None
|
||||||
|
|
||||||
|
match task.type:
|
||||||
|
case 'message':
|
||||||
|
fn = send_messages_task
|
||||||
|
|
||||||
|
case 'add':
|
||||||
|
fn = add_to_group_task
|
||||||
|
|
||||||
|
if task.task_id:
|
||||||
|
task_fn = fn.AsyncResult(task.task_id)
|
||||||
|
task_fn.abort()
|
||||||
|
|
||||||
|
return 'Stopped', 200
|
||||||
|
|
||||||
|
|
||||||
|
@tasks.route("/<int:task_id>/start", methods=["PUT"])
|
||||||
|
def start_task(task_id: int):
|
||||||
|
task: Task = Task.query.get_or_404(task_id)
|
||||||
|
|
||||||
|
match task.type:
|
||||||
|
case 'message':
|
||||||
|
send_messages_task.delay(task_id=task_id)
|
||||||
|
|
||||||
|
case 'add':
|
||||||
|
add_to_group_task.delay(task_id=task_id)
|
||||||
|
|
||||||
|
return 'Started', 200
|
|
@ -0,0 +1,47 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from paper.parser import PaperParser
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.extensions import db
|
||||||
|
from .handlers import run_state, failure_state, success_state
|
||||||
|
|
||||||
|
async def add_to_group(session, task, task_self):
|
||||||
|
async with PaperParser(session.name) as parser:
|
||||||
|
await parser.invite_users(session.users, task.url, task_self)
|
||||||
|
|
||||||
|
async def sending_message(session, task, task_self):
|
||||||
|
async with PaperParser(session.name) as parser:
|
||||||
|
await parser.send_messages(session.users, task.message, task.file, task_self)
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def add_to_group_task(self, task_id):
|
||||||
|
try:
|
||||||
|
task: Task = Task.query.get(task_id)
|
||||||
|
run_state(self.request.id, task_id)
|
||||||
|
|
||||||
|
session = task.session
|
||||||
|
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
add_to_group(session, task, self)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failure_state(self.request.id, e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
else:
|
||||||
|
success_state(self.request.id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def send_messages_task(self, task_id):
|
||||||
|
task: Task = Task.query.get(task_id)
|
||||||
|
|
||||||
|
session = task.session
|
||||||
|
users = session.users
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% for task in tasks %}
|
||||||
|
<div data-task="{{ task.id }}" class="col mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">{{ task.name }}</h3>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">Статус: {{ task.status }}</li>
|
||||||
|
<li class="list-group-item">Сообщение: {{ task.status_message }}</li>
|
||||||
|
<li class="list-group-item">Действие: {{ task.type }}</li>
|
||||||
|
<li class="list-group-item">Сессия: {{ task.session.name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<button
|
||||||
|
hx-delete="/api/tasks/{{task.id}}"
|
||||||
|
hx-swap="delete"
|
||||||
|
hx-target='[data-task="{{ task.id }}"]'
|
||||||
|
hx-confirm="Вы уверены, что хотите удалить эту задачу?"
|
||||||
|
class="btn btn-outline-danger mt-2 mx-2"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
<button hx-put="/api/tasks/{{task.id}}/stop" hx-swap="none" class="btn btn-outline-secondary mt-2 mx-2">
|
||||||
|
Стоп
|
||||||
|
</button>
|
||||||
|
<button hx-put="/api/tasks/{{task.id}}/start" hx-swap="none" class="btn btn-outline-success mt-2 mx-2">
|
||||||
|
Запустить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<small id="helpId" class="form-text">Здесь ничего нет.</small>
|
||||||
|
{% endfor %}
|
|
@ -0,0 +1,58 @@
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db
|
||||||
|
from paper.parser import PaperParser
|
||||||
|
|
||||||
|
|
||||||
|
users = Blueprint("users", __name__, url_prefix="/users", template_folder="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@users.route("/parse/<int:session_id>", methods=["POST"])
|
||||||
|
async def parse_users(session_id: int):
|
||||||
|
session = Session.query.get_or_404(session_id)
|
||||||
|
|
||||||
|
data = request.form
|
||||||
|
|
||||||
|
async with PaperParser(session.name) as parser:
|
||||||
|
users = await parser.get_participants(data.get("group"))
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if not user.username:
|
||||||
|
continue
|
||||||
|
|
||||||
|
exists = db.session.query(User.id).filter_by(username=user.username).first()
|
||||||
|
if not exists:
|
||||||
|
db.session.add(
|
||||||
|
User(
|
||||||
|
first_name=user.first_name,
|
||||||
|
last_name=user.last_name,
|
||||||
|
username=user.username,
|
||||||
|
phone=user.phone,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return render_template("user_cards.j2", users=session.users)
|
||||||
|
|
||||||
|
|
||||||
|
@users.route("/<int:session_id>", methods=["GET"])
|
||||||
|
def get_users(session_id: int):
|
||||||
|
users = Session.query.get_or_404(session_id).users
|
||||||
|
|
||||||
|
return render_template("user_cards.j2", users=users)
|
||||||
|
|
||||||
|
@users.route("/<int:session_id>", methods=["DELETE"])
|
||||||
|
def delete_users(session_id):
|
||||||
|
session: Session = Session.query.get_or_404(session_id)
|
||||||
|
|
||||||
|
if session.has_depending_tasks():
|
||||||
|
return 'Есть зависимые задачи', 409
|
||||||
|
|
||||||
|
User.query.filter_by(session=session).delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return render_template("user_cards.j2", users=[])
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% for user in users %}
|
||||||
|
<div class="col mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">{{ user.first_name }}</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">{{ user.first_name }}</li>
|
||||||
|
<li class="list-group-item">{{ user.last_name }}</li>
|
||||||
|
<li class="list-group-item"><a href="https://t.me/{{ user.username }}" target=”_blank”>Open chat with @{{
|
||||||
|
user.username }}</a></li>
|
||||||
|
<li class="list-group-item"><a href="tel:{{ user.phone }}">{{ user.phone }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<small id="helpId" class="form-text">Здесь ничего нет.</small>
|
||||||
|
{% endfor %}
|
|
@ -0,0 +1,23 @@
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
from app.blueprints.api.sessions.routes import get_sessions
|
||||||
|
from app.blueprints.api.users.routes import get_users
|
||||||
|
from app.blueprints.api.tasks.routes import get_tasks
|
||||||
|
|
||||||
|
frontend = Blueprint("frontend", __name__, url_prefix="/", template_folder="templates", static_folder="static", static_url_path="/static/frontend")
|
||||||
|
|
||||||
|
@frontend.route("/")
|
||||||
|
def index():
|
||||||
|
sessions = get_sessions()
|
||||||
|
return render_template("index.j2", sessions=sessions)
|
||||||
|
|
||||||
|
@frontend.route("/add")
|
||||||
|
def add():
|
||||||
|
return render_template("add.j2")
|
||||||
|
|
||||||
|
@frontend.route("/tasks")
|
||||||
|
def tasks():
|
||||||
|
return render_template("tasks.j2", tasks_template=get_tasks())
|
||||||
|
|
||||||
|
@frontend.route("/parse/<int:id>")
|
||||||
|
def parse(id: int):
|
||||||
|
return render_template("parse.j2", session_id=id, users_template=get_users(id))
|
|
@ -0,0 +1,115 @@
|
||||||
|
const API_URL = window.location.origin;
|
||||||
|
|
||||||
|
const nameInput = document.querySelector("#input-name");
|
||||||
|
const phoneInput = document.querySelector("#input-phone");
|
||||||
|
const addSessionForm = document.querySelector("#add-session-form");
|
||||||
|
|
||||||
|
const openFileButton = document.querySelector("#open-file-btn");
|
||||||
|
|
||||||
|
const secretCodeModal = new bootstrap.Modal('#secretCodeModal');
|
||||||
|
const secretCodeForm = document.querySelector("#secret-code-form");
|
||||||
|
const secretCodeInput = document.querySelector("#input-secret-code");
|
||||||
|
|
||||||
|
const passwordModal = new bootstrap.Modal('#passwordModal');
|
||||||
|
const passwordForm = document.querySelector("#password-form");
|
||||||
|
const passwordInput = document.querySelector("#input-password");
|
||||||
|
|
||||||
|
let phoneHash = "";
|
||||||
|
|
||||||
|
const handleCreation = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (phoneInput.value == "" || nameInput.value == "") {
|
||||||
|
alert("Пожалуйста, заполните все поля.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = getAuthData();
|
||||||
|
|
||||||
|
res = await fetch(`${API_URL}/api/sessions/`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
result = await res.json();
|
||||||
|
|
||||||
|
switch(result.status) {
|
||||||
|
case "ok":
|
||||||
|
window.location = "/";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
handleError(result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "exception":
|
||||||
|
alert(`Ошибка: ${result.message}.`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
alert(`Неизвестная ошибка. Проверьте логи`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuthData = () => {
|
||||||
|
const inputFields = [passwordInput, secretCodeInput, nameInput, phoneInput]
|
||||||
|
data = {}
|
||||||
|
inputFields.forEach((el) => {
|
||||||
|
if (el.value !== "") {
|
||||||
|
data[el.attributes.name.value] = el.value.replace(/ /g, '');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (phoneHash !== "")
|
||||||
|
data["phone_hash"] = phoneHash;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequest = async (fn) => {
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (result) => {
|
||||||
|
switch(result.action) {
|
||||||
|
case "code":
|
||||||
|
phoneHash = result.phone_hash;
|
||||||
|
secretCodeModal.show();
|
||||||
|
break;
|
||||||
|
case "password":
|
||||||
|
passwordModal.show();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSessionForm.addEventListener("submit", handleCreation);
|
||||||
|
secretCodeForm.addEventListener("submit", handleCreation);
|
||||||
|
passwordForm.addEventListener("submit", handleCreation);
|
||||||
|
|
||||||
|
|
||||||
|
// openFileButton.addEventListener("click", handleOpening);
|
||||||
|
|
||||||
|
function handleOpening() {
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = ".zip";
|
||||||
|
input.onchange = async () => {
|
||||||
|
// you can use this method to get file and perform respective operations
|
||||||
|
let archive = Array.from(input.files)[0];
|
||||||
|
let formData = new FormData();
|
||||||
|
|
||||||
|
formData.append("archive", archive);
|
||||||
|
response = await fetch(`${API_URL}/api/save_session_file.php`, { method: "POST", body: formData });
|
||||||
|
|
||||||
|
if (response.status == "201") {
|
||||||
|
window.location = "/"
|
||||||
|
} else {
|
||||||
|
const error_text = await response.text();
|
||||||
|
const error_status = await response.status;
|
||||||
|
alert(`${error_status}: ${error_text}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
{% extends "base.j2" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
PaperParser: Вход в аккаунт
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container ">
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<!-- if you want to close by clicking outside the modal, delete the last endpoint:data-bs-backdrop and data-bs-keyboard -->
|
||||||
|
<div class="modal fade" id="secretCodeModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"
|
||||||
|
role="dialog" aria-labelledby="modalTitleId" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form action="" id="secret-code-form">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalTitleId">Введите секретный код</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="text" name="code" id="input-secret-code" class="form-control"
|
||||||
|
placeholder="Секретный код">
|
||||||
|
<label for="input-secret-code">Секретный код</label>
|
||||||
|
</div>
|
||||||
|
<small id="helpId" class="form-text">
|
||||||
|
Вы должны были получить 6-ти значный код в вашем
|
||||||
|
аккаунте. Если это не так, обратитесь к администратору, проверьте логи и вывод прокси
|
||||||
|
сервера.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Подтвердить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="passwordModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"
|
||||||
|
role="dialog" aria-labelledby="modalTitleId" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form action="" id="password-form">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modalTitleId">Введите пароль</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="text" name="password" id="input-password" class="form-control"
|
||||||
|
placeholder="Пароль">
|
||||||
|
<label for="input-password">Пароль</label>
|
||||||
|
</div>
|
||||||
|
<small id="helpId" class="form-text">Введите пароль двухфакторной аутентификации. Если у акаунта его нет, да поможет вам бог.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Подтвердить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="" id="add-session-form">
|
||||||
|
|
||||||
|
<!-- Telegram Account Info -->
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="" class="form-label">Данные телеграм аккаунта</label>
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2">
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="text" name="name" id="input-name" class="form-control" placeholder="Имя">
|
||||||
|
<label for="input-name">название сессии</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input type="phone" name="phone" id="input-phone" class="form-control" placeholder="Phone">
|
||||||
|
<label for="input-phone">Номер телефона</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Отправить код</button>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<form action="/api/sessions/file" method="POST">
|
||||||
|
<label for="open-file-btn" class="form-label">Архив с аккаунтом (или .session файл)</label>
|
||||||
|
<div class="mb-3">
|
||||||
|
<button id="open-file-btn" type="submit" class="btn btn-secondary mt-3 disabled">Открыть файл</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='frontend/add.js') }}"></script>
|
||||||
|
{% endblock scripts %}
|
|
@ -0,0 +1,64 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}{% endblock %}</title>
|
||||||
|
<!-- Required meta tags -->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS v5.2.1 -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-iYQeCzEYFbKjA/T2uDLTpkwGzCiq6soy8tYaI1GyVh/UjpbCx/TYkiZhlZB6+fzT" crossorigin="anonymous">
|
||||||
|
|
||||||
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
<header>
|
||||||
|
<!-- place navbar here -->
|
||||||
|
<nav class="navbar navbar-expand-sm navbar-light bg-light mb-3">
|
||||||
|
<div class="container justify-content-beetwen">
|
||||||
|
<a class="navbar-brand" href="/">PaperParser</a>
|
||||||
|
<ul class="nav">
|
||||||
|
<li><a class="nav-link" href="/tasks">Задачи</a></li>
|
||||||
|
<li><a class="btn btn-outline-success" href="/add" role="button">Войти</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% block main %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="container mt-auto">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||||
|
<p class="col-md-4 mb-0 text-body-secondary">Anatoly "wineT" Bogomolov</p>
|
||||||
|
|
||||||
|
<ul class="nav col-md-4 justify-content-end">
|
||||||
|
<li class="nav-item"><a href="https://t.me/AnatolyFL" class="nav-link px-2 text-body-secondary">Telegram</a></li>
|
||||||
|
<li class="nav-item"><a href="https://mastodon.ml/@winet" class="nav-link px-2 text-body-secondary">Mastodon</a></li>
|
||||||
|
<li class="nav-item"><a href="#" class="nav-link px-2 text-body-secondary">Project on Gitiea</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Bootstrap JavaScript Libraries -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"
|
||||||
|
integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.1/dist/js/bootstrap.min.js"
|
||||||
|
integrity="sha384-7VPbUDkoPSGFnVtYi0QogXtr74QeVeeIs99Qfg5YCF+TidwNdjvaKZX19NZ/e6oz" crossorigin="anonymous">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "base.j2" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
PaperParser: Главная
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
<div id="cards-grid" class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4">
|
||||||
|
|
||||||
|
{% for session in sessions %}
|
||||||
|
<div class="col mb-3" data-session="{{ session.name }}">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">{{ session.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a href="/parse/{{ session.id }}" class="btn btn-outline-primary mt-2">
|
||||||
|
Использовать
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-delete="/api/sessions/{{ session.id }}"
|
||||||
|
hx-swap="delete"
|
||||||
|
hx-target='[data-session="{{ session.name }}"]'
|
||||||
|
hx-confirm="Вы уверены, что хотите удалить эту сессию?"
|
||||||
|
class="btn btn-outline-danger mt-2 mx-2"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
|
@ -0,0 +1,101 @@
|
||||||
|
{% extends "base.j2" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
PaperParser: Парсинг
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
<!-- Actions -->
|
||||||
|
<section id="actions-section" class="mb-3">
|
||||||
|
<div class="separator">
|
||||||
|
<h2>Действия</h2>
|
||||||
|
<hr class="divider">
|
||||||
|
</div>
|
||||||
|
<section id="actions-parse-add-section">
|
||||||
|
<label for="" class="form-label">Парсинг пользователей и добавление в группу</label>
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2">
|
||||||
|
<div class="col mb-3">
|
||||||
|
<form hx-post="/api/users/parse/{{session_id}}" hx-swap="innerHTML" hx-target="#cards-grid" hx-indicator="#loading-spinner" class="input-group" id="group-from-form">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" class="form-control" name="group" id="group-from-input"
|
||||||
|
placeholder="Группа для парсинга">
|
||||||
|
<label for="group-from-input">Группа для парсинга</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Спарсить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col mb-3">
|
||||||
|
<form hx-post="/api/tasks/session/{{session_id}}" hx-swap="none" hx-indicator="#loading-spinner" class="input-group" id="group-to-form">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="url" class="form-control" id="group-to-input"
|
||||||
|
placeholder="Группа, в которую нужно добавить">
|
||||||
|
<label for="group-to-input">Группа для добавления</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="task" value="add" class="btn btn-outline-secondary">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="actions-message-section">
|
||||||
|
<form hx-post="/api/tasks/session/{{session_id}}" hx-swap="none" hx-indicator="#loading-spinner" id="message-form">
|
||||||
|
<label for="" class="form-label">Сообщение для пользователей</label>
|
||||||
|
<div class="row row-cols-1 row-cols-sm-2">
|
||||||
|
<div class="col">
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea class="form-control" name="message" rows="8" id="message-textarea"
|
||||||
|
placeholder="Напишите здесь своё сообщение"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="formFile" class="form-label">Выберите изображение</label>
|
||||||
|
<input class="form-control" name="file" type="file" id="picture-file-input">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<button type="submit" name="task" value="message" class="btn btn-outline-primary" style="width: 100%;">
|
||||||
|
Отправить сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Users -->
|
||||||
|
<section id="users-section" class="mb-3">
|
||||||
|
<div id="separator">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<h2 style="margin-bottom: 0;">Пользователи</h2>
|
||||||
|
<div class="spinner-container htmx-indicator" id="loading-spinner">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-primary disabled" onClick="exportToCSV()">Экспорт</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-danger"
|
||||||
|
hx-delete="/api/users/{{session_id}}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#cards-grid"
|
||||||
|
hx-confirm="Вы уверены, что хотите удалить пользователей для этой сессии?"
|
||||||
|
hx-indicator="#loading-spinner"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<hr class="divider">
|
||||||
|
</div>
|
||||||
|
<div id="cards-grid" class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4">
|
||||||
|
{{ users_template }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "base.j2" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
PaperParser: Заадчи
|
||||||
|
{% endblock title %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="separator">
|
||||||
|
<h2>Задачи</h2>
|
||||||
|
<hr class="divider">
|
||||||
|
</div>
|
||||||
|
<div id="cards-grid" class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4">
|
||||||
|
{{ tasks_template }}
|
||||||
|
</div>
|
||||||
|
{% endblock main %}
|
|
@ -0,0 +1,18 @@
|
||||||
|
from flask import Flask
|
||||||
|
from celery import Celery
|
||||||
|
from celery.contrib.abortable import AbortableTask
|
||||||
|
from app.blueprints.api.tasks.handlers import *
|
||||||
|
|
||||||
|
def celery_init_app(app: Flask) -> Celery:
|
||||||
|
class FlaskTask(AbortableTask):
|
||||||
|
def __call__(self, *args: object, **kwargs: object) -> object:
|
||||||
|
with app.app_context():
|
||||||
|
return self.run(*args, **kwargs)
|
||||||
|
|
||||||
|
celery_app = Celery(app.name, task_cls=FlaskTask)
|
||||||
|
|
||||||
|
celery_app.config_from_object(app.config["CELERY"])
|
||||||
|
celery_app.set_default()
|
||||||
|
app.extensions["celery"] = celery_app
|
||||||
|
return celery_app
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate(db=db)
|
|
@ -0,0 +1,24 @@
|
||||||
|
from typing import List
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class Session(db.Model):
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
|
||||||
|
authorized: Mapped[bool]
|
||||||
|
users: Mapped[List[User]] = relationship("User", back_populates="session")
|
||||||
|
|
||||||
|
tasks: Mapped[List[Task]] = relationship("Task", back_populates="session")
|
||||||
|
|
||||||
|
def has_depending_tasks(self):
|
||||||
|
tasks = Task.query \
|
||||||
|
.filter_by(session_id=self.id) \
|
||||||
|
.filter((Task.status == "RUNNING") | (Task.status == "RUNNING")) \
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return len(tasks) > 0
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
from sqlalchemy import Integer
|
||||||
|
from sqlalchemy.schema import Column, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class Task(db.Model):
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
task_id: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(unique=True)
|
||||||
|
|
||||||
|
session_id = Column(Integer, ForeignKey("session.id"))
|
||||||
|
session: Mapped['Session'] = relationship("Session", back_populates="tasks")
|
||||||
|
status: Mapped[str]
|
||||||
|
status_message: Mapped[str]
|
||||||
|
type: Mapped[str]
|
||||||
|
|
||||||
|
url: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
message: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
file: Mapped[str] = mapped_column(nullable=True)
|
|
@ -0,0 +1,19 @@
|
||||||
|
from sqlalchemy import Integer
|
||||||
|
from sqlalchemy.schema import (
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
first_name: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
last_name: Mapped[str] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
phone: Mapped[str] = mapped_column(nullable=True, unique=True)
|
||||||
|
username: Mapped[str] = mapped_column(nullable=True, unique=True)
|
||||||
|
|
||||||
|
session_id = Column(Integer, ForeignKey("session.id"))
|
||||||
|
session: Mapped["Session"] = relationship("Session", back_populates="users")
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -0,0 +1,15 @@
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
|
||||||
|
def to_sync_task(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_forever()
|
||||||
|
return loop.run_until_complete(func(*args, **kwargs))
|
||||||
|
|
||||||
|
return wrapper
|
|
@ -0,0 +1,14 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY')
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ.get('POSTGRES_USER')}:{os.environ.get('POSTGRES_PASSWORD')}@postgres/{os.environ.get('POSTGRES_DB')}"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
CELERY = dict(
|
||||||
|
broker_url="redis://redis/0",
|
||||||
|
result_backend="redis://redis/0",
|
||||||
|
task_ignore_result=True,
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
image: postgres:16
|
||||||
|
|
||||||
|
celery:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command: celery -A make_celery worker --loglevel INFO
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command: gunicorn --bind 0.0.0.0:5000 "app:create_app()"
|
||||||
|
ports:
|
||||||
|
- 5000:5000
|
||||||
|
depends_on:
|
||||||
|
- celery
|
||||||
|
- postgres
|
|
@ -0,0 +1,4 @@
|
||||||
|
FLASK_APP=
|
||||||
|
POSTGRES_USER=
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=
|
|
@ -0,0 +1,4 @@
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
flask_app = create_app()
|
||||||
|
celery_app = flask_app.extensions['celery']
|
|
@ -0,0 +1 @@
|
||||||
|
Single-database configuration for Flask.
|
|
@ -0,0 +1,50 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
|
@ -0,0 +1,113 @@
|
||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Innitial commit
|
||||||
|
|
||||||
|
Revision ID: 31cd88d78cb4
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-21 14:31:12.188455
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '31cd88d78cb4'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('session',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('authorized', sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('first_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('last_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(), nullable=True),
|
||||||
|
sa.Column('username', sa.String(), nullable=True),
|
||||||
|
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['session.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('phone'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('session')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Deleted json filed
|
||||||
|
|
||||||
|
Revision ID: af22ea98e0da
|
||||||
|
Revises: 31cd88d78cb4
|
||||||
|
Create Date: 2024-01-22 02:14:59.504400
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'af22ea98e0da'
|
||||||
|
down_revision = '31cd88d78cb4'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('task',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task_id', sa.String(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('session_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(), nullable=False),
|
||||||
|
sa.Column('status_message', sa.String(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(), nullable=False),
|
||||||
|
sa.Column('url', sa.String(), nullable=True),
|
||||||
|
sa.Column('message', sa.String(), nullable=True),
|
||||||
|
sa.Column('file', sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['session.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('task')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,103 @@
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from opentele.tl import TelegramClient
|
||||||
|
from opentele.api import API
|
||||||
|
|
||||||
|
from telethon.tl.functions.channels import InviteToChannelRequest, JoinChannelRequest
|
||||||
|
from telethon.tl.functions.messages import AddChatUserRequest
|
||||||
|
|
||||||
|
# Types
|
||||||
|
from telethon.hints import (Entity, EntityLike, MessageLike)
|
||||||
|
from telethon.types import Channel, Chat, User
|
||||||
|
from telethon.sessions.abstract import Session
|
||||||
|
|
||||||
|
from paper.errors import *
|
||||||
|
from telethon.errors.rpcerrorlist import (
|
||||||
|
ChatAdminRequiredError,
|
||||||
|
UserPrivacyRestrictedError,
|
||||||
|
UserNotMutualContactError,
|
||||||
|
ChannelPrivateError,
|
||||||
|
ChatWriteForbiddenError,
|
||||||
|
PeerFloodError,
|
||||||
|
FloodWaitError,
|
||||||
|
UsersTooMuchError,
|
||||||
|
UserChannelsTooMuchError,
|
||||||
|
UserIsBlockedError,
|
||||||
|
YouBlockedUserError
|
||||||
|
)
|
||||||
|
|
||||||
|
class PaperClient(TelegramClient):
|
||||||
|
def __init__(self, session: str | Session) -> None:
|
||||||
|
api = API.TelegramAndroid.Generate("paper")
|
||||||
|
super().__init__(session, api)
|
||||||
|
|
||||||
|
async def invite_self(self, group: Entity | EntityLike):
|
||||||
|
group = await self.__cast_to_entity(group)
|
||||||
|
|
||||||
|
if isinstance(group, Channel):
|
||||||
|
await self(JoinChannelRequest(group))
|
||||||
|
logger.info(f"Added self to a channel {group.title}")
|
||||||
|
|
||||||
|
@logger.catch(reraise=True)
|
||||||
|
async def invite_user(self, user: Entity | EntityLike, group: Entity | EntityLike):
|
||||||
|
user, group = await self.__cast_to_entity(user), await self.__cast_to_entity(group)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(group, Channel) and isinstance(user, User):
|
||||||
|
await self(InviteToChannelRequest(group, [user])) # type: ignore
|
||||||
|
logger.info(f"User {user} was added to channel {group.title}")
|
||||||
|
|
||||||
|
elif isinstance(group, Chat) and isinstance(user, User):
|
||||||
|
await self(AddChatUserRequest(group, user, 30)) # type: ignore
|
||||||
|
logger.info(f"User {user} was added to chat {group.title}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"Can't determine group type for {group}. Skipping...")
|
||||||
|
|
||||||
|
except (UserPrivacyRestrictedError, UserNotMutualContactError) as e:
|
||||||
|
raise UserPrivacyException(e)
|
||||||
|
|
||||||
|
except (ChatWriteForbiddenError, ChannelPrivateError, ChatAdminRequiredError) as e:
|
||||||
|
raise AdminPrivilegesExceptions(e)
|
||||||
|
|
||||||
|
except (PeerFloodError, FloodWaitError) as e:
|
||||||
|
raise FloodException(e)
|
||||||
|
|
||||||
|
except (UserChannelsTooMuchError, UsersTooMuchError, UserIsBlockedError, YouBlockedUserError) as e:
|
||||||
|
raise IgnoreException(e)
|
||||||
|
|
||||||
|
@logger.catch(reraise=True)
|
||||||
|
async def send_message(self, entity: Entity | EntityLike, message: str, file: str | None):
|
||||||
|
entity = await self.__cast_to_entity(entity)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await super().send_message(entity, message=message, file=file) #type: ignore
|
||||||
|
|
||||||
|
except (UserPrivacyRestrictedError, UserNotMutualContactError) as e:
|
||||||
|
raise UserPrivacyException(e)
|
||||||
|
|
||||||
|
except (ChatWriteForbiddenError, ChannelPrivateError, ChatAdminRequiredError) as e:
|
||||||
|
raise AdminPrivilegesExceptions(e)
|
||||||
|
|
||||||
|
except (PeerFloodError, FloodWaitError) as e:
|
||||||
|
raise FloodException(e)
|
||||||
|
|
||||||
|
except (UserChannelsTooMuchError, UsersTooMuchError, UserIsBlockedError, YouBlockedUserError) as e:
|
||||||
|
raise IgnoreException(e)
|
||||||
|
|
||||||
|
|
||||||
|
@logger.catch(reraise=True)
|
||||||
|
async def get_participants(self, group: Entity | EntityLike, *args, **kwargs):
|
||||||
|
group = await self.__cast_to_entity(group)
|
||||||
|
|
||||||
|
return await super().get_participants(group, *args, **kwargs)
|
||||||
|
|
||||||
|
async def __cast_to_entity(self, entity: Entity | EntityLike) -> Entity:
|
||||||
|
if not isinstance(entity, Entity):
|
||||||
|
if hasattr(entity, "username"):
|
||||||
|
entity = await self.get_entity(entity.username)
|
||||||
|
else:
|
||||||
|
entity = await self.get_entity(entity)
|
||||||
|
|
||||||
|
return entity # type: ignore
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
class UserPrivacyException(Exception):
|
||||||
|
"""Occurs when user's privacy setting doesn't allow certain actions (e.g., inviting to a channel, sending a message)"""
|
||||||
|
def __init__(self, error: Exception, *args: object) -> None:
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
self.message = f"Can't do action due to user's privacy settings"
|
||||||
|
super().__init__(self.message, *args)
|
||||||
|
|
||||||
|
class AdminPrivilegesExceptions(Exception):
|
||||||
|
"""Occurs when administrator privileges are required to perform a certain action (e.g., channel invitations, user parsing)"""
|
||||||
|
def __init__(self, error: Exception, *args: object) -> None:
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
self.message = f"Administrator privileges are required to perform the action."
|
||||||
|
super().__init__(self.message, *args)
|
||||||
|
|
||||||
|
class FloodException(Exception):
|
||||||
|
def __init__(self, error, *args: object):
|
||||||
|
self.error = error
|
||||||
|
self.seconds = self.error.seconds if hasattr(self.error, "seconds") else -1 #type: ignore
|
||||||
|
|
||||||
|
super().__init__(*args)
|
||||||
|
|
||||||
|
class IgnoreException(Exception):
|
||||||
|
def __init__(self, error, *args):
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
super().__init__(*args)
|
||||||
|
|
||||||
|
class TooMuchException(Exception):
|
||||||
|
def __init__(self, error, *args):
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
super().__init__(*args)
|
||||||
|
|
||||||
|
class NeedPasswordException(Exception):
|
||||||
|
def __init__(self, *args):
|
||||||
|
self.message = "Need password"
|
||||||
|
|
||||||
|
super().__init__(self.message, *args)
|
|
@ -0,0 +1,13 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from paper.utils.classes import DataclassBase
|
||||||
|
|
||||||
|
dataclass(init=False)
|
||||||
|
class Message(DataclassBase):
|
||||||
|
text: str = ''
|
||||||
|
images: None | list[str] = None
|
||||||
|
|
||||||
|
force_document: bool = False
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
return self.text or self.images
|
|
@ -0,0 +1,92 @@
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from paper.client import PaperClient
|
||||||
|
from paper.errors import IgnoreException, NeedPasswordException, UserPrivacyException
|
||||||
|
from paper.models import Message
|
||||||
|
|
||||||
|
|
||||||
|
class PaperParser:
|
||||||
|
def __init__(self, session: str) -> None:
|
||||||
|
if not session:
|
||||||
|
raise ValueError("Session name can't be None")
|
||||||
|
|
||||||
|
self.client = PaperClient(session)
|
||||||
|
|
||||||
|
self.users_to_delete = []
|
||||||
|
|
||||||
|
async def invite_users(self, users, group, task = None):
|
||||||
|
await self.client.invite_self(group)
|
||||||
|
|
||||||
|
group_participants = await self.client.get_participants(group)
|
||||||
|
|
||||||
|
if group_participants:
|
||||||
|
participants_usernames = [participant.username for participant in group_participants if participant.username]
|
||||||
|
users = filter(lambda user: user.username not in participants_usernames, users)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
if task.is_aborted():
|
||||||
|
return self.users_to_delete
|
||||||
|
|
||||||
|
await self.client.invite_user(user, group)
|
||||||
|
|
||||||
|
except (UserPrivacyException, IgnoreException) as e:
|
||||||
|
self.users_to_delete.append(user)
|
||||||
|
logger.exception(e)
|
||||||
|
logger.warning("Exception occurred. Skipping user...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if not task.is_aborted():
|
||||||
|
await asyncio.sleep(50) # FIXME: Change to config value
|
||||||
|
|
||||||
|
async def send_messages(self, users, message: str, file: str | None = None, task = None):
|
||||||
|
# TODO: Filter users, that already get this message
|
||||||
|
# for dialog in await self.client.get_dialogs():
|
||||||
|
# if dialog.is_user:
|
||||||
|
# messages = tuple(filter(lambda chat_message: message.text == chat_message.text, await self.client.get_messages(dialog)))
|
||||||
|
# print(messages)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
if task.is_aborted():
|
||||||
|
return self.users_to_delete
|
||||||
|
|
||||||
|
await self.client.send_message(user, message, file)
|
||||||
|
|
||||||
|
except (UserPrivacyException, IgnoreException) as e:
|
||||||
|
self.users_to_delete.append(user)
|
||||||
|
logger.exception(e)
|
||||||
|
logger.warning("Exception occurred. Skipping user...")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if not task.is_aborted():
|
||||||
|
await asyncio.sleep(50) # FIXME: Change to config value
|
||||||
|
|
||||||
|
async def get_participants(self, group):
|
||||||
|
await self.client.invite_self(group)
|
||||||
|
|
||||||
|
return await self.client.get_participants(group)
|
||||||
|
|
||||||
|
async def sign_in(self, phone: str, password: str | None = None, code: str | None = None, phone_hash: str | None = None, **kwargs):
|
||||||
|
if not await self.client.is_user_authorized():
|
||||||
|
try:
|
||||||
|
return await self.client.sign_in(phone=phone, code=code, phone_code_hash=phone_hash) #type: ignore
|
||||||
|
except:
|
||||||
|
if not password:
|
||||||
|
raise NeedPasswordException()
|
||||||
|
|
||||||
|
return await self.client.sign_in(password=password)
|
||||||
|
|
||||||
|
async def __aenter__(self, *args, **kwargs):
|
||||||
|
await self.client.connect()
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args, **kwargs):
|
||||||
|
await self.client.disconnect()
|
|
@ -0,0 +1,20 @@
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(init=False)
|
||||||
|
class DataclassBase:
|
||||||
|
"""
|
||||||
|
It just works™
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
names = set([f.name for f in dataclasses.fields(self)])
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if k in names:
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
def dict(self):
|
||||||
|
return {
|
||||||
|
k: v.dict() if isinstance(v, DataclassBase) else v
|
||||||
|
for k, v in dataclasses.asdict(self).items()
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
alembic==1.13.1
|
||||||
|
amqp==5.2.0
|
||||||
|
asgiref==3.7.2
|
||||||
|
async-timeout==4.0.3
|
||||||
|
billiard==4.2.0
|
||||||
|
blinker==1.7.0
|
||||||
|
celery==5.3.6
|
||||||
|
click==8.1.7
|
||||||
|
click-didyoumean==0.3.0
|
||||||
|
click-plugins==1.1.1
|
||||||
|
click-repl==0.3.0
|
||||||
|
Flask==3.0.1
|
||||||
|
Flask-Migrate==4.0.5
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
greenlet==3.0.3
|
||||||
|
gunicorn==21.2.0
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
Jinja2==3.1.3
|
||||||
|
kombu==5.3.5
|
||||||
|
loguru==0.7.2
|
||||||
|
Mako==1.3.0
|
||||||
|
MarkupSafe==2.1.4
|
||||||
|
opentele==1.15.1
|
||||||
|
packaging==23.2
|
||||||
|
prompt-toolkit==3.0.43
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pyaes==1.6.1
|
||||||
|
pyasn1==0.5.1
|
||||||
|
PyQt5==5.15.10
|
||||||
|
PyQt5-Qt5==5.15.2
|
||||||
|
PyQt5-sip==12.13.0
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
redis==5.0.1
|
||||||
|
rsa==4.9
|
||||||
|
six==1.16.0
|
||||||
|
SQLAlchemy==2.0.25
|
||||||
|
Telethon==1.33.1
|
||||||
|
TgCrypto==1.2.5
|
||||||
|
typing_extensions==4.9.0
|
||||||
|
tzdata==2023.4
|
||||||
|
vine==5.1.0
|
||||||
|
wcwidth==0.2.13
|
||||||
|
Werkzeug==3.0.1
|
Loading…
Reference in New Issue