diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 20b5eb4dd..18d2d37ca 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -23,6 +23,11 @@ export const TlonAccountSchema = z.object({ dmAllowlist: z.array(ShipSchema).optional(), autoDiscoverChannels: z.boolean().optional(), showModelSignature: z.boolean().optional(), + // Auto-accept invites settings + autoAcceptGroupInvites: z.boolean().optional(), + autoAcceptDmInvites: z.boolean().optional(), + inviteAllowlist: z.array(ShipSchema).optional(), + inviteBlocklist: z.array(ShipSchema).optional(), }); export const TlonConfigSchema = z.object({ @@ -37,6 +42,11 @@ export const TlonConfigSchema = z.object({ showModelSignature: z.boolean().optional(), authorization: TlonAuthorizationSchema.optional(), defaultAuthorizedShips: z.array(ShipSchema).optional(), + // Auto-accept invites settings + autoAcceptGroupInvites: z.boolean().optional(), + autoAcceptDmInvites: z.boolean().optional(), + inviteAllowlist: z.array(ShipSchema).optional(), + inviteBlocklist: z.array(ShipSchema).optional(), accounts: z.record(z.string(), TlonAccountSchema).optional(), }); diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 26ea1407d..9db5bba46 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -7,7 +7,7 @@ import { resolveTlonAccount } from "../types.js"; import { normalizeShip, parseChannelNest } from "../targets.js"; import { authenticate } from "../urbit/auth.js"; import { UrbitSSEClient } from "../urbit/sse-client.js"; -import { sendDm, sendGroupMessage } from "../urbit/send.js"; +import { sendDm, sendGroupMessage, acceptGroupInvite, acceptDmInvite } from "../urbit/send.js"; import { cacheMessage, getChannelHistory } from "./history.js"; import { createProcessedMessageTracker } from "./processed-messages.js"; import { @@ -423,6 +423,185 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise(); + const processedDmInvites = new Set(); + + // Check if an invite should be auto-accepted based on config + function shouldAutoAcceptInvite(inviterShip: string, isGroupInvite: boolean): boolean { + const autoAcceptEnabled = isGroupInvite ? account.autoAcceptGroupInvites : account.autoAcceptDmInvites; + if (!autoAcceptEnabled) return false; + + const normalizedInviter = normalizeShip(inviterShip); + + // Check blocklist first (blocklist takes priority) + if (account.inviteBlocklist.length > 0) { + const normalizedBlocklist = account.inviteBlocklist.map(normalizeShip); + if (normalizedBlocklist.includes(normalizedInviter)) { + runtime.log?.(`[tlon] Invite from ${inviterShip} blocked by blocklist`); + return false; + } + } + + // If allowlist is set, only accept from those ships + if (account.inviteAllowlist.length > 0) { + const normalizedAllowlist = account.inviteAllowlist.map(normalizeShip); + if (!normalizedAllowlist.includes(normalizedInviter)) { + runtime.log?.(`[tlon] Invite from ${inviterShip} not in allowlist, skipping`); + return false; + } + } + + return true; + } + + // Handle incoming foreign groups updates (includes invites) + async function handleForeignGroupsUpdate(update: any) { + try { + if (!update || typeof update !== "object") return; + + for (const [groupId, foreignData] of Object.entries(update)) { + if (!foreignData || typeof foreignData !== "object") continue; + + const foreign = foreignData as { + invites?: Array<{ from: string; valid: boolean; time?: number }>; + }; + + const validInvites = foreign.invites?.filter((inv) => inv.valid) ?? []; + if (validInvites.length === 0) continue; + + for (const invite of validInvites) { + const inviteKey = `${groupId}:${invite.from}:${invite.time ?? "unknown"}`; + if (processedInvites.has(inviteKey)) continue; + processedInvites.add(inviteKey); + + runtime.log?.(`[tlon] Received group invite: ${groupId} from ${invite.from}`); + + if (shouldAutoAcceptInvite(invite.from, true)) { + try { + runtime.log?.(`[tlon] Auto-accepting invite to ${groupId} from ${invite.from}`); + await acceptGroupInvite(api!, groupId); + runtime.log?.(`[tlon] Successfully joined group ${groupId}`); + + setTimeout(() => { + refreshChannelSubscriptions().catch((error) => { + runtime.error?.(`[tlon] Failed to refresh channels after join: ${error?.message ?? String(error)}`); + }); + }, 2000); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to accept invite to ${groupId}: ${error?.message ?? String(error)}`); + } + } else { + runtime.log?.(`[tlon] Invite to ${groupId} not auto-accepted (auto-accept disabled or filtered)`); + } + } + } + } catch (error: any) { + runtime.error?.(`[tlon] Error handling foreign groups update: ${error?.message ?? String(error)}`); + } + } + + // Subscribe to foreign groups updates to detect invites + let foreignGroupsSubscribed = false; + async function subscribeToForeignGroups() { + if (foreignGroupsSubscribed) return; + if (!account.autoAcceptGroupInvites) { + runtime.log?.("[tlon] Auto-accept group invites disabled, skipping foreign groups subscription"); + return; + } + + try { + await api!.subscribe({ + app: "groups", + path: "/v1/foreigns", + event: handleForeignGroupsUpdate, + err: (error) => { + runtime.error?.(`[tlon] Foreign groups subscription error: ${String(error)}`); + }, + quit: () => { + runtime.log?.("[tlon] Foreign groups subscription ended"); + foreignGroupsSubscribed = false; + }, + }); + foreignGroupsSubscribed = true; + runtime.log?.("[tlon] Subscribed to foreign groups (invite detection enabled)"); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to subscribe to foreign groups: ${error?.message ?? String(error)}`); + } + } + + // Handle incoming DM invite updates from /v3 subscription + async function handleDmInviteUpdate(update: any) { + try { + if (!update || typeof update !== "object") return; + + let pendingShips: string[] = []; + + if (Array.isArray(update)) { + pendingShips = update.filter((item) => typeof item === "string"); + } else if (update.ship && update.pending) { + pendingShips = [update.ship]; + } + + for (const ship of pendingShips) { + const normalizedShip = normalizeShip(ship); + if (processedDmInvites.has(normalizedShip)) continue; + processedDmInvites.add(normalizedShip); + + runtime.log?.(`[tlon] Received DM invite from ${normalizedShip}`); + + if (shouldAutoAcceptInvite(normalizedShip, false)) { + try { + runtime.log?.(`[tlon] Auto-accepting DM invite from ${normalizedShip}`); + await acceptDmInvite(api!, normalizedShip); + runtime.log?.(`[tlon] Successfully accepted DM from ${normalizedShip}`); + + setTimeout(() => { + subscribeToDM(normalizedShip).catch((error) => { + runtime.error?.(`[tlon] Failed to subscribe to new DM: ${error?.message ?? String(error)}`); + }); + }, 1000); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to accept DM invite from ${normalizedShip}: ${error?.message ?? String(error)}`); + } + } else { + runtime.log?.(`[tlon] DM invite from ${normalizedShip} not auto-accepted (auto-accept disabled or filtered)`); + } + } + } catch (error: any) { + runtime.error?.(`[tlon] Error handling DM invite update: ${error?.message ?? String(error)}`); + } + } + + // Subscribe to chat updates for DM invite detection + let dmInvitesSubscribed = false; + async function subscribeToDmInvites() { + if (dmInvitesSubscribed) return; + if (!account.autoAcceptDmInvites) { + runtime.log?.("[tlon] Auto-accept DM invites disabled, skipping DM invite subscription"); + return; + } + + try { + await api!.subscribe({ + app: "chat", + path: "/v3", + event: handleDmInviteUpdate, + err: (error) => { + runtime.error?.(`[tlon] DM invite subscription error: ${String(error)}`); + }, + quit: () => { + runtime.log?.("[tlon] DM invite subscription ended"); + dmInvitesSubscribed = false; + }, + }); + dmInvitesSubscribed = true; + runtime.log?.("[tlon] Subscribed to chat updates (DM invite detection enabled)"); + } catch (error: any) { + runtime.error?.(`[tlon] Failed to subscribe to DM invites: ${error?.message ?? String(error)}`); + } + } + async function refreshChannelSubscriptions() { try { const dmShips = await api!.scry("/chat/dm.json"); @@ -465,6 +644,12 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise>; } | undefined; @@ -43,6 +52,10 @@ export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | nul dmAllowlist: [], autoDiscoverChannels: null, showModelSignature: null, + autoAcceptGroupInvites: null, + autoAcceptDmInvites: null, + inviteAllowlist: [], + inviteBlocklist: [], }; } @@ -58,6 +71,12 @@ export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | nul (account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null; const showModelSignature = (account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null; + const autoAcceptGroupInvites = + (account?.autoAcceptGroupInvites ?? base.autoAcceptGroupInvites ?? null) as boolean | null; + const autoAcceptDmInvites = + (account?.autoAcceptDmInvites ?? base.autoAcceptDmInvites ?? null) as boolean | null; + const inviteAllowlist = (account?.inviteAllowlist ?? base.inviteAllowlist ?? []) as string[]; + const inviteBlocklist = (account?.inviteBlocklist ?? base.inviteBlocklist ?? []) as string[]; const configured = Boolean(ship && url && code); return { @@ -72,6 +91,10 @@ export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | nul dmAllowlist, autoDiscoverChannels, showModelSignature, + autoAcceptGroupInvites, + autoAcceptDmInvites, + inviteAllowlist, + inviteBlocklist, }; } diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index 35f7f2d74..276f2fa3e 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -112,3 +112,38 @@ export function buildMediaText(text: string | undefined, mediaUrl: string | unde if (cleanUrl) return cleanUrl; return cleanText; } + +// Accept a group invite by sending a group-join poke +export async function acceptGroupInvite(api: TlonPokeApi, groupId: string): Promise { + await api.poke({ + app: "groups", + mark: "group-join", + json: { + flag: groupId, + "join-all": true, + }, + }); +} + +// Decline a group invite +export async function declineGroupInvite(api: TlonPokeApi, groupId: string): Promise { + await api.poke({ + app: "groups", + mark: "invite-decline", + json: groupId, + }); +} + +// Accept a DM invite by sending a chat-dm-rsvp poke +export async function acceptDmInvite(api: TlonPokeApi, ship: string, accept: boolean = true): Promise { + // Ship MUST have the ~ prefix for this poke + const shipName = ship.startsWith("~") ? ship : `~${ship}`; + await api.poke({ + app: "chat", + mark: "chat-dm-rsvp", + json: { + ship: shipName, + ok: accept, + }, + }); +}