UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

832 lines (831 loc) • 31.4 kB
import { s as __toESM } from "./rolldown-runtime-Dr7-SnC6.mjs"; import { a as resolveGlobalSingleton, r as resolveGlobalMap } from "./global-singleton-0m-JziJW.mjs"; import { a as asOptionalRecord } from "./record-coerce-TIsquoVR.mjs"; import { _ as resolveSecretInputString } from "./types.secrets-DD-Q2SS_.mjs"; import { r as createRuntimeConfigReader } from "./runtime-snapshot-De_QxZI9.mjs"; import { r as PlatformMessageNotDispatchedError } from "./deliver-types-D5Rd5TTU.mjs"; import "./error-runtime-oJAVOxQN.mjs"; import "./string-coerce-runtime-BbIJhy7G.mjs"; import { t as LinkifyIt } from "./build-CSGemSDQ.mjs"; import "./secret-input-B0WNzmEZ.mjs"; import "./runtime-config-snapshot--IkldAgP.mjs"; import { t as require_tlds } from "./tlds-Cq_6F5yw.mjs"; import { i as resolveXAccount } from "./accounts-DgZqMZQn.mjs"; import { t as getXRuntime } from "./runtime-DJewOfvF.mjs"; import { createHash, randomUUID } from "node:crypto"; //#region extensions/x/src/urls.ts var import_tlds = /* @__PURE__ */ __toESM(require_tlds(), 1); const links = new LinkifyIt({ fuzzyLink: true, tlds: import_tlds.default }); function findXUrls(text) { return (links.match(text) ?? []).filter((match) => !match.schema || [ "http:", "https:", "//" ].includes(match.schema)); } //#endregion //#region extensions/x/src/pricing.ts const X_POST_READ_MICRO_USD = 5e3; const X_USER_READ_MICRO_USD = 1e4; const X_POST_EVENT_MICRO_USD = 5e3; const X_REPLY_MICRO_USD = 15e3; const X_LINK_REPLY_MICRO_USD = 2e5; function xReplyCost(text) { return findXUrls(text).length ? X_LINK_REPLY_MICRO_USD : X_REPLY_MICRO_USD; } function xReadCost(value, kind) { const row = asOptionalRecord(value); const includes = asOptionalRecord(row?.includes); const count = (data) => Array.isArray(data) ? data.length : asOptionalRecord(data) ? 1 : 0; return count(row?.data) * (kind === "posts" ? X_POST_READ_MICRO_USD : X_USER_READ_MICRO_USD) + count(includes?.tweets) * X_POST_READ_MICRO_USD + count(includes?.users) * X_USER_READ_MICRO_USD; } function xActivityCost(value) { const event = asOptionalRecord(asOptionalRecord(value)?.data); return (typeof event?.event_type === "string" && event.event_type.startsWith("post.") ? X_POST_EVENT_MICRO_USD : 0) + xReadCost({ includes: event?.includes }, "posts"); } //#endregion //#region extensions/x/src/spend.ts const X_STREAM_HEADROOM_MICRO_USD = 5e5; const MICRO_USD = 1e6; const RETENTION_MS = 6048e6; const XBudgetExceededError = resolveGlobalSingleton(Symbol.for("openclaw.x.budget-exceeded-error"), () => class extends Error { constructor(message, exhaustedUntil) { super(message); this.exhaustedUntil = exhaustedUntil; this.name = "XBudgetExceededError"; } }); const owners = resolveGlobalMap(Symbol.for("openclaw.x.spend"), async (active) => { await Promise.all([...active.values()].map((owner) => owner.close())); active.clear(); }, "close-and-restart"); function periodAt(now, cycleStartDay) { const date = new Date(now); const year = date.getUTCFullYear(); const month = date.getUTCMonth(); const cycleMonth = date.getUTCDate() < cycleStartDay ? month - 1 : month; return { day: date.toISOString().slice(0, 10), cycle: new Date(Date.UTC(year, cycleMonth, cycleStartDay)).toISOString().slice(0, 10), dayEnd: Date.UTC(year, month, date.getUTCDate() + 1), cycleEnd: Date.UTC(year, cycleMonth + 1, cycleStartDay) }; } function checkedAmount(value) { if (!Number.isSafeInteger(value) || value < 0) throw new Error("X spend must be a nonnegative safe integer in micro-dollars"); return value; } function createSpendOwner(runtime, accountId, getLimits) { const prefix = `${encodeURIComponent(accountId)}:`; const openStore = (current) => current.state.openKeyedStore({ namespace: "x.spend", maxEntries: 2e5, overflowPolicy: "reject-new", defaultTtlMs: RETENTION_MS }); let store = openStore(runtime); let state = runtime.state; let logger = runtime.logging?.getChildLogger({ channel: "x", accountId }); let limits = getLimits; let tail = Promise.resolve(); let initialized = false; let failure; let refusedAmount = 0; let warnedUntil; let reconciledCycle; const pending = /* @__PURE__ */ new Map(); const listeners = /* @__PURE__ */ new Set(); const notify = () => { for (const listener of listeners) listener(); }; const serialized = (work) => { const result = tail.then(async () => { if (failure) throw failure; try { return await work(); } catch (error) { if (!(error instanceof XBudgetExceededError)) { failure = new Error("X spend accounting unavailable; paid requests paused", { cause: error }); notify(); } throw error; } }); tail = result.catch(() => void 0); return result; }; const bucketKey = (kind, date) => `${prefix}${kind}:${date}`; const read = async (kind, date) => checkedAmount(await store.lookup(bucketKey(kind, date)) ?? 0); const add = async (kind, date, amount) => { if (amount) await store.register(bucketKey(kind, date), checkedAmount(await read(kind, date) + amount)); }; const finish = async (reservation, actual) => { const current = periodAt(Date.now(), limits().cycleStartDay); for (const day of /* @__PURE__ */ new Set([reservation.day, current.day])) await add("day", day, actual); for (const cycle of /* @__PURE__ */ new Set([reservation.cycle, current.cycle])) await add("cycle", cycle, actual); if (reservation.day !== current.day && actual) { const key = `${prefix}overlap:${reservation.day}:${current.day}`; await store.register(key, checkedAmount(checkedAmount(await store.lookup(key) ?? 0) + actual)); } await store.delete(reservation.key); pending.delete(reservation.key); }; const initialize = async () => { if (initialized) return; const pendingPrefix = `${prefix}pending:`; for (const entry of await store.entries()) { if (!entry.key.startsWith(pendingPrefix)) continue; const [day, cycle] = entry.key.slice(pendingPrefix.length).split(":"); if (!day || !cycle || !/^\d{4}-\d{2}-\d{2}$/.test(day) || !/^\d{4}-\d{2}-\d{2}$/.test(cycle)) throw new Error("Invalid persisted X spend reservation"); const reservation = { key: entry.key, amount: checkedAmount(entry.value), day, cycle }; await finish(reservation, reservation.amount); } initialized = true; }; const snapshot = async () => { await initialize(); const config = limits(); const period = periodAt(Date.now(), config.cycleStartDay); if (reconciledCycle !== period.cycle) { const dayPrefix = `${prefix}day:`; const overlapPrefix = `${prefix}overlap:`; const inCycle = (day) => day >= period.cycle && day <= period.day; let dayTotal = 0; let overlaps = 0; for (const entry of await store.entries()) if (entry.key.startsWith(dayPrefix)) { if (inCycle(entry.key.slice(dayPrefix.length))) dayTotal = checkedAmount(dayTotal + checkedAmount(entry.value)); } else if (entry.key.startsWith(overlapPrefix)) { const [startDay, completionDay] = entry.key.slice(overlapPrefix.length).split(":"); if (startDay && completionDay && inCycle(startDay) && inCycle(completionDay)) overlaps = checkedAmount(overlaps + checkedAmount(entry.value)); } const cycleFromDays = checkedAmount(dayTotal - overlaps); if (cycleFromDays > await read("cycle", period.cycle)) await store.register(bucketKey("cycle", period.cycle), cycleFromDays); reconciledCycle = period.cycle; } const held = [...pending.values()].reduce((total, item) => checkedAmount(total + item.amount), 0); return { config, period, held, day: checkedAmount(await read("day", period.day) + held), cycle: checkedAmount(await read("cycle", period.cycle) + held), dailyLimit: Math.floor(config.dailyUsd * MICRO_USD), monthlyLimit: Math.floor(config.monthlyUsd * MICRO_USD) }; }; const refusal = (view, amount, inclusive = false) => { const dayExceeded = inclusive ? view.day + amount > view.dailyLimit : view.day + amount >= view.dailyLimit; const cycleExceeded = inclusive ? view.cycle + amount > view.monthlyLimit : view.cycle + amount >= view.monthlyLimit; if (!dayExceeded && !cycleExceeded) return; const until = Math.max(dayExceeded ? view.period.dayEnd : 0, cycleExceeded ? view.period.cycleEnd : 0); const reason = cycleExceeded ? `billing-cycle budget of $${view.config.monthlyUsd}` : `daily budget of $${view.config.dailyUsd}`; return new XBudgetExceededError(`X API ${reason} reached; resumes at ${new Date(until).toISOString().replace(":00.000Z", "Z")}`, until); }; const updateNotice = (view) => { const error = refusal(view, refusedAmount, refusedAmount > 0); const notice = refusedAmount ? error : refusal({ ...view, day: view.day - view.held, cycle: view.cycle - view.held }, 0); if (warnedUntil !== void 0 && (Date.now() >= warnedUntil || !notice)) { logger?.info("X API budget reset; paid requests may resume"); warnedUntil = void 0; } if (!error) refusedAmount = 0; else if (notice && logger && warnedUntil === void 0) { warnedUntil = notice.exhaustedUntil; logger?.warn(notice.message); } return error; }; const reserve = async (amount, enforce, receivedAt) => { checkedAmount(amount); const view = await snapshot(); updateNotice(view); const error = amount ? refusal(view, amount, true) : void 0; if (enforce && error) { refusedAmount = Math.max(refusedAmount, amount); updateNotice(view); throw error; } const period = receivedAt === void 0 ? view.period : periodAt(receivedAt, view.config.cycleStartDay); const reservation = { key: `${prefix}pending:${period.day}:${period.cycle}:${randomUUID()}`, amount, day: period.day, cycle: period.cycle }; await store.register(reservation.key, amount); pending.set(reservation.key, reservation); notify(); return reservation; }; return { api: { async reserve(microUsd) { const reservation = await serialized(() => reserve(microUsd, true)); let settled = false; return { settle: (actualMicroUsd) => serialized(async () => { if (settled) return; checkedAmount(actualMicroUsd); settled = true; await finish(reservation, actualMicroUsd); updateNotice(await snapshot()); notify(); }) }; }, charge: (microUsd) => { const receivedAt = Date.now(); return serialized(async () => { const reservation = await reserve(microUsd, false, receivedAt); await finish(reservation, microUsd); updateNotice(await snapshot()); notify(); }); }, status: () => serialized(async () => { const view = await snapshot(); const exhausted = updateNotice(view); const dollars = (amount) => Math.round(amount / 1e4) / 100; return { dayUsd: dollars(view.day), cycleUsd: dollars(view.cycle), dailyLimitUsd: dollars(view.dailyLimit), monthlyLimitUsd: dollars(view.monthlyLimit), cycleStart: view.period.cycle, ...exhausted ? { exhaustedUntil: new Date(exhausted.exhaustedUntil).toISOString() } : {} }; }), streamResumeAt: () => serialized(async () => { const view = await snapshot(); updateNotice(view); return refusal(view, X_STREAM_HEADROOM_MICRO_USD, true)?.exhaustedUntil; }), subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; } }, update(nextRuntime, nextLimits) { limits = nextLimits; logger = nextRuntime.logging?.getChildLogger({ channel: "x", accountId }) ?? logger; if (state !== nextRuntime.state) { store = openStore(nextRuntime); state = nextRuntime.state; } }, async close() { failure = /* @__PURE__ */ new Error("X spend owner closed; pending reservations retained for recovery"); await tail; } }; } function openXSpend(runtime, accountId, getLimits) { const key = JSON.stringify([runtime.state.resolveStateDir(), accountId]); let owner = owners.get(key); if (!owner) { owner = createSpendOwner(runtime, accountId, getLimits); owners.set(key, owner); } else owner.update(runtime, getLimits); return owner.api; } //#endregion //#region extensions/x/src/api.ts const X_API_ORIGIN = "https://api.x.com"; const POST_FIELDS = "author_id,conversation_id,created_at,in_reply_to_user_id,referenced_tweets,entities,withheld"; var XApiError = class extends Error { constructor(status, operation, detail) { super(`X API ${operation} failed (HTTP ${status})${detail ? `: ${detail}` : ""}`); this.status = status; this.operation = operation; this.name = "XApiError"; } }; async function readActivityErrorDetail(response, signal, secrets) { try { const { readResponseWithLimit } = await import("./plugin-sdk/response-limit-runtime.js"); const body = asOptionalRecord(JSON.parse((await readResponseWithLimit(response, 16384, { signal, timeoutMs: 3e4 })).toString())); const first = asOptionalRecord(Array.isArray(body?.errors) ? body.errors[0] : void 0); let detail = [ first?.message, first?.detail, first?.title, body?.detail, body?.message, body?.title ].find((value) => typeof value === "string" && Boolean(value.trim())); if (!detail) return; for (const secret of secrets.filter((value) => Boolean(value)).toSorted((a, b) => b.length - a.length)) detail = detail.replaceAll(secret, "[redacted]"); return detail.replace(/\s+/g, " ").trim().slice(0, 512); } catch { return; } } async function readJson(response) { try { return await response.json(); } catch { throw new Error("X API returned an unreadable JSON response"); } } function parseXPost(input) { const row = asOptionalRecord(input); if (!row || typeof row.id !== "string" || !/^\d+$/.test(row.id) || typeof row.text !== "string" || typeof row.author_id !== "string" || !/^\d+$/.test(row.author_id) || typeof row.conversation_id !== "string" || !/^\d+$/.test(row.conversation_id)) return; const references = []; for (const value of Array.isArray(row.referenced_tweets) ? row.referenced_tweets : []) { const reference = asOptionalRecord(value); if (reference && typeof reference.id === "string" && /^\d+$/.test(reference.id) && (reference.type === "replied_to" || reference.type === "quoted" || reference.type === "retweeted")) references.push({ id: reference.id, type: reference.type }); } const entities = asOptionalRecord(row.entities); const mentions = []; for (const value of Array.isArray(entities?.mentions) ? entities.mentions : []) { const mention = asOptionalRecord(value); if (mention && typeof mention.username === "string") mentions.push({ username: mention.username, ...typeof mention.id === "string" ? { id: mention.id } : {} }); } return { id: row.id, text: row.text, author_id: row.author_id, conversation_id: row.conversation_id, ...row.withheld !== void 0 ? { withheld: true } : {}, ...typeof row.created_at === "string" ? { created_at: row.created_at } : {}, ...typeof row.in_reply_to_user_id === "string" ? { in_reply_to_user_id: row.in_reply_to_user_id } : {}, ...references.length ? { referenced_tweets: references } : {}, ...entities ? { entities: Array.isArray(entities.mentions) ? { mentions } : {} } : {} }; } function parseUser(value) { const row = asOptionalRecord(value); return row && typeof row.id === "string" && /^\d+$/.test(row.id) && typeof row.username === "string" ? { id: row.id, username: row.username, ...typeof row.protected === "boolean" ? { protected: row.protected } : {}, ...typeof row.name === "string" ? { name: row.name } : {} } : void 0; } function parseXPostEnvelope(input) { const row = asOptionalRecord(input); const post = parseXPost(row?.post); return post ? { post, users: (Array.isArray(row?.users) ? row.users : []).flatMap((value) => parseUser(value) ?? []), ...row?.recipientPending === true ? { recipientPending: true } : {} } : void 0; } function parsePage(input) { const row = asOptionalRecord(input); if (!row || row.data !== void 0 && !Array.isArray(row.data)) throw new Error("X API returned an invalid post page"); const data = (row.data ?? []).map((value) => { const post = parseXPost(value); if (!post) throw new Error("X API returned an invalid post"); return post; }); const includes = asOptionalRecord(row.includes); const meta = asOptionalRecord(row.meta); return { data, includes: { users: (Array.isArray(includes?.users) ? includes.users : []).flatMap((value) => parseUser(value) ?? []), tweets: (Array.isArray(includes?.tweets) ? includes.tweets : []).flatMap((value) => parseXPost(value) ?? []) }, meta: { ...typeof meta?.newest_id === "string" ? { newest_id: meta.newest_id } : {}, ...typeof meta?.next_token === "string" ? { next_token: meta.next_token } : {} } }; } function createXApiClient(options) { let accessToken; let expiresAt = 0; let refreshToken = options.refreshToken; let loadedRefreshToken = false; let pendingRefreshToken; let refreshTask; async function fetcher(url, init, assertActive, onDispatch) { if (options.fetch) { const assertCurrent = await assertActive?.(); init.signal?.throwIfAborted(); assertCurrent?.(); onDispatch?.(); return options.fetch(url, init); } const [{ fetchWithSsrFGuard }, { responseWithRelease }, { fetchWithRuntimeDispatcher }] = await Promise.all([ import("./plugin-sdk/ssrf-runtime.js"), import("./plugin-sdk/fetch-runtime.js"), import("./plugin-sdk/runtime-fetch.js") ]); const guarded = await fetchWithSsrFGuard({ url, init, capture: false, requireHttps: true, policy: { hostnameAllowlist: ["api.x.com"] }, maxRedirects: 0, fetchImpl: async (input, prepared) => { const assertCurrent = await assertActive?.(); prepared?.signal?.throwIfAborted(); assertCurrent?.(); onDispatch?.(); return fetchWithRuntimeDispatcher(input, prepared); } }); return responseWithRelease(guarded.response, guarded.release); } function requestSignal(signal) { return AbortSignal.any([...[options.signal, signal].filter((value) => Boolean(value)), AbortSignal.timeout(3e4)]); } async function refresh() { options.onTokenState?.("refreshing"); try { if (!loadedRefreshToken) { refreshToken = await options.loadRefreshToken?.() ?? refreshToken; loadedRefreshToken = true; } if (pendingRefreshToken) { await options.saveRefreshToken(pendingRefreshToken); pendingRefreshToken = void 0; } const response = await fetcher(`${X_API_ORIGIN}/2/oauth2/token`, { method: "POST", redirect: "error", headers: { Authorization: `Basic ${Buffer.from(`${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }).toString(), signal: requestSignal() }); if (!response.ok) { await response.body?.cancel(); throw new XApiError(response.status, "token refresh"); } const token = asOptionalRecord(await readJson(response)); if (!token || typeof token.access_token !== "string" || !token.access_token) throw new Error("X API returned an invalid token response"); if (typeof token.refresh_token === "string" && token.refresh_token) { refreshToken = token.refresh_token; pendingRefreshToken = refreshToken; await options.saveRefreshToken(refreshToken); pendingRefreshToken = void 0; } accessToken = token.access_token; expiresAt = Date.now() + (typeof token.expires_in === "number" ? Math.max(0, token.expires_in - 30) : 7170) * 1e3; options.onTokenState?.("ready"); } catch { accessToken = void 0; options.onTokenState?.("error"); throw new Error("X token refresh failed; check the account credentials and token storage"); } } async function ensureAccessToken() { if (accessToken && Date.now() < expiresAt) return; refreshTask ??= refresh().finally(() => { refreshTask = void 0; }); await refreshTask; } async function request(path, params = {}) { for (let attempt = 0; attempt < 2; attempt++) { const reservation = params.billing ? await options.spend.reserve(params.billing.maximum) : void 0; let cost = 0; try { if (!params.appOnly) await ensureAccessToken(); const token = params.appOnly ? options.bearerToken : accessToken; if (!token) throw new Error("X Activity API requires a bearer token"); const signal = params.stream ? AbortSignal.any([options.signal, params.signal].filter((value) => Boolean(value))) : requestSignal(params.signal); signal.throwIfAborted(); let response; let authorityRejected = false; let authorityError; try { response = await fetcher(`${X_API_ORIGIN}${path}`, { method: params.method ?? "GET", redirect: "error", headers: { Authorization: `Bearer ${token}`, ...params.body ? { "Content-Type": "application/json" } : {} }, ...params.body ? { body: JSON.stringify(params.body) } : {}, signal }, params.assertActive ? async () => { try { const assertCurrent = await params.assertActive?.(); return () => { try { assertCurrent?.(); } catch (error) { authorityRejected = true; authorityError = error; throw error; } }; } catch (error) { authorityRejected = true; authorityError = error; throw error; } } : void 0, () => { cost = params.billing?.maximum ?? 0; params.onDispatch?.(); }); } catch { if (authorityRejected) throw authorityError; signal.throwIfAborted(); throw new Error("X API network request failed"); } if (response.status >= 400 && response.status < 500) { cost = 0; if (params.billing) { const value = await readJson(response).catch(() => void 0); cost = params.billing.actual(value); } } if (response.status === 401) { params.onAuthenticationRejected?.(); if (!params.appOnly && attempt === 0) { await response.body?.cancel().catch(() => {}); if (accessToken === token) accessToken = void 0; continue; } } if (!response.ok) { let detail; if (path.startsWith("/2/activity/")) detail = await readActivityErrorDetail(response, signal, [ token, accessToken, options.bearerToken, refreshToken, options.refreshToken, options.clientSecret ]); else await response.body?.cancel().catch(() => {}); throw new XApiError(response.status, path.split("?")[0] ?? path, detail); } if (!params.billing) return response; const value = await readJson(response); const parsed = params.billing.parse(value); cost = params.billing.actual(value); return parsed; } finally { await reservation?.settle(cost); } } throw new Error("X user token was rejected after refresh"); } async function page(path, query, maximumPosts, signal, appOnly = false) { const params = new URLSearchParams({ "tweet.fields": POST_FIELDS, expansions: "author_id", "user.fields": "username,name,protected" }); for (const [key, value] of Object.entries(query)) if (value !== void 0) params.set(key, value); return request(`${path}?${params}`, { signal, appOnly, billing: { maximum: maximumPosts * 15e3, actual: (value) => xReadCost(value, "posts"), parse: parsePage } }); } return { spend: options.spend, getMentions: (params) => page(`/2/users/${encodeURIComponent(params.userId)}/mentions`, { since_id: params.sinceId, pagination_token: params.paginationToken, max_results: "10" }, 10, params.signal), getPosts: (ids, signal) => { if (!ids.length || ids.length > 100) throw new Error("X post lookup requires 1–100 post ids"); return page("/2/tweets", { ids: ids.join(",") }, ids.length, signal); }, getPublicPosts: (ids, signal) => { if (!options.bearerToken || !ids.length || ids.length > 100) throw new Error("Public X lookup requires an app-only bearer token and 1–100 post ids"); return page("/2/tweets", { ids: ids.join(",") }, ids.length, signal, true); }, searchConversation: (params) => page("/2/tweets/search/recent", { query: `conversation_id:${params.conversationId}`, next_token: params.nextToken, max_results: String(Math.max(10, Math.min(Math.floor(params.maxPosts), 100))), sort_order: "recency" }, Math.max(10, Math.min(Math.floor(params.maxPosts), 100)), params.signal), async getUsersByUsernames(usernames, signal, assertActive) { const handles = usernames.map((username) => username.replace(/^@/, "")); if (!handles.length || handles.length > 100 || handles.some((handle) => !/^[A-Za-z0-9_]{1,15}$/.test(handle))) throw new Error("X user lookup requires 1–100 valid usernames"); return request(`/2/users/by?${new URLSearchParams({ usernames: handles.join(","), "user.fields": "id,username,name" })}`, { signal, assertActive, billing: { maximum: handles.length * X_USER_READ_MICRO_USD, actual: (value) => xReadCost(value, "users"), parse: (value) => { const row = asOptionalRecord(value); if (!row || row.data !== void 0 && !Array.isArray(row.data)) throw new Error("X API returned an invalid users response"); return (row.data ?? []).map((entry) => { const user = parseUser(entry); if (!user || !/^[A-Za-z0-9_]{1,15}$/.test(user.username)) throw new Error("X API returned an invalid user"); return user; }); } } }); }, async getUserByUsername(username, signal) { const user = await request(`/2/users/by/username/${encodeURIComponent(username.replace(/^@/, ""))}?user.fields=id,username,name`, { signal, billing: { maximum: X_USER_READ_MICRO_USD, actual: (value) => xReadCost(value, "users"), parse: (value) => { const row = asOptionalRecord(value); if (!row) throw new Error("X API returned an invalid user response"); if (row.data === void 0) return; const resolved = parseUser(row.data); if (!resolved) throw new Error("X API returned no matching user"); return resolved; } } }); if (!user) throw new Error("X API returned no matching user"); return user; }, async reply(params) { let dispatched = false; try { params.signal?.throwIfAborted(); return await request("/2/tweets", { billing: { maximum: xReplyCost(params.text), actual: (value) => asOptionalRecord(asOptionalRecord(value)?.data)?.id ? xReplyCost(params.text) : 0, parse: (value) => { const id = asOptionalRecord(asOptionalRecord(value)?.data)?.id; if (typeof id !== "string" || !/^\d+$/.test(id)) throw new Error("X API returned no reply id; delivery outcome is unknown"); return id; } }, method: "POST", body: { text: params.text, reply: { in_reply_to_tweet_id: params.inReplyToId } }, signal: params.signal, assertActive: params.assertActive, onDispatch: () => { dispatched = true; }, onAuthenticationRejected: () => { dispatched = false; } }); } catch (cause) { if (cause instanceof PlatformMessageNotDispatchedError) throw cause; if (cause instanceof XBudgetExceededError) throw new PlatformMessageNotDispatchedError(cause.message, { cause, retryable: false }); if (cause instanceof XApiError && cause.status < 500) throw new PlatformMessageNotDispatchedError(cause.message, { cause, retryable: cause.status === 429 }); if (dispatched) throw cause; throw new PlatformMessageNotDispatchedError(cause instanceof Error ? cause.message : "X reply failed before dispatch", { cause, retryable: !params.signal?.aborted && !options.signal?.aborted }); } }, async ensureActivitySubscriptions(userId, signal) { const response = await request("/2/activity/subscriptions", { appOnly: true, signal }); const data = asOptionalRecord(await readJson(response))?.data; if ((Array.isArray(data) ? data : []).some((value) => { const subscription = asOptionalRecord(value); return subscription?.event_type === "post.mention.create" && asOptionalRecord(subscription.filter)?.user_id === userId; })) return; await (await request("/2/activity/subscriptions", { method: "POST", body: { event_type: "post.mention.create", filter: { user_id: userId } }, appOnly: false, signal })).body?.cancel(); }, openActivityStream: (signal) => request("/2/activity/stream", { appOnly: true, stream: true, signal }) }; } //#endregion //#region extensions/x/src/client.ts const clients = /* @__PURE__ */ new WeakMap(); function getXTokenState(accountId) { return clients.get(getXRuntime())?.get(accountId)?.tokenState ?? "idle"; } async function getXApi(accountId, cfg) { const runtime = getXRuntime(); const account = resolveXAccount(cfg, accountId); if (!account.enabled || !account.configured) throw new Error("X account is disabled or incomplete; configure userId, username, clientId, clientSecret and refreshToken."); const secret = (field) => resolveSecretInputString({ value: account.config[field], defaults: cfg.secrets?.defaults, path: `channels.x.accounts.${accountId}.${field}`, mode: "strict" }).value; const clientSecret = secret("clientSecret"); const refreshToken = secret("refreshToken"); const bearerToken = secret("bearerToken"); const lineageFingerprint = createHash("sha256").update(JSON.stringify([ account.userId, account.config.clientId, refreshToken ])).digest("hex"); let accounts = clients.get(runtime); if (!accounts) { accounts = /* @__PURE__ */ new Map(); clients.set(runtime, accounts); } const readConfig = createRuntimeConfigReader(cfg); const spend = openXSpend(runtime, accountId, () => resolveXAccount(readConfig(), accountId).costLimits); const existing = accounts.get(accountId); if (existing?.lineageFingerprint === lineageFingerprint) { existing.options.clientSecret = clientSecret; existing.options.bearerToken = bearerToken; return existing.api; } const accountClients = accounts; const tokens = runtime.state.openKeyedStore({ namespace: "x.oauth", maxEntries: 1e3, overflowPolicy: "reject-new" }); const options = { spend, clientId: account.config.clientId, clientSecret, refreshToken, bearerToken, loadRefreshToken: async () => { const stored = await tokens.lookup(accountId); return stored?.lineageFingerprint === lineageFingerprint ? stored.refreshToken : void 0; }, saveRefreshToken: async (next) => { await tokens.register(accountId, { lineageFingerprint, refreshToken: next }, { assertCurrent: () => { if (getXRuntime() !== runtime || accountClients.get(accountId) !== entry) throw new Error("X account was replaced during token refresh"); } }); }, onTokenState: (state) => { entry.tokenState = state; } }; const entry = { lineageFingerprint, options, tokenState: "idle", api: createXApiClient(options) }; accounts.set(accountId, entry); return entry.api; } //#endregion export { XBudgetExceededError as a, findXUrls as c, parseXPostEnvelope as i, getXTokenState as n, openXSpend as o, XApiError as r, xActivityCost as s, getXApi as t };