Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ DISCORD_TOKEN=
DISCORD_CLIENT_ID=
SRCDS_LOG_ADDRESS=

# Optional, but required for Owner-only commands
DISCORD_OWNER_GUILD_ID=

# Required for interacting with Oracle Cloud
OCI_CONFIG_FILE=

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/PlayerConnectionHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export type PlayerConnectionHistory = {
steamId3: string;
ipAddress: string;
nickname: string;
timestamp?: Date;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { PlayerConnectionHistory } from "../domain/PlayerConnectionHistory";

export interface PlayerConnectionHistoryRepository {
save(params: { connectionHistory: Omit<PlayerConnectionHistory, "id"> }): Promise<PlayerConnectionHistory>;
findBySteamId3(params: { steamId3: string }): Promise<PlayerConnectionHistory[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SlashCommandBuilder } from "discord.js";

export const getPlayerConnectionHistoryDefinition = new SlashCommandBuilder()
.setName('get-player-connection-history')
.setDescription('Gets the connection history for a specific player (Owner only)')
.setDefaultMemberPermissions(0) // Hides from everyone by default
.addStringOption(option =>
option.setName('player_steam_id3')
.setDescription('Player Steam ID3 (e.g., U:1:12345678)')
.setRequired(true)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { mock } from "vitest-mock-extended";
import { when } from "vitest-when";
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { PlayerConnectionHistoryRepository } from "@tf2qs/core";
import { getPlayerConnectionHistoryHandlerFactory } from "./handler";

describe("getPlayerConnectionHistoryHandler", () => {
function makeSut() {
const playerConnectionHistoryRepositoryMock = mock<PlayerConnectionHistoryRepository>();
const interactionMock = mock<ChatInputCommandInteraction>();
interactionMock.options = mock();

const sut = getPlayerConnectionHistoryHandlerFactory({
playerConnectionHistoryRepository: playerConnectionHistoryRepositoryMock,
});

return {
sut,
playerConnectionHistoryRepositoryMock,
interactionMock,
};
}

it("should return a message when no connection history is found", async () => {
// Given
const { sut, playerConnectionHistoryRepositoryMock, interactionMock } = makeSut();
const steamId3 = "[U:1:12345678]";

when(interactionMock.options.getString)
.calledWith('player_steam_id3', true)
.thenReturn(steamId3);
Comment on lines +28 to +32

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler tests use bracketed SteamID3 ([U:1:...]), but production ingestion stores steamId3 without brackets (e.g., U:1:29162964). Updating the test inputs (or explicitly testing normalization of bracketed inputs) will better reflect real usage.

Copilot uses AI. Check for mistakes.

when(playerConnectionHistoryRepositoryMock.findBySteamId3)
.calledWith({ steamId3 })
.thenResolve([]);

// When
await sut(interactionMock);

// Then
expect(interactionMock.reply).toHaveBeenCalledWith({
content: expect.stringContaining("No connection history found for: `[U:1:12345678]`"),
flags: MessageFlags.Ephemeral,
});
});

it("should display connection history entries", async () => {
// Given
const { sut, playerConnectionHistoryRepositoryMock, interactionMock } = makeSut();
const steamId3 = "[U:1:12345678]";
const mockHistory = [
{
id: 1,
steamId3,
ipAddress: "192.168.1.1",
nickname: "Player1",
timestamp: new Date("2024-01-15T10:00:00Z"),
},
{
id: 2,
steamId3,
ipAddress: "192.168.1.2",
nickname: "Player2",
timestamp: new Date("2024-01-14T09:00:00Z"),
},
];

when(interactionMock.options.getString)
.calledWith('player_steam_id3', true)
.thenReturn(steamId3);

when(playerConnectionHistoryRepositoryMock.findBySteamId3)
.calledWith({ steamId3 })
.thenResolve(mockHistory);

// When
await sut(interactionMock);

// Then
expect(interactionMock.reply).toHaveBeenCalledWith({
content: expect.stringContaining("Found 2 connection(s)"),
flags: MessageFlags.Ephemeral,
});

const firstReplyArg = interactionMock.reply.mock.calls[0][0];
const content = typeof firstReplyArg === "string"
? firstReplyArg
: "content" in firstReplyArg && typeof firstReplyArg.content === "string"
? firstReplyArg.content
: "";

expect(content).toContain("Player1");
expect(content).toContain("192.168.1.1");
expect(content).toContain("Player2");
expect(content).toContain("192.168.1.2");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { PlayerConnectionHistoryRepository } from "@tf2qs/core";

type GetPlayerConnectionHistoryHandlerDependencies = {
playerConnectionHistoryRepository: PlayerConnectionHistoryRepository;
};

export function getPlayerConnectionHistoryHandlerFactory(dependencies: GetPlayerConnectionHistoryHandlerDependencies) {
return async (interaction: ChatInputCommandInteraction) => {
const playerSteamId3 = interaction.options.getString('player_steam_id3', true);

const history = await dependencies.playerConnectionHistoryRepository.findBySteamId3({
steamId3: playerSteamId3,
});
Comment on lines +10 to +14

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

steamId3 values persisted from SRCDS logs appear to be stored without brackets (e.g., U:1:29162964). If users enter [U:1:...] here, findBySteamId3 will not match and will misleadingly return no history. Consider normalizing the input (strip surrounding [] / optional U: prefix variants) before querying, and/or clarify the required format in the command UX.

Copilot uses AI. Check for mistakes.

if (history.length === 0) {
await interaction.reply({
content: `🔍 **Player Connection History**\n\nNo connection history found for: \`${playerSteamId3}\``,
flags: MessageFlags.Ephemeral
});
return;
}

const historyLines = history.map((entry, index) => {
const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : 'Unknown';
return `${index + 1}. **${entry.nickname}** - IP: \`${entry.ipAddress}\` - ${timestamp}`;
});
Comment on lines +24 to +27

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This builds one reply containing all history entries. If a player has many connections, the message can exceed Discord’s 2000-character limit and the reply will fail. Consider capping entries (most recent N) and/or adding a length check/pagination similar to GetMyServers.

Copilot uses AI. Check for mistakes.

const content = [
`🔍 **Player Connection History for ${playerSteamId3}**`,
``,
`Found ${history.length} connection(s):`,
``,
...historyLines,
].join('\n');

await interaction.reply({
content,
flags: MessageFlags.Ephemeral
});
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getPlayerConnectionHistoryDefinition } from "./definition";
export { getPlayerConnectionHistoryHandlerFactory } from "./handler";
29 changes: 21 additions & 8 deletions packages/entrypoints/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, SlashCommandOptionsOnlyBuilder } from "discord.js";
import { UserCreditsRepository } from "@tf2qs/core";
import { UserCreditsRepository, PlayerConnectionHistoryRepository } from "@tf2qs/core";
import { CreateCreditsPurchaseOrder } from "@tf2qs/core";
import { CreateServerForUser } from "@tf2qs/core";
import { GetServerStatus } from "@tf2qs/core";
Expand All @@ -14,6 +14,14 @@ import { ConfigManager } from "@tf2qs/core";
import { setUserDataDefinition, setUserDataHandlerFactory } from "./SetUserData";
import { SetUserData } from "@tf2qs/core";
import { statusCommandDefinition, createStatusCommandHandlerFactory } from "./Status";
import { getPlayerConnectionHistoryDefinition, getPlayerConnectionHistoryHandlerFactory } from "./GetPlayerConnectionHistory";

export type Command = {
name: string;
definition: SlashCommandOptionsOnlyBuilder;
handler: (interaction: ChatInputCommandInteraction) => Promise<void>;
ownerOnly?: boolean;
}

export type CommandDependencies = {
createServerForUser: CreateServerForUser;
Expand All @@ -24,9 +32,10 @@ export type CommandDependencies = {
backgroundTaskQueue: BackgroundTaskQueue;
getServerStatus: GetServerStatus;
getUserServers: GetUserServers;
playerConnectionHistoryRepository: PlayerConnectionHistoryRepository;
}

export function createCommands(dependencies: CommandDependencies) {
export function createCommands(dependencies: CommandDependencies): Record<string, Command> {
return {
createServer: {
name: "create-server",
Expand All @@ -48,7 +57,15 @@ export function createCommands(dependencies: CommandDependencies) {
definition: setUserDataDefinition,
handler: setUserDataHandlerFactory({
setUserData: dependencies.setUserData,
})
}),
},
getPlayerConnectionHistory: {
name: "get-player-connection-history",
definition: getPlayerConnectionHistoryDefinition,
handler: getPlayerConnectionHistoryHandlerFactory({
playerConnectionHistoryRepository: dependencies.playerConnectionHistoryRepository,
}),
ownerOnly: true,
},
status: {
name: "status",
Expand Down Expand Up @@ -82,9 +99,5 @@ export function createCommands(dependencies: CommandDependencies) {
// createCreditsPurchaseOrder: dependencies.createCreditsPurchaseOrder,
// })
// }
} satisfies Record<string, {
name: string;
definition: SlashCommandOptionsOnlyBuilder,
handler: (interaction: ChatInputCommandInteraction) => Promise<void>;
}>
};
}
19 changes: 17 additions & 2 deletions packages/entrypoints/src/discordBot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,35 @@ describe("startDiscordBot", () => {
beforeAll(async () => {
process.env.DISCORD_TOKEN = 'valid_token';
process.env.DISCORD_CLIENT_ID = 'valid_client_id';
process.env.DISCORD_OWNER_GUILD_ID = 'test_guild_123';
await startDiscordBot();
})

it("should initialize KnexConnectionManager", () => {
expect(KnexConnectionManager.initialize).toHaveBeenCalled();
})
it("should register all commands globally with Discord API", async () => {
const expectedCommands = Object.values(discordCommands)
const regularCommands = Object.values(discordCommands)
.filter(cmd => !cmd.ownerOnly)
.map(command => command.definition.toJSON());

expect(rest.put).toHaveBeenCalledWith(
Routes.applicationCommands('valid_client_id'),
expect.objectContaining({
body: expectedCommands
body: regularCommands
})
);
})

it("should register owner-only commands to the specific guild", async () => {
const ownerOnlyCommands = Object.values(discordCommands)
.filter(cmd => cmd.ownerOnly)
.map(command => command.definition.toJSON());

expect(rest.put).toHaveBeenCalledWith(
Routes.applicationGuildCommands('valid_client_id', 'test_guild_123'),
expect.objectContaining({
body: ownerOnlyCommands
})
);
})
Expand Down
41 changes: 37 additions & 4 deletions packages/entrypoints/src/discordBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function startDiscordBot() {
// Define the bot token
const token = process.env.DISCORD_TOKEN!;
const clientId = process.env.DISCORD_CLIENT_ID!;
const ownerGuildId = process.env.DISCORD_OWNER_GUILD_ID;

// Check if token and clientId are available
if (!token) {
Expand Down Expand Up @@ -193,7 +194,8 @@ export async function startDiscordBot() {
}),
userCreditsRepository,
configManager: defaultConfigManager,
backgroundTaskQueue
backgroundTaskQueue,
playerConnectionHistoryRepository
})

// Schedule jobs
Expand Down Expand Up @@ -279,7 +281,16 @@ export async function startDiscordBot() {
})

// Slash commands
const commands = Object.values(discordCommands).map(command => command.definition)
const allCommands = Object.values(discordCommands);

// Separate regular commands from owner-only commands
const regularCommands = allCommands
.filter(cmd => !cmd.ownerOnly)
.map(cmd => cmd.definition);

Comment on lines +286 to +290

Copilot AI Feb 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ownerOnly split only affects registration, not execution-time authorization. Any member in the owner guild who’s granted permission to use the command could still run it and access sensitive results (e.g., IPs). Consider adding a runtime guard (e.g., in the interactionCreate handler) that denies command.ownerOnly unless the caller is an allowed owner (guild owner / allowlist env var), replying ephemerally on denial.

Copilot uses AI. Check for mistakes.
const ownerOnlyCommands = allCommands
.filter(cmd => cmd.ownerOnly)
.map(cmd => cmd.definition);

// Register commands with Discord API
const rest = new REST({ version: '10' }).setToken(token);
Expand All @@ -290,11 +301,33 @@ export async function startDiscordBot() {
body: 'Started refreshing application (/) commands.'
});

// Register commands globally (this applies to all guilds)
// Register regular commands globally (this applies to all guilds)
await rest.put(Routes.applicationCommands(clientId), {
body: commands.map(command => command.toJSON()), // Convert CommandBuilder to JSON
body: regularCommands.map(command => command.toJSON()), // Convert CommandBuilder to JSON
});

logger.emit({
severityText: 'INFO',
body: `Successfully registered ${regularCommands.length} global command(s).`
});

// Register owner-only commands to the specific guild if configured
if (ownerGuildId && ownerOnlyCommands.length > 0) {
await rest.put(Routes.applicationGuildCommands(clientId, ownerGuildId), {
body: ownerOnlyCommands.map(command => command.toJSON()),
});

logger.emit({
severityText: 'INFO',
body: `Successfully registered ${ownerOnlyCommands.length} owner-only command(s) to guild ${ownerGuildId}.`
});
} else if (ownerOnlyCommands.length > 0) {
logger.emit({
severityText: 'WARN',
body: 'DISCORD_OWNER_GUILD_ID not set. Owner-only commands will not be registered.'
});
}

logger.emit({
severityText: 'INFO',
body: 'Successfully reloaded application (/) commands.'
Expand Down
Loading
Loading