diff --git a/.changeset/fix-daily-digest.md b/.changeset/fix-daily-digest.md new file mode 100644 index 0000000000..2c7c08bdc9 --- /dev/null +++ b/.changeset/fix-daily-digest.md @@ -0,0 +1,21 @@ +--- +"adcontextprotocol": minor +--- + +Redesign industry news alerts for better engagement + +**Paced single-article posting (replaces daily digest)** +- Quality 5 articles post immediately +- Quality 4 articles post only if channel has been quiet for 3+ hours +- Quality < 4 articles are ignored (not relevant enough for Slack or website) +- One article at a time to encourage discussion + +**Community article amplification** +- When someone shares an article link in a managed channel, Addie reacts with :eyes: +- Article gets queued for processing through content pipeline +- After processing, Addie replies with her take to acknowledge the share +- Community shares appear on the website alongside RSS content + +**Removed daily digest** +- No more batch digests - organic paced flow instead +- Each article gets its own post with Addie's spicy take diff --git a/.changeset/fruity-wasps-hope.md b/.changeset/fruity-wasps-hope.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/fruity-wasps-hope.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/addie/bolt-app.ts b/server/src/addie/bolt-app.ts index a835518a30..70f00c4380 100644 --- a/server/src/addie/bolt-app.ts +++ b/server/src/addie/bolt-app.ts @@ -67,6 +67,11 @@ import { getThreadReplies, getSlackUser, getChannelInfo } from '../slack/client. import { AddieRouter, type RoutingContext, type ExecutionPlan } from './router.js'; import { getCachedInsights, prefetchInsights } from './insights-cache.js'; import { URL_TOOLS, createUrlToolHandlers } from './mcp/url-tools.js'; +import { + isManagedChannel, + extractArticleUrls, + queueCommunityArticle, +} from './services/community-articles.js'; /** * Slack attachment type for forwarded messages @@ -1628,6 +1633,51 @@ async function handleChannelMessage({ // Errors already logged in indexChannelMessage }); + // Check for community article shares in managed channels + // This happens before routing so we can react quickly + const articleUrls = extractArticleUrls(messageText); + if (articleUrls.length > 0 && !isInThread) { + // Only process articles in top-level messages (not thread replies) + const isManaged = await isManagedChannel(channelId); + if (isManaged) { + // React with eyes to acknowledge we're looking at it + try { + await boltApp?.client.reactions.add({ + channel: channelId, + timestamp: event.ts, + name: 'eyes', + }); + } catch (reactionError) { + // Ignore - may already have reaction + } + + // Get user display name for context + let displayName: string | undefined; + try { + const slackUser = await getSlackUser(userId); + displayName = slackUser?.profile?.display_name || slackUser?.profile?.real_name; + } catch { + // Ignore + } + + // Queue each article URL for processing + for (const url of articleUrls) { + await queueCommunityArticle({ + url, + sharedByUserId: userId, + channelId, + messageTs: event.ts, + sharedByDisplayName: displayName, + }); + } + + logger.info( + { channelId, userId, articleCount: articleUrls.length }, + 'Addie Bolt: Queued community articles for processing' + ); + } + } + logger.debug({ channelId, userId, isInThread }, 'Addie Bolt: Evaluating channel message for potential response'); diff --git a/server/src/addie/services/community-articles.ts b/server/src/addie/services/community-articles.ts new file mode 100644 index 0000000000..43048808e2 --- /dev/null +++ b/server/src/addie/services/community-articles.ts @@ -0,0 +1,268 @@ +/** + * Community Articles Service + * + * Handles articles shared by community members in managed channels. + * When someone posts an article link: + * 1. React with :eyes: to acknowledge + * 2. Queue for content processing + * 3. Reply with Addie's take once processed + * + * This amplifies community contributions and feeds the website. + */ + +import { logger } from '../../logger.js'; +import { query } from '../../db/client.js'; +import { getChannelBySlackId } from '../../db/notification-channels-db.js'; + +/** + * Check if a Slack channel is a managed notification channel + */ +export async function isManagedChannel(slackChannelId: string): Promise { + const channel = await getChannelBySlackId(slackChannelId); + return channel !== null && channel.is_active; +} + +/** + * Extract article URLs from message text + * Filters out non-article URLs (images, videos, social media posts, etc.) + */ +export function extractArticleUrls(text: string): string[] { + // Match URLs in Slack format or plain URLs + const slackUrlPattern = /<(https?:\/\/[^|>]+)(?:\|[^>]*)?>|(?]+)/gi; + const urls: string[] = []; + let match; + + while ((match = slackUrlPattern.exec(text)) !== null) { + const url = match[1] || match[2]; + if (url && !urls.includes(url) && isLikelyArticleUrl(url)) { + urls.push(url); + } + } + + return urls; +} + +/** + * Check if a URL is likely an article (vs image, video, social post, etc.) + */ +function isLikelyArticleUrl(url: string): boolean { + const urlLower = url.toLowerCase(); + + // Skip media files + const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov', '.pdf']; + if (mediaExtensions.some(ext => urlLower.endsWith(ext))) { + return false; + } + + // Skip social media posts (these don't have article content) + const socialPatterns = [ + /twitter\.com\/\w+\/status/, + /x\.com\/\w+\/status/, + /linkedin\.com\/posts/, + /linkedin\.com\/feed/, + /instagram\.com\/p\//, + /tiktok\.com/, + /youtube\.com\/watch/, + /youtu\.be\//, + ]; + if (socialPatterns.some(pattern => pattern.test(urlLower))) { + return false; + } + + // Skip docs/sheets/slides + const googlePatterns = [ + /docs\.google\.com/, + /sheets\.google\.com/, + /slides\.google\.com/, + /drive\.google\.com/, + ]; + if (googlePatterns.some(pattern => pattern.test(urlLower))) { + return false; + } + + return true; +} + +/** + * Queue a community-shared article for processing + */ +export async function queueCommunityArticle(params: { + url: string; + sharedByUserId: string; + channelId: string; + messageTs: string; + sharedByDisplayName?: string; +}): Promise { + const { url, sharedByUserId, channelId, messageTs, sharedByDisplayName } = params; + + // Check if URL already exists in knowledge base + const existing = await query<{ id: number }>( + `SELECT id FROM addie_knowledge WHERE source_url = $1`, + [url] + ); + + if (existing.rows.length > 0) { + logger.debug({ url }, 'Community article already in knowledge base'); + return null; + } + + // Insert into addie_knowledge with community source + const result = await query<{ id: number }>( + `INSERT INTO addie_knowledge ( + title, + category, + source_url, + fetch_url, + source_type, + fetch_status, + discovery_source, + discovery_context, + created_by + ) VALUES ( + 'Community shared article', + 'Industry News', + $1, + $1, + 'community', + 'pending', + 'community_share', + $2, + 'system' + ) + ON CONFLICT (source_url) DO NOTHING + RETURNING id`, + [ + url, + JSON.stringify({ + shared_by_user_id: sharedByUserId, + shared_by_display_name: sharedByDisplayName, + channel_id: channelId, + message_ts: messageTs, + shared_at: new Date().toISOString(), + }), + ] + ); + + if (result.rows.length > 0) { + logger.info( + { url, userId: sharedByUserId, channelId }, + 'Queued community article for processing' + ); + return result.rows[0].id; + } + + return null; +} + +/** + * Get community articles that have been processed and need replies + */ +export async function getPendingCommunityReplies(): Promise> { + const result = await query<{ + id: number; + source_url: string; + title: string; + addie_notes: string; + quality_score: number; + discovery_context: { + channel_id: string; + message_ts: string; + shared_by_user_id: string; + reply_sent?: boolean; + }; + }>( + `SELECT id, source_url, title, addie_notes, quality_score, discovery_context + FROM addie_knowledge + WHERE source_type = 'community' + AND fetch_status = 'success' + AND discovery_source = 'community_share' + AND (discovery_context->>'reply_sent')::boolean IS NOT TRUE + AND quality_score >= 3 + ORDER BY created_at ASC + LIMIT 10` + ); + + return result.rows.map(row => ({ + id: row.id, + source_url: row.source_url, + title: row.title, + addie_notes: row.addie_notes, + quality_score: row.quality_score, + channel_id: row.discovery_context.channel_id, + message_ts: row.discovery_context.message_ts, + shared_by_user_id: row.discovery_context.shared_by_user_id, + })); +} + +/** + * Mark a community article as replied to + */ +export async function markCommunityReplyComplete(id: number): Promise { + await query( + `UPDATE addie_knowledge + SET discovery_context = discovery_context || '{"reply_sent": true}'::jsonb, + updated_at = NOW() + WHERE id = $1`, + [id] + ); +} + +/** + * Send replies to processed community articles + * Called periodically to reply to articles that have been analyzed + */ +export async function sendCommunityReplies( + sendReply: (channelId: string, threadTs: string, text: string) => Promise +): Promise<{ sent: number; failed: number }> { + const pendingReplies = await getPendingCommunityReplies(); + + if (pendingReplies.length === 0) { + return { sent: 0, failed: 0 }; + } + + let sent = 0; + let failed = 0; + + for (const article of pendingReplies) { + // Build reply message + const replyText = article.addie_notes + ? `Thanks for sharing! ${article.addie_notes}` + : `Thanks for sharing this article on ${article.title}!`; + + try { + const success = await sendReply( + article.channel_id, + article.message_ts, + replyText + ); + + if (success) { + await markCommunityReplyComplete(article.id); + sent++; + logger.info( + { id: article.id, channelId: article.channel_id }, + 'Sent community article reply' + ); + } else { + failed++; + } + } catch (error) { + logger.error({ error, id: article.id }, 'Failed to send community article reply'); + failed++; + } + + // Small delay between replies + await new Promise(resolve => setTimeout(resolve, 500)); + } + + return { sent, failed }; +} diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index bc4f5c5152..425c7c6fc5 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -537,6 +537,145 @@ function checkMentionsAdcp(content: string, _summary: string): boolean { return adcpTerms.some(term => text.includes(term)); } +/** + * Process pending community articles in batches + * These are articles shared by members in managed channels + */ +export async function processCommunityArticles(options: { + limit?: number; +} = {}): Promise<{ processed: number; succeeded: number; failed: number }> { + const limit = options.limit ?? 5; + + // Get pending community articles + const result = await query<{ + id: number; + source_url: string; + title: string; + discovery_context: { + shared_by_user_id: string; + shared_by_display_name?: string; + channel_id: string; + message_ts: string; + }; + }>( + `SELECT id, source_url, title, discovery_context + FROM addie_knowledge + WHERE source_type = 'community' + AND fetch_status = 'pending' + ORDER BY created_at ASC + LIMIT $1`, + [limit] + ); + + if (result.rows.length === 0) { + logger.debug('No community articles need processing'); + return { processed: 0, succeeded: 0, failed: 0 }; + } + + logger.debug({ count: result.rows.length }, 'Processing pending community articles'); + + // Fetch active notification channels for routing decisions + const activeChannels = await getActiveChannels(); + const channelsForRouting: ChannelForRouting[] = activeChannels.map(ch => ({ + slack_channel_id: ch.slack_channel_id, + name: ch.name, + description: ch.description, + })); + + let succeeded = 0; + let failed = 0; + + for (const article of result.rows) { + try { + // Fetch content + const content = await fetchUrlContent(article.source_url); + + if (content.length < 100) { + logger.warn({ id: article.id }, 'Community article content too short'); + await query( + `UPDATE addie_knowledge SET fetch_status = 'failed', updated_at = NOW() WHERE id = $1`, + [article.id] + ); + failed++; + continue; + } + + // Generate analysis with channel routing + const analysis = await generateAnalysis( + article.title || 'Shared article', + content, + article.source_url, + channelsForRouting + ); + + // Update knowledge entry + await query( + `UPDATE addie_knowledge SET + title = $2, + content = $3, + summary = $4, + key_insights = $5, + addie_notes = $6, + relevance_tags = $7, + quality_score = $8, + mentions_agentic = $9, + mentions_adcp = $10, + notification_channel_ids = $11, + fetch_status = 'success', + last_fetched_at = NOW(), + updated_at = NOW() + WHERE id = $1`, + [ + article.id, + extractTitleFromContent(content) || article.title || 'Shared article', + content, + analysis.summary, + analysis.key_insights ? JSON.stringify(analysis.key_insights) : null, + analysis.addie_notes, + analysis.relevance_tags, + analysis.quality_score, + checkMentionsAgentic(content, '', analysis.relevance_tags), + checkMentionsAdcp(content, ''), + analysis.notification_channels || [], + ] + ); + + logger.debug( + { id: article.id, quality: analysis.quality_score, url: article.source_url }, + 'Successfully processed community article' + ); + succeeded++; + } catch (error) { + logger.error({ error, id: article.id }, 'Failed to process community article'); + await query( + `UPDATE addie_knowledge SET fetch_status = 'failed', updated_at = NOW() WHERE id = $1`, + [article.id] + ); + failed++; + } + + // Small delay between requests + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + return { processed: result.rows.length, succeeded, failed }; +} + +/** + * Extract a title from article content (first heading or first sentence) + */ +function extractTitleFromContent(content: string): string | null { + // Try to find a heading at the start + const lines = content.split('\n').filter(l => l.trim()); + if (lines.length > 0) { + const firstLine = lines[0].trim(); + if (firstLine.length > 10 && firstLine.length < 200) { + return firstLine; + } + } + return null; +} + /** * Process pending RSS perspectives in batches * Called by background job alongside processPendingResources diff --git a/server/src/addie/services/industry-alerts.ts b/server/src/addie/services/industry-alerts.ts index 0e93ac2c6a..fb31edeede 100644 --- a/server/src/addie/services/industry-alerts.ts +++ b/server/src/addie/services/industry-alerts.ts @@ -1,9 +1,12 @@ /** * Industry Alerts Service * - * Sends Slack notifications for industry articles based on: - * 1. AI-driven channel routing (from addie_knowledge.notification_channel_ids) - * 2. Fallback rules when AI routing is empty/null + * Sends Slack notifications for industry articles with pacing: + * - Quality 5 articles: Post immediately + * - Quality 4 articles: Post only if channel has been quiet for 3+ hours + * - Quality < 4: Ignored (not relevant enough for our community) + * + * Posts one article at a time to encourage engagement. */ import { logger } from '../../logger.js'; @@ -21,8 +24,8 @@ import { type FallbackRules, } from '../../db/notification-channels-db.js'; -// Legacy channel for backward compatibility (deprecated) -const LEGACY_CHANNEL_ID = process.env.INDUSTRY_INTEL_CHANNEL_ID; +// Minimum hours of quiet before posting a quality 4 article +const QUIET_PERIOD_HOURS = 3; interface ArticleToAlert { id: string; @@ -100,13 +103,10 @@ function evaluateFallbackRules( } /** - * Build Slack message blocks for an article alert - * Format: Title header, source link, Addie's take (with emoji baked in) + * Build Slack message blocks for a single article alert + * Format: Title header, source link, Addie's take with discussion CTA */ -function buildAlertBlocks( - article: ArticleToAlert, - _alertLevel: 'urgent' | 'high' | 'medium' | 'digest' -): SlackBlock[] { +function buildAlertBlocks(article: ArticleToAlert): SlackBlock[] { // Slack header blocks have a 150 character limit for plain_text const headerTitle = (article.title || 'Industry Alert').substring(0, 150); @@ -131,7 +131,7 @@ function buildAlertBlocks( }, ]; - // Addie's take includes emoji and CTA baked in from content curator + // Addie's take includes emoji and discussion prompt baked in from content curator if (article.addie_notes) { blocks.push({ type: 'section', @@ -142,8 +142,6 @@ function buildAlertBlocks( }); } - blocks.push({ type: 'divider' }); - return blocks; } @@ -156,7 +154,7 @@ async function sendAlertToChannel( alertLevel: 'urgent' | 'high' | 'medium' | 'digest' ): Promise { try { - const blocks = buildAlertBlocks(article, alertLevel); + const blocks = buildAlertBlocks(article); const fallbackText = `${article.title} - ${article.feed_name}`; const result = await sendChannelMessage(channelId, { @@ -171,6 +169,7 @@ async function sendAlertToChannel( perspectiveId: article.perspective_id, channelId, alertLevel, + qualityScore: article.quality_score, title: article.title, }, 'Sent industry alert' @@ -193,9 +192,30 @@ async function sendAlertToChannel( } /** - * Get articles ready for alerting (processed with analysis, not yet alerted) + * Check if a channel has been quiet (no Addie posts) for the specified hours + */ +async function isChannelQuiet(channelId: string, hours: number): Promise { + const result = await query<{ last_post: Date | null }>( + `SELECT MAX(sent_at) as last_post + FROM industry_alerts + WHERE channel_id = $1 + AND sent_at > NOW() - INTERVAL '1 hour' * $2`, + [channelId, hours] + ); + return !result.rows[0]?.last_post; +} + +/** + * Get a single high-quality article ready for alerting to a specific channel + * Quality 5 articles are always eligible; quality 4 only if channel is quiet */ -async function getArticlesToAlert(): Promise { +async function getNextArticleForChannel( + channelId: string, + channelQuiet: boolean +): Promise { + // Build quality filter: always include 5, include 4 if quiet + const minQuality = channelQuiet ? 4 : 5; + const result = await query( `SELECT k.id, @@ -215,217 +235,101 @@ async function getArticlesToAlert(): Promise { JOIN industry_feeds f ON p.feed_id = f.id WHERE p.source_type = 'rss' AND k.fetch_status = 'success' + AND k.quality_score >= $1 + AND ( + $2 = ANY(k.notification_channel_ids) + OR k.notification_channel_ids IS NULL + OR array_length(k.notification_channel_ids, 1) = 0 + ) AND NOT EXISTS ( SELECT 1 FROM industry_alerts ia WHERE ia.perspective_id = p.id + AND ia.channel_id = $2 ) ORDER BY + k.quality_score DESC, k.mentions_adcp DESC, k.mentions_agentic DESC, - k.quality_score DESC NULLS LAST - LIMIT 20` + k.created_at ASC + LIMIT 1`, + [minQuality, channelId] ); - return result.rows; + return result.rows[0] || null; } + /** * Process and send alerts for qualifying articles - * Uses AI-driven routing with fallback rules + * + * Pacing rules: + * - Only posts ONE article per channel per run + * - Quality 5: Post immediately + * - Quality 4: Post only if channel has been quiet for 3+ hours + * - Quality < 4: Ignored (not posted anywhere) */ export async function processAlerts(): Promise<{ checked: number; alerted: number; byChannel: Record; }> { - const articles = await getArticlesToAlert(); - - if (articles.length === 0) { - logger.debug('No articles need alerting'); - return { checked: 0, alerted: 0, byChannel: {} }; - } - - logger.debug({ count: articles.length }, 'Processing articles for alerting'); - // Get active notification channels const channels = await getActiveChannels(); - const channelMap = new Map(); - for (const ch of channels) { - channelMap.set(ch.slack_channel_id, ch); - } - - let alerted = 0; const byChannel: Record = {}; + let alerted = 0; + let checked = 0; - for (const article of articles) { - // Skip if already alerted (race condition protection) - if (await hasAlertedPerspective(article.perspective_id)) { + // Process each Slack channel (skip website-only) + for (const channel of channels) { + if (isWebsiteOnlyChannel(channel)) { continue; } - const alertLevel = determineAlertLevel(article); + const channelId = channel.slack_channel_id; - // Determine which channels to send to - let targetChannelIds: string[] = []; + // Check if channel has been quiet + const isQuiet = await isChannelQuiet(channelId, QUIET_PERIOD_HOURS); - // 1. Use AI-routed channels if available - if (article.notification_channel_ids && article.notification_channel_ids.length > 0) { - // Filter to only active channels - targetChannelIds = article.notification_channel_ids.filter(id => channelMap.has(id)); - } + // Get next article for this channel + const article = await getNextArticleForChannel(channelId, isQuiet); + checked++; - // 2. If no AI routing, apply fallback rules - if (targetChannelIds.length === 0 && channels.length > 0) { - for (const channel of channels) { - if (evaluateFallbackRules(article, channel.fallback_rules)) { - targetChannelIds.push(channel.slack_channel_id); - } - } + if (!article) { + logger.debug({ channelId, channelName: channel.name, isQuiet }, 'No article ready for channel'); + continue; } - // 3. Legacy fallback: use env var channel if no channels configured - if (targetChannelIds.length === 0 && channels.length === 0 && LEGACY_CHANNEL_ID) { - targetChannelIds = [LEGACY_CHANNEL_ID]; + // Skip if already alerted (race condition protection) + if (await hasAlertedPerspective(article.perspective_id)) { + continue; } - // 4. If still no channels, record as digest (no notification) - if (targetChannelIds.length === 0) { - await recordPerspectiveAlert(article.perspective_id, 'digest'); - byChannel['(no-channel)'] = (byChannel['(no-channel)'] || 0) + 1; + // Verify pacing: quality 5 always posts, quality 4 only if quiet + if (article.quality_score === 4 && !isQuiet) { + logger.debug( + { channelId, channelName: channel.name, qualityScore: article.quality_score }, + 'Skipping quality 4 article - channel not quiet' + ); continue; } - // Send to each target channel - for (const channelId of targetChannelIds) { - const channel = channelMap.get(channelId); - - // Skip Slack delivery for website-only channels but still record the alert - if (channel && isWebsiteOnlyChannel(channel)) { - await recordPerspectiveAlert(article.perspective_id, alertLevel, channelId); - alerted++; - byChannel[channel.name] = (byChannel[channel.name] || 0) + 1; - logger.debug( - { perspectiveId: article.perspective_id, channelId, channelName: channel.name }, - 'Recorded website-only alert (no Slack delivery)' - ); - continue; - } - - const success = await sendAlertToChannel(article, channelId, alertLevel); - if (success) { - alerted++; - const channelName = channel?.name || channelId; - byChannel[channelName] = (byChannel[channelName] || 0) + 1; - } - - // Small delay between alerts - await new Promise(resolve => setTimeout(resolve, 300)); + const alertLevel = determineAlertLevel(article); + const success = await sendAlertToChannel(article, channelId, alertLevel); + + if (success) { + alerted++; + byChannel[channel.name] = (byChannel[channel.name] || 0) + 1; } } - logger.debug({ checked: articles.length, alerted, byChannel }, 'Completed alert processing'); + logger.debug({ checked, alerted, byChannel }, 'Completed alert processing'); - return { checked: articles.length, alerted, byChannel }; + return { checked, alerted, byChannel }; } /** - * Send a daily digest to all active channels + * @deprecated Daily digest is no longer used - replaced by paced single-article posts */ export async function sendDailyDigest(): Promise { - const channels = await getActiveChannels(); - - // Filter out website-only channels (they don't receive Slack messages) - const slackChannels = channels.filter(c => !isWebsiteOnlyChannel(c)); - - // Fall back to legacy channel if no channels configured - const targetChannels = slackChannels.length > 0 - ? slackChannels.map(c => c.slack_channel_id) - : LEGACY_CHANNEL_ID - ? [LEGACY_CHANNEL_ID] - : []; - - if (targetChannels.length === 0) { - logger.debug('No channels configured for daily digest'); - return false; - } - - // Get articles from last 24 hours that weren't sent as real-time alerts - const result = await query<{ - id: number; - title: string; - link: string; - summary: string; - feed_name: string; - quality_score: number; - }>( - `SELECT DISTINCT ON (k.source_url) - k.id, k.title, k.source_url as link, k.summary, f.name as feed_name, k.quality_score - FROM addie_knowledge k - JOIN perspectives p ON k.source_url = p.external_url - JOIN industry_feeds f ON p.feed_id = f.id - LEFT JOIN industry_alerts ia ON ia.perspective_id = p.id - WHERE p.source_type = 'rss' - AND k.fetch_status = 'success' - AND k.created_at > NOW() - INTERVAL '24 hours' - AND (ia.id IS NULL OR ia.alert_level = 'digest') - ORDER BY k.source_url, k.quality_score DESC NULLS LAST - LIMIT 10` - ); - - if (result.rows.length === 0) { - logger.debug('No digest articles to send'); - return true; - } - - const articles = result.rows; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blocks: any[] = [ - { - type: 'header', - text: { - type: 'plain_text', - text: ':newspaper: Daily Industry Digest', - emoji: true, - }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `Here are ${articles.length} notable articles from the last 24 hours:`, - }, - }, - { type: 'divider' }, - ]; - - for (const article of articles) { - blocks.push({ - type: 'section', - text: { - type: 'mrkdwn', - text: `*<${article.link}|${article.title}>*\n_${article.feed_name}_ • Quality: ${article.quality_score || '?'}/5\n${article.summary?.substring(0, 200) || ''}${article.summary && article.summary.length > 200 ? '...' : ''}`, - }, - }); - } - - blocks.push({ type: 'divider' }); - - let success = false; - for (const channelId of targetChannels) { - try { - const msgResult = await sendChannelMessage(channelId, { - text: `Daily Industry Digest - ${articles.length} articles`, - blocks, - }); - - if (msgResult.ok) { - logger.info({ channelId, articleCount: articles.length }, 'Sent daily digest'); - success = true; - } - } catch (error) { - logger.error({ error, channelId }, 'Failed to send daily digest'); - } - } - - return success; + logger.debug('Daily digest is deprecated - using paced single-article posts instead'); + return true; } diff --git a/server/src/http.ts b/server/src/http.ts index 0f46048fcd..53f8931179 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -51,7 +51,7 @@ import { createWebhooksRouter } from "./routes/webhooks.js"; import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js"; import { createAdminSlackRouter, createAdminEmailRouter, createAdminFeedsRouter, createAdminNotificationChannelsRouter, createAdminUsersRouter } from "./routes/admin/index.js"; import { processFeedsToFetch } from "./addie/services/feed-fetcher.js"; -import { processAlerts, sendDailyDigest } from "./addie/services/industry-alerts.js"; +import { processAlerts } from "./addie/services/industry-alerts.js"; import { createBillingRouter } from "./routes/billing.js"; import { createPublicBillingRouter } from "./routes/billing-public.js"; import { createOrganizationsRouter } from "./routes/organizations.js"; @@ -60,7 +60,9 @@ import { createLatestRouter } from "./routes/latest.js"; import { createCommitteeRouters } from "./routes/committees.js"; import { sendWelcomeEmail, sendUserSignupEmail, emailDb } from "./notifications/email.js"; import { emailPrefsDb } from "./db/email-preferences-db.js"; -import { queuePerspectiveLink, processPendingResources, processRssPerspectives } from "./addie/services/content-curator.js"; +import { queuePerspectiveLink, processPendingResources, processRssPerspectives, processCommunityArticles } from "./addie/services/content-curator.js"; +import { sendCommunityReplies } from "./addie/services/community-articles.js"; +import { sendChannelMessage } from "./slack/client.js"; import { notifyJoinRequest, notifyMemberAdded, notifySubscriptionThankYou } from "./slack/org-group-dm.js"; const __filename = fileURLToPath(import.meta.url); @@ -256,7 +258,6 @@ export class HTTPServer { private feedFetcherInitialTimeoutId: NodeJS.Timeout | null = null; private alertProcessorIntervalId: NodeJS.Timeout | null = null; private alertProcessorInitialTimeoutId: NodeJS.Timeout | null = null; - private dailyDigestTimeoutId: NodeJS.Timeout | null = null; constructor() { this.app = express(); @@ -7178,6 +7179,19 @@ Disallow: /api/admin/ if (rssResult.processed > 0) { logger.info(rssResult, 'Content curator: processed RSS perspectives'); } + // Process community articles + const communityResult = await processCommunityArticles({ limit: 5 }); + if (communityResult.processed > 0) { + logger.info(communityResult, 'Content curator: processed community articles'); + } + // Send replies to processed community articles + const replyResult = await sendCommunityReplies(async (channelId, threadTs, text) => { + const result = await sendChannelMessage(channelId, { text, thread_ts: threadTs }); + return result.ok; + }); + if (replyResult.sent > 0) { + logger.info(replyResult, 'Content curator: sent community article replies'); + } } catch (err) { logger.error({ err }, 'Content curator: initial processing failed'); } @@ -7196,6 +7210,19 @@ Disallow: /api/admin/ if (rssResult.processed > 0) { logger.info(rssResult, 'Content curator: processed RSS perspectives'); } + // Process community articles + const communityResult = await processCommunityArticles({ limit: 5 }); + if (communityResult.processed > 0) { + logger.info(communityResult, 'Content curator: processed community articles'); + } + // Send replies to processed community articles + const replyResult = await sendCommunityReplies(async (channelId, threadTs, text) => { + const result = await sendChannelMessage(channelId, { text, thread_ts: threadTs }); + return result.ok; + }); + if (replyResult.sent > 0) { + logger.info(replyResult, 'Content curator: sent community article replies'); + } } catch (err) { logger.error({ err }, 'Content curator: periodic processing failed'); } @@ -7262,48 +7289,12 @@ Disallow: /api/admin/ } }, ALERT_CHECK_INTERVAL_MINUTES * 60 * 1000); - // Daily digest - schedule for 9am local time - this.scheduleDailyDigest(); - logger.info({ feedFetchIntervalMinutes: FEED_FETCH_INTERVAL_MINUTES, alertCheckIntervalMinutes: ALERT_CHECK_INTERVAL_MINUTES, }, 'Industry monitor started'); } - /** - * Schedule daily digest to run at 9am local time - */ - private scheduleDailyDigest(): void { - const now = new Date(); - const targetHour = 9; // 9am local time - - // Calculate next 9am - const nextRun = new Date(now); - nextRun.setHours(targetHour, 0, 0, 0); - - // If it's past 9am today, schedule for tomorrow - if (now >= nextRun) { - nextRun.setDate(nextRun.getDate() + 1); - } - - const msUntilNextRun = nextRun.getTime() - now.getTime(); - - logger.info({ nextRun: nextRun.toISOString(), msUntilNextRun }, 'Daily digest scheduled'); - - this.dailyDigestTimeoutId = setTimeout(async () => { - try { - await sendDailyDigest(); - logger.info('Industry monitor: sent daily digest'); - } catch (err) { - logger.error({ err }, 'Industry monitor: daily digest failed'); - } - - // Schedule next day's digest - this.scheduleDailyDigest(); - }, msUntilNextRun); - } - /** * Setup graceful shutdown handlers for SIGTERM and SIGINT */ @@ -7348,10 +7339,6 @@ Disallow: /api/admin/ clearInterval(this.alertProcessorIntervalId); this.alertProcessorIntervalId = null; } - if (this.dailyDigestTimeoutId) { - clearTimeout(this.dailyDigestTimeoutId); - this.dailyDigestTimeoutId = null; - } logger.info('Industry monitor stopped'); // Close HTTP server