social-autoposter 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/bin/server.js +130 -3
  2. package/package.json +3 -9
  3. package/schema-postgres.sql +8 -0
  4. package/scripts/dm_conversation.py +2 -1
  5. package/scripts/dm_short_links.py +25 -0
  6. package/scripts/linkedin_api.py +10 -3
  7. package/scripts/post_reddit.py +15 -1
  8. package/scripts/reddit_tools.py +14 -0
  9. package/scripts/score_twitter_candidates.py +10 -1
  10. package/scripts/send_dashboard_invite.py +141 -0
  11. package/scripts/twitter_browser.py +11 -2
  12. package/scripts/twitter_post_plan.py +1 -1
  13. package/skill/amplitude-24h-signups.sh +38 -0
  14. package/skill/archive-old-logs.sh +40 -0
  15. package/skill/audit-dm-staleness.sh +55 -0
  16. package/skill/audit-linkedin.sh +4 -0
  17. package/skill/audit-moltbook.sh +4 -0
  18. package/skill/audit-reddit-resurrect.sh +65 -0
  19. package/skill/audit-reddit.sh +4 -0
  20. package/skill/audit-twitter.sh +4 -0
  21. package/skill/audit.sh +228 -0
  22. package/skill/check-external-pool-depth.sh +7 -0
  23. package/skill/check-web-chats.sh +206 -0
  24. package/skill/dm-outreach-linkedin.sh +237 -0
  25. package/skill/dm-outreach-reddit.sh +276 -0
  26. package/skill/dm-outreach-twitter.sh +296 -0
  27. package/skill/engage-dm-replies-linkedin.sh +4 -0
  28. package/skill/engage-dm-replies-reddit.sh +4 -0
  29. package/skill/engage-dm-replies-twitter.sh +4 -0
  30. package/skill/engage-dm-replies.sh +1682 -0
  31. package/skill/engage-linkedin.sh +475 -0
  32. package/skill/engage-moltbook.sh +36 -0
  33. package/skill/engage-reddit.sh +123 -0
  34. package/skill/engage-twitter.sh +489 -0
  35. package/skill/github-engage.sh +172 -0
  36. package/skill/ingest-web-chat-replies.sh +40 -0
  37. package/skill/lib/platform.sh +48 -0
  38. package/skill/lib/twitter-backend.sh +165 -0
  39. package/skill/link-edit-github.sh +126 -0
  40. package/skill/link-edit-linkedin.sh +127 -0
  41. package/skill/link-edit-moltbook.sh +128 -0
  42. package/skill/link-edit-reddit.sh +205 -0
  43. package/skill/lock.sh +516 -0
  44. package/skill/octolens-linkedin.sh +4 -0
  45. package/skill/octolens-reddit.sh +4 -0
  46. package/skill/octolens-twitter.sh +4 -0
  47. package/skill/octolens.sh +163 -0
  48. package/skill/precompute-stats.sh +35 -0
  49. package/skill/promote-engagement-styles.sh +45 -0
  50. package/skill/run-github-launchd.sh +62 -0
  51. package/skill/run-instagram-daily.sh +94 -0
  52. package/skill/run-instagram-render.sh +519 -0
  53. package/skill/run-linkedin-launchd.sh +70 -0
  54. package/skill/run-moltbook-launchd.sh +61 -0
  55. package/skill/run-reddit-search-launchd.sh +64 -0
  56. package/skill/run-reddit-threads-double.sh +32 -0
  57. package/skill/run-scan-moltbook-replies.sh +57 -0
  58. package/skill/run-twitter-cycle-launchd.sh +63 -0
  59. package/skill/run-twitter-threads.sh +602 -0
  60. package/skill/scan-twitter-followups.sh +52 -0
  61. package/skill/stats-linkedin.sh +189 -0
  62. package/skill/stats-moltbook.sh +4 -0
  63. package/skill/stats-reddit.sh +4 -0
  64. package/skill/stats-twitter.sh +4 -0
  65. package/skill/strike-alert.sh +18 -0
  66. package/skill/styles.sh +10 -0
  67. package/skill/sweep-link-clicks.sh +40 -0
package/bin/server.js CHANGED
@@ -4728,7 +4728,7 @@ async function handleApi(req, res) {
4728
4728
  "COALESCE(tlm.last_at, d.last_message_at) AS last_message_at, " +
4729
4729
  "d.discovered_at, " +
4730
4730
  "d.conversation_status, d.interest_level, d.mode, " +
4731
- "d.human_reason, d.flagged_at, " +
4731
+ "d.human_reason, d.flagged_at, d.snoozed_until, " +
4732
4732
  "d.target_project, d.icp_precheck, d.icp_matches, d.qualification_status, " +
4733
4733
  "d.qualification_notes, d.booking_link_sent_at, " +
4734
4734
  // dm_links aggregates replace the legacy single-link columns. Latest
@@ -4845,7 +4845,8 @@ async function handleApi(req, res) {
4845
4845
  "WHERE mm.dm_id = d.id), " +
4846
4846
  "'[]'::json" +
4847
4847
  ") AS campaign_names, " +
4848
- "CASE WHEN d.conversation_status = 'needs_human' THEN 0 " +
4848
+ "CASE WHEN d.conversation_status = 'needs_human' AND (d.snoozed_until IS NULL OR d.snoozed_until <= NOW()) THEN 0 " +
4849
+ "WHEN d.conversation_status = 'needs_human' THEN 75 " +
4849
4850
  "WHEN d.conversation_status IN ('converted','closed') THEN 90 " +
4850
4851
  "WHEN d.interest_level = 'hot' THEN 10 " +
4851
4852
  "WHEN d.interest_level = 'warm' THEN 20 " +
@@ -5028,6 +5029,54 @@ async function handleApi(req, res) {
5028
5029
  }).catch(e => json(res, { error: e.message }, 500));
5029
5030
  }
