From d91992d09ed47d050253c4baa46acc6e138c5f7c Mon Sep 17 00:00:00 2001 From: HakobP-Solicy Date: Fri, 5 Sep 2025 20:25:04 +0400 Subject: [PATCH 1/5] Integrated Discord Identity --- .env.example | 5 +- src/libs/abstraction/index.ts | 9 +- src/libs/abstraction/web2/discord.ts | 94 +++++++++++++++++ src/libs/abstraction/web2/parsers.ts | 4 + src/libs/identity/tools/discord.ts | 151 +++++++++++++++++++++++++++ src/libs/network/manageNodeCall.ts | 94 +++++++++++++++-- 6 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 src/libs/abstraction/web2/discord.ts create mode 100644 src/libs/identity/tools/discord.ts diff --git a/.env.example b/.env.example index 34b0fa492..4b9bb59f2 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_BOT_TOKEN=MTQxMjEyMjcyMzc0Mzk1NzA2NA.GKYDD0.GHyxhRm2xcGK2-f6yOH4p5sfbC8cJtJWTz-yVk +DISCORD_API_URL=https://discord.com/api/v10 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..3784a0206 --- /dev/null +++ b/src/libs/abstraction/web2/discord.ts @@ -0,0 +1,94 @@ +import axios from "axios" +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 + private botToken: string + discord: Discord + + constructor() { + super() + this.discord = Discord.getInstance() + this.botToken = process.env.DISCORD_BOT_TOKEN ?? "" + } + + private parseDiscordMessageUrl(proofUrl: string): { + channelId: string + messageId: string + } { + try { + const url = new URL(proofUrl) + const parts = url.pathname.split("/").filter(Boolean) + const channelsIndex = parts.indexOf("channels") + if (channelsIndex === -1 || parts.length < channelsIndex + 4) { + throw new Error("Invalid Discord message URL format") + } + + const channelId = parts[channelsIndex + 2] + const messageId = parts[channelsIndex + 3] + + return { channelId, messageId } + } catch (error) { + console.error(error) + throw new Error("Failed to extract Discord message details") + } + } + + async getMessageFromUrl(messageUrl: string) { + const apiUrl = "https://discord.com/api/v10" + const parts = messageUrl.split("/").filter(Boolean) + const channelId = parts[5] + const messageId = parts[6] + + const res = await axios.get( + `${apiUrl}/channels/${channelId}/messages/${messageId}`, + { + headers: { Authorization: `Bot ${this.botToken}` }, + }, + ) + + return res.data + } + + async readData(proofUrl: string): Promise<{ + message: string + signature: string + type: SigningAlgorithm + }> { + this.verifyProofFormat(proofUrl, "discord") + + const { channelId, messageId } = this.parseDiscordMessageUrl(proofUrl) + + const res = await axios.get( + `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, + { + headers: { + Authorization: `Bot ${this.botToken}`, + }, + }, + ) + + if (res.status !== 200) { + throw new Error(`Failed to fetch Discord message: ${res.status}`) + } + + const content = (res.data?.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..98314f43d 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -8,6 +8,10 @@ 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", + ], } constructor() {} diff --git a/src/libs/identity/tools/discord.ts b/src/libs/identity/tools/discord.ts new file mode 100644 index 000000000..09b3d6245 --- /dev/null +++ b/src/libs/identity/tools/discord.ts @@ -0,0 +1,151 @@ +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 + + api_url = process.env.DISCORD_API_URL ?? "https://discord.com/api/v10" + bot_token = process.env.DISCORD_BOT_TOKEN as string + + private constructor() { + if (!this.bot_token) { + throw new Error("Missing DISCORD_BOT_TOKEN env variable") + } + + this.axios = axios.create({ + baseURL: this.api_url, + headers: { + Authorization: `Bot ${this.bot_token}`, + "Content-Type": "application/json", + }, + }) + } + + // Extracts IDs from a Discord message URL + extractMessageDetails(messageUrl: string): { + guildId: string + channelId: string + messageId: string + } { + try { + const url = new URL(messageUrl) + + // Normalize hosts like discordapp.com -> discord.com + if ( + !/discord\.com$/i.test(url.host) && + !/discordapp\.com$/i.test(url.host) + ) { + throw new Error( + "URL host must be discord.com or discordapp.com", + ) + } + + 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.error(`Failed to extract details from URL: ${messageUrl}`) + 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..c17d43604 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,91 @@ export async function manageNodeCall(content: NodeCall): Promise { break } + case "getDiscordMessage": { + if (!data.discordUrl) { + response.result = 400 + response.response = "No Discord URL specified" + break + } + + const discord = Discord.getInstance() + 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, + content: message.content, + timestamp: message.timestamp, + edited_timestamp: message.edited_timestamp ?? null, + + authorUsername: message.author?.username ?? null, + authorGlobalName: message.author?.global_name ?? null, + authorId: message.author?.id ?? null, + authorIsBot: !!message.author?.bot, + + channelId: message.channel_id ?? channelIdFromUrl ?? null, + guildId: + (message as any).guild_id ?? guildIdFromUrl ?? null, + + attachments: (message.attachments || []).map(a => ({ + id: a.id, + filename: a.filename, + size: a.size, + url: a.url, + proxy_url: a.proxy_url, + content_type: a.content_type ?? null, + })), + embedsCount: Array.isArray(message.embeds) + ? message.embeds.length + : 0, + mentions: (message.mentions || []).map(m => ({ + id: m.id, + username: m.username, + })), + replyToId: message.referenced_message?.id ?? null, + originalMessageIdFromUrl: messageIdFromUrl ?? 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) { From ee9f49e5a9f6676dacfc311aec4b688c06590a00 Mon Sep 17 00:00:00 2001 From: HakobP-Solicy Date: Wed, 10 Sep 2025 20:16:21 +0400 Subject: [PATCH 2/5] Updated point system for the Discord identity --- .env.example | 4 +- src/features/incentive/PointSystem.ts | 157 ++++++++++++++++-- .../gcr/gcr_routines/GCRIdentityRoutines.ts | 57 ++++++- .../gcr/gcr_routines/IncentiveManager.ts | 22 ++- 4 files changed, 220 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 4b9bb59f2..47a98dbed 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,5 @@ TWITTER_EMAIL= GITHUB_TOKEN= -DISCORD_BOT_TOKEN=MTQxMjEyMjcyMzc0Mzk1NzA2NA.GKYDD0.GHyxhRm2xcGK2-f6yOH4p5sfbC8cJtJWTz-yVk -DISCORD_API_URL=https://discord.com/api/v10 +DISCORD_BOT_TOKEN= +DISCORD_API_URL= diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index 9cfc8769d..04b85378c 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,125 @@ 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 { + 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/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index 76538765d..b808ae77d 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, + data.userId, + ) + } } else { log.info(`Web2 identity linked: ${context}/${data.username}`) } @@ -281,10 +294,11 @@ 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 + if (context === "github" || context === "discord") { + removedIdentity = + accountGCR.identities.web2[context].find( + (id: Web2GCRData["data"]) => id.username === username, + ) || null } accountGCR.identities.web2[context] = accountGCR.identities.web2[ @@ -299,11 +313,21 @@ 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" && + removedIdentity && + removedIdentity.userId + ) { + await IncentiveManager.discordUnlinked(editOperation.account) } } @@ -600,9 +624,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 +668,25 @@ 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(gcr.identities->'web2'->'discord') 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) + } } From ab5dfa231aa9730588108378ba54ab44713f6893 Mon Sep 17 00:00:00 2001 From: HakobP-Solicy Date: Thu, 11 Sep 2025 17:08:09 +0400 Subject: [PATCH 3/5] Fixed comments --- .env.example | 2 +- src/features/incentive/PointSystem.ts | 20 +++++++ src/libs/abstraction/web2/discord.ts | 56 +++---------------- src/libs/abstraction/web2/parsers.ts | 2 + .../gcr/gcr_routines/GCRIdentityRoutines.ts | 2 +- src/libs/identity/tools/discord.ts | 19 ++++++- src/libs/network/manageNodeCall.ts | 37 ++++-------- 7 files changed, 60 insertions(+), 78 deletions(-) diff --git a/.env.example b/.env.example index 47a98dbed..9e4e7e01f 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,5 @@ TWITTER_EMAIL= GITHUB_TOKEN= -DISCORD_BOT_TOKEN= DISCORD_API_URL= +DISCORD_BOT_TOKEN= diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index 04b85378c..4c812c8d1 100644 --- a/src/features/incentive/PointSystem.ts +++ b/src/features/incentive/PointSystem.ts @@ -717,6 +717,26 @@ export class PointSystem { 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, ) diff --git a/src/libs/abstraction/web2/discord.ts b/src/libs/abstraction/web2/discord.ts index 3784a0206..613e4575d 100644 --- a/src/libs/abstraction/web2/discord.ts +++ b/src/libs/abstraction/web2/discord.ts @@ -1,55 +1,27 @@ -import axios from "axios" 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 - private botToken: string discord: Discord constructor() { super() this.discord = Discord.getInstance() - this.botToken = process.env.DISCORD_BOT_TOKEN ?? "" } private parseDiscordMessageUrl(proofUrl: string): { channelId: string messageId: string } { - try { - const url = new URL(proofUrl) - const parts = url.pathname.split("/").filter(Boolean) - const channelsIndex = parts.indexOf("channels") - if (channelsIndex === -1 || parts.length < channelsIndex + 4) { - throw new Error("Invalid Discord message URL format") - } - - const channelId = parts[channelsIndex + 2] - const messageId = parts[channelsIndex + 3] - - return { channelId, messageId } - } catch (error) { - console.error(error) - throw new Error("Failed to extract Discord message details") - } + const { channelId, messageId } = + this.discord.extractMessageDetails(proofUrl) + return { channelId, messageId } } async getMessageFromUrl(messageUrl: string) { - const apiUrl = "https://discord.com/api/v10" - const parts = messageUrl.split("/").filter(Boolean) - const channelId = parts[5] - const messageId = parts[6] - - const res = await axios.get( - `${apiUrl}/channels/${channelId}/messages/${messageId}`, - { - headers: { Authorization: `Bot ${this.botToken}` }, - }, - ) - - return res.data + return await this.discord.getMessageByUrl(messageUrl) } async readData(proofUrl: string): Promise<{ @@ -59,22 +31,10 @@ export class DiscordProofParser extends Web2ProofParser { }> { this.verifyProofFormat(proofUrl, "discord") - const { channelId, messageId } = this.parseDiscordMessageUrl(proofUrl) - - const res = await axios.get( - `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, - { - headers: { - Authorization: `Bot ${this.botToken}`, - }, - }, - ) - - if (res.status !== 200) { - throw new Error(`Failed to fetch Discord message: ${res.status}`) - } - - const content = (res.data?.content as string) || "" + // 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) { diff --git a/src/libs/abstraction/web2/parsers.ts b/src/libs/abstraction/web2/parsers.ts index 98314f43d..9a20cd890 100644 --- a/src/libs/abstraction/web2/parsers.ts +++ b/src/libs/abstraction/web2/parsers.ts @@ -11,6 +11,8 @@ export abstract class Web2ProofParser { discord: [ "https://discord.com/channels", "https://ptb.discord.com/channels", + "https://canary.discord.com/channels", + "https://discordapp.com/channels", ], } diff --git a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index b808ae77d..2347fae61 100644 --- a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts +++ b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts @@ -258,7 +258,7 @@ export default class GCRIdentityRoutines { if (isFirst) { await IncentiveManager.discordLinked( editOperation.account, - data.userId, + editOperation.referralCode, ) } } else { diff --git a/src/libs/identity/tools/discord.ts b/src/libs/identity/tools/discord.ts index 09b3d6245..d71095fe8 100644 --- a/src/libs/identity/tools/discord.ts +++ b/src/libs/identity/tools/discord.ts @@ -32,14 +32,27 @@ export class Discord { private static instance: Discord private axios: AxiosInstance - api_url = process.env.DISCORD_API_URL ?? "https://discord.com/api/v10" - bot_token = process.env.DISCORD_BOT_TOKEN as string + 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 === "localhost" || + host.endsWith(".discord.com") || + host === "discord.com" || + host === "discordapp.com" + + if (!isTrusted) { + throw new Error(`Untrusted DISCORD_API_URL host: ${host}`) + } + this.axios = axios.create({ baseURL: this.api_url, headers: { @@ -88,7 +101,7 @@ export class Discord { return { guildId, channelId, messageId } } catch (err) { - console.error(`Failed to extract details from URL: ${messageUrl}`) + console.warn("Failed to extract details from Discord URL") throw new Error( `Invalid Discord message URL: ${ err instanceof Error ? err.message : "Unknown error" diff --git a/src/libs/network/manageNodeCall.ts b/src/libs/network/manageNodeCall.ts index c17d43604..1f852e339 100644 --- a/src/libs/network/manageNodeCall.ts +++ b/src/libs/network/manageNodeCall.ts @@ -271,7 +271,18 @@ export async function manageNodeCall(content: NodeCall): Promise { break } - const discord = Discord.getInstance() + 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 { @@ -304,36 +315,12 @@ export async function manageNodeCall(content: NodeCall): Promise { const payload = { id: message.id, - content: message.content, timestamp: message.timestamp, - edited_timestamp: message.edited_timestamp ?? null, - authorUsername: message.author?.username ?? null, - authorGlobalName: message.author?.global_name ?? null, authorId: message.author?.id ?? null, - authorIsBot: !!message.author?.bot, - channelId: message.channel_id ?? channelIdFromUrl ?? null, guildId: (message as any).guild_id ?? guildIdFromUrl ?? null, - - attachments: (message.attachments || []).map(a => ({ - id: a.id, - filename: a.filename, - size: a.size, - url: a.url, - proxy_url: a.proxy_url, - content_type: a.content_type ?? null, - })), - embedsCount: Array.isArray(message.embeds) - ? message.embeds.length - : 0, - mentions: (message.mentions || []).map(m => ({ - id: m.id, - username: m.username, - })), - replyToId: message.referenced_message?.id ?? null, - originalMessageIdFromUrl: messageIdFromUrl ?? null, } response.response = { From cf48dad2ebc7fdb21ea35fe6fdec92955d099d38 Mon Sep 17 00:00:00 2001 From: HakobP-Solicy Date: Fri, 12 Sep 2025 13:09:59 +0400 Subject: [PATCH 4/5] Fixed comments --- .../gcr/gcr_routines/GCRIdentityRoutines.ts | 14 ++++---------- src/libs/identity/tools/discord.ts | 4 +++- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index 2347fae61..32dfd4882 100644 --- a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts +++ b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts @@ -294,7 +294,7 @@ export default class GCRIdentityRoutines { // Store the identity being removed for GitHub unlinking (need userId) let removedIdentity: Web2GCRData["data"] | null = null - if (context === "github" || context === "discord") { + if (context === "github") { removedIdentity = accountGCR.identities.web2[context].find( (id: Web2GCRData["data"]) => id.username === username, @@ -322,11 +322,7 @@ export default class GCRIdentityRoutines { editOperation.account, removedIdentity.userId, ) - } else if ( - context === "discord" && - removedIdentity && - removedIdentity.userId - ) { + } else if (context === "discord") { await IncentiveManager.discordUnlinked(editOperation.account) } } @@ -679,10 +675,8 @@ export default class GCRIdentityRoutines { const result = await gcrMainRepository .createQueryBuilder("gcr") .where( - "EXISTS (SELECT 1 FROM jsonb_array_elements(gcr.identities->'web2'->'discord') as discord_id WHERE discord_id->>'userId' = :userId)", - { - userId: data.userId, - }, + "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() diff --git a/src/libs/identity/tools/discord.ts b/src/libs/identity/tools/discord.ts index d71095fe8..5b5068b23 100644 --- a/src/libs/identity/tools/discord.ts +++ b/src/libs/identity/tools/discord.ts @@ -32,7 +32,8 @@ export class Discord { private static instance: Discord private axios: AxiosInstance - readonly api_url = process.env.DISCORD_API_URL ?? "https://discord.com/api/v10" + 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() { @@ -59,6 +60,7 @@ export class Discord { Authorization: `Bot ${this.bot_token}`, "Content-Type": "application/json", }, + timeout: 10000, // 10s }) } From 9036b99948fcd99729480be868c8c60cf0901499 Mon Sep 17 00:00:00 2001 From: HakobP-Solicy Date: Fri, 12 Sep 2025 13:37:09 +0400 Subject: [PATCH 5/5] Fixed Discord host issues --- src/libs/identity/tools/discord.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/libs/identity/tools/discord.ts b/src/libs/identity/tools/discord.ts index 5b5068b23..8b994bd39 100644 --- a/src/libs/identity/tools/discord.ts +++ b/src/libs/identity/tools/discord.ts @@ -45,10 +45,7 @@ export class Discord { const parsed = new URL(this.api_url) const host = parsed.hostname.toLowerCase() const isTrusted = - host === "localhost" || - host.endsWith(".discord.com") || - host === "discord.com" || - host === "discordapp.com" + host.endsWith(".discord.com") || host === "discord.com" if (!isTrusted) { throw new Error(`Untrusted DISCORD_API_URL host: ${host}`) @@ -73,13 +70,16 @@ export class Discord { try { const url = new URL(messageUrl) - // Normalize hosts like discordapp.com -> discord.com - if ( - !/discord\.com$/i.test(url.host) && - !/discordapp\.com$/i.test(url.host) - ) { + 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", + "URL host must be discord.com or discordapp.com (including ptb/canary).", ) }