Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 54 additions & 23 deletions backend/app/http/endpoints/api/channels.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package api

import (
"context"
"fmt"
"math"
"net/http"
"sort"

"github.com/TicketsBot-cloud/gdl/objects/channel"
"github.com/TicketsBot-cloud/gdl/rest"
"github.com/gin-gonic/gin"
"github.com/ticketsbot-cloud/dashboard/backend/botcontext"
"github.com/ticketsbot-cloud/dashboard/backend/log"
"github.com/ticketsbot-cloud/dashboard/backend/redis"
"github.com/ticketsbot-cloud/dashboard/backend/rpc/cache"
"github.com/ticketsbot-cloud/dashboard/backend/utils"
"go.uber.org/zap"
)

func ChannelsHandler(ctx *gin.Context) {
Expand All @@ -21,37 +27,38 @@ func ChannelsHandler(ctx *gin.Context) {
return
}

refresh := ctx.Query("refresh") == "true"

var channels []channel.Channel
if ctx.Query("refresh") == "true" {
hasToken, err := redis.Client.TakeChannelRefreshToken(ctx, guildId)
if !refresh {
channels, err = botContext.GetGuildChannels(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to take channel refresh token for guild %d. Please try again."))
ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again."))
return
}
}

if hasToken {
channels, err = rest.GetGuildChannels(ctx, botContext.Token, botContext.RateLimiter, guildId)
// GetGuildChannels trusts an empty cache whenever the guild row exists, which is also what a
// partially repopulated cache looks like, so an empty result goes through the refresh path.
if refresh || len(channels) == 0 {
fetched, ok, err := refreshChannels(ctx, botContext, guildId)
switch {
case err != nil && refresh:
ctx.JSON(500, utils.ErrorStr("Unable to load channels from Discord. Please try again."))
return
case err != nil:
log.Logger.Warn("Failed to refresh empty channel cache", zap.Error(err), zap.Uint64("guild_id", guildId))
case ok:
channels = fetched
case refresh:
remaining, err := redis.Client.ChannelRefreshCooldownRemaining(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Unable to load channels from Discord. Please try again."))
return
remaining = redis.ChannelRefreshCooldown
}

if err := cache.Instance.StoreChannels(ctx, channels); err != nil {
ctx.JSON(500, utils.ErrorStr("Failed to store channels in cache for guild %d. Please try again."))
return
}
} else {
channels, err = cache.Instance.GetGuildChannels(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again."))
return
}
}
} else {
var err error
channels, err = botContext.GetGuildChannels(ctx, guildId)
if err != nil {
ctx.JSON(500, utils.ErrorStr("Unable to load channels. Please try again."))
body := utils.ErrorStr("Channels were refreshed recently. Please try again shortly.")
body["retry_after"] = max(1, int(math.Ceil(remaining.Seconds())))
ctx.JSON(http.StatusTooManyRequests, body)
return
}
}
Expand All @@ -78,3 +85,27 @@ func ChannelsHandler(ctx *gin.Context) {

ctx.JSON(200, filtered)
}

// refreshChannels fetches the guild's channels from Discord and writes them to the cache. The
// bool is false when the guild is on refresh cooldown and nothing was fetched.
func refreshChannels(ctx context.Context, botContext *botcontext.BotContext, guildId uint64) ([]channel.Channel, bool, error) {
hasToken, err := redis.Client.TakeChannelRefreshToken(ctx, guildId)
if err != nil {
return nil, false, fmt.Errorf("take channel refresh token: %w", err)
}

if !hasToken {
return nil, false, nil
}

channels, err := rest.GetGuildChannels(ctx, botContext.Token, botContext.RateLimiter, guildId)
if err != nil {
return nil, false, fmt.Errorf("fetch guild channels: %w", err)
}

if err := cache.Instance.StoreChannels(ctx, channels); err != nil {
return nil, false, fmt.Errorf("store guild channels: %w", err)
}

return channels, true, nil
}
23 changes: 20 additions & 3 deletions backend/redis/channelrefreshcooldown.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,30 @@ import (

const ChannelRefreshCooldown = 60 * time.Second

func (c *RedisClient) TakeChannelRefreshToken(ctx context.Context, guildId uint64) (bool, error) {
key := fmt.Sprintf("tickets:channelrefershcooldown:%d", guildId)
func channelRefreshCooldownKey(guildId uint64) string {
return fmt.Sprintf("tickets:channelrefershcooldown:%d", guildId)
}

res, err := c.SetNX(ctx, key, "1", ChannelRefreshCooldown).Result()
func (c *RedisClient) TakeChannelRefreshToken(ctx context.Context, guildId uint64) (bool, error) {
res, err := c.SetNX(ctx, channelRefreshCooldownKey(guildId), "1", ChannelRefreshCooldown).Result()
if err != nil {
return false, err
}

return res, nil
}

// ChannelRefreshCooldownRemaining returns zero if the guild is not on cooldown.
func (c *RedisClient) ChannelRefreshCooldownRemaining(ctx context.Context, guildId uint64) (time.Duration, error) {
ttl, err := c.TTL(ctx, channelRefreshCooldownKey(guildId)).Result()
if err != nil {
return 0, err
}

// go-redis reports a missing key or one without an expiry as a negative duration
if ttl < 0 {
return 0, nil
}

return ttl, nil
}
53 changes: 53 additions & 0 deletions frontend/src/components/ChannelRefreshHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { FC } from "react";
import { useChannelRefresh } from "@/hooks/useChannelRefresh";
import { useGuildStore } from "@/stores/guild";
import type { GuildChannel } from "@/types";

interface ChannelRefreshHintProps {
/** Defaults to the selected guild. */
guildId?: string;
onRefreshed?: (channels: GuildChannel[]) => void;
noun?: "channels" | "categories";
}

const ChannelRefreshHint: FC<ChannelRefreshHintProps> = ({
guildId,
onRefreshed,
noun = "channels",
}) => {
const selectedGuildId = useGuildStore((s) => s.selectedGuild?.id);
const { refresh, refreshing, secondsLeft } = useChannelRefresh(
guildId ?? selectedGuildId?.toString(),
onRefreshed,
);

const unavailable = refreshing || secondsLeft > 0;

let action = "Click here to refresh";
if (refreshing) {
action = "Refreshing…";
} else if (secondsLeft > 0) {
action = `Refresh available in ${secondsLeft}s`;
}

return (
<p className="mt-1 text-xs text-gray-400">
Missing {noun}?{" "}
{/* aria-disabled rather than disabled, so keyboard focus is not dropped mid-refresh */}
<button
type="button"
aria-disabled={unavailable}
className={
unavailable
? "cursor-not-allowed text-gray-500"
: "text-blue-400 underline hover:text-blue-300"
}
onClick={() => void refresh()}
>
{action}
</button>
</p>
);
};

export default ChannelRefreshHint;
6 changes: 5 additions & 1 deletion frontend/src/components/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import { useState, useRef, useEffect, useId, useCallback } from "react";
import { createPortal } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
Expand Down Expand Up @@ -34,6 +34,7 @@ interface SelectProps {
required?: boolean;
missing?: boolean;
info?: SelectInfo;
hint?: ReactNode;
}

const defaultProps = {
Expand Down Expand Up @@ -63,6 +64,7 @@ const Select: FC<SelectProps> = (props) => {
hideSearch,
error,
info,
hint,
required,
} = {
...defaultProps,
Expand Down Expand Up @@ -199,6 +201,8 @@ const Select: FC<SelectProps> = (props) => {
</div>
</button>

{hint}

{isOpen &&
!disabled &&
createPortal(
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/modals/GalleryImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toast } from "sonner";
import ActionModal from "@/components/modal-primitives/ActionModal";
import Button from "@/components/Button";
import Select from "@/components/Select";
import ChannelRefreshHint from "@/components/ChannelRefreshHint";
import { apiClient } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import type { GalleryListing, GuildChannel } from "@/types";
Expand Down Expand Up @@ -111,6 +112,9 @@ const GalleryImportModal: FC<GalleryImportModalProps> = ({ listing, open, onClos
<>
<Select
label="Panel Channel"
hint={
<ChannelRefreshHint guildId={selectedGuildId} onRefreshed={setChannels} />
}
required
placeholder="Select a channel..."
value={selectedChannelId}
Expand All @@ -119,6 +123,13 @@ const GalleryImportModal: FC<GalleryImportModalProps> = ({ listing, open, onClos
/>
<Select
label="Ticket Category"
hint={
<ChannelRefreshHint
guildId={selectedGuildId}
onRefreshed={setChannels}
noun="categories"
/>
}
required
placeholder="Select a category..."
value={selectedCategoryId}
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/hooks/useChannelRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useCallback } from "react";
import { isAxiosError } from "axios";
import { toast } from "sonner";
import { apiClient } from "@/lib/api";
import { useChannelRefreshStore } from "@/stores/channelRefresh";
import { useGuildStore } from "@/stores/guild";
import type { GuildChannel } from "@/types";

const DEFAULT_COOLDOWN_SECONDS = 60;

/**
* Re-fetches a guild's channels from Discord. Without `onRefreshed` the result is written to
* the guild store; pass it when the caller holds channels for a guild other than the selected one.
*/
export function useChannelRefresh(
guildId: string | undefined,
onRefreshed?: (channels: GuildChannel[]) => void,
) {
const secondsLeft = useChannelRefreshStore((s) => (guildId ? (s.remaining[guildId] ?? 0) : 0));
const refreshing = useChannelRefreshStore((s) => (guildId ? !!s.inFlight[guildId] : false));
const updateGuild = useGuildStore((s) => s.updateGuild);

const refresh = useCallback(async () => {
if (!guildId) return;

const { inFlight, remaining, setInFlight, startCooldown } = useChannelRefreshStore.getState();
if (inFlight[guildId] || (remaining[guildId] ?? 0) > 0) return;

setInFlight(guildId, true);
try {
const res = await apiClient.guilds.refreshChannels(guildId);
if (onRefreshed) {
onRefreshed(res.data);
} else {
updateGuild(guildId, { channels: res.data });
}
startCooldown(guildId, DEFAULT_COOLDOWN_SECONDS);
toast.success("Channel list refreshed.");
} catch (error) {
if (isAxiosError(error) && error.response?.status === 429) {
const retryAfter = Number(error.response.data?.retry_after) || DEFAULT_COOLDOWN_SECONDS;
startCooldown(guildId, retryAfter);
toast.warning(
`Channels were refreshed recently. You can refresh again in ${retryAfter} seconds.`,
);
} else {
toast.error("Could not refresh the channel list. Please try again.");
}
} finally {
setInFlight(guildId, false);
}
}, [guildId, onRefreshed, updateGuild]);

return { refresh, refreshing, secondsLeft };
}
7 changes: 7 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ export const apiClient = {
api.get<{ id: string | number; name: string; icon: string }>(`/api/${guildId}/guild`),
getChannels: (guildId: string) =>
api.get<GuildChannel[]>(`/api/${guildId}/channels`, { timeout: DISCORD_HEAVY_TIMEOUT_MS }),
/** Rate limited per guild; a 429 carries `retry_after` in seconds. */
refreshChannels: (guildId: string) =>
api.get<GuildChannel[]>(`/api/${guildId}/channels`, {
...SKIP_ERROR_TOAST,
params: { refresh: true },
timeout: DISCORD_HEAVY_TIMEOUT_MS,
}),
getRoles: (guildId: string) =>
api.get<{ roles: GuildRole[] }>(`/api/${guildId}/roles`, {
timeout: DISCORD_HEAVY_TIMEOUT_MS,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/manage/multipanels/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { prepareMultiPanelForApi } from "@/lib/panel-payload";
import { scrollToFirstMissingField } from "@/lib/scroll-to-missing";
import MultiSelect from "@/components/MultiSelect";
import Select from "@/components/Select";
import ChannelRefreshHint from "@/components/ChannelRefreshHint";
import TextInput from "@/components/TextInput";
import ColourSelect from "@/components/ColourSelect";
import { intToColour } from "@/lib/colour";
Expand Down Expand Up @@ -228,6 +229,7 @@ const MultiPanelsPage: FC = () => {
<div className="p-4 grid gap-4 grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<Select
label="Panel Channel"
hint={<ChannelRefreshHint />}
info={PANEL_MESSAGE_INFO}
required
error={staleChannel}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/manage/multipanels/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { prepareMultiPanelForApi } from "@/lib/panel-payload";
import { scrollToFirstMissingField } from "@/lib/scroll-to-missing";
import MultiSelect from "@/components/MultiSelect";
import Select from "@/components/Select";
import ChannelRefreshHint from "@/components/ChannelRefreshHint";
import TextInput from "@/components/TextInput";
import ColourSelect from "@/components/ColourSelect";
import { intToColour } from "@/lib/colour";
Expand Down Expand Up @@ -229,6 +230,7 @@ const MultiPanelsPage: FC = () => {
<div className="p-4 grid gap-4 grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<Select
label="Panel Channel"
hint={<ChannelRefreshHint />}
info={PANEL_MESSAGE_INFO}
required
error={staleChannel}
Expand Down
Loading
Loading