discord-bot-update

This commit is contained in:
hhftechnologies 2026-04-23 16:08:51 +05:30
parent fd455dabec
commit 81b28d62a8
15 changed files with 1404 additions and 264 deletions

View file

@ -17,3 +17,21 @@ export async function testBot(telegramToken: string, allowedChatId: string) {
return response.json() as Promise<{ success: boolean; message: string }>;
}
export async function testDiscordBot(botToken: string, allowedChannelId: string) {
const response = await authenticatedFetch(
`${API_BASE_URL}/api/v1/settings/test/discord-bot`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botToken, allowedChannelId }),
},
);
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Failed to test Discord bot");
}
return response.json() as Promise<{ success: boolean; message: string }>;
}

View file

@ -8,6 +8,13 @@ export interface UpdateBotPayload {
mode: "polling" | "jwt-relay";
telegramToken: string;
allowedChatId: string;
discord: {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
};
}
export async function updateBot(payload: UpdateBotPayload): Promise<string> {

View file

@ -21,7 +21,11 @@ import {
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useTestBot, useUpdateBot } from "../hooks/use-settings";
import {
useTestBot,
useTestDiscordBot,
useUpdateBot,
} from "../hooks/use-settings";
import type { BotConfig } from "../types";
import { EnvBadge } from "./env-badge";
@ -41,25 +45,49 @@ export function BotSection({
const [mode, setMode] = useState(config.mode);
const [telegramToken, setTelegramToken] = useState(config.telegramToken);
const [allowedChatId, setAllowedChatId] = useState(config.allowedChatId);
const [discordEnabled, setDiscordEnabled] = useState(config.discord.enabled);
const [discordBotToken, setDiscordBotToken] = useState(config.discord.botToken);
const [discordApplicationId, setDiscordApplicationId] = useState(
config.discord.applicationId,
);
const [discordGuildId, setDiscordGuildId] = useState(config.discord.guildId);
const [discordAllowedChannelId, setDiscordAllowedChannelId] = useState(
config.discord.allowedChannelId,
);
const updateMutation = useUpdateBot();
const testMutation = useTestBot();
const discordTestMutation = useTestDiscordBot();
useEffect(() => {
setEnabled(config.enabled);
setMode(config.mode);
setTelegramToken(config.telegramToken);
setAllowedChatId(config.allowedChatId);
setDiscordEnabled(config.discord.enabled);
setDiscordBotToken(config.discord.botToken);
setDiscordApplicationId(config.discord.applicationId);
setDiscordGuildId(config.discord.guildId);
setDiscordAllowedChannelId(config.discord.allowedChannelId);
}, [config]);
const hasChanges =
enabled !== config.enabled ||
mode !== config.mode ||
telegramToken !== config.telegramToken ||
allowedChatId !== config.allowedChatId;
allowedChatId !== config.allowedChatId ||
discordEnabled !== config.discord.enabled ||
discordBotToken !== config.discord.botToken ||
discordApplicationId !== config.discord.applicationId ||
discordGuildId !== config.discord.guildId ||
discordAllowedChannelId !== config.discord.allowedChannelId;
const controlsDisabled =
disabled || isEnv || updateMutation.isPending || testMutation.isPending;
disabled ||
isEnv ||
updateMutation.isPending ||
testMutation.isPending ||
discordTestMutation.isPending;
const handleSave = () => {
updateMutation.mutate(
@ -68,6 +96,13 @@ export function BotSection({
mode,
telegramToken,
allowedChatId,
discord: {
enabled: discordEnabled,
botToken: discordBotToken,
applicationId: discordApplicationId,
guildId: discordGuildId,
allowedChannelId: discordAllowedChannelId,
},
},
{
onSuccess: (message) => toast.success(message),
@ -92,117 +127,245 @@ export function BotSection({
);
};
const handleDiscordTest = () => {
discordTestMutation.mutate(
{
botToken: discordBotToken,
allowedChannelId: discordAllowedChannelId,
},
{
onSuccess: (result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
},
onError: (error) => toast.error(error.message),
},
);
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Telegram Bot</CardTitle>
{isEnv && <EnvBadge />}
</div>
<CardDescription>
Configure the Telegram bot for `/help`, `/status`, and `/critical`
commands.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="bot-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="bot-enabled" className="cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="bot-mode">Mode</Label>
<Select
value={mode}
onValueChange={(value) => setMode(value as BotConfig["mode"])}
disabled={controlsDisabled}
>
<SelectTrigger id="bot-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="polling">Polling</SelectItem>
<SelectItem value="jwt-relay" disabled={!authEnabled}>
JWT Relay
</SelectItem>
</SelectContent>
</Select>
<>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Telegram Bot</CardTitle>
{isEnv && <EnvBadge />}
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram-token">Telegram token</Label>
<Input
id="telegram-token"
value={telegramToken}
onChange={(event) => setTelegramToken(event.target.value)}
<CardDescription>
Configure the Telegram bot for `/help`, `/status`, and `/critical`
commands.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="bot-enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={controlsDisabled}
type="password"
placeholder="123456:ABC..."
/>
<Label htmlFor="bot-enabled" className="cursor-pointer">
{enabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="space-y-1.5">
<Label htmlFor="allowed-chat-id">Allowed chat ID</Label>
<Input
id="allowed-chat-id"
value={allowedChatId}
onChange={(event) => setAllowedChatId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789"
/>
</div>
</div>
{mode === "jwt-relay" && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">JWT relay</p>
<p className="mt-1 text-muted-foreground">
Relay path: <span className="font-mono">{config.relayPath}</span>
</p>
<p className="mt-1 text-muted-foreground">
Protected by existing auth: {config.relayUsesAuth ? "yes" : "no"}
</p>
{!authEnabled && (
<p className="mt-2 text-destructive">
Enable dashboard auth before using JWT relay mode.
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="bot-mode">Mode</Label>
<Select
value={mode}
onValueChange={(value) => setMode(value as BotConfig["mode"])}
disabled={controlsDisabled}
>
<SelectTrigger id="bot-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="polling">Polling</SelectItem>
<SelectItem value="jwt-relay" disabled={!authEnabled}>
JWT Relay
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram-token">Telegram token</Label>
<Input
id="telegram-token"
value={telegramToken}
onChange={(event) => setTelegramToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="123456:ABC..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="allowed-chat-id">Allowed chat ID</Label>
<Input
id="allowed-chat-id"
value={allowedChatId}
onChange={(event) => setAllowedChatId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789"
/>
</div>
</div>
{mode === "jwt-relay" && (
<div className="rounded-md border bg-muted/30 p-3 text-sm">
<p className="font-medium">JWT relay</p>
<p className="mt-1 text-muted-foreground">
Relay path:{" "}
<span className="font-mono">{config.relayPath}</span>
</p>
)}
</div>
)}
{!isEnv && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
<p className="mt-1 text-muted-foreground">
Protected by existing auth:{" "}
{config.relayUsesAuth ? "yes" : "no"}
</p>
{!authEnabled && (
<p className="mt-2 text-destructive">
Enable dashboard auth before using JWT relay mode.
</p>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTest}
disabled={controlsDisabled}
>
{testMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
{!isEnv && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleTest}
disabled={controlsDisabled}
>
{testMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CardTitle>Discord Bot</CardTitle>
{isEnv && <EnvBadge />}
</div>
)}
</CardContent>
</Card>
<CardDescription>
Configure Discord slash commands for `/help`, `/status`, and
`/critical`.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Switch
id="discord-bot-enabled"
checked={discordEnabled}
onCheckedChange={setDiscordEnabled}
disabled={controlsDisabled}
/>
<Label htmlFor="discord-bot-enabled" className="cursor-pointer">
{discordEnabled ? "Enabled" : "Disabled"}
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="discord-bot-token">Bot token</Label>
<Input
id="discord-bot-token"
value={discordBotToken}
onChange={(event) => setDiscordBotToken(event.target.value)}
disabled={controlsDisabled}
type="password"
placeholder="MTA..."
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-application-id">Application ID</Label>
<Input
id="discord-application-id"
value={discordApplicationId}
onChange={(event) => setDiscordApplicationId(event.target.value)}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-guild-id">Guild ID</Label>
<Input
id="discord-guild-id"
value={discordGuildId}
onChange={(event) => setDiscordGuildId(event.target.value)}
disabled={controlsDisabled}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord-channel-id">Allowed channel ID</Label>
<Input
id="discord-channel-id"
value={discordAllowedChannelId}
onChange={(event) =>
setDiscordAllowedChannelId(event.target.value)
}
disabled={controlsDisabled}
placeholder="123456789012345678"
/>
</div>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
Slash command responses are ephemeral. Set a guild ID to register
commands to one server immediately; leave it blank for global
command registration.
</div>
{!isEnv && (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || controlsDisabled}
>
{updateMutation.isPending ? (
<>
<Spinner className="size-3" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDiscordTest}
disabled={controlsDisabled}
>
{discordTestMutation.isPending ? "Testing..." : "Send test"}
</Button>
</div>
)}
</CardContent>
</Card>
</>
);
}

View file

@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getSettings } from "../api/get-settings";
import { testBot } from "../api/test-bot";
import { testBot, testDiscordBot } from "../api/test-bot";
import { testCoolifyHost } from "../api/test-coolify-host";
import { testDockerHost } from "../api/test-docker-host";
import { type UpdateAuthPayload, updateAuth } from "../api/update-auth";
@ -105,3 +105,15 @@ export function useTestBot() {
}) => testBot(telegramToken, allowedChatId),
});
}
export function useTestDiscordBot() {
return useMutation({
mutationFn: ({
botToken,
allowedChannelId,
}: {
botToken: string;
allowedChannelId: string;
}) => testDiscordBot(botToken, allowedChannelId),
});
}

View file

@ -43,6 +43,15 @@ export interface BotConfig {
allowedChatId: string;
relayPath: string;
relayUsesAuth: boolean;
discord: DiscordBotConfig;
}
export interface DiscordBotConfig {
enabled: boolean;
botToken: string;
applicationId: string;
guildId: string;
allowedChannelId: string;
}
export interface SettingsResponse {

View file

@ -283,6 +283,7 @@ func (ar *APIRouter) registerSettingsRoutes(r chi.Router) {
r.Put("/auth", ar.UpdateAuth)
r.Put("/bot", ar.UpdateBot)
r.Post("/test/bot", ar.TestBot)
r.Post("/test/discord-bot", ar.TestDiscordBot)
r.Post("/test/docker-host", ar.TestDockerHost)
r.Post("/test/coolify-host", ar.TestCoolifyHost)
if ar.scanHandlers != nil {

View file

@ -83,12 +83,26 @@ func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) {
"allowedChatId": cfg.Bot.AllowedChatID,
"relayPath": "/api/v1/bot/relay/command",
"relayUsesAuth": true,
"discord": map[string]any{
"enabled": cfg.Bot.Discord.Enabled,
"botToken": "",
"applicationId": cfg.Bot.Discord.ApplicationID,
"guildId": cfg.Bot.Discord.GuildID,
"allowedChannelId": cfg.Bot.Discord.AllowedChannelID,
},
}
if sources.Bot == config.SourceEnv && cfg.Bot.TelegramToken != "" {
botResp["telegramToken"] = secretMask
} else if fc.Bot != nil && fc.Bot.TelegramToken != "" {
botResp["telegramToken"] = secretMask
}
if discordResp, ok := botResp["discord"].(map[string]any); ok {
if sources.Bot == config.SourceEnv && cfg.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
} else if fc.Bot != nil && fc.Bot.Discord != nil && fc.Bot.Discord.BotToken != "" {
discordResp["botToken"] = secretMask
}
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"dockerHosts": map[string]any{
@ -294,6 +308,13 @@ func (ar *APIRouter) UpdateBot(w http.ResponseWriter, r *http.Request) {
Mode string `json:"mode"`
TelegramToken string `json:"telegramToken"`
AllowedChatID string `json:"allowedChatId"`
Discord *struct {
Enabled bool `json:"enabled"`
BotToken string `json:"botToken"`
ApplicationID string `json:"applicationId"`
GuildID string `json:"guildId"`
AllowedChannelID string `json:"allowedChannelId"`
} `json:"discord,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
@ -326,12 +347,44 @@ func (ar *APIRouter) UpdateBot(w http.ResponseWriter, r *http.Request) {
}
}
err := ar.manager.UpdateBotConfig(&config.FileBotConfig{
fc := ar.manager.FileConfigSnapshot()
nextBot := &config.FileBotConfig{
Enabled: &req.Enabled,
Mode: mode,
TelegramToken: token,
AllowedChatID: chatID,
})
}
if fc.Bot != nil && fc.Bot.Discord != nil {
existingDiscord := *fc.Bot.Discord
nextBot.Discord = &existingDiscord
}
if req.Discord != nil {
discordToken := strings.TrimSpace(req.Discord.BotToken)
if discordToken == secretMask {
if fc.Bot != nil && fc.Bot.Discord != nil {
discordToken = fc.Bot.Discord.BotToken
}
}
applicationID := strings.TrimSpace(req.Discord.ApplicationID)
guildID := strings.TrimSpace(req.Discord.GuildID)
channelID := strings.TrimSpace(req.Discord.AllowedChannelID)
if req.Discord.Enabled && (discordToken == "" || applicationID == "" || channelID == "") {
http.Error(w, "discord botToken, applicationId, and allowedChannelId are required when enabling Discord bot", http.StatusBadRequest)
return
}
nextBot.Discord = &config.FileDiscordBotConfig{
Enabled: &req.Discord.Enabled,
BotToken: discordToken,
ApplicationID: applicationID,
GuildID: guildID,
AllowedChannelID: channelID,
}
}
err := ar.manager.UpdateBotConfig(nextBot)
if err != nil {
http.Error(w, err.Error(), settingsErrorStatus(err))
return
@ -373,6 +426,39 @@ func (ar *APIRouter) TestBot(w http.ResponseWriter, r *http.Request) {
})
}
func (ar *APIRouter) TestDiscordBot(w http.ResponseWriter, r *http.Request) {
var req struct {
BotToken string `json:"botToken"`
AllowedChannelID string `json:"allowedChannelId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.BotToken)
if token == secretMask {
fc := ar.manager.FileConfigSnapshot()
if fc.Bot != nil && fc.Bot.Discord != nil {
token = fc.Bot.Discord.BotToken
}
}
svc := bot.NewService(ar.registry, ar.registry.Config().Bot)
if err := svc.SendDiscordTestMessage(r.Context(), token, strings.TrimSpace(req.AllowedChannelID)); err != nil {
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": false,
"message": err.Error(),
})
return
}
WriteJsonResponse(w, http.StatusOK, map[string]any{
"success": true,
"message": "Discord test message sent",
})
}
// TestDockerHost handles POST /api/v1/settings/test/docker-host.
func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) {
var req struct {

View file

@ -2,13 +2,17 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/coolify"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type fakeCoolifySyncer struct {
@ -144,3 +148,143 @@ func TestSettingsErrorStatusDirectErrEnvironmentConfigured(t *testing.T) {
t.Fatalf("expected %d for direct ErrEnvironmentConfigured, got %d", http.StatusConflict, got)
}
}
func TestUpdateBotRejectsIncompleteDiscordConfig(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"allowedChannelId": ""
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String())
}
}
func TestUpdateBotPreservesMaskedDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "discord-token",
"applicationId": "app-1",
"guildId": "guild-1",
"allowedChannelId": "channel-1"
}
}`))
rec := httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected initial update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
router.registry.UpdateConfig(manager.Config())
req = httptest.NewRequest(http.MethodPut, "/api/v1/settings/bot", strings.NewReader(`{
"enabled": false,
"mode": "polling",
"telegramToken": "",
"allowedChatId": "",
"discord": {
"enabled": true,
"botToken": "••••••••",
"applicationId": "app-2",
"guildId": "",
"allowedChannelId": "channel-2"
}
}`))
rec = httptest.NewRecorder()
router.UpdateBot(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected masked update to succeed, got %d: %s", rec.Code, rec.Body.String())
}
got := manager.Config().Bot.Discord
if got.BotToken != "discord-token" {
t.Fatalf("expected masked update to preserve token, got %q", got.BotToken)
}
if got.ApplicationID != "app-2" || got.AllowedChannelID != "channel-2" || got.GuildID != "" {
t.Fatalf("unexpected discord config after masked update: %+v", got)
}
}
func TestGetSettingsMasksDiscordToken(t *testing.T) {
manager := newTestSettingsManager(t)
enabled := true
if err := manager.UpdateBotConfig(&config.FileBotConfig{
Discord: &config.FileDiscordBotConfig{
Enabled: &enabled,
BotToken: "discord-token",
ApplicationID: "app-1",
AllowedChannelID: "channel-1",
},
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
router := &APIRouter{
manager: manager,
registry: services.NewRegistry(nil, nil, nil, manager.Config(), nil),
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
rec := httptest.NewRecorder()
router.GetSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var body struct {
Bot struct {
Discord struct {
BotToken string `json:"botToken"`
} `json:"discord"`
} `json:"bot"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode settings response: %v", err)
}
if body.Bot.Discord.BotToken != secretMask {
t.Fatalf("expected masked discord token, got %q", body.Bot.Discord.BotToken)
}
}
func newTestSettingsManager(t *testing.T) *config.Manager {
t.Helper()
t.Setenv("CONFIG_PATH", t.TempDir()+"/config.json")
t.Setenv("BOT_ENABLED", "")
t.Setenv("BOT_MODE", "")
t.Setenv("BOT_TELEGRAM_TOKEN", "")
t.Setenv("BOT_ALLOWED_CHAT_ID", "")
t.Setenv("BOT_POLL_INTERVAL", "")
t.Setenv("BOT_DISCORD_ENABLED", "")
t.Setenv("BOT_DISCORD_TOKEN", "")
t.Setenv("BOT_DISCORD_APPLICATION_ID", "")
t.Setenv("BOT_DISCORD_GUILD_ID", "")
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "")
return config.NewManager()
}

View file

@ -0,0 +1,158 @@
package bot
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/services"
)
type commandHandler struct {
registry *services.Registry
}
func newCommandHandler(registry *services.Registry) *commandHandler {
return &commandHandler{registry: registry}
}
func (h *commandHandler) handle(text string) string {
switch {
case strings.HasPrefix(text, "/help"), strings.HasPrefix(text, "/start"):
return "Available commands:\n/status - current container health with history\n/critical - latest critical alerts\n/help - command list"
case strings.HasPrefix(text, "/critical"):
return h.buildCriticalMessage()
case strings.HasPrefix(text, "/status"):
return h.buildStatusMessage()
default:
return "Unknown command. Use /help."
}
}
func (h *commandHandler) buildCriticalMessage() string {
if h.registry == nil {
return "Alert monitoring is disabled."
}
monitor := h.registry.Alerts()
if monitor == nil {
return "Alert monitoring is disabled."
}
alertsList := monitor.GetHistory().GetAll()
critical := make([]models.Alert, 0, len(alertsList))
for _, alert := range alertsList {
if alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold {
critical = append(critical, alert)
}
}
if len(critical) == 0 {
return "No critical alerts."
}
sort.SliceStable(critical, func(i, j int) bool {
return critical[i].Timestamp > critical[j].Timestamp
})
var lines []string
lines = append(lines, "Critical alerts:")
for _, alert := range critical[:min(5, len(critical))] {
lines = append(lines, fmt.Sprintf("- %s on %s (%s)", alert.ContainerName, alert.Host, alert.Type))
}
return strings.Join(lines, "\n")
}
func (h *commandHandler) buildStatusMessage() string {
if h.registry == nil {
return "Docker client unavailable."
}
dockerClient, release := h.registry.AcquireDocker()
defer release()
if dockerClient == nil {
return "Docker client unavailable."
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
containersMap, _, err := dockerClient.ListContainersAllHosts(ctx)
if err != nil {
return fmt.Sprintf("Failed to list containers: %v", err)
}
type containerLine struct {
name string
cpu float64
line string
}
var lines []containerLine
total := 0
running := 0
history := h.registry.Alerts()
var historyManager interface {
Get1hAverages(string, string) (float64, float64, bool)
Get12hAverages(string, string) (float64, float64, bool)
}
if history != nil {
historyManager = history.GetStatsHistory()
}
for hostName, containers := range containersMap {
for _, ctr := range containers {
total++
if ctr.State != "running" {
continue
}
running++
stats, err := dockerClient.GetContainerStatsOnce(ctx, hostName, ctr.ID)
if err != nil {
continue
}
name := ctr.ID[:12]
if len(ctr.Names) > 0 {
name = strings.TrimPrefix(ctr.Names[0], "/")
}
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hostName, stats.CPUPercent, stats.MemoryPercent)
if historyManager != nil {
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hostName, ctr.ID)
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hostName, ctr.ID)
if has1h || has12h {
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
if has12h {
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
}
}
}
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
}
}
sort.SliceStable(lines, func(i, j int) bool {
return lines[i].cpu > lines[j].cpu
})
message := []string{
fmt.Sprintf("Containers: %d total, %d running", total, running),
}
for _, line := range lines[:min(5, len(lines))] {
message = append(message, line.line)
}
return strings.Join(message, "\n")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,485 @@
package bot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
)
const (
discordAPIBase = "https://discord.com/api/v10"
discordOpDispatch = 0
discordOpHeartbeat = 1
discordOpIdentify = 2
discordOpResume = 6
discordOpReconnect = 7
discordOpInvalidSession = 9
discordOpHello = 10
discordOpHeartbeatACK = 11
discordInteractionApplicationCommand = 2
discordResponseDeferredMessage = 5
discordMessageFlagEphemeral = 64
)
type websocketDialer interface {
Dial(urlStr string, requestHeader http.Header) (*websocket.Conn, *http.Response, error)
}
type discordGatewayResponse struct {
URL string `json:"url"`
}
type discordGatewayPayload struct {
Op int `json:"op"`
D json.RawMessage `json:"d"`
S *int64 `json:"s,omitempty"`
T string `json:"t,omitempty"`
}
type discordHelloPayload struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type discordReadyPayload struct {
SessionID string `json:"session_id"`
ResumeGatewayURL string `json:"resume_gateway_url"`
}
type discordInteraction struct {
ID string `json:"id"`
Token string `json:"token"`
Type int `json:"type"`
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
Data struct {
Name string `json:"name"`
} `json:"data"`
}
type discordCommand struct {
Name string `json:"name"`
Description string `json:"description"`
Type int `json:"type"`
}
func (s *Service) startDiscordLocked() {
if s.discordRunning || !isDiscordConfigured(s.cfg.Discord) {
return
}
cfg := s.cfg
s.discordStopCh = make(chan struct{})
s.discordDoneCh = make(chan struct{})
s.discordRunning = true
go s.discordLoop(cfg, s.discordStopCh, s.discordDoneCh)
}
func (s *Service) stopDiscord() {
s.mu.Lock()
if !s.discordRunning {
s.mu.Unlock()
return
}
stopCh := s.discordStopCh
doneCh := s.discordDoneCh
s.discordRunning = false
s.discordStopCh = nil
s.discordDoneCh = nil
s.mu.Unlock()
close(stopCh)
<-doneCh
}
func (s *Service) discordLoop(cfg config.BotConfig, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
if err := s.registerDiscordCommands(context.Background(), cfg.Discord); err != nil {
log.Printf("discord bot command registration failed: %v", err)
}
for {
select {
case <-stopCh:
return
default:
}
if err := s.runDiscordConnection(cfg, stopCh); err != nil {
log.Printf("discord bot gateway failed: %v", err)
}
select {
case <-time.After(5 * time.Second):
case <-stopCh:
return
}
}
}
func (s *Service) runDiscordConnection(cfg config.BotConfig, stopCh <-chan struct{}) error {
gatewayURL, err := s.discordGatewayURL(context.Background(), cfg.Discord.BotToken)
if err != nil {
return err
}
sessionID, resumeURL, _ := s.discordSession()
connectURL := gatewayURL
resuming := sessionID != "" && resumeURL != ""
if resuming {
connectURL = resumeURL
}
if !strings.Contains(connectURL, "?") {
connectURL += "?v=10&encoding=json"
}
conn, _, err := s.discordDialer.Dial(connectURL, nil)
if err != nil {
return err
}
defer conn.Close()
stopped := make(chan struct{})
go func() {
select {
case <-stopCh:
_ = conn.Close()
case <-stopped:
}
}()
defer close(stopped)
var writeMu sync.Mutex
ackMu := sync.Mutex{}
heartbeatAcked := true
heartbeatStop := make(chan struct{})
defer close(heartbeatStop)
for {
var payload discordGatewayPayload
if err := conn.ReadJSON(&payload); err != nil {
select {
case <-stopCh:
return nil
default:
return err
}
}
if payload.S != nil {
s.setDiscordSeq(*payload.S)
}
switch payload.Op {
case discordOpHello:
var hello discordHelloPayload
if err := json.Unmarshal(payload.D, &hello); err != nil {
return err
}
if hello.HeartbeatInterval <= 0 {
return fmt.Errorf("discord gateway hello missing heartbeat interval")
}
go s.discordHeartbeatLoop(conn, &writeMu, &ackMu, &heartbeatAcked, time.Duration(hello.HeartbeatInterval)*time.Millisecond, heartbeatStop)
if resuming {
if err := s.discordResume(conn, &writeMu, cfg.Discord.BotToken, sessionID); err != nil {
return err
}
} else if err := s.discordIdentify(conn, &writeMu, cfg.Discord.BotToken); err != nil {
return err
}
case discordOpHeartbeatACK:
ackMu.Lock()
heartbeatAcked = true
ackMu.Unlock()
case discordOpHeartbeat:
if err := s.discordSendHeartbeat(conn, &writeMu); err != nil {
return err
}
case discordOpReconnect:
return fmt.Errorf("discord gateway requested reconnect")
case discordOpInvalidSession:
s.clearDiscordSession()
return fmt.Errorf("discord gateway invalidated session")
case discordOpDispatch:
if payload.T == "READY" {
var ready discordReadyPayload
if err := json.Unmarshal(payload.D, &ready); err != nil {
return err
}
s.setDiscordSession(ready.SessionID, ready.ResumeGatewayURL)
}
if payload.T == "INTERACTION_CREATE" {
var interaction discordInteraction
if err := json.Unmarshal(payload.D, &interaction); err != nil {
log.Printf("discord interaction decode failed: %v", err)
continue
}
go s.handleDiscordInteraction(context.Background(), cfg.Discord, interaction)
}
}
}
}
func (s *Service) discordHeartbeatLoop(conn *websocket.Conn, writeMu, ackMu *sync.Mutex, acked *bool, interval time.Duration, stopCh <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
ackMu.Lock()
*acked = false
ackMu.Unlock()
for {
select {
case <-ticker.C:
ackMu.Lock()
if !*acked {
ackMu.Unlock()
_ = conn.Close()
return
}
*acked = false
ackMu.Unlock()
if err := s.discordSendHeartbeat(conn, writeMu); err != nil {
_ = conn.Close()
return
}
case <-stopCh:
return
}
}
}
func (s *Service) discordIdentify(conn *websocket.Conn, writeMu *sync.Mutex, token string) error {
payload := map[string]any{
"op": discordOpIdentify,
"d": map[string]any{
"token": token,
"intents": 0,
"properties": map[string]string{
"os": "linux",
"browser": "vps-monitor",
"device": "vps-monitor",
},
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordResume(conn *websocket.Conn, writeMu *sync.Mutex, token, sessionID string) error {
payload := map[string]any{
"op": discordOpResume,
"d": map[string]any{
"token": token,
"session_id": sessionID,
"seq": s.discordSeq(),
},
}
return discordWriteJSON(conn, writeMu, payload)
}
func (s *Service) discordSendHeartbeat(conn *websocket.Conn, writeMu *sync.Mutex) error {
return discordWriteJSON(conn, writeMu, map[string]any{
"op": discordOpHeartbeat,
"d": s.discordSeq(),
})
}
func discordWriteJSON(conn *websocket.Conn, writeMu *sync.Mutex, payload any) error {
writeMu.Lock()
defer writeMu.Unlock()
return conn.WriteJSON(payload)
}
func (s *Service) discordGatewayURL(ctx context.Context, token string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.discordAPIBase+"/gateway/bot", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bot "+token)
res, err := s.client.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return "", fmt.Errorf("discord gateway lookup returned status %d", res.StatusCode)
}
var payload discordGatewayResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return "", err
}
if payload.URL == "" {
return "", fmt.Errorf("discord gateway lookup returned empty url")
}
return payload.URL, nil
}
func (s *Service) registerDiscordCommands(ctx context.Context, cfg config.DiscordBotConfig) error {
commands := []discordCommand{
{Name: "help", Description: "Show VPS Monitor bot commands", Type: 1},
{Name: "status", Description: "Show current container health with history", Type: 1},
{Name: "critical", Description: "Show latest critical alerts", Type: 1},
}
endpoint := fmt.Sprintf("%s/applications/%s/commands", s.discordAPIBase, cfg.ApplicationID)
if cfg.GuildID != "" {
endpoint = fmt.Sprintf("%s/applications/%s/guilds/%s/commands", s.discordAPIBase, cfg.ApplicationID, cfg.GuildID)
}
return s.doDiscordJSON(ctx, http.MethodPut, endpoint, cfg.BotToken, commands, nil)
}
func (s *Service) handleDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) {
if interaction.Type != discordInteractionApplicationCommand {
return
}
if err := s.deferDiscordInteraction(ctx, cfg, interaction); err != nil {
log.Printf("discord interaction defer failed: %v", err)
return
}
reply := s.discordInteractionReply(cfg, interaction)
if reply == "" {
reply = "No response."
}
if err := s.editDiscordInteractionResponse(ctx, cfg, interaction.Token, reply); err != nil {
log.Printf("discord interaction response failed: %v", err)
}
}
func (s *Service) discordInteractionReply(cfg config.DiscordBotConfig, interaction discordInteraction) string {
if cfg.GuildID != "" && interaction.GuildID != cfg.GuildID {
return "This Discord server is not allowed."
}
if cfg.AllowedChannelID != "" && interaction.ChannelID != cfg.AllowedChannelID {
return "This Discord channel is not allowed."
}
switch interaction.Data.Name {
case "help", "status", "critical":
return s.commands.handle("/" + interaction.Data.Name)
default:
return "Unknown command. Use /help."
}
}
func (s *Service) deferDiscordInteraction(ctx context.Context, cfg config.DiscordBotConfig, interaction discordInteraction) error {
endpoint := fmt.Sprintf("%s/interactions/%s/%s/callback", s.discordAPIBase, interaction.ID, interaction.Token)
payload := map[string]any{
"type": discordResponseDeferredMessage,
"data": map[string]any{"flags": discordMessageFlagEphemeral},
}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) editDiscordInteractionResponse(ctx context.Context, cfg config.DiscordBotConfig, token, content string) error {
endpoint := fmt.Sprintf("%s/webhooks/%s/%s/messages/@original", s.discordAPIBase, cfg.ApplicationID, token)
payload := map[string]any{
"content": content,
"flags": discordMessageFlagEphemeral,
}
return s.doDiscordJSON(ctx, http.MethodPatch, endpoint, cfg.BotToken, payload, nil)
}
func (s *Service) SendDiscordTestMessage(ctx context.Context, token, channelID string) error {
if strings.TrimSpace(token) == "" || strings.TrimSpace(channelID) == "" {
return fmt.Errorf("discord bot token and channel id are required")
}
endpoint := fmt.Sprintf("%s/channels/%s/messages", s.discordAPIBase, strings.TrimSpace(channelID))
payload := map[string]string{"content": "VPS Monitor Discord bot test successful."}
return s.doDiscordJSON(ctx, http.MethodPost, endpoint, strings.TrimSpace(token), payload, nil)
}
func (s *Service) doDiscordJSON(ctx context.Context, method, endpoint, token string, payload, out any) error {
var body *bytes.Reader
if payload == nil {
body = bytes.NewReader(nil)
} else {
data, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
req.Header.Set("Content-Type", "application/json")
res, err := s.client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("discord %s returned status %d", method, res.StatusCode)
}
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}
func (s *Service) discordSession() (string, string, int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordSessionID, s.discordResumeURL, s.discordLastSeq
}
func (s *Service) setDiscordSession(sessionID, resumeURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = sessionID
s.discordResumeURL = resumeURL
}
func (s *Service) clearDiscordSession() {
s.mu.Lock()
defer s.mu.Unlock()
s.discordSessionID = ""
s.discordResumeURL = ""
s.discordLastSeq = 0
}
func (s *Service) discordSeq() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.discordLastSeq
}
func (s *Service) setDiscordSeq(seq int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.discordLastSeq = seq
}
func isDiscordConfigured(cfg config.DiscordBotConfig) bool {
return cfg.Enabled &&
strings.TrimSpace(cfg.BotToken) != "" &&
strings.TrimSpace(cfg.ApplicationID) != "" &&
strings.TrimSpace(cfg.AllowedChannelID) != ""
}

