// 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 }