feat: Add AlexHost deployment pipeline and bot control functionality
All checks were successful
Build and Deploy LittleShop / Deploy to Pre-Production (CT109) (push) Successful in 59s
Build and Deploy LittleShop / Deploy to Production VPS (Manual Only) (push) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
SysAdmin 2025-11-26 12:33:46 +00:00
parent 0997cc8c57
commit 86f19ba044
20 changed files with 1186 additions and 37 deletions

View File

@ -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"

View File

@ -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

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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
}

View File

@ -61,6 +61,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);
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);
}

View File

@ -180,7 +180,7 @@
<hr />
<div class="d-flex gap-2">
<div class="d-flex gap-2 flex-wrap">
<form asp-area="Admin" asp-controller="Bots" asp-action="CheckRemoteStatus" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-info">
@ -195,6 +195,37 @@
</a>
}
</div>
@if (Model.DiscoveryStatus == "Configured")
{
<hr />
<h6 class="mb-2"><i class="fas fa-sliders-h"></i> Bot Control</h6>
<div class="d-flex gap-2 flex-wrap">
<form asp-area="Admin" asp-controller="Bots" asp-action="StartBot" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-success">
<i class="fas fa-play"></i> Start
</button>
</form>
<form asp-area="Admin" asp-controller="Bots" asp-action="StopBot" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to stop the bot? Users will not be able to interact with it.')">
<i class="fas fa-stop"></i> Stop
</button>
</form>
<form asp-area="Admin" asp-controller="Bots" asp-action="RestartBot" asp-route-id="@Model.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-warning">
<i class="fas fa-redo"></i> Restart
</button>
</form>
</div>
<small class="text-muted mt-2 d-block">
<i class="fas fa-info-circle"></i> Controls the Telegram polling connection on the remote bot instance.
</small>
}
</div>
</div>
}
@ -331,6 +362,7 @@
<hr />
<form action="/Admin/Bots/Delete/@Model.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-danger w-100"
onclick="return confirm('Are you sure you want to delete this bot? This action cannot be undone.')">
<i class="bi bi-trash"></i> Delete Bot

View File

@ -37,15 +37,17 @@
The TeleBot must be running and configured with the same discovery secret.
</p>
<form asp-area="Admin" asp-controller="Bots" asp-action="ProbeRemote" method="post">
<form action="/Admin/Bots/ProbeRemote" method="post">
@Html.AntiForgeryToken()
<div class="row mb-3">
<div class="col-md-8">
<label for="IpAddress" class="form-label">IP Address / Hostname</label>
<input name="IpAddress" id="IpAddress" value="@Model.IpAddress" class="form-control"
placeholder="e.g., 192.168.1.100 or telebot.example.com" required />
<small class="text-muted">The IP address or hostname where TeleBot is running</small>
placeholder="e.g., telebot, 193.233.245.41, or telebot.example.com" required />
<small class="text-muted">
Use <code>telebot</code> for same-server Docker deployments, or the public IP/hostname for remote servers
</small>
</div>
<div class="col-md-4">
<label for="Port" class="form-label">Port</label>
@ -107,7 +109,7 @@
<h5 class="mb-0"><i class="fas fa-robot"></i> Step 2: Register Bot</h5>
</div>
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="RegisterRemote" method="post">
<form action="/Admin/Bots/RegisterRemote" method="post">
@Html.AntiForgeryToken()
<!-- Hidden fields to preserve discovery data -->
@ -188,7 +190,7 @@
Now enter the Telegram bot token from BotFather to activate this bot.
</p>
<form asp-area="Admin" asp-controller="Bots" asp-action="ConfigureRemote" method="post">
<form action="/Admin/Bots/ConfigureRemote" method="post">
@Html.AntiForgeryToken()
<!-- Hidden fields -->

View File

@ -144,3 +144,22 @@ public static class DiscoveryStatus
public const string Offline = "Offline";
public const string Error = "Error";
}
/// <summary>
/// Result of a bot control action (start/stop/restart)
/// </summary>
public class BotControlResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>
/// Current bot status after the action
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Whether Telegram polling is currently running
/// </summary>
public bool IsRunning { get; set; }
}

View File

