diff --git a/.env.example b/.env.example index 34b0fa492..9e4e7e01f 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,7 @@ TWITTER_USERNAME= TWITTER_PASSWORD= TWITTER_EMAIL= -GITHUB_TOKEN= \ No newline at end of file +GITHUB_TOKEN= + +DISCORD_API_URL= +DISCORD_BOT_TOKEN= diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index 9cfc8769d..4c812c8d1 100644 --- a/src/features/incentive/PointSystem.ts +++ b/src/features/incentive/PointSystem.ts @@ -14,6 +14,7 @@ const pointValues = { LINK_TWITTER: 2, LINK_GITHUB: 1, FOLLOW_DEMOS: 1, + LINK_DISCORD: 1, } export class PointSystem { @@ -33,13 +34,17 @@ export class PointSystem { */ private async getUserIdentitiesFromGCR(userId: string): Promise<{ linkedWallets: string[] - linkedSocials: { twitter?: string } + linkedSocials: { twitter?: string; discord?: string } }> { const xmIdentities = await IdentityManager.getIdentities(userId) const twitterIdentities = await IdentityManager.getWeb2Identities( userId, "twitter", ) + const discordIdentities = await IdentityManager.getWeb2Identities( + userId, + "discord", + ) const linkedWallets: string[] = [] @@ -63,12 +68,16 @@ export class PointSystem { } } - const linkedSocials: { twitter?: string } = {} + const linkedSocials: { twitter?: string; discord?: string } = {} if (Array.isArray(twitterIdentities) && twitterIdentities.length > 0) { linkedSocials.twitter = twitterIdentities[0].username } + if (Array.isArray(discordIdentities) && discordIdentities.length > 0) { + linkedSocials.discord = discordIdentities[0].username + } + return { linkedWallets, linkedSocials } } @@ -436,7 +445,9 @@ export class PointSystem { // Verify the GitHub account is actually linked to this user const githubIdentities = account.identities.web2?.github || [] - const isOwner = githubIdentities.some((gh: any) => gh.userId === githubUserId) + const isOwner = githubIdentities.some( + (gh: any) => gh.userId === githubUserId, + ) if (!isOwner) { return { @@ -444,7 +455,8 @@ export class PointSystem { response: { pointsAwarded: 0, totalPoints: account.points.totalPoints || 0, - message: "Error: GitHub account not linked to this user", + message: + "Error: GitHub account not linked to this user", }, require_reply: false, extra: {}, @@ -616,14 +628,19 @@ export class PointSystem { * @param githubUserId The GitHub user ID to verify ownership * @returns RPCResponse */ - async deductGithubPoints(userId: string, githubUserId: string): Promise { + async deductGithubPoints( + userId: string, + githubUserId: string, + ): Promise { try { // Get user's account data from GCR to verify GitHub ownership const account = await ensureGCRForUser(userId) // Verify the GitHub account is actually linked to this user const githubIdentities = account.identities.web2?.github || [] - const isOwner = githubIdentities.some((gh: any) => gh.userId === githubUserId) + const isOwner = githubIdentities.some( + (gh: any) => gh.userId === githubUserId, + ) if (!isOwner) { return { @@ -631,7 +648,8 @@ export class PointSystem { response: { pointsDeducted: 0, totalPoints: account.points.totalPoints || 0, - message: "Error: GitHub account not linked to this user", + message: + "Error: GitHub account not linked to this user", }, require_reply: false, extra: {}, @@ -643,9 +661,7 @@ export class PointSystem { ) // Check if user has GitHub points to deduct - if ( - userPointsWithIdentities.breakdown.socialAccounts.github <= 0 - ) { + if (userPointsWithIdentities.breakdown.socialAccounts.github <= 0) { return { result: 200, response: { @@ -689,4 +705,145 @@ export class PointSystem { } } } + + /** + * Award points for linking a Discord account + * @param userId The user's Demos address + * @param referralCode Optional referral code + * @returns RPCResponse + */ + async awardDiscordPoints( + userId: string, + referralCode?: string, + ): Promise { + try { + // Verify the Discord account is actually linked to this user + const account = await ensureGCRForUser(userId) + const discordIdentities = account.identities.web2?.discord || [] + + const hasDiscord = + Array.isArray(discordIdentities) && discordIdentities.length > 0 + if (!hasDiscord) { + return { + result: 400, + response: { + pointsAwarded: 0, + totalPoints: account.points.totalPoints || 0, + message: + "Error: Discord account not linked to this user", + }, + require_reply: false, + extra: {}, + } + } + + const userPointsWithIdentities = await this.getUserPointsInternal( + userId, + ) + + // Check if user already has Discord points specifically + if (userPointsWithIdentities.breakdown.socialAccounts.discord > 0) { + return { + result: 200, + response: { + pointsAwarded: 0, + totalPoints: userPointsWithIdentities.totalPoints, + message: "Discord points already awarded", + }, + require_reply: false, + extra: {}, + } + } + + await this.addPointsToGCR( + userId, + pointValues.LINK_DISCORD, + "socialAccounts", + "discord", + referralCode, + ) + + const updatedPoints = await this.getUserPointsInternal(userId) + + return { + result: 200, + response: { + pointsAwarded: pointValues.LINK_DISCORD, + totalPoints: updatedPoints.totalPoints, + message: "Points awarded for linking Discord", + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error awarding points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } + + /** + * Deduct points for unlinking a Discord account + * @param userId The user's Demos address + * @returns RPCResponse + */ + async deductDiscordPoints(userId: string): Promise { + try { + const userPointsWithIdentities = await this.getUserPointsInternal( + userId, + ) + + // Check if user has Discord points to deduct + if ( + userPointsWithIdentities.breakdown.socialAccounts.discord <= 0 + ) { + return { + result: 200, + response: { + pointsDeducted: 0, + totalPoints: userPointsWithIdentities.totalPoints, + message: "No Discord points to deduct", + }, + require_reply: false, + extra: {}, + } + } + + await this.addPointsToGCR( + userId, + -pointValues.LINK_DISCORD, + "socialAccounts", + "discord", + ) + + const updatedPoints = await this.getUserPointsInternal(userId) + + return { + result: 200, + response: { + pointsDeducted: pointValues.LINK_DISCORD, + totalPoints: updatedPoints.totalPoints, + message: "Points deducted for unlinking Discord", + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error deducting points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } } diff --git a/src/libs/abstraction/index.ts b/src/libs/abstraction/index.ts index a8d06e23b..0e2c2dddb 100644 --- a/src/libs/abstraction/index.ts +++ b/src/libs/abstraction/index.ts @@ -1,5 +1,6 @@ import { GithubProofParser } from "./web2/github" import { TwitterProofParser } from "./web2/twitter" +import { DiscordProofParser } from "./web2/discord" import { type Web2ProofParser } from "./web2/parsers" import { Web2CoreTargetIdentityPayload } from "@kynesyslabs/demosdk/abstraction" import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" @@ -15,7 +16,10 @@ export async function verifyWeb2Proof( payload: Web2CoreTargetIdentityPayload, sender: string, ) { - let parser: typeof TwitterProofParser | typeof GithubProofParser + let parser: + | typeof TwitterProofParser + | typeof GithubProofParser + | typeof DiscordProofParser switch (payload.context) { case "twitter": @@ -24,6 +28,9 @@ export async function verifyWeb2Proof( case "github": parser = GithubProofParser break + case "discord": + parser = DiscordProofParser + break default: return { success: false, diff --git a/src/libs/abstraction/web2/discord.ts b/src/libs/abstraction/web2/discord.ts new file mode 100644 index 000000000..613e4575d --- /dev/null +++ b/src/libs/abstraction/web2/discord.ts @@ -0,0 +1,54 @@ +import { Web2ProofParser } from "./parsers" +import { SigningAlgorithm } from "@kynesyslabs/demosdk/types" +import { Discord } from "@/libs/identity/tools/discord" + +export class DiscordProofParser extends Web2ProofParser { + private static instance: DiscordProofParser + discord: Discord + + constructor() { + super() + this.discord = Discord.getInstance() + } + + private parseDiscordMessageUrl(proofUrl: string): { + channelId: string + messageId: string + } { + const { channelId, messageId } = + this.discord.extractMessageDetails(proofUrl) + return { channelId, messageId } + } + + async getMessageFromUrl(messageUrl: string) { + return await this.discord.getMessageByUrl(messageUrl) + } + + async readData(proofUrl: string): Promise<{ + message: string + signature: string + type: SigningAlgorithm + }> { + this.verifyProofFormat(proofUrl, "discord") + + // Validate and fetch via shared client + this.parseDiscordMessageUrl(proofUrl) + const msg = await this.discord.getMessageByUrl(proofUrl) + const content = (msg?.content as string) || "" + + const payload = this.parsePayload(content) + if (!payload) { + throw new Error("Invalid proof format") + } + + return payload + } + + static async getInstance() { + if (!this.instance) { + this.instance = new DiscordProofParser() + } + + return this.instance + } +} diff --git a/src/libs/abstraction/web2/parsers.ts b/src/libs/abstraction/web2/parsers.ts index 1f9193733..9a20cd890 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -8,6 +8,12 @@ export abstract class Web2ProofParser { "https://gist.githubusercontent.com", ], twitter: ["https://x.com", "https://twitter.com"], + discord: [ + "https://discord.com/channels", + "https://ptb.discord.com/channels", + "https://canary.discord.com/channels", + "https://discordapp.com/channels", + ], } constructor() {} diff --git a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index 76538765d..32dfd4882 100644 --- a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts +++ b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts @@ -248,6 +248,19 @@ export default class GCRIdentityRoutines { editOperation.referralCode, ) } + } else if (context === "discord") { + const isFirst = await this.isFirstConnection( + "discord", + { userId: data.userId }, + gcrMainRepository, + editOperation.account, + ) + if (isFirst) { + await IncentiveManager.discordLinked( + editOperation.account, + editOperation.referralCode, + ) + } } else { log.info(`Web2 identity linked: ${context}/${data.username}`) } @@ -282,9 +295,10 @@ export default class GCRIdentityRoutines { // Store the identity being removed for GitHub unlinking (need userId) let removedIdentity: Web2GCRData["data"] | null = null if (context === "github") { - removedIdentity = accountGCR.identities.web2[context].find( - (id: Web2GCRData["data"]) => id.username === username, - ) || null + removedIdentity = + accountGCR.identities.web2[context].find( + (id: Web2GCRData["data"]) => id.username === username, + ) || null } accountGCR.identities.web2[context] = accountGCR.identities.web2[ @@ -299,11 +313,17 @@ export default class GCRIdentityRoutines { */ if (context === "twitter") { await IncentiveManager.twitterUnlinked(editOperation.account) - } else if (context === "github" && removedIdentity && removedIdentity.userId) { + } else if ( + context === "github" && + removedIdentity && + removedIdentity.userId + ) { await IncentiveManager.githubUnlinked( editOperation.account, removedIdentity.userId, ) + } else if (context === "discord") { + await IncentiveManager.discordUnlinked(editOperation.account) } } @@ -600,9 +620,9 @@ export default class GCRIdentityRoutines { } private static async isFirstConnection( - type: "twitter" | "github" | "web3", + type: "twitter" | "github" | "web3" | "discord", data: { - userId?: string // for twitter/github + userId?: string // for twitter/github/discord chain?: string // for web3 subchain?: string // for web3 address?: string // for web3 @@ -644,6 +664,23 @@ export default class GCRIdentityRoutines { .andWhere("gcr.pubkey != :currentAccount", { currentAccount }) .getOne() + /** + * Return true if no account has this userId + */ + return !result + } else if (type === "discord") { + /** + * Check if this Discord userId exists anywhere + */ + const result = await gcrMainRepository + .createQueryBuilder("gcr") + .where( + "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(gcr.identities->'web2'->'discord', '[]'::jsonb)) AS discord_id WHERE discord_id->>'userId' = :userId)", + { userId: data.userId }, + ) + .andWhere("gcr.pubkey != :currentAccount", { currentAccount }) + .getOne() + /** * Return true if no account has this userId */ diff --git a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts index 9209f3eb8..967fb5beb 100644 --- a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts @@ -80,7 +80,10 @@ export class IncentiveManager { /** * Hook to be called after GitHub unlinking */ - static async githubUnlinked(userId: string, githubUserId: string): Promise { + static async githubUnlinked( + userId: string, + githubUserId: string, + ): Promise { return await this.pointSystem.deductGithubPoints(userId, githubUserId) } @@ -90,4 +93,21 @@ export class IncentiveManager { static async getPoints(address: string): Promise { return await this.pointSystem.getUserPoints(address) } + + /** + * Hook to be called after Discord linking + */ + static async discordLinked( + userId: string, + referralCode?: string, + ): Promise { + return await this.pointSystem.awardDiscordPoints(userId, referralCode) + } + + /** + * Hook to be called after Discord unlinking + */ + static async discordUnlinked(userId: string): Promise { + return await this.pointSystem.deductDiscordPoints(userId) + } } diff --git a/src/libs/identity/tools/discord.ts b/src/libs/identity/tools/discord.ts new file mode 100644 index 000000000..8b994bd39 --- /dev/null +++ b/src/libs/identity/tools/discord.ts @@ -0,0 +1,166 @@ +import axios, { AxiosInstance, AxiosResponse } from "axios" +import { URL } from "url" + +export type DiscordMessage = { + id: string + channel_id: string + guild_id?: string + author: { + id: string + username: string + global_name?: string + bot?: boolean + } + content: string + timestamp: string + edited_timestamp?: string | null + mention_everyone: boolean + attachments: Array<{ + id: string + filename: string + size: number + url: string + proxy_url: string + content_type?: string + }> + embeds: any[] + mentions: Array<{ id: string; username: string }> + referenced_message?: DiscordMessage | null +} + +export class Discord { + private static instance: Discord + private axios: AxiosInstance + + readonly api_url = + process.env.DISCORD_API_URL ?? "https://discord.com/api/v10" + readonly bot_token = process.env.DISCORD_BOT_TOKEN as string + + private constructor() { + if (!this.bot_token) { + throw new Error("Missing DISCORD_BOT_TOKEN env variable") + } + + // Validate host to avoid accidental redirection to internal networks + const parsed = new URL(this.api_url) + const host = parsed.hostname.toLowerCase() + const isTrusted = + host.endsWith(".discord.com") || host === "discord.com" + + if (!isTrusted) { + throw new Error(`Untrusted DISCORD_API_URL host: ${host}`) + } + + this.axios = axios.create({ + baseURL: this.api_url, + headers: { + Authorization: `Bot ${this.bot_token}`, + "Content-Type": "application/json", + }, + timeout: 10000, // 10s + }) + } + + // Extracts IDs from a Discord message URL + extractMessageDetails(messageUrl: string): { + guildId: string + channelId: string + messageId: string + } { + try { + const url = new URL(messageUrl) + + const host = url.hostname.toLowerCase() + const isDiscordHost = + host === "discord.com" || + host.endsWith(".discord.com") || + host === "discordapp.com" || + host.endsWith(".discordapp.com") + + if (!isDiscordHost) { + throw new Error( + "URL host must be discord.com or discordapp.com (including ptb/canary).", + ) + } + + const parts = url.pathname.split("/").filter(Boolean) + + if (parts.length !== 4 || parts[0] !== "channels") { + throw new Error("Invalid Discord message URL format") + } + + const [_, guildId, channelId, messageId] = parts + + if ( + !this.isSnowflake(guildId) || + !this.isSnowflake(channelId) || + !this.isSnowflake(messageId) + ) { + throw new Error( + "One or more IDs are not valid Discord snowflakes", + ) + } + + return { guildId, channelId, messageId } + } catch (err) { + console.warn("Failed to extract details from Discord URL") + throw new Error( + `Invalid Discord message URL: ${ + err instanceof Error ? err.message : "Unknown error" + }`, + ) + } + } + + // Basic snowflake validator (numeric string up to 19-20 digits) + private isSnowflake(id: string): boolean { + return /^\d{17,20}$/.test(id) + } + + // Generic GET with simple rate-limit handling + private async get(url: string, delay = 0): Promise> { + if (delay > 0) { + await new Promise(r => setTimeout(r, delay)) + } + + try { + return await this.axios.get(url) + } catch (e: any) { + if (e?.response?.status === 429) { + const retryAfter = Number( + e.response.headers["retry-after"] ?? 1, + ) + await new Promise(r => + setTimeout(r, Math.ceil(retryAfter * 1000)), + ) + return await this.axios.get(url) + } + throw e + } + } + + // Fetch a message by channel & message ID + async getMessageById( + channelId: string, + messageId: string, + ): Promise { + const res = await this.get( + `/channels/${channelId}/messages/${messageId}`, + ) + if (res.status === 200) return res.data + throw new Error("Failed to get Discord message") + } + + // Fetch a message by full URL + async getMessageByUrl(messageUrl: string): Promise { + const { channelId, messageId } = this.extractMessageDetails(messageUrl) + return await this.getMessageById(channelId, messageId) + } + + static getInstance() { + if (!Discord.instance) { + Discord.instance = new Discord() + } + return Discord.instance + } +} diff --git a/src/libs/network/manageNodeCall.ts b/src/libs/network/manageNodeCall.ts index 66943289f..1f852e339 100644 --- a/src/libs/network/manageNodeCall.ts +++ b/src/libs/network/manageNodeCall.ts @@ -1,7 +1,6 @@ import { RPCResponse } from "@kynesyslabs/demosdk/types" import { emptyResponse } from "./server_rpc" import Chain from "../blockchain/chain" -import GCR from "../blockchain/gcr/gcr" import eggs from "./routines/eggs" import { getSharedState } from "src/utilities/sharedState" import _ from "lodash" @@ -19,16 +18,12 @@ import getTransactions from "./routines/nodecalls/getTransactions" import Hashing from "../crypto/hashing" import log from "src/utilities/logger" import HandleGCR from "../blockchain/gcr/handleGCR" -import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" -import { - hexToUint8Array, - ucrypto, - uint8ArrayToHex, -} from "@kynesyslabs/demosdk/encryption" +import { uint8ArrayToHex } from "@kynesyslabs/demosdk/encryption" import { Twitter } from "../identity/tools/twitter" import { Tweet } from "@kynesyslabs/demosdk/types" import Mempool from "../blockchain/mempool_v2" import ensureGCRForUser from "../blockchain/gcr/gcr_routines/ensureGCRForUser" +import { Discord, DiscordMessage } from "../identity/tools/discord" export interface NodeCall { message: string @@ -269,6 +264,78 @@ export async function manageNodeCall(content: NodeCall): Promise { break } + case "getDiscordMessage": { + if (!data.discordUrl) { + response.result = 400 + response.response = "No Discord URL specified" + break + } + + let discord: Discord + try { + discord = Discord.getInstance() + } catch (e) { + response.result = 500 + response.response = { + success: false, + error: "Discord not configured", + } + break + } + + let message: DiscordMessage | null = null + + try { + message = await discord.getMessageByUrl(data.discordUrl) + } catch (error) { + response.result = 400 + response.response = { + success: false, + error: "Failed to get Discord message", + } + break + } + + response.result = message ? 200 : 400 + if (message) { + let guildIdFromUrl: string | undefined + let channelIdFromUrl: string | undefined + let messageIdFromUrl: string | undefined + + try { + const details = discord.extractMessageDetails( + data.discordUrl, + ) + guildIdFromUrl = details.guildId + channelIdFromUrl = details.channelId + messageIdFromUrl = details.messageId + } catch { + // non-fatal, e.g. if URL format was unexpected + } + + const payload = { + id: message.id, + timestamp: message.timestamp, + authorUsername: message.author?.username ?? null, + authorId: message.author?.id ?? null, + channelId: message.channel_id ?? channelIdFromUrl ?? null, + guildId: + (message as any).guild_id ?? guildIdFromUrl ?? null, + } + + response.response = { + message: payload, + success: true, + } + } else { + response.response = { + success: false, + error: "Failed to get Discord message", + } + } + break + } + // INFO: Tests if twitter account is a bot // case "checkIsBot": { // if (!data.username || !data.userId) {