Implement bidirectional Discord and feed channel synchronization

Add an HTTP endpoint to the Discord bot to receive posts from the main server and call the Discord API to send these posts as rich embeds to the configured feed channel. Also, update the main server to call this new Discord bot endpoint when a new post is created.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 82d93ef8-d6c2-4d69-96c4-6fa5da4ec508
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/duiWnI1
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sirpiglr 2025-12-03 03:36:05 +00:00
commit 1ff8670b10
5 changed files with 249 additions and 4 deletions

View file

@ -61,7 +61,7 @@ localPort = 40437
externalPort = 3001
[[ports]]
localPort = 45189
localPort = 43741
externalPort = 3002
[deployment]

View file

@ -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

View file

@ -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 };

View file

@ -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

View file

@ -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));