@ -369,13 +369,127 @@ public class BotDiscoveryService : IBotDiscoveryService
}
}
public async Task<BotControlResult> ControlBotAsync(Guid botId, string action)
{
_logger.LogInformation("Sending control action '{Action}' to bot {BotId}", action, botId);
try
{
// Get the bot details
var bot = await _botService.GetBotByIdAsync(botId);
if (bot == null)
{
return new BotControlResult
{
Success = false,
Message = "Bot not found"
};
}
if (string.IsNullOrEmpty(bot.RemoteAddress) || !bot.RemotePort.HasValue)
{
return new BotControlResult
{
Success = false,
Message = "Bot does not have remote address configured. Control only works for remote bots."
};
}
// Get the BotKey securely
var botKey = await _botService.GetBotKeyAsync(botId);
if (string.IsNullOrEmpty(botKey))
{
return new BotControlResult
{
Success = false,
Message = "Bot key not found"
};
}
var endpoint = BuildEndpoint(bot.RemoteAddress, bot.RemotePort.Value, "/api/discovery/control");
var payload = new { Action = action };
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
request.Headers.Add("X-Bot-Key", botKey);
request.Content = new StringContent(
JsonSerializer.Serialize(payload, JsonOptions),
Encoding.UTF8,
"application/json");
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var controlResponse = JsonSerializer.Deserialize<BotControlResult>(content, JsonOptions);
_logger.LogInformation("Bot control action '{Action}' completed for bot {BotId}: {Success}",
action, botId, controlResponse?.Success);
return controlResponse ?? new BotControlResult
{
Success = true,
Message = $"Action '{action}' completed"
};
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
return new BotControlResult
{
Success = false,
Message = "Invalid bot key. The bot may need to be re-initialized."
};
}
else
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Bot control action failed: {StatusCode} - {Content}",
response.StatusCode, errorContent);
return new BotControlResult
{
Success = false,
Message = $"Control action failed: {response.StatusCode}"
};
}
}
catch (TaskCanceledException)
{
_logger.LogWarning("Bot control timed out for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = "Connection timed out. The bot may be offline."
};
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Bot control connection failed for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = $"Connection failed: {ex.Message}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bot control for bot {BotId}", botId);
return new BotControlResult
{
Success = false,
Message = $"Error: {ex.Message}"
};
}
}
#region Private Methods
private string BuildEndpoint(string ipAddress, int port, string path)
{
// Use HTTP for local/private networks, HTTPS for public
var scheme = IsPrivateNetwork(ipAddress) ? "http" : "https";
return $"{scheme}://{ipAddress}:{port}{path}";
// Always use HTTP for discovery on custom ports
// HTTPS would require proper certificate setup which is unlikely on non-standard ports
// If HTTPS is needed, the reverse proxy should handle SSL termination
return $"http://{ipAddress}:{port}{path}";
}
private bool IsPrivateNetwork(string ipAddress)

View File

@ -65,7 +65,7 @@ public class BotService : IBotService
{
Id = Guid.NewGuid(),
Name = dto.Name,
Description = dto.Description,
Description = dto.Description ?? string.Empty,
Type = dto.Type,
BotKey = botKey,
Status = BotStatus.Active,
@ -158,6 +158,7 @@ public class BotService : IBotService
var bots = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.Where(b => b.Status != BotStatus.Deleted) // Filter out deleted bots
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();

View File

@ -31,4 +31,11 @@ public interface IBotDiscoveryService
/// Get the status of a remote TeleBot instance
/// </summary>
Task<DiscoveryProbeResponse?> GetRemoteStatusAsync(string ipAddress, int port, string botKey);
/// <summary>
/// Control a remote TeleBot instance (start/stop/restart)
/// </summary>
/// <param name="botId">The bot ID in LittleShop</param>
/// <param name="action">Action to perform: "start", "stop", or "restart"</param>
Task<BotControlResult> ControlBotAsync(Guid botId, string action);
}

View File

