/**
* POSSE Engine — Publish Own Site, Syndicate Elsewhere
*
* Compose once, publish to selected platforms.
* Stores all posts and ingested timeline items in FeedStore.
*/
class POSSEEngine {
constructor (feedStore) {
this.feedStore = feedStore
this.clients = new Map() // id -> { platform, client }
}
addClient (id, platform, client) {
this.clients.set(id, { platform, client })
}
removeClient (id) {
this.clients.delete(id)
}
getClients (platform) {
const result = []
for (const [id, entry] of this.clients) {
if (!platform || entry.platform === platform) {
result.push({ id, ...entry })
}
}
return result
}
/**
* Publish content to selected platforms
* @param {string} text - Post text
* @param {string[]} targets - Platform names to publish to (e.g., ['mastodon', 'atproto'])
* @param {object} opts - Additional options (media, replyTo, etc.)
*/
async publish (text, targets = [], opts = {}) {
if (!text || !text.trim()) throw new Error('Text is required')
const results = {}
const errors = {}
// media: { buffer, mimeType, width, height } from processMedia()
const media = opts.media || null
for (const target of targets) {
const clients = this.getClients(target)
if (clients.length === 0) {
errors[target] = 'No connected account'
continue
}
for (const { id, client } of clients) {
try {
if (target === 'mastodon') {
const postOpts = { visibility: opts.visibility || 'public' }
if (media) {
const uploaded = await client.uploadMedia(media.buffer, media.mimeType, opts.altText || '')
postOpts.mediaIds = [uploaded.id]
}
const status = await client.postStatus(text, postOpts)
results[id] = { platform: target, id: status.id, url: status.url }
} else if (target === 'atproto') {
const postOpts = {}
if (media) {
const uploaded = await client.uploadBlob(media.buffer, media.mimeType)
postOpts.images = [{
blob: uploaded.blob,
alt: opts.altText || '',
width: media.width,
height: media.height
}]
}
const record = await client.post(text, postOpts)
results[id] = { platform: target, uri: record.uri, cid: record.cid }
}
} catch (err) {
errors[id] = err.message
}
}
}
// Store in feed
const item = await this.feedStore.add({
source: 'local',
author: 'me',
text,
type: media ? 'media' : 'post',
mediaUrl: media ? '(uploaded)' : null,
mediaType: media ? media.mimeType : null,
syndicated: results,
metadata: { targets, errors: Object.keys(errors).length > 0 ? errors : undefined }
})
return { item, results, errors }
}
/**
* Refresh — pull latest posts from all connected platforms into feed store
*/
async refresh () {
let count = 0
for (const [id, { platform, client }] of this.clients) {
try {
if (platform === 'mastodon') {
count += await this._refreshMastodon(id, client)
} else if (platform === 'atproto') {
count += await this._refreshAtproto(id, client)
}
} catch (err) {
console.error(`[posse] Refresh ${id} failed:`, err.message)
}
}
return count
}
async _refreshMastodon (accountId, client) {
const statuses = await client.homeTimeline(30)
let count = 0
for (const status of statuses) {
const actual = status.reblog || status
const author = actual.account?.acct || actual.account?.username || 'unknown'
const text = stripHtml(actual.content || '')
if (!text && !actual.media_attachments?.length) continue
// Deduplicate by platform-specific ID
const itemId = `mastodon:${actual.id}`
const existing = await this.feedStore.get(itemId)
if (existing) continue
const attachment = actual.media_attachments?.[0]
await this.feedStore.add({
id: itemId,
source: 'mastodon',
author,
authorId: `mastodon:${actual.account?.id || author}`,
text,
timestamp: new Date(actual.created_at).getTime(),
type: status.reblog ? 'boost' : (actual.in_reply_to_id ? 'reply' : 'post'),
mediaUrl: attachment?.url || null,
mediaType: attachment?.type || null,
tags: (actual.tags || []).map(t => t.name),
url: actual.url,
metadata: {
statusId: actual.id,
avatar: actual.account?.avatar_static || actual.account?.avatar || null,
displayName: actual.account?.display_name || '',
spoilerText: actual.spoiler_text || '',
isBoost: !!status.reblog,
boostBy: status.reblog ? (status.account?.acct || '') : '',
boostByAvatar: status.reblog ? (status.account?.avatar_static || status.account?.avatar || null) : null,
visibility: actual.visibility
}
})
count++
}
return count
}
async _refreshAtproto (accountId, client) {
const feed = await client.getTimeline(30)
let count = 0
for (const item of feed) {
const post = item.post
if (!post) continue
const author = post.author?.handle || post.author?.did || 'unknown'
const text = post.record?.text || ''
if (!text) continue
const itemId = `atproto:${post.uri}`
const existing = await this.feedStore.get(itemId)
if (existing) continue
const embed = post.embed
let mediaUrl = null
let mediaType = null
if (embed?.images?.[0]) {
mediaUrl = embed.images[0].fullsize || embed.images[0].thumb
mediaType = 'image'
}
await this.feedStore.add({
id: itemId,
source: 'atproto',
author,
authorId: `atproto:${post.author?.did || author}`,
text,
timestamp: new Date(post.record?.createdAt || post.indexedAt).getTime(),
type: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ? 'boost' : (post.record?.reply ? 'reply' : 'post'),
mediaUrl,
mediaType,
tags: [],
url: `https://bsky.app/profile/${post.author?.handle}/post/${post.uri.split('/').pop()}`,
metadata: {
uri: post.uri,
cid: post.cid,
avatar: post.author?.avatar || null,
displayName: post.author?.displayName || '',
likeCount: post.likeCount || 0,
repostCount: post.repostCount || 0,
replyCount: post.replyCount || 0
}
})
count++
}
return count
}
/**
* Get notifications from all connected platforms
*/
async notifications () {
const all = []
for (const [id, { platform, client }] of this.clients) {
try {
if (platform === 'mastodon') {
const notifs = await client.notifications(20)
for (const n of notifs) {
all.push({
platform: 'mastodon',
type: n.type, // mention, reblog, favourite, follow
author: n.account?.acct || 'unknown',
text: n.status ? stripHtml(n.status.content || '') : '',
timestamp: new Date(n.created_at).getTime()
})
}
} else if (platform === 'atproto') {
const notifs = await client.getNotifications(20)
for (const n of notifs) {
all.push({
platform: 'atproto',
type: n.reason, // like, repost, follow, mention, reply
author: n.author?.handle || 'unknown',
text: n.record?.text || '',
timestamp: new Date(n.indexedAt).getTime()
})
}
}
} catch (err) {
console.error(`[posse] Notifications ${id} failed:`, err.message)
}
}
// Sort by timestamp descending
all.sort((a, b) => b.timestamp - a.timestamp)
return all.slice(0, 50)
}
}
function stripHtml (html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.trim()
}
export { POSSEEngine }