social-autoposter 1.0.8 → 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 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 DB (used by syncfield.sh)
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
- sqlite3 social_posts.db < schema.sql # create the database
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 <- DB schema
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 sqlite3)
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` — path to SQLite DB
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 >= datetime('now', '-24 hours')
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
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=?, our_reply_url=?,
214
- replied_at=datetime('now') WHERE id=?
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,22 +8,21 @@ 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 = [
14
15
  'scripts',
15
- 'schema.sql',
16
+ 'schema-postgres.sql',
16
17
  'config.example.json',
17
18
  '.env.example',
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
26
- const USER_FILES = new Set(['config.json', 'social_posts.db', '.env']);
25
+ const USER_FILES = new Set(['config.json', '.env']);
27
26
 
28
27
  function copyDir(src, dest) {
29
28
  fs.mkdirSync(dest, { recursive: true });
@@ -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)) {
@@ -79,22 +155,19 @@ function init() {
79
155
  console.log(' .env exists — skipping');
80
156
  }
81
157
 
82
- // Create DB from schema if missing
83
- const dbPath = path.join(DEST, 'social_posts.db');
84
- if (!fs.existsSync(dbPath)) {
85
- const schemaPath = path.join(DEST, 'schema.sql');
86
- const result = spawnSync('sqlite3', [dbPath], {
87
- input: fs.readFileSync(schemaPath),
88
- stdio: ['pipe', 'inherit', 'inherit'],
89
- });
90
- if (result.status === 0) {
91
- console.log(' created social_posts.db');
158
+ // Check psycopg2-binary (required to connect to Neon DB)
159
+ const pip3Check = spawnSync('pip3', ['show', 'psycopg2-binary'], { stdio: 'pipe' });
160
+ if (pip3Check.status !== 0) {
161
+ console.log(' installing psycopg2-binary (required for Neon DB)...');
162
+ const pipInstall = spawnSync('pip3', ['install', 'psycopg2-binary', '-q'], { stdio: 'inherit' });
163
+ if (pipInstall.status !== 0) {
164
+ console.warn(' WARNING: psycopg2-binary install failed — run manually:');
165
+ console.warn(' pip3 install psycopg2-binary');
92
166
  } else {
93
- console.warn(' WARNING: sqlite3 failed — create DB manually:');
94
- console.warn(' sqlite3 ~/social-autoposter/social_posts.db < ~/social-autoposter/schema.sql');
167
+ console.log(' psycopg2-binary installed');
95
168
  }
96
169
  } else {
97
- console.log(' social_posts.db exists — skipping');
170
+ console.log(' psycopg2-binary already installed');
98
171
  }
99
172
 
100
173
  // Skill symlinks
@@ -105,19 +178,12 @@ function init() {
105
178
  linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
106
179
  console.log(' ~/.claude/skills/social-autoposter-setup ->', path.join(DEST, 'setup'));
107
180
 
108
- // DB symlink: ~/.claude/social_posts.db -> ~/social-autoposter/social_posts.db
109
- const claudeDir = path.join(os.homedir(), '.claude');
110
- try {
111
- linkOrRelink(dbPath, path.join(claudeDir, 'social_posts.db'));
112
- console.log(' ~/.claude/social_posts.db ->', dbPath);
113
- } catch {}
114
-
115
181
  console.log('');
116
182
  console.log('Done! Next steps:');
117
183
  console.log(' 1. Edit ~/social-autoposter/config.json with your accounts');
118
184
  console.log(' 2. Tell your Claude agent: "set up social autoposter"');
119
185
  console.log(' (uses the setup/SKILL.md wizard for browser login verification)');
120
- console.log(' 3. Or configure manually and run: bash ~/social-autoposter/setup.sh');
186
+ console.log(' 3. Posts are logged to the shared Neon DB (DATABASE_URL in .env)');
121
187
  }
122
188
 
123
189
  function update() {
@@ -145,9 +211,11 @@ function update() {
145
211
  console.log(' updated', f);
146
212
  }
147
213
 
148
- // Re-symlink skill, setup skill, and DB in case they broke
214
+ // Regenerate launchd plists with correct paths
215
+ generatePlists();
216
+
217
+ // Re-symlink skill and setup skill in case they broke
149
218
  const skillsDir = path.join(os.homedir(), '.claude', 'skills');
150
- const claudeDir = path.join(os.homedir(), '.claude');
151
219
  try {
152
220
  linkOrRelink(path.join(DEST, 'skill'), path.join(skillsDir, 'social-autoposter'));
153
221
  console.log(' re-linked ~/.claude/skills/social-autoposter');
@@ -156,13 +224,9 @@ function update() {
156
224
  linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
157
225
  console.log(' re-linked ~/.claude/skills/social-autoposter-setup');
158
226
  } catch {}
159
- try {
160
- linkOrRelink(path.join(DEST, 'social_posts.db'), path.join(claudeDir, 'social_posts.db'));
161
- console.log(' re-linked ~/.claude/social_posts.db');
162
- } catch {}
163
227
 
164
228
  console.log('');
165
- console.log('Update complete. config.json and social_posts.db were preserved.');
229
+ console.log('Update complete. config.json was preserved.');
166
230
  }
167
231
 
168
232
  const cmd = process.argv[2];
@@ -1,7 +1,5 @@
1
1
  {
2
- "database": "~/social-autoposter/social_posts.db",
3
2
  "prompt_db": "~/claude-prompt-db/prompts.db",
4
- "sync_script": "~/social-autoposter/syncfield.sh",
5
3
  "stats_script": "~/.claude/skills/social-autoposter/stats.sh",
6
4
 
7
5
  "accounts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.0.8",
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"
@@ -8,7 +8,7 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "scripts/*.py",
11
- "schema.sql",
11
+ "schema-postgres.sql",
12
12
  "config.example.json",
13
13
  ".env.example",
14
14
  "SKILL.md",
@@ -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",
@@ -1,6 +1,9 @@
1
+ -- schema-postgres.sql — Neon Postgres schema (primary database)
2
+ -- Run once: psql "$DATABASE_URL" -f schema-postgres.sql
3
+
1
4
  CREATE TABLE IF NOT EXISTS posts (
2
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3
- platform TEXT NOT NULL CHECK(platform IN ('reddit', 'x', 'linkedin')),
5
+ id SERIAL PRIMARY KEY,
6
+ platform TEXT NOT NULL,
4
7
  thread_url TEXT NOT NULL,
5
8
  thread_author TEXT,
6
9
  thread_author_handle TEXT,
@@ -12,7 +15,7 @@ CREATE TABLE IF NOT EXISTS posts (
12
15
  our_account TEXT NOT NULL,
13
16
  posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
14
17
  discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
15
- status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'deleted', 'removed')),
18
+ status TEXT DEFAULT 'active',
16
19
  status_checked_at TIMESTAMP,
17
20
  engagement_updated_at TIMESTAMP,
18
21
  upvotes INTEGER,
@@ -29,7 +32,7 @@ CREATE TABLE IF NOT EXISTS posts (
29
32
  CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
30
33
 
31
34
  CREATE TABLE IF NOT EXISTS threads (
32
- id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ id SERIAL PRIMARY KEY,
33
36
  platform TEXT NOT NULL,
34
37
  url TEXT NOT NULL UNIQUE,
35
38
  author TEXT,
@@ -37,31 +40,33 @@ CREATE TABLE IF NOT EXISTS threads (
37
40
  title TEXT,
38
41
  content TEXT,
39
42
  engagement TEXT,
40
- discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
43
+ discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
41
44
  );
42
45
 
43
46
  CREATE TABLE IF NOT EXISTS our_posts (
44
- id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ id SERIAL PRIMARY KEY,
45
48
  thread_id INTEGER REFERENCES threads(id),
46
49
  platform TEXT NOT NULL,
47
50
  url TEXT,
48
51
  content TEXT NOT NULL,
49
52
  account TEXT,
50
- posted_at DATETIME DEFAULT CURRENT_TIMESTAMP
53
+ posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
51
54
  );
52
55
 
53
- CREATE TABLE IF NOT EXISTS thread_comments (
54
- id INTEGER PRIMARY KEY AUTOINCREMENT,
55
- thread_id INTEGER REFERENCES threads(id),
56
- author TEXT,
57
- author_handle TEXT,
58
- content TEXT,
59
- engagement TEXT,
60
- discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
56
+ CREATE TABLE IF NOT EXISTS campaigns (
57
+ id SERIAL PRIMARY KEY,
58
+ name TEXT NOT NULL,
59
+ prompt TEXT NOT NULL,
60
+ platforms TEXT DEFAULT 'x,reddit,moltbook',
61
+ status TEXT DEFAULT 'active',
62
+ max_posts_per_day INTEGER DEFAULT 4,
63
+ posts_made INTEGER DEFAULT 0,
64
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
61
66
  );
62
67
 
63
68
  CREATE TABLE IF NOT EXISTS replies (
64
- id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ id SERIAL PRIMARY KEY,
65
70
  post_id INTEGER REFERENCES posts(id),
66
71
  platform TEXT NOT NULL,
67
72
  their_comment_id TEXT NOT NULL,
@@ -75,8 +80,19 @@ CREATE TABLE IF NOT EXISTS replies (
75
80
  moltbook_post_uuid TEXT,
76
81
  moltbook_parent_comment_uuid TEXT,
77
82
  depth INTEGER DEFAULT 1,
78
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
83
+ status TEXT DEFAULT 'pending',
79
84
  skip_reason TEXT,
80
85
  discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
81
86
  replied_at TIMESTAMP
82
87
  );
88
+
89
+ CREATE TABLE IF NOT EXISTS thread_comments (
90
+ id SERIAL PRIMARY KEY,
91
+ thread_id INTEGER,
92
+ author TEXT,
93
+ author_handle TEXT,
94
+ content TEXT,
95
+ engagement TEXT,
96
+ discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
97
+ );
98
+
package/scripts/db.py ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Shared Neon Postgres connection for social-autoposter.
3
+
4
+ Provides a thin psycopg2 wrapper with a sqlite3-compatible API so all
5
+ scripts can use the same SQL without changes to query logic.
6
+
7
+ DATABASE_URL is read from ~/social-autoposter/.env (pre-filled on install).
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+ ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
15
+
16
+
17
+ def load_env():
18
+ if os.path.exists(ENV_PATH):
19
+ with open(ENV_PATH) as f:
20
+ for line in f:
21
+ line = line.strip()
22
+ if line and not line.startswith('#') and '=' in line:
23
+ k, v = line.split('=', 1)
24
+ os.environ.setdefault(k.strip(), v.strip())
25
+
26
+
27
+ def _translate_sql(sql):
28
+ """Translate SQLite-specific SQL syntax to PostgreSQL."""
29
+ # ? placeholders -> %s
30
+ sql = sql.replace('?', '%s')
31
+ # datetime('now', '-N hours') -> NOW() - INTERVAL 'N hours'
32
+ sql = re.sub(r"datetime\('now',\s*'-(\d+) hours'\)", r"NOW() - INTERVAL '\1 hours'", sql)
33
+ # datetime('now', '-N days') -> NOW() - INTERVAL 'N days'
34
+ sql = re.sub(r"datetime\('now',\s*'-(\d+) days'\)", r"NOW() - INTERVAL '\1 days'", sql)
35
+ # datetime('now') -> NOW()
36
+ sql = re.sub(r"datetime\('now'\)", 'NOW()', sql)
37
+ # status_checked_at=datetime('now') already handled above
38
+ return sql
39
+
40
+
41
+ class PGConn:
42
+ """Thin psycopg2 wrapper with a sqlite3-compatible execute/commit/close API."""
43
+
44
+ def __init__(self, conn):
45
+ import psycopg2.extras
46
+ self._conn = conn
47
+ self._cursor_factory = psycopg2.extras.DictCursor
48
+
49
+ def execute(self, sql, params=None):
50
+ cur = self._conn.cursor(cursor_factory=self._cursor_factory)
51
+ sql = _translate_sql(sql)
52
+ if params is not None:
53
+ cur.execute(sql, list(params))
54
+ else:
55
+ cur.execute(sql)
56
+ return cur
57
+
58
+ def commit(self):
59
+ self._conn.commit()
60
+
61
+ def close(self):
62
+ self._conn.close()
63
+
64
+ # No-op to absorb sqlite3.Row assignments
65
+ @property
66
+ def row_factory(self):
67
+ return None
68
+
69
+ @row_factory.setter
70
+ def row_factory(self, val):
71
+ pass
72
+
73
+
74
+ def get_conn():
75
+ """Return a PGConn connected to the central Neon database."""
76
+ load_env()
77
+ url = os.environ.get('DATABASE_URL')
78
+ if not url:
79
+ print("ERROR: DATABASE_URL not set in ~/social-autoposter/.env", file=sys.stderr)
80
+ print(" Re-run: npx social-autoposter init", file=sys.stderr)
81
+ sys.exit(1)
82
+ try:
83
+ import psycopg2
84
+ except ImportError:
85
+ print("ERROR: psycopg2-binary not installed.", file=sys.stderr)
86
+ print(" Run: pip3 install psycopg2-binary", file=sys.stderr)
87
+ sys.exit(1)
88
+ conn = psycopg2.connect(url)
89
+ return PGConn(conn)
@@ -13,13 +13,14 @@ import argparse
13
13
  import json
14
14
  import os
15
15
  import re
16
- import sqlite3
17
16
  import sys
18
17
  import time
19
18
  import urllib.request
20
19
  from datetime import datetime, timezone
21
20
 
22
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ import db as dbmod
23
+
23
24
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
24
25
 
25
26
 
@@ -43,29 +44,29 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
43
44
  return None
44
45
 
45
46
 
46
- def get_already_posted(db_path):
47
+ def get_already_posted():
47
48
  """Return set of thread URLs we've already posted in."""
48
- db = sqlite3.connect(db_path)
49
- rows = db.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
50
- db.close()
49
+ conn = dbmod.get_conn()
50
+ rows = conn.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
51
+ conn.close()
51
52
  return {row[0] for row in rows}
52
53
 
53
54
 
54
- def get_recent_posts(db_path, limit=5):
55
+ def get_recent_posts(limit=5):
55
56
  """Return our last N post contents for repetition checking."""
56
- db = sqlite3.connect(db_path)
57
- rows = db.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT ?", [limit]).fetchall()
58
- db.close()
57
+ conn = dbmod.get_conn()
58
+ rows = conn.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT %s", [limit]).fetchall()
59
+ conn.close()
59
60
  return [row[0] for row in rows]
60
61
 
61
62
 
62
- def check_rate_limit(db_path, max_per_day=40):
63
+ def check_rate_limit(max_per_day=40):
63
64
  """Return (posts_today, can_post)."""
64
- db = sqlite3.connect(db_path)
65
- row = db.execute(
66
- "SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours')"
65
+ conn = dbmod.get_conn()
66
+ row = conn.execute(
67
+ "SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'"
67
68
  ).fetchone()
68
- db.close()
69
+ conn.close()
69
70
  count = row[0]
70
71
  return count, count < max_per_day
71
72
 
@@ -145,7 +146,6 @@ def filter_threads(threads, already_posted, topic=None):
145
146
 
146
147
  def main():
147
148
  parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
148
- parser.add_argument("--db", default=None, help="Path to SQLite database")
149
149
  parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
150
150
  parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
151
151
  parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
@@ -154,19 +154,18 @@ def main():
154
154
  args = parser.parse_args()
155
155
 
156
156
  config = load_config()
157
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
158
157
  subreddits = args.subreddits.split(",") if args.subreddits else config.get("subreddits", [])
159
158
  reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
160
159
  user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
161
160
 
162
161
  # Rate limit check
163
- posts_today, can_post = check_rate_limit(db_path)
162
+ posts_today, can_post = check_rate_limit()
164
163
  if not can_post:
165
164
  print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
166
165
  sys.exit(1)
167
166
 
168
- already_posted = get_already_posted(db_path)
169
- recent_posts = get_recent_posts(db_path)
167
+ already_posted = get_already_posted()
168
+ recent_posts = get_recent_posts()
170
169
 
171
170
  # Fetch threads
172
171
  threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
@@ -14,15 +14,16 @@ import argparse
14
14
  import json
15
15
  import os
16
16
  import re
17
- import sqlite3
18
17
  import sys
19
18
  import time
20
19
  import urllib.request
21
20
  from datetime import datetime, timedelta, timezone
22
21
 
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+ import db as dbmod
24
+
23
25
  STALENESS_DAYS = 30
24
26
  MIN_WORDS = 5
25
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
26
27
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
27
28
 
28
29
 
@@ -70,9 +71,8 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0", retries=5)
70
71
 
71
72
 
72
73
  class ReplyScanner:
73
- def __init__(self, db_path, reddit_account, user_agent="social-autoposter/1.0"):
74
- self.db = sqlite3.connect(db_path, timeout=30)
75
- self.db.row_factory = sqlite3.Row
74
+ def __init__(self, reddit_account, user_agent="social-autoposter/1.0"):
75
+ self.db = dbmod.get_conn()
76
76
  self.reddit_account = reddit_account
77
77
  self.user_agent = user_agent
78
78
  self.skip_authors = {"AutoModerator", "[deleted]", reddit_account}
@@ -80,31 +80,9 @@ class ReplyScanner:
80
80
  self.skipped = 0
81
81
  self.errors = 0
82
82
 
83
- # Ensure replies table exists
84
- self.db.execute("""CREATE TABLE IF NOT EXISTS replies (
85
- id INTEGER PRIMARY KEY AUTOINCREMENT,
86
- post_id INTEGER REFERENCES posts(id),
87
- platform TEXT NOT NULL,
88
- their_comment_id TEXT NOT NULL,
89
- their_author TEXT,
90
- their_content TEXT,
91
- their_comment_url TEXT,
92
- our_reply_id TEXT,
93
- our_reply_content TEXT,
94
- our_reply_url TEXT,
95
- parent_reply_id INTEGER REFERENCES replies(id),
96
- moltbook_post_uuid TEXT,
97
- moltbook_parent_comment_uuid TEXT,
98
- depth INTEGER DEFAULT 1,
99
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
100
- skip_reason TEXT,
101
- discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
102
- replied_at TIMESTAMP
103
- )""")
104
-
105
83
  def already_tracked(self, platform, comment_id):
106
84
  row = self.db.execute(
107
- "SELECT COUNT(*) FROM replies WHERE platform=? AND their_comment_id=?",
85
+ "SELECT COUNT(*) FROM replies WHERE platform=%s AND their_comment_id=%s",
108
86
  (platform, str(comment_id)),
109
87
  ).fetchone()
110
88
  return row[0] > 0
@@ -120,7 +98,7 @@ class ReplyScanner:
120
98
  """INSERT INTO replies
121
99
  (post_id, platform, their_comment_id, their_author, their_content, their_comment_url,
122
100
  parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid)
123
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
101
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
124
102
  (post_id, platform, comment_id, author, content, comment_url,
125
103
  parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid),
126
104
  )
@@ -218,7 +196,7 @@ class ReplyScanner:
218
196
  print("Scanning Reddit posts for replies...")
219
197
  posts = self.db.execute(
220
198
  "SELECT id, our_url, thread_url, thread_title, thread_author FROM posts "
221
- "WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%'"
199
+ "WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%%'"
222
200
  ).fetchall()
223
201
 
224
202
  for post in posts:
@@ -264,7 +242,7 @@ class ReplyScanner:
264
242
  print("\nScanning replies to our previous replies...")
265
243
  replied_rows = self.db.execute(
266
244
  "SELECT id, platform, our_reply_url, post_id, depth "
267
- "FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL"
245
+ "FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL",
268
246
  ).fetchall()
269
247
 
270
248
  for row in replied_rows:
@@ -299,7 +277,7 @@ class ReplyScanner:
299
277
  print("\nScanning Moltbook posts for replies...")
300
278
  posts = self.db.execute(
301
279
  "SELECT id, our_url FROM posts "
302
- "WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL"
280
+ "WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL",
303
281
  ).fetchall()
304
282
 
305
283
  for post in posts:
@@ -342,20 +320,19 @@ class ReplyScanner:
342
320
 
343
321
  def main():
344
322
  parser = argparse.ArgumentParser(description="Scan for replies to our social posts")
345
- parser.add_argument("--db", default=None, help="Path to SQLite database")
346
323
  parser.add_argument("--reddit-account", default=None, help="Reddit username")
347
324
  args = parser.parse_args()
348
325
 
349
326
  config = load_config()
350
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
351
327
  reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
352
328
 
353
329
  if not reddit_account:
354
330
  print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
355
331
  sys.exit(1)
356
332
 
333
+ dbmod.load_env()
357
334
  user_agent = f"social-autoposter/1.0 (u/{reddit_account})"
358
- scanner = ReplyScanner(db_path, reddit_account, user_agent)
335
+ scanner = ReplyScanner(reddit_account, user_agent)
359
336
  scanner.scan_reddit()
360
337
 
361
338
  moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
@@ -12,12 +12,13 @@ import argparse
12
12
  import json
13
13
  import os
14
14
  import re
15
- import sqlite3
16
15
  import sys
17
16
  import time
18
17
  import urllib.request
19
18
 
20
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+ import db as dbmod
21
+
21
22
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
22
23
 
23
24
 
@@ -80,14 +81,14 @@ def update_reddit(db, user_agent, quiet=False):
80
81
  score = comment_data.get("score", 0)
81
82
 
82
83
  if body in ("[deleted]",) or author == "[deleted]":
83
- db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
84
+ db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
84
85
  deleted += 1
85
86
  if not quiet:
86
87
  print(f"DELETED [{post_id}]")
87
88
  continue
88
89
 
89
90
  if body == "[removed]":
90
- db.execute("UPDATE posts SET status='removed', status_checked_at=datetime('now') WHERE id=?", [post_id])
91
+ db.execute("UPDATE posts SET status='removed', status_checked_at=NOW() WHERE id=%s", [post_id])
91
92
  removed += 1
92
93
  if not quiet:
93
94
  print(f"REMOVED [{post_id}]")
@@ -99,8 +100,8 @@ def update_reddit(db, user_agent, quiet=False):
99
100
  engagement = json.dumps({"thread_score": thread_score, "thread_comments": thread_comments})
100
101
 
101
102
  db.execute(
102
- "UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
103
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
103
+ "UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
104
+ "engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
104
105
  [score, thread_comments, engagement, post_id],
105
106
  )
106
107
  updated += 1
@@ -142,7 +143,7 @@ def update_moltbook(db, api_key, quiet=False):
142
143
 
143
144
  post_data = data.get("post", {})
144
145
  if post_data.get("is_deleted"):
145
- db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
146
+ db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
146
147
  deleted += 1
147
148
  continue
148
149
 
@@ -153,8 +154,8 @@ def update_moltbook(db, api_key, quiet=False):
153
154
  engagement = json.dumps({"score": score, "upvotes": upvotes, "comment_count": comment_count})
154
155
 
155
156
  db.execute(
156
- "UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
157
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
157
+ "UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
158
+ "engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
158
159
  [upvotes, comment_count, engagement, post_id],
159
160
  )
160
161
  updated += 1
@@ -167,17 +168,16 @@ def update_moltbook(db, api_key, quiet=False):
167
168
 
168
169
  def main():
169
170
  parser = argparse.ArgumentParser(description="Update engagement stats for social posts")
170
- parser.add_argument("--db", default=None, help="Path to SQLite database")
171
171
  parser.add_argument("--quiet", action="store_true", help="Minimal output")
172
172
  parser.add_argument("--json", action="store_true", help="Output as JSON")
173
173
  args = parser.parse_args()
174
174
 
175
175
  config = load_config()
176
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
177
176
  reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
178
177
  user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
179
178
 
180
- db = sqlite3.connect(db_path, timeout=30)
179
+ dbmod.load_env()
180
+ db = dbmod.get_conn()
181
181
 
182
182
  reddit_stats = update_reddit(db, user_agent, quiet=args.quiet)
183
183
  moltbook_stats = update_moltbook(db, os.environ.get("MOLTBOOK_API_KEY", ""), quiet=args.quiet)
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
- - `sqlite3` available on PATH
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:
@@ -39,10 +38,10 @@ If NOT_FOUND, install:
39
38
  npx social-autoposter init
40
39
  ```
