@serverkgg/bridge 0.1.8 → 0.1.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
package/rules/package.md CHANGED
@@ -56,6 +56,7 @@ One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pi
56
56
  ## Guides and image
57
57
 
58
58
  - A guide is `guides/<id>/ar.md` and `en.md`, starting at `##` with no `#` title; its title and summary live in `serverk.yml`. Every package has a `getting-started` guide tagged `start`.
59
+ - A heading may end with `{#<id>}`, lowercase letters, digits and single hyphens, to fix its anchor. A bot package's `errors` guide gives each cause section its cause id, the same in both languages. Why: the panel links every named cause to `errors#<cause id>`.
59
60
  - `image/Dockerfile` keeps the shared contract: `ARG BRIDGE_VERSION` with `LABEL gg.serverk.bridge.version`, a `container` user with uid 1000 and home `/home/container`, `COPY dist/bridge /serverk/bridge`, `ENTRYPOINT ["/usr/bin/tini", "--", "/serverk/bridge"]`, and no `USER` line. Game files download at install, a bot's code arrives with a deploy; neither is baked in.
60
61
  - The outermost start wrapper ends in `exec`, with no `setsid`, `nohup` or double fork. Why: the bridge checks the uid of the pid it spawned, and stop escalation signals the process group, so an orphan outside it keeps the port.
61
62
 
@@ -29,6 +29,10 @@ const CASES: readonly {
29
29
  line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
30
30
  cause: BridgeCrashCause.TokenInvalid,
31
31
  },
