Hide Playerbot accounts from search, character pages and guild rosters
Some checks failed
Build / build (push) Waiting to run
Lint / eslint (push) Waiting to run
Build / build (pull_request) Has been cancelled
Lint / eslint (pull_request) Has been cancelled

Playerbots are ordinary accounts, not game masters, so hideGameMasters does
not touch them. On a realm running mod-playerbots that means the armory is
almost entirely bots: 1,377 characters listed, of which about 1,350 are bots.

Adds two options, hideBotAccounts (default true) and botAccountPattern
(default "RNDBOT%"), applied the same way the game master filter already
works: left join the accounts we do not want, then require the join to have
missed. A realm without bots matches nothing, so the default is harmless.

Applied in the search listing, guild rosters, and the character lookup, so a
bot's page 404s rather than rendering.

The pattern is spliced into join clauses the query builder emits as raw SQL,
so it cannot be a bound parameter. It comes from the operator's own config
rather than from a request, but Utils.quoteSqlString quotes it so a stray
apostrophe cannot produce a broken query.

Verified against the live realm:

  listed before  1377
  listed after     27
  Spinnaker, Kdog  shown
  Rarzosh (bot)    hidden

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NW2ooBP2KPqQVzdZZDMvZd
This commit is contained in:
Claude 2026-09-02 20:08:24 +00:00
parent ffdeacc0c4
commit cc7ca06182
No known key found for this signature in database
7 changed files with 61 additions and 0 deletions

View file