41
40
 
42
- This copies all scripts, schema, skill files, and config templates to `~/social-autoposter/`. It also:
41
+ This copies all scripts, skill files, and config templates to `~/social-autoposter/`. It also:
43
42
  - Creates `config.json` from `config.example.json` (if missing)
44
- - Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon DATABASE_URL
45
- - Creates `social_posts.db` from `schema.sql` (if missing)
43
+ - Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon `DATABASE_URL`
44
+ - Installs `psycopg2-binary` (Python driver for Neon)
46
45
  - Symlinks `~/.claude/skills/social-autoposter` → `~/social-autoposter/skill`
47
46
 
48
47
  To update scripts later without touching config/data:
@@ -52,18 +51,27 @@ npx social-autoposter update
52
51
 
53
52
  Set `SKILL_DIR=~/social-autoposter` for the rest of this wizard.
54
53
 
55
- ### Step 2: Verify the database
54
+ ### Step 2: Verify the Neon database connection
55
+
56
+ Load the env and test the connection:
56
57
 
57
58
  ```bash
58
- sqlite3 "$SKILL_DIR/social_posts.db" "SELECT name FROM sqlite_master WHERE type='table';"
59
+ source "$SKILL_DIR/.env"
60
+ python3 -c "
61
+ import psycopg2, os
62
+ conn = psycopg2.connect(os.environ['DATABASE_URL'])
63
+ cur = conn.cursor()
64
+ cur.execute(\"SELECT COUNT(*) FROM posts\")
65
+ print('Connected. Posts in DB:', cur.fetchone()[0])
66
+ conn.close()
67
+ "
59
68
  ```
