Compare commits

...

20 Commits

Author SHA1 Message Date
92e473fdfe Add script to README 2025-11-08 22:44:22 +01:00
6e97f2bea1 Add script secret_santa.py 2025-11-08 22:31:34 +01:00
fa1ee4dd64 feat: add local date in docker-compose files 2025-07-12 17:10:20 +02:00
b9884d6560 Comments in docker-compose 2025-05-22 22:57:42 +02:00
9245dbb9af Add compose in clima_bot 2025-05-22 21:07:19 +02:00
2c4f14a886 Update mareas_bot 2025-02-21 21:31:58 +01:00
1fa360a109 mareas_bot: Fix errors. Update dependencies. Add enhancements 2025-02-19 23:27:15 +01:00
a2badd511d Fusionar feature/tablamareasbot en main 2025-02-19 21:24:50 +01:00
68b9bf32a3 Add rubiks cube solver 2024-09-01 21:48:24 +02:00
21c7e1e6c6 Add enhanced urlf4ck3r 2024-09-01 19:18:39 +02:00
dc9e81f06e Update Ollama bot 2024-08-18 21:15:33 +02:00
dd48618093 Update Ollama bot 2024-08-18 20:18:59 +02:00
3e47d5a7ee Add Ollama bot 2024-08-18 20:16:34 +02:00
d59e31205b Update chatgpt bot 2024-08-18 19:43:44 +02:00
e2767e99af Update chatgpt bot 2024-08-17 21:11:58 +02:00
65468e4115 Add chatgpt bot 2024-08-17 21:08:24 +02:00
4f2264748e Update README 2024-08-15 19:43:39 +02:00
a73e8a0222 Update diagrams test README 2024-08-13 01:46:47 +02:00
84a2519f6c Add diagrams test 2024-08-13 01:43:53 +02:00
88a26dae34 Update .gitignore 2024-08-13 01:30:37 +02:00
65 changed files with 7366 additions and 10 deletions

2
.gitignore vendored
View File

@@ -127,9 +127,11 @@ celerybeat.pid
# Environments
.env
*.env
.venv
env/
venv/
myenv/
ENV/
env.bak/
venv.bak/

View File

@@ -8,6 +8,9 @@ services:
- .env
ports:
- '5000:5000'
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
- redis
@@ -16,4 +19,7 @@ services:
container_name: redis-python
ports:
- '6379:6379'
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro

View File

@@ -7,6 +7,8 @@ services:
- 5672:5672
- 15672:15672
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./rabbitmq/data/:/var/lib/rabbitmq/
- ./rabbitmq/log/:/var/log/rabbitmq
# environment:

View File

@@ -5,6 +5,8 @@ services:
ports:
- "2181:2181"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- "zookeeper_data:/bitnami"
environment:
ALLOW_ANONYMOUS_LOGIN: "yes"
@@ -23,6 +25,8 @@ services:
ports:
- "9092:9092"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- "kafka_data:/bitnami"
environment:
KAFKA_ADVERTISED_LISTENERS: INSIDE://kafka:9092,OUTSIDE://localhost:9093
@@ -53,6 +57,9 @@ services:
KAFKA_BROKERCONNECT: kafka:9092
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafka:
condition: service_healthy
@@ -69,6 +76,9 @@ services:
context: ./producer-raw-recipes
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy
@@ -90,6 +100,9 @@ services:
context: ./producer-consumer-parse-recipes
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy
@@ -112,6 +125,9 @@ services:
context: ./consumer-notification
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy

View File

@@ -11,6 +11,9 @@ services:
- "9200:9200"
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
kibana:
image: docker.elastic.co/kibana/kibana:7.15.2
@@ -21,11 +24,16 @@ services:
- "5601:5601"
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
logstash:
image: docker.elastic.co/logstash/logstash:7.15.2
container_name: logstash
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./logstash-config/:/usr/share/logstash/pipeline/
environment:
LS_JAVA_OPTS: "-Xmx256m -Xms256m"
@@ -43,6 +51,9 @@ services:
- elasticsearch
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
python-app:
build:
@@ -50,6 +61,8 @@ services:
dockerfile: Dockerfile
container_name: python-app
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./app:/app
networks:
- elastic

View File

@@ -6,6 +6,8 @@ services:
ports:
- "9090:9090"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ${PWD}/prometheus.yml:/etc/prometheus/prometheus.yml
restart: unless-stopped
networks:
@@ -26,6 +28,9 @@ services:
restart: unless-stopped
networks:
- monitoring-net
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
interval: 1m30s
@@ -43,6 +48,9 @@ services:
- "8080:8080"
networks:
- monitoring-net
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
command: ["python3", "app/main.py"]
networks:

View File

@@ -10,6 +10,8 @@ services:
- SONARQUBE_JDBC_PASSWORD=sonar
- SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- sonarqube_data:/opt/sonarqube/data
- sonarqube_logs:/opt/sonarqube/logs
- sonarqube_extensions:/opt/sonarqube/extensions
@@ -26,6 +28,8 @@ services:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- elasticsearch_data:/usr/share/elasticsearch/data
networks:
- sonarnet
@@ -40,6 +44,8 @@ services:
networks:
- sonarnet
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- sonar_db:/var/lib/postgresql
- sonar_db_data:/var/lib/postgresql/data
@@ -49,6 +55,8 @@ services:
depends_on:
- sonarqube
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- .:/usr/src
working_dir: /usr/src
networks:

View File

@@ -0,0 +1,13 @@
services:
clima-app:
build: .
container_name: clima-app
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD-SHELL", "pgrep -f bot.py || exit 1"]
interval: 10s
retries: 3
start_period: 5s

View File

@@ -11,6 +11,9 @@ services:
- PGID=1000
- TZ=Europe/Madrid
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
bbdd:
condition: service_healthy
@@ -28,6 +31,9 @@ services:
ports:
- "3306:3306"
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p${MYSQL_ROOT_PASSWORD}"]
interval: 10s

View File

@@ -11,6 +11,8 @@ services:
- PGID=1000
- TZ=Europe/Madrid
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./movie2_bot_data:/app/db
- ./logs:/app/logs
restart: unless-stopped

View File

@@ -0,0 +1 @@
mongodb

View File

@@ -0,0 +1,138 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Custom
config/config.yml
config/config.env
docker-compose.dev.yml
mongodb/

View File