@ -9,6 +9,8 @@
}, },
"loadDbcs": true, "loadDbcs": true,
"hideGameMasters": true, "hideGameMasters": true,
"hideBotAccounts": true,
"botAccountPattern": "RNDBOT%",
"transmogModule": false, "transmogModule": false,
"useZamCdn": false, "useZamCdn": false,
"realms": [ "realms": [

View file

@ -41,6 +41,12 @@ services:
# --- behaviour ----------------------------------------------------- # --- behaviour -----------------------------------------------------
ACORE_ARMORY_LOAD_DBCS: ${ARMORY_LOAD_DBCS:-1} ACORE_ARMORY_LOAD_DBCS: ${ARMORY_LOAD_DBCS:-1}
ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-1} ACORE_ARMORY_HIDE_GAME_MASTERS: ${ARMORY_HIDE_GMS:-1}
# Playerbots are ordinary accounts, not game masters, so the GM
# filter does not touch them. Without this the armory lists ~1350
# bots against ~29 real characters.
ACORE_ARMORY_HIDE_BOT_ACCOUNTS: ${ARMORY_HIDE_BOTS:-1}
ACORE_ARMORY_BOT_ACCOUNT_PATTERN: ${ARMORY_BOT_PATTERN:-RNDBOT%}
ACORE_ARMORY_TRANSMOG_MODULE: ${ARMORY_TRANSMOG:-0} ACORE_ARMORY_TRANSMOG_MODULE: ${ARMORY_TRANSMOG:-0}
# 0 = serve 3D model assets from the local data directory. # 0 = serve 3D model assets from the local data directory.

View file

@ -31,6 +31,8 @@ export class Config {
public iframeMode: IIframeModeConfig; public iframeMode: IIframeModeConfig;
public loadDbcs: boolean; public loadDbcs: boolean;
public hideGameMasters: boolean; public hideGameMasters: boolean;
public hideBotAccounts: boolean;
public botAccountPattern: string;
public transmogModule: boolean; public transmogModule: boolean;
public useZamCdn: boolean; public useZamCdn: boolean;
public realms: IRealmConfig[]; public realms: IRealmConfig[];

View file

@ -60,4 +60,17 @@ export class Utils {
background: obj.background.toString().padStart(padLength, "0"), background: obj.background.toString().padStart(padLength, "0"),
}; };
} }
/**
* Quote a configuration-supplied string for inline use in SQL.
*
* The bot-account filter has to be spliced into join clauses that the query
* builder emits as raw SQL, so it cannot be passed as a bound parameter the
* way a normal value would be. The value comes from the operator's own
* config rather than from a request, but quoting it keeps a stray apostrophe
* from producing a broken or surprising query.
*/
public static quoteSqlString(value: string): string {
return `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
}
} }

View file

@ -342,6 +342,16 @@ export class CharacterController {
private async getCharacterData(realm: IRealmConfig, character: string | number): Promise<ICharacterData> { private async getCharacterData(realm: IRealmConfig, character: string | number): Promise<ICharacterData> {
const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?"; const where = typeof character === "string" ? "LOWER(`characters`.`name`) = LOWER(?)" : "`characters`.`guid` = ?";
// Bot accounts are hidden the same way game masters are: left join the
// rows we do not want, then require the join to have missed. Keeping the
// pattern inline rather than bound avoids disturbing the positional
// values below.
const botJoin = this.armory.config.hideBotAccounts
? `LEFT JOIN \`${realm.authDatabase}\`.\`account\` AS \`bot_account\` ON \`bot_account\`.\`id\` = \`characters\`.\`account\` AND \`bot_account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`
: "";
const botWhere = this.armory.config.hideBotAccounts ? "AND `bot_account`.`id` IS NULL" : "";
const [rows] = await this.armory.getCharactersDb(realm.name).query({ const [rows] = await this.armory.getCharactersDb(realm.name).query({
sql: ` sql: `
SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\` SELECT \`characters\`.\`guid\`, \`characters\`.\`name\`, \`race\`, \`class\`, \`gender\`, \`level\`, \`skin\`, \`face\`, \`hairStyle\`, \`hairColor\`, \`facialStyle\`, \`playerFlags\`, \`online\`, \`guild\`.\`name\` AS \`guild\`
@ -349,9 +359,11 @@ export class CharacterController {
LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\` LEFT JOIN \`guild_member\` ON \`guild_member\`.\`guid\` = \`characters\`.\`guid\`
LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\` LEFT JOIN \`guild\` ON \`guild\`.\`guildid\` = \`guild_member\`.\`guildid\`
LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0 LEFT JOIN \`${realm.authDatabase}\`.\`account_access\` ON \`account_access\`.\`id\` = \`characters\`.\`account\` AND \`account_access\`.\`RealmID\` IN (-1, ${realm.realmId}) AND \`account_access\`.\`gmlevel\` > 0
${botJoin}
WHERE WHERE
${where} ${where}
AND (\`account_access\`.\`id\` IS NULL OR ? = 0) AND (\`account_access\`.\`id\` IS NULL OR ? = 0)
${botWhere}
`, `,
values: [character, this.armory.config.hideGameMasters ? 1 : 0], values: [character, this.armory.config.hideGameMasters ? 1 : 0],
timeout: this.armory.config.dbQueryTimeout, timeout: this.armory.config.dbQueryTimeout,

View file

@ -93,6 +93,19 @@ export class GuildController {
ssp = ssp.where("`account_access`.`id` IS NULL"); ssp = ssp.where("`account_access`.`id` IS NULL");
} }
if (this.armory.config.hideBotAccounts) {
ssp.joins.push({
table1: "characters",
column1: "account",
table2: "account",
column2: "id",
database2: realm.authDatabase,
kind: "LEFT",
where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`,
});
ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`);
}
const result = await ssp.where("`guildid` = ?", guildId).where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); const result = await ssp.where("`guildid` = ?", guildId).where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout);
const ranks = await this.getGuildRanks(realm, guildId); const ranks = await this.getGuildRanks(realm, guildId);

View file

@ -55,6 +55,19 @@ export class IndexController {
ssp = ssp.where("`account_access`.`id` IS NULL"); ssp = ssp.where("`account_access`.`id` IS NULL");
} }
if (this.armory.config.hideBotAccounts) {
ssp.joins.push({
table1: "characters",
column1: "account",
table2: "account",
column2: "id",
database2: realm.authDatabase,
kind: "LEFT",
where: `AND \`${realm.authDatabase}\`.\`account\`.\`username\` LIKE ${Utils.quoteSqlString(this.armory.config.botAccountPattern)}`,
});
ssp = ssp.where(`\`${realm.authDatabase}\`.\`account\`.\`id\` IS NULL`);
}
const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout); const result = await ssp.where("`deleteInfos_Account` IS NULL").run(this.armory.config.dbQueryTimeout);
res.json({ res.json({