@ -7,7 +7,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Data Source=teleshop-production.db"
"DefaultConnection": "Data Source=/app/data/littleshop-production.db"
},
"Jwt": {
"Key": "${JWT_SECRET_KEY}",

View File

@ -219,6 +219,109 @@ public class DiscoveryController : ControllerBase
});
}
/// <summary>
/// Control the bot - start, stop, or restart Telegram polling
/// </summary>
[HttpPost("control")]
public async Task<IActionResult> Control([FromBody] BotControlRequest request)
{
// Require BotKey authentication for control actions
if (!ValidateBotKey())
{
_logger.LogWarning("Bot control rejected: invalid or missing X-Bot-Key");
return Unauthorized(new { error = "Invalid bot key" });
}
if (string.IsNullOrEmpty(request.Action))
{
return BadRequest(new BotControlResponse
{
Success = false,
Message = "Action is required (start, stop, restart)",
Status = _botManagerService.CurrentStatus,
IsRunning = _telegramBotService.IsRunning
});
}
var action = request.Action.ToLower();
_logger.LogInformation("Bot control action requested: {Action}", action);
try
{
bool success;
string message;
switch (action)
{
case "start":
if (_telegramBotService.IsRunning)
{
return Ok(new BotControlResponse
{
Success = false,
Message = "Bot is already running",
Status = _botManagerService.CurrentStatus,
IsRunning = true
});
}
success = await _telegramBotService.StartPollingAsync();
message = success ? "Bot started successfully" : "Failed to start bot";
break;
case "stop":
if (!_telegramBotService.IsRunning)
{
return Ok(new BotControlResponse
{
Success = false,
Message = "Bot is not running",
Status = _botManagerService.CurrentStatus,
IsRunning = false
});
}
_telegramBotService.StopPolling();
success = true;
message = "Bot stopped successfully";
break;
case "restart":
success = await _telegramBotService.RestartPollingAsync();
message = success ? "Bot restarted successfully" : "Failed to restart bot";
break;
default:
return BadRequest(new BotControlResponse
{
Success = false,
Message = $"Unknown action: {action}. Valid actions: start, stop, restart",
Status = _botManagerService.CurrentStatus,
IsRunning = _telegramBotService.IsRunning
});
}
_logger.LogInformation("Bot control action '{Action}' completed: {Success}", action, success);
return Ok(new BotControlResponse
{
Success = success,
Message = message,
Status = _botManagerService.CurrentStatus,
IsRunning = _telegramBotService.IsRunning
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during bot control action '{Action}'", action);
return StatusCode(500, new BotControlResponse
{
Success = false,
Message = $"Error: {ex.Message}",
Status = _botManagerService.CurrentStatus,
IsRunning = _telegramBotService.IsRunning
});
}
}
private bool ValidateDiscoverySecret()
{
var providedSecret = Request.Headers["X-Discovery-Secret"].ToString();

View File

@ -135,3 +135,33 @@ public class BotStatusUpdate
public DateTime LastActivityAt { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
/// <summary>
/// Request to control the bot (start/stop/restart)
/// </summary>
public class BotControlRequest
{
/// <summary>
/// Control action: "start", "stop", or "restart"
/// </summary>
public string Action { get; set; } = string.Empty;
}
/// <summary>
/// Response after a control action
/// </summary>
public class BotControlResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>
/// Current bot status after the action
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Whether Telegram polling is currently running
/// </summary>
public bool IsRunning { get; set; }
}

View File

@ -227,6 +227,125 @@ namespace TeleBot
return null;
}
/// <summary>
/// Start Telegram polling (if not already running)
/// </summary>
public async Task<bool> StartPollingAsync()
{
if (_isRunning)
{
_logger.LogWarning("Bot polling is already running");
return false;
}
if (string.IsNullOrEmpty(_currentBotToken))
{
_currentBotToken = _configuration["Telegram:BotToken"];
}
if (string.IsNullOrEmpty(_currentBotToken) || _currentBotToken == "YOUR_BOT_TOKEN_HERE")
{
_logger.LogError("Cannot start: No bot token configured");
return false;
}
try
{
// Create bot client with TOR support if enabled
var torEnabled = _configuration.GetValue<bool>("Privacy:EnableTor");
if (torEnabled)
{
var torSocksHost = _configuration.GetValue<string>("Privacy:TorSocksHost") ?? "127.0.0.1";
var torSocksPort = _configuration.GetValue<int>("Privacy:TorSocksPort", 9050);
var proxyUri = $"socks5://{torSocksHost}:{torSocksPort}";
var handler = new SocketsHttpHandler
{
Proxy = new WebProxy(proxyUri)
{
BypassProxyOnLocal = false,
UseDefaultCredentials = false
},
UseProxy = true,
AllowAutoRedirect = false,
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2)
};
var httpClient = new HttpClient(handler);
_botClient = new TelegramBotClient(_currentBotToken, httpClient);
}
else
{
_botClient = new TelegramBotClient(_currentBotToken);
}
_cancellationTokenSource = new CancellationTokenSource();
var receiverOptions = new ReceiverOptions
{
AllowedUpdates = Array.Empty<UpdateType>(),
ThrowPendingUpdates = true
};
_botClient.StartReceiving(
HandleUpdateAsync,
HandleErrorAsync,
receiverOptions,
cancellationToken: _cancellationTokenSource.Token
);
_isRunning = true;
var me = await _botClient.GetMeAsync();
_logger.LogInformation("Bot polling started: @{Username} ({Id})", me.Username, me.Id);
// Update message delivery service
if (_messageDeliveryService is MessageDeliveryService deliveryService)
{
deliveryService.SetBotClient(_botClient);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start bot polling");
_isRunning = false;
return false;
}
}
/// <summary>
/// Stop Telegram polling
/// </summary>
public void StopPolling()
{
if (!_isRunning)
{
_logger.LogWarning("Bot polling is not running");
return;
}
_cancellationTokenSource?.Cancel();
_isRunning = false;
_logger.LogInformation("Bot polling stopped");
}
/// <summary>
/// Restart Telegram polling
/// </summary>
public async Task<bool> RestartPollingAsync()
{
_logger.LogInformation("Restarting bot polling...");
StopPolling();
// Brief pause to ensure clean shutdown
await Task.Delay(500);
return await StartPollingAsync();
}
public async Task UpdateBotTokenAsync(string newToken)
{
// If bot wasn't started or token changed, start/restart

View File

@ -92,7 +92,7 @@
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5010"
"Url": "http://0.0.0.0:5010"
}
}
}

