delimit-cli 3.13.2 → 3.14.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/CHANGELOG.md +5 -0
- package/bin/delimit-cli.js +139 -1
- package/bin/delimit-setup.js +10 -4
- package/gateway/ai/inbox_daemon.py +623 -0
- package/gateway/ai/ledger_manager.py +88 -19
- package/gateway/ai/notify.py +975 -0
- package/gateway/ai/server.py +3570 -426
- package/gateway/ai/social.py +504 -0
- package/gateway/ai/tool_metadata.py +201 -0
- package/lib/cross-model-hooks.js +173 -43
- package/package.json +1 -1
- package/scripts/crosspost_devto.py +304 -0
- package/scripts/postinstall.js +13 -2
|
@@ -0,0 +1,504 @@
|
|
|
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": "concise, technical, value-first",
|
|
32
|
+
"rules": [
|
|
33
|
+
"Lead with the insight, not the product",
|
|
34
|
+
"One clear point per tweet",
|
|
35
|
+
"Include a link or install command when relevant",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
"reddit": {
|
|
39
|
+
"max_length": 500,
|
|
40
|
+
"tone": "casual, opinionated, human",
|
|
41
|
+
"rules": [
|
|
42
|
+
"NEVER use bullet points or numbered lists — instant AI tell",
|
|
43
|
+
"Keep it to 2-3 sentences max, not multi-paragraph essays",
|
|
44
|
+
"Write like you're typing on your phone, not writing a blog post",
|
|
45
|
+
"Be opinionated — 'honestly X drives me nuts' not 'X is a known issue'",
|
|
46
|
+
"Use contractions, sentence fragments, rough edges",
|
|
47
|
+
"One sharp point per comment — don't cover three topics",
|
|
48
|
+
"Personal experience framing — 'we hit this exact problem' not 'teams often encounter'",
|
|
49
|
+
"A typo or two is fine — too polished = flagged as AI by mods",
|
|
50
|
+
"NEVER structure responses with headers, bold text, or formatted lists",
|
|
51
|
+
"r/devops mods actively flag LLM-generated content (learned 2026-03-27)",
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
"linkedin": {
|
|
55
|
+
"max_length": 1300,
|
|
56
|
+
"tone": "professional, concise, insight-driven",
|
|
57
|
+
"rules": [
|
|
58
|
+
"Lead with a hook question or surprising stat",
|
|
59
|
+
"Keep paragraphs to 1-2 lines",
|
|
60
|
+
"End with a clear CTA or question",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Content templates — each provides genuine value
|
|
66
|
+
CONTENT_TEMPLATES = {
|
|
67
|
+
"tip": [
|
|
68
|
+
"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.",
|
|
69
|
+
"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.",
|
|
70
|
+
"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.",
|
|
71
|
+
"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.",
|
|
72
|
+
"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`",
|
|
73
|
+
],
|
|
74
|
+
"changelog": [
|
|
75
|
+
"Just shipped: {feature}\n\n{detail}\n\nUpdate: npx delimit-cli@latest setup",
|
|
76
|
+
],
|
|
77
|
+
"insight": [
|
|
78
|
+
"We analyzed {count} API changes this week. {percent}% were breaking. The most common? {top_change}.\n\nAutomate this check: delimit.ai",
|
|
79
|
+
"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.",
|
|
80
|
+
"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.",
|
|
81
|
+
],
|
|
82
|
+
"engagement": [
|
|
83
|
+
"What's the worst API breaking change you've shipped to production? We've seen some creative ones.",
|
|
84
|
+
"How many AI coding assistants does your team use? We're seeing teams average 2-3, with context scattered across all of them.",
|
|
85
|
+
"What's your API governance process today? Manual review? CI check? Nothing? (No judgment — that's why we built this.)",
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_creds_path(account: str = "") -> Path | None:
|
|
91
|
+
"""Resolve credentials file for a given account handle.
|
|
92
|
+
|
|
93
|
+
Lookup order:
|
|
94
|
+
1. ~/.delimit/secrets/twitter-<account>.json (per-handle)
|
|
95
|
+
2. ~/.delimit/secrets/twitter-full.json (legacy default)
|
|
96
|
+
"""
|
|
97
|
+
if account:
|
|
98
|
+
per_handle = SECRETS_DIR / f"twitter-{account}.json"
|
|
99
|
+
if per_handle.exists():
|
|
100
|
+
return per_handle
|
|
101
|
+
# Legacy fallback
|
|
102
|
+
legacy = SECRETS_DIR / "twitter-full.json"
|
|
103
|
+
if legacy.exists():
|
|
104
|
+
return legacy
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_twitter_client(account: str = ""):
|
|
109
|
+
"""Get authenticated Twitter client via tweepy for a specific account.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (client, handle, error). On success error is None.
|
|
113
|
+
On failure client and handle are None and error is a non-empty string
|
|
114
|
+
that distinguishes between "not configured" and "auth failed".
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
account: Twitter handle (without @). Empty string = default account.
|
|
118
|
+
"""
|
|
119
|
+
acct_label = account or "default"
|
|
120
|
+
creds_path = _resolve_creds_path(account)
|
|
121
|
+
if not creds_path:
|
|
122
|
+
configured = list_twitter_accounts()
|
|
123
|
+
if configured:
|
|
124
|
+
handles = [a["handle"] for a in configured]
|
|
125
|
+
return None, None, (
|
|
126
|
+
f"Account '{acct_label}' is not configured. "
|
|
127
|
+
f"Configured accounts: {handles}. "
|
|
128
|
+
f"Place credentials in ~/.delimit/secrets/twitter-{account}.json"
|
|
129
|
+
)
|
|
130
|
+
return None, None, (
|
|
131
|
+
f"No Twitter accounts configured. "
|
|
132
|
+
f"Place credentials in ~/.delimit/secrets/twitter-<handle>.json"
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
import tweepy
|
|
136
|
+
creds = json.loads(creds_path.read_text())
|
|
137
|
+
client = tweepy.Client(
|
|
138
|
+
consumer_key=creds["consumer_key"],
|
|
139
|
+
consumer_secret=creds["consumer_secret"],
|
|
140
|
+
access_token=creds["access_token"],
|
|
141
|
+
access_token_secret=creds["access_token_secret"],
|
|
142
|
+
)
|
|
143
|
+
handle = creds.get("handle", account or "delimit_ai")
|
|
144
|
+
return client, handle, None
|
|
145
|
+
except KeyError as e:
|
|
146
|
+
msg = (
|
|
147
|
+
f"Account '{acct_label}' is configured ({creds_path.name}) "
|
|
148
|
+
f"but missing credential field {e}"
|
|
149
|
+
)
|
|
150
|
+
logger.error(msg)
|
|
151
|
+
return None, None, msg
|
|
152
|
+
except ImportError:
|
|
153
|
+
msg = "tweepy is not installed. Run: pip install tweepy"
|
|
154
|
+
logger.error(msg)
|
|
155
|
+
return None, None, msg
|
|
156
|
+
except json.JSONDecodeError as e:
|
|
157
|
+
msg = (
|
|
158
|
+
f"Account '{acct_label}' credentials file ({creds_path.name}) "
|
|
159
|
+
f"contains invalid JSON: {e}"
|
|
160
|
+
)
|
|
161
|
+
logger.error(msg)
|
|
162
|
+
return None, None, msg
|
|
163
|
+
except Exception as e:
|
|
164
|
+
msg = (
|
|
165
|
+
f"Account '{acct_label}' is configured ({creds_path.name}) "
|
|
166
|
+
f"but authentication failed: {e}"
|
|
167
|
+
)
|
|
168
|
+
logger.error(msg, exc_info=True)
|
|
169
|
+
return None, None, msg
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def list_twitter_accounts() -> list[dict]:
|
|
173
|
+
"""List all configured Twitter accounts, deduplicated by handle.
|
|
174
|
+
|
|
175
|
+
When multiple credential files resolve to the same handle,
|
|
176
|
+
the per-handle file (twitter-<handle>.json) wins over legacy files.
|
|
177
|
+
"""
|
|
178
|
+
accounts = []
|
|
179
|
+
seen_handles: set[str] = set()
|
|
180
|
+
if not SECRETS_DIR.exists():
|
|
181
|
+
return accounts
|
|
182
|
+
for f in sorted(SECRETS_DIR.glob("twitter-*.json")):
|
|
183
|
+
name = f.stem # e.g. "twitter-delimit_ai"
|
|
184
|
+
if name == "twitter-accounts":
|
|
185
|
+
continue
|
|
186
|
+
# Skip legacy twitter-full.json in this pass (handled below)
|
|
187
|
+
if name == "twitter-full":
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
creds = json.loads(f.read_text())
|
|
191
|
+
handle = creds.get("handle", name.removeprefix("twitter-"))
|
|
192
|
+
if handle in seen_handles:
|
|
193
|
+
continue
|
|
194
|
+
seen_handles.add(handle)
|
|
195
|
+
accounts.append({"handle": handle, "file": f.name})
|
|
196
|
+
except (json.JSONDecodeError, ValueError):
|
|
197
|
+
pass
|
|
198
|
+
# Include legacy twitter-full.json only if its handle is not already covered
|
|
199
|
+
legacy = SECRETS_DIR / "twitter-full.json"
|
|
200
|
+
if legacy.exists():
|
|
201
|
+
try:
|
|
202
|
+
creds = json.loads(legacy.read_text())
|
|
203
|
+
handle = creds.get("handle", "default")
|
|
204
|
+
if handle not in seen_handles:
|
|
205
|
+
seen_handles.add(handle)
|
|
206
|
+
accounts.append({"handle": handle, "file": "twitter-full.json", "default": True})
|
|
207
|
+
except (json.JSONDecodeError, ValueError):
|
|
208
|
+
pass
|
|
209
|
+
return accounts
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def post_tweet(text: str, account: str = "", quote_tweet_id: str = "",
|
|
213
|
+
reply_to_id: str = "") -> dict:
|
|
214
|
+
"""Post a tweet via the Twitter API.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
text: Tweet text content.
|
|
218
|
+
account: Twitter handle (without @) to post from. Empty = default.
|
|
219
|
+
quote_tweet_id: Tweet ID to quote. Creates a quote tweet.
|
|
220
|
+
reply_to_id: Tweet ID to reply to. Creates a reply.
|
|
221
|
+
"""
|
|
222
|
+
client, handle, init_error = get_twitter_client(account)
|
|
223
|
+
if not client:
|
|
224
|
+
# Always return the specific error from get_twitter_client.
|
|
225
|
+
# Previous code fell through to a misleading "not found" message
|
|
226
|
+
# when init_error was empty, even though the account was configured.
|
|
227
|
+
if init_error:
|
|
228
|
+
return {"error": init_error}
|
|
229
|
+
# Fallback: should not be reachable, but be explicit
|
|
230
|
+
return {"error": f"Failed to initialize Twitter client for account '{account or 'default'}'. "
|
|
231
|
+
f"Check credentials in ~/.delimit/secrets/twitter-{account or 'full'}.json"}
|
|
232
|
+
try:
|
|
233
|
+
kwargs = {"text": text}
|
|
234
|
+
if quote_tweet_id:
|
|
235
|
+
kwargs["quote_tweet_id"] = quote_tweet_id
|
|
236
|
+
if reply_to_id:
|
|
237
|
+
kwargs["in_reply_to_tweet_id"] = reply_to_id
|
|
238
|
+
result = client.create_tweet(**kwargs)
|
|
239
|
+
tweet_id = result.data["id"]
|
|
240
|
+
log_post("twitter", text, tweet_id, handle=handle,
|
|
241
|
+
quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id)
|
|
242
|
+
return {
|
|
243
|
+
"posted": True,
|
|
244
|
+
"id": tweet_id,
|
|
245
|
+
"handle": handle,
|
|
246
|
+
"url": f"https://x.com/{handle}/status/{tweet_id}",
|
|
247
|
+
"type": "quote_tweet" if quote_tweet_id else "reply" if reply_to_id else "tweet",
|
|
248
|
+
}
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return {"error": str(e), "handle": handle}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def generate_post(category: str = "", custom: str = "") -> dict:
|
|
254
|
+
"""Generate a post. If custom is provided, use that. Otherwise pick from templates."""
|
|
255
|
+
if custom:
|
|
256
|
+
return {"text": custom, "category": "custom"}
|
|
257
|
+
|
|
258
|
+
if not category or category not in CONTENT_TEMPLATES:
|
|
259
|
+
category = random.choice(list(CONTENT_TEMPLATES.keys()))
|
|
260
|
+
|
|
261
|
+
templates = CONTENT_TEMPLATES[category]
|
|
262
|
+
template = random.choice(templates)
|
|
263
|
+
|
|
264
|
+
# Fill in template variables with realistic data
|
|
265
|
+
text = template.format(
|
|
266
|
+
count=27,
|
|
267
|
+
percent=random.randint(15, 35),
|
|
268
|
+
top_change=random.choice([
|
|
269
|
+
"endpoint removed",
|
|
270
|
+
"type changed",
|
|
271
|
+
"required field added",
|
|
272
|
+
]),
|
|
273
|
+
feature="(specify feature name)",
|
|
274
|
+
detail="(specify feature details)",
|
|
275
|
+
bullet="\u2022",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return {"text": text, "category": category}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_post_history(limit: int = 20) -> list:
|
|
282
|
+
"""Get recent post history from the JSONL log."""
|
|
283
|
+
if not SOCIAL_LOG.exists():
|
|
284
|
+
return []
|
|
285
|
+
posts = []
|
|
286
|
+
for line in reversed(SOCIAL_LOG.read_text().splitlines()):
|
|
287
|
+
if not line.strip():
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
posts.append(json.loads(line))
|
|
291
|
+
except (json.JSONDecodeError, ValueError):
|
|
292
|
+
pass
|
|
293
|
+
if len(posts) >= limit:
|
|
294
|
+
break
|
|
295
|
+
return posts
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def log_post(platform: str, text: str, post_id: str = "", handle: str = "",
|
|
299
|
+
quote_tweet_id: str = "", reply_to_id: str = ""):
|
|
300
|
+
"""Log a social media post to the JSONL log."""
|
|
301
|
+
SOCIAL_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
entry = {
|
|
303
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
304
|
+
"platform": platform,
|
|
305
|
+
"handle": handle,
|
|
306
|
+
"text": text[:200],
|
|
307
|
+
"post_id": post_id,
|
|
308
|
+
}
|
|
309
|
+
if quote_tweet_id:
|
|
310
|
+
entry["quote_tweet_id"] = quote_tweet_id
|
|
311
|
+
if reply_to_id:
|
|
312
|
+
entry["reply_to_id"] = reply_to_id
|
|
313
|
+
with open(SOCIAL_LOG, "a") as f:
|
|
314
|
+
f.write(json.dumps(entry) + "\n")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def should_post_today() -> bool:
|
|
318
|
+
"""Check if we've hit the daily posting limit.
|
|
319
|
+
|
|
320
|
+
Limit is configurable via DELIMIT_DAILY_TWEETS env var (default 8).
|
|
321
|
+
Uses US Eastern Time for day boundaries since the posting schedule
|
|
322
|
+
targets 9am/3pm ET.
|
|
323
|
+
"""
|
|
324
|
+
from zoneinfo import ZoneInfo
|
|
325
|
+
|
|
326
|
+
daily_limit = int(os.environ.get("DELIMIT_DAILY_TWEETS", "8"))
|
|
327
|
+
et_now = datetime.now(ZoneInfo("America/New_York"))
|
|
328
|
+
today_et = et_now.strftime("%Y-%m-%d")
|
|
329
|
+
history = get_post_history(100)
|
|
330
|
+
today_posts = []
|
|
331
|
+
for p in history:
|
|
332
|
+
ts_str = p.get("ts", "")
|
|
333
|
+
if ts_str:
|
|
334
|
+
try:
|
|
335
|
+
ts = datetime.fromisoformat(ts_str).astimezone(ZoneInfo("America/New_York"))
|
|
336
|
+
if ts.strftime("%Y-%m-%d") == today_et:
|
|
337
|
+
today_posts.append(p)
|
|
338
|
+
except (ValueError, TypeError):
|
|
339
|
+
continue
|
|
340
|
+
return len(today_posts) < daily_limit
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
344
|
+
# DRAFT MODE — Queue content for review before posting
|
|
345
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_platform_tone(platform: str = "twitter") -> dict:
|
|
349
|
+
"""Return tone guidelines for a platform.
|
|
350
|
+
|
|
351
|
+
AI agents should call this before drafting content to get
|
|
352
|
+
platform-specific rules for voice, length, and formatting.
|
|
353
|
+
"""
|
|
354
|
+
return PLATFORM_TONE.get(platform, PLATFORM_TONE.get("twitter", {}))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
358
|
+
quote_tweet_id: str = "", reply_to_id: str = "") -> dict:
|
|
359
|
+
"""Save a social media post as a draft for later approval.
|
|
360
|
+
|
|
361
|
+
Returns the draft entry with a unique draft_id and platform tone guidelines.
|
|
362
|
+
"""
|
|
363
|
+
DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
draft_id = uuid.uuid4().hex[:12]
|
|
365
|
+
tone = get_platform_tone(platform)
|
|
366
|
+
entry = {
|
|
367
|
+
"draft_id": draft_id,
|
|
368
|
+
"text": text,
|
|
369
|
+
"platform": platform,
|
|
370
|
+
"account": account,
|
|
371
|
+
"quote_tweet_id": quote_tweet_id,
|
|
372
|
+
"reply_to_id": reply_to_id,
|
|
373
|
+
"status": "pending",
|
|
374
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
375
|
+
}
|
|
376
|
+
# Check tone violations
|
|
377
|
+
warnings = []
|
|
378
|
+
if tone.get("max_length") and len(text) > tone["max_length"]:
|
|
379
|
+
warnings.append(f"Text exceeds {platform} max length ({len(text)}/{tone['max_length']})")
|
|
380
|
+
if platform == "reddit":
|
|
381
|
+
if any(line.strip().startswith(("- ", "* ", "1.", "2.", "3.")) for line in text.split("\n")):
|
|
382
|
+
warnings.append("REDDIT WARNING: Contains bullet/numbered lists — high risk of mod removal as AI content")
|
|
383
|
+
if text.count("\n\n") >= 3:
|
|
384
|
+
warnings.append("REDDIT WARNING: Multi-paragraph essay format — shorten to 2-3 sentences")
|
|
385
|
+
if "**" in text:
|
|
386
|
+
warnings.append("REDDIT WARNING: Contains bold formatting — too polished for Reddit")
|
|
387
|
+
if warnings:
|
|
388
|
+
entry["tone_warnings"] = warnings
|
|
389
|
+
with open(DRAFTS_FILE, "a") as f:
|
|
390
|
+
f.write(json.dumps(entry) + "\n")
|
|
391
|
+
return entry
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def store_draft_message_id(draft_id: str, message_id: str) -> bool:
|
|
395
|
+
"""Store the outbound notification Message-ID on a draft record.
|
|
396
|
+
|
|
397
|
+
This enables In-Reply-To header matching for auto-approval via the
|
|
398
|
+
inbox polling daemon (Consensus 116).
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
draft_id: The 12-char hex draft ID.
|
|
402
|
+
message_id: The Message-ID header from the sent notification email.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
True if the draft was found and updated, False otherwise.
|
|
406
|
+
"""
|
|
407
|
+
all_entries = _load_all_drafts()
|
|
408
|
+
for entry in all_entries:
|
|
409
|
+
if entry.get("draft_id") == draft_id:
|
|
410
|
+
entry["notification_message_id"] = message_id
|
|
411
|
+
_rewrite_drafts(all_entries)
|
|
412
|
+
return True
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def list_drafts(status: str = "pending") -> list[dict]:
|
|
417
|
+
"""List drafts filtered by status (pending, approved, rejected)."""
|
|
418
|
+
if not DRAFTS_FILE.exists():
|
|
419
|
+
return []
|
|
420
|
+
drafts = []
|
|
421
|
+
for line in DRAFTS_FILE.read_text().splitlines():
|
|
422
|
+
if not line.strip():
|
|
423
|
+
continue
|
|
424
|
+
try:
|
|
425
|
+
entry = json.loads(line)
|
|
426
|
+
if entry.get("status") == status:
|
|
427
|
+
drafts.append(entry)
|
|
428
|
+
except (json.JSONDecodeError, ValueError):
|
|
429
|
+
pass
|
|
430
|
+
return drafts
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _rewrite_drafts(all_entries: list[dict]) -> None:
|
|
434
|
+
"""Rewrite the drafts file with updated entries."""
|
|
435
|
+
DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
436
|
+
with open(DRAFTS_FILE, "w") as f:
|
|
437
|
+
for entry in all_entries:
|
|
438
|
+
f.write(json.dumps(entry) + "\n")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _load_all_drafts() -> list[dict]:
|
|
442
|
+
"""Load all draft entries from the JSONL file."""
|
|
443
|
+
if not DRAFTS_FILE.exists():
|
|
444
|
+
return []
|
|
445
|
+
entries = []
|
|
446
|
+
for line in DRAFTS_FILE.read_text().splitlines():
|
|
447
|
+
if not line.strip():
|
|
448
|
+
continue
|
|
449
|
+
try:
|
|
450
|
+
entries.append(json.loads(line))
|
|
451
|
+
except (json.JSONDecodeError, ValueError):
|
|
452
|
+
pass
|
|
453
|
+
return entries
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def approve_draft(draft_id: str) -> dict:
|
|
457
|
+
"""Approve a draft and post it. Returns the post result."""
|
|
458
|
+
all_entries = _load_all_drafts()
|
|
459
|
+
target = None
|
|
460
|
+
for entry in all_entries:
|
|
461
|
+
if entry.get("draft_id") == draft_id:
|
|
462
|
+
target = entry
|
|
463
|
+
break
|
|
464
|
+
if not target:
|
|
465
|
+
return {"error": f"Draft '{draft_id}' not found"}
|
|
466
|
+
if target.get("status") != "pending":
|
|
467
|
+
return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
|
|
468
|
+
|
|
469
|
+
# Post it
|
|
470
|
+
result = post_tweet(
|
|
471
|
+
target["text"],
|
|
472
|
+
account=target.get("account", ""),
|
|
473
|
+
quote_tweet_id=target.get("quote_tweet_id", ""),
|
|
474
|
+
reply_to_id=target.get("reply_to_id", ""),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if "error" in result:
|
|
478
|
+
return result
|
|
479
|
+
|
|
480
|
+
# Update status
|
|
481
|
+
target["status"] = "approved"
|
|
482
|
+
target["approved_at"] = datetime.now(timezone.utc).isoformat()
|
|
483
|
+
target["post_result"] = result
|
|
484
|
+
_rewrite_drafts(all_entries)
|
|
485
|
+
return {"draft_id": draft_id, "status": "approved", "post_result": result}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def reject_draft(draft_id: str) -> dict:
|
|
489
|
+
"""Reject a draft. It will not be posted."""
|
|
490
|
+
all_entries = _load_all_drafts()
|
|
491
|
+
target = None
|
|
492
|
+
for entry in all_entries:
|
|
493
|
+
if entry.get("draft_id") == draft_id:
|
|
494
|
+
target = entry
|
|
495
|
+
break
|
|
496
|
+
if not target:
|
|
497
|
+
return {"error": f"Draft '{draft_id}' not found"}
|
|
498
|
+
if target.get("status") != "pending":
|
|
499
|
+
return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
|
|
500
|
+
|
|
501
|
+
target["status"] = "rejected"
|
|
502
|
+
target["rejected_at"] = datetime.now(timezone.utc).isoformat()
|
|
503
|
+
_rewrite_drafts(all_entries)
|
|
504
|
+
return {"draft_id": draft_id, "status": "rejected"}
|