@serverkgg/bridge 0.0.21 → 0.1.1
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 +3 -1
- package/src/bot/botDeploy.test.ts +363 -0
- package/src/bot/botDeploy.ts +136 -0
- package/src/bot/botEnvironment.test.ts +189 -0
- package/src/bot/botEnvironment.ts +141 -0
- package/src/bot/botIdentity.test.ts +251 -0
- package/src/bot/botIdentity.ts +140 -0
- package/src/bot/botPanel.test.ts +216 -0
- package/src/bot/botPanel.ts +121 -0
- package/src/bot/botState.test.ts +45 -0
- package/src/bot/botState.ts +54 -0
- package/src/bot/botTerminal.test.ts +79 -0
- package/src/bot/botTerminal.ts +62 -0
- package/src/bot/botVariables.test.ts +193 -0
- package/src/bot/botVariables.ts +73 -0
- package/src/bot/detect/botFramework.ts +23 -0
- package/src/bot/detect/botFrameworks.ts +198 -0
- package/src/bot/detect/botInspectWarning.ts +15 -0
- package/src/bot/detect/botPackageManager.ts +6 -0
- package/src/bot/detect/botPlatform.ts +10 -0
- package/src/bot/detect/botRuntime.ts +15 -0
- package/src/bot/detect/botToken.test.ts +71 -0
- package/src/bot/detect/botToken.ts +126 -0
- package/src/bot/detect/detect.test.ts +543 -0
- package/src/bot/detect/detect.ts +842 -0
- package/src/bot/detect/detectResult.ts +40 -0
- package/src/bot/detect/detectTable.test.ts +72 -0
- package/src/bot/detect/detectTable.ts +166 -0
- package/src/bot/detect/envExample.test.ts +29 -0
- package/src/bot/detect/envExample.ts +48 -0
- package/src/bot/detect/envKey.ts +7 -0
- package/src/bot/detect/envScan.test.ts +39 -0
- package/src/bot/detect/envScan.ts +87 -0
- package/src/bot/detect/index.ts +17 -0
- package/src/bot/detect/inferBotPlatform.test.ts +60 -0
- package/src/bot/detect/inferBotPlatform.ts +28 -0
- package/src/bot/detect/keys.test.ts +28 -0
- package/src/bot/detect/keys.ts +64 -0
- package/src/bot/detect/sensitiveKey.test.ts +91 -0
- package/src/bot/detect/sensitiveKey.ts +44 -0
- package/src/bot/detect/serverkJson.test.ts +88 -0
- package/src/bot/detect/serverkJson.ts +174 -0
- package/src/bot/index.ts +8 -0
- package/src/cli/detectTable.test.ts +66 -0
- package/src/cli/detectTable.ts +43 -0
- package/src/cli/main.ts +12 -4
- package/src/cli/renderedFile.ts +35 -0
- package/src/cli/schema.ts +7 -26
- package/src/cli/validate.test.ts +210 -2
- package/src/cli/validate.ts +71 -7
- package/src/context/bridgeContext.ts +11 -0
- package/src/core/bridgePort.test.ts +45 -0
- package/src/core/bridgePort.ts +26 -0
- package/src/guides/compile.ts +2 -2
- package/src/manifest/index.ts +1 -0
- package/src/manifest/kind.ts +5 -0
- package/src/manifest/manifest.test.ts +630 -3
- package/src/manifest/manifest.ts +391 -87
- package/src/manifest/runtime.ts +2 -2
- package/src/manifest/source.ts +4 -4
- package/src/modules/bridgeLifecycleSubject.ts +8 -0
- package/src/modules/bridgeModule.ts +6 -0
- package/src/modules/bridgeStopSignal.ts +13 -0
- package/src/modules/index.ts +2 -0
- package/src/panel/bridgeDetail.ts +2 -0
- package/src/protocol/bridgeConfig.ts +2 -0
- package/src/runtime/bridgeConfigReader.test.ts +80 -0
- package/src/runtime/bridgeConfigReader.ts +20 -0
- package/src/runtime/bridgeContext.test.ts +241 -0
- package/src/runtime/bridgeContext.ts +35 -9
- package/src/runtime/bridgeDriver.test.ts +132 -1
- package/src/runtime/bridgeDriver.ts +54 -1
- package/src/runtime/bridgeFiles.test.ts +62 -0
- package/src/runtime/bridgeFiles.ts +12 -2
- package/src/runtime/bridgeLiveConfig.ts +18 -0
- package/src/runtime/bridgeRedaction.test.ts +410 -0
- package/src/runtime/bridgeRedaction.ts +214 -0
- package/src/runtime/bridgeRegistry.test.ts +3 -0
- package/src/runtime/bridgeRegistry.ts +15 -0
- package/src/runtime/bridgeServer.test.ts +130 -0
- package/src/runtime/bridgeServer.ts +24 -7
- package/src/runtime/bridgeSetupHandlers.test.ts +88 -0
- package/src/runtime/bridgeSetupHandlers.ts +16 -6
- package/src/runtime/bridgeSupervisor.test.ts +670 -2
- package/src/runtime/bridgeSupervisor.ts +219 -67
- package/src/runtime/serve.ts +47 -13
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { BotConfig } from "./botVariables";
|
|
2
|
+
import type { BotPlatform } from "./detect";
|
|
3
|
+
import { BOT_PLATFORM, isReservedBotKey, SERVERK_BOT, SERVERK_SERVER_CODE } from "./detect";
|
|
4
|
+
|
|
5
|
+
export const LAUNCHER_REPO_URL = "SERVERK_REPO_URL";
|
|
6
|
+
|
|
7
|
+
export const LAUNCHER_REPO_REF = "SERVERK_REPO_REF";
|
|
8
|
+
|
|
9
|
+
export const LAUNCHER_REPO_BRANCH = "SERVERK_REPO_BRANCH";
|
|
10
|
+
|
|
11
|
+
export const LAUNCHER_GIT_TOKEN = "SERVERK_GIT_TOKEN";
|
|
12
|
+
|
|
13
|
+
export const LAUNCHER_START_COMMAND = "SERVERK_START_COMMAND";
|
|
14
|
+
|
|
15
|
+
export const LAUNCHER_INSTALL_COMMAND = "SERVERK_INSTALL_COMMAND";
|
|
16
|
+
|
|
17
|
+
export const LAUNCHER_ROOT_DIRECTORY = "SERVERK_ROOT_DIRECTORY";
|
|
18
|
+
|
|
19
|
+
export const LAUNCHER_DEPLOYMENT_ID = "SERVERK_DEPLOYMENT_ID";
|
|
20
|
+
|
|
21
|
+
export const LAUNCHER_SNAPSHOT_ID = "SERVERK_SNAPSHOT_ID";
|
|
22
|
+
|
|
23
|
+
export const LAUNCHER_STATE_DIR = "SERVERK_STATE_DIR";
|
|
24
|
+
|
|
25
|
+
export const LAUNCHER_DETECT_TABLE = "SERVERK_DETECT_TABLE";
|
|
26
|
+
|
|
27
|
+
export const LAUNCHER_KEYS: readonly string[] = [
|
|
28
|
+
LAUNCHER_REPO_URL,
|
|
29
|
+
LAUNCHER_REPO_REF,
|
|
30
|
+
LAUNCHER_REPO_BRANCH,
|
|
31
|
+
LAUNCHER_GIT_TOKEN,
|
|
32
|
+
LAUNCHER_START_COMMAND,
|
|
33
|
+
LAUNCHER_INSTALL_COMMAND,
|
|
34
|
+
LAUNCHER_ROOT_DIRECTORY,
|
|
35
|
+
LAUNCHER_DEPLOYMENT_ID,
|
|
36
|
+
LAUNCHER_SNAPSHOT_ID,
|
|
37
|
+
LAUNCHER_STATE_DIR,
|
|
38
|
+
LAUNCHER_DETECT_TABLE,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const customerEnvironment = (
|
|
42
|
+
variables: Readonly<Record<string, string>>,
|
|
43
|
+
secrets: Readonly<Record<string, string>>,
|
|
44
|
+
): Record<string, string> => {
|
|
45
|
+
const environment: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
for (const [key, value] of [
|
|
48
|
+
...Object.entries(variables),
|
|
49
|
+
...Object.entries(secrets),
|
|
50
|
+
]) {
|
|
51
|
+
if (!isReservedBotKey(key)) {
|
|
52
|
+
environment[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return environment;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const launcherEnvironment = (config: BotConfig, stateDir: string): Record<string, string> => {
|
|
60
|
+
const environment: Record<string, string> = {
|
|
61
|
+
[LAUNCHER_STATE_DIR]: stateDir,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mapped: [
|
|
65
|
+
string,
|
|
66
|
+
string | null,
|
|
67
|
+
][] = [
|
|
68
|
+
[
|
|
69
|
+
LAUNCHER_REPO_URL,
|
|
70
|
+
config.repo,
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
LAUNCHER_REPO_REF,
|
|
74
|
+
config.repo ? config.ref : null,
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
LAUNCHER_REPO_BRANCH,
|
|
78
|
+
config.repo ? config.branch : null,
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
LAUNCHER_GIT_TOKEN,
|
|
82
|
+
config.repo ? config.gitToken : null,
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
LAUNCHER_START_COMMAND,
|
|
86
|
+
config.startCommand,
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
LAUNCHER_INSTALL_COMMAND,
|
|
90
|
+
config.installCommand,
|
|
91
|
+
],
|
|
92
|
+
[
|
|
93
|
+
LAUNCHER_ROOT_DIRECTORY,
|
|
94
|
+
config.rootDirectory,
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
LAUNCHER_DEPLOYMENT_ID,
|
|
98
|
+
config.deployment,
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
LAUNCHER_SNAPSHOT_ID,
|
|
102
|
+
config.snapshot,
|
|
103
|
+
],
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of mapped) {
|
|
107
|
+
if (value !== null) {
|
|
108
|
+
environment[key] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return environment;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const platformEnvironment = (code: string, platform: BotPlatform | null): Record<string, string> => {
|
|
116
|
+
return {
|
|
117
|
+
[SERVERK_BOT]: "1",
|
|
118
|
+
[SERVERK_SERVER_CODE]: code,
|
|
119
|
+
...(platform
|
|
120
|
+
? {
|
|
121
|
+
[BOT_PLATFORM]: platform,
|
|
122
|
+
}
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export interface BotProcessEnvironmentInput {
|
|
128
|
+
tool: Record<string, string>;
|
|
129
|
+
customer: Record<string, string>;
|
|
130
|
+
launcher: Record<string, string>;
|
|
131
|
+
platform: Record<string, string>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const botProcessEnvironment = (input: BotProcessEnvironmentInput): Record<string, string> => {
|
|
135
|
+
return {
|
|
136
|
+
...input.tool,
|
|
137
|
+
...input.customer,
|
|
138
|
+
...input.launcher,
|
|
139
|
+
...input.platform,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
botNameOf,
|
|
4
|
+
IDENTITY_CACHE_FILE,
|
|
5
|
+
identityFromToken,
|
|
6
|
+
inviteLink,
|
|
7
|
+
LOGIN_LINE,
|
|
8
|
+
readIdentityCache,
|
|
9
|
+
resolveBotIdentity,
|
|
10
|
+
watchLoginLine,
|
|
11
|
+
writeIdentityCache,
|
|
12
|
+
} from "./botIdentity";
|
|
13
|
+
import { BotPlatform } from "./detect";
|
|
14
|
+
|
|
15
|
+
const DISCORD_ID = "1136470478027245683";
|
|
16
|
+
|
|
17
|
+
const DISCORD_TOKEN = `${btoa(DISCORD_ID).replaceAll("=", "")}.GxYz12.${"a".repeat(38)}`;
|
|
18
|
+
|
|
19
|
+
const TELEGRAM_TOKEN = "7123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw";
|
|
20
|
+
|
|
21
|
+
const JWT = `${btoa('{"alg":"HS256","typ":"JWT"}').replaceAll("=", "")}.eyJzdWIiOiIxIn0.${"b".repeat(43)}`;
|
|
22
|
+
|
|
23
|
+
const memoryFiles = (initial: Record<string, string> = {}) => {
|
|
24
|
+
const stored = new Map<string, string>(Object.entries(initial));
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
stored,
|
|
28
|
+
files: {
|
|
29
|
+
async exists(path: string) {
|
|
30
|
+
return stored.has(path);
|
|
31
|
+
},
|
|
32
|
+
async read(path: string) {
|
|
33
|
+
return stored.get(path) ?? "";
|
|
34
|
+
},
|
|
35
|
+
async write(path: string, content: string) {
|
|
36
|
+
stored.set(path, content);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe("LOGIN_LINE", () => {
|
|
43
|
+
test("reads the name from the login lines the popular libraries and our templates print", () => {
|
|
44
|
+
const names = [
|
|
45
|
+
"Logged in as MyBot#1234, /ping is registered",
|
|
46
|
+
"Logged in as MyBot (ID: 1136470478027245683)",
|
|
47
|
+
"Logged in as @serverk_test_bot",
|
|
48
|
+
"[INFO] logged in as mybot!",
|
|
49
|
+
].map((line) => botNameOf(LOGIN_LINE.exec(line)?.groups?.name ?? ""));
|
|
50
|
+
|
|
51
|
+
expect(names).toEqual([
|
|
52
|
+
"MyBot#1234",
|
|
53
|
+
"MyBot",
|
|
54
|
+
"serverk_test_bot",
|
|
55
|
+
"mybot",
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("ignores lines that only mention logging", () => {
|
|
60
|
+
expect(LOGIN_LINE.test("serverk: bot process starting")).toBe(false);
|
|
61
|
+
expect(LOGIN_LINE.test("user logged out")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("identityFromToken", () => {
|
|
66
|
+
test("reads a Telegram bot id and a Discord application id without the network", () => {
|
|
67
|
+
expect(identityFromToken(BotPlatform.Telegram, TELEGRAM_TOKEN)).toBe("7123456789");
|
|
68
|
+
expect(identityFromToken(BotPlatform.Discord, DISCORD_TOKEN)).toBe(DISCORD_ID);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("refuses anything that is not shaped like the platform's token", () => {
|
|
72
|
+
expect(identityFromToken(BotPlatform.Discord, JWT)).toBeNull();
|
|
73
|
+
expect(identityFromToken(BotPlatform.Discord, TELEGRAM_TOKEN)).toBeNull();
|
|
74
|
+
expect(identityFromToken(BotPlatform.Telegram, DISCORD_TOKEN)).toBeNull();
|
|
75
|
+
expect(identityFromToken(BotPlatform.Telegram, "")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("the name cache", () => {
|
|
80
|
+
test("keeps only the name the bot printed, per platform", async () => {
|
|
81
|
+
const { files, stored } = memoryFiles();
|
|
82
|
+
|
|
83
|
+
await writeIdentityCache(files, {
|
|
84
|
+
platform: BotPlatform.Telegram,
|
|
85
|
+
name: "serverk_test_bot",
|
|
86
|
+
seenAt: "2026-09-27T10:00:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(JSON.parse(stored.get(IDENTITY_CACHE_FILE) ?? "{}")).toEqual({
|
|
90
|
+
platform: BotPlatform.Telegram,
|
|
91
|
+
name: "serverk_test_bot",
|
|
92
|
+
seenAt: "2026-09-27T10:00:00.000Z",
|
|
93
|
+
});
|
|
94
|
+
expect((await readIdentityCache(files, BotPlatform.Telegram))?.name).toBe("serverk_test_bot");
|
|
95
|
+
expect(await readIdentityCache(files, BotPlatform.Discord)).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("reads nothing from a broken or older cache", async () => {
|
|
99
|
+
expect(
|
|
100
|
+
await readIdentityCache(
|
|
101
|
+
memoryFiles({
|
|
102
|
+
[IDENTITY_CACHE_FILE]: "{",
|
|
103
|
+
}).files,
|
|
104
|
+
BotPlatform.Discord,
|
|
105
|
+
),
|
|
106
|
+
).toBeNull();
|
|
107
|
+
expect(
|
|
108
|
+
await readIdentityCache(
|
|
109
|
+
memoryFiles({
|
|
110
|
+
[IDENTITY_CACHE_FILE]: JSON.stringify({
|
|
111
|
+
key: "abc",
|
|
112
|
+
identity: {},
|
|
113
|
+
}),
|
|
114
|
+
}).files,
|
|
115
|
+
BotPlatform.Discord,
|
|
116
|
+
),
|
|
117
|
+
).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("inviteLink", () => {
|
|
122
|
+
test("invites a Discord bot by id and opens a Telegram bot by name", () => {
|
|
123
|
+
expect(
|
|
124
|
+
inviteLink({
|
|
125
|
+
platform: BotPlatform.Discord,
|
|
126
|
+
id: DISCORD_ID,
|
|
127
|
+
name: null,
|
|
128
|
+
seenAt: null,
|
|
129
|
+
}),
|
|
130
|
+
).toBe(`https://discord.com/oauth2/authorize?client_id=${DISCORD_ID}&scope=bot+applications.commands`);
|
|
131
|
+
expect(
|
|
132
|
+
inviteLink({
|
|
133
|
+
platform: BotPlatform.Telegram,
|
|
134
|
+
id: "7123456789",
|
|
135
|
+
name: "serverk_test_bot",
|
|
136
|
+
seenAt: null,
|
|
137
|
+
}),
|
|
138
|
+
).toBe("https://t.me/serverk_test_bot");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("offers nothing it cannot build", () => {
|
|
142
|
+
expect(
|
|
143
|
+
inviteLink({
|
|
144
|
+
platform: BotPlatform.Discord,
|
|
145
|
+
id: null,
|
|
146
|
+
name: "MyBot",
|
|
147
|
+
seenAt: null,
|
|
148
|
+
}),
|
|
149
|
+
).toBeNull();
|
|
150
|
+
expect(
|
|
151
|
+
inviteLink({
|
|
152
|
+
platform: BotPlatform.Telegram,
|
|
153
|
+
id: "7123456789",
|
|
154
|
+
name: null,
|
|
155
|
+
seenAt: null,
|
|
156
|
+
}),
|
|
157
|
+
).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("watchLoginLine", () => {
|
|
162
|
+
test("remembers the first login name and stops listening", async () => {
|
|
163
|
+
const { files, stored } = memoryFiles();
|
|
164
|
+
const watchers: ((match: RegExpMatchArray) => void)[] = [];
|
|
165
|
+
let unsubscribed = 0;
|
|
166
|
+
|
|
167
|
+
const context = {
|
|
168
|
+
files,
|
|
169
|
+
logs: {
|
|
170
|
+
tail: async () => [],
|
|
171
|
+
watch: async () => null,
|
|
172
|
+
follow(pattern: RegExp, onMatch: (match: RegExpMatchArray) => void) {
|
|
173
|
+
watchers.push((match) => onMatch(match));
|
|
174
|
+
expect(pattern).toBe(LOGIN_LINE);
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
unsubscribed += 1;
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
log: Object.assign(() => {}, {
|
|
182
|
+
warn: () => {},
|
|
183
|
+
error: () => {},
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
watchLoginLine(context, BotPlatform.Discord);
|
|
188
|
+
|
|
189
|
+
const deliver = (line: string) => {
|
|
190
|
+
const match = LOGIN_LINE.exec(line);
|
|
191
|
+
|
|
192
|
+
if (match) {
|
|
193
|
+
for (const watcher of watchers) {
|
|
194
|
+
watcher(match);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
deliver("Logged in as FirstBot#0001");
|
|
200
|
+
deliver("Logged in as SecondBot#0002");
|
|
201
|
+
|
|
202
|
+
await Bun.sleep(10);
|
|
203
|
+
|
|
204
|
+
expect(unsubscribed).toBe(1);
|
|
205
|
+
expect(JSON.parse(stored.get(IDENTITY_CACHE_FILE) ?? "{}")).toMatchObject({
|
|
206
|
+
platform: BotPlatform.Discord,
|
|
207
|
+
name: "FirstBot#0001",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("resolveBotIdentity", () => {
|
|
213
|
+
test("combines the inferred platform, the id from the token and the remembered name", async () => {
|
|
214
|
+
const { files } = memoryFiles({
|
|
215
|
+
[IDENTITY_CACHE_FILE]: JSON.stringify({
|
|
216
|
+
platform: BotPlatform.Discord,
|
|
217
|
+
name: "MyBot#1234",
|
|
218
|
+
seenAt: "2026-09-27T10:00:00.000Z",
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(
|
|
223
|
+
await resolveBotIdentity({
|
|
224
|
+
files,
|
|
225
|
+
variables: () => ({
|
|
226
|
+
GUILD_ID: "1",
|
|
227
|
+
}),
|
|
228
|
+
secrets: () => ({
|
|
229
|
+
DISCORD_TOKEN,
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
).toEqual({
|
|
233
|
+
platform: BotPlatform.Discord,
|
|
234
|
+
id: DISCORD_ID,
|
|
235
|
+
name: "MyBot#1234",
|
|
236
|
+
seenAt: "2026-09-27T10:00:00.000Z",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("knows nothing about a bot with no platform hint", async () => {
|
|
241
|
+
expect(
|
|
242
|
+
await resolveBotIdentity({
|
|
243
|
+
files: memoryFiles().files,
|
|
244
|
+
variables: () => ({}),
|
|
245
|
+
secrets: () => ({
|
|
246
|
+
API_KEY: "sk-live-123",
|
|
247
|
+
}),
|
|
248
|
+
}),
|
|
249
|
+
).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { BridgeContext, BridgeFiles } from "../context";
|
|
3
|
+
import { BOT_STATE_DIR } from "./botState";
|
|
4
|
+
import { BotPlatform, discordApplicationId, findBotToken, inferBotPlatform, telegramBotId } from "./detect";
|
|
5
|
+
|
|
6
|
+
export const IDENTITY_CACHE_FILE = `${BOT_STATE_DIR}/identity.json`;
|
|
7
|
+
|
|
8
|
+
export const LOGIN_LINE = /\blogged in as @?(?<name>[^\s,!()]+)/i;
|
|
9
|
+
|
|
10
|
+
export const LOGIN_WATCH_MS = 600_000;
|
|
11
|
+
|
|
12
|
+
export const BOT_NAME_LENGTH_LIMIT = 64;
|
|
13
|
+
|
|
14
|
+
export interface BotIdentity {
|
|
15
|
+
platform: BotPlatform;
|
|
16
|
+
id: string | null;
|
|
17
|
+
name: string | null;
|
|
18
|
+
seenAt: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const identityCacheSchema = z.object({
|
|
22
|
+
platform: z.enum(BotPlatform),
|
|
23
|
+
name: z.string().min(1).max(BOT_NAME_LENGTH_LIMIT),
|
|
24
|
+
seenAt: z.string(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type BotIdentityCache = z.infer<typeof identityCacheSchema>;
|
|
28
|
+
|
|
29
|
+
const NAME_TRAILER = /[.:;]+$/;
|
|
30
|
+
|
|
31
|
+
export const botNameOf = (raw: string): string | null => {
|
|
32
|
+
const name = raw.replace(NAME_TRAILER, "").slice(0, BOT_NAME_LENGTH_LIMIT);
|
|
33
|
+
|
|
34
|
+
return name.length === 0 ? null : name;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const identityFromToken = (platform: BotPlatform, token: string): string | null => {
|
|
38
|
+
return platform === BotPlatform.Telegram ? telegramBotId(token) : discordApplicationId(token);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const readIdentityCache = async (
|
|
42
|
+
files: Pick<BridgeFiles, "exists" | "read">,
|
|
43
|
+
platform: BotPlatform,
|
|
44
|
+
): Promise<BotIdentityCache | null> => {
|
|
45
|
+
if (!(await files.exists(IDENTITY_CACHE_FILE))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const cached = identityCacheSchema.safeParse(JSON.parse(await files.read(IDENTITY_CACHE_FILE)));
|
|
51
|
+
|
|
52
|
+
return cached.success && cached.data.platform === platform ? cached.data : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const writeIdentityCache = async (files: Pick<BridgeFiles, "write">, cache: BotIdentityCache) => {
|
|
59
|
+
await files.write(IDENTITY_CACHE_FILE, `${JSON.stringify(cache, null, "\t")}\n`);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const inviteLink = (identity: BotIdentity): string | null => {
|
|
63
|
+
if (identity.platform === BotPlatform.Discord) {
|
|
64
|
+
return identity.id
|
|
65
|
+
? `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(identity.id)}&scope=bot+applications.commands`
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return identity.name ? `https://t.me/${encodeURIComponent(identity.name)}` : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface LoginWatchContext extends Pick<BridgeContext, "logs" | "log"> {
|
|
73
|
+
files: Pick<BridgeFiles, "write">;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const watchLoginLine = (context: LoginWatchContext, platform: BotPlatform) => {
|
|
77
|
+
let done = false;
|
|
78
|
+
let unsubscribe = () => {};
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
80
|
+
|
|
81
|
+
const stop = () => {
|
|
82
|
+
done = true;
|
|
83
|
+
unsubscribe();
|
|
84
|
+
|
|
85
|
+
if (timer) {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
unsubscribe = context.logs.follow(LOGIN_LINE, (match) => {
|
|
91
|
+
const name = botNameOf(match.groups?.name ?? "");
|
|
92
|
+
|
|
93
|
+
if (done || !name) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
stop();
|
|
98
|
+
|
|
99
|
+
void writeIdentityCache(context.files, {
|
|
100
|
+
platform,
|
|
101
|
+
name,
|
|
102
|
+
seenAt: new Date().toISOString(),
|
|
103
|
+
}).catch((error: unknown) => {
|
|
104
|
+
context.log.warn("the bot name was not remembered", {
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
timer = setTimeout(stop, LOGIN_WATCH_MS);
|
|
111
|
+
timer.unref?.();
|
|
112
|
+
|
|
113
|
+
return stop;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export interface IdentityContext extends Pick<BridgeContext, "variables" | "secrets"> {
|
|
117
|
+
files: Pick<BridgeFiles, "exists" | "read">;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const resolveBotIdentity = async (context: IdentityContext): Promise<BotIdentity | null> => {
|
|
121
|
+
const maps = {
|
|
122
|
+
variables: context.variables(),
|
|
123
|
+
secrets: context.secrets(),
|
|
124
|
+
};
|
|
125
|
+
const platform = inferBotPlatform(maps);
|
|
126
|
+
|
|
127
|
+
if (!platform) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const token = findBotToken(platform, maps);
|
|
132
|
+
const cache = await readIdentityCache(context.files, platform);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
platform,
|
|
136
|
+
id: token ? identityFromToken(platform, token.value) : null,
|
|
137
|
+
name: cache?.name ?? null,
|
|
138
|
+
seenAt: cache?.seenAt ?? null,
|
|
139
|
+
};
|
|
140
|
+
};
|