social-autoposter 1.6.0 → 1.6.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/bin/server.js CHANGED
@@ -42,7 +42,7 @@ function agentPath(job) {
42
42
 
43
43
  // Matrix: rows = job types, columns = platforms
44
44
  // Each cell is a job (or null if that combo doesn't exist)
45
- const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub'];
45
+ const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub', 'Instagram'];
46
46
  const JOB_TYPES = ['Post Threads', 'Post Comments', 'Engage', 'DM Outreach', 'DM Replies', 'Link Edit', 'Stats', 'Post Audit', 'Octolens'];
47
47
 
48
48
  const JOBS = [
@@ -86,6 +86,7 @@ const JOBS = [
86
86
  { label: 'com.m13v.social-stats-twitter', name: 'Stats Twitter', type: 'Stats', platform: 'Twitter', script: 'stats-twitter.sh', logPrefix: 'stats-twitter-', plist: 'com.m13v.social-stats-twitter.plist' },
87
87
  { label: 'com.m13v.social-stats-linkedin', name: 'Stats LinkedIn', type: 'Stats', platform: 'LinkedIn', script: 'stats-linkedin.sh', logPrefix: 'stats-linkedin-', plist: 'com.m13v.social-stats-linkedin.plist' },
88
88
  { label: 'com.m13v.social-stats-moltbook', name: 'Stats MoltBook', type: 'Stats', platform: 'MoltBook', script: 'stats-moltbook.sh', logPrefix: 'stats-moltbook-', plist: 'com.m13v.social-stats-moltbook.plist' },
89
+ { label: 'com.m13v.social-stats-instagram', name: 'Stats Instagram', type: 'Stats', platform: 'Instagram', script: 'stats-instagram.sh', logPrefix: 'stats-instagram-', plist: 'com.m13v.social-stats-instagram.plist' },
89
90
  // Post Audit row (verify posts still exist / API health)
90
91
  { label: 'com.m13v.social-audit-reddit', name: 'Post Audit Reddit', type: 'Post Audit', platform: 'Reddit', script: 'audit-reddit.sh', logPrefix: 'audit-reddit-', plist: 'com.m13v.social-audit-reddit.plist' },
91
92
  { label: 'com.m13v.social-audit-twitter', name: 'Post Audit Twitter', type: 'Post Audit', platform: 'Twitter', script: 'audit-twitter.sh', logPrefix: 'audit-twitter-', plist: 'com.m13v.social-audit-twitter.plist' },
@@ -128,6 +129,7 @@ const REQUIRED_LOCKS = {
128
129
  'link-edit-moltbook.sh': ['link-edit-moltbook'],
129
130
  'link-edit-github.sh': ['link-edit-github'],
130
131
  'stats-reddit.sh': ['reddit-browser'],
132
+ 'stats-instagram.sh': ['instagram-poster'],
131
133
  'audit-reddit.sh': ['reddit-browser', 'audit-reddit'],
132
134
  'audit-twitter.sh': ['twitter-browser', 'audit-twitter'],
133
135
  'audit-linkedin.sh': ['linkedin-browser', 'audit-linkedin'],
@@ -154,6 +156,38 @@ function json(res, obj, status = 200) {
154
156
  res.end(JSON.stringify(obj));
155
157
  }
156
158
 
159
+ // Short-link code alphabet matches scripts/dm_short_links.py CODE_ALPHABET
160
+ // so codes minted by either path are visually consistent. 32 chars (no 0/1/l/o
161
+ // to avoid ambiguity in handwritten/printed contexts).
162
+ const SHORT_LINK_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789';
163
+
164
+ function mintShortLinkCode(n = 8) {
165
+ let s = '';
166
+ const bytes = crypto.randomBytes(n);
167
+ for (let i = 0; i < n; i++) {
168
+ s += SHORT_LINK_ALPHABET[bytes[i] % SHORT_LINK_ALPHABET.length];
169
+ }
170
+ return s;
171
+ }
172
+
173
+ // Look up a project's wrapper host (where /r/<code> is served) by name from
174
+ // config.json. Mirrors _project_short_links_host in scripts/dm_short_links.py.
175
+ // Falls back to project.website. Returns null if the project is not configured.
176
+ function getProjectWrapperHost(projectName) {
177
+ if (!projectName) return null;
178
+ try {
179
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
180
+ const projects = Array.isArray(config.projects) ? config.projects : [];
181
+ const p = projects.find(x => x && x.name === projectName);
182
+ if (!p) return null;
183
+ const host = (p.short_links_host || p.website || '').trim().replace(/\/+$/, '');
184
+ return host || null;
185
+ } catch (e) {
186
+ console.error('[wrapper-host] config read failed:', e.message);
187
+ return null;
188
+ }
189
+ }
190
+
157
191
  function isJobLoaded(label) {
158
192
  return driver.isLoaded(label);
159
193
  }
@@ -3246,32 +3280,79 @@ async function handleApi(req, res) {
3246
3280
  RETURNING post_id, reply_id, platform, project_name,
3247
3281
  target_url, kind`;
3248
3282
  const postRes = await pool.query(postSql, [code]);
3249
- if (!postRes.rows.length) {
3283
+ if (postRes.rows.length) {
3284
+ const prow = postRes.rows[0];
3285
+ if (!prow.target_url) {
3286
+ return json(res, { error: 'no_target_url', post_id: prow.post_id, reply_id: prow.reply_id }, 404);
3287
+ }
3288
+ // Per-click log for post rail (humans + bots).
3289
+ try {
3290
+ await pool.query(
3291
+ `INSERT INTO post_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3292
+ VALUES ($1, $2, $3, $4, $5)`,
3293
+ [code, ipHash, fwdUa, isBot, fwdRef]
3294
+ );
3295
+ } catch (e) {
3296
+ console.error('[short-links] post_link_clicks insert failed (non-fatal):', e.message);
3297
+ }
3298
+ let pPlatform = (prow.platform || 'reddit').toLowerCase();
3299
+ if (pPlatform === 'x') pPlatform = 'twitter';
3300
+ return json(res, {
3301
+ post_id: prow.post_id,
3302
+ reply_id: prow.reply_id,
3303
+ platform: pPlatform,
3304
+ project: prow.project_name || null,
3305
+ kind: prow.kind || null,
3306
+ target_url: prow.target_url,
3307
+ });
3308
+ }
3309
+
3310
+ // Final fallback: newsletter_links rail. Outbound broadcast emails
3311
+ // sent by ~/analytics (dash.m13v.com) mint per-recipient codes via
3312
+ // POST /api/newsletter/mint; the resolver here closes the loop on
3313
+ // click attribution. broadcast_product + broadcast_id let the
3314
+ // analytics dashboard cross-reference back into the originating
3315
+ // studyly_broadcast_log / fazm_broadcasts / etc. row. The customer
3316
+ // /r/[code] handler (see ~/seo-components/src/lib/dm-short-link-redirect.ts)
3317
+ // fires `newsletter_short_link_clicked` in PostHog when broadcast_id
3318
+ // is set in the response, so signup attribution stitches via the
3319
+ // same anonymous distinct_id session that landed on the page.
3320
+ const newsSql = isBot
3321
+ ? `SELECT broadcast_product, broadcast_id, recipient_email_hash,
3322
+ recipient_email, target_url, kind, project_name
3323
+ FROM newsletter_links WHERE code = $1`
3324
+ : `UPDATE newsletter_links SET
3325
+ clicks = clicks + 1,
3326
+ first_click_at = COALESCE(first_click_at, NOW()),
3327
+ last_click_at = NOW()
3328
+ WHERE code = $1
3329
+ RETURNING broadcast_product, broadcast_id, recipient_email_hash,
3330
+ recipient_email, target_url, kind, project_name`;
3331
+ const newsRes = await pool.query(newsSql, [code]);
3332
+ if (!newsRes.rows.length) {
3250
3333
  return json(res, { error: 'not_found', code }, 404);
3251
3334
  }
3252
- const prow = postRes.rows[0];
3253
- if (!prow.target_url) {
3254
- return json(res, { error: 'no_target_url', post_id: prow.post_id, reply_id: prow.reply_id }, 404);
3335
+ const nrow = newsRes.rows[0];
3336
+ if (!nrow.target_url) {
3337
+ return json(res, { error: 'no_target_url', broadcast_id: nrow.broadcast_id }, 404);
3255
3338
  }
3256
- // Per-click log for post rail (humans + bots).
3339
+ // Per-click log for newsletter rail (humans + bots).
3257
3340
  try {
3258
3341
  await pool.query(
3259
- `INSERT INTO post_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3342
+ `INSERT INTO newsletter_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3260
3343
  VALUES ($1, $2, $3, $4, $5)`,
3261
3344
  [code, ipHash, fwdUa, isBot, fwdRef]
3262
3345
  );
3263
3346
  } catch (e) {
3264
- console.error('[short-links] post_link_clicks insert failed (non-fatal):', e.message);
3347
+ console.error('[short-links] newsletter_link_clicks insert failed (non-fatal):', e.message);
3265
3348
  }
3266
- let pPlatform = (prow.platform || 'reddit').toLowerCase();
3267
- if (pPlatform === 'x') pPlatform = 'twitter';
3268
3349
  return json(res, {
3269
- post_id: prow.post_id,
3270
- reply_id: prow.reply_id,
3271
- platform: pPlatform,
3272
- project: prow.project_name || null,
3273
- kind: prow.kind || null,
3274
- target_url: prow.target_url,
3350
+ broadcast_id: Number(nrow.broadcast_id),
3351
+ broadcast_product: nrow.broadcast_product,
3352
+ recipient_email_hash: nrow.recipient_email_hash,
3353
+ project: nrow.project_name || nrow.broadcast_product || null,
3354
+ kind: nrow.kind || null,
3355
+ target_url: nrow.target_url,
3275
3356
  });
3276
3357
  } catch (e) {
3277
3358
  console.error('[short-links] resolver db error:', e.message);
@@ -3279,6 +3360,218 @@ async function handleApi(req, res) {
3279
3360
  }
3280
3361
  }
3281
3362
 
3363
+ // PUBLIC (shared-secret): newsletter rail mint endpoint.
3364
+ //
3365
+ // Called server-to-server by ~/analytics (dash.m13v.com) at broadcast
3366
+ // send time. For every recipient x URL, mints a code and INSERTs a row
3367
+ // in newsletter_links so /api/short-links/<code> can resolve it later.
3368
+ // The caller passes raw target URLs (already UTM-stamped on its side
3369
+ // per the canonical s4l UTM scheme), this endpoint just wraps them.
3370
+ //
3371
+ // Auth is a NEWSLETTER_API_SECRET shared between analytics + this server.
3372
+ // Without it the endpoint 401s. We deliberately do NOT use the Firebase
3373
+ // auth path because the caller is a Vercel serverless function with no
3374
+ // Firebase ID token of its own.
3375
+ //
3376
+ // Request: { product: "studyly", broadcast_id: 5,
3377
+ // recipient_email: "a@b.com",
3378
+ // urls: [{ target_url: "https://studyly.io/...", kind: "web" }, ...] }
3379
+ // Response: { links: [{ target_url, code, short_url } | { target_url, error }, ...] }
3380
+ if (p === '/api/newsletter/mint' && req.method === 'POST') {
3381
+ const authz = (req.headers['authorization'] || '').toString();
3382
+ const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
3383
+ if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
3384
+ return json(res, { error: 'unauthorized' }, 401);
3385
+ }
3386
+ let body;
3387
+ try {
3388
+ const raw = await readBody(req);
3389
+ body = JSON.parse(raw || '{}');
3390
+ } catch {
3391
+ return json(res, { error: 'bad_json' }, 400);
3392
+ }
3393
+ const { product, broadcast_id, recipient_email, urls, wrapper_host: wrapperHostInput } = body || {};
3394
+ if (typeof product !== 'string' || !product.trim()) {
3395
+ return json(res, { error: 'product required' }, 400);
3396
+ }
3397
+ const broadcastId = Number(broadcast_id);
3398
+ if (!Number.isFinite(broadcastId)) {
3399
+ return json(res, { error: 'broadcast_id (numeric) required' }, 400);
3400
+ }
3401
+ if (typeof recipient_email !== 'string' || !recipient_email.includes('@')) {
3402
+ return json(res, { error: 'recipient_email required' }, 400);
3403
+ }
3404
+ if (!Array.isArray(urls) || urls.length === 0) {
3405
+ return json(res, { error: 'urls (non-empty array) required' }, 400);
3406
+ }
3407
+ if (urls.length > 50) {
3408
+ return json(res, { error: 'too many urls (max 50 per request)' }, 400);
3409
+ }
3410
+ // Wrapper host (where /r/<code> is served): caller can pass it directly,
3411
+ // otherwise fall back to config.json lookup. The Cloud Run image does NOT
3412
+ // bundle config.json (the local-operator dashboard does), so analytics
3413
+ // always passes it explicitly. Local operator usage just works.
3414
+ let wrapperHost = null;
3415
+ if (typeof wrapperHostInput === 'string' && /^https?:\/\//i.test(wrapperHostInput)) {
3416
+ wrapperHost = wrapperHostInput.trim().replace(/\/+$/, '');
3417
+ } else {
3418
+ wrapperHost = getProjectWrapperHost(product);
3419
+ }
3420
+ if (!wrapperHost) {
3421
+ return json(res, { error: `no wrapper host for project '${product}' (pass wrapper_host in request body or set in config.json)` }, 400);
3422
+ }
3423
+ const pool = getPool();
3424
+ if (!pool) return json(res, { error: 'no_db' }, 500);
3425
+
3426
+ const emailLower = recipient_email.trim().toLowerCase();
3427
+ const emailHash = crypto.createHash('sha256').update(emailLower).digest('hex').slice(0, 16);
3428
+
3429
+ const out = [];
3430
+ for (const u of urls) {
3431
+ if (!u || typeof u !== 'object') {
3432
+ out.push({ target_url: null, error: 'not_an_object' });
3433
+ continue;
3434
+ }
3435
+ const targetUrl = String(u.target_url || '').trim();
3436
+ const kind = String(u.kind || 'web').slice(0, 32);
3437
+ if (!/^https?:\/\//i.test(targetUrl)) {
3438
+ out.push({ target_url: targetUrl, error: 'invalid_url' });
3439
+ continue;
3440
+ }
3441
+ // Mint with PK collision retry (8 chars x 32 alphabet = ~10^12 keyspace,
3442
+ // collisions are astronomically rare but cheap to retry).
3443
+ let code = null;
3444
+ let lastErr = null;
3445
+ for (let attempt = 0; attempt < 5; attempt++) {
3446
+ const candidate = mintShortLinkCode(8);
3447
+ try {
3448
+ await pool.query(
3449
+ `INSERT INTO newsletter_links
3450
+ (code, broadcast_product, broadcast_id, recipient_email_hash,
3451
+ recipient_email, target_url, kind, project_name)
3452
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
3453
+ [candidate, product, broadcastId, emailHash, recipient_email,
3454
+ targetUrl, kind, product]
3455
+ );
3456
+ code = candidate;
3457
+ break;
3458
+ } catch (e) {
3459
+ lastErr = e;
3460
+ if (!/duplicate key/i.test(e.message || '')) {
3461
+ console.error('[newsletter/mint] insert failed:', e.message);
3462
+ break;
3463
+ }
3464
+ }
3465
+ }
3466
+ if (!code) {
3467
+ out.push({ target_url: targetUrl, error: lastErr ? lastErr.message.slice(0, 200) : 'mint_failed' });
3468
+ continue;
3469
+ }
3470
+ out.push({
3471
+ target_url: targetUrl,
3472
+ code,
3473
+ short_url: `${wrapperHost}/r/${code}`,
3474
+ });
3475
+ }
3476
+ return json(res, {
3477
+ product,
3478
+ broadcast_id: broadcastId,
3479
+ recipient_email,
3480
+ recipient_email_hash: emailHash,
3481
+ links: out,
3482
+ });
3483
+ }
3484
+
3485
+ // PUBLIC (shared-secret): newsletter rail aggregate stats.
3486
+ //
3487
+ // Returns click + recipient breakdown for a broadcast so the analytics
3488
+ // dashboard and the social-autoposter dashboard can render the same
3489
+ // numbers without either holding state about the other. Click counts
3490
+ // live here (the canonical clicks store); signup attribution lives
3491
+ // in PostHog and is computed analytics-side via HogQL against the
3492
+ // utm_content this endpoint reports.
3493
+ //
3494
+ // Request: GET /api/newsletter/stats?product=studyly&broadcast_id=5
3495
+ // Response: { product, broadcast_id, total_links, total_recipients,
3496
+ // total_human_clicks, recipients_clicked, human_hits,
3497
+ // bot_hits, per_recipient: [...] }
3498
+ if (p === '/api/newsletter/stats' && req.method === 'GET') {
3499
+ const authz = (req.headers['authorization'] || '').toString();
3500
+ const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
3501
+ if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
3502
+ return json(res, { error: 'unauthorized' }, 401);
3503
+ }
3504
+ const product = (url.searchParams.get('product') || '').trim();
3505
+ const broadcastIdRaw = (url.searchParams.get('broadcast_id') || '').trim();
3506
+ if (!product || !broadcastIdRaw) {
3507
+ return json(res, { error: 'product and broadcast_id required' }, 400);
3508
+ }
3509
+ const broadcastId = Number(broadcastIdRaw);
3510
+ if (!Number.isFinite(broadcastId)) {
3511
+ return json(res, { error: 'broadcast_id must be numeric' }, 400);
3512
+ }
3513
+ const pool = getPool();
3514
+ if (!pool) return json(res, { error: 'no_db' }, 500);
3515
+
3516
+ try {
3517
+ const agg = await pool.query(
3518
+ `SELECT
3519
+ COUNT(*) AS total_links,
3520
+ COUNT(DISTINCT recipient_email_hash) AS total_recipients,
3521
+ COALESCE(SUM(clicks), 0) AS total_human_clicks,
3522
+ COUNT(DISTINCT recipient_email_hash) FILTER (WHERE clicks > 0) AS recipients_clicked
3523
+ FROM newsletter_links
3524
+ WHERE broadcast_product = $1 AND broadcast_id = $2`,
3525
+ [product, broadcastId]
3526
+ );
3527
+ const hitsRes = await pool.query(
3528
+ `SELECT
3529
+ COUNT(*) FILTER (WHERE is_bot = false) AS human_hits,
3530
+ COUNT(*) FILTER (WHERE is_bot = true) AS bot_hits
3531
+ FROM newsletter_link_clicks nlc
3532
+ JOIN newsletter_links nl ON nl.code = nlc.code
3533
+ WHERE nl.broadcast_product = $1 AND nl.broadcast_id = $2`,
3534
+ [product, broadcastId]
3535
+ );
3536
+ const perRecipient = await pool.query(
3537
+ `SELECT
3538
+ recipient_email,
3539
+ recipient_email_hash,
3540
+ COUNT(*) AS link_count,
3541
+ COALESCE(SUM(clicks), 0) AS clicks,
3542
+ MAX(last_click_at) AS last_click_at
3543
+ FROM newsletter_links
3544
+ WHERE broadcast_product = $1 AND broadcast_id = $2
3545
+ GROUP BY recipient_email, recipient_email_hash
3546
+ ORDER BY clicks DESC, link_count DESC
3547
+ LIMIT 1000`,
3548
+ [product, broadcastId]
3549
+ );
3550
+ const row = agg.rows[0] || {};
3551
+ const hitRow = hitsRes.rows[0] || {};
3552
+ return json(res, {
3553
+ product,
3554
+ broadcast_id: broadcastId,
3555
+ total_links: Number(row.total_links || 0),
3556
+ total_recipients: Number(row.total_recipients || 0),
3557
+ total_human_clicks: Number(row.total_human_clicks || 0),
3558
+ recipients_clicked: Number(row.recipients_clicked || 0),
3559
+ human_hits: Number(hitRow.human_hits || 0),
3560
+ bot_hits: Number(hitRow.bot_hits || 0),
3561
+ per_recipient: perRecipient.rows.map(r => ({
3562
+ email: r.recipient_email,
3563
+ email_hash: r.recipient_email_hash,
3564
+ link_count: Number(r.link_count || 0),
3565
+ clicks: Number(r.clicks || 0),
3566
+ last_click_at: r.last_click_at,
3567
+ })),
3568
+ });
3569
+ } catch (e) {
3570
+ console.error('[newsletter/stats] db error:', e.message);
3571
+ return json(res, { error: 'stats_failed', detail: String(e.message).slice(0, 500) }, 500);
3572
+ }
3573
+ }
3574
+
3282
3575
  // Auth: no-op when CLIENT_MODE is unset (local operator use).
3283
3576
  // When CLIENT_MODE=1, require a Firebase Bearer token and enforce admin/project claims.
3284
3577
  const av = await auth.verifyAuth(req, p);
@@ -4416,8 +4709,12 @@ async function handleApi(req, res) {
4416
4709
  // and scripts/engagement_styles.py picker). clicks weighted ×10 because a
4417
4710
  // real human click outvalues 10 likes of vibes. Clicks come from a
4418
4711
  // bot-filtered subquery against post_link_clicks (matching the picker).
4712
+ // NOTE: reference pl.total_clicks directly here, NOT the `clicks` alias.
4713
+ // Postgres does not let one expression in a SELECT list reference another
4714
+ // expression's alias from the same SELECT; doing so previously caused
4715
+ // `column "clicks" does not exist` and silently returned 0 rows.
4419
4716
  const scoreExpr =
4420
- "(COALESCE(clicks, 0) * 10 + " +
4717
+ "(COALESCE(pl.total_clicks, 0) * 10 + " +
4421
4718
  "COALESCE(comments_count,0) * 3 + " +
4422
4719
  "CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
4423
4720
  "THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
@@ -5647,7 +5944,7 @@ async function handleApi(req, res) {
5647
5944
  const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
5648
5945
  const windowHours = WINDOW_HOURS[windowKey];
5649
5946
  const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5650
- const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook']);
5947
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'instagram']);
5651
5948
  const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
5652
5949
  const rawKind = String(url.searchParams.get('kind') || 'all').toLowerCase().trim();
5653
5950
  const kindFilter = (rawKind === 'threads' || rawKind === 'comments') ? rawKind : 'all';
@@ -8446,7 +8743,7 @@ function fmtInterval(secs) {
8446
8743
  }
8447
8744
 
8448
8745
  let _initialized = false;
8449
- const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub'];
8746
+ const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub', 'Instagram'];
8450
8747
  const JOB_TYPES = ['Post Threads', 'Post Comments', 'Engage', 'DM Outreach', 'DM Replies', 'Link Edit', 'Stats', 'Post Audit', 'Octolens'];
8451
8748
 
8452
8749
  function renderToggle(label, loaded) {
@@ -14378,6 +14675,20 @@ function renderTopPosts(payload) {
14378
14675
  filterMode: 'none' },
14379
14676
  ],
14380
14677
  });
14678
+ // Reconcile the top-level "Link sent" pill with the column filter that
14679
+ // mountSortableTable just hydrated from localStorage. See
14680
+ // syncTopLinkPillFromColumn for the full reasoning.
14681
+ syncTopLinkPillFromColumn();
14682
+ // Also live-sync the pill when the user changes the inline column dropdown
14683
+ // directly. Without this, the pill would stay stale until the next render
14684
+ // (project switch, window flip, etc.) and the user would see the same
14685
+ // mismatch they just reported. mountSortableTable is generic and shouldn't
14686
+ // know about specific columns, so wire this here at the call site.
14687
+ const linkColDd = document.querySelector('select.activity-col-filter[data-filter-key="link_clicks"]');
14688
+ if (linkColDd && !linkColDd._linkPillSyncWired) {
14689
+ linkColDd.addEventListener('change', () => { syncTopLinkPillFromColumn(); });
14690
+ linkColDd._linkPillSyncWired = true;
14691
+ }
14381
14692
  }
14382
14693
 
14383
14694
  async function loadTopPosts(force) {
@@ -14410,6 +14721,39 @@ async function loadTopPosts(force) {
14410
14721
  }
14411
14722
  }
14412
14723
 
14724
+ // Two layers can set the "link sent" filter on the Top > Posts table:
14725
+ // 1. The top-level pill row (#top-dm-link-pills, persisted at sa.top.dmLink.v1)
14726
+ // 2. The inline column dropdown for the link_clicks column (persisted inside
14727
+ // sa.topTable.v2.filters.link_clicks via mountSortableTable).
14728
+ // They hydrate from different localStorage keys, independently, on every page
14729
+ // load. Without reconciliation the table can be rendering with link_clicks=
14730
+ // 'has_link' (only link-sent rows visible) while the pill row reads "All",
14731
+ // leaving the user with no top-level indication that a filter is active.
14732
+ //
14733
+ // Source of truth = the column filter (it's what actually drives the visible
14734
+ // rows). After mountSortableTable hydrates, mirror its value into the pill so
14735
+ // the top-level UI never lies. has_clicks has no pill equivalent: surface it
14736
+ // as "All" on the pill but leave the column filter intact so the inline
14737
+ // dropdown still works for that advanced case.
14738
+ function syncTopLinkPillFromColumn() {
14739
+ const linkRow = document.getElementById('top-dm-link-pills');
14740
+ if (!linkRow) return;
14741
+ const colVal = (_topTableState && _topTableState.filters && _topTableState.filters.link_clicks) || '';
14742
+ const colToPill = { has_link: 'yes', no_link: 'no' };
14743
+ const desired = colToPill[colVal] || 'all';
14744
+ if (_topDmLink === desired) {
14745
+ // Defensive: even if our in-memory _topDmLink is consistent, the visible
14746
+ // active-class on the pill might still be stale from a prior render path
14747
+ // (e.g. inline column dropdown change that bypassed the pill). Force the
14748
+ // DOM to match.
14749
+ setTopPillActive(linkRow, desired);
14750
+ return;
14751
+ }
14752
+ _topDmLink = desired;
14753
+ try { saSave('sa.top.dmLink.v1', _topDmLink); } catch {}
14754
+ setTopPillActive(linkRow, desired);
14755
+ }
14756
+
14413
14757
  function setTopPillActive(row, value) {
14414
14758
  if (!row) return;
14415
14759
  row.dataset.selected = value || 'all';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -0,0 +1,141 @@
1
+ """Single source of truth for the posting account on every platform.
2
+
3
+ Resolution order for each platform (first non-empty wins):
4
+
5
+ 1. Env var `AUTOPOSTER_<PLATFORM>_HANDLE` (used by the VM / per-account
6
+ systemd or launchd units to override config.json without rewriting the
7
+ checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
8
+ name as an alias.
9
+ 2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
10
+
11
+ The handle is normalized:
12
+ - leading `@` is stripped (twitter)
13
+ - leading `u/` is stripped (reddit)
14
+ - surrounding whitespace is stripped
15
+ So both `@matt_diak` and `matt_diak` resolve to `matt_diak`, both
16
+ `u/Deep_Ad1959` and `Deep_Ad1959` resolve to `Deep_Ad1959`, matching the
17
+ canonical shape stored in `posts.our_account` after the 2026-05-20 migration.
18
+
19
+ Returns None if neither source has a value. Callers should treat None as
20
+ "unknown account" and decline to scope per-account work that needs a handle
21
+ (e.g. dedupe filters).
22
+
23
+ Platform key map:
24
+ twitter -> accounts.twitter.handle (env: AUTOPOSTER_TWITTER_HANDLE)
25
+ reddit -> accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
26
+ linkedin -> accounts.linkedin.name (env: AUTOPOSTER_LINKEDIN_NAME)
27
+ github -> accounts.github.username (env: AUTOPOSTER_GITHUB_USERNAME)
28
+ moltbook -> accounts.moltbook.username (env: AUTOPOSTER_MOLTBOOK_USERNAME)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ from functools import lru_cache
35
+ from typing import Optional
36
+
37
+ _PLATFORM_CONFIG_FIELD = {
38
+ "twitter": ("twitter", "handle"),
39
+ "x": ("twitter", "handle"), # alias for the canonical post-platform
40
+ "reddit": ("reddit", "username"),
41
+ "linkedin": ("linkedin", "name"),
42
+ "github": ("github", "username"),
43
+ "moltbook": ("moltbook", "username"),
44
+ }
45
+
46
+ _PLATFORM_ENV_NAME = {
47
+ "twitter": "AUTOPOSTER_TWITTER_HANDLE",
48
+ "x": "AUTOPOSTER_TWITTER_HANDLE",
49
+ "reddit": "AUTOPOSTER_REDDIT_USERNAME",
50
+ "linkedin": "AUTOPOSTER_LINKEDIN_NAME",
51
+ "github": "AUTOPOSTER_GITHUB_USERNAME",
52
+ "moltbook": "AUTOPOSTER_MOLTBOOK_USERNAME",
53
+ }
54
+
55
+
56
+ def normalize(handle: Optional[str]) -> Optional[str]:
57
+ """Canonicalize a raw account handle.
58
+
59
+ Drops leading `@` (twitter) and `u/` (reddit) plus surrounding
60
+ whitespace. Returns None for empty input.
61
+ """
62
+ if not handle:
63
+ return None
64
+ h = handle.strip()
65
+ if h.startswith("@"):
66
+ h = h[1:]
67
+ elif h.lower().startswith("u/"):
68
+ h = h[2:]
69
+ h = h.strip()
70
+ return h or None
71
+
72
+
73
+ @lru_cache(maxsize=1)
74
+ def _load_config() -> dict:
75
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
76
+ cfg_path = os.path.join(repo_root, "config.json")
77
+ try:
78
+ with open(cfg_path, "r", encoding="utf-8") as f:
79
+ return json.load(f) or {}
80
+ except (OSError, json.JSONDecodeError):
81
+ return {}
82
+
83
+
84
+ def resolve(platform: str) -> Optional[str]:
85
+ """Return the normalized posting handle for `platform`, or None."""
86
+ key = (platform or "").strip().lower()
87
+ if key not in _PLATFORM_CONFIG_FIELD:
88
+ return None
89
+
90
+ env_name = _PLATFORM_ENV_NAME[key]
91
+ env_value = normalize(os.environ.get(env_name))
92
+ if env_value:
93
+ return env_value
94
+
95
+ section, field = _PLATFORM_CONFIG_FIELD[key]
96
+ cfg = _load_config()
97
+ accounts = cfg.get("accounts") or {}
98
+ block = accounts.get(section) or {}
99
+ return normalize(block.get(field))
100
+
101
+
102
+ def require(platform: str) -> str:
103
+ """Like resolve() but raises if no handle is configured."""
104
+ h = resolve(platform)
105
+ if not h:
106
+ section, field = _PLATFORM_CONFIG_FIELD.get(
107
+ (platform or "").lower(), ("?", "?")
108
+ )
109
+ env_name = _PLATFORM_ENV_NAME.get(
110
+ (platform or "").lower(), f"AUTOPOSTER_{platform.upper()}_HANDLE"
111
+ )
112
+ raise RuntimeError(
113
+ f"No account configured for platform={platform!r}. "
114
+ f"Set env {env_name} or accounts.{section}.{field} in config.json."
115
+ )
116
+ return h
117
+
118
+
119
+ # Backwards-compatible shim so the existing twitter-only call site keeps
120
+ # working without churn. `from twitter_account import resolve_handle` will
121
+ # continue to work; new code should call `account_resolver.resolve('twitter')`.
122
+ def resolve_handle() -> Optional[str]:
123
+ return resolve("twitter")
124
+
125
+
126
+ def require_handle() -> str:
127
+ return require("twitter")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ import sys
132
+ if len(sys.argv) > 1:
133
+ plat = sys.argv[1]
134
+ else:
135
+ plat = "twitter"
136
+ h = resolve(plat)
137
+ if h:
138
+ sys.stdout.write(h + "\n")
139
+ sys.exit(0)
140
+ sys.stderr.write(f"no handle configured for platform={plat}\n")
141
+ sys.exit(1)