From 5069153045dc8e98012cc8114948ab6989b95647 Mon Sep 17 00:00:00 2001 From: Infuseting Date: Wed, 14 Oct 2026 11:20:41 +0200 Subject: [PATCH] feat: add cross-server admin messages with compact notifications --- docs/admin-messages.md | 30 ++++ flamework.build | 5 +- .../controllers/admin-message-controller.tsx | 46 ++++++ src/client/ui/admin-message.tsx | 144 ++++++++++++++++++ src/server/commands/announce.ts | 10 ++ src/server/commands/announceServer.ts | 8 + src/server/commands/pm.ts | 21 +++ src/server/commands/pmServer.ts | 9 ++ src/server/services/admin-message-service.ts | 136 +++++++++++++++++ src/server/services/cmdr-service.ts | 3 +- src/shared/admin-message.ts | 29 ++++ 11 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 docs/admin-messages.md create mode 100644 src/client/controllers/admin-message-controller.tsx create mode 100644 src/client/ui/admin-message.tsx create mode 100644 src/server/commands/announce.ts create mode 100644 src/server/commands/announceServer.ts create mode 100644 src/server/commands/pm.ts create mode 100644 src/server/commands/pmServer.ts create mode 100644 src/server/services/admin-message-service.ts create mode 100644 src/shared/admin-message.ts diff --git a/docs/admin-messages.md b/docs/admin-messages.md new file mode 100644 index 0000000..8a5a37f --- /dev/null +++ b/docs/admin-messages.md @@ -0,0 +1,30 @@ +# Messages administrateur + +Dans Cmdr (F2), les commandes suivent les permissions existantes du projet. + +```text +announce "Hello World!" +ann "Server event starts soon." +pm me "First line\nSecond line" +pm Infu "A message using a shortened local username." +pm all "A message for players in this server." +pm others "A message for everyone here except me." +pm ?2 "A message for two random local players." +pm @FullUsername "A message for a player in any live server." +pm #123456789 "A message addressed by UserId." +pm @FirstUsername,SecondUsername "A message for multiple recipients." +``` + +Les commandes sont insensibles à la casse. `m` et `ann` sont des alias de `announce`. Les sélecteurs Cmdr (`.`, `*`, `**`, `?`, `?N`, `me`, `all`, `others`, `random`, noms abrégés et listes) concernent les joueurs du serveur courant. Les préfixes `@` et `#` permettent de cibler par nom complet ou UserId dans les autres serveurs de la même expérience. Les joueurs hors ligne ne reçoivent rien. + +Le texte est limité à 400 octets, PM à 20 destinataires et les envois sont espacés de 3 secondes par administrateur. Chaque carte reste 10 secondes, avec une file limitée à 8 messages en attente. La carte est centrée horizontalement en haut, avec une marge de 24 pixels sous la zone réservée à l'interface Roblox. Le fond s'ajuste au texte, avec une largeur maximale de 560 pixels, une limite adaptée au viewport et un défilement pour les messages longs. Les vrais retours à la ligne et `\n` sont pris en charge ; les balises RichText saisies sont affichées comme du texte. + +## Architecture et références + +- Cmdr `@rbxts/cmdr` 1.12.1 fournit les commandes, les sélecteurs et les unions préfixées : [commandes et shorthand](https://eryn.io/Cmdr/docs/commands/). Les types et le parseur installés ont été vérifiés localement. +- Flamework Networking 1.3.2 fournit l'événement serveur → client typé, via `createServer` et `createClient`, conformément aux déclarations installées : [documentation](https://flamework.fireboltofdeath.dev/docs/additional-modules/networking/introduction/). +- [MessagingService](https://create.roblox.com/docs/reference/engine/classes/MessagingService) fournit la diffusion interserveurs sur un topic versionné. Ce choix permet de contrôler les échecs, la reconnexion et la déduplication, sans utiliser la file globale de `@rbxts/net` 3.0.10. Aucun paquet ajouté. +- [TextService](https://create.roblox.com/docs/reference/engine/classes/TextService) filtre le texte avant toute diffusion. Le résultat utilise [GetNonChatStringForBroadcastAsync](https://create.roblox.com/docs/reference/engine/classes/TextFilterResult) et PM contrôle les permissions avec `TextChatService.CanUsersChatAsync`. +- React et ReactRoblox 17.3.13-ts.1 rendent la carte ; `TextService.GetTextSize` mesure le texte pour ajuster ses dimensions. + +Les messages interserveurs sont éphémères, sans garantie de livraison ou de présence du destinataire. L'échec de publication est signalé dans Cmdr, même si le message a pu être livré localement. Une expérience non publiée fonctionne uniquement localement ; la diffusion réelle doit être vérifiée avec deux serveurs d'une expérience publiée et les accès aux services Roblox disponibles. diff --git a/flamework.build b/flamework.build index 75e970c..aa011d5 100644 --- a/flamework.build +++ b/flamework.build @@ -5,7 +5,10 @@ "crownstudio-roblox-ts-template:out/server/services/startup-service@StartupService": "server/services/startup-service@StartupService", "crownstudio-roblox-ts-template:out/client/controllers/cmdr-controller@CmdrController": "client/controllers/cmdr-controller@CmdrController", "crownstudio-roblox-ts-template:out/server/services/cmdr-service@CmdrService": "server/services/cmdr-service@CmdrService", - "crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController" + "crownstudio-roblox-ts-template:out/client/controllers/welcome-chat-controller@WelcomeChatController": "client/controllers/welcome-chat-controller@WelcomeChatController", + "crownstudio-roblox-ts-template:out/server/services/admin-message-service@AdminMessageService": "server/services/admin-message-service@AdminMessageService", + "crownstudio-roblox-ts-template:out/shared/admin-message@AdminMessageEvents": "shared/admin-message@AdminMessageEvents", + "crownstudio-roblox-ts-template:out/client/controllers/admin-message-controller@AdminMessageController": "client/controllers/admin-message-controller@AdminMessageController" }, "metadata": {} } \ No newline at end of file diff --git a/src/client/controllers/admin-message-controller.tsx b/src/client/controllers/admin-message-controller.tsx new file mode 100644 index 0000000..d052d11 --- /dev/null +++ b/src/client/controllers/admin-message-controller.tsx @@ -0,0 +1,46 @@ +import { Controller, OnStart } from "@flamework/core"; +import React from "@rbxts/react"; +import { createRoot } from "@rbxts/react-roblox"; +import { Players } from "@rbxts/services"; +import { Trove } from "@rbxts/trove"; +import { AdminMessageCard } from "client/ui/admin-message"; +import { AdminMessage, AdminMessageEvents } from "shared/admin-message"; + +@Controller() +export class AdminMessageController implements OnStart { + private readonly trove = new Trove(); + private readonly events = AdminMessageEvents.createClient({}); + + public onStart(): void { + const folder = new Instance("Folder"); + folder.Name = "AdminMessageRoot"; + folder.Parent = Players.LocalPlayer.WaitForChild("PlayerGui"); + const root = createRoot(folder); + this.trove.add(folder); + this.trove.add(() => root.unmount()); + const queue: AdminMessage[] = []; + let running = false; + let alive = true; + this.trove.add(() => { + alive = false; + }); + this.trove.add( + this.events.adminMessage.connect((message) => { + if (queue.size() >= 8) queue.shift(); + queue.push(message); + if (running) return; + running = true; + task.spawn(() => { + while (alive && queue.size() > 0) { + const current = queue.shift()!; + root.render(); + task.wait(10); + } + if (alive) root.render(undefined); + running = false; + }); + }), + ); + this.trove.attachToInstance(script); + } +} diff --git a/src/client/ui/admin-message.tsx b/src/client/ui/admin-message.tsx new file mode 100644 index 0000000..a3125d2 --- /dev/null +++ b/src/client/ui/admin-message.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from "@rbxts/react"; +import { Players, TextService, Workspace } from "@rbxts/services"; +import { AdminMessage } from "shared/admin-message"; + +function escapeRichText(text: string): string { + return text + .gsub("&", "&")[0] + .gsub("<", "<")[0] + .gsub(">", ">")[0] + .gsub('"', """)[0]; +} + +export function AdminMessageCard({ message }: { message: AdminMessage }): React.Element { + const [image, setImage] = useState(""); + const [viewport, setViewport] = useState( + Workspace.CurrentCamera?.ViewportSize ?? new Vector2(800, 600), + ); + useEffect(() => { + let active = true; + task.spawn(() => { + const [ok, thumbnail] = pcall( + () => + Players.GetUserThumbnailAsync( + message.senderId, + Enum.ThumbnailType.HeadShot, + Enum.ThumbnailSize.Size150x150, + )[0], + ); + if (ok && active) setImage(thumbnail); + }); + return () => { + active = false; + }; + }, [message.senderId]); + useEffect(() => { + let connection: RBXScriptConnection | undefined; + const bindCamera = () => { + connection?.Disconnect(); + const camera = Workspace.CurrentCamera; + if (!camera) return; + setViewport(camera.ViewportSize); + connection = camera + .GetPropertyChangedSignal("ViewportSize") + .Connect(() => setViewport(camera.ViewportSize)); + }; + bindCamera(); + const cameraChanged = Workspace.GetPropertyChangedSignal("CurrentCamera").Connect(bindCamera); + return () => { + connection?.Disconnect(); + cameraChanged.Disconnect(); + }; + }, []); + const textSize = viewport.X < 480 ? 16 : 18; + const prefixWidth = message.private ? 42 : 0; + const fixedWidth = 24 + prefixWidth + 30 + 8; + const maximumWidth = math.max(80, math.min(560, viewport.X - 32) - fixedWidth); + const plainText = `${message.displayName}: ${message.text}`; + const bounds = TextService.GetTextSize( + plainText, + textSize, + Enum.Font.GothamMedium, + new Vector2(maximumWidth, 10000), + ); + const textWidth = math.min(maximumWidth, math.ceil(bounds.X) + 4); + const measured = TextService.GetTextSize( + plainText, + textSize, + Enum.Font.GothamMedium, + new Vector2(textWidth, 10000), + ); + const fullHeight = math.ceil(measured.Y) + 4; + const bodyHeight = math.min(fullHeight, math.max(36, math.min(120, viewport.Y * 0.22))); + const contentHeight = math.max(30, bodyHeight); + const richText = `${escapeRichText(message.displayName)}: ${escapeRichText(message.text)}`; + return ( + + + + {message.private && ( + + )} + + + + bodyHeight ? 3 : 0} + BackgroundTransparency={1} + BorderSizePixel={0} + > + + + + + ); +} diff --git a/src/server/commands/announce.ts b/src/server/commands/announce.ts new file mode 100644 index 0000000..6d53deb --- /dev/null +++ b/src/server/commands/announce.ts @@ -0,0 +1,10 @@ +const command = { + Name: "announce", + Aliases: ["m", "ann"], + Description: "Display an announcement across all live servers. Use \\n for a new line.", + Group: "DefaultAdmin", + Args: [ + { Type: "string", Name: "message", Description: "The announcement text (maximum 400 bytes)." }, + ], +}; +export = command; diff --git a/src/server/commands/announceServer.ts b/src/server/commands/announceServer.ts new file mode 100644 index 0000000..b206d05 --- /dev/null +++ b/src/server/commands/announceServer.ts @@ -0,0 +1,8 @@ +import { Dependency } from "@flamework/core"; +import { CommandContext } from "@rbxts/cmdr"; +import { AdminMessageService } from "server/services/admin-message-service"; + +function run(context: CommandContext, text: string): string { + return Dependency().send(context.Executor, text); +} +export = run; diff --git a/src/server/commands/pm.ts b/src/server/commands/pm.ts new file mode 100644 index 0000000..6d73b65 --- /dev/null +++ b/src/server/commands/pm.ts @@ -0,0 +1,21 @@ +const command = { + Name: "pm", + Aliases: ["privatemessage"], + Description: + "Send a private message, including to players in another server. Use \\n for a new line.", + Group: "DefaultAdmin", + Args: [ + { + Type: "players @ playerIds # integers", + Name: "recipients", + Description: + "Local names/shorthand, @FullUsername, or #UserId; comma-separated lists supported.", + }, + { + Type: "string", + Name: "message", + Description: "The private message text (maximum 400 bytes).", + }, + ], +}; +export = command; diff --git a/src/server/commands/pmServer.ts b/src/server/commands/pmServer.ts new file mode 100644 index 0000000..7922526 --- /dev/null +++ b/src/server/commands/pmServer.ts @@ -0,0 +1,9 @@ +import { Dependency } from "@flamework/core"; +import { CommandContext } from "@rbxts/cmdr"; +import { AdminMessageService } from "server/services/admin-message-service"; + +function run(context: CommandContext, targets: (Player | number)[], text: string): string { + const recipients = targets.map((target) => (typeIs(target, "number") ? target : target.UserId)); + return Dependency().send(context.Executor, text, recipients); +} +export = run; diff --git a/src/server/services/admin-message-service.ts b/src/server/services/admin-message-service.ts new file mode 100644 index 0000000..ae35057 --- /dev/null +++ b/src/server/services/admin-message-service.ts @@ -0,0 +1,136 @@ +import { OnStart, Service } from "@flamework/core"; +import Log from "@rbxts/log"; +import { + HttpService, + MessagingService, + Players, + RunService, + TextChatService, + TextService, +} from "@rbxts/services"; +import { Trove } from "@rbxts/trove"; +import { isCmdrAllowed } from "server/config/cmdr-permissions"; +import { + ADMIN_MESSAGE_TOPIC, + AdminMessage, + AdminMessageEvents, + isAdminMessage, +} from "shared/admin-message"; + +@Service() +export class AdminMessageService implements OnStart { + private readonly trove = new Trove(); + private readonly seen = new Set(); + private readonly lastSent = new Map(); + private closed = false; + private readonly events = AdminMessageEvents.createServer({}); + + public onStart(): void { + this.trove.connect(Players.PlayerRemoving, (player) => this.lastSent.delete(player.UserId)); + game.BindToClose(() => { + this.closed = true; + this.trove.destroy(); + }); + if (game.GameId === 0) { + Log.Info("Admin messages: local mode in an unpublished experience."); + return; + } + task.spawn(() => { + while (!this.closed) { + const [ok, connection] = pcall(() => + MessagingService.SubscribeAsync(ADMIN_MESSAGE_TOPIC, (message) => { + if (isAdminMessage(message.Data)) this.receive(message.Data); + }), + ); + if (ok) { + this.trove.add(connection); + return; + } + Log.Warn("Admin message subscription failed: {Reason}; retrying in 30 seconds", connection); + task.wait(30); + } + }); + } + + public send(sender: Player, rawText: string, targets?: number[]): string { + if ( + !isCmdrAllowed( + sender.UserId, + RunService.IsStudio(), + game.CreatorType === Enum.CreatorType.User ? game.CreatorId : undefined, + ) + ) { + return "You do not have permission to run this command."; + } + const text = rawText.gsub("\\n", "\n")[0]; + if (text.size() > 400 || text.gsub("%s", "")[0].size() === 0) + return "Enter a message between 1 and 400 bytes."; + if (targets && (targets.size() === 0 || targets.size() > 20)) + return "Select between 1 and 20 recipients."; + const now = os.clock(); + if (now - (this.lastSent.get(sender.UserId) ?? -10) < 3) + return "Please wait 3 seconds between messages."; + this.lastSent.set(sender.UserId, now); + const [ok, result] = pcall(() => { + const recipients = targets ? [...new Set(targets)] : []; + if (targets) { + const otherIds = recipients.filter((id) => id !== sender.UserId); + if (otherIds.size() > 0) { + if ( + otherIds.some( + (id) => + !TextChatService.CanUsersChatAsync( + sender.UserId as unknown as User, + id as unknown as User, + ), + ) + ) + return "Private messaging is unavailable for one or more recipients."; + } + } + const filtered = TextService.FilterStringAsync( + text, + sender.UserId, + Enum.TextFilterContext.PublicChat, + ).GetNonChatStringForBroadcastAsync(); + const message: AdminMessage = { + id: HttpService.GenerateGUID(false), + sentAt: os.time(), + senderId: sender.UserId, + displayName: sender.DisplayName, + text: filtered, + private: targets !== undefined, + recipients, + }; + if (HttpService.JSONEncode(message).size() > 1000) + return "The encoded message is too large. Shorten it or select fewer recipients."; + this.receive(message); + if (game.GameId === 0) + return "Message delivered locally. Publish the experience to enable cross-server delivery."; + const [published, reason] = pcall(() => + MessagingService.PublishAsync(ADMIN_MESSAGE_TOPIC, message), + ); + if (!published) { + Log.Warn("Admin message publish failed: {Reason}", reason); + return "Message delivered locally, but cross-server delivery failed. Try again later."; + } + return "Message sent to live servers. Offline players do not receive messages."; + }); + if (!ok) { + Log.Warn("Admin message could not be sent: {Reason}", result); + return "Message could not be sent. Text filtering or messaging is unavailable."; + } + return result; + } + + private receive(message: AdminMessage): void { + if (this.seen.has(message.id) || math.abs(os.time() - message.sentAt) > 60) return; + if (message.text.size() > 400 || message.recipients.size() > 20) return; + this.seen.add(message.id); + task.delay(65, () => this.seen.delete(message.id)); + for (const player of Players.GetPlayers()) { + if (!message.private || message.recipients.includes(player.UserId)) + this.events.adminMessage.fire(player, message); + } + } +} diff --git a/src/server/services/cmdr-service.ts b/src/server/services/cmdr-service.ts index 16177d6..daead66 100644 --- a/src/server/services/cmdr-service.ts +++ b/src/server/services/cmdr-service.ts @@ -17,7 +17,8 @@ export class CmdrService implements OnStart { return "You do not have permission to run this command."; } }); - Cmdr.Registry.RegisterDefaultCommands(); + Cmdr.Registry.RegisterDefaultCommands((command) => command.Name !== "announce"); + Cmdr.Registry.RegisterCommandsIn(script.Parent!.Parent!.WaitForChild("commands")); this.trove.connect(Players.PlayerAdded, (player) => { this.updateAccess(player); diff --git a/src/shared/admin-message.ts b/src/shared/admin-message.ts new file mode 100644 index 0000000..87b3f31 --- /dev/null +++ b/src/shared/admin-message.ts @@ -0,0 +1,29 @@ +import { Networking } from "@flamework/networking"; +import { t } from "@rbxts/t"; + +export interface AdminMessage { + id: string; + sentAt: number; + senderId: number; + displayName: string; + text: string; + private: boolean; + recipients: number[]; +} + +export const isAdminMessage = t.interface({ + id: t.string, + sentAt: t.number, + senderId: t.number, + displayName: t.string, + text: t.string, + private: t.boolean, + recipients: t.array(t.number), +}); + +interface ServerEvents { + adminMessage(message: AdminMessage): void; +} + +export const AdminMessageEvents = Networking.createEvent, ServerEvents>(); +export const ADMIN_MESSAGE_TOPIC = "AdminMessage.v1";