5030
5031
 
5032
+ // POST /api/dm/:id/snooze - skip a flagged DM until the prospect sends a new
5033
+ // inbound. Sets dms.snoozed_until to NOW()+30d (cap) so the engage loop and
5034
+ // dashboard escalation card both hide it. Auto-cleared in
5035
+ // scripts/dm_conversation.py log_inbound() the next time a real inbound
5036
+ // message lands, which re-arms the thread under its existing conversation_status.
5037
+ // Body: { hours?: number, unsnooze?: boolean }. hours capped to 720 (30d).
5038
+ const snoozeMatch = p.match(/^\/api\/dm\/(\d+)\/snooze$/);
5039
+ if (snoozeMatch && req.method === 'POST') {
5040
+ const dmId = parseInt(snoozeMatch[1], 10);
5041
+ return readBody(req).then(async (body) => {
5042
+ let payload = {};
5043
+ if (body) { try { payload = JSON.parse(body); } catch { return json(res, { error: 'invalid_json' }, 400); } }
5044
+ const unsnooze = !!(payload && payload.unsnooze);
5045
+ const dmRows = await pq(
5046
+ "SELECT d.id, d.platform, d.their_author, " +
5047
+ "COALESCE(p_direct.project_name, p_via_reply.project_name, d.target_project) AS project_name " +
5048
+ "FROM dms d " +
5049
+ "LEFT JOIN posts p_direct ON p_direct.id = d.post_id " +
5050
+ "LEFT JOIN replies r_link ON r_link.id = d.reply_id " +
5051
+ "LEFT JOIN posts p_via_reply ON p_via_reply.id = r_link.post_id " +
5052
+ "WHERE d.id = $1",
5053
+ [dmId]
5054
+ );
5055
+ if (!dmRows || !dmRows.length) return json(res, { error: 'dm_not_found' }, 404);
5056
+ const dm = dmRows[0];
5057
+ if (!req.user || !req.user.admin) {
5058
+ const projName = dm.project_name || '';
5059
+ const claims = (req.user && Array.isArray(req.user.projects)) ? req.user.projects : [];
5060
+ if (!projName || !claims.includes(projName)) {
5061
+ return json(res, { error: 'forbidden' }, 403);
5062
+ }
5063
+ }
5064
+ let upd;
5065
+ if (unsnooze) {
5066
+ upd = await pq("UPDATE dms SET snoozed_until = NULL WHERE id = $1 RETURNING id, snoozed_until", [dmId]);
5067
+ } else {
5068
+ const rawHours = Number(payload && payload.hours);
5069
+ const hours = Number.isFinite(rawHours) && rawHours > 0 ? Math.min(720, Math.floor(rawHours)) : 720;
5070
+ upd = await pq(
5071
+ "UPDATE dms SET snoozed_until = NOW() + ($2 || ' hours')::interval WHERE id = $1 RETURNING id, snoozed_until",
5072
+ [dmId, String(hours)]
5073
+ );
5074
+ }
5075
+ if (!upd || !upd.length) return json(res, { error: 'update_failed' }, 500);
5076
+ return json(res, { ok: true, dm_id: dmId, snoozed_until: upd[0].snoozed_until }, 200);
5077
+ }).catch(e => json(res, { error: e.message }, 500));
5078
+ }
5079
+
5031
5080
  // GET /api/top - top-performing posts by engagement
5032
5081
  // Mirrors scripts/top_performers.py: active posts, non-trivial content,
5033
5082
  // excludes platforms we don't score. Default ranking is upvotes DESC (that's