View File

@ -5,39 +5,49 @@ services:
build:
context: ../
dockerfile: TeleBot/TeleBot/Dockerfile
image: telebot:latest
image: localhost:5000/telebot:latest
container_name: telebot
restart: unless-stopped
ports:
- "5010:5010" # TeleBot API/health endpoint
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5010
- TelegramBot__BotToken=${BOT_TOKEN}
- TelegramBot__WebhookUrl=${WEBHOOK_URL}
- TelegramBot__UseWebhook=false
- LittleShopApi__BaseUrl=http://littleshop:5000
- LittleShopApi__BaseUrl=http://teleshop:8080
- LittleShopApi__ApiKey=${LITTLESHOP_API_KEY}
- Logging__LogLevel__Default=Information
- Logging__LogLevel__Microsoft=Warning
- Logging__LogLevel__Microsoft.Hosting.Lifetime=Information
volumes:
- ./logs:/app/logs
- ./data:/app/data
- ./image_cache:/app/image_cache
- /opt/telebot/logs:/app/logs
- /opt/telebot/data:/app/data
- /opt/telebot/image_cache:/app/image_cache
networks:
- littleshop-network
depends_on:
- littleshop
teleshop-network:
aliases:
- telebot
silverpay-network:
aliases:
- telebot
healthcheck:
test: ["CMD", "pgrep", "-f", "dotnet.*TeleBot"]
test: ["CMD", "curl", "-f", "http://localhost:5010/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
littleshop:
external: true
name: littleshop
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
littleshop-network:
teleshop-network:
name: sysadmin_teleshop-network
external: true
silverpay-network:
name: silverdotpay_silverdotpay-network
external: true
name: littleshop-network

249
deploy-alexhost.sh Normal file
View File

@ -0,0 +1,249 @@
#!/bin/bash
# AlexHost Deployment Script
# Usage: ./deploy-alexhost.sh [teleshop|telebot|all] [--no-cache]
#
# This script transfers source to AlexHost and builds Docker images natively
# on the server to ensure correct architecture (AMD64).
#
# Requirements:
# - sshpass installed (for password-based SSH)
# - tar installed
# - Access to AlexHost server
set -e
# Configuration - can be overridden by environment variables
ALEXHOST_IP="${ALEXHOST_IP:-193.233.245.41}"
ALEXHOST_USER="${ALEXHOST_USER:-sysadmin}"
ALEXHOST_PASS="${ALEXHOST_PASS:-}"
REGISTRY="${REGISTRY:-localhost:5000}"
# Check for required password
if [ -z "$ALEXHOST_PASS" ]; then
echo -e "${RED}Error: ALEXHOST_PASS environment variable is required${NC}"
echo "Set it with: export ALEXHOST_PASS='your-password'"
exit 1
fi
DEPLOY_DIR="/home/sysadmin/teleshop-source"
BUILD_DIR="/tmp/littleshop-build"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Parse arguments
DEPLOY_TARGET="${1:-all}"
NO_CACHE=""
if [[ "$2" == "--no-cache" ]] || [[ "$1" == "--no-cache" ]]; then
NO_CACHE="--no-cache"
if [[ "$1" == "--no-cache" ]]; then
DEPLOY_TARGET="all"
fi
fi
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} AlexHost Deployment Script${NC}"
echo -e "${BLUE} Target: ${DEPLOY_TARGET}${NC}"
echo -e "${BLUE} Server: ${ALEXHOST_IP}${NC}"
echo -e "${BLUE} Mode: Server-side build (AMD64)${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Function to run SSH commands with sudo
ssh_sudo() {
sshpass -p "$ALEXHOST_PASS" ssh -o StrictHostKeyChecking=no "$ALEXHOST_USER@$ALEXHOST_IP" "echo '$ALEXHOST_PASS' | sudo -S bash -c '$1'"
}
# Function to copy files to AlexHost
scp_file() {
sshpass -p "$ALEXHOST_PASS" scp -o StrictHostKeyChecking=no "$1" "$ALEXHOST_USER@$ALEXHOST_IP:$2"
}
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Transfer source to server
transfer_source() {
echo -e "${YELLOW}=== Transferring source to AlexHost ===${NC}"
# Create tarball excluding unnecessary files
echo "Creating source tarball..."
tar -czf /tmp/littleshop-source.tar.gz \
--exclude='.git' \
--exclude='node_modules' \
--exclude='bin' \
--exclude='obj' \
--exclude='*.tar.gz' \
-C "$SCRIPT_DIR" .
echo "Source tarball size: $(ls -lh /tmp/littleshop-source.tar.gz | awk '{print $5}')"
# Transfer to server
echo "Transferring to AlexHost..."
scp_file "/tmp/littleshop-source.tar.gz" "/tmp/"
scp_file "docker-compose.alexhost.yml" "/tmp/"
# Extract on server
echo "Extracting on server..."
ssh_sudo "rm -rf $BUILD_DIR && mkdir -p $BUILD_DIR && cd $BUILD_DIR && tar -xzf /tmp/littleshop-source.tar.gz"
# Cleanup local
rm -f /tmp/littleshop-source.tar.gz
echo -e "${GREEN}Source transferred successfully!${NC}"
}
# Deploy TeleShop
deploy_teleshop() {
echo -e "${YELLOW}=== Building TeleShop on AlexHost ===${NC}"
ssh_sudo "
set -e
cd $BUILD_DIR
echo 'Building TeleShop image...'
docker build $NO_CACHE -t littleshop:latest -f Dockerfile . 2>&1 | tail -15
echo 'Tagging and pushing to local registry...'
docker tag littleshop:latest localhost:5000/littleshop:latest
docker push localhost:5000/littleshop:latest
echo 'Stopping existing container...'
docker stop teleshop 2>/dev/null || true
docker rm teleshop 2>/dev/null || true
echo 'Copying compose file...'
mkdir -p $DEPLOY_DIR
cp /tmp/docker-compose.alexhost.yml $DEPLOY_DIR/docker-compose.yml
echo 'Starting TeleShop...'
cd $DEPLOY_DIR
docker compose up -d teleshop
echo 'Waiting for health check...'
sleep 30
docker ps | grep teleshop
"
echo -e "${GREEN}TeleShop deployment complete!${NC}"
}
# Deploy TeleBot
deploy_telebot() {
echo -e "${YELLOW}=== Building TeleBot on AlexHost ===${NC}"
ssh_sudo "
set -e
cd $BUILD_DIR
echo 'Building TeleBot image...'
docker build $NO_CACHE -t telebot:latest -f Dockerfile.telebot . 2>&1 | tail -15
echo 'Tagging and pushing to local registry...'
docker tag telebot:latest localhost:5000/telebot:latest
docker push localhost:5000/telebot:latest
echo 'Stopping existing container...'
docker stop telebot 2>/dev/null || true
docker rm telebot 2>/dev/null || true
echo 'Copying compose file...'
mkdir -p $DEPLOY_DIR
cp /tmp/docker-compose.alexhost.yml $DEPLOY_DIR/docker-compose.yml 2>/dev/null || true
echo 'Starting TeleBot...'
cd $DEPLOY_DIR
docker compose up -d telebot
echo 'Waiting for startup...'
sleep 20
docker ps | grep telebot
"
echo -e "${GREEN}TeleBot deployment complete!${NC}"
}
# Verify deployment
verify_deployment() {
echo -e "${YELLOW}=== Verifying Deployment ===${NC}"
ssh_exec "
echo ''
echo 'Container Status:'
sudo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep -E 'NAMES|teleshop|telebot'
echo ''
echo 'Testing TeleShop health...'
if curl -sf http://localhost:5100/health > /dev/null; then
echo 'TeleShop: OK'
else
echo 'TeleShop: FAIL or starting...'
fi
echo ''
echo 'Testing TeleBot...'
if sudo docker ps | grep -q 'telebot.*Up'; then
echo 'TeleBot: Running'
else
echo 'TeleBot: Not running'
fi
"
}
# Cleanup build directory
cleanup() {
echo -e "${YELLOW}=== Cleaning up ===${NC}"
ssh_sudo "rm -rf $BUILD_DIR /tmp/littleshop-source.tar.gz"
echo -e "${GREEN}Cleanup complete${NC}"
}
# Main execution
case "$DEPLOY_TARGET" in
teleshop)
transfer_source
deploy_teleshop
cleanup
verify_deployment
;;
telebot)
transfer_source
deploy_telebot
cleanup
verify_deployment
;;
all)
transfer_source
deploy_teleshop
deploy_telebot
cleanup
verify_deployment
;;
verify)
verify_deployment
;;
*)
echo -e "${RED}Usage: $0 [teleshop|telebot|all|verify] [--no-cache]${NC}"
echo ""
echo "Examples:"
echo " $0 all # Deploy both services"
echo " $0 teleshop # Deploy only TeleShop"
echo " $0 telebot # Deploy only TeleBot"
echo " $0 all --no-cache # Deploy both without Docker cache"
echo " $0 verify # Just verify current deployment"
exit 1
;;
esac
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} Deployment Complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Access points:"
echo " TeleShop Admin: https://teleshop.silentmary.mywire.org/Admin"
echo " TeleShop API: https://teleshop.silentmary.mywire.org/api"
echo " TeleBot API: http://${ALEXHOST_IP}:5010"

