@lobu/cli 6.0.1 → 7.0.0

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 (265) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +11 -11
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +38 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +574 -40
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +180 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/client.js +308 -28
  10. package/dist/commands/_lib/apply/client.js.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.d.ts +134 -3
  12. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  13. package/dist/commands/_lib/apply/desired-state.js +703 -89
  14. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  15. package/dist/commands/_lib/apply/diff.d.ts +61 -3
  16. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  17. package/dist/commands/_lib/apply/diff.js +382 -92
  18. package/dist/commands/_lib/apply/diff.js.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  20. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  21. package/dist/commands/_lib/apply/prompt.js +16 -0
  22. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  23. package/dist/commands/_lib/apply/render.d.ts +9 -0
  24. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  25. package/dist/commands/_lib/apply/render.js +80 -3
  26. package/dist/commands/_lib/apply/render.js.map +1 -1
  27. package/dist/commands/agent.d.ts +7 -0
  28. package/dist/commands/agent.d.ts.map +1 -1
  29. package/dist/commands/agent.js +65 -1
  30. package/dist/commands/agent.js.map +1 -1
  31. package/dist/commands/chat.d.ts +12 -9
  32. package/dist/commands/chat.d.ts.map +1 -1
  33. package/dist/commands/chat.js +125 -57
  34. package/dist/commands/chat.js.map +1 -1
  35. package/dist/commands/dev.d.ts +23 -7
  36. package/dist/commands/dev.d.ts.map +1 -1
  37. package/dist/commands/dev.js +197 -49
  38. package/dist/commands/dev.js.map +1 -1
  39. package/dist/commands/doctor.d.ts +1 -0
  40. package/dist/commands/doctor.d.ts.map +1 -1
  41. package/dist/commands/doctor.js +136 -0
  42. package/dist/commands/doctor.js.map +1 -1
  43. package/dist/commands/eval.d.ts +8 -0
  44. package/dist/commands/eval.d.ts.map +1 -1
  45. package/dist/commands/eval.js +72 -6
  46. package/dist/commands/eval.js.map +1 -1
  47. package/dist/commands/init.d.ts +22 -5
  48. package/dist/commands/init.d.ts.map +1 -1
  49. package/dist/commands/init.js +355 -182
  50. package/dist/commands/init.js.map +1 -1
  51. package/dist/commands/link.d.ts +11 -0
  52. package/dist/commands/link.d.ts.map +1 -0
  53. package/dist/commands/link.js +28 -0
  54. package/dist/commands/link.js.map +1 -0
  55. package/dist/commands/login.d.ts.map +1 -1
  56. package/dist/commands/login.js +14 -2
  57. package/dist/commands/login.js.map +1 -1
  58. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  59. package/dist/commands/memory/_lib/browser-auth-cmd.js +3 -3
  60. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  61. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  62. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/mcp.js +24 -12
  64. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  65. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  66. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  67. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  68. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  69. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  70. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  71. package/dist/commands/memory/_lib/schema.d.ts +29 -2
  72. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  73. package/dist/commands/memory/_lib/schema.js +121 -5
  74. package/dist/commands/memory/_lib/schema.js.map +1 -1
  75. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  76. package/dist/commands/memory/_lib/seed-cmd.js +46 -24
  77. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  78. package/dist/commands/memory/run.d.ts.map +1 -1
  79. package/dist/commands/memory/run.js +2 -2
  80. package/dist/commands/memory/run.js.map +1 -1
  81. package/dist/commands/org.d.ts +4 -0
  82. package/dist/commands/org.d.ts.map +1 -1
  83. package/dist/commands/org.js +10 -0
  84. package/dist/commands/org.js.map +1 -1
  85. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  86. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  87. package/dist/commands/platforms/platform-prompts.js +54 -8
  88. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  89. package/dist/commands/telemetry.d.ts +10 -0
  90. package/dist/commands/telemetry.d.ts.map +1 -0
  91. package/dist/commands/telemetry.js +68 -0
  92. package/dist/commands/telemetry.js.map +1 -0
  93. package/dist/commands/token.d.ts +9 -0
  94. package/dist/commands/token.d.ts.map +1 -1
  95. package/dist/commands/token.js +54 -0
  96. package/dist/commands/token.js.map +1 -1
  97. package/dist/commands/whoami.d.ts.map +1 -1
  98. package/dist/commands/whoami.js +1 -1
  99. package/dist/commands/whoami.js.map +1 -1
  100. package/dist/connectors/README.md +534 -0
  101. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  102. package/dist/connectors/apple_health.ts +138 -0
  103. package/dist/connectors/apple_screen_time.ts +82 -0
  104. package/dist/connectors/browser-scraper-utils.ts +246 -0
  105. package/dist/connectors/capterra.ts +277 -0
  106. package/dist/connectors/g2.ts +290 -0
  107. package/dist/connectors/github.ts +1530 -0
  108. package/dist/connectors/glassdoor.ts +295 -0
  109. package/dist/connectors/gmaps.ts +197 -0
  110. package/dist/connectors/google_calendar.ts +641 -0
  111. package/dist/connectors/google_gmail.ts +754 -0
  112. package/dist/connectors/google_photos.ts +776 -0
  113. package/dist/connectors/google_play.ts +349 -0
  114. package/dist/connectors/hackernews.ts +471 -0
  115. package/dist/connectors/index.ts +28 -0
  116. package/dist/connectors/ios_appstore.ts +226 -0
  117. package/dist/connectors/linkedin.ts +494 -0
  118. package/dist/connectors/local_directory.ts +91 -0
  119. package/dist/connectors/microsoft_outlook.ts +410 -0
  120. package/dist/connectors/producthunt.ts +471 -0
  121. package/dist/connectors/reddit.ts +600 -0
  122. package/dist/connectors/revolut.ts +572 -0
  123. package/dist/connectors/rss.ts +448 -0
  124. package/dist/connectors/spotify.ts +590 -0
  125. package/dist/connectors/trustpilot.ts +203 -0
  126. package/dist/connectors/website.ts +629 -0
  127. package/dist/connectors/whatsapp.ts +1081 -0
  128. package/dist/connectors/whatsapp_local.ts +125 -0
  129. package/dist/connectors/x.ts +536 -0
  130. package/dist/connectors/youtube.ts +666 -0
  131. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  132. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  133. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  134. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  135. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  136. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  137. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  138. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  139. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  140. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  141. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  142. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  143. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  144. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  145. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  146. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  147. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  148. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  149. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  150. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  151. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  152. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  153. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  154. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  155. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  156. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  157. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  158. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  159. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  160. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  161. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  162. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  163. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  164. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  165. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  166. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  167. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  168. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  169. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  170. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  171. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  172. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  173. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  174. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  175. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  176. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  177. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  178. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  179. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  180. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  181. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  182. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  183. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  184. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  185. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  186. package/dist/eval/types.d.ts +2 -0
  187. package/dist/eval/types.d.ts.map +1 -1
  188. package/dist/index.d.ts +11 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +210 -132
  191. package/dist/index.js.map +1 -1
  192. package/dist/internal/api-client.d.ts +4 -8
  193. package/dist/internal/api-client.d.ts.map +1 -1
  194. package/dist/internal/api-client.js +1 -1
  195. package/dist/internal/api-client.js.map +1 -1
  196. package/dist/internal/context.js +2 -2
  197. package/dist/internal/context.js.map +1 -1
  198. package/dist/internal/credentials.d.ts.map +1 -1
  199. package/dist/internal/credentials.js +6 -1
  200. package/dist/internal/credentials.js.map +1 -1
  201. package/dist/internal/gateway-url.d.ts +14 -0
  202. package/dist/internal/gateway-url.d.ts.map +1 -1
  203. package/dist/internal/gateway-url.js +19 -0
  204. package/dist/internal/gateway-url.js.map +1 -1
  205. package/dist/internal/index.d.ts +3 -4
  206. package/dist/internal/index.d.ts.map +1 -1
  207. package/dist/internal/index.js +3 -3
  208. package/dist/internal/index.js.map +1 -1
  209. package/dist/internal/oauth.d.ts +6 -5
  210. package/dist/internal/oauth.d.ts.map +1 -1
  211. package/dist/internal/oauth.js +2 -2
  212. package/dist/internal/project-link.d.ts +10 -0
  213. package/dist/internal/project-link.d.ts.map +1 -0
  214. package/dist/internal/project-link.js +48 -0
  215. package/dist/internal/project-link.js.map +1 -0
  216. package/dist/providers.json +2 -2
  217. package/dist/server.bundle.mjs +31654 -30866
  218. package/dist/start-local.bundle.mjs +74409 -0
  219. package/dist/templates/README.md.tmpl +10 -11
  220. package/dist/templates/TESTING.md.tmpl +9 -9
  221. package/package.json +15 -13
  222. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  223. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  224. package/dist/__tests__/chat.integration.test.js +0 -337
  225. package/dist/__tests__/chat.integration.test.js.map +0 -1
  226. package/dist/__tests__/dev.test.d.ts +0 -2
  227. package/dist/__tests__/dev.test.d.ts.map +0 -1
  228. package/dist/__tests__/dev.test.js +0 -25
  229. package/dist/__tests__/dev.test.js.map +0 -1
  230. package/dist/__tests__/init-memory.test.d.ts +0 -2
  231. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  232. package/dist/__tests__/init-memory.test.js +0 -45
  233. package/dist/__tests__/init-memory.test.js.map +0 -1
  234. package/dist/__tests__/token.test.d.ts +0 -2
  235. package/dist/__tests__/token.test.d.ts.map +0 -1
  236. package/dist/__tests__/token.test.js +0 -52
  237. package/dist/__tests__/token.test.js.map +0 -1
  238. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  239. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  240. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  241. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  242. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  243. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  244. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  245. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  246. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  247. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  248. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  249. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  250. package/dist/commands/apply.d.ts +0 -3
  251. package/dist/commands/apply.d.ts.map +0 -1
  252. package/dist/commands/apply.js +0 -5
  253. package/dist/commands/apply.js.map +0 -1
  254. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  255. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  256. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  257. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  258. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  259. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  260. package/dist/internal/__tests__/api-client.test.js +0 -95
  261. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  262. package/dist/internal/__tests__/context.test.d.ts +0 -2
  263. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  264. package/dist/internal/__tests__/context.test.js +0 -77
  265. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -0,0 +1,600 @@
