commands/ward-user.ts
18 KB · sha256:00eff89d6acc1a9c54030c2d619e351a49b9f81b8fffd3225a727a1acbd6bebd
import {
addGroup,
addNode,
addPermission,
denyPermission,
findPlayerByUuid,
getPlayerGroups,
hasPermission,
removeGroup,
removeNode,
removePermission,
resolvePlayerPermissions,
resolvePrefix,
resolveSuffix,
setGroup,
setPrefix,
setSuffix,
upsertPlayer,
} from "../models/Player";
import { findGroup, listGroupNames } from "../models/Group";
import { attemptDemote, attemptPromote, describeFailure } from "../lib/track-engine";
import { inferNodeType, isActive, nodeKey, parseAffix } from "../models/Node";
import {
groupSuggester,
trackSuggester,
booleanSuggester,
} from "../lib/cache";
import { describeExpiry, parseDuration } from "../lib/duration";
import { refreshOne } from "../lib/attachments";
import { err, escapeMM, ok, raw, requirePerm, warn } from "../lib/format";
import { PERM } from "./ward";
function fromPlayer(player: Player): { uuid: string; name: string } {
return {
uuid: String(player.getUniqueId()),
name: String(player.getName()),
};
}
async function fromPlayerMutate(
player: Player,
): Promise<{ uuid: string; name: string }> {
const id = fromPlayer(player);
await upsertPlayer(id.uuid, id.name);
return id;
}
function parseExpiry(
ctx: CommandCtx,
raw: string | undefined,
): number | null | false {
const ms = parseDuration(raw);
if (Number.isNaN(ms)) {
err(
ctx.sender,
`invalid duration: <white>${escapeMM(String(raw))}</white> (try 7d, 1h30m, perm)`,
);
return false;
}
return ms == null ? null : Date.now() + ms;
}
// ---------------------------------------------------------------------------
// /ward user <name> -- info card
// /ward user <name> info -- alias of the above
// ---------------------------------------------------------------------------
@Command("ward user")
export class WardUser {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userInfo)) return;
await renderUserCard(ctx, this.user);
}
}
@Command("ward user info")
export class WardUserInfo {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userInfo)) return;
await renderUserCard(ctx, this.user);
}
}
async function renderUserCard(ctx: CommandCtx, player: Player) {
const id = fromPlayer(player);
const doc = await findPlayerByUuid(id.uuid);
if (!doc) {
warn(
ctx.sender,
`<white>${escapeMM(id.name)}</white> has no Ward record yet`,
);
return;
}
const groups = await getPlayerGroups(id.uuid);
const prefix = await resolvePrefix(id.uuid);
const suffix = await resolveSuffix(id.uuid);
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
raw(
ctx.sender,
`<gradient:gold:yellow>${escapeMM(doc.username || id.name)}</gradient> <dark_gray>${doc.uuid}`,
);
raw(
ctx.sender,
`<gray>Primary: <white>${escapeMM(doc.primaryGroup || "(none)")}`,
);
raw(
ctx.sender,
`<gray>Groups: <white>${groups.length ? groups.map(escapeMM).join(", ") : "<dark_gray>(none)"}`,
);
raw(
ctx.sender,
`<gray>Prefix: <white>${prefix ? escapeMM(prefix) : "<dark_gray>(none)"}`,
);
raw(
ctx.sender,
`<gray>Suffix: <white>${suffix ? escapeMM(suffix) : "<dark_gray>(none)"}`,
);
raw(ctx.sender, `<gray>Nodes (${doc.nodes.length}):`);
if (doc.nodes.length === 0) {
raw(ctx.sender, " <dark_gray>(none)");
} else {
const sorted = [...doc.nodes].sort((a, b) => a.type.localeCompare(b.type));
for (const n of sorted) {
const sign = n.value ? "<green>+" : "<red>-";
const expiryTag = n.expiry
? ` <dark_gray>${describeExpiry(n.expiry)}`
: "";
const ctxTag = describeContext(n.context as any);
raw(
ctx.sender,
` ${sign}<white>${escapeMM(n.key)}</white> <dark_gray>[${n.type}]</dark_gray>${ctxTag}${expiryTag}`,
);
}
}
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
}
function describeContext(
c: { server?: string; world?: string } | undefined,
): string {
if (!c) return "";
const parts: string[] = [];
if (c.server) parts.push(`server=${c.server}`);
if (c.world) parts.push(`world=${c.world}`);
return parts.length ? ` <dark_gray>{${parts.join(", ")}}</dark_gray>` : "";
}
// ---------------------------------------------------------------------------
// /ward user <name> permission add <perm> [value] [duration]
// /ward user <name> permission remove <perm>
// /ward user <name> permission check <perm>
// /ward user <name> permission info
// ---------------------------------------------------------------------------
@Command("ward user permission add")
export class WardUserPermissionAdd {
@Arg("user", { type: "player" })
user!: Player;
@Arg("perm", { type: "string" })
perm!: string;
@Arg("value", { type: "string", optional: true, suggest: booleanSuggester })
value?: string;
@Arg("duration", { type: "string", optional: true })
duration?: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const isDeny = this.value === "false";
const expiry = parseExpiry(ctx, this.duration);
if (expiry === false) return;
await (isDeny
? denyPermission(id.uuid, this.perm)
: addPermission(id.uuid, this.perm));
if (expiry != null) {
// re-add with expiry (addNode upsert)
await addNode(id.uuid, this.perm, { value: !isDeny, expiry });
}
await refreshOne(id.uuid);
ok(
ctx.sender,
`${isDeny ? "denied" : "granted"} <white>${escapeMM(this.perm)}</white> for <white>${escapeMM(id.name)}` +
(expiry != null ? ` <gray>(${describeExpiry(expiry)})` : ""),
);
}
}
@Command("ward user permission remove")
export class WardUserPermissionRemove {
@Arg("user", { type: "player" })
user!: Player;
@Arg("perm", { type: "string" })
perm!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
await removePermission(id.uuid, this.perm);
await refreshOne(id.uuid);
ok(
ctx.sender,
`removed <white>${escapeMM(this.perm)}</white> from <white>${escapeMM(id.name)}`,
);
}
}
@Command("ward user permission check")
export class WardUserPermissionCheck {
@Arg("user", { type: "player" })
user!: Player;
@Arg("perm", { type: "string" })
perm!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userInfo)) return;
const id = fromPlayer(this.user);
const granted = await hasPermission(id.uuid, this.perm);
const tag = granted ? "<green>YES" : "<red>NO";
ok(
ctx.sender,
`<white>${escapeMM(id.name)}</white> ${tag}<reset> for <white>${escapeMM(this.perm)}`,
);
}
}
@Command("ward user permission info")
export class WardUserPermissionInfo {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userInfo)) return;
const id = fromPlayer(this.user);
const perms = await resolvePlayerPermissions(id.uuid);
const entries = [...perms.entries()].sort();
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
raw(
ctx.sender,
`<gray>Effective permissions for <white>${escapeMM(id.name)}</white> (${entries.length})`,
);
if (entries.length === 0) raw(ctx.sender, " <dark_gray>(none)");
for (const [k, v] of entries) {
raw(ctx.sender, ` ${v ? "<green>+" : "<red>-"}<white>${escapeMM(k)}`);
}
raw(ctx.sender, "<dark_gray>━━━━━━━━━━━━━━━━━━━━━━━━");
}
}
// ---------------------------------------------------------------------------
// /ward user <name> group add|remove|set <group>
// /ward user <name> group list
// ---------------------------------------------------------------------------
@Command("ward user group add")
export class WardUserGroupAdd {
@Arg("user", { type: "player" })
user!: Player;
@Arg("group", { type: "string", suggest: groupSuggester })
group!: string;
@Arg("duration", { type: "string", optional: true })
duration?: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
if (!(await findGroup(this.group))) {
err(ctx.sender, `unknown group: <white>${escapeMM(this.group)}`);
return;
}
const expiry = parseExpiry(ctx, this.duration);
if (expiry === false) return;
if (expiry == null) {
await addGroup(id.uuid, this.group);
} else {
await addNode(id.uuid, nodeKey.group(this.group), {
value: true,
expiry,
});
}
await refreshOne(id.uuid);
ok(
ctx.sender,
`added <white>${escapeMM(id.name)}</white> to <white>${escapeMM(this.group)}` +
(expiry != null ? ` <gray>(${describeExpiry(expiry)})` : ""),
);
}
}
@Command("ward user group remove")
export class WardUserGroupRemove {
@Arg("user", { type: "player" })
user!: Player;
@Arg("group", { type: "string", suggest: groupSuggester })
group!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
await removeGroup(id.uuid, this.group);
await refreshOne(id.uuid);
ok(
ctx.sender,
`removed <white>${escapeMM(id.name)}</white> from <white>${escapeMM(this.group)}`,
);
}
}
@Command("ward user group set")
export class WardUserGroupSet {
@Arg("user", { type: "player" })
user!: Player;
@Arg("group", { type: "string", suggest: groupSuggester })
group!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
if (!(await findGroup(this.group))) {
err(ctx.sender, `unknown group: <white>${escapeMM(this.group)}`);
return;
}
await setGroup(id.uuid, this.group);
await refreshOne(id.uuid);
ok(
ctx.sender,
`set <white>${escapeMM(id.name)}</white>'s group to <white>${escapeMM(this.group)}`,
);
}
}
@Command("ward user group list")
export class WardUserGroupList {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userInfo)) return;
const id = fromPlayer(this.user);
const groups = await getPlayerGroups(id.uuid);
ok(
ctx.sender,
`<white>${escapeMM(id.name)}</white> groups: <white>${
groups.length ? groups.map(escapeMM).join(", ") : "<dark_gray>(none)"
}`,
);
}
}
// ---------------------------------------------------------------------------
// /ward user <name> prefix set <text> [priority]
// /ward user <name> prefix clear
// /ward user <name> suffix set <text> [priority]
// /ward user <name> suffix clear
// ---------------------------------------------------------------------------
@Command("ward user prefix set")
export class WardUserPrefixSet {
@Arg("user", { type: "player" })
user!: Player;
@Arg("text", { type: "greedy", greedy: true })
text!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const parsed = splitPriorityText(this.text, 100);
await setPrefix(id.uuid, parsed.text, parsed.priority);
await refreshOne(id.uuid);
ok(
ctx.sender,
`set prefix for <white>${escapeMM(id.name)}</white> -> ${parsed.text} <dark_gray>(p=${parsed.priority})`,
);
}
}
@Command("ward user prefix clear")
export class WardUserPrefixClear {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
await setPrefix(id.uuid, "");
await refreshOne(id.uuid);
ok(ctx.sender, `cleared prefix for <white>${escapeMM(id.name)}`);
}
}
@Command("ward user suffix set")
export class WardUserSuffixSet {
@Arg("user", { type: "player" })
user!: Player;
@Arg("text", { type: "greedy", greedy: true })
text!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const parsed = splitPriorityText(this.text, 100);
await setSuffix(id.uuid, parsed.text, parsed.priority);
await refreshOne(id.uuid);
ok(
ctx.sender,
`set suffix for <white>${escapeMM(id.name)}</white> -> ${parsed.text} <dark_gray>(p=${parsed.priority})`,
);
}
}
@Command("ward user suffix clear")
export class WardUserSuffixClear {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
await setSuffix(id.uuid, "");
await refreshOne(id.uuid);
ok(ctx.sender, `cleared suffix for <white>${escapeMM(id.name)}`);
}
}
/**
* Accept either `"<text>"` (priority defaults to `dflt`) or
* `"<priority> <text>"` so admins can override priority inline.
*/
function splitPriorityText(
input: string,
dflt: number,
): { priority: number; text: string } {
const m = /^(-?\d+)\s+(.*)$/s.exec(input);
if (m) return { priority: Number(m[1]), text: m[2] };
return { priority: dflt, text: input };
}
// ---------------------------------------------------------------------------
// /ward user <name> meta set <key> <value>
// /ward user <name> meta unset <key>
// ---------------------------------------------------------------------------
@Command("ward user meta set")
export class WardUserMetaSet {
@Arg("user", { type: "player" })
user!: Player;
@Arg("key", { type: "string" })
key!: string;
@Arg("value", { type: "greedy", greedy: true })
value!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const doc = await findPlayerByUuid(id.uuid);
if (!doc) return;
// Replace any existing meta node for this key first.
doc.nodes = doc.nodes.filter(
(n) => !(n.type === "meta" && n.key.startsWith(`meta.${this.key}.`)),
);
await doc.save();
await addNode(id.uuid, nodeKey.meta(this.key, this.value));
await refreshOne(id.uuid);
ok(
ctx.sender,
`set meta <white>${escapeMM(this.key)}=${escapeMM(this.value)}</white> on <white>${escapeMM(id.name)}`,
);
}
}
@Command("ward user meta unset")
export class WardUserMetaUnset {
@Arg("user", { type: "player" })
user!: Player;
@Arg("key", { type: "string" })
key!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const doc = await findPlayerByUuid(id.uuid);
if (!doc) return;
const before = doc.nodes.length;
doc.nodes = doc.nodes.filter(
(n) => !(n.type === "meta" && n.key.startsWith(`meta.${this.key}.`)),
);
if (doc.nodes.length === before) {
warn(
ctx.sender,
`<white>${escapeMM(id.name)}</white> had no meta key <white>${escapeMM(this.key)}`,
);
return;
}
await doc.save();
await refreshOne(id.uuid);
ok(
ctx.sender,
`cleared meta <white>${escapeMM(this.key)}</white> from <white>${escapeMM(id.name)}`,
);
}
}
// ---------------------------------------------------------------------------
// /ward user <name> promote|demote <track>
// ---------------------------------------------------------------------------
@Command("ward user promote")
export class WardUserPromote {
@Arg("user", { type: "player" })
user!: Player;
@Arg("track", { type: "string", suggest: trackSuggester })
track!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const groups = await getPlayerGroups(id.uuid);
const res = await attemptPromote(this.user, this.track, groups);
if (!res.ok) {
if (res.failure) {
err(
ctx.sender,
`cannot promote <white>${escapeMM(id.name)}</white> on <white>${escapeMM(this.track)}</white>:<newline>${describeFailure(res.failure)}`,
);
} else {
err(ctx.sender, `promote failed: <white>${res.detail ?? res.reason}`);
}
return;
}
ok(
ctx.sender,
`<white>${escapeMM(id.name)}</white> promoted: <gray>${escapeMM(res.from ?? "(none)")} → <white>${escapeMM(res.to)}`,
);
}
}
@Command("ward user demote")
export class WardUserDemote {
@Arg("user", { type: "player" })
user!: Player;
@Arg("track", { type: "string", suggest: trackSuggester })
track!: string;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const res = await attemptDemote(this.user, this.track);
if (!res.ok) {
err(ctx.sender, `demote failed: <white>${res.reason}`);
return;
}
ok(
ctx.sender,
`<white>${escapeMM(id.name)}</white> demoted: <gray>${escapeMM(res.from ?? "(none)")} → <white>${escapeMM(res.to || "(removed)")}`,
);
}
}
// ---------------------------------------------------------------------------
// /ward user <name> clear -- strip every node from the player.
// ---------------------------------------------------------------------------
@Command("ward user clear")
export class WardUserClear {
@Arg("user", { type: "player" })
user!: Player;
@Run
async run(ctx: CommandCtx) {
if (!requirePerm(ctx, PERM.userEdit)) return;
const id = await fromPlayerMutate(this.user);
const doc = await findPlayerByUuid(id.uuid);
if (!doc) return;
doc.nodes = [];
await doc.save();
await refreshOne(id.uuid);
ok(ctx.sender, `cleared all nodes on <white>${escapeMM(id.name)}`);
}
}