@lobu/cli 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +12 -12
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +26 -0
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +1 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.js +6 -6
  10. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  11. package/dist/commands/agent.d.ts +7 -0
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +65 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/chat.d.ts +12 -9
  16. package/dist/commands/chat.d.ts.map +1 -1
  17. package/dist/commands/chat.js +117 -56
  18. package/dist/commands/chat.js.map +1 -1
  19. package/dist/commands/dev.d.ts +15 -7
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +79 -44
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/doctor.d.ts +1 -0
  24. package/dist/commands/doctor.d.ts.map +1 -1
  25. package/dist/commands/doctor.js +136 -0
  26. package/dist/commands/doctor.js.map +1 -1
  27. package/dist/commands/eval.d.ts +8 -0
  28. package/dist/commands/eval.d.ts.map +1 -1
  29. package/dist/commands/eval.js +56 -1
  30. package/dist/commands/eval.js.map +1 -1
  31. package/dist/commands/init.d.ts +20 -5
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +332 -183
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/commands/link.d.ts +11 -0
  36. package/dist/commands/link.d.ts.map +1 -0
  37. package/dist/commands/link.js +28 -0
  38. package/dist/commands/link.js.map +1 -0
  39. package/dist/commands/login.d.ts.map +1 -1
  40. package/dist/commands/login.js +14 -2
  41. package/dist/commands/login.js.map +1 -1
  42. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  43. package/dist/commands/memory/_lib/browser-auth-cmd.js +4 -4
  44. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  45. package/dist/commands/memory/_lib/install-targets.d.ts.map +1 -1
  46. package/dist/commands/memory/_lib/install-targets.js +1 -5
  47. package/dist/commands/memory/_lib/install-targets.js.map +1 -1
  48. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  49. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  50. package/dist/commands/memory/_lib/mcp.js +24 -12
  51. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  52. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  53. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  54. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  55. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  56. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  57. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  58. package/dist/commands/memory/_lib/schema.d.ts +2 -2
  59. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  60. package/dist/commands/memory/_lib/schema.js +3 -3
  61. package/dist/commands/memory/_lib/schema.js.map +1 -1
  62. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/seed-cmd.js +5 -6
  64. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  65. package/dist/commands/memory/run.d.ts.map +1 -1
  66. package/dist/commands/memory/run.js +2 -2
  67. package/dist/commands/memory/run.js.map +1 -1
  68. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  69. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  70. package/dist/commands/platforms/platform-prompts.js +54 -8
  71. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  72. package/dist/commands/telemetry.d.ts +10 -0
  73. package/dist/commands/telemetry.d.ts.map +1 -0
  74. package/dist/commands/telemetry.js +68 -0
  75. package/dist/commands/telemetry.js.map +1 -0
  76. package/dist/commands/whoami.d.ts.map +1 -1
  77. package/dist/commands/whoami.js +1 -1
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/connectors/README.md +534 -0
  80. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  81. package/dist/connectors/browser-scraper-utils.ts +214 -0
  82. package/dist/connectors/capterra.ts +273 -0
  83. package/dist/connectors/g2.ts +286 -0
  84. package/dist/connectors/github.ts +1553 -0
  85. package/dist/connectors/glassdoor.ts +291 -0
  86. package/dist/connectors/gmaps.ts +197 -0
  87. package/dist/connectors/google_calendar.ts +631 -0
  88. package/dist/connectors/google_gmail.ts +751 -0
  89. package/dist/connectors/google_photos.ts +776 -0
  90. package/dist/connectors/google_play.ts +342 -0
  91. package/dist/connectors/hackernews.ts +471 -0
  92. package/dist/connectors/index.ts +23 -0
  93. package/dist/connectors/ios_appstore.ts +226 -0
  94. package/dist/connectors/linkedin.ts +471 -0
  95. package/dist/connectors/microsoft_outlook.ts +410 -0
  96. package/dist/connectors/producthunt.ts +471 -0
  97. package/dist/connectors/reddit.ts +600 -0
  98. package/dist/connectors/rss.ts +448 -0
  99. package/dist/connectors/spotify.ts +590 -0
  100. package/dist/connectors/trustpilot.ts +199 -0
  101. package/dist/connectors/website.ts +629 -0
  102. package/dist/connectors/whatsapp.ts +1073 -0
  103. package/dist/connectors/x.ts +526 -0
  104. package/dist/connectors/youtube.ts +666 -0
  105. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  106. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  107. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  108. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  109. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  110. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  111. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  112. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  113. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  114. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  115. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  116. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  117. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  118. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  119. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  120. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  121. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  122. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  123. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  124. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  125. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  126. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  127. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  128. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  129. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  130. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  131. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  132. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  133. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  134. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  135. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  136. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  137. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  138. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  139. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  140. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  141. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  142. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  143. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  144. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  145. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  146. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  147. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  148. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  149. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  150. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +147 -23
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/api-client.d.ts +4 -8
  155. package/dist/internal/api-client.d.ts.map +1 -1
  156. package/dist/internal/api-client.js +1 -1
  157. package/dist/internal/api-client.js.map +1 -1
  158. package/dist/internal/context.js +2 -2
  159. package/dist/internal/context.js.map +1 -1
  160. package/dist/internal/credentials.d.ts.map +1 -1
  161. package/dist/internal/credentials.js +6 -1
  162. package/dist/internal/credentials.js.map +1 -1
  163. package/dist/internal/index.d.ts +2 -3
  164. package/dist/internal/index.d.ts.map +1 -1
  165. package/dist/internal/index.js +2 -2
  166. package/dist/internal/index.js.map +1 -1
  167. package/dist/internal/oauth.d.ts +7 -6
  168. package/dist/internal/oauth.d.ts.map +1 -1
  169. package/dist/internal/oauth.js +3 -3
  170. package/dist/internal/project-link.d.ts +10 -0
  171. package/dist/internal/project-link.d.ts.map +1 -0
  172. package/dist/internal/project-link.js +48 -0
  173. package/dist/internal/project-link.js.map +1 -0
  174. package/dist/providers.json +2 -2
  175. package/dist/server.bundle.mjs +3173 -4404
  176. package/dist/start-local.bundle.mjs +71481 -0
  177. package/dist/templates/README.md.tmpl +10 -11
  178. package/package.json +14 -12
  179. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  180. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  181. package/dist/__tests__/chat.integration.test.js +0 -337
  182. package/dist/__tests__/chat.integration.test.js.map +0 -1
  183. package/dist/__tests__/dev.test.d.ts +0 -2
  184. package/dist/__tests__/dev.test.d.ts.map +0 -1
  185. package/dist/__tests__/dev.test.js +0 -25
  186. package/dist/__tests__/dev.test.js.map +0 -1
  187. package/dist/__tests__/init-memory.test.d.ts +0 -2
  188. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  189. package/dist/__tests__/init-memory.test.js +0 -45
  190. package/dist/__tests__/init-memory.test.js.map +0 -1
  191. package/dist/__tests__/token.test.d.ts +0 -2
  192. package/dist/__tests__/token.test.d.ts.map +0 -1
  193. package/dist/__tests__/token.test.js +0 -52
  194. package/dist/__tests__/token.test.js.map +0 -1
  195. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  196. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  197. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  198. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  199. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  200. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  201. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  202. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  203. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  204. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  205. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  206. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  207. package/dist/commands/apply.d.ts +0 -3
  208. package/dist/commands/apply.d.ts.map +0 -1
  209. package/dist/commands/apply.js +0 -5
  210. package/dist/commands/apply.js.map +0 -1
  211. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  212. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  213. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  214. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  215. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  216. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  217. package/dist/internal/__tests__/api-client.test.js +0 -95
  218. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  219. package/dist/internal/__tests__/context.test.d.ts +0 -2
  220. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  221. package/dist/internal/__tests__/context.test.js +0 -77
  222. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -0,0 +1,526 @@
