forked from Crown-Studio/roblox-ts-template
feat: add cross-server admin messages with compact notifications
This commit is contained in:
parent
64c5272c4e
commit
5069153045
|
|
@ -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.
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = `<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue