@serverkgg/bridge 0.1.3 → 0.1.5
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 +1 -1
- package/rules/package.md +5 -0
- package/src/bot/botCauses.test.ts +284 -0
- package/src/bot/botCauses.ts +58 -0
- package/src/bot/botDeploy.test.ts +260 -1
- package/src/bot/botDeploy.ts +125 -6
- package/src/bot/botState.ts +6 -0
- package/src/bot/detect/botInspectWarning.ts +3 -1
- package/src/bot/detect/botPackageManager.ts +3 -0
- package/src/bot/detect/corpus/alexmubarakshin-ts-telegraf-boilerplate.json +37 -0
- package/src/bot/detect/corpus/alsarmad-discord-quran-bot.json +40 -0
- package/src/bot/detect/corpus/anasty17-mirror-leech-telegram-bot.json +50 -0
- package/src/bot/detect/corpus/anidiotsguide-guidebot.json +36 -0
- package/src/bot/detect/corpus/anonymousx1025-anonxmusic.json +45 -0
- package/src/bot/detect/corpus/bot-base-telegram-bot-template.json +35 -0
- package/src/bot/detect/corpus/bracketed-templates-discordjs.json +40 -0
- package/src/bot/detect/corpus/cog-creators-red-discordbot.json +49 -0
- package/src/bot/detect/corpus/cranberriez-discord.ts-bot-template.json +35 -0
- package/src/bot/detect/corpus/crazycat256-nextcord-bot-template.json +38 -0
- package/src/bot/detect/corpus/discord-tickets-bot.json +45 -0
- package/src/bot/detect/corpus/edjopato-telegram-typescript-bot-template.json +37 -0
- package/src/bot/detect/corpus/haniali3161-bot-adhkar-telegram.json +33 -0
- package/src/bot/detect/corpus/hellosverre-discord-ts-template.json +34 -0
- package/src/bot/detect/corpus/kevinnovak-discord-bot-typescript-template.json +43 -0
- package/src/bot/detect/corpus/kkrypt0nn-python-discord-bot-template.json +41 -0
- package/src/bot/detect/corpus/kolezka-telegram-grammy-template.json +37 -0
- package/src/bot/detect/corpus/latand-tgbot_template_v3.json +37 -0
- package/src/bot/detect/corpus/mahmoudbm77-zekr-telegram-bot.json +36 -0
- package/src/bot/detect/corpus/mericcan41-discordjs-v14-template-ts.json +33 -0
- package/src/bot/detect/corpus/modmail-dev-modmail.json +52 -0
- package/src/bot/detect/corpus/nezdemkovski-telegram-bot-boilerplate.json +39 -0
- package/src/bot/detect/corpus/notchum-discord-bot-template.json +39 -0
- package/src/bot/detect/corpus/python-discord-bot.json +44 -0
- package/src/bot/detect/corpus/python-telegram-bot-rules-bot.json +39 -0
- package/src/bot/detect/corpus/rajdave69-pycord-bot-template.json +31 -0
- package/src/bot/detect/corpus/rapptz-robodanny.json +36 -0
- package/src/bot/detect/corpus/ry2x-typescript-discord.js-v.14-template.json +35 -0
- package/src/bot/detect/corpus/saiteja-madha-discord-js-bot.json +45 -0
- package/src/bot/detect/corpus/seegreatness-discord.js-for-heroku.json +37 -0
- package/src/bot/detect/corpus/serverk-discord-node.json +36 -0
- package/src/bot/detect/corpus/serverk-discord-python.json +35 -0
- package/src/bot/detect/corpus/serverk-telegram-node.json +36 -0
- package/src/bot/detect/corpus/serverk-telegram-python.json +35 -0
- package/src/bot/detect/corpus/shuruhatik-quran-radio-bot-discord.json +33 -0
- package/src/bot/detect/corpus/skyra-project-skyra.json +46 -0
- package/src/bot/detect/corpus/tgbot-collection-ytdlbot.json +41 -0
- package/src/bot/detect/corpus/zeriodev-music-bot.json +38 -0
- package/src/bot/detect/detect.corpus.test.ts +77 -0
- package/src/bot/detect/detect.test.ts +414 -17
- package/src/bot/detect/detect.ts +347 -36
- package/src/bot/detect/detectResult.ts +4 -0
- package/src/bot/detect/detectTable.test.ts +22 -6
- package/src/bot/detect/detectTable.ts +187 -19
- package/src/bot/detect/detectedVersion.ts +5 -0
- package/src/bot/detect/envExample.test.ts +55 -1
- package/src/bot/detect/envExample.ts +14 -0
- package/src/bot/detect/index.ts +1 -0
- package/src/bot/detect/keys.test.ts +32 -1
- package/src/bot/detect/keys.ts +15 -0
- package/src/bot/detect/serverkJson.test.ts +2 -0
- package/src/bot/detect/serverkJson.ts +4 -1
- package/src/bot/index.ts +1 -0
- package/src/cli/validate.test.ts +127 -1
- package/src/cli/validate.ts +42 -0
- package/src/manifest/manifest.ts +2 -1
- package/src/modules/bridge.ts +2 -0
- package/src/modules/bridgeModule.ts +5 -0
- package/src/protocol/bridgeCrash.ts +49 -0
- package/src/protocol/bridgeEvents.ts +9 -0
- package/src/protocol/bridgePreview.ts +1 -0
- package/src/rcon/rconAccess.ts +1 -1
- package/src/runtime/bridgeDriver.test.ts +45 -0
- package/src/runtime/bridgeDriver.ts +25 -0
- package/src/runtime/bridgeEvents.ts +2 -0
- package/src/runtime/bridgeRegistry.test.ts +32 -0
- package/src/runtime/bridgeSupervisor.test.ts +163 -3
- package/src/runtime/bridgeSupervisor.ts +42 -1
- package/src/runtime/serve.ts +26 -5
package/package.json
CHANGED
package/rules/package.md
CHANGED
|
@@ -37,6 +37,11 @@ One Serverk package (a game or a bot runtime) on `@serverkgg/bridge`, with no pi
|
|
|
37
37
|
- A settings form targets `BridgeFormTarget.Settings` and module `settings`, with a one-sentence `help` and `restartHint: true`.
|
|
38
38
|
- In a game, the roster and moderation tables go on `place: BridgePlace.Players`, metrics on `BridgePlace.Overview`.
|
|
39
39
|
- A setup `Form` step points at an existing panel form by `tab`, `section` and field keys, never redeclaring a field; `Form` and `Open` steps state `required`. A game's setup ends with the `Open` step `invite` targeting `GuideOpenTab.Access`.
|
|
40
|
+
- A preview module that knows the change cannot install answers `blocked: true`, with the reason in `lines`. The panel then shows the lines with a close button only, and the platform refuses the change before it rebuilds anything.
|
|
41
|
+
- `BridgeConfirm.None` runs on the click: reads, downloads, start, stop and restart.
|
|
42
|
+
- `BridgeConfirm.Normal` asks once, for anything the player can come back from: a settings form, a variable flip, a reinstall, a version or type switch, a modpack or mod install, op and deop, kick, ban, whitelist, rotating a password.
|
|
43
|
+
- `BridgeConfirm.Strong` is only for an action with no way back, where no restore point is taken and nothing in the panel brings the state back: deleting a world, save, map or character data, resetting a dimension, a wipe. A protected action takes no restore point.
|
|
44
|
+
- A `Form` or `Catalog` section, a `Preview` answer and any section with `reinstall` are never `Strong`, because the platform takes a restore point first; `serverk-bridge validate` fails on such a section. The web shows `Strong` as a checkbox acknowledgement.
|
|
40
45
|
- Emit event names from `BridgeEventName`, never strings, and list every event raised from code in `emits` (`patterns: []` when none match the log). A bot matches deployments with `DEPLOYMENT_EVENT_PATTERN`.
|
|
41
46
|
- `install.run` runs on every boot: keep it idempotent with `readStamp` and `writeStamp` (`@serverkgg/bridge/install`), the stamp in `files.protected`. A game protects the default `.serverk-install.json`; a bot writes `.serverk/install.json` (`INSTALL_STAMP`, `shared/botRuntime.ts`) and protects `.serverk/**`.
|
|
42
47
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { BridgeKind, BridgeStream } from "../core";
|
|
3
|
+
import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
4
|
+
import { createEventRing } from "../runtime/bridgeEvents";
|
|
5
|
+
import type { BridgeSupervisor, SupervisorLine } from "../runtime/bridgeSupervisor";
|
|
6
|
+
import { BOT_CRASH_EVIDENCE } from "./botCauses";
|
|
7
|
+
|
|
8
|
+
const CASES: readonly {
|
|
9
|
+
line: string;
|
|
10
|
+
cause: BridgeCrashCause;
|
|
11
|
+
}[] = [
|
|
12
|
+
{
|
|
13
|
+
line: "Error [TokenInvalid]: An invalid token was provided.",
|
|
14
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
line: "discord.errors.LoginFailure: Improper token has been passed.",
|
|
18
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
line: "GrammyError: Call to 'getMe' failed! (401: Unauthorized)",
|
|
22
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
line: "telegram.error.InvalidToken: The token `123:abc` was rejected by the server.",
|
|
26
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
line: "GrammyError: Call to 'deleteWebhook' failed! (404: Not Found)",
|
|
30
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
line: "404: Not Found at https://api.telegram.org/bot***/getMe",
|
|
34
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
line: "aiogram.exceptions.TelegramUnauthorizedError: Telegram server says - Unauthorized",
|
|
38
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
line: "Error: Used disallowed intents",
|
|
42
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
line: "Error [DisallowedIntents]: Privileged intent provided is not enabled or whitelisted.",
|
|
46
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
line: "discord.errors.PrivilegedIntentsRequired: Shard ID None is requesting privileged intents that have not been explicitly enabled in the developer portal.",
|
|
50
|
+
cause: BridgeCrashCause.IntentsDisallowed,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
line: "telegram.error.Conflict: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running",
|
|
54
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
line: "GrammyError: Call to 'getUpdates' failed! (409: Conflict: terminated by other getUpdates request; make sure that only one bot instance is running)",
|
|
58
|
+
cause: BridgeCrashCause.TokenInUse,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
line: "Error: Cannot find module 'discord.js'",
|
|
62
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
line: "Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'telegraf' imported from /home/container/index.js",
|
|
66
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
line: "ModuleNotFoundError: No module named 'discord'",
|
|
70
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
line: "RuntimeError: davey library needed in order to use voice",
|
|
74
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
line: "RuntimeError: PyNaCl library needed in order to use voice",
|
|
78
|
+
cause: BridgeCrashCause.MissingDependency,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
line: "ImportError: cannot import name 'Update' from 'telegram' (/home/container/.venv/lib/python3.12/site-packages/telegram/__init__.py)",
|
|
82
|
+
cause: BridgeCrashCause.WrongTelegramPackage,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
line: "AttributeError: module 'discord' has no attribute 'Bot'",
|
|
86
|
+
cause: BridgeCrashCause.WrongDiscordPackage,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
line: "ModuleNotFoundError: No module named 'discord.app_commands'",
|
|
90
|
+
cause: BridgeCrashCause.WrongDiscordPackage,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
line: "ImportError: cannot import name 'app_commands' from 'discord' (/home/container/.venv/lib/python3.12/site-packages/discord/__init__.py)",
|
|
94
|
+
cause: BridgeCrashCause.WrongDiscordPackage,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
line: "gyp ERR! stack Error: not found: make",
|
|
98
|
+
cause: BridgeCrashCause.BuildTools,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
line: "error: command 'gcc' failed: No such file or directory",
|
|
102
|
+
cause: BridgeCrashCause.BuildTools,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
line: "error: command 'x86_64-linux-gnu-gcc' failed with exit status 1",
|
|
106
|
+
cause: BridgeCrashCause.BuildTools,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
line: "discord.errors.ClientException: ffmpeg was not found.",
|
|
110
|
+
cause: BridgeCrashCause.FfmpegMissing,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
line: "Error: spawn ffmpeg ENOENT",
|
|
114
|
+
cause: BridgeCrashCause.FfmpegMissing,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
line: "ModuleNotFoundError: No module named 'audioop'",
|
|
118
|
+
cause: BridgeCrashCause.AudioopRemoved,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
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.",
|
|
122
|
+
cause: BridgeCrashCause.RateLimited,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
line: "GrammyError: Call to 'sendMessage' failed! (429: Too Many Requests: retry after 5)",
|
|
126
|
+
cause: BridgeCrashCause.RateLimited,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
line: "error code: 1015",
|
|
130
|
+
cause: BridgeCrashCause.RateLimited,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
line: "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
|
|
134
|
+
cause: BridgeCrashCause.OutOfMemory,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
line: "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory",
|
|
138
|
+
cause: BridgeCrashCause.OutOfMemory,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
line: "MemoryError",
|
|
142
|
+
cause: BridgeCrashCause.OutOfMemory,
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const recordOf = (overrides: Partial<BridgeCrashRecord> = {}): BridgeCrashRecord => {
|
|
147
|
+
return {
|
|
148
|
+
exitCode: 1,
|
|
149
|
+
outOfMemory: false,
|
|
150
|
+
phase: BridgeCrashPhase.Booting,
|
|
151
|
+
uptimeSeconds: 5,
|
|
152
|
+
restarting: false,
|
|
153
|
+
looping: false,
|
|
154
|
+
restarts: 0,
|
|
155
|
+
lastLine: null,
|
|
156
|
+
...overrides,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const botRing = () => {
|
|
161
|
+
const watchers: ((entry: SupervisorLine) => void)[] = [];
|
|
162
|
+
|
|
163
|
+
const supervisor = {
|
|
164
|
+
subscribe(watcher: (entry: SupervisorLine) => void) {
|
|
165
|
+
watchers.push(watcher);
|
|
166
|
+
|
|
167
|
+
return () => {};
|
|
168
|
+
},
|
|
169
|
+
} as unknown as BridgeSupervisor;
|
|
170
|
+
|
|
171
|
+
const ring = createEventRing(
|
|
172
|
+
{
|
|
173
|
+
kind: BridgeKind.Events,
|
|
174
|
+
patterns: [
|
|
175
|
+
...BOT_CRASH_EVIDENCE,
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
supervisor,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const print = (line: string) => {
|
|
182
|
+
for (const watcher of watchers) {
|
|
183
|
+
watcher({
|
|
184
|
+
stream: BridgeStream.Stderr,
|
|
185
|
+
line,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ring,
|
|
192
|
+
print,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
describe("BOT_CRASH_EVIDENCE", () => {
|
|
197
|
+
for (const item of CASES) {
|
|
198
|
+
it(`names ${item.cause} from "${item.line}"`, () => {
|
|
199
|
+
const { ring, print } = botRing();
|
|
200
|
+
|
|
201
|
+
print(item.line);
|
|
202
|
+
|
|
203
|
+
expect(ring.crash(recordOf())).toBe(item.cause);
|
|
204
|
+
expect(ring.drain(0).events.at(0)?.payload?.cause).toBe(item.cause);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("names the removed audioop module before any other missing module", () => {
|
|
209
|
+
const { ring, print } = botRing();
|
|
210
|
+
|
|
211
|
+
print("ModuleNotFoundError: No module named 'audioop'");
|
|
212
|
+
print("ModuleNotFoundError: No module named 'discord'");
|
|
213
|
+
|
|
214
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.AudioopRemoved);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("names the wrong discord library before the missing module it shows up as", () => {
|
|
218
|
+
const { ring, print } = botRing();
|
|
219
|
+
|
|
220
|
+
print("ModuleNotFoundError: No module named 'discord.app_commands'");
|
|
221
|
+
|
|
222
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.WrongDiscordPackage);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("names missing build tools before the missing module they left behind", () => {
|
|
226
|
+
const { ring, print } = botRing();
|
|
227
|
+
|
|
228
|
+
print("gyp ERR! stack Error: not found: make");
|
|
229
|
+
print("Error: Cannot find module 'better-sqlite3'");
|
|
230
|
+
|
|
231
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.BuildTools);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("names a missing module before a token the bot never got to use", () => {
|
|
235
|
+
const { ring, print } = botRing();
|
|
236
|
+
|
|
237
|
+
print("GrammyError: Call to 'getMe' failed! (401: Unauthorized)");
|
|
238
|
+
print("Error: Cannot find module 'grammy'");
|
|
239
|
+
|
|
240
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.MissingDependency);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("leaves a 404 from a Telegram call that says nothing about the token unexplained", () => {
|
|
244
|
+
const { ring, print } = botRing();
|
|
245
|
+
|
|
246
|
+
print("GrammyError: Call to 'sendMessage' failed! (400: Bad Request: chat not found)");
|
|
247
|
+
print("GET /assets/logo.png 404: Not Found");
|
|
248
|
+
|
|
249
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("leaves a line no library prints unexplained", () => {
|
|
253
|
+
const { ring, print } = botRing();
|
|
254
|
+
|
|
255
|
+
print("TypeError: Cannot read properties of undefined (reading 'send')");
|
|
256
|
+
|
|
257
|
+
expect(ring.crash(recordOf())).toBe(BridgeCrashCause.Unknown);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("calls exit 137 out of memory only when the record carries the flag", () => {
|
|
261
|
+
const unflagged = botRing();
|
|
262
|
+
|
|
263
|
+
expect(
|
|
264
|
+
unflagged.ring.crash(
|
|
265
|
+
recordOf({
|
|
266
|
+
exitCode: 137,
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
).toBe(BridgeCrashCause.Unknown);
|
|
270
|
+
|
|
271
|
+
const flagged = botRing();
|
|
272
|
+
|
|
273
|
+
flagged.print("Error [TokenInvalid]: An invalid token was provided.");
|
|
274
|
+
|
|
275
|
+
expect(
|
|
276
|
+
flagged.ring.crash(
|
|
277
|
+
recordOf({
|
|
278
|
+
exitCode: 137,
|
|
279
|
+
outOfMemory: true,
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
).toBe(BridgeCrashCause.OomKill);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { BridgeEventPattern } from "../modules";
|
|
2
|
+
import { BridgeEventName } from "../protocol";
|
|
3
|
+
|
|
4
|
+
export const BOT_CRASH_EVIDENCE: readonly BridgeEventPattern[] = [
|
|
5
|
+
{
|
|
6
|
+
match:
|
|
7
|
+
/\[TokenInvalid\]|An invalid token was provided|LoginFailure: Improper token|\b401:? Unauthorized\b|\bInvalidToken\b|TelegramUnauthorizedError/,
|
|
8
|
+
emit: BridgeEventName.TokenInvalid,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
match:
|
|
12
|
+
/Call to '(?:getMe|deleteWebhook|getUpdates)' failed! \(404: Not Found\)|\/bot[^/\s]*\/(?:getMe|deleteWebhook|getUpdates)\b.*\b404:? Not Found\b|\b404:? Not Found\b.*\/bot[^/\s]*\/(?:getMe|deleteWebhook|getUpdates)\b/,
|
|
13
|
+
emit: BridgeEventName.TokenInvalid,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
match: /Used disallowed intents|\[DisallowedIntents\]|PrivilegedIntentsRequired|close code:? 4014\b/,
|
|
17
|
+
emit: BridgeEventName.IntentsDisallowed,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
match: /terminated by other getUpdates request|\b409:? Conflict\b/,
|
|
21
|
+
emit: BridgeEventName.TokenInUse,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
match:
|
|
25
|
+
/Cannot find module|ERR_MODULE_NOT_FOUND|ModuleNotFoundError: No module named|(?:davey|PyNaCl) library needed in order to use voice/,
|
|
26
|
+
emit: BridgeEventName.MissingDependency,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
match: /ImportError: cannot import name .+ from 'telegram'/,
|
|
30
|
+
emit: BridgeEventName.WrongTelegramPackage,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
match:
|
|
34
|
+
/AttributeError: module 'discord' has no attribute 'Bot'|No module named 'discord\.app_commands'|ImportError: cannot import name 'app_commands' from 'discord'/,
|
|
35
|
+
emit: BridgeEventName.WrongDiscordPackage,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
match: /gyp ERR!|error: command '(?:[\w.-]+-)?gcc' failed/,
|
|
39
|
+
emit: BridgeEventName.BuildToolsMissing,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
match: /ffmpeg was not found|\bENOENT\b.*\bffmpeg\b|\bffmpeg\b.*\bENOENT\b/i,
|
|
43
|
+
emit: BridgeEventName.FfmpegMissing,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
match: /No module named 'audioop'/,
|
|
47
|
+
emit: BridgeEventName.AudioopRemoved,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
match: /\b429:? Too Many Requests\b|\berror code:? 1015\b|\bError 1015\b|You are being rate limited/i,
|
|
51
|
+
emit: BridgeEventName.RateLimited,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
match:
|
|
55
|
+
/FATAL ERROR: (?:Reached heap limit|Ineffective mark-compacts near heap limit) Allocation failed - JavaScript heap out of memory|^MemoryError\b/,
|
|
56
|
+
emit: BridgeEventName.ServerCrashed,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { BridgeValues } from "../core";
|
|
3
|
-
import { BridgeEventName } from "../protocol";
|
|
3
|
+
import { BridgeCrashCause, BridgeCrashPhase, type BridgeCrashReport, BridgeEventName } from "../protocol";
|
|
4
4
|
import {
|
|
5
|
+
BOT_READY_GRACE_SECONDS,
|
|
6
|
+
createBotReadiness,
|
|
5
7
|
DEPLOY_MARKER,
|
|
6
8
|
DEPLOY_MARKER_DRAFT,
|
|
7
9
|
DEPLOYMENT_EVENT_PATTERN,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
type DeployMarker,
|
|
10
12
|
deploymentLine,
|
|
11
13
|
deployPending,
|
|
14
|
+
pendingDeploymentOf,
|
|
12
15
|
requestDeploy,
|
|
13
16
|
} from "./botDeploy";
|
|
14
17
|
import { BotDeploymentOutcome, BotDeploymentSource, BotDeploymentStage, DEPLOYMENT_STATE_FILE } from "./botState";
|
|
@@ -361,3 +364,259 @@ describe("deployPending", () => {
|
|
|
361
364
|
expect(await deployPending(stopped.context)).toBe(false);
|
|
362
365
|
});
|
|
363
366
|
});
|
|
367
|
+
|
|
368
|
+
const LIVE_AT = Date.parse("2026-09-27T10:00:00.000Z");
|
|
369
|
+
|
|
370
|
+
const LAUNCHED_BEFORE = LIVE_AT - 60_000;
|
|
371
|
+
|
|
372
|
+
const LAUNCHED_AFTER = LIVE_AT + 60_000;
|
|
373
|
+
|
|
374
|
+
const reportOf = (overrides: Partial<BridgeCrashReport> = {}): BridgeCrashReport => {
|
|
375
|
+
return {
|
|
376
|
+
cause: BridgeCrashCause.TokenInvalid,
|
|
377
|
+
exitCode: 1,
|
|
378
|
+
outOfMemory: false,
|
|
379
|
+
phase: BridgeCrashPhase.Booting,
|
|
380
|
+
uptimeSeconds: 5,
|
|
381
|
+
restarting: false,
|
|
382
|
+
looping: false,
|
|
383
|
+
restarts: 0,
|
|
384
|
+
lastLine: null,
|
|
385
|
+
...overrides,
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const readyBot = async (launchedAt: number) => {
|
|
390
|
+
const deploy = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
391
|
+
const readiness = createBotReadiness();
|
|
392
|
+
|
|
393
|
+
readiness.launching(launchedAt);
|
|
394
|
+
|
|
395
|
+
await readiness.ready(deploy.context);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
...deploy,
|
|
399
|
+
readiness,
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
describe("createBotReadiness", () => {
|
|
404
|
+
it("waits twenty seconds for a bot to prove it is ready", () => {
|
|
405
|
+
expect(BOT_READY_GRACE_SECONDS).toBe(20);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("reports the deployment ready once the bot is stable, never when the launcher hands over", async () => {
|
|
409
|
+
const { context, emitted, stored, readiness } = await readyBot(LAUNCHED_BEFORE);
|
|
410
|
+
|
|
411
|
+
expect(emitted).toEqual([]);
|
|
412
|
+
|
|
413
|
+
await readiness.stable(context);
|
|
414
|
+
|
|
415
|
+
expect(JSON.parse(stored.get(DEPLOYMENT_STATE_FILE) ?? "null").settled).toBe(true);
|
|
416
|
+
|
|
417
|
+
expect(emitted).toEqual([
|
|
418
|
+
{
|
|
419
|
+
event: BridgeEventName.ServerUpdated,
|
|
420
|
+
payload: {
|
|
421
|
+
kind: "deployment",
|
|
422
|
+
deployment: "dep_a1b2c3d4",
|
|
423
|
+
outcome: "ready",
|
|
424
|
+
commit: "0123456789ab",
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("fails the deployment at the start stage with the cause when the bot exits inside the grace", async () => {
|
|
431
|
+
const { context, stored, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
|
|
432
|
+
|
|
433
|
+
await readiness.exit(context, reportOf());
|
|
434
|
+
|
|
435
|
+
expect(emitted).toEqual([
|
|
436
|
+
{
|
|
437
|
+
event: BridgeEventName.ServerUpdated,
|
|
438
|
+
payload: {
|
|
439
|
+
kind: "deployment",
|
|
440
|
+
deployment: "dep_a1b2c3d4",
|
|
441
|
+
outcome: "failed",
|
|
442
|
+
stage: "start",
|
|
443
|
+
detail: "token-invalid",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
]);
|
|
447
|
+
|
|
448
|
+
const state = JSON.parse(stored.get(DEPLOYMENT_STATE_FILE) ?? "null");
|
|
449
|
+
|
|
450
|
+
expect({
|
|
451
|
+
...state,
|
|
452
|
+
finishedAt: null,
|
|
453
|
+
}).toEqual({
|
|
454
|
+
...JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]),
|
|
455
|
+
outcome: BotDeploymentOutcome.Failed,
|
|
456
|
+
stage: BotDeploymentStage.Start,
|
|
457
|
+
detail: "token-invalid",
|
|
458
|
+
finishedAt: null,
|
|
459
|
+
settled: true,
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("names the failure unknown when nothing the bot printed explains it", async () => {
|
|
464
|
+
const { context, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
|
|
465
|
+
|
|
466
|
+
await readiness.exit(
|
|
467
|
+
context,
|
|
468
|
+
reportOf({
|
|
469
|
+
cause: BridgeCrashCause.Unknown,
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
expect(emitted.at(0)?.payload?.detail).toBe("unknown");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("leaves a deployment that went live before this launch alone on a plain restart", async () => {
|
|
477
|
+
const { context, emitted, stored, readiness } = await readyBot(LAUNCHED_AFTER);
|
|
478
|
+
const before = stored.get(DEPLOYMENT_STATE_FILE);
|
|
479
|
+
|
|
480
|
+
await readiness.exit(context, reportOf());
|
|
481
|
+
await readiness.stable(context);
|
|
482
|
+
|
|
483
|
+
expect(emitted).toEqual([]);
|
|
484
|
+
expect(stored.get(DEPLOYMENT_STATE_FILE)).toBe(before);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("spends the deployment once, so a crash after the bot became stable fails nothing", async () => {
|
|
488
|
+
const { context, emitted, readiness } = await readyBot(LAUNCHED_BEFORE);
|
|
489
|
+
|
|
490
|
+
await readiness.stable(context);
|
|
491
|
+
await readiness.exit(
|
|
492
|
+
context,
|
|
493
|
+
reportOf({
|
|
494
|
+
phase: BridgeCrashPhase.Running,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
expect(emitted.map((entry) => entry.payload?.outcome)).toEqual([
|
|
499
|
+
"ready",
|
|
500
|
+
]);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("the deployment a bot is still proving", () => {
|
|
505
|
+
it("waits for the state read still in flight before it reports ready", async () => {
|
|
506
|
+
const deploy = deployContext(true, liveState("dep_a1b2c3d4"));
|
|
507
|
+
const readiness = createBotReadiness();
|
|
508
|
+
|
|
509
|
+
readiness.launching(LAUNCHED_BEFORE);
|
|
510
|
+
|
|
511
|
+
const reading = readiness.ready(deploy.context);
|
|
512
|
+
|
|
513
|
+
await readiness.stable(deploy.context);
|
|
514
|
+
await reading;
|
|
515
|
+
|
|
516
|
+
expect(deploy.emitted.map((entry) => entry.payload?.outcome)).toEqual([
|
|
517
|
+
"ready",
|
|
518
|
+
]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("keeps proving a deployment the launcher left unsettled when the bot starts again", async () => {
|
|
522
|
+
const unsettled = JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]);
|
|
523
|
+
const deploy = deployContext(true, {
|
|
524
|
+
[DEPLOYMENT_STATE_FILE]: JSON.stringify({
|
|
525
|
+
...unsettled,
|
|
526
|
+
settled: false,
|
|
527
|
+
}),
|
|
528
|
+
});
|
|
529
|
+
const readiness = createBotReadiness();
|
|
530
|
+
|
|
531
|
+
readiness.launching(LAUNCHED_AFTER);
|
|
532
|
+
|
|
533
|
+
await readiness.ready(deploy.context);
|
|
534
|
+
await readiness.exit(deploy.context, reportOf());
|
|
535
|
+
|
|
536
|
+
expect(deploy.emitted.map((entry) => entry.payload?.outcome)).toEqual([
|
|
537
|
+
"failed",
|
|
538
|
+
]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("does not call a deployment live while the bot is still proving it, and restarts nothing", async () => {
|
|
542
|
+
const unsettled = JSON.parse(liveState("dep_a1b2c3d4")[DEPLOYMENT_STATE_FILE]);
|
|
543
|
+
const { context, stored, events, emitted } = deployContext(true, {
|
|
544
|
+
[DEPLOYMENT_STATE_FILE]: JSON.stringify({
|
|
545
|
+
...unsettled,
|
|
546
|
+
settled: false,
|
|
547
|
+
}),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
await requestDeploy(context, {
|
|
551
|
+
deployment: "dep_a1b2c3d4",
|
|
552
|
+
ref: SHA,
|
|
553
|
+
snapshot: null,
|
|
554
|
+
reinstall: false,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
expect(emitted).toEqual([]);
|
|
558
|
+
expect(stored.has(DEPLOY_MARKER)).toBe(false);
|
|
559
|
+
expect(events).toEqual([
|
|
560
|
+
"log the deployment is running but has not proven itself yet, its outcome follows",
|
|
561
|
+
]);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("pendingDeploymentOf", () => {
|
|
566
|
+
it("takes a ready deployment the launcher wrote during this launch or left unsettled, never a settled one", () => {
|
|
567
|
+
const state = {
|
|
568
|
+
deployment: "dep_a1b2c3d4",
|
|
569
|
+
source: BotDeploymentSource.Snapshot,
|
|
570
|
+
ref: null,
|
|
571
|
+
commit: null,
|
|
572
|
+
snapshot: "snap_1",
|
|
573
|
+
outcome: BotDeploymentOutcome.Ready,
|
|
574
|
+
stage: null,
|
|
575
|
+
detail: null,
|
|
576
|
+
finishedAt: "2026-09-27T10:00:00.000Z",
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
expect(pendingDeploymentOf(state, LAUNCHED_BEFORE)).toEqual(state);
|
|
580
|
+
expect(pendingDeploymentOf(state, LAUNCHED_AFTER)).toBeNull();
|
|
581
|
+
expect(
|
|
582
|
+
pendingDeploymentOf(
|
|
583
|
+
{
|
|
584
|
+
...state,
|
|
585
|
+
outcome: BotDeploymentOutcome.Failed,
|
|
586
|
+
},
|
|
587
|
+
LAUNCHED_BEFORE,
|
|
588
|
+
),
|
|
589
|
+
).toBeNull();
|
|
590
|
+
expect(
|
|
591
|
+
pendingDeploymentOf(
|
|
592
|
+
{
|
|
593
|
+
...state,
|
|
594
|
+
deployment: null,
|
|
595
|
+
},
|
|
596
|
+
LAUNCHED_BEFORE,
|
|
597
|
+
),
|
|
598
|
+
).toBeNull();
|
|
599
|
+
expect(pendingDeploymentOf(null, LAUNCHED_BEFORE)).toBeNull();
|
|
600
|
+
expect(
|
|
601
|
+
pendingDeploymentOf(
|
|
602
|
+
{
|
|
603
|
+
...state,
|
|
604
|
+
settled: false,
|
|
605
|
+
},
|
|
606
|
+
LAUNCHED_AFTER,
|
|
607
|
+
),
|
|
608
|
+
).toEqual({
|
|
609
|
+
...state,
|
|
610
|
+
settled: false,
|
|
611
|
+
});
|
|
612
|
+
expect(
|
|
613
|
+
pendingDeploymentOf(
|
|
614
|
+
{
|
|
615
|
+
...state,
|
|
616
|
+
settled: true,
|
|
617
|
+
},
|
|
618
|
+
LAUNCHED_BEFORE,
|
|
619
|
+
),
|
|
620
|
+
).toBeNull();
|
|
621
|
+
});
|
|
622
|
+
});
|