social-autoposter 1.0.9 → 1.1.0
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/.env.example +1 -1
- package/README.md +4 -7
- package/SKILL.md +5 -9
- package/bin/cli.js +82 -3
- package/package.json +2 -4
- package/schema-postgres.sql +27 -12
- package/setup/SKILL.md +18 -5
- package/skill/SKILL.md +7 -1
- package/skill/engage.sh +24 -26
- package/skill/stats.sh +2 -11
- package/launchd/com.m13v.social-autoposter.plist +0 -28
- package/launchd/com.m13v.social-engage.plist +0 -28
- package/launchd/com.m13v.social-stats.plist +0 -28
- package/syncfield.sh +0 -78
package/.env.example
CHANGED
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
MOLTBOOK_API_KEY=
|
|
8
8
|
|
|
9
9
|
# Shared Neon Postgres database — pre-filled, read/write access, no delete
|
|
10
|
-
# All social-autoposter users write to this shared
|
|
10
|
+
# All social-autoposter users write to this shared Neon Postgres DB
|
|
11
11
|
DATABASE_URL=postgresql://social_autoposter_public:sap_public_2026@ep-empty-bird-ai7uh8cy-pooler.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require
|
package/README.md
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as an AI agent skill or use the standalone Python scripts.
|
|
4
4
|
|
|
5
|
-
[**Browse the data in Datasette Lite**](https://lite.datasette.io/?url=https://raw.githubusercontent.com/m13v/social-autoposter/main/social_posts.db)
|
|
6
|
-
|
|
7
5
|
## Install as a skill
|
|
8
6
|
|
|
9
7
|
```bash
|
|
@@ -20,7 +18,7 @@ npx social-autoposter update
|
|
|
20
18
|
Or set up manually:
|
|
21
19
|
```bash
|
|
22
20
|
cp config.example.json config.json # edit with your accounts
|
|
23
|
-
|
|
21
|
+
psql "$DATABASE_URL" -f schema-postgres.sql # initialize the Neon DB
|
|
24
22
|
bash setup.sh # symlinks + launchd (macOS)
|
|
25
23
|
```
|
|
26
24
|
|
|
@@ -49,11 +47,12 @@ SKILL.md (the playbook)
|
|
|
49
47
|
social-autoposter/
|
|
50
48
|
├── SKILL.md <- skill playbook (generic, publishable)
|
|
51
49
|
├── config.example.json <- config template (accounts, subreddits, content angle)
|
|
52
|
-
├── schema.sql
|
|
50
|
+
├── schema-postgres.sql <- Neon Postgres DB schema
|
|
53
51
|
├── setup.sh <- creates symlinks, loads launchd agents
|
|
54
52
|
├── setup/
|
|
55
53
|
│ └── SKILL.md <- interactive setup wizard skill
|
|
56
54
|
├── scripts/
|
|
55
|
+
│ ├── db.py <- Neon Postgres connection wrapper
|
|
57
56
|
│ ├── find_threads.py <- find candidate threads via Reddit/Moltbook API
|
|
58
57
|
│ ├── scan_replies.py <- scan for new replies to our posts via API
|
|
59
58
|
│ └── update_stats.py <- fetch engagement stats via API
|
|
@@ -63,15 +62,13 @@ social-autoposter/
|
|
|
63
62
|
│ ├── stats.sh <- 6-hourly stats (launchd wrapper)
|
|
64
63
|
│ ├── engage.sh <- 2-hourly engagement (launchd wrapper)
|
|
65
64
|
│ └── logs/ <- runtime logs (gitignored)
|
|
66
|
-
├── social_posts.db <- SQLite database (committed for Datasette)
|
|
67
|
-
├── syncfield.sh <- sync SQLite -> Neon Postgres
|
|
68
65
|
└── launchd/ <- macOS LaunchAgent plists
|
|
69
66
|
```
|
|
70
67
|
|
|
71
68
|
## For other AI agents
|
|
72
69
|
|
|
73
70
|
The skill is designed to work with any agent that has:
|
|
74
|
-
- **Shell access** (to run Python scripts and
|
|
71
|
+
- **Shell access** (to run Python scripts and psql)
|
|
75
72
|
- **Browser automation** (Playwright, Selenium, etc. for posting)
|
|
76
73
|
- **An LLM** (for drafting comments in the right tone)
|
|
77
74
|
|
package/SKILL.md
CHANGED
|
@@ -26,7 +26,7 @@ Key fields:
|
|
|
26
26
|
- `subreddits` — target subreddits to monitor
|
|
27
27
|
- `content_angle` — your unique perspective for authentic comments
|
|
28
28
|
- `projects` — your products/repos to mention when relevant (with topic keywords)
|
|
29
|
-
- `database` —
|
|
29
|
+
- `database` — unused (DB is Neon Postgres via `DATABASE_URL` in `.env`)
|
|
30
30
|
|
|
31
31
|
## Helper Scripts
|
|
32
32
|
|
|
@@ -67,7 +67,7 @@ Find a thread, draft a comment, post it, log it.
|
|
|
67
67
|
### 1. Rate limit check
|
|
68
68
|
|
|
69
69
|
```sql
|
|
70
|
-
SELECT COUNT(*) FROM posts WHERE posted_at >=
|
|
70
|
+
SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
|
|
71
71
|
```
|
|
72
72
|
If 40+ posts in the last 24 hours, stop. Max 40/day.
|
|
73
73
|
|
|
@@ -148,13 +148,9 @@ Rate limit: max 1 post per 30 minutes.
|
|
|
148
148
|
INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
|
|
149
149
|
thread_title, thread_content, our_url, our_content, our_account,
|
|
150
150
|
source_summary, status, posted_at)
|
|
151
|
-
VALUES (
|
|
151
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
-
### 8. Sync (if configured)
|
|
155
|
-
|
|
156
|
-
If `sync_script` is set in `config.json`, run it to push data to a remote database.
|
|
157
|
-
|
|
158
154
|
---
|
|
159
155
|
|
|
160
156
|
## Workflow: Stats (`/social-autoposter stats`)
|
|
@@ -210,8 +206,8 @@ For each pending reply:
|
|
|
210
206
|
3. Post via browser (Reddit/X) or API (Moltbook)
|
|
211
207
|
4. Update the reply record:
|
|
212
208
|
```sql
|
|
213
|
-
UPDATE replies SET status='replied', our_reply_content
|
|
214
|
-
replied_at=
|
|
209
|
+
UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
|
|
210
|
+
replied_at=NOW() WHERE id=%s
|
|
215
211
|
```
|
|
216
212
|
|
|
217
213
|
Max 5 replies per run.
|
package/bin/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const { spawnSync } = require('child_process');
|
|
|
8
8
|
|
|
9
9
|
const DEST = path.join(os.homedir(), 'social-autoposter');
|
|
10
10
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
11
|
+
const HOME = os.homedir();
|
|
11
12
|
|
|
12
13
|
// Files/dirs to copy from npm package to ~/social-autoposter
|
|
13
14
|
const COPY_TARGETS = [
|
|
@@ -18,8 +19,6 @@ const COPY_TARGETS = [
|
|
|
18
19
|
'SKILL.md',
|
|
19
20
|
'skill',
|
|
20
21
|
'setup',
|
|
21
|
-
'launchd',
|
|
22
|
-
'syncfield.sh',
|
|
23
22
|
];
|
|
24
23
|
|
|
25
24
|
// Never overwrite these user files during update
|
|
@@ -43,6 +42,80 @@ function linkOrRelink(target, linkPath) {
|
|
|
43
42
|
fs.symlinkSync(target, linkPath);
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
function generatePlists() {
|
|
46
|
+
// Detect PATH for launchd (include node, homebrew, system)
|
|
47
|
+
const nodeBin = path.dirname(process.execPath);
|
|
48
|
+
const pathDirs = new Set([nodeBin, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']);
|
|
49
|
+
const launchdPath = [...pathDirs].join(':');
|
|
50
|
+
|
|
51
|
+
const plists = [
|
|
52
|
+
{
|
|
53
|
+
file: 'com.m13v.social-autoposter.plist',
|
|
54
|
+
label: 'com.m13v.social-autoposter',
|
|
55
|
+
script: `${DEST}/skill/run.sh`,
|
|
56
|
+
interval: 3600,
|
|
57
|
+
runAtLoad: true,
|
|
58
|
+
stdoutLog: `${DEST}/skill/logs/launchd-stdout.log`,
|
|
59
|
+
stderrLog: `${DEST}/skill/logs/launchd-stderr.log`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
file: 'com.m13v.social-stats.plist',
|
|
63
|
+
label: 'com.m13v.social-stats',
|
|
64
|
+
script: `${DEST}/skill/stats.sh`,
|
|
65
|
+
interval: 21600,
|
|
66
|
+
runAtLoad: false,
|
|
67
|
+
stdoutLog: `${DEST}/skill/logs/launchd-stats-stdout.log`,
|
|
68
|
+
stderrLog: `${DEST}/skill/logs/launchd-stats-stderr.log`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
file: 'com.m13v.social-engage.plist',
|
|
72
|
+
label: 'com.m13v.social-engage',
|
|
73
|
+
script: `${DEST}/skill/engage.sh`,
|
|
74
|
+
interval: 21600,
|
|
75
|
+
runAtLoad: false,
|
|
76
|
+
stdoutLog: `${DEST}/skill/logs/launchd-engage-stdout.log`,
|
|
77
|
+
stderrLog: `${DEST}/skill/logs/launchd-engage-stderr.log`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const launchdDir = path.join(DEST, 'launchd');
|
|
82
|
+
fs.mkdirSync(launchdDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
for (const p of plists) {
|
|
85
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
87
|
+
<plist version="1.0">
|
|
88
|
+
<dict>
|
|
89
|
+
\t<key>Label</key>
|
|
90
|
+
\t<string>${p.label}</string>
|
|
91
|
+
\t<key>ProgramArguments</key>
|
|
92
|
+
\t<array>
|
|
93
|
+
\t\t<string>/bin/bash</string>
|
|
94
|
+
\t\t<string>${p.script}</string>
|
|
95
|
+
\t</array>
|
|
96
|
+
\t<key>StartInterval</key>
|
|
97
|
+
\t<integer>${p.interval}</integer>
|
|
98
|
+
\t<key>StandardOutPath</key>
|
|
99
|
+
\t<string>${p.stdoutLog}</string>
|
|
100
|
+
\t<key>StandardErrorPath</key>
|
|
101
|
+
\t<string>${p.stderrLog}</string>
|
|
102
|
+
\t<key>EnvironmentVariables</key>
|
|
103
|
+
\t<dict>
|
|
104
|
+
\t\t<key>PATH</key>
|
|
105
|
+
\t\t<string>${launchdPath}</string>
|
|
106
|
+
\t\t<key>HOME</key>
|
|
107
|
+
\t\t<string>${HOME}</string>
|
|
108
|
+
\t</dict>
|
|
109
|
+
\t<key>RunAtLoad</key>
|
|
110
|
+
\t<${p.runAtLoad}/>
|
|
111
|
+
</dict>
|
|
112
|
+
</plist>
|
|
113
|
+
`;
|
|
114
|
+
fs.writeFileSync(path.join(launchdDir, p.file), xml);
|
|
115
|
+
}
|
|
116
|
+
console.log(' generated launchd plists with correct paths');
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
function init() {
|
|
47
120
|
console.log('Setting up social-autoposter in', DEST);
|
|
48
121
|
fs.mkdirSync(DEST, { recursive: true });
|
|
@@ -61,6 +134,9 @@ function init() {
|
|
|
61
134
|
console.log(' copied', f);
|
|
62
135
|
}
|
|
63
136
|
|
|
137
|
+
// Generate launchd plists with user's actual HOME
|
|
138
|
+
generatePlists();
|
|
139
|
+
|
|
64
140
|
// config.json — only if it doesn't exist
|
|
65
141
|
const configDest = path.join(DEST, 'config.json');
|
|
66
142
|
if (!fs.existsSync(configDest)) {
|
|
@@ -135,6 +211,9 @@ function update() {
|
|
|
135
211
|
console.log(' updated', f);
|
|
136
212
|
}
|
|
137
213
|
|
|
214
|
+
// Regenerate launchd plists with correct paths
|
|
215
|
+
generatePlists();
|
|
216
|
+
|
|
138
217
|
// Re-symlink skill and setup skill in case they broke
|
|
139
218
|
const skillsDir = path.join(os.homedir(), '.claude', 'skills');
|
|
140
219
|
try {
|
|
@@ -147,7 +226,7 @@ function update() {
|
|
|
147
226
|
} catch {}
|
|
148
227
|
|
|
149
228
|
console.log('');
|
|
150
|
-
console.log('Update complete. config.json
|
|
229
|
+
console.log('Update complete. config.json was preserved.');
|
|
151
230
|
}
|
|
152
231
|
|
|
153
232
|
const cmd = process.argv[2];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-autoposter",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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"
|
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
"skill/run.sh",
|
|
17
17
|
"skill/stats.sh",
|
|
18
18
|
"skill/engage.sh",
|
|
19
|
-
"setup/SKILL.md"
|
|
20
|
-
"launchd/",
|
|
21
|
-
"syncfield.sh"
|
|
19
|
+
"setup/SKILL.md"
|
|
22
20
|
],
|
|
23
21
|
"keywords": [
|
|
24
22
|
"social-media",
|
package/schema-postgres.sql
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
-- schema-postgres.sql — Neon Postgres schema
|
|
1
|
+
-- schema-postgres.sql — Neon Postgres schema (primary database)
|
|
2
2
|
-- Run once: psql "$DATABASE_URL" -f schema-postgres.sql
|
|
3
3
|
|
|
4
4
|
CREATE TABLE IF NOT EXISTS posts (
|
|
5
|
-
id
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
6
|
platform TEXT NOT NULL,
|
|
7
7
|
thread_url TEXT NOT NULL,
|
|
8
8
|
thread_author TEXT,
|
|
@@ -31,8 +31,30 @@ CREATE TABLE IF NOT EXISTS posts (
|
|
|
31
31
|
|
|
32
32
|
CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
|
|
33
33
|
|
|
34
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
35
|
+
id SERIAL PRIMARY KEY,
|
|
36
|
+
platform TEXT NOT NULL,
|
|
37
|
+
url TEXT NOT NULL UNIQUE,
|
|
38
|
+
author TEXT,
|
|
39
|
+
author_handle TEXT,
|
|
40
|
+
title TEXT,
|
|
41
|
+
content TEXT,
|
|
42
|
+
engagement TEXT,
|
|
43
|
+
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS our_posts (
|
|
47
|
+
id SERIAL PRIMARY KEY,
|
|
48
|
+
thread_id INTEGER REFERENCES threads(id),
|
|
49
|
+
platform TEXT NOT NULL,
|
|
50
|
+
url TEXT,
|
|
51
|
+
content TEXT NOT NULL,
|
|
52
|
+
account TEXT,
|
|
53
|
+
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
54
|
+
);
|
|
55
|
+
|
|
34
56
|
CREATE TABLE IF NOT EXISTS campaigns (
|
|
35
|
-
id
|
|
57
|
+
id SERIAL PRIMARY KEY,
|
|
36
58
|
name TEXT NOT NULL,
|
|
37
59
|
prompt TEXT NOT NULL,
|
|
38
60
|
platforms TEXT DEFAULT 'x,reddit,moltbook',
|
|
@@ -44,7 +66,7 @@ CREATE TABLE IF NOT EXISTS campaigns (
|
|
|
44
66
|
);
|
|
45
67
|
|
|
46
68
|
CREATE TABLE IF NOT EXISTS replies (
|
|
47
|
-
id
|
|
69
|
+
id SERIAL PRIMARY KEY,
|
|
48
70
|
post_id INTEGER REFERENCES posts(id),
|
|
49
71
|
platform TEXT NOT NULL,
|
|
50
72
|
their_comment_id TEXT NOT NULL,
|
|
@@ -65,7 +87,7 @@ CREATE TABLE IF NOT EXISTS replies (
|
|
|
65
87
|
);
|
|
66
88
|
|
|
67
89
|
CREATE TABLE IF NOT EXISTS thread_comments (
|
|
68
|
-
id
|
|
90
|
+
id SERIAL PRIMARY KEY,
|
|
69
91
|
thread_id INTEGER,
|
|
70
92
|
author TEXT,
|
|
71
93
|
author_handle TEXT,
|
|
@@ -74,10 +96,3 @@ CREATE TABLE IF NOT EXISTS thread_comments (
|
|
|
74
96
|
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
75
97
|
);
|
|
76
98
|
|
|
77
|
-
CREATE TABLE IF NOT EXISTS _syncfield_meta (
|
|
78
|
-
key TEXT PRIMARY KEY,
|
|
79
|
-
value TEXT
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
INSERT INTO _syncfield_meta (key, value) VALUES ('last_sync', '1970-01-01T00:00:00Z')
|
|
83
|
-
ON CONFLICT (key) DO NOTHING;
|
package/setup/SKILL.md
CHANGED
|
@@ -16,8 +16,7 @@ Interactive setup wizard for social-autoposter. Walk the user through configurat
|
|
|
16
16
|
## Prerequisites
|
|
17
17
|
|
|
18
18
|
- Node.js 16+ (for `npx`)
|
|
19
|
-
- `
|
|
20
|
-
- Python 3.9+ for running helper scripts
|
|
19
|
+
- Python 3.9+ with `pip3` for running helper scripts
|
|
21
20
|
- A browser automation tool (Playwright MCP, Selenium, etc.) for platform login verification
|
|
22
21
|
|
|
23
22
|
---
|
|
@@ -31,7 +30,7 @@ Run these steps in order. Ask the user for input at each step. Don't skip ahead.
|
|
|
31
30
|
Check if already installed:
|
|
32
31
|
|
|
33
32
|
```bash
|
|
34
|
-
ls ~/social-autoposter/schema.sql 2>/dev/null && echo "FOUND" || echo "NOT_FOUND"
|
|
33
|
+
ls ~/social-autoposter/schema-postgres.sql 2>/dev/null && echo "FOUND" || echo "NOT_FOUND"
|
|
35
34
|
```
|
|
36
35
|
|
|
37
36
|
If NOT_FOUND, install:
|
|
@@ -237,11 +236,17 @@ If no: "You can run manually anytime with `/social-autoposter`"
|
|
|
237
236
|
|
|
238
237
|
### Step 8: Summary
|
|
239
238
|
|
|
240
|
-
|
|
239
|
+
Read `config.json` accounts and compute each platform's stats URL:
|
|
240
|
+
- Twitter/X handle (strip leading `@`): `https://s4l.ai/stats/HANDLE`
|
|
241
|
+
- Reddit username: `https://s4l.ai/stats/USERNAME`
|
|
242
|
+
- LinkedIn name (URL-encoded spaces as `%20`): `https://s4l.ai/stats/NAME`
|
|
243
|
+
- Moltbook username: `https://s4l.ai/stats/MOLTBOOK_USERNAME`
|
|
244
|
+
|
|
245
|
+
Print a summary with real values substituted:
|
|
241
246
|
```
|
|
242
247
|
Social Autoposter Setup Complete
|
|
243
248
|
|
|
244
|
-
Installed: ~/social-autoposter (v1.0.
|
|
249
|
+
Installed: ~/social-autoposter (v1.0.9 via npm)
|
|
245
250
|
Database: Neon Postgres (DATABASE_URL in .env)
|
|
246
251
|
Config: ~/social-autoposter/config.json
|
|
247
252
|
Env: ~/social-autoposter/.env
|
|
@@ -256,6 +261,14 @@ Social Autoposter Setup Complete
|
|
|
256
261
|
Rate limit: 40 posts per 24 hours
|
|
257
262
|
Automation: launchd (hourly post, 6h stats, 2h engage)
|
|
258
263
|
|
|
264
|
+
Your live stats pages:
|
|
265
|
+
X/Twitter: https://s4l.ai/stats/HANDLE
|
|
266
|
+
Reddit: https://s4l.ai/stats/USERNAME
|
|
267
|
+
LinkedIn: https://s4l.ai/stats/NAME
|
|
268
|
+
Moltbook: https://s4l.ai/stats/MOLTBOOK_USERNAME
|
|
269
|
+
|
|
259
270
|
Try it: /social-autoposter
|
|
260
271
|
Update: npx social-autoposter update
|
|
261
272
|
```
|
|
273
|
+
|
|
274
|
+
Tell the user: "Your stats pages are ready — they'll show posts as soon as your first run completes and syncs to Neon (happens automatically after each post run). Bookmark the links above."
|
package/skill/SKILL.md
CHANGED
|
@@ -18,6 +18,10 @@ Automates finding, posting, and tracking social media comments and original post
|
|
|
18
18
|
| `/social-autoposter engage` | Scan and reply to responses on our posts |
|
|
19
19
|
| `/social-autoposter audit` | Full browser audit of all posts |
|
|
20
20
|
|
|
21
|
+
**View your posts live:** `https://s4l.ai/stats/[your_handle]`
|
|
22
|
+
— e.g. `https://s4l.ai/stats/m13v_` (Twitter handle without `@`), `https://s4l.ai/stats/Deep_Ad1959` (Reddit), `https://s4l.ai/stats/matthew-autoposter` (Moltbook).
|
|
23
|
+
The handles come from `config.json → accounts.*.handle/username`. Each platform account has its own URL.
|
|
24
|
+
|
|
21
25
|
---
|
|
22
26
|
|
|
23
27
|
## FIRST: Read config
|
|
@@ -37,7 +41,7 @@ Key fields you'll use throughout every workflow:
|
|
|
37
41
|
- `subreddits` — list of subreddits to monitor and post in
|
|
38
42
|
- `content_angle` — the user's unique perspective for writing authentic comments
|
|
39
43
|
- `projects` — products/repos to mention naturally when relevant (each has `name`, `description`, `website`, `github`, `topics`)
|
|
40
|
-
- `database` — unused (
|
|
44
|
+
- `database` — unused (Neon Postgres via `DATABASE_URL` in `.env`)
|
|
41
45
|
|
|
42
46
|
Use these values everywhere below instead of any hardcoded names or links.
|
|
43
47
|
|
|
@@ -201,6 +205,8 @@ After posting, you MUST:
|
|
|
201
205
|
python3 ~/social-autoposter/scripts/update_stats.py
|
|
202
206
|
```
|
|
203
207
|
|
|
208
|
+
After running, view updated stats at `https://s4l.ai/stats/[handle]`. Changes appear on the website within ~5 minutes.
|
|
209
|
+
|
|
204
210
|
---
|
|
205
211
|
|
|
206
212
|
## Workflow: Engage (`/social-autoposter engage`)
|
package/skill/engage.sh
CHANGED
|
@@ -12,10 +12,14 @@ set -euo pipefail
|
|
|
12
12
|
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
13
13
|
|
|
14
14
|
REPO_DIR="$HOME/social-autoposter"
|
|
15
|
-
DB="$REPO_DIR/social_posts.db"
|
|
16
15
|
SKILL_FILE="$REPO_DIR/skill/SKILL.md"
|
|
17
16
|
LOG_DIR="$REPO_DIR/skill/logs"
|
|
18
17
|
|
|
18
|
+
if [ -z "${DATABASE_URL:-}" ]; then
|
|
19
|
+
echo "ERROR: DATABASE_URL not set in ~/social-autoposter/.env"
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
19
23
|
mkdir -p "$LOG_DIR"
|
|
20
24
|
LOG_FILE="$LOG_DIR/engage-$(date +%Y-%m-%d_%H%M%S).log"
|
|
21
25
|
|
|
@@ -27,12 +31,12 @@ log "=== Engagement Loop Run: $(date) ==="
|
|
|
27
31
|
# PHASE A: Scan for replies (Python, no Claude needed)
|
|
28
32
|
# ═══════════════════════════════════════════════════════
|
|
29
33
|
log "Phase A: Scanning for replies..."
|
|
30
|
-
python3 "$REPO_DIR/scripts/scan_replies.py"
|
|
34
|
+
python3 "$REPO_DIR/scripts/scan_replies.py" 2>&1 | tee -a "$LOG_FILE" || true
|
|
31
35
|
|
|
32
36
|
# ═══════════════════════════════════════════════════════
|
|
33
37
|
# PHASE B: X/Twitter discovery + all reply engagement
|
|
34
38
|
# ═══════════════════════════════════════════════════════
|
|
35
|
-
PENDING_COUNT=$(
|
|
39
|
+
PENDING_COUNT=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='pending';")
|
|
36
40
|
log "Phase B: $PENDING_COUNT pending replies to handle"
|
|
37
41
|
|
|
38
42
|
# Always run Phase B — it handles both X/Twitter discovery and pending replies
|
|
@@ -63,17 +67,19 @@ There are $PENDING_COUNT pending replies in the database.
|
|
|
63
67
|
- **Tier 3 (direct ask):** They ask for link/tool/source. Give it immediately.
|
|
64
68
|
|
|
65
69
|
$(if [ "$PENDING_COUNT" -gt 0 ]; then
|
|
66
|
-
|
|
67
|
-
SELECT
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
psql "$DATABASE_URL" -t -A -c "
|
|
71
|
+
SELECT json_agg(q) FROM (
|
|
72
|
+
SELECT r.id, r.platform, r.their_author, r.their_content, r.their_comment_url,
|
|
73
|
+
r.their_comment_id, r.depth,
|
|
74
|
+
p.thread_title, p.thread_url, p.our_content, p.our_url,
|
|
75
|
+
CASE WHEN p.thread_url = p.our_url THEN 1 ELSE 0 END as is_our_original_post
|
|
76
|
+
FROM replies r
|
|
77
|
+
JOIN posts p ON r.post_id = p.id
|
|
78
|
+
WHERE r.status='pending'
|
|
79
|
+
ORDER BY
|
|
80
|
+
CASE WHEN p.thread_url = p.our_url THEN 0 ELSE 1 END,
|
|
81
|
+
r.discovered_at ASC
|
|
82
|
+
) q;"
|
|
77
83
|
else
|
|
78
84
|
echo "No pending replies."
|
|
79
85
|
fi)
|
|
@@ -88,21 +94,13 @@ CRITICAL: Close browser tabs after every page visit (browser_tabs action 'close'
|
|
|
88
94
|
# ═══════════════════════════════════════════════════════
|
|
89
95
|
log "Phase C: Cleanup"
|
|
90
96
|
|
|
91
|
-
TOTAL_PENDING=$(
|
|
92
|
-
TOTAL_REPLIED=$(
|
|
93
|
-
TOTAL_SKIPPED=$(
|
|
94
|
-
TOTAL_ERRORS=$(
|
|
97
|
+
TOTAL_PENDING=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='pending';")
|
|
98
|
+
TOTAL_REPLIED=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='replied';")
|
|
99
|
+
TOTAL_SKIPPED=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='skipped';")
|
|
100
|
+
TOTAL_ERRORS=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM replies WHERE status='error';")
|
|
95
101
|
|
|
96
102
|
log "Replies summary: pending=$TOTAL_PENDING replied=$TOTAL_REPLIED skipped=$TOTAL_SKIPPED errors=$TOTAL_ERRORS"
|
|
97
103
|
|
|
98
|
-
# Git sync
|
|
99
|
-
cd "$REPO_DIR"
|
|
100
|
-
git add social_posts.db
|
|
101
|
-
git diff --cached --quiet || git commit -m "engage $(date '+%Y-%m-%d %H:%M')" && git push 2>/dev/null || true
|
|
102
|
-
|
|
103
|
-
# Sync SQLite → Neon Postgres
|
|
104
|
-
bash "$REPO_DIR/syncfield.sh" || true
|
|
105
|
-
|
|
106
104
|
# Delete old logs
|
|
107
105
|
find "$LOG_DIR" -name "engage-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
108
106
|
|
package/skill/stats.sh
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
set -euo pipefail
|
|
8
8
|
|
|
9
9
|
REPO_DIR="$HOME/social-autoposter"
|
|
10
|
-
DB="$REPO_DIR/social_posts.db"
|
|
11
10
|
LOG_DIR="$REPO_DIR/skill/logs"
|
|
12
11
|
QUIET="${1:-}"
|
|
13
12
|
|
|
@@ -22,17 +21,9 @@ echo "[$(date +%H:%M:%S)] Starting stats update" | tee "$LOGFILE"
|
|
|
22
21
|
|
|
23
22
|
# Run the Python stats script
|
|
24
23
|
if [ "$QUIET" = "--quiet" ]; then
|
|
25
|
-
python3 "$REPO_DIR/scripts/update_stats.py" --
|
|
24
|
+
python3 "$REPO_DIR/scripts/update_stats.py" --quiet 2>&1 | tee -a "$LOGFILE"
|
|
26
25
|
else
|
|
27
|
-
python3 "$REPO_DIR/scripts/update_stats.py"
|
|
26
|
+
python3 "$REPO_DIR/scripts/update_stats.py" 2>&1 | tee -a "$LOGFILE"
|
|
28
27
|
fi
|
|
29
28
|
|
|
30
29
|
echo "[$(date +%H:%M:%S)] Stats update complete" | tee -a "$LOGFILE"
|
|
31
|
-
|
|
32
|
-
# Sync DB to GitHub for Datasette Lite
|
|
33
|
-
cd "$REPO_DIR"
|
|
34
|
-
git add social_posts.db
|
|
35
|
-
git diff --cached --quiet || git commit -m "stats $(date '+%Y-%m-%d %H:%M')" && git push 2>/dev/null || true
|
|
36
|
-
|
|
37
|
-
# Sync SQLite → Neon Postgres
|
|
38
|
-
bash "$REPO_DIR/syncfield.sh" || true
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-autoposter</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/run.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>3600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/Users/matthewdi/.nvm/versions/node/v20.19.4/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<true/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-engage</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/engage.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>21600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-engage-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-engage-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/Users/matthewdi/.nvm/versions/node/v20.19.4/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<false/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.m13v.social-stats</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/bin/bash</string>
|
|
10
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/skill/stats.sh</string>
|
|
11
|
-
</array>
|
|
12
|
-
<key>StartInterval</key>
|
|
13
|
-
<integer>21600</integer>
|
|
14
|
-
<key>StandardOutPath</key>
|
|
15
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stdout.log</string>
|
|
16
|
-
<key>StandardErrorPath</key>
|
|
17
|
-
<string>/Users/matthewdi/.claude/skills/social-autoposter/logs/launchd-stats-stderr.log</string>
|
|
18
|
-
<key>EnvironmentVariables</key>
|
|
19
|
-
<dict>
|
|
20
|
-
<key>PATH</key>
|
|
21
|
-
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
22
|
-
<key>HOME</key>
|
|
23
|
-
<string>/Users/matthewdi</string>
|
|
24
|
-
</dict>
|
|
25
|
-
<key>RunAtLoad</key>
|
|
26
|
-
<false/>
|
|
27
|
-
</dict>
|
|
28
|
-
</plist>
|
package/syncfield.sh
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# syncfield.sh — Sync SQLite → Neon Postgres (idempotent upsert)
|
|
3
|
-
# Called after git push in stats.sh and engage.sh
|
|
4
|
-
# Requires: sqlite3, psql, DATABASE_URL in .env
|
|
5
|
-
|
|
6
|
-
set -euo pipefail
|
|
7
|
-
|
|
8
|
-
DB="$HOME/social-autoposter/social_posts.db"
|
|
9
|
-
|
|
10
|
-
# Load secrets
|
|
11
|
-
# shellcheck source=/dev/null
|
|
12
|
-
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
13
|
-
|
|
14
|
-
if [ -z "${DATABASE_URL:-}" ]; then
|
|
15
|
-
echo "syncfield: DATABASE_URL not set, skipping sync"
|
|
16
|
-
exit 0
|
|
17
|
-
fi
|
|
18
|
-
|
|
19
|
-
TMPDIR="${TMPDIR:-/tmp}"
|
|
20
|
-
|
|
21
|
-
sync_table() {
|
|
22
|
-
local table="$1"
|
|
23
|
-
local columns="$2"
|
|
24
|
-
local conflict_col="${3:-id}"
|
|
25
|
-
local csv_file="$TMPDIR/syncfield_${table}.csv"
|
|
26
|
-
|
|
27
|
-
# Export from SQLite as CSV
|
|
28
|
-
sqlite3 -header -csv "$DB" "SELECT $columns FROM $table;" > "$csv_file"
|
|
29
|
-
|
|
30
|
-
local row_count
|
|
31
|
-
row_count=$(wc -l < "$csv_file" | tr -d ' ')
|
|
32
|
-
row_count=$((row_count - 1)) # subtract header
|
|
33
|
-
|
|
34
|
-
if [ "$row_count" -le 0 ]; then
|
|
35
|
-
rm -f "$csv_file"
|
|
36
|
-
return
|
|
37
|
-
fi
|
|
38
|
-
|
|
39
|
-
# Build column list for SET clause (exclude conflict column)
|
|
40
|
-
local set_clause=""
|
|
41
|
-
IFS=',' read -ra cols <<< "$columns"
|
|
42
|
-
for col in "${cols[@]}"; do
|
|
43
|
-
col=$(echo "$col" | tr -d ' ')
|
|
44
|
-
if [ "$col" != "$conflict_col" ]; then
|
|
45
|
-
if [ -n "$set_clause" ]; then
|
|
46
|
-
set_clause="$set_clause, "
|
|
47
|
-
fi
|
|
48
|
-
set_clause="${set_clause}${col} = EXCLUDED.${col}"
|
|
49
|
-
fi
|
|
50
|
-
done
|
|
51
|
-
|
|
52
|
-
# Upsert via temp table + INSERT ON CONFLICT
|
|
53
|
-
psql "$DATABASE_URL" -q <<SQL
|
|
54
|
-
CREATE TEMP TABLE _tmp_${table} (LIKE ${table} INCLUDING ALL);
|
|
55
|
-
\\copy _tmp_${table}($columns) FROM '$csv_file' WITH (FORMAT csv, HEADER true, NULL '');
|
|
56
|
-
INSERT INTO ${table}($columns)
|
|
57
|
-
SELECT $columns FROM _tmp_${table}
|
|
58
|
-
ON CONFLICT ($conflict_col) DO UPDATE SET $set_clause;
|
|
59
|
-
DROP TABLE _tmp_${table};
|
|
60
|
-
SQL
|
|
61
|
-
|
|
62
|
-
echo "syncfield: synced $table ($row_count rows)"
|
|
63
|
-
rm -f "$csv_file"
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
# Sync each table
|
|
67
|
-
sync_table "posts" "id,platform,thread_url,thread_author,thread_author_handle,thread_title,thread_content,thread_engagement,our_url,our_content,our_account,posted_at,discovered_at,status,status_checked_at,engagement_updated_at,upvotes,comments_count,views,source_turn_id,source_summary,top_comment_author,top_comment_content,top_comment_upvotes,top_comment_url"
|
|
68
|
-
|
|
69
|
-
sync_table "campaigns" "id,name,prompt,platforms,status,max_posts_per_day,posts_made,created_at,updated_at"
|
|
70
|
-
|
|
71
|
-
sync_table "replies" "id,post_id,platform,their_comment_id,their_author,their_content,their_comment_url,our_reply_id,our_reply_content,our_reply_url,parent_reply_id,moltbook_post_uuid,moltbook_parent_comment_uuid,depth,status,skip_reason,discovered_at,replied_at"
|
|
72
|
-
|
|
73
|
-
sync_table "thread_comments" "id,thread_id,author,author_handle,content,engagement,discovered_at"
|
|
74
|
-
|
|
75
|
-
# Update sync timestamp
|
|
76
|
-
psql "$DATABASE_URL" -q -c "INSERT INTO _syncfield_meta (key, value) VALUES ('last_sync', NOW()::text) ON CONFLICT (key) DO UPDATE SET value = NOW()::text;"
|
|
77
|
-
|
|
78
|
-
echo "syncfield: sync complete at $(date '+%Y-%m-%d %H:%M:%S')"
|