import test from 'brittle'
import { crosspost, matchFilter, formatForTarget, stripRoutingTags, PLATFORM_LIMITS } from '../daemons/crosspost.js'
import {
summarize, extractTopics, extractKeyMessages, extractParticipants,
getTimeRange, formatSummary, formatDuration, tokenize
} from '../daemons/summarize.js'
// ---------------------------------------------------------------------------
// Crosspost tests
// ---------------------------------------------------------------------------
test('crosspost - match by source platform', (t) => {
const msg = { source: 'keet', text: 'Hello #public world', author: 'alice' }
const config = {
rules: [
{ from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
]
}
const actions = crosspost.process(msg, config)
t.is(actions.length, 1)
t.is(actions[0].target, 'mastodon')
})
test('crosspost - no match when source differs', (t) => {
const msg = { source: 'discord', text: 'Hello #public world', author: 'alice' }
const config = {
rules: [
{ from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
]
}
const actions = crosspost.process(msg, config)
t.is(actions.length, 0)
})
test('crosspost - match by tag filter', (t) => {
const msg = { source: 'keet', text: 'Check this out #public', author: 'bob' }
const result = matchFilter(msg, { tags: ['public'] })
t.ok(result)
})
test('crosspost - no match when tag absent', (t) => {
const msg = { source: 'keet', text: 'This is private stuff', author: 'bob' }
const result = matchFilter(msg, { tags: ['public'] })
t.absent(result)
})
test('crosspost - match by type filter', (t) => {
const msg = { source: 'keet', text: 'Image post', type: 'image', author: 'alice' }
const result = matchFilter(msg, { type: 'image' })
t.ok(result)
})
test('crosspost - no match when type differs', (t) => {
const msg = { source: 'keet', text: 'Text post', type: 'text', author: 'alice' }
const result = matchFilter(msg, { type: 'image' })
t.absent(result)
})
test('crosspost - match by author filter', (t) => {
const msg = { source: 'keet', text: 'Hello', author: 'alice' }
const result = matchFilter(msg, { author: 'alice' })
t.ok(result)
})
test('crosspost - no match when author differs', (t) => {
const msg = { source: 'keet', text: 'Hello', author: 'bob' }
const result = matchFilter(msg, { author: 'alice' })
t.absent(result)
})
test('crosspost - match by pattern filter', (t) => {
const msg = { source: 'keet', text: 'Check https://example.com for details', author: 'alice' }
const result = matchFilter(msg, { pattern: 'https?://\\S+' })
t.ok(result)
})
test('crosspost - empty filter matches everything', (t) => {
const msg = { source: 'mastodon', text: 'Anything goes', author: 'carol' }
const result = matchFilter(msg, {})
t.ok(result)
})
test('crosspost - format for mastodon trims to 500 chars', (t) => {
const longText = 'A'.repeat(600)
const msg = { source: 'keet', text: longText, author: 'alice' }
const formatted = formatForTarget(msg, 'mastodon')
t.ok(formatted.length <= PLATFORM_LIMITS.mastodon)
t.ok(formatted.includes('...'))
t.ok(formatted.includes('[via keet/alice]'))
})
test('crosspost - format for mastodon with short text', (t) => {
const msg = { source: 'keet', text: 'Short post', author: 'alice' }
const formatted = formatForTarget(msg, 'mastodon')
t.ok(formatted.length <= PLATFORM_LIMITS.mastodon)
t.ok(formatted.includes('[via keet/alice]'))
t.ok(formatted.includes('Short post'))
})
test('crosspost - format for keet adds attribution', (t) => {
const msg = { source: 'mastodon', text: 'Hello from the fediverse', author: 'bob' }
const formatted = formatForTarget(msg, 'keet')
t.ok(formatted.includes('[via mastodon/bob]'))
t.ok(formatted.includes('Hello from the fediverse'))
})
test('crosspost - format for irc trims and uses angle brackets', (t) => {
const msg = { source: 'keet', text: 'Hello IRC', author: 'alice' }
const formatted = formatForTarget(msg, 'irc')
t.ok(formatted.includes('<alice@keet>'))
t.ok(formatted.includes('Hello IRC'))
})
test('crosspost - format for discord uses markdown bold', (t) => {
const msg = { source: 'keet', text: 'Hey discord', author: 'alice' }
const formatted = formatForTarget(msg, 'discord')
t.ok(formatted.includes('**alice**'))
t.ok(formatted.includes('Hey discord'))
})
test('crosspost - format for matrix uses brackets', (t) => {
const msg = { source: 'keet', text: 'Hello matrix', author: 'alice' }
const formatted = formatForTarget(msg, 'matrix')
t.ok(formatted.includes('[keet]'))
t.ok(formatted.includes('<alice>'))
})
test('crosspost - multiple rules produce multiple actions', (t) => {
const msg = { source: 'keet', text: 'Broadcast #public', author: 'alice' }
const config = {
rules: [
{ from: 'keet', to: 'mastodon', filter: { tags: ['public'] } },
{ from: 'keet', to: 'discord', filter: { tags: ['public'] } },
{ from: 'keet', to: 'matrix', filter: { tags: ['public'] } }
]
}
const actions = crosspost.process(msg, config)
t.is(actions.length, 3)
t.is(actions[0].target, 'mastodon')
t.is(actions[1].target, 'discord')
t.is(actions[2].target, 'matrix')
})
test('crosspost - empty rules produce no actions', (t) => {
const msg = { source: 'keet', text: 'Hello', author: 'alice' }
const config = { rules: [] }
const actions = crosspost.process(msg, config)
t.is(actions.length, 0)
})
test('crosspost - metadata includes crosspost info', (t) => {
const msg = { source: 'keet', text: 'Cross me #public', author: 'alice', timestamp: 12345 }
const config = {
rules: [
{ from: 'keet', to: 'mastodon', filter: { tags: ['public'] } }
]
}
const actions = crosspost.process(msg, config)
t.is(actions[0].metadata.crosspostedFrom, 'keet')
t.is(actions[0].metadata.originalAuthor, 'alice')
t.is(actions[0].metadata.originalTimestamp, 12345)
t.alike(actions[0].metadata.rule, { from: 'keet', to: 'mastodon' })
})
test('crosspost - stripRoutingTags removes #public from output', (t) => {
const result = stripRoutingTags('Hello #public world', ['public'])
t.is(result, 'Hello world')
})
test('crosspost - routing tags stripped in formatted output', (t) => {
const msg = { source: 'keet', text: 'Important #public announcement', author: 'alice' }
const rule = { from: 'keet', to: 'keet', filter: { tags: ['public'] } }
const formatted = formatForTarget(msg, 'keet', rule)
t.absent(formatted.includes('#public'))
t.ok(formatted.includes('Important'))
t.ok(formatted.includes('announcement'))
})
test('crosspost - contains filter works', (t) => {
const msg = { source: 'keet', text: 'secret word: banana', author: 'alice' }
t.ok(matchFilter(msg, { contains: 'banana' }))
t.absent(matchFilter(msg, { contains: 'orange' }))
})
// ---------------------------------------------------------------------------
// Summarize tests
// ---------------------------------------------------------------------------
function makeMessages (count, opts = {}) {
const messages = []
const authors = opts.authors || ['alice', 'bob', 'carol']
const baseTime = opts.baseTime || 1700000000000
for (let i = 0; i < count; i++) {
messages.push({
text: opts.textFn ? opts.textFn(i) : `Message number ${i} about hypercore and distributed systems`,
author: authors[i % authors.length],
timestamp: baseTime + (i * 60000), // 1 min apart
type: 'text'
})
}
return messages
}
test('summarize - extract topics from messages', (t) => {
const messages = [
{ text: 'hypercore is great for distributed data', author: 'alice' },
{ text: 'I love hypercore and hyperbee', author: 'bob' },
{ text: 'hypercore makes P2P easy', author: 'carol' },
{ text: 'hyperbee is built on hypercore', author: 'alice' },
{ text: 'distributed systems with hypercore', author: 'bob' }
]
const topics = extractTopics(messages, { minCount: 2 })
t.ok(topics.length > 0)
// hypercore should be the top topic (appears in all 5)
t.is(topics[0].word, 'hypercore')
t.is(topics[0].count, 5)
})
test('summarize - extract key messages with questions', (t) => {
const messages = [
{ text: 'Hello', author: 'alice' },
{ text: 'How does hypercore replication work?', author: 'bob' },
{ text: 'ok', author: 'carol' },
{ text: 'Check https://docs.holepunch.to for details', author: 'alice' },
{ text: 'cool', author: 'bob' }
]
const keyMsgs = extractKeyMessages(messages)
t.ok(keyMsgs.length >= 2)
// The question and the link should be picked up
const texts = keyMsgs.map(m => m.text)
t.ok(texts.some(t => t.includes('?')))
t.ok(texts.some(t => t.includes('https://')))
})
test('summarize - extract key messages with links', (t) => {
const messages = [
{ text: 'hi', author: 'alice' },
{ text: 'See https://example.com/docs', author: 'bob' },
{ text: 'thanks', author: 'carol' }
]
const keyMsgs = extractKeyMessages(messages)
t.ok(keyMsgs.length >= 1)
t.ok(keyMsgs[0].text.includes('https://'))
})
test('summarize - participant list sorted by message count', (t) => {
const messages = [
{ text: 'a', author: 'alice' },
{ text: 'b', author: 'alice' },
{ text: 'c', author: 'alice' },
{ text: 'd', author: 'bob' },
{ text: 'e', author: 'carol' }
]
const participants = extractParticipants(messages)
t.is(participants.length, 3)
t.is(participants[0].name, 'alice')
t.is(participants[0].count, 3)
t.is(participants[1].name, 'bob')
t.is(participants[1].count, 1)
})
test('summarize - time range calculation', (t) => {
const messages = [
{ text: 'first', author: 'alice', timestamp: 1000 },
{ text: 'second', author: 'bob', timestamp: 2000 },
{ text: 'third', author: 'carol', timestamp: 5000 }
]
const range = getTimeRange(messages)
t.is(range.start, 1000)
t.is(range.end, 5000)
t.is(range.durationMs, 4000)
})
test('summarize - time range with no timestamps', (t) => {
const messages = [
{ text: 'first', author: 'alice' },
{ text: 'second', author: 'bob' }
]
const range = getTimeRange(messages)
t.is(range.start, 0)
t.is(range.end, 0)
t.is(range.durationMs, 0)
})
test('summarize - summary format includes all sections', (t) => {
const messages = makeMessages(20, {
textFn: (i) => {
if (i === 5) return 'How does hypercore replication work?'
if (i === 10) return 'Check https://docs.holepunch.to for info'
return `Discussing hypercore distributed database systems for message ${i}`
}
})
const result = summarize.process(messages)
t.ok(result.summary.includes('## Conversation Summary'))
t.ok(result.summary.includes('### Participants'))
t.ok(result.summary.includes('### Key Messages'))
t.is(result.messageCount, 20)
t.ok(result.participants.length > 0)
})
test('summarize - minimum message threshold', (t) => {
const messages = makeMessages(5) // below default minMessages of 10
const result = summarize.process(messages)
t.is(result.summary, '')
t.ok(result.belowThreshold)
t.is(result.messageCount, 5)
// Participants still extracted even below threshold
t.ok(result.participants.length > 0)
})
test('summarize - empty input handling', (t) => {
const result = summarize.process([])
t.is(result.summary, '')
t.is(result.messageCount, 0)
t.is(result.participants.length, 0)
t.is(result.topics.length, 0)
t.is(result.keyMessages.length, 0)
})
test('summarize - null input handling', (t) => {
const result = summarize.process(null)
t.is(result.summary, '')
t.is(result.messageCount, 0)
})
test('summarize - window size limits messages', (t) => {
const messages = makeMessages(100)
const result = summarize.process(messages, { windowSize: 20 })
t.is(result.messageCount, 20)
})
test('summarize - tokenize strips stop words', (t) => {
const tokens = tokenize('the quick brown fox jumps over the lazy dog')
t.absent(tokens.includes('the'))
t.absent(tokens.includes('over'))
t.ok(tokens.includes('quick'))
t.ok(tokens.includes('brown'))
t.ok(tokens.includes('fox'))
})
test('summarize - tokenize strips URLs', (t) => {
const tokens = tokenize('check https://example.com for details')
t.absent(tokens.some(t => t.includes('https')))
t.absent(tokens.some(t => t.includes('example')))
})
test('summarize - formatDuration readable output', (t) => {
t.is(formatDuration(30000), 'less than a minute')
t.is(formatDuration(60000), '1 minute')
t.is(formatDuration(120000), '2 minutes')
t.is(formatDuration(3600000), '1 hour')
t.is(formatDuration(7200000), '2 hours')
t.is(formatDuration(90000000), '1 day')
})
test('summarize - messages with various content types', (t) => {
const messages = makeMessages(15, {
textFn: (i) => {
switch (i % 5) {
case 0: return 'Is hypercore better than IPFS?'
case 1: return 'Read https://docs.holepunch.to/hypercore'
case 2: return 'The `hypercore.append()` method is key'
case 3: return 'I think hypercore is amazing!'
case 4: return 'ok'
}
}
})
const keyMsgs = extractKeyMessages(messages)
t.ok(keyMsgs.length > 0)
// Questions should be highest priority (score 3)
t.ok(keyMsgs[0].text.includes('?'))
})
test('summarize - topics minCount threshold', (t) => {
const messages = [
{ text: 'hypercore is cool', author: 'alice' },
{ text: 'hypercore rocks', author: 'bob' },
{ text: 'randomword', author: 'carol' }
]
// minCount=3 means hypercore (2 occurrences) won't pass
const topics = extractTopics(messages, { minCount: 3 })
t.is(topics.length, 0)
// minCount=2 means hypercore passes
const topics2 = extractTopics(messages, { minCount: 2 })
t.ok(topics2.length > 0)
t.is(topics2[0].word, 'hypercore')
})
test('summarize - config override', (t) => {
const messages = makeMessages(20)
const result = summarize.process(messages, {
includeParticipants: false,
includeTopics: false
})
t.is(result.participants.length, 0)
t.is(result.topics.length, 0)
// Key messages still extracted
t.ok(result.messageCount > 0)
})