124
docker-compose.alexhost.yml Normal file
View File

@ -0,0 +1,124 @@
# AlexHost Deployment Configuration
# Server: 193.233.245.41 (alexhost.silentmary.mywire.org)
# Registry: localhost:5000
version: '3.8'
services:
teleshop:
image: localhost:5000/littleshop:latest
container_name: teleshop
restart: unless-stopped
ports:
- "5100:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080
- ConnectionStrings__DefaultConnection=Data Source=/app/data/littleshop-production.db
# JWT Configuration
- Jwt__Key=ThisIsAVeryLongSecretKeyThatIsDefinitelyLongerThan32BytesForSure123456789ABCDEF
- Jwt__Issuer=LittleShop-Production
- Jwt__Audience=LittleShop-Production
- Jwt__ExpiryInHours=24
# SilverPay Configuration
- SilverPay__BaseUrl=http://silverdotpay-api:8080
- SilverPay__PublicUrl=https://pay.thebankofdebbie.giize.com
- SilverPay__ApiKey=7703aa7a62fa4b40a87e9cfd867f5407147515c0986116ea54fc00c0a0bc30d8
- SilverPay__WebhookSecret=Thefa1r1esd1d1twebhooks2024
- SilverPay__DefaultWebhookUrl=https://admin.thebankofdebbie.giize.com/api/orders/payments/webhook
- SilverPay__AllowUnsignedWebhooks=false
# Admin Credentials
- AdminUser__Username=admin
- AdminUser__Password=Thefa1r1esd1d1t
# WebPush Notifications
- WebPush__VapidPublicKey=BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4
- WebPush__VapidPrivateKey=dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY
- WebPush__Subject=mailto:admin@thebankofdebbie.giize.com
# Bot Discovery Configuration
- BotDiscovery__SharedSecret=AlexHostDiscovery2025SecretKey
- BotDiscovery__WebhookSecret=AlexHostWebhook2025SecretKey
- BotDiscovery__LittleShopApiUrl=https://admin.thebankofdebbie.giize.com
volumes:
- /opt/littleshop/data:/app/data
- /opt/littleshop/uploads:/app/wwwroot/uploads
- /opt/littleshop/logs:/app/logs
networks:
teleshop-network:
aliases:
- teleshop
- littleshop
silverpay-network:
aliases:
- teleshop
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
telebot:
image: localhost:5000/telebot:latest
container_name: telebot
restart: unless-stopped
ports:
- "5010:5010"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5010
# LittleShop API Connection (internal network)
- LittleShop__ApiUrl=http://teleshop:8080
- LittleShop__UseTor=false
# Telegram Bot Token (set via environment or will be configured via discovery)
- Telegram__BotToken=${TELEGRAM_BOT_TOKEN:-}
# Discovery Configuration (must match TeleShop)
- Discovery__Secret=AlexHostDiscovery2025SecretKey
# Privacy Settings
- Privacy__EnableTor=false
volumes:
- /opt/telebot/data:/app/data
- /opt/telebot/logs:/app/logs
- /opt/telebot/image_cache:/app/image_cache
networks:
teleshop-network:
aliases:
- telebot
silverpay-network:
depends_on:
teleshop:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5010/health || pgrep -f 'dotnet.*TeleBot' > /dev/null"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
teleshop-network:
name: sysadmin_teleshop-network
external: true
silverpay-network:
name: silverdotpay_silverdotpay-network
external: true