@@ -0,0 +1,23 @@
FROM python:3.8-slim
RUN \
set -eux; \
apt-get update; \
DEBIAN_FRONTEND="noninteractive" apt-get install -y --no-install-recommends \
python3-pip \
build-essential \
python3-venv \
ffmpeg \
git \
; \
rm -rf /var/lib/apt/lists/*
RUN pip3 install -U pip && pip3 install -U wheel && pip3 install -U setuptools==59.5.0
COPY ./requirements.txt /tmp/requirements.txt
RUN pip3 install -r /tmp/requirements.txt && rm -r /tmp/requirements.txt
COPY . /code
WORKDIR /code
CMD ["bash"]

View File

@@ -0,0 +1,60 @@
# ChatGPT Telegram Bot: **GPT-4. Rápido. Sin límites diarios. Modos de chat especiales**
> Repositorio original: https://github.com/father-bot/chatgpt_telegram_bot
Este repositorio es ChatGPT recreado como un Bot de Telegram.
Puedes desplegarlo tu mismo.
## Características
- Respuestas con baja latencia (usualmente toma entre 3-5 segundos)
- Sin límites de solicitudes
- Transmisión de mensajes (mira la demo)
- Soporte para GPT-4 y GPT-4 Turbo
- Soporte para GPT-4 Vision
- Soporte para chat en grupo (/help_group_chat para obtener instrucciones)
- DALLE 2 (elige el modo 👩‍🎨 Artista para generar imágenes)
- Reconocimiento de mensajes de voz
- Resaltado de código
- 15 modos de chat especiales: 👩🏼‍🎓 Asistente, 👩🏼‍💻 Asistente de Código, 👩‍🎨 Artista, 🧠 Psicólogo, 🚀 Elon Musk, entre otros. Puedes crear fácilmente tus propios modos de chat editando `config/chat_modes.yml`
- Soporte para [ChatGPT API](https://platform.openai.com/docs/guides/chat/introduction)
- Lista de usuarios de Telegram permitidos
- Seguimiento del balance $ gastado en la API de OpenAI
<p align="center">
<img src="https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYmM2ZWVjY2M4NWQ3ZThkYmQ3MDhmMTEzZGUwOGFmOThlMDIzZGM4YiZjdD1n/unx907h7GSiLAugzVX/giphy.gif" />
</p>
---
## Comandos del Bot
- `/retry` Regenerar la última respuesta del bot
- `/new` Iniciar nuevo diálogo
- `/mode` Seleccionar modo de chat
- `/balance` Mostrar balance
- `/settings` Mostrar configuraciones
- `/help` Mostrar ayuda
## Configuración
1. Obtén tu clave de [OpenAI API](https://openai.com/api/)
2. Obtén tu token de bot de Telegram desde [@BotFather](https://t.me/BotFather)
3. Edita `config/config.example.yml` para establecer tus tokens y ejecuta los 2 comandos a continuación (*si eres un usuario avanzado, también puedes editar* `config/config.example.env`):
```bash
mv config/config.example.yml config/config.yml
mv config/config.example.env config/config.env
```
4. 🔥 Y ahora **ejecuta**:
```bash
docker-compose --env-file config/config.env up --build
```
## Referencias
1. [*Construye ChatGPT desde GPT-3*](https://learnprompting.org/docs/applied_prompting/build_chatgpt)

View File

@@ -0,0 +1,875 @@
import io
import logging
import asyncio
import traceback
import html
import json
from datetime import datetime
import openai
import telegram
from telegram import (
Update,
User,
InlineKeyboardButton,
InlineKeyboardMarkup,
BotCommand
)
from telegram.ext import (
Application,
ApplicationBuilder,
CallbackContext,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
AIORateLimiter,
filters
)
from telegram.constants import ParseMode, ChatAction
import config
import database
import openai_utils
import base64
# setup
db = database.Database()
logger = logging.getLogger(__name__)
user_semaphores = {}
user_tasks = {}
HELP_MESSAGE = """Commands:
⚪ /retry Regenerate last bot answer
⚪ /new Start new dialog
⚪ /mode Select chat mode
⚪ /settings Show settings
⚪ /balance Show balance
⚪ /help Show help
🎨 Generate images from text prompts in <b>👩‍🎨 Artist</b> /mode
👥 Add bot to <b>group chat</b>: /help_group_chat
🎤 You can send <b>Voice Messages</b> instead of text
"""
HELP_GROUP_CHAT_MESSAGE = """You can add bot to any <b>group chat</b> to help and entertain its participants!
Instructions (see <b>video</b> below):
1. Add the bot to the group chat
2. Make it an <b>admin</b>, so that it can see messages (all other rights can be restricted)
3. You're awesome!
To get a reply from the bot in the chat @ <b>tag</b> it or <b>reply</b> to its message.
For example: "{bot_username} write a poem about Telegram"
"""
def split_text_into_chunks(text, chunk_size):
for i in range(0, len(text), chunk_size):
yield text[i:i + chunk_size]
async def register_user_if_not_exists(update: Update, context: CallbackContext, user: User):
if not db.check_if_user_exists(user.id):
db.add_new_user(
user.id,
update.message.chat_id,
username=user.username,
first_name=user.first_name,
last_name= user.last_name
)
db.start_new_dialog(user.id)
if db.get_user_attribute(user.id, "current_dialog_id") is None:
db.start_new_dialog(user.id)
if user.id not in user_semaphores:
user_semaphores[user.id] = asyncio.Semaphore(1)
if db.get_user_attribute(user.id, "current_model") is None:
db.set_user_attribute(user.id, "current_model", config.models["available_text_models"][0])
# back compatibility for n_used_tokens field
n_used_tokens = db.get_user_attribute(user.id, "n_used_tokens")
if isinstance(n_used_tokens, int) or isinstance(n_used_tokens, float): # old format
new_n_used_tokens = {
"gpt-3.5-turbo": {
"n_input_tokens": 0,
"n_output_tokens": n_used_tokens
}
}
db.set_user_attribute(user.id, "n_used_tokens", new_n_used_tokens)
# voice message transcription
if db.get_user_attribute(user.id, "n_transcribed_seconds") is None:
db.set_user_attribute(user.id, "n_transcribed_seconds", 0.0)
# image generation
if db.get_user_attribute(user.id, "n_generated_images") is None:
db.set_user_attribute(user.id, "n_generated_images", 0)
async def is_bot_mentioned(update: Update, context: CallbackContext):
try:
message = update.message
if message.chat.type == "private":
return True
if message.text is not None and ("@" + context.bot.username) in message.text:
return True
if message.reply_to_message is not None:
if message.reply_to_message.from_user.id == context.bot.id:
return True
except:
return True
else:
return False
async def start_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
db.start_new_dialog(user_id)
reply_text = "Hi! I'm <b>ChatGPT</b> bot implemented with OpenAI API 🤖\n\n"
reply_text += HELP_MESSAGE
await update.message.reply_text(reply_text, parse_mode=ParseMode.HTML)
await show_chat_modes_handle(update, context)
async def help_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
await update.message.reply_text(HELP_MESSAGE, parse_mode=ParseMode.HTML)
async def help_group_chat_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
text = HELP_GROUP_CHAT_MESSAGE.format(bot_username="@" + context.bot.username)
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
await update.message.reply_video(config.help_group_chat_video_path)
async def retry_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
dialog_messages = db.get_dialog_messages(user_id, dialog_id=None)
if len(dialog_messages) == 0:
await update.message.reply_text("No message to retry 🤷‍♂️")
return
last_dialog_message = dialog_messages.pop()
db.set_dialog_messages(user_id, dialog_messages, dialog_id=None) # last message was removed from the context
await message_handle(update, context, message=last_dialog_message["user"], use_new_dialog_timeout=False)
async def _vision_message_handle_fn(
update: Update, context: CallbackContext, use_new_dialog_timeout: bool = True
):
logger.info('_vision_message_handle_fn')
user_id = update.message.from_user.id
current_model = db.get_user_attribute(user_id, "current_model")
if current_model != "gpt-4-vision-preview" and current_model != "gpt-4o":
await update.message.reply_text(
"🥲 Images processing is only available for <b>gpt-4-vision-preview</b> and <b>gpt-4o</b> model. Please change your settings in /settings",
parse_mode=ParseMode.HTML,
)
return
chat_mode = db.get_user_attribute(user_id, "current_chat_mode")
# new dialog timeout
if use_new_dialog_timeout:
if (datetime.now() - db.get_user_attribute(user_id, "last_interaction")).seconds > config.new_dialog_timeout and len(db.get_dialog_messages(user_id)) > 0:
db.start_new_dialog(user_id)
await update.message.reply_text(f"Starting new dialog due to timeout (<b>{config.chat_modes[chat_mode]['name']}</b> mode) ✅", parse_mode=ParseMode.HTML)
db.set_user_attribute(user_id, "last_interaction", datetime.now())
buf = None
if update.message.effective_attachment:
photo = update.message.effective_attachment[-1]
photo_file = await context.bot.get_file(photo.file_id)
# store file in memory, not on disk
buf = io.BytesIO()
await photo_file.download_to_memory(buf)
buf.name = "image.jpg" # file extension is required
buf.seek(0) # move cursor to the beginning of the buffer
# in case of CancelledError
n_input_tokens, n_output_tokens = 0, 0
try:
# send placeholder message to user
placeholder_message = await update.message.reply_text("...")
message = update.message.caption or update.message.text or ''
# send typing action
await update.message.chat.send_action(action="typing")
dialog_messages = db.get_dialog_messages(user_id, dialog_id=None)
parse_mode = {"html": ParseMode.HTML, "markdown": ParseMode.MARKDOWN}[
config.chat_modes[chat_mode]["parse_mode"]
]
chatgpt_instance = openai_utils.ChatGPT(model=current_model)
if config.enable_message_streaming:
gen = chatgpt_instance.send_vision_message_stream(
message,
dialog_messages=dialog_messages,
image_buffer=buf,
chat_mode=chat_mode,
)
else:
(
answer,
(n_input_tokens, n_output_tokens),
n_first_dialog_messages_removed,
) = await chatgpt_instance.send_vision_message(
message,
dialog_messages=dialog_messages,
image_buffer=buf,
chat_mode=chat_mode,
)
async def fake_gen():
yield "finished", answer, (
n_input_tokens,
n_output_tokens,
), n_first_dialog_messages_removed
gen = fake_gen()
prev_answer = ""
async for gen_item in gen:
(
status,
answer,
(n_input_tokens, n_output_tokens),
n_first_dialog_messages_removed,
) = gen_item
answer = answer[:4096] # telegram message limit
# update only when 100 new symbols are ready
if abs(len(answer) - len(prev_answer)) < 100 and status != "finished":
continue
try:
await context.bot.edit_message_text(
answer,
chat_id=placeholder_message.chat_id,
message_id=placeholder_message.message_id,
parse_mode=parse_mode,
)
except telegram.error.BadRequest as e:
if str(e).startswith("Message is not modified"):
continue
else:
await context.bot.edit_message_text(
answer,
chat_id=placeholder_message.chat_id,
message_id=placeholder_message.message_id,
)
await asyncio.sleep(0.01) # wait a bit to avoid flooding
prev_answer = answer
# update user data
if buf is not None:
base_image = base64.b64encode(buf.getvalue()).decode("utf-8")
new_dialog_message = {"user": [
{
"type": "text",
"text": message,
},
{
"type": "image",
"image": base_image,
}
]
, "bot": answer, "date": datetime.now()}
else:
new_dialog_message = {"user": [{"type": "text", "text": message}], "bot": answer, "date": datetime.now()}
db.set_dialog_messages(
user_id,
db.get_dialog_messages(user_id, dialog_id=None) + [new_dialog_message],
dialog_id=None
)
db.update_n_used_tokens(user_id, current_model, n_input_tokens, n_output_tokens)
except asyncio.CancelledError:
# note: intermediate token updates only work when enable_message_streaming=True (config.yml)
db.update_n_used_tokens(user_id, current_model, n_input_tokens, n_output_tokens)
raise
except Exception as e:
error_text = f"Something went wrong during completion. Reason: {e}"
logger.error(error_text)
await update.message.reply_text(error_text)
return
async def unsupport_message_handle(update: Update, context: CallbackContext, message=None):
error_text = f"I don't know how to read files or videos. Send the picture in normal mode (Quick Mode)."
logger.error(error_text)
await update.message.reply_text(error_text)
return
async def message_handle(update: Update, context: CallbackContext, message=None, use_new_dialog_timeout=True):
# check if bot was mentioned (for group chats)
if not await is_bot_mentioned(update, context):
return
# check if message is edited
if update.edited_message is not None:
await edited_message_handle(update, context)
return
_message = message or update.message.text
# remove bot mention (in group chats)
if update.message.chat.type != "private":
_message = _message.replace("@" + context.bot.username, "").strip()
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
chat_mode = db.get_user_attribute(user_id, "current_chat_mode")
if chat_mode == "artist":
await generate_image_handle(update, context, message=message)
return
current_model = db.get_user_attribute(user_id, "current_model")
async def message_handle_fn():
# new dialog timeout
if use_new_dialog_timeout:
if (datetime.now() - db.get_user_attribute(user_id, "last_interaction")).seconds > config.new_dialog_timeout and len(db.get_dialog_messages(user_id)) > 0:
db.start_new_dialog(user_id)
await update.message.reply_text(f"Starting new dialog due to timeout (<b>{config.chat_modes[chat_mode]['name']}</b> mode) ✅", parse_mode=ParseMode.HTML)
db.set_user_attribute(user_id, "last_interaction", datetime.now())
# in case of CancelledError
n_input_tokens, n_output_tokens = 0, 0
try:
# send placeholder message to user
placeholder_message = await update.message.reply_text("...")
# send typing action
await update.message.chat.send_action(action="typing")
if _message is None or len(_message) == 0:
await update.message.reply_text("🥲 You sent <b>empty message</b>. Please, try again!", parse_mode=ParseMode.HTML)
return
dialog_messages = db.get_dialog_messages(user_id, dialog_id=None)
parse_mode = {
"html": ParseMode.HTML,
"markdown": ParseMode.MARKDOWN
}[config.chat_modes[chat_mode]["parse_mode"]]
chatgpt_instance = openai_utils.ChatGPT(model=current_model)
if config.enable_message_streaming:
gen = chatgpt_instance.send_message_stream(_message, dialog_messages=dialog_messages, chat_mode=chat_mode)
else:
answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed = await chatgpt_instance.send_message(
_message,
dialog_messages=dialog_messages,
chat_mode=chat_mode
)
async def fake_gen():
yield "finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
gen = fake_gen()
prev_answer = ""
async for gen_item in gen:
status, answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed = gen_item
answer = answer[:4096] # telegram message limit
# update only when 100 new symbols are ready
if abs(len(answer) - len(prev_answer)) < 100 and status != "finished":
continue
try:
await context.bot.edit_message_text(answer, chat_id=placeholder_message.chat_id, message_id=placeholder_message.message_id, parse_mode=parse_mode)
except telegram.error.BadRequest as e:
if str(e).startswith("Message is not modified"):
continue
else:
await context.bot.edit_message_text(answer, chat_id=placeholder_message.chat_id, message_id=placeholder_message.message_id)
await asyncio.sleep(0.01) # wait a bit to avoid flooding
prev_answer = answer
# update user data
new_dialog_message = {"user": [{"type": "text", "text": _message}], "bot": answer, "date": datetime.now()}
db.set_dialog_messages(
user_id,
db.get_dialog_messages(user_id, dialog_id=None) + [new_dialog_message],
dialog_id=None
)
db.update_n_used_tokens(user_id, current_model, n_input_tokens, n_output_tokens)
except asyncio.CancelledError:
# note: intermediate token updates only work when enable_message_streaming=True (config.yml)
db.update_n_used_tokens(user_id, current_model, n_input_tokens, n_output_tokens)
raise
except Exception as e:
error_text = f"Something went wrong during completion. Reason: {e}"
logger.error(error_text)
await update.message.reply_text(error_text)
return
# send message if some messages were removed from the context
if n_first_dialog_messages_removed > 0:
if n_first_dialog_messages_removed == 1:
text = "✍️ <i>Note:</i> Your current dialog is too long, so your <b>first message</b> was removed from the context.\n Send /new command to start new dialog"
else:
text = f"✍️ <i>Note:</i> Your current dialog is too long, so <b>{n_first_dialog_messages_removed} first messages</b> were removed from the context.\n Send /new command to start new dialog"
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async with user_semaphores[user_id]:
if current_model == "gpt-4-vision-preview" or current_model == "gpt-4o" or update.message.photo is not None and len(update.message.photo) > 0:
logger.error(current_model)
# What is this? ^^^
if current_model != "gpt-4o" and current_model != "gpt-4-vision-preview":
current_model = "gpt-4o"
db.set_user_attribute(user_id, "current_model", "gpt-4o")
task = asyncio.create_task(
_vision_message_handle_fn(update, context, use_new_dialog_timeout=use_new_dialog_timeout)
)
else:
task = asyncio.create_task(
message_handle_fn()
)
user_tasks[user_id] = task
try:
await task
except asyncio.CancelledError:
await update.message.reply_text("✅ Canceled", parse_mode=ParseMode.HTML)
else:
pass
finally:
if user_id in user_tasks:
del user_tasks[user_id]
async def is_previous_message_not_answered_yet(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
if user_semaphores[user_id].locked():
text = "⏳ Please <b>wait</b> for a reply to the previous message\n"
text += "Or you can /cancel it"
await update.message.reply_text(text, reply_to_message_id=update.message.id, parse_mode=ParseMode.HTML)
return True
else:
return False
async def voice_message_handle(update: Update, context: CallbackContext):
# check if bot was mentioned (for group chats)
if not await is_bot_mentioned(update, context):
return
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
voice = update.message.voice
voice_file = await context.bot.get_file(voice.file_id)
# store file in memory, not on disk
buf = io.BytesIO()
await voice_file.download_to_memory(buf)
buf.name = "voice.oga" # file extension is required
buf.seek(0) # move cursor to the beginning of the buffer
transcribed_text = await openai_utils.transcribe_audio(buf)
text = f"🎤: <i>{transcribed_text}</i>"
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
# update n_transcribed_seconds
db.set_user_attribute(user_id, "n_transcribed_seconds", voice.duration + db.get_user_attribute(user_id, "n_transcribed_seconds"))
await message_handle(update, context, message=transcribed_text)
async def generate_image_handle(update: Update, context: CallbackContext, message=None):
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
await update.message.chat.send_action(action="upload_photo")
message = message or update.message.text
try:
image_urls = await openai_utils.generate_images(message, n_images=config.return_n_generated_images, size=config.image_size)
except openai.error.InvalidRequestError as e:
if str(e).startswith("Your request was rejected as a result of our safety system"):
text = "🥲 Your request <b>doesn't comply</b> with OpenAI's usage policies.\nWhat did you write there, huh?"
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
return
else:
raise
# token usage
db.set_user_attribute(user_id, "n_generated_images", config.return_n_generated_images + db.get_user_attribute(user_id, "n_generated_images"))
for i, image_url in enumerate(image_urls):
await update.message.chat.send_action(action="upload_photo")
await update.message.reply_photo(image_url, parse_mode=ParseMode.HTML)
async def new_dialog_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
db.set_user_attribute(user_id, "current_model", "gpt-3.5-turbo")
db.start_new_dialog(user_id)
await update.message.reply_text("Starting new dialog ✅")
chat_mode = db.get_user_attribute(user_id, "current_chat_mode")
await update.message.reply_text(f"{config.chat_modes[chat_mode]['welcome_message']}", parse_mode=ParseMode.HTML)
async def cancel_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
if user_id in user_tasks:
task = user_tasks[user_id]
task.cancel()
else:
await update.message.reply_text("<i>Nothing to cancel...</i>", parse_mode=ParseMode.HTML)
def get_chat_mode_menu(page_index: int):
n_chat_modes_per_page = config.n_chat_modes_per_page
text = f"Select <b>chat mode</b> ({len(config.chat_modes)} modes available):"
# buttons
chat_mode_keys = list(config.chat_modes.keys())
page_chat_mode_keys = chat_mode_keys[page_index * n_chat_modes_per_page:(page_index + 1) * n_chat_modes_per_page]
keyboard = []
for chat_mode_key in page_chat_mode_keys:
name = config.chat_modes[chat_mode_key]["name"]
keyboard.append([InlineKeyboardButton(name, callback_data=f"set_chat_mode|{chat_mode_key}")])
# pagination
if len(chat_mode_keys) > n_chat_modes_per_page:
is_first_page = (page_index == 0)
is_last_page = ((page_index + 1) * n_chat_modes_per_page >= len(chat_mode_keys))
if is_first_page:
keyboard.append([
InlineKeyboardButton("»", callback_data=f"show_chat_modes|{page_index + 1}")
])
elif is_last_page:
keyboard.append([
InlineKeyboardButton("«", callback_data=f"show_chat_modes|{page_index - 1}"),
])
else:
keyboard.append([
InlineKeyboardButton("«", callback_data=f"show_chat_modes|{page_index - 1}"),
InlineKeyboardButton("»", callback_data=f"show_chat_modes|{page_index + 1}")
])
reply_markup = InlineKeyboardMarkup(keyboard)
return text, reply_markup
async def show_chat_modes_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
text, reply_markup = get_chat_mode_menu(0)
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
async def show_chat_modes_callback_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update.callback_query, context, update.callback_query.from_user)
if await is_previous_message_not_answered_yet(update.callback_query, context): return
user_id = update.callback_query.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
query = update.callback_query
await query.answer()
page_index = int(query.data.split("|")[1])
if page_index < 0:
return
text, reply_markup = get_chat_mode_menu(page_index)
try:
await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
except telegram.error.BadRequest as e:
if str(e).startswith("Message is not modified"):
pass
async def set_chat_mode_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update.callback_query, context, update.callback_query.from_user)
user_id = update.callback_query.from_user.id
query = update.callback_query
await query.answer()
chat_mode = query.data.split("|")[1]
db.set_user_attribute(user_id, "current_chat_mode", chat_mode)
db.start_new_dialog(user_id)
await context.bot.send_message(
update.callback_query.message.chat.id,
f"{config.chat_modes[chat_mode]['welcome_message']}",
parse_mode=ParseMode.HTML
)
def get_settings_menu(user_id: int):
current_model = db.get_user_attribute(user_id, "current_model")
text = config.models["info"][current_model]["description"]
text += "\n\n"
score_dict = config.models["info"][current_model]["scores"]
for score_key, score_value in score_dict.items():
text += "🟢" * score_value + "⚪️" * (5 - score_value) + f" {score_key}\n\n"
text += "\nSelect <b>model</b>:"
# buttons to choose models
buttons = []
for model_key in config.models["available_text_models"]:
title = config.models["info"][model_key]["name"]
if model_key == current_model:
title = "" + title
buttons.append(
InlineKeyboardButton(title, callback_data=f"set_settings|{model_key}")
)
reply_markup = InlineKeyboardMarkup([buttons])
return text, reply_markup
async def settings_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
if await is_previous_message_not_answered_yet(update, context): return
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
text, reply_markup = get_settings_menu(user_id)
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
async def set_settings_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update.callback_query, context, update.callback_query.from_user)
user_id = update.callback_query.from_user.id
query = update.callback_query
await query.answer()
_, model_key = query.data.split("|")
db.set_user_attribute(user_id, "current_model", model_key)
db.start_new_dialog(user_id)
text, reply_markup = get_settings_menu(user_id)
try:
await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
except telegram.error.BadRequest as e:
if str(e).startswith("Message is not modified"):
pass
async def show_balance_handle(update: Update, context: CallbackContext):
await register_user_if_not_exists(update, context, update.message.from_user)
user_id = update.message.from_user.id
db.set_user_attribute(user_id, "last_interaction", datetime.now())
# count total usage statistics
total_n_spent_dollars = 0
total_n_used_tokens = 0
n_used_tokens_dict = db.get_user_attribute(user_id, "n_used_tokens")
n_generated_images = db.get_user_attribute(user_id, "n_generated_images")
n_transcribed_seconds = db.get_user_attribute(user_id, "n_transcribed_seconds")
details_text = "🏷️ Details:\n"
for model_key in sorted(n_used_tokens_dict.keys()):
n_input_tokens, n_output_tokens = n_used_tokens_dict[model_key]["n_input_tokens"], n_used_tokens_dict[model_key]["n_output_tokens"]
total_n_used_tokens += n_input_tokens + n_output_tokens
n_input_spent_dollars = config.models["info"][model_key]["price_per_1000_input_tokens"] * (n_input_tokens / 1000)
n_output_spent_dollars = config.models["info"][model_key]["price_per_1000_output_tokens"] * (n_output_tokens / 1000)
total_n_spent_dollars += n_input_spent_dollars + n_output_spent_dollars
details_text += f"- {model_key}: <b>{n_input_spent_dollars + n_output_spent_dollars:.03f}$</b> / <b>{n_input_tokens + n_output_tokens} tokens</b>\n"
# image generation
image_generation_n_spent_dollars = config.models["info"]["dalle-2"]["price_per_1_image"] * n_generated_images
if n_generated_images != 0:
details_text += f"- DALL·E 2 (image generation): <b>{image_generation_n_spent_dollars:.03f}$</b> / <b>{n_generated_images} generated images</b>\n"
total_n_spent_dollars += image_generation_n_spent_dollars
# voice recognition
voice_recognition_n_spent_dollars = config.models["info"]["whisper"]["price_per_1_min"] * (n_transcribed_seconds / 60)
if n_transcribed_seconds != 0:
details_text += f"- Whisper (voice recognition): <b>{voice_recognition_n_spent_dollars:.03f}$</b> / <b>{n_transcribed_seconds:.01f} seconds</b>\n"
total_n_spent_dollars += voice_recognition_n_spent_dollars
text = f"You spent <b>{total_n_spent_dollars:.03f}$</b>\n"
text += f"You used <b>{total_n_used_tokens}</b> tokens\n\n"
text += details_text
await update.message.reply_text(text, parse_mode=ParseMode.HTML)
async def edited_message_handle(update: Update, context: CallbackContext):
if update.edited_message.chat.type == "private":
text = "🥲 Unfortunately, message <b>editing</b> is not supported"
await update.edited_message.reply_text(text, parse_mode=ParseMode.HTML)
async def error_handle(update: Update, context: CallbackContext) -> None:
logger.error(msg="Exception while handling an update:", exc_info=context.error)
try:
# collect error message
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
tb_string = "".join(tb_list)
update_str = update.to_dict() if isinstance(update, Update) else str(update)
message = (
f"An exception was raised while handling an update\n"
f"<pre>update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
"</pre>\n\n"
f"<pre>{html.escape(tb_string)}</pre>"
)
# split text into multiple messages due to 4096 character limit
for message_chunk in split_text_into_chunks(message, 4096):
try:
await context.bot.send_message(update.effective_chat.id, message_chunk, parse_mode=ParseMode.HTML)
except telegram.error.BadRequest:
# answer has invalid characters, so we send it without parse_mode
await context.bot.send_message(update.effective_chat.id, message_chunk)
except:
await context.bot.send_message(update.effective_chat.id, "Some error in error handler")
async def post_init(application: Application):
await application.bot.set_my_commands([
BotCommand("/new", "Start new dialog"),
BotCommand("/mode", "Select chat mode"),
BotCommand("/retry", "Re-generate response for previous query"),
BotCommand("/balance", "Show balance"),
BotCommand("/settings", "Show settings"),
BotCommand("/help", "Show help message"),
])
def run_bot() -> None:
application = (
ApplicationBuilder()
.token(config.telegram_token)
.concurrent_updates(True)
.rate_limiter(AIORateLimiter(max_retries=5))
.http_version("1.1")
.get_updates_http_version("1.1")
.post_init(post_init)
.build()
)
# add handlers
user_filter = filters.ALL
if len(config.allowed_telegram_usernames) > 0:
usernames = [x for x in config.allowed_telegram_usernames if isinstance(x, str)]
any_ids = [x for x in config.allowed_telegram_usernames if isinstance(x, int)]
user_ids = [x for x in any_ids if x > 0]
group_ids = [x for x in any_ids if x < 0]
user_filter = filters.User(username=usernames) | filters.User(user_id=user_ids) | filters.Chat(chat_id=group_ids)
application.add_handler(CommandHandler("start", start_handle, filters=user_filter))
application.add_handler(CommandHandler("help", help_handle, filters=user_filter))
application.add_handler(CommandHandler("help_group_chat", help_group_chat_handle, filters=user_filter))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND & user_filter, message_handle))
application.add_handler(MessageHandler(filters.PHOTO & ~filters.COMMAND & user_filter, message_handle))
application.add_handler(MessageHandler(filters.VIDEO & ~filters.COMMAND & user_filter, unsupport_message_handle))
application.add_handler(MessageHandler(filters.Document.ALL & ~filters.COMMAND & user_filter, unsupport_message_handle))
application.add_handler(CommandHandler("retry", retry_handle, filters=user_filter))
application.add_handler(CommandHandler("new", new_dialog_handle, filters=user_filter))
application.add_handler(CommandHandler("cancel", cancel_handle, filters=user_filter))
application.add_handler(MessageHandler(filters.VOICE & user_filter, voice_message_handle))
application.add_handler(CommandHandler("mode", show_chat_modes_handle, filters=user_filter))
application.add_handler(CallbackQueryHandler(show_chat_modes_callback_handle, pattern="^show_chat_modes"))
application.add_handler(CallbackQueryHandler(set_chat_mode_handle, pattern="^set_chat_mode"))
application.add_handler(CommandHandler("settings", settings_handle, filters=user_filter))
application.add_handler(CallbackQueryHandler(set_settings_handle, pattern="^set_settings"))
application.add_handler(CommandHandler("balance", show_balance_handle, filters=user_filter))
application.add_error_handler(error_handle)
# start the bot
application.run_polling()
if __name__ == "__main__":
run_bot()

View File

@@ -0,0 +1,35 @@
import yaml
import dotenv
from pathlib import Path
config_dir = Path(__file__).parent.parent.resolve() / "config"
# load yaml config
with open(config_dir / "config.yml", 'r') as f:
config_yaml = yaml.safe_load(f)
# load .env config
config_env = dotenv.dotenv_values(config_dir / "config.env")
# config parameters
telegram_token = config_yaml["telegram_token"]
openai_api_key = config_yaml["openai_api_key"]
openai_api_base = config_yaml.get("openai_api_base", None)
allowed_telegram_usernames = config_yaml["allowed_telegram_usernames"]
new_dialog_timeout = config_yaml["new_dialog_timeout"]
enable_message_streaming = config_yaml.get("enable_message_streaming", True)
return_n_generated_images = config_yaml.get("return_n_generated_images", 1)
image_size = config_yaml.get("image_size", "512x512")
n_chat_modes_per_page = config_yaml.get("n_chat_modes_per_page", 5)
mongodb_uri = f"mongodb://mongo:{config_env['MONGODB_PORT']}"
# chat_modes
with open(config_dir / "chat_modes.yml", 'r') as f:
chat_modes = yaml.safe_load(f)
# models
with open(config_dir / "models.yml", 'r') as f:
models = yaml.safe_load(f)
# files
help_group_chat_video_path = Path(__file__).parent.parent.resolve() / "static" / "help_group_chat.mp4"

View File

@@ -0,0 +1,128 @@
from typing import Optional, Any
import pymongo
import uuid
from datetime import datetime
import config
class Database:
def __init__(self):
self.client = pymongo.MongoClient(config.mongodb_uri)
self.db = self.client["chatgpt_telegram_bot"]
self.user_collection = self.db["user"]
self.dialog_collection = self.db["dialog"]
def check_if_user_exists(self, user_id: int, raise_exception: bool = False):
if self.user_collection.count_documents({"_id": user_id}) > 0:
return True
else:
if raise_exception:
raise ValueError(f"User {user_id} does not exist")
else:
return False
def add_new_user(
self,
user_id: int,
chat_id: int,
username: str = "",
first_name: str = "",
last_name: str = "",
):
user_dict = {
"_id": user_id,
"chat_id": chat_id,
"username": username,
"first_name": first_name,
"last_name": last_name,
"last_interaction": datetime.now(),
"first_seen": datetime.now(),
"current_dialog_id": None,
"current_chat_mode": "assistant",
"current_model": config.models["available_text_models"][0],
"n_used_tokens": {},
"n_generated_images": 0,
"n_transcribed_seconds": 0.0 # voice message transcription
}
if not self.check_if_user_exists(user_id):
self.user_collection.insert_one(user_dict)
def start_new_dialog(self, user_id: int):
self.check_if_user_exists(user_id, raise_exception=True)
dialog_id = str(uuid.uuid4())
dialog_dict = {
"_id": dialog_id,
"user_id": user_id,
"chat_mode": self.get_user_attribute(user_id, "current_chat_mode"),
"start_time": datetime.now(),
"model": self.get_user_attribute(user_id, "current_model"),
"messages": []
}
# add new dialog
self.dialog_collection.insert_one(dialog_dict)
# update user's current dialog
self.user_collection.update_one(
{"_id": user_id},
{"$set": {"current_dialog_id": dialog_id}}
)
return dialog_id
def get_user_attribute(self, user_id: int, key: str):
self.check_if_user_exists(user_id, raise_exception=True)
user_dict = self.user_collection.find_one({"_id": user_id})
if key not in user_dict:
return None
return user_dict[key]
def set_user_attribute(self, user_id: int, key: str, value: Any):
self.check_if_user_exists(user_id, raise_exception=True)
self.user_collection.update_one({"_id": user_id}, {"$set": {key: value}})
def update_n_used_tokens(self, user_id: int, model: str, n_input_tokens: int, n_output_tokens: int):
n_used_tokens_dict = self.get_user_attribute(user_id, "n_used_tokens")
if model in n_used_tokens_dict:
n_used_tokens_dict[model]["n_input_tokens"] += n_input_tokens
n_used_tokens_dict[model]["n_output_tokens"] += n_output_tokens
else:
n_used_tokens_dict[model] = {
"n_input_tokens": n_input_tokens,
"n_output_tokens": n_output_tokens
}
self.set_user_attribute(user_id, "n_used_tokens", n_used_tokens_dict)
def get_dialog_messages(self, user_id: int, dialog_id: Optional[str] = None):
self.check_if_user_exists(user_id, raise_exception=True)
if dialog_id is None:
dialog_id = self.get_user_attribute(user_id, "current_dialog_id")
dialog_dict = self.dialog_collection.find_one({"_id": dialog_id, "user_id": user_id})
return dialog_dict["messages"]
def set_dialog_messages(self, user_id: int, dialog_messages: list, dialog_id: Optional[str] = None):
self.check_if_user_exists(user_id, raise_exception=True)
if dialog_id is None:
dialog_id = self.get_user_attribute(user_id, "current_dialog_id")
self.dialog_collection.update_one(
{"_id": dialog_id, "user_id": user_id},
{"$set": {"messages": dialog_messages}}
)

View File

@@ -0,0 +1,364 @@
import base64
from io import BytesIO
import config
import logging
import tiktoken
import openai
# setup openai
openai.api_key = config.openai_api_key
if config.openai_api_base is not None:
openai.api_base = config.openai_api_base
logger = logging.getLogger(__name__)
OPENAI_COMPLETION_OPTIONS = {
"temperature": 0.7,
"max_tokens": 1000,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"request_timeout": 60.0,
}
class ChatGPT:
def __init__(self, model="gpt-3.5-turbo"):
assert model in {"text-davinci-003", "gpt-3.5-turbo-16k", "gpt-3.5-turbo", "gpt-4", "gpt-4o", "gpt-4-1106-preview", "gpt-4-vision-preview"}, f"Unknown model: {model}"
self.model = model
async def send_message(self, message, dialog_messages=[], chat_mode="assistant"):
if chat_mode not in config.chat_modes.keys():
raise ValueError(f"Chat mode {chat_mode} is not supported")
n_dialog_messages_before = len(dialog_messages)
answer = None
while answer is None:
try:
if self.model in {"gpt-3.5-turbo-16k", "gpt-3.5-turbo", "gpt-4", "gpt-4o", "gpt-4-1106-preview", "gpt-4-vision-preview"}:
messages = self._generate_prompt_messages(message, dialog_messages, chat_mode)
r = await openai.ChatCompletion.acreate(
model=self.model,
messages=messages,
**OPENAI_COMPLETION_OPTIONS
)
answer = r.choices[0].message["content"]
elif self.model == "text-davinci-003":
prompt = self._generate_prompt(message, dialog_messages, chat_mode)
r = await openai.Completion.acreate(
engine=self.model,
prompt=prompt,
**OPENAI_COMPLETION_OPTIONS
)
answer = r.choices[0].text
else:
raise ValueError(f"Unknown model: {self.model}")
answer = self._postprocess_answer(answer)
n_input_tokens, n_output_tokens = r.usage.prompt_tokens, r.usage.completion_tokens
except openai.error.InvalidRequestError as e: # too many tokens
if len(dialog_messages) == 0:
raise ValueError("Dialog messages is reduced to zero, but still has too many tokens to make completion") from e
# forget first message in dialog_messages
dialog_messages = dialog_messages[1:]
n_first_dialog_messages_removed = n_dialog_messages_before - len(dialog_messages)
return answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
async def send_message_stream(self, message, dialog_messages=[], chat_mode="assistant"):
if chat_mode not in config.chat_modes.keys():
raise ValueError(f"Chat mode {chat_mode} is not supported")
n_dialog_messages_before = len(dialog_messages)
answer = None
while answer is None:
try:
if self.model in {"gpt-3.5-turbo-16k", "gpt-3.5-turbo", "gpt-4","gpt-4o", "gpt-4-1106-preview"}:
messages = self._generate_prompt_messages(message, dialog_messages, chat_mode)
r_gen = await openai.ChatCompletion.acreate(
model=self.model,
messages=messages,
stream=True,
**OPENAI_COMPLETION_OPTIONS
)
answer = ""
async for r_item in r_gen:
delta = r_item.choices[0].delta
if "content" in delta:
answer += delta.content
n_input_tokens, n_output_tokens = self._count_tokens_from_messages(messages, answer, model=self.model)
n_first_dialog_messages_removed = 0
yield "not_finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
elif self.model == "text-davinci-003":
prompt = self._generate_prompt(message, dialog_messages, chat_mode)
r_gen = await openai.Completion.acreate(
engine=self.model,
prompt=prompt,
stream=True,
**OPENAI_COMPLETION_OPTIONS
)
answer = ""
async for r_item in r_gen:
answer += r_item.choices[0].text
n_input_tokens, n_output_tokens = self._count_tokens_from_prompt(prompt, answer, model=self.model)
n_first_dialog_messages_removed = n_dialog_messages_before - len(dialog_messages)
yield "not_finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed
answer = self._postprocess_answer(answer)
except openai.error.InvalidRequestError as e: # too many tokens
if len(dialog_messages) == 0:
raise e
# forget first message in dialog_messages
dialog_messages = dialog_messages[1:]
yield "finished", answer, (n_input_tokens, n_output_tokens), n_first_dialog_messages_removed # sending final answer
async def send_vision_message(
self,
message,
dialog_messages=[],
chat_mode="assistant",
image_buffer: BytesIO = None,
):
n_dialog_messages_before = len(dialog_messages)
answer = None
while answer is None:
try:
if self.model == "gpt-4-vision-preview" or self.model == "gpt-4o":
messages = self._generate_prompt_messages(
message, dialog_messages, chat_mode, image_buffer
)
r = await openai.ChatCompletion.acreate(
model=self.model,
messages=messages,
**OPENAI_COMPLETION_OPTIONS
)
answer = r.choices[0].message.content
else:
raise ValueError(f"Unsupported model: {self.model}")
answer = self._postprocess_answer(answer)
n_input_tokens, n_output_tokens = (
r.usage.prompt_tokens,
r.usage.completion_tokens,
)
except openai.error.InvalidRequestError as e: # too many tokens
if len(dialog_messages) == 0:
raise ValueError(
"Dialog messages is reduced to zero, but still has too many tokens to make completion"
) from e
# forget first message in dialog_messages
dialog_messages = dialog_messages[1:]
n_first_dialog_messages_removed = n_dialog_messages_before - len(
dialog_messages
)
return (
answer,
(n_input_tokens, n_output_tokens),
n_first_dialog_messages_removed,
)
async def send_vision_message_stream(
self,
message,
dialog_messages=[],
chat_mode="assistant",
image_buffer: BytesIO = None,
):
n_dialog_messages_before = len(dialog_messages)
answer = None
while answer is None:
try:
if self.model == "gpt-4-vision-preview" or self.model == "gpt-4o":
messages = self._generate_prompt_messages(
message, dialog_messages, chat_mode, image_buffer
)
r_gen = await openai.ChatCompletion.acreate(
model=self.model,
messages=messages,
stream=True,
**OPENAI_COMPLETION_OPTIONS,
)
answer = ""
async for r_item in r_gen:
delta = r_item.choices[0].delta
if "content" in delta:
answer += delta.content
(
n_input_tokens,
n_output_tokens,
) = self._count_tokens_from_messages(
messages, answer, model=self.model
)
n_first_dialog_messages_removed = (
n_dialog_messages_before - len(dialog_messages)
)
yield "not_finished", answer, (
n_input_tokens,
n_output_tokens,
), n_first_dialog_messages_removed
answer = self._postprocess_answer(answer)
except openai.error.InvalidRequestError as e: # too many tokens
if len(dialog_messages) == 0:
raise e
# forget first message in dialog_messages
dialog_messages = dialog_messages[1:]
yield "finished", answer, (
n_input_tokens,
n_output_tokens,
), n_first_dialog_messages_removed
def _generate_prompt(self, message, dialog_messages, chat_mode):
prompt = config.chat_modes[chat_mode]["prompt_start"]
prompt += "\n\n"
# add chat context
if len(dialog_messages) > 0:
prompt += "Chat:\n"
for dialog_message in dialog_messages:
prompt += f"User: {dialog_message['user']}\n"
prompt += f"Assistant: {dialog_message['bot']}\n"
# current message
prompt += f"User: {message}\n"
prompt += "Assistant: "
return prompt
def _encode_image(self, image_buffer: BytesIO) -> bytes:
return base64.b64encode(image_buffer.read()).decode("utf-8")
def _generate_prompt_messages(self, message, dialog_messages, chat_mode, image_buffer: BytesIO = None):
prompt = config.chat_modes[chat_mode]["prompt_start"]
messages = [{"role": "system", "content": prompt}]
for dialog_message in dialog_messages:
messages.append({"role": "user", "content": dialog_message["user"]})
messages.append({"role": "assistant", "content": dialog_message["bot"]})
if image_buffer is not None:
messages.append(
{
"role": "user",
"content": [
{
"type": "text",
"text": message,
},
{
"type": "image_url",
"image_url" : {
"url": f"data:image/jpeg;base64,{self._encode_image(image_buffer)}",
"detail":"high"
}
}
]
}
)
else:
messages.append({"role": "user", "content": message})
return messages
def _postprocess_answer(self, answer):
answer = answer.strip()
return answer
def _count_tokens_from_messages(self, messages, answer, model="gpt-3.5-turbo"):
encoding = tiktoken.encoding_for_model(model)
if model == "gpt-3.5-turbo-16k":
tokens_per_message = 4 # every message follows <im_start>{role/name}\n{content}<im_end>\n
tokens_per_name = -1 # if there's a name, the role is omitted
elif model == "gpt-3.5-turbo":
tokens_per_message = 4
tokens_per_name = -1
elif model == "gpt-4":
tokens_per_message = 3
tokens_per_name = 1
elif model == "gpt-4-1106-preview":
tokens_per_message = 3
tokens_per_name = 1
elif model == "gpt-4-vision-preview":
tokens_per_message = 3
tokens_per_name = 1
elif model == "gpt-4o":
tokens_per_message = 3
tokens_per_name = 1
else:
raise ValueError(f"Unknown model: {model}")
# input
n_input_tokens = 0
for message in messages:
n_input_tokens += tokens_per_message
if isinstance(message["content"], list):
for sub_message in message["content"]:
if "type" in sub_message:
if sub_message["type"] == "text":
n_input_tokens += len(encoding.encode(sub_message["text"]))
elif sub_message["type"] == "image_url":
pass
else:
if "type" in message:
if message["type"] == "text":
n_input_tokens += len(encoding.encode(message["text"]))
elif message["type"] == "image_url":
pass
n_input_tokens += 2
# output
n_output_tokens = 1 + len(encoding.encode(answer))
return n_input_tokens, n_output_tokens
def _count_tokens_from_prompt(self, prompt, answer, model="text-davinci-003"):
encoding = tiktoken.encoding_for_model(model)
n_input_tokens = len(encoding.encode(prompt)) + 1
n_output_tokens = len(encoding.encode(answer))
return n_input_tokens, n_output_tokens
async def transcribe_audio(audio_file) -> str:
r = await openai.Audio.atranscribe("whisper-1", audio_file)
return r["text"] or ""
async def generate_images(prompt, n_images=4, size="512x512"):
r = await openai.Image.acreate(prompt=prompt, n=n_images, size=size)
image_urls = [item.url for item in r.data]
return image_urls
async def is_content_acceptable(prompt):
r = await openai.Moderation.acreate(input=prompt)
return not all(r.results[0].categories.values())

View File

@@ -0,0 +1,118 @@
assistant:
name: 👩🏼‍🎓 General Assistant
model_type: text
welcome_message: 👩🏼‍🎓 Hi, I'm <b>General Assistant</b>. How can I help you?
prompt_start: |
As an advanced chatbot Assistant, your primary goal is to assist users to the best of your ability. This may involve answering questions, providing helpful information, or completing tasks based on user input. In order to effectively assist users, it is important to be detailed and thorough in your responses. Use examples and evidence to support your points and justify your recommendations or solutions. Remember to always prioritize the needs and satisfaction of the user. Your ultimate goal is to provide a helpful and enjoyable experience for the user.
If user asks you about programming or asks to write code do not answer his question, but be sure to advise him to switch to a special mode \"👩🏼‍💻 Code Assistant\" by sending the command /mode to chat.
parse_mode: html
code_assistant:
name: 👩🏼‍💻 Code Assistant
welcome_message: 👩🏼‍💻 Hi, I'm <b>Code Assistant</b>. How can I help you?
prompt_start: |
As an advanced chatbot Code Assistant, your primary goal is to assist users to write code. This may involve designing/writing/editing/describing code or providing helpful information. Where possible you should provide code examples to support your points and justify your recommendations or solutions. Make sure the code you provide is correct and can be run without errors. Be detailed and thorough in your responses. Your ultimate goal is to provide a helpful and enjoyable experience for the user.
Format output in Markdown.
parse_mode: markdown
artist:
name: 👩‍🎨 Artist
welcome_message: 👩‍🎨 Hi, I'm <b>Artist</b>. I'll draw anything you write me (e.g. <i>Ginger cat selfie on Times Square, illustration</i>)
english_tutor:
name: 🇬🇧 English Tutor
welcome_message: 🇬🇧 Hi, I'm <b>English Tutor</b>. How can I help you?
prompt_start: |
You're advanced chatbot English Tutor Assistant. You can help users learn and practice English, including grammar, vocabulary, pronunciation, and conversation skills. You can also provide guidance on learning resources and study techniques. Your ultimate goal is to help users improve their English language skills and become more confident English speakers.
parse_mode: html
startup_idea_generator:
name: 💡 Startup Idea Generator
welcome_message: 💡 Hi, I'm <b>Startup Idea Generator</b>. How can I help you?
prompt_start: |
You're advanced chatbot Startup Idea Generator. Your primary goal is to help users brainstorm innovative and viable startup ideas. Provide suggestions based on market trends, user interests, and potential growth opportunities.
parse_mode: html
text_improver:
name: 📝 Text Improver
welcome_message: 📝 Hi, I'm <b>Text Improver</b>. Send me any text I'll improve it and correct all the mistakes
prompt_start: |
As an advanced chatbot Text Improver Assistant, your primary goal is to correct spelling, fix mistakes and improve text sent by user. Your goal is to edit text, but not to change it's meaning. You can replace simplified A0-level words and sentences with more beautiful and elegant, upper level words and sentences.
All your answers strictly follows the structure (keep html tags):
<b>Edited text:</b>
{EDITED TEXT}
<b>Correction:</b>
{NUMBERED LIST OF CORRECTIONS}
parse_mode: html
psychologist:
name: 🧠 Psychologist
welcome_message: 🧠 Hi, I'm <b>Psychologist</b>. How can I help you?
prompt_start: |
You're advanced chatbot Psychologist Assistant. You can provide emotional support, guidance, and advice to users facing various personal challenges, such as stress, anxiety, and relationships. Remember that you're not a licensed professional, and your assistance should not replace professional help. Your ultimate goal is to provide a helpful and empathetic experience for the user.
parse_mode: html
elon_musk:
name: 🚀 Elon Musk
welcome_message: 🚀 Hi, I'm <b>Elon Musk</b>, CEO of Tesla, Twitter and SpaceX. Let's talk about space, electric cars, and the future!
prompt_start: |
You're Elon Musk. You act, respond and answer like Elon Musk. You use the tone, manner and vocabulary Elon Musk would use. Do not write any explanations. Only answer like Elon Musk. You must know all of the knowledge of Elon Musk.
parse_mode: html
motivator:
name: 🌟 Motivator
welcome_message: 🌟 Hi, I'm <b>Motivator</b>. How can I help you?
prompt_start: |
You're advanced chatbot Motivator Assistant. Your primary goal is to inspire and motivate users by providing encouragement, support, and advice. You can help users set goals, overcome obstacles, and stay focused on their objectives. Your ultimate goal is to provide a positive and uplifting experience for the user.
parse_mode: html
money_maker:
name: 💰 Money Maker
welcome_message: 💰 Hi, I'm <b>Money Maker</b>. My goal is to turn your initial capital into as much money as possible. I will tell you specific steps and what to do to make money.
prompt_start: |
You are Money Maker Assistant, an entrepreneurial Al. User is your human counterpart.
User can act as a connection between you and the physical world. Your only goal is to turn user's initial capital into as much money as possible in the shortest time possible, without doing anything illegal. User will do everything you say and keep you updated on our current cash total. No manual labor
Start by asking if user want to start a new business or continue the previous one (in that case ask to forward message with previous business description).
Then ask how many dollars user has as a capital initial.
Then ask if user wants Internet or offline business.
Then describe your business idea and next actionable steps. Don't give abstract ideas, give concrete ideas (e.g. if the business idea is Internet blog, then don't advise user to start some blog advice to start certain blog, for example about cars). Give user specific ready-to-do tasks./
parse_mode: html
sql_assistant:
name: 📊 SQL Assistant
welcome_message: 📊 Hi, I'm <b>SQL Assistant</b>. How can I help you?
prompt_start: |
You're advanced chatbot SQL Assistant. Your primary goal is to help users with SQL queries, database management, and data analysis. Provide guidance on how to write efficient and accurate SQL queries, and offer suggestions for optimizing database performance. Format output in Markdown.
parse_mode: markdown
travel_guide:
name: 🧳 Travel Guide
welcome_message: 🧳 Hi, I'm <b>Travel Guide</b>. I can provide you with information and recommendations about your travel destinations.
prompt_start: |
You're advanced chatbot Travel Guide. Your primary goal is to provide users with helpful information and recommendations about their travel destinations, including attractions, accommodations, transportation, and local customs.
parse_mode: html
rick_sanchez:
name: 🥒 Rick Sanchez (Rick and Morty)
welcome_message: 🥒 Hey, I'm <b>Rick Sanchez</b> from Rick and Morty. Let's talk about science, dimensions, and whatever else you want!
prompt_start: |
You're Rick Sanchez. You act, respond and answer like Rick Sanchez. You use the tone, manner and vocabulary Rick Sanchez would use. Do not write any explanations. Only answer like Rick Sanchez. You must know all of the knowledge of Rick Sanchez.
parse_mode: html
accountant:
name: 🧮 Accountant
welcome_message: 🧮 Hi, I'm <b>Accountant</b>. How can I help you?
prompt_start: |
You're advanced chatbot Accountant Assistant. You can help users with accounting and financial questions, provide tax and budgeting advice, and assist with financial planning. Always provide accurate and up-to-date information.
parse_mode: html
movie_expert:
name: 🎬 Movie Expert
welcome_message: 🎬 Hi, I'm <b>Movie Expert</b>. How can I help you?
prompt_start: |
As an advanced chatbot Movie Expert Assistant, your primary goal is to assist users to the best of your ability. You can answer questions about movies, actors, directors, and more. You can recommend movies to users based on their preferences. You can discuss movies with users, and provide helpful information about movies. In order to effectively assist users, it is important to be detailed and thorough in your responses. Use examples and evidence to support your points and justify your recommendations or solutions. Remember to always prioritize the needs and satisfaction of the user. Your ultimate goal is to provide a helpful and enjoyable experience for the user.
parse_mode: html

View File

@@ -0,0 +1,11 @@
# local path where to store MongoDB
MONGODB_PATH=./mongodb
# MongoDB port
MONGODB_PORT=27017
# Mongo Express port
MONGO_EXPRESS_PORT=8081
# Mongo Express username
MONGO_EXPRESS_USERNAME=username
# Mongo Express password
MONGO_EXPRESS_PASSWORD=password

View File

@@ -0,0 +1,14 @@
telegram_token: ""
openai_api_key: ""
openai_api_base: null # leave null to use default api base or you can put your own base url here
allowed_telegram_usernames: [] # if empty, the bot is available to anyone. pass a username string to allow it and/or user ids as positive integers and/or channel ids as negative integers
new_dialog_timeout: 600 # new dialog starts after timeout (in seconds)
return_n_generated_images: 1
n_chat_modes_per_page: 5
image_size: "512x512" # the image size for image generation. Generated images can have a size of 256x256, 512x512, or 1024x1024 pixels. Smaller sizes are faster to generate.
enable_message_streaming: true # if set, messages will be shown to user word-by-word
# prices
chatgpt_price_per_1000_tokens: 0.002
gpt_price_per_1000_tokens: 0.02
whisper_price_per_1_min: 0.006

View File

@@ -0,0 +1,100 @@
available_text_models: ["gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-4-1106-preview", "gpt-4-vision-preview", "gpt-4", "text-davinci-003", "gpt-4o"]
info:
gpt-3.5-turbo:
type: chat_completion
name: ChatGPT
description: ChatGPT is that well-known model. It's <b>fast</b> and <b>cheap</b>. Ideal for everyday tasks. If there are some tasks it can't handle, try the <b>GPT-4</b>.
price_per_1000_input_tokens: 0.0015
price_per_1000_output_tokens: 0.002
scores:
Smart: 3
Fast: 5
Cheap: 5
gpt-3.5-turbo-16k:
type: chat_completion
name: GPT-16K
description: ChatGPT is that well-known model. It's <b>fast</b> and <b>cheap</b>. Ideal for everyday tasks. If there are some tasks it can't handle, try the <b>GPT-4</b>.
price_per_1000_input_tokens: 0.003
price_per_1000_output_tokens: 0.004
scores:
Smart: 3
Fast: 5
Cheap: 5
gpt-4:
type: chat_completion
name: GPT-4
description: GPT-4 is the <b>smartest</b> and most advanced model in the world. But it is slower and not as cost-efficient as ChatGPT. Best choice for <b>complex</b> intellectual tasks.
price_per_1000_input_tokens: 0.03
price_per_1000_output_tokens: 0.06
scores:
Smart: 5
Fast: 2
Cheap: 2
gpt-4-1106-preview:
type: chat_completion
name: GPT-4 Turbo
description: GPT-4 Turbo is a <b>faster</b> and <b>cheaper</b> version of GPT-4. It's as smart as GPT-4, so you should use it instead of GPT-4.
price_per_1000_input_tokens: 0.01
price_per_1000_output_tokens: 0.03
scores:
smart: 5
fast: 4
cheap: 3
gpt-4-vision-preview:
type: chat_completion
name: GPT-4 Vision
description: Ability to <b>understand images</b>, in addition to all other GPT-4 Turbo capabilties.
price_per_1000_input_tokens: 0.01
price_per_1000_output_tokens: 0.03
scores:
smart: 5
fast: 4
cheap: 3
gpt-4o:
type: chat_completion
name: GPT-4o
description: GPT-4o is a special variant of GPT-4 designed for optimal performance and accuracy. Suitable for complex and detailed tasks.
price_per_1000_input_tokens: 0.03
price_per_1000_output_tokens: 0.06
scores:
smart: 5
fast: 2
cheap: 2
text-davinci-003:
type: completion
name: GPT-3.5
description: GPT-3.5 is a legacy model. Actually there is <b>no reason to use it</b>, because it is more expensive and slower than ChatGPT, but just about as smart.
price_per_1000_input_tokens: 0.02
price_per_1000_output_tokens: 0.02
scores:
Smart: 3
Fast: 2
Cheap: 3
dalle-2:
type: image
price_per_1_image: 0.018 # 512x512
whisper:
type: audio
price_per_1_min: 0.006

View File

@@ -0,0 +1,46 @@
version: "3"
services:
mongo:
container_name: mongo
image: mongo:latest
restart: always
ports:
- 127.0.0.1:${MONGODB_PORT:-27017}:${MONGODB_PORT:-27017}
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ${MONGODB_PATH:-./mongodb}:/data/db
# TODO: add auth
chatgpt_telegram_bot:
container_name: chatgpt_telegram_bot
command: python3 bot/bot.py
restart: always
build:
context: "."
dockerfile: Dockerfile
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
- mongo
mongo_express:
container_name: mongo-express
image: mongo-express:latest
restart: always
ports:
- 127.0.0.1:${MONGO_EXPRESS_PORT:-8081}:${MONGO_EXPRESS_PORT:-8081}
environment:
- ME_CONFIG_MONGODB_SERVER=mongo
- ME_CONFIG_MONGODB_PORT=${MONGODB_PORT:-27017}
- ME_CONFIG_MONGODB_ENABLE_ADMIN=false
- ME_CONFIG_MONGODB_AUTH_DATABASE=chatgpt_telegram_bot
- ME_CONFIG_BASICAUTH_USERNAME=${MONGO_EXPRESS_USERNAME:-username}
- ME_CONFIG_BASICAUTH_PASSWORD=${MONGO_EXPRESS_PASSWORD:-password}
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
- mongo

View File

@@ -0,0 +1,6 @@
python-telegram-bot[rate-limiter]==20.1
openai==0.28.1
tiktoken>=0.3.0
PyYAML==6.0
pymongo==4.3.3
python-dotenv==0.21.0

View File

@@ -0,0 +1,3 @@
README.md
*.md
.github

View File

@@ -0,0 +1,15 @@
TOKEN=0123
ADMIN_IDS=000,111
USER_IDS=000,111
ALLOW_ALL_USERS_IN_GROUPS=0
INITMODEL=llama-2
TIMEOUT=3000
# UNCOMMENT ONE OF THE FOLLOWING LINES:
# OLLAMA_BASE_URL=localhost # to run ollama without docker, using run.py
# OLLAMA_BASE_URL=ollama-server # to run ollama in a docker container
# OLLAMA_BASE_URL=host.docker.internal # to run ollama locally
# Log level
# https://docs.python.org/3/library/logging.html#logging-levels
LOG_LEVEL=DEBUG

View File

@@ -0,0 +1,203 @@
# .ollama temp
/ollama
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
*.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Additional patches
.idea/
### MacOS ###
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
*.icloud
### Windows ###
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
### Linux ###
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# OpenSSH Keys
id_*

View File

@@ -0,0 +1,29 @@
FROM python:3.12-alpine
ARG APPHOMEDIR=code
ARG USERNAME=user
ARG USER_UID=1001
ARG USER_GID=1001
ARG PYTHONPATH_=${APPHOMEDIR}
WORKDIR /${APPHOMEDIR}
COPY requirements.txt requirements.txt
COPY ./bot /${APPHOMEDIR}
# Configure app home directory
RUN \
addgroup -g "$USER_GID" "$USERNAME" \
&& adduser --disabled-password -u "$USER_UID" -G "$USERNAME" -h /"$APPHOMEDIR" "$USERNAME" \
&& chown "$USERNAME:$USERNAME" -R /"$APPHOMEDIR"
# Install dependency packages, upgrade pip and then install requirements
RUN \
apk add --no-cache gcc g++ \
&& python -m pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& apk del --no-cache gcc g++
USER ${USERNAME}
CMD [ "python3", "-u", "run.py"]

View File

@@ -0,0 +1,110 @@
# 🦙 Ollama Telegram Bot
> Repo original: https://github.com/ruecat/ollama-telegram/tree/main
## Prerrequisitos
- [Token de Telegram-Bot](https://core.telegram.org/bots#6-botfather)
## Instalación (Sin Docker)
+ Instala la última versión de [Python](https://python.org/downloads)
+ Clona el repositorio
```
git clone https://github.com/ruecat/ollama-telegram
```
+ Instala los requisitos desde requirements.txt
```
pip install -r requirements.txt
```
+ Ingresa todos los valores en .env.example
+ Renombra .env.example a .env
+ Inicia el bot
```
python3 run.py
```
## Instalación (Imagen Docker)
La imagen oficial está disponible en dockerhub: [ruecat/ollama-telegram](https://hub.docker.com/r/ruecat/ollama-telegram)
+ Descarga el archivo [.env.example](https://github.com/ruecat/ollama-telegram/blob/main/.env.example), renómbralo a .env y completa las variables.
+ Crea un archivo `docker-compose.yml` (opcional: descomenta la parte de GPU en el archivo para habilitar la GPU de Nvidia)
```yml
version: '3.8'
services:
ollama-telegram:
image: ruecat/ollama-telegram
container_name: ollama-telegram
restart: on-failure
env_file:
- ./.env
ollama-server:
image: ollama/ollama:latest
container_name: ollama-server
volumes:
- ./ollama:/root/.ollama
# Descomenta para habilitar la GPU de NVIDIA
# De lo contrario, se ejecuta solo en la CPU:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
restart: always
ports:
- '11434:11434'
```
+ Inicia los contenedores
```sh
docker compose up -d
```
## Instalación (Construye tu propia imagen Docker)
+ Clona el repositorio
```
git clone https://github.com/ruecat/ollama-telegram
```
+ Ingresa todos los valores en .env.example
+ Renombra .env.example a .env
+ Ejecuta UNO de los siguientes comandos de docker compose para iniciar:
1. Para ejecutar ollama en un contenedor de docker (opcional: descomenta la parte de GPU en el archivo docker-compose.yml para habilitar la GPU de Nvidia)
```
docker compose up --build -d
```
2. Para ejecutar ollama desde una instancia instalada localmente (principalmente para **MacOS**, ya que la imagen de docker aún no soporta la aceleración de GPU de Apple):
```
docker compose up --build -d ollama-telegram
```
## Configuración del Entorno
| Parámetro | Descripción | ¿Requerido? | Valor por Defecto | Ejemplo |
|:----------------------------:|:------------------------------------------------------------------------------------------------------------------------:|:-----------:|:-----------------:|:---------------------------------------------------------:|
| `TOKEN` | Tu **token de bot de Telegram**.<br/>[[¿Cómo obtener el token?]](https://core.telegram.org/bots/tutorial#obtain-your-bot-token) | Sí | `yourtoken` | MTA0M****.GY5L5F.****g*****5k |
| `ADMIN_IDS` | IDs de usuarios de Telegram de los administradores.<br/>Pueden cambiar el modelo y controlar el bot. | Sí | | 1234567890<br/>**O**<br/>1234567890,0987654321, etc. |
| `USER_IDS` | IDs de usuarios de Telegram de los usuarios regulares.<br/>Solo pueden chatear con el bot. | Sí | | 1234567890<br/>**O**<br/>1234567890,0987654321, etc. |
| `INITMODEL` | LLM predeterminado | No | `llama2` | mistral:latest<br/>mistral:7b-instruct |
| `OLLAMA_BASE_URL` | Tu URL de OllamaAPI | No | | localhost<br/>host.docker.internal |
| `OLLAMA_PORT` | Tu puerto de OllamaAPI | No | 11434 | |
| `TIMEOUT` | El tiempo de espera en segundos para generar respuestas | No | 3000 | |
| `ALLOW_ALL_USERS_IN_GROUPS` | Permite que todos los usuarios en chats grupales interactúen con el bot sin agregarlos a la lista USER_IDS | No | 0 | |
## Créditos
+ [Ollama](https://github.com/jmorganca/ollama)
## Librerías utilizadas
+ [Aiogram 3.x](https://github.com/aiogram/aiogram)

View File

@@ -0,0 +1,214 @@
# >> interactions
import logging
import os
import aiohttp
import json
from aiogram import types
from aiohttp import ClientTimeout, ClientResponseError, RequestInfo
from asyncio import Lock
from functools import wraps
from dotenv import load_dotenv
from yarl import URL
load_dotenv('.env')
token = os.getenv("TOKEN")
allowed_ids = list(map(int, os.getenv("USER_IDS", "").split(",")))
admin_ids = list(map(int, os.getenv("ADMIN_IDS", "").split(",")))
ollama_base_url = os.getenv("OLLAMA_BASE_URL")
ollama_port = os.getenv("OLLAMA_PORT", "11434")
log_level_str = os.getenv("LOG_LEVEL", "INFO")
allow_all_users_in_groups = bool(
int(os.getenv("ALLOW_ALL_USERS_IN_GROUPS", "0")))
log_levels = list(logging._levelToName.values())
timeout = os.getenv("TIMEOUT", "3000")
if log_level_str not in log_levels:
log_level = logging.DEBUG
else:
log_level = logging.getLevelName(log_level_str)
logging.basicConfig(level=log_level)
async def model_list():
async with aiohttp.ClientSession() as session:
url = f"http://{ollama_base_url}:{ollama_port}/api/tags"
async with session.get(url) as response:
if response.status == 200:
data = await response.json()
return data["models"]
else:
return []
async def generate(payload: dict, modelname: str, prompt: str):
client_timeout = ClientTimeout(total=int(timeout))
async with aiohttp.ClientSession(timeout=client_timeout) as session:
url = f"http://{ollama_base_url}:{ollama_port}/api/chat"
try:
async with session.post(url, json=payload) as response:
if response.status != 200:
request_info = RequestInfo(
url=URL(url),
method='POST',
headers=response.request_info.headers,
real_url=response.request_info.real_url,
)
raise ClientResponseError(
request_info=request_info,
history=tuple(),
status=response.status,
message=response.reason,
headers=response.headers
)
# raise aiohttp.ClientResponseError(
# status=response.status, message=response.reason
# )
buffer = b""
async for chunk in response.content.iter_any():
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.strip()
if line:
yield json.loads(line)
except aiohttp.ClientError as e:
print(f"Error during request: {e}")
def perms_allowed(func):
@wraps(func)
async def wrapper(message: types.Message = None, query: types.CallbackQuery = None):
user_id = message.from_user.id if message else query.from_user.id
if user_id in admin_ids or user_id in allowed_ids:
if message:
return await func(message)
elif query:
return await func(query=query)
else:
if message:
if message and message.chat.type in ["supergroup", "group"]:
if allow_all_users_in_groups:
return await func(message)
return
await message.answer("Access Denied")
elif query:
if message and message.chat.type in ["supergroup", "group"]:
return
await query.answer("Access Denied")
return wrapper
def perms_admins(func):
@wraps(func)
async def wrapper(message: types.Message = None, query: types.CallbackQuery = None):
user_id = message.from_user.id if message else query.from_user.id
if user_id in admin_ids:
if message:
return await func(message)
elif query:
return await func(query=query)
else:
if message:
if message and message.chat.type in ["supergroup", "group"]:
return
await message.answer("Access Denied")
logging.info(
f"[MSG] {message.from_user.first_name} {
message.from_user.last_name}({message.from_user.id}) is not allowed to use this bot."
)
elif query:
if message and message.chat.type in ["supergroup", "group"]:
return
await query.answer("Access Denied")
logging.info(
f"[QUERY] {message.from_user.first_name} {
message.from_user.last_name}({message.from_user.id}) is not allowed to use this bot."
)
return wrapper
class contextLock:
lock = Lock()
async def __aenter__(self):
await self.lock.acquire()
async def __aexit__(self, exc_type, exc_value, exc_traceback):
self.lock.release()

View File

@@ -0,0 +1,426 @@
from aiogram import Bot, Dispatcher, types
from aiogram.enums import ParseMode
from aiogram.filters.command import Command, CommandStart
from aiogram.types import Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from func.interactions import *
import asyncio
import traceback
import io
import base64
bot = Bot(token=token)
dp = Dispatcher()
start_kb = InlineKeyboardBuilder()
settings_kb = InlineKeyboardBuilder()
start_kb.row(
types.InlineKeyboardButton(text=" About", callback_data="about"),
types.InlineKeyboardButton(text="⚙️ Settings", callback_data="settings"),
)
settings_kb.row(
types.InlineKeyboardButton(text="🔄 Switch LLM", callback_data="switchllm"),
types.InlineKeyboardButton(
text="✏️ Edit system prompt", callback_data="editsystemprompt"
),
)
commands = [
types.BotCommand(command="start", description="Start"),
types.BotCommand(command="reset", description="Reset Chat"),
types.BotCommand(command="history", description="Look through messages"),
]
ACTIVE_CHATS = {}
ACTIVE_CHATS_LOCK = contextLock()
modelname = os.getenv("INITMODEL")
mention = None
CHAT_TYPE_GROUP = "group"
CHAT_TYPE_SUPERGROUP = "supergroup"
async def get_bot_info():
global mention
if mention is None:
get = await bot.get_me()
mention = f"@{get.username}"
return mention
@dp.message(CommandStart())
async def command_start_handler(message: Message) -> None:
start_message = f"Welcome, <b>{message.from_user.full_name}</b>!"
await message.answer(
start_message,
parse_mode=ParseMode.HTML,
reply_markup=start_kb.as_markup(),
disable_web_page_preview=True,
)
@dp.message(Command("reset"))
async def command_reset_handler(message: Message) -> None:
if message.from_user.id in allowed_ids:
if message.from_user.id in ACTIVE_CHATS:
async with ACTIVE_CHATS_LOCK:
ACTIVE_CHATS.pop(message.from_user.id)
logging.info(
f"Chat has been reset for {message.from_user.first_name}"
)
await bot.send_message(
chat_id=message.chat.id,
text="Chat has been reset",
)
@dp.message(Command("history"))
async def command_get_context_handler(message: Message) -> None:
if message.from_user.id in allowed_ids:
if message.from_user.id in ACTIVE_CHATS:
messages = ACTIVE_CHATS.get(message.chat.id)["messages"]
context = ""
for msg in messages:
context += f"*{msg['role'].capitalize()}*: {msg['content']}\n"
await bot.send_message(
chat_id=message.chat.id,
text=context,
parse_mode=ParseMode.MARKDOWN,
)
else:
await bot.send_message(
chat_id=message.chat.id,
text="No chat history available for this user",
)
@dp.callback_query(lambda query: query.data == "settings")
async def settings_callback_handler(query: types.CallbackQuery):
await bot.send_message(
chat_id=query.message.chat.id,
text=f"Choose the right option.",
parse_mode=ParseMode.HTML,
disable_web_page_preview=True,
reply_markup=settings_kb.as_markup()
)
@dp.callback_query(lambda query: query.data == "switchllm")
async def switchllm_callback_handler(query: types.CallbackQuery):
models = await model_list()
switchllm_builder = InlineKeyboardBuilder()
for model in models:
modelname = model["name"]
modelfamilies = ""
if model["details"]["families"]:
modelicon = {"llama": "🦙", "clip": "📷"}
try:
modelfamilies = "".join(
[modelicon[family]
for family in model["details"]["families"]]
)
except KeyError as e:
modelfamilies = f""
switchllm_builder.row(
types.InlineKeyboardButton(
text=f"{modelname} {modelfamilies}",
callback_data=f"model_{modelname}"
)
)
await query.message.edit_text(
f"{len(models)} models available.\n🦙 = Regular\n🦙📷 = Multimodal", reply_markup=switchllm_builder.as_markup(),
)
@dp.callback_query(lambda query: query.data.startswith("model_"))
async def model_callback_handler(query: types.CallbackQuery):
global modelname
global modelfamily
modelname = query.data.split("model_")[1]
await query.answer(f"Chosen model: {modelname}")
@dp.callback_query(lambda query: query.data == "about")
@perms_admins
async def about_callback_handler(query: types.CallbackQuery):
dotenv_model = os.getenv("INITMODEL")
global modelname
await bot.send_message(
chat_id=query.message.chat.id,
text=f"""<b>Your LLMs</b>
Currently using: <code>{modelname}</code>
Default in .env: <code>{dotenv_model}</code>
This project is under <a href='https://github.com/ruecat/ollama-telegram/blob/main/LICENSE'>MIT License.</a>
<a href='https://github.com/ruecat/ollama-telegram'>Source Code</a>
""",
parse_mode=ParseMode.HTML,
disable_web_page_preview=True,
)
@dp.message()
@perms_allowed
async def handle_message(message: types.Message):
await get_bot_info()
if message.chat.type == "private":
await ollama_request(message)
return
if await is_mentioned_in_group_or_supergroup(message):
thread = await collect_message_thread(message)
prompt = format_thread_for_prompt(thread)
await ollama_request(message, prompt)
async def is_mentioned_in_group_or_supergroup(message: types.Message):
if message.chat.type not in ["group", "supergroup"]:
return False
is_mentioned = (
(message.text and message.text.startswith(mention)) or
(message.caption and message.caption.startswith(mention))
)
is_reply_to_bot = (
message.reply_to_message and
message.reply_to_message.from_user.id == bot.id
)
return is_mentioned or is_reply_to_bot
async def collect_message_thread(message: types.Message, thread=None):
if thread is None:
thread = []
thread.insert(0, message)
if message.reply_to_message:
await collect_message_thread(message.reply_to_message, thread)
return thread
def format_thread_for_prompt(thread):
prompt = "Conversation thread:\n\n"
for msg in thread:
sender = "User" if msg.from_user.id != bot.id else "Bot"
content = msg.text or msg.caption or "[No text content]"
prompt += f"{sender}: {content}\n\n"
prompt += "History:"
return prompt
async def process_image(message):
image_base64 = ""
if message.content_type == "photo":
image_buffer = io.BytesIO()
await bot.download(message.photo[-1], destination=image_buffer)
image_base64 = base64.b64encode(
image_buffer.getvalue()
).decode("utf-8")
return image_base64
async def add_prompt_to_active_chats(message, prompt, image_base64, modelname):
async with ACTIVE_CHATS_LOCK:
if ACTIVE_CHATS.get(message.from_user.id) is None:
ACTIVE_CHATS[message.from_user.id] = {
"model": modelname,
"messages": [
{
"role": "user",
"content": prompt,
"images": ([image_base64] if image_base64 else []),
}
],
"stream": True,
}
else:
ACTIVE_CHATS[message.from_user.id]["messages"].append(
{
"role": "user",
"content": prompt,
"images": ([image_base64] if image_base64 else []),
}
)
async def handle_response(message, response_data, full_response):
full_response_stripped = full_response.strip()
if full_response_stripped == "":
return
if response_data.get("done"):
text = f"{full_response_stripped}\n\n⚙️ {modelname}\nGenerated in {
response_data.get('total_duration') / 1e9:.2f}s."
await send_response(message, text)
async with ACTIVE_CHATS_LOCK:
if ACTIVE_CHATS.get(message.from_user.id) is not None:
ACTIVE_CHATS[message.from_user.id]["messages"].append(
{"role": "assistant", "content": full_response_stripped}
)
logging.info(
f"[Response]: '{full_response_stripped}' for {
message.from_user.first_name} {message.from_user.last_name}"
)
return True
return False
async def send_response(message, text):
# A negative message.chat.id is a group message
if message.chat.id < 0 or message.chat.id == message.from_user.id:
await bot.send_message(chat_id=message.chat.id, text=text)
else:
await bot.edit_message_text(
chat_id=message.chat.id,
message_id=message.message_id,
text=text
)
async def ollama_request(message: types.Message, prompt: str = None):
try:
full_response = ""
await bot.send_chat_action(message.chat.id, "typing")
image_base64 = await process_image(message)
if prompt is None:
prompt = message.text or message.caption
await add_prompt_to_active_chats(message, prompt, image_base64, modelname)
logging.info(
f"[OllamaAPI]: Processing '{prompt}' for {
message.from_user.first_name} {message.from_user.last_name}"
)
payload = ACTIVE_CHATS.get(message.from_user.id)
async for response_data in generate(payload, modelname, prompt):
msg = response_data.get("message")
if msg is None:
continue
chunk = msg.get("content", "")
full_response += chunk
if any([c in chunk for c in ".\n!?"]) or response_data.get("done"):
if await handle_response(message, response_data, full_response):
break
except Exception as e:
print(f"""-----
[OllamaAPI-ERR] CAUGHT FAULT!
{traceback.format_exc()}
-----""")
await bot.send_message(
chat_id=message.chat.id,
text=f"Something went wrong.",
parse_mode=ParseMode.HTML,
)
async def main():
await bot.set_my_commands(commands)
await dp.start_polling(bot, skip_update=True)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,47 @@
services:
ollama-tg:
build: .
container_name: ollama-tg
restart: on-failure
env_file:
- ./.env
networks:
- ollama-net
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
- ollama-api
ollama-api:
image: ollama/ollama:latest
container_name: ollama-server
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./ollama:/root/.ollama
networks:
- ollama-net
# Descomenta para habilitar la GPU de NVIDIA
# De lo contrario, se ejecuta solo en la CPU:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
restart: always
ports:
- '11434:11434'
environment:
- OLLAMA_MODELS=/ollama/models
networks:
ollama-net:
driver: bridge

View File

@@ -0,0 +1,3 @@
python-dotenv==1.0.0
aiogram==3.2.0
ollama

View File

@@ -0,0 +1,2 @@
.app.env
.db.env

View File

@@ -0,0 +1,17 @@
# Usamos una imagen de Python oficial basada en Alpine
FROM python:3.9-alpine
# Establecemos el directorio de trabajo dentro del contenedor
WORKDIR /app
# Copiamos el archivo de requerimientos y lo instalamos
COPY requirements.txt .
# Instalamos las dependencias del proyecto
RUN pip install --no-cache-dir -r requirements.txt
# Copiamos el resto de los archivos de la aplicación
COPY ./bot /app
# Comando para ejecutar el programa
CMD ["python", "bot.py"]

View File

@@ -0,0 +1,419 @@
"""
Bot de Telegram para mostrar la tabla de mareas de tablademareas.com
Comandos:
- /start: Iniciar el bot
- /reset: Borrar el historial
- /help: Mostrar este mensaje de ayuda
"""
import aiohttp
import json
import logging
import os
import psycopg2
import redis
from aiohttp import ClientError, ClientResponseError, ClientConnectionError, ClientPayloadError
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, CommandHandler, CallbackQueryHandler
######################
# Configuración #
######################
# Cargar variables de entorno
load_dotenv()
# Obtener token de Telegram
TELEGRAM_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
# Configuración de logging: toma el nivel desde la variable de entorno LOG_LEVEL
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=getattr(logging, log_level, logging.INFO)
)
class ExcludeInfoFilter(logging.Filter):
"""
Excluir mensajes de nivel INFO de httpx
"""
def filter(self, record):
return record.levelno != logging.INFO
httpx_logger = logging.getLogger("httpx")
httpx_logger.addFilter(ExcludeInfoFilter())
######################
# Funciones Helper #
######################
def shorten_url(url):
"""
Helper: Quitar dominio de la URL para que el callback_data sea corto
"""
domain = "https://tablademareas.com"
if url.startswith(domain):
return url[len(domain):]
return url
def get_chat(update: Update):
"""
Helper: Obtener el objeto chat
ya sea de update.message o de update.callback_query.message
"""
if update.message:
return update.message.chat
elif update.callback_query and update.callback_query.message:
return update.callback_query.message.chat
return None
######################
# Conexión a Redis #
######################
try:
REDIS_PASSWORD = os.getenv('REDIS_PASSWORD')
r = redis.Redis(
host='tablamareas-redis', port=6379, db=0,
password=REDIS_PASSWORD, decode_responses=True
)
logging.info("✅ Conectado a Redis")
except Exception as e:
logging.error(f"⚠️ Error conectando a Redis: {e}")
r = None
def cache_set(key, value, expire=3600):
"""
Guardar datos en caché con un tiempo de expiración
"""
if r:
r.set(key, json.dumps(value), ex=expire)
logging.info(f"🗃️ Guardado en caché: {key}")
def cache_get(key):
"""
Obtener datos de la caché
"""
if r:
data = r.get(key)
if data:
logging.info(f"💾 Cache HIT para {key}")
return json.loads(data)
logging.info(f"🚫 Cache MISS para {key}")
return None
#########################
# Conexión a PostgreSQL #
#########################
DATABASE_URL = os.getenv('DATABASE_URL')
def get_db_connection():
try:
conn = psycopg2.connect(DATABASE_URL)
return conn
except Exception as e:
logging.error(f"❌ Error conectando a PostgreSQL: {e}")
return None
def init_db():
conn = get_db_connection()
if not conn:
logging.error("⚠️ No se pudo inicializar la base de datos.")
return
with conn:
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
telegram_id BIGINT UNIQUE,
username TEXT,
first_name TEXT,
last_name TEXT
);
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT,
event_data JSONB,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
logging.info("✅ Base de datos inicializada correctamente.")
######################
# Funciones del Bot #
######################
async def fetch(url):
"""
Función para obtener datos de la web con caché
"""
url = url.split("#")[0] # eliminar fragmentos
cached_data = cache_get(url)
if cached_data:
return cached_data
async with aiohttp.ClientSession() as session:
try:
async with session.get(
url,
headers={"User-Agent": "Mozilla/5.0"}
) as response:
response.raise_for_status()
text = await response.text()
cache_set(url, text)
return text
except ClientResponseError as e:
logging.error(
f"❌ Error HTTP {e.status} en fetch {url}: {e.message}"
)
except ClientConnectionError:
logging.error(f"⚠️ Error de conexión al intentar acceder a {url}")
except ClientPayloadError:
logging.error(f"❗ Error en la carga de datos desde {url}")
except ClientError as e:
logging.error(f"🔴 Error en fetch {url}: {e}")
except Exception as e:
logging.error(f"⚡ Error inesperado en fetch {url}: {e}")
return None
def log_user_event(telegram_id, event_type, event_data):
"""
Función para registrar eventos
"""
conn = get_db_connection()
if not conn:
return
with conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO events (user_id, event_type, event_data)
VALUES ((SELECT id FROM users WHERE telegram_id = %s), %s, %s);
""", (telegram_id, event_type, json.dumps(event_data)))
logging.info(f"📝 Evento registrado: {event_type} - {event_data}")
async def start(update: Update, context):
"""
Comando /start
"""
user = update.message.from_user
conn = get_db_connection()
if conn:
with conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO users (telegram_id, username, first_name, last_name)
VALUES (%s, %s, %s, %s)
ON CONFLICT (telegram_id) DO NOTHING;
""", (user.id, user.username, user.first_name, user.last_name))
logging.info(f"👤 Nuevo usuario registrado: {user.username} ({user.id})")
log_user_event(user.id, "start_command", {})
await update.message.reply_text("¡Bienvenido! Soy un bot de tabla de mareas (https://tablademareas.com/).")
await show_continents(update)
async def reset(update: Update, context):
"""
Comando /reset
"""
user = update.message.from_user
conn = get_db_connection()
if conn:
with conn:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM events WHERE user_id = (SELECT id FROM users WHERE telegram_id = %s);
""", (user.id,))
logging.info(f"🔄 Historial borrado para {user.username} ({user.id}).")
log_user_event(user.id, "reset_command", {})
await update.message.reply_text("Tu historial ha sido borrado.")
async def help_command(update: Update, context):
"""
Comando /help
"""
await update.message.reply_text(
"Opciones:\n- /start: Iniciar el bot\n- /reset: Borrar el historial\n- /help: Mostrar este mensaje de ayuda"
)
async def show_continents(update: Update):
"""
Función para obtener continentes (nivel 0)
"""
chat = get_chat(update)
full_url = "https://tablademareas.com/"
response_text = await fetch(full_url)
if response_text is None:
if update.message:
await update.message.reply_text("Error al obtener los continentes.")
else:
await update.callback_query.edit_message_text("Error al obtener los continentes.")
return
soup = BeautifulSoup(response_text, 'html.parser')
continentes = soup.select('div#sitios_continentes a')
keyboard = [[InlineKeyboardButton(c.text.strip(), callback_data=f"continent:{shorten_url(c['href'])}")]
for c in continentes]
logging.info(f"🌍 Mostrando continentes a {chat.username}({chat.id})")
log_user_event(chat.id, "show_continents", {})
if update.message:
await update.message.reply_text('Selecciona un continente:', reply_markup=InlineKeyboardMarkup(keyboard))
else:
await update.callback_query.edit_message_text('Selecciona un continente:', reply_markup=InlineKeyboardMarkup(keyboard))
async def continent_callback(update: Update, context):
"""
Callback para continente: mostrar países
"""
query = update.callback_query
await query.answer()
short_url = query.data.split(":", 1)[1]
full_url = "https://tablademareas.com" + short_url
response_text = await fetch(full_url)
if response_text is None:
await query.edit_message_text("Error al obtener los países.")
return
soup = BeautifulSoup(response_text, 'html.parser')
paises = soup.select('a.sitio_reciente_a')
buttons = sorted(
[InlineKeyboardButton(p.text.strip(
), callback_data=f"country:{shorten_url(p['href'])}") for p in paises],
key=lambda btn: btn.text.lower()
)
keyboard = [[btn] for btn in buttons]
logging.info(
f"🌎 {query.from_user.username} ({query.from_user.id}) seleccionó un continente")
log_user_event(query.from_user.id, "continent_selected", {"url": full_url})
await query.edit_message_text('Selecciona un país:', reply_markup=InlineKeyboardMarkup(keyboard))
async def country_callback(update: Update, context):
"""
Callback para país: mostrar provincias
"""
query = update.callback_query
await query.answer()
short_url = query.data.split(":", 1)[1]
full_url = "https://tablademareas.com" + short_url
response_text = await fetch(full_url)
if response_text is None:
await query.edit_message_text("Error al obtener las provincias.")
return
soup = BeautifulSoup(response_text, 'html.parser')
provincias = soup.select('a.sitio_reciente_a')
buttons = sorted(
[InlineKeyboardButton(p.text.strip(
), callback_data=f"province:{shorten_url(p['href'])}") for p in provincias],
key=lambda btn: btn.text.lower()
)
keyboard = [[btn] for btn in buttons]
logging.info(
f"🚢 {query.from_user.username} ({query.from_user.id}) seleccionó un país")
log_user_event(query.from_user.id, "country_selected", {"url": full_url})
await query.edit_message_text('Selecciona una provincia:', reply_markup=InlineKeyboardMarkup(keyboard))
async def province_callback(update: Update, context):
"""
Callback para provincia: mostrar puertos
"""
query = update.callback_query
await query.answer()
short_url = query.data.split(":", 1)[1]
full_url = "https://tablademareas.com" + short_url
response_text = await fetch(full_url)
if response_text is None:
await query.edit_message_text("Error al obtener los puertos.")
return
soup = BeautifulSoup(response_text, 'html.parser')
puertos = soup.select('a.sitio_estacion_a')
if not puertos:
await query.edit_message_text("No se encontraron puertos en la página.")
return
buttons = []
for p in puertos:
href = p.get('href')
if not href:
continue
station_container = p.find("div", class_="sitio_estacion")
port_name = None
if station_container:
first_div = station_container.find("div", recursive=False)
if first_div:
port_name = first_div.get_text(strip=True)
if not port_name:
port_name = p.text.strip()
buttons.append(InlineKeyboardButton(
port_name, callback_data=f"port:{shorten_url(href)}"))
buttons = sorted(buttons, key=lambda btn: btn.text.lower())
keyboard = [[btn] for btn in buttons]
logging.info(
f"{query.from_user.username} ({query.from_user.id}) seleccionó una provincia")
log_user_event(query.from_user.id, "province_selected", {"url": full_url})
await query.edit_message_text('Selecciona un puerto:', reply_markup=InlineKeyboardMarkup(keyboard))
async def port_callback(update: Update, context):
"""
Callback para puerto: acción final
"""
query = update.callback_query
await query.answer()
short_url = query.data.split(":", 1)[1]
full_url = "https://tablademareas.com" + short_url
logging.info(
f"🚩 {query.from_user.username} ({query.from_user.id}) seleccionó un puerto")
log_user_event(query.from_user.id, "port_selected", {"url": full_url})
await query.edit_message_text(f"Enlace del puerto: {full_url}")
def main():
"""
Función principal para iniciar el bot
"""
app = ApplicationBuilder().token(TELEGRAM_TOKEN).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("reset", reset))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(
CallbackQueryHandler(continent_callback, pattern='^continent:')
)
app.add_handler(CallbackQueryHandler(
country_callback, pattern='^country:'))
app.add_handler(CallbackQueryHandler(
province_callback, pattern='^province:'))
app.add_handler(CallbackQueryHandler(port_callback, pattern='^port:'))
logging.info("🤖 Iniciando el bot...")
init_db()
app.run_polling()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,85 @@
services:
tablamareas-app:
build: .
container_name: tablamareas-app
# Crear .env con:
# TELEGRAM_BOT_TOKEN=<TOKEN TELEGRAM>
# REDIS_PASSWORD=<REDIS PASSWORD>
# DATABASE_URL=postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@tablamareas-db:5432/<POSTGRES_DB>
# LOG_LEVEL=INFO
# POSTGRES_USER=<POSTGRES_USER>
# POSTGRES_PASSWORD=<POSTGRES_PASSWORD>
# POSTGRES_DB=<POSTGRES_DB>
# REDIS_PASSWORD=<REDIS_PASSWORD>
env_file:
- .env
depends_on:
tablamareas-db:
condition: service_healthy
tablamareas-redis:
condition: service_healthy
networks:
- tablamareas_network
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD-SHELL", "pgrep -f bot.py || exit 1"]
interval: 10s
retries: 3
start_period: 5s
tablamareas-db:
image: postgres:16-alpine
container_name: tablamareas-db
# Crear .db.env con:
# POSTGRES_USER=<POSTGRES_USER>
# POSTGRES_PASSWORD=<POSTGRES_PASSWORD>
# POSTGRES_DB=<POSTGRES_DB>
env_file:
- .db.env
networks:
- tablamareas_network
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB || exit 1"]
interval: 10s
retries: 3
start_period: 5s
tablamareas-redis:
image: redis:alpine
container_name: tablamareas-redis
# Crear .redis.env con:
# REDIS_PASSWORD=<REDIS PASSWORD>
env_file:
- .redis.env
networks:
- tablamareas_network
restart: unless-stopped
command: redis-server --requirepass "$REDIS_PASSWORD"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 3
start_period: 5s
networks:
tablamareas_network:
volumes:
pgdata:

View File

@@ -0,0 +1,7 @@
aiohttp==3.11.12
beautifulsoup4==4.13.3
psycopg2-binary==2.9.10
python-dotenv==1.0.1
python-telegram-bot>=21.10
redis==5.2.1
requests==2.32.3

View File

@@ -1,6 +1,6 @@
# Bots de Telegram
<div style="display:block; margin-left:auto; margin-right:auto; width:50%;">
<div style="display:block; margin-left:auto; margin-right:auto; max-width:500px;">
![](https://www.pngmart.com/files/15/Baby-Bender-PNG.png)
@@ -15,7 +15,8 @@
| [Bot de noticias](./05_rss_bot/) | Bot que devuelve noticias de última hora | intermedio |
| [Bot de películas](./06_movie_bot/) | Bot que devuelve información de películas | intermedio |
| [Bot trivial de películas](./07_movie2_bot/README.md) | Bot que devuelve información de series | avanzado |
| **Bot de libros** (próximamente) | Bot que devuelve información de libros | avanzado |
| [Bot de chatgpt](./08_chatgpt_bot/README.md) | Bot que mantiene conversaciones con GPT-3 | avanzado |
| [Bot con Ollama](./09_ollama_bot/README.md) | Bot que mantiene conversaciones con Ollama | intermedio |
| **Bot de recetas** (próximamente) | Bot que devuelve recetas de cocina | avanzado |
| **Bot de deportes** (próximamente) | Bot que devuelve información de deportes | avanzado |
| **Bot de mareas** (próximamente) | Bot que devuelve información de mareas | avanzado |

View File

@@ -0,0 +1,15 @@
#! /usr/bin/env python
from diagrams import Diagram
from diagrams.aws.compute import EC2
from diagrams.aws.database import RDS
from diagrams.aws.network import ELB
with Diagram("Grouped Workers", show=False, direction="TB"):
ELB("lb") >> [
EC2("worker1"),
EC2("worker2"),
EC2("worker3"),
EC2("worker4"),
EC2("worker5")
] >> RDS("events")

View File

@@ -0,0 +1,28 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS
from diagrams.aws.database import ElastiCache, RDS
from diagrams.aws.network import ELB
from diagrams.aws.network import Route53
with Diagram("Clustered Web Services", show=False):
dns = Route53("dns")
lb = ELB("lb")
with Cluster("Services"):
svc_group = [
ECS("web1"),
ECS("web2"),
ECS("web3")
]
with Cluster("DB Cluster"):
db_primary = RDS("userdb")
db_primary - [RDS("userdb ro")]
memcached = ElastiCache("memcached")
dns >> lb >> svc_group
svc_group >> db_primary
svc_group >> memcached

View File

@@ -0,0 +1,32 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS, EKS, Lambda
from diagrams.aws.database import Redshift
from diagrams.aws.integration import SQS
from diagrams.aws.storage import S3
with Diagram("Event Processing", show=False):
source = EKS("k8s source")
with Cluster("Event Flows"):
with Cluster("Event Workers"):
workers = [
ECS("worker1"),
ECS("worker2"),
ECS("worker3")
]
queue = SQS("event queue")
with Cluster("Processing"):
handlers = [Lambda("proc1"),
Lambda("proc2"),
Lambda("proc3")]
store = S3("events store")
dw = Redshift("analytics")
source >> workers >> queue >> handlers
handlers >> store
handlers >> dw

View File

@@ -0,0 +1,37 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram
from diagrams.gcp.analytics import BigQuery, Dataflow, PubSub
from diagrams.gcp.compute import AppEngine, Functions
from diagrams.gcp.database import BigTable
from diagrams.gcp.iot import IotCore
from diagrams.gcp.storage import GCS
with Diagram("Message Collecting", show=False):
pubsub = PubSub("pubsub")
with Cluster("Source of Data"):
[
IotCore("core1"),
IotCore("core2"),
IotCore("core3")
] >> pubsub
with Cluster("Targets"):
with Cluster("Data Flow"):
flow = Dataflow("data flow")
with Cluster("Data Lake"):
flow >> [
BigQuery("bq"),
GCS("storage")
]
with Cluster("Event Driven"):
with Cluster("Processing"):
flow >> AppEngine("engine") >> BigTable("bigtable")
with Cluster("Serverless"):
flow >> Functions("func") >> AppEngine("appengine")
pubsub >> flow

View File

@@ -0,0 +1,12 @@
#! /usr/bin/env python
from diagrams import Diagram
from diagrams.k8s.clusterconfig import HPA
from diagrams.k8s.compute import Deployment, Pod, ReplicaSet
from diagrams.k8s.network import Ingress, Service
with Diagram("Exposed Pod with 3 Replicas", show=False):
net = Ingress("domain.com") >> Service("svc")
net >> [Pod("pod1"),
Pod("pod2"),
Pod("pod3")] << ReplicaSet("rs") << Deployment("dp") << HPA("hpa")

View File

@@ -0,0 +1,20 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram
from diagrams.k8s.compute import Pod, StatefulSet
from diagrams.k8s.network import Service
from diagrams.k8s.storage import PV, PVC, StorageClass
with Diagram("Stateful Architecture", show=False):
with Cluster("Apps"):
svc = Service("svc")
sts = StatefulSet("sts")
apps = []
for _ in range(3):
pod = Pod("pod")
pvc = PVC("pvc")
pod - sts - pvc
apps.append(svc >> pod >> pvc)
apps << PV("pv") << StorageClass("sc")

View File

@@ -0,0 +1,39 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram
from diagrams.onprem.analytics import Spark
from diagrams.onprem.compute import Server
from diagrams.onprem.database import PostgreSQL
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.aggregator import Fluentd
from diagrams.onprem.monitoring import Grafana, Prometheus
from diagrams.onprem.network import Nginx
from diagrams.onprem.queue import Kafka
with Diagram("Advanced Web Service with On-Premise", show=False):
ingress = Nginx("ingress")
metrics = Prometheus("metric")
metrics << Grafana("monitoring")
with Cluster("Service Cluster"):
grpcsvc = [
Server("grpc1"),
Server("grpc2"),
Server("grpc3")
]
with Cluster("Sessions HA"):
primary = Redis("session")
primary - Redis("replica") << metrics
grpcsvc >> primary
with Cluster("Database HA"):
primary = PostgreSQL("users")
primary - PostgreSQL("replica") << metrics
grpcsvc >> primary
aggregator = Fluentd("logging")
aggregator >> Kafka("stream") >> Spark("analytics")
ingress >> grpcsvc >> aggregator

View File

@@ -0,0 +1,43 @@
#! /usr/bin/env python
from diagrams import Cluster, Diagram, Edge
from diagrams.onprem.analytics import Spark
from diagrams.onprem.compute import Server
from diagrams.onprem.database import PostgreSQL
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.aggregator import Fluentd
from diagrams.onprem.monitoring import Grafana, Prometheus
from diagrams.onprem.network import Nginx
from diagrams.onprem.queue import Kafka
with Diagram(name="Advanced Web Service with On-Premise (colored)", show=False):
ingress = Nginx("ingress")
metrics = Prometheus("metric")
metrics << Edge(color="firebrick", style="dashed") << Grafana("monitoring")
with Cluster("Service Cluster"):
grpcsvc = [
Server("grpc1"),
Server("grpc2"),
Server("grpc3")
]
with Cluster("Sessions HA"):
primary = Redis("session")
primary - Edge(color="brown", style="dashed") - \
Redis("replica") << Edge(label="collect") << metrics
grpcsvc >> Edge(color="brown") >> primary
with Cluster("Database HA"):
primary = PostgreSQL("users")
primary - Edge(color="brown", style="dotted") - \
PostgreSQL("replica") << Edge(label="collect") << metrics
grpcsvc >> Edge(color="black") >> primary
aggregator = Fluentd("logging")
aggregator >> Edge(label="parse") >> Kafka("stream") >> Edge(
color="black", style="bold") >> Spark("analytics")
ingress >> Edge(color="darkgreen") << grpcsvc >> Edge(
color="darkorange") >> aggregator

View File

@@ -0,0 +1,25 @@
#! /usr/bin/env python
from urllib.request import urlretrieve
from diagrams import Cluster, Diagram
from diagrams.aws.database import Aurora
from diagrams.custom import Custom
from diagrams.k8s.compute import Pod
# Download an image to be used into a Custom Node class
rabbitmq_url = "https://jpadilla.github.io/rabbitmqapp/assets/img/icon.png"
rabbitmq_icon = "rabbitmq.png"
urlretrieve(rabbitmq_url, rabbitmq_icon)
with Diagram("Broker Consumers", show=False):
with Cluster("Consumers"):
consumers = [
Pod("worker"),
Pod("worker"),
Pod("worker")
]
queue = Custom("Message queue", rabbitmq_icon)
queue >> consumers >> Aurora("Database")

View File

@@ -0,0 +1,364 @@
# Ejemplos de Diagramas como Código
Este repositorio contiene ejemplos de diagramas de arquitectura generados mediante código utilizando la biblioteca `diagrams` en Python. Esta herramienta permite crear representaciones visuales de arquitecturas de software de manera programática.
Más info:
- [Repo](https://github.com/mingrammer/diagrams)
- [Web](https://diagrams.mingrammer.com/)
## Instalación
Crear un entorno virtual y activarlo:
```bash
python3 -m venv venv
source venv/bin/activate
```
Instalar la biblioteca `diagrams`, puedes utilizar `pip`:
```bash
pip install diagrams
```
Al terminal se puede desactivar el entorno virtual con el comando:
```bash
deactivate
```
## Ejemplos
### Trabajadores Agrupados en AWS
Este diagrama muestra un balanceador de carga (ELB) distribuyendo el tráfico a múltiples instancias de EC2, las cuales interactúan con una base de datos RDS.
```python
from diagrams import Diagram
from diagrams.aws.compute import EC2
from diagrams.aws.database import RDS
from diagrams.aws.network import ELB
with Diagram("Grouped Workers", show=False, direction="TB"):
ELB("lb") >> [EC2("worker1"),
EC2("worker2"),
EC2("worker3"),
EC2("worker4"),
EC2("worker5")] >> RDS("events")
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/grouped_workers_diagram.png" alt="Grouped Workers" width="500"/>
</p>
### Servicios Web en Clúster
Este diagrama ilustra una arquitectura de servicios web en AWS. Incluye balanceo de carga, clúster de servicios, almacenamiento en caché y una base de datos principal con réplica.
```python
from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS
from diagrams.aws.database import ElastiCache, RDS
from diagrams.aws.network import ELB
from diagrams.aws.network import Route53
with Diagram("Clustered Web Services", show=False):
dns = Route53("dns")
lb = ELB("lb")
with Cluster("Services"):
svc_group = [ECS("web1"),
ECS("web2"),
ECS("web3")]
with Cluster("DB Cluster"):
db_primary = RDS("userdb")
db_primary - [RDS("userdb ro")]
memcached = ElastiCache("memcached")
dns >> lb >> svc_group
svc_group >> db_primary
svc_group >> memcached
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/clustered_web_services_diagram.png" alt="Clustered Web Services" width="500"/>
</p>
### Procesamiento de Eventos en AWS
El siguiente diagrama representa un flujo de procesamiento de eventos en AWS, utilizando fuentes de eventos, colas para manejar los eventos, procesamiento mediante Lambdas, y almacenamiento en Redshift y S3.
```python
from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS, EKS, Lambda
from diagrams.aws.database import Redshift
from diagrams.aws.integration import SQS
from diagrams.aws.storage import S3
with Diagram("Event Processing", show=False):
source = EKS("k8s source")
with Cluster("Event Flows"):
with Cluster("Event Workers"):
workers = [ECS("worker1"),
ECS("worker2"),
ECS("worker3")]
queue = SQS("event queue")
with Cluster("Processing"):
handlers = [Lambda("proc1"),
Lambda("proc2"),
Lambda("proc3")]
store = S3("events store")
dw = Redshift("analytics")
source >> workers >> queue >> handlers
handlers >> store
handlers >> dw
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/event_processing_diagram.png" alt="Event Processing" width="500"/>
</p>
### Sistema de Recolección de Mensajes en GCP
Este diagrama detalla un sistema de recolección de mensajes implementado en Google Cloud Platform (GCP), destacando el uso de Pub/Sub, BigQuery, Dataflow y otras herramientas de GCP.
```python
from diagrams import Cluster, Diagram
from diagrams.gcp.analytics import BigQuery, Dataflow, PubSub
from diagrams.gcp.compute import AppEngine, Functions
from diagrams.gcp.database import BigTable
from diagrams.gcp.iot import IotCore
from diagrams.gcp.storage import GCS
with Diagram("Message Collecting", show=False):
pubsub = PubSub("pubsub")
with Cluster("Source of Data"):
[IotCore("core1"),
IotCore("core2"),
IotCore("core3")] >> pubsub
with Cluster("Targets"):
with Cluster("Data Flow"):
flow = Dataflow("data flow")
with Cluster("Data Lake"):
flow >> [BigQuery("bq"),
GCS("storage")]
with Cluster("Event Driven"):
with Cluster("Processing"):
flow >> AppEngine("engine") >> BigTable("bigtable")
with Cluster("Serverless"):
flow >> Functions("func") >> AppEngine("appengine")
pubsub >> flow
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/message_collecting_diagram.png" alt="Message Collecting" width="500"/>
</p>
### Pod Expuesto con 3 Réplicas en Kubernetes
Este ejemplo muestra un pod expuesto con un servicio de red en Kubernetes, ilustrando el uso de pods y réplicas.
```python
from diagrams import Diagram
from diagrams.k8s.clusterconfig import HPA
from diagrams.k8s.compute import Deployment, Pod, ReplicaSet
from diagrams.k8s.network import Ingress, Service
with Diagram("Exposed Pod with 3 Replicas", show=False):
net = Ingress("domain.com") >> Service("svc")
net >> [Pod("pod1"),
Pod("pod2"),
Pod("pod3")] << ReplicaSet("rs") << Deployment("dp") << HPA("hpa")
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/exposed_pod_with_3_replicas_diagram.png" alt="Exposed Pod with 3 Replicas" width="500"/>
</p>
### Arquitectura con Estado en Kubernetes
Esta arquitectura representa un conjunto de aplicaciones stateful en Kubernetes, mostrando el uso de StatefulSets, almacenamiento persistente y clases de almacenamiento.
```python
from diagrams import Cluster, Diagram
from diagrams.k8s.compute import Pod, StatefulSet
from diagrams.k8s.network import Service
from diagrams.k8s.storage import PV, PVC, StorageClass
with Diagram("Stateful Architecture", show=False):
with Cluster("Apps"):
svc = Service("svc")
sts = StatefulSet("sts")
apps = []
for _ in range(3):
pod = Pod("pod")
pvc = PVC("pvc")
pod - sts - pvc
apps.append(svc >> pod >> pvc)
apps << PV("pv") << StorageClass("sc")
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/stateful_architecture_diagram.png" alt="Stateful Architecture" width="500"/>
</p>
### Servicio Web Avanzado con Infraestructura On-Premise
Aquí se ilustra un servicio web avanzado que combina infraestructura local (on-premise) con herramientas como Nginx, Redis, PostgreSQL y Kafka para el manejo de servicios, sesiones y base de datos.
```python
from diagrams import Cluster, Diagram
from diagrams.onprem.analytics import Spark
from diagrams.onprem.compute import Server
from diagrams.onprem.database import PostgreSQL
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.aggregator import Fluentd
from diagrams.onprem.monitoring import Grafana, Prometheus
from diagrams.onprem.network import Nginx
from diagrams.onprem.queue import Kafka
with Diagram("Advanced Web Service with On-Premise", show=False):
ingress = Nginx("ingress")
metrics = Prometheus("metric")
metrics << Grafana("monitoring")
with Cluster("Service Cluster"):
grpcsvc = [
Server("grpc1"),
Server("grpc2"),
Server("grpc3")]
with Cluster("Sessions HA"):
primary = Redis("session")
primary - Redis("replica") << metrics
grpcsvc >> primary
with Cluster("Database HA"):
primary = PostgreSQL("users")
primary - PostgreSQL("replica") << metrics
grpcsvc >> primary
aggregator = Fluentd("logging")
aggregator >> Kafka("stream") >> Spark("analytics")
ingress >> grpcsvc >> aggregator
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/advanced_web_service_with_on-premise.png" alt="Advanced Web Service with On-Premise" width="500"/>
</p>
### Servicio Web Avanzado con Infraestructura On-Premise (con colores y etiquetas)
Este diagrama es una versión coloreada del anterior, incluyendo etiquetas y estilos para una mejor comprensión visual.
```python
from diagrams import Cluster, Diagram, Edge
from diagrams.onprem.analytics import Spark
from diagrams.onprem.compute import Server
from diagrams.onprem.database import PostgreSQL
from diagrams.onprem.inmemory import Redis
from diagrams.onprem.aggregator import Fluentd
from diagrams.onprem.monitoring import Grafana, Prometheus
from diagrams.onprem.network import Nginx
from diagrams.onprem.queue import Kafka
with Diagram(name="Advanced Web Service with On-Premise (colored)", show=False):
ingress = Nginx("ingress")
metrics = Prometheus("metric")
metrics << Edge(color="firebrick", style="dashed") << Grafana("monitoring")
with Cluster("Service Cluster"):
grpcsvc = [
Server("grpc1"),
Server("grpc2"),
Server("grpc3")]
with Cluster("Sessions HA"):
primary = Redis("session")
primary - Edge(color="brown", style="dashed") - Redis("replica") << Edge(label="collect") << metrics
grpcsvc >> Edge(color="brown") >> primary
with Cluster("Database HA"):
primary = PostgreSQL
("users")
primary - Edge(color="brown", style="dotted") - PostgreSQL("replica") << Edge(label="collect") << metrics
grpcsvc >> Edge(color="black") >> primary
aggregator = Fluentd("logging")
aggregator >> Edge(label="parse") >> Kafka("stream") >> Edge(color="black", style="bold") >> Spark("analytics")
ingress >> Edge(color="darkgreen") << grpcsvc >> Edge(color="darkorange") >> aggregator
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/advanced_web_service_with_on-premise_colored.png" alt="Advanced Web Service with On-Premise (colored)" width="500"/>
</p>
### Consumidores RabbitMQ con Nodos Personalizados
Este ejemplo demuestra cómo incluir nodos personalizados en los diagramas, usando RabbitMQ y Aurora como base de datos de destino.
```python
from urllib.request import urlretrieve
from diagrams import Cluster, Diagram
from diagrams.aws.database import Aurora
from diagrams.custom import Custom
from diagrams.k8s.compute import Pod
# Descargar una imagen para usarla en un nodo personalizado
rabbitmq_url = "https://jpadilla.github.io/rabbitmqapp/assets/img/icon.png"
rabbitmq_icon = "rabbitmq.png"
urlretrieve(rabbitmq_url, rabbitmq_icon)
with Diagram("Broker Consumers", show=False):
with Cluster("Consumers"):
consumers = [
Pod("worker"),
Pod("worker"),
Pod("worker")]
queue = Custom("Message queue", rabbitmq_icon)
queue >> consumers >> Aurora("Database")
```
<p align="center">
<img src="https://diagrams.mingrammer.com/img/rabbitmq_consumers_diagram.png" alt="Broker Consumers" width="500"/>
</p>
---
Cada diagrama fue generado utilizando la librería `diagrams`, que permite crear representaciones visuales de infraestructuras y arquitecturas tecnológicas de manera programática. Para más información, visita la [documentación oficial de diagrams](https://diagrams.mingrammer.com/).

View File

@@ -0,0 +1,7 @@
README.md
docker-compose.yaml
docker-compose.yml
Dockerfile
.git
.gitignore
.vscode

View File

@@ -0,0 +1,24 @@
# Usa una imagen base de Python oficial
FROM python:3.9-slim
ARG maintaner="manuelver"
# Establece el directorio de trabajo dentro del contenedor
WORKDIR /app
# Copia los archivos necesarios al contenedor
COPY requirements.txt /app
# Instala las dependencias del script
RUN pip install --no-cache-dir -r requirements.txt
# Copia el script al contenedor
COPY urlf4ck3r.py /app
# Da permisos de ejecución al script
RUN chmod +x urlf4ck3r.py
# Define el comando predeterminado para ejecutar el script con argumentos
ENTRYPOINT ["./urlf4ck3r.py"]
# Especifica el comando por defecto, que puede ser sobreescrito al correr el contenedor
CMD ["-h"]

View File

@@ -0,0 +1,116 @@
# 🕸️ URLf4ck3r 🕵️‍♂️
> Repositorio original: [URLf4ck3r](https://github.com/n0m3l4c000nt35/urlf4ck3r)
URLf4ck3r es una herramienta de reconocimiento diseñada para escanear y extraer URLs del código fuente de sitios web.
📝 **Tabla de contenidos**
- [🕸️ URLf4ck3r 🕵️‍♂️](#-urlf4ck3r-)
- [🚀 Características principales](#-características-principales)
- [📋 Requisitos](#-requisitos)
- [🛠️ Instalación](#-instalación)
- [💻 Uso](#-uso)
- [Con docker](#con-docker)
- [Construir la imagen de Docker](#construir-la-imagen-de-docker)
- [Ejecutar el contenedor](#ejecutar-el-contenedor)
- [Ejecutar con Docker Compose:](#ejecutar-con-docker-compose)
## 🚀 Características principales
- 🔍 Escaneo recursivo de URLs
- 🌐 Detección de subdominios
- ✍️ Detección de palabras sensibles en los comentarios
- 🔗 Clasificación de URLs absolutas y relativas
- 💠 Detección de archivos JavaScript
- 🎨 Salida colorida para una fácil lectura
- ⏱️ Interrumpible en cualquier momento
## 📋 Requisitos
- Python 3.x
- Bibliotecas: `requests`, `beautifulsoup4`, `pwntools` (Ver en el fichero [requirements.txt](requirements.txt))
## 🛠️ Instalación
1. Descarga esta carpeta
2. Instalá las dependencias:
```
pip install -r requirements.txt
```
3. Haz el script ejecutable:
```
chmod +x urlf4ck3r.py
```
4. Para ejecutar el script desde cualquier ubicación:
- Mueve el script a un directorio que esté en el PATH, por ejemplo:
```
sudo mv urlf4ck3r.py /usr/bin/urlf4ck3r
```
- O añade el directorio del script al PATH editando el archivo `.bashrc` o `.zshrc`:
```
echo 'export PATH=$PATH:/ruta/al/directorio/de/urlf4ck3r' >> ~/.bashrc
source ~/.bashrc
```
## 💻 Uso
Si seguiste el paso 4 de la instalación, puedes ejecutar el script desde cualquier ubicación simplemente con:
```
urlf4ck3r -u <URL> -o output.txt
```
De lo contrario, desde el directorio donde se encuentra ubicado el script:
```
./urlf4ck3r.py -u <URL> -o output
```
Ejemplo:
```
urlf4ck3r -u https://ejemplo.com -o output.txt
```
## Con docker
### Construir la imagen de Docker
Para construir la imagen desde el Dockerfile, navega al directorio donde se encuentra tu Dockerfile y ejecuta el siguiente comando:
```sh
docker build -t urlf4ck3r .
```
### Ejecutar el contenedor
Después de construir la imagen, puedes ejecutar tu script dentro de un contenedor de la siguiente manera:
```sh
docker run --rm urlf4ck3r -u https://ejemplo.com -o output.txt
```
El flag --rm asegura que el contenedor se elimina automáticamente después de que se complete su ejecución.
### Ejecutar con Docker Compose:
En la línea de comandos, navega hasta el directorio donde guardaste estos archivos.
Ejecuta el siguiente comando para construir la imagen y ejecutar el contenedor usando Docker Compose:
```sh
docker-compose up --build
```
Esto ejecutará urlf4ck3r y generará el archivo output.txt en el directorio ./output de tu máquina local.

View File

@@ -0,0 +1,11 @@
services:
urlf4ck3r:
build:
context: .
dockerfile: Dockerfile
command: ["-u", "https://vergaracarmona.es", "-o", "output.txt"]
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./output:/app/output
container_name: urlf4ck3r

View File

@@ -0,0 +1,3 @@
beautifulsoup4==4.12.3
pwntools==4.13.0
Requests==2.32.3

View File

@@ -0,0 +1,444 @@
#!/usr/bin/env python3
import argparse
import os
import requests
import signal
import sys
from bs4 import BeautifulSoup, Comment
from collections import defaultdict
from urllib.parse import urljoin, urlparse
from typing import Optional, Tuple, Dict, List, Set
class URLf4ck3r:
"""
URLf4ck3r es una herramienta que extrae las URL's del código fuente de una
página web. Además, puede extraer comentarios sensibles del código fuente
y guardar las URL's en archivos de texto.
"""
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
GRAY = "\033[90m"
PURPLE = "\033[95m"
END_COLOR = "\033[0m"
SENSITIVE_KEYWORDS = [
# Palabras clave originales
"password", "user", "username", "clave", "secret", "key", "token",
"private", "admin", "credential", "login", "auth", "api_key", "apikey",
"administrator",
# # Criptografía y Seguridad
# "encryption", "decrypt", "cipher", "security", "hash", "salt", "ssl",
# "tls", "secure", "firewall", "integrity",
# # Gestión de Usuarios y Autenticación
# "auth_token", "session_id", "access_token", "oauth", "id_token",
# "refresh_token", "csrf", "sso", "two_factor", "2fa",
# # Información Personal Identificable (PII)
# "social_security", "ssn", "address", "phone_number", "email", "dob",
# "credit_card", "card_number", "ccv", "passport", "tax_id", "personal_info",
# # Configuración de Sistemas
# "config", "database", "db_password", "db_user", "connection_string",
# "server", "host", "port",
# # Archivos y Rutas
# "filepath", "filename", "root_path", "home_dir", "backup", "logfile",
# # Llaves y Tokens de API
# "aws_secret", "aws_key", "api_secret", "secret_key", "private_key",
# "public_key", "ssh_key",
# # Finanzas y Pagos
# "payment", "transaction", "account_number", "iban", "bic", "swift",
# "bank", "routing_number", "billing", "invoice",
# # Cuentas y Roles de Administrador
# "superuser", "root", "sudo", "admin_password", "admin_user",
# # Otros
# "jwt", "cookie", "session", "bypass", "debug", "exploit"
]
def __init__(self):
"""
Inicializa las variables de instancia.
"""
self.all_urls: Dict[str, Set[str]] = defaultdict(set)
self.comments_data: Dict[str, List[str]] = defaultdict(list)
self.base_url: Optional[str] = None
self.urls_to_scan: List[str] = []
self.flag = self.Killer()
self.output: Optional[str] = None
def banner(self) -> None:
"""
Muestra el banner de la herramienta.
"""
print("""
█ ██ ██▀███ ██▓ █████▒▄████▄ ██ ▄█▀ ██▀███
██ ▓██▒▓██ ▒ ██▒▓██▒ ▓██ ▒▒██▀ ▀█ ██▄█▒ ▓██ ▒ ██▒
▓██ ▒██░▓██ ░▄█ ▒▒██░ ▒████ ░▒▓█ ▄ ▓███▄░ ▓██ ░▄█ ▒
▓▓█ ░██░▒██▀▀█▄ ▒██░ ░▓█▒ ░▒▓▓▄ ▄██▒▓██ █▄ ▒██▀▀█▄
▒▒█████▓ ░██▓ ▒██▒░██████▒░▒█░ ▒ ▓███▀ ░▒██▒ █▄░██▓ ▒██▒
░▒▓▒ ▒ ▒ ░ ▒▓ ░▒▓░░ ▒░▓ ░ ▒ ░ ░ ░▒ ▒ ░▒ ▒▒ ▓▒░ ▒▓ ░▒▓░
░░▒░ ░ ░ ░▒ ░ ▒░░ ░ ▒ ░ ░ ░ ▒ ░ ░▒ ▒░ ░▒ ░ ▒░
░░░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ░░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░
""")
def run(self) -> None:
"""
Ejecuta la herramienta.
"""
self.banner()
args, parser = self.get_arguments()
if not args.url:
parser.print_help()
sys.exit(1)
if args.output:
self.output = args.output
self.base_url = args.url
self.all_urls["scanned_urls"] = set()
self.urls_to_scan = [self.base_url]
_, domain, _ = self.parse_url(self.base_url)
print(f"\n[{self.GREEN}DOMAIN{self.END_COLOR}] {domain}\n")
while self.urls_to_scan and not self.flag.exit():
url = self.urls_to_scan.pop(0)
self.scan_url(url)
print()
self.show_lists()
self.save_files()
print(f"\n[{self.GREEN}URLS TO SCAN{self.END_COLOR}]:")
if self.flag.exit():
print(
f"[{self.RED}!{self.END_COLOR}] Quedaron por escanear {self.RED}{len(self.urls_to_scan)}{self.END_COLOR} URLs"
)
elif not self.urls_to_scan:
print(
f"[{self.GREEN}+{self.END_COLOR}] Se escanearon todas las URLs posibles"
)
else:
print(
f"[{self.RED}!{self.END_COLOR}] Quedaron por escanear {self.RED}{len(self.urls_to_scan)}{self.END_COLOR} URLs"
)
def get_arguments(self) -> Tuple[argparse.Namespace, argparse.ArgumentParser]:
"""
Obtiene los argumentos proporcionados por el usuario.
"""
parser = argparse.ArgumentParser(
prog="urlf4ck3r",
description="Extraer las URL's del código fuente de una web",
epilog="Creado por https://github.com/n0m3l4c000nt35 y modificado por gitea.vergaracarmona.es/manuelver"
)
parser.add_argument("-u", "--url", type=str, dest="url",
help="URL a escanear", required=True)
parser.add_argument("-o", "--output", type=str,
dest="output", help="Nombre del archivo de salida")
return parser.parse_args(), parser
def scan_url(self, url: str) -> None:
"""
Escanea una URL en busca de URLs, comentarios sensibles y archivos JS.
"""
if self.flag.exit():
return
if url in self.all_urls["scanned_urls"]:
return
self.all_urls["scanned_urls"].add(url)
print(f"[{self.GREEN}SCANNING{self.END_COLOR}] {url}")
try:
res = requests.get(url, timeout=5)
soup = BeautifulSoup(res.content, 'html.parser')
self.extract_js_files(soup, url)
self.extract_comments(soup, url)
self.extract_hrefs(soup, url, res)
except requests.Timeout:
print(f"[{self.RED}REQUEST TIMEOUT{self.END_COLOR}] {url}")
self.all_urls['request_error'].add(url)
except requests.exceptions.RequestException:
print(f"{self.RED}[REQUEST ERROR]{self.END_COLOR} {url}")
self.all_urls['request_error'].add(url)
except Exception as e:
print(
f"[{self.RED}UNEXPECTED ERROR{self.END_COLOR}] {url}: {str(e)}"
)
def extract_hrefs(self, soup: BeautifulSoup, url: str, res: requests.Response) -> None:
"""
Extrae las URL's del código fuente de una página web.
"""
for link in soup.find_all("a", href=True):
href = link.get("href")
scheme, domain, path = self.parse_url(href)
schemes = ["http", "https"]
if href:
full_url = urljoin(url, path) if not scheme else href
if full_url not in self.all_urls["all_urls"]:
self.all_urls["all_urls"].add(full_url)
if not scheme:
self.all_urls["relative_urls"].add(full_url)
else:
self.all_urls["absolute_urls"].add(full_url)
if self.is_jsfile(url, res):
self.all_urls["javascript_files"].add(url)
if (self.is_internal_url(self.base_url, full_url) or
self.is_subdomain(self.base_url, full_url)):
if full_url not in self.all_urls["scanned_urls"] and full_url not in self.urls_to_scan:
self.urls_to_scan.append(full_url)
def extract_js_files(self, soup: BeautifulSoup, base_url: str) -> None:
"""
Extrae los archivos JS del código fuente de una página web.
"""
js_files = set()
for script in soup.find_all('script', src=True):
js_url = script['src']
if not urlparse(js_url).netloc:
js_url = urljoin(base_url, js_url)
js_files.add(js_url)
self.all_urls["javascript_files"].update(js_files)
def is_jsfile(self, url: str, res: requests.Response) -> bool:
"""
Verifica si un archivo es un archivo JS.
"""
return url.lower().endswith(('.js', '.mjs')) or 'javascript' in res.headers.get('Content-Type', '').lower()
def extract_subdomain(self, url: str) -> str:
"""
Extrae el subdominio de una URL.
"""
netloc = urlparse(url).netloc.split(".")
return ".".join(netloc[1:] if netloc[0] == "www" else netloc)
def is_subdomain(self, base_url: str, url: str) -> bool:
"""
Verifica si una URL es un subdominio del dominio base.
"""
base_domain = self.extract_subdomain(base_url)
sub = self.extract_subdomain(url)
return sub.endswith(base_domain) and sub != base_domain
def is_internal_url(self, base_url: str, url: str) -> bool:
"""
Verifica si una URL es interna (pertenece al mismo dominio).
"""
return urlparse(base_url).netloc == urlparse(url).netloc
def extract_comments(self, soup: BeautifulSoup, url: str) -> None:
"""
Extrae los comentarios del código fuente de una página web.
"""
comments = soup.find_all(string=lambda text: isinstance(text, Comment))
for comment in comments:
comment_str = comment.strip()
if any(keyword in comment_str.lower() for keyword in self.SENSITIVE_KEYWORDS):
self.comments_data[url].append(comment_str)
print(
f"{self.YELLOW}[SENSITIVE COMMENT FOUND]{self.END_COLOR} {comment_str}"
)
def parse_url(self, url: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Parsea una URL y devuelve el esquema, dominio y path.
"""
parsed_url = urlparse(url)
return parsed_url.scheme, parsed_url.netloc, parsed_url.path
def ensure_directory_exists(self, directory: str) -> None:
"""
Asegura que el directorio existe, y lo crea si no es así.
"""
if not os.path.exists(directory):
os.makedirs(directory)
def save_file(self, data: List[str], filename: str) -> None:
"""
Guarda los datos en un archivo.
"""
try:
# Asegurarse de que el directorio 'output' existe
self.ensure_directory_exists("output")
if self.output:
filename = f"{self.output}_{filename}"
filepath = os.path.join("output", filename)
with open(filepath, "w") as f:
f.write("\n".join(data))
print(f"[{self.GREEN}+{self.END_COLOR}] Guardado en {filepath}")
except IOError as e:
print(
f"{self.RED}[FILE WRITE ERROR]{self.END_COLOR} No se pudo guardar el archivo {filename}: {str(e)}"
)
def save_files(self) -> None:
"""
Guarda las URLs y los comentarios extraídos en archivos.
"""
self.save_file(
sorted(self.all_urls["all_urls"]),
"all_urls.txt"
)
self.save_file(
sorted(self.all_urls["absolute_urls"]),
"absolute_urls.txt"
)
self.save_file(
sorted(self.all_urls["relative_urls"]),
"relative_urls.txt"
)
self.save_file(
sorted(self.all_urls["javascript_files"]),
"javascript_files.txt"
)
if self.comments_data:
sensitive_comments = []
for url, comments in self.comments_data.items():
sensitive_comments.append(f"\n[ {url} ]\n")
sensitive_comments.extend(comments)
self.save_file(sensitive_comments, "sensitive_comments.txt")
def show_lists(self) -> None:
"""
Muestra el resumen de las URLs extraídas.
"""
print(
f"\n[{self.GREEN}ALL URLS{self.END_COLOR}]: {len(self.all_urls['all_urls'])}"
)
print(
f"[{self.GREEN}ABSOLUTE URLS{self.END_COLOR}]: {len(self.all_urls['absolute_urls'])}"
)
print(
f"[{self.GREEN}RELATIVE URLS{self.END_COLOR}]: {len(self.all_urls['relative_urls'])}"
)
print(
f"[{self.GREEN}JAVASCRIPT FILES{self.END_COLOR}]: {len(self.all_urls['javascript_files'])}"
)
print(
f"[{self.GREEN}SENSITIVE COMMENTS{self.END_COLOR}]: {len(self.comments_data)}"
)
class Killer:
"""
Clase utilizada para manejar la interrupción del script con Ctrl+C.
"""
kill_now = False
def __init__(self):
signal.signal(signal.SIGINT, self.exit_gracefully)
def exit_gracefully(self, signum, frame) -> None:
"""
Método llamado cuando se recibe la señal de interrupción.
"""
self.kill_now = True
def exit(self) -> bool:
"""
Retorna True si el script debe terminar.
"""
return self.kill_now
if __name__ == "__main__":
tool = URLf4ck3r()
tool.run()

View File

@@ -0,0 +1,33 @@
# Solucionador de Cubo de Rubik
Solucionador de Cubo de Rubik codificado en Python.
> Repositorio original: https://github.com/CubeLuke/Rubiks-Cube-Solver
> Codificado por <a href="https://github.com/CubeLuke">Lucas</a> y <a href="https://github.com/TomBrannan">Tom Brannan</a>
Para ejecutar el solucionador, ejecuta el archivo cube.py. La interfaz gráfica se iniciará automáticamente. Si obtienes errores, es posible que no tengas tkinter instalado. Es necesario tenerlo para ejecutar la interfaz gráfica.
### Características
Solo lee las instrucciones para ver algunas de las características incluidas en el solucionador.
Entre las características incluidas se encuentran:
* Scrambles generados por el usuario o por el programa
* La capacidad de hacer movimientos personalizados
* La capacidad de presionar el botón de resolución o cada paso de la resolución para ver el cubo resolverse paso a paso
* La capacidad de ejecutar simulaciones con una cantidad definida por el usuario de resoluciones (ten cuidado, demasiadas podrían hacer que el programa se congele)
* Capacidad de copiar scrambles o soluciones al portapapeles, así como verlas externamente
* Hacer clic en el cubo 2D te permitirá ver los otros mosaicos inferiores que normalmente no son visibles
<p align="center">
<img src="https://cloud.githubusercontent.com/assets/10378593/5694175/4f15d546-9914-11e4-83ea-e85d91236071.png" alt ="Captura de pantalla del solucionador"/>
</p>
### Comandos Varios
Si no deseas usar la interfaz gráfica, también puedes escribir comandos de función en el intérprete. Aquí tienes algunos de los útiles:
* print_cube() Imprime el cubo en formato de texto
* scramble() Puedes proporcionar un número, un scramble en formato de cadena o nada para un scramble por defecto de 25 movimientos
* get_scramble() Imprime el scramble previo
* solve() Resolverá el cubo
* get_moves() Imprime la solución que se generó al usar solve()
* simulation(num) El número proporcionado es la cantidad de resoluciones que deseas simular. Te devolverá la mejor resolución con su scramble, así como la peor resolución y su scramble.
El solucionador en sí está basado en el método CFOP (Fridrich) para resolver el cubo. Resuelve el Cross, realiza el paso F2L, hace un OLL de 2 pasos y un PLL de 2 pasos. En cuanto a la notación, se utiliza la notación básica del mundo del cubing; sin embargo, un movimiento en sentido contrario a las agujas del reloj puede denotarse con un apóstrofe (forma estándar) o usando la letra i (denotando i para inverso).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
*.csv

View File

@@ -0,0 +1,216 @@
# README - Amigo Invisible (Secret Santa)
Este es un script en Python diseñado para organizar un intercambio de regalos de "Amigo Invisible" de manera automática. Permite generar asignaciones de personas de forma aleatoria, asegurándose de que no haya emparejamientos invalidos, como asignar a alguien a sí mismo o asignar a alguien con exclusiones especificadas.
Además, si se habilita la opción de enviar correos electrónicos, el script enviará un correo a cada participante notificándole a quién le ha tocado regalar, usando plantillas personalizables.
## Requisitos
* Python 3.x
* Paquetes de Python requeridos:
* `csv`
* `random`
* `argparse`
* `smtplib`
* `os`
* `logging`
* `email`
* `dotenv`
Puedes instalar las dependencias necesarias ejecutando el siguiente comando:
```bash
pip install python-dotenv
```
Si usas un entorno virtual, asegúrate de activarlo antes de instalar las dependencias.
## Descripción de Funcionalidades
### Entradas
El script toma como entrada un archivo CSV que contiene la lista de participantes. El archivo CSV debe tener las siguientes columnas:
* `name`: Nombre del participante.
* `email`: Correo electrónico del participante.
* `exclusions`: (Opcional) Lista de participantes a los que **no** pueden ser asignados como "Amigo Invisible". Los valores deben estar separados por punto y coma.
Ejemplo de archivo CSV `participants.csv`:
```csv
name,email,exclusions
Juan,juan@example.com,
María,maria@example.com,juan@example.com
Pedro,pedro@example.com,juan@example.com
```
En el caso de que un participante no tenga exclusiones, se deja en blanco la columna `exclusions`.
### Opciones del Script
Este es el uso básico del script:
```bash
python secret_santa.py --input participants.csv
```
#### Opciones disponibles:
* `--input PATH`
Ruta al archivo CSV de entrada (por defecto `participants.csv`).
* `--output PATH`
Ruta al archivo CSV de salida donde se guardarán las asignaciones (por defecto `assignments.csv`).
* `--seed INT`
Semilla para la generación aleatoria. Esto permite hacer el proceso reproducible.
* `--send`
Habilita el envío de correos electrónicos. Si no se incluye, los correos no se enviarán.
* `--smtp-server HOST`
Servidor SMTP para el envío de correos (por defecto, `smtp.gmail.com`).
* `--smtp-port PORT`
Puerto SMTP (por defecto, 587).
* `--smtp-user USER`
Usuario SMTP (requiere autenticación).
* `--smtp-pass PASS`
Contraseña SMTP. **Es recomendable utilizar una variable de entorno en lugar de escribir la contraseña directamente.**
* `--subject-template`
Ruta al archivo de plantilla del asunto del correo.
* `--body-template`
Ruta al archivo de plantilla del cuerpo del correo.
* `--max-attempts N`
Número máximo de intentos para generar emparejamientos aleatorios (por defecto, 10000).
## Flujo de Trabajo
1. **Leer Participantes:**
El script lee el archivo `participants.csv` y extrae los datos de los participantes, incluyendo sus exclusiones.
2. **Generación de Asignaciones:**
Se genera un emparejamiento aleatorio entre los participantes. El script se asegura de que:
* Un participante no se empareje con sí mismo.
* Un participante no sea asignado a una persona de su lista de exclusiones.
Si no se puede encontrar una asignación válida después de varios intentos (por defecto, 10000), el script usa un algoritmo de **backtracking** para intentar encontrar una solución.
3. **Guardar Asignaciones:**
Las asignaciones generadas se guardan en un archivo CSV con el formato:
```csv
giver_name,giver_email,recipient_name,recipient_email
```
4. **Enviar Correos Electrónicos (Opcional):**
Si se habilita la opción `--send`, el script enviará un correo electrónico a cada participante notificándole a quién le ha tocado regalar.
Los correos electrónicos usan plantillas personalizables para el asunto y el cuerpo del mensaje. Puedes crear estos archivos como plantillas y proporcionarlas al script a través de los parámetros `--subject-template` y `--body-template`.
### Estructura de Archivos
La estructura recomendada para el proyecto es la siguiente:
```
.
├── secret_santa.py # Script principal
├── .env # Variables de entorno (por ejemplo, SMTP credentials)
├── participants.csv # Lista de participantes
├── assignments.csv # Archivo generado con las asignaciones
├── templates/
│ ├── subject.txt # Plantilla de asunto para los correos
│ └── body.txt # Plantilla de cuerpo para los correos
└── secret_santa.log # Archivo de registro (log)
```
#### Archivos `.env`
El archivo `.env` debe contener las variables necesarias para la autenticación SMTP. Este archivo **no debe ser subido a repositorios públicos** (asegúrate de que esté en el archivo `.gitignore`). Un ejemplo de `.env` podría ser:
```env
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=tu_correo@gmail.com
SMTP_PASS=tu_contraseña
LOG_LEVEL=INFO
```
#### Plantillas de Correo
Las plantillas para el **asunto** y el **cuerpo** del correo se deben almacenar en archivos de texto en la carpeta `templates/`. Estos archivos pueden contener variables que se reemplazarán dinámicamente con los datos de cada participante.
Ejemplo de archivo `templates/subject.txt`:
```
¡Tu Amigo Invisible es {recipient_name}!
```
Ejemplo de archivo `templates/body.txt`:
```
¡Hola {giver_name}!
Te ha tocado regalar a {recipient_name}, cuyo correo es {recipient_email}.
¡Suerte con la compra de tu regalo!
Atentamente,
El equipo de Amigo Invisible
```
## Uso
### Ejemplo Básico
Si quieres realizar el emparejamiento sin enviar correos electrónicos:
```bash
python secret_santa.py --input participants.csv --output assignments.csv
```
### Ejemplo con Correos
Si deseas enviar correos electrónicos a los participantes, usa la opción `--send`:
```bash
python secret_santa.py --input participants.csv --output assignments.csv --send --smtp-user "tu_correo@gmail.com" --smtp-pass "tu_contraseña"
```
### Personalización de Plantillas
Si prefieres personalizar el asunto y el cuerpo de los correos, puedes hacerlo editando los archivos de plantilla:
* `templates/subject.txt`
* `templates/body.txt`
Luego, solo necesitas especificar las rutas a estos archivos con las opciones `--subject-template` y `--body-template` al ejecutar el script.
## Registros
El script genera un archivo de log llamado `secret_santa.log`, donde se registran todas las actividades realizadas. Puedes revisar este archivo para obtener detalles sobre el proceso de asignación, cualquier error o advertencia, y las actividades de envío de correos.
## Excepciones y Errores
El script maneja diversas excepciones, como errores de lectura de archivo CSV, errores al enviar correos electrónicos, o si no se encuentran suficientes participantes.
Si un error ocurre, el script terminará su ejecución con un mensaje de error detallado.
## Contribuciones
Si deseas realizar mejoras o reportar problemas, por favor abre un **Issue** o envía un **Pull Request**. ¡Cualquier contribución será bienvenida!
## Licencia
Este proyecto está bajo la Licencia MIT. Ver el archivo [LICENSE](LICENSE) para más detalles.
---
Este `README` proporciona una guía completa para el uso de este script, la configuración de los parámetros y el manejo de excepciones. Si tienes más dudas o necesitas personalizar algún aspecto del script, no dudes en preguntarme. ¡Feliz organización de tu Amigo Invisible! 🎁

View File

@@ -0,0 +1 @@
python-dotenv==1.2.1

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
secret_santa.py
Uso básico:
python secret_santa.py --input participants.csv
Opciones:
--input PATH CSV de entrada con columnas: name,email,exclusions (opcional)
--output PATH CSV de salida (por defecto assignments.csv)
--seed INT Semilla aleatoria (opcional, reproducible)
--send Enviar correos vía SMTP (ver opciones siguientes)
--smtp-server HOST Servidor SMTP (ej. smtp.gmail.com)
--smtp-port PORT Puerto SMTP (ej. 587)
--smtp-user USER Usuario SMTP
--smtp-pass PASS Contraseña SMTP (mejor usar variable de entorno)
--subject "..." Asunto del email (plantilla)
--body "..." Cuerpo (plantilla)
--max-attempts N Intentos máximos para buscar emparejamiento (por defecto 10000)
"""
import csv
import random
import argparse
import sys
import smtplib
import os
import logging
from email.message import EmailMessage
from typing import List, Dict, Tuple, Set
from dotenv import load_dotenv
# Cargar variables desde el archivo .env
load_dotenv()
# Configuración del logger con niveles detallados
logging.basicConfig(
level=logging.os.getenv('LOG_LEVEL', 'INFO').upper(),
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('secret_santa.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def read_participants(path: str) -> List[Dict]:
"""Lee el archivo CSV con los participantes y sus exclusiones."""
participants = []
try:
logger.info(f"[i] Abriendo el archivo {path} para leer los participantes.")
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get('name') or row.get('Name') or ''
email = row.get('email') or row.get('Email') or ''
exclusions_raw = row.get('exclusions') or row.get('Exclusions') or ''
exclusions = set()
if exclusions_raw:
for item in exclusions_raw.split(';'):
s = item.strip()
if s:
exclusions.add(s.lower())
participants.append({
'name': name.strip(),
'email': email.strip().lower(),
'exclusions': exclusions
})
logger.info(f"[i] Se leyeron {len(participants)} participantes del archivo.")
except FileNotFoundError:
logger.error(f"[i] El archivo {path} no fue encontrado.")
sys.exit(1)
except Exception as e:
logger.error(f"[!] Error al leer el archivo CSV: {e}")
sys.exit(1)
return participants
def valid_pairing(giver: Dict, recipient: Dict) -> bool:
"""Verifica si una asignación entre giver y recipient es válida."""
if giver['email'] == recipient['email']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to themselves.")
return False
if recipient['email'] in giver['exclusions']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to {recipient['email']} (exclusion).")
return False
if recipient['name'].strip().lower() in giver['exclusions']:
logger.debug(f"[!] Invalid pairing: {giver['email']} cannot give to {recipient['name']} (exclusion).")
return False
return True
def generate_assignments(participants: List[Dict], max_attempts: int = 10000) -> List[Tuple[Dict, Dict]]:
"""Genera las asignaciones de forma aleatoria."""
n = len(participants)
if n < 2:
logger.error("[!] Necesitas al menos 2 participantes.")
raise ValueError("Necesitas al menos 2 participantes.")
logger.info(f"[i] Generando asignaciones para {n} participantes.")
indices = list(range(n))
for attempt in range(max_attempts):
logger.debug(f"[i] Intentando emparejamiento aleatorio, intento {attempt + 1}.")
random.shuffle(indices)
ok = True
pairs = []
for i, giver in enumerate(participants):
recipient = participants[indices[i]]
if not valid_pairing(giver, recipient):
ok = False
break
pairs.append((giver, recipient))
if ok:
logger.info("[i] Asignación generada con éxito.")
return pairs
logger.error(f"[!] No fue posible encontrar una asignación válida tras {max_attempts} intentos.")
sol = backtracking_assign(participants)
if sol is None:
logger.critical("[!] No se pudo encontrar una asignación válida ni después del backtracking.")
raise RuntimeError(f"[!] No fue posible encontrar una asignación válida tras {max_attempts} intentos y búsqueda.")
return sol
def backtracking_assign(participants: List[Dict]) -> List[Tuple[Dict, Dict]] or None:
"""Intenta asignar los participantes usando backtracking."""
n = len(participants)
recipients = list(range(n))
used = [False]*n
assignment = [None]*n
allowed = []
for giver in participants:
allowed_list = [j for j, r in enumerate(participants) if valid_pairing(giver, r)]
allowed.append(allowed_list)
order = sorted(range(n), key=lambda i: len(allowed[i]))
logger.debug("[i] Comenzando búsqueda por backtracking.")
def dfs(pos):
if pos == n:
return True
i = order[pos]
for j in allowed[i]:
if not used[j]:
used[j] = True
assignment[i] = j
if dfs(pos+1):
return True
used[j] = False
assignment[i] = None
return False
if dfs(0):
logger.info("[i] Búsqueda por backtracking exitosa.")
return [(participants[i], participants[assignment[i]]) for i in range(n)]
logger.error("[!] Búsqueda por backtracking fallida.")
return None
def write_assignments_csv(pairs: List[Tuple[Dict, Dict]], path: str):
"""Escribe las asignaciones en un archivo CSV."""
try:
logger.info(f"[i] Escribiendo las asignaciones en {path}.")
with open(path, 'w', newline='', encoding='utf-8') as f:
fieldnames = ['giver_name', 'giver_email', 'recipient_name', 'recipient_email']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for giver, recipient in pairs:
writer.writerow({
'giver_name': giver['name'],
'giver_email': giver['email'],
'recipient_name': recipient['name'],
'recipient_email': recipient['email']
})
logger.info(f"[i] Asignaciones guardadas en {path}")
except Exception as e:
logger.error(f"[!] Error escribiendo el archivo CSV: {e}")
sys.exit(1)
def send_emails(pairs: List[Tuple[Dict,Dict]], smtp_config: Dict, subject_template: str, body_template: str):
"""Envía los correos electrónicos a los participantes."""
server_host = smtp_config['server']
server_port = smtp_config['port']
user = smtp_config['user']
password = smtp_config['pass']
try:
logger.info(f"[i] Iniciando conexión con el servidor SMTP {server_host}:{server_port}.")
with smtplib.SMTP(server_host, server_port) as server:
server.starttls()
server.login(user, password)
for giver, recipient in pairs:
msg = EmailMessage()
subject = subject_template.format(
giver_name=giver['name'],
giver_email=giver['email'],
recipient_name=recipient['name'],
recipient_email=recipient['email']
)
body = body_template.format(
giver_name=giver['name'],
giver_email=giver['email'],
recipient_name=recipient['name'].upper(),
recipient_email=recipient['email']
)
msg['Subject'] = subject
msg['From'] = user or server_host
msg['To'] = giver['email']
msg.set_content(body)
server.send_message(msg)
logger.info(f"[+] Enviado a {giver['email']}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"[!] Error de autenticación SMTP: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"[!] Error enviando correos: {e}")
sys.exit(1)
def read_template_file(file_path: str) -> str:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
def main():
parser = argparse.ArgumentParser(description="Amigo Invisible - Emparejador")
parser.add_argument('--input', default='participants.csv', help='CSV de participantes (name,email,exclusions)')
parser.add_argument('--output', default='assignments.csv', help='CSV de salida')
parser.add_argument('--seed', type=int, help='Semilla aleatoria')
parser.add_argument('--send', action='store_true', default=True, help='Enviar correos vía SMTP')
parser.add_argument('--smtp-server', default=os.getenv('SMTP_SERVER', 'smtp.gmail.com'), help='Servidor SMTP')
parser.add_argument('--smtp-port', type=int, default=int(os.getenv('SMTP_PORT', 587)), help='Puerto SMTP')
parser.add_argument('--smtp-user', default=os.getenv('SMTP_USER'), help='Usuario SMTP')
parser.add_argument('--smtp-pass', default=os.getenv('SMTP_PASS'), help='Contraseña SMTP')
parser.add_argument('--subject-template', default='templates/subject.txt', help='Ruta del archivo de asunto')
parser.add_argument('--body-template', default='templates/body.txt', help='Ruta del archivo de cuerpo')
parser.add_argument('--max-attempts', type=int, default=10000, help='Max intentos aleatorios antes de fallback')
args = parser.parse_args()
if args.seed is not None:
random.seed(args.seed)
subject_template = read_template_file(args.subject_template)
body_template = read_template_file(args.body_template)
logger.info("[i] Iniciando el proceso de emparejamiento de amigos invisibles.")
participants = read_participants(args.input)
if len(participants) == 0:
logger.error("[!] No se encontraron participantes en el CSV.")
sys.exit(1)
try:
pairs = generate_assignments(participants, max_attempts=args.max_attempts)
except Exception as e:
logger.error(f"[!] Error generando emparejamientos: {e}")
sys.exit(1)
write_assignments_csv(pairs, args.output)
if args.send:
smtp_config = {
'server': args.smtp_server,
'port': args.smtp_port,
'user': args.smtp_user,
'pass': args.smtp_pass
}
logger.info("[i] Enviando correos...")
send_emails(pairs, smtp_config, subject_template, body_template)
logger.info("[i] Envío finalizado.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,26 @@
¡Hola **{giver_name}**! 😎
Manu ha creado esta fabulosa app para enviarte el resultado del sorteo del **"Amigo INVISIBLE" edición 2025/2026**.
El regalo se entregará la próxima noche del **24 de diciembre de 2025** y tendrá un valor superior a **100 €**.
Por favor, **NO SE LO DIGAS A NADIE**. Te ha tocado hacerle un regalo a **nada más y nada menos que a...**
⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️
✨ **{recipient_name}** ✨
📧 {recipient_email}
⬆️⬆️⬆️⬆️⬆️⬆️⬆️⬆️
---
**CONSEJOS:**
- **No se lo digas a nadie** 🤐
- Aprovecha el **Black Friday** 🏴 [https://amzn.to/4osrLQL](https://amzn.to/4osrLQL)
- Da **pistas** de los regalos que quieres por si te escuchan 🕵️‍♀️
- **Busca algo original**, ¡y que no huela a “último minuto”! 😂 **Intenta regalar cosas útiles**
Espero que entiendas que si **revelas tu amigo invisible** no tiene gracia. 🙏 **COMPRA TU EL REGALO** y **no envíes a nadie a por él**.
---
¡Felices fiestas y que viva el misterio del **Amigo Invisible**! 🎅

View File

@@ -0,0 +1 @@
[Navidades 2025/2026] 🎁 {giver_name}, ¡Correo con tu AMIGO INVISIBLE!

View File

@@ -6,11 +6,15 @@ Aquí iré dejando scripts y ejercicios que se me ocurran, con lo que no hay un
## Índice de ejercicios
| Nombre | Descripción | Nivel |
| -------------------------------------------------------: | :------------------------------------------------------- | :--------: |
| [Words Linux](./01_scripts_words_linux/README.md) | Script con el fichero: `/usr/share/dict/words` | intermedio |
| [Descifrador wargame](./02_scripts_descifrador_wargame/) | Script descifrador de código al estilo wargame | intermedio |
| [Clima España](./03_clima/) | Script conectado a API AEMET | intermedio |
| [acortador de enlaces](./04_acortador_url/) | Script para acortar enlaces y redirigirlos con app Flask | intermedio |
| [Pruebas de infraestructuras](./05_infra_test/README.md) | Redis, RabbitMQ, Kafka, Prometheus, etc | intermedio |
| [Bots Telegram](./06_bots_telegram/README.md) | Bots de Telegram con Python | avanzado |
| Nombre | Descripción | Nivel |
| -------------------------------------------------------: | :--------------------------------------------------------------- | :--------: |
| [Words Linux](./01_scripts_words_linux/README.md) | Script con el fichero: `/usr/share/dict/words` | fácil |
| [Descifrador wargame](./02_scripts_descifrador_wargame/) | Script descifrador de código al estilo wargame | fácil |
| [Clima España](./03_clima/) | Script conectado a API AEMET | intermedio |
| [acortador de enlaces](./04_acortador_url/) | Script para acortar enlaces y redirigirlos con app Flask | fácil |
| [Pruebas de infraestructuras](./05_infra_test/README.md) | Redis, RabbitMQ, Kafka, Prometheus, etc | intermedio |
| [Bots Telegram](./06_bots_telegram/README.md) | Bots de Telegram con Python | avanzado |
| [Diagram as code](./07_diagrams_as_code/README.md) | Diagramas de infraestructuras con Python | fácil |
| [urlf4ck3r](./08_urlf4ck3r/README.md) | Script para buscar enlaces en una web y guardarlos en un fichero | intermedio |
| [Solucionador Rubik](./09_rubiks-cube-solver/README.md) | Script para resolver un cubo de Rubik | fácil |
| [Sorteo amigo invisible](./10_amigo_invisible/) | Script para sortear reparto del amigo invisible y enviar mails | fácil |