mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(player): unified authorize() gating + non-destructive guest play-now
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
821fa0669d
commit
d763043305
2 files changed
+178
-23
No files matched your search
@@ -235,3 +235,129 @@ describe("permission enforcement on action routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Guest enforcement on the player routes. Guests carry per-flag permissions
|
||||||
|
// (req.user.guest) instead of capabilities; authorize() opens a route only
|
||||||
|
// when its guestFlag is set AND enabled. Routes with no guestFlag are denied
|
||||||
|
// to guests no matter which flags are on. We reuse makeApp() (it injects
|
||||||
|
// req.user and mounts the real player router over the fake bot manager) and
|
||||||
|
// assert purely on 403-vs-not-403 — a 200/500 from the fake bot both prove
|
||||||
|
// the gate let the request through.
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SONG = { id: "1", platform: "netease", name: "x", artist: "y" };
|
||||||
|
|
||||||
|
// Build a guest user with all flags off, then override the ones passed in.
|
||||||
|
const guest = (perms: Partial<Record<string, boolean>> = {}) => ({
|
||||||
|
id: "__guest__",
|
||||||
|
username: "游客",
|
||||||
|
role: "guest" as const,
|
||||||
|
capabilities: new Set<string>(),
|
||||||
|
bots: "all" as const,
|
||||||
|
guest: {
|
||||||
|
addToQueue: false,
|
||||||
|
playNext: false,
|
||||||
|
playNow: false,
|
||||||
|
skip: false,
|
||||||
|
transport: false,
|
||||||
|
removeClear: false,
|
||||||
|
playMode: false,
|
||||||
|
...perms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountGuest = (perms: Partial<Record<string, boolean>> = {}) => makeApp(guest(perms));
|
||||||
|
|
||||||
|
describe("guest enforcement on player routes", () => {
|
||||||
|
it("addToQueue flag gates POST /add, /add-song, /add-by-id", async () => {
|
||||||
|
const allow = mountGuest({ addToQueue: true });
|
||||||
|
const deny = mountGuest({ addToQueue: false });
|
||||||
|
for (const path of ["add", "add-song", "add-by-id"]) {
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).not.toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/${path}`).send({ song: SONG, songId: "1", query: "x" })).status).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("playNext flag gates /play-next-song", async () => {
|
||||||
|
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).not.toBe(403);
|
||||||
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("playNow flag gates the new /play-now-song", async () => {
|
||||||
|
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).not.toBe(403);
|
||||||
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
// playNext does NOT open play-now-song, and playNow does NOT open play-next-song.
|
||||||
|
expect((await request(mountGuest({ playNext: true })).post(`/api/player/${ALLOWED_BOT}/play-now-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
expect((await request(mountGuest({ playNow: true })).post(`/api/player/${ALLOWED_BOT}/play-next-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skip flag gates /next", async () => {
|
||||||
|
expect((await request(mountGuest({ skip: true })).post(`/api/player/${ALLOWED_BOT}/next`)).status).not.toBe(403);
|
||||||
|
expect((await request(mountGuest({})).post(`/api/player/${ALLOWED_BOT}/next`)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transport flag gates /pause, /resume, /seek, /volume", async () => {
|
||||||
|
const allow = mountGuest({ transport: true });
|
||||||
|
const deny = mountGuest({ transport: false });
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/pause`)).status).not.toBe(403);
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/resume`)).status).not.toBe(403);
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).not.toBe(403);
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).not.toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/resume`)).status).toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/seek`).send({ position: 0 })).status).toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/volume`).send({ volume: 50 })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("playMode flag gates /mode, /fm", async () => {
|
||||||
|
const allow = mountGuest({ playMode: true });
|
||||||
|
const deny = mountGuest({ playMode: false });
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).not.toBe(403);
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).not.toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/mode`).send({ mode: "seq" })).status).toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/fm`).send({})).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeClear flag gates /clear and DELETE /queue/:index", async () => {
|
||||||
|
const allow = mountGuest({ removeClear: true });
|
||||||
|
const deny = mountGuest({ removeClear: false });
|
||||||
|
expect((await request(allow).post(`/api/player/${ALLOWED_BOT}/clear`)).status).not.toBe(403);
|
||||||
|
expect((await request(allow).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).not.toBe(403);
|
||||||
|
expect((await request(deny).post(`/api/player/${ALLOWED_BOT}/clear`)).status).toBe(403);
|
||||||
|
expect((await request(deny).delete(`/api/player/${ALLOWED_BOT}/queue/0`)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each guest flag opens exactly its own route(s) — a single flag does not leak", async () => {
|
||||||
|
// With only addToQueue on, a transport route stays denied.
|
||||||
|
expect((await request(mountGuest({ addToQueue: true })).post(`/api/player/${ALLOWED_BOT}/pause`)).status).toBe(403);
|
||||||
|
// With only transport on, an add route stays denied.
|
||||||
|
expect((await request(mountGuest({ transport: true })).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guests are always denied /play, /prev, /stop, /play-song, /play-at, /play-playlist, /play-album, /playlist, /profile even with ALL flags on", async () => {
|
||||||
|
const all = mountGuest({
|
||||||
|
addToQueue: true,
|
||||||
|
playNext: true,
|
||||||
|
playNow: true,
|
||||||
|
skip: true,
|
||||||
|
transport: true,
|
||||||
|
removeClear: true,
|
||||||
|
playMode: true,
|
||||||
|
});
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play`).send({ query: "x" })).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/prev`)).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/stop`)).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-song`).send({ song: SONG })).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-at`).send({ index: 0 })).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-playlist`).send({ playlistId: "1" })).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/play-album`).send({ albumId: "1" })).status).toBe(403);
|
||||||
|
expect((await request(all).post(`/api/player/${ALLOWED_BOT}/playlist`).send({ playlistId: "1" })).status).toBe(403);
|
||||||
|
expect((await request(all).put(`/api/player/${ALLOWED_BOT}/profile`).send({})).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("members are unaffected — player.queue still reaches /add-song", async () => {
|
||||||
|
const m = makeApp(member(["player.queue"], [ALLOWED_BOT]));
|
||||||
|
expect((await request(m).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).not.toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
+52
-23
@@ -4,7 +4,8 @@ import type { BotDatabase } from "../../data/database.js";
|
|||||||
import type { MusicProvider } from "../../music/provider.js";
|
import type { MusicProvider } from "../../music/provider.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import { parseCommand } from "../../bot/commands.js";
|
import { parseCommand } from "../../bot/commands.js";
|
||||||
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
|
import { requireBotAccess } from "../middleware/requirePermission.js";
|
||||||
|
import { authorize } from "../middleware/authorize.js";
|
||||||
|
|
||||||
export function createPlayerRouter(
|
export function createPlayerRouter(
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
@@ -41,7 +42,7 @@ export function createPlayerRouter(
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post("/:botId/play", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { query, platform } = req.body;
|
const { query, platform } = req.body;
|
||||||
@@ -61,7 +62,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/add", requirePermission("player.queue"), async (req, res) => {
|
router.post("/:botId/add", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { query, platform } = req.body;
|
const { query, platform } = req.body;
|
||||||
@@ -88,14 +89,14 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post("/:botId/pause", requirePermission("player.control"), simpleCommand("!pause"));
|
router.post("/:botId/pause", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!pause"));
|
||||||
router.post("/:botId/resume", requirePermission("player.control"), simpleCommand("!resume"));
|
router.post("/:botId/resume", authorize({ capability: "player.control", guestFlag: "transport" }), simpleCommand("!resume"));
|
||||||
router.post("/:botId/next", requirePermission("player.control"), simpleCommand("!next"));
|
router.post("/:botId/next", authorize({ capability: "player.control", guestFlag: "skip" }), simpleCommand("!next"));
|
||||||
router.post("/:botId/prev", requirePermission("player.control"), simpleCommand("!prev"));
|
router.post("/:botId/prev", authorize({ capability: "player.control" }), simpleCommand("!prev"));
|
||||||
router.post("/:botId/stop", requirePermission("player.control"), simpleCommand("!stop"));
|
router.post("/:botId/stop", authorize({ capability: "player.control" }), simpleCommand("!stop"));
|
||||||
router.post("/:botId/clear", requirePermission("player.queue"), simpleCommand("!clear"));
|
router.post("/:botId/clear", authorize({ capability: "player.queue", guestFlag: "removeClear" }), simpleCommand("!clear"));
|
||||||
|
|
||||||
router.post("/:botId/fm", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/fm", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { platform } = req.body;
|
const { platform } = req.body;
|
||||||
@@ -117,7 +118,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/volume", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/volume", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { volume } = req.body;
|
const { volume } = req.body;
|
||||||
@@ -145,7 +146,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
const VALID_MODES = new Set(["seq", "loop", "random", "rloop"]);
|
||||||
|
|
||||||
router.post("/:botId/mode", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/mode", authorize({ capability: "player.control", guestFlag: "playMode" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { mode } = req.body;
|
const { mode } = req.body;
|
||||||
@@ -170,7 +171,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Seek to position
|
// Seek to position
|
||||||
router.post("/:botId/seek", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/seek", authorize({ capability: "player.control", guestFlag: "transport" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { position } = req.body; // seconds
|
const { position } = req.body; // seconds
|
||||||
@@ -194,7 +195,7 @@ export function createPlayerRouter(
|
|||||||
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:botId/queue/:index", requirePermission("player.queue"), async (req, res) => {
|
router.delete("/:botId/queue/:index", authorize({ capability: "player.queue", guestFlag: "removeClear" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
const cmd = parseCommand(`!remove ${req.params.index}`, "!")!;
|
||||||
@@ -206,7 +207,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Jump to a specific index in the queue (without clearing it)
|
// Jump to a specific index in the queue (without clearing it)
|
||||||
router.post("/:botId/play-at", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play-at", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { index } = req.body;
|
const { index } = req.body;
|
||||||
@@ -240,7 +241,7 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/playlist", requirePermission("player.queue"), async (req, res) => {
|
router.post("/:botId/playlist", authorize({ capability: "player.queue" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
@@ -257,7 +258,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
// Play a playlist by ID — stores metadata only, resolves URL for first song
|
||||||
// Respects current play mode (random = pick random first song)
|
// Respects current play mode (random = pick random first song)
|
||||||
router.post("/:botId/play-playlist", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play-playlist", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { playlistId, platform } = req.body;
|
const { playlistId, platform } = req.body;
|
||||||
@@ -344,7 +345,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
// Play an album by ID — mirrors play-playlist but calls getAlbumSongs
|
||||||
router.post("/:botId/play-album", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play-album", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { albumId, platform } = req.body;
|
const { albumId, platform } = req.body;
|
||||||
@@ -416,7 +417,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Play a single song by ID — resolves URL on demand
|
// Play a single song by ID — resolves URL on demand
|
||||||
router.post("/:botId/play-song", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play-song", authorize({ capability: "player.control" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -444,7 +445,7 @@ export function createPlayerRouter(
|
|||||||
|
|
||||||
// Insert a single song to play right after the current one.
|
// Insert a single song to play right after the current one.
|
||||||
// If nothing is playing, behaves like /play-song (start immediately).
|
// If nothing is playing, behaves like /play-song (start immediately).
|
||||||
router.post("/:botId/play-next-song", requirePermission("player.control"), async (req, res) => {
|
router.post("/:botId/play-next-song", authorize({ capability: "player.control", guestFlag: "playNext" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -482,7 +483,35 @@ export function createPlayerRouter(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/:botId/add-song", requirePermission("player.queue"), async (req, res) => {
|
// Play a song "now" without clearing the queue: insert after current, then
|
||||||
|
// promote to current and start it. Non-destructive (unlike /play-song which
|
||||||
|
// clears the whole queue) — this is the guest-safe "play now".
|
||||||
|
router.post("/:botId/play-now-song", authorize({ capability: "player.control", guestFlag: "playNow" }), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const bot = (req as any).bot;
|
||||||
|
const { song } = req.body;
|
||||||
|
if (!song || !song.id || !song.platform) {
|
||||||
|
res.status(400).json({ error: "song object with id and platform is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queue = bot.getQueueManager();
|
||||||
|
const insertedAt =
|
||||||
|
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
|
||||||
|
queue.addNext(song);
|
||||||
|
queue.playAt(insertedAt);
|
||||||
|
bot.getPlayer().resetFailures();
|
||||||
|
const ok = await bot.resolveAndPlay(queue.current()!);
|
||||||
|
if (!ok) {
|
||||||
|
res.json({ ok: false, message: `无法播放「${song.name || song.id}」(区域/版权限制)` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ok: true, message: `正在播放:${song.name || "Unknown"} - ${song.artist || "Unknown"}` });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:botId/add-song", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { song } = req.body;
|
const { song } = req.body;
|
||||||
@@ -510,7 +539,7 @@ export function createPlayerRouter(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a song to queue by ID — metadata only
|
// Add a song to queue by ID — metadata only
|
||||||
router.post("/:botId/add-by-id", requirePermission("player.queue"), async (req, res) => {
|
router.post("/:botId/add-by-id", authorize({ capability: "player.queue", guestFlag: "addToQueue" }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const { songId, platform } = req.body;
|
const { songId, platform } = req.body;
|
||||||
@@ -548,7 +577,7 @@ export function createPlayerRouter(
|
|||||||
res.json(bot.getProfileManager().getConfig());
|
res.json(bot.getProfileManager().getConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:botId/profile", requirePermission("bot.manage"), (req, res) => {
|
router.put("/:botId/profile", authorize({ capability: "bot.manage" }), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const bot = (req as any).bot;
|
const bot = (req as any).bot;
|
||||||
const pm = bot.getProfileManager();
|
const pm = bot.getProfileManager();
|
||||||
|
|||||||
Reference in new issue
Block a user