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:
commit
1ff8670b10
5 changed files with 249 additions and 4 deletions
2
.replit
2
.replit
|
|
@ -61,7 +61,7 @@ localPort = 40437
|
|||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 45189
|
||||
localPort = 43741
|
||||
externalPort = 3002
|
||||
|
||||
[deployment]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
150
discord-bot/listeners/feedSync.js
Normal file
150
discord-bot/listeners/feedSync.js
Normal 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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue