UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

907 lines (906 loc) • 32 kB
import { o as asRecord } from "./record-coerce-TIsquoVR.mjs"; import { r as createRuntimeConfigReader } from "./runtime-snapshot-De_QxZI9.mjs"; import { F as bindIngressLifecycleToReplyOptions, P as createChannelIngressError } from "./channel-outbound-2iQdEUqa.mjs"; import "./string-coerce-runtime-BbIJhy7G.mjs"; import { o as resolveAgentRoute } from "./resolve-route-DeZAxBYa.mjs"; import "./routing-DvCc0aVb.mjs"; import { n as createChannelIngressMonitor } from "./ingress-monitor-vYTUA99O.mjs"; import "./runtime-config-snapshot--IkldAgP.mjs"; import { i as resolveXAccount } from "./accounts-DgZqMZQn.mjs"; import { i as openXAllowlist, t as XAllowlistChangedError } from "./allowlist-ZXKTf1Qk.mjs"; import { a as XBudgetExceededError, i as parseXPostEnvelope, n as getXTokenState, r as XApiError, s as xActivityCost, t as getXApi } from "./client-DleoFeSj.mjs"; import { t as getXRuntime } from "./runtime-DJewOfvF.mjs"; import { n as resolveXGuestSettings, r as resolveXGuestToolPolicy, t as formatXSenderLine } from "./guest-policy-CztQ6gM5.mjs"; import { n as XGuestUsageUnavailableError, r as openXGuestUsage } from "./guest-usage-AjpeBr4l.mjs"; import { n as resolveXGuestContainmentError, t as getXGuestStatus } from "./guests-C4Ujua48.mjs"; import { a as resolveXRecipient, i as xMentionFacts, r as resolveXIngress, t as sendXDelivery } from "./send-BbiaVhJp.mjs"; import { nextTick } from "node:process"; //#region extensions/x/src/events.ts function laterId(a, b) { return !a || BigInt(b) > BigInt(a) ? b : a; } async function pollXMentions(options) { const cursor = await options.getCursor(); const sinceId = cursor.sinceId; let backfill = cursor.backfill; if (backfill && backfill.sinceId !== sinceId) { backfill = void 0; await options.setCursor({ sinceId }); } let newestId = backfill?.newestId ?? sinceId; let paginationToken = backfill?.paginationToken; let retriedContinuation = false; let continuationInvalid = false; let pageError; const posts = []; const seenTokens = new Set(paginationToken ? [paginationToken] : []); for (;;) { options.signal.throwIfAborted(); let page; try { page = await options.api.getMentions({ userId: options.userId, sinceId, paginationToken, signal: options.signal }); } catch (error) { options.signal.throwIfAborted(); if (paginationToken && error instanceof XApiError && error.status === 400 && !retriedContinuation) { await options.setCursor({ sinceId }); backfill = void 0; paginationToken = void 0; seenTokens.clear(); retriedContinuation = true; continue; } pageError = error instanceof Error ? error : new Error("X mentions request failed", { cause: error }); continuationInvalid = Boolean(paginationToken) && error instanceof XApiError && error.status === 400; break; } posts.push(...page.data.map((post) => ({ post, users: page.includes.users }))); paginationToken = page.meta.next_token; if (paginationToken && seenTokens.has(paginationToken)) { pageError = /* @__PURE__ */ new Error("X mentions pagination repeated a token"); continuationInvalid = true; break; } if (paginationToken) seenTokens.add(paginationToken); else break; } if (continuationInvalid) { await options.setCursor({ sinceId }); paginationToken = void 0; } posts.sort((a, b) => BigInt(a.post.id) < BigInt(b.post.id) ? -1 : BigInt(a.post.id) > BigInt(b.post.id) ? 1 : 0); for (const envelope of posts) { options.signal.throwIfAborted(); await options.onPost(envelope); newestId = laterId(newestId, envelope.post.id); options.onStatus?.({ lastEventAt: Date.now() }); } if (pageError) { if (paginationToken) await options.setCursor({ sinceId, backfill: { sinceId, paginationToken, newestId } }); throw pageError; } if (backfill || newestId !== sinceId) { await options.setCursor({ sinceId: newestId }); if (newestId) options.onStatus?.({ cursor: newestId }); } } function wait(ms, signal) { return new Promise((resolve, reject) => { signal.throwIfAborted(); const abort = () => { clearTimeout(timer); reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("X event source aborted")); }; const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms); signal.addEventListener("abort", abort, { once: true }); }); } async function waitForXBudgetReset(until, signal) { while (Date.now() < until) await wait(Math.min(until - Date.now(), 2147483647), signal); signal.throwIfAborted(); } function eventEnvelope(value) { const event = asRecord(asRecord(value).data); if (event.event_type !== "post.mention.create") return; return parseXPostEnvelope({ post: event.payload, users: asRecord(event.includes).users }); } async function receiveStream(options, backfillMs) { const controller = new AbortController(); const signal = AbortSignal.any([options.signal, controller.signal]); let budgetPaused = false; let drainRequested = false; let closed = false; let idleTimer; let reader; let backfill; let receiver; let receiveDone = false; let receiveError; let wakeReader; let wakeConsumer; const charges = /* @__PURE__ */ new Set(); const batches = []; const cancelOnAbort = () => { reader?.cancel().catch(() => {}); }; const requestDrain = () => { drainRequested = true; wakeReader?.(); }; const pause = (until) => { budgetPaused = true; options.onBudgetPause(until); requestDrain(); }; const deferFailure = (error) => { receiveError ??= error instanceof Error ? error : new Error("X activity stream failed", { cause: error }); requestDrain(); }; const fail = (error) => { deferFailure(error); controller.abort(receiveError); }; const checkBudget = async () => { const until = await options.api.spend.streamResumeAt(); if (!closed && until !== void 0) pause(until); }; const unsubscribe = options.api.spend.subscribe(() => { checkBudget().catch((error) => controller.abort(error)); }); const armIdleTimer = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("X activity stream idle timeout")), 25e3); }; const receiveBatches = async (source) => { const decoder = new TextDecoder(); let pending = ""; try { for (;;) { options.signal.throwIfAborted(); const read = source.read(); let ready = false; read.then(() => { ready = true; }, () => { ready = true; }); if (!drainRequested) { armIdleTimer(); try { await new Promise((resolve) => { wakeReader = resolve; read.then(() => resolve(), () => resolve()); }); } finally { wakeReader = void 0; clearTimeout(idleTimer); } } if (drainRequested && !ready) { await new Promise((resolve) => { nextTick(resolve); }); if (!ready) { controller.abort(); options.onStatus?.({ streamConnected: false }); } } const result = await read; options.signal.throwIfAborted(); if (result.done) { if (drainRequested) return; throw new Error("X activity stream disconnected"); } pending += decoder.decode(result.value, { stream: true }); if (pending.length > 1048576) throw new Error("X activity stream event exceeded 1 MiB"); const events = []; let boundary; while ((boundary = pending.indexOf("\n")) !== -1) { const line = pending.slice(0, boundary).trim(); pending = pending.slice(boundary + 1); if (!line) continue; try { events.push(JSON.parse(line)); } catch { events.push(void 0); } } if (!events.length) continue; const cost = events.reduce((total, event) => total + xActivityCost(event), 0); const charged = cost ? options.api.spend.charge(cost) : Promise.resolve(); charges.add(charged); charged.then(() => { charges.delete(charged); }, (error) => { charges.delete(charged); fail(error); }); batches.push({ events, charged }); wakeConsumer?.(); } } catch (error) { if (!(drainRequested && controller.signal.aborted && !options.signal.aborted)) fail(error); } finally { receiveDone = true; wakeConsumer?.(); clearTimeout(idleTimer); } }; try { await checkBudget(); if (budgetPaused) return; armIdleTimer(); const response = await options.api.openActivityStream(signal); if (!response.body) throw new Error("X activity stream has no response body"); reader = response.body.getReader(); signal.addEventListener("abort", cancelOnAbort, { once: true }); if (signal.aborted) cancelOnAbort(); clearTimeout(idleTimer); options.onStatus?.({ eventMode: "stream", streamConnected: true, streamBackoffMs: 0, message: "activity stream connected" }); receiver = receiveBatches(reader); let recipientChecksDeferred = false; try { await pollXMentions(options); } catch (error) { options.signal.throwIfAborted(); if (error instanceof XBudgetExceededError) pause(error.exhaustedUntil); else deferFailure(error); } backfill = (async () => { while (!signal.aborted) { if (drainRequested) return; await wait(backfillMs, signal); if (signal.aborted || drainRequested) return; try { await pollXMentions(options); } catch (error) { options.signal.throwIfAborted(); if (error instanceof XBudgetExceededError) { pause(error.exhaustedUntil); return; } if (!signal.aborted) options.onStatus?.({ message: "mentions backfill failed; retrying after the backfill interval" }); } } })().catch((error) => { if (!signal.aborted) fail(error); }); let unparsed = 0; for (;;) { options.signal.throwIfAborted(); const batch = batches.shift(); if (!batch) { if (receiveDone) break; await new Promise((resolve) => { wakeConsumer = resolve; }); wakeConsumer = void 0; continue; } await batch.charged; for (const value of batch.events) { options.signal.throwIfAborted(); const envelope = eventEnvelope(value); if (!envelope) { const event = asRecord(value); const type = asRecord(event.data).event_type; if (typeof type === "string" && type !== "post.mention.create") continue; if (++unparsed === 3) { const message = `X activity stream: 3 consecutive events could not be parsed; type=${JSON.stringify(typeof type === "string" ? type.slice(0, 96) : "unknown")} keys=${JSON.stringify(Object.keys(event).slice(0, 20).map((key) => key.slice(0, 64)))}`; options.onWarning?.(message); options.onStatus?.({ message }); } continue; } if (unparsed >= 3 && !signal.aborted) options.onStatus?.({ message: "activity stream connected" }); unparsed = 0; if (envelope.post.author_id === options.userId) continue; let addressed; if (recipientChecksDeferred) addressed = { ...envelope, recipientPending: true }; else try { addressed = await resolveXRecipient({ api: options.api, post: envelope.post, users: envelope.users, userId: options.userId, signal: options.signal }); } catch (error) { options.signal.throwIfAborted(); if (!(error instanceof XBudgetExceededError)) { recipientChecksDeferred = true; deferFailure(error); } addressed = { ...envelope, recipientPending: true }; } if (!addressed) continue; await options.onPost(addressed); options.onStatus?.({ lastEventAt: Date.now() }); } } if (receiveError) throw receiveError; } finally { closed = true; unsubscribe(); clearTimeout(idleTimer); controller.abort(); await backfill; signal.removeEventListener("abort", cancelOnAbort); await reader?.cancel().catch(() => {}); await receiver; await Promise.allSettled(charges); reader?.releaseLock(); options.onStatus?.({ streamConnected: false, ...budgetPaused ? {} : { message: "activity stream disconnected" } }); } } async function runXEvents(options) { let stream = options.mode !== "poll" && options.bearerConfigured; let streamPausedUntil = 0; const pauseStream = (until) => { streamPausedUntil = Math.max(streamPausedUntil, until); }; let backoffMs = 1e3; let pollMessage = "mentions polling"; const fallback = (error) => { stream = false; pollMessage = `${error instanceof Error ? error.message : "X Activity API request failed"}; polling`; options.onStatus?.({ eventMode: "poll", streamConnected: false, streamBackoffMs: 0, message: pollMessage }); }; if (stream) try { await options.api.ensureActivitySubscriptions(options.userId, options.signal); } catch (error) { options.signal.throwIfAborted(); if (options.mode !== "stream" || error instanceof XApiError && error.status === 403) fallback(error); else throw error; } try { while (!options.signal.aborted) { if (stream && Date.now() >= streamPausedUntil) streamPausedUntil = await options.api.spend.streamResumeAt() ?? 0; if (!stream || Date.now() < streamPausedUntil) { options.onStatus?.({ eventMode: "poll", streamConnected: false, ...streamPausedUntil > Date.now() ? { message: `activity stream paused below $0.50 budget headroom; polling until ${new Date(streamPausedUntil).toISOString()}` } : { message: pollMessage } }); try { await pollXMentions(options); } catch (error) { options.signal.throwIfAborted(); if (error instanceof XBudgetExceededError) { options.onStatus?.({ message: error.message, spend: await options.api.spend.status() }); await waitForXBudgetReset(error.exhaustedUntil, options.signal); continue; } options.onStatus?.({ message: "mentions poll failed; retrying after the poll interval" }); } const pollWait = Math.max(15, options.pollSeconds ?? 60) * 1e3; await wait(streamPausedUntil > Date.now() ? Math.min(pollWait, streamPausedUntil - Date.now()) : pollWait, options.signal); continue; } try { await receiveStream({ ...options, onBudgetPause: pauseStream }, Math.max(60, (options.pollSeconds ?? 60) * 4) * 1e3); } catch (error) { options.signal.throwIfAborted(); if (error instanceof XApiError && (error.status === 401 || error.status === 403)) { fallback(error); continue; } options.onStatus?.({ streamConnected: false, streamBackoffMs: backoffMs, message: "activity stream reconnect backoff" }); await wait(backoffMs, options.signal); backoffMs = Math.min(backoffMs * 2, 6e4); } } } catch (error) { if (!options.signal.aborted) throw error; } } //#endregion //#region extensions/x/src/public-thread.ts /** Never infer audience from a permalink, mention feed, or user-context API success. */ async function verifyPublicXThread(api, posts, signal, rootId) { if (!posts.some((post) => post.id === rootId)) return false; try { const page = await api.getPublicPosts(posts.map((post) => post.id), signal); signal.throwIfAborted(); const publicPosts = new Map(page.data.map((post) => [post.id, post])); const authors = new Map(page.includes.users.map((user) => [user.id, user])); return posts.every((post) => { const publicPost = publicPosts.get(post.id); return publicPost !== void 0 && publicPost.text === post.text && publicPost.author_id === post.author_id && publicPost.conversation_id === post.conversation_id && !publicPost.withheld && !post.withheld && authors.get(post.author_id)?.protected === false; }); } catch { signal.throwIfAborted(); return false; } } //#endregion //#region extensions/x/src/thread.ts function comparePosts(a, b) { return BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0; } async function assembleXThread(options) { const maxPosts = Math.max(2, Math.floor(options.maxPosts ?? 50)); const posts = /* @__PURE__ */ new Map([[options.mention.id, options.mention]]); const users = new Map((options.users ?? []).map((user) => [user.id, user])); const fetched = /* @__PURE__ */ new Set(); const merge = (page) => { for (const post of [...page.includes.tweets, ...page.data]) posts.set(post.id, post); for (const user of page.includes.users) users.set(user.id, user); }; async function fetchPosts(ids) { const pending = [...new Set(ids)].filter((id) => !fetched.has(id) && !posts.has(id)); for (let offset = 0; offset < pending.length; offset += 100) { const batch = pending.slice(offset, offset + 100); batch.forEach((id) => fetched.add(id)); merge(await options.api.getPosts(batch, options.signal)); } } let budgetTruncated = false; try { let nextToken; const seenTokens = /* @__PURE__ */ new Set(); do { const page = await options.api.searchConversation({ conversationId: options.mention.conversation_id, maxPosts: maxPosts - posts.size, nextToken, signal: options.signal }); merge(page); nextToken = page.meta.next_token; if (nextToken && seenTokens.has(nextToken)) throw new Error("X thread search pagination repeated a token"); if (nextToken) seenTokens.add(nextToken); } while (nextToken && posts.size < maxPosts); if (!users.has(options.mention.author_id)) merge(await options.api.getPosts([options.mention.id], options.signal)); await fetchPosts([options.mention.conversation_id]); let ancestor = options.mention; for (let depth = 0; ancestor && depth < maxPosts; depth++) { const parentId = ancestor.referenced_tweets?.find((reference) => reference.type === "replied_to")?.id; if (!parentId || parentId === ancestor.id) break; await fetchPosts([parentId]); ancestor = posts.get(parentId); } await fetchPosts([...posts.values()].flatMap((post) => post.referenced_tweets?.filter((reference) => reference.type === "quoted").map((reference) => reference.id) ?? [])); } catch (error) { if (!(error instanceof XBudgetExceededError)) throw error; budgetTruncated = true; } const root = posts.get(options.mention.conversation_id); const retained = /* @__PURE__ */ new Map(); if (root) retained.set(root.id, root); retained.set(options.mention.id, options.mention); for (const post of [...posts.values()].toSorted(comparePosts).toReversed()) { if (retained.size >= maxPosts) break; retained.set(post.id, post); } const selected = [...retained.values()].toSorted(comparePosts); const handle = (post) => `@${users.get(post.author_id)?.username ?? post.author_id}`; const quotedIds = new Set([...posts.values()].flatMap((post) => post.referenced_tweets?.filter((reference) => reference.type === "quoted").map((reference) => reference.id) ?? [])); const lines = selected.map((post) => { const marker = post.id === options.mention.id ? " [triggering mention]" : quotedIds.has(post.id) ? " [quoted post]" : ""; return `${handle(post)} (${post.created_at ?? "time unavailable"})${marker}: ${post.text}`; }); const labelPost = root ?? selected[0] ?? options.mention; return { bodyForAgent: `X thread context (oldest to newest):\n${lines.map((line) => line.split(/\r\n|[\r\n\u2028\u2029]/).map((part) => `> ${part}`).join("\n")).join("\n")}${budgetTruncated ? "\n[thread context truncated by budget]" : ""}\n\nReply to the triggering mention.`, label: `${handle(labelPost)}: ${labelPost.text.replace(/\s+/g, " ").slice(0, 80)}`, posts: selected, users: [...users.values()] }; } //#endregion //#region extensions/x/src/monitor.ts const InvalidXEvent = createChannelIngressError("InvalidXEvent"); async function startXAccount(ctx) { const core = getXRuntime(); const account = ctx.account; const readConfig = createRuntimeConfigReader(ctx.cfg); const api = await getXApi(account.accountId, ctx.cfg); const log = core.logging.getChildLogger({ channel: "x", accountId: account.accountId }); const stats = { droppedMentions: 0 }; const guestUsage = openXGuestUsage(core); const publish = (next = {}) => { if (next.message && next.message !== stats.message && !next.message.startsWith("X API ")) log.info(next.message); Object.assign(stats, next); ctx.setStatus({ ...ctx.getStatus(), ...stats, ...stats.guests ? { guests: { ...stats.guests, enabled: resolveXGuestSettings(resolveXAccount(readConfig(), account.accountId)).enabled } } : {}, accountId: account.accountId, mode: stats.eventMode, connected: stats.eventMode === "poll" || stats.streamConnected, tokenStatus: getXTokenState(account.accountId), lastInboundAt: stats.lastEventAt }); }; const cursor = core.state.openKeyedStore({ namespace: "x.cursor", maxEntries: 1e3, overflowPolicy: "reject-new" }); const queue = core.state.openChannelIngressQueue({ accountId: account.accountId }); const previous = await cursor.lookup(account.accountId); if (previous && previous.userId !== account.userId) { if (!queue.purge) throw new Error("X bot identity changed; this host cannot reset its ingress queue."); await queue.purge({ signal: ctx.abortSignal }); } const assertCurrent = () => { ctx.abortSignal.throwIfAborted(); if (getXRuntime() !== core) throw new Error("X runtime was replaced"); }; if (!previous || previous.userId !== account.userId) await cursor.register(account.accountId, { userId: account.userId }, { assertCurrent }); const ingress = createChannelIngressMonitor({ queue, inspect: ({ post }) => ({ eventId: post.id, laneKey: post.conversation_id }), payload: { storage: "raw-event", version: 1, serialize: (value) => JSON.stringify(value), deserialize: (value) => { const envelope = parseXPostEnvelope(JSON.parse(value)); if (!envelope) throw new InvalidXEvent("Invalid stored X mention envelope"); return envelope; }, createClaimError: () => new InvalidXEvent("X mention identity changed after admission") }, pollIntervalMs: 1e3, retention: { completedMaxEntries: 2e3, completedTtlMs: 2592e6, failedMaxEntries: 1e3 }, abortSignal: ctx.abortSignal, deliver: async (envelope, lifecycle) => { let { post, users } = envelope; const dropMention = () => { publish({ droppedMentions: stats.droppedMentions + 1, lastDroppedAuthor: post.author_id }); log.info(`mention post=${post.id} author=${post.author_id} dropped`); }; let cfg = readConfig(); let initialAuthorization = await resolveXIngress(account.accountId, post, cfg); const initial = initialAuthorization.ingress; if (post.author_id === account.userId || !initial.senderAccess.allowed) { dropMention(); return; } if (envelope.recipientPending) for (;;) { cfg = readConfig(); initialAuthorization = await resolveXIngress(account.accountId, post, cfg); if (post.author_id === account.userId || !initialAuthorization.ingress.senderAccess.allowed) { dropMention(); return; } try { const addressed = await resolveXRecipient({ api, post, users, userId: account.userId, signal: lifecycle.abortSignal }); if (!addressed) return; ({post, users} = addressed); break; } catch (error) { if (!(error instanceof XBudgetExceededError)) throw error; const heartbeat = setInterval(() => lifecycle.onDeferredHeartbeat?.(), lifecycle.deferredHeartbeatIntervalMs ?? 6e4); try { await waitForXBudgetReset(error.exhaustedUntil, lifecycle.abortSignal); } finally { clearInterval(heartbeat); } } } const admitGuest = async (authorization, currentCfg) => { const guestAccount = resolveXAccount(currentCfg, account.accountId); const guestRoute = resolveAgentRoute({ cfg: currentCfg, channel: "x", accountId: account.accountId, peer: { kind: "group", id: post.conversation_id } }); const blocked = resolveXGuestContainmentError(currentCfg, guestRoute.agentId); const assertGuestCurrent = () => { assertCurrent(); lifecycle.abortSignal.throwIfAborted(); authorization.assertCurrent(); if (currentCfg !== readConfig()) throw new Error("X guest configuration changed during admission; retrying mention"); }; let admitted = false; let blockedReason = blocked; if (!blockedReason) try { admitted = await guestUsage.admit({ accountId: account.accountId, authorId: post.author_id, postId: post.id, limit: resolveXGuestSettings(guestAccount).maxMentionsPerAuthorPerDay, assertCurrent: assertGuestCurrent }); } catch (error) { if (!(error instanceof XGuestUsageUnavailableError)) throw error; blockedReason = error.message; } const guests = await getXGuestStatus(core, guestAccount, currentCfg, guestRoute.agentId); assertGuestCurrent(); publish({ guests, guestModeBlockedReason: blockedReason, ...blockedReason ? { message: blockedReason } : {}, ...!admitted ? { droppedMentions: stats.droppedMentions + 1, lastDroppedAuthor: post.author_id } : {} }); return admitted; }; const initialSettings = resolveXGuestSettings(resolveXAccount(cfg, account.accountId)); if (initialAuthorization.tier === "guest" && !await admitGuest(initialAuthorization, cfg)) return; const thread = await assembleXThread({ api, mention: post, users, maxPosts: initialAuthorization.tier === "guest" ? initialSettings.threadContextMaxPosts : resolveXAccount(cfg, account.accountId).config.threadContext?.maxPosts ?? 50, signal: lifecycle.abortSignal }); cfg = readConfig(); const route = resolveAgentRoute({ cfg, channel: "x", accountId: account.accountId, peer: { kind: "group", id: post.conversation_id } }); const sessionKey = initialAuthorization.tier === "guest" ? `${route.sessionKey}:guest:${post.id}` : route.sessionKey; const publicationSnapshot = initialAuthorization.tier === "maintainer" && resolveXAccount(cfg, account.accountId).config.autoPublishWorkSessions === true ? await openXAllowlist(core).readSnapshot(account.accountId, resolveXAccount(cfg, account.accountId).config.verifiedFromGitHub) : void 0; const publicThread = publicationSnapshot && await verifyPublicXThread(api, thread.posts, lifecycle.abortSignal, post.conversation_id); const publication = publicationSnapshot && publicThread ? { audience: "public", assertCurrent: () => { lifecycle.abortSignal.throwIfAborted(); assertCurrent(); publicationSnapshot.assertCurrent(); if (readConfig() !== cfg) throw new Error("X work-session publication policy changed; send a new mention."); } } : void 0; const authorization = await resolveXIngress(account.accountId, post, cfg, { agentId: route.agentId, sessionKey, messageId: post.id, inboundEventKind: "user_request" }, publication); const assertAdmissionCurrent = () => { assertCurrent(); lifecycle.abortSignal.throwIfAborted(); authorization.assertCurrent(); if (cfg !== readConfig()) throw new Error("X routing configuration changed during admission; retrying mention"); }; const channelIngress = authorization.ingress; assertAdmissionCurrent(); if (!channelIngress.senderAccess.allowed) { dropMention(); return; } if (authorization.tier !== initialAuthorization.tier || authorization.tier === "guest" && initialSettings.threadContextMaxPosts !== resolveXGuestSettings(resolveXAccount(cfg, account.accountId)).threadContextMaxPosts) throw new XAllowlistChangedError(); const guest = authorization.tier === "guest"; if (guest && !await admitGuest(authorization, cfg)) return; const handle = thread.users.find((user) => user.id === post.author_id)?.username ?? post.author_id; const ctxPayload = core.channel.inbound.buildContext({ channelIngress, access: { mentions: { ...xMentionFacts, requireMention: true, explicitlyMentionedBot: true }, ...guest ? { toolPolicy: resolveXGuestToolPolicy(resolveXAccount(cfg, account.accountId)) } : {} }, channel: "x", accountId: account.accountId, messageId: post.id, timestamp: post.created_at ? Date.parse(post.created_at) : void 0, from: `x:group:${post.conversation_id}`, sender: { id: post.author_id, name: `@${handle}` }, conversation: { kind: "group", id: post.conversation_id, label: thread.label }, route: { agentId: route.agentId, accountId: route.accountId, routeSessionKey: sessionKey }, reply: { to: `x:${post.id}`, replyToId: post.id }, message: { body: post.text, rawBody: post.text, commandBody: post.text, bodyForAgent: `${formatXSenderLine(authorization.tier, post.author_id, thread.users.find((user) => user.id === post.author_id), authorization.github)}\n${thread.bodyForAgent}` } }); const sessions = []; log.info(`mention post=${post.id} author=${post.author_id} allowed`); assertAdmissionCurrent(); await core.channel.inbound.dispatch({ cfg, channel: "x", accountId: account.accountId, route: { agentId: route.agentId, sessionKey }, ctxPayload, ...bindIngressLifecycleToReplyOptions(lifecycle), replyOptions: { onVisibleWorkSessions: (visible) => { if (!guest) sessions.push(...visible); } }, delivery: { preparePayload: (payload, info) => info.kind === "final" ? payload : null, observeMessageSent: true, deliver: async (payload) => { if (!payload.text?.trim() && !payload.mediaUrl && !payload.mediaUrls?.length) return { visibleReplySent: false }; const sent = await sendXDelivery({ cfg: readConfig(), accountId: account.accountId, to: `x:${post.id}`, text: payload.text ?? "", mediaUrl: payload.mediaUrl, mediaUrls: payload.mediaUrls, mention: post, visibleWorkSessions: guest ? void 0 : sessions, senderTier: authorization.tier, signal: lifecycle.abortSignal }); ctx.setStatus({ ...ctx.getStatus(), lastOutboundAt: Date.now() }); return { visibleReplySent: true, content: sent.content, receipt: sent.receipt }; } } }); }, onError: () => { log.warn("X ingress failed; pending mentions will retry"); }, drain: { resolveNonRetryableFailure: (error) => error instanceof InvalidXEvent ? { reason: "invalid-event", message: error.message } : null } }); const startupConfig = readConfig(); stats.guests = await getXGuestStatus(core, resolveXAccount(startupConfig, account.accountId), startupConfig); ingress.start(); ctx.setStatus({ ...ctx.getStatus(), running: true, lifecycle: "ready" }); publish(); let stopped = false; const publishSpend = async () => { try { const spend = await api.spend.status(); if (!stopped) publish({ spend }); } catch { if (!stopped) publish({ message: "X spend accounting unavailable; paid requests paused" }); } }; const unsubscribeSpend = api.spend.subscribe(() => { publishSpend(); }); try { await publishSpend(); await runXEvents({ api, userId: account.userId, mode: account.config.events?.mode ?? "auto", pollSeconds: account.config.events?.pollSeconds ?? 60, bearerConfigured: Boolean(account.config.bearerToken), signal: ctx.abortSignal, onStatus: publish, onWarning: (message) => log.warn(message), getCursor: async () => await cursor.lookup(account.accountId) ?? {}, setCursor: async (next) => { await cursor.register(account.accountId, { ...next, userId: account.userId }, { assertCurrent }); }, onPost: async (post) => { await ingress.admit(post); } }); } finally { stopped = true; unsubscribeSpend(); await ingress.stop(); ctx.setStatus({ ...ctx.getStatus(), running: false, connected: false, lifecycle: "stopped" }); } } //#endregion export { startXAccount };