thumbgate 0.9.10 → 0.9.11
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/.claude-plugin/README.md +2 -2
- package/.claude-plugin/marketplace.json +4 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +115 -312
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +61 -1
- package/adapters/opencode/opencode.json +4 -2
- package/bin/cli.js +156 -8
- package/bin/memory.sh +3 -3
- package/config/e2e-critical-flows.json +4 -0
- package/config/gates/default.json +74 -2
- package/config/github-about.json +1 -1
- package/config/mcp-allowlists.json +27 -0
- package/package.json +22 -5
- package/plugins/amp-skill/INSTALL.md +1 -0
- package/plugins/amp-skill/SKILL.md +1 -0
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +4 -2
- package/plugins/claude-skill/INSTALL.md +1 -0
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +4 -2
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-marketplace/README.md +3 -3
- package/plugins/cursor-marketplace/mcp.json +3 -1
- package/plugins/cursor-marketplace/scripts/gate-check.sh +15 -5
- package/plugins/gemini-extension/INSTALL.md +3 -3
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/dashboard.html +15 -8
- package/public/index.html +125 -185
- package/public/js/buyer-intent.js +252 -0
- package/public/pro.html +1085 -0
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/adk-consolidator.js +14 -2
- package/scripts/agent-readiness.js +3 -1
- package/scripts/agent-security-hardening.js +4 -4
- package/scripts/auto-promote-gates.js +2 -0
- package/scripts/auto-wire-hooks.js +105 -17
- package/scripts/behavioral-extraction.js +2 -6
- package/scripts/billing.js +107 -3
- package/scripts/budget-guard.js +2 -2
- package/scripts/build-metadata.js +14 -0
- package/scripts/context-engine.js +1 -0
- package/scripts/deploy-policy.js +3 -17
- package/scripts/dpo-optimizer.js +3 -6
- package/scripts/ensure-repo-bootstrap.js +129 -0
- package/scripts/export-dpo-pairs.js +2 -3
- package/scripts/export-kto-pairs.js +3 -4
- package/scripts/export-training.js +8 -6
- package/scripts/feedback-attribution.js +23 -11
- package/scripts/feedback-loop.js +40 -2
- package/scripts/feedback-to-rules.js +2 -1
- package/scripts/filesystem-search.js +3 -2
- package/scripts/gates-engine.js +760 -29
- package/scripts/generate-pretool-hook.sh +0 -0
- package/scripts/gtm-revenue-loop.js +20 -1
- package/scripts/hook-auto-capture.sh +8 -3
- package/scripts/hook-runtime.js +89 -0
- package/scripts/hook-stop-self-score.sh +3 -3
- package/scripts/hook-thumbgate-cache-updater.js +99 -38
- package/scripts/hosted-config.js +4 -16
- package/scripts/hybrid-feedback-context.js +54 -14
- package/scripts/install-mcp.js +13 -0
- package/scripts/intent-router.js +2 -2
- package/scripts/license.js +52 -14
- package/scripts/local-model-profile.js +3 -2
- package/scripts/mcp-config.js +68 -6
- package/scripts/meta-policy.js +4 -8
- package/scripts/money-watcher.js +166 -16
- package/scripts/obsidian-export.js +1 -0
- package/scripts/operational-integrity.js +480 -0
- package/scripts/post-everywhere.js +7 -12
- package/scripts/pr-manager.js +14 -11
- package/scripts/profile-router.js +2 -0
- package/scripts/prompt-dlp.js +1 -0
- package/scripts/publish-decision.js +10 -0
- package/scripts/published-cli.js +34 -0
- package/scripts/risk-scorer.js +3 -2
- package/scripts/rlhf_session_start.sh +32 -0
- package/scripts/skill-quality-tracker.js +3 -5
- package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
- package/scripts/social-analytics/db/social-analytics.db-wal +0 -0
- package/scripts/social-analytics/engagement-audit.js +202 -0
- package/scripts/social-analytics/instagram-thumbgate-post.js +45 -7
- package/scripts/social-analytics/install-growth-automation.js +114 -0
- package/scripts/social-analytics/load-env.js +46 -0
- package/scripts/social-analytics/poll-all.js +3 -18
- package/scripts/social-analytics/pollers/zernio.js +3 -0
- package/scripts/social-analytics/publish-instagram-thumbgate.js +22 -3
- package/scripts/social-analytics/publish-thumbgate-launch.js +316 -0
- package/scripts/social-analytics/publishers/reddit.js +7 -12
- package/scripts/social-analytics/publishers/zernio.js +210 -22
- package/scripts/social-analytics/reconcile-thumbgate-campaign.js +165 -0
- package/scripts/social-analytics/schedule-thumbgate-campaign.js +275 -0
- package/scripts/social-analytics/sync-launch-assets.js +185 -0
- package/scripts/social-post-hourly.js +185 -0
- package/scripts/social-quality-gate.js +119 -3
- package/scripts/social-reply-monitor.js +148 -32
- package/scripts/statusline-cache-path.js +27 -0
- package/scripts/statusline-meta.js +22 -0
- package/scripts/statusline.sh +24 -32
- package/scripts/sync-version.js +11 -3
- package/scripts/test-coverage.js +20 -13
- package/scripts/tool-registry.js +97 -0
- package/scripts/train_from_feedback.py +32 -9
- package/scripts/validate-feedback.js +3 -2
- package/scripts/vector-store.js +2 -3
- package/scripts/verify-obsidian-setup.sh +3 -3
- package/src/api/server.js +281 -33
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { buildUTMLink } = require('./utm');
|
|
5
|
+
const { publishInstagramThumbGate } = require('./publish-instagram-thumbgate');
|
|
6
|
+
const {
|
|
7
|
+
getConnectedAccounts,
|
|
8
|
+
groupAccountsByPlatform,
|
|
9
|
+
publishPost,
|
|
10
|
+
schedulePost,
|
|
11
|
+
} = require('./publishers/zernio');
|
|
12
|
+
const { THUMBGATE_CAPTION } = require('./instagram-thumbgate-post');
|
|
13
|
+
const { resolveHostedBillingConfig } = require('../hosted-config');
|
|
14
|
+
|
|
15
|
+
const APP_ORIGIN = resolveHostedBillingConfig({
|
|
16
|
+
requestOrigin: 'https://thumbgate-production.up.railway.app',
|
|
17
|
+
}).appOrigin;
|
|
18
|
+
const DEFAULT_TIMEZONE = 'America/New_York';
|
|
19
|
+
const LAUNCH_CAMPAIGN = 'first_customer_push';
|
|
20
|
+
const DEFAULT_LAUNCH_PLATFORMS = ['twitter', 'linkedin', 'instagram'];
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv = []) {
|
|
23
|
+
const options = {
|
|
24
|
+
dryRun: false,
|
|
25
|
+
platforms: [],
|
|
26
|
+
schedule: '',
|
|
27
|
+
timezone: DEFAULT_TIMEZONE,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
31
|
+
const token = argv[index];
|
|
32
|
+
if (token === '--dry-run') {
|
|
33
|
+
options.dryRun = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (token.startsWith('--platforms=')) {
|
|
38
|
+
options.platforms = token
|
|
39
|
+
.slice('--platforms='.length)
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((platform) => platform.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (token === '--platforms' && argv[index + 1]) {
|
|
47
|
+
options.platforms = String(argv[index + 1])
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((platform) => platform.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
index += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (token.startsWith('--schedule=')) {
|
|
56
|
+
options.schedule = token.slice('--schedule='.length).trim();
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (token === '--schedule' && argv[index + 1]) {
|
|
61
|
+
options.schedule = String(argv[index + 1]).trim();
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token.startsWith('--timezone=')) {
|
|
67
|
+
options.timezone = token.slice('--timezone='.length).trim() || DEFAULT_TIMEZONE;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (token === '--timezone' && argv[index + 1]) {
|
|
72
|
+
options.timezone = String(argv[index + 1]).trim() || DEFAULT_TIMEZONE;
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return options;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildLandingUrl(platform, content) {
|
|
81
|
+
return buildUTMLink(`${APP_ORIGIN}/`, {
|
|
82
|
+
source: platform,
|
|
83
|
+
medium: 'organic_social',
|
|
84
|
+
campaign: LAUNCH_CAMPAIGN,
|
|
85
|
+
content,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildPlatformPost(platform) {
|
|
90
|
+
const normalized = String(platform || '').trim().toLowerCase();
|
|
91
|
+
|
|
92
|
+
if (normalized === 'twitter' || normalized === 'x') {
|
|
93
|
+
return [
|
|
94
|
+
'Claude Code kept repeating the same mistakes across sessions.',
|
|
95
|
+
'ThumbGate turns thumbs-down feedback into a prevention rule that blocks the same pattern next time.',
|
|
96
|
+
'Local-first. Free path. Pro trial.',
|
|
97
|
+
buildLandingUrl('x', 'launch_post_twitter'),
|
|
98
|
+
].join(' ');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (normalized === 'linkedin') {
|
|
102
|
+
return [
|
|
103
|
+
'AI coding agents do not reliably learn from your repo-level pain.',
|
|
104
|
+
'That is the problem I kept hitting with Claude Code and Cursor: the same broken config, import, or workflow mistake would come back in the next session.',
|
|
105
|
+
'ThumbGate turns thumbs-down feedback into a prevention rule and blocks the same pattern before the next tool call lands.',
|
|
106
|
+
'Local-first. Free path. Pro adds the personal dashboard, DPO export, and a gate debugger.',
|
|
107
|
+
buildLandingUrl('linkedin', 'launch_post_linkedin'),
|
|
108
|
+
].join(' ');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (normalized === 'instagram') {
|
|
112
|
+
return `${THUMBGATE_CAPTION}\n\n${buildLandingUrl('instagram', 'launch_post_instagram')}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (normalized === 'reddit') {
|
|
116
|
+
return [
|
|
117
|
+
'I built ThumbGate after watching Claude Code repeat the same repo mistakes across sessions.',
|
|
118
|
+
'It turns thumbs-down feedback into a prevention rule so the same pattern gets blocked next time.',
|
|
119
|
+
'Free local path, no cloud account required.',
|
|
120
|
+
buildLandingUrl('reddit', 'launch_post_reddit'),
|
|
121
|
+
].join(' ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return [
|
|
125
|
+
'ThumbGate turns AI coding-agent feedback into enforced prevention rules so the same mistake gets blocked in the next session.',
|
|
126
|
+
'Local-first. Free path. Pro adds the personal dashboard and DPO export.',
|
|
127
|
+
buildLandingUrl(normalized || 'zernio', `launch_post_${normalized || 'generic'}`),
|
|
128
|
+
].join(' ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildCampaignEntries() {
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
slug: 'proof_pack',
|
|
135
|
+
posts: {
|
|
136
|
+
twitter: [
|
|
137
|
+
'AI coding agents do not need more hype. They need proof-backed workflow hardening.',
|
|
138
|
+
'ThumbGate turns thumbs-down feedback into a prevention rule that blocks the same mistake next session.',
|
|
139
|
+
'Proof pack:',
|
|
140
|
+
buildLandingUrl('x', 'campaign_proof_pack'),
|
|
141
|
+
].join(' '),
|
|
142
|
+
linkedin: [
|
|
143
|
+
'Workflow hardening beats generic AI hype.',
|
|
144
|
+
'ThumbGate captures failure signals, promotes them into prevention rules, and blocks the same bad pattern before the next tool call executes.',
|
|
145
|
+
'This is about one workflow becoming safe enough to ship, not abstract "agent memory."',
|
|
146
|
+
buildLandingUrl('linkedin', 'campaign_proof_pack'),
|
|
147
|
+
].join(' '),
|
|
148
|
+
instagram: `${THUMBGATE_CAPTION}\n\nProof-backed workflow hardening.\n\n${buildLandingUrl('instagram', 'campaign_proof_pack')}`,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
slug: 'free_local',
|
|
153
|
+
posts: {
|
|
154
|
+
twitter: [
|
|
155
|
+
'The free path is the point.',
|
|
156
|
+
'ThumbGate runs local-first, keeps lesson state in .thumbgate, and blocks repeated coding-agent mistakes without a cloud account.',
|
|
157
|
+
buildLandingUrl('x', 'campaign_free_local'),
|
|
158
|
+
].join(' '),
|
|
159
|
+
linkedin: [
|
|
160
|
+
'Most AI tooling tries to sell a hosted layer first. ThumbGate does not.',
|
|
161
|
+
'The free local path gives you feedback capture, prevention rules, and blocking on your machine. Pro adds the personal dashboard and exports when the workflow is already valuable.',
|
|
162
|
+
buildLandingUrl('linkedin', 'campaign_free_local'),
|
|
163
|
+
].join(' '),
|
|
164
|
+
instagram: [
|
|
165
|
+
'Your AI coding agent forgets everything between sessions.',
|
|
166
|
+
'ThumbGate keeps the feedback loop local, durable, and enforceable.',
|
|
167
|
+
buildLandingUrl('instagram', 'campaign_free_local'),
|
|
168
|
+
].join('\n\n'),
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
slug: 'checkout_path',
|
|
173
|
+
posts: {
|
|
174
|
+
twitter: [
|
|
175
|
+
'If your agent repeats the same repo mistake every week, the fix is not another prompt.',
|
|
176
|
+
'ThumbGate blocks known-bad patterns before the next tool call lands.',
|
|
177
|
+
'Free local path, Pro trial here:',
|
|
178
|
+
buildLandingUrl('x', 'campaign_checkout_path'),
|
|
179
|
+
].join(' '),
|
|
180
|
+
linkedin: [
|
|
181
|
+
'Repeated agent mistakes are a systems problem, not a prompt-writing problem.',
|
|
182
|
+
'ThumbGate turns explicit feedback into prevention rules and gives individual operators a paid path when they want the dashboard, exports, and gate debugger.',
|
|
183
|
+
buildLandingUrl('linkedin', 'campaign_checkout_path'),
|
|
184
|
+
].join(' '),
|
|
185
|
+
instagram: [
|
|
186
|
+
'ThumbGate turns thumbs-down feedback into a prevention rule.',
|
|
187
|
+
'Next session, the same mistake gets blocked.',
|
|
188
|
+
buildLandingUrl('instagram', 'campaign_checkout_path'),
|
|
189
|
+
].join('\n\n'),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultCampaignSchedule(now = new Date()) {
|
|
196
|
+
const target = new Date(now.getTime());
|
|
197
|
+
target.setDate(target.getDate() + 1);
|
|
198
|
+
const year = target.getFullYear();
|
|
199
|
+
const month = String(target.getMonth() + 1).padStart(2, '0');
|
|
200
|
+
const day = String(target.getDate()).padStart(2, '0');
|
|
201
|
+
return [
|
|
202
|
+
`${year}-${month}-${day}T10:15:00-04:00`,
|
|
203
|
+
`${year}-${month}-${day}T14:30:00-04:00`,
|
|
204
|
+
`${year}-${month}-${day}T18:45:00-04:00`,
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function publishLaunchCampaign(options = {}, publisher = {}) {
|
|
209
|
+
const api = {
|
|
210
|
+
getConnectedAccounts: publisher.getConnectedAccounts || getConnectedAccounts,
|
|
211
|
+
groupAccountsByPlatform: publisher.groupAccountsByPlatform || groupAccountsByPlatform,
|
|
212
|
+
publishPost: publisher.publishPost || publishPost,
|
|
213
|
+
schedulePost: publisher.schedulePost || schedulePost,
|
|
214
|
+
publishInstagramThumbGate: publisher.publishInstagramThumbGate || publishInstagramThumbGate,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const platforms = Array.isArray(options.platforms) && options.platforms.length > 0
|
|
218
|
+
? options.platforms
|
|
219
|
+
: DEFAULT_LAUNCH_PLATFORMS;
|
|
220
|
+
const schedule = String(options.schedule || '').trim();
|
|
221
|
+
const timezone = String(options.timezone || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
|
|
222
|
+
const accounts = await api.getConnectedAccounts();
|
|
223
|
+
const groupedAccounts = api.groupAccountsByPlatform(accounts);
|
|
224
|
+
const results = {
|
|
225
|
+
dryRun: options.dryRun === true,
|
|
226
|
+
platforms,
|
|
227
|
+
previews: [],
|
|
228
|
+
published: [],
|
|
229
|
+
scheduled: [],
|
|
230
|
+
skipped: [],
|
|
231
|
+
errors: [],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
for (const platform of platforms) {
|
|
235
|
+
const normalizedPlatform = String(platform || '').trim().toLowerCase();
|
|
236
|
+
const platformAccounts = groupedAccounts.get(normalizedPlatform) || [];
|
|
237
|
+
if (platformAccounts.length === 0) {
|
|
238
|
+
results.skipped.push({ platform: normalizedPlatform, reason: 'not_connected' });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const content = buildPlatformPost(normalizedPlatform);
|
|
243
|
+
results.previews.push({
|
|
244
|
+
platform: normalizedPlatform,
|
|
245
|
+
content,
|
|
246
|
+
accountCount: platformAccounts.length,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (results.dryRun) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const utm = {
|
|
254
|
+
source: normalizedPlatform === 'twitter' ? 'x' : normalizedPlatform,
|
|
255
|
+
medium: 'organic_social',
|
|
256
|
+
campaign: LAUNCH_CAMPAIGN,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
if (normalizedPlatform === 'instagram') {
|
|
261
|
+
if (schedule) {
|
|
262
|
+
results.skipped.push({ platform: normalizedPlatform, reason: 'schedule_not_supported_for_instagram_launch' });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const instagramResult = await api.publishInstagramThumbGate({ caption: content });
|
|
267
|
+
results.published.push({ platform: normalizedPlatform, result: instagramResult });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (schedule) {
|
|
272
|
+
const scheduledResult = await api.schedulePost(content, platformAccounts, schedule, timezone, { utm });
|
|
273
|
+
results.scheduled.push({ platform: normalizedPlatform, result: scheduledResult });
|
|
274
|
+
} else {
|
|
275
|
+
const publishResult = await api.publishPost(content, platformAccounts, { utm });
|
|
276
|
+
results.published.push({ platform: normalizedPlatform, result: publishResult });
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
results.errors.push({
|
|
280
|
+
platform: normalizedPlatform,
|
|
281
|
+
error: error && error.message ? error.message : String(error),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return results;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function main() {
|
|
290
|
+
const options = parseArgs(process.argv.slice(2));
|
|
291
|
+
const results = await publishLaunchCampaign(options);
|
|
292
|
+
process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
|
|
293
|
+
if (results.errors.length > 0) {
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
main().catch((error) => {
|
|
300
|
+
console.error(error && error.message ? error.message : error);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
APP_ORIGIN,
|
|
307
|
+
DEFAULT_LAUNCH_PLATFORMS,
|
|
308
|
+
DEFAULT_TIMEZONE,
|
|
309
|
+
LAUNCH_CAMPAIGN,
|
|
310
|
+
buildCampaignEntries,
|
|
311
|
+
buildLandingUrl,
|
|
312
|
+
buildPlatformPost,
|
|
313
|
+
defaultCampaignSchedule,
|
|
314
|
+
parseArgs,
|
|
315
|
+
publishLaunchCampaign,
|
|
316
|
+
};
|
|
@@ -257,24 +257,19 @@ async function submitComment(token, userAgent, { parentId, text }) {
|
|
|
257
257
|
// ---------------------------------------------------------------------------
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
* Build
|
|
261
|
-
*
|
|
260
|
+
* Build a follow-up comment for a Reddit post.
|
|
261
|
+
* Kept minimal and non-promotional to avoid spam flags.
|
|
262
|
+
* Reddit communities aggressively downvote/ban promotional CTAs.
|
|
262
263
|
*
|
|
263
|
-
* @param {string} subreddit - The subreddit name
|
|
264
|
-
* @param {string} [utmContent] - Optional UTM content tag
|
|
264
|
+
* @param {string} subreddit - The subreddit name
|
|
265
|
+
* @param {string} [utmContent] - Optional UTM content tag
|
|
265
266
|
* @returns {string} The follow-up comment text
|
|
266
267
|
*/
|
|
267
268
|
function buildFollowUpComment(subreddit, utmContent) {
|
|
268
|
-
const content = utmContent || `${subreddit}_post`;
|
|
269
|
-
const trialUrl = `https://thumbgate-production.up.railway.app/?utm_source=reddit&utm_medium=organic_social&utm_campaign=reddit_followup_comment&utm_content=${encodeURIComponent(content)}&community=${encodeURIComponent(subreddit)}`;
|
|
270
269
|
return [
|
|
271
|
-
'
|
|
270
|
+
'Happy to answer questions about the implementation.',
|
|
272
271
|
'',
|
|
273
|
-
'
|
|
274
|
-
'',
|
|
275
|
-
`Try free for 7 days (no credit card, 2-minute setup): ${trialUrl}`,
|
|
276
|
-
'',
|
|
277
|
-
'Source code (MIT licensed): https://github.com/IgorGanapolsky/ThumbGate',
|
|
272
|
+
'Source code (MIT): https://github.com/IgorGanapolsky/ThumbGate',
|
|
278
273
|
'',
|
|
279
274
|
'Disclosure: I built this.',
|
|
280
275
|
].join('\n');
|
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* ZERNIO_API_KEY — Bearer token for https://zernio.com/api/v1
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
11
13
|
const { tagUrlsInText } = require('../utm');
|
|
14
|
+
const { loadLocalEnv } = require('../load-env');
|
|
12
15
|
|
|
13
16
|
const ZERNIO_UTM = { source: 'zernio', medium: 'social', campaign: 'organic' };
|
|
14
17
|
|
|
15
18
|
const ZERNIO_BASE = 'https://zernio.com/api/v1';
|
|
16
19
|
|
|
20
|
+
loadLocalEnv();
|
|
21
|
+
|
|
17
22
|
function requireApiKey() {
|
|
18
23
|
const key = process.env.ZERNIO_API_KEY;
|
|
19
24
|
if (!key) {
|
|
@@ -22,6 +27,82 @@ function requireApiKey() {
|
|
|
22
27
|
return key;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
function resolveAccountId(account) {
|
|
31
|
+
if (!account || typeof account !== 'object') {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
return String(account.accountId || account._id || account.id || '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAccount(account) {
|
|
38
|
+
if (!account || typeof account !== 'object') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const platform = String(account.platform || '').trim();
|
|
42
|
+
const accountId = resolveAccountId(account);
|
|
43
|
+
return {
|
|
44
|
+
...account,
|
|
45
|
+
platform,
|
|
46
|
+
accountId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizePlatforms(platforms) {
|
|
51
|
+
return platforms
|
|
52
|
+
.map(normalizeAccount)
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((platform) => ({
|
|
55
|
+
platform: platform.platform,
|
|
56
|
+
accountId: platform.accountId,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function groupAccountsByPlatform(accounts) {
|
|
61
|
+
const groups = new Map();
|
|
62
|
+
for (const account of accounts) {
|
|
63
|
+
if (!account || !account.platform || !account.accountId) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const existing = groups.get(account.platform) || [];
|
|
67
|
+
existing.push({
|
|
68
|
+
platform: account.platform,
|
|
69
|
+
accountId: account.accountId,
|
|
70
|
+
});
|
|
71
|
+
groups.set(account.platform, existing);
|
|
72
|
+
}
|
|
73
|
+
return groups;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inferContentType(filePath) {
|
|
77
|
+
const ext = path.extname(String(filePath || '')).toLowerCase();
|
|
78
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
|
79
|
+
if (ext === '.png') return 'image/png';
|
|
80
|
+
if (ext === '.webp') return 'image/webp';
|
|
81
|
+
if (ext === '.gif') return 'image/gif';
|
|
82
|
+
if (ext === '.mp4') return 'video/mp4';
|
|
83
|
+
if (ext === '.mov') return 'video/quicktime';
|
|
84
|
+
if (ext === '.webm') return 'video/webm';
|
|
85
|
+
return 'application/octet-stream';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function inferMediaType(contentType) {
|
|
89
|
+
if (String(contentType || '').startsWith('video/')) {
|
|
90
|
+
return 'video';
|
|
91
|
+
}
|
|
92
|
+
return 'image';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizePostResult(payload) {
|
|
96
|
+
const data = payload && typeof payload === 'object' ? (payload.data ?? payload) : {};
|
|
97
|
+
const post = data.post && typeof data.post === 'object' ? data.post : null;
|
|
98
|
+
const id = data.id || data._id || post?._id || post?.id || null;
|
|
99
|
+
return {
|
|
100
|
+
...data,
|
|
101
|
+
id,
|
|
102
|
+
post: post || data.post,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
25
106
|
async function zernioFetch(method, endpoint, body = null) {
|
|
26
107
|
const apiKey = requireApiKey();
|
|
27
108
|
const url = `${ZERNIO_BASE}${endpoint}`;
|
|
@@ -48,20 +129,92 @@ async function zernioFetch(method, endpoint, body = null) {
|
|
|
48
129
|
return res.json();
|
|
49
130
|
}
|
|
50
131
|
|
|
132
|
+
async function listPosts(options = {}) {
|
|
133
|
+
const query = new URLSearchParams();
|
|
134
|
+
if (options.limit) query.set('limit', String(options.limit));
|
|
135
|
+
if (options.page) query.set('page', String(options.page));
|
|
136
|
+
if (options.status) query.set('status', String(options.status));
|
|
137
|
+
|
|
138
|
+
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
139
|
+
const json = await zernioFetch('GET', `/posts${suffix}`);
|
|
140
|
+
return Array.isArray(json.posts) ? json.posts : (json.data?.posts || json.data || []);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function deletePost(postId) {
|
|
144
|
+
if (!postId) throw new Error('deletePost: postId is required');
|
|
145
|
+
const json = await zernioFetch('DELETE', `/posts/${encodeURIComponent(String(postId).trim())}`);
|
|
146
|
+
return json.data ?? json;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function requestMediaPresign(filename, contentType, size) {
|
|
150
|
+
if (!filename) throw new Error('requestMediaPresign: filename is required');
|
|
151
|
+
if (!contentType) throw new Error('requestMediaPresign: contentType is required');
|
|
152
|
+
|
|
153
|
+
const json = await zernioFetch('POST', '/media/presign', {
|
|
154
|
+
filename,
|
|
155
|
+
contentType,
|
|
156
|
+
size,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return json.data ?? json;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function uploadLocalMedia(filePath, options = {}) {
|
|
163
|
+
const resolvedPath = path.resolve(String(filePath || ''));
|
|
164
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
165
|
+
throw new Error(`uploadLocalMedia: file not found at ${resolvedPath}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const stats = fs.statSync(resolvedPath);
|
|
169
|
+
const filename = options.filename || path.basename(resolvedPath);
|
|
170
|
+
const contentType = options.contentType || inferContentType(resolvedPath);
|
|
171
|
+
const presign = await requestMediaPresign(filename, contentType, stats.size);
|
|
172
|
+
|
|
173
|
+
if (!presign.uploadUrl || !presign.publicUrl) {
|
|
174
|
+
throw new Error('uploadLocalMedia: presign response missing uploadUrl or publicUrl');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const uploadResponse = await fetch(presign.uploadUrl, {
|
|
178
|
+
method: 'PUT',
|
|
179
|
+
headers: {
|
|
180
|
+
'Content-Type': contentType,
|
|
181
|
+
},
|
|
182
|
+
body: fs.readFileSync(resolvedPath),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (!uploadResponse.ok) {
|
|
186
|
+
const errorText = await uploadResponse.text().catch(() => '');
|
|
187
|
+
throw new Error(`uploadLocalMedia: upload failed with ${uploadResponse.status}: ${errorText}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
contentType,
|
|
192
|
+
key: presign.key || '',
|
|
193
|
+
size: stats.size,
|
|
194
|
+
type: presign.type || inferMediaType(contentType),
|
|
195
|
+
url: presign.publicUrl,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
51
199
|
/**
|
|
52
200
|
* Publishes a post immediately to one or more platforms.
|
|
53
201
|
* @param {string} content
|
|
54
202
|
* @param {Array<{platform: string, accountId: string}>} platforms
|
|
55
203
|
* @returns {Promise<object>}
|
|
56
204
|
*/
|
|
57
|
-
async function publishPost(content, platforms) {
|
|
205
|
+
async function publishPost(content, platforms, options = {}) {
|
|
58
206
|
if (!content) throw new Error('publishPost: content is required');
|
|
59
207
|
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
60
208
|
throw new Error('publishPost: platforms must be a non-empty array');
|
|
61
209
|
}
|
|
62
210
|
|
|
211
|
+
const normalizedPlatforms = normalizePlatforms(platforms);
|
|
212
|
+
if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
|
|
213
|
+
throw new Error('publishPost: each platform entry requires platform and accountId');
|
|
214
|
+
}
|
|
215
|
+
|
|
63
216
|
// Tag trackable URLs with Zernio UTM parameters before publishing
|
|
64
|
-
content = tagUrlsInText(content, ZERNIO_UTM);
|
|
217
|
+
content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
|
|
65
218
|
|
|
66
219
|
const qualityGate = require('../../social-quality-gate');
|
|
67
220
|
const gateResult = qualityGate.gatePost(content);
|
|
@@ -71,15 +224,17 @@ async function publishPost(content, platforms) {
|
|
|
71
224
|
return { blocked: true, reasons: gateResult.findings };
|
|
72
225
|
}
|
|
73
226
|
|
|
74
|
-
console.log(`[zernio:publisher] Publishing to ${
|
|
227
|
+
console.log(`[zernio:publisher] Publishing to ${normalizedPlatforms.length} platform(s): ${normalizedPlatforms.map((p) => p.platform).join(', ')}`);
|
|
75
228
|
|
|
76
229
|
const json = await zernioFetch('POST', '/posts', {
|
|
77
230
|
content,
|
|
231
|
+
firstComment: options.firstComment,
|
|
232
|
+
mediaItems: options.mediaItems,
|
|
78
233
|
publishNow: true,
|
|
79
|
-
platforms,
|
|
234
|
+
platforms: normalizedPlatforms,
|
|
80
235
|
});
|
|
81
236
|
|
|
82
|
-
const data = json
|
|
237
|
+
const data = normalizePostResult(json);
|
|
83
238
|
console.log(`[zernio:publisher] Post published. id=${data.id ?? 'unknown'}`);
|
|
84
239
|
return data;
|
|
85
240
|
}
|
|
@@ -92,7 +247,7 @@ async function publishPost(content, platforms) {
|
|
|
92
247
|
* @param {string} timezone IANA timezone string
|
|
93
248
|
* @returns {Promise<object>}
|
|
94
249
|
*/
|
|
95
|
-
async function schedulePost(content, platforms, scheduledFor, timezone) {
|
|
250
|
+
async function schedulePost(content, platforms, scheduledFor, timezone, options = {}) {
|
|
96
251
|
if (!content) throw new Error('schedulePost: content is required');
|
|
97
252
|
if (!Array.isArray(platforms) || platforms.length === 0) {
|
|
98
253
|
throw new Error('schedulePost: platforms must be a non-empty array');
|
|
@@ -100,17 +255,26 @@ async function schedulePost(content, platforms, scheduledFor, timezone) {
|
|
|
100
255
|
if (!scheduledFor) throw new Error('schedulePost: scheduledFor is required');
|
|
101
256
|
if (!timezone) throw new Error('schedulePost: timezone is required');
|
|
102
257
|
|
|
103
|
-
|
|
258
|
+
const normalizedPlatforms = normalizePlatforms(platforms);
|
|
259
|
+
if (normalizedPlatforms.length === 0 || normalizedPlatforms.some((entry) => !entry.platform || !entry.accountId)) {
|
|
260
|
+
throw new Error('schedulePost: each platform entry requires platform and accountId');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
|
|
264
|
+
|
|
265
|
+
console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${normalizedPlatforms.length} platform(s)`);
|
|
104
266
|
|
|
105
267
|
const json = await zernioFetch('POST', '/posts', {
|
|
106
268
|
content,
|
|
269
|
+
firstComment: options.firstComment,
|
|
270
|
+
mediaItems: options.mediaItems,
|
|
107
271
|
publishNow: false,
|
|
108
|
-
platforms,
|
|
272
|
+
platforms: normalizedPlatforms,
|
|
109
273
|
scheduledFor,
|
|
110
274
|
timezone,
|
|
111
275
|
});
|
|
112
276
|
|
|
113
|
-
const data = json
|
|
277
|
+
const data = normalizePostResult(json);
|
|
114
278
|
console.log(`[zernio:publisher] Post scheduled. id=${data.id ?? 'unknown'}`);
|
|
115
279
|
return data;
|
|
116
280
|
}
|
|
@@ -123,7 +287,9 @@ async function getConnectedAccounts() {
|
|
|
123
287
|
console.log('[zernio:publisher] Fetching connected accounts');
|
|
124
288
|
|
|
125
289
|
const json = await zernioFetch('GET', '/accounts');
|
|
126
|
-
const accounts = Array.isArray(json) ? json : (json.data ?? json.accounts ?? [])
|
|
290
|
+
const accounts = (Array.isArray(json) ? json : (json.data ?? json.accounts ?? []))
|
|
291
|
+
.map(normalizeAccount)
|
|
292
|
+
.filter((account) => account && account.platform && account.accountId);
|
|
127
293
|
|
|
128
294
|
console.log(`[zernio:publisher] ${accounts.length} connected account(s) found`);
|
|
129
295
|
return accounts;
|
|
@@ -134,7 +300,7 @@ async function getConnectedAccounts() {
|
|
|
134
300
|
* @param {string} content
|
|
135
301
|
* @returns {Promise<{ published: object[], errors: object[] }>}
|
|
136
302
|
*/
|
|
137
|
-
async function publishToAllPlatforms(content) {
|
|
303
|
+
async function publishToAllPlatforms(content, options = {}) {
|
|
138
304
|
if (!content) throw new Error('publishToAllPlatforms: content is required');
|
|
139
305
|
|
|
140
306
|
console.log('[zernio:publisher] Fetching all connected accounts for bulk publish');
|
|
@@ -145,20 +311,31 @@ async function publishToAllPlatforms(content) {
|
|
|
145
311
|
return { published: [], errors: [] };
|
|
146
312
|
}
|
|
147
313
|
|
|
148
|
-
const platforms = accounts.map((acc) => ({
|
|
149
|
-
platform: acc.platform,
|
|
150
|
-
accountId: acc.accountId,
|
|
151
|
-
}));
|
|
152
|
-
|
|
153
314
|
const published = [];
|
|
154
315
|
const errors = [];
|
|
316
|
+
const requestedPlatforms = Array.isArray(options.platforms) && options.platforms.length > 0
|
|
317
|
+
? new Set(options.platforms.map((platform) => String(platform || '').trim()).filter(Boolean))
|
|
318
|
+
: null;
|
|
319
|
+
const groupedAccounts = groupAccountsByPlatform(accounts);
|
|
320
|
+
|
|
321
|
+
for (const [platform, platformAccounts] of groupedAccounts.entries()) {
|
|
322
|
+
if (requestedPlatforms && !requestedPlatforms.has(platform)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
155
325
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
326
|
+
try {
|
|
327
|
+
const result = await publishPost(content, platformAccounts, {
|
|
328
|
+
utm: {
|
|
329
|
+
source: platform,
|
|
330
|
+
medium: options.medium || ZERNIO_UTM.medium,
|
|
331
|
+
campaign: options.campaign || ZERNIO_UTM.campaign,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
published.push({ platform, result });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error(`[zernio:publisher] Bulk publish failed for ${platform}: ${err.message}`);
|
|
337
|
+
errors.push({ error: err.message, platform });
|
|
338
|
+
}
|
|
162
339
|
}
|
|
163
340
|
|
|
164
341
|
console.log(`[zernio:publisher] Bulk publish complete. published=${published.length} errors=${errors.length}`);
|
|
@@ -166,10 +343,21 @@ async function publishToAllPlatforms(content) {
|
|
|
166
343
|
}
|
|
167
344
|
|
|
168
345
|
module.exports = {
|
|
346
|
+
deletePost,
|
|
347
|
+
listPosts,
|
|
169
348
|
publishPost,
|
|
170
349
|
schedulePost,
|
|
171
350
|
publishToAllPlatforms,
|
|
172
351
|
getConnectedAccounts,
|
|
352
|
+
groupAccountsByPlatform,
|
|
353
|
+
inferContentType,
|
|
354
|
+
inferMediaType,
|
|
355
|
+
normalizeAccount,
|
|
356
|
+
normalizePlatforms,
|
|
357
|
+
normalizePostResult,
|
|
358
|
+
requestMediaPresign,
|
|
359
|
+
resolveAccountId,
|
|
360
|
+
uploadLocalMedia,
|
|
173
361
|
};
|
|
174
362
|
|
|
175
363
|
if (require.main === module) {
|