@serverkgg/bridge 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/bot/botCauses.test.ts +137 -0
  3. package/src/bot/botCauses.ts +13 -4
  4. package/src/bot/botEnvironment.test.ts +218 -1
  5. package/src/bot/botEnvironment.ts +85 -3
  6. package/src/bot/botVariables.test.ts +32 -0
  7. package/src/bot/botVariables.ts +9 -0
  8. package/src/bot/detect/botInspectWarning.ts +1 -0
  9. package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +3 -2
  10. package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +3 -1
  11. package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +3 -1
  12. package/src/bot/detect/corpus/serverk-discord-node.json +3 -2
  13. package/src/bot/detect/corpus/serverk-discord-python.json +3 -2
  14. package/src/bot/detect/corpus/serverk-telegram-node.json +3 -2
  15. package/src/bot/detect/corpus/serverk-telegram-python.json +3 -2
  16. package/src/bot/detect/detect.test.ts +307 -0
  17. package/src/bot/detect/detect.ts +39 -14
  18. package/src/bot/detect/detectTable.ts +4 -0
  19. package/src/bot/detect/detectedVersion.test.ts +160 -0
  20. package/src/bot/detect/detectedVersion.ts +55 -0
  21. package/src/bot/detect/envExample.test.ts +4 -0
  22. package/src/bot/detect/envExample.ts +1 -0
  23. package/src/bot/detect/envScan.test.ts +110 -0
  24. package/src/bot/detect/envScan.ts +7 -3
  25. package/src/bot/detect/keys.test.ts +50 -0
  26. package/src/bot/detect/keys.ts +45 -1
  27. package/src/cli/validate.test.ts +33 -3
  28. package/src/cli/validate.ts +13 -5
  29. package/src/manifest/manifest.test.ts +229 -0
  30. package/src/manifest/manifest.ts +109 -1
  31. package/src/protocol/bridgeCrash.ts +10 -1
  32. package/src/protocol/bridgeEvents.ts +2 -0
  33. package/src/runtime/bridgeDriver.test.ts +24 -0
  34. package/src/runtime/bridgeDriver.ts +6 -2
  35. package/src/runtime/bridgeEvents.test.ts +65 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -25,6 +25,26 @@ const CASES: readonly {
25
25
  line: "GrammyError: Call to 'getMe' failed! (401: Unauthorized)",
26
26
  cause: BridgeCrashCause.TokenInvalid,
27
27
  },
28
+ {
29
+ line: "TelegramError: 401: Unauthorized",
30
+ cause: BridgeCrashCause.TokenInvalid,
31
+ },
32
+ {
33
+ line: 'error: [polling_error] {"code":"ETELEGRAM","message":"ETELEGRAM: 401 Unauthorized"}',
34
+ cause: BridgeCrashCause.TokenInvalid,
35
+ },
36
+ {
37
+ line: "TelegramApiError: 401: Unauthorized",
38
+ cause: BridgeCrashCause.TokenInvalid,
39
+ },
40
+ {
41
+ line: "DiscordAPIError[0]: 401: Unauthorized",
42
+ cause: BridgeCrashCause.TokenInvalid,
43
+ },
44
+ {
45
+ line: "httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.telegram.org/bot***/getMe'",
46
+ cause: BridgeCrashCause.TokenInvalid,
47
+ },
28
48
  {
29
49
  line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
30
50
  cause: BridgeCrashCause.TokenInvalid,
@@ -101,6 +121,18 @@ const CASES: readonly {
101
121
  line: "discord.errors.PrivilegedIntentsRequired: Shard ID None is requesting privileged intents that have not been explicitly enabled in the developer portal.",
102
122
  cause: BridgeCrashCause.IntentsDisallowed,
103
123
  },
124
+ {
125
+ line: "Error: Disallowed intents specified",
126
+ cause: BridgeCrashCause.IntentsDisallowed,
127
+ },
128
+ {
129
+ line: "GatewayError: Disallowed intents specified. Make sure any privileged intents you're trying to access have been enabled in the developer portal.",
130
+ cause: BridgeCrashCause.IntentsDisallowed,
131
+ },
132
+ {
133
+ line: "hikari.errors.GatewayServerClosedConnectionError: Server closed connection with code 4014 (Disallowed intent(s))",
134
+ cause: BridgeCrashCause.IntentsDisallowed,
135
+ },
104
136
  {
105
137
  line: "telegram.error.Conflict: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
106
138
  cause: BridgeCrashCause.TokenInUse,
@@ -109,6 +141,62 @@ const CASES: readonly {
109
141
  line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
110
142
  cause: BridgeCrashCause.TokenInUse,
111
143
  },
144
+ {
145
+ line: "TelegramError: 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
146
+ cause: BridgeCrashCause.TokenInUse,
147
+ },
148
+ {
149
+ line: 'error: [polling_error] {"code":"ETELEGRAM","message":"ETELEGRAM: 409 Conflict: terminated by other getUpdates request; make sure that only one bot instance is running"}',
150
+ cause: BridgeCrashCause.TokenInUse,
151
+ },
152
+ {
153
+ line: "TelegramApiError: 409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
154
+ cause: BridgeCrashCause.TokenInUse,
155
+ },
156
+ {
157
+ line: "TelegramError: 409: Conflict: terminated by setWebhook request",
158
+ cause: BridgeCrashCause.TokenInUse,
159
+ },
160
+ {
161
+ line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first)",
162
+ cause: BridgeCrashCause.WebhookActive,
163
+ },
164
+ {
165
+ line: "TelegramError: 409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
166
+ cause: BridgeCrashCause.WebhookActive,
167
+ },
168
+ {
169
+ line: "telegram.error.Conflict: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
170
+ cause: BridgeCrashCause.WebhookActive,
171
+ },
172
+ {
173
+ line: "aiogram.exceptions.TelegramConflictError: Telegram server says - Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first",
174
+ cause: BridgeCrashCause.WebhookActive,
175
+ },
176
+ {
177
+ line: "Error: listen EADDRINUSE: address already in use :::8080",
178
+ cause: BridgeCrashCause.PortBusy,
179
+ },
180
+ {
181
+ line: "Error: listen EADDRINUSE: address already in use 0.0.0.0:8080",
182
+ cause: BridgeCrashCause.PortBusy,
183
+ },
184
+ {
185
+ line: "OSError: [Errno 98] Address already in use",
186
+ cause: BridgeCrashCause.PortBusy,
187
+ },
188
+ {
189
+ line: "OSError: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use",
190
+ cause: BridgeCrashCause.PortBusy,
191
+ },
192
+ {
193
+ line: "ERROR: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8080): address already in use",
194
+ cause: BridgeCrashCause.PortBusy,
195
+ },
196
+ {
197
+ line: "OSError: [Errno 48] Address already in use",
198
+ cause: BridgeCrashCause.PortBusy,
199
+ },
112
200
  {
113
201
  line: "Error: Cannot find module 'discord.js'",
114
202
  cause: BridgeCrashCause.MissingDependency,
@@ -185,6 +273,14 @@ const CASES: readonly {
185
273
  line: "error code: 1015",
186
274
  cause: BridgeCrashCause.RateLimited,
187
275
  },
276
+ {
277
+ line: "Error 1015: You are being rate limited",
278
+ cause: BridgeCrashCause.RateLimited,
279
+ },
280
+ {
281
+ line: '"message": "You are being rate limited.",',
282
+ cause: BridgeCrashCause.RateLimited,
283
+ },
188
284
  {
189
285
  line: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
190
286
  cause: BridgeCrashCause.OutOfMemory,
@@ -285,6 +381,26 @@ describe("BOT_CRASH_EVIDENCE", () => {
285
381
  });
286
382
  }
287
383
 
384
+ it("names an active webhook before the token in use its 409 line also matches", () => {
385
+ const { ring, print } = botRing();
386
+
387
+ print(
388
+ "GrammyError: Call to 'getUpdates' failed! (409: Conflict: can't use getUpdates method while webhook is active; use deleteWebhook to delete the webhook first)",
389
+ );
390
+
391
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.WebhookActive);
392
+ });
393
+
394
+ it("still names a second poller on the token as the token in use", () => {
395
+ const { ring, print } = botRing();
396
+
397
+ print(
398
+ "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
399
+ );
400
+
401
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.TokenInUse);
402
+ });
403
+
288
404
  it("names the removed audioop module before any other missing module", () => {
289
405
  const { ring, print } = botRing();
290
406
 
@@ -345,6 +461,27 @@ describe("BOT_CRASH_EVIDENCE", () => {
345
461
  expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
346
462
  });
347
463
 
464
+ for (const line of [
465
+ "AxiosError: Request failed with status code 401",
466
+ "AxiosError: Request failed with status code 409",
467
+ "requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://api.spotify.com/v1/me",
468
+ "requests.exceptions.HTTPError: 409 Client Error: Conflict for url: https://api.github.com/repos/owner/repo/merges",
469
+ "aiohttp.client_exceptions.ClientResponseError: 401, message='Unauthorized', url='https://api.spotify.com/v1/me'",
470
+ "httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.spotify.com/v1/me'",
471
+ "httpx.HTTPStatusError: Client error '409 Conflict' for url 'https://api.github.com/repos/owner/repo/merges'",
472
+ "urllib.error.HTTPError: HTTP Error 401: Unauthorized",
473
+ "urllib.error.HTTPError: HTTP Error 409: Conflict",
474
+ "openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-proj-****. You can find your API key at https://platform.openai.com/account/api-keys.', 'type': 'invalid_request_error', 'param': None, 'code': 'invalid_api_key'}}",
475
+ ]) {
476
+ it(`leaves a refusal from an API other than the bot's platform unexplained from "${line}"`, () => {
477
+ const { ring, print } = botRing();
478
+
479
+ print(line);
480
+
481
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
482
+ });
483
+ }
484
+
348
485
  it("calls exit 137 out of memory only when the record carries the flag", () => {
349
486
  const unflagged = botRing();
350
487
 
@@ -4,7 +4,7 @@ import { BridgeEventName } from "../protocol";
4
4
  export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
5
5
  {
6
6
  match:
7
- /\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|\b401:? Unauthorized\b|\bInvalidToken\b|TelegramUnauthorizedError|\bACCESS_TOKEN_(?:INVALID|EXPIRED)\b/,
7
+ /\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|(?:Telegram|ETELEGRAM|Call to '|\/bot[^/\s]*\/|DiscordAPIError\[).*\b401:? Unauthorized\b|\b401:? Unauthorized\b.*(?:Telegram|ETELEGRAM|\/bot[^/\s]*\/)|\bInvalidToken\b|TelegramUnauthorizedError|\bACCESS_TOKEN_(?:INVALID|EXPIRED)\b/,
8
8
  emit: BridgeEventName.TokenInvalid,
9
9
  },
10
10
  {
@@ -19,13 +19,22 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
19
19
  },
20
20
  {
21
21
  match:
22
- /Used disallowed intents|\[DisallowedIntents\]|Privileged intent provided is not enabled or whitelisted|PrivilegedIntentsRequired|close code:? 4014\b/,
22
+ /Used disallowed intents|\[DisallowedIntents\]|Privileged intent provided is not enabled or whitelisted|PrivilegedIntentsRequired|Disallowed intents specified|Server closed connection with code 4014\b|close code:? 4014\b/,
23
23
  emit: BridgeEventName.IntentsDisallowed,
24
24
  },
25
25
  {
26
- match: /terminated by other getUpdates request|\b409:? Conflict\b/,
26
+ match: /can't use getUpdates method while webhook is active/,
27
+ emit: BridgeEventName.WebhookActive,
28
+ },
29
+ {
30
+ match:
31
+ /terminated by other getUpdates request|(?:Telegram|ETELEGRAM|Call to '|\/bot[^/\s]*\/).*\b409:? Conflict\b|\b409:? Conflict\b.*(?:Telegram|ETELEGRAM|\/bot[^/\s]*\/)/,
27
32
  emit: BridgeEventName.TokenInUse,
28
33
  },
34
+ {
35
+ match: /EADDRINUSE|\[Errno 98\][^\n]*[Aa]ddress already in use|OSError: [^\n]*Address already in use/,
36
+ emit: BridgeEventName.PortBindFailed,
37
+ },
29
38
  {
30
39
  match:
31
40
  /Cannot find module|ERR_MODULE_NOT_FOUND|ModuleNotFoundError: No module named|(?:davey|PyNaCl) library needed in order to use voice/,
@@ -63,6 +72,6 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
63
72
  {
64
73
  match:
65
74
  /FATAL ERROR: (?:Reached heap limit|Ineffective mark-compacts near heap limit) Allocation failed - JavaScript heap out of memory|^MemoryError\b/,
66
- emit: BridgeEventName.ServerCrashed,
75
+ emit: BridgeEventName.OutOfMemory,
67
76
  },
68
77
  ];
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ BOT_NO_EXPOSURE,
4
+ type BotExposure,
5
+ botExposureOf,
3
6
  botProcessEnvironment,
4
7
  customerEnvironment,
5
8
  LAUNCHER_DEPLOYMENT_ID,
@@ -17,7 +20,17 @@ import {
17
20
  platformEnvironment,
18
21
  } from "./botEnvironment";
19
22
  import { type BotConfig, DEFAULT_REPO_BRANCH } from "./botVariables";
20
- import { BotPlatform } from "./detect";
23
+ import {
24
+ BOT_PORT_1_KEY,
25
+ BOT_PORT_2_KEY,
26
+ BOT_WEB_PORT_KEY,
27
+ BotPlatform,
28
+ PORT,
29
+ SERVERK_PORT_1,
30
+ SERVERK_PORT_2,
31
+ SERVERK_WEB_HOST,
32
+ SERVERK_WEB_URL,
33
+ } from "./detect";
21
34
 
22
35
  const SHA = "0123456789abcdef0123456789abcdef01234567";
23
36
 
@@ -32,9 +45,33 @@ const config = (overrides: Partial<BotConfig> = {}): BotConfig => ({
32
45
  deployment: null,
33
46
  snapshot: null,
34
47
  gitToken: null,
48
+ webUrl: null,
49
+ ...overrides,
50
+ });
51
+
52
+ const WEB_URL = "https://my-bot.srvk.gg";
53
+
54
+ const exposureOf = (overrides: Partial<BotExposure> = {}): BotExposure => ({
55
+ ...BOT_NO_EXPOSURE,
35
56
  ...overrides,
36
57
  });
37
58
 
59
+ const contextWith = (ports: Record<string, number>, variables: Record<string, string> = {}) => {
60
+ return {
61
+ port: (key: string) => {
62
+ const value = ports[key];
63
+
64
+ if (value === undefined) {
65
+ throw new Error(`this game declares no port named "${key}"`);
66
+ }
67
+
68
+ return value;
69
+ },
70
+ variable: (key: string) => variables[key] ?? null,
71
+ secret: () => null,
72
+ };
73
+ };
74
+
38
75
  describe("customerEnvironment", () => {
39
76
  it("hands the process every vault key and nothing the platform or the runtime owns", () => {
40
77
  expect(
@@ -152,6 +189,153 @@ describe("platformEnvironment", () => {
152
189
  SERVERK_SERVER_CODE: "abc",
153
190
  });
154
191
  });
192
+
193
+ it("hands the bot PORT, the web address and its host while the web port is active", () => {
194
+ expect(
195
+ platformEnvironment(
196
+ "abc",
197
+ null,
198
+ exposureOf({
199
+ web: 8080,
200
+ webUrl: WEB_URL,
201
+ }),
202
+ ),
203
+ ).toEqual({
204
+ SERVERK_BOT: "1",
205
+ SERVERK_SERVER_CODE: "abc",
206
+ [PORT]: "8080",
207
+ [SERVERK_WEB_URL]: WEB_URL,
208
+ [SERVERK_WEB_HOST]: "my-bot.srvk.gg",
209
+ });
210
+ });
211
+
212
+ it("hands the bot no PORT and no address while the web port is off, even with an address delivered", () => {
213
+ expect(
214
+ platformEnvironment(
215
+ "abc",
216
+ null,
217
+ exposureOf({
218
+ webUrl: WEB_URL,
219
+ }),
220
+ ),
221
+ ).toEqual({
222
+ SERVERK_BOT: "1",
223
+ SERVERK_SERVER_CODE: "abc",
224
+ });
225
+ });
226
+
227
+ it("hands the bot PORT alone while the web port is active before its address is known", () => {
228
+ expect(
229
+ platformEnvironment(
230
+ "abc",
231
+ null,
232
+ exposureOf({
233
+ web: 8080,
234
+ }),
235
+ ),
236
+ ).toEqual({
237
+ SERVERK_BOT: "1",
238
+ SERVERK_SERVER_CODE: "abc",
239
+ [PORT]: "8080",
240
+ });
241
+ });
242
+
243
+ it("drops an address that is not a bare https host", () => {
244
+ for (const webUrl of [
245
+ "http://my-bot.srvk.gg",
246
+ "https://my-bot.srvk.gg/",
247
+ "https://my-bot.srvk.gg:8443",
248
+ "https://My-Bot.srvk.gg",
249
+ ]) {
250
+ expect(
251
+ platformEnvironment(
252
+ "abc",
253
+ null,
254
+ exposureOf({
255
+ web: 8080,
256
+ webUrl,
257
+ }),
258
+ ),
259
+ webUrl,
260
+ ).toEqual({
261
+ SERVERK_BOT: "1",
262
+ SERVERK_SERVER_CODE: "abc",
263
+ [PORT]: "8080",
264
+ });
265
+ }
266
+ });
267
+
268
+ it("hands the bot each raw port only while it is active", () => {
269
+ expect(
270
+ platformEnvironment(
271
+ "abc",
272
+ null,
273
+ exposureOf({
274
+ port1: 29_004,
275
+ port2: 30_017,
276
+ }),
277
+ ),
278
+ ).toEqual({
279
+ SERVERK_BOT: "1",
280
+ SERVERK_SERVER_CODE: "abc",
281
+ [SERVERK_PORT_1]: "29004",
282
+ [SERVERK_PORT_2]: "30017",
283
+ });
284
+ expect(
285
+ platformEnvironment(
286
+ "abc",
287
+ null,
288
+ exposureOf({
289
+ port2: 30_017,
290
+ }),
291
+ ),
292
+ ).toEqual({
293
+ SERVERK_BOT: "1",
294
+ SERVERK_SERVER_CODE: "abc",
295
+ [SERVERK_PORT_2]: "30017",
296
+ });
297
+ });
298
+ });
299
+
300
+ describe("botExposureOf", () => {
301
+ it("reads the active ports and the validated address from the bridge context", () => {
302
+ expect(
303
+ botExposureOf(
304
+ contextWith(
305
+ {
306
+ [BOT_WEB_PORT_KEY]: 8080,
307
+ [BOT_PORT_2_KEY]: 30_017,
308
+ },
309
+ {
310
+ [SERVERK_WEB_URL]: WEB_URL,
311
+ },
312
+ ),
313
+ ),
314
+ ).toEqual({
315
+ web: 8080,
316
+ webUrl: WEB_URL,
317
+ port1: null,
318
+ port2: 30_017,
319
+ });
320
+ });
321
+
322
+ it("answers no exposure for a bundle that declares no port", () => {
323
+ expect(botExposureOf(contextWith({}))).toEqual(BOT_NO_EXPOSURE);
324
+ });
325
+
326
+ it("reads a raw port without the web port", () => {
327
+ expect(
328
+ botExposureOf(
329
+ contextWith({
330
+ [BOT_PORT_1_KEY]: 29_004,
331
+ }),
332
+ ),
333
+ ).toEqual(
334
+ exposureOf({
335
+ port1: 29_004,
336
+ }),
337
+ );
338
+ });
155
339
  });
156
340
 
157
341
  describe("botProcessEnvironment", () => {
@@ -186,4 +370,37 @@ describe("botProcessEnvironment", () => {
186
370
  BOT_PLATFORM: BotPlatform.Telegram,
187
371
  });
188
372
  });
373
+
374
+ it("lets the platform's PORT win over the customer's while the web port is active", () => {
375
+ const customer = customerEnvironment(
376
+ {
377
+ [PORT]: "3000",
378
+ },
379
+ {},
380
+ );
381
+
382
+ expect(
383
+ botProcessEnvironment({
384
+ tool: {},
385
+ customer,
386
+ launcher: {},
387
+ platform: platformEnvironment(
388
+ "abc",
389
+ null,
390
+ exposureOf({
391
+ web: 8080,
392
+ webUrl: WEB_URL,
393
+ }),
394
+ ),
395
+ })[PORT],
396
+ ).toBe("8080");
397
+ expect(
398
+ botProcessEnvironment({
399
+ tool: {},
400
+ customer,
401
+ launcher: {},
402
+ platform: platformEnvironment("abc", null),
403
+ })[PORT],
404
+ ).toBe("3000");
405
+ });
189
406
  });
@@ -1,6 +1,20 @@
1
- import type { BotConfig } from "./botVariables";
1
+ import type { BridgeContext } from "../context";
2
+ import { type BotConfig, readBotConfig, WEB_URL_PATTERN } from "./botVariables";
2
3
  import type { BotPlatform } from "./detect";
3
- import { BOT_PLATFORM, isReservedBotKey, SERVERK_BOT, SERVERK_SERVER_CODE } from "./detect";
4
+ import {
5
+ BOT_PLATFORM,
6
+ BOT_PORT_1_KEY,
7
+ BOT_PORT_2_KEY,
8
+ BOT_WEB_PORT_KEY,
9
+ isReservedBotKey,
10
+ PORT,
11
+ SERVERK_BOT,
12
+ SERVERK_PORT_1,
13
+ SERVERK_PORT_2,
14
+ SERVERK_SERVER_CODE,
15
+ SERVERK_WEB_HOST,
16
+ SERVERK_WEB_URL,
17
+ } from "./detect";
4
18
 
5
19
  export const LAUNCHER_REPO_URL = "SERVERK_REPO_URL";
6
20
 
@@ -112,7 +126,73 @@ export const launcherEnvironment = (config: BotConfig, stateDir: string): Record
112
126
  return environment;
113
127
  };
114
128
 
115
- export const platformEnvironment = (code: string, platform: BotPlatform | null): Record<string, string> => {
129
+ export interface BotExposure {
130
+ web: number | null;
131
+ webUrl: string | null;
132
+ port1: number | null;
133
+ port2: number | null;
134
+ }
135
+
136
+ export const BOT_NO_EXPOSURE: BotExposure = {
137
+ web: null,
138
+ webUrl: null,
139
+ port1: null,
140
+ port2: null,
141
+ };
142
+
143
+ const activePortOf = (context: Pick<BridgeContext, "port">, key: string) => {
144
+ try {
145
+ return context.port(key);
146
+ } catch {
147
+ return null;
148
+ }
149
+ };
150
+
151
+ export const botExposureOf = (context: Pick<BridgeContext, "port" | "variable" | "secret">): BotExposure => {
152
+ return {
153
+ web: activePortOf(context, BOT_WEB_PORT_KEY),
154
+ webUrl: readBotConfig(context).webUrl,
155
+ port1: activePortOf(context, BOT_PORT_1_KEY),
156
+ port2: activePortOf(context, BOT_PORT_2_KEY),
157
+ };
158
+ };
159
+
160
+ const webEnvironment = (exposure: BotExposure): Record<string, string> => {
161
+ if (exposure.web === null) {
162
+ return {};
163
+ }
164
+
165
+ const environment: Record<string, string> = {
166
+ [PORT]: String(exposure.web),
167
+ };
168
+
169
+ if (exposure.webUrl !== null && WEB_URL_PATTERN.test(exposure.webUrl)) {
170
+ environment[SERVERK_WEB_URL] = exposure.webUrl;
171
+ environment[SERVERK_WEB_HOST] = new URL(exposure.webUrl).hostname;
172
+ }
173
+
174
+ return environment;
175
+ };
176
+
177
+ const rawPortEnvironment = (exposure: BotExposure): Record<string, string> => {
178
+ const environment: Record<string, string> = {};
179
+
180
+ if (exposure.port1 !== null) {
181
+ environment[SERVERK_PORT_1] = String(exposure.port1);
182
+ }
183
+
184
+ if (exposure.port2 !== null) {
185
+ environment[SERVERK_PORT_2] = String(exposure.port2);
186
+ }
187
+
188
+ return environment;
189
+ };
190
+
191
+ export const platformEnvironment = (
192
+ code: string,
193
+ platform: BotPlatform | null,
194
+ exposure: BotExposure = BOT_NO_EXPOSURE,
195
+ ): Record<string, string> => {
116
196
  return {
117
197
  [SERVERK_BOT]: "1",
118
198
  [SERVERK_SERVER_CODE]: code,
@@ -121,6 +201,8 @@ export const platformEnvironment = (code: string, platform: BotPlatform | null):
121
201
  [BOT_PLATFORM]: platform,
122
202
  }
123
203
  : {}),
204
+ ...webEnvironment(exposure),
205
+ ...rawPortEnvironment(exposure),
124
206
  };
125
207
  };
126
208
 
@@ -9,6 +9,7 @@ import {
9
9
  ROOT_DIRECTORY_LENGTH_LIMIT,
10
10
  readBotConfig,
11
11
  SNAPSHOT_ID_PATTERN,
12
+ WEB_URL_PATTERN,
12
13
  } from "./botVariables";
13
14
  import {
14
15
  BOT_PLATFORM,
@@ -20,6 +21,7 @@ import {
20
21
  REPO_REF,
21
22
  REPO_URL,
22
23
  ROOT_DIRECTORY,
24
+ SERVERK_WEB_URL,
23
25
  SNAPSHOT_ID,
24
26
  START_COMMAND,
25
27
  } from "./detect";
@@ -49,6 +51,7 @@ describe("readBotConfig", () => {
49
51
  [DEPLOYMENT_ID]: "dep_a1b2c3d4",
50
52
  [SNAPSHOT_ID]: "snap_1",
51
53
  [GIT_TOKEN]: "ignored-in-variables",
54
+ [SERVERK_WEB_URL]: " https://my-bot.srvk.gg ",
52
55
  },
53
56
  {
54
57
  [GIT_TOKEN]: "ghs_installation",
@@ -66,6 +69,7 @@ describe("readBotConfig", () => {
66
69
  deployment: "dep_a1b2c3d4",
67
70
  snapshot: "snap_1",
68
71
  gitToken: "ghs_installation",
72
+ webUrl: "https://my-bot.srvk.gg",
69
73
  });
70
74
  });
71
75
 
@@ -88,8 +92,29 @@ describe("readBotConfig", () => {
88
92
  deployment: null,
89
93
  snapshot: null,
90
94
  gitToken: null,
95
+ webUrl: null,
91
96
  });
92
97
  });
98
+
99
+ it("drops a web address that is not a bare https host", () => {
100
+ for (const value of [
101
+ "http://my-bot.srvk.gg",
102
+ "https://my-bot.srvk.gg/webhook",
103
+ "https://my-bot.srvk.gg:443",
104
+ "https://user@my-bot.srvk.gg",
105
+ "https://My-Bot.srvk.gg",
106
+ "https://",
107
+ ]) {
108
+ expect(
109
+ readBotConfig(
110
+ contextWith({
111
+ [SERVERK_WEB_URL]: value,
112
+ }),
113
+ ).webUrl,
114
+ value,
115
+ ).toBeNull();
116
+ }
117
+ });
93
118
  });
