chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export { xConnector } from '@/connectors/x/x'
+103
View File
@@ -0,0 +1,103 @@
import { xIcon } from '@/components/icons'
import type { ConnectorMeta } from '@/connectors/types'
export const DEFAULT_MAX_POSTS = 200
export const xConnectorMeta: ConnectorMeta = {
id: 'x',
name: 'X',
description: 'Sync posts from X (formerly Twitter) into your knowledge base',
version: '1.0.0',
icon: xIcon,
auth: {
mode: 'oauth',
provider: 'x',
requiredScopes: ['tweet.read', 'users.read', 'bookmark.read', 'like.read', 'offline.access'],
},
configFields: [
{
id: 'syncMode',
title: 'Sync Mode',
type: 'dropdown',
required: false,
description: 'Which posts to sync into the knowledge base',
options: [
{ label: 'My posts', id: 'me' },
{ label: 'Another user', id: 'user' },
{ label: 'My mentions', id: 'mentions' },
{ label: 'My bookmarks', id: 'bookmarks' },
{ label: 'My likes', id: 'likes' },
],
},
{
id: 'username',
title: 'Username(s)',
type: 'short-input',
required: false,
multi: true,
placeholder: 'e.g. jack, xdevelopers (required for "Another user")',
description:
'One or more X usernames to sync posts from (comma-separated). Only used when Sync Mode is "Another user".',
},
{
id: 'includeReplies',
title: 'Include Replies',
type: 'dropdown',
required: false,
options: [
{ label: 'Exclude replies', id: 'false' },
{ label: 'Include replies', id: 'true' },
],
description: 'Whether to include reply posts. Applies to "My posts" and "Another user".',
},
{
id: 'includeRetweets',
title: 'Include Retweets',
type: 'dropdown',
required: false,
options: [
{ label: 'Exclude retweets', id: 'false' },
{ label: 'Include retweets', id: 'true' },
],
description: 'Whether to include retweets. Applies to "My posts" and "Another user".',
},
{
id: 'startTime',
title: 'Start Time',
type: 'short-input',
required: false,
mode: 'advanced',
placeholder: 'e.g. 2024-01-01T00:00:00Z',
description:
'Oldest post time (ISO 8601 UTC). Applies to posts and mentions; ignored for bookmarks and likes.',
},
{
id: 'endTime',
title: 'End Time',
type: 'short-input',
required: false,
mode: 'advanced',
placeholder: 'e.g. 2024-12-31T23:59:59Z',
description:
'Newest post time (ISO 8601 UTC). Applies to posts and mentions; ignored for bookmarks and likes.',
},
{
id: 'maxPosts',
title: 'Max Posts',
type: 'short-input',
required: false,
placeholder: `e.g. 100 (default: ${DEFAULT_MAX_POSTS})`,
description:
'Maximum number of posts to sync (across all configured users). Posts beyond this limit are not deleted from the knowledge base; X also only exposes a limited recent window (≈3,200 timeline posts, ≈800 bookmarks), so posts that age out of that window are removed on the next sync.',
},
],
tagDefinitions: [
{ id: 'author', displayName: 'Author', fieldType: 'text' },
{ id: 'createdAt', displayName: 'Created Date', fieldType: 'date' },
{ id: 'likeCount', displayName: 'Like Count', fieldType: 'number' },
{ id: 'retweetCount', displayName: 'Retweet Count', fieldType: 'number' },
],
}
+533
View File
@@ -0,0 +1,533 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseMultiValue, parseTagDate } from '@/connectors/utils'
import { DEFAULT_MAX_POSTS, xConnectorMeta } from '@/connectors/x/meta'
const logger = createLogger('XConnector')
const X_API_BASE = 'https://api.x.com/2'
/** Max page size accepted by the timeline, mentions, bookmarks, and likes endpoints. */
const POSTS_PER_PAGE = 100
/**
* Minimum `max_results` accepted by the user-tweets, mentions, and liked-tweets
* endpoints. The bookmarks endpoint is the sole exception and accepts a minimum of 1.
*/
const MIN_PAGE_SIZE = 5
/**
* `edit_history_tweet_ids` is requested explicitly (it is not a default field) so the
* content hash can key on edit-history length and detect edits.
*/
const TWEET_FIELDS = 'created_at,public_metrics,text,edit_history_tweet_ids'
/**
* Sync mode determines which timeline the connector reads.
* - `me`: the authenticated user's own posts (GET /2/users/:id/tweets)
* - `user`: another account's posts by username (GET /2/users/:id/tweets)
* - `mentions`: posts mentioning the authenticated user (GET /2/users/:id/mentions)
* - `bookmarks`: the authenticated user's bookmarks (GET /2/users/:id/bookmarks)
* - `likes`: posts the authenticated user has liked (GET /2/users/:id/liked_tweets)
*/
type SyncMode = 'me' | 'user' | 'mentions' | 'bookmarks' | 'likes'
/** Modes whose endpoint supports the `exclude=retweets,replies` parameter. */
const EXCLUDE_CAPABLE_MODES: ReadonlySet<SyncMode> = new Set<SyncMode>(['me', 'user'])
/** Modes whose endpoint supports the `start_time` / `end_time` parameters. */
const DATE_RANGE_CAPABLE_MODES: ReadonlySet<SyncMode> = new Set<SyncMode>([
'me',
'user',
'mentions',
])
interface XPublicMetrics {
retweet_count?: number
reply_count?: number
like_count?: number
quote_count?: number
}
interface XTweet {
id: string
text: string
created_at?: string
author_id?: string
public_metrics?: XPublicMetrics
edit_history_tweet_ids?: string[]
}
interface XUser {
id: string
name?: string
username?: string
}
interface XListResponse {
data?: XTweet[]
includes?: { users?: XUser[] }
meta?: { next_token?: string; result_count?: number }
errors?: Array<{ detail?: string; title?: string }>
}
interface XSingleResponse {
data?: XTweet
includes?: { users?: XUser[] }
errors?: Array<{ detail?: string; title?: string }>
}
/**
* Resolves the configured sync mode, defaulting to the authenticated user's
* own posts.
*/
function resolveSyncMode(sourceConfig: Record<string, unknown>): SyncMode {
const mode = sourceConfig.syncMode
if (mode === 'user' || mode === 'mentions' || mode === 'bookmarks' || mode === 'likes') {
return mode
}
return 'me'
}
/**
* Reads a boolean toggle from a dropdown config field that stores 'true' / 'false'
* strings. Falls back to `defaultValue` when unset or unrecognized.
*/
function readBooleanOption(value: unknown, defaultValue: boolean): boolean {
if (value === 'true' || value === true) return true
if (value === 'false' || value === false) return false
return defaultValue
}
/**
* Parses the configured usernames into a normalized, deduplicated handle list.
*
* Handles are lowercased and stripped of a leading `@` before deduplication so
* that `jack`, `@jack`, and `Jack` collapse to a single entry — avoiding a
* duplicate user-id lookup and a redundant `userIndex` slot in the packed
* pagination cursor. Both `validateConfig` and `listDocuments` call this so the
* cursor's `userIndex` stays aligned to the same array across pages.
*/
function parseUsernames(value: unknown): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of parseMultiValue(value)) {
const handle = raw.replace(/^@/, '').toLowerCase()
if (!handle || seen.has(handle)) continue
seen.add(handle)
out.push(handle)
}
return out
}
/**
* Reads and trims a string config field, returning undefined when blank.
*/
function readTrimmed(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
/**
* Performs an authenticated GET against the X API v2 and returns the parsed JSON.
*/
async function xApiGet(
path: string,
accessToken: string,
params?: Record<string, string>,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<unknown> {
const queryParams = params ? `?${new URLSearchParams(params).toString()}` : ''
const url = `${X_API_BASE}${path}${queryParams}`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
},
retryOptions
)
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`X API HTTP error: ${response.status} ${response.statusText} ${body}`.trim())
}
return response.json()
}
/**
* Resolves the authenticated user's numeric ID via GET /2/users/me.
*/
async function resolveMyUserId(
accessToken: string,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<string> {
const data = (await xApiGet('/users/me', accessToken, undefined, retryOptions)) as {
data?: { id?: string }
}
const id = data.data?.id
if (!id) throw new Error('Failed to resolve authenticated user ID')
return id
}
/**
* Resolves a public username to its numeric user ID via
* GET /2/users/by/username/:username.
*/
async function resolveUsernameId(
accessToken: string,
username: string,
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<string> {
const handle = username.trim().replace(/^@/, '')
const data = (await xApiGet(
`/users/by/username/${encodeURIComponent(handle)}`,
accessToken,
undefined,
retryOptions
)) as { data?: { id?: string }; errors?: Array<{ detail?: string }> }
const id = data.data?.id
if (!id) {
throw new Error(data.errors?.[0]?.detail || `User @${handle} not found`)
}
return id
}
/**
* Builds a deterministic, metadata-based content hash for a tweet.
*
* Tweets are immutable outside the brief post-publish edit window; an edit
* appends a new ID to `edit_history_tweet_ids`. We therefore key the hash on
* the edit-history length when present (so edits are detected as changes), and
* fall back to `created_at` when the field is absent.
*/
function tweetContentHash(tweet: XTweet): string {
const historyLength = Array.isArray(tweet.edit_history_tweet_ids)
? tweet.edit_history_tweet_ids.length
: undefined
const changeIndicator = historyLength ?? tweet.created_at ?? ''
return `x:${tweet.id}:${changeIndicator}`
}
/**
* Builds the canonical source URL for a tweet. When the author's username is
* unknown, falls back to the username-agnostic permalink which X redirects.
*/
function tweetSourceUrl(tweetId: string, username?: string): string {
if (username) return `https://x.com/${username}/status/${tweetId}`
return `https://x.com/i/web/status/${tweetId}`
}
/**
* Derives a short title from the tweet text (first line, truncated).
*/
function tweetTitle(text: string): string {
const firstLine = text.split('\n')[0].trim()
if (!firstLine) return 'Tweet'
return firstLine.length > 80 ? truncate(firstLine, 77) : firstLine
}
/**
* Converts a tweet (and its resolved author) into an ExternalDocument with
* inline content — the list API returns full text, so no deferral is needed.
*
* The author is the actual tweet author resolved from the `author_id` expansion,
* not the credential owner — important for bookmarks and likes, where most posts
* belong to other accounts.
*/
function tweetToDocument(tweet: XTweet, author?: XUser): ExternalDocument {
const metrics = tweet.public_metrics ?? {}
return {
externalId: tweet.id,
title: tweetTitle(tweet.text),
content: tweet.text,
mimeType: 'text/plain',
sourceUrl: tweetSourceUrl(tweet.id, author?.username),
contentHash: tweetContentHash(tweet),
metadata: {
author: author?.username ?? author?.name ?? undefined,
authorName: author?.name ?? undefined,
createdAt: tweet.created_at ?? undefined,
likeCount: metrics.like_count ?? 0,
retweetCount: metrics.retweet_count ?? 0,
replyCount: metrics.reply_count ?? 0,
quoteCount: metrics.quote_count ?? 0,
},
}
}
/**
* Maps tweets from a list response to documents, joining each tweet to its
* author via the `includes.users` expansion (matched on `author_id`).
*/
function mapTweets(response: XListResponse): ExternalDocument[] {
const usersById = new Map<string, XUser>()
for (const user of response.includes?.users ?? []) {
usersById.set(user.id, user)
}
const tweets = response.data ?? []
return tweets.map((tweet) => tweetToDocument(tweet, usersById.get(tweet.author_id ?? '')))
}
/**
* Returns the API path for a given mode and resolved user ID.
*/
function listPathForMode(mode: SyncMode, userId: string): string {
switch (mode) {
case 'bookmarks':
return `/users/${userId}/bookmarks`
case 'likes':
return `/users/${userId}/liked_tweets`
case 'mentions':
return `/users/${userId}/mentions`
default:
return `/users/${userId}/tweets`
}
}
/**
* Builds the query string for the active listing endpoint. `pageSize` is the
* per-request `max_results`, already clamped to the endpoint's valid range and
* to any remaining cap. `exclude` and date-range params are only attached for
* the modes whose endpoint supports them.
*/
function buildListParams(
sourceConfig: Record<string, unknown>,
mode: SyncMode,
pageSize: number,
cursor?: string
): Record<string, string> {
const params: Record<string, string> = {
max_results: String(pageSize),
'tweet.fields': TWEET_FIELDS,
expansions: 'author_id',
'user.fields': 'name,username',
}
if (EXCLUDE_CAPABLE_MODES.has(mode)) {
const includeReplies = readBooleanOption(sourceConfig.includeReplies, false)
const includeRetweets = readBooleanOption(sourceConfig.includeRetweets, false)
const exclude: string[] = []
if (!includeRetweets) exclude.push('retweets')
if (!includeReplies) exclude.push('replies')
if (exclude.length > 0) params.exclude = exclude.join(',')
}
if (DATE_RANGE_CAPABLE_MODES.has(mode)) {
const startTime = readTrimmed(sourceConfig.startTime)
const endTime = readTrimmed(sourceConfig.endTime)
if (startTime) params.start_time = startTime
if (endTime) params.end_time = endTime
}
if (cursor) params.pagination_token = cursor
return params
}
/**
* Clamps the requested page size to the endpoint's valid range and to the number
* of posts still needed under the cap. The user-tweets, mentions, and liked-tweets
* endpoints require `max_results` ≥ 5; only bookmarks accepts ≥ 1. We always request
* at least the endpoint minimum (over-fetch on the final page is trimmed afterward).
*/
function resolvePageSize(mode: SyncMode, remaining: number): number {
const floor = mode === 'bookmarks' ? 1 : MIN_PAGE_SIZE
if (remaining <= 0) return POSTS_PER_PAGE
return Math.max(floor, Math.min(POSTS_PER_PAGE, remaining))
}
export const xConnector: ConnectorConfig = {
...xConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const mode = resolveSyncMode(sourceConfig)
const maxPosts = sourceConfig.maxPosts ? Number(sourceConfig.maxPosts) : DEFAULT_MAX_POSTS
const collectedSoFar = (syncContext?.collected as number) ?? 0
if (maxPosts > 0 && collectedSoFar >= maxPosts) {
return { documents: [], hasMore: false }
}
// For the multi-username "user" mode, walk one username per cursor cycle. The
// cursor packs the username index and that user's pagination token; the shared
// cap is enforced across all users via syncContext.collected.
const usernames = mode === 'user' ? parseUsernames(sourceConfig.username) : []
if (mode === 'user' && usernames.length === 0) {
throw new Error('Username is required when Sync Mode is "Another user"')
}
let userIndex = 0
let pageToken = cursor
if (mode === 'user' && cursor) {
const sep = cursor.indexOf(':')
if (sep >= 0) {
userIndex = Number(cursor.slice(0, sep)) || 0
const token = cursor.slice(sep + 1)
pageToken = token.length > 0 ? token : undefined
}
}
// Resolve the target user ID. For `user` mode it depends on the current index
// (resolved per page, cheap); for self-modes it is cached on syncContext.
let userId: string
if (mode === 'user') {
userId = await resolveUsernameId(accessToken, usernames[userIndex])
} else {
userId = (syncContext?.userId as string | undefined) ?? (await resolveMyUserId(accessToken))
if (syncContext) syncContext.userId = userId
}
const remaining = maxPosts > 0 ? maxPosts - collectedSoFar : 0
const pageSize = resolvePageSize(mode, remaining)
const path = listPathForMode(mode, userId)
const params = buildListParams(sourceConfig, mode, pageSize, pageToken)
logger.info('Syncing X posts', { mode, userId, userIndex, maxPosts })
const response = (await xApiGet(path, accessToken, params)) as XListResponse
if (response.errors?.length && !response.data) {
throw new Error(response.errors[0]?.detail || response.errors[0]?.title || 'X API error')
}
let documents = mapTweets(response)
if (maxPosts > 0 && collectedSoFar + documents.length > maxPosts) {
documents = documents.slice(0, maxPosts - collectedSoFar)
}
const newCollected = collectedSoFar + documents.length
if (syncContext) syncContext.collected = newCollected
const capReached = maxPosts > 0 && newCollected >= maxPosts
const nextToken = response.meta?.next_token
// Advance pagination: continue the current user's pages, else move to the next
// username (user mode), else stop.
if (capReached) {
// We stopped before exhausting the source, so the listing is incomplete:
// older previously-synced posts may still exist beyond the `maxPosts` cap.
// Flag the sync as capped so the engine skips deletion reconciliation and
// does not soft-delete posts that simply fell outside this run's window.
// A forced full sync bypasses this guard and reconciles normally.
if (syncContext) syncContext.listingCapped = true
return { documents, hasMore: false }
}
if (mode === 'user') {
if (nextToken) {
return { documents, nextCursor: `${userIndex}:${nextToken}`, hasMore: true }
}
const nextUserIndex = userIndex + 1
if (nextUserIndex < usernames.length) {
return { documents, nextCursor: `${nextUserIndex}:`, hasMore: true }
}
return { documents, hasMore: false }
}
return {
documents,
nextCursor: nextToken ?? undefined,
hasMore: Boolean(nextToken),
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
try {
const response = (await xApiGet(`/tweets/${encodeURIComponent(externalId)}`, accessToken, {
'tweet.fields': TWEET_FIELDS,
expansions: 'author_id',
'user.fields': 'name,username',
})) as XSingleResponse
const tweet = response.data
if (!tweet) return null
const author = response.includes?.users?.find((u) => u.id === tweet.author_id)
return tweetToDocument(tweet, author)
} catch (error) {
logger.warn('Failed to get X tweet document', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const mode = resolveSyncMode(sourceConfig)
const usernames = mode === 'user' ? parseUsernames(sourceConfig.username) : []
const maxPosts = sourceConfig.maxPosts as string | undefined
if (mode === 'user' && usernames.length === 0) {
return { valid: false, error: 'Username is required when Sync Mode is "Another user"' }
}
if (maxPosts && (Number.isNaN(Number(maxPosts)) || Number(maxPosts) <= 0)) {
return { valid: false, error: 'Max posts must be a positive number' }
}
const startTime = readTrimmed(sourceConfig.startTime)
if (startTime && Number.isNaN(new Date(startTime).getTime())) {
return { valid: false, error: 'Start Time must be a valid ISO 8601 timestamp' }
}
const endTime = readTrimmed(sourceConfig.endTime)
if (endTime && Number.isNaN(new Date(endTime).getTime())) {
return { valid: false, error: 'End Time must be a valid ISO 8601 timestamp' }
}
try {
await resolveMyUserId(accessToken, VALIDATE_RETRY_OPTIONS)
if (mode === 'user') {
for (const username of usernames) {
await resolveUsernameId(accessToken, username, VALIDATE_RETRY_OPTIONS)
}
}
return { valid: true }
} catch (error) {
return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.author === 'string') {
result.author = metadata.author
}
const createdAt = parseTagDate(metadata.createdAt)
if (createdAt) {
result.createdAt = createdAt
}
if (metadata.likeCount != null) {
const num = Number(metadata.likeCount)
if (!Number.isNaN(num)) result.likeCount = num
}
if (metadata.retweetCount != null) {
const num = Number(metadata.retweetCount)
if (!Number.isNaN(num)) result.retweetCount = num
}
return result
},
}