From 86f19ba044454f22376df579f5cdebe4f109fa96 Mon Sep 17 00:00:00 2001
From: SysAdmin
Date: Wed, 26 Nov 2025 12:33:46 +0000
Subject: [PATCH] feat: Add AlexHost deployment pipeline and bot control
functionality
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add Gitea Actions workflow for manual AlexHost deployment
- Add docker-compose.alexhost.yml for production deployment
- Add deploy-alexhost.sh script with server-side build support
- Add Bot Control feature (Start/Stop/Restart) for remote bot management
- Add discovery control endpoint in TeleBot
- Update TeleBot with StartPollingAsync/StopPolling/RestartPollingAsync
- Fix platform architecture issues by building on target server
- Update docker-compose configurations for all environments
Deployment tested successfully:
- TeleShop: healthy at https://teleshop.silentmary.mywire.org
- TeleBot: healthy with discovery integration
- SilverPay: connectivity verified
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.gitea/workflows/deploy-alexhost.yml | 193 ++++++++++++++
Dockerfile | 2 +-
.../Areas/Admin/Controllers/BotsController.cs | 126 +++++++++
.../Admin/Controllers/ProductsController.cs | 12 +-
.../Areas/Admin/Views/Bots/Details.cshtml | 36 ++-
.../Admin/Views/Bots/DiscoverRemote.cshtml | 12 +-
LittleShop/DTOs/BotDiscoveryDto.cs | 19 ++
LittleShop/Services/BotDiscoveryService.cs | 120 ++++++++-
LittleShop/Services/BotService.cs | 3 +-
LittleShop/Services/IBotDiscoveryService.cs | 7 +
LittleShop/appsettings.Production.json | 2 +-
.../Controllers/DiscoveryController.cs | 103 ++++++++
TeleBot/TeleBot/DTOs/DiscoveryDtos.cs | 30 +++
TeleBot/TeleBot/TelegramBotService.cs | 119 +++++++++
TeleBot/TeleBot/appsettings.json | 2 +-
TeleBot/docker-compose.hostinger.yml | 40 +--
deploy-alexhost.sh | 249 ++++++++++++++++++
docker-compose.alexhost.yml | 124 +++++++++
docker-compose.hostinger.yml | 22 +-
docker-compose.yml | 2 +-
20 files changed, 1186 insertions(+), 37 deletions(-)
create mode 100644 .gitea/workflows/deploy-alexhost.yml
create mode 100644 deploy-alexhost.sh
create mode 100644 docker-compose.alexhost.yml
diff --git a/.gitea/workflows/deploy-alexhost.yml b/.gitea/workflows/deploy-alexhost.yml
new file mode 100644
index 0000000..f3bed1c
--- /dev/null
+++ b/.gitea/workflows/deploy-alexhost.yml
@@ -0,0 +1,193 @@
+# Gitea Actions Workflow for AlexHost Deployment
+# This workflow provides manual deployment to the AlexHost production server
+# Server: 193.233.245.41 (teleshop.silentmary.mywire.org)
+
+name: Deploy to AlexHost
+
+on:
+ workflow_dispatch:
+ inputs:
+ deploy_teleshop:
+ description: 'Deploy TeleShop (LittleShop)'
+ required: true
+ default: 'true'
+ type: boolean
+ deploy_telebot:
+ description: 'Deploy TeleBot'
+ required: true
+ default: 'true'
+ type: boolean
+ force_rebuild:
+ description: 'Force rebuild without cache'
+ required: false
+ default: 'false'
+ type: boolean
+
+env:
+ ALEXHOST_IP: 193.233.245.41
+ ALEXHOST_USER: sysadmin
+ REGISTRY: localhost:5000
+ TELESHOP_IMAGE: littleshop
+ TELEBOT_IMAGE: telebot
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build TeleShop Image
+ if: ${{ inputs.deploy_teleshop == 'true' }}
+ run: |
+ echo "Building TeleShop image..."
+ CACHE_FLAG=""
+ if [ "${{ inputs.force_rebuild }}" = "true" ]; then
+ CACHE_FLAG="--no-cache"
+ fi
+ docker build $CACHE_FLAG -t ${{ env.TELESHOP_IMAGE }}:${{ github.sha }} -t ${{ env.TELESHOP_IMAGE }}:latest -f Dockerfile .
+ docker save ${{ env.TELESHOP_IMAGE }}:latest | gzip > teleshop-image.tar.gz
+ echo "TeleShop image built successfully"
+
+ - name: Build TeleBot Image
+ if: ${{ inputs.deploy_telebot == 'true' }}
+ run: |
+ echo "Building TeleBot image..."
+ CACHE_FLAG=""
+ if [ "${{ inputs.force_rebuild }}" = "true" ]; then
+ CACHE_FLAG="--no-cache"
+ fi
+ docker build $CACHE_FLAG -t ${{ env.TELEBOT_IMAGE }}:${{ github.sha }} -t ${{ env.TELEBOT_IMAGE }}:latest -f Dockerfile.telebot .
+ docker save ${{ env.TELEBOT_IMAGE }}:latest | gzip > telebot-image.tar.gz
+ echo "TeleBot image built successfully"
+
+ - name: Configure SSH
+ run: |
+ mkdir -p ~/.ssh
+ echo "${{ secrets.ALEXHOST_SSH_KEY }}" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ ssh-keyscan -H ${{ env.ALEXHOST_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
+
+ - name: Copy TeleShop Image to AlexHost
+ if: ${{ inputs.deploy_teleshop == 'true' }}
+ run: |
+ echo "Transferring TeleShop image to AlexHost..."
+ scp -o StrictHostKeyChecking=no teleshop-image.tar.gz ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/
+ echo "TeleShop image transferred"
+
+ - name: Copy TeleBot Image to AlexHost
+ if: ${{ inputs.deploy_telebot == 'true' }}
+ run: |
+ echo "Transferring TeleBot image to AlexHost..."
+ scp -o StrictHostKeyChecking=no telebot-image.tar.gz ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/
+ echo "TeleBot image transferred"
+
+ - name: Copy Docker Compose to AlexHost
+ run: |
+ echo "Copying deployment files..."
+ scp -o StrictHostKeyChecking=no docker-compose.alexhost.yml ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }}:/tmp/
+
+ - name: Deploy TeleShop on AlexHost
+ if: ${{ inputs.deploy_teleshop == 'true' }}
+ run: |
+ ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'DEPLOY_EOF'
+ set -e
+ echo "=== Deploying TeleShop ==="
+
+ # Load image
+ echo "Loading TeleShop image..."
+ gunzip -c /tmp/teleshop-image.tar.gz | sudo docker load
+
+ # Tag and push to local registry
+ echo "Pushing to local registry..."
+ sudo docker tag littleshop:latest localhost:5000/littleshop:latest
+ sudo docker push localhost:5000/littleshop:latest
+
+ # Stop and remove existing container
+ echo "Stopping existing container..."
+ sudo docker stop teleshop 2>/dev/null || true
+ sudo docker rm teleshop 2>/dev/null || true
+
+ # Start new container using compose
+ echo "Starting new container..."
+ cd /home/sysadmin/teleshop-source 2>/dev/null || mkdir -p /home/sysadmin/teleshop-source
+ cp /tmp/docker-compose.alexhost.yml /home/sysadmin/teleshop-source/docker-compose.yml
+ cd /home/sysadmin/teleshop-source
+ sudo docker compose up -d teleshop
+
+ # Wait for health check
+ echo "Waiting for health check..."
+ sleep 30
+ if sudo docker ps | grep -q "teleshop.*healthy"; then
+ echo "TeleShop deployed successfully!"
+ else
+ echo "Warning: Container may still be starting..."
+ sudo docker ps | grep teleshop
+ fi
+
+ # Cleanup
+ rm /tmp/teleshop-image.tar.gz
+ echo "=== TeleShop deployment complete ==="
+ DEPLOY_EOF
+
+ - name: Deploy TeleBot on AlexHost
+ if: ${{ inputs.deploy_telebot == 'true' }}
+ run: |
+ ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'DEPLOY_EOF'
+ set -e
+ echo "=== Deploying TeleBot ==="
+
+ # Load image
+ echo "Loading TeleBot image..."
+ gunzip -c /tmp/telebot-image.tar.gz | sudo docker load
+
+ # Tag and push to local registry
+ echo "Pushing to local registry..."
+ sudo docker tag telebot:latest localhost:5000/telebot:latest
+ sudo docker push localhost:5000/telebot:latest
+
+ # Stop and remove existing container
+ echo "Stopping existing container..."
+ sudo docker stop telebot 2>/dev/null || true
+ sudo docker rm telebot 2>/dev/null || true
+
+ # Start new container using compose
+ echo "Starting new container..."
+ cd /home/sysadmin/teleshop-source
+ sudo docker compose up -d telebot
+
+ # Wait for startup
+ echo "Waiting for startup..."
+ sleep 20
+ sudo docker ps | grep telebot
+
+ # Cleanup
+ rm /tmp/telebot-image.tar.gz
+ echo "=== TeleBot deployment complete ==="
+ DEPLOY_EOF
+
+ - name: Verify Deployment
+ run: |
+ ssh -o StrictHostKeyChecking=no ${{ env.ALEXHOST_USER }}@${{ env.ALEXHOST_IP }} << 'VERIFY_EOF'
+ echo "=== Deployment Verification ==="
+ echo ""
+ echo "Running Containers:"
+ sudo docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+ echo ""
+ echo "Testing TeleShop health..."
+ curl -sf http://localhost:5100/health && echo "TeleShop: OK" || echo "TeleShop: FAIL"
+ echo ""
+ echo "Testing TeleBot health..."
+ curl -sf http://localhost:5010/health 2>/dev/null && echo "TeleBot: OK" || echo "TeleBot: API endpoint not exposed (normal for bot-only mode)"
+ echo ""
+ echo "=== Verification complete ==="
+ VERIFY_EOF
+
+ - name: Cleanup Local Artifacts
+ if: always()
+ run: |
+ rm -f teleshop-image.tar.gz telebot-image.tar.gz
+ echo "Cleanup complete"
diff --git a/Dockerfile b/Dockerfile
index f62057e..2367c87 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -74,7 +74,7 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=0 \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production \
- ConnectionStrings__DefaultConnection="Data Source=/app/data/teleshop-prod.db;Cache=Shared" \
+ ConnectionStrings__DefaultConnection="Data Source=/app/data/littleshop-production.db;Cache=Shared" \
SilverPay__BaseUrl="http://31.97.57.205:8001" \
SilverPay__ApiKey="your-api-key-here" \
TMPDIR=/tmp
diff --git a/LittleShop/Areas/Admin/Controllers/BotsController.cs b/LittleShop/Areas/Admin/Controllers/BotsController.cs
index 8ffc0ff..777ec86 100644
--- a/LittleShop/Areas/Admin/Controllers/BotsController.cs
+++ b/LittleShop/Areas/Admin/Controllers/BotsController.cs
@@ -793,5 +793,131 @@ public class BotsController : Controller
await _botService.UpdateRemoteInfoAsync(botId, ipAddress, port, instanceId, status);
}
+ // POST: Admin/Bots/StartBot/5
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task StartBot(Guid id)
+ {
+ _logger.LogInformation("Start bot requested for {BotId}", id);
+
+ var bot = await _botService.GetBotByIdAsync(id);
+ if (bot == null)
+ {
+ TempData["Error"] = "Bot not found";
+ return RedirectToAction(nameof(Index));
+ }
+
+ if (!bot.IsRemote)
+ {
+ TempData["Error"] = "Bot control is only available for remote bots";
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ try
+ {
+ var result = await _discoveryService.ControlBotAsync(id, "start");
+
+ if (result.Success)
+ {
+ TempData["Success"] = result.Message;
+ }
+ else
+ {
+ TempData["Error"] = result.Message;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start bot {BotId}", id);
+ TempData["Error"] = $"Failed to start bot: {ex.Message}";
+ }
+
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ // POST: Admin/Bots/StopBot/5
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task StopBot(Guid id)
+ {
+ _logger.LogInformation("Stop bot requested for {BotId}", id);
+
+ var bot = await _botService.GetBotByIdAsync(id);
+ if (bot == null)
+ {
+ TempData["Error"] = "Bot not found";
+ return RedirectToAction(nameof(Index));
+ }
+
+ if (!bot.IsRemote)
+ {
+ TempData["Error"] = "Bot control is only available for remote bots";
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ try
+ {
+ var result = await _discoveryService.ControlBotAsync(id, "stop");
+
+ if (result.Success)
+ {
+ TempData["Success"] = result.Message;
+ }
+ else
+ {
+ TempData["Error"] = result.Message;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to stop bot {BotId}", id);
+ TempData["Error"] = $"Failed to stop bot: {ex.Message}";
+ }
+
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ // POST: Admin/Bots/RestartBot/5
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task RestartBot(Guid id)
+ {
+ _logger.LogInformation("Restart bot requested for {BotId}", id);
+
+ var bot = await _botService.GetBotByIdAsync(id);
+ if (bot == null)
+ {
+ TempData["Error"] = "Bot not found";
+ return RedirectToAction(nameof(Index));
+ }
+
+ if (!bot.IsRemote)
+ {
+ TempData["Error"] = "Bot control is only available for remote bots";
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
+ try
+ {
+ var result = await _discoveryService.ControlBotAsync(id, "restart");
+
+ if (result.Success)
+ {
+ TempData["Success"] = result.Message;
+ }
+ else
+ {
+ TempData["Error"] = result.Message;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to restart bot {BotId}", id);
+ TempData["Error"] = $"Failed to restart bot: {ex.Message}";
+ }
+
+ return RedirectToAction(nameof(Details), new { id });
+ }
+
#endregion
}
\ No newline at end of file
diff --git a/LittleShop/Areas/Admin/Controllers/ProductsController.cs b/LittleShop/Areas/Admin/Controllers/ProductsController.cs
index 3e8b05a..4fa6d5d 100644
--- a/LittleShop/Areas/Admin/Controllers/ProductsController.cs
+++ b/LittleShop/Areas/Admin/Controllers/ProductsController.cs
@@ -58,9 +58,14 @@ public class ProductsController : Controller
if (!ModelState.IsValid)
{
-
+
var categories = await _categoryService.GetAllCategoriesAsync();
ViewData["Categories"] = categories.Where(c => c.IsActive);
+
+ // FIX: Re-populate VariantCollections for view rendering when validation fails
+ var variantCollections = await _variantCollectionService.GetAllVariantCollectionsAsync();
+ ViewData["VariantCollections"] = variantCollections.Where(vc => vc.IsActive);
+
return View(model);
}
@@ -134,6 +139,11 @@ public class ProductsController : Controller
{
var categories = await _categoryService.GetAllCategoriesAsync();
ViewData["Categories"] = categories.Where(c => c.IsActive);
+
+ // FIX: Re-populate VariantCollections for view rendering when validation fails
+ var variantCollections = await _variantCollectionService.GetAllVariantCollectionsAsync();
+ ViewData["VariantCollections"] = variantCollections.Where(vc => vc.IsActive);
+
ViewData["ProductId"] = id;
return View(model);
}
diff --git a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml
index 68a8677..0fca262 100644
--- a/LittleShop/Areas/Admin/Views/Bots/Details.cshtml
+++ b/LittleShop/Areas/Admin/Views/Bots/Details.cshtml
@@ -180,7 +180,7 @@
-
+
+
+ @if (Model.DiscoveryStatus == "Configured")
+ {
+
+
Bot Control
+
+
+
+
+
+
+
+
+ Controls the Telegram polling connection on the remote bot instance.
+
+ }
}
@@ -331,7 +362,8 @@
-