Skip to content

Commit

Permalink
initial push of functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
jerdog committed Dec 5, 2024
1 parent 19ac509 commit de66134
Show file tree
Hide file tree
Showing 6 changed files with 376 additions and 30 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,4 @@ wrangler.toml
*.tmp
.temp/
.cache/
test-payload.json
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ A serverless bot that generates and posts content using Markov chain text genera

## Features

- Generates unique content using Markov chain text generation
- Posts to multiple platforms (Mastodon, Bluesky)
- Configurable posting frequency (30% chance to post)
- Source content management through KV storage
- Configurable excluded words and content parameters
- Debug mode for detailed logging
### Content Generation
- Generates unique social media content using Markov chains
- Configurable parameters for content generation
- Filters out excluded words and phrases
- 30% random posting probability

### Multi-Platform Support
- Posts to Mastodon
- Posts to Bluesky
- Extensible for additional platforms

### AI-Powered Reply Generation
- Generates witty, contextual replies using ChatGPT
- Supports both Mastodon and Bluesky post URLs
- Test endpoint for trying replies before posting
- Configurable response style and tone

## Configuration

Expand All @@ -20,6 +30,7 @@ A serverless bot that generates and posts content using Markov chain text genera
- `BLUESKY_API_URL` - Bluesky API URL (default: https://bsky.social)
- `BLUESKY_USERNAME` - Your Bluesky username
- `BLUESKY_PASSWORD` - Your Bluesky app password
- `OPENAI_API_KEY` - Your OpenAI API key (required for reply generation)

### Optional Environment Variables

Expand Down Expand Up @@ -51,6 +62,7 @@ A serverless bot that generates and posts content using Markov chain text genera
BLUESKY_SOURCE_ACCOUNTS[email protected]
DEBUG_MODE=true
DEBUG_LEVEL=verbose
OPENAI_API_KEY=your_openai_api_key
```

3. Start the development server:
Expand All @@ -63,6 +75,7 @@ A serverless bot that generates and posts content using Markov chain text genera
- `POST /run` - Execute the bot (30% chance to post)
- `POST /upload-tweets` - Upload source content
- `GET /upload-tweets` - Get source content count
- `POST /test-reply` - Test AI-powered reply generation

## Deployment

Expand Down
69 changes: 46 additions & 23 deletions bot.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Essential imports only
import fetch from 'node-fetch';
import { getSourceTweets } from './kv.js';
import { storeRecentPost } from './replies.js';

// HTML processing functions
function decodeHtmlEntities(text) {
Expand Down Expand Up @@ -583,49 +584,71 @@ async function generatePost(content) {

// Social Media Integration
async function postToMastodon(content) {
const response = await fetch(`${CONFIG.mastodon.url}/api/v1/statuses`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.mastodon.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: content })
});
return await response.json();
try {
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: content })
});

if (!response.ok) {
throw new Error(`Failed to post to Mastodon: ${response.statusText}`);
}

const data = await response.json();
storeRecentPost('mastodon', data.id, content);
debug('Posted to Mastodon successfully');
return true;
} catch (error) {
debug('Error posting to Mastodon:', 'error', error);
return false;
}
}

async function postToBluesky(content) {
try {
const did = await getBlueskyDid();
debug('Creating Bluesky post record...');

const postRecord = {
$type: 'app.bsky.feed.post',
text: content,
createdAt: new Date().toISOString()
};
// Get existing auth if available
let auth = blueskyAuth;

Check failure on line 614 in bot.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'blueskyAuth' is not defined

// If no auth or expired, create new session
if (!auth) {
auth = await getBlueskyAuth();
if (!auth) {
throw new Error('Failed to authenticate with Bluesky');
}
blueskyAuth = auth;

Check failure on line 622 in bot.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'blueskyAuth' is not defined
}

const response = await fetch(`${process.env.BLUESKY_API_URL}/xrpc/com.atproto.repo.createRecord`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${await getBlueskyAuth()}`,
'Authorization': `Bearer ${auth.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
repo: did,
repo: auth.did,
collection: 'app.bsky.feed.post',
record: postRecord
record: {
text: content,
createdAt: new Date().toISOString()
}
})
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Unknown error');
throw new Error(`Failed to post to Bluesky: ${response.statusText}`);
}

const data = await response.json();
storeRecentPost('bluesky', data.uri, content);
debug('Posted to Bluesky successfully');
return true;
} catch (error) {
debug('Bluesky posting failed: ' + error.message, 'error', error);
debug('Error posting to Bluesky:', 'error', error);
blueskyAuth = null; // Clear auth on error

Check failure on line 651 in bot.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'blueskyAuth' is not defined
return false;
}
}
Expand Down
221 changes: 221 additions & 0 deletions replies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { debug } from './bot.js';

// Cache to store our bot's recent posts
const recentPosts = new Map();

// 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
export 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
export function storeRecentPost(platform, postId, content) {
recentPosts.set(`${platform}:${postId}`, {
content,
timestamp: Date.now()
});

// Clean up old posts (older than 24 hours)
const dayAgo = Date.now() - (24 * 60 * 60 * 1000);
for (const [key, value] of recentPosts.entries()) {
if (value.timestamp < dayAgo) {
recentPosts.delete(key);
}
}
}

// Get the original post content
export function getOriginalPost(platform, postId) {
return recentPosts.get(`${platform}:${postId}`);
}

// Generate a reply using ChatGPT
export async function generateReply(originalPost, replyContent) {
try {
debug('Generating reply with ChatGPT...', 'verbose', { originalPost, replyContent });
debug('Using API key:', 'verbose', process.env.OPENAI_API_KEY ? 'Present' : 'Missing');

const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: "gpt-4",

Check failure on line 139 in replies.js

View workflow job for this annotation

GitHub Actions / test (20.x)

Strings must use singlequote
messages: [
{
role: "system",

Check failure on line 142 in replies.js

View workflow job for this annotation

GitHub Actions / test (20.x)

Strings must use singlequote
content: "You are a witty, funny, and slightly off-the-wall social media bot. Your responses should be engaging, humorous, and occasionally absurd, while still being relevant to the conversation. Keep responses under 280 characters."

Check failure on line 143 in replies.js

View workflow job for this annotation

GitHub Actions / test (20.x)

Strings must use singlequote
},
{
role: "user",

Check failure on line 146 in replies.js

View workflow job for this annotation

GitHub Actions / test (20.x)

Strings must use singlequote
content: `Original post: "${originalPost}"\nSomeone replied with: "${replyContent}"\nGenerate a witty and funny response:`
}
],
max_tokens: 100,
temperature: 0.9
})
});

if (!response.ok) {
const errorText = await response.text();
debug('ChatGPT API error:', 'error', errorText);
throw new Error(`ChatGPT API error: ${response.status} ${response.statusText}`);
}

const data = await response.json();
debug('ChatGPT response:', 'verbose', data);

if (data.choices && data.choices[0] && data.choices[0].message) {
return data.choices[0].message.content.trim();
}
throw new Error('Invalid response from ChatGPT');
} catch (error) {
debug('Error generating reply with ChatGPT:', 'error', error);
return null;
}
}

// Handle replies on Mastodon
export async function handleMastodonReply(notification) {
try {
if (notification.type !== 'mention') return;

const replyToId = notification.status.in_reply_to_id;
if (!replyToId) return;

const originalPost = getOriginalPost('mastodon', replyToId);
if (!originalPost) return;

const replyContent = notification.status.content;
const reply = await generateReply(originalPost.content, replyContent);

if (reply) {
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: reply,
in_reply_to_id: notification.status.id
})
});

if (!response.ok) {
throw new Error(`Failed to post Mastodon reply: ${response.statusText}`);
}

debug('Successfully posted reply to Mastodon', 'info', { reply });
}
} catch (error) {
debug('Error handling Mastodon reply:', 'error', error);
}
}

// Handle replies on Bluesky
export async function handleBlueskyReply(notification) {

Check failure on line 213 in replies.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'notification' is defined but never used. Allowed unused args must match /^_/u
try {
// Implement Bluesky reply handling here
// This will be similar to Mastodon but use Bluesky's API
debug('Bluesky reply handling not yet implemented', 'warn');
} catch (error) {
debug('Error handling Bluesky reply:', 'error', error);
}
}
4 changes: 4 additions & 0 deletions test-payload.json-example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"postUrl": "insert-social-media-url-here",
"replyContent": "insert-test-reply-content-here"
}
Loading

0 comments on commit de66134

Please sign in to comment.