nestjs-social-quest
v1.0.0
Published
NestJS library for social media quests (X, Discord, Telegram) via official API auth and twitterapi.io verification
Downloads
107
Maintainers
Readme
nestjs-social-quest
A production-ready NestJS library for building social media quest campaigns — verify follows, reposts, likes, Discord roles, and Telegram membership using official APIs. Designed for Web3 and marketing platforms that reward users for completing social tasks.
Why nestjs-social-quest?
| Feature | Details |
|---------|---------|
| 🐦 X (Twitter) | OAuth 2.0 PKCE login + follow/repost/quote via twitterapi.io + like via official Twitter v2 API |
| 🎮 Discord | OAuth 2.0 login + guild membership and role verification via Discord Bot API |
| ✈️ Telegram | Login Widget validation + channel/group membership via Telegram Bot API |
| 🗄️ CRUD + DB | Optional TypeORM integration for managing quests and tracking user completions |
| 🧪 Mock Mode | Skip real API calls per-quest with a runtime flag — great for testing and development |
| ⚡ CLI Migrate | One command to create DB tables — no migration files to manage |
| 🚨 Typed Exceptions | SocialQuestException, RateLimitException, AccountNotLinkedException for precise error handling |
Table of Contents
- Installation
- Quick Start
- Database Migration
- Module Configuration
- Authentication Flows
- Quest Verification
- CRUD Quest Management
- Error Handling
- API Reference
Installation
npm install nestjs-social-quest
# Required peer deps
npm install @nestjs/common @nestjs/core axios
# If using database features
npm install typeorm @nestjs/typeorm
# If using the migrate CLI
npm install pgQuick Start
// app.module.ts
import { Module } from '@nestjs/common';
import { SocialQuestModule } from 'nestjs-social-quest';
@Module({
imports: [
SocialQuestModule.forRoot({
twitter: {
clientId: process.env.X_CLIENT_ID,
clientSecret: process.env.X_CLIENT_SECRET,
callbackUrl: 'http://localhost:3000/auth/twitter/callback',
},
twitterapiIo: {
apiKey: process.env.TWITTERAPI_IO_KEY,
},
discord: {
botToken: process.env.DISCORD_BOT_TOKEN,
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackUrl: 'http://localhost:3000/auth/discord/callback',
},
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN,
},
// Optional: enable DB-backed quest CRUD
database: { enabled: true },
}),
],
})
export class AppModule {}Database Migration
If database: { enabled: true }, run the CLI once to create the required tables:
npx social-quest migrateEnv vars (all optional, with defaults):
| Variable | Default | Description |
|---------------|----------------|------------------------|
| DB_HOST | localhost | PostgreSQL host |
| DB_PORT | 5432 | PostgreSQL port |
| DB_USERNAME | postgres | Database user |
| DB_PASSWORD | postgres | Database password |
| DB_DATABASE | social_quest | Database name |
# With explicit credentials
DB_HOST=myhost DB_USERNAME=myuser DB_PASSWORD=secret DB_DATABASE=mydb npx social-quest migrateIdempotent — safe to run multiple times. Uses
CREATE TABLE IF NOT EXISTS.
Alternatively, if your client app already uses TypeORM with synchronize: true, the tables will be created automatically on startup (development only).
Module Configuration
Full configuration reference:
SocialQuestModule.forRoot({
twitter: {
clientId: 'YOUR_X_CLIENT_ID',
clientSecret: 'YOUR_X_CLIENT_SECRET',
callbackUrl: 'https://yourapp.com/auth/twitter/callback',
},
twitterapiIo: {
apiKey: 'YOUR_TWITTERAPI_IO_KEY',
// Optional: override twitterapi.io base URL
baseUrl: 'https://api.twitterapi.io/twitter',
// Optional: override individual verification endpoints
endpoints: {
verifyFollow: '/user/following', // default
verifyRepost: '/tweet/retweeters', // default
verifyQuote: '/tweet/quotes', // default
},
},
discord: {
botToken: 'YOUR_DISCORD_BOT_TOKEN',
clientId: 'YOUR_DISCORD_CLIENT_ID',
clientSecret: 'YOUR_DISCORD_CLIENT_SECRET',
callbackUrl: 'https://yourapp.com/auth/discord/callback',
},
telegram: {
botToken: 'YOUR_TELEGRAM_BOT_TOKEN',
},
// Optional: enable database-backed quest management
database: { enabled: true },
})Authentication Flows
X (Twitter) OAuth 2.0
The library uses OAuth 2.0 PKCE — no client secret is sent over the browser.
import { XAuthService } from 'nestjs-social-quest';
@Controller('auth/twitter')
export class TwitterAuthController {
constructor(private readonly xAuth: XAuthService) {}
@Get('url')
getAuthUrl() {
const { codeVerifier, codeChallenge } = this.xAuth.generatePKCE();
const state = 'random-state-string'; // store in session
// store codeVerifier in session for use in callback
return {
url: this.xAuth.generateAuthUrl(state, codeChallenge),
// Optional: custom scopes
// url: this.xAuth.generateAuthUrl(state, codeChallenge, ['tweet.read', 'users.read', 'like.read']),
};
}
@Get('callback')
async callback(@Query('code') code: string, @Query('state') state: string) {
const codeVerifier = '...'; // retrieve from session
// Returns: { accessToken, refreshToken, expiresIn, userId, username }
const result = await this.xAuth.handleCallback(code, codeVerifier);
// Store result.accessToken and result.userId for later quest verification
return result;
}
}XAuthCallbackResult:
| Field | Type | Description |
|-------|------|-------------|
| accessToken | string | Bearer token for Twitter API v2 |
| refreshToken | string? | Refresh token (if offline.access scope requested) |
| expiresIn | number? | Token lifetime in seconds |
| userId | string | Twitter user ID |
| username | string | Twitter handle (@username) |
Discord OAuth 2.0
import { DiscordAuthService } from 'nestjs-social-quest';
@Controller('auth/discord')
export class DiscordAuthController {
constructor(private readonly discordAuth: DiscordAuthService) {}
@Get('url')
getAuthUrl() {
const state = 'random-state-string'; // store in session
return {
url: this.discordAuth.generateAuthUrl(state),
// Optional custom scopes — default: ['identify', 'guilds', 'guilds.join']
// url: this.discordAuth.generateAuthUrl(state, ['identify', 'guilds']),
};
}
@Get('callback')
async callback(@Query('code') code: string) {
// Returns: { accessToken, refreshToken, expiresIn, userId, username }
const result = await this.discordAuth.handleCallback(code);
// Store result.userId as the Discord user ID for quest verification
return result;
}
}DiscordAuthCallbackResult:
| Field | Type | Description |
|-------|------|-------------|
| accessToken | string | Discord user access token |
| refreshToken | string | Refresh token |
| expiresIn | number | Token lifetime in seconds |
| userId | string | Discord user snowflake ID |
| username | string | Discord username |
Telegram Login Widget
Telegram uses a widget-based login — no redirect flow. The frontend widget sends signed user data directly to your backend.
import { TelegramAuthService, TelegramAuthResult } from 'nestjs-social-quest';
@Controller('auth/telegram')
export class TelegramAuthController {
constructor(private readonly telegramAuth: TelegramAuthService) {}
@Post('callback')
validateLogin(@Body() data: TelegramAuthResult) {
// Validates HMAC-SHA256 signature from Telegram Login Widget
const isValid = this.telegramAuth.validateAuthData(data);
if (!isValid) {
throw new UnauthorizedException('Invalid Telegram auth data');
}
// data.id is the Telegram user ID — store it for quest verification
return { userId: data.id, username: data.username };
}
}TelegramAuthResult (sent by the widget):
| Field | Type | Required |
|-------|------|----------|
| id | number | ✅ Telegram user ID |
| first_name | string | ✅ |
| last_name | string? | optional |
| username | string? | optional |
| photo_url | string? | optional |
| auth_date | number | ✅ Unix timestamp |
| hash | string | ✅ HMAC signature |
Quest Verification
X (Twitter) Quests
import { XQuestService } from 'nestjs-social-quest';
@Injectable()
export class CampaignService {
constructor(private readonly xQuest: XQuestService) {}
// Check if user follows @brand — via twitterapi.io
async checkFollow(twitterUserId: string) {
return this.xQuest.verifyFollow(twitterUserId, 'brand');
// Returns: boolean
}
// Check if user reposted a tweet — via twitterapi.io
async checkRepost(twitterUserId: string, tweetId: string) {
return this.xQuest.verifyRepost(twitterUserId, tweetId);
}
// Check if user quoted a tweet — via twitterapi.io
async checkQuote(twitterUserId: string, tweetId: string) {
return this.xQuest.verifyQuote(twitterUserId, tweetId);
}
// Check if user liked a tweet — via Twitter API v2 (requires user's accessToken)
async checkLike(twitterUserId: string, tweetId: string, accessToken: string) {
return this.xQuest.verifyLike(twitterUserId, tweetId, accessToken);
// Throws RateLimitException on HTTP 429
}
}Discord Quests
import { DiscordQuestService } from 'nestjs-social-quest';
@Injectable()
export class CampaignService {
constructor(private readonly discordQuest: DiscordQuestService) {}
// Check if user is a member of a guild
async checkGuild(discordUserId: string, guildId: string) {
return this.discordQuest.verifyGuildMember(discordUserId, guildId);
// Returns: boolean (false if user not found, throws on API errors)
}
// Check if user has a specific role in a guild
async checkRole(discordUserId: string, guildId: string, roleId: string) {
return this.discordQuest.verifyRole(discordUserId, guildId, roleId);
}
}Telegram Quests
import { TelegramQuestService } from 'nestjs-social-quest';
@Injectable()
export class CampaignService {
constructor(private readonly telegramQuest: TelegramQuestService) {}
// Check if user is a member of a channel or group
async checkMembership(telegramUserId: number, chatId: string) {
return this.telegramQuest.verifyChatMember(telegramUserId, chatId);
// chatId: '@mychannel' or numeric chat ID like '-1001234567890'
// Returns: boolean
}
}CRUD Quest Management
Enable with database: { enabled: true }. Requires TypeORM + PostgreSQL.
import { QuestCrudService } from 'nestjs-social-quest/dist/services/quest/quest-crud.service';
import { Quest } from 'nestjs-social-quest/dist/entities/quest.entity';
import { UserQuest } from 'nestjs-social-quest/dist/entities/user-quest.entity';Create a Quest
const quest = await questCrud.create({
id: 'q-follow-brand', // your custom ID
name: 'Follow @brand on X',
type: 'x_follow',
metadata: { targetUsername: 'brand' },
url: 'https://x.com/brand',
point: 10,
});Quest Types & Metadata Reference
| Type | Required Metadata | Verification API | Notes |
|------|-------------------|-----------------|-------|
| x_follow | { targetUsername: string } | twitterapi.io | |
| x_repost | { tweetId: string } | twitterapi.io | |
| x_quote | { tweetId: string } | twitterapi.io | |
| x_like | { tweetId: string } | Twitter API v2 | Requires accessToken in SubmitQuestDto |
| discord_guild | { guildId: string } | Discord Bot API | |
| discord_role | { guildId: string, roleId: string } | Discord Bot API | |
| telegram_member | { chatId: string } | Telegram Bot API | |
CRUD Operations
// List all quests (optional filter by type)
const quests: Quest[] = await questCrud.findAll();
const xQuests: Quest[] = await questCrud.findAll({ type: 'x_follow' });
// Get by ID
const quest: Quest = await questCrud.findById('q-follow-brand');
// Update
const updated: Quest = await questCrud.update('q-follow-brand', {
name: 'Follow @newbrand',
point: 20,
});
// Delete
await questCrud.delete('q-follow-brand');Submit & Verify a Quest
// Real verification
const result: UserQuest = await questCrud.submitQuest({
questId: 'q-follow-brand',
userId: 'user-123', // your internal user ID
socialId: '987654321', // Twitter user ID
});
// For x_like, also pass the user's Twitter accessToken
const result = await questCrud.submitQuest({
questId: 'q-like-tweet',
userId: 'user-123',
socialId: '987654321',
accessToken: 'user-bearer-token',
});
// Mock verification — skips API call, always marks as complete
const result = await questCrud.submitQuest({
questId: 'q-follow-brand',
userId: 'user-123',
mockVerify: true,
});
submitQuestis idempotent: if the user already completed the quest, it returns the existingUserQuestrecord without re-verifying.
DB Schema
CREATE TABLE quest (
id varchar(44) PRIMARY KEY,
name varchar(255) NOT NULL,
type varchar(255) NOT NULL,
metadata jsonb,
url varchar(255),
point int NOT NULL DEFAULT 0,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp
);
CREATE TABLE user_quests (
user_id varchar(44) NOT NULL,
quest_id varchar(44) NOT NULL,
social_id varchar(255),
completed_at timestamp,
PRIMARY KEY (user_id, quest_id),
CONSTRAINT fk_user_quests_quest
FOREIGN KEY (quest_id) REFERENCES quest(id) ON DELETE CASCADE
);Error Handling
The library throws typed exceptions you can catch in NestJS exception filters or service code:
import {
SocialQuestException,
RateLimitException,
AccountNotLinkedException,
} from 'nestjs-social-quest';
try {
await xQuest.verifyLike(userId, tweetId, accessToken);
} catch (err) {
if (err instanceof RateLimitException) {
// Twitter API v2 returned HTTP 429
// Suggest retry-after or queue the check
} else if (err instanceof AccountNotLinkedException) {
// User's social account is not connected
} else if (err instanceof SocialQuestException) {
// General quest error (quest not found, verification failed, etc.)
}
}| Exception | When thrown |
|-----------|-------------|
| SocialQuestException | Base exception — general errors (quest not found, auth failure, missing metadata, etc.) |
| RateLimitException | API returned HTTP 429 (rate limited) |
| AccountNotLinkedException | User's social account is not linked |
API Reference
XAuthService
| Method | Signature | Description |
|--------|-----------|-------------|
| generatePKCE() | () => { codeVerifier, codeChallenge } | Generate PKCE pair for OAuth flow |
| generateAuthUrl() | (state, codeChallenge, scopes?) => string | Build Twitter OAuth 2.0 authorization URL |
| handleCallback() | (code, codeVerifier) => Promise<XAuthCallbackResult> | Exchange code for access token + user info |
DiscordAuthService
| Method | Signature | Description |
|--------|-----------|-------------|
| generateAuthUrl() | (state, scopes?) => string | Build Discord OAuth 2.0 authorization URL |
| handleCallback() | (code) => Promise<DiscordAuthCallbackResult> | Exchange code for access token + user info |
TelegramAuthService
| Method | Signature | Description |
|--------|-----------|-------------|
| validateAuthData() | (data: TelegramAuthResult) => boolean | Validate Telegram Login Widget HMAC signature |
XQuestService
| Method | Signature | Description |
|--------|-----------|-------------|
| verifyFollow() | (userId, targetUsername) => Promise<boolean> | Check follow via twitterapi.io |
| verifyRepost() | (userId, tweetId) => Promise<boolean> | Check repost via twitterapi.io |
| verifyQuote() | (userId, tweetId) => Promise<boolean> | Check quote via twitterapi.io |
| verifyLike() | (userId, tweetId, accessToken) => Promise<boolean> | Check like via Twitter API v2 |
DiscordQuestService
| Method | Signature | Description |
|--------|-----------|-------------|
| verifyGuildMember() | (userId, guildId) => Promise<boolean> | Check guild membership |
| verifyRole() | (userId, guildId, roleId) => Promise<boolean> | Check role membership |
TelegramQuestService
| Method | Signature | Description |
|--------|-----------|-------------|
| verifyChatMember() | (userId, chatId) => Promise<boolean> | Check channel/group membership |
QuestCrudService (requires database: { enabled: true })
| Method | Signature | Description |
|--------|-----------|-------------|
| create() | (dto: CreateQuestDto) => Promise<Quest> | Create a quest |
| findAll() | (filter?: { type? }) => Promise<Quest[]> | List all quests, optionally filtered |
| findById() | (id) => Promise<Quest> | Get quest by ID |
| update() | (id, dto: UpdateQuestDto) => Promise<Quest> | Update quest fields |
| delete() | (id) => Promise<void> | Delete quest |
| submitQuest() | (dto: SubmitQuestDto) => Promise<UserQuest> | Verify and record quest completion |
