Discord Feed Sync - Post to Discord webhook
cgen-d3c9a84bab384df6bbaaae65b9f5f7f3
This commit is contained in:
parent
466ce9ba83
commit
748f41b222
3 changed files with 644 additions and 0 deletions
134
api/discord/feed-sync.ts
Normal file
134
api/discord/feed-sync.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
export const config = {
|
||||
runtime: "nodejs",
|
||||
};
|
||||
|
||||
const webhookUrl = process.env.DISCORD_FEED_WEBHOOK_URL;
|
||||
|
||||
interface FeedPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author_name: string;
|
||||
author_avatar?: string | null;
|
||||
arm_affiliation: string;
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default async function handler(req: any, res: any) {
|
||||
// Only accept POST requests
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate webhook is configured
|
||||
if (!webhookUrl) {
|
||||
console.warn(
|
||||
"[Discord Feed Sync] No webhook URL configured. Skipping Discord post.",
|
||||
);
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Discord webhook not configured, post skipped",
|
||||
});
|
||||
}
|
||||
|
||||
const post: FeedPost = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (
|
||||
!post.id ||
|
||||
!post.title ||
|
||||
!post.content ||
|
||||
!post.author_name ||
|
||||
!post.arm_affiliation
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Missing required fields: id, title, content, author_name, arm_affiliation",
|
||||
});
|
||||
}
|
||||
|
||||
// Truncate content if too long (Discord has limits)
|
||||
const description =
|
||||
post.content.length > 1024
|
||||
? post.content.substring(0, 1021) + "..."
|
||||
: post.content;
|
||||
|
||||
// Build Discord embed
|
||||
const armColors: Record<string, number> = {
|
||||
labs: 0xfbbf24, // yellow
|
||||
gameforge: 0x22c55e, // green
|
||||
corp: 0x3b82f6, // blue
|
||||
foundation: 0xef4444, // red
|
||||
devlink: 0x06b6d4, // cyan
|
||||
nexus: 0xa855f7, // purple
|
||||
staff: 0x6366f1, // indigo
|
||||
};
|
||||
|
||||
const embed = {
|
||||
title: post.title,
|
||||
description: description,
|
||||
color: armColors[post.arm_affiliation] || 0x8b5cf6,
|
||||
author: {
|
||||
name: post.author_name,
|
||||
icon_url: post.author_avatar || undefined,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "Arm",
|
||||
value: post.arm_affiliation.charAt(0).toUpperCase() +
|
||||
post.arm_affiliation.slice(1),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Engagement",
|
||||
value: `👍 ${post.likes_count} • 💬 ${post.comments_count}`,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
text: "AeThex Community Feed",
|
||||
},
|
||||
timestamp: post.created_at,
|
||||
};
|
||||
|
||||
// Send to Discord webhook
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: "AeThex Community Feed",
|
||||
avatar_url:
|
||||
"https://aethex.dev/logo.png", // Update with your logo URL
|
||||
embeds: [embed],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
"[Discord Feed Sync] Webhook failed:",
|
||||
response.status,
|
||||
errorText,
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to post to Discord",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Post sent to Discord feed",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Discord Feed Sync] Error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Internal server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
337
api/integrations/fourthwall.ts
Normal file
337
api/integrations/fourthwall.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
export const config = {
|
||||
runtime: "nodejs",
|
||||
};
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL;
|
||||
const supabaseServiceRole = process.env.SUPABASE_SERVICE_ROLE;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceRole) {
|
||||
throw new Error("Missing Supabase configuration");
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceRole);
|
||||
|
||||
const FOURTHWALL_API_EMAIL = process.env.FOURTHWALL_API_EMAIL;
|
||||
const FOURTHWALL_API_PASSWORD = process.env.FOURTHWALL_API_PASSWORD;
|
||||
const FOURTHWALL_STOREFRONT_TOKEN = process.env.FOURTHWALL_STOREFRONT_TOKEN;
|
||||
|
||||
const FOURTHWALL_API_BASE = "https://api.fourthwall.com";
|
||||
|
||||
interface FourthwallAuthResponse {
|
||||
token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface FourthwallProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
image_url?: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// Get Fourthwall auth token
|
||||
async function getFourthwallToken(): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`${FOURTHWALL_API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: FOURTHWALL_API_EMAIL,
|
||||
password: FOURTHWALL_API_PASSWORD,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fourthwall auth failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: FourthwallAuthResponse = await response.json();
|
||||
return data.token;
|
||||
} catch (error) {
|
||||
console.error("[Fourthwall] Auth error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: any, res: any) {
|
||||
const action = req.query.action || "";
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "products":
|
||||
return await handleGetProducts(req, res);
|
||||
case "sync-products":
|
||||
return await handleSyncProducts(req, res);
|
||||
case "store-settings":
|
||||
return await handleGetStoreSettings(req, res);
|
||||
case "webhook":
|
||||
return await handleWebhook(req, res);
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid action" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Fourthwall API] Error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Internal server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get products from Fourthwall storefront
|
||||
async function handleGetProducts(req: any, res: any) {
|
||||
try {
|
||||
const token = await getFourthwallToken();
|
||||
|
||||
const response = await fetch(
|
||||
`${FOURTHWALL_API_BASE}/storefront/products?storefront_token=${FOURTHWALL_STOREFRONT_TOKEN}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch products: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
products: data.products || [],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Fourthwall] Get products error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Failed to fetch products",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Fourthwall products to AeThex database
|
||||
async function handleSyncProducts(req: any, res: any) {
|
||||
try {
|
||||
const token = await getFourthwallToken();
|
||||
|
||||
const response = await fetch(
|
||||
`${FOURTHWALL_API_BASE}/storefront/products?storefront_token=${FOURTHWALL_STOREFRONT_TOKEN}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch products: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const products: FourthwallProduct[] = data.products || [];
|
||||
|
||||
// Sync products to Supabase
|
||||
const syncResults = [];
|
||||
|
||||
for (const product of products) {
|
||||
const { error } = await supabase
|
||||
.from("fourthwall_products")
|
||||
.upsert(
|
||||
{
|
||||
fourthwall_id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
image_url: product.image_url,
|
||||
category: product.category,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: "fourthwall_id",
|
||||
},
|
||||
);
|
||||
|
||||
syncResults.push({
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
success: !error,
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: `Synced ${products.length} products`,
|
||||
results: syncResults,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Fourthwall] Sync products error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Failed to sync products",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get store settings
|
||||
async function handleGetStoreSettings(req: any, res: any) {
|
||||
try {
|
||||
const token = await getFourthwallToken();
|
||||
|
||||
const response = await fetch(`${FOURTHWALL_API_BASE}/store/settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch store settings: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
settings: data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Fourthwall] Get settings error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Failed to fetch store settings",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Fourthwall webhooks (order events, etc)
|
||||
async function handleWebhook(req: any, res: any) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { event_type, data } = req.body;
|
||||
|
||||
if (!event_type) {
|
||||
return res.status(400).json({ error: "Missing event_type" });
|
||||
}
|
||||
|
||||
// Log webhook event
|
||||
const { error } = await supabase.from("fourthwall_webhook_logs").insert({
|
||||
event_type,
|
||||
payload: data,
|
||||
received_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Fourthwall] Webhook log error:", error);
|
||||
}
|
||||
|
||||
// Handle specific events
|
||||
switch (event_type) {
|
||||
case "order.created":
|
||||
await handleOrderCreated(data);
|
||||
break;
|
||||
case "order.paid":
|
||||
await handleOrderPaid(data);
|
||||
break;
|
||||
case "product.updated":
|
||||
await handleProductUpdated(data);
|
||||
break;
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Webhook processed",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Fourthwall] Webhook error:", error);
|
||||
return res.status(500).json({
|
||||
error: error.message || "Failed to process webhook",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Fourthwall order created event
|
||||
async function handleOrderCreated(data: any) {
|
||||
try {
|
||||
const { order_id, customer_email, items, total_amount } = data;
|
||||
|
||||
// Store order in database for later processing
|
||||
const { error } = await supabase
|
||||
.from("fourthwall_orders")
|
||||
.insert({
|
||||
fourthwall_order_id: order_id,
|
||||
customer_email,
|
||||
items: items || [],
|
||||
total_amount,
|
||||
status: "pending",
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[Fourthwall] Failed to store order:", error);
|
||||
}
|
||||
|
||||
console.log(`[Fourthwall] Order created: ${order_id}`);
|
||||
} catch (error) {
|
||||
console.error("[Fourthwall] Order creation error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Fourthwall order paid event
|
||||
async function handleOrderPaid(data: any) {
|
||||
try {
|
||||
const { order_id } = data;
|
||||
|
||||
// Update order status
|
||||
const { error } = await supabase
|
||||
.from("fourthwall_orders")
|
||||
.update({
|
||||
status: "paid",
|
||||
paid_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("fourthwall_order_id", order_id);
|
||||
|
||||
if (error) {
|
||||
console.error("[Fourthwall] Failed to update order:", error);
|
||||
}
|
||||
|
||||
console.log(`[Fourthwall] Order paid: ${order_id}`);
|
||||
} catch (error) {
|
||||
console.error("[Fourthwall] Order payment error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Fourthwall product updated event
|
||||
async function handleProductUpdated(data: any) {
|
||||
try {
|
||||
const { product_id, ...updates } = data;
|
||||
|
||||
// Update product in database
|
||||
const { error } = await supabase
|
||||
.from("fourthwall_products")
|
||||
.update({
|
||||
...updates,
|
||||
synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("fourthwall_id", product_id);
|
||||
|
||||
if (error) {
|
||||
console.error("[Fourthwall] Failed to update product:", error);
|
||||
}
|
||||
|
||||
console.log(`[Fourthwall] Product updated: ${product_id}`);
|
||||
} catch (error) {
|
||||
console.error("[Fourthwall] Product update error:", error);
|
||||
}
|
||||
}
|
||||
173
discord-bot/events/messageCreate.js
Normal file
173
discord-bot/events/messageCreate.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
const { createClient } = require("@supabase/supabase-js");
|
||||
|
||||
// Initialize Supabase
|
||||
const supabase = createClient(
|
||||
process.env.SUPABASE_URL,
|
||||
process.env.SUPABASE_SERVICE_ROLE,
|
||||
);
|
||||
|
||||
const FEED_CHANNEL_ID = process.env.DISCORD_FEED_CHANNEL_ID;
|
||||
const FEED_GUILD_ID = process.env.DISCORD_FEED_GUILD_ID;
|
||||
|
||||
module.exports = {
|
||||
name: "messageCreate",
|
||||
async execute(message, client) {
|
||||
// Ignore bot messages
|
||||
if (message.author.bot) return;
|
||||
|
||||
// Only listen to messages in the feed channel
|
||||
if (
|
||||
FEED_CHANNEL_ID &&
|
||||
message.channelId !== FEED_CHANNEL_ID
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only listen to the correct guild
|
||||
if (FEED_GUILD_ID && message.guildId !== FEED_GUILD_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user's linked AeThex account
|
||||
const { data: linkedAccount, error } = await supabase
|
||||
.from("discord_links")
|
||||
.select("user_id")
|
||||
.eq("discord_user_id", message.author.id)
|
||||
.single();
|
||||
|
||||
if (error || !linkedAccount) {
|
||||
// Optionally, send a DM asking them to link their account
|
||||
try {
|
||||
await message.author.send(
|
||||
"To have your message posted to AeThex, please link your Discord account! Use `/verify` command.",
|
||||
);
|
||||
} catch (dmError) {
|
||||
console.warn("[Feed Sync] Could not send DM to user:", dmError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user profile for author info
|
||||
const { data: userProfile, error: profileError } = await supabase
|
||||
.from("user_profiles")
|
||||
.select("id, username, full_name, avatar_url")
|
||||
.eq("id", linkedAccount.user_id)
|
||||
.single();
|
||||
|
||||
if (profileError || !userProfile) {
|
||||
console.error("[Feed Sync] Could not fetch user profile:", profileError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare message content and media
|
||||
let content = message.content || "Shared a message on Discord";
|
||||
|
||||
// Handle embeds and attachments
|
||||
let mediaUrl = null;
|
||||
let mediaType = "none";
|
||||
|
||||
// Check for attachments (images, videos)
|
||||
if (message.attachments.size > 0) {
|
||||
const attachment = message.attachments.first();
|
||||
if (attachment) {
|
||||
mediaUrl = attachment.url;
|
||||
|
||||
// Detect media type
|
||||
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
const videoExtensions = [".mp4", ".webm", ".mov", ".avi"];
|
||||
|
||||
const attachmentLower = attachment.name.toLowerCase();
|
||||
|
||||
if (imageExtensions.some((ext) => attachmentLower.endsWith(ext))) {
|
||||
mediaType = "image";
|
||||
} else if (videoExtensions.some((ext) =>
|
||||
attachmentLower.endsWith(ext),
|
||||
)) {
|
||||
mediaType = "video";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare post content JSON
|
||||
const postContent = JSON.stringify({
|
||||
text: content,
|
||||
mediaUrl: mediaUrl,
|
||||
mediaType: mediaType,
|
||||
});
|
||||
|
||||
// Determine arm affiliation from guild name or default to 'labs'
|
||||
let armAffiliation = "labs";
|
||||
const guild = message.guild;
|
||||
if (guild) {
|
||||
const guildNameLower = guild.name.toLowerCase();
|
||||
|
||||
if (guildNameLower.includes("gameforge")) armAffiliation = "gameforge";
|
||||
else if (guildNameLower.includes("corp")) armAffiliation = "corp";
|
||||
else if (guildNameLower.includes("foundation"))
|
||||
armAffiliation = "foundation";
|
||||
else if (guildNameLower.includes("devlink"))
|
||||
armAffiliation = "devlink";
|
||||
else if (guildNameLower.includes("nexus")) armAffiliation = "nexus";
|
||||
else if (guildNameLower.includes("staff")) armAffiliation = "staff";
|
||||
}
|
||||
|
||||
// Create post in AeThex
|
||||
const { data: createdPost, error: insertError } = await supabase
|
||||
.from("community_posts")
|
||||
.insert({
|
||||
title: content.substring(0, 100) || "Discord Shared Message",
|
||||
content: postContent,
|
||||
arm_affiliation: armAffiliation,
|
||||
author_id: userProfile.id,
|
||||
tags: ["discord"],
|
||||
category: null,
|
||||
is_published: true,
|
||||
likes_count: 0,
|
||||
comments_count: 0,
|
||||
})
|
||||
.select(
|
||||
`id, title, content, arm_affiliation, author_id, created_at, updated_at, likes_count, comments_count,
|
||||
user_profiles!community_posts_author_id_fkey (id, username, full_name, avatar_url)`,
|
||||
);
|
||||
|
||||
if (insertError) {
|
||||
console.error("[Feed Sync] Failed to create post:", insertError);
|
||||
try {
|
||||
await message.react("❌");
|
||||
} catch (reactionError) {
|
||||
console.warn("[Feed Sync] Could not add reaction:", reactionError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Feed Sync] ✅ Posted message from ${message.author.tag} to AeThex`,
|
||||
);
|
||||
|
||||
// React with success emoji
|
||||
try {
|
||||
await message.react("✅");
|
||||
} catch (reactionError) {
|
||||
console.warn("[Feed Sync] Could not add success reaction:", reactionError);
|
||||
}
|
||||
|
||||
// Send confirmation DM
|
||||
try {
|
||||
await message.author.send(
|
||||
`✅ Your message was posted to AeThex Community Feed! Check it out at https://aethex.dev/feed`,
|
||||
);
|
||||
} catch (dmError) {
|
||||
console.warn("[Feed Sync] Could not send confirmation DM:", dmError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Feed Sync] Unexpected error:", error);
|
||||
|
||||
try {
|
||||
await message.react("⚠️");
|
||||
} catch (reactionError) {
|
||||
console.warn("[Feed Sync] Could not add warning reaction:", reactionError);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue