363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import type { RuntimeEnv } from "../../api.js";
|
|
import type { PendingApproval, TlonSettingsStore } from "../settings.js";
|
|
import { normalizeShip } from "../targets.js";
|
|
import { sendDm } from "../urbit/send.js";
|
|
import type { UrbitSSEClient } from "../urbit/sse-client.js";
|
|
import {
|
|
findPendingApproval,
|
|
formatApprovalConfirmation,
|
|
formatApprovalRequest,
|
|
formatBlockedList,
|
|
formatPendingList,
|
|
parseAdminCommand,
|
|
parseApprovalResponse,
|
|
removePendingApproval,
|
|
} from "./approval.js";
|
|
|
|
type TlonApprovalApi = Pick<UrbitSSEClient, "poke" | "scry">;
|
|
|
|
type ApprovedMessageProcessor = (approval: PendingApproval) => Promise<void>;
|
|
|
|
export function createTlonApprovalRuntime(params: {
|
|
api: TlonApprovalApi;
|
|
runtime: RuntimeEnv;
|
|
botShipName: string;
|
|
getPendingApprovals: () => PendingApproval[];
|
|
setPendingApprovals: (approvals: PendingApproval[]) => void;
|
|
getCurrentSettings: () => TlonSettingsStore;
|
|
setCurrentSettings: (settings: TlonSettingsStore) => void;
|
|
getEffectiveDmAllowlist: () => string[];
|
|
setEffectiveDmAllowlist: (ships: string[]) => void;
|
|
getEffectiveOwnerShip: () => string | null;
|
|
processApprovedMessage: ApprovedMessageProcessor;
|
|
refreshWatchedChannels: () => Promise<number>;
|
|
}) {
|
|
const {
|
|
api,
|
|
runtime,
|
|
botShipName,
|
|
getPendingApprovals,
|
|
setPendingApprovals,
|
|
getCurrentSettings,
|
|
setCurrentSettings,
|
|
getEffectiveDmAllowlist,
|
|
setEffectiveDmAllowlist,
|
|
getEffectiveOwnerShip,
|
|
processApprovedMessage,
|
|
refreshWatchedChannels,
|
|
} = params;
|
|
|
|
const savePendingApprovals = async (): Promise<void> => {
|
|
try {
|
|
await api.poke({
|
|
app: "settings",
|
|
mark: "settings-event",
|
|
json: {
|
|
"put-entry": {
|
|
desk: "moltbot",
|
|
"bucket-key": "tlon",
|
|
"entry-key": "pendingApprovals",
|
|
value: JSON.stringify(getPendingApprovals()),
|
|
},
|
|
},
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to save pending approvals: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const addToDmAllowlist = async (ship: string): Promise<void> => {
|
|
const normalizedShip = normalizeShip(ship);
|
|
const nextAllowlist = getEffectiveDmAllowlist().includes(normalizedShip)
|
|
? getEffectiveDmAllowlist()
|
|
: [...getEffectiveDmAllowlist(), normalizedShip];
|
|
setEffectiveDmAllowlist(nextAllowlist);
|
|
try {
|
|
await api.poke({
|
|
app: "settings",
|
|
mark: "settings-event",
|
|
json: {
|
|
"put-entry": {
|
|
desk: "moltbot",
|
|
"bucket-key": "tlon",
|
|
"entry-key": "dmAllowlist",
|
|
value: nextAllowlist,
|
|
},
|
|
},
|
|
});
|
|
runtime.log?.(`[tlon] Added ${normalizedShip} to dmAllowlist`);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to update dmAllowlist: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const addToChannelAllowlist = async (ship: string, channelNest: string): Promise<void> => {
|
|
const normalizedShip = normalizeShip(ship);
|
|
const currentSettings = getCurrentSettings();
|
|
const channelRules = currentSettings.channelRules ?? {};
|
|
const rule = channelRules[channelNest] ?? { mode: "restricted", allowedShips: [] };
|
|
const allowedShips = [...(rule.allowedShips ?? [])];
|
|
|
|
if (!allowedShips.includes(normalizedShip)) {
|
|
allowedShips.push(normalizedShip);
|
|
}
|
|
|
|
const updatedRules = {
|
|
...channelRules,
|
|
[channelNest]: { ...rule, allowedShips },
|
|
};
|
|
setCurrentSettings({ ...currentSettings, channelRules: updatedRules });
|
|
|
|
try {
|
|
await api.poke({
|
|
app: "settings",
|
|
mark: "settings-event",
|
|
json: {
|
|
"put-entry": {
|
|
desk: "moltbot",
|
|
"bucket-key": "tlon",
|
|
"entry-key": "channelRules",
|
|
value: JSON.stringify(updatedRules),
|
|
},
|
|
},
|
|
});
|
|
runtime.log?.(`[tlon] Added ${normalizedShip} to ${channelNest} allowlist`);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to update channelRules: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const blockShip = async (ship: string): Promise<void> => {
|
|
const normalizedShip = normalizeShip(ship);
|
|
try {
|
|
await api.poke({
|
|
app: "chat",
|
|
mark: "chat-block-ship",
|
|
json: { ship: normalizedShip },
|
|
});
|
|
runtime.log?.(`[tlon] Blocked ship ${normalizedShip}`);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to block ship ${normalizedShip}: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const isShipBlocked = async (ship: string): Promise<boolean> => {
|
|
const normalizedShip = normalizeShip(ship);
|
|
try {
|
|
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
|
|
return (
|
|
Array.isArray(blocked) && blocked.some((item) => normalizeShip(item) === normalizedShip)
|
|
);
|
|
} catch (err) {
|
|
runtime.log?.(`[tlon] Failed to check blocked list: ${String(err)}`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const getBlockedShips = async (): Promise<string[]> => {
|
|
try {
|
|
const blocked = (await api.scry("/chat/blocked.json")) as string[] | undefined;
|
|
return Array.isArray(blocked) ? blocked : [];
|
|
} catch (err) {
|
|
runtime.log?.(`[tlon] Failed to get blocked list: ${String(err)}`);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const unblockShip = async (ship: string): Promise<boolean> => {
|
|
const normalizedShip = normalizeShip(ship);
|
|
try {
|
|
await api.poke({
|
|
app: "chat",
|
|
mark: "chat-unblock-ship",
|
|
json: { ship: normalizedShip },
|
|
});
|
|
runtime.log?.(`[tlon] Unblocked ship ${normalizedShip}`);
|
|
return true;
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to unblock ship ${normalizedShip}: ${String(err)}`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const sendOwnerNotification = async (message: string): Promise<void> => {
|
|
const ownerShip = getEffectiveOwnerShip();
|
|
if (!ownerShip) {
|
|
runtime.log?.("[tlon] No ownerShip configured, cannot send notification");
|
|
return;
|
|
}
|
|
try {
|
|
await sendDm({
|
|
api,
|
|
fromShip: botShipName,
|
|
toShip: ownerShip,
|
|
text: message,
|
|
});
|
|
runtime.log?.(`[tlon] Sent notification to owner ${ownerShip}`);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to send notification to owner: ${String(err)}`);
|
|
}
|
|
};
|
|
|
|
const queueApprovalRequest = async (approval: PendingApproval): Promise<void> => {
|
|
if (await isShipBlocked(approval.requestingShip)) {
|
|
runtime.log?.(`[tlon] Ignoring request from blocked ship ${approval.requestingShip}`);
|
|
return;
|
|
}
|
|
|
|
const approvals = getPendingApprovals();
|
|
const existingIndex = approvals.findIndex(
|
|
(item) =>
|
|
item.type === approval.type &&
|
|
item.requestingShip === approval.requestingShip &&
|
|
(approval.type !== "channel" || item.channelNest === approval.channelNest) &&
|
|
(approval.type !== "group" || item.groupFlag === approval.groupFlag),
|
|
);
|
|
|
|
if (existingIndex !== -1) {
|
|
const existing = approvals[existingIndex];
|
|
if (approval.originalMessage) {
|
|
existing.originalMessage = approval.originalMessage;
|
|
existing.messagePreview = approval.messagePreview;
|
|
}
|
|
runtime.log?.(
|
|
`[tlon] Updated existing approval for ${approval.requestingShip} (${approval.type}) - re-sending notification`,
|
|
);
|
|
await savePendingApprovals();
|
|
await sendOwnerNotification(formatApprovalRequest(existing));
|
|
return;
|
|
}
|
|
|
|
setPendingApprovals([...approvals, approval]);
|
|
await savePendingApprovals();
|
|
await sendOwnerNotification(formatApprovalRequest(approval));
|
|
runtime.log?.(
|
|
`[tlon] Queued approval request: ${approval.id} (${approval.type} from ${approval.requestingShip})`,
|
|
);
|
|
};
|
|
|
|
const handleApprovalResponse = async (text: string): Promise<boolean> => {
|
|
const parsed = parseApprovalResponse(text);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
|
|
const approval = findPendingApproval(getPendingApprovals(), parsed.id);
|
|
if (!approval) {
|
|
await sendOwnerNotification(
|
|
`No pending approval found${parsed.id ? ` for ID: ${parsed.id}` : ""}`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (parsed.action === "approve") {
|
|
switch (approval.type) {
|
|
case "dm":
|
|
await addToDmAllowlist(approval.requestingShip);
|
|
if (approval.originalMessage) {
|
|
runtime.log?.(
|
|
`[tlon] Processing original message from ${approval.requestingShip} after approval`,
|
|
);
|
|
await processApprovedMessage(approval);
|
|
}
|
|
break;
|
|
case "channel":
|
|
if (approval.channelNest) {
|
|
await addToChannelAllowlist(approval.requestingShip, approval.channelNest);
|
|
if (approval.originalMessage) {
|
|
runtime.log?.(
|
|
`[tlon] Processing original message from ${approval.requestingShip} in ${approval.channelNest} after approval`,
|
|
);
|
|
await processApprovedMessage(approval);
|
|
}
|
|
}
|
|
break;
|
|
case "group":
|
|
if (approval.groupFlag) {
|
|
try {
|
|
await api.poke({
|
|
app: "groups",
|
|
mark: "group-join",
|
|
json: {
|
|
flag: approval.groupFlag,
|
|
"join-all": true,
|
|
},
|
|
});
|
|
runtime.log?.(`[tlon] Joined group ${approval.groupFlag} after approval`);
|
|
setTimeout(() => {
|
|
void (async () => {
|
|
try {
|
|
const newCount = await refreshWatchedChannels();
|
|
if (newCount > 0) {
|
|
runtime.log?.(
|
|
`[tlon] Discovered ${newCount} new channel(s) after joining group`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
runtime.log?.(
|
|
`[tlon] Channel discovery after group join failed: ${String(err)}`,
|
|
);
|
|
}
|
|
})();
|
|
}, 2000);
|
|
} catch (err) {
|
|
runtime.error?.(`[tlon] Failed to join group ${approval.groupFlag}: ${String(err)}`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
await sendOwnerNotification(formatApprovalConfirmation(approval, "approve"));
|
|
} else if (parsed.action === "block") {
|
|
await blockShip(approval.requestingShip);
|
|
await sendOwnerNotification(formatApprovalConfirmation(approval, "block"));
|
|
} else {
|
|
await sendOwnerNotification(formatApprovalConfirmation(approval, "deny"));
|
|
}
|
|
|
|
setPendingApprovals(removePendingApproval(getPendingApprovals(), approval.id));
|
|
await savePendingApprovals();
|
|
return true;
|
|
};
|
|
|
|
const handleAdminCommand = async (text: string): Promise<boolean> => {
|
|
const command = parseAdminCommand(text);
|
|
if (!command) {
|
|
return false;
|
|
}
|
|
|
|
switch (command.type) {
|
|
case "blocked": {
|
|
const blockedShips = await getBlockedShips();
|
|
await sendOwnerNotification(formatBlockedList(blockedShips));
|
|
runtime.log?.(`[tlon] Owner requested blocked ships list (${blockedShips.length} ships)`);
|
|
return true;
|
|
}
|
|
case "pending":
|
|
await sendOwnerNotification(formatPendingList(getPendingApprovals()));
|
|
runtime.log?.(
|
|
`[tlon] Owner requested pending approvals list (${getPendingApprovals().length} pending)`,
|
|
);
|
|
return true;
|
|
case "unblock": {
|
|
const shipToUnblock = command.ship;
|
|
if (!(await isShipBlocked(shipToUnblock))) {
|
|
await sendOwnerNotification(`${shipToUnblock} is not blocked.`);
|
|
return true;
|
|
}
|
|
const success = await unblockShip(shipToUnblock);
|
|
await sendOwnerNotification(
|
|
success ? `Unblocked ${shipToUnblock}.` : `Failed to unblock ${shipToUnblock}.`,
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
};
|
|
|
|
return {
|
|
queueApprovalRequest,
|
|
handleApprovalResponse,
|
|
handleAdminCommand,
|
|
};
|
|
}
|