Skip to content

Commit d7afcfa

Browse files
authored
feat: improve integration with TF2Center platform
* chore: allow status command from extra allowed IPs via environment variable * feat(tf2center): increase emptyMinutesTerminate for TF2Center integration from 60 to 120 * feat: add custom creation messages for server variants * fix(tf2center): correct rcon_address placeholder in custom creation message * fix(ociServerManager): makes OCI deletion idempotent
1 parent 95e43da commit d7afcfa

10 files changed

Lines changed: 136 additions & 32 deletions

File tree

config/default.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@
167167
"hostname": "br.tf2pickup.org | {region} @ TF2-QuickServer",
168168
"image": "sonikro/fat-tf2-pickup:latest",
169169
"emptyMinutesTerminate": 60,
170-
"managedExternally": true,
170+
"customCreationMessage": "🎉 **Server Created Successfully!** 🎉\n\n🤖 This server is managed by the TF2 Pickup system.\n📋 It will automatically register to the platform and be available for matches.\n✅ No further action is needed - the server is ready to go!",
171171
"admins": [
172172
"STEAM_0:0:14581482",
173173
"STEAM_0:0:26146577",
@@ -187,14 +187,15 @@
187187
"displayName": "TF2Center",
188188
"hostname": "TF2Center | {region} @ TF2-QuickServer",
189189
"image": "sonikro/fat-tf2center:latest",
190-
"emptyMinutesTerminate": 60
190+
"emptyMinutesTerminate": 120,
191+
"customCreationMessage": "🎉 **Server Created Successfully!** 🎉\n\n📍 **Direct Connect Information:**\n`{rconAddress}:27015`\n`rcon_password: {rconPassword}`\n\nUse this information to create a lobby in the TF2Center platform."
191192
},
192193
"hlbr-tf2pickup": {
193194
"displayName": "hl.br.tf2pickup.org",
194195
"hostname": "hl.br.tf2pickup.org | {region} @ TF2-QuickServer",
195196
"image": "sonikro/fat-tf2-pickup:latest",
196197
"emptyMinutesTerminate": 60,
197-
"managedExternally": true,
198+
"customCreationMessage": "🎉 **Server Created and Registered!** 🎉\n\n💻 This server is managed by an external system.\n🔒 Therefore, the connect information will not be shared with you.\n✅ The server is now **available to be used**!",
198199
"admins": [
199200
"STEAM_0:1:433143455",
200201
"STEAM_0:1:79008369",

packages/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ export * from './src/usecase/TerminateServersWithoutCredit';
5151

5252
// Utils
5353
export * from './src/utils/ConfigManager';
54+
export * from './src/utils/interpolateString';
5455

packages/core/src/domain/Variant.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@ export type VariantConfig = {
3838
guildId?: string;
3939

4040
/**
41-
* Whether the server is managed by an external system.
42-
* If true, users will not receive connection details as the server is managed externally.
43-
* @default false
41+
* Custom message shown to the user when the server is created successfully.
42+
* Supports variable interpolation using double braces, e.g. {hostIp}, {hostPort}, {serverId}
43+
* Available variables come from the Server object properties.
44+
* If not set, the default server connection message will be shown.
4445
*/
45-
managedExternally?: boolean;
46+
customCreationMessage?: string;
4647
}
4748

4849
export function getVariantConfig(variant: Variant) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { interpolateString } from './interpolateString';
3+
4+
describe('interpolateString', () => {
5+
it('should replace single variable with provided value', () => {
6+
const result = interpolateString('Hello {name}', { name: 'World' });
7+
expect(result).toBe('Hello World');
8+
});
9+
10+
it('should replace multiple variables with provided values', () => {
11+
const result = interpolateString(
12+
'Server {serverId} at {rconAddress}:{hostPort}',
13+
{ serverId: 'srv-123', rconAddress: '192.168.1.1', hostPort: 27015 }
14+
);
15+
expect(result).toBe('Server srv-123 at 192.168.1.1:27015');
16+
});
17+
18+
it('should handle repeated variables', () => {
19+
const result = interpolateString(
20+
'{value} and {value}',
21+
{ value: 'test' }
22+
);
23+
expect(result).toBe('test and test');
24+
});
25+
26+
it('should leave unmapped variables unchanged', () => {
27+
const result = interpolateString(
28+
'Value: {exists}, Missing: {notExists}',
29+
{ exists: 'found' }
30+
);
31+
expect(result).toBe('Value: found, Missing: {notExists}');
32+
});
33+
34+
it('should handle empty template', () => {
35+
const result = interpolateString('', { key: 'value' });
36+
expect(result).toBe('');
37+
});
38+
39+
it('should handle template with no variables', () => {
40+
const result = interpolateString('No variables here', { key: 'value' });
41+
expect(result).toBe('No variables here');
42+
});
43+
44+
it('should handle empty data object', () => {
45+
const result = interpolateString('Hello {name}', {});
46+
expect(result).toBe('Hello {name}');
47+
});
48+
49+
it('should convert non-string values to strings', () => {
50+
const result = interpolateString(
51+
'Player count: {count}, Status: {ready}',
52+
{ count: 24, ready: true }
53+
);
54+
expect(result).toBe('Player count: 24, Status: true');
55+
});
56+
57+
it('should handle multiline templates with variables', () => {
58+
const result = interpolateString(
59+
'Server: {serverId}\nAddress: {rconAddress}:{hostPort}\nPassword: {rconPassword}',
60+
{
61+
serverId: 'srv-001',
62+
rconAddress: '10.0.0.1',
63+
hostPort: 27015,
64+
rconPassword: 'secret123'
65+
}
66+
);
67+
expect(result).toBe(
68+
'Server: srv-001\nAddress: 10.0.0.1:27015\nPassword: secret123'
69+
);
70+
});
71+
72+
it('should handle nested braces correctly', () => {
73+
const result = interpolateString(
74+
'Data: {key}',
75+
{ key: '{nested}' }
76+
);
77+
expect(result).toBe('Data: {nested}');
78+
});
79+
80+
it('should handle variables with underscores and numbers', () => {
81+
const result = interpolateString(
82+
'Values: {var_1}, {var2_name}',
83+
{ var_1: 'first', var2_name: 'second' }
84+
);
85+
expect(result).toBe('Values: first, second');
86+
});
87+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function interpolateString(template: string, data: Record<string, unknown> | Record<string, any>): string {
2+
return template.replace(/\{([^}]+)\}/g, (match, key) => {
3+
const value = data[key];
4+
return value !== undefined ? String(value) : match;
5+
});
6+
}

packages/entrypoints/src/commands/CreateServer/handler.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe("createServerCommandHandler", () => {
127127
});
128128
});
129129

130-
it("should reply with custom message for variants that are managed externally", async () => {
130+
it("should reply with custom message for variants that have customCreationMessage", async () => {
131131
const { handler, interaction, createServerForUser, message, collector } = createHandler();
132132
const region = getTestRegion();
133133
const variantName = "tf2pickup";
@@ -175,7 +175,7 @@ describe("createServerCommandHandler", () => {
175175
content: expect.stringContaining(`Creating server in region`),
176176
});
177177
expect(buttonInteraction.followUp).toHaveBeenCalledWith({
178-
content: expect.stringContaining("managed by an external system"),
178+
content: expect.stringContaining("TF2 Pickup system"),
179179
flags: MessageFlags.Ephemeral,
180180
});
181181
});

packages/entrypoints/src/commands/CreateServer/handler.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
Collection,
1111
PermissionFlagsBits
1212
} from "discord.js";
13-
import { getRegionDisplayName, getVariantConfigs, getVariantConfig, Region } from "@tf2qs/core";
13+
import { getRegionDisplayName, getVariantConfigs, getVariantConfig, Region, interpolateString } from "@tf2qs/core";
1414
import { CreateServerForUser } from "@tf2qs/core";
1515
import { createInteractionStatusUpdater } from "@tf2qs/providers";
1616
import { commandErrorHandler } from "../commandErrorHandler";
@@ -101,16 +101,16 @@ export function createServerCommandHandlerFactory(dependencies: {
101101
statusUpdater: createInteractionStatusUpdater(buttonInteraction)
102102
});
103103
const variantConfig = getVariantConfig(variantName);
104-
if (variantConfig.managedExternally) {
104+
105+
if (variantConfig.customCreationMessage) {
106+
const interpolatedMessage = interpolateString(variantConfig.customCreationMessage, deployedServer);
105107
await buttonInteraction.followUp({
106-
content: `🎉 **Server Created and Registered!** 🎉\n\n` +
107-
`💻 This server is managed by an external system.\n` +
108-
`🔒 Therefore, the connect information will not be shared with you.\n` +
109-
`✅ The server is now **available to be used**!`,
108+
content: interpolatedMessage,
110109
flags: MessageFlags.Ephemeral
111110
});
112111
return;
113112
}
113+
114114
await buttonInteraction.followUp({
115115
content: `🎉 **Server Created Successfully!** 🎉\n\n${formatServerMessage(deployedServer)}`,
116116
flags: MessageFlags.Ephemeral

packages/entrypoints/src/udp/srcdsCommands/Rcon.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,15 @@ describe("rcon command parser", () => {
105105
expect(services.serverCommander.query).not.toHaveBeenCalled();
106106
expect(vi.mocked(logger.emit)).not.toHaveBeenCalledWith(expect.objectContaining({ severityText: 'WARN' }), expect.anything());
107107
});
108+
109+
it("should allow status command from extra allowed IPs", async () => {
110+
process.env.STATUS_EXTRA_ALLOWED_IPS = "1.2.3.4,10.20.30.40";
111+
const allowedIpsCommand = 'rcon from "10.20.30.40:51736": command "status"';
112+
const command = rcon(allowedIpsCommand);
113+
if (!command || !command.handler) throw new Error("No handler");
114+
await command.handler({ args: command.args, password: "123", services });
115+
expect(services.serverCommander.query).not.toHaveBeenCalled();
116+
expect(vi.mocked(logger.emit)).not.toHaveBeenCalledWith(expect.objectContaining({ severityText: 'WARN' }), expect.anything());
117+
})
108118
});
109119
});

packages/entrypoints/src/udp/srcdsCommands/Rcon.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export const rcon: SRCDSCommandParser<{ sourceIp: string, command: string }> = (
2222
if (args.command === "status") {
2323
const myIP = await publicIpv4();
2424
const sourceCommandIP = sourceIp.split(":")[0];
25-
const allowedIPs = [myIP, "127.0.0.1"];
25+
const extraAllowedIps = process.env.STATUS_EXTRA_ALLOWED_IPS ? process.env.STATUS_EXTRA_ALLOWED_IPS.split(",") : [];
26+
const allowedIPs = [myIP, "127.0.0.1", ...extraAllowedIps];
2627
if (!allowedIPs.includes(sourceCommandIP)) {
2728
await services.serverCommander.query({
2829
host: server.rconAddress,

packages/providers/src/cloud-providers/oracle/OCIServerManager.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -373,30 +373,25 @@ export class OCIServerManager implements ServerManager {
373373
displayName: serverId,
374374
});
375375

376-
if (!containerInstances.containerInstanceCollection.items || containerInstances.containerInstanceCollection.items.length === 0) {
377-
throw new Error(`No container instance found for serverId: ${serverId}`);
376+
const containerInstanceId = containerInstances.containerInstanceCollection.items?.[0]?.id;
377+
378+
if (containerInstanceId) {
379+
logger.emit({ severityText: 'INFO', body: `Deleting container instance for server ID: ${serverId}`, attributes: { serverId } });
380+
await containerClient.deleteContainerInstance({
381+
containerInstanceId,
382+
});
383+
} else {
384+
logger.emit({ severityText: 'INFO', body: `Container instance not found for server ID: ${serverId}, skipping deletion`, attributes: { serverId } });
378385
}
379386

380-
const containerInstanceId = containerInstances.containerInstanceCollection.items[0].id;
381-
// Get NSG ID by name (serverId)
382-
// NOTE: vcn_id must be present in your OracleRegionSettings config
383-
const vcnId = oracleRegionConfig.vnc_id
387+
const vcnId = oracleRegionConfig.vnc_id;
384388
const nsgs = await vncClient.listNetworkSecurityGroups({
385389
compartmentId: oracleRegionConfig.compartment_id,
386390
displayName: serverId,
387391
vcnId: vcnId,
388392
});
389-
let nsgId: string | undefined = undefined;
390-
if (nsgs.items && nsgs.items.length > 0) {
391-
nsgId = nsgs.items[0].id;
392-
}
393-
394-
logger.emit({ severityText: 'INFO', body: `Deleting container instance for server ID: ${serverId}`, attributes: { serverId } });
395-
await containerClient.deleteContainerInstance({
396-
containerInstanceId,
397-
});
393+
const nsgId = nsgs.items?.[0]?.id;
398394

399-
// Wait for the NSG to have no VNICs associated before deleting it
400395
if (nsgId) {
401396
logger.emit({ severityText: 'INFO', body: `Deleting network security group for server ID: ${serverId}`, attributes: { serverId } });
402397
await waitUntil(async () => {
@@ -407,6 +402,8 @@ export class OCIServerManager implements ServerManager {
407402
throw new Error("NSG still has associated VNICs");
408403
}, { interval: 5000, timeout: 300000 });
409404
await this.deleteNetworkSecurityGroup({ nsgId, vncClient });
405+
} else {
406+
logger.emit({ severityText: 'INFO', body: `Network security group not found for server ID: ${serverId}, skipping deletion`, attributes: { serverId } });
410407
}
411408

412409
logger.emit({ severityText: 'INFO', body: `Server deletion completed successfully for server ID: ${serverId}`, attributes: { serverId } });

0 commit comments

Comments
 (0)