@@ -5312,7 +5361,7 @@ async function handleApi(req, res) {
5312
5361
  "COUNT(*) FILTER (WHERE d.qualification_status = 'disqualified')::int AS q_disqualified, " +
5313
5362
  "COUNT(*) FILTER (WHERE d.booking_link_sent_at IS NOT NULL)::int AS booking_sent, " +
5314
5363
  "COUNT(*) FILTER (WHERE d.conversation_status = 'converted')::int AS converted, " +
5315
- "COUNT(*) FILTER (WHERE d.conversation_status = 'needs_human')::int AS needs_human " +
5364
+ "COUNT(*) FILTER (WHERE d.conversation_status = 'needs_human' AND (d.snoozed_until IS NULL OR d.snoozed_until <= NOW()))::int AS needs_human " +
5316
5365
  "FROM dms d " +
5317
5366
  "LEFT JOIN posts p_direct ON p_direct.id = d.post_id " +
5318
5367
  "LEFT JOIN replies r_link ON r_link.id = d.reply_id " +
@@ -6429,6 +6478,10 @@ const HTML = `<!DOCTYPE html>
6429
6478
  .dm-esc-link { margin-left: auto; padding: 2px 8px; font-size: 11px; font-weight: 600; color: #92400e; background: #fef3c7; border: 1px solid #fde68a; border-radius: 4px; text-decoration: none; }
6430
6479
  .dm-esc-link:hover { background: #fde68a; }
6431
6480
  .dm-esc-link-missing { font-size: 10px; color: var(--text-muted); font-style: italic; }
6481
+ .dm-esc-skip { padding: 2px 8px; font-size: 11px; font-weight: 600; color: var(--text-secondary); background: transparent; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-family: inherit; }
6482
+ .dm-esc-skip:hover { color: var(--text-strong); border-color: var(--border-hover); }
6483
+ .dm-esc-skip:disabled { opacity: 0.6; cursor: not-allowed; }
6484
+ .dm-esc-snoozed { padding: 2px 8px; font-size: 10px; font-weight: 600; color: #1d4ed8; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
6432
6485
 
6433
6486
  .prospect-modal-overlay { position: fixed; inset: 0; background: var(--shadow-modal); display: flex; align-items: flex-start; justify-content: center; z-index: 9999; padding: 60px 20px 20px; overflow-y: auto; }
6434
6487
  .prospect-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; max-width: 640px; width: 100%; padding: 24px 28px; color: var(--text); font-size: 13px; line-height: 1.5; }
@@ -12920,6 +12973,7 @@ function renderTopDms(payload) {
12920
12973
  interest_level: d.interest_level || '',
12921
12974
  mode: d.mode || 'rapport',
12922
12975
  human_reason: d.human_reason || '',
12976
+ snoozed_until: d.snoozed_until || null,
12923
12977
  project_name: d.project_name || '',
12924
12978
  target_project: d.target_project || '',
12925
12979
  project_display: d.target_project || d.project_name || '',
@@ -13328,10 +13382,23 @@ function renderDmEscalationCard(dm) {
13328
13382
  '<a class="dm-esc-link" href="' + escapeHtml(profileUrl) + '" target="_blank" rel="noopener">open profile</a>';
13329
13383
  }
13330
13384
  }
13385
+ const snoozedTs = dm.snoozed_until ? parseServerUtcTs(dm.snoozed_until) : null;
13386
+ const isSnoozed = !!(snoozedTs && snoozedTs.getTime() > Date.now());
13387
+ const snoozeBtnId = 'dm-esc-skip-' + Number(dm.id);
13388
+ const snoozeLabel = isSnoozed ? 'Unskip' : 'Skip until they reply';
13389
+ const snoozeTitle = isSnoozed
13390
+ ? 'Stop ignoring this thread; re-show in human queue.'
13391
+ : 'Hide this thread from the engage loop and the dashboard escalation surface. If they send a new inbound message, it auto-re-arms.';
13392
+ const snoozedBadge = isSnoozed
13393
+ ? '<span class="dm-esc-snoozed" title="Auto-cleared when they send a new inbound.">skipped</span>'
13394
+ : '';
13395
+ const skipBtn = '<button type="button" class="dm-esc-skip" id="' + snoozeBtnId + '" title="' + escapeHtml(snoozeTitle) + '" onclick="toggleDmSnooze(this, ' + Number(dm.id) + ', ' + (isSnoozed ? 'true' : 'false') + ')">' + escapeHtml(snoozeLabel) + '</button>';
13331
13396
  const head =
13332
13397
  '<div class="dm-esc-head">' +
13333
13398
  '<span class="dm-esc-tag">escalation</span>' +
13334
13399
  (dm.flagged_at ? '<span class="dm-exp-ctx-author">flagged ' + escapeHtml(relTime(dm.flagged_at)) + '</span>' : '') +
13400
+ snoozedBadge +
13401
+ skipBtn +
13335
13402
  linkHtml +
13336
13403
  '</div>';
13337
13404
 
@@ -13526,6 +13593,66 @@ async function submitDmInstructions(btn, dmId) {
13526
13593
  }
13527
13594
  }
13528
13595
 
13596
+ // Toggle the snoozed_until flag on a flagged DM. POSTs to /api/dm/:id/snooze
13597
+ // with {unsnooze:true} (to clear) or {} (to set NOW()+30d). After the response
13598
+ // lands we update the in-memory dm.snoozed_until and re-render the card so the
13599
+ // badge and button label flip without a full reload. The next time the
13600
+ // prospect sends an inbound, dm_conversation.log_inbound clears snoozed_until
13601
+ // automatically and the thread re-surfaces in the engage queue.
13602
+ async function toggleDmSnooze(btn, dmId, currentlySnoozed) {
13603
+ if (!btn) return;
13604
+ btn.disabled = true;
13605
+ const prevText = btn.textContent;
13606
+ btn.textContent = currentlySnoozed ? 'Unskipping…' : 'Skipping…';
13607
+ try {
13608
+ const resp = await fetch('/api/dm/' + dmId + '/snooze', {
13609
+ method: 'POST',
13610
+ headers: { 'Content-Type': 'application/json' },
13611
+ body: JSON.stringify(currentlySnoozed ? { unsnooze: true } : {}),
13612
+ });
13613
+ let data = {};
13614
+ try { data = await resp.json(); } catch (_) {}
13615
+ if (!resp.ok) {
13616
+ btn.textContent = prevText;
13617
+ btn.disabled = false;
13618
+ const fb = document.getElementById('dm-esc-fb-' + dmId);
13619
+ if (fb) {
13620
+ fb.className = 'dm-esc-feedback dm-esc-feedback-err';
13621
+ fb.textContent = (data && data.error) ? ('Failed: ' + data.error) : ('Failed (HTTP ' + resp.status + ')');
13622
+ }
13623
+ return;
13624
+ }
13625
+ const dm = (window.__dmsById || {})[dmId];
13626
+ if (dm) dm.snoozed_until = data && data.snoozed_until || null;
13627
+ const nowSnoozed = !currentlySnoozed;
13628
+ btn.textContent = nowSnoozed ? 'Unskip' : 'Skip until they reply';
13629
+ btn.setAttribute('onclick', 'toggleDmSnooze(this, ' + dmId + ', ' + nowSnoozed + ')');
13630
+ btn.disabled = false;
13631
+ const card = btn.closest('.dm-esc-card');
13632
+ if (card) {
13633
+ const head = card.querySelector('.dm-esc-head');
13634
+ const existingBadge = head ? head.querySelector('.dm-esc-snoozed') : null;
13635
+ if (nowSnoozed && head && !existingBadge) {
13636
+ const badge = document.createElement('span');
13637
+ badge.className = 'dm-esc-snoozed';
13638
+ badge.title = 'Auto-cleared when they send a new inbound.';
13639
+ badge.textContent = 'skipped';
13640
+ head.insertBefore(badge, btn);
13641
+ } else if (!nowSnoozed && existingBadge) {
13642
+ existingBadge.remove();
13643
+ }
13644
+ }
13645
+ } catch (e) {
13646
+ btn.textContent = prevText;
13647
+ btn.disabled = false;
13648
+ const fb = document.getElementById('dm-esc-fb-' + dmId);
13649
+ if (fb) {
13650
+ fb.className = 'dm-esc-feedback dm-esc-feedback-err';
13651
+ fb.textContent = 'Network error: ' + ((e && e.message) || 'unknown');
13652
+ }
13653
+ }
13654
+ }
13655
+
13529
13656
  // Cmd/Ctrl+Enter inside any escalation textarea triggers send.
13530
13657
  if (!window.__dmEscKeydownInstalled) {
13531
13658
  window.__dmEscKeydownInstalled = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
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"
@@ -18,14 +18,8 @@
18
18
  "config.example.json",
19
19
  "requirements.txt",
20
20
  "SKILL.md",
21
- "skill/run-reddit-search.sh",
22
- "skill/run-reddit-threads.sh",
23
- "skill/run-twitter-cycle.sh",
24
- "skill/run-linkedin.sh",
25
- "skill/run-moltbook.sh",
26
- "skill/run-github.sh",
27
- "skill/stats.sh",
28
- "skill/engage.sh",
21
+ "skill/*.sh",
22
+ "skill/lib/*.sh",
29
23
  "setup/SKILL.md",
30
24
  "browser-agent-configs/"
31
25
  ],
@@ -180,6 +180,14 @@ ALTER TABLE dms ADD COLUMN IF NOT EXISTS short_link_first_click_at TIMESTAMP;
180
180
  ALTER TABLE dms ADD COLUMN IF NOT EXISTS short_link_last_click_at TIMESTAMP;
181
181
  CREATE UNIQUE INDEX IF NOT EXISTS idx_dms_short_link_code ON dms(short_link_code) WHERE short_link_code IS NOT NULL;
182
182
 
183
+ -- Dashboard "skip until next inbound" affordance for needs_human (and any other)
184
+ -- escalations: while snoozed_until > NOW(), engage-dm-replies.sh hides the row
185
+ -- and the escalation card collapses to a "snoozed" badge. Auto-cleared by
186
+ -- dm_conversation.log_inbound() when a new inbound message arrives, which
187
+ -- re-surfaces the DM under its existing conversation_status on the next cycle.
188
+ ALTER TABLE dms ADD COLUMN IF NOT EXISTS snoozed_until TIMESTAMP;
189
+ CREATE INDEX IF NOT EXISTS idx_dms_snoozed_until ON dms(snoozed_until) WHERE snoozed_until IS NOT NULL;
190
+
183
191
  -- prospects: persistent per-(platform, author) record. One person can have multiple DMs over time.
184
192
  CREATE TABLE IF NOT EXISTS prospects (
185
193
  id SERIAL PRIMARY KEY,
@@ -410,7 +410,8 @@ def log_inbound(conn, dm_id, author, content, message_at=None, event_id=None):
410
410
  conversation_status = CASE
411
411
  WHEN conversation_status IN ('needs_human','converted','closed','public_only') THEN conversation_status
412
412
  ELSE 'needs_reply'
413
- END
413
+ END,
414
+ snoozed_until = NULL
414
415
  WHERE id = %s
415
416
  """, (dm_id,))
416
417
  conn.commit()
@@ -1072,6 +1072,18 @@ def cmd_wrap_post_text(args):
1072
1072
  print(json.dumps(res))
1073
1073
 
1074
1074
 
1075
+ def cmd_utm_text(args):
1076
+ """UTM-only wrap (no DB, no minting). Prints the wrapped text on stdout.
1077
+ Used by the Twitter engagement prompt where Claude types the reply through
1078
+ mcp__twitter-agent__browser_type and there is no Python posting layer to
1079
+ invoke wrap_text_for_post. The typed URL itself carries all attribution
1080
+ via utm_source=s4l + utm_term=<platform>; PostHog captures it on landing.
1081
+ """
1082
+ out = utm_only_text(text=args.text, platform=args.platform,
1083
+ project_name=args.project)
1084
+ sys.stdout.write(out)
1085
+
1086
+
1075
1087
  def cmd_backfill_post(args):
1076
1088
  n = backfill_post_id(minted_session=args.minted_session, post_id=args.post_id)
1077
1089
  print(json.dumps({'backfilled': n, 'post_id': args.post_id,
@@ -1119,6 +1131,17 @@ def main():
1119
1131
  p_wrap_post.add_argument('--project', required=True,
1120
1132
  help='project_name from config.json (drives wrapper hostname)')
1121
1133
 
1134
+ p_utm = sub.add_parser('utm-text',
1135
+ help='UTM-only wrap (no DB write). Replaces every URL '
1136
+ 'in --text with its UTM-tagged version and prints '
1137
+ 'the result on stdout. Use when no Python posting '
1138
+ 'layer is available (Claude-driven MCP typing).')
1139
+ p_utm.add_argument('--text', required=True)
1140
+ p_utm.add_argument('--platform', required=True,
1141
+ choices=['reddit', 'twitter', 'x', 'linkedin', 'github_issues', 'github', 'moltbook'])
1142
+ p_utm.add_argument('--project', required=True,
1143
+ help='project_name from config.json (drives utm_campaign + wrapper hostname classification)')
1144
+
1122
1145
  p_bp = sub.add_parser('backfill-post',
1123
1146
  help='Stamp post_links.post_id for every code minted '
1124
1147
  'under --minted-session. Idempotent.')
@@ -1140,6 +1163,8 @@ def main():
1140
1163
  cmd_wrap_text(args)
1141
1164
  elif args.cmd == 'wrap-post-text':
1142
1165
  cmd_wrap_post_text(args)
1166
+ elif args.cmd == 'utm-text':
1167
+ cmd_utm_text(args)
1143
1168
  elif args.cmd == 'backfill-post':
1144
1169
  cmd_backfill_post(args)
1145
1170
  elif args.cmd == 'backfill-reply':
@@ -94,7 +94,7 @@ def _wrap_if_project(text, project):
94
94
  return text, None
95
95
  try:
96
96
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
97
- from dm_short_links import wrap_text_for_post
97
+ from dm_short_links import wrap_text_for_post, utm_only_text
98
98
  res = wrap_text_for_post(text=text, platform="linkedin", project_name=project)
99
99
  if res.get("ok"):
100
100
  if res.get("codes"):
@@ -102,10 +102,17 @@ def _wrap_if_project(text, project):
102
102
  f"{res['codes']}", file=sys.stderr)
103
103
  return res.get("text", text), res.get("minted_session")
104
104
  print(f"[linkedin_api] WARNING: URL wrap failed "
105
- f"({res.get('error')}); posting unwrapped", file=sys.stderr)
105
+ f"({res.get('error')}); falling back to UTM-only", file=sys.stderr)
106
+ return utm_only_text(text=text, platform="linkedin", project_name=project), None
106
107
  except Exception as e:
107
- print(f"[linkedin_api] WARNING: URL wrap raised ({e}); posting unwrapped",
108
+ print(f"[linkedin_api] WARNING: URL wrap raised ({e}); falling back to UTM-only",
108
109
  file=sys.stderr)
110
+ try:
111
+ from dm_short_links import utm_only_text
112
+ return utm_only_text(text=text, platform="linkedin", project_name=project), None
113
+ except Exception as ee:
114
+ print(f"[linkedin_api] WARNING: UTM-only fallback also failed ({ee}); "
115
+ f"posting unwrapped", file=sys.stderr)
109
116
  return text, None
110
117
 
111
118
 
@@ -449,13 +449,27 @@ def _ban_entries_to_subs(entries) -> set[str]:
449
449
 
450
450
 
451
451
  def _make_ban_entry(sub: str, reason: str | None, project: str | None) -> dict:
452
- """Build a new ban-list entry with the current UTC timestamp."""
452
+ """Build a new ban-list entry with the current UTC timestamp.
453
+
454
+ Stamps the current Reddit account (top-level config.json reddit_account
455
+ .username) so per-account scoping in reddit_tools._load_comment_blocked_subs
456
+ can ignore this entry on other machines posting as a different account.
457
+ Returns account=None if the config has no reddit_account, in which case
458
+ the reader treats the entry as global (back-compat with pre-2026-05-15).
459
+ """
453
460
  from datetime import datetime, timezone
461
+ account = None
462
+ try:
463
+ with open(CONFIG_PATH) as _f:
464
+ account = (json.load(_f).get("reddit_account") or {}).get("username") or None
465
+ except Exception:
466
+ pass
454
467
  return {
455
468
  "sub": sub.strip().lower(),
456
469
  "added_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
457
470
  "reason": reason or None,
458
471
  "project": project or None,
472
+ "account": account,
459
473
  }
460
474
 
461
475
 
@@ -190,6 +190,13 @@ def _load_comment_blocked_subs(project_name=None):
190
190
  config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config.json")
191
191
  with open(config_path) as f:
192
192
  config = json.load(f)
193
+ # Per-account scoping (2026-05-15): a ban applies only to the account
194
+ # that triggered it. Different machines may post the same project as
195
+ # different accounts (laptop=Deep_Ad1959, sandbox VM=StreetRefuse7512);
196
+ # without this filter, account A's real ban would suppress a sub for
197
+ # account B that has no such ban. Entries with account=null are
198
+ # treated as global (apply regardless), preserving pre-2026-05-15 data.
199
+ current_account = (config.get("reddit_account") or {}).get("username") or None
193
200
  blocked = set()
194
201
  bans = config.get("subreddit_bans") or {}
195
202
  if isinstance(bans, dict):
@@ -198,8 +205,15 @@ def _load_comment_blocked_subs(project_name=None):
198
205
  if not slug:
199
206
  continue
200
207
  entry_project = None
208
+ entry_account = None
201
209
  if isinstance(entry, dict):
202
210
  entry_project = entry.get("project") or None
211
+ entry_account = entry.get("account") or None
212
+ # Account filter first: if entry is tagged with a specific
213
+ # account and it's not the current one, this ban doesn't apply.
214
+ if (entry_account is not None and current_account is not None
215
+ and entry_account.lower() != current_account.lower()):
216
+ continue
203
217
  if entry_project is None:
204
218
  blocked.add(slug)
205
219
  elif project_name and entry_project.lower() == project_name.lower():
@@ -214,7 +214,16 @@ def upsert_candidates(tweets, config, batch_id=None):
214
214
  bookmarks_t1 = NULL,
215
215
  t1_checked_at = NULL,
216
216
  delta_score = NULL,
217
- batch_id = COALESCE(EXCLUDED.batch_id, twitter_candidates.batch_id)
217
+ batch_id = COALESCE(twitter_candidates.batch_id, EXCLUDED.batch_id)
218
+ WHERE NOT (
219
+ twitter_candidates.status = 'pending'
220
+ AND twitter_candidates.batch_id IS DISTINCT FROM EXCLUDED.batch_id
221
+ AND EXISTS (
222
+ SELECT 1 FROM twitter_batches tb
223
+ WHERE tb.batch_id = twitter_candidates.batch_id
224
+ AND tb.phase_started_at > NOW() - INTERVAL '20 minutes'
225
+ )
226
+ )
218
227
  """,
219
228
  [
220
229
  url,
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ send_dashboard_invite.py
4
+ Send the S4L dashboard onboarding email to a previously-provisioned user.
5
+ Reads the user's name and scoped projects from dashboard_users, composes the
6
+ invite, and sends via Gmail API from i@m13v.com.
7
+
8
+ Usage:
9
+ python3 send_dashboard_invite.py <email> [<email> ...]
10
+ python3 send_dashboard_invite.py --dry-run kent@runner.now
11
+ """
12
+
13
+ import argparse
14
+ import base64
15
+ import os
16
+ import sys
17
+ from email.mime.text import MIMEText
18
+ from email.mime.multipart import MIMEMultipart
19
+ from pathlib import Path
20
+
21
+ import psycopg2
22
+ from psycopg2.extras import RealDictCursor
23
+ from google.auth.transport.requests import Request
24
+ from google.oauth2.credentials import Credentials
25
+ from googleapiclient.discovery import build
26
+
27
+ REPO_ROOT = Path(__file__).resolve().parent.parent
28
+ ENV_FILE = REPO_ROOT / ".env"
29
+ if ENV_FILE.exists():
30
+ with open(ENV_FILE) as f:
31
+ for line in f:
32
+ line = line.strip()
33
+ if line and not line.startswith("#") and "=" in line:
34
+ k, v = line.split("=", 1)
35
+ os.environ.setdefault(k.strip(), v.strip())
36
+
37
+ FROM_NAME = "Matthew Diakonov"
38
+ FROM_EMAIL = "i@m13v.com"
39
+ CC_EMAIL = "i@m13v.com"
40
+ TOKEN_PATH = os.path.expanduser("~/gmail-api/token_i_at_m13v.com.json")
41
+ SCOPES = ["https://mail.google.com/"]
42
+ DASHBOARD_URL = "https://app.s4l.ai"
43
+
44
+
45
+ def load_user(email):
46
+ conn = psycopg2.connect(os.environ["DATABASE_URL"])
47
+ try:
48
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
49
+ cur.execute(
50
+ "SELECT email, name, projects FROM dashboard_users WHERE email=%s",
51
+ (email.lower(),),
52
+ )
53
+ row = cur.fetchone()
54
+ if not row:
55
+ raise RuntimeError(f"No dashboard_users row for {email}")
56
+ return dict(row)
57
+ finally:
58
+ conn.close()
59
+
60
+
61
+ def build_invite(user):
62
+ name = user.get("name") or user["email"]
63
+ first = name.split()[0] if " " in name else name
64
+ projects = user.get("projects") or []
65
+ scope = ", ".join(projects) if projects else "all projects"
66
+ subject = f"Your S4L dashboard access ({scope})"
67
+
68
+ text_body = f"""Hi {first},
69
+
70
+ I set up dashboard access for you covering {scope}. You'll see live posting, replies, DMs, SEO pages, and weekly stats for {'these projects' if len(projects) > 1 else 'the project'}.
71
+
72
+ Sign in:
73
+ 1. Go to {DASHBOARD_URL}
74
+ 2. Enter {user['email']} and click "Email me a sign-in link"
75
+ 3. Open the link from your inbox; you'll land in your dashboard
76
+
77
+ A weekly report covering the same scope will land in your inbox every Monday at 9am.
78
+
79
+ Reply to this email with any questions.
80
+
81
+ Matthew
82
+ """
83
+
84
+ html_body = f"""<html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#222;max-width:640px;line-height:1.5;">
85
+ <p>Hi {first},</p>
86
+ <p>I set up dashboard access for you covering <b>{scope}</b>. You'll see live posting, replies, DMs, SEO pages, and weekly stats for {'these projects' if len(projects) > 1 else 'the project'}.</p>
87
+ <p><b>Sign in:</b></p>
88
+ <ol>
89
+ <li>Go to <a href="{DASHBOARD_URL}">{DASHBOARD_URL}</a></li>
90
+ <li>Enter <code>{user['email']}</code> and click "Email me a sign-in link"</li>
91
+ <li>Open the link from your inbox; you'll land in your dashboard</li>
92
+ </ol>
93
+ <p>A weekly report covering the same scope will land in your inbox every Monday at 9am.</p>
94
+ <p>Reply to this email with any questions.</p>
95
+ <p>Matthew</p>
96
+ </body></html>"""
97
+
98
+ return subject, text_body, html_body
99
+
100
+
101
+ def gmail_service():
102
+ creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
103
+ if creds.expired and creds.refresh_token:
104
+ creds.refresh(Request())
105
+ with open(TOKEN_PATH, "w") as f:
106
+ f.write(creds.to_json())
107
+ return build("gmail", "v1", credentials=creds)
108
+
109
+
110
+ def send(service, to_addr, subject, text_body, html_body):
111
+ msg = MIMEMultipart("alternative")
112
+ msg["from"] = f"{FROM_NAME} <{FROM_EMAIL}>"
113
+ msg["to"] = to_addr
114
+ msg["cc"] = CC_EMAIL
115
+ msg["subject"] = subject
116
+ msg.attach(MIMEText(text_body, "plain"))
117
+ msg.attach(MIMEText(html_body, "html"))
118
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
119
+ result = service.users().messages().send(userId="me", body={"raw": raw}).execute()
120
+ return result["id"]
121
+
122
+
123
+ def main():
124
+ parser = argparse.ArgumentParser()
125
+ parser.add_argument("emails", nargs="+")
126
+ parser.add_argument("--dry-run", action="store_true")
127
+ args = parser.parse_args()
128
+
129
+ service = None if args.dry_run else gmail_service()
130
+ for email in args.emails:
131
+ user = load_user(email)
132
+ subject, text_body, html_body = build_invite(user)
133
+ if args.dry_run:
134
+ print(f"--- {email} ---\nSubject: {subject}\n\n{text_body}")
135
+ continue
136
+ mid = send(service, email, subject, text_body, html_body)
137
+ print(f"SENT -> {email} cc={CC_EMAIL} id={mid} subj='{subject}'")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
@@ -517,10 +517,19 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
517
517
  if detected_project:
518
518
  wrap_res = wrap_text_for_post(text=suffix, platform='twitter',
519
519
  project_name=detected_project)
520
- if wrap_res.get('ok') and wrap_res.get('codes'):
520
+ # Use the wrapped text whenever the wrap call succeeded.
521
+ # codes=[] is now valid (UTM-only fallback path for
522
+ # projects with short_links_live=false), and the
523
+ # rewritten text still carries full s4l attribution.
524
+ # Old guard `and wrap_res.get('codes')` silently
525
+ # skipped utm_only fallbacks and let bare URLs
526
+ # through in the suffix.
527
+ if wrap_res.get('ok'):
521
528
  wrapped_suffix = wrap_res['text']
529
+ tag = 'codes' if wrap_res.get('codes') else 'utm_only'
522
530
  print(f"[reply_to_tweet] suffix wrap project={detected_project} "
523
- f"codes={wrap_res['codes']}", file=sys.stderr)
531
+ f"{tag}={wrap_res.get('codes') or [s.get('reason') for s in wrap_res.get('skipped',[])]}",
532
+ file=sys.stderr)
524
533
  except Exception as _e:
525
534
  print(f"[reply_to_tweet] suffix wrap failed ({_e}); raw",
526
535
  file=sys.stderr)
@@ -117,7 +117,7 @@ def update_candidate(cid: int, status: str) -> None:
117
117
  return
118
118
  cmd = [
119
119
  "psql", DATABASE_URL, "-c",
120
- f"UPDATE twitter_candidates SET status='{sql_status}' WHERE id={cid}",
120
+ f"UPDATE twitter_candidates SET status='{sql_status}' WHERE id={cid} AND status != 'posted'",
121
121
  ]
122
122
  rc, out, err = run_subprocess(cmd, timeout_sec=30)
123
123
  if rc != 0:
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # amplitude-24h-signups.sh — launchd wrapper for scripts/amplitude_24h_signups.py.
3
+ #
4
+ # Fires every 5 min from com.m13v.social-amplitude-24h.plist.
5
+ # Writes ~/social-autoposter/skill/cache/amplitude_24h_signups.json.
6
+ #
7
+ # The script itself uses a real-time PostHog count for the headline number
8
+ # (cheap, ~1s) and refreshes the eventually-consistent Amplitude export
9
+ # only every ~25 min (heavy, ~30s + ~150 MB).
10
+ #
11
+ # Read by project_stats_json.py:_amplitude_signups when days==1.
12
+
13
+ set -uo pipefail
14
+
15
+ REPO_DIR="$HOME/social-autoposter"
16
+
17
+ # shellcheck source=/dev/null
18
+ [ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
19
+
20
+ # Inject Amplitude + PostHog creds from keychain so the export half can run
21
+ # without env vars being baked into the launchd plist.
22
+ export AMPLITUDE_STUDYLY_API_KEY="${AMPLITUDE_STUDYLY_API_KEY:-$(security find-generic-password -s amplitude-studyly-api-key -w 2>/dev/null)}"
23
+ export AMPLITUDE_STUDYLY_SECRET_KEY="${AMPLITUDE_STUDYLY_SECRET_KEY:-$(security find-generic-password -s amplitude-studyly-secret-key -w 2>/dev/null)}"
24
+ export POSTHOG_PERSONAL_API_KEY="${POSTHOG_PERSONAL_API_KEY:-$(security find-generic-password -s PostHog-Personal-API-Key-m13v -w 2>/dev/null)}"
25
+
26
+ cd "$REPO_DIR" || exit 2
27
+
28
+ # shellcheck source=lock.sh
29
+ source "$REPO_DIR/skill/lock.sh"
30
+ acquire_lock amplitude-24h-signups 5
31
+
32
+ RUN_START=$(date +%s)
33
+ /opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/amplitude_24h_signups.py"
34
+ EXIT_CODE=$?
35
+ RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
36
+
37
+ echo "[$(date +%H:%M:%S)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
38
+ exit "$EXIT_CODE"
@@ -0,0 +1,40 @@
1
+ #!/bin/bash
2
+ # Archive log files older than 7 days from skill/logs/ to skill/logs-archive/.
3
+ # The dashboard (bin/server.js) does many fs.readdirSync(LOG_DIR) calls per
4
+ # pulse. Letting that directory grow to 17k+ files starves the event loop
5
+ # and the dashboard stops responding. Pruning to a sibling dir keeps the
6
+ # files around for forensics without including them in the dashboard scan.
7
+ #
8
+ # Scheduled daily by ~/Library/LaunchAgents/com.m13v.social-archive-logs.plist
9
+
10
+ set -uo pipefail
11
+
12
+ LOG_DIR="/Users/matthewdi/social-autoposter/skill/logs"
13
+ ARCHIVE_DIR="/Users/matthewdi/social-autoposter/skill/logs-archive"
14
+ DAYS="${ARCHIVE_DAYS:-7}"
15
+
16
+ mkdir -p "$ARCHIVE_DIR" "$LOG_DIR"
17
+
18
+ # Per-run summary log so the dashboard's "Other" section can find this job.
19
+ # Filename matches the JOBS[].logPrefix value in bin/server.js.
20
+ RUN_LOG="$LOG_DIR/archive-logs-$(date +%Y-%m-%d_%H%M%S).log"
21
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$RUN_LOG"; }
22
+
23
+ if [ ! -d "$LOG_DIR" ]; then
24
+ log "ERROR: LOG_DIR not found: $LOG_DIR"
25
+ exit 0
26
+ fi
27
+
28
+ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
29
+
30
+ # Only top-level files; do not touch claude-sessions/ or other subdirs.
31
+ # Also exclude the per-run summary we just created so we don't archive
32
+ # ourselves on long-tail edge cases.
33
+ find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
34
+ | xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
35
+
36
+ remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
37
+ archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
38
+
39
+ log "kept=$remaining archived_total=$archived"
40
+ log "=== done ==="