commit f5dc96eee5ae8e3884fd288de1d0c607ee098f2d Author: Marcos Date: Mon Apr 13 21:42:04 2026 +0200 Version Inicial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e30c2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binarios y archivos de compilaciรณn +bin/ +out/ +dist/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Herramientas de Go +*.test +*.out +vendor/ + +# Carpetas solicitadas por el usuario +.vscode/ +resources/ +*.txt + +# Variables de entorno y secretos +.env +*.env.local + +# Archivos del sistema +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/conf/DockerBot.jpg b/conf/DockerBot.jpg new file mode 100644 index 0000000..78b6ea6 Binary files /dev/null and b/conf/DockerBot.jpg differ diff --git a/conf/locales/en.json b/conf/locales/en.json new file mode 100644 index 0000000..5269fbc --- /dev/null +++ b/conf/locales/en.json @@ -0,0 +1,85 @@ +{ + "app.title": "DockerBot ๐Ÿค–", + "app.welcome": "Hello, {name}! ๐Ÿ‘‹\n\nWelcome to DockerBot. ๐Ÿค–\n\nThis bot allows you to:\n- Check container status\n- Monitor container health\n- View detailed container information\n- Check logs\n- Start, stop, or restart containers\n\nUse the /help command to see all available commands.", + "app.help": "๐Ÿค– DockerBot\nYour container management assistant\n\n๐Ÿ“ฆ Available commands\n\n๐Ÿ”น /docker\n โ”” List all containers\n\n๐Ÿ”น /docker info <container>\n โ”” Show detailed container info\n\n๐Ÿ”น /docker start|stop|pause|unpause|restart <container>\n โ”” Manage container state\n\n๐Ÿ”น /docker logs <container> [--tail N] [--follow]\n โ”” View container logs\n\n๐Ÿ”น /docker help\n โ”” Show this help message", + "app.headerList": "๐Ÿ“Š DOCKER STATUS", + "app.short": "Your container captain on Telegram ๐Ÿ‹", + "app.about": "The only bot not afraid of whales! ๐Ÿค–\n\nManaging, monitoring, and reviving your Docker containers so you can sleep without 'Exited (1)' nightmares.\n\nTotal control: listing, logs, and state management without touching the terminal. ๐Ÿ› ๏ธ", + "app.status": "Managing containers... โš™๏ธ", + + "ui.separator": "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”", + "ui.prev": "โฌ…๏ธ Previous", + "ui.next": "โžก๏ธ Next", + "ui.back": "โ†ฉ๏ธ Return", + "ui.page": "๐Ÿ“„ Page {current}/{total}", + "ui.updated": "Updated", + + "btn.start":"โ–ถ๏ธ Start", + "btn.stop":"โน๏ธ Stop", + "btn.restart":"๐Ÿ” Restart", + "btn.pause":"โธ๏ธ Pause", + "btn.resume":"๐Ÿ”‚ Resume", + "btn.logs": "๐Ÿ“œ Logs", + "btn.refresh": "๐Ÿ”„ Refresh", + + "list.container.header": "๐Ÿ“ฆ {name}", + "list.container.image": "{image}", + "list.container.state": "{emoji} {state}", + "list.container.health_suffix": " ({health})", + + "list.summary": "๐ŸŸข {running} ๐Ÿ”ด {stopped} ๐ŸŸก {restarting} โธ {paused} โšช {other}", + + "i.cmd.start": "Start the bot", + "i.cmd.docker": "Manage Docker containers", + "i.cmd.docker.detailed": "Docker commands \n (list, info, logs...)", + "i.cmd.help": "Show help", + "i.auth.unauthorized": "You are not authorized to use this bot ๐Ÿšซ", + + "d.state.created": "Created", + "d.state.running": "Running", + "d.state.paused": "Paused", + "d.state.restarting": "Restarting", + "d.state.removing": "Removing", + "d.state.exited": "Stopped", + "d.state.dead": "Dead", + "d.health.none": "No healthcheck", + "d.health.starting": "Starting", + "d.health.healthy": "Healthy", + "d.health.unhealthy": "Unhealthy", + + "c.header": "๐Ÿ“ฆ {name}", + "c.separator": "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + "c.id": "๐Ÿ†” ID: {id}", + "c.image": "๐Ÿ–ผ๏ธ Image: {image}", + "c.path": "๐Ÿ—‚๏ธ Path: {path}", + "c.command": "โš™๏ธ Command: {command}", + "c.created": "๐Ÿ“… Created: {created}", + "c.state": "๐ŸŸข State: {state}", + "c.health": "{emoji} Health: {health}", + "c.restart": "๐Ÿ”„ Restart Count: {count}", + "c.started": "๐ŸŸข Started At: {started}", + "c.finished": "๐Ÿ”ด Finished At: {finished}", + "c.ports": "๐ŸŒ Ports:", + "c.mounts": "๐Ÿ“ Mounts:", + "c.networks": "๐Ÿ”— Networks:", + + "e.container.list": "Error listing containers โŒ", + "e.container.list.not_found": "No containers found ๐Ÿšซ", + "e.container.missing_name": "You must provide the container name. Example: /docker {example} my_container {opts}", + "e.container.action": "Error executing action on container โŒ", + "e.container.info": "An error occurred while retrieving container information โŒ", + "e.container.not_found": "Container not found ๐Ÿšซ", + "e.container.logs": "Error retrieving logs", + + "e.unknown_command": "Unknown command โŒ\nUse /docker help or /help to see all available commands", + + "a.container.start": "Container {name} started successfully", + "a.container.stop": "Container {name} stopped successfully", + "a.container.restart": "Container {name} restarted successfully", + "a.container.pause": "Container {name} paused successfully", + "a.container.unpause": "Container {name} resumed successfully", + + "logs.header": "๐Ÿ“œ Logs for {name}", + "logs.empty": "No logs available", + "logs.truncated": "โš ๏ธ Showing last part of logs" +} \ No newline at end of file diff --git a/conf/locales/es.json b/conf/locales/es.json new file mode 100644 index 0000000..07f6328 --- /dev/null +++ b/conf/locales/es.json @@ -0,0 +1,85 @@ +{ + "app.title": "DockerBot ๐Ÿค–", + "app.welcome": "ยกHola, {name}! ๐Ÿ‘‹\n\nBienvenido a DockerBot. ๐Ÿค–\n\nEste bot te permite:\n- Consultar el estado de los contenedores\n- Revisar la salud de tus contenedores\n- Ver informaciรณn detallada de cada contenedor\n- Consultar logs\n- Iniciar, detener o reiniciar contenedores\n\nUsa el comando /help para obtener una lista de todos los comandos disponibles.", + "app.help": "๐Ÿค– DockerBot\nTu asistente para gestionar contenedores\n\n๐Ÿ“ฆ Comandos disponibles\n\n๐Ÿ”น /docker\n โ”” Lista todos los contenedores\n\n๐Ÿ”น /docker info <contenedor>\n โ”” Muestra informaciรณn detallada\n\n๐Ÿ”น /docker start|stop|pause|unpause|restart <contenedor>\n โ”” Gestiona el estado del contenedor\n\n๐Ÿ”น /docker logs <contenedor> [--tail N] [--follow]\n โ”” Muestra logs del contenedor\n\n๐Ÿ”น /docker help\n โ”” Muestra esta ayuda", + "app.headerList": "๐Ÿ“Š ESTADO DE DOCKER", + "app.short": "Tu capitรกn de contenedores en Telegram ๐Ÿ‹", + "app.about": "ยกEl รบnico bot que no teme a las ballenas! ๐Ÿค–\n\nGestiono, vigilo y revivo tus contenedores Docker para que puedas dormir sin pesadillas de 'Exited (1)'.\n\nControl total: listado, logs y gestiรณn de estados sin tocar la terminal. ๐Ÿ› ๏ธ", + "app.status": "Operando contenedores... โš™๏ธ", + + "ui.separator": "โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”", + "ui.prev": "โฌ…๏ธ Anterior", + "ui.next": "โžก๏ธ Siguiente", + "ui.back": "โ†ฉ๏ธ Regresar", + "ui.page": "๐Ÿ“„ Pรกgina {current}/{total}", + "ui.updated": "Actualizado", + + "btn.start":"โ–ถ๏ธ Iniciar", + "btn.stop":"โน๏ธ Detener", + "btn.restart":"๐Ÿ” Reiniciar", + "btn.pause":"โธ๏ธ Pausar", + "btn.resume":"๐Ÿ”‚ Reanudar", + "btn.logs": "๐Ÿ“œ Logs", + "btn.refresh": "๐Ÿ”„ Actualizar", + + "list.container.header": "๐Ÿ“ฆ {name}", + "list.container.image": "{image}", + "list.container.state": "{emoji} {state}", + "list.container.health_suffix": " ({health})", + + "list.summary": "๐ŸŸข {running} ๐Ÿ”ด {stopped} ๐ŸŸก {restarting} โธ {paused} โšช {other}", + + "i.cmd.start": "Iniciar el bot", + "i.cmd.docker": "Gestionar contenedores Docker", + "i.cmd.docker.detailed": "Comandos Docker \n (list, info, logs...)", + "i.cmd.help": "Mostrar ayuda", + "i.auth.unauthorized": "No tienes permisos para usar este bot ๐Ÿšซ", + + "d.state.created":"Creado", + "d.state.running":"Ejecutando", + "d.state.paused":"Pausado", + "d.state.restarting":"Reiniciando", + "d.state.removing":"Eliminando", + "d.state.exited":"Detenido", + "d.state.dead":"No operativo", + "d.health.none":"Sin healthcheck", + "d.health.starting":"Iniciando", + "d.health.healthy":"Saludable", + "d.health.unhealthy":"No saludable", + + "c.header": "๐Ÿ“ฆ {name}", + "c.separator": "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + "c.id": "๐Ÿ†” ID: {id}", + "c.image": "๐Ÿ–ผ๏ธ Imagen: {image}", + "c.path": "๐Ÿ—‚๏ธ Ruta: {path}", + "c.command": "โš™๏ธ Comando: {command}", + "c.created": "๐Ÿ“… Creado: {created}", + "c.state": "{emoji} Estado: {state}", + "c.health": "{emoji} Salud: {health}", + "c.restart": "๐Ÿ”„ Reinicios: {count}", + "c.started": "๐ŸŸข Iniciado: {started}", + "c.finished": "๐Ÿ”ด Finalizado: {finished}", + "c.ports": "๐ŸŒ Puertos:", + "c.mounts": "๐Ÿ“ Volรบmenes:", + "c.networks": "๐Ÿ”— Redes:", + + "e.container.list": "Error al listar contenedores โŒ", + "e.container.list.not_found": "No se han encontrado contenedores ๐Ÿšซ", + "e.container.missing_name": "Debes indicar el nombre del contenedor. Ej: /docker {example} my_container {opts}", + "e.container.action": "Error al ejecutar la acciรณn en el contenedor โŒ", + "e.container.info": "Ocurriรณ un error al consultar la informaciรณn del contenedor โŒ", + "e.container.not_found": "No se ha encontrado el contenedor ๐Ÿšซ", + "e.container.logs": "Error obteniendo logs", + + "e.unknown_command": "Comando no reconocido โŒ\nUsa /docker help o /help para ver todos los comandos disponibles", + + "a.container.start": "Contenedor {name} iniciado correctamente", + "a.container.stop": "Contenedor {name} detenido correctamente", + "a.container.restart": "Contenedor {name} reiniciado correctamente", + "a.container.pause": "Contenedor {name} pausado correctamente", + "a.container.unpause": "Contenedor {name} reanudado correctamente", + + "logs.header": "๐Ÿ“œ Logs de {name}", + "logs.empty": "No hay logs disponibles", + "logs.truncated": "โš ๏ธ Mostrando las รบltimas lรญneas de los logs" +} diff --git a/conf/users.json b/conf/users.json new file mode 100644 index 0000000..dd1c389 --- /dev/null +++ b/conf/users.json @@ -0,0 +1,3 @@ +{ + "2048330133": true +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..09c9495 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module fergalla.com/dockerbot + +go 1.26.1 + +require ( + github.com/go-telegram/bot v1.20.0 + github.com/moby/moby/client v0.4.0 +) + +require github.com/cespare/xxhash/v2 v2.3.0 // indirect + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/containerd/errdefs v1.0.0 + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.54.1 + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c001a3c --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/handleDockerAction.go b/handleDockerAction.go new file mode 100644 index 0000000..c182270 --- /dev/null +++ b/handleDockerAction.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "errors" + + "fergalla.com/dockerbot/internal/apperrors" + "github.com/go-telegram/bot" +) + +func (appCtx *App) handleDockerAction(ctx context.Context, b *bot.Bot, chatID int64, action string, params []string) { + + if len(params) == 0 { + appCtx.sendMessage(ctx, b, chatID, + appCtx.T("e.container.missing_name", map[string]string{"example": action}), + true, + nil, + ) + return + } + + container := params[0] + + err := appCtx.dockerInfo.ContainerAction(container, action) + if err != nil { + appCtx.logger.Error( + "Failed to execute docker action", + "module", "Docker", + "action", action, + "container", container, + "error", err, + ) + var msg string + if errors.Is(err, apperrors.ErrContainerNotFound) { + msg = appCtx.T("e.docker.connection") + } else { + msg = appCtx.T("e.container.action") + } + appCtx.sendMessage(ctx, b, chatID, msg, true, nil) + return + } + + // ๐Ÿ”„ invalidar cache + appCtx.containerCache.Invalidate() + + // ๐ŸŽจ render vista actualizada + text, keyboard, err := appCtx.renderContainerDetail(ctx, container, 0) + if err != nil { + appCtx.logger.Error( + "Failed to render container detail after action", + "module", "Bot", + "container", container, + "error", err, + ) + var msg string + if errors.Is(err, apperrors.ErrContainerNotFound) { + msg = appCtx.T("e.container.not_found") + } else { + msg = appCtx.T("e.container.info") + } + appCtx.sendMessage(ctx, b, chatID, msg, true, nil) + return + } + + // โœ… feedback + vista + finalText := appCtx.T("a.container."+action, map[string]string{ + "name": container, + }) + "\n\n" + text + + appCtx.sendMessage(ctx, b, chatID, finalText, true, keyboard) +} diff --git a/handleDockerInfo.go b/handleDockerInfo.go new file mode 100644 index 0000000..904a673 --- /dev/null +++ b/handleDockerInfo.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "errors" + "strconv" + "strings" + + "fergalla.com/dockerbot/internal/apperrors" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func (appCtx *App) handleDockerInfo(ctx context.Context, b *bot.Bot, chatID int64, params []string) { + if len(params) == 0 { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.container.missing_name", map[string]string{"example": "info"}), true, nil) + return + } + text, keyboard, err := appCtx.renderContainerDetail(ctx, params[0], 0) + if err != nil { + appCtx.logger.Error( + "Failed to render container info", + "module", "Bot", + "container", params[0], + "error", err, + ) + if errors.Is(err, apperrors.ErrContainerNotFound) { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.container.not_found"), true, nil) + } else { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.container.info"), true, nil) + } + return + } + + appCtx.sendMessage(ctx, b, chatID, text, true, keyboard) +} + +func (appCtx *App) callbackHandlerContainer(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + data := cb.Data + + parts := strings.Split(data, ":") + if len(parts) < 3 { + return + } + + scope := parts[0] + action := parts[1] + container := parts[2] + + if scope != "docker_action" { + return + } + + // ๐Ÿง  estado (page opcional) + pageIndex := 0 + if len(parts) >= 4 { + if p, err := strconv.Atoi(parts[3]); err == nil { + pageIndex = p + } + } + + var successMsg string + + // ๐Ÿ”ง acciรณn (excepto refresh) + if action != "refresh" { + err := appCtx.dockerInfo.ContainerAction(container, action) + if err != nil { + appCtx.logger.Error( + "Docker action failed (callback)", + "module", "Docker", + "action", action, + "container", container, + "error", err, + ) + var msg string + if errors.Is(err, apperrors.ErrContainerNotFound) { + msg = appCtx.T("e.docker.connection") + + } else { + msg = appCtx.T("e.container.action") + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: msg, + ShowAlert: true, + }) + return + } + + successMsg = appCtx.T("a.container."+action, map[string]string{ + "name": container, + }) + } + + // ๐Ÿ”„ invalidar cache SIEMPRE (acciรณn o refresh) + appCtx.containerCache.Invalidate() + + // ๐ŸŽจ render centralizado + text, keyboard, err := appCtx.renderContainerDetail(ctx, container, pageIndex) + if err != nil { + msg := appCtx.T("e.container.info") + if err.Error() == "container_not_found" { + msg = appCtx.T("e.container.not_found") + } + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: msg, + ShowAlert: true, + }) + return + } + + // โœ… respuesta UX + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: successMsg, + ShowAlert: false, + }) + + // ๐Ÿ”„ actualizar mensaje + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn( + "Failed to edit message after action", + "module", "Bot", + "error", err, + ) + return + } +} + +func (appCtx *App) callbackDockerInfo(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) < 2 { + return + } + + container := parts[1] + + pageIndex := 0 + if len(parts) >= 3 { + if p, err := strconv.Atoi(parts[2]); err == nil { + pageIndex = p + } + } + + text, keyboard, err := appCtx.renderContainerDetail(ctx, container, pageIndex) + if err != nil { + var msg string + if errors.Is(err, apperrors.ErrContainerNotFound) { + msg = appCtx.T("e.container.not_found") + } else { + msg = appCtx.T("e.container.info") + } + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: msg, + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed to edit message (container info)", "error", err) + return + } +} diff --git a/handleDockerList.go b/handleDockerList.go new file mode 100644 index 0000000..f1baf01 --- /dev/null +++ b/handleDockerList.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "strconv" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func (appCtx *App) handleDockerList(ctx context.Context, b *bot.Bot, chatID int64) { + text, keyboard, _ := appCtx.renderContainerPage(ctx, 0, true) + appCtx.sendMessage(ctx, b, chatID, text, true, keyboard) +} + +func (appCtx *App) callbackDockerPagination(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) != 2 { + return + } + + pageIndex, err := strconv.Atoi(parts[1]) + if err != nil { + pageIndex = 0 + } + + text, keyboard, err := appCtx.renderContainerPage(ctx, pageIndex, false) + if err != nil { + appCtx.logger.Error("Pagination render failed", "error", err) + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: appCtx.T("e.container.list"), + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed pagination edit", "error", err) + return + } +} + +func (appCtx *App) callbackDockerListRefresh(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) != 2 { + return + } + + pageIndex, err := strconv.Atoi(parts[1]) + if err != nil { + pageIndex = 0 + } + + text, keyboard, err := appCtx.renderContainerPage(ctx, pageIndex, true) + if err != nil { + appCtx.logger.Error("Refresh render failed", "error", err) + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: appCtx.T("e.container.list"), + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: "๐Ÿ”„ Actualizado", + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed refresh edit", "error", err) + return + } +} + +func (appCtx *App) callbackDockerBack(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) != 2 { + return + } + + pageIndex, err := strconv.Atoi(parts[1]) + if err != nil { + pageIndex = 0 + } + + text, keyboard, err := appCtx.renderContainerPage(ctx, pageIndex, false) + if err != nil { + appCtx.logger.Error("Back render failed", "error", err) + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: appCtx.T("e.container.list"), + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed back edit", "error", err) + return + } +} diff --git a/handleDockerLogs.go b/handleDockerLogs.go new file mode 100644 index 0000000..23d8a4e --- /dev/null +++ b/handleDockerLogs.go @@ -0,0 +1,256 @@ +package main + +import ( + "context" + "errors" + "strconv" + "strings" + + "fergalla.com/dockerbot/internal/apperrors" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func (appCtx *App) handleDockerLogs(ctx context.Context, b *bot.Bot, chatID int64, params []string) { + if len(params) == 0 { + appCtx.sendMessage( + ctx, + b, + chatID, + appCtx.T("e.container.missing_name", map[string]string{ + "example": "logs", + "opts": " --tail 10", + }), + true, + nil, + ) + return + } + + container, tail := parseLogsParams(params) + + text, keyboard, err := appCtx.renderContainerLogs(ctx, container, 0, tail) + if err != nil { + appCtx.logger.Error( + "Failed to render container logs", + "module", "Bot", + "container", container, + "tail", tail, + "error", err, + ) + if errors.Is(err, apperrors.ErrContainerNotFound) { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.container.not_found"), true, nil) + } else { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.container.logs"), true, nil) + } + return + } + appCtx.sendMessage(ctx, b, chatID, text, true, keyboard) +} + +func parseLogsParams(params []string) (container string, tail int) { + const defaultTail = 0 + const maxTail = 100 + + if len(params) == 0 { + return "", defaultTail + } + + container = params[0] + tail = defaultTail + + for i := 1; i < len(params); i++ { + if params[i] == "--tail" && i+1 < len(params) { + if v, err := strconv.Atoi(params[i+1]); err == nil { + tail = v + } + i++ // saltar valor + } + } + + // clamp + if tail <= 0 { + tail = defaultTail + } + if tail > maxTail { + tail = maxTail + } + + return +} + +func (appCtx *App) callbackDockerLogs(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) < 2 { + return + } + + container := parts[1] + + pageIndex := 0 + if len(parts) >= 3 { + if p, err := strconv.Atoi(parts[2]); err == nil { + pageIndex = p + } + } + tail := 0 // default + if len(parts) >= 4 { + if t, err := strconv.Atoi(parts[3]); err == nil { + tail = t + } + } + + text, keyboard, err := appCtx.renderContainerLogs(ctx, container, pageIndex, tail) + if err != nil { + appCtx.logger.Error("Logs render generator failed", "error", err) + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: appCtx.T("e.container.logs"), + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed to render logs", "error", err) + return + } +} + +func (appCtx *App) callbackDockerLogsRefresh(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) < 2 { + return + } + + container := parts[1] + + pageIndex := 0 + if len(parts) >= 3 { + if p, err := strconv.Atoi(parts[2]); err == nil { + pageIndex = p + } + } + tail := 0 // default + if len(parts) >= 4 { + if t, err := strconv.Atoi(parts[3]); err == nil { + tail = t + } + } + + // ๐ŸŽจ render logs actualizado + text, keyboard, err := appCtx.renderContainerLogs(ctx, container, pageIndex, tail) + if err != nil { + var msg string + if errors.Is(err, apperrors.ErrContainerNotFound) { + msg = appCtx.T("e.container.not_found") + } else { + msg = appCtx.T("e.container.logs") + } + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: msg, + ShowAlert: true, + }) + return + } + + // โœ… feedback UX + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: "๐Ÿ”„ " + appCtx.T("ui.updated"), + ShowAlert: false, + }) + + // ๐Ÿ”„ actualizar mensaje + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn( + "Failed to refresh logs", + "module", "Bot", + "container", container, + "error", err, + ) + return + } +} + +func (appCtx *App) callbackDockerBackDetail(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery == nil { + return + } + + cb := update.CallbackQuery + parts := strings.Split(cb.Data, ":") + + if len(parts) < 2 { + return + } + + container := parts[1] + + pageIndex := 0 + if len(parts) >= 3 { + if p, err := strconv.Atoi(parts[2]); err == nil { + pageIndex = p + } + } + + text, keyboard, err := appCtx.renderContainerDetail(ctx, container, pageIndex) + if err != nil { + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + Text: appCtx.T("e.container.info"), + ShowAlert: true, + }) + return + } + + b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: cb.ID, + }) + + _, err = b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: cb.Message.Message.Chat.ID, + MessageID: cb.Message.Message.ID, + Text: text, + ParseMode: "html", + ReplyMarkup: keyboard, + }) + + if err != nil { + appCtx.logger.Warn("Failed to go back to detail", "error", err) + return + } +} diff --git a/internal/apperrors/apperrors.go b/internal/apperrors/apperrors.go new file mode 100644 index 0000000..14ea7ce --- /dev/null +++ b/internal/apperrors/apperrors.go @@ -0,0 +1,5 @@ +package apperrors + +import "errors" + +var ErrContainerNotFound = errors.New("container_not_found") diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..0dbf327 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,87 @@ +package auth + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "sync" +) + +type Store struct { + file string + users map[int64]bool + mu sync.RWMutex +} + +func New(file string) (*Store, error) { + users, err := loadUsers(file) + if err != nil { + return nil, err + } + return &Store{ + file: file, + users: users, + }, nil +} +func (s *Store) IsAllowed(userID int64) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.users[userID] +} +func (s *Store) IsEmpty() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.users) == 0 +} +func (s *Store) FirstUser(userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.users[userID] = true + return s.save() +} +func (s *Store) Add(userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + s.users[userID] = true + return s.save() +} +func (s *Store) Remove(userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.users, userID) + return s.save() +} +func (s *Store) save() error { + data, err := json.MarshalIndent(s.users, "", " ") + if err != nil { + return err + } + + tmpFile := s.file + ".tmp" + if err := os.WriteFile(tmpFile, data, 0644); err != nil { + return err + } + + return os.Rename(tmpFile, s.file) +} +func loadUsers(file string) (map[int64]bool, error) { + data, err := os.ReadFile(file) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return make(map[int64]bool), nil + } + return nil, err + } + + users := make(map[int64]bool) + if len(data) == 0 { + return users, nil + } + + if err := json.Unmarshal(data, &users); err != nil { + return nil, err + } + + return users, nil +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..63c34f2 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,83 @@ +package cache + +import ( + "sync" + "time" + + "fergalla.com/dockerbot/internal/docker" +) + +type ContainerCache struct { + mu sync.RWMutex + + data []docker.ContainerBasicInfo + expiresAt time.Time + ttl time.Duration + + // funciรณn para obtener datos frescos + fetchFunc func() ([]docker.ContainerBasicInfo, error) +} + +// Constructor +func NewContainerCache(ttl time.Duration, fetch func() ([]docker.ContainerBasicInfo, error)) *ContainerCache { + return &ContainerCache{ + ttl: ttl, + fetchFunc: fetch, + } +} + +// Get devuelve datos cacheados o refresca si ha expirado +func (c *ContainerCache) Get() ([]docker.ContainerBasicInfo, error) { + // ๐Ÿ” lectura rรกpida + c.mu.RLock() + if time.Now().Before(c.expiresAt) && c.data != nil { + data := c.data + c.mu.RUnlock() + return data, nil + } + c.mu.RUnlock() + + // ๐Ÿ”’ lock de escritura (refresco) + c.mu.Lock() + defer c.mu.Unlock() + + // โš ๏ธ doble check (evita refrescos duplicados) + if time.Now().Before(c.expiresAt) && c.data != nil { + return c.data, nil + } + + data, err := c.fetchFunc() + if err != nil { + return nil, err + } + + c.data = data + c.expiresAt = time.Now().Add(c.ttl) + + return c.data, nil +} + +// Invalidate fuerza limpieza de cache +func (c *ContainerCache) Invalidate() { + c.mu.Lock() + defer c.mu.Unlock() + + c.data = nil + c.expiresAt = time.Time{} +} + +// ForceRefresh fuerza actualizaciรณn inmediata +func (c *ContainerCache) ForceRefresh() ([]docker.ContainerBasicInfo, error) { + c.mu.Lock() + defer c.mu.Unlock() + + data, err := c.fetchFunc() + if err != nil { + return nil, err + } + + c.data = data + c.expiresAt = time.Now().Add(c.ttl) + + return c.data, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..88b640d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + Locale string + Token string + Webhookurl string + Webhooktoken string + PageSize int + CacheTTL int +} + +func Load() *Config { + return &Config{ + Locale: getEnv("DOCKERBOT_LOCALE", "en"), + Token: getEnv("DOCKERBOT_TOKEN", ""), + Webhookurl: getEnv("DOCKERBOT_WEBHOOKURL", ""), + Webhooktoken: getEnv("DOCKERBOT_WEBHOOK_TOKEN", ""), + PageSize: getEnvInt("DOCKERBOT_PAGE_SIZE", 4), + CacheTTL: getEnvInt("DOCKERBOT_CACHE_TTL", 10), + } +} + +func getEnv(key, defaultValue string) string { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + return val +} + +func getEnvInt(key string, defaultValue int) int { + valStr := os.Getenv(key) + if valStr == "" { + return defaultValue + } + + val, err := strconv.Atoi(valStr) + if err != nil { + return defaultValue + } + + if val <= 0 { + return defaultValue + } + + return val +} + +func getEnvBool(key string, defaultValue bool) bool { + val := os.Getenv(key) + if val == "" { + return defaultValue + } + + v, err := strconv.ParseBool(val) + if err != nil { + return defaultValue + } + return v +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..0c6ac63 --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,238 @@ +package docker + +import ( + "bytes" + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "fergalla.com/dockerbot/internal/apperrors" + "github.com/containerd/errdefs" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/client" +) + +type DockerInfo struct { + ctxDocker context.Context + dockerClient *client.Client +} + +/* type ContainerStats struct { + CPUPercent float64 + MemUsage uint64 + MemLimit uint64 + MemPercent float64 + NetInput uint64 + NetOutput uint64 +} + +type statsResponse struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + PercpuUsage []uint64 `json:"percpu_usage"` + } `json:"cpu_usage"` + SystemUsage uint64 `json:"system_cpu_usage"` + } `json:"cpu_stats"` + + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + + MemoryStats struct { + Usage uint64 `json:"usage"` + Limit uint64 `json:"limit"` + } `json:"memory_stats"` + + Networks map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + } `json:"networks"` +} */ + +type ContainerInspectInfo struct { + ID string + Name string + Image string + Created time.Time + Path string + Command string + State string + Health string + RestartCount int + StartedAt time.Time + FinishedAt time.Time + Ports map[string]string + Mounts []string + Networks []string +} + +type ContainerBasicInfo struct { + Name string + Image string + State string + Health string +} + +func New() (*DockerInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + apiClient, err := client.New(client.FromEnv) + if err != nil { + return nil, err + } + + return &DockerInfo{ + dockerClient: apiClient, + ctxDocker: ctx, + }, nil +} + +func (d *DockerInfo) ListContainers() ([]ContainerBasicInfo, error) { + containers, err := d.dockerClient.ContainerList(d.ctxDocker, client.ContainerListOptions{All: true}) + if err != nil { + return nil, err + } + var basicInfo []ContainerBasicInfo + for _, c := range containers.Items { + name := "unknown" + if len(c.Names) > 0 { + name = c.Names[0][1:] + } + image := c.Image + state := c.State + health := c.Health.Status + basicInfo = append(basicInfo, ContainerBasicInfo{ + Name: name, + Image: image, + State: string(state), + Health: string(health), + }) + } + return basicInfo, nil +} + +func (d *DockerInfo) GetContainerInfo(containerID string) (*ContainerInspectInfo, error) { + containerJSON, err := d.dockerClient.ContainerInspect(d.ctxDocker, containerID, client.ContainerInspectOptions{}) + if err != nil { + if errdefs.IsNotFound(err) { + return nil, apperrors.ErrContainerNotFound + } + return nil, err + } + c := containerJSON.Container + info := &ContainerInspectInfo{ + Name: strings.TrimPrefix(c.Name, "/"), + Image: c.Config.Image, + ID: c.ID, + Path: c.Path, + Command: c.Path + " " + strings.Join(c.Args, " "), + State: string(c.State.Status), + Health: "none", + RestartCount: c.RestartCount, + Created: strToTime(c.Created), + StartedAt: strToTime(c.State.StartedAt), + FinishedAt: strToTime(c.State.FinishedAt), + } + + if c.State.Health != nil { + info.Health = string(c.State.Health.Status) + } + + info.Ports = make(map[string]string) + for port, bindings := range c.NetworkSettings.Ports { + if len(bindings) > 0 { + info.Ports[port.String()] = bindings[0].HostPort + } + } + + for _, m := range c.Mounts { + info.Mounts = append(info.Mounts, m.Source+"->"+m.Destination) + } + + for name := range c.NetworkSettings.Networks { + info.Networks = append(info.Networks, name) + } + return info, nil +} + +func (d *DockerInfo) ContainerAction(containerID string, action string) error { + var err error + switch action { + + case "start": + _, err = d.dockerClient.ContainerStart(d.ctxDocker, containerID, client.ContainerStartOptions{}) + + case "stop": + _, err = d.dockerClient.ContainerStop(d.ctxDocker, containerID, client.ContainerStopOptions{}) + + case "restart": + _, err = d.dockerClient.ContainerRestart(d.ctxDocker, containerID, client.ContainerRestartOptions{}) + + case "pause": + _, err = d.dockerClient.ContainerPause(d.ctxDocker, containerID, client.ContainerPauseOptions{}) + + case "unpause": + _, err = d.dockerClient.ContainerUnpause(d.ctxDocker, containerID, client.ContainerUnpauseOptions{}) + + default: + return fmt.Errorf("unknown docker action: %s", action) + } + if errdefs.IsNotFound(err) { + return apperrors.ErrContainerNotFound + } else { + return err + } +} + +func (d *DockerInfo) GetContainerLogs(containerID string, tail int) (string, error) { + opts := client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + } + if tail != 0 { + opts.Tail = strconv.Itoa(tail) + } + + reader, err := d.dockerClient.ContainerLogs(d.ctxDocker, containerID, opts) + if err != nil { + if errdefs.IsNotFound(err) { + return "", apperrors.ErrContainerNotFound + } else { + return "", err + } + } + + defer reader.Close() + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + + _, err = stdcopy.StdCopy(&stdoutBuf, &stderrBuf, reader) + if err != nil && err != io.EOF { + return "", err + } + + out := "\n\nโš ๏ธ STDOUT:\n" + stdoutBuf.String() + errOut := stderrBuf.String() + + if errOut != "" { + out += "\n\nโš ๏ธ STDERR:\n" + errOut + } + + return out, nil +} + +func strToTime(date string) time.Time { + t, err := time.Parse(time.RFC3339Nano, date) + if err != nil { + t = time.Time{} + } + return t +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..3db92fe --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,160 @@ +package i18n + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// I18n es la estructura principal +type I18n struct { + current string + fallback string + locales map[string]map[string]string + mu sync.RWMutex // protege acceso concurrente +} + +// New crea una instancia de i18n +/* func New(defaultLocale, fallback string) *I18n { + return &I18n{ + current: defaultLocale, + fallback: fallback, + locales: make(map[string]map[string]string), + } +} +*/ +func New(path string, fallback string) (*I18n, error) { + i := &I18n{ + current: fallback, + fallback: fallback, + locales: make(map[string]map[string]string), + } + + if err := i.loadDir(path); err != nil { + return nil, err + } + + if len(i.locales) == 0 { + return nil, fmt.Errorf("no locales loaded from %s", path) + } + + return i, nil +} + +func (i *I18n) loadDir(path string) error { + files, err := os.ReadDir(path) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + continue + } + + name := file.Name() + + if !strings.HasSuffix(name, ".json") { + continue + } + + lang := strings.TrimSuffix(name, ".json") + + if err := i.loadFile(filepath.Join(path, name), lang); err != nil { + return err + } + } + + return nil +} + +// LoadLocale carga un archivo JSON en memoria +func (i *I18n) LoadLocale(locale, path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var parsed map[string]string + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + + i.mu.Lock() + i.locales[locale] = parsed + i.mu.Unlock() + + return nil +} + +func (i *I18n) loadFile(path string, lang string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var content map[string]string + + if err := json.Unmarshal(data, &content); err != nil { + return err + } + + i.mu.Lock() + defer i.mu.Unlock() + + i.locales[lang] = content + + return nil +} + +// SetLocale cambia el idioma activo +func (i *I18n) SetLocale(locale string) { + i.mu.Lock() + i.current = locale + i.mu.Unlock() +} + +// T traduce una clave, opcionalmente interpolando variables +func (i *I18n) T(key string, vars map[string]string) string { + val := i.translate(key) + if vars == nil { + return val + } + // Interpolaciรณn simple: reemplaza {var} por su valor + for k, v := range vars { + val = strings.ReplaceAll(val, "{"+k+"}", v) + } + + return val +} + +// translate busca la clave en el idioma actual y fallback +func (i *I18n) translate(key string) string { + i.mu.RLock() + defer i.mu.RUnlock() + + // 1. Buscar en idioma actual + if localeData, ok := i.locales[i.current]; ok { + if val, ok := localeData[key]; ok { + return val + } + } + + // 2. Buscar en fallback + if localeData, ok := i.locales[i.fallback]; ok { + if val, ok := localeData[key]; ok { + return val + } + } + + // 3. Si no existe โ†’ devolver clave marcada + return fmt.Sprintf("??%s??", key) +} + +// Helper para construir claves con seguridad +func Key(parts ...string) string { + return strings.Join(parts, ".") +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..b7fcdb7 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,42 @@ +package logger + +import ( + "log/slog" + "os" + "strings" +) + +// Logger envuelve slog.Logger +type Logger struct { + *slog.Logger +} + +func (l *Logger) Fatalf(s string, err error) { + panic("unimplemented") +} + +// parseLevel convierte string โ†’ slog.Level +func parseLevel(level string) slog.Level { + switch strings.ToUpper(level) { + case "DEBUG": + return slog.LevelDebug + case "INFO": + return slog.LevelInfo + case "WARN", "WARNING": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func New(level string, service string) *Logger { + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: parseLevel(level), + }) + base := slog.New(handler).With( + "service", service, + ) + return &Logger{base} +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..4b8d466 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,450 @@ +package ui + +import ( + "fmt" + "strings" + + "fergalla.com/dockerbot/internal/docker" + "fergalla.com/dockerbot/internal/i18n" + "github.com/go-telegram/bot/models" +) + +// Renderer encapsula toda la lรณgica de renderizado UI + +type Renderer struct { + T func(string, ...map[string]string) string +} + +// ========================= +// ๐Ÿ“ฆ LISTADO DE CONTENEDORES +// ========================= + +type Page struct { + Items []docker.ContainerBasicInfo + Page int + PageSize int + TotalItems int +} + +func (r *Renderer) RenderContainerList(p Page) (string, *models.InlineKeyboardMarkup) { + var sb strings.Builder + + // Header + sb.WriteString(r.T("app.headerList")) + sb.WriteString("\n") + sb.WriteString(r.separator()) + sb.WriteString("\n") + + // Summary + sb.WriteString(r.renderStateSummary(p.Items)) + + // Slice paginado + start := p.Page * p.PageSize + end := start + p.PageSize + + if start > len(p.Items) { + return r.T("e.container.list.not_found"), nil + } + if end > len(p.Items) { + end = len(p.Items) + } + + visibleItems := p.Items[start:end] + + // Items (texto informativo) + for _, c := range visibleItems { + sb.WriteString(r.renderContainerItem(c)) + } + + // Footer + totalPages := (p.TotalItems + p.PageSize - 1) / p.PageSize + sb.WriteString("\n") + sb.WriteString(r.T("ui.page", map[string]string{ + "current": fmt.Sprintf("%d", p.Page+1), + "total": fmt.Sprintf("%d", totalPages), + })) + + // Keyboard combinando contenedores + navegaciรณn + keyboard := r.buildContainerListKeyboard(visibleItems, p.Page, totalPages) + + return sb.String(), keyboard +} + +func (r *Renderer) renderContainerItem(c docker.ContainerBasicInfo) string { + var sb strings.Builder + + sb.WriteString(r.T("list.container.header", map[string]string{ + "name": c.Name, + })) + + sb.WriteString("\n") + + sb.WriteString(r.T("list.container.image", map[string]string{ + "image": c.Image, + })) + + sb.WriteString("\n") + + state := r.T(i18n.Key("d", "state", c.State)) + + line := r.T("list.container.state", map[string]string{ + "emoji": stateEmoji(c.State), + "state": state, + }) + + if c.Health != "" { + health := r.T(i18n.Key("d", "health", c.Health)) + line += r.T("list.container.health_suffix", map[string]string{ + "health": health, + }) + } + + sb.WriteString(line) + sb.WriteString("\n\n") + + return sb.String() +} + +// ========================= +// ๐Ÿ“Š HELPERS +// ========================= + +func (r *Renderer) renderStateSummary(data []docker.ContainerBasicInfo) string { + running, stopped, restarting, paused, other := 0, 0, 0, 0, 0 + + for _, c := range data { + switch c.State { + case "running": + running++ + case "exited", "dead": + stopped++ + case "restarting": + restarting++ + case "paused": + paused++ + default: + other++ + } + } + + return r.T("list.summary", map[string]string{ + "running": fmt.Sprintf("%d", running), + "stopped": fmt.Sprintf("%d", stopped), + "restarting": fmt.Sprintf("%d", restarting), + "paused": fmt.Sprintf("%d", paused), + "other": fmt.Sprintf("%d", other), + }) + "\n\n" +} + +func (r *Renderer) separator() string { + return r.T("ui.separator") +} + +// ========================= +// โฌ…๏ธโžก๏ธ PAGINACIร“N + BOTONES +// ========================= + +func (r *Renderer) buildContainerListKeyboard(items []docker.ContainerBasicInfo, page, totalPages int) *models.InlineKeyboardMarkup { + var rows [][]models.InlineKeyboardButton + + // ๐Ÿ”˜ botones de contenedores (2 por fila) + var row []models.InlineKeyboardButton + + for i, c := range items { + btn := models.InlineKeyboardButton{ + Text: stateEmoji(c.State) + " " + c.Name, + CallbackData: fmt.Sprintf("dockerList_infoDocker:%s:%d", c.Name, page), + } + + row = append(row, btn) + + if len(row) == 2 { + rows = append(rows, row) + row = []models.InlineKeyboardButton{} + } + + if i == len(items)-1 && len(row) > 0 { + rows = append(rows, row) + } + } + + // โฌ…๏ธโžก๏ธ navegaciรณn + var navRow []models.InlineKeyboardButton + + if page > 0 { + navRow = append(navRow, models.InlineKeyboardButton{ + Text: r.T("ui.prev"), + CallbackData: fmt.Sprintf("dockerList_page:%d", page-1), + }) + } + + if page < totalPages-1 { + navRow = append(navRow, models.InlineKeyboardButton{ + Text: r.T("ui.next"), + CallbackData: fmt.Sprintf("dockerList_page:%d", page+1), + }) + } + + // ๐Ÿ”„ refresh siempre visible + navRow = append(navRow, models.InlineKeyboardButton{ + Text: r.T("btn.refresh"), + CallbackData: fmt.Sprintf("dockerList_refresh:%d", page), + }) + + rows = append(rows, navRow) + + return &models.InlineKeyboardMarkup{ + InlineKeyboard: rows, + } +} + +// ========================= +// ๐ŸŽจ EMOJIS +// ========================= + +func stateEmoji(state string) string { + switch state { + case "running": + return "๐ŸŸข" + case "exited": + return "๐Ÿ”ด" + case "dead": + return "๐Ÿชฆ" + case "restarting": + return "๐ŸŸก" + case "paused": + return "โธ๏ธ" + default: + return "โšช" + } +} + +/**************************************************************************************************************/ +/**************************************************************************************************************/ +/**************************************************************************************************************/ +type ContainerDetailView struct { + Container *docker.ContainerInspectInfo + Page int +} + +func (r *Renderer) RenderContainerDetail(v ContainerDetailView) (string, *models.InlineKeyboardMarkup) { + c := v.Container + + var sb strings.Builder + + // Header + sb.WriteString(r.T("c.header", map[string]string{"name": capitalize(c.Name)})) + sb.WriteString("\n") + sb.WriteString(r.T("c.separator")) + sb.WriteString("\n\n") + + // Basic info + sb.WriteString(r.line("c.id", map[string]string{"id": c.ID})) + sb.WriteString(r.line("c.image", map[string]string{"image": c.Image})) + sb.WriteString(r.line("c.path", map[string]string{"path": c.Path})) + sb.WriteString(r.line("c.command", map[string]string{"command": c.Command})) + sb.WriteString(r.line("c.created", map[string]string{"created": formatTime(c.Created)})) + + // State + sb.WriteString(r.line("c.state", map[string]string{ + "emoji": stateEmoji(c.State), + "state": r.T(i18n.Key("d", "state", c.State)), + })) + + // Health + sb.WriteString(r.line("c.health", map[string]string{ + "emoji": healthEmoji(c.Health), + "health": r.T(i18n.Key("d", "health", c.Health)), + })) + + // Stats + sb.WriteString(r.line("c.restart", map[string]string{"count": fmt.Sprintf("%d", c.RestartCount)})) + sb.WriteString(r.line("c.started", map[string]string{"started": formatTime(c.StartedAt)})) + sb.WriteString(r.line("c.finished", map[string]string{"finished": formatTime(c.FinishedAt)})) + + // Ports + if len(c.Ports) > 0 { + sb.WriteString("\n" + r.T("c.ports") + "\n") + for port, mapping := range c.Ports { + sb.WriteString(fmt.Sprintf(" - %s โ†’ %s\n", port, mapping)) + } + } + + // Mounts + if len(c.Mounts) > 0 { + sb.WriteString("\n" + r.T("c.mounts") + "\n") + for _, m := range c.Mounts { + sb.WriteString(fmt.Sprintf(" - %s\n", m)) + } + } + + // Networks + if len(c.Networks) > 0 { + sb.WriteString("\n" + r.T("c.networks") + "\n") + for _, n := range c.Networks { + sb.WriteString(fmt.Sprintf(" - %s\n", n)) + } + } + + keyboard := r.buildContainerDetailKeyboard(v) + + return sb.String(), keyboard +} + +// ========================= +// ๐Ÿ”˜ KEYBOARD +// ========================= + +func (r *Renderer) buildContainerDetailKeyboard(v ContainerDetailView) *models.InlineKeyboardMarkup { + c := v.Container + page := v.Page + var buttons [][]models.InlineKeyboardButton + + switch c.State { + + case "running": + buttons = [][]models.InlineKeyboardButton{ + { + {Text: r.T("btn.stop"), CallbackData: fmt.Sprintf("docker_action:stop:%s:%d", c.Name, page)}, + {Text: r.T("btn.restart"), CallbackData: fmt.Sprintf("docker_action:restart:%s:%d", c.Name, page)}, + }, + { + {Text: r.T("btn.pause"), CallbackData: fmt.Sprintf("docker_action:pause:%s:%d", c.Name, page)}, + }, + } + + case "exited", "created": + buttons = [][]models.InlineKeyboardButton{ + { + {Text: r.T("btn.start"), CallbackData: fmt.Sprintf("docker_action:start:%s:%d", c.Name, page)}, + }, + } + + case "paused": + buttons = [][]models.InlineKeyboardButton{ + { + {Text: r.T("btn.resume"), CallbackData: fmt.Sprintf("docker_action:unpause:%s:%d", c.Name, page)}, + {Text: r.T("btn.stop"), CallbackData: fmt.Sprintf("docker_action:stop:%s:%d", c.Name, page)}, + }, + } + + default: + buttons = [][]models.InlineKeyboardButton{ + { + {Text: r.T("btn.restart"), CallbackData: fmt.Sprintf("docker_action:restart:%s:%d", c.Name, page)}, + }, + } + } + + buttons = append(buttons, []models.InlineKeyboardButton{ + {Text: r.T("ui.back"), CallbackData: fmt.Sprintf("docker_back_list:%d", page)}, + }) + + buttons = append(buttons, []models.InlineKeyboardButton{ + {Text: r.T("btn.logs"), CallbackData: fmt.Sprintf("logs:%s:%d", c.Name, page)}, + {Text: r.T("btn.refresh"), CallbackData: fmt.Sprintf("docker_action:refresh:%s:%d", c.Name, page)}, + }) + + return &models.InlineKeyboardMarkup{ + InlineKeyboard: buttons, + } +} + +// ========================= +// ๐Ÿงฉ HELPERS +// ========================= + +func (r *Renderer) line(key string, vars map[string]string) string { + return r.T(key, vars) + "\n" +} + +func formatTime(t any) string { + if v, ok := t.(interface{ Format(string) string }); ok { + return v.Format("2006-01-02 15:04:05") + } + return "-" +} + +func capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(string(s[0])) + s[1:] +} + +func healthEmoji(h string) string { + switch h { + case "healthy": + return "โœ…" + case "unhealthy": + return "โš ๏ธ" + case "starting": + return "โณ" + default: + return "โ“" + } +} + +/**************************************************************************************************************/ +/**************************************************************************************************************/ +/**************************************************************************************************************/ + +type ContainerLogsView struct { + Container string + Logs string + Page int + Tail int +} + +func (r *Renderer) RenderContainerLogs(v ContainerLogsView) (string, *models.InlineKeyboardMarkup) { + const maxLen = 3500 + var sb strings.Builder + + if len(v.Logs) > maxLen { + v.Logs = v.Logs[len(v.Logs)-maxLen:] + + sb.WriteString(r.T("logs.truncated")) + sb.WriteString("\n\n") + } + + sb.WriteString(r.T("logs.header", map[string]string{ + "name": v.Container, + })) + sb.WriteString("\n") + sb.WriteString(r.T("ui.separator")) + sb.WriteString("\n\n") + + if v.Logs == "" { + sb.WriteString(r.T("logs.empty")) + } else { + sb.WriteString("
")
+		sb.WriteString(v.Logs)
+		sb.WriteString("
") + } + + keyboard := r.buildLogsKeyboard(v) + + return sb.String(), keyboard +} + +func (r *Renderer) buildLogsKeyboard(v ContainerLogsView) *models.InlineKeyboardMarkup { + + rows := [][]models.InlineKeyboardButton{ + { + { + Text: r.T("btn.refresh"), + CallbackData: fmt.Sprintf("logs_refresh:%s:%d:%d", v.Container, v.Page, v.Tail), + }, + }, + { + { + Text: r.T("ui.back"), + CallbackData: fmt.Sprintf("logs_back_detail:%s:%d", v.Container, v.Page), + }, + }, + } + + return &models.InlineKeyboardMarkup{ + InlineKeyboard: rows, + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0aab467 --- /dev/null +++ b/main.go @@ -0,0 +1,303 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "fergalla.com/dockerbot/internal/auth" + "fergalla.com/dockerbot/internal/cache" + "fergalla.com/dockerbot/internal/config" + "fergalla.com/dockerbot/internal/docker" + "fergalla.com/dockerbot/internal/i18n" + "fergalla.com/dockerbot/internal/logger" + "fergalla.com/dockerbot/internal/ui" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type App struct { + logger *logger.Logger + store *auth.Store + i18n *i18n.I18n + dockerInfo *docker.DockerInfo + renderer *ui.Renderer + config *config.Config + containerCache *cache.ContainerCache +} + +func main() { + log := logger.New("DEBUG", "Bot Telegram") + cfg := config.Load() + i, err := i18n.New("./conf/locales", "en") + if err != nil { + log.Error( + "Failed to load locale file", + "module", "Bot", + "error", err) + os.Exit(1) + } + i.SetLocale(cfg.Locale) + + store, err := auth.New("./conf/users.json") + if err != nil { + log.Error( + "Failed to load users file", + "module", "Auth", + "error", err, + ) + } + d, err := docker.New() + if err != nil { + log.Error( + "Failed to create client docker", + "module", "Bot", + "error", err) + os.Exit(1) + } + ttl := time.Duration(cfg.CacheTTL) * time.Second + containerCache := cache.NewContainerCache(ttl, func() ([]docker.ContainerBasicInfo, error) { + return d.ListContainers() + }) + appCtx := App{ + logger: log, + store: store, + i18n: i, + dockerInfo: d, + config: cfg, + containerCache: containerCache, + } + renderer := &ui.Renderer{ + T: appCtx.T, + } + appCtx.renderer = renderer + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + if appCtx.config.Token == "" { + appCtx.logger.Error( + "Bot token not found", + "module", "Bot", + ) + os.Exit(1) + } + opts := []bot.Option{ + bot.WithMiddlewares( + appCtx.AuthMiddleware(), + appCtx.RecoveryMiddleware(), + appCtx.LoggingMiddleware(), + ), + bot.WithDefaultHandler(appCtx.defaultHandler), + } + + b, err := bot.New(appCtx.config.Token, opts...) + if err != nil { + appCtx.logger.Error( + "Bot created failed", + "module", "Bot", + "error", err, + ) + os.Exit(1) + } + + if err := appCtx.setConfigBot(ctx, b); err != nil { + appCtx.logger.Error("Bot setup failed", "error", err) + } + + b.RegisterHandler(bot.HandlerTypeMessageText, "/start", bot.MatchTypeExact, appCtx.startHandler) + b.RegisterHandler(bot.HandlerTypeMessageText, "/help", bot.MatchTypeExact, appCtx.helpHandler) + b.RegisterHandler(bot.HandlerTypeMessageText, "/docker", bot.MatchTypePrefix, appCtx.dockerHandler) + + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "dockerList_page", bot.MatchTypePrefix, appCtx.callbackDockerPagination) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "dockerList_refresh", bot.MatchTypePrefix, appCtx.callbackDockerListRefresh) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "dockerList_infoDocker", bot.MatchTypePrefix, appCtx.callbackDockerInfo) + + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "docker_action", bot.MatchTypePrefix, appCtx.callbackHandlerContainer) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "docker_back_list", bot.MatchTypePrefix, appCtx.callbackDockerBack) + + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "logs_back_detail", bot.MatchTypePrefix, appCtx.callbackDockerBackDetail) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "logs_refresh", bot.MatchTypePrefix, appCtx.callbackDockerLogsRefresh) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "logs", bot.MatchTypePrefix, appCtx.callbackDockerLogs) + + if appCtx.config.Webhookurl != "" { + _, err := b.SetWebhook(ctx, &bot.SetWebhookParams{ + URL: appCtx.config.Webhookurl, + SecretToken: appCtx.config.Webhooktoken, + }) + if err != nil { + appCtx.logger.Error( + "Failed to set webhook", + "module", "Bot", + "url", appCtx.config.Webhookurl, + "error", err, + ) + os.Exit(1) + } + + go b.StartWebhook(ctx) + appCtx.logger.Info( + "Starting app", + "module", "Bot", + "mode", "webhook", + "url", appCtx.config.Webhookurl, + ) + err = http.ListenAndServe(":8080", b.WebhookHandler()) + if err != nil { + appCtx.logger.Error( + "HTTP server failed", + "module", "Bot", + "error", err, + ) + os.Exit(1) + } + } else { + b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: true}) + appCtx.logger.Info( + "Starting app", + "module", "Bot", + "mode", "polling", + ) + b.Start(ctx) + } + +} + +func (a *App) T(key string, vars ...map[string]string) string { + var values map[string]string + if len(vars) > 0 { + values = vars[0] + } else { + values = nil + } + return a.i18n.T(key, values) +} + +func (appCtx *App) setConfigBot(ctx context.Context, b *bot.Bot) error { + var errs []error + + commands := []models.BotCommand{ + { + Command: "start", + Description: appCtx.T("i.cmd.start"), + }, + { + Command: "docker", + Description: appCtx.T("i.cmd.docker.detailed"), + }, + { + Command: "help", + Description: appCtx.T("i.cmd.help"), + }, + } + + if _, err := b.SetMyCommands(ctx, &bot.SetMyCommandsParams{ + Commands: commands, + }); err != nil { + errs = append(errs, fmt.Errorf("set commands: %w", err)) + } + + /* if _, err := b.SetMyName(ctx, &bot.SetMyNameParams{ + Name: "DockerBot ๐Ÿค–", + }); err != nil { + errs = append(errs, fmt.Errorf("set name: %w", err)) + } + + if _, err := b.SetMyShortDescription(ctx, &bot.SetMyShortDescriptionParams{ + ShortDescription: appCtx.T("app.short"), + }); err != nil { + errs = append(errs, fmt.Errorf("set short description: %w", err)) + } + + if _, err := b.SetMyDescription(ctx, &bot.SetMyDescriptionParams{ + Description: appCtx.T("app.about"), + }); err != nil { + errs = append(errs, fmt.Errorf("set description: %w", err)) + } + + if err := setProfileImage(ctx, b); err != nil { + errs = append(errs, fmt.Errorf("set profile image: %w", err)) + } */ + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (appCtx *App) defaultHandler(ctx context.Context, b *bot.Bot, update *models.Update) { + chatID := update.Message.Chat.ID + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.unknown_command"), false, nil) +} +func (appCtx *App) startHandler(ctx context.Context, b *bot.Bot, update *models.Update) { + appCtx.sendMessage(ctx, b, update.Message.Chat.ID, appCtx.T("app.welcome", map[string]string{"name": update.Message.From.FirstName}), true, nil) +} +func (appCtx *App) helpHandler(ctx context.Context, b *bot.Bot, update *models.Update) { + appCtx.sendMessage(ctx, b, update.Message.Chat.ID, appCtx.T("app.help", nil), true, nil) +} + +func (appCtx *App) dockerHandler(ctx context.Context, b *bot.Bot, update *models.Update) { + chatID := update.Message.Chat.ID + args := strings.Fields(strings.TrimSpace(update.Message.Text[len("/docker"):])) + + if len(args) == 0 || args[0] == "list" { + appCtx.handleDockerList(ctx, b, chatID) + return + } + + subcommand := strings.ToLower(args[0]) + params := args[1:] + + appCtx.handleDockerSubcommand(ctx, b, chatID, subcommand, params) +} +func (appCtx *App) handleDockerSubcommand(ctx context.Context, b *bot.Bot, chatID int64, subcommand string, params []string) { + switch subcommand { + case "info": + appCtx.handleDockerInfo(ctx, b, chatID, params) + case "start", "stop", "pause", "unpause", "restart": + appCtx.handleDockerAction(ctx, b, chatID, subcommand, params) + case "logs": + appCtx.handleDockerLogs(ctx, b, chatID, params) + case "help": + appCtx.sendDockerHelp(ctx, b, chatID) + default: + appCtx.sendMessage(ctx, b, chatID, appCtx.T("e.unknown_command"), true, nil) + } +} +func (appCtx *App) sendDockerHelp(ctx context.Context, b *bot.Bot, chatID int64) { + appCtx.sendMessage(ctx, b, chatID, appCtx.T("app.help", nil), true, nil) +} + +func (appCtx *App) sendMessage(ctx context.Context, b *bot.Bot, chatID int64, text string, parseHTML bool, keyboard *models.InlineKeyboardMarkup) { + params := &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + } + + if parseHTML { + params.ParseMode = "html" + params.LinkPreviewOptions = &models.LinkPreviewOptions{IsDisabled: bot.True()} + } else { + params.ParseMode = "markdown" + } + if keyboard != nil { + params.ReplyMarkup = keyboard + } + + _, err := b.SendMessage(ctx, params) + if err != nil { + appCtx.logger.Error( + "Failed to send message", + "module", "Bot", + "chat_id", chatID, + "error", err, + ) + return + } +} diff --git a/middelwares.go b/middelwares.go new file mode 100644 index 0000000..9095a8d --- /dev/null +++ b/middelwares.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func (appCtx *App) LoggingMiddleware() bot.Middleware { + return func(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + + var chatID int64 + var text string + + if update.Message != nil { + chatID = update.Message.Chat.ID + text = update.Message.Text + } else { + chatID = 0 + text = "" + } + + appCtx.logger.Debug( + "Update received", + "module", "Bot", + "chat_id", chatID, + "text", text, + ) + + next(ctx, b, update) + + appCtx.logger.Debug( + "Update processed", + "module", "Bot", + ) + } + } +} + +func (appCtx *App) RecoveryMiddleware() bot.Middleware { + return func(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + defer func() { + if r := recover(); r != nil { + appCtx.logger.Error( + "Recovered from panic", + "module", "Bot", + "panic", r, + ) + } + }() + next(ctx, b, update) + } + } +} + +func (appCtx *App) AuthMiddleware() bot.Middleware { + return func(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + + var user *models.User + var chatID int64 + + if update.Message != nil && update.Message.From != nil { + user = update.Message.From + chatID = update.Message.Chat.ID + + } else if update.CallbackQuery != nil { + + if update.CallbackQuery.Message.Message == nil { + return + } + + user = &update.CallbackQuery.From + chatID = update.CallbackQuery.Message.Message.Chat.ID + + } else { + return + } + + // Bootstrap primer usuario + if appCtx.store.IsEmpty() { + if err := appCtx.store.FirstUser(user.ID); err != nil { + appCtx.logger.Error("Failed to save first user", "error", err) + } else { + appCtx.logger.Info("First user registered", "user_id", user.ID) + } + next(ctx, b, update) + return + } + + // Autorizaciรณn + if !appCtx.store.IsAllowed(user.ID) { + appCtx.logger.Warn("Unauthorized access", "user_id", user.ID, "chat_id", chatID) + appCtx.sendMessage(ctx, b, chatID, appCtx.T("i.auth.unauthorized"), true, nil) + return + } + + next(ctx, b, update) + } + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..f2835e4 --- /dev/null +++ b/utils.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "os" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func setProfileImage(ctx context.Context, b *bot.Bot) error { + file, err := os.Open("./conf/DockerBot.jpg") + if err != nil { + return err + } + defer file.Close() + attachName := "profile_photo" + photo := &models.InputProfilePhotoStatic{ + Photo: "attach://" + attachName, + MediaAttachment: file, + } + _, err = b.SetMyProfilePhoto(ctx, &bot.SetMyProfilePhotoParams{ + Photo: photo}) + if err != nil { + return err + } + return nil +} diff --git a/view_containers.go b/view_containers.go new file mode 100644 index 0000000..dfbce94 --- /dev/null +++ b/view_containers.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + + "fergalla.com/dockerbot/internal/docker" + "fergalla.com/dockerbot/internal/ui" + "github.com/go-telegram/bot/models" +) + +func (appCtx *App) renderContainerPage(ctx context.Context, pageIndex int, forceRefresh bool) (string, *models.InlineKeyboardMarkup, error) { + var data []docker.ContainerBasicInfo + var err error + + // ๐Ÿ“ฆ obtener datos + if forceRefresh { + data, err = appCtx.containerCache.ForceRefresh() + } else { + data, err = appCtx.containerCache.Get() + } + + if err != nil { + return "", nil, err + } + + totalItems := len(data) + if totalItems == 0 { + return appCtx.T("e.container.list.not_found"), nil, nil + } + + pageSize := appCtx.config.PageSize + totalPages := (totalItems + pageSize - 1) / pageSize + + // ๐Ÿ›‘ clamp + if pageIndex < 0 { + pageIndex = 0 + } + if pageIndex >= totalPages { + pageIndex = totalPages - 1 + } + + // ๐Ÿงฉ construir view + page := ui.Page{ + Items: data, + Page: pageIndex, + PageSize: pageSize, + TotalItems: totalItems, + } + + // ๐ŸŽจ render + text, keyboard := appCtx.renderer.RenderContainerList(page) + + return text, keyboard, nil +} + +func (appCtx *App) renderContainerDetail(ctx context.Context, container string, page int) (string, *models.InlineKeyboardMarkup, error) { + + info, err := appCtx.dockerInfo.GetContainerInfo(container) + if err != nil { + return "", nil, err + } + + view := ui.ContainerDetailView{ + Container: info, + Page: page, + } + + text, keyboard := appCtx.renderer.RenderContainerDetail(view) + + return text, keyboard, nil +} + +func (appCtx *App) renderContainerLogs(ctx context.Context, container string, page int, tail int) (string, *models.InlineKeyboardMarkup, error) { + logs, err := appCtx.dockerInfo.GetContainerLogs(container, tail) + if err != nil { + return "", nil, err + } + + view := ui.ContainerLogsView{ + Container: container, + Logs: logs, + Page: page, + Tail: tail, + } + + text, keyboard := appCtx.renderer.RenderContainerLogs(view) + + return text, keyboard, nil +}