OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-daemon / daemons / crosspost.js

181 lines · javascript ref:
// Crosspost daemon — selective content syndication
// Routes messages between platforms based on configurable rules

const PLATFORM_LIMITS = {
  mastodon: 500,
  keet: 0, // no limit
  irc: 450,
  discord: 2000,
  matrix: 0 // no limit
}

const DEFAULT_RULES = [
  // keet messages tagged #public → mastodon
  { from: 'keet', to: 'mastodon', filter: { tags: ['public'] } },
  // all mastodon posts → keet
  { from: 'mastodon', to: 'keet', filter: {} }
]

// Check if a message matches a filter
function matchFilter (msg, filter) {
  if (!filter || Object.keys(filter).length === 0) return true

  // Tag filter — at least one tag must appear in the text
  if (filter.tags && filter.tags.length > 0) {
    const text = (msg.text || '').toLowerCase()
    const hasTag = filter.tags.some(tag => {
      const normalized = tag.toLowerCase()
      // Match #tag or plain tag
      return text.includes('#' + normalized) || text.includes(normalized)
    })
    if (!hasTag) return false
  }

  // Author filter — exact match
  if (filter.author && msg.author !== filter.author) return false

  // Type filter — message type (text, image, link, etc.)
  if (filter.type && msg.type !== filter.type) return false

  // Text regex pattern
  if (filter.pattern) {
    try {
      const regex = new RegExp(filter.pattern, 'i')
      if (!regex.test(msg.text || '')) return false
    } catch {
      return false
    }
  }

  // Contains — simple substring
  if (filter.contains) {
    if (!(msg.text || '').includes(filter.contains)) return false
  }

  return true
}

// Strip hashtags used for routing (e.g., #public) to keep output clean
function stripRoutingTags (text, tags) {
  if (!tags || !tags.length) return text
  let result = text
  for (const tag of tags) {
    // Remove #tag variants (case-insensitive)
    result = result.replace(new RegExp('#' + tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), '')
  }
  return result.replace(/\s{2,}/g, ' ').trim()
}

// Adapt message text for target platform
function formatForTarget (msg, target, rule) {
  const author = msg.author || 'unknown'
  const source = msg.source || 'unknown'
  let text = msg.text || ''

  // Strip routing tags from output
  if (rule && rule.filter && rule.filter.tags) {
    text = stripRoutingTags(text, rule.filter.tags)
  }

  const attribution = `[via ${source}/${author}]`

  switch (target) {
    case 'mastodon': {
      const limit = PLATFORM_LIMITS.mastodon
      const prefix = attribution + ' '
      const available = limit - prefix.length
      if (text.length > available) {
        text = text.slice(0, available - 3) + '...'
      }
      return prefix + text
    }

    case 'keet': {
      return `${attribution} ${text}`
    }

    case 'irc': {
      const limit = PLATFORM_LIMITS.irc
      const prefix = `<${author}@${source}> `
      const available = limit - prefix.length
      if (text.length > available) {
        text = text.slice(0, available - 3) + '...'
      }
      return prefix + text
    }

    case 'discord': {
      return `**${author}** (from ${source}):\n${text}`
    }

    case 'matrix': {
      return `[${source}] <${author}> ${text}`
    }

    default: {
      return `${attribution} ${text}`
    }
  }
}

const crosspost = {
  name: 'crosspost',
  description: 'Selective syndication -- route content to specific platforms',

  defaultConfig: {
    rules: DEFAULT_RULES
  },

  // Process a message through all rules
  // msg: { source, text, tags, author, type, timestamp }
  // config: { rules: [...] }
  // Returns: [{ target, text, metadata }]
  process (msg, config) {
    const rules = (config && config.rules) || DEFAULT_RULES
    const actions = []

    for (const rule of rules) {
      // Source platform must match
      if (msg.source !== rule.from) continue

      // Filter must match
      if (!matchFilter(msg, rule.filter)) continue

      actions.push({
        target: rule.to,
        text: formatForTarget(msg, rule.to, rule),
        metadata: {
          crosspostedFrom: msg.source,
          originalAuthor: msg.author,
          originalTimestamp: msg.timestamp || Date.now(),
          rule: { from: rule.from, to: rule.to }
        }
      })
    }

    return actions
  },

  // Generate a daemon script for the DaemonRuntime
  // Wraps process() into the trigger/action format the runtime expects
  toDaemon (config, opts = {}) {
    const targetRoom = opts.targetRoom
    const sourceRoom = opts.sourceRoom

    return {
      trigger: {
        type: 'message',
        filter: {}
      },
      actions: [{
        type: 'send-message',
        roomId: targetRoom,
        text: `[crosspost from ${(sourceRoom || '').slice(0, 8) || 'source'}] {author}: {text}`
      }],
      enabled: true
    }
  }
}

export { crosspost, matchFilter, formatForTarget, stripRoutingTags, PLATFORM_LIMITS }