delimit-cli 3.14.43 → 3.14.44
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/package.json +5 -1
- package/gateway/ai/founding_users.py +0 -163
- package/gateway/ai/inbox_daemon.py +0 -684
- package/gateway/ai/social.py +0 -666
- package/gateway/ai/social_target.py +0 -1583
package/gateway/ai/social.py
DELETED
|
@@ -1,666 +0,0 @@
|
|
|
1
|
-
"""Automated social media — authentic engagement at scale.
|
|
2
|
-
|
|
3
|
-
Posts are value-first: tips, changelogs, governance insights.
|
|
4
|
-
Never spam, never generic marketing. Every post teaches something.
|
|
5
|
-
|
|
6
|
-
Multi-account support: credentials stored per handle in
|
|
7
|
-
~/.delimit/secrets/twitter-<handle>.json (e.g. twitter-delimit_ai.json).
|
|
8
|
-
Legacy twitter-full.json is treated as the default account.
|
|
9
|
-
"""
|
|
10
|
-
import json
|
|
11
|
-
import logging
|
|
12
|
-
import os
|
|
13
|
-
import random
|
|
14
|
-
import uuid
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from datetime import datetime, timezone
|
|
17
|
-
|
|
18
|
-
logger = logging.getLogger("delimit.ai.social")
|
|
19
|
-
|
|
20
|
-
SECRETS_DIR = Path.home() / ".delimit" / "secrets"
|
|
21
|
-
SOCIAL_LOG = Path.home() / ".delimit" / "social_log.jsonl"
|
|
22
|
-
DRAFTS_FILE = Path.home() / ".delimit" / "social_drafts.jsonl"
|
|
23
|
-
ACCOUNTS_FILE = SECRETS_DIR / "twitter-accounts.json"
|
|
24
|
-
|
|
25
|
-
# Platform-specific tone guidelines for draft generation
|
|
26
|
-
# Used by AI agents when drafting content — included in draft metadata
|
|
27
|
-
# so the agent (or human reviewer) can adjust tone before posting.
|
|
28
|
-
PLATFORM_TONE = {
|
|
29
|
-
"twitter": {
|
|
30
|
-
"max_length": 280,
|
|
31
|
-
"tone": "confident technical brand, direct, professional",
|
|
32
|
-
"identity": "@delimit_ai — verified, professional account",
|
|
33
|
-
"conversion_targets": [
|
|
34
|
-
"action: delimit-ai/delimit-action (GitHub Marketplace)",
|
|
35
|
-
"mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
|
|
36
|
-
"vscode: Delimit VS Code extension (Marketplace)",
|
|
37
|
-
"install: npx delimit-cli setup",
|
|
38
|
-
],
|
|
39
|
-
"rules": [
|
|
40
|
-
"ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain, never air gaps or insecurities publicly",
|
|
41
|
-
"Frame every metric as a win: '400 clones in 2 weeks' NOT '400 clones but 0 stars'",
|
|
42
|
-
"If there's a problem to solve, handle it internally — don't tweet about it",
|
|
43
|
-
"Confident technical brand voice — direct, clear, authoritative",
|
|
44
|
-
"NOT a Reddit voice — no rough edges, no typos, no 'typing on phone' energy",
|
|
45
|
-
"Proper capitalization and punctuation — we're a product account",
|
|
46
|
-
"Lead with the insight, not the product",
|
|
47
|
-
"One clear point per tweet",
|
|
48
|
-
"No em dashes or en dashes — most people can't type them, so they look AI-generated. Use commas, periods, or hyphens instead",
|
|
49
|
-
"Short punchy lines with line breaks for rhythm",
|
|
50
|
-
"Include a link or install command when relevant",
|
|
51
|
-
"Quote tweets: position Delimit relative to the quoted content, not as a generic response",
|
|
52
|
-
"Replies: be genuinely helpful or insightful, not just 'cool project!'",
|
|
53
|
-
],
|
|
54
|
-
},
|
|
55
|
-
"reddit": {
|
|
56
|
-
"max_length": 500,
|
|
57
|
-
"tone": "proud builder, genuinely helpful, never salesy",
|
|
58
|
-
"identity": "u/delimitdev — we're openly building Delimit, not hiding it",
|
|
59
|
-
"conversion_targets": [
|
|
60
|
-
"action: delimit-ai/delimit-action (GitHub Marketplace)",
|
|
61
|
-
"mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
|
|
62
|
-
"vscode: Delimit VS Code extension (Marketplace)",
|
|
63
|
-
],
|
|
64
|
-
"subreddit_angles": {
|
|
65
|
-
"r/vibecoding": {
|
|
66
|
-
"angle": "founder was a non-technical vibe coder who kept breaking things — built Delimit as the safety net",
|
|
67
|
-
"narrative": "'I was in your shoes' — fellow builder sharing what they made, not selling",
|
|
68
|
-
"best_for": "threads about stability, breaking things, fear of AI-introduced bugs, QA loops",
|
|
69
|
-
"conversion": "action or vscode",
|
|
70
|
-
},
|
|
71
|
-
"r/ClaudeAI": {
|
|
72
|
-
"angle": "MCP power user — shared context across models, agent governance",
|
|
73
|
-
"narrative": "deep practitioner who's built tooling on top of Claude's ecosystem",
|
|
74
|
-
"best_for": "threads about MCP, multi-model workflows, context loss, agent tooling",
|
|
75
|
-
"conversion": "mcp",
|
|
76
|
-
},
|
|
77
|
-
"r/devops": {
|
|
78
|
-
"angle": "CI/CD governance — catch breaking API changes before merge",
|
|
79
|
-
"narrative": "team that ships a GitHub Action for API compatibility checks",
|
|
80
|
-
"best_for": "threads about API versioning, breaking changes, CI pipelines, OpenAPI",
|
|
81
|
-
"conversion": "action",
|
|
82
|
-
},
|
|
83
|
-
"r/programming": {
|
|
84
|
-
"angle": "developer tooling builder — 27 breaking change types, one YAML line",
|
|
85
|
-
"narrative": "technical founder sharing what the engine detects and why",
|
|
86
|
-
"best_for": "threads about API design, backward compatibility, schema evolution",
|
|
87
|
-
"conversion": "action",
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
"rules": [
|
|
91
|
-
"We post as u/delimitdev — no need to hide that we build Delimit",
|
|
92
|
-
"Mention Delimit ONLY when it genuinely helps the person's problem",
|
|
93
|
-
"Be proud of our work, not promotional — 'we built X to solve this' not 'check out X!'",
|
|
94
|
-
"NEVER post unsolicited promos — only mention our tools in direct response to a real problem",
|
|
95
|
-
"If Delimit isn't relevant to the thread, just be helpful with no mention at all",
|
|
96
|
-
"Confident builder energy — proud of what we've shipped, not cocky or boastful",
|
|
97
|
-
"NEVER use bullet points or numbered lists — instant AI tell",
|
|
98
|
-
"Keep it to 2-3 sentences max, not multi-paragraph essays",
|
|
99
|
-
"Write like you're typing on your phone, not writing a blog post",
|
|
100
|
-
"Use contractions, sentence fragments, rough edges",
|
|
101
|
-
"One sharp point per comment — don't cover three topics",
|
|
102
|
-
"ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain or air gaps publicly",
|
|
103
|
-
"NEVER commiserate or self-deprecate — sound like someone who's figured it out",
|
|
104
|
-
"A typo or two is fine — too polished = flagged as AI by mods",
|
|
105
|
-
"NEVER use em dashes (—), en dashes (–), or fancy punctuation — use commas, 'and', or hyphens instead",
|
|
106
|
-
"NEVER use semicolons, ellipsis (…), or curly quotes — type like a human on a phone keyboard",
|
|
107
|
-
"NEVER structure responses with headers, bold text, or formatted lists",
|
|
108
|
-
"r/devops mods actively flag LLM-generated content (learned 2026-03-27)",
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
|
-
"linkedin": {
|
|
112
|
-
"max_length": 1300,
|
|
113
|
-
"tone": "professional, concise, insight-driven",
|
|
114
|
-
"rules": [
|
|
115
|
-
"Lead with a hook question or surprising stat",
|
|
116
|
-
"Keep paragraphs to 1-2 lines",
|
|
117
|
-
"End with a clear CTA or question",
|
|
118
|
-
],
|
|
119
|
-
},
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
# Content templates — each provides genuine value
|
|
123
|
-
CONTENT_TEMPLATES = {
|
|
124
|
-
"tip": [
|
|
125
|
-
"Tip: You can detect {count} types of breaking API changes with one line of YAML:\n\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n\nNo config needed. Advisory mode by default.",
|
|
126
|
-
"Did you know? When you switch from Claude Code to Codex, you lose all context. With a shared ledger, say \"what's on the ledger?\" in any assistant and pick up exactly where you left off.",
|
|
127
|
-
"API governance tip: The 3 most common breaking changes we catch:\n\n1. Endpoint removed without deprecation\n2. Required field added to request body\n3. Response field type changed\n\nAll detectable before merge.",
|
|
128
|
-
"Quick tip: Run `npx delimit-cli doctor` in any project to check your governance setup. It checks for policies, specs, workflows, and git config in seconds.",
|
|
129
|
-
"Pro tip: Use policy presets to match your team's risk tolerance:\n\n{bullet} strict — all violations are errors\n{bullet} default — balanced\n{bullet} relaxed — warnings only\n\n`npx delimit-cli init --preset strict`",
|
|
130
|
-
],
|
|
131
|
-
"changelog": [
|
|
132
|
-
"Just shipped: {feature}\n\n{detail}\n\nUpdate: npx delimit-cli@latest setup",
|
|
133
|
-
],
|
|
134
|
-
"insight": [
|
|
135
|
-
"We analyzed {count} API changes this week. {percent}% were breaking. The most common? {top_change}.\n\nAutomate this check: delimit.ai",
|
|
136
|
-
"Hot take: In 2 years, unmanaged AI agents touching production code will be as unacceptable as unmanaged SSH keys.\n\nGovernance isn't optional. It's infrastructure.",
|
|
137
|
-
"The problem with AI coding assistants isn't capability — it's context loss. Every time you switch models, you start from zero. That's the real productivity killer.",
|
|
138
|
-
],
|
|
139
|
-
"engagement": [
|
|
140
|
-
"What's the worst API breaking change you've shipped to production? We've seen some creative ones.",
|
|
141
|
-
"How many AI coding assistants does your team use? We're seeing teams average 2-3, with context scattered across all of them.",
|
|
142
|
-
"What's your API governance process today? Manual review? CI check? Nothing? (No judgment — that's why we built this.)",
|
|
143
|
-
],
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def _resolve_creds_path(account: str = "") -> Path | None:
|
|
148
|
-
"""Resolve credentials file for a given account handle.
|
|
149
|
-
|
|
150
|
-
Lookup order:
|
|
151
|
-
1. ~/.delimit/secrets/twitter-<account>.json (per-handle)
|
|
152
|
-
2. ~/.delimit/secrets/twitter-full.json (legacy default)
|
|
153
|
-
"""
|
|
154
|
-
if account:
|
|
155
|
-
per_handle = SECRETS_DIR / f"twitter-{account}.json"
|
|
156
|
-
if per_handle.exists():
|
|
157
|
-
return per_handle
|
|
158
|
-
# Legacy fallback
|
|
159
|
-
legacy = SECRETS_DIR / "twitter-full.json"
|
|
160
|
-
if legacy.exists():
|
|
161
|
-
return legacy
|
|
162
|
-
return None
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def get_twitter_client(account: str = ""):
|
|
166
|
-
"""Get authenticated Twitter client via tweepy for a specific account.
|
|
167
|
-
|
|
168
|
-
Returns:
|
|
169
|
-
Tuple of (client, handle, error). On success error is None.
|
|
170
|
-
On failure client and handle are None and error is a non-empty string
|
|
171
|
-
that distinguishes between "not configured" and "auth failed".
|
|
172
|
-
|
|
173
|
-
Args:
|
|
174
|
-
account: Twitter handle (without @). Empty string = default account.
|
|
175
|
-
"""
|
|
176
|
-
acct_label = account or "default"
|
|
177
|
-
creds_path = _resolve_creds_path(account)
|
|
178
|
-
if not creds_path:
|
|
179
|
-
configured = list_twitter_accounts()
|
|
180
|
-
if configured:
|
|
181
|
-
handles = [a["handle"] for a in configured]
|
|
182
|
-
return None, None, (
|
|
183
|
-
f"Account '{acct_label}' is not configured. "
|
|
184
|
-
f"Configured accounts: {handles}. "
|
|
185
|
-
f"Place credentials in ~/.delimit/secrets/twitter-{account}.json"
|
|
186
|
-
)
|
|
187
|
-
return None, None, (
|
|
188
|
-
f"No Twitter accounts configured. "
|
|
189
|
-
f"Place credentials in ~/.delimit/secrets/twitter-<handle>.json"
|
|
190
|
-
)
|
|
191
|
-
try:
|
|
192
|
-
import tweepy
|
|
193
|
-
creds = json.loads(creds_path.read_text())
|
|
194
|
-
client = tweepy.Client(
|
|
195
|
-
consumer_key=creds["consumer_key"],
|
|
196
|
-
consumer_secret=creds["consumer_secret"],
|
|
197
|
-
access_token=creds["access_token"],
|
|
198
|
-
access_token_secret=creds["access_token_secret"],
|
|
199
|
-
)
|
|
200
|
-
handle = creds.get("handle", account or "delimit_ai")
|
|
201
|
-
return client, handle, None
|
|
202
|
-
except KeyError as e:
|
|
203
|
-
msg = (
|
|
204
|
-
f"Account '{acct_label}' is configured ({creds_path.name}) "
|
|
205
|
-
f"but missing credential field {e}"
|
|
206
|
-
)
|
|
207
|
-
logger.error(msg)
|
|
208
|
-
return None, None, msg
|
|
209
|
-
except ImportError:
|
|
210
|
-
msg = "tweepy is not installed. Run: pip install tweepy"
|
|
211
|
-
logger.error(msg)
|
|
212
|
-
return None, None, msg
|
|
213
|
-
except json.JSONDecodeError as e:
|
|
214
|
-
msg = (
|
|
215
|
-
f"Account '{acct_label}' credentials file ({creds_path.name}) "
|
|
216
|
-
f"contains invalid JSON: {e}"
|
|
217
|
-
)
|
|
218
|
-
logger.error(msg)
|
|
219
|
-
return None, None, msg
|
|
220
|
-
except Exception as e:
|
|
221
|
-
msg = (
|
|
222
|
-
f"Account '{acct_label}' is configured ({creds_path.name}) "
|
|
223
|
-
f"but authentication failed: {e}"
|
|
224
|
-
)
|
|
225
|
-
logger.error(msg, exc_info=True)
|
|
226
|
-
return None, None, msg
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def list_twitter_accounts() -> list[dict]:
|
|
230
|
-
"""List all configured Twitter accounts, deduplicated by handle.
|
|
231
|
-
|
|
232
|
-
When multiple credential files resolve to the same handle,
|
|
233
|
-
the per-handle file (twitter-<handle>.json) wins over legacy files.
|
|
234
|
-
"""
|
|
235
|
-
accounts = []
|
|
236
|
-
seen_handles: set[str] = set()
|
|
237
|
-
if not SECRETS_DIR.exists():
|
|
238
|
-
return accounts
|
|
239
|
-
for f in sorted(SECRETS_DIR.glob("twitter-*.json")):
|
|
240
|
-
name = f.stem # e.g. "twitter-delimit_ai"
|
|
241
|
-
if name == "twitter-accounts":
|
|
242
|
-
continue
|
|
243
|
-
# Skip legacy twitter-full.json in this pass (handled below)
|
|
244
|
-
if name == "twitter-full":
|
|
245
|
-
continue
|
|
246
|
-
try:
|
|
247
|
-
creds = json.loads(f.read_text())
|
|
248
|
-
handle = creds.get("handle", name.removeprefix("twitter-"))
|
|
249
|
-
if handle in seen_handles:
|
|
250
|
-
continue
|
|
251
|
-
seen_handles.add(handle)
|
|
252
|
-
accounts.append({"handle": handle, "file": f.name})
|
|
253
|
-
except (json.JSONDecodeError, ValueError):
|
|
254
|
-
pass
|
|
255
|
-
# Include legacy twitter-full.json only if its handle is not already covered
|
|
256
|
-
legacy = SECRETS_DIR / "twitter-full.json"
|
|
257
|
-
if legacy.exists():
|
|
258
|
-
try:
|
|
259
|
-
creds = json.loads(legacy.read_text())
|
|
260
|
-
handle = creds.get("handle", "default")
|
|
261
|
-
if handle not in seen_handles:
|
|
262
|
-
seen_handles.add(handle)
|
|
263
|
-
accounts.append({"handle": handle, "file": "twitter-full.json", "default": True})
|
|
264
|
-
except (json.JSONDecodeError, ValueError):
|
|
265
|
-
pass
|
|
266
|
-
return accounts
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def post_tweet(text: str, account: str = "", quote_tweet_id: str = "",
|
|
270
|
-
reply_to_id: str = "") -> dict:
|
|
271
|
-
"""Post a tweet via the Twitter API.
|
|
272
|
-
|
|
273
|
-
Args:
|
|
274
|
-
text: Tweet text content.
|
|
275
|
-
account: Twitter handle (without @) to post from. Empty = default.
|
|
276
|
-
quote_tweet_id: Tweet ID to quote. Creates a quote tweet.
|
|
277
|
-
reply_to_id: Tweet ID to reply to. Creates a reply.
|
|
278
|
-
"""
|
|
279
|
-
client, handle, init_error = get_twitter_client(account)
|
|
280
|
-
if not client:
|
|
281
|
-
# Always return the specific error from get_twitter_client.
|
|
282
|
-
# Previous code fell through to a misleading "not found" message
|
|
283
|
-
# when init_error was empty, even though the account was configured.
|
|
284
|
-
if init_error:
|
|
285
|
-
return {"error": init_error}
|
|
286
|
-
# Fallback: should not be reachable, but be explicit
|
|
287
|
-
return {"error": f"Failed to initialize Twitter client for account '{account or 'default'}'. "
|
|
288
|
-
f"Check credentials in ~/.delimit/secrets/twitter-{account or 'full'}.json"}
|
|
289
|
-
try:
|
|
290
|
-
kwargs = {"text": text}
|
|
291
|
-
if quote_tweet_id:
|
|
292
|
-
kwargs["quote_tweet_id"] = quote_tweet_id
|
|
293
|
-
if reply_to_id:
|
|
294
|
-
kwargs["in_reply_to_tweet_id"] = reply_to_id
|
|
295
|
-
result = client.create_tweet(**kwargs)
|
|
296
|
-
tweet_id = result.data["id"]
|
|
297
|
-
log_post("twitter", text, tweet_id, handle=handle,
|
|
298
|
-
quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id)
|
|
299
|
-
return {
|
|
300
|
-
"posted": True,
|
|
301
|
-
"id": tweet_id,
|
|
302
|
-
"handle": handle,
|
|
303
|
-
"url": f"https://x.com/{handle}/status/{tweet_id}",
|
|
304
|
-
"type": "quote_tweet" if quote_tweet_id else "reply" if reply_to_id else "tweet",
|
|
305
|
-
}
|
|
306
|
-
except Exception as e:
|
|
307
|
-
return {"error": str(e), "handle": handle}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def generate_post(category: str = "", custom: str = "") -> dict:
|
|
311
|
-
"""Generate a post. If custom is provided, use that. Otherwise pick from templates."""
|
|
312
|
-
if custom:
|
|
313
|
-
return {"text": custom, "category": "custom"}
|
|
314
|
-
|
|
315
|
-
if not category or category not in CONTENT_TEMPLATES:
|
|
316
|
-
category = random.choice(list(CONTENT_TEMPLATES.keys()))
|
|
317
|
-
|
|
318
|
-
templates = CONTENT_TEMPLATES[category]
|
|
319
|
-
template = random.choice(templates)
|
|
320
|
-
|
|
321
|
-
# Fill in template variables with realistic data
|
|
322
|
-
text = template.format(
|
|
323
|
-
count=27,
|
|
324
|
-
percent=random.randint(15, 35),
|
|
325
|
-
top_change=random.choice([
|
|
326
|
-
"endpoint removed",
|
|
327
|
-
"type changed",
|
|
328
|
-
"required field added",
|
|
329
|
-
]),
|
|
330
|
-
feature="(specify feature name)",
|
|
331
|
-
detail="(specify feature details)",
|
|
332
|
-
bullet="\u2022",
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
return {"text": text, "category": category}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def get_post_history(limit: int = 20, platform: str = "",
|
|
339
|
-
user: str = "", subreddit: str = "") -> list:
|
|
340
|
-
"""Get recent post history from the JSONL log.
|
|
341
|
-
|
|
342
|
-
Args:
|
|
343
|
-
limit: Max entries to return.
|
|
344
|
-
platform: Filter by platform (e.g. "twitter", "reddit").
|
|
345
|
-
user: Filter by Reddit user we replied to (replying_to_user field).
|
|
346
|
-
subreddit: Filter by subreddit (e.g. "r/vibecoding").
|
|
347
|
-
"""
|
|
348
|
-
if not SOCIAL_LOG.exists():
|
|
349
|
-
return []
|
|
350
|
-
posts = []
|
|
351
|
-
for line in reversed(SOCIAL_LOG.read_text().splitlines()):
|
|
352
|
-
if not line.strip():
|
|
353
|
-
continue
|
|
354
|
-
try:
|
|
355
|
-
entry = json.loads(line)
|
|
356
|
-
except (json.JSONDecodeError, ValueError):
|
|
357
|
-
continue
|
|
358
|
-
# Apply filters
|
|
359
|
-
if platform and entry.get("platform") != platform:
|
|
360
|
-
continue
|
|
361
|
-
if user and user.lower() not in (entry.get("replying_to_user") or "").lower():
|
|
362
|
-
continue
|
|
363
|
-
if subreddit and subreddit.lower() not in (entry.get("subreddit") or "").lower():
|
|
364
|
-
continue
|
|
365
|
-
posts.append(entry)
|
|
366
|
-
if len(posts) >= limit:
|
|
367
|
-
break
|
|
368
|
-
return posts
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def log_post(platform: str, text: str, post_id: str = "", handle: str = "",
|
|
372
|
-
quote_tweet_id: str = "", reply_to_id: str = "",
|
|
373
|
-
subreddit: str = "", thread_url: str = "",
|
|
374
|
-
thread_title: str = "", replying_to_user: str = "",
|
|
375
|
-
conversion_target: str = ""):
|
|
376
|
-
"""Log a social media post to the JSONL log.
|
|
377
|
-
|
|
378
|
-
For Reddit comments, include subreddit, thread context, and the user
|
|
379
|
-
being replied to so we can recall full conversation threads later.
|
|
380
|
-
"""
|
|
381
|
-
SOCIAL_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
382
|
-
entry = {
|
|
383
|
-
"ts": datetime.now(timezone.utc).isoformat(),
|
|
384
|
-
"platform": platform,
|
|
385
|
-
"handle": handle,
|
|
386
|
-
"text": text[:500] if platform == "reddit" else text[:200],
|
|
387
|
-
"post_id": post_id,
|
|
388
|
-
}
|
|
389
|
-
if quote_tweet_id:
|
|
390
|
-
entry["quote_tweet_id"] = quote_tweet_id
|
|
391
|
-
if reply_to_id:
|
|
392
|
-
entry["reply_to_id"] = reply_to_id
|
|
393
|
-
# Reddit-specific fields
|
|
394
|
-
if subreddit:
|
|
395
|
-
entry["subreddit"] = subreddit
|
|
396
|
-
if thread_url:
|
|
397
|
-
entry["thread_url"] = thread_url
|
|
398
|
-
if thread_title:
|
|
399
|
-
entry["thread_title"] = thread_title
|
|
400
|
-
if replying_to_user:
|
|
401
|
-
entry["replying_to_user"] = replying_to_user
|
|
402
|
-
if conversion_target:
|
|
403
|
-
entry["conversion_target"] = conversion_target
|
|
404
|
-
with open(SOCIAL_LOG, "a") as f:
|
|
405
|
-
f.write(json.dumps(entry) + "\n")
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def should_post_today() -> bool:
|
|
409
|
-
"""Check if we've hit the daily posting limit.
|
|
410
|
-
|
|
411
|
-
Limit is configurable via DELIMIT_DAILY_TWEETS env var (default 8).
|
|
412
|
-
Uses US Eastern Time for day boundaries since the posting schedule
|
|
413
|
-
targets 9am/3pm ET.
|
|
414
|
-
"""
|
|
415
|
-
from zoneinfo import ZoneInfo
|
|
416
|
-
|
|
417
|
-
daily_limit = int(os.environ.get("DELIMIT_DAILY_TWEETS", "8"))
|
|
418
|
-
et_now = datetime.now(ZoneInfo("America/New_York"))
|
|
419
|
-
today_et = et_now.strftime("%Y-%m-%d")
|
|
420
|
-
history = get_post_history(100)
|
|
421
|
-
today_posts = []
|
|
422
|
-
for p in history:
|
|
423
|
-
ts_str = p.get("ts", "")
|
|
424
|
-
if ts_str:
|
|
425
|
-
try:
|
|
426
|
-
ts = datetime.fromisoformat(ts_str).astimezone(ZoneInfo("America/New_York"))
|
|
427
|
-
if ts.strftime("%Y-%m-%d") == today_et:
|
|
428
|
-
today_posts.append(p)
|
|
429
|
-
except (ValueError, TypeError):
|
|
430
|
-
continue
|
|
431
|
-
return len(today_posts) < daily_limit
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
# ═════════════════════════════════════════════════════════════════════
|
|
435
|
-
# DRAFT MODE — Queue content for review before posting
|
|
436
|
-
# ═════════════════════════════════════════════════════════════════════
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def get_platform_tone(platform: str = "twitter") -> dict:
|
|
440
|
-
"""Return tone guidelines for a platform.
|
|
441
|
-
|
|
442
|
-
AI agents should call this before drafting content to get
|
|
443
|
-
platform-specific rules for voice, length, and formatting.
|
|
444
|
-
"""
|
|
445
|
-
return PLATFORM_TONE.get(platform, PLATFORM_TONE.get("twitter", {}))
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
449
|
-
quote_tweet_id: str = "", reply_to_id: str = "",
|
|
450
|
-
conversion_target: str = "", thread_url: str = "",
|
|
451
|
-
context: str = "") -> dict:
|
|
452
|
-
"""Save a social media post as a draft for later approval.
|
|
453
|
-
|
|
454
|
-
Returns the draft entry with a unique draft_id and platform tone guidelines.
|
|
455
|
-
|
|
456
|
-
Args:
|
|
457
|
-
conversion_target: For Reddit — "action", "mcp", "vscode", or "" (no promo, just helpful).
|
|
458
|
-
thread_url: URL of the Reddit thread being replied to.
|
|
459
|
-
context: WHY this post should be made — strategic reasoning shown in the email.
|
|
460
|
-
"""
|
|
461
|
-
DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
462
|
-
draft_id = uuid.uuid4().hex[:12]
|
|
463
|
-
tone = get_platform_tone(platform)
|
|
464
|
-
entry = {
|
|
465
|
-
"draft_id": draft_id,
|
|
466
|
-
"text": text,
|
|
467
|
-
"platform": platform,
|
|
468
|
-
"account": account,
|
|
469
|
-
"quote_tweet_id": quote_tweet_id,
|
|
470
|
-
"reply_to_id": reply_to_id,
|
|
471
|
-
"conversion_target": conversion_target,
|
|
472
|
-
"thread_url": thread_url,
|
|
473
|
-
"context": context,
|
|
474
|
-
"status": "pending",
|
|
475
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
476
|
-
}
|
|
477
|
-
# Check tone violations
|
|
478
|
-
warnings = []
|
|
479
|
-
if tone.get("max_length") and len(text) > tone["max_length"]:
|
|
480
|
-
warnings.append(f"Text exceeds {platform} max length ({len(text)}/{tone['max_length']})")
|
|
481
|
-
# Fancy AI punctuation checks — applies to ALL platforms
|
|
482
|
-
# Most people can't type em dashes, curly quotes, etc. on a keyboard, so they look AI-generated
|
|
483
|
-
_fancy_chars = {
|
|
484
|
-
"\u2014": "em dash",
|
|
485
|
-
"\u2013": "en dash",
|
|
486
|
-
"\u2026": "ellipsis (...)",
|
|
487
|
-
"\u201c": "curly left quote",
|
|
488
|
-
"\u201d": "curly right quote",
|
|
489
|
-
"\u2018": "curly left single quote",
|
|
490
|
-
"\u2019": "curly right single quote",
|
|
491
|
-
}
|
|
492
|
-
_found_fancy = [name for char, name in _fancy_chars.items() if char in text]
|
|
493
|
-
if _found_fancy:
|
|
494
|
-
warnings.append(f"AI TELL WARNING: Fancy punctuation detected: {', '.join(_found_fancy)} — use plain keyboard characters only")
|
|
495
|
-
# Negativity check — applies to ALL platforms
|
|
496
|
-
_lower_text = text.lower()
|
|
497
|
-
_negative_patterns = [
|
|
498
|
-
"zero stars", "no stars", "0 stars", "nobody cared",
|
|
499
|
-
"no one noticed", "nobody noticed", "crickets",
|
|
500
|
-
"the challenge is", "the problem is", "the hard part is",
|
|
501
|
-
"but zero", "but no one", "but nobody",
|
|
502
|
-
"struggling to", "failing to", "can't seem to",
|
|
503
|
-
"not working", "isn't working",
|
|
504
|
-
]
|
|
505
|
-
if any(p in _lower_text for p in _negative_patterns):
|
|
506
|
-
warnings.append("NEGATIVITY WARNING: Post sounds negative or self-defeating. Reframe as a win or celebration. If there's a problem, handle it internally — don't tweet about it.")
|
|
507
|
-
if platform == "reddit":
|
|
508
|
-
if any(line.strip().startswith(("- ", "* ", "1.", "2.", "3.")) for line in text.split("\n")):
|
|
509
|
-
warnings.append("REDDIT WARNING: Contains bullet/numbered lists — high risk of mod removal as AI content")
|
|
510
|
-
if text.count("\n\n") >= 3:
|
|
511
|
-
warnings.append("REDDIT WARNING: Multi-paragraph essay format — shorten to 2-3 sentences")
|
|
512
|
-
if "**" in text:
|
|
513
|
-
warnings.append("REDDIT WARNING: Contains bold formatting — too polished for Reddit")
|
|
514
|
-
if ";" in text:
|
|
515
|
-
warnings.append("REDDIT WARNING: Contains semicolon — too formal, use a comma or period instead")
|
|
516
|
-
# Self-deprecating / commiserating tone check
|
|
517
|
-
_lower = text.lower()
|
|
518
|
-
_commiserate_patterns = [
|
|
519
|
-
"same issue here", "i've been hitting", "i struggle with",
|
|
520
|
-
"yeah i have this", "me too", "same problem",
|
|
521
|
-
"i've been dealing with", "drives me nuts too",
|
|
522
|
-
"i'm stuck on", "can't figure out", "been struggling",
|
|
523
|
-
]
|
|
524
|
-
if any(p in _lower for p in _commiserate_patterns):
|
|
525
|
-
warnings.append("REDDIT WARNING: Self-deprecating/commiserating tone detected — rewrite with confident practitioner voice")
|
|
526
|
-
# Unsolicited promo check — mention Delimit only when genuinely helpful
|
|
527
|
-
_promo_patterns = [
|
|
528
|
-
"check out", "you should try", "give it a try",
|
|
529
|
-
"we just launched", "just shipped", "shameless plug",
|
|
530
|
-
"i'd recommend delimit", "you need delimit",
|
|
531
|
-
]
|
|
532
|
-
_mentions_delimit = "delimit" in _lower
|
|
533
|
-
_is_salesy = any(p in _lower for p in _promo_patterns)
|
|
534
|
-
if _mentions_delimit and _is_salesy:
|
|
535
|
-
warnings.append("REDDIT WARNING: Looks like an unsolicited promo — mention Delimit only in direct response to a real problem, never as a pitch")
|
|
536
|
-
if _mentions_delimit and not conversion_target:
|
|
537
|
-
warnings.append("REDDIT NOTE: Mentions Delimit but no conversion_target set — specify 'action', 'mcp', or 'vscode' so the email shows the funnel intent")
|
|
538
|
-
if warnings:
|
|
539
|
-
entry["tone_warnings"] = warnings
|
|
540
|
-
with open(DRAFTS_FILE, "a") as f:
|
|
541
|
-
f.write(json.dumps(entry) + "\n")
|
|
542
|
-
return entry
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def store_draft_message_id(draft_id: str, message_id: str) -> bool:
|
|
546
|
-
"""Store the outbound notification Message-ID on a draft record.
|
|
547
|
-
|
|
548
|
-
This enables In-Reply-To header matching for auto-approval via the
|
|
549
|
-
inbox polling daemon (Consensus 116).
|
|
550
|
-
|
|
551
|
-
Args:
|
|
552
|
-
draft_id: The 12-char hex draft ID.
|
|
553
|
-
message_id: The Message-ID header from the sent notification email.
|
|
554
|
-
|
|
555
|
-
Returns:
|
|
556
|
-
True if the draft was found and updated, False otherwise.
|
|
557
|
-
"""
|
|
558
|
-
all_entries = _load_all_drafts()
|
|
559
|
-
for entry in all_entries:
|
|
560
|
-
if entry.get("draft_id") == draft_id:
|
|
561
|
-
entry["notification_message_id"] = message_id
|
|
562
|
-
_rewrite_drafts(all_entries)
|
|
563
|
-
return True
|
|
564
|
-
return False
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def list_drafts(status: str = "pending") -> list[dict]:
|
|
568
|
-
"""List drafts filtered by status (pending, approved, rejected)."""
|
|
569
|
-
if not DRAFTS_FILE.exists():
|
|
570
|
-
return []
|
|
571
|
-
drafts = []
|
|
572
|
-
for line in DRAFTS_FILE.read_text().splitlines():
|
|
573
|
-
if not line.strip():
|
|
574
|
-
continue
|
|
575
|
-
try:
|
|
576
|
-
entry = json.loads(line)
|
|
577
|
-
if entry.get("status") == status:
|
|
578
|
-
drafts.append(entry)
|
|
579
|
-
except (json.JSONDecodeError, ValueError):
|
|
580
|
-
pass
|
|
581
|
-
return drafts
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def _rewrite_drafts(all_entries: list[dict]) -> None:
|
|
585
|
-
"""Rewrite the drafts file with updated entries."""
|
|
586
|
-
DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
587
|
-
with open(DRAFTS_FILE, "w") as f:
|
|
588
|
-
for entry in all_entries:
|
|
589
|
-
f.write(json.dumps(entry) + "\n")
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
def _load_all_drafts() -> list[dict]:
|
|
593
|
-
"""Load all draft entries from the JSONL file."""
|
|
594
|
-
if not DRAFTS_FILE.exists():
|
|
595
|
-
return []
|
|
596
|
-
entries = []
|
|
597
|
-
for line in DRAFTS_FILE.read_text().splitlines():
|
|
598
|
-
if not line.strip():
|
|
599
|
-
continue
|
|
600
|
-
try:
|
|
601
|
-
entries.append(json.loads(line))
|
|
602
|
-
except (json.JSONDecodeError, ValueError):
|
|
603
|
-
pass
|
|
604
|
-
return entries
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
def approve_draft(draft_id: str) -> dict:
|
|
608
|
-
"""Approve a draft — marks it approved and emails the final text to the founder.
|
|
609
|
-
|
|
610
|
-
Auto-posting via Twitter API is disabled. Founder posts manually from their device.
|
|
611
|
-
"""
|
|
612
|
-
all_entries = _load_all_drafts()
|
|
613
|
-
target = None
|
|
614
|
-
for entry in all_entries:
|
|
615
|
-
if entry.get("draft_id") == draft_id:
|
|
616
|
-
target = entry
|
|
617
|
-
break
|
|
618
|
-
if not target:
|
|
619
|
-
return {"error": f"Draft '{draft_id}' not found"}
|
|
620
|
-
if target.get("status") != "pending":
|
|
621
|
-
return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
|
|
622
|
-
|
|
623
|
-
# Mark approved but do NOT auto-post — email to founder for manual posting
|
|
624
|
-
target["status"] = "approved"
|
|
625
|
-
target["approved_at"] = datetime.now(timezone.utc).isoformat()
|
|
626
|
-
_rewrite_drafts(all_entries)
|
|
627
|
-
|
|
628
|
-
# Email the approved text for manual posting
|
|
629
|
-
try:
|
|
630
|
-
from ai.notify import send_email
|
|
631
|
-
qt = target.get("quote_tweet_id", "")
|
|
632
|
-
rt = target.get("reply_to_id", "")
|
|
633
|
-
context_lines = []
|
|
634
|
-
if qt:
|
|
635
|
-
context_lines.append(f"Quote tweet: https://x.com/i/status/{qt}")
|
|
636
|
-
if rt:
|
|
637
|
-
context_lines.append(f"Reply to: https://x.com/i/status/{rt}")
|
|
638
|
-
context = "\n".join(context_lines)
|
|
639
|
-
body = f"APPROVED — post this manually:\n\n---\n{target['text']}\n---\n\n{context}"
|
|
640
|
-
send_email(
|
|
641
|
-
subject=f"APPROVED X Post: {draft_id}",
|
|
642
|
-
body=body,
|
|
643
|
-
)
|
|
644
|
-
except Exception:
|
|
645
|
-
pass
|
|
646
|
-
|
|
647
|
-
return {"draft_id": draft_id, "status": "approved", "mode": "manual_post", "message": "Emailed to founder for manual posting. Auto-posting is disabled."}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
def reject_draft(draft_id: str) -> dict:
|
|
651
|
-
"""Reject a draft. It will not be posted."""
|
|
652
|
-
all_entries = _load_all_drafts()
|
|
653
|
-
target = None
|
|
654
|
-
for entry in all_entries:
|
|
655
|
-
if entry.get("draft_id") == draft_id:
|
|
656
|
-
target = entry
|
|
657
|
-
break
|
|
658
|
-
if not target:
|
|
659
|
-
return {"error": f"Draft '{draft_id}' not found"}
|
|
660
|
-
if target.get("status") != "pending":
|
|
661
|
-
return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
|
|
662
|
-
|
|
663
|
-
target["status"] = "rejected"
|
|
664
|
-
target["rejected_at"] = datetime.now(timezone.utc).isoformat()
|
|
665
|
-
_rewrite_drafts(all_entries)
|
|
666
|
-
return {"draft_id": draft_id, "status": "rejected"}
|