feat: add cross-server admin messages with compact notifications

This commit is contained in:
Infuseting 2026-10-14 11:20:41 +02:00
parent 64c5272c4e
commit 5069153045
11 changed files with 439 additions and 2 deletions

30
docs/admin-messages.md Normal file
View File

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

View File

@ -5,7 +5,10 @@
"crownstudio-roblox-ts-template:out/server/services/startup-service@StartupService": "server/services/startup-service@StartupService", "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/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/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": {} "metadata": {}
} }

View File

@ -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(<AdminMessageCard key={current.id} message={current} />);
task.wait(10);
}
if (alive) root.render(undefined);
running = false;
});
}),
);
this.trove.attachToInstance(script);
}
}

View File

@ -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("&", "&amp;")[0]
.gsub("<", "&lt;")[0]
.gsub(">", "&gt;")[0]
.gsub('"', "&quot;")[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 = `<font color="#FFDD41">${escapeRichText(message.displayName)}:</font> ${escapeRichText(message.text)}`;
return (
<screengui
key="AdminMessages"
ResetOnSpawn={false}
DisplayOrder={50}
ZIndexBehavior={Enum.ZIndexBehavior.Sibling}
>
<frame
key="Card"
AnchorPoint={new Vector2(0.5, 0)}
Position={new UDim2(0.5, 0, 0, 24)}
Size={UDim2.fromOffset(fixedWidth + textWidth, contentHeight + 20)}
BackgroundColor3={Color3.fromRGB(0, 0, 0)}
BackgroundTransparency={0.35}
BorderSizePixel={0}
>
<uicorner CornerRadius={new UDim(0, 8)} />
{message.private && (
<textlabel
key="PrivatePrefix"
Position={UDim2.fromOffset(12, 10)}
Size={UDim2.fromOffset(36, 30)}
BackgroundTransparency={1}
Text="[PM]"
TextColor3={Color3.fromRGB(170, 170, 170)}
TextSize={textSize}
Font={Enum.Font.GothamMedium}
TextStrokeColor3={Color3.fromRGB(0, 0, 0)}
TextStrokeTransparency={0}
/>
)}
<imagelabel
key="Headshot"
Position={UDim2.fromOffset(12 + prefixWidth, 10)}
Size={UDim2.fromOffset(30, 30)}
BackgroundTransparency={1}
Image={image}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</imagelabel>
<scrollingframe
key="Body"
Position={UDim2.fromOffset(12 + prefixWidth + 38, 10 + (contentHeight - bodyHeight) / 2)}
Size={UDim2.fromOffset(textWidth, bodyHeight)}
CanvasSize={UDim2.fromOffset(0, fullHeight)}
ScrollingDirection={Enum.ScrollingDirection.Y}
ScrollBarThickness={fullHeight > bodyHeight ? 3 : 0}
BackgroundTransparency={1}
BorderSizePixel={0}
>
<textlabel
key="MessageText"
Size={UDim2.fromOffset(textWidth, fullHeight)}
BackgroundTransparency={1}
Text={richText}
RichText={true}
TextWrapped={true}
TextColor3={Color3.fromRGB(255, 255, 255)}
TextSize={textSize}
Font={Enum.Font.GothamMedium}
TextStrokeColor3={Color3.fromRGB(0, 0, 0)}
TextStrokeTransparency={0}
TextXAlignment={Enum.TextXAlignment.Left}
TextYAlignment={Enum.TextYAlignment.Top}
/>
</scrollingframe>
</frame>
</screengui>
);
}

View File

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

View File

@ -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<AdminMessageService>().send(context.Executor, text);
}
export = run;

21
src/server/commands/pm.ts Normal file
View File

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

View File

@ -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<AdminMessageService>().send(context.Executor, text, recipients);
}
export = run;

View File

@ -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<string>();
private readonly lastSent = new Map<number, number>();
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);
}
}
}

View File

@ -17,7 +17,8 @@ export class CmdrService implements OnStart {
return "You do not have permission to run this command."; 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.trove.connect(Players.PlayerAdded, (player) => {
this.updateAccess(player); this.updateAccess(player);

View File

@ -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<Record<never, never>, ServerEvents>();
export const ADMIN_MESSAGE_TOPIC = "AdminMessage.v1";