View File

@ -1,12 +1,12 @@
version: '3.8'
services:
littleshop:
teleshop:
image: localhost:5000/littleshop:latest
container_name: littleshop-admin
container_name: teleshop
restart: unless-stopped
ports:
- "127.0.0.1:5100:8080" # Local only, BunkerWeb will proxy
- "5100:8080" # External access on port 5100
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080 # CRITICAL: Must use URLS not HTTP_PORTS
@ -19,7 +19,7 @@ services:
- Jwt__ExpiryInHours=24
# SilverPay Configuration (pay.thebankofdebbie.giize.com)
- SilverPay__BaseUrl=http://silverpay-api:8000 # Internal Docker network - correct port
- SilverPay__BaseUrl=http://silverdotpay-api:8080 # Internal Docker network via silverpay-network
- SilverPay__PublicUrl=https://pay.thebankofdebbie.giize.com
- SilverPay__ApiKey=7703aa7a62fa4b40a87e9cfd867f5407147515c0986116ea54fc00c0a0bc30d8
- SilverPay__WebhookSecret=Thefa1r1esd1d1twebhooks2024
@ -44,7 +44,13 @@ services:
- /opt/littleshop/uploads:/app/wwwroot/uploads
- /opt/littleshop/logs:/app/logs
networks:
- littleshop-network # Shared network for container communication
teleshop-network:
aliases:
- teleshop
- littleshop
silverpay-network:
aliases:
- teleshop
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
@ -58,5 +64,9 @@ services:
max-file: "3"
networks:
littleshop-network:
teleshop-network:
name: sysadmin_teleshop-network
external: true
silverpay-network:
name: silverdotpay_silverdotpay-network
external: true

View File

@ -11,7 +11,7 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5000
- ConnectionStrings__DefaultConnection=Data Source=/app/data/teleshop-prod.db
- ConnectionStrings__DefaultConnection=Data Source=/app/data/littleshop-production.db
- Jwt__Key=LittleShop-Production-JWT-SecretKey-32Characters-2025
- Jwt__Issuer=LittleShop
- Jwt__Audience=LittleShop