@kodelyth/tlon 2026.5.42 → 2026.6.2
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/klaw.plugin.json +203 -3
- package/package.json +19 -6
- package/api.ts +0 -16
- package/channel-plugin-api.ts +0 -1
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -16
- package/runtime-api.ts +0 -17
- package/setup-api.ts +0 -2
- package/setup-entry.ts +0 -9
- package/src/account-fields.ts +0 -31
- package/src/channel.message-adapter.test.ts +0 -145
- package/src/channel.runtime.ts +0 -259
- package/src/channel.ts +0 -192
- package/src/config-schema.ts +0 -54
- package/src/core.test.ts +0 -298
- package/src/doctor-contract.ts +0 -9
- package/src/doctor.test.ts +0 -46
- package/src/doctor.ts +0 -10
- package/src/logger-runtime.ts +0 -1
- package/src/monitor/approval-runtime.ts +0 -363
- package/src/monitor/approval.test.ts +0 -33
- package/src/monitor/approval.ts +0 -283
- package/src/monitor/authorization.ts +0 -30
- package/src/monitor/cites.ts +0 -54
- package/src/monitor/discovery.ts +0 -68
- package/src/monitor/history.ts +0 -226
- package/src/monitor/index.ts +0 -1523
- package/src/monitor/media.test.ts +0 -80
- package/src/monitor/media.ts +0 -156
- package/src/monitor/processed-messages.test.ts +0 -58
- package/src/monitor/processed-messages.ts +0 -89
- package/src/monitor/settings-helpers.test.ts +0 -113
- package/src/monitor/settings-helpers.ts +0 -158
- package/src/monitor/utils.ts +0 -402
- package/src/runtime.ts +0 -9
- package/src/security.test.ts +0 -658
- package/src/session-route.ts +0 -40
- package/src/settings.ts +0 -391
- package/src/setup-core.ts +0 -231
- package/src/setup-surface.ts +0 -99
- package/src/targets.ts +0 -102
- package/src/tlon-api.test.ts +0 -572
- package/src/tlon-api.ts +0 -389
- package/src/types.ts +0 -160
- package/src/urbit/auth.ssrf.test.ts +0 -45
- package/src/urbit/auth.ts +0 -48
- package/src/urbit/base-url.test.ts +0 -48
- package/src/urbit/base-url.ts +0 -61
- package/src/urbit/channel-ops.test.ts +0 -36
- package/src/urbit/channel-ops.ts +0 -149
- package/src/urbit/context.ts +0 -50
- package/src/urbit/errors.ts +0 -51
- package/src/urbit/fetch.ts +0 -38
- package/src/urbit/foreigns.ts +0 -49
- package/src/urbit/send.test.ts +0 -83
- package/src/urbit/send.ts +0 -228
- package/src/urbit/sse-client.test.ts +0 -234
- package/src/urbit/sse-client.ts +0 -492
- package/src/urbit/story.ts +0 -332
- package/src/urbit/upload.test.ts +0 -155
- package/src/urbit/upload.ts +0 -60
- package/test-api.ts +0 -1
- package/tsconfig.json +0 -16
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
import type { RuntimeEnv } from "klaw/plugin-sdk/runtime";
|
|
2
|
-
import type { PendingApproval, TlonSettingsStore } from "../settings.js";
|
|
3
|
-
import { normalizeShip } from "../targets.js";
|
|
4
|
-
import { sendDm } from "../urbit/send.js";
|
|
5
|
-
import type { UrbitSSEClient } from "../urbit/sse-client.js";
|
|
6
|
-
import {
|
|
7
|
-
findPendingApproval,
|
|
8
|
-
formatApprovalConfirmation,
|
|
9
|
-
formatApprovalRequest,
|
|
10
|
-
formatBlockedList,
|
|
11
|
-
formatPendingList,
|
|
12
|
-
parseAdminCommand,
|
|
13
|
-
parseApprovalResponse,
|
|
14
|
-
removePendingApproval,
|
|
15
|
-
} from "./approval.js";
|
|
16
|
-
|
|
17
|
-
type TlonApprovalApi = Pick<UrbitSSEClient, "poke" | "scry">;
|
|
18
|
-
|
|
19
|
-
type ApprovedMessageProcessor = (approval: PendingApproval) => Promise<void>;
|
|
20
|
-
|
|
21
|
-
export function createTlonApprovalRuntime(params: {
|
|
22
|
-
api: TlonApprovalApi;
|
|
23
|
-
runtime: RuntimeEnv;
|
|
24
|
-
botShipName: string;
|
|
25
|
-
getPendingApprovals: () => PendingApproval[];
|
|
26
|
-
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
|
27
|
-
getCurrentSettings: () => TlonSettingsStore;
|
|
28
|
-
setCurrentSettings: (settings: TlonSettingsStore) => void;
|
|
29
|
-
getEffectiveDmAllowlist: () => string[];
|
|
30
|
-
setEffectiveDmAllowlist: (ships: string[]) => void;
|
|
31
|
-
getEffectiveOwnerShip: () => string | null;
|
|
32
|
-
processApprovedMessage: ApprovedMessageProcessor;
|
|
33
|
-
refreshWatchedChannels: () => Promise<number>;
|
|
34
|
-
}) {
|
|
35
|
-
const {
|
|
36
|
-
api,
|
|
37
|
-
runtime,
|
|
38
|
-
botShipName,
|
|
39
|
-
getPendingApprovals,
|
|
40
|
-
setPendingApprovals,
|
|
41
|
-
getCurrentSettings,
|
|
42
|
-
setCurrentSettings,
|
|
43
|
-
getEffectiveDmAllowlist,
|
|
44
|
-
setEffectiveDmAllowlist,
|
|
45
|
-
getEffectiveOwnerShip,
|
|
46
|
-
processApprovedMessage,
|
|
47
|
-
refreshWatchedChannels,
|
|
48
|
-
} = params;
|
|
49
|
-
|
|
50
|
-
const savePendingApprovals = async (): Promise<void> => {
|
|
51
|
-
try {
|
|
52
|
-
await api.poke({
|
|
53
|
-
app: "settings",
|
|
54
|
-
mark: "settings-event",
|
|
55
|
-
json: {
|
|
56
|
-
"put-entry": {
|
|
57
|
-
desk: "moltbot",
|
|
58
|
-
"bucket-key": "tlon",
|
|
59
|
-
"entry-key": "pendingApprovals",
|
|
60
|
-
value: JSON.stringify(getPendingApprovals()),
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
} catch (err) {
|
|
65
|
-
runtime.error?.(`[tlon] Failed to save pending approvals: ${String(err)}`);
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const addToDmAllowlist = async (ship: string): Promise<void> => {
|
|
70
|
-
const normalizedShip = normalizeShip(ship);
|
|
71
|
-
const nextAllowlist = getEffectiveDmAllowlist().includes(normalizedShip)
|
|
72
|
-
? getEffectiveDmAllowlist()
|
|
73
|
-
: [...getEffectiveDmAllowlist(), normalizedShip];
|
|
74
|
-
setEffectiveDmAllowlist(nextAllowlist);
|
|
75
|
-
try {
|
|
76
|
-
await api.poke({
|
|
77
|
-
app: "settings",
|
|
78
|
-
mark: "settings-event",
|
|
79
|
-
json: {
|
|
80
|
-
"put-entry": {
|
|
81
|
-
desk: "moltbot",
|
|
82
|
-
"bucket-key": "tlon",
|
|
83
|
-
"entry-key": "dmAllowlist",
|
|
84
|
-
value: nextAllowlist,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
runtime.log?.(`[tlon] Added ${normalizedShip} to dmAllowlist`);
|
|
89
|
-
} catch (err) {
|
|
90
|
-
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const addToChannelAllowlist = async (ship: string, channelNest: string): Promise<void> => {
|
|
95
|
-
const normalizedShip = normalizeShip(ship);
|
|
96
|
-
const currentSettings = getCurrentSettings();
|
|
97
|
-
const channelRules = currentSettings.channelRules ?? {};
|
|
98
|
-
const rule = channelRules[channelNest] ?? { mode: "restricted", allowedShips: [] };
|
|
99
|
-
const allowedShips = [...(rule.allowedShips ?? [])];
|
|
100
|
-
|
|
101
|
-
if (!allowedShips.includes(normalizedShip)) {
|
|
102
|
-
allowedShips.push(normalizedShip);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const updatedRules = {
|
|
106
|
-
...channelRules,
|
|
107
|
-
[channelNest]: { ...rule, allowedShips },
|
|
108
|
-
};
|
|
109
|
-
setCurrentSettings({ ...currentSettings, channelRules: updatedRules });
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
await api.poke({
|
|
113
|
-
app: "settings",
|
|
114
|
-
mark: "settings-event",
|
|
115
|
-
json: {
|
|
116
|
-
"put-entry": {
|
|
117
|
-
desk: "moltbot",
|
|
118
|
-
"bucket-key": "tlon",
|
|
119
|
-
"entry-key": "channelRules",
|
|
120
|
-
value: JSON.stringify(updatedRules),
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
runtime.log?.(`[tlon] Added ${normalizedShip} to ${channelNest} allowlist`);
|
|
125
|
-
} catch (err) {
|
|
126
|
-
runtime.error?.(`[tlon] Failed to update channelRules: ${String(err)}`);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const blockShip = async (ship: string): Promise<void> => {
|
|
131
|
-
const normalizedShip = normalizeShip(ship);
|
|
132
|
-
try {
|
|
133
|
-
await api.poke({
|
|
134
|
-
app: "chat",
|
|
135
|
-
mark: "chat-block-ship",
|
|
136
|
-
json: { ship: normalizedShip },
|
|
137
|
-
});
|
|
138
|
-
runtime.log?.(`[tlon] Blocked ship ${normalizedShip}`);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const isShipBlocked = async (ship: string): Promise<boolean> => {
|
|
145
|
-
const normalizedShip = normalizeShip(ship);
|
|
146
|
-
try {
|
|
147
|
-
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
|
|
148
|
-
return (
|
|
149
|
-
Array.isArray(blocked) && blocked.some((item) => normalizeShip(item) === normalizedShip)
|
|
150
|
-
);
|
|
151
|
-
} catch (err) {
|
|
152
|
-
runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const getBlockedShips = async (): Promise<string[]> => {
|
|
158
|
-
try {
|
|
159
|
-
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
|
|
160
|
-
return Array.isArray(blocked) ? blocked : [];
|
|
161
|
-
} catch (err) {
|
|
162
|
-
runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
|
|
163
|
-
return [];
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const unblockShip = async (ship: string): Promise<boolean> => {
|
|
168
|
-
const normalizedShip = normalizeShip(ship);
|
|
169
|
-
try {
|
|
170
|
-
await api.poke({
|
|
171
|
-
app: "chat",
|
|
172
|
-
mark: "chat-unblock-ship",
|
|
173
|
-
json: { ship: normalizedShip },
|
|
174
|
-
});
|
|
175
|
-
runtime.log?.(`[tlon] Unblocked ship ${normalizedShip}`);
|
|
176
|
-
return true;
|
|
177
|
-
} catch (err) {
|
|
178
|
-
runtime.error?.(`[tlon] Failed to unblock ship ${normalizedShip}: ${String(err)}`);
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const sendOwnerNotification = async (message: string): Promise<void> => {
|
|
184
|
-
const ownerShip = getEffectiveOwnerShip();
|
|
185
|
-
if (!ownerShip) {
|
|
186
|
-
runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
try {
|
|
190
|
-
await sendDm({
|
|
191
|
-
api,
|
|
192
|
-
fromShip: botShipName,
|
|
193
|
-
toShip: ownerShip,
|
|
194
|
-
text: message,
|
|
195
|
-
});
|
|
196
|
-
runtime.log?.(`[tlon] Sent notification to owner ${ownerShip}`);
|
|
197
|
-
} catch (err) {
|
|
198
|
-
runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const queueApprovalRequest = async (approval: PendingApproval): Promise<void> => {
|
|
203
|
-
if (await isShipBlocked(approval.requestingShip)) {
|
|
204
|
-
runtime.log?.(`[tlon] Ignoring request from blocked ship ${approval.requestingShip}`);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const approvals = getPendingApprovals();
|
|
209
|
-
const existingIndex = approvals.findIndex(
|
|
210
|
-
(item) =>
|
|
211
|
-
item.type === approval.type &&
|
|
212
|
-
item.requestingShip === approval.requestingShip &&
|
|
213
|
-
(approval.type !== "channel" || item.channelNest === approval.channelNest) &&
|
|
214
|
-
(approval.type !== "group" || item.groupFlag === approval.groupFlag),
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
if (existingIndex !== -1) {
|
|
218
|
-
const existing = approvals[existingIndex];
|
|
219
|
-
if (approval.originalMessage) {
|
|
220
|
-
existing.originalMessage = approval.originalMessage;
|
|
221
|
-
existing.messagePreview = approval.messagePreview;
|
|
222
|
-
}
|
|
223
|
-
runtime.log?.(
|
|
224
|
-
`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`,
|
|
225
|
-
);
|
|
226
|
-
await savePendingApprovals();
|
|
227
|
-
await sendOwnerNotification(formatApprovalRequest(existing));
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
setPendingApprovals([...approvals, approval]);
|
|
232
|
-
await savePendingApprovals();
|
|
233
|
-
await sendOwnerNotification(formatApprovalRequest(approval));
|
|
234
|
-
runtime.log?.(
|
|
235
|
-
`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`,
|
|
236
|
-
);
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const handleApprovalResponse = async (text: string): Promise<boolean> => {
|
|
240
|
-
const parsed = parseApprovalResponse(text);
|
|
241
|
-
if (!parsed) {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const approval = findPendingApproval(getPendingApprovals(), parsed.id);
|
|
246
|
-
if (!approval) {
|
|
247
|
-
await sendOwnerNotification(
|
|
248
|
-
`No pending approval found${parsed.id ? ` for ID: ${parsed.id}` : ""}`,
|
|
249
|
-
);
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (parsed.action === "approve") {
|
|
254
|
-
switch (approval.type) {
|
|
255
|
-
case "dm":
|
|
256
|
-
await addToDmAllowlist(approval.requestingShip);
|
|
257
|
-
if (approval.originalMessage) {
|
|
258
|
-
runtime.log?.(
|
|
259
|
-
`[tlon] Processing original message from ${approval.requestingShip} after approval`,
|
|
260
|
-
);
|
|
261
|
-
await processApprovedMessage(approval);
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
case "channel":
|
|
265
|
-
if (approval.channelNest) {
|
|
266
|
-
await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
|
|
267
|
-
if (approval.originalMessage) {
|
|
268
|
-
runtime.log?.(
|
|
269
|
-
`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`,
|
|
270
|
-
);
|
|
271
|
-
await processApprovedMessage(approval);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
break;
|
|
275
|
-
case "group":
|
|
276
|
-
if (approval.groupFlag) {
|
|
277
|
-
try {
|
|
278
|
-
await api.poke({
|
|
279
|
-
app: "groups",
|
|
280
|
-
mark: "group-join",
|
|
281
|
-
json: {
|
|
282
|
-
flag: approval.groupFlag,
|
|
283
|
-
"join-all": true,
|
|
284
|
-
},
|
|
285
|
-
});
|
|
286
|
-
runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
|
|
287
|
-
setTimeout(() => {
|
|
288
|
-
void (async () => {
|
|
289
|
-
try {
|
|
290
|
-
const newCount = await refreshWatchedChannels();
|
|
291
|
-
if (newCount > 0) {
|
|
292
|
-
runtime.log?.(
|
|
293
|
-
`[tlon] Discovered ${newCount} new channel(s) after joining group`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
runtime.log?.(
|
|
298
|
-
`[tlon] Channel discovery after group join failed: ${String(err)}`,
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
302
|
-
}, 2000);
|
|
303
|
-
} catch (err) {
|
|
304
|
-
runtime.error?.(`[tlon] Failed to join group ${approval.groupFlag}: ${String(err)}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
|
|
311
|
-
} else if (parsed.action === "block") {
|
|
312
|
-
await blockShip(approval.requestingShip);
|
|
313
|
-
await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
|
|
314
|
-
} else {
|
|
315
|
-
await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
setPendingApprovals(removePendingApproval(getPendingApprovals(), approval.id));
|
|
319
|
-
await savePendingApprovals();
|
|
320
|
-
return true;
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
const handleAdminCommand = async (text: string): Promise<boolean> => {
|
|
324
|
-
const command = parseAdminCommand(text);
|
|
325
|
-
if (!command) {
|
|
326
|
-
return false;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
switch (command.type) {
|
|
330
|
-
case "blocked": {
|
|
331
|
-
const blockedShips = await getBlockedShips();
|
|
332
|
-
await sendOwnerNotification(formatBlockedList(blockedShips));
|
|
333
|
-
runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
case "pending":
|
|
337
|
-
await sendOwnerNotification(formatPendingList(getPendingApprovals()));
|
|
338
|
-
runtime.log?.(
|
|
339
|
-
`[tlon] Owner requested pending approvals list (${getPendingApprovals().length} pending)`,
|
|
340
|
-
);
|
|
341
|
-
return true;
|
|
342
|
-
case "unblock": {
|
|
343
|
-
const shipToUnblock = command.ship;
|
|
344
|
-
if (!(await isShipBlocked(shipToUnblock))) {
|
|
345
|
-
await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
const success = await unblockShip(shipToUnblock);
|
|
349
|
-
await sendOwnerNotification(
|
|
350
|
-
success ? `Unblocked ${shipToUnblock}.` : `Failed to unblock ${shipToUnblock}.`,
|
|
351
|
-
);
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
throw new Error("Unsupported Tlon admin command");
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
return {
|
|
359
|
-
queueApprovalRequest,
|
|
360
|
-
handleApprovalResponse,
|
|
361
|
-
handleAdminCommand,
|
|
362
|
-
};
|
|
363
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
const cryptoMocks = vi.hoisted(() => ({
|
|
4
|
-
randomBytes: vi.fn(),
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
vi.mock("node:crypto", () => ({
|
|
8
|
-
randomBytes: cryptoMocks.randomBytes,
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
let generateApprovalId: typeof import("./approval.js").generateApprovalId;
|
|
12
|
-
|
|
13
|
-
beforeAll(async () => {
|
|
14
|
-
({ generateApprovalId } = await import("./approval.js"));
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
cryptoMocks.randomBytes.mockReset();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe("generateApprovalId", () => {
|
|
22
|
-
it("uses secure hex entropy while preserving the ID format", () => {
|
|
23
|
-
cryptoMocks.randomBytes.mockReturnValueOnce(Buffer.from("a1b2c3", "hex"));
|
|
24
|
-
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_717_171_717_171);
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
expect(generateApprovalId("dm")).toBe("dm-1717171717171-a1b2c3");
|
|
28
|
-
expect(cryptoMocks.randomBytes).toHaveBeenCalledWith(3);
|
|
29
|
-
} finally {
|
|
30
|
-
nowSpy.mockRestore();
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
});
|
package/src/monitor/approval.ts
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Approval system for managing DM, channel mention, and group invite approvals.
|
|
3
|
-
*
|
|
4
|
-
* When an unknown ship tries to interact with the bot, the owner receives
|
|
5
|
-
* a notification and can approve or deny the request.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// Extensions cannot import core internals directly, so use node:crypto here.
|
|
9
|
-
import { randomBytes } from "node:crypto";
|
|
10
|
-
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
11
|
-
import type { PendingApproval } from "../settings.js";
|
|
12
|
-
|
|
13
|
-
export type { PendingApproval };
|
|
14
|
-
|
|
15
|
-
export type ApprovalType = "dm" | "channel" | "group";
|
|
16
|
-
|
|
17
|
-
export type CreateApprovalParams = {
|
|
18
|
-
type: ApprovalType;
|
|
19
|
-
requestingShip: string;
|
|
20
|
-
channelNest?: string;
|
|
21
|
-
groupFlag?: string;
|
|
22
|
-
messagePreview?: string;
|
|
23
|
-
originalMessage?: {
|
|
24
|
-
messageId: string;
|
|
25
|
-
messageText: string;
|
|
26
|
-
messageContent: unknown;
|
|
27
|
-
timestamp: number;
|
|
28
|
-
parentId?: string;
|
|
29
|
-
isThreadReply?: boolean;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Generate a unique approval ID in the format: {type}-{timestamp}-{shortHash}
|
|
35
|
-
*/
|
|
36
|
-
export function generateApprovalId(type: ApprovalType): string {
|
|
37
|
-
const timestamp = Date.now();
|
|
38
|
-
const randomPart = randomBytes(3).toString("hex");
|
|
39
|
-
return `${type}-${timestamp}-${randomPart}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Create a pending approval object.
|
|
44
|
-
*/
|
|
45
|
-
export function createPendingApproval(params: CreateApprovalParams): PendingApproval {
|
|
46
|
-
return {
|
|
47
|
-
id: generateApprovalId(params.type),
|
|
48
|
-
type: params.type,
|
|
49
|
-
requestingShip: params.requestingShip,
|
|
50
|
-
channelNest: params.channelNest,
|
|
51
|
-
groupFlag: params.groupFlag,
|
|
52
|
-
messagePreview: params.messagePreview,
|
|
53
|
-
originalMessage: params.originalMessage,
|
|
54
|
-
timestamp: Date.now(),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Truncate text to a maximum length with ellipsis.
|
|
60
|
-
*/
|
|
61
|
-
function truncate(text: string, maxLength: number): string {
|
|
62
|
-
if (text.length <= maxLength) {
|
|
63
|
-
return text;
|
|
64
|
-
}
|
|
65
|
-
return text.slice(0, maxLength - 3) + "...";
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Format a notification message for the owner about a pending approval.
|
|
70
|
-
*/
|
|
71
|
-
export function formatApprovalRequest(approval: PendingApproval): string {
|
|
72
|
-
const preview = approval.messagePreview ? `\n"${truncate(approval.messagePreview, 100)}"` : "";
|
|
73
|
-
|
|
74
|
-
switch (approval.type) {
|
|
75
|
-
case "dm":
|
|
76
|
-
return (
|
|
77
|
-
`New DM request from ${approval.requestingShip}:${preview}\n\n` +
|
|
78
|
-
`Reply "approve", "deny", or "block" (ID: ${approval.id})`
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
case "channel":
|
|
82
|
-
return (
|
|
83
|
-
`${approval.requestingShip} mentioned you in ${approval.channelNest}:${preview}\n\n` +
|
|
84
|
-
`Reply "approve", "deny", or "block"\n` +
|
|
85
|
-
`(ID: ${approval.id})`
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
case "group":
|
|
89
|
-
return (
|
|
90
|
-
`Group invite from ${approval.requestingShip} to join ${approval.groupFlag}\n\n` +
|
|
91
|
-
`Reply "approve", "deny", or "block"\n` +
|
|
92
|
-
`(ID: ${approval.id})`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
throw new Error("Unsupported approval type");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export type ApprovalResponse = {
|
|
99
|
-
action: "approve" | "deny" | "block";
|
|
100
|
-
id?: string;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Parse an owner's response to an approval request.
|
|
105
|
-
* Supports formats:
|
|
106
|
-
* - "approve" / "deny" / "block" (applies to most recent pending)
|
|
107
|
-
* - "approve dm-1234567890-abc" / "deny dm-1234567890-abc" (specific ID)
|
|
108
|
-
* - "block" permanently blocks the ship via Tlon's native blocking
|
|
109
|
-
*/
|
|
110
|
-
export function parseApprovalResponse(text: string): ApprovalResponse | null {
|
|
111
|
-
const trimmed = normalizeLowercaseStringOrEmpty(text);
|
|
112
|
-
|
|
113
|
-
// Match "approve", "deny", or "block" optionally followed by an ID
|
|
114
|
-
const match = trimmed.match(/^(approve|deny|block)(?:\s+(.+))?$/);
|
|
115
|
-
if (!match) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const action = match[1] as "approve" | "deny" | "block";
|
|
120
|
-
const id = match[2]?.trim();
|
|
121
|
-
|
|
122
|
-
return { action, id };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Check if a message text looks like an approval response.
|
|
127
|
-
* Used to determine if we should intercept the message before normal processing.
|
|
128
|
-
*/
|
|
129
|
-
export function isApprovalResponse(text: string): boolean {
|
|
130
|
-
const trimmed = normalizeLowercaseStringOrEmpty(text);
|
|
131
|
-
return trimmed.startsWith("approve") || trimmed.startsWith("deny") || trimmed.startsWith("block");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Find a pending approval by ID, or return the most recent if no ID specified.
|
|
136
|
-
*/
|
|
137
|
-
export function findPendingApproval(
|
|
138
|
-
pendingApprovals: PendingApproval[],
|
|
139
|
-
id?: string,
|
|
140
|
-
): PendingApproval | undefined {
|
|
141
|
-
if (id) {
|
|
142
|
-
return pendingApprovals.find((a) => a.id === id);
|
|
143
|
-
}
|
|
144
|
-
// Return most recent
|
|
145
|
-
return pendingApprovals[pendingApprovals.length - 1];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Check if there's already a pending approval for the same ship/channel/group combo.
|
|
150
|
-
* Used to avoid sending duplicate notifications.
|
|
151
|
-
*/
|
|
152
|
-
export function hasDuplicatePending(
|
|
153
|
-
pendingApprovals: PendingApproval[],
|
|
154
|
-
type: ApprovalType,
|
|
155
|
-
requestingShip: string,
|
|
156
|
-
channelNest?: string,
|
|
157
|
-
groupFlag?: string,
|
|
158
|
-
): boolean {
|
|
159
|
-
return pendingApprovals.some((approval) => {
|
|
160
|
-
if (approval.type !== type || approval.requestingShip !== requestingShip) {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
if (type === "channel" && approval.channelNest !== channelNest) {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
if (type === "group" && approval.groupFlag !== groupFlag) {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Remove a pending approval from the list by ID.
|
|
175
|
-
*/
|
|
176
|
-
export function removePendingApproval(
|
|
177
|
-
pendingApprovals: PendingApproval[],
|
|
178
|
-
id: string,
|
|
179
|
-
): PendingApproval[] {
|
|
180
|
-
return pendingApprovals.filter((a) => a.id !== id);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Format a confirmation message after an approval action.
|
|
185
|
-
*/
|
|
186
|
-
export function formatApprovalConfirmation(
|
|
187
|
-
approval: PendingApproval,
|
|
188
|
-
action: "approve" | "deny" | "block",
|
|
189
|
-
): string {
|
|
190
|
-
if (action === "block") {
|
|
191
|
-
return `Blocked ${approval.requestingShip}. They will no longer be able to contact the bot.`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const actionText = action === "approve" ? "Approved" : "Denied";
|
|
195
|
-
|
|
196
|
-
switch (approval.type) {
|
|
197
|
-
case "dm":
|
|
198
|
-
if (action === "approve") {
|
|
199
|
-
return `${actionText} DM access for ${approval.requestingShip}. They can now message the bot.`;
|
|
200
|
-
}
|
|
201
|
-
return `${actionText} DM request from ${approval.requestingShip}.`;
|
|
202
|
-
|
|
203
|
-
case "channel":
|
|
204
|
-
if (action === "approve") {
|
|
205
|
-
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}. They can now interact in this channel.`;
|
|
206
|
-
}
|
|
207
|
-
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}.`;
|
|
208
|
-
|
|
209
|
-
case "group":
|
|
210
|
-
if (action === "approve") {
|
|
211
|
-
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}. Joining group...`;
|
|
212
|
-
}
|
|
213
|
-
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}.`;
|
|
214
|
-
}
|
|
215
|
-
throw new Error("Unsupported approval type");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ============================================================================
|
|
219
|
-
// Admin Commands
|
|
220
|
-
// ============================================================================
|
|
221
|
-
|
|
222
|
-
export type AdminCommand =
|
|
223
|
-
| { type: "unblock"; ship: string }
|
|
224
|
-
| { type: "blocked" }
|
|
225
|
-
| { type: "pending" };
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Parse an admin command from owner message.
|
|
229
|
-
* Supports:
|
|
230
|
-
* - "unblock ~ship" - unblock a specific ship
|
|
231
|
-
* - "blocked" - list all blocked ships
|
|
232
|
-
* - "pending" - list all pending approvals
|
|
233
|
-
*/
|
|
234
|
-
export function parseAdminCommand(text: string): AdminCommand | null {
|
|
235
|
-
const trimmed = normalizeLowercaseStringOrEmpty(text);
|
|
236
|
-
|
|
237
|
-
// "blocked" - list blocked ships
|
|
238
|
-
if (trimmed === "blocked") {
|
|
239
|
-
return { type: "blocked" };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// "pending" - list pending approvals
|
|
243
|
-
if (trimmed === "pending") {
|
|
244
|
-
return { type: "pending" };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// "unblock ~ship" - unblock a specific ship
|
|
248
|
-
const unblockMatch = trimmed.match(/^unblock\s+(~[\w-]+)$/);
|
|
249
|
-
if (unblockMatch) {
|
|
250
|
-
return { type: "unblock", ship: unblockMatch[1] };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Check if a message text looks like an admin command.
|
|
258
|
-
*/
|
|
259
|
-
export function isAdminCommand(text: string): boolean {
|
|
260
|
-
return parseAdminCommand(text) !== null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Format the list of blocked ships for display to owner.
|
|
265
|
-
*/
|
|
266
|
-
export function formatBlockedList(ships: string[]): string {
|
|
267
|
-
if (ships.length === 0) {
|
|
268
|
-
return "No ships are currently blocked.";
|
|
269
|
-
}
|
|
270
|
-
return `Blocked ships (${ships.length}):\n${ships.map((s) => `• ${s}`).join("\n")}`;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Format the list of pending approvals for display to owner.
|
|
275
|
-
*/
|
|
276
|
-
export function formatPendingList(approvals: PendingApproval[]): string {
|
|
277
|
-
if (approvals.length === 0) {
|
|
278
|
-
return "No pending approval requests.";
|
|
279
|
-
}
|
|
280
|
-
return `Pending approvals (${approvals.length}):\n${approvals
|
|
281
|
-
.map((a) => `• ${a.id}: ${a.type} from ${a.requestingShip}`)
|
|
282
|
-
.join("\n")}`;
|
|
283
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
|
|
2
|
-
import type { TlonSettingsStore } from "../settings.js";
|
|
3
|
-
|
|
4
|
-
type ChannelAuthorization = {
|
|
5
|
-
mode?: "restricted" | "open";
|
|
6
|
-
allowedShips?: string[];
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export function resolveChannelAuthorization(
|
|
10
|
-
cfg: KlawConfig,
|
|
11
|
-
channelNest: string,
|
|
12
|
-
settings?: TlonSettingsStore,
|
|
13
|
-
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
|
14
|
-
const tlonConfig = cfg.channels?.tlon as
|
|
15
|
-
| {
|
|
16
|
-
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
|
|
17
|
-
defaultAuthorizedShips?: string[];
|
|
18
|
-
}
|
|
19
|
-
| undefined;
|
|
20
|
-
|
|
21
|
-
const fileRules = tlonConfig?.authorization?.channelRules ?? {};
|
|
22
|
-
const settingsRules = settings?.channelRules ?? {};
|
|
23
|
-
const rule = settingsRules[channelNest] ?? fileRules[channelNest];
|
|
24
|
-
const defaultShips = settings?.defaultAuthorizedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
mode: rule?.mode ?? "restricted",
|
|
28
|
-
allowedShips: rule?.allowedShips ?? defaultShips,
|
|
29
|
-
};
|
|
30
|
-
}
|