Skip to content

Commit 7cfc87a

Browse files
CoderCococlaude
andauthored
feat(discord): per-game connect_message in server-running responses (#44)
Adds an optional `connect_message` field to the `game_servers` Terraform variable. Operators set a template string (e.g. `connect at {host}:{port}`) that is rendered in Discord when a server reaches RUNNING — both on the initial `/server-start` reply and in the update-dns PATCH when the public IP/hostname resolves. Supports `{host}`, `{ip}`, `{port}`, and `{game}` placeholders. When absent the existing inline `` — `{host}` `` suffix is preserved as the fallback, so no operator action is required for existing deployments. ## Changes - `terraform/variables.tf` — add `connect_message = optional(string)` to `game_servers` - `terraform/followup.tf` + `route53.tf` — wire `CONNECT_MESSAGES` and `GAME_PORTS` env vars - `app/packages/shared/src/formatStatus.ts` — extend `formatGameStatus()` to accept optional connect message and port; interpolate placeholders; render on a second line - `app/packages/lambda/followup/src/handler.ts` — pass connect message through `handleList`, `handleStatus`, and `handleStart` (start reply gets a hostname preview) - `app/packages/lambda/update-dns/src/handler.ts` — pass connect message to `notifyDiscordIfPending` so the RUNNING-state PATCH includes the hint - Tests, `terraform.tfvars.example`, and docs updated per CLAUDE.md checklist ## Test plan - [ ] All 238 existing tests pass (`cd app && npm test`) - [ ] 10 new `formatGameStatus` test cases cover placeholder substitution, multi-line messages, missing placeholders, and fallback behaviour - [ ] Deploy with a game that has `connect_message = "connect in game at {host}:{port}"` and verify the hint appears in the Discord start reply and in the RUNNING update Closes #40 https://claude.ai/code/session_015YprouEhsqpJLPDC5ZrrSE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e499a1 commit 7cfc87a

10 files changed

Lines changed: 155 additions & 11 deletions

File tree

app/packages/lambda/followup/src/handler.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ function gameListFromEnv(): string[] {
6969
return (process.env['GAME_NAMES'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
7070
}
7171

72+
/** Per-game connect message templates from Terraform, keyed by game name. */
73+
const CONNECT_MESSAGES: Record<string, string> = JSON.parse(process.env['CONNECT_MESSAGES'] ?? '{}');
74+
75+
/** First container port per game, used to resolve the `{port}` placeholder. */
76+
const GAME_PORTS: Record<string, number> = JSON.parse(process.env['GAME_PORTS'] ?? '{}');
77+
7278
function extractEniId(task: Task): string | null {
7379
for (const att of task.attachments ?? []) {
7480
if (att.type !== 'ElasticNetworkInterface') continue;
@@ -199,13 +205,13 @@ async function handleList(event: FollowupEvent, cfg: DiscordConfig): Promise<str
199205
);
200206
if (!visible.length) return "You don't have permission to view any server statuses.";
201207
const statuses = await Promise.all(visible.map((g) => getStatus(g)));
202-
return statuses.map((s) => formatGameStatus(s)).join('\n');
208+
return statuses.map((s) => formatGameStatus(s, CONNECT_MESSAGES[s.game], GAME_PORTS[s.game])).join('\n');
203209
}
204210

205211
async function handleStatus(event: FollowupEvent): Promise<string> {
206212
if (!event.game) return 'Game is required.';
207213
const status = await getStatus(event.game);
208-
return formatGameStatus(status);
214+
return formatGameStatus(status, CONNECT_MESSAGES[event.game], GAME_PORTS[event.game]);
209215
}
210216

211217
async function handleStart(event: FollowupEvent): Promise<string> {
@@ -222,6 +228,18 @@ async function handleStart(event: FollowupEvent): Promise<string> {
222228
game: event.game,
223229
action: 'start',
224230
});
231+
const connectMsg = CONNECT_MESSAGES[event.game];
232+
if (connectMsg) {
233+
const port = GAME_PORTS[event.game];
234+
const domain = process.env['DOMAIN_NAME'] ?? '';
235+
const host = domain ? `${event.game}.${domain}` : '';
236+
const rendered = connectMsg
237+
.replace(/\{host\}/g, host)
238+
.replace(/\{ip\}/g, '')
239+
.replace(/\{port\}/g, port !== undefined ? String(port) : '')
240+
.replace(/\{game\}/g, event.game);
241+
return `${message}\n${rendered}`;
242+
}
225243
}
226244
return message;
227245
}

app/packages/lambda/update-dns/src/handler.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const ALB_TARGET_GROUPS: Record<string, string> = JSON.parse(
5050
);
5151
const TABLE_NAME = process.env['TABLE_NAME'] ?? '';
5252

53+
/** Per-game connect message templates from Terraform, keyed by game name. */
54+
const CONNECT_MESSAGES: Record<string, string> = JSON.parse(process.env['CONNECT_MESSAGES'] ?? '{}');
55+
56+
/** First container port per game, used to resolve the `{port}` placeholder. */
57+
const GAME_PORTS: Record<string, number> = JSON.parse(process.env['GAME_PORTS'] ?? '{}');
58+
5359
const FAMILY_TO_GAME = new Map<string, string>(GAME_NAMES.map((g) => [`${g}-server`, g]));
5460

5561
function requireEnv(name: string): string {
@@ -234,18 +240,25 @@ async function patchOriginal(
234240
/**
235241
* If a Discord interaction is pending for this task, PATCH it with the
236242
* resolved hostname/IP and delete the pending row.
243+
*
244+
* `publicIp` is omitted for HTTPS games because only the private IP is known
245+
* at this point — the public entry point is always the ALB hostname.
237246
*/
238247
async function notifyDiscordIfPending(
239248
taskArn: string,
240249
game: string,
241-
publicIp: string,
250+
publicIp?: string,
242251
): Promise<void> {
243252
if (!TABLE_NAME) return;
244253
try {
245254
const pending = await getPending(TABLE_NAME, taskArn);
246255
if (!pending) return;
247256
const hostname = `${game}.${DOMAIN_NAME}`;
248-
const message = formatGameStatus({ game, state: 'running', publicIp, hostname, taskArn });
257+
const message = formatGameStatus(
258+
{ game, state: 'running', publicIp, hostname, taskArn },
259+
CONNECT_MESSAGES[game],
260+
GAME_PORTS[game],
261+
);
249262
await patchOriginal(pending.applicationId, pending.interactionToken, message);
250263
await deletePending(TABLE_NAME, taskArn);
251264
} catch (err) {
@@ -309,7 +322,7 @@ async function handleHttps(
309322
const ip = await resolveIp(taskArn, clusterArn, 'private');
310323
if (!ip) return { status: 'error', reason: 'no_ip' };
311324
await registerAlb(tgArn, ip);
312-
await notifyDiscordIfPending(taskArn, game, ip);
325+
await notifyDiscordIfPending(taskArn, game); // private IP intentionally omitted — use hostname only
313326
return { status: 'registered', game, ip };
314327
}
315328
if (lastStatus === 'STOPPED') {

app/packages/shared/src/formatStatus.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,90 @@ describe('formatGameStatus', () => {
4242
expect(line).toContain('palworld.example.com');
4343
expect(line).not.toContain('1.2.3.4');
4444
});
45+
46+
it('should render a connect message on a second line when provided', () => {
47+
const line = formatGameStatus(
48+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
49+
'connect in game at palworld.example.com:8211',
50+
);
51+
expect(line).toBe('🟢 **palworld**: running\nconnect in game at palworld.example.com:8211');
52+
});
53+
54+
it('should substitute {host} placeholder with the resolved hostname', () => {
55+
const line = formatGameStatus(
56+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
57+
'connect at {host}:8211',
58+
);
59+
expect(line).toBe('🟢 **palworld**: running\nconnect at palworld.example.com:8211');
60+
});
61+
62+
it('should substitute {ip} placeholder with the public IP', () => {
63+
const line = formatGameStatus(
64+
{ game: 'palworld', state: 'running', publicIp: '1.2.3.4' },
65+
'direct IP: {ip}',
66+
);
67+
expect(line).toBe('🟢 **palworld**: running\ndirect IP: 1.2.3.4');
68+
});
69+
70+
it('should substitute {port} placeholder when a port is supplied', () => {
71+
const line = formatGameStatus(
72+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
73+
'connect at {host}:{port}',
74+
8211,
75+
);
76+
expect(line).toBe('🟢 **palworld**: running\nconnect at palworld.example.com:8211');
77+
});
78+
79+
it('should substitute {game} placeholder with the game name', () => {
80+
const line = formatGameStatus(
81+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
82+
'{game} server at {host}',
83+
);
84+
expect(line).toBe('🟢 **palworld**: running\npalworld server at palworld.example.com');
85+
});
86+
87+
it('should leave {port} empty when no port is supplied', () => {
88+
const line = formatGameStatus(
89+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
90+
'connect at {host}:{port}',
91+
);
92+
expect(line).toBe('🟢 **palworld**: running\nconnect at palworld.example.com:');
93+
});
94+
95+
it('should allow multi-line connect messages', () => {
96+
const line = formatGameStatus(
97+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com' },
98+
'host: {host}\nport: 8211',
99+
);
100+
expect(line).toBe('🟢 **palworld**: running\nhost: palworld.example.com\nport: 8211');
101+
});
102+
103+
it('should fall back to the inline address format when connect message is absent', () => {
104+
const line = formatGameStatus({ game: 'palworld', state: 'running', hostname: 'palworld.example.com' });
105+
expect(line).toBe('🟢 **palworld**: running — `palworld.example.com`');
106+
});
107+
108+
it('should prefer hostname over publicIp when substituting {host}', () => {
109+
const line = formatGameStatus(
110+
{ game: 'palworld', state: 'running', hostname: 'palworld.example.com', publicIp: '1.2.3.4' },
111+
'connect at {host}',
112+
);
113+
expect(line).toBe('🟢 **palworld**: running\nconnect at palworld.example.com');
114+
});
115+
116+
it('should treat an empty connect message the same as absent and use the fallback format', () => {
117+
const line = formatGameStatus({ game: 'palworld', state: 'running', hostname: 'palworld.example.com' }, '');
118+
expect(line).toBe('🟢 **palworld**: running — `palworld.example.com`');
119+
});
120+
121+
it('should ignore the connect message when state is not running', () => {
122+
const stopped = formatGameStatus({ game: 'palworld', state: 'stopped' }, 'connect at {host}:8211');
123+
expect(stopped).toBe('⚫ **palworld**: stopped');
124+
125+
const starting = formatGameStatus({ game: 'palworld', state: 'starting' }, 'connect at {host}:8211');
126+
expect(starting).toBe('🟡 **palworld**: starting');
127+
128+
const error = formatGameStatus({ game: 'palworld', state: 'error' }, 'connect at {host}:8211');
129+
expect(error).toBe('⚠️ **palworld**: error');
130+
});
45131
});
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import type { GameStatus } from './types.js';
22

33
/**
4-
* Render a game's status as a single Discord-ready line:
5-
* emoji + bold name + state + optional hostname/IP.
4+
* Render a game's status as a Discord-ready message.
5+
*
6+
* When `connectMessage` is provided and state is `running`, it is rendered on
7+
* a second line with host, ip, port, and game placeholders substituted.
8+
* When absent, falls back to the original single-line address suffix.
9+
*
10+
* @param port - First exposed port for the port placeholder (optional).
611
*/
7-
export function formatGameStatus(status: GameStatus): string {
12+
export function formatGameStatus(status: GameStatus, connectMessage?: string, port?: number): string {
813
const emoji =
914
status.state === 'running' ? '🟢'
1015
: status.state === 'starting' ? '🟡'
1116
: status.state === 'stopped' ? '⚫'
1217
: '⚠️';
1318
const host = status.hostname ?? status.publicIp;
19+
const statusLine = `${emoji} **${status.game}**: ${status.state}`;
20+
21+
if (connectMessage && status.state === 'running') {
22+
const rendered = connectMessage
23+
.replace(/\{host\}/g, host ?? '')
24+
.replace(/\{ip\}/g, status.publicIp ?? '')
25+
.replace(/\{port\}/g, port !== undefined ? String(port) : '')
26+
.replace(/\{game\}/g, status.game);
27+
return `${statusLine}\n${rendered}`;
28+
}
29+
1430
const addr = host ? ` — \`${host}\`` : '';
15-
return `${emoji} **${status.game}**: ${status.state}${addr}`;
31+
return `${statusLine}${addr}`;
1632
}

docs/docs/components/terraform.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ step 3 of the [setup guide](/setup) for details.
3232
| `aws_region` | `string` | `us-east-1` | AWS region for all resources. |
3333
| `project_name` | `string` | `game-servers` | Prefix for named resources and the Secrets Manager paths. |
3434
| `vpc_cidr` | `string` | `10.0.0.0/16` | Parent CIDR; subnets are /24s within it. |
35-
| `game_servers` | `map(object)` || The single source of truth. Per-game: `image`, `cpu`, `memory`, `ports[]`, `environment[]`, `volumes[]` (`name` + `container_path`), `https`, `file_seeds[]` (optional). Each `volumes` entry creates its own EFS access point rooted at `/${game}/${name}`. See `game_servers[].file_seeds` below. |
35+
| `game_servers` | `map(object)` || The single source of truth. Per-game: `image`, `cpu`, `memory`, `ports[]`, `environment[]`, `volumes[]` (`name` + `container_path`), `https`, `connect_message` (optional), `file_seeds[]` (optional). Each `volumes` entry creates its own EFS access point rooted at `/${game}/${name}`. `connect_message` controls the Discord connection hint shown when a server reaches RUNNING; supports `{host}`, `{ip}`, `{port}`, and `{game}` placeholders. See `game_servers[].file_seeds` below. |
3636
| `hosted_zone_name` | `string` | _(required)_ | Existing Route 53 zone looked up as a data source (e.g. `example.com`). |
3737
| `acm_certificate_domain` | `string` | `null``*.{hosted_zone_name}` | Wildcard ACM cert for the ALB listener. |
3838
| `dns_ttl` | `number` | `30` | TTL on Route 53 A records the update-dns Lambda writes. Keep low for fast task churn. |

docs/docs/setup.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ game_servers = {
201201
{ name = "saves", container_path = "/palworld" },
202202
]
203203
https = false
204+
# Optional: Discord message shown when the server reaches RUNNING.
205+
# Supports {host}, {ip}, {port} (first port), and {game} placeholders.
206+
# connect_message = "connect in game at {host}:{port}"
204207
}
205208
}
206209
```

terraform/followup.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ resource "aws_lambda_function" "followup" {
8888
SECURITY_GROUP_ID = aws_security_group.game_servers.id
8989
DOMAIN_NAME = var.hosted_zone_name
9090
GAME_NAMES = join(",", keys(var.game_servers))
91+
CONNECT_MESSAGES = jsonencode({ for g, cfg in var.game_servers : g => cfg.connect_message if cfg.connect_message != null })
92+
GAME_PORTS = jsonencode({ for g, cfg in var.game_servers : g => cfg.ports[0].container if length(cfg.ports) > 0 })
9193
}
9294
}
9395

terraform/route53.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ resource "aws_lambda_function" "dns_updater" {
112112
HTTPS_GAMES = join(",", keys(local.https_games))
113113
ALB_TARGET_GROUPS = jsonencode({ for name, _ in local.https_games : name => aws_lb_target_group.game[name].arn })
114114
TABLE_NAME = aws_dynamodb_table.discord.name
115+
CONNECT_MESSAGES = jsonencode({ for g, cfg in var.game_servers : g => cfg.connect_message if cfg.connect_message != null })
116+
GAME_PORTS = jsonencode({ for g, cfg in var.game_servers : g => cfg.ports[0].container if length(cfg.ports) > 0 })
115117
}
116118
}
117119

terraform/terraform.tfvars.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ watchdog_min_packets = 100
7070
# { name = "saves", container_path = "/palworld" },
7171
# ]
7272
# https = false
73+
# # Optional: message shown in Discord when the server is running.
74+
# # Supports {host}, {ip}, {port} (first port), and {game} placeholders.
75+
# # connect_message = "connect in game at {host}:{port}"
7376
#
7477
# # Optional: pre-seed files onto the EFS volume before the server starts.
7578
# # path is the in-container path; the container_path prefix is stripped to

terraform/variables.tf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ variable "game_servers" {
2929
ports = list(object({ container = number, protocol = string }))
3030
environment = optional(list(object({ name = string, value = string })), [])
3131
volumes = list(object({ name = string, container_path = string }))
32-
https = optional(bool, false) # If true, traffic is routed through ALB with TLS termination
32+
https = optional(bool, false) # If true, traffic is routed through ALB with TLS termination
33+
connect_message = optional(string) # Discord connect hint; supports {host}, {ip}, {port}, {game} placeholders
3334
file_seeds = optional(list(object({
3435
path = string # In-container path, e.g. "/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini"
3536
content = optional(string) # UTF-8 text content

0 commit comments

Comments
 (0)