94
119
 
95
120
  describe("the patterns", () => {
@@ -145,6 +170,13 @@ describe("the patterns", () => {
145
170
  }
146
171
  });
147
172
 
173
+ it("take a web address only as https and a lowercase host", () => {
174
+ expect(WEB_URL_PATTERN.test("https://my-bot.srvk.gg")).toBe(true);
175
+ expect(WEB_URL_PATTERN.test("https://abc123.srvk.localhost")).toBe(true);
176
+ expect(WEB_URL_PATTERN.test("https://my-bot.srvk.gg/")).toBe(false);
177
+ expect(WEB_URL_PATTERN.test("http://my-bot.srvk.gg")).toBe(false);
178
+ });
179
+
148
180
  it("accept https repositories and git branch names", () => {
149
181
  expect(REPO_URL_PATTERN.test("https://github.com/you/bot.git")).toBe(true);
150
182
  expect(REPO_URL_PATTERN.test("https://user:pass@github.com/you/bot")).toBe(false);
@@ -10,6 +10,7 @@ import {
10
10
  REPO_REF,
11
11
  REPO_URL,
12
12
  ROOT_DIRECTORY,
13
+ SERVERK_WEB_URL,
13
14
  SNAPSHOT_ID,
14
15
  START_COMMAND,
15
16
  } from "./detect";
@@ -30,6 +31,8 @@ export const ROOT_DIRECTORY_PATTERN =
30
31
 
31
32
  export const ROOT_DIRECTORY_LENGTH_LIMIT = 255;
32
33
 
34
+ export const WEB_URL_PATTERN = /^https:\/\/[a-z0-9.-]+$/;
35
+
33
36
  export const DEFAULT_REPO_BRANCH = "main";
34
37
 
35
38
  export const isRootDirectory = (value: string) => {
@@ -47,6 +50,7 @@ export interface BotConfig {
47
50
  deployment: string | null;
48
51
  snapshot: string | null;
49
52
  gitToken: string | null;
53
+ webUrl: string | null;
50
54
  }
51
55
 
52
56
  const present = (value: string | null) => {
@@ -55,6 +59,10 @@ const present = (value: string | null) => {
55
59
  return trimmed.length === 0 ? null : trimmed;
56
60
  };
57
61
 
62
+ const webUrlOf = (value: string | null) => {
63
+ return value !== null && WEB_URL_PATTERN.test(value) ? value : null;
64
+ };
65
+
58
66
  export const readBotConfig = (context: Pick<BridgeContext, "variable" | "secret">): BotConfig => {
59
67
  const platform = present(context.variable(BOT_PLATFORM));
60
68
 
@@ -69,5 +77,6 @@ export const readBotConfig = (context: Pick<BridgeContext, "variable" | "secret"
69
77
  deployment: present(context.variable(DEPLOYMENT_ID)),
70
78
  snapshot: present(context.variable(SNAPSHOT_ID)),
71
79
  gitToken: present(context.secret(GIT_TOKEN)),
80
+ webUrl: webUrlOf(present(context.variable(SERVERK_WEB_URL))),
72
81
  };
73
82
  };