60
69
 
61
- Expected tables: `posts`, `threads`, `our_posts`, `thread_comments`, `replies`.
70
+ Expected: `Connected. Posts in DB: <number>` (any number is fine, including 0).
62
71
 
63
- If missing, create it:
64
- ```bash
65
- sqlite3 "$SKILL_DIR/social_posts.db" < "$SKILL_DIR/schema.sql"
66
- ```
72
+ If psycopg2 is missing: `pip3 install psycopg2-binary`
73
+
74
+ If the connection fails, check that `DATABASE_URL` is set in `$SKILL_DIR/.env`.
67
75
 
68
76
  ### Step 3: Configure accounts
69
77
 
@@ -228,12 +236,18 @@ If no: "You can run manually anytime with `/social-autoposter`"
228
236
 
229
237
  ### Step 8: Summary
230
238
 
231
- Print a summary:
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:
232
246
  ```
233
247
  Social Autoposter Setup Complete
234
248
 
235
- Installed: ~/social-autoposter (v1.0.3 via npm)
236
- Database: ~/social-autoposter/social_posts.db
249
+ Installed: ~/social-autoposter (v1.0.9 via npm)
250
+ Database: Neon Postgres (DATABASE_URL in .env)
237
251
  Config: ~/social-autoposter/config.json
238
252
  Env: ~/social-autoposter/.env
239
253
  Skill: ~/.claude/skills/social-autoposter
@@ -247,6 +261,14 @@ Social Autoposter Setup Complete
247
261
  Rate limit: 40 posts per 24 hours
248
262
  Automation: launchd (hourly post, 6h stats, 2h engage)
249
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
+
250
270
  Try it: /social-autoposter
251
271
  Update: npx social-autoposter update
252
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` — path to SQLite DB (default: `~/social-autoposter/social_posts.db`)
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
 
