Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/fix-daily-digest.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .changeset/fruity-wasps-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
50 changes: 50 additions & 0 deletions server/src/addie/bolt-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');

Expand Down
268 changes: 268 additions & 0 deletions server/src/addie/services/community-articles.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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 <url|label> or plain URLs
const slackUrlPattern = /<(https?:\/\/[^|>]+)(?:\|[^>]*)?>|(?<![<|])(https?:\/\/[^\s<>]+)/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<number | null> {
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<Array<{
id: number;
source_url: string;
title: string;
addie_notes: string;
quality_score: number;
channel_id: string;
message_ts: string;
shared_by_user_id: string;
}>> {
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<void> {
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<boolean>
): 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 };
}
Loading