View file

@ -7,28 +7,36 @@ import (
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/hhftechnology/vps-monitor/internal/config"
"github.com/hhftechnology/vps-monitor/internal/models"
"github.com/hhftechnology/vps-monitor/internal/services"
)
const telegramAPIBase = "https://api.telegram.org"
type Service struct {
mu sync.Mutex
registry *services.Registry
client *http.Client
cfg config.BotConfig
running bool
stopCh chan struct{}
doneCh chan struct{}
offset int64
mu sync.Mutex
registry *services.Registry
commands *commandHandler
client *http.Client
discordAPIBase string
discordDialer websocketDialer
cfg config.BotConfig
running bool
stopCh chan struct{}
doneCh chan struct{}
offset int64
discordRunning bool
discordStopCh chan struct{}
discordDoneCh chan struct{}
discordSessionID string
discordResumeURL string
discordLastSeq int64
}
type telegramUpdateResponse struct {
@ -57,7 +65,10 @@ func NewService(registry *services.Registry, cfg config.BotConfig) *Service {
client: &http.Client{
Timeout: 35 * time.Second,
},
cfg: cfg,
commands: newCommandHandler(registry),
discordAPIBase: discordAPIBase,
discordDialer: websocket.DefaultDialer,
cfg: cfg,
}
}
@ -65,18 +76,20 @@ func (s *Service) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running || !isConfigured(s.cfg) || s.cfg.Mode != config.BotModePolling {
return
if !s.running && isConfigured(s.cfg) && s.cfg.Mode == config.BotModePolling {
s.stopCh = make(chan struct{})
s.doneCh = make(chan struct{})
s.running = true
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
}
s.stopCh = make(chan struct{})
s.doneCh = make(chan struct{})
s.running = true
go s.pollLoop(s.cfg, s.stopCh, s.doneCh)
s.startDiscordLocked()
}
func (s *Service) Stop() {
s.stopDiscord()
s.mu.Lock()
if !s.running {
s.mu.Unlock()
@ -99,6 +112,9 @@ func (s *Service) UpdateConfig(cfg config.BotConfig) {
s.mu.Lock()
s.cfg = cfg
s.offset = 0
s.discordLastSeq = 0
s.discordSessionID = ""
s.discordResumeURL = ""
s.mu.Unlock()
s.Start()
@ -124,7 +140,7 @@ func (s *Service) RelayCommand(ctx context.Context, chatID, text string) (string
return "", fmt.Errorf("chat id is not allowed")
}
reply := s.handleCommand(strings.TrimSpace(text))
reply := s.commands.handle(strings.TrimSpace(text))
if reply == "" {
return "", nil
}
@ -211,7 +227,7 @@ func (s *Service) pollOnce(cfg config.BotConfig) error {
continue
}
reply := s.handleCommand(strings.TrimSpace(update.Message.Text))
reply := s.commands.handle(strings.TrimSpace(update.Message.Text))
if reply == "" {
continue
}
@ -224,129 +240,6 @@ func (s *Service) pollOnce(cfg config.BotConfig) error {
return nil
}
func (s *Service) handleCommand(text string) string {
switch {
case strings.HasPrefix(text, "/help"), strings.HasPrefix(text, "/start"):
return "Available commands:\n/status - current container health with history\n/critical - latest critical alerts\n/help - command list"
case strings.HasPrefix(text, "/critical"):
return s.buildCriticalMessage()
case strings.HasPrefix(text, "/status"):
return s.buildStatusMessage()
default:
return "Unknown command. Use /help."
}
}
func (s *Service) buildCriticalMessage() string {
monitor := s.registry.Alerts()
if monitor == nil {
return "Alert monitoring is disabled."
}
alertsList := monitor.GetHistory().GetAll()
critical := make([]models.Alert, 0, len(alertsList))
for _, alert := range alertsList {
if alert.Type == models.AlertCPUThreshold || alert.Type == models.AlertMemoryThreshold {
critical = append(critical, alert)
}
}
if len(critical) == 0 {
return "No critical alerts."
}
sort.SliceStable(critical, func(i, j int) bool {
return critical[i].Timestamp > critical[j].Timestamp
})
var lines []string
lines = append(lines, "Critical alerts:")
for _, alert := range critical[:min(5, len(critical))] {
lines = append(lines, fmt.Sprintf("- %s on %s (%s)", alert.ContainerName, alert.Host, alert.Type))
}
return strings.Join(lines, "\n")
}
func (s *Service) buildStatusMessage() string {
dockerClient, release := s.registry.AcquireDocker()
defer release()
if dockerClient == nil {
return "Docker client unavailable."
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
containersMap, _, err := dockerClient.ListContainersAllHosts(ctx)
if err != nil {
return fmt.Sprintf("Failed to list containers: %v", err)
}
type containerLine struct {
name string
cpu float64
line string
}
var lines []containerLine
total := 0
running := 0
history := s.registry.Alerts()
var historyManager interface {
Get1hAverages(string, string) (float64, float64, bool)
Get12hAverages(string, string) (float64, float64, bool)
}
if history != nil {
historyManager = history.GetStatsHistory()
}
for hostName, containers := range containersMap {
for _, ctr := range containers {
total++
if ctr.State != "running" {
continue
}
running++
stats, err := dockerClient.GetContainerStatsOnce(ctx, hostName, ctr.ID)
if err != nil {
continue
}
name := ctr.ID[:12]
if len(ctr.Names) > 0 {
name = strings.TrimPrefix(ctr.Names[0], "/")
}
line := fmt.Sprintf("- %s@%s CPU %.1f%% MEM %.1f%%", name, hostName, stats.CPUPercent, stats.MemoryPercent)
if historyManager != nil {
cpu1h, mem1h, has1h := historyManager.Get1hAverages(hostName, ctr.ID)
cpu12h, mem12h, has12h := historyManager.Get12hAverages(hostName, ctr.ID)
if has1h || has12h {
line += fmt.Sprintf(" | 1h %.1f/%.1f", cpu1h, mem1h)
if has12h {
line += fmt.Sprintf(" | 12h %.1f/%.1f", cpu12h, mem12h)
}
}
}
lines = append(lines, containerLine{name: name, cpu: stats.CPUPercent, line: line})
}
}
sort.SliceStable(lines, func(i, j int) bool {
return lines[i].cpu > lines[j].cpu
})
message := []string{
fmt.Sprintf("Containers: %d total, %d running", total, running),
}
for _, line := range lines[:min(5, len(lines))] {
message = append(message, line.line)
}
return strings.Join(message, "\n")
}
func (s *Service) sendMessage(ctx context.Context, token, chatID, text string) error {
form := url.Values{}
form.Set("chat_id", chatID)
@ -382,10 +275,3 @@ func (s *Service) apiURL(token, method string, params url.Values) string {
func isConfigured(cfg config.BotConfig) bool {
return cfg.Enabled && strings.TrimSpace(cfg.TelegramToken) != "" && strings.TrimSpace(cfg.AllowedChatID) != ""
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -2,6 +2,7 @@ package bot
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
@ -87,3 +88,68 @@ func TestRelayCommandSendsReplyViaTelegram(t *testing.T) {
t.Fatalf("expected reply to be sent, got text=%q reply=%q", gotText, reply)
}
}
func TestSharedCommandHandlerKeepsTelegramHelpReply(t *testing.T) {
reply := newCommandHandler(nil).handle("/help")
if !strings.Contains(reply, "/status") || !strings.Contains(reply, "/critical") {
t.Fatalf("expected help reply to list existing commands, got %q", reply)
}
}
func TestDiscordInteractionReplyRejectsUnexpectedChannel(t *testing.T) {
svc := NewService(nil, config.BotConfig{})
var interaction discordInteraction
interaction.Type = discordInteractionApplicationCommand
interaction.ChannelID = "channel-2"
interaction.Data.Name = "help"
reply := svc.discordInteractionReply(config.DiscordBotConfig{
Enabled: true,
BotToken: "token",
ApplicationID: "app",
AllowedChannelID: "channel-1",
}, interaction)
if !strings.Contains(reply, "channel is not allowed") {
t.Fatalf("expected channel rejection, got %q", reply)
}
}
func TestSendDiscordTestMessagePostsToChannel(t *testing.T) {
var gotAuth string
var gotPath string
var gotContent string
svc := NewService(nil, config.BotConfig{})
svc.client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotAuth = req.Header.Get("Authorization")
gotPath = req.URL.Path
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, err
}
gotContent = body.Content
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"id":"message-1"}`)),
Header: make(http.Header),
}, nil
}),
}
svc.discordAPIBase = "https://discord.test"
if err := svc.SendDiscordTestMessage(context.Background(), "token-1", "channel-1"); err != nil {
t.Fatalf("SendDiscordTestMessage returned error: %v", err)
}
if gotAuth != "Bot token-1" {
t.Fatalf("unexpected auth header %q", gotAuth)
}
if gotPath != "/channels/channel-1/messages" {
t.Fatalf("unexpected path %q", gotPath)
}
if !strings.Contains(gotContent, "VPS Monitor Discord bot test successful") {
t.Fatalf("unexpected content %q", gotContent)
}
}

View file

@ -46,6 +46,15 @@ type BotConfig struct {
TelegramToken string
AllowedChatID string
PollInterval time.Duration
Discord DiscordBotConfig
}
type DiscordBotConfig struct {
Enabled bool
BotToken string
ApplicationID string
GuildID string
AllowedChannelID string
}
const (
@ -166,6 +175,13 @@ func parseBotConfig() BotConfig {
TelegramToken: strings.TrimSpace(os.Getenv("BOT_TELEGRAM_TOKEN")),
AllowedChatID: strings.TrimSpace(os.Getenv("BOT_ALLOWED_CHAT_ID")),
PollInterval: 15 * time.Second,
Discord: DiscordBotConfig{
Enabled: os.Getenv("BOT_DISCORD_ENABLED") == "true",
BotToken: strings.TrimSpace(os.Getenv("BOT_DISCORD_TOKEN")),
ApplicationID: strings.TrimSpace(os.Getenv("BOT_DISCORD_APPLICATION_ID")),
GuildID: strings.TrimSpace(os.Getenv("BOT_DISCORD_GUILD_ID")),
AllowedChannelID: strings.TrimSpace(os.Getenv("BOT_DISCORD_ALLOWED_CHANNEL_ID")),
},
}
if intervalStr := strings.TrimSpace(os.Getenv("BOT_POLL_INTERVAL")); intervalStr != "" {
@ -177,6 +193,9 @@ func parseBotConfig() BotConfig {
if cfg.TelegramToken == "" || cfg.AllowedChatID == "" {
cfg.Enabled = false
}
if cfg.Discord.BotToken == "" || cfg.Discord.ApplicationID == "" || cfg.Discord.AllowedChannelID == "" {
cfg.Discord.Enabled = false
}
return cfg
}

View file

@ -40,10 +40,19 @@ type FileNotificationConfig struct {
}
type FileBotConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Mode string `json:"mode,omitempty"`
TelegramToken string `json:"telegramToken,omitempty"`
AllowedChatID string `json:"allowedChatId,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Mode string `json:"mode,omitempty"`
TelegramToken string `json:"telegramToken,omitempty"`
AllowedChatID string `json:"allowedChatId,omitempty"`
Discord *FileDiscordBotConfig `json:"discord,omitempty"`
}
type FileDiscordBotConfig struct {
Enabled *bool `json:"enabled,omitempty"`
BotToken string `json:"botToken,omitempty"`
ApplicationID string `json:"applicationId,omitempty"`
GuildID string `json:"guildId,omitempty"`
AllowedChannelID string `json:"allowedChannelId,omitempty"`
}
// FileConfig represents the JSON config file structure.
@ -117,7 +126,13 @@ func NewManager() *Manager {
BotSet: os.Getenv("BOT_TELEGRAM_TOKEN") != "" ||
os.Getenv("BOT_ALLOWED_CHAT_ID") != "" ||
os.Getenv("BOT_ENABLED") != "" ||
os.Getenv("BOT_POLL_INTERVAL") != "",
os.Getenv("BOT_MODE") != "" ||
os.Getenv("BOT_POLL_INTERVAL") != "" ||
os.Getenv("BOT_DISCORD_ENABLED") != "" ||
os.Getenv("BOT_DISCORD_TOKEN") != "" ||
os.Getenv("BOT_DISCORD_APPLICATION_ID") != "" ||
os.Getenv("BOT_DISCORD_GUILD_ID") != "" ||
os.Getenv("BOT_DISCORD_ALLOWED_CHANNEL_ID") != "",
ScannerSet: os.Getenv("SCANNER_GRYPE_IMAGE") != "" ||
os.Getenv("SCANNER_TRIVY_IMAGE") != "" ||
os.Getenv("SCANNER_SYFT_IMAGE") != "" ||
@ -522,10 +537,30 @@ func (m *Manager) merge() (*Config, ConfigSources) {
if fc.AllowedChatID != "" {
cfg.Bot.AllowedChatID = fc.AllowedChatID
}
if fc.Discord != nil {
if fc.Discord.Enabled != nil {
cfg.Bot.Discord.Enabled = *fc.Discord.Enabled
}
if fc.Discord.BotToken != "" {
cfg.Bot.Discord.BotToken = fc.Discord.BotToken
}
if fc.Discord.ApplicationID != "" {
cfg.Bot.Discord.ApplicationID = fc.Discord.ApplicationID
}
if fc.Discord.GuildID != "" {
cfg.Bot.Discord.GuildID = fc.Discord.GuildID
}
if fc.Discord.AllowedChannelID != "" {
cfg.Bot.Discord.AllowedChannelID = fc.Discord.AllowedChannelID
}
}
}
if cfg.Bot.TelegramToken == "" || cfg.Bot.AllowedChatID == "" {
cfg.Bot.Enabled = false
}
if cfg.Bot.Discord.BotToken == "" || cfg.Bot.Discord.ApplicationID == "" || cfg.Bot.Discord.AllowedChannelID == "" {
cfg.Bot.Discord.Enabled = false
}
if m.envSnapshot.BotSet && m.fileConfig.Bot != nil {
sources.Bot = SourceMixed
} else if m.envSnapshot.BotSet {

View file

@ -741,3 +741,54 @@ func TestUpdateBotConfigPersistsAndMerges(t *testing.T) {
t.Fatalf("expected bot source to be file, got %s", m.Sources().Bot)
}
}
func TestDiscordBotEnvConfigParsesAndDisablesWhenIncomplete(t *testing.T) {
t.Setenv("BOT_DISCORD_ENABLED", "true")
t.Setenv("BOT_DISCORD_TOKEN", "discord-token")
t.Setenv("BOT_DISCORD_APPLICATION_ID", "app-1")
cfg := NewConfig()
if cfg.Bot.Discord.Enabled {
t.Fatalf("expected incomplete discord bot config to be disabled: %+v", cfg.Bot.Discord)
}
t.Setenv("BOT_DISCORD_ALLOWED_CHANNEL_ID", "channel-1")
cfg = NewConfig()
if !cfg.Bot.Discord.Enabled {
t.Fatalf("expected complete discord bot config to be enabled: %+v", cfg.Bot.Discord)
}
if cfg.Bot.Discord.BotToken != "discord-token" || cfg.Bot.Discord.ApplicationID != "app-1" || cfg.Bot.Discord.AllowedChannelID != "channel-1" {
t.Fatalf("unexpected discord bot config: %+v", cfg.Bot.Discord)
}
}
func TestUpdateBotConfigPersistsAndMergesDiscord(t *testing.T) {
m := &Manager{
envSnapshot: EnvSnapshot{},
envConfig: NewConfig(),
filePath: filepath.Join(t.TempDir(), "config.json"),
}
m.merged, m.sources = m.merge()
enabled := true
if err := m.UpdateBotConfig(&FileBotConfig{
Discord: &FileDiscordBotConfig{
Enabled: &enabled,
BotToken: "discord-token",
ApplicationID: "app-1",
GuildID: "guild-1",
AllowedChannelID: "channel-1",
},
}); err != nil {
t.Fatalf("UpdateBotConfig returned error: %v", err)
}
merged := m.Config()
if !merged.Bot.Discord.Enabled ||
merged.Bot.Discord.BotToken != "discord-token" ||
merged.Bot.Discord.ApplicationID != "app-1" ||
merged.Bot.Discord.GuildID != "guild-1" ||
merged.Bot.Discord.AllowedChannelID != "channel-1" {
t.Fatalf("unexpected merged discord bot config: %+v", merged.Bot.Discord)
}
}