-
Notifications
You must be signed in to change notification settings - Fork 0
/
replies.js
683 lines (592 loc) · 22.2 KB
/
replies.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
import { debug, getBlueskyAuth } from './bot.js';
// Cache to store our bot's recent posts
const recentPosts = new Map();
// Local storage for development
class LocalStorage {
constructor() {
this.store = new Map();
}
async put(key, value) {
this.store.set(key, value);
return Promise.resolve();
}
async get(key) {
return Promise.resolve(this.store.get(key));
}
async delete(key) {
this.store.delete(key);
return Promise.resolve();
}
async list({ prefix }) {
const keys = Array.from(this.store.keys())
.filter(key => key.startsWith(prefix))
.map(name => ({ name }));
return Promise.resolve({ keys });
}
}
// KV namespace for storing posts
let _postsKV = null;
let kvInitialized = false;
const _localStorage = new LocalStorage();
// Initialize KV namespace
async function initializeKV(namespace) {
if (!namespace) {
debug('No KV namespace provided, using local storage', 'warn');
_postsKV = _localStorage;
} else {
_postsKV = namespace;
}
kvInitialized = true;
}
// Helper to get KV namespace
function getKVNamespace() {
return _postsKV;
}
// Load recent posts from KV storage
async function loadRecentPostsFromKV() {
if (!kvInitialized) {
throw new Error('Cannot load posts - KV not initialized');
}
try {
const kv = getKVNamespace();
debug('Loading posts from storage...', 'info');
const { keys } = await kv.list({ prefix: 'post:' });
debug('Found posts in storage', 'info', { count: keys.length });
// Clear existing cache before loading
recentPosts.clear();
for (const key of keys) {
const post = await kv.get(key.name);
if (post) {
const [platform, postId] = key.name.replace('post:', '').split(':');
const parsedPost = JSON.parse(post);
recentPosts.set(`${platform}:${postId}`, parsedPost);
debug('Loaded post', 'info', {
platform,
postId,
content: parsedPost.content.substring(0, 50) + '...'
});
}
}
debug('Loaded all posts from storage', 'info', {
count: recentPosts.size,
keys: Array.from(recentPosts.keys())
});
} catch (error) {
debug('Error loading posts from storage:', 'error', error);
throw error;
}
}
// Helper function to extract post ID from Mastodon URL
function extractMastodonPostId(url) {
const match = url.match(/\/(\d+)$/);
return match ? match[1] : null;
}
// Helper function to extract post ID from Bluesky URL
function extractBlueskyPostId(url) {
debug('Extracting Bluesky post ID from URL:', 'verbose', url);
const match = url.match(/\/post\/([a-zA-Z0-9]+)$/);
debug('Match result:', 'verbose', match);
return match ? match[1] : null;
}
// Helper function to extract handle from Bluesky URL
function extractBlueskyHandle(url) {
debug('Extracting Bluesky handle from URL:', 'verbose', url);
const match = url.match(/\/profile\/([^/]+)/);
debug('Match result:', 'verbose', match);
return match ? match[1] : null;
}
// Fetch post content from Mastodon URL
async function fetchMastodonPost(url) {
try {
const postId = extractMastodonPostId(url);
if (!postId) {
throw new Error('Invalid Mastodon post URL');
}
const response = await fetch(`${process.env.MASTODON_API_URL}/api/v1/statuses/${postId}`, {
headers: {
'Authorization': `Bearer ${process.env.MASTODON_ACCESS_TOKEN}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch Mastodon post');
}
const post = await response.json();
return post.content.replace(/<[^>]+>/g, ''); // Strip HTML tags
} catch (error) {
debug('Error fetching Mastodon post:', 'error', error);
return null;
}
}
// Fetch post content from Bluesky URL
async function fetchBlueskyPost(url) {
try {
const postId = extractBlueskyPostId(url);
debug('Extracted post ID:', 'verbose', postId);
if (!postId) {
throw new Error('Invalid Bluesky post URL - could not extract post ID');
}
const handle = extractBlueskyHandle(url);
debug('Extracted handle:', 'verbose', handle);
if (!handle) {
throw new Error('Invalid Bluesky URL format - could not extract handle');
}
const apiUrl = `${process.env.BLUESKY_API_URL}/xrpc/com.atproto.repo.getRecord?repo=${handle}&collection=app.bsky.feed.post&rkey=${postId}`;
debug('Fetching from URL:', 'verbose', apiUrl);
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
debug('Bluesky API error:', 'error', errorText);
throw new Error(`Failed to fetch Bluesky post: ${response.status} ${response.statusText}`);
}
const post = await response.json();
debug('Received post data:', 'verbose', post);
return post.value.text;
} catch (error) {
debug('Error fetching Bluesky post:', 'error', error);
return null;
}
}
// Fetch post content from URL
async function fetchPostContent(postUrl) {
if (postUrl.includes('mastodon') || postUrl.includes('hachyderm.io')) {
return await fetchMastodonPost(postUrl);
} else if (postUrl.includes('bsky.app')) {
return await fetchBlueskyPost(postUrl);
} else {
throw new Error('Unsupported platform URL');
}
}
// Store a new post from our bot
async function storeRecentPost(platform, postId, content) {
debug('Storing recent post', 'info', {
platform,
postId,
content: content.substring(0, 50) + '...',
cacheSize: recentPosts.size
});
const key = `${platform}:${postId}`;
const post = { content, timestamp: Date.now() };
// Store in memory
recentPosts.set(key, post);
// Store in storage
try {
const kv = getKVNamespace();
if (!kv) {
throw new Error('Storage not initialized');
}
await kv.put(`post:${key}`, JSON.stringify(post));
debug('Stored post in storage', 'info', {
key,
postCount: recentPosts.size,
storage: 'KV'
});
// Clean up old posts (older than 24 hours)
const now = Date.now();
const oldPosts = [];
for (const [existingKey, existingPost] of recentPosts.entries()) {
if (now - existingPost.timestamp > 24 * 60 * 60 * 1000) {
oldPosts.push(existingKey);
}
}
// Remove old posts
for (const oldKey of oldPosts) {
debug('Removing old post', 'info', { key: oldKey });
recentPosts.delete(oldKey);
await kv.delete(`post:${oldKey}`);
}
debug('Storage cleanup complete', 'info', {
removed: oldPosts.length,
remaining: recentPosts.size
});
} catch (error) {
debug('Error in post storage:', 'error', {
error: error.message,
stack: error.stack
});
throw error;
}
}
// Get the original post content
async function getOriginalPost(platform, postId) {
const key = `${platform}:${postId}`;
debug('Getting original post', 'info', {
platform,
postId,
key,
exists: recentPosts.has(key),
cacheSize: recentPosts.size,
cacheKeys: Array.from(recentPosts.keys())
});
// First check memory cache
let post = recentPosts.get(key);
// If not in memory, try loading from storage
if (!post) {
try {
const kv = getKVNamespace();
const storedPost = await kv.get(`post:${key}`);
if (storedPost) {
try {
post = JSON.parse(storedPost);
// Add back to memory cache
recentPosts.set(key, post);
debug('Loaded post from storage', 'info', {
key,
content: post.content?.substring(0, 50)
});
} catch (parseError) {
debug('Error parsing stored post:', 'error', {
error: parseError,
storedPost
});
}
} else {
debug('Post not found in storage', 'info', { key });
}
} catch (error) {
debug('Error loading post from storage:', 'error', error);
}
}
if (!post || !post.content) {
debug('Post not found in cache or storage', 'info', {
key,
hasPost: !!post,
hasContent: !!(post && post.content)
});
return null;
}
debug('Found post', 'info', {
key,
content: post.content.substring(0, 50) + '...'
});
return post.content;
}
// Generate a reply using OpenAI
async function generateReply(originalPost, replyContent) {
try {
if (!originalPost || !replyContent) {
debug('Missing required content for reply generation', 'error', {
hasOriginalPost: !!originalPost,
hasReplyContent: !!replyContent
});
return null;
}
debug('Generating reply with OpenAI', 'info', {
originalPost: originalPost?.substring(0, 100),
replyContent: replyContent?.substring(0, 100)
});
// Clean up the posts
const cleanOriginal = originalPost
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\s+/g, ' ') // Normalize whitespace
.replace(/@[\w]+/g, '') // Remove user mentions
.trim();
const cleanReplyContent = replyContent
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.replace(/@[\w]+/g, '') // Remove user mentions
.trim();
// Create the prompt
const prompt = `As a witty and engaging social media bot, generate a brief, clever reply to this conversation.
DO NOT include any @mentions or usernames in your response - those will be handled separately.
DO NOT use hashtags unless they're contextually relevant.
Keep the response under 400 characters.
Original post: "${cleanOriginal}"
Reply to original: "${cleanReplyContent}"
Generate a witty response that:
1. Is relevant to the conversation
2. Shows personality but stays respectful
3. Encourages further engagement
4. Is concise and to the point
Your response:`;
// Get completion from OpenAI
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{
role: 'user',
content: prompt
}],
temperature: 0.7,
max_tokens: 150
})
});
if (!response.ok) {
debug('OpenAI API error:', 'error', {
status: response.status,
statusText: response.statusText
});
return null;
}
const data = await response.json();
const reply = data.choices[0]?.message?.content?.trim();
if (!reply) {
debug('No reply generated from OpenAI', 'warn');
return null;
}
// Clean up any remaining mentions or formatting
const cleanReply = reply
.replace(/@[\w]+/g, '') // Remove any mentions that might have slipped through
.replace(/^["']|["']$/g, '') // Remove quotes if present
.trim();
debug('Generated reply', 'info', { reply: cleanReply });
return cleanReply;
} catch (error) {
debug('Error generating reply:', 'error', error);
return null;
}
}
// Track rate limit state for OpenAI
const _rateLimitState = {
lastError: null,
backoffMinutes: 5,
maxBackoffMinutes: 60,
resetTime: null
};
// Fallback responses when rate limited
const fallbackResponses = [
'Hmm, I need a moment to think about that one...',
'My circuits are a bit overloaded right now...',
'Give me a minute to process that...',
'Taking a quick break to cool my processors...',
'Sometimes even bots need a moment to reflect...',
'Processing... please stand by...'
];
// Get a random fallback response
function _getFallbackResponse() {
const index = Math.floor(Math.random() * fallbackResponses.length);
return fallbackResponses[index];
}
// Handle a reply on Mastodon
async function handleMastodonReply(notification) {
try {
debug('Processing Mastodon reply...', 'info', {
id: notification.id,
type: notification.type,
account: notification.account?.username,
status: {
id: notification.status.id,
content: notification.status.content,
in_reply_to_id: notification.status.in_reply_to_id
}
});
// Check if we've already replied to this notification
const replyKey = `replied:mastodon:${notification.id}`;
const hasReplied = await getKVNamespace().get(replyKey);
if (hasReplied) {
debug('Already replied to this notification', 'info', { replyKey });
return;
}
// Clean the content
const content = notification.status.content;
const cleanedContent = content
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
debug('Cleaned content', 'info', {
original: content,
cleaned: cleanedContent
});
// Get the original post we're replying to
const originalPostId = notification.status.in_reply_to_id;
// Try to get the original post from our cache first
let originalPost = await getOriginalPost('mastodon', originalPostId);
// If not in cache, fetch it from Mastodon
if (!originalPost) {
debug('Original post not in cache, fetching from Mastodon...', 'info', { originalPostId });
const response = await fetch(`${process.env.MASTODON_API_URL}/api/v1/statuses/${originalPostId}`, {
headers: {
'Authorization': `Bearer ${process.env.MASTODON_ACCESS_TOKEN}`
}
});
if (!response.ok) {
debug('Failed to fetch original post', 'error', {
status: response.status,
statusText: response.statusText
});
return;
}
const post = await response.json();
originalPost = post.content;
// Store the post for future reference
await storeRecentPost('mastodon', originalPostId, originalPost);
}
// If we still don't have the original post, skip
if (!originalPost) {
debug('Could not find original post, skipping', 'warn', { originalPostId });
return;
}
// Check if this is a reply to our own post
const isOurPost = await getOriginalPost('mastodon', notification.status.id);
if (isOurPost) {
debug('Skipping reply to our own post', 'info', { postId: notification.status.id });
// Mark as replied to prevent future processing
await getKVNamespace().put(replyKey, 'true', { expirationTtl: 86400 }); // 24 hours
return;
}
// Generate and post the reply
const reply = await generateReply(originalPost, cleanedContent);
if (!reply) {
debug('No reply generated', 'warn');
// Still mark as processed to prevent retries
await getKVNamespace().put(replyKey, 'true', { expirationTtl: 86400 }); // 24 hours
return;
}
// Add the user mention to the reply
const userHandle = notification.account?.acct || notification.account?.username;
const replyWithMention = `@${userHandle} ${reply}`;
// Post the reply
if (process.env.DEBUG_MODE !== 'true') {
const response = await fetch(`${process.env.MASTODON_API_URL}/api/v1/statuses`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MASTODON_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status: replyWithMention,
in_reply_to_id: notification.status.id, // Reply to the notification that mentioned us
visibility: 'public'
})
});
if (!response.ok) {
debug('Failed to post reply', 'error', {
status: response.status,
statusText: response.statusText
});
return;
}
const postedReply = await response.json();
await storeRecentPost('mastodon', postedReply.id, replyWithMention);
// Mark as replied to prevent duplicate replies
await getKVNamespace().put(replyKey, 'true', { expirationTtl: 86400 }); // 24 hours
debug('Successfully posted reply', 'info', {
replyId: postedReply.id,
inReplyTo: notification.status.id,
userHandle,
content: replyWithMention
});
} else {
debug('Debug mode: Would have posted reply', 'info', {
content: replyWithMention,
inReplyTo: notification.status.id,
userHandle
});
// Even in debug mode, mark as replied to prevent duplicate processing
await getKVNamespace().put(replyKey, 'true', { expirationTtl: 86400 }); // 24 hours
}
} catch (error) {
debug('Error handling Mastodon reply:', 'error', error);
}
}
// Handle replies on Bluesky
async function handleBlueskyReply(notification) {
try {
debug('Processing Bluesky reply...', notification);
// Load recent posts from storage if needed
if (recentPosts.size === 0) {
await loadRecentPostsFromKV();
}
// Check if this is a reply to one of our posts
const originalPost = await getOriginalPost('bluesky', notification.reasonSubject);
if (!originalPost) {
debug('Not a reply to our post', 'info', {
replyToId: notification.reasonSubject,
recentPostsCount: recentPosts.size,
recentPostKeys: Array.from(recentPosts.keys())
});
return;
}
// Check if we've already replied to this post
const replyKey = `replied:bluesky:${notification.uri}`;
const hasReplied = await getKVNamespace().get(replyKey);
if (hasReplied) {
debug('Already replied to this post', 'info', { replyKey });
return;
}
// Generate the reply
const generatedReply = await generateReply(originalPost, notification.record.text);
if (!generatedReply) {
debug('Failed to generate reply');
return;
}
// In debug mode, just log what would have been posted
if (process.env.DEBUG_MODE === 'true') {
debug('Debug mode: Would reply to Bluesky post', 'info', {
originalPost,
replyTo: notification.record.text,
generatedReply,
notification
});
// Still store that we "replied" to prevent duplicate debug logs
await getKVNamespace().put(replyKey, 'true');
debug('Marked post as replied to (debug mode)', 'info', { replyKey });
return;
}
// Get auth token
const auth = await getBlueskyAuth();
if (!auth || !auth.accessJwt) {
throw new Error('Failed to authenticate with Bluesky');
}
// Create the post
const response = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
repo: auth.did,
collection: 'app.bsky.feed.post',
record: {
text: generatedReply,
reply: {
root: {
uri: notification.reasonSubject,
cid: notification.record.reply?.root?.cid
},
parent: {
uri: notification.uri,
cid: notification.cid
}
},
createdAt: new Date().toISOString()
}
})
});
if (!response.ok) {
const errorData = await response.text();
debug('Error response from Bluesky:', 'error', {
status: response.status,
statusText: response.statusText,
error: errorData
});
throw new Error(`Failed to post reply: ${errorData}`);
}
// Mark this post as replied to
await getKVNamespace().put(replyKey, 'true');
debug('Successfully replied to Bluesky post', 'info', { replyKey });
} catch (error) {
debug('Error handling Bluesky reply:', 'error', error);
}
}
// Export all functions
export {
handleMastodonReply,
handleBlueskyReply,
generateReply,
fetchPostContent,
loadRecentPostsFromKV,
storeRecentPost,
getOriginalPost,
initializeKV
};