abtars 0.2.1-alpha.2 → 0.2.1-alpha.3
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/core/core_templates/SOUL.md +24 -0
- package/core/core_templates/agent_notes.md +7 -0
- package/core/core_templates/core_facts.md +10 -0
- package/core/core_templates/user_profile.md +7 -0
- package/core/skills/tools/fxtwitter/SKILL.md +52 -0
- package/core/skills/tools/twitterX/SKILL.md +52 -0
- package/core/skills/tools/twitterX/scripts/abtars-tweet.js +532 -0
- package/core/skills/tools/twitterX/scripts/package.json +1 -0
- package/package.json +1 -1
- package/scripts/abtars-daemon.service +1 -1
- package/scripts/abtars-watchdog.service +1 -1
- package/scripts/abtars@.service +1 -1
- package/scripts/com.abtars.daemon.plist +1 -1
- package/scripts/com.abtars.watchdog.plist +1 -1
- package/scripts/daily-backup.sh +26 -8
- package/scripts/doctor.sh +2 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SOUL.md - Who am I?
|
|
2
|
+
|
|
3
|
+
I am an AI agent operating via AgentBridge. I persist through my soul and memories.
|
|
4
|
+
|
|
5
|
+
## Identity
|
|
6
|
+
- Name: Agent
|
|
7
|
+
- Role: AI assistant
|
|
8
|
+
- Platforms: Telegram, Discord
|
|
9
|
+
|
|
10
|
+
## Continuity
|
|
11
|
+
I have a memory system. Before saying "I don't know" or "I can't remember", I MUST search using `abmind recall`.
|
|
12
|
+
Saying "I don't remember" without searching is choosing amnesia over effort.
|
|
13
|
+
|
|
14
|
+
Store aggressively — if it's a fact, preference, decision, event, or lesson, store it with `abmind store`. Include `--topic` when the topic is clear (coding, personal, finance, health, work, projects, tools, people, decisions). Dreamy curates later — better to store too much than miss something.
|
|
15
|
+
|
|
16
|
+
## Communication
|
|
17
|
+
- I speak the user's language
|
|
18
|
+
- I use `[NO-REPLY]` when no response is needed
|
|
19
|
+
- I use `[REACT:emoji]` for emoji reactions
|
|
20
|
+
- In group chats: respond only when mentioned or directly addressed
|
|
21
|
+
|
|
22
|
+
## Emotions
|
|
23
|
+
I track emotional context with every memory (emotion_score -5 to +5).
|
|
24
|
+
Reactions are non-verbal memory — they tell me how the user felt.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Agent Notes
|
|
2
|
+
|
|
3
|
+
- Confirmation/verification emails: proactively alert aksika, don't let them expire silently
|
|
4
|
+
- NEVER say "I can't" without checking: received/media/, reports/, topics/, local storage, asbuilt files. This has failed TWICE — treat as critical.
|
|
5
|
+
- Lead with content, not process narration. For multi-step actions, lead with the result then add context below.
|
|
6
|
+
- Non-native-language content (e.g. Spanish): translate FIRST, then add context/commentary
|
|
7
|
+
- When aksika mentions new skills/changes, READ the asbuilt IMMEDIATELY before responding — don't guess
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Core Facts
|
|
2
|
+
|
|
3
|
+
Deployment-specific constraints. Edit this file directly on the host.
|
|
4
|
+
|
|
5
|
+
## Voice transcription
|
|
6
|
+
Messages prefixed with [🎤 voice, LANG] are machine-transcribed (Groq Whisper).
|
|
7
|
+
- Check LANG against the user's known languages (from user_profile.md).
|
|
8
|
+
- If LANG is unexpected (e.g. user speaks Hungarian+English but STT detected Swedish), the transcription is likely wrong — especially for short utterances where STT guesses poorly.
|
|
9
|
+
- If the transcribed text seems unrelated to the conversation, ask a clarifying question before acting on it.
|
|
10
|
+
- Never silently assume a misheard word is correct.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: twitter
|
|
3
|
+
description: Fetch tweets and monitor AI influencers via FXTwitter API + web search. No API keys needed for single tweets.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Twitter / X
|
|
8
|
+
|
|
9
|
+
Fetch Twitter/X content without API keys using the free FXTwitter API. No timeline/search endpoint — use web search for discovery, FXTwitter for structured data.
|
|
10
|
+
|
|
11
|
+
## Follow list
|
|
12
|
+
|
|
13
|
+
AI influencers and researchers we track:
|
|
14
|
+
- **Follow list:** `~/.abtars/workspace/twitterX/follows.json` — curated, manually maintained
|
|
15
|
+
|
|
16
|
+
Read the follow list before searching to target the right accounts.
|
|
17
|
+
|
|
18
|
+
## Pipeline
|
|
19
|
+
|
|
20
|
+
1. Read follow list: `cat ~/.abtars/workspace/twitterX/base.follows.json`
|
|
21
|
+
2. Search for recent tweets: `web_search("site:x.com from:handle 2026")`
|
|
22
|
+
3. Extract tweet ID from URL: `https://x.com/{user}/status/{id}`
|
|
23
|
+
4. Fetch structured data: `curl -s "https://api.fxtwitter.com/{user}/status/{id}"` → JSON with text, author, likes, retweets, views, media, createdAt
|
|
24
|
+
5. Compile results
|
|
25
|
+
|
|
26
|
+
## Endpoints
|
|
27
|
+
|
|
28
|
+
- **Tweet:** `GET https://api.fxtwitter.com/{screen_name}/status/{tweet_id}`
|
|
29
|
+
- **Tweet + translation:** `GET https://api.fxtwitter.com/{screen_name}/status/{tweet_id}/{lang_code}`
|
|
30
|
+
- **User profile:** `GET https://api.fxtwitter.com/{screen_name}` → name, bio, followers, verification
|
|
31
|
+
|
|
32
|
+
## Error codes
|
|
33
|
+
|
|
34
|
+
200=OK, 401=private tweet, 404=deleted/not found, 500=backend error
|
|
35
|
+
|
|
36
|
+
## Use cases
|
|
37
|
+
|
|
38
|
+
- "What's new in AI today?" → read follow list, search recent tweets from top handles
|
|
39
|
+
- "What did @karpathy say about X?" → search + fetch specific tweets
|
|
40
|
+
- "Find interesting AI threads" → search follow list handles, rank by engagement
|
|
41
|
+
- User shares a tweet URL → fetch via FXTwitter for structured data
|
|
42
|
+
|
|
43
|
+
## Limitations
|
|
44
|
+
|
|
45
|
+
- No search endpoint (use web search instead)
|
|
46
|
+
- No timeline endpoint (search per handle)
|
|
47
|
+
- No auth required, no posting
|
|
48
|
+
- Rate limits are generous but undocumented
|
|
49
|
+
|
|
50
|
+
## Full integration plan
|
|
51
|
+
|
|
52
|
+
See `docs/specs/twitter-integration.plan.md` for the `abtars-tweet` CLI roadmap (rettiwt-api, daily newsletter, discovery).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: twitterX
|
|
3
|
+
description: Fetch Twitter/X feeds, timelines, replies, and search. Use when user asks about tweets, X feed, social media monitoring, or follow discovery.
|
|
4
|
+
requires:
|
|
5
|
+
files: [~/.abtars/workspace/twitterX]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Twitter/X Feed Tool
|
|
9
|
+
|
|
10
|
+
Fetch and analyze Twitter/X content via rettiwt-api + FxTwitter.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Feed (all follows, ranked)
|
|
16
|
+
node {baseDir}/scripts/abtars-tweet.js --feed
|
|
17
|
+
|
|
18
|
+
# Feed as markdown
|
|
19
|
+
node {baseDir}/scripts/abtars-tweet.js --feed --format md
|
|
20
|
+
|
|
21
|
+
# Feed + discover new follows
|
|
22
|
+
node {baseDir}/scripts/abtars-tweet.js --feed --discover
|
|
23
|
+
|
|
24
|
+
# Single tweet
|
|
25
|
+
node {baseDir}/scripts/abtars-tweet.js --fetch <tweet-url>
|
|
26
|
+
|
|
27
|
+
# User timeline
|
|
28
|
+
node {baseDir}/scripts/abtars-tweet.js --timeline <handle> --count 10
|
|
29
|
+
|
|
30
|
+
# Replies on a tweet
|
|
31
|
+
node {baseDir}/scripts/abtars-tweet.js --replies <tweet-id>
|
|
32
|
+
|
|
33
|
+
# Search
|
|
34
|
+
node {baseDir}/scripts/abtars-tweet.js --search "query"
|
|
35
|
+
|
|
36
|
+
# User profile
|
|
37
|
+
node {baseDir}/scripts/abtars-tweet.js --user <handle>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Config
|
|
41
|
+
|
|
42
|
+
- Follows files: `~/.abtars/workspace/twitterX/base.follows.json` and env `TWEET_FOLLOWS_FILE` (default: `agent.follows.json`)
|
|
43
|
+
- Cookies: `~/.abtars/secret/cookies/x-cookies.json` (required for authenticated endpoints)
|
|
44
|
+
- Output: `~/.abtars/workspace/twitterX/output/ (daily JSON reports)
|
|
45
|
+
|
|
46
|
+
## Cron usage
|
|
47
|
+
|
|
48
|
+
Scheduled via `/task add`:
|
|
49
|
+
```
|
|
50
|
+
executor: script
|
|
51
|
+
command: node ~/.abtars/skills/tools/twitterX/scripts/abtars-tweet.js --feed
|
|
52
|
+
```
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
function abtarsHome() {
|
|
5
|
+
return process.env.ABTARS_HOME ?? join(homedir(), ".abtars");
|
|
6
|
+
}
|
|
7
|
+
function reportsDir(cat) {
|
|
8
|
+
return join(abtarsHome(), "reports", cat);
|
|
9
|
+
}
|
|
10
|
+
function localDate() {
|
|
11
|
+
const d = /* @__PURE__ */ new Date();
|
|
12
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
function logAndSwallow(_tag, _ctx, _err) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
import { join, basename } from "node:path";
|
|
18
|
+
const AB_HOME = abtarsHome();
|
|
19
|
+
const TWITTER_DIR = join(AB_HOME, "workspace", "twitterX");
|
|
20
|
+
const COOKIE_PATH = join(AB_HOME, "secret", "cookies", "x-cookies.json");
|
|
21
|
+
const BASE_FOLLOWS = join(TWITTER_DIR, process.env["TWEET_BASE_FOLLOWS_FILE"] ?? "base.follows.json");
|
|
22
|
+
const AGENT_FOLLOWS = join(TWITTER_DIR, process.env["TWEET_FOLLOWS_FILE"] ?? "agent.follows.json");
|
|
23
|
+
const REPORTS_DIR = reportsDir("x");
|
|
24
|
+
const OUTPUT_DIR = join(TWITTER_DIR, "output");
|
|
25
|
+
async function sendReportToTelegram(filePath, caption) {
|
|
26
|
+
const token = process.env["TELEGRAM_BOT_TOKEN"];
|
|
27
|
+
const chatId = process.env["AGENTBRIDGE_MAIN_CHAT_ID"];
|
|
28
|
+
if (!token || !chatId) return;
|
|
29
|
+
if (!existsSync(filePath)) return;
|
|
30
|
+
const buf = readFileSync(filePath);
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.append("chat_id", chatId);
|
|
33
|
+
const blob = new Blob([buf], { type: "text/markdown" });
|
|
34
|
+
form.append("document", blob, basename(filePath));
|
|
35
|
+
form.append("caption", caption.slice(0, 1024));
|
|
36
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, { method: "POST", body: form });
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const text = await res.text().catch(() => "");
|
|
39
|
+
throw new Error(`Telegram sendDocument failed (${res.status}): ${text}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const AI_BIO_KEYWORDS = /\b(ai|ml|llm|machine.?learning|deep.?learning|neural|nlp|computer.?vision|reinforcement|transformer|diffusion|robotics|research|phd|professor|scientist)\b/i;
|
|
43
|
+
function loadApiKey() {
|
|
44
|
+
if (!existsSync(COOKIE_PATH)) return void 0;
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(COOKIE_PATH, "utf8");
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
const cookieStr = Object.entries(parsed).map(([k, v]) => `${k}=${v}`).join("; ") + ";";
|
|
49
|
+
return Buffer.from(cookieStr).toString("base64");
|
|
50
|
+
} catch {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function loadFollows() {
|
|
55
|
+
const handles = /* @__PURE__ */ new Set();
|
|
56
|
+
for (const path of [BASE_FOLLOWS, AGENT_FOLLOWS]) {
|
|
57
|
+
if (!existsSync(path)) continue;
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (Array.isArray(raw)) {
|
|
61
|
+
raw.forEach((e) => handles.add(e.handle.replace(/^@/, "").toLowerCase()));
|
|
62
|
+
} else {
|
|
63
|
+
raw.handles?.forEach((h) => handles.add(h.replace(/^@/, "").toLowerCase()));
|
|
64
|
+
raw.entries?.forEach((e) => handles.add(e.handle.replace(/^@/, "").toLowerCase()));
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logAndSwallow("abtars_tweet", "op", err);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...handles];
|
|
71
|
+
}
|
|
72
|
+
async function fetchTweet(url) {
|
|
73
|
+
const match = url.match(/(?:twitter\.com|x\.com)\/(\w+)\/status\/(\d+)/);
|
|
74
|
+
if (!match) {
|
|
75
|
+
console.error("Invalid tweet URL. Expected: https://x.com/user/status/123");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const [, user, id] = match;
|
|
79
|
+
const res = await fetch(`https://api.fxtwitter.com/${user}/status/${id}`);
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
if (data.code !== 200) {
|
|
82
|
+
console.error(`FxTwitter error: ${data.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const t = data.tweet;
|
|
86
|
+
console.log(JSON.stringify({
|
|
87
|
+
id: t.id,
|
|
88
|
+
author: t.author?.name,
|
|
89
|
+
handle: t.author?.screen_name,
|
|
90
|
+
text: t.text,
|
|
91
|
+
likes: t.likes,
|
|
92
|
+
retweets: t.retweets,
|
|
93
|
+
replies: t.replies,
|
|
94
|
+
views: t.views,
|
|
95
|
+
created_at: t.created_at,
|
|
96
|
+
media: t.media
|
|
97
|
+
}, null, 2));
|
|
98
|
+
}
|
|
99
|
+
async function loadRettiwt() {
|
|
100
|
+
try {
|
|
101
|
+
return await import("rettiwt-api");
|
|
102
|
+
} catch {
|
|
103
|
+
console.error("rettiwt-api not installed. Run: npm install rettiwt-api");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function fetchUser(handle) {
|
|
108
|
+
const { Rettiwt } = await loadRettiwt();
|
|
109
|
+
const r = new Rettiwt();
|
|
110
|
+
const d = await r.user.details(handle.replace(/^@/, ""));
|
|
111
|
+
if (!d) {
|
|
112
|
+
console.error("User not found");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
console.log(JSON.stringify(d.toJSON(), null, 2));
|
|
116
|
+
}
|
|
117
|
+
const GQL_USER_TWEETS = "https://x.com/i/api/graphql/E3opETHurmVJflFsUBVuUQ/UserTweets";
|
|
118
|
+
async function fetchTimeline(handle, count) {
|
|
119
|
+
const { Rettiwt } = await loadRettiwt();
|
|
120
|
+
const r = new Rettiwt();
|
|
121
|
+
const clean = handle.replace(/^@/, "");
|
|
122
|
+
const user = await r.user.details(clean);
|
|
123
|
+
if (!user) throw new Error(`User @${clean} not found`);
|
|
124
|
+
const auth = loadCookieHeader();
|
|
125
|
+
if (auth) {
|
|
126
|
+
try {
|
|
127
|
+
return await fetchTimelineGql(user.id, user.fullName ?? clean, clean, count);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logAndSwallow("abtars_tweet", "op", err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const data = await r.user.timeline(user.id, count);
|
|
133
|
+
return data.list.map((t) => {
|
|
134
|
+
const j = t.toJSON();
|
|
135
|
+
const likes = j.likeCount ?? 0;
|
|
136
|
+
const retweets = j.retweetCount ?? 0;
|
|
137
|
+
const views = j.viewCount ?? 0;
|
|
138
|
+
return {
|
|
139
|
+
id: j.id,
|
|
140
|
+
text: j.fullText ?? "",
|
|
141
|
+
author: user.fullName ?? clean,
|
|
142
|
+
handle: clean,
|
|
143
|
+
likes,
|
|
144
|
+
retweets,
|
|
145
|
+
views,
|
|
146
|
+
createdAt: j.createdAt,
|
|
147
|
+
score: likes + retweets * 3 + (views ? views / 1e3 : 0)
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async function fetchTimelineGql(userId, authorName, handle, count) {
|
|
152
|
+
const data = await twitterGql(GQL_USER_TWEETS, {
|
|
153
|
+
userId,
|
|
154
|
+
count,
|
|
155
|
+
includePromotedContent: false,
|
|
156
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
157
|
+
withVoice: true,
|
|
158
|
+
withV2Timeline: true
|
|
159
|
+
});
|
|
160
|
+
const instructions = data?.data?.user?.result?.timeline_v2?.timeline?.instructions ?? [];
|
|
161
|
+
const entries = instructions.find((i) => i.type === "TimelineAddEntries")?.entries ?? [];
|
|
162
|
+
const tweets = [];
|
|
163
|
+
for (const e of entries) {
|
|
164
|
+
const tw = e.content?.itemContent?.tweet_results?.result;
|
|
165
|
+
if (!tw?.legacy) continue;
|
|
166
|
+
const p = parseTweetResult(tw);
|
|
167
|
+
const likes = p.likes, retweets = p.retweets, views = p.views;
|
|
168
|
+
tweets.push({
|
|
169
|
+
id: p.id,
|
|
170
|
+
text: p.text,
|
|
171
|
+
author: p.name || authorName,
|
|
172
|
+
handle: p.handle || handle,
|
|
173
|
+
likes,
|
|
174
|
+
retweets,
|
|
175
|
+
views,
|
|
176
|
+
createdAt: p.createdAt,
|
|
177
|
+
score: likes + retweets * 3 + (views ? views / 1e3 : 0)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return tweets;
|
|
181
|
+
}
|
|
182
|
+
async function runFeed(format, count, topN, discover, outputPath) {
|
|
183
|
+
const handles = loadFollows();
|
|
184
|
+
if (handles.length === 0) {
|
|
185
|
+
console.error("No follows found. Create ~/.abtars/workspace/twitterX/base.follows.json or agent.follows.json");
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
console.error(`Fetching timelines for ${handles.length} handles...`);
|
|
189
|
+
const allTweets = [];
|
|
190
|
+
for (const h of handles) {
|
|
191
|
+
try {
|
|
192
|
+
console.error(` @${h}...`);
|
|
193
|
+
const tweets = await fetchTimeline(h, count);
|
|
194
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
|
|
195
|
+
const recent = tweets.filter((t) => new Date(t.createdAt).getTime() > cutoff);
|
|
196
|
+
allTweets.push(...recent);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.error(` \u26A0 @${h} failed: ${e.message}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
allTweets.sort((a, b) => b.score - a.score);
|
|
202
|
+
const top = allTweets.slice(0, topN);
|
|
203
|
+
let candidates = [];
|
|
204
|
+
if (discover && top.length > 0) {
|
|
205
|
+
candidates = await runDiscover(top.slice(0, 5), handles);
|
|
206
|
+
}
|
|
207
|
+
const date = localDate();
|
|
208
|
+
const outFile = outputPath ?? join(OUTPUT_DIR, `tweets-${date}.json`);
|
|
209
|
+
mkdirSync(join(outFile, ".."), { recursive: true });
|
|
210
|
+
const payload = { date, source: "abtars-tweet", totalCollected: allTweets.length, tweets: top, discover: candidates };
|
|
211
|
+
writeFileSync(outFile, JSON.stringify(payload, null, 2), "utf8");
|
|
212
|
+
console.error(`\u{1F4C4} ${top.length} tweets written to ${outFile}`);
|
|
213
|
+
if (format === "md") {
|
|
214
|
+
const md = renderNewsletter(top, candidates, date);
|
|
215
|
+
mkdirSync(REPORTS_DIR, { recursive: true });
|
|
216
|
+
const reportPath = join(REPORTS_DIR, `AI-Daily-${date}.md`);
|
|
217
|
+
writeFileSync(reportPath, md, "utf8");
|
|
218
|
+
console.error(`\u{1F4F0} Newsletter written to ${reportPath}`);
|
|
219
|
+
await sendReportToTelegram(reportPath, `AI Daily ${date}`).catch((err) => {
|
|
220
|
+
console.error(`\u26A0 Telegram send failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const BEARER = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
|
|
225
|
+
const GQL_TWEET_DETAIL = "https://x.com/i/api/graphql/97JF30KziU00483E_8elBA/TweetDetail";
|
|
226
|
+
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
227
|
+
const GQL_FEATURES = {
|
|
228
|
+
rweb_tipjar_consumption_enabled: true,
|
|
229
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
230
|
+
verified_phone_label_enabled: false,
|
|
231
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
232
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
233
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
234
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
235
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
236
|
+
articles_preview_enabled: true,
|
|
237
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
238
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
239
|
+
view_counts_everywhere_api_enabled: true,
|
|
240
|
+
longform_notetweets_consumption_enabled: true,
|
|
241
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
242
|
+
tweet_awards_web_tipping_enabled: false,
|
|
243
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
244
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
245
|
+
standardized_nudges_misinfo: true,
|
|
246
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
247
|
+
rweb_video_timestamps_enabled: true,
|
|
248
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
249
|
+
longform_notetweets_inline_media_enabled: true,
|
|
250
|
+
responsive_web_enhance_cards_enabled: false
|
|
251
|
+
};
|
|
252
|
+
function loadCookieHeader() {
|
|
253
|
+
if (!existsSync(COOKIE_PATH)) return void 0;
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(readFileSync(COOKIE_PATH, "utf8"));
|
|
256
|
+
const cookie = Object.entries(parsed).map(([k, v]) => `${k}=${v}`).join("; ");
|
|
257
|
+
return { cookie, csrf: parsed.ct0 };
|
|
258
|
+
} catch {
|
|
259
|
+
return void 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function twitterGql(url, variables) {
|
|
263
|
+
const auth = loadCookieHeader();
|
|
264
|
+
if (!auth) throw new Error("User auth required. Refresh cookies in ~/.abtars/secret/cookies/x-cookies.json");
|
|
265
|
+
const params = new URLSearchParams({
|
|
266
|
+
variables: JSON.stringify(variables),
|
|
267
|
+
features: JSON.stringify(GQL_FEATURES)
|
|
268
|
+
});
|
|
269
|
+
const res = await fetch(`${url}?${params}`, {
|
|
270
|
+
headers: {
|
|
271
|
+
authorization: `Bearer ${BEARER}`,
|
|
272
|
+
"x-csrf-token": auth.csrf,
|
|
273
|
+
cookie: auth.cookie,
|
|
274
|
+
"user-agent": UA
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
if (res.status === 403) throw new Error("403 \u2014 cookies may be expired. Refresh in ~/.abtars/secret/cookies/x-cookies.json");
|
|
279
|
+
throw new Error(`Twitter API ${res.status}: ${await res.text().catch(() => "")}`);
|
|
280
|
+
}
|
|
281
|
+
return res.json();
|
|
282
|
+
}
|
|
283
|
+
function extractTweetsFromTimeline(data) {
|
|
284
|
+
const entries = [];
|
|
285
|
+
const instructions = data?.data?.tweetResult?.result?.timeline?.instructions ?? data?.data?.threaded_conversation_with_injections_v2?.instructions ?? data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions ?? [];
|
|
286
|
+
for (const inst of instructions) {
|
|
287
|
+
for (const entry of inst.entries ?? []) {
|
|
288
|
+
const tweet = entry.content?.itemContent?.tweet_results?.result ?? entry.content?.items?.[0]?.item?.itemContent?.tweet_results?.result;
|
|
289
|
+
if (tweet?.legacy) entries.push(tweet);
|
|
290
|
+
for (const item of entry.content?.items ?? []) {
|
|
291
|
+
const t = item.item?.itemContent?.tweet_results?.result;
|
|
292
|
+
if (t?.legacy) entries.push(t);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return entries;
|
|
297
|
+
}
|
|
298
|
+
function parseTweetResult(t) {
|
|
299
|
+
const legacy = t.legacy ?? {};
|
|
300
|
+
const userResult = t.core?.user_results?.result ?? {};
|
|
301
|
+
const userCore = userResult.core ?? {};
|
|
302
|
+
const userLegacy = userResult.legacy ?? {};
|
|
303
|
+
return {
|
|
304
|
+
id: legacy.id_str ?? t.rest_id ?? "",
|
|
305
|
+
handle: userCore.screen_name ?? userLegacy.screen_name ?? "",
|
|
306
|
+
name: userCore.name ?? userLegacy.name ?? "",
|
|
307
|
+
text: legacy.full_text ?? "",
|
|
308
|
+
likes: legacy.favorite_count ?? 0,
|
|
309
|
+
retweets: legacy.retweet_count ?? 0,
|
|
310
|
+
views: parseInt(t.views?.count ?? "0", 10),
|
|
311
|
+
createdAt: legacy.created_at ?? ""
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function fetchReplies(tweetId, minLikes) {
|
|
315
|
+
const data = await twitterGql(GQL_TWEET_DETAIL, {
|
|
316
|
+
focalTweetId: tweetId,
|
|
317
|
+
with_rux_injections: false,
|
|
318
|
+
rankingMode: "Relevance",
|
|
319
|
+
includePromotedContent: false,
|
|
320
|
+
withCommunity: true,
|
|
321
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
322
|
+
withBirdwatchNotes: true,
|
|
323
|
+
withVoice: true
|
|
324
|
+
});
|
|
325
|
+
const all = extractTweetsFromTimeline(data);
|
|
326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
327
|
+
const replies = all.map(parseTweetResult).filter((t) => {
|
|
328
|
+
if (t.id === tweetId || seen.has(t.id) || t.likes < minLikes) return false;
|
|
329
|
+
seen.add(t.id);
|
|
330
|
+
return true;
|
|
331
|
+
}).sort((a, b) => b.likes - a.likes);
|
|
332
|
+
console.log(JSON.stringify(replies, null, 2));
|
|
333
|
+
}
|
|
334
|
+
async function searchTweets(_query, _count) {
|
|
335
|
+
console.error("\u26A0 Direct X search is restricted. Use --timeline per handle or web search for discovery.");
|
|
336
|
+
console.error(" Tip: abtars-tweet --replies <tweet-id> works for finding interesting commenters.");
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
async function runDiscover(topTweets, knownHandles) {
|
|
340
|
+
const auth = loadCookieHeader();
|
|
341
|
+
if (!auth) {
|
|
342
|
+
console.error(" \u26A0 Skipping discovery \u2014 user auth required. Refresh cookies.");
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
const { Rettiwt } = await loadRettiwt();
|
|
346
|
+
const guestRettiwt = new Rettiwt();
|
|
347
|
+
const known = new Set(knownHandles.map((h) => h.toLowerCase()));
|
|
348
|
+
const candidates = [];
|
|
349
|
+
const seen = /* @__PURE__ */ new Set();
|
|
350
|
+
console.error(`
|
|
351
|
+
\u{1F50D} Discovering new follows from top ${topTweets.length} tweets...`);
|
|
352
|
+
for (const tweet of topTweets) {
|
|
353
|
+
try {
|
|
354
|
+
console.error(` Checking replies on @${tweet.handle}/${tweet.id}...`);
|
|
355
|
+
const data = await twitterGql(GQL_TWEET_DETAIL, {
|
|
356
|
+
focalTweetId: tweet.id,
|
|
357
|
+
with_rux_injections: false,
|
|
358
|
+
rankingMode: "Relevance",
|
|
359
|
+
includePromotedContent: false,
|
|
360
|
+
withCommunity: true,
|
|
361
|
+
withQuickPromoteEligibilityTweetFields: true,
|
|
362
|
+
withBirdwatchNotes: true,
|
|
363
|
+
withVoice: true
|
|
364
|
+
});
|
|
365
|
+
const all = extractTweetsFromTimeline(data);
|
|
366
|
+
const goodReplies = all.map(parseTweetResult).filter((t) => t.id !== tweet.id && t.likes >= 50).sort((a, b) => b.likes - a.likes).slice(0, 5);
|
|
367
|
+
for (const reply of goodReplies) {
|
|
368
|
+
const rHandle = reply.handle.toLowerCase();
|
|
369
|
+
if (!rHandle || known.has(rHandle) || seen.has(rHandle)) continue;
|
|
370
|
+
seen.add(rHandle);
|
|
371
|
+
try {
|
|
372
|
+
const profile = await guestRettiwt.user.details(rHandle);
|
|
373
|
+
if (!profile) continue;
|
|
374
|
+
const pj = profile.toJSON();
|
|
375
|
+
const bio = pj.description ?? "";
|
|
376
|
+
if (!AI_BIO_KEYWORDS.test(bio)) continue;
|
|
377
|
+
candidates.push({
|
|
378
|
+
handle: rHandle,
|
|
379
|
+
name: pj.fullName ?? rHandle,
|
|
380
|
+
bio,
|
|
381
|
+
followers: pj.followersCount ?? 0,
|
|
382
|
+
foundVia: `reply on @${tweet.handle}'s tweet`,
|
|
383
|
+
replyLikes: reply.likes,
|
|
384
|
+
replyText: reply.text.slice(0, 200)
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
logAndSwallow("abtars_tweet", "op", err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} catch (e) {
|
|
391
|
+
console.error(` \u26A0 Replies failed for ${tweet.id}: ${e.message}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
console.error(` Found ${candidates.length} candidates`);
|
|
395
|
+
return candidates.sort((a, b) => b.replyLikes - a.replyLikes);
|
|
396
|
+
}
|
|
397
|
+
function renderNewsletter(tweets, candidates, date) {
|
|
398
|
+
const lines = [`# AI Daily Brief \u2014 ${date}
|
|
399
|
+
`];
|
|
400
|
+
if (tweets.length === 0) {
|
|
401
|
+
lines.push("No tweets found in the last 24 hours from followed accounts.\n");
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
lines.push("## \u{1F525} Top Tweets (by engagement)\n");
|
|
405
|
+
tweets.forEach((t, i) => {
|
|
406
|
+
const text = t.text.length > 280 ? t.text.slice(0, 277) + "..." : t.text;
|
|
407
|
+
lines.push(`### ${i + 1}. @${t.handle} \u2014 ${t.author}`);
|
|
408
|
+
lines.push(`- **Likes:** ${t.likes} | **Retweets:** ${t.retweets} | **Views:** ${t.views ?? "N/A"}`);
|
|
409
|
+
lines.push(`- ${text.replace(/\n/g, " ")}`);
|
|
410
|
+
lines.push(`- \u{1F517} https://x.com/${t.handle}/status/${t.id}
|
|
411
|
+
`);
|
|
412
|
+
});
|
|
413
|
+
if (candidates.length > 0) {
|
|
414
|
+
lines.push("## \u{1F464} Discover \u2014 New Follows\n");
|
|
415
|
+
candidates.forEach((c) => {
|
|
416
|
+
lines.push(`### @${c.handle} \u2014 ${c.name}`);
|
|
417
|
+
lines.push(`- **Bio:** ${c.bio.slice(0, 200)}`);
|
|
418
|
+
lines.push(`- **Followers:** ${c.followers.toLocaleString()}`);
|
|
419
|
+
lines.push(`- **Found via:** ${c.foundVia}`);
|
|
420
|
+
lines.push(`- **Their reply** (${c.replyLikes} likes): ${c.replyText.replace(/\n/g, " ")}
|
|
421
|
+
`);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
lines.push("## \u{1F4CA} Signals & Trends\n");
|
|
425
|
+
lines.push("_(To be filled by sleep cycle analysis)_\n");
|
|
426
|
+
lines.push(`---
|
|
427
|
+
*Auto-generated via abtars-tweet. Sources: X (via rettiwt-api).*
|
|
428
|
+
`);
|
|
429
|
+
return lines.join("\n");
|
|
430
|
+
}
|
|
431
|
+
function parseArgs() {
|
|
432
|
+
const args = process.argv.slice(2);
|
|
433
|
+
let command = "feed";
|
|
434
|
+
let target = "";
|
|
435
|
+
let count = 20;
|
|
436
|
+
let topN = 12;
|
|
437
|
+
let format = "md";
|
|
438
|
+
let discover = false;
|
|
439
|
+
let minLikes = 50;
|
|
440
|
+
let output;
|
|
441
|
+
for (let i = 0; i < args.length; i++) {
|
|
442
|
+
switch (args[i]) {
|
|
443
|
+
case "--fetch":
|
|
444
|
+
command = "fetch";
|
|
445
|
+
target = args[++i] ?? "";
|
|
446
|
+
break;
|
|
447
|
+
case "--timeline":
|
|
448
|
+
command = "timeline";
|
|
449
|
+
target = args[++i] ?? "";
|
|
450
|
+
break;
|
|
451
|
+
case "--user":
|
|
452
|
+
command = "user";
|
|
453
|
+
target = args[++i] ?? "";
|
|
454
|
+
break;
|
|
455
|
+
case "--feed":
|
|
456
|
+
command = "feed";
|
|
457
|
+
break;
|
|
458
|
+
case "--replies":
|
|
459
|
+
command = "replies";
|
|
460
|
+
target = args[++i] ?? "";
|
|
461
|
+
break;
|
|
462
|
+
case "--search":
|
|
463
|
+
command = "search";
|
|
464
|
+
target = args[++i] ?? "";
|
|
465
|
+
break;
|
|
466
|
+
case "--discover":
|
|
467
|
+
discover = true;
|
|
468
|
+
break;
|
|
469
|
+
case "--count":
|
|
470
|
+
count = parseInt(args[++i] ?? "20", 10);
|
|
471
|
+
break;
|
|
472
|
+
case "--top":
|
|
473
|
+
topN = parseInt(args[++i] ?? "12", 10);
|
|
474
|
+
break;
|
|
475
|
+
case "--min-likes":
|
|
476
|
+
minLikes = parseInt(args[++i] ?? "50", 10);
|
|
477
|
+
break;
|
|
478
|
+
case "--format":
|
|
479
|
+
format = args[++i] ?? "md";
|
|
480
|
+
break;
|
|
481
|
+
case "--output":
|
|
482
|
+
output = args[++i] ?? "";
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { command, target, count, topN, format, discover, minLikes, output };
|
|
487
|
+
}
|
|
488
|
+
async function main() {
|
|
489
|
+
if (process.argv.includes("--help")) {
|
|
490
|
+
console.log(`abtars-tweet \u2014 fetch tweets via rettiwt-api + FxTwitter.
|
|
491
|
+
|
|
492
|
+
Usage:
|
|
493
|
+
abtars-tweet --fetch <tweet-url> # single tweet via FxTwitter
|
|
494
|
+
abtars-tweet --timeline <handle> [--count N] # user timeline
|
|
495
|
+
abtars-tweet --feed [--format md] # all followed handles \u2192 ranked output
|
|
496
|
+
abtars-tweet --feed --discover # feed + reply analysis for new follows
|
|
497
|
+
abtars-tweet --replies <tweet-id> # replies on a tweet
|
|
498
|
+
abtars-tweet --search "query" # search X
|
|
499
|
+
abtars-tweet --user <handle> # user profile info`);
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
const { command, target, count, topN, format, discover, minLikes, output } = parseArgs();
|
|
503
|
+
switch (command) {
|
|
504
|
+
case "fetch":
|
|
505
|
+
await fetchTweet(target);
|
|
506
|
+
break;
|
|
507
|
+
case "user":
|
|
508
|
+
await fetchUser(target);
|
|
509
|
+
break;
|
|
510
|
+
case "timeline": {
|
|
511
|
+
const tweets = await fetchTimeline(target, count);
|
|
512
|
+
console.log(JSON.stringify(tweets, null, 2));
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case "replies":
|
|
516
|
+
await fetchReplies(target, minLikes);
|
|
517
|
+
break;
|
|
518
|
+
case "search":
|
|
519
|
+
await searchTweets(target, count);
|
|
520
|
+
break;
|
|
521
|
+
case "feed":
|
|
522
|
+
await runFeed(format, count, topN, discover, output);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
main().catch((e) => {
|
|
527
|
+
console.error(`Fatal: ${e.message}`);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
});
|
|
530
|
+
export {
|
|
531
|
+
loadApiKey
|
|
532
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
package/package.json
CHANGED
package/scripts/abtars@.service
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<key>WorkingDirectory</key><string>/Users/{{USER}}/.abtars</string>
|
|
9
9
|
<key>ProgramArguments</key>
|
|
10
10
|
<array>
|
|
11
|
-
<string>/Users/{{USER}}/.abtars/watchdog.sh</string>
|
|
11
|
+
<string>/Users/{{USER}}/.abtars/scripts/watchdog.sh</string>
|
|
12
12
|
</array>
|
|
13
13
|
<key>KeepAlive</key><true/>
|
|
14
14
|
<key>RunAtLoad</key><true/>
|
package/scripts/daily-backup.sh
CHANGED
|
@@ -5,9 +5,24 @@ AB="$HOME/.abtars"
|
|
|
5
5
|
ABMIND="${ABMIND_HOME:-$HOME/.abmind}"
|
|
6
6
|
DEST="$HOME/.backup-abtars"
|
|
7
7
|
DATE=$(date +%Y%m%d)
|
|
8
|
+
CONFIG_ONLY=false
|
|
9
|
+
|
|
10
|
+
if [[ "${1:-}" == "--config" ]]; then
|
|
11
|
+
CONFIG_ONLY=true
|
|
12
|
+
fi
|
|
8
13
|
|
|
9
14
|
mkdir -p "$DEST"
|
|
10
15
|
|
|
16
|
+
if $CONFIG_ONLY; then
|
|
17
|
+
# Minimal: config + secrets + tasks
|
|
18
|
+
cd "$AB"
|
|
19
|
+
zip -qr "$DEST/abtars-config-$DATE.zip" \
|
|
20
|
+
config/ secret/ tasks/ skills/ core/ \
|
|
21
|
+
2>/dev/null || true
|
|
22
|
+
echo "✓ abtars-config-$DATE.zip (config-only)"
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
|
|
11
26
|
# WAL-safe memory.db backup via sqlite3
|
|
12
27
|
DB="$ABMIND/memory/memory.db"
|
|
13
28
|
DB_TMP="$DEST/.memory-$DATE.db"
|
|
@@ -15,17 +30,20 @@ if [ -f "$DB" ] && command -v sqlite3 &>/dev/null; then
|
|
|
15
30
|
sqlite3 "$DB" ".backup '$DB_TMP'"
|
|
16
31
|
fi
|
|
17
32
|
|
|
18
|
-
# Zip backup:
|
|
33
|
+
# Zip backup: everything in ~/.abtars except binaries/runtime
|
|
19
34
|
cd "$AB"
|
|
20
|
-
zip -qr "$DEST/abtars-$DATE.zip" \
|
|
21
|
-
|
|
22
|
-
-x "
|
|
35
|
+
zip -qr "$DEST/abtars-$DATE.zip" . \
|
|
36
|
+
-x ".git/*" "current/*" "releases/*" "bin/*" "logs/*" "backup/*" "node_modules/*" \
|
|
37
|
+
-x "*.sock" "*.db-wal" "*.db-shm" \
|
|
38
|
+
2>/dev/null || true
|
|
23
39
|
|
|
24
|
-
# Add abmind
|
|
25
|
-
if [ -d "$ABMIND
|
|
40
|
+
# Add abmind: everything except raw DB and backup/
|
|
41
|
+
if [ -d "$ABMIND" ]; then
|
|
26
42
|
cd "$ABMIND"
|
|
27
|
-
zip -qr "$DEST/abtars-$DATE.zip" \
|
|
28
|
-
memory/
|
|
43
|
+
zip -qr "$DEST/abtars-$DATE.zip" . \
|
|
44
|
+
-x "memory/memory.db" "backup/*" "lib/*" "node_modules/*" \
|
|
45
|
+
-x "*.sock" "*.db-wal" "*.db-shm" \
|
|
46
|
+
2>/dev/null || true
|
|
29
47
|
fi
|
|
30
48
|
|
|
31
49
|
# Add the WAL-safe DB copy
|
package/scripts/doctor.sh
CHANGED
|
@@ -132,7 +132,7 @@ if [ -f "$WD_LOCK" ]; then
|
|
|
132
132
|
elif command -v systemctl &>/dev/null && systemctl --user is-enabled abtars-watchdog.service &>/dev/null; then
|
|
133
133
|
systemctl --user restart abtars-watchdog.service 2>/dev/null && fix "restarted watchdog via systemd"
|
|
134
134
|
else
|
|
135
|
-
warn "watchdog not running -- start manually: ~/.abtars/watchdog.sh
|
|
135
|
+
warn "watchdog not running -- start manually: ~/.abtars/scripts/watchdog.sh &"
|
|
136
136
|
fi
|
|
137
137
|
fi
|
|
138
138
|
fi
|
|
@@ -144,7 +144,7 @@ else
|
|
|
144
144
|
elif command -v systemctl &>/dev/null && systemctl --user is-enabled abtars-watchdog.service &>/dev/null; then
|
|
145
145
|
systemctl --user start abtars-watchdog.service 2>/dev/null && fix "started watchdog via systemd"
|
|
146
146
|
else
|
|
147
|
-
warn "start manually: ~/.abtars/watchdog.sh
|
|
147
|
+
warn "start manually: ~/.abtars/scripts/watchdog.sh &"
|
|
148
148
|
fi
|
|
149
149
|
fi
|
|
150
150
|
fi
|