mirror of
https://github.com/hhftechnology/vps-monitor.git
synced 2026-04-28 03:29:55 +00:00
discord-bot-update
This commit is contained in:
parent
fd455dabec
commit
81b28d62a8
15 changed files with 1404 additions and 264 deletions
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
158
home/internal/bot/command.go
Normal file
158
home/internal/bot/command.go
Normal 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
|
||||
}
|
||||
485
home/internal/bot/discord.go
Normal file
485
home/internal/bot/discord.go
Normal 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) != ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue