feat(tlon): add auto-accept invites for groups and DMs
Add configuration options to automatically accept group and DM invites from specified ships. This enables bot accounts to automatically join groups and accept DM conversations without manual intervention. New config options: - autoAcceptGroupInvites: boolean - auto-accept group invites - autoAcceptDmInvites: boolean - auto-accept DM invites - inviteAllowlist: string[] - only accept from these ships - inviteBlocklist: string[] - never accept from these ships (priority) Implementation: - Subscribe to /v1/foreigns (groups app) for group invite detection - Subscribe to /v3 (chat app) for DM invite detection - Send group-join poke to accept group invites - Send chat-dm-rsvp poke to accept DM invites - Blocklist takes priority over allowlist - Auto-refresh subscriptions after accepting invites Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c8063bdcd8
commit
121a674a54
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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<v
|
||||
}
|
||||
}
|
||||
|
||||
// Track processed invites to avoid duplicates
|
||||
const processedInvites = new Set<string>();
|
||||
const processedDmInvites = new Set<string>();
|
||||
|
||||
// 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<v
|
||||
await subscribeToChannel(channelNest);
|
||||
}
|
||||
|
||||
// Subscribe to foreign groups for invite detection (if auto-accept enabled)
|
||||
await subscribeToForeignGroups();
|
||||
|
||||
// Subscribe to DM invites (if auto-accept enabled)
|
||||
await subscribeToDmInvites();
|
||||
|
||||
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
||||
await api!.connect();
|
||||
runtime.log?.("[tlon] Connected! All subscriptions active");
|
||||
|
||||
@ -12,6 +12,11 @@ export type TlonResolvedAccount = {
|
||||
dmAllowlist: string[];
|
||||
autoDiscoverChannels: boolean | null;
|
||||
showModelSignature: boolean | null;
|
||||
// Auto-accept invites settings
|
||||
autoAcceptGroupInvites: boolean | null;
|
||||
autoAcceptDmInvites: boolean | null;
|
||||
inviteAllowlist: string[];
|
||||
inviteBlocklist: string[];
|
||||
};
|
||||
|
||||
export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount {
|
||||
@ -26,6 +31,10 @@ export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | nul
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
showModelSignature?: boolean;
|
||||
autoAcceptGroupInvites?: boolean;
|
||||
autoAcceptDmInvites?: boolean;
|
||||
inviteAllowlist?: string[];
|
||||
inviteBlocklist?: string[];
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
| 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user