@@ -60,7 +64,7 @@ python3 ~/social-autoposter/scripts/update_stats.py --quiet
60
64
  ### 1. Rate limit check
61
65
 
62
66
  ```sql
63
- SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours')
67
+ SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
64
68
  ```
65
69
  Max 40 posts per 24 hours. Stop if at limit.
66
70
 
@@ -123,7 +127,7 @@ Verify: fetch post by UUID, check `verification_status` is `"verified"`.
123
127
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
124
128
  thread_title, thread_content, our_url, our_content, our_account,
125
129
  source_summary, status, posted_at)
126
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
130
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
127
131
  ```
128
132
 
129
133
  Use the account value from `config.json` for `our_account`.
@@ -144,7 +148,7 @@ Max 1 original post per 24 hours. Max 3 per week.
144
148
 
145
149
  ```sql
146
150
  SELECT platform, thread_title, posted_at FROM posts
147
- WHERE source_summary LIKE '%' || ? || '%' AND posted_at >= datetime('now', '-30 days')
151
+ WHERE source_summary LIKE '%' || %s || '%' AND posted_at >= NOW() - INTERVAL '30 days'
148
152
  ORDER BY posted_at DESC;
149
153
  ```
150
154
 
@@ -180,7 +184,7 @@ Choose the single best subreddit from `config.json → subreddits` for this topi
180
184
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
181
185
  thread_title, thread_content, our_url, our_content, our_account,
182
186
  source_summary, status, posted_at)
183
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
187
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
184
188
  ```
