OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-social-web / lib / posse.js

279 lines · javascript ref:
/**
 * 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(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .trim()
}

export { POSSEEngine }