32
+ {
33
+ line: "telegram.error.InvalidToken: You must pass the token you received from https://t.me/Botfather!",
34
+ cause: BridgeCrashCause.TokenInvalid,
35
+ },
32
36
  {
33
37
  line: "GrammyError: Call to 'deleteWebhook' failed! (404: Not Found)",
34
38
  cause: BridgeCrashCause.TokenInvalid,
@@ -165,6 +169,10 @@ const CASES: readonly {
165
169
  line: "ModuleNotFoundError: No module named 'audioop'",
166
170
  cause: BridgeCrashCause.AudioopRemoved,
167
171
  },
172
+ {
173
+ line: "KeyError: 'TELEGRAM_TOKEN'",
174
+ cause: BridgeCrashCause.VariableMissing,
175
+ },
168
176
  {
169
177
  line: "discord.errors.HTTPException: 429 Too Many Requests (error code: 0): You are being blocked from accessing our API temporarily due to exceeding our rate limits frequently.",
170
178
  cause: BridgeCrashCause.RateLimited,
@@ -253,6 +261,30 @@ describe("BOT_CRASH_EVIDENCE", () => {
253
261
  });
254
262
  }
255
263
 
264
+ it("names the variable the code read from an empty vault", () => {
265
+ const { ring, print } = botRing();
266
+
267
+ print("KeyError: 'TELEGRAM_TOKEN'");
268
+
269
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.VariableMissing);
270
+ expect(ring.drain(0).events.at(0)?.payload?.variable).toBe("TELEGRAM_TOKEN");
271
+ });
272
+
273
+ for (const line of [
274
+ "KeyError: 0",
275
+ "KeyError: 'user'",
276
+ "KeyError: 'X'",
277
+ ]) {
278
+ it(`leaves a key that is not a variable name unexplained from "${line}"`, () => {
279
+ const { ring, print } = botRing();
280
+
281
+ print(line);
282
+
283
+ expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
284
+ expect(ring.drain(0).events.at(0)?.payload?.variable).toBeUndefined();
285
+ });
286
+ }
287
+
256
288
  it("names the removed audioop module before any other missing module", () => {
257
289
  const { ring, print } = botRing();
258
290
 
@@ -48,6 +48,10 @@ export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
48
48
  match: /ffmpeg was not found|\bENOENT\b.*\bffmpeg\b|\bffmpeg\b.*\bENOENT\b/i,
49
49
  emit: BridgeEventName.FfmpegMissing,
50
50
  },
51
+ {
52
+ match: /^KeyError: '(?<variable>[A-Z][A-Z0-9_]{1,63})'\s*$/,
53
+ emit: BridgeEventName.VariableMissing,
54
+ },
51
55
  {
52
56
  match: /No module named 'audioop'/,
53
57
  emit: BridgeEventName.AudioopRemoved,
@@ -8,8 +8,10 @@ import {
8
8
  DEPLOY_MARKER_DRAFT,
9
9
  DEPLOYMENT_EVENT_PATTERN,
10
10
  DEPLOYMENT_LINE,
11
+ DEPLOYMENT_STAGE_LINE,
11
12
  type DeployMarker,
12
13
  deploymentLine,
14
+ deploymentStageLine,
13
15
  deployPending,
14
16
  pendingDeploymentOf,
15
17
  requestDeploy,
@@ -216,6 +218,36 @@ describe("deploymentLine", () => {
216
218
  });
217
219
  });
218
220
 
221
+ describe("deploymentStageLine", () => {
222
+ it("names the deployment and the stage it entered", () => {
223
+ expect(deploymentStageLine("dep_a1b2c3d4", BotDeploymentStage.Install)).toBe(
224
+ "serverk: deployment dep_a1b2c3d4 stage install",
225
+ );
226
+ });
227
+
228
+ it("round-trips through the stage pattern for every stage", () => {
229
+ for (const stage of Object.values(BotDeploymentStage)) {
230
+ expect(DEPLOYMENT_STAGE_LINE.exec(deploymentStageLine("dep_a1b2c3d4", stage))?.groups).toEqual({
231
+ deployment: "dep_a1b2c3d4",
232
+ stage,
233
+ });
234
+ }
235
+ });
236
+
237
+ it("is never read as a deployment outcome, so it raises no event", () => {
238
+ const line = deploymentStageLine("dep_a1b2c3d4", BotDeploymentStage.Start);
239
+
240
+ expect(DEPLOYMENT_LINE.test(line)).toBe(false);
241
+ expect(DEPLOYMENT_EVENT_PATTERN.match.test(line)).toBe(false);
242
+ expect(DEPLOYMENT_STAGE_LINE.test("serverk: deployment dep_a1b2c3d4 ready")).toBe(false);
243
+ });
244
+
245
+ it("reads only deployment ids of at most 64 characters", () => {
246
+ expect(DEPLOYMENT_STAGE_LINE.test(`serverk: deployment ${"d".repeat(64)} stage sync`)).toBe(true);
247
+ expect(DEPLOYMENT_STAGE_LINE.test(`serverk: deployment ${"d".repeat(65)} stage sync`)).toBe(false);
248
+ });
249
+ });
250
+
219
251
  describe("requestDeploy", () => {
220
252
  it("writes the request as JSON and restarts a running bot", async () => {
221
253
  const { context, stored, events } = deployContext(true);
@@ -21,6 +21,8 @@ export const DEPLOYMENT_EVENT_KIND = "deployment";
21
21
  export const DEPLOYMENT_LINE =
22
22
  /^serverk: deployment (?<deployment>\S{1,64}) (?<outcome>ready|failed)(?: at (?<commit>[0-9a-f]{7,64}))?(?:: (?<stage>[a-z-]+): (?<detail>.{1,200}))?$/u;
23
23
 
24
+ export const DEPLOYMENT_STAGE_LINE = /^serverk: deployment (?<deployment>\S{1,64}) stage (?<stage>[a-z-]+)$/u;
25
+
24
26
  export const DEPLOYMENT_DETAIL_LIMIT = 200;
25
27
 
26
28
  export const DEPLOYMENT_COMMIT_LENGTH = 12;
@@ -64,6 +66,10 @@ export const deploymentLine = (input: DeploymentLineInput) => {
64
66
  return `serverk: deployment ${input.deployment} failed: ${input.stage}: ${detail}`;
65
67
  };
66
68
 
69
+ export const deploymentStageLine = (deployment: string, stage: BotDeploymentStage) => {
70
+ return `serverk: deployment ${deployment} stage ${stage}`;
71
+ };
72
+
67
73
  export const DEPLOYMENT_EVENT_PATTERN: BridgeEventPattern = {
68
74
  match: DEPLOYMENT_LINE,
69
75
  emit: BridgeEventName.ServerUpdated,
@@ -0,0 +1,35 @@
1
+ {
2
+ "source": {
3
+ "url": "https://github.com/mruniquehacker/Knightbot-MD",
4
+ "branch": "main",
5
+ "commit": "871bce832169c3a52a632dc100c1f235cb35977c",
6
+ "stars": 8128
7
+ },
8
+ "files": [
9
+ ".gitignore",
10
+ "README.md",
11
+ "config.js",
12
+ "index.js",
13
+ "main.js",
14
+ "package.json",
15
+ "settings.js"
16
+ ],
17
+ "contents": {
18
+ "package.json": "{\n \"name\": \"knightbot\",\n \"version\": \"1.0.0\",\n \"description\": \"WhatsApp Bot\",\n \"main\": \"index.js\",\n \"scripts\": {\n \"start\": \"node index.js\",\n \"start:optimized\": \"node --max-old-space-size=512 --optimize-for-size --gc-interval=100 index.js\",\n \"cleanup\": \"node cleanup.js\",\n \"reset-session\": \"node reset-session.js\",\n \"start:clean\": \"npm run cleanup && npm run start:optimized\",\n \"start:fresh\": \"npm run reset-session && npm start\",\n \"install:panel\": \"npm install --legacy-peer-deps\",\n \"install:force\": \"npm install --force\",\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n \"docker:build\": \"docker build -t docker run -e SESSION_ID=$SESSION_ID knightbot\"\n },\n \"keywords\": [\n \"whatsapp-bot\"\n ],\n \"author\": \"\",\n \"license\": \"ISC\",\n \"dependencies\": {\n \"@adiwajshing/keyed-db\": \"^0.2.4\",\n \"@ffmpeg/ffmpeg\": \"^0.12.15\",\n \"@hapi/boom\": \"^10.0.1\",\n \"@types/node\": \"^18.0.6\",\n \"@whiskeysockets/baileys\": \"^v7.0.0-rc.9\",\n \"awesome-phonenumber\": \"^5.9.0\",\n \"axios\": \"^1.8.4\",\n \"chalk\": \"^4.1.2\",\n \"cheerio\": \"^1.0.0-rc.12\",\n \"cookie\": \"^0.5.0\",\n \"dotenv\": \"^16.4.5\",\n \"events\": \"^3.3.0\",\n \"file-type\": \"^16.5.4\",\n \"fluent-ffmpeg\": \"^2.1.3\",\n \"form-data\": \"^4.0.1\",\n \"fs-extra\": \"^11.2.0\",\n \"gtts\": \"^0.2.1\",\n \"human-readable\": \"^0.2.1\",\n \"jimp\": \"^1.6.0\",\n \"jsdom\": \"^22.1.0\",\n \"libphonenumber-js\": \"^1.11.18\",\n \"libsignal\": \"^2.0.1\",\n \"link-preview-js\": \"^3.0.5\",\n \"moment-timezone\": \"^0.5.43\",\n \"mumaker\": \"^2.0.0\",\n \"node-cache\": \"^5.1.2\",\n \"node-fetch\": \"^2.7.0\",\n \"node-id3\": \"^0.2.3\",\n \"node-webpmux\": \"^3.1.0\",\n \"node-youtube-music\": \"^0.8.3\",\n \"performance-now\": \"^2.1.0\",\n \"phin\": \"^3.7.1\",\n \"pino\": \"^8.21.0\",\n \"qrcode\": \"^1.5.4\",\n \"qrcode-reader\": \"^1.0.4\",\n \"qrcode-terminal\": \"^0.12.0\",\n \"request\": \"^2.88.2\",\n \"ruhend-scraper\": \"^8.3.0\",\n \"safe-stable-stringify\": \"^2.5.0\",\n \"set-cookie\": \"^0.0.4\",\n \"sharp\": \"^0.32.6\",\n \"tough-cookie\": \"^5.0.0\",\n \"translate-google-api\": \"^1.0.4\",\n \"ws\": \"^8.17.1\",\n \"yargs\": \"^17.6.0\",\n \"yargs-parser\": \"^21.1.1\",\n \"youtube-yts\": \"^2.0.0\",\n \"youtubedl-core\": \"^4.11.7\",\n \"yt-search\": \"^2.12.1\",\n \"ytdl-core\": \"^4.11.5\"\n },\n \"overrides\": {\n \"jimp\": \"^1.6.0\"\n },\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n"
19
+ },
20
+ "expected": {
21
+ "runtime": "node",
22
+ "packageManager": "npm",
23
+ "framework": null,
24
+ "platform": null,
25
+ "install": "npm install",
26
+ "build": null,
27
+ "start": "node index.js",
28
+ "nodeVersion": "18",
29
+ "pythonVersion": null,
30
+ "tokenKey": null,
31
+ "warnings": [
32
+ "no-lockfile"
33
+ ]
34
+ }
35
+ }
@@ -390,3 +390,121 @@ describe("conditionals", () => {
390
390
  expect(() => parse(":::if server.address")).toThrow(/conditionals open with :::when/);
391
391
  });
392
392
  });
393
+
394
+ describe("heading anchors", () => {
395
+ it("takes the id after a heading and keeps it out of the heading text", () => {
396
+ const { blocks } = parse(
397
+ [
398
+ "## The token is invalid {#token-invalid}",
399
+ "",
400
+ "### التوكن غير صحيح {#token-invalid-2}",
401
+ ].join("\n"),
402
+ );
403
+
404
+ expect(blocks).toEqual([
405
+ {
406
+ type: "heading",
407
+ level: 2,
408
+ id: "token-invalid",
409
+ children: [
410
+ {
411
+ type: "text",
412
+ text: "The token is invalid",
413
+ },
414
+ ],
415
+ },
416
+ {
417
+ type: "heading",
418
+ level: 3,
419
+ id: "token-invalid-2",
420
+ children: [
421
+ {
422
+ type: "text",
423
+ text: "التوكن غير صحيح",
424
+ },
425
+ ],
426
+ },
427
+ ]);
428
+ });
429
+
430
+ it("keeps inline markup before the anchor", () => {
431
+ const { blocks } = parse("## `ffmpeg` is missing {#ffmpeg-missing}");
432
+
433
+ expect(blocks[0]).toEqual({
434
+ type: "heading",
435
+ level: 2,
436
+ id: "ffmpeg-missing",
437
+ children: [
438
+ {
439
+ type: "code",
440
+ text: "ffmpeg",
441
+ },
442
+ {
443
+ type: "text",
444
+ text: " is missing",
445
+ },
446
+ ],
447
+ });
448
+ });
449
+
450
+ it("still slugs a heading without an anchor", () => {
451
+ const { blocks } = parse("## Find your line");
452
+
453
+ expect(blocks[0]).toMatchObject({
454
+ id: "find-your-line",
455
+ });
456
+ });
457
+
458
+ it("moves a later slug aside instead of taking an anchored id", () => {
459
+ const { blocks } = parse(
460
+ [
461
+ "## Rate limits {#rate-limited}",
462
+ "",
463
+ "## Rate limited",
464
+ ].join("\n"),
465
+ );
466
+
467
+ expect(blocks.map((block) => (block.type === "heading" ? block.id : null))).toEqual([
468
+ "rate-limited",
469
+ "rate-limited-2",
470
+ ]);
471
+ });
472
+
473
+ it("rejects an anchored id that is already used", () => {
474
+ expect(() =>
475
+ parse(
476
+ [
477
+ "## Rate limited",
478
+ "",
479
+ "## Too many requests {#rate-limited}",
480
+ ].join("\n"),
481
+ ),
482
+ ).toThrow(/heading id "rate-limited" is already used/);
483
+ expect(() =>
484
+ parse(
485
+ [
486
+ "## One {#same}",
487
+ "",
488
+ "## Two {#same}",
489
+ ].join("\n"),
490
+ ),
491
+ ).toThrow(/heading id "same" is already used/);
492
+ });
493
+
494
+ it("rejects an id that is not lowercase letters, digits and hyphens", () => {
495
+ for (const id of [
496
+ "Token-Invalid",
497
+ "token_invalid",
498
+ "token invalid",
499
+ "-token",
500
+ "token--invalid",
501
+ "",
502
+ ]) {
503
+ expect(() => parse(`## The token is invalid {#${id}}`)).toThrow(/must be lowercase letters, digits/);
504
+ }
505
+ });
506
+
507
+ it("rejects an anchor with no heading text", () => {
508
+ expect(() => parse("## {#token-invalid}")).toThrow(/empty text run/);
509
+ });
510
+ });
@@ -52,6 +52,10 @@ const HEADING_2_PATTERN = /^##\s+(.+)$/;
52
52
 
53
53
  const HEADING_3_PATTERN = /^###\s+(.+)$/;
54
54
 
55
+ const HEADING_ANCHOR_PATTERN = /^(.*?)\s*\{#([^}]*)\}$/;
56
+
57
+ const HEADING_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
58
+
55
59
  const CALLOUT_OPEN_PATTERN = /^>\s*\[!(note|warning|danger)\]\s*(.*)$/;
56
60
 
57
61
  const IMAGE_LINE_PATTERN = /^!\[([^\]]*)\]\(([^)\s]+)\)$/;
@@ -285,6 +289,20 @@ const headingId = (inlines: GuideInline[], used: Map<string, number>, fallback:
285
289
  return count === 0 ? base : `${base}-${count + 1}`;
286
290
  };
287
291
 
292
+ const anchoredHeadingId = (path: string, line: number, id: string, used: Map<string, number>) => {
293
+ if (!HEADING_ID_PATTERN.test(id)) {
294
+ fail(path, line, `heading id "${id}" must be lowercase letters, digits and single hyphens`);
295
+ }
296
+
297
+ if (used.has(id)) {
298
+ fail(path, line, `heading id "${id}" is already used in this guide`);
299
+ }
300
+
301
+ used.set(id, 1);
302
+
303
+ return id;
304
+ };
305
+
288
306
  const parseOpenTarget = (path: string, line: number, value: string): GuideOpenTarget => {
289
307
  const separator = value.indexOf(":");
290
308
  const tab = separator === -1 ? value : value.slice(0, separator);
@@ -459,12 +477,16 @@ const scanLeafBlocks = (scanner: Scanner, nested: boolean): ParsedLeafBlock[] =>
459
477
  fail(path, lineNumber, "unsupported heading depth; use ## or ###");
460
478
  }
461
479
 
462
- const children = parseInlines(path, lineNumber, match[1].trim());
480
+ const text = match[1].trim();
481
+ const anchor = text.match(HEADING_ANCHOR_PATTERN);
482
+ const children = parseInlines(path, lineNumber, anchor ? (anchor[1] ?? "") : text);
463
483
 
464
484
  blocks.push({
465
485
  type: "heading",
466
486
  level: h3 ? 3 : 2,
467
- id: headingId(children, scanner.headingIds, `section-${scanner.headingIds.size + 1}`),
487
+ id: anchor
488
+ ? anchoredHeadingId(path, lineNumber, anchor[2] ?? "", scanner.headingIds)
489
+ : headingId(children, scanner.headingIds, `section-${scanner.headingIds.size + 1}`),
468
490
  children,
469
491
  });
470
492
 
@@ -17,6 +17,7 @@ export enum BridgeCrashCause {
17
17
  FfmpegMissing = "ffmpeg-missing",
18
18
  AudioopRemoved = "audioop-removed",
19
19
  RateLimited = "rate-limited",
20
+ VariableMissing = "variable-missing",
20
21
  Unknown = "unknown",
21
22
  }
22
23
 
@@ -75,6 +76,10 @@ export const BRIDGE_CRASH_EVIDENCE: readonly BridgeCrashEvidence[] = [
75
76
  name: BridgeEventName.FfmpegMissing,
76
77
  cause: BridgeCrashCause.FfmpegMissing,
77
78
  },
79
+ {
80
+ name: BridgeEventName.VariableMissing,
81
+ cause: BridgeCrashCause.VariableMissing,
82
+ },
78
83
  {
79
84
  name: BridgeEventName.IntentsDisallowed,
80
85
  cause: BridgeCrashCause.IntentsDisallowed,
@@ -47,6 +47,7 @@ export enum BridgeEventName {
47
47
  TickLagging = "TickLagging",
48
48
  TokenInUse = "TokenInUse",
49
49
  TokenInvalid = "TokenInvalid",
50
+ VariableMissing = "VariableMissing",
50
51
  WorldActivated = "WorldActivated",
51
52
  WorldCloned = "WorldCloned",
52
53
  WorldCorrupt = "WorldCorrupt",