diff --git a/.replit b/.replit index 6196281d..f32b9f73 100644 --- a/.replit +++ b/.replit @@ -61,7 +61,7 @@ localPort = 40437 externalPort = 3001 [[ports]] -localPort = 45189 +localPort = 43741 externalPort = 3002 [deployment] diff --git a/discord-bot/bot.js b/discord-bot/bot.js index e015b6d5..8e2d0cec 100644 --- a/discord-bot/bot.js +++ b/discord-bot/bot.js @@ -12,6 +12,8 @@ const fs = require("fs"); const path = require("path"); require("dotenv").config(); +const { setupFeedListener, sendPostToDiscord, getFeedChannelId } = require("./listeners/feedSync"); + // Validate environment variables const requiredEnvVars = [ "DISCORD_BOT_TOKEN", @@ -290,6 +292,52 @@ http return; } + // POST /send-to-discord - Send a post from AeThex to Discord channel + if (req.url === "/send-to-discord" && req.method === "POST") { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", async () => { + try { + // Simple auth check + const authHeader = req.headers.authorization; + const expectedToken = process.env.DISCORD_BRIDGE_TOKEN || "aethex-bridge"; + if (authHeader !== `Bearer ${expectedToken}`) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + const post = JSON.parse(body); + console.log("[API] Received post to send to Discord:", post.id); + + const result = await sendPostToDiscord(post, post.author); + res.writeHead(result.success ? 200 : 500); + res.end(JSON.stringify(result)); + } catch (error) { + console.error("[API] Error processing send-to-discord:", error); + res.writeHead(500); + res.end(JSON.stringify({ error: error.message })); + } + }); + return; + } + + // GET /bridge-status - Check if bridge is configured + if (req.url === "/bridge-status") { + const channelId = getFeedChannelId(); + res.writeHead(200); + res.end( + JSON.stringify({ + enabled: !!channelId, + channelId: channelId, + botReady: client.isReady(), + }), + ); + return; + } + if (req.url === "/register-commands") { if (req.method === "GET") { // Show HTML form with button @@ -507,6 +555,9 @@ client.once("ready", () => { client.user.setActivity("/verify to link your AeThex account", { type: "LISTENING", }); + + // Setup bidirectional feed bridge (AeThex → Discord) + setupFeedListener(client); }); // Error handling diff --git a/discord-bot/listeners/feedSync.js b/discord-bot/listeners/feedSync.js new file mode 100644 index 00000000..a6c822f9 --- /dev/null +++ b/discord-bot/listeners/feedSync.js @@ -0,0 +1,150 @@ +const { EmbedBuilder } = require("discord.js"); +const { createClient } = require("@supabase/supabase-js"); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE, +); + +const FEED_CHANNEL_ID = process.env.DISCORD_MAIN_CHAT_CHANNELS + ? process.env.DISCORD_MAIN_CHAT_CHANNELS.split(",")[0].trim() + : null; + +let discordClient = null; + +function getArmColor(arm) { + const colors = { + labs: 0x00d4ff, + gameforge: 0xff6b00, + corp: 0x9945ff, + foundation: 0x14f195, + devlink: 0xf7931a, + nexus: 0xff00ff, + staff: 0xffd700, + }; + return colors[arm] || 0x5865f2; +} + +function getArmEmoji(arm) { + const emojis = { + labs: "🔬", + gameforge: "🎮", + corp: "🏢", + foundation: "🎓", + devlink: "🔗", + nexus: "🌐", + staff: "⭐", + }; + return emojis[arm] || "📝"; +} + +async function sendPostToDiscord(post, authorInfo = null) { + if (!discordClient || !FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No Discord client or channel configured"); + return { success: false, error: "No Discord client or channel configured" }; + } + + try { + const channel = await discordClient.channels.fetch(FEED_CHANNEL_ID); + if (!channel || !channel.isTextBased()) { + console.error("[Feed Bridge] Could not find text channel:", FEED_CHANNEL_ID); + return { success: false, error: "Could not find text channel" }; + } + + let content = {}; + try { + content = typeof post.content === "string" ? JSON.parse(post.content) : post.content; + } catch { + content = { text: post.content }; + } + + if (content.source === "discord") { + console.log("[Feed Bridge] Skipping Discord-sourced post to prevent loop"); + return { success: true, skipped: true, reason: "Discord-sourced post" }; + } + + let author = authorInfo; + if (!author && post.author_id) { + const { data } = await supabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", post.author_id) + .single(); + author = data; + } + + const authorName = author?.full_name || author?.username || "AeThex User"; + const authorAvatar = author?.avatar_url || "https://aethex.dev/logo.png"; + const arm = post.arm_affiliation || "labs"; + + const embed = new EmbedBuilder() + .setColor(getArmColor(arm)) + .setAuthor({ + name: `${getArmEmoji(arm)} ${authorName}`, + iconURL: authorAvatar, + url: `https://aethex.dev/creators/${author?.username || post.author_id}`, + }) + .setDescription(content.text || post.title || "New post") + .setTimestamp(post.created_at ? new Date(post.created_at) : new Date()) + .setFooter({ + text: `Posted from AeThex • ${arm.charAt(0).toUpperCase() + arm.slice(1)}`, + iconURL: "https://aethex.dev/logo.png", + }); + + if (content.mediaUrl) { + if (content.mediaType === "image") { + embed.setImage(content.mediaUrl); + } else if (content.mediaType === "video") { + embed.addFields({ + name: "🎬 Video", + value: `[Watch Video](${content.mediaUrl})`, + }); + } + } + + if (post.tags && post.tags.length > 0) { + const tagString = post.tags + .filter((t) => t !== "discord" && t !== "main-chat") + .map((t) => `#${t}`) + .join(" "); + if (tagString) { + embed.addFields({ name: "Tags", value: tagString, inline: true }); + } + } + + const postUrl = `https://aethex.dev/community/feed?post=${post.id}`; + embed.addFields({ + name: "🔗 View on AeThex", + value: `[Open Post](${postUrl})`, + inline: true, + }); + + await channel.send({ embeds: [embed] }); + console.log(`[Feed Bridge] ✅ Sent post ${post.id} to Discord`); + return { success: true }; + } catch (error) { + console.error("[Feed Bridge] Error sending to Discord:", error); + return { success: false, error: error.message }; + } +} + +function setupFeedListener(client) { + discordClient = client; + + if (!FEED_CHANNEL_ID) { + console.log("[Feed Bridge] No DISCORD_MAIN_CHAT_CHANNELS configured - bridge disabled"); + return; + } + + console.log("[Feed Bridge] ✅ Feed bridge ready (channel: " + FEED_CHANNEL_ID + ")"); +} + +function getDiscordClient() { + return discordClient; +} + +function getFeedChannelId() { + return FEED_CHANNEL_ID; +} + +module.exports = { setupFeedListener, sendPostToDiscord, getDiscordClient, getFeedChannelId }; diff --git a/replit.md b/replit.md index c15522ee..d9f15573 100644 --- a/replit.md +++ b/replit.md @@ -143,11 +143,14 @@ https://supabase.aethex.tech/auth/v1/callback - `https://supabase.aethex.tech/auth/v1/callback` ## Recent Changes (December 3, 2025) -- ✅ **Discord-to-Feed Integration**: Messages from Discord FEED channel sync to AeThex community feed +- ✅ **Bidirectional Discord-Feed Bridge**: Full two-way sync between Discord and AeThex feed + - **Discord → AeThex**: Messages from Discord FEED channel sync to community feed + - **AeThex → Discord**: Posts created in AeThex appear in Discord with rich embeds - Bot listens to configured channel (DISCORD_MAIN_CHAT_CHANNELS env var) - Posts display with purple Discord badge and channel name - - Supports images/videos from Discord messages - - Real-time updates via Supabase subscriptions + - Supports images/videos from both platforms + - Loop prevention: Discord-sourced posts don't re-post back to Discord + - API endpoint: Discord bot exposes `/send-to-discord` for main server to call - ✅ **Moved /feed to /community/feed**: Feed is now a tab within the Community page - Old /feed URL redirects to /community/feed - Added redirect in vercel.json for production diff --git a/server/index.ts b/server/index.ts index 74b595d7..5d682884 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2523,6 +2523,47 @@ export function createServer() { .json({ error: error.message || "Failed to create post" }); } + // Send post to Discord feed channel (fire and forget) + try { + const contentParsed = JSON.parse(String(payload.content).trim()); + // Only sync to Discord if this is NOT a Discord-sourced post + if (contentParsed.source !== "discord") { + // Get author info for the Discord embed + const { data: authorProfile } = await adminSupabase + .from("user_profiles") + .select("username, full_name, avatar_url") + .eq("id", payload.author_id) + .single(); + + const discordBotPort = process.env.DISCORD_BOT_PORT || "8044"; + const discordBridgeToken = process.env.DISCORD_BRIDGE_TOKEN || "aethex-bridge"; + + fetch(`http://localhost:${discordBotPort}/send-to-discord`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${discordBridgeToken}`, + }, + body: JSON.stringify({ + ...data, + author: authorProfile, + }), + }).then(async (resp) => { + if (resp.ok) { + console.log("[Feed Bridge] ✅ Post sent to Discord"); + } else { + const errText = await resp.text(); + console.log("[Feed Bridge] Discord sync response:", resp.status, errText); + } + }).catch((err) => { + console.log("[Feed Bridge] Could not sync to Discord:", err.message); + }); + } + } catch (syncErr: any) { + // Non-blocking - just log it + console.log("[Feed Bridge] Sync error:", syncErr?.message); + } + res.json(data); } catch (e: any) { console.error("[API] /api/posts exception:", e?.message || String(e));