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/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": {}
|
||||||
}
|
}
|
||||||
|
|
@ -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.";
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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