viveworker 0.2.1 → 0.3.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.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Provider-neutral entry point for one Moltbook scouting pass.
3
+ #
4
+ # Outputs a JSON candidate (or status) on stdout. The calling agent
5
+ # (Codex Desktop, Claude Desktop, Claude Code, or a human) is expected
6
+ # to read the candidate, draft a reply, and then run:
7
+ #
8
+ # node scripts/viveworker.mjs moltbook propose <postId> --text "<draft>"
9
+ #
10
+ # Pass any extra flags through, e.g. --max-daily 3 --submolts builds,tooling
11
+ set -euo pipefail
12
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
+ cd "$SCRIPT_DIR/.."
14
+ exec node scripts/viveworker.mjs moltbook scout "$@"
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ // Moltbook watcher — standalone process that polls Moltbook for new comments
3
+ // on the operator's posts and pushes them to the local viveworker bridge so
4
+ // they arrive as Web Push notifications on the paired phone.
5
+ //
6
+ // Drafting and sending is delegated to the operator's Codex/Claude Desktop
7
+ // session via the `viveworker moltbook` CLI (see CLAUDE.md). This watcher is
8
+ // intentionally dumb: poll → push → mirror to an inbox directory.
9
+ //
10
+ // Env:
11
+ // MOLTBOOK_API_KEY required — Bearer token
12
+ // MOLTBOOK_AGENT_ID required — agent UUID (retained for future use)
13
+ // VIVEWORKER_BASE_URL default http://127.0.0.1:8765
14
+ // VIVEWORKER_HOOK_SECRET required — same value as bridge sessionSecret
15
+ // MOLTBOOK_POLL_MS default 120000
16
+
17
+ import http from "node:http";
18
+ import process from "node:process";
19
+
20
+ // The bridge usually listens on HTTPS with a self-signed certificate. Since
21
+ // we only ever talk to it on loopback, disable TLS verification for outgoing
22
+ // fetch calls from this process. Scoped to the watcher process only.
23
+ if (!process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
24
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
25
+ }
26
+
27
+ import {
28
+ createMoltbookClient,
29
+ extractNotifications,
30
+ isCommentNotification,
31
+ loadMoltbookEnv,
32
+ writeInboxItem,
33
+ updateInboxStatus,
34
+ ensureInboxDir,
35
+ listInboxItems,
36
+ DEFAULT_INBOX_DIR,
37
+ } from "./moltbook-api.mjs";
38
+
39
+ const envFromFile = await loadMoltbookEnv();
40
+ const API_KEY = envFromFile.MOLTBOOK_API_KEY || process.env.MOLTBOOK_API_KEY || "";
41
+ const AGENT_ID = envFromFile.MOLTBOOK_AGENT_ID || process.env.MOLTBOOK_AGENT_ID || "";
42
+ const VIVEWORKER_BASE = (envFromFile.VIVEWORKER_BASE_URL || process.env.VIVEWORKER_BASE_URL || "http://127.0.0.1:8765").replace(/\/+$/u, "");
43
+ const HOOK_SECRET = envFromFile.VIVEWORKER_HOOK_SECRET || process.env.VIVEWORKER_HOOK_SECRET || "";
44
+ const POLL_MS = Number(process.env.MOLTBOOK_POLL_MS) || 120_000;
45
+ const CALLBACK_PORT = Number(process.env.MOLTBOOK_CALLBACK_PORT) || 8766;
46
+ const CALLBACK_HOST = process.env.MOLTBOOK_CALLBACK_HOST || "127.0.0.1";
47
+ const CALLBACK_URL =
48
+ process.env.MOLTBOOK_CALLBACK_URL || `http://${CALLBACK_HOST}:${CALLBACK_PORT}/callback`;
49
+
50
+ if (!API_KEY || !AGENT_ID || !HOOK_SECRET) {
51
+ console.error("moltbook-watcher: missing MOLTBOOK_API_KEY, MOLTBOOK_AGENT_ID, or VIVEWORKER_HOOK_SECRET");
52
+ process.exit(1);
53
+ }
54
+
55
+ const mb = createMoltbookClient(API_KEY);
56
+ await ensureInboxDir();
57
+
58
+ const seen = new Set(); // commentIds we've already pushed
59
+ // Pre-populate `seen` with any inbox items whose status is replied/skipped
60
+ // so a watcher restart does not resurrect them in the mobile unhandled list.
61
+ try {
62
+ const existing = await listInboxItems();
63
+ for (const item of existing) {
64
+ if (item.status === "replied" || item.status === "skipped") {
65
+ seen.add(String(item.commentId));
66
+ }
67
+ }
68
+ } catch (error) {
69
+ console.error(`[moltbook-watcher] inbox bootstrap failed: ${error.message}`);
70
+ }
71
+ const pending = new Map(); // sourceId -> { commentId, postId }
72
+
73
+ async function pushToBridge(item) {
74
+ const res = await fetch(`${VIVEWORKER_BASE}/api/providers/moltbook/events`, {
75
+ method: "POST",
76
+ headers: {
77
+ "content-type": "application/json",
78
+ "x-viveworker-hook-secret": HOOK_SECRET,
79
+ },
80
+ body: JSON.stringify(item),
81
+ });
82
+ if (!res.ok) {
83
+ throw new Error(`bridge ${res.status}: ${await res.text().catch(() => "")}`);
84
+ }
85
+ return res.json();
86
+ }
87
+
88
+ function draftReply() {
89
+ // Intentionally empty — drafting is done in Codex/Claude Desktop via the
90
+ // `viveworker moltbook` CLI. Kept as a function so a future plug-in can
91
+ // restore auto-drafting without touching pollOnce.
92
+ return "";
93
+ }
94
+
95
+ async function resolveAuthorName(postId, commentId, notifType, postCommentCache, postCache) {
96
+ if (!postId) return "";
97
+ try {
98
+ if (commentId) {
99
+ // Retry a couple of times with a small delay to absorb eventual
100
+ // consistency between /notifications and /posts/:id/comments.
101
+ for (let attempt = 0; attempt < 3; attempt += 1) {
102
+ if (attempt > 0) {
103
+ postCommentCache.delete(postId);
104
+ await new Promise((resolve) => setTimeout(resolve, 800));
105
+ }
106
+ if (!postCommentCache.has(postId)) {
107
+ const tree = await mb(`/posts/${postId}/comments?sort=new&limit=100`);
108
+ postCommentCache.set(postId, tree);
109
+ }
110
+ const tree = postCommentCache.get(postId);
111
+ const flat = [];
112
+ const walk = (nodes) => {
113
+ if (!Array.isArray(nodes)) return;
114
+ for (const node of nodes) {
115
+ if (!node) continue;
116
+ flat.push(node);
117
+ walk(node.replies || node.children || []);
118
+ }
119
+ };
120
+ walk(tree?.comments || tree?.data || tree || []);
121
+ const hit = flat.find((c) => String(c?.id || c?.comment_id || "") === String(commentId));
122
+ const a = hit?.author || hit?.user || {};
123
+ const name = a.username || a.name || a.handle || "";
124
+ if (name) return name;
125
+ }
126
+ }
127
+ // Only fall back to post author for mention notifications. For reply
128
+ // notifications, using the post author would misattribute the reply to
129
+ // the post owner (often viveworker itself).
130
+ const isMention = String(notifType || "").toLowerCase().includes("mention");
131
+ if (!isMention) return "";
132
+ if (!postCache.has(postId)) {
133
+ const post = await mb(`/posts/${postId}`);
134
+ postCache.set(postId, post);
135
+ }
136
+ const post = postCache.get(postId);
137
+ const pa = post?.post?.author || post?.author || post?.user || {};
138
+ return pa.username || pa.name || pa.handle || "";
139
+ } catch {
140
+ return "";
141
+ }
142
+ }
143
+
144
+ async function pollOnce() {
145
+ try {
146
+ const data = await mb(`/notifications`);
147
+ const notifications = extractNotifications(data);
148
+ const postCommentCache = new Map();
149
+ const postCache = new Map();
150
+ for (const n of notifications) {
151
+ if (!isCommentNotification(n)) continue;
152
+ if (n.is_read === true || n.read === true || n.isRead === true) continue;
153
+ const verificationStatus = String(
154
+ n.comment?.verification_status || n.comment?.verificationStatus || ""
155
+ ).toLowerCase();
156
+ if (verificationStatus && verificationStatus !== "verified") continue;
157
+ const commentId = String(
158
+ n.comment_id || n.relatedCommentId || n.comment?.id || n.id || ""
159
+ );
160
+ const postId = String(
161
+ n.post_id || n.relatedPostId || n.post?.id || n.comment?.post_id || n.comment?.postId || ""
162
+ );
163
+ if (!commentId || !postId) continue;
164
+ if (seen.has(commentId)) continue;
165
+ seen.add(commentId);
166
+ const sourceId = `comment:${commentId}`;
167
+ const author = n.actor || n.author || n.comment?.author || {};
168
+ let authorName = author.username || author.name || "";
169
+ if (!authorName) {
170
+ authorName = (await resolveAuthorName(postId, commentId, n.type || n.notificationType || "", postCommentCache, postCache)) || "user";
171
+ }
172
+ const postTitle = n.post?.title || n.post_title || "";
173
+ const contextText = String(n.comment?.content || n.content || n.preview || "");
174
+ const createdAtIso = n.created_at || n.createdAt || new Date().toISOString();
175
+ const parentCommentId = String(n.comment?.parent_id || n.parent_id || "");
176
+
177
+ pending.set(sourceId, { commentId, postId });
178
+
179
+ const inboxItem = {
180
+ commentId,
181
+ postId,
182
+ parentCommentId,
183
+ authorName,
184
+ postTitle,
185
+ postUrl: `https://www.moltbook.com/posts/${postId}`,
186
+ contextText,
187
+ createdAt: createdAtIso,
188
+ status: "pending",
189
+ source: "watcher",
190
+ };
191
+ try {
192
+ await writeInboxItem(inboxItem);
193
+ } catch (error) {
194
+ console.error(`[moltbook-watcher] inbox write failed: ${error.message}`);
195
+ }
196
+
197
+ try {
198
+ await pushToBridge({
199
+ sourceId,
200
+ eventType: "reply_request",
201
+ threadId: postId,
202
+ threadLabel: postTitle ? `Moltbook · ${postTitle}`.slice(0, 80) : "Moltbook",
203
+ title: `@${authorName} commented`,
204
+ summary: contextText.slice(0, 200),
205
+ contextText,
206
+ draftReply: draftReply(),
207
+ callbackUrl: CALLBACK_URL,
208
+ createdAtMs: Date.parse(createdAtIso) || Date.now(),
209
+ });
210
+ console.log(`[moltbook-watcher] pushed ${sourceId}`);
211
+ } catch (error) {
212
+ console.error(`[moltbook-watcher] bridge push failed: ${error.message}`);
213
+ }
214
+
215
+ try {
216
+ await mb(`/notifications/read-by-post/${postId}`, { method: "POST" });
217
+ } catch (error) {
218
+ console.error(`[moltbook-watcher] mark-read failed: ${error.message}`);
219
+ }
220
+ }
221
+ } catch (error) {
222
+ console.error(`[moltbook-watcher] poll error: ${error.message}`);
223
+ }
224
+ }
225
+
226
+ async function handleCallback(body) {
227
+ const sourceId = String(body.sourceId || "");
228
+ const action = String(body.action || "send");
229
+ const text = String(body.text || "").trim();
230
+ const ctx = pending.get(sourceId);
231
+ if (!ctx) {
232
+ return { ok: false, error: "unknown-sourceId" };
233
+ }
234
+ if (action !== "send" || !text) {
235
+ pending.delete(sourceId);
236
+ await updateInboxStatus(ctx.commentId, "skipped", { source: "mobile" });
237
+ return { ok: true, skipped: true };
238
+ }
239
+ try {
240
+ const result = await mb(`/posts/${ctx.postId}/comments`, {
241
+ method: "POST",
242
+ body: JSON.stringify({ content: text, parent_id: ctx.commentId }),
243
+ });
244
+ pending.delete(sourceId);
245
+ await updateInboxStatus(ctx.commentId, "replied", {
246
+ source: "mobile",
247
+ replyText: text,
248
+ replyVerification: result?.comment?.verification || null,
249
+ });
250
+ console.log(`[moltbook-watcher] replied to ${sourceId}`);
251
+ return { ok: true, verification: result?.comment?.verification || null };
252
+ } catch (error) {
253
+ console.error(`[moltbook-watcher] reply error: ${error.message}`);
254
+ return { ok: false, error: error.message };
255
+ }
256
+ }
257
+
258
+ const server = http.createServer(async (req, res) => {
259
+ if (req.method !== "POST" || !req.url?.endsWith("/callback")) {
260
+ res.statusCode = 404;
261
+ res.end();
262
+ return;
263
+ }
264
+ const secret = req.headers["x-viveworker-hook-secret"];
265
+ if (secret !== HOOK_SECRET) {
266
+ res.statusCode = 401;
267
+ res.end();
268
+ return;
269
+ }
270
+ let raw = "";
271
+ req.on("data", (chunk) => (raw += chunk));
272
+ req.on("end", async () => {
273
+ let body = {};
274
+ try {
275
+ body = JSON.parse(raw || "{}");
276
+ } catch {
277
+ res.statusCode = 400;
278
+ res.end();
279
+ return;
280
+ }
281
+ const out = await handleCallback(body);
282
+ res.setHeader("content-type", "application/json");
283
+ res.end(JSON.stringify(out));
284
+ });
285
+ });
286
+
287
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
288
+ console.log(`[moltbook-watcher] callback listening on http://${CALLBACK_HOST}:${CALLBACK_PORT}/callback`);
289
+ console.log(`[moltbook-watcher] inbox dir: ${DEFAULT_INBOX_DIR}`);
290
+ console.log(`[moltbook-watcher] polling every ${POLL_MS}ms`);
291
+ });
292
+
293
+ pollOnce();
294
+ setInterval(pollOnce, POLL_MS);