lib/chatbridge.ts
4.9 KB · sha256:90402ad1cd712eac142f047603a44af9ce7a4c1acb308f039e1f02a8d1a12657
import { env } from "../config/env";
const lastApplied = new Map<string, { prefix: string; suffix: string }>();
export function getLastDisplay(uuid: string): {
prefix: string;
suffix: string;
} {
return lastApplied.get(uuid) ?? { prefix: "", suffix: "" };
}
export function clearDisplay(uuid: string): void {
lastApplied.delete(uuid);
}
export function applyDisplay(
player: Player,
prefix: string,
suffix: string,
): void {
lastApplied.set(String(player.getUniqueId()), { prefix, suffix });
}
type ChatAwaiter = (text: string) => void;
const awaitingChat = new Map<string, ChatAwaiter>();
export function awaitChat(uuid: string, fn: ChatAwaiter): void {
awaitingChat.set(uuid, fn);
}
export function cancelAwait(uuid: string): void {
awaitingChat.delete(uuid);
}
interface PlainTextSerializerRef {
serialize(component: AsyncChatEvent["message"]): string;
}
function plainText(message: AsyncChatEvent["message"]): string {
try {
const Plain = rune.callStatic<PlainTextSerializerRef>(
"net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer",
"plainText",
);
return String(Plain.serialize(message));
} catch {
return String(message);
}
}
@Listener
export class WardChatListener {
@EventHandler(Events.AsyncChatEvent)
onChat(e: AsyncChatEvent) {
const uuid = String(e.getPlayer().getUniqueId());
const awaiter = awaitingChat.get(uuid);
if (awaiter) {
awaitingChat.delete(uuid);
e.setCancelled(true);
try {
awaiter(plainText(e.message));
} catch (err) {
console.error(
`ward.chatbridge: chat prompt callback threw for ${uuid}:`,
(err as Error)?.stack ?? err,
);
}
return;
}
if (!env.CHAT_DISPLAY) return;
// A higher-priority listener may have already consumed this message
// -- don't re-broadcast it.
if (e.isCancelled()) return;
const player = e.getPlayer();
const formatted = buildChatLine(player, e.message);
e.setCancelled(true);
try {
rune.bukkit.broadcast(formatted);
} catch (err) {
// Older / forked servers may not expose Server#broadcast(Component);
// fall through to per-player sendMessage which Audience always has.
for (const p of rune.bukkit.getOnlinePlayers()) {
try {
p.sendMessage(formatted);
} catch {}
}
console.warn(
"ward.chatbridge: broadcast fell back to per-player:",
(err as Error)?.message ?? err,
);
}
}
}
const MESSAGE_TOKEN = "<message>";
/**
* Render env.CHAT_FORMAT into a Component, splicing the player's literal
* chat `message` at every occurrence of `<message>`. Each surrounding
* segment is PAPI-resolved against `player`, then MiniMessage-parsed,
* and decorated with env.CHAT_HOVER as hover-text (also PAPI + MM).
*
* Hover is attached per-segment rather than to the whole line so that
* mousing over the player's literal message text shows nothing -- only
* the formatting (prefix, name, suffix, separator) reveals the card.
*
* Never throws -- PAPI / MiniMessage failures degrade to the raw string
* so chat keeps flowing even with a misconfigured template.
*/
function buildChatLine(
player: Player,
message: AsyncChatEvent["message"],
): TextComponent {
const segments = env.CHAT_FORMAT.split(MESSAGE_TOKEN);
const hover = env.CHAT_HOVER ? renderSegment(player, env.CHAT_HOVER) : null;
let line = Component.empty();
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg) {
let cmp = renderSegment(player, seg);
if (hover) cmp = withHover(cmp, hover);
line = line.append(cmp);
}
if (i < segments.length - 1) line = line.append(message);
}
return line;
}
/**
* Attach `hover` as a show_text HoverEvent. Falls back through a couple
* of API shapes because rune's proxy may expose either the Adventure
* builder-style call or the static factory.
*/
function withHover(cmp: TextComponent, hover: TextComponent): TextComponent {
try {
return cmp.hoverEvent(HoverEvent.showText(hover));
} catch {
try {
const evt = rune.callStatic<HoverEvent>(
"net.kyori.adventure.text.event.HoverEvent",
"showText",
hover,
);
return cmp.hoverEvent(evt);
} catch {
return cmp;
}
}
}
/**
* PAPI-resolve then MiniMessage-deserialize a single template segment.
* Errors at either stage fall back to the next-best textual rendering.
*/
function renderSegment(player: Player, segment: string): TextComponent {
let resolved = segment;
if (typeof papi !== "undefined" && papi?.PlaceholderAPI) {
try {
resolved = String(
papi.PlaceholderAPI.setPlaceholders(player, segment),
);
} catch (e) {
console.warn(
"ward.chatbridge: PAPI resolve failed; using raw segment:",
(e as Error)?.message ?? e,
);
}
}
try {
return rune.mm(resolved);
} catch {
return Component.text(resolved);
}
}