1
+ /**
2
+ * X (Twitter) Connector (V1 runtime)
3
+ *
4
+ * Supports two auth modes:
5
+ * - OAuth 2.0 user context against the X API v2 (preferred when available)
6
+ * - Browser session cookies for scraping/network interception fallback
7
+ */
8
+
9
+ import {
10
+ type ActionContext,
11
+ type ActionResult,
12
+ browserNetworkSync,
13
+ type ConnectorDefinition,
14
+ ConnectorRuntime,
15
+ calculateEngagementScore,
16
+ type EventEnvelope,
17
+ type SyncContext,
18
+ type SyncResult,
19
+ } from '@lobu/connector-sdk';
20
+ import { getBrowserCookies, validateCookieNotExpired } from './browser-scraper-utils';
21
+
22
+ interface XCheckpoint {
23
+ last_tweet_id?: string;
24
+ last_timestamp?: Date | string;
25
+ }
26
+
27
+ interface XTweet {
28
+ id: string;
29
+ text: string;
30
+ username: string;
31
+ likes: number;
32
+ retweets: number;
33
+ replies: number;
34
+ quotes: number;
35
+ publishedAt: Date;
36
+ isRetweet: boolean;
37
+ isReply: boolean;
38
+ isQuote: boolean;
39
+ conversationId?: string;
40
+ inReplyToId?: string;
41
+ }
42
+
43
+ interface XApiTweetRecord {
44
+ id: string;
45
+ text?: string;
46
+ author_id?: string;
47
+ created_at?: string;
48
+ conversation_id?: string;
49
+ public_metrics?: {
50
+ like_count?: number;
51
+ retweet_count?: number;
52
+ reply_count?: number;
53
+ quote_count?: number;
54
+ };
55
+ referenced_tweets?: Array<{ type?: string; id?: string }>;
56
+ }
57
+
58
+ interface XApiUserRecord {
59
+ id: string;
60
+ username?: string;
61
+ name?: string;
62
+ }
63
+
64
+ interface XApiListResponse {
65
+ data?: XApiTweetRecord[];
66
+ includes?: {
67
+ users?: XApiUserRecord[];
68
+ };
69
+ meta?: {
70
+ next_token?: string;
71
+ result_count?: number;
72
+ };
73
+ errors?: Array<{ detail?: string; message?: string }>;
74
+ }
75
+
76
+ function normalizeHandle(input: string | undefined): string | null {
77
+ if (!input) return null;
78
+ const trimmed = input.trim().replace(/^@+/, '');
79
+ if (!trimmed) return null;
80
+ const match = trimmed.match(/^[A-Za-z0-9_]{1,15}/);
81
+ return match?.[0] ?? null;
82
+ }
83
+
84
+ function buildSearchQuery(config: Record<string, unknown>): string {
85
+ const explicitSearchQuery =
86
+ typeof config.search_query === 'string' ? config.search_query.trim() : '';
87
+ if (explicitSearchQuery.length > 0) {
88
+ return explicitSearchQuery;
89
+ }
90
+
91
+ const accountHandle = normalizeHandle(
92
+ typeof config.account_handle === 'string' ? config.account_handle : undefined
93
+ );
94
+ if (!accountHandle) {
95
+ throw new Error('search_query or account_handle is required');
96
+ }
97
+
98
+ return `from:${accountHandle}`;
99
+ }
100
+
101
+ function buildApiTweet(
102
+ tweet: XApiTweetRecord,
103
+ usernameById: Map<string, string>,
104
+ defaultUsername?: string
105
+ ): XTweet | null {
106
+ if (!tweet.id || !tweet.text || !tweet.created_at) return null;
107
+
108
+ const referenced = tweet.referenced_tweets ?? [];
109
+ const publicMetrics = tweet.public_metrics ?? {};
110
+ const inReplyToId = referenced.find((ref) => ref.type === 'replied_to')?.id;
111
+
112
+ return {
113
+ id: tweet.id,
114
+ text: tweet.text,
115
+ username: usernameById.get(tweet.author_id ?? '') ?? defaultUsername ?? '',
116
+ likes: publicMetrics.like_count ?? 0,
117
+ retweets: publicMetrics.retweet_count ?? 0,
118
+ replies: publicMetrics.reply_count ?? 0,
119
+ quotes: publicMetrics.quote_count ?? 0,
120
+ publishedAt: new Date(tweet.created_at),
121
+ isRetweet: referenced.some((ref) => ref.type === 'retweeted'),
122
+ isReply: Boolean(inReplyToId),
123
+ isQuote: referenced.some((ref) => ref.type === 'quoted'),
124
+ conversationId: tweet.conversation_id,
125
+ inReplyToId,
126
+ };
127
+ }
128
+
129
+ function parseApiListResponse(json: XApiListResponse, defaultUsername?: string): XTweet[] {
130
+ const users = json.includes?.users ?? [];
131
+ const usernameById = new Map(users.map((user) => [user.id, user.username ?? '']));
132
+
133
+ return (json.data ?? [])
134
+ .map((tweet) => buildApiTweet(tweet, usernameById, defaultUsername))
135
+ .filter((tweet): tweet is XTweet => tweet !== null);
136
+ }
137
+
138
+ /** Extract tweets from X's GraphQL SearchTimeline response */
139
+ function parseBrowserSearchResponse(_url: string, json: unknown): XTweet[] {
140
+ const tweets: XTweet[] = [];
141
+ const data = json as any;
142
+
143
+ const instructions =
144
+ data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions ?? [];
145
+
146
+ for (const instruction of instructions) {
147
+ const entries = instruction.entries ?? instruction.moduleItems ?? [];
148
+ for (const entry of entries) {
149
+ const result =
150
+ entry?.content?.itemContent?.tweet_results?.result ??
151
+ entry?.item?.itemContent?.tweet_results?.result;
152
+ if (!result) continue;
153
+
154
+ const legacy = result.legacy ?? result.tweet?.legacy;
155
+ if (!legacy?.full_text) continue;
156
+
157
+ const userResult =
158
+ result.core?.user_results?.result ?? result.tweet?.core?.user_results?.result;
159
+ const screenName = userResult?.core?.screen_name ?? userResult?.legacy?.screen_name ?? '';
160
+
161
+ tweets.push({
162
+ id: legacy.id_str ?? result.rest_id ?? entry.entryId,
163
+ text: legacy.full_text,
164
+ username: screenName,
165
+ likes: legacy.favorite_count ?? 0,
166
+ retweets: legacy.retweet_count ?? 0,
167
+ replies: legacy.reply_count ?? 0,
168
+ quotes: legacy.quote_count ?? 0,
169
+ publishedAt: new Date(legacy.created_at),
170
+ isRetweet: !!legacy.retweeted_status_result,
171
+ isReply: !!legacy.in_reply_to_status_id_str,
172
+ isQuote: !!legacy.is_quote_status,
173
+ conversationId: legacy.conversation_id_str,
174
+ inReplyToId: legacy.in_reply_to_status_id_str,
175
+ });
176
+ }
177
+ }
178
+
179
+ return tweets;
180
+ }
181
+
182
+ function tweetToEvent(tweet: XTweet): EventEnvelope {
183
+ const engagementData = {
184
+ reply_count: tweet.replies,
185
+ upvotes: tweet.likes,
186
+ score: tweet.retweets * 2 + tweet.likes,
187
+ };
188
+
189
+ return {
190
+ origin_id: tweet.id,
191
+ payload_text: tweet.text,
192
+ author_name: tweet.username ? `@${tweet.username}` : undefined,
193
+ occurred_at: tweet.publishedAt,
194
+ origin_type: tweet.isReply ? 'reply' : 'tweet',
195
+ score: calculateEngagementScore('x', engagementData),
196
+ source_url: `https://x.com/${tweet.username || 'i'}/status/${tweet.id}`,
197
+ origin_parent_id: tweet.inReplyToId || undefined,
198
+ metadata: {
199
+ ...engagementData,
200
+ retweet_count: tweet.retweets,
201
+ quote_count: tweet.quotes,
202
+ is_retweet: tweet.isRetweet,
203
+ is_reply: tweet.isReply,
204
+ is_quote: tweet.isQuote,
205
+ ...(tweet.conversationId ? { conversation_id: tweet.conversationId } : {}),
206
+ },
207
+ };
208
+ }
209
+
210
+ function finalizeSyncResult(
211
+ tweets: XTweet[],
212
+ checkpoint: XCheckpoint,
213
+ metadata: Record<string, unknown>,
214
+ authUpdate?: Record<string, unknown> | null
215
+ ): SyncResult {
216
+ const seenIds = new Set<string>();
217
+ const deduped = tweets.filter((tweet) => {
218
+ if (!tweet.id || !tweet.text || seenIds.has(tweet.id)) return false;
219
+ seenIds.add(tweet.id);
220
+ if (checkpoint.last_tweet_id && tweet.id === checkpoint.last_tweet_id) return false;
221
+ return true;
222
+ });
223
+
224
+ const events: EventEnvelope[] = deduped.map(tweetToEvent);
225
+ events.sort((a, b) => new Date(b.occurred_at).getTime() - new Date(a.occurred_at).getTime());
226
+
227
+ const newestTweetId = events.length > 0 ? events[0].origin_id : checkpoint.last_tweet_id;
228
+ const newCheckpoint: XCheckpoint = {
229
+ last_tweet_id: newestTweetId,
230
+ last_timestamp: events.length > 0 ? events[0].occurred_at : checkpoint.last_timestamp,
231
+ };
232
+
233
+ return {
234
+ events,
235
+ checkpoint: newCheckpoint as unknown as Record<string, unknown>,
236
+ ...(authUpdate ? { auth_update: authUpdate } : {}),
237
+ metadata: {
238
+ items_found: events.length,
239
+ items_skipped: tweets.length - deduped.length,
240
+ ...metadata,
241
+ },
242
+ };
243
+ }
244
+
245
+ async function fetchJson<T>(url: URL, accessToken: string): Promise<T> {
246
+ const response = await fetch(url, {
247
+ headers: {
248
+ Authorization: `Bearer ${accessToken}`,
249
+ 'Content-Type': 'application/json',
250
+ },
251
+ });
252
+
253
+ if (!response.ok) {
254
+ const text = await response.text();
255
+ throw new Error(`X API request failed (${response.status}): ${text}`);
256
+ }
257
+
258
+ return (await response.json()) as T;
259
+ }
260
+
261
+ async function resolveUserId(handle: string, accessToken: string): Promise<string> {
262
+ const url = new URL(`https://api.x.com/2/users/by/username/${encodeURIComponent(handle)}`);
263
+ const json = await fetchJson<{ data?: { id?: string } }>(url, accessToken);
264
+ const userId = json.data?.id;
265
+ if (!userId) {
266
+ throw new Error(`Could not resolve X user id for @${handle}`);
267
+ }
268
+ return userId;
269
+ }
270
+
271
+ async function syncViaOAuthApi(
272
+ ctx: SyncContext,
273
+ config: Record<string, unknown>,
274
+ checkpoint: XCheckpoint
275
+ ): Promise<SyncResult> {
276
+ const accessToken = ctx.credentials?.accessToken;
277
+ if (!accessToken) {
278
+ throw new Error('OAuth access token missing for X connector');
279
+ }
280
+
281
+ const maxPages = Math.max(1, Math.min(50, Number(config.max_scrolls ?? 10) || 10));
282
+ const accountHandle = normalizeHandle(
283
+ typeof config.account_handle === 'string' ? config.account_handle : undefined
284
+ );
285
+ const explicitSearchQuery =
286
+ typeof config.search_query === 'string' ? config.search_query.trim() : '';
287
+
288
+ const tweets: XTweet[] = [];
289
+ let pageCount = 0;
290
+ let nextToken: string | undefined;
291
+
292
+ if (explicitSearchQuery.length === 0 && accountHandle) {
293
+ const userId = await resolveUserId(accountHandle, accessToken);
294
+
295
+ for (let page = 0; page < maxPages; page += 1) {
296
+ const url = new URL(`https://api.x.com/2/users/${encodeURIComponent(userId)}/tweets`);
297
+ url.searchParams.set('max_results', '100');
298
+ url.searchParams.set(
299
+ 'tweet.fields',
300
+ 'author_id,conversation_id,created_at,public_metrics,referenced_tweets'
301
+ );
302
+ if (checkpoint.last_tweet_id) {
303
+ url.searchParams.set('since_id', checkpoint.last_tweet_id);
304
+ }
305
+ if (nextToken) {
306
+ url.searchParams.set('pagination_token', nextToken);
307
+ }
308
+
309
+ const json = await fetchJson<XApiListResponse>(url, accessToken);
310
+ tweets.push(...parseApiListResponse(json, accountHandle));
311
+ pageCount += 1;
312
+
313
+ nextToken = json.meta?.next_token;
314
+ if (!nextToken) break;
315
+ }
316
+ } else {
317
+ const searchQuery = buildSearchQuery(config);
318
+
319
+ for (let page = 0; page < maxPages; page += 1) {
320
+ const url = new URL('https://api.x.com/2/tweets/search/recent');
321
+ url.searchParams.set('query', searchQuery);
322
+ url.searchParams.set('max_results', '100');
323
+ url.searchParams.set(
324
+ 'tweet.fields',
325
+ 'author_id,conversation_id,created_at,public_metrics,referenced_tweets'
326
+ );
327
+ url.searchParams.set('expansions', 'author_id');
328
+ url.searchParams.set('user.fields', 'username');
329
+ if (checkpoint.last_tweet_id) {
330
+ url.searchParams.set('since_id', checkpoint.last_tweet_id);
331
+ }
332
+ if (nextToken) {
333
+ url.searchParams.set('next_token', nextToken);
334
+ }
335
+
336
+ const json = await fetchJson<XApiListResponse>(url, accessToken);
337
+ tweets.push(...parseApiListResponse(json));
338
+ pageCount += 1;
339
+
340
+ nextToken = json.meta?.next_token;
341
+ if (!nextToken) break;
342
+ }
343
+ }
344
+
345
+ return finalizeSyncResult(tweets, checkpoint, {
346
+ backend: 'oauth_api',
347
+ api_calls: pageCount,
348
+ });
349
+ }
350
+
351
+ async function syncViaBrowser(
352
+ ctx: SyncContext,
353
+ config: Record<string, unknown>,
354
+ checkpoint: XCheckpoint
355
+ ): Promise<SyncResult> {
356
+ const searchQuery = buildSearchQuery(config);
357
+ const maxScrolls = (config.max_scrolls as number) ?? 10;
358
+ const searchFilter = (config.search_filter as string) ?? 'live';
359
+ const searchUrl = `https://x.com/search?q=${encodeURIComponent(searchQuery)}&src=typed_query&f=${searchFilter}`;
360
+
361
+ let cookies: any[] = [];
362
+ try {
363
+ cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'x');
364
+ validateCookieNotExpired(cookies, 'auth_token', 'x');
365
+ } catch {
366
+ // No stored cookies — CDP will be the only path
367
+ }
368
+
369
+ const result = await browserNetworkSync<XTweet>({
370
+ config: {
371
+ interceptPatterns: [/\/i\/api\/graphql\/.*Search/],
372
+ authDomains: ['x.com', '.x.com'],
373
+ maxScrolls,
374
+ scrollDelayMs: 2000,
375
+ responseTimeoutMs: 5000,
376
+ navigationTimeoutMs: 15000,
377
+ },
378
+ url: searchUrl,
379
+ cdpUrl: 'auto',
380
+ cookies,
381
+ parseResponse: parseBrowserSearchResponse,
382
+ checkAuth: async (page) => {
383
+ const url = page.url();
384
+ return !url.includes('/login') && !url.includes('/i/flow/login');
385
+ },
386
+ });
387
+
388
+ return finalizeSyncResult(
389
+ result.items,
390
+ checkpoint,
391
+ {
392
+ backend: result.backend,
393
+ api_calls: result.apiCallCount,
394
+ },
395
+ { cookies: result.cookies }
396
+ );
397
+ }
398
+
399
+ const configSchema = {
400
+ type: 'object',
401
+ anyOf: [{ required: ['search_query'] }, { required: ['account_handle'] }],
402
+ properties: {
403
+ search_query: {
404
+ type: 'string',
405
+ minLength: 1,
406
+ description: 'Search query for tweets (e.g., "nodejs", "#programming", "from:user")',
407
+ },
408
+ account_handle: {
409
+ type: 'string',
410
+ minLength: 1,
411
+ description:
412
+ 'Optional X handle to track directly (e.g. "openai" or "@openai"). Used when search_query is omitted.',
413
+ },
414
+ search_filter: {
415
+ type: 'string',
416
+ enum: ['live', 'top'],
417
+ default: 'live',
418
+ description:
419
+ 'Search tab: "live" for Latest (chronological), "top" for Top (popular/algorithmic)',
420
+ },
421
+ max_scrolls: {
422
+ type: 'integer',
423
+ minimum: 1,
424
+ maximum: 50,
425
+ default: 10,
426
+ description: 'Maximum pagination iterations (default: 10, API pages or browser scrolls)',
427
+ },
428
+ },
429
+ };
430
+
431
+ const engagementMetadataSchema = {
432
+ type: 'object',
433
+ properties: {
434
+ reply_count: { type: 'number' },
435
+ upvotes: { type: 'number', description: 'Likes' },
436
+ score: { type: 'number' },
437
+ retweet_count: { type: 'number' },
438
+ quote_count: { type: 'number' },
439
+ is_retweet: { type: 'boolean' },
440
+ is_reply: { type: 'boolean' },
441
+ is_quote: { type: 'boolean' },
442
+ },
443
+ };
444
+
445
+ export default class XConnector extends ConnectorRuntime {
446
+ readonly definition: ConnectorDefinition = {
447
+ key: 'x',
448
+ name: 'X (Twitter)',
449
+ description: 'Fetches tweets via the X API v2 with browser-cookie fallback.',
450
+ version: '2.1.0',
451
+ faviconDomain: 'x.com',
452
+ authSchema: {
453
+ methods: [
454
+ {
455
+ type: 'oauth',
456
+ provider: 'twitter',
457
+ requiredScopes: ['tweet.read', 'users.read', 'offline.access'],
458
+ optionalScopes: ['users.email', 'follows.read', 'like.read', 'bookmark.read'],
459
+ loginScopes: ['users.read', 'tweet.read', 'offline.access', 'users.email'],
460
+ authorizationUrl: 'https://x.com/i/oauth2/authorize',
461
+ tokenUrl: 'https://api.x.com/2/oauth2/token',
462
+ userinfoUrl: 'https://api.x.com/2/users/me?user.fields=username',
463
+ tokenEndpointAuthMethod: 'client_secret_basic',
464
+ usePkce: true,
465
+ clientIdKey: 'TWITTER_CLIENT_ID',
466
+ clientSecretKey: 'TWITTER_CLIENT_SECRET',
467
+ description:
468
+ 'Preferred auth mode. Uses the X OAuth 2.0 API for server-side syncs and login.',
469
+ setupInstructions:
470
+ 'Create an X OAuth 2.0 app, add {{redirect_uri}} as the callback URL, then paste the client ID and client secret below.',
471
+ loginProvisioning: {
472
+ autoCreateConnection: true,
473
+ },
474
+ },
475
+ {
476
+ type: 'browser',
477
+ capture: 'cli',
478
+ requiredDomains: ['x.com', '.x.com'],
479
+ description:
480
+ 'Fallback for browser-based scraping when API access is unavailable or insufficient.',
481
+ },
482
+ ],
483
+ },
484
+ feeds: {
485
+ tweets: {
486
+ key: 'tweets',
487
+ name: 'Tweets',
488
+ requiredScopes: ['tweet.read', 'users.read'],
489
+ description: 'Search and sync tweets matching a query or a specific account handle.',
490
+ configSchema,
491
+ eventKinds: {
492
+ tweet: {
493
+ description: 'A tweet (original post)',
494
+ metadataSchema: engagementMetadataSchema,
495
+ },
496
+ reply: {
497
+ description: 'A reply to a tweet',
498
+ metadataSchema: {
499
+ ...engagementMetadataSchema,
500
+ properties: {
501
+ ...engagementMetadataSchema.properties,
502
+ conversation_id: { type: 'string' },
503
+ },
504
+ },
505
+ },
506
+ },
507
+ },
508
+ },
509
+ optionsSchema: configSchema,
510
+ };
511
+
512
+ async sync(ctx: SyncContext): Promise<SyncResult> {
513
+ const config = ctx.config as Record<string, unknown>;
514
+ const checkpoint = (ctx.checkpoint ?? {}) as XCheckpoint;
515
+
516
+ if (ctx.credentials?.accessToken) {
517
+ return syncViaOAuthApi(ctx, config, checkpoint);
518
+ }
519
+
520
+ return syncViaBrowser(ctx, config, checkpoint);
521
+ }
522
+
523
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
524
+ return { success: false, error: 'Actions not supported' };
525
+ }
526
+ }