@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.
- package/README.md +20 -27
- package/dist/bundled-skills/lobu/SKILL.md +12 -12
- package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
- package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
- package/dist/commands/_lib/apply/apply-cmd.js +26 -0
- package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
- package/dist/commands/_lib/apply/client.d.ts +1 -1
- package/dist/commands/_lib/apply/client.d.ts.map +1 -1
- package/dist/commands/_lib/apply/desired-state.js +6 -6
- package/dist/commands/_lib/apply/desired-state.js.map +1 -1
- package/dist/commands/agent.d.ts +7 -0
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +65 -1
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/chat.d.ts +12 -9
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +117 -56
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/dev.d.ts +15 -7
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +79 -44
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +136 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/eval.d.ts +8 -0
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +56 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/init.d.ts +20 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +332 -183
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +11 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +28 -0
- package/dist/commands/link.js.map +1 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +14 -2
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/browser-auth-cmd.js +4 -4
- package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
- package/dist/commands/memory/_lib/install-targets.d.ts.map +1 -1
- package/dist/commands/memory/_lib/install-targets.js +1 -5
- package/dist/commands/memory/_lib/install-targets.js.map +1 -1
- package/dist/commands/memory/_lib/mcp.d.ts +2 -2
- package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
- package/dist/commands/memory/_lib/mcp.js +24 -12
- package/dist/commands/memory/_lib/mcp.js.map +1 -1
- package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
- package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
- package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
- package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
- package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
- package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
- package/dist/commands/memory/_lib/schema.d.ts +2 -2
- package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
- package/dist/commands/memory/_lib/schema.js +3 -3
- package/dist/commands/memory/_lib/schema.js.map +1 -1
- package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
- package/dist/commands/memory/_lib/seed-cmd.js +5 -6
- package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
- package/dist/commands/memory/run.d.ts.map +1 -1
- package/dist/commands/memory/run.js +2 -2
- package/dist/commands/memory/run.js.map +1 -1
- package/dist/commands/platforms/platform-prompts.d.ts +0 -1
- package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
- package/dist/commands/platforms/platform-prompts.js +54 -8
- package/dist/commands/platforms/platform-prompts.js.map +1 -1
- package/dist/commands/telemetry.d.ts +10 -0
- package/dist/commands/telemetry.d.ts.map +1 -0
- package/dist/commands/telemetry.js +68 -0
- package/dist/commands/telemetry.js.map +1 -0
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +1 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/connectors/README.md +534 -0
- package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
- package/dist/connectors/browser-scraper-utils.ts +214 -0
- package/dist/connectors/capterra.ts +273 -0
- package/dist/connectors/g2.ts +286 -0
- package/dist/connectors/github.ts +1553 -0
- package/dist/connectors/glassdoor.ts +291 -0
- package/dist/connectors/gmaps.ts +197 -0
- package/dist/connectors/google_calendar.ts +631 -0
- package/dist/connectors/google_gmail.ts +751 -0
- package/dist/connectors/google_photos.ts +776 -0
- package/dist/connectors/google_play.ts +342 -0
- package/dist/connectors/hackernews.ts +471 -0
- package/dist/connectors/index.ts +23 -0
- package/dist/connectors/ios_appstore.ts +226 -0
- package/dist/connectors/linkedin.ts +471 -0
- package/dist/connectors/microsoft_outlook.ts +410 -0
- package/dist/connectors/producthunt.ts +471 -0
- package/dist/connectors/reddit.ts +600 -0
- package/dist/connectors/rss.ts +448 -0
- package/dist/connectors/spotify.ts +590 -0
- package/dist/connectors/trustpilot.ts +199 -0
- package/dist/connectors/website.ts +629 -0
- package/dist/connectors/whatsapp.ts +1073 -0
- package/dist/connectors/x.ts +526 -0
- package/dist/connectors/youtube.ts +666 -0
- package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
- package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
- package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
- package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
- package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
- package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
- package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
- package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
- package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
- package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
- package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
- package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
- package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
- package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
- package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
- package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
- package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
- package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
- package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
- package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
- package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
- package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
- package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
- package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
- package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
- package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
- package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
- package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
- package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
- package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
- package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
- package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
- package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
- package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
- package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
- package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
- package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
- package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
- package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
- package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
- package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
- package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
- package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
- package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
- package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
- package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -23
- package/dist/index.js.map +1 -1
- package/dist/internal/api-client.d.ts +4 -8
- package/dist/internal/api-client.d.ts.map +1 -1
- package/dist/internal/api-client.js +1 -1
- package/dist/internal/api-client.js.map +1 -1
- package/dist/internal/context.js +2 -2
- package/dist/internal/context.js.map +1 -1
- package/dist/internal/credentials.d.ts.map +1 -1
- package/dist/internal/credentials.js +6 -1
- package/dist/internal/credentials.js.map +1 -1
- package/dist/internal/index.d.ts +2 -3
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +2 -2
- package/dist/internal/index.js.map +1 -1
- package/dist/internal/oauth.d.ts +7 -6
- package/dist/internal/oauth.d.ts.map +1 -1
- package/dist/internal/oauth.js +3 -3
- package/dist/internal/project-link.d.ts +10 -0
- package/dist/internal/project-link.d.ts.map +1 -0
- package/dist/internal/project-link.js +48 -0
- package/dist/internal/project-link.js.map +1 -0
- package/dist/providers.json +2 -2
- package/dist/server.bundle.mjs +3173 -4404
- package/dist/start-local.bundle.mjs +71481 -0
- package/dist/templates/README.md.tmpl +10 -11
- package/package.json +14 -12
- package/dist/__tests__/chat.integration.test.d.ts +0 -2
- package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
- package/dist/__tests__/chat.integration.test.js +0 -337
- package/dist/__tests__/chat.integration.test.js.map +0 -1
- package/dist/__tests__/dev.test.d.ts +0 -2
- package/dist/__tests__/dev.test.d.ts.map +0 -1
- package/dist/__tests__/dev.test.js +0 -25
- package/dist/__tests__/dev.test.js.map +0 -1
- package/dist/__tests__/init-memory.test.d.ts +0 -2
- package/dist/__tests__/init-memory.test.d.ts.map +0 -1
- package/dist/__tests__/init-memory.test.js +0 -45
- package/dist/__tests__/init-memory.test.js.map +0 -1
- package/dist/__tests__/token.test.d.ts +0 -2
- package/dist/__tests__/token.test.d.ts.map +0 -1
- package/dist/__tests__/token.test.js +0 -52
- package/dist/__tests__/token.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
- package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
- package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
- package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
- package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
- package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
- package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
- package/dist/commands/apply.d.ts +0 -3
- package/dist/commands/apply.d.ts.map +0 -1
- package/dist/commands/apply.js +0 -5
- package/dist/commands/apply.js.map +0 -1
- package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
- package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
- package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
- package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
- package/dist/internal/__tests__/api-client.test.d.ts +0 -2
- package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
- package/dist/internal/__tests__/api-client.test.js +0 -95
- package/dist/internal/__tests__/api-client.test.js.map +0 -1
- package/dist/internal/__tests__/context.test.d.ts +0 -2
- package/dist/internal/__tests__/context.test.d.ts.map +0 -1
- package/dist/internal/__tests__/context.test.js +0 -77
- 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
|
+
}
|