1
+ /**
2
+ * Reddit Connector (V1 runtime)
3
+ *
4
+ * Fetches posts and comments from Reddit subreddits or search queries.
5
+ * Supports both authenticated (OAuth) and unauthenticated (public JSON API) modes.
6
+ */
7
+
8
+ import {
9
+ type ActionContext,
10
+ type ActionResult,
11
+ type ConnectorDefinition,
12
+ ConnectorRuntime,
13
+ calculateEngagementScore,
14
+ type EventEnvelope,
15
+ type SyncContext,
16
+ type SyncResult,
17
+ } from '@lobu/connector-sdk';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Reddit API types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ interface RedditPost {
24
+ name: string;
25
+ id: string;
26
+ title: string;
27
+ selftext: string;
28
+ author: string;
29
+ permalink: string;
30
+ url: string;
31
+ created_utc: number;
32
+ score: number;
33
+ ups: number;
34
+ num_comments: number;
35
+ upvote_ratio: number;
36
+ is_self: boolean;
37
+ domain: string;
38
+ subreddit: string;
39
+ crosspost_parent?: string;
40
+ thumbnail?: string;
41
+ }
42
+
43
+ interface RedditComment {
44
+ name: string;
45
+ id: string;
46
+ body: string;
47
+ author: string;
48
+ permalink: string;
49
+ created_utc: number;
50
+ score: number;
51
+ ups: number;
52
+ parent_id: string;
53
+ link_id: string;
54
+ subreddit: string;
55
+ }
56
+
57
+ interface RedditListingResponse {
58
+ data: {
59
+ children: Array<{
60
+ kind: string;
61
+ data: RedditPost & RedditComment;
62
+ }>;
63
+ after: string | null;
64
+ };
65
+ }
66
+
67
+ interface RedditCheckpoint {
68
+ last_timestamp?: string;
69
+ pagination_token?: string;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Connector
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export default class RedditConnector extends ConnectorRuntime {
77
+ readonly definition: ConnectorDefinition = {
78
+ key: 'reddit',
79
+ name: 'Reddit',
80
+ description: 'Fetches posts and comments from Reddit subreddits or search queries.',
81
+ version: '1.0.0',
82
+ authSchema: {
83
+ methods: [
84
+ {
85
+ type: 'oauth',
86
+ provider: 'reddit',
87
+ requiredScopes: ['identity', 'read', 'history'],
88
+ setupInstructions:
89
+ 'Create a Reddit app at https://www.reddit.com/prefs/apps — choose "web app" as the type. Set the redirect URI to {{redirect_uri}}, then copy the client ID and secret below.',
90
+ },
91
+ {
92
+ type: 'none',
93
+ },
94
+ ],
95
+ },
96
+ feeds: {
97
+ posts: {
98
+ key: 'posts',
99
+ name: 'Posts',
100
+ description: 'Fetch posts from subreddits or search queries.',
101
+ displayNameTemplate: 'r/{subreddit} posts',
102
+ configSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ subreddit: {
106
+ type: 'string',
107
+ description: 'Subreddit name without r/ prefix (e.g., "programming").',
108
+ },
109
+ search_terms: {
110
+ type: 'string',
111
+ description: 'Search terms to query across Reddit.',
112
+ },
113
+ lookback_days: {
114
+ type: 'integer',
115
+ minimum: 1,
116
+ maximum: 730,
117
+ default: 365,
118
+ description: 'Number of days to look back for historical data.',
119
+ },
120
+ },
121
+ },
122
+ eventKinds: {
123
+ post: {
124
+ description: 'A Reddit post (self-post or link)',
125
+ metadataSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ subreddit: { type: 'string' },
129
+ score: { type: 'number', description: 'Reddit score (upvotes - downvotes)' },
130
+ num_comments: { type: 'number' },
131
+ upvote_ratio: { type: 'number' },
132
+ is_self: {
133
+ type: 'boolean',
134
+ description: 'True for text posts, false for link/media posts',
135
+ },
136
+ domain: {
137
+ type: 'string',
138
+ description: 'Content domain (e.g., "i.redd.it", "self.programming")',
139
+ },
140
+ thumbnail: { type: 'string', format: 'uri', description: 'Preview thumbnail URL' },
141
+ media_url: {
142
+ type: 'string',
143
+ format: 'uri',
144
+ description: 'Linked content URL for non-self posts (image, video, article)',
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ comments: {
152
+ key: 'comments',
153
+ name: 'Comments',
154
+ description: 'Fetch comments from subreddits.',
155
+ displayNameTemplate: 'r/{subreddit} comments',
156
+ configSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ subreddit: {
160
+ type: 'string',
161
+ description: 'Subreddit name without r/ prefix (e.g., "programming").',
162
+ },
163
+ lookback_days: {
164
+ type: 'integer',
165
+ minimum: 1,
166
+ maximum: 730,
167
+ default: 365,
168
+ description: 'Number of days to look back for historical data.',
169
+ },
170
+ },
171
+ },
172
+ eventKinds: {
173
+ comment: {
174
+ description: 'A Reddit comment',
175
+ metadataSchema: {
176
+ type: 'object',
177
+ properties: {
178
+ subreddit: { type: 'string' },
179
+ score: { type: 'number', description: 'Reddit score (upvotes - downvotes)' },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ user_activity: {
186
+ key: 'user_activity',
187
+ name: 'User activity',
188
+ description:
189
+ "Fetch a Reddit user's posts and comments interleaved. Defaults to the connected user.",
190
+ displayNameTemplate: 'u/{username} activity',
191
+ configSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ username: {
195
+ type: 'string',
196
+ description:
197
+ "Reddit username without u/ prefix. Leave empty to use the connected account's identity.",
198
+ },
199
+ lookback_days: {
200
+ type: 'integer',
201
+ minimum: 1,
202
+ maximum: 730,
203
+ default: 365,
204
+ description: 'Number of days to look back for historical activity.',
205
+ },
206
+ },
207
+ },
208
+ eventKinds: {
209
+ post: {
210
+ description: 'A Reddit post authored by the user',
211
+ metadataSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ subreddit: { type: 'string' },
215
+ score: { type: 'number', description: 'Reddit score (upvotes - downvotes)' },
216
+ num_comments: { type: 'number' },
217
+ upvote_ratio: { type: 'number' },
218
+ is_self: { type: 'boolean' },
219
+ domain: { type: 'string' },
220
+ },
221
+ },
222
+ },
223
+ comment: {
224
+ description: 'A Reddit comment authored by the user',
225
+ metadataSchema: {
226
+ type: 'object',
227
+ properties: {
228
+ subreddit: { type: 'string' },
229
+ score: { type: 'number', description: 'Reddit score (upvotes - downvotes)' },
230
+ },
231
+ },
232
+ },
233
+ },
234
+ },
235
+ },
236
+ optionsSchema: {
237
+ type: 'object',
238
+ properties: {
239
+ subreddit: {
240
+ type: 'string',
241
+ description: 'Subreddit name without r/ prefix (e.g., "programming").',
242
+ },
243
+ search_terms: {
244
+ type: 'string',
245
+ description: 'Search terms to query across Reddit.',
246
+ },
247
+ lookback_days: {
248
+ type: 'integer',
249
+ minimum: 1,
250
+ maximum: 730,
251
+ default: 365,
252
+ description: 'Number of days to look back for historical data.',
253
+ },
254
+ },
255
+ },
256
+ };
257
+
258
+ private readonly MAX_PAGES = 10;
259
+ private readonly RATE_LIMIT_MS = 1000;
260
+ private readonly USER_AGENT = 'Lobu-Connector/1.0.0';
261
+
262
+ // -------------------------------------------------------------------------
263
+ // sync
264
+ // -------------------------------------------------------------------------
265
+
266
+ async sync(ctx: SyncContext): Promise<SyncResult> {
267
+ const subreddit = ctx.config.subreddit as string | undefined;
268
+ const searchTerms = ctx.config.search_terms as string | undefined;
269
+ const isUserFeed = ctx.feedKey === 'user_activity';
270
+ const contentType = isUserFeed ? 'overview' : ctx.feedKey === 'comments' ? 'comment' : 'post';
271
+ const lookbackDays = (ctx.config.lookback_days as number) ?? 365;
272
+
273
+ // Resolve access token: user OAuth > app-only OAuth > unauthenticated
274
+ const userAccessToken = ctx.credentials?.accessToken ?? null;
275
+ let accessToken: string | undefined = userAccessToken ?? undefined;
276
+ if (!accessToken) {
277
+ accessToken = await this.getAppOnlyToken(ctx);
278
+ }
279
+ const baseUrl = accessToken ? 'https://oauth.reddit.com' : 'https://www.reddit.com';
280
+
281
+ let username: string | undefined;
282
+ if (isUserFeed) {
283
+ if (!userAccessToken) {
284
+ throw new Error(
285
+ 'user_activity feed requires user OAuth. Connect Reddit with read+history scopes.'
286
+ );
287
+ }
288
+ username = await this.resolveUsername(ctx, userAccessToken);
289
+ }
290
+
291
+ const cutoffDate = new Date();
292
+ cutoffDate.setDate(cutoffDate.getDate() - lookbackDays);
293
+
294
+ const events: EventEnvelope[] = [];
295
+ let after: string | null = null;
296
+ let page = 0;
297
+ let reachedCutoff = false;
298
+
299
+ while (page < this.MAX_PAGES && !reachedCutoff) {
300
+ const url = this.buildFetchUrl({
301
+ baseUrl,
302
+ subreddit,
303
+ searchTerms,
304
+ username,
305
+ contentType,
306
+ after,
307
+ isOAuth: !!accessToken,
308
+ });
309
+
310
+ const headers: Record<string, string> = {
311
+ 'User-Agent': this.USER_AGENT,
312
+ };
313
+ if (accessToken) {
314
+ headers.Authorization = `Bearer ${accessToken}`;
315
+ }
316
+
317
+ const response = await fetch(url, { headers });
318
+ if (!response.ok) {
319
+ const status = response.status;
320
+ if (status === 429) {
321
+ throw new Error('Reddit rate limit exceeded. Please wait before retrying.');
322
+ }
323
+ if (status === 404) {
324
+ throw new Error('Subreddit or resource not found. Please check the subreddit name.');
325
+ }
326
+ if (status === 403) {
327
+ throw new Error('Access forbidden. The subreddit may be private or banned.');
328
+ }
329
+ throw new Error(`Reddit API error (${status}): ${await response.text()}`);
330
+ }
331
+
332
+ const listing = (await response.json()) as RedditListingResponse;
333
+ const children = listing.data.children;
334
+
335
+ if (children.length === 0) break;
336
+
337
+ for (const child of children) {
338
+ const itemData = child.data;
339
+ const itemDate = new Date(itemData.created_utc * 1000);
340
+
341
+ if (itemDate < cutoffDate) {
342
+ reachedCutoff = true;
343
+ break;
344
+ }
345
+
346
+ // Filter deleted/removed items
347
+ if (itemData.author === '[deleted]') continue;
348
+
349
+ // Use actual Reddit API kind (t3=post, t1=comment) instead of config
350
+ const isPost = child.kind === 't3';
351
+ const isComment = child.kind === 't1';
352
+
353
+ if (isPost) {
354
+ const post = itemData as RedditPost;
355
+ if (post.crosspost_parent) continue;
356
+ if (post.selftext === '[removed]' || post.selftext === '[deleted]') continue;
357
+
358
+ events.push(this.transformPost(post));
359
+ } else if (isComment) {
360
+ const comment = itemData as RedditComment;
361
+ if (!comment.body || comment.body === '[removed]' || comment.body === '[deleted]')
362
+ continue;
363
+
364
+ events.push(this.transformComment(comment));
365
+ }
366
+ }
367
+
368
+ after = listing.data.after;
369
+ if (!after) break;
370
+
371
+ page++;
372
+
373
+ if (page < this.MAX_PAGES && !reachedCutoff) {
374
+ await this.sleep(this.RATE_LIMIT_MS);
375
+ }
376
+ }
377
+
378
+ const checkpoint: RedditCheckpoint = {
379
+ last_timestamp: new Date().toISOString(),
380
+ pagination_token: after ?? undefined,
381
+ };
382
+
383
+ return {
384
+ events,
385
+ checkpoint: checkpoint as Record<string, unknown>,
386
+ metadata: {
387
+ items_found: events.length,
388
+ },
389
+ };
390
+ }
391
+
392
+ // -------------------------------------------------------------------------
393
+ // execute
394
+ // -------------------------------------------------------------------------
395
+
396
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
397
+ return { success: false, error: 'Actions not supported' };
398
+ }
399
+
400
+ // -------------------------------------------------------------------------
401
+ // App-only OAuth
402
+ // -------------------------------------------------------------------------
403
+
404
+ private appOnlyToken: string | null = null;
405
+
406
+ private async getAppOnlyToken(ctx: SyncContext): Promise<string | undefined> {
407
+ if (this.appOnlyToken) return this.appOnlyToken;
408
+
409
+ const clientId = (ctx.config as Record<string, unknown>).REDDIT_CLIENT_ID as string | undefined;
410
+ const clientSecret = (ctx.config as Record<string, unknown>).REDDIT_CLIENT_SECRET as
411
+ | string
412
+ | undefined;
413
+ if (!clientId || !clientSecret) return undefined;
414
+
415
+ const userAgent =
416
+ ((ctx.config as Record<string, unknown>).REDDIT_USER_AGENT as string) || this.USER_AGENT;
417
+ const response = await fetch('https://www.reddit.com/api/v1/access_token', {
418
+ method: 'POST',
419
+ headers: {
420
+ 'Content-Type': 'application/x-www-form-urlencoded',
421
+ Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
422
+ 'User-Agent': userAgent,
423
+ },
424
+ body: 'grant_type=client_credentials',
425
+ });
426
+
427
+ if (!response.ok) {
428
+ console.error(
429
+ `Reddit app-only auth failed (${response.status}), falling back to unauthenticated`
430
+ );
431
+ return undefined;
432
+ }
433
+
434
+ const data = (await response.json()) as { access_token?: string };
435
+ if (data.access_token) {
436
+ this.appOnlyToken = data.access_token;
437
+ return data.access_token;
438
+ }
439
+ return undefined;
440
+ }
441
+
442
+ // -------------------------------------------------------------------------
443
+ // Username resolution
444
+ // -------------------------------------------------------------------------
445
+
446
+ private async resolveUsername(ctx: SyncContext, userAccessToken: string): Promise<string> {
447
+ const configured = (ctx.config.username as string | undefined)?.trim();
448
+ if (configured) return configured.replace(/^u\//, '');
449
+
450
+ const response = await fetch('https://oauth.reddit.com/api/v1/me', {
451
+ headers: {
452
+ Authorization: `Bearer ${userAccessToken}`,
453
+ 'User-Agent': this.USER_AGENT,
454
+ },
455
+ });
456
+ if (!response.ok) {
457
+ throw new Error(
458
+ `Failed to resolve Reddit username via /api/v1/me (${response.status}). ` +
459
+ 'Set "username" in feed config or re-authenticate.'
460
+ );
461
+ }
462
+ const data = (await response.json()) as { name?: string };
463
+ if (!data.name) {
464
+ throw new Error('Reddit /api/v1/me returned no username.');
465
+ }
466
+ return data.name;
467
+ }
468
+
469
+ // -------------------------------------------------------------------------
470
+ // URL building
471
+ // -------------------------------------------------------------------------
472
+
473
+ private buildFetchUrl(params: {
474
+ baseUrl: string;
475
+ subreddit?: string;
476
+ searchTerms?: string;
477
+ username?: string;
478
+ contentType: string;
479
+ after: string | null;
480
+ isOAuth: boolean;
481
+ }): string {
482
+ const { baseUrl, subreddit, searchTerms, username, contentType, after, isOAuth } = params;
483
+ const jsonSuffix = isOAuth ? '' : '.json';
484
+ const afterParam = after ? `&after=${after}` : '';
485
+
486
+ if (contentType === 'overview') {
487
+ if (!username) {
488
+ throw new Error('user_activity feed requires a resolved username.');
489
+ }
490
+ return `${baseUrl}/user/${encodeURIComponent(username)}/overview${jsonSuffix}?limit=100&sort=new${afterParam}`;
491
+ }
492
+
493
+ if (contentType === 'comment') {
494
+ // Comments from a subreddit
495
+ if (subreddit) {
496
+ return `${baseUrl}/r/${subreddit}/comments${jsonSuffix}?limit=100${afterParam}`;
497
+ }
498
+ // Comments aren't searchable via Reddit search API, fall back to r/all
499
+ return `${baseUrl}/r/all/comments${jsonSuffix}?limit=100${afterParam}`;
500
+ }
501
+
502
+ // Posts mode
503
+ if (searchTerms) {
504
+ const query = encodeURIComponent(searchTerms);
505
+ if (subreddit) {
506
+ return `${baseUrl}/r/${subreddit}/search${jsonSuffix}?q=${query}&restrict_sr=on&sort=relevance&t=year&limit=100${afterParam}`;
507
+ }
508
+ return `${baseUrl}/search${jsonSuffix}?q=${query}&sort=relevance&t=year&limit=100${afterParam}`;
509
+ }
510
+
511
+ // Subreddit listing
512
+ if (subreddit) {
513
+ return `${baseUrl}/r/${subreddit}/new${jsonSuffix}?t=year&limit=100${afterParam}`;
514
+ }
515
+
516
+ // Fallback to r/all
517
+ return `${baseUrl}/r/all/new${jsonSuffix}?t=year&limit=100${afterParam}`;
518
+ }
519
+
520
+ // -------------------------------------------------------------------------
521
+ // Transform helpers
522
+ // -------------------------------------------------------------------------
523
+
524
+ private transformPost(post: RedditPost): EventEnvelope {
525
+ const engagementScore = calculateEngagementScore('reddit', {
526
+ score: post.score,
527
+ reply_count: post.num_comments,
528
+ upvotes: post.ups,
529
+ });
530
+
531
+ // For non-self posts, the Reddit `url` field points to the linked content (image, article, etc.)
532
+ const mediaUrl = !post.is_self ? post.url : undefined;
533
+ const thumbnail =
534
+ post.thumbnail && !['self', 'default', 'nsfw', 'spoiler', ''].includes(post.thumbnail)
535
+ ? post.thumbnail
536
+ : undefined;
537
+
538
+ return {
539
+ origin_id: `reddit_post_${post.name}`,
540
+ title: post.title,
541
+ payload_text: (post.selftext ?? '').trim(),
542
+ author_name: post.author,
543
+ source_url: `https://reddit.com${post.permalink}`,
544
+ occurred_at: new Date(post.created_utc * 1000),
545
+ origin_type: 'post',
546
+ score: engagementScore,
547
+ metadata: {
548
+ subreddit: post.subreddit,
549
+ score: post.score,
550
+ num_comments: post.num_comments,
551
+ upvote_ratio: post.upvote_ratio,
552
+ is_self: post.is_self,
553
+ domain: post.domain,
554
+ ...(thumbnail && { thumbnail }),
555
+ ...(mediaUrl && { media_url: mediaUrl }),
556
+ },
557
+ };
558
+ }
559
+
560
+ private transformComment(comment: RedditComment): EventEnvelope {
561
+ const engagementScore = calculateEngagementScore('reddit', {
562
+ score: comment.score,
563
+ upvotes: comment.ups,
564
+ });
565
+
566
+ let parentExternalId: string | undefined;
567
+ if (comment.parent_id) {
568
+ if (comment.parent_id.startsWith('t1_')) {
569
+ // Parent is another comment
570
+ parentExternalId = `reddit_comment_${comment.parent_id}`;
571
+ } else if (comment.parent_id.startsWith('t3_')) {
572
+ // Parent is a post
573
+ parentExternalId = `reddit_post_${comment.parent_id}`;
574
+ }
575
+ }
576
+
577
+ return {
578
+ origin_id: `reddit_comment_${comment.name}`,
579
+ payload_text: comment.body ?? '',
580
+ author_name: comment.author,
581
+ source_url: `https://reddit.com${comment.permalink}`,
582
+ occurred_at: new Date(comment.created_utc * 1000),
583
+ origin_type: 'comment',
584
+ score: engagementScore,
585
+ origin_parent_id: parentExternalId,
586
+ metadata: {
587
+ subreddit: comment.subreddit,
588
+ score: comment.score,
589
+ },
590
+ };
591
+ }
592
+
593
+ // -------------------------------------------------------------------------
594
+ // Utilities
595
+ // -------------------------------------------------------------------------
596
+
597
+ private sleep(ms: number): Promise<void> {
598
+ return new Promise((resolve) => setTimeout(resolve, ms));
599
+ }
600
+ }