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.
- package/bin/server.js +130 -3
- package/package.json +3 -9
- package/schema-postgres.sql +8 -0
- package/scripts/dm_conversation.py +2 -1
- package/scripts/dm_short_links.py +25 -0
- package/scripts/linkedin_api.py +10 -3
- package/scripts/post_reddit.py +15 -1
- package/scripts/reddit_tools.py +14 -0
- package/scripts/score_twitter_candidates.py +10 -1
- package/scripts/send_dashboard_invite.py +141 -0
- package/scripts/twitter_browser.py +11 -2
- package/scripts/twitter_post_plan.py +1 -1
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +55 -0
- package/skill/audit-linkedin.sh +4 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +65 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +228 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +206 -0
- package/skill/dm-outreach-linkedin.sh +237 -0
- package/skill/dm-outreach-reddit.sh +276 -0
- package/skill/dm-outreach-twitter.sh +296 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1682 -0
- package/skill/engage-linkedin.sh +475 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +123 -0
- package/skill/engage-twitter.sh +489 -0
- package/skill/github-engage.sh +172 -0
- package/skill/ingest-web-chat-replies.sh +40 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/twitter-backend.sh +165 -0
- package/skill/link-edit-github.sh +126 -0
- package/skill/link-edit-linkedin.sh +127 -0
- package/skill/link-edit-moltbook.sh +128 -0
- package/skill/link-edit-reddit.sh +205 -0
- package/skill/lock.sh +516 -0
- package/skill/octolens-linkedin.sh +4 -0
- package/skill/octolens-reddit.sh +4 -0
- package/skill/octolens-twitter.sh +4 -0
- package/skill/octolens.sh +163 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/promote-engagement-styles.sh +45 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-instagram-daily.sh +94 -0
- package/skill/run-instagram-render.sh +519 -0
- package/skill/run-linkedin-launchd.sh +70 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-threads.sh +602 -0
- package/skill/scan-twitter-followups.sh +52 -0
- package/skill/stats-linkedin.sh +189 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +10 -0
- 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.
|
|
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
|
|
22
|
-
"skill/
|
|
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
|
],
|
package/schema-postgres.sql
CHANGED
|
@@ -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':
|
package/scripts/linkedin_api.py
CHANGED
|
@@ -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')});
|
|
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});
|
|
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
|
|
package/scripts/post_reddit.py
CHANGED
|
@@ -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
|
|
package/scripts/reddit_tools.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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"
|
|
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 ==="
|