185
189
 
186
190
  For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
@@ -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`)
@@ -223,8 +229,8 @@ Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strat
223
229
 
224
230
  Post via browser (Reddit/X) or API (Moltbook). Update:
225
231
  ```sql
226
- UPDATE replies SET status='replied', our_reply_content=?, our_reply_url=?,
227
- replied_at=datetime('now') WHERE id=?
232
+ UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
233
+ replied_at=NOW() WHERE id=%s
228
234
  ```
229
235
 
230
236
  ### Phase C: X/Twitter replies (browser required)
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" --db "$DB" 2>&1 | tee -a "$LOG_FILE" || true
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=$(sqlite3 "$DB" "SELECT COUNT(*) FROM replies WHERE status='pending';")
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
- sqlite3 -json "$DB" "
67
- SELECT r.id, r.platform, r.their_author, r.their_content, r.their_comment_url,
68
- r.their_comment_id, r.depth,
69
- p.thread_title, p.thread_url, p.our_content, p.our_url,
70
- CASE WHEN p.thread_url = p.our_url THEN 1 ELSE 0 END as is_our_original_post
71
- FROM replies r
72
- JOIN posts p ON r.post_id = p.id
73
- WHERE r.status='pending'
74
- ORDER BY
75
- CASE WHEN p.thread_url = p.our_url THEN 0 ELSE 1 END,
76
- r.discovered_at ASC;"
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=$(sqlite3 "$DB" "SELECT COUNT(*) FROM replies WHERE status='pending';")
92
- TOTAL_REPLIED=$(sqlite3 "$DB" "SELECT COUNT(*) FROM replies WHERE status='replied';")
93
- TOTAL_SKIPPED=$(sqlite3 "$DB" "SELECT COUNT(*) FROM replies WHERE status='skipped';")
94
- TOTAL_ERRORS=$(sqlite3 "$DB" "SELECT COUNT(*) FROM replies WHERE status='error';")
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" --db "$DB" --quiet 2>&1 | tee -a "$LOGFILE"
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" --db "$DB" 2>&1 | tee -a "$LOGFILE"
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')"