Version Inicial
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -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
|
||||||
BIN
conf/DockerBot.jpg
Normal file
BIN
conf/DockerBot.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
85
conf/locales/en.json
Normal file
85
conf/locales/en.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"app.title": "DockerBot 🤖",
|
||||||
|
"app.welcome": "Hello, {name}! 👋\n\nWelcome to <b>DockerBot</b>. 🤖\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": "<b>🤖 DockerBot</b>\n<i>Your container management assistant</i>\n\n<u>📦 Available commands</u>\n\n🔹 <b>/docker</b>\n └ List all containers\n\n🔹 <b>/docker info <container></b>\n └ Show detailed container info\n\n🔹 <b>/docker start|stop|pause|unpause|restart <container></b>\n └ Manage container state\n\n🔹 <b>/docker logs <container> [--tail N] [--follow]</b>\n └ View container logs\n\n🔹 <b>/docker help</b>\n └ Show this help message",
|
||||||
|
"app.headerList": "<b>📊 DOCKER STATUS</b>",
|
||||||
|
"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": "<b>📦 <u>{name}</u></b>",
|
||||||
|
"list.container.image": "<code>{image}</code>",
|
||||||
|
"list.container.state": "{emoji} <i>{state}</i>",
|
||||||
|
"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": "📦 <b>{name}</b>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
85
conf/locales/es.json
Normal file
85
conf/locales/es.json
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"app.title": "DockerBot 🤖",
|
||||||
|
"app.welcome": "¡Hola, {name}! 👋\n\nBienvenido a <b>DockerBot</b>. 🤖\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": "<b>🤖 DockerBot</b>\n<i>Tu asistente para gestionar contenedores</i>\n\n<u>📦 Comandos disponibles</u>\n\n🔹 <b>/docker</b>\n └ Lista todos los contenedores\n\n🔹 <b>/docker info <contenedor></b>\n └ Muestra información detallada\n\n🔹 <b>/docker start|stop|pause|unpause|restart <contenedor></b>\n └ Gestiona el estado del contenedor\n\n🔹 <b>/docker logs <contenedor> [--tail N] [--follow]</b>\n └ Muestra logs del contenedor\n\n🔹 <b>/docker help</b>\n └ Muestra esta ayuda",
|
||||||
|
"app.headerList": "<b>📊 ESTADO DE DOCKER</b>",
|
||||||
|
"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": "<b>📦 <u>{name}</u></b>",
|
||||||
|
"list.container.image": "<code>{image}</code>",
|
||||||
|
"list.container.state": "{emoji} <i>{state}</i>",
|
||||||
|
"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": "📦 <b>{name}</b>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
3
conf/users.json
Normal file
3
conf/users.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"2048330133": true
|
||||||
|
}
|
||||||
32
go.mod
Normal file
32
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
65
go.sum
Normal file
65
go.sum
Normal file
@@ -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=
|
||||||
71
handleDockerAction.go
Normal file
71
handleDockerAction.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
198
handleDockerInfo.go
Normal file
198
handleDockerInfo.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
154
handleDockerList.go
Normal file
154
handleDockerList.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
256
handleDockerLogs.go
Normal file
256
handleDockerLogs.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/apperrors/apperrors.go
Normal file
5
internal/apperrors/apperrors.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package apperrors
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var ErrContainerNotFound = errors.New("container_not_found")
|
||||||
87
internal/auth/auth.go
Normal file
87
internal/auth/auth.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
83
internal/cache/cache.go
vendored
Normal file
83
internal/cache/cache.go
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
65
internal/config/config.go
Normal file
65
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
238
internal/docker/docker.go
Normal file
238
internal/docker/docker.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
160
internal/i18n/i18n.go
Normal file
160
internal/i18n/i18n.go
Normal file
@@ -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, ".")
|
||||||
|
}
|
||||||
42
internal/logger/logger.go
Normal file
42
internal/logger/logger.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
450
internal/ui/ui.go
Normal file
450
internal/ui/ui.go
Normal file
@@ -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("<pre>")
|
||||||
|
sb.WriteString(v.Logs)
|
||||||
|
sb.WriteString("</pre>")
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
303
main.go
Normal file
303
main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
104
middelwares.go
Normal file
104
middelwares.go
Normal file
@@ -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 = "<no message>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
utils.go
Normal file
28
utils.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
89
view_containers.go
Normal file
89
view_containers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user