Initial commit

master
j 2024-03-19 01:16:12 -04:00
commit 5ab8150b33
30 changed files with 5606 additions and 0 deletions

8
.gitignore vendored 100644
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.js
*.js.map
.DS_Store
.vscode/
*.log

55
README.MD 100644
View File

@ -0,0 +1,55 @@
# Drama Harvester
## Introduction
Drama Harvester is a specialized tool designed to interface with rDrama's public APIs, retrieve content such as comments and posts, and publish them to Redis for caching and further processing. Its goal is to facilitate the real-time ingestion of rDrama content, serving as a foundational layer for applications requiring streamlined access to rDrama data.
## Features
- Retrieves comments and posts from rDrama's endpoints.
- Publishes content to Redis, ensuring efficient caching.
- Supports incremental fetching to minimize redundant data retrieval.
## Getting Started
### Prerequisites
- Node.js (version 14 or later recommended)
- Redis server instance
### Installation
1. Clone the repository:
```bash
git clone https://fsdfsd.net/J/DramaHarvester.git
```
2. Navigate to the project directory:
```bash
cd DramaHarvester
```
3. Install dependencies:
```bash
npm install
```
### Configuration
1. Copy the `.env.example` file to a new file named `.env`.
```bash
cp .env.example .env
```
2. Edit the `.env` file to include your Redis connection details and any other configuration variables.
### Running Drama Harvester
To start the application, run:
```bash
npm start

3659
package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

26
package.json 100644
View File

@ -0,0 +1,26 @@
{
"dependencies": {
"axios": "^1.6.7",
"axios-request-throttle": "^1.0.0",
"axios-retry": "^4.0.0",
"bottleneck": "^2.19.5",
"dotenv": "^16.4.5",
"form-data": "^4.0.0",
"fs": "^0.0.1-security",
"ioredis": "^5.3.2",
"path": "^0.12.7",
"qs": "^6.11.2",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/node": "^20.11.19",
"copyfiles": "^2.4.1",
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc && copyfiles -u 3 \"src/db/migrations/*.sql\" dist/db/migrations && copyfiles -u 3 \"src/db/seed/*.sql\" dist/db/seed && copyfiles -u 2 \"src/messages/*.txt\" dist/messages",
"start": "node dist/index.js",
"test": "tsc -p tsconfig.tests.json"
}
}

View File

@ -0,0 +1,115 @@
import { Database, open } from 'sqlite';
import sqlite3 from 'sqlite3';
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* Singleton class responsible for initializing and setting up the SQLite database.
* It ensures that only one instance of the database is created and utilized throughout the application.
*/
export class DatabaseInitializer {
private static instance: DatabaseInitializer;
private db: Database | undefined;
private initializationPromise: Promise<void> | undefined;
/**
* The DatabaseInitializer's constructor is private to prevent direct instantiation with the `new` operator
* and ensure the Singleton pattern is followed.
*/
private constructor() { }
/**
* Gets the singleton instance of the DatabaseInitializer.
* @returns The singleton instance of the DatabaseInitializer.
*/
public static getInstance(): DatabaseInitializer {
if (!DatabaseInitializer.instance) {
DatabaseInitializer.instance = new DatabaseInitializer();
DatabaseInitializer.instance.initializationPromise = DatabaseInitializer.instance.setupDatabase();
}
return DatabaseInitializer.instance;
}
/**
* Retrieves the initialized database instance.
*
* @returns {Promise<Database | undefined>} The initialized database instance, or `undefined` if the
* database has not been initialized or if initialization failed.
*/
public async getDbInstance(): Promise<Database | undefined> {
if (this.initializationPromise) {
await this.initializationPromise; // Wait for the database setup to complete.
this.initializationPromise = undefined; // Clear promise after initial setup to avoid subsequent waits.
}
return this.db;
}
/**
* Initializes the SQLite database. If the database file does not exist, it will be created.
* @param dbPath The path to the SQLite database file.
* @returns A promise that resolves with the Database instance.
* @throws {Error} Throws an error if there's an issue opening the database.
*/
private async initializeDatabase(dbPath: string): Promise<Database> {
try {
const db = await open({
filename: dbPath,
driver: sqlite3.Database
});
console.log('Database initialized successfully.');
return db;
} catch (error) {
console.error('Failed to initialize the database:', error);
throw new Error('Failed to initialize the database.');
}
}
/**
* Executes SQL files found within a specified folder. This function is designed to run migration and seed files.
* @param db The SQLite database instance.
* @param folderName The name of the folder containing the SQL files, relative to the class location.
* @throws {Error} Throws an error if there's an issue reading the directory or executing SQL files.
*/
private async runSqlFiles(db: Database, folderName: string): Promise<void> {
const folderPath = path.join(__dirname, '.', folderName); // Adjust for class location within src/db
console.log('folderPath', folderPath)
let files: string[];
try {
files = await fs.readdir(folderPath);
} catch (error) {
console.log(`Could not find or access the folder at ${folderPath}. Skipping execution of SQL files.`);
return; // Exit the function if the folder doesn't exist or can't be accessed
}
console.log('files', files)
const sqlFiles = files.filter(file => file.endsWith('.sql'));
if (sqlFiles.length === 0) {
console.log(`No SQL files found in ${folderName}. Skipping.`);
return;
}
for (const file of sqlFiles.sort()) {
const sql = await fs.readFile(path.join(folderPath, file), 'utf8');
await db.exec(sql);
console.log(`Executed ${file} in ${folderName}`);
}
}
/**
* The main method for setting up the database. It runs the migrations first, then seeds the database.
* @example
* DatabaseInitializer.getInstance();
*/
private async setupDatabase(): Promise<void> {
const dbPath = path.join(__dirname, '.', 'appData.db');
this.db = await this.initializeDatabase(dbPath);
await this.runSqlFiles(this.db!, 'migrations');
await this.runSqlFiles(this.db!, 'seed');
}
}

View File

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS comments (
db_id INTEGER PRIMARY KEY,
id INTEGER UNIQUE NOT NULL,
author_id INTEGER NOT NULL,
author_name TEXT NOT NULL,
body TEXT NOT NULL,
body_html TEXT NOT NULL,
created_utc INTEGER NOT NULL,
deleted_utc INTEGER DEFAULT 0,
distinguished BOOLEAN DEFAULT FALSE,
downvotes INTEGER DEFAULT 0,
edited_utc INTEGER DEFAULT 0,
is_banned BOOLEAN DEFAULT FALSE,
is_bot BOOLEAN DEFAULT FALSE,
is_nsfw BOOLEAN DEFAULT FALSE,
level INTEGER DEFAULT 0,
permalink TEXT NOT NULL,
pinned TEXT,
post_id INTEGER,
replies TEXT, -- Storing as JSON; consider relational integrity and querying needs.
reports TEXT, -- Storing as JSON; same considerations as 'replies'.
score INTEGER DEFAULT 0,
upvotes INTEGER DEFAULT 0
);

View File

@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS user_mentions (
-- Unique identifier for each record. Auto-incremented to ensure uniqueness.
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- The unique identifier of the comment from the r/Drama platform. Not unique in this table
-- because a single comment can mention multiple users.
rdrama_comment_id TEXT NOT NULL,
-- The mentioned Reddit username in a standardized format (e.g., u/username). Lowercased
-- to ensure consistency and prevent duplicate entries due to case differences.
username TEXT NOT NULL,
-- The content of the message sent to the mentioned user, if any. Allows tracking
-- of what communication has been made, useful for audit purposes or resending messages.
message TEXT,
-- Timestamp when the mention was processed and, if applicable, when a message was sent.
-- Defaults to the current timestamp at the time of record creation.
sent_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Enforces uniqueness for each comment-username pair to prevent processing and notifying
-- the same user mention in the same comment more than once.
CONSTRAINT unique_comment_user UNIQUE (rdrama_comment_id, username)
);
-- Consider adding indexes based on query patterns for improved performance, such as:
-- CREATE INDEX idx_username ON user_mentions(username);
-- CREATE INDEX idx_rdrama_comment_id ON user_mentions(rdrama_comment_id);

View File

@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS maintenance_log (
task_name TEXT PRIMARY KEY,
last_run TIMESTAMP NOT NULL
);

View File

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY,
token_identifier TEXT NOT NULL UNIQUE, -- Static identifier for the OAuth token
access_token TEXT NOT NULL,
token_type TEXT NOT NULL,
expires_in INTEGER NOT NULL,
expiry_timestamp INTEGER NOT NULL,
scope TEXT NOT NULL
);

View File

@ -0,0 +1,271 @@
import { Database } from 'sqlite';
import { Comment } from '../../rdrama/models/Comment';
import { DatabaseInitializer } from '../initializeDatabase';
/**
* Service for interacting with the SQLite database for operations related to comments and user mentions.
*/
export class DatabaseService {
/**
* Retrieves the singleton instance of the database.
* This static method ensures that a single database instance is used throughout the application,
* following the singleton pattern for managing database connections.
*
* @example
* const db = await DatabaseService.getDatabase();
*
* @returns {Promise<Database>} A promise that resolves to the initialized database instance.
* @throws {Error} Will throw an error if the database cannot be initialized.
*/
private static async getDatabase(): Promise<Database> {
const databaseInitializer = DatabaseInitializer.getInstance();
const db = await databaseInitializer.getDbInstance()
if (!db) {
throw new Error('Failed to initialize the database.');
}
return db
}
/**
* Inserts a new comment into the database.
* This static method constructs an SQL statement to insert all fields of the Comment object
* into the corresponding columns in the 'comments' table.
*
* @example
* await DatabaseService.insertComment({
* id: 1,
* author_id: 123,
* author_name: 'exampleUser',
* body: 'This is a comment.',
* // More fields as per the Comment type
* });
*
* @param {Comment} comment - The comment object to insert.
* @throws {Error} Will throw an error if the insert operation fails.
*/
public static async insertComment(comment: Comment): Promise<void> {
const db = await DatabaseService.getDatabase()
const sql = `
INSERT INTO comments (
id, author_id, author_name, body, body_html, created_utc, deleted_utc,
distinguished, downvotes, edited_utc, is_banned, is_bot, is_nsfw, level,
permalink, pinned, post_id, replies, reports, score, upvotes
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?
)
`;
await db.run(sql, [
comment.id, comment.author_id, comment.author_name, comment.body, comment.body_html, comment.created_utc, comment.deleted_utc,
comment.distinguished ? 1 : 0, comment.downvotes, comment.edited_utc, comment.is_banned ? 1 : 0, comment.is_bot ? 1 : 0, comment.is_nsfw ? 1 : 0, comment.level,
comment.permalink, comment.pinned, comment.post_id, JSON.stringify(comment.replies), JSON.stringify(comment.reports), comment.score, comment.upvotes
]);
}
/**
* Inserts a new user mention into the database.
* This static method adds a record of a user being mentioned in a comment.
*
* @example
* await DatabaseService.insertUserMention({
* rdrama_comment_id: 456,
* username: 'mentionedUser',
* message: 'You were mentioned in a comment.'
* });
*
* @param {Object} mention - The user mention object to insert.
* @param {number} mention.rdrama_comment_id - The ID of the comment from the r/Drama platform.
* @param {string} mention.username - The mentioned Reddit username.
* @param {string} [mention.message] - The content of the message sent to the mentioned user.
* @throws {Error} Will throw an error if the insert operation fails.
*/
public static async insertUserMention(mention: { rdrama_comment_id: number; username: string; message?: string }): Promise<void> {
const db = await DatabaseService.getDatabase()
const sql = `INSERT INTO user_mentions (rdrama_comment_id, username, message) VALUES (?, ?, ?)`;
await db.run(sql, [mention.rdrama_comment_id, mention.username, mention.message]);
}
/**
* Queries the database for an existing comment by its ID.
*
* @example
* const exists = await DatabaseService.commentExists('123');
* console.log(exists ? 'Comment exists.' : 'Comment does not exist.');
*
* @param {string} commentId - The ID of the comment to search for.
* @returns {Promise<boolean>} A boolean indicating whether the comment exists.
* @throws {Error} Will throw an error if the query operation fails.
*/
public static async commentExists(commentId: string): Promise<boolean> {
const db = await DatabaseService.getDatabase()
const sql = `SELECT 1 FROM comments WHERE id = ?`;
const result = await db.get(sql, [commentId]);
return !!result;
}
/**
* Queries the database to check if a username has been mentioned.
*
* @example
* const mentioned = await DatabaseService.userMentionExists('exampleUser');
* console.log(mentioned ? 'User has been mentioned.' : 'User has not been mentioned.');
*
* @param {string} username - The username to search for.
* @returns {Promise<boolean>} A boolean indicating whether the username has been mentioned.
* @throws {Error} Will throw an error if the query operation fails.
*/
public static async userMentionExists(username: string): Promise<boolean> {
const db = await DatabaseService.getDatabase()
const sql = `SELECT 1 FROM user_mentions WHERE username = ?`;
const result = await db.get(sql, [username]);
return !!result;
}
/**
* Updates the last run timestamp for a maintenance task, using an "upsert" approach.
*
* @example
* await DatabaseService.updateLastRunTimestamp('purgeOldComments');
*
* @param {string} taskName - The name of the maintenance task.
* @throws {Error} Will throw an error if the update operation fails.
*/
public static async getLastRunTimestamp(taskName: string): Promise<Date | null> {
const db = await DatabaseService.getDatabase()
const result = await db.get(`SELECT last_run FROM maintenance_log WHERE task_name = ?`, [taskName]);
return result ? new Date(result.last_run) : null;
}
/**
* Updates the last run timestamp for a maintenance task, using an "upsert" approach.
*
* @example
* await DatabaseService.updateLastRunTimestamp('purgeOldComments');
*
* @param {string} taskName - The name of the maintenance task.
* @throws {Error} Will throw an error if the update operation fails.
*/
public static async updateLastRunTimestamp(taskName: string): Promise<void> {
// Assumes an "upsert" approach for the maintenance_log table
const db = await DatabaseService.getDatabase()
await db.run(
`INSERT INTO maintenance_log (task_name, last_run)
VALUES (?, ?)
ON CONFLICT(task_name)
DO UPDATE SET last_run = ?`,
[taskName, new Date(), new Date()]
);
}
/**
* Deletes comments from the database older than a specified number of days.
*
* @example
* await DatabaseService.purgeOldComments(30); // Purge comments older than 30 days
*
* @param {number} days - The age of comments to be purged, in days.
* @throws {Error} Will throw an error if the purge operation fails.
*/
public static async purgeOldComments(days: number = 1): Promise<void> {
const db = await DatabaseService.getDatabase()
console.log(`Purging comments older than ${days} days...`);
await db.run(`
DELETE FROM comments
WHERE datetime(created_utc, 'unixepoch') < datetime('now', '-${days} days')
`);
}
/**
* Inserts or updates the OAuth token in the database for a specific service.
*
* @example
* await DatabaseService.upsertOAuthToken('https://oauth.reddit.com', {
* access_token: 'abc123',
* token_type: 'bearer',
* expires_in: 3600,
* scope: 'read'
* });
*
* @param {string} token_identifier - A unique identifier for the token, typically the service's base URL.
* @param {Object} tokenData - The OAuth token data including access_token, token_type, expires_in, and scope.
* @throws {Error} Will throw an error if the upsert operation fails.
*/
public static async upsertOAuthToken(token_identifier: string, tokenData: any) {
const db = await DatabaseService.getDatabase()
const { access_token, token_type, expires_in, scope } = tokenData;
const expiryTimestamp = Math.floor(Date.now() / 1000) + expires_in;
console.log('token_identifier', token_identifier)
console.log('access_token', `${access_token.substring(0, 5)}XXXXX`)
console.log('token_type', token_type)
console.log('expires_in', expires_in)
console.log('scope', scope)
await db.run(`
INSERT INTO oauth_tokens (token_identifier, access_token, token_type, expires_in, expiry_timestamp, scope)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(token_identifier) DO UPDATE SET
access_token = excluded.access_token,
token_type = excluded.token_type,
expires_in = excluded.expires_in,
expiry_timestamp = excluded.expiry_timestamp,
scope = excluded.scope
`, [token_identifier, access_token, token_type, expires_in, expiryTimestamp, scope]);
}
/**
* Retrieves the current, unexpired OAuth token for a specific service.
*
* @example
* const token = await DatabaseService.getCurrentOAuthToken('https://oauth.reddit.com');
* console.log(token ? `Current token: ${token.access_token}` : 'No valid token found.');
*
* @param {string} token_identifier - The unique identifier for the token, typically the service's base URL.
* @returns {Promise<Object|null>} The current OAuth token data or null if expired or not found.
* @throws {Error} Will throw an error if the query operation fails.
*/
public static async getCurrentOAuthToken(token_identifier: string) {
const db = await DatabaseService.getDatabase()
const tokenRow = await db.get(`
SELECT access_token, token_type, scope, expiry_timestamp FROM oauth_tokens
WHERE token_identifier = ?
`, token_identifier);
return tokenRow || null;
}
/**
* Checks if the cooldown period has passed since the last notification was sent, allowing for a new notification to be sent.
*
* @example
* const canSend = await DatabaseService.canSendNotification();
* console.log(canSend ? 'Can send a new notification.' : 'Still in cooldown period.');
*
* @returns {Promise<boolean>} True if the cooldown period has passed, allowing new notifications to be sent.
* @throws {Error} Will throw an error if the check operation fails.
*/
public static async canSendNotification(): Promise<boolean> {
const db = await DatabaseService.getDatabase()
const cooldownHours = process.env.NOTIFICATION_COOLDOWN_HOURS || 4;
const sql = `
SELECT MAX(sent_time) as last_notification_time
FROM user_mentions
`;
const result = await db.get(sql);
if (!result || !result.last_notification_time) {
// No notifications have been sent yet, or unable to retrieve the last sent time.
return true;
}
const lastNotificationTime = new Date(result.last_notification_time).getTime();
const currentTime = new Date(new Date().toISOString().slice(0, 19).replace('T', ' ')).getTime();
const timeElapsed = currentTime - lastNotificationTime;
console.log('timeElapsed', timeElapsed)
const cooldownPeriod = +cooldownHours * 60 * 60 * 1000; // Convert hours to milliseconds
console.log('cooldownPeriod', cooldownPeriod)
return timeElapsed >= cooldownPeriod;
}
}

View File

@ -0,0 +1,69 @@
import { DatabaseService } from './Database';
/**
* Manages and executes database maintenance tasks such as purging old comments.
* This service is responsible for periodically running maintenance tasks based on specified intervals.
*/
export class DatabaseMaintenanceService {
/**
* A list of maintenance tasks to be executed, each with a name, action, and interval.
*/
private maintenanceTasks = [
{
name: 'PurgeOldComments',
action: this.purgeOldComments.bind(this),
interval: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
},
// Add more tasks here as needed
];
/**
* Executes all maintenance tasks that are due based on their defined intervals.
*/
public async runMaintenanceTasks() {
for (const task of this.maintenanceTasks) {
const shouldRun = await this.shouldRunTask(task.name, task.interval);
if (shouldRun) {
await task.action();
await this.updateLastRunTimestamp(task.name);
}
}
}
/**
* Determines whether a specific maintenance task should run based on its last execution time and defined interval.
*
* @param {string} taskName - The name of the task to check.
* @param {number} interval - The interval in milliseconds to determine if the task should run.
* @returns {Promise<boolean>} True if the task should run, otherwise false.
*/
private async shouldRunTask(taskName: string, interval: number): Promise<boolean> {
// Use the DatabaseService to check the last run timestamp from the maintenance_log table
const lastRun = await DatabaseService.getLastRunTimestamp(taskName);
if (!lastRun) return true; // Task has never run
const now = Date.now();
return (now - lastRun.getTime()) > interval;
}
/**
* Purges old comments from the database.
*/
private async purgeOldComments() {
console.log("Purging old comments...");
// Use the DatabaseService for the SQL operation
await DatabaseService.purgeOldComments();
}
/**
* Updates the last run timestamp for a specific maintenance task.
*
* @param {string} taskName - The name of the task for which to update the last run timestamp.
*/
private async updateLastRunTimestamp(taskName: string) {
// Use the DatabaseService to update the last run timestamp in the maintenance_log table
await DatabaseService.updateLastRunTimestamp(taskName);
}
}

50
src/index.ts 100644
View File

@ -0,0 +1,50 @@
import dotenv from 'dotenv';
dotenv.config();
import WorkflowOrchestrator from './workflows/WorkflowOrchestrator';
import rDramaSession from './rdrama/session/SessionManager';
import redditSession from './reddit/session/SessionManager';
import { DatabaseInitializer } from './db/initializeDatabase';
import { DatabaseService } from './db/services/Database';
import { DatabaseMaintenanceService } from './db/services/DatabaseMaintenance';
// Import other necessary services or configurations
async function startApplication() {
console.log('Database Start')
const databaseInitializer = DatabaseInitializer.getInstance();
const db = await databaseInitializer.getDbInstance()
if (!db) {
throw new Error('Failed to initialize the database.');
}
const canSend = await DatabaseService.canSendNotification();
const coolDownHours = process.env.NOTIFICATION_COOLDOWN_HOURS
if (!canSend) {
console.log(`Last Message Sent less than ${coolDownHours ? coolDownHours : 4} hours ago. Set NOTIFICATION_COOLDOWN_HOURS to change this`)
return;
}
console.log('RDrama Session Start')
// Initialize SessionManager or other global configurations
const rDramaSessionManager = rDramaSession.getInstance();
if (!process.env.RDRAMA_API_KEY) {
throw new Error('RDRAMA_API_KEY is undefined. Please set this environment variable.');
}
rDramaSessionManager.setAuthorizationToken(process.env.RDRAMA_API_KEY);
console.log('Database Maintenance Start')
const databaseMaintenance = new DatabaseMaintenanceService()
await databaseMaintenance.runMaintenanceTasks()
console.log('Reddit Session Start')
await redditSession.getInstance()
// Initialize and start your workflow
const workflowOrchestrator = new WorkflowOrchestrator();
await workflowOrchestrator.executeWorkflow();
await rDramaSessionManager.shutdown()
}
startApplication()
.then(() => console.log('Application started successfully.'))
.catch((error) => console.error('Application failed to start:', error));

View File

@ -0,0 +1,282 @@
Hi {author_name},
We've noticed your recent mention of a Reddit user in your post/comment. It's essential to remember the value of privacy and respect in our community. We've informed the individual mentioned, but we kindly ask you to consider the implications of such actions in the future.
Thank you for contributing to a respectful and positive environment.
Warm regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
Your recent activity involving the mention of a Reddit user has been noted. We advocate for privacy and respectful discourse. The mentioned user has been notified. Please reflect on the importance of privacy and respect in all your future interactions.
Best wishes,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
We're reaching out regarding your mention of a Reddit user. As a community, we emphasize respect and privacy. We've alerted the mentioned individual and encourage you to consider the potential impact of such mentions in the future.
Thank you for understanding,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
A recent comment of yours mentioning a Reddit user has prompted this message. We believe in maintaining respect and privacy across platforms. The mentioned user has been informed. Moving forward, we urge you to keep these values in mind.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey {author_name},
We've picked up on your mention of a Reddit user. Our platform stands for respectful and privacy-conscious interactions. The Reddit user has been notified of the mention. Please consider the implications of such mentions and strive for positive dialogue.
Thanks for your cooperation,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
Your mention of a Reddit user caught our attention. We aim to foster a community where respect and privacy are paramount. The mentioned user has been made aware. We kindly ask you to reflect on this and encourage respectful engagement moving forward.
Regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We're addressing a mention you made of a Reddit user. It's a good moment to remember the significance of privacy and respectful conduct. The individual has been notified, and we encourage you to consider these principles in your future interactions.
Best,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
A recent action of yours mentioning a Reddit user has been brought to our attention. We prioritize respect and the privacy of individuals. The Reddit user has been informed. Please keep these values in mind as you continue to participate in our community.
Many thanks,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey {author_name},
We need to talk about your recent mention of a Reddit user. Our community respects privacy and encourages thoughtful interactions. We've let the mentioned user know. Let's all strive for a kinder, more respectful community.
Appreciatively,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
We've observed your mention of a Reddit user in your content. Our platform cherishes respectful and private interactions. The mentioned individual has been alerted. We ask you to consider the broader impact of your words and promote positivity.
Thank you for being part of our community,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
We noticed your mention of a Reddit username in your discussion. At CrossTalk PM, we prioritize privacy and respectful communication. The Reddit user has been notified about this mention. Please be mindful of others' privacy in your future interactions.
Best regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
Your recent activity mentioned a Reddit user, which has been brought to our attention. CrossTalk PM encourages a culture of respect and privacy. We've informed the mentioned individual. Reflecting on the impact of such mentions is crucial for a positive community atmosphere.
Thank you,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We're reaching out due to your mention of a Reddit user. CrossTalk PM is committed to fostering respectful interactions and protecting privacy. The user has been notified. Let's continue to create a welcoming environment for everyone.
Warmly,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
A mention you made of a Reddit user has prompted this automated message. At CrossTalk PM, we value privacy and respectful discourse. The mentioned user has been alerted. Your cooperation in maintaining these values is appreciated.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey {author_name},
Your mention of a Reddit username has been noted. CrossTalk PM supports a community built on respect and privacy. We've informed the Reddit user of this mention. Going forward, please keep the privacy of others in mind.
Thanks,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
We've detected your mention of a Reddit user. CrossTalk PM stands for respectful and considerate communication. The individual has been notified. We kindly ask you to be thoughtful about privacy and respect in all your posts and comments.
Best,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
Your recent mention of a Reddit user has caught our attention. As part of CrossTalk PM's commitment to privacy and respect, the user has been informed. We encourage you to reflect on the importance of these values in our community.
Regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We're addressing your mention of a Reddit user. CrossTalk PM values the privacy and dignity of all individuals. The mentioned user has been made aware. Please consider the implications of your actions and contribute to a respectful community dialogue.
Thank you for your understanding,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
Your action of mentioning a Reddit user has prompted us to send this reminder. CrossTalk PM believes in the power of respectful and private interactions. The mentioned individual has been notified. Let's all contribute to a respectful and caring community environment.
Many thanks,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey {author_name},
We noticed you mentioned a Reddit user in your comment. Here at CrossTalk PM, we champion respect and the right to privacy. The Reddit user has been informed of your mention. As we move forward, let's remember to treat everyone's privacy with the utmost respect.
Appreciatively,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
Your recent mention of a Reddit user raised our attention. At CrossTalk PM, we emphasize the importance of respecting individual privacy and fostering a positive online environment. The mentioned Reddit user has been informed. We kindly ask you to consider the impact of your words and to promote a culture of respect and understanding in all your interactions.
Best wishes,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We've observed your mention of a Reddit username in one of your comments. CrossTalk PM is dedicated to maintaining a respectful and safe space for discussions. As part of our efforts to ensure privacy and respect for all users, the Reddit user mentioned has been notified. Please take a moment to reflect on the implications of mentioning others without their consent and strive to maintain a respectful discourse in our community.
Thank you,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
It has come to our attention that you've mentioned a Reddit user in your post. CrossTalk PM values the privacy and well-being of individuals both within and outside our community. The individual has been alerted to your mention. Moving forward, we encourage you to be mindful of others' privacy and to contribute positively to the online community.
Regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
Your action of mentioning a Reddit user has led to this automated notification. CrossTalk PM advocates for a community where privacy is respected and upheld. The Reddit user mentioned has been made aware. We urge you to consider the personal boundaries of others and to foster a respectful environment in all your online engagements.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey there {author_name},
We noticed that you mentioned a Reddit user's username in a recent post. CrossTalk PM is committed to creating a respectful and privacy-conscious community. The mentioned user has been informed about this mention. We hope you understand the importance of respecting privacy and encourage you to continue engaging in positive and respectful interactions.
Cheers,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello again {author_name},
A mention of a Reddit user by you has prompted us to send you this message. At CrossTalk PM, we stand for respectful interactions and the privacy of all internet users. The Reddit user involved has been notified of your mention. We kindly remind you to be considerate of others' privacy in your future posts and comments.
Warm regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
We've detected your mention of a Reddit username in your recent activity. CrossTalk PM encourages a community ethos of respect and privacy. The individual mentioned has been informed. Let's all make an effort to ensure our community remains a respectful and welcoming space for everyone.
Thanks,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
Your mention of a Reddit user has been brought to our notice. CrossTalk PM is built on principles of respect, privacy, and positive communication. The mentioned Reddit user has been alerted. We request that you keep these principles in mind, ensuring our platform remains a respectful space for all.
Regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We're reaching out because of your mention of a Reddit user. At CrossTalk PM, we prioritize the privacy and respect of all users. The Reddit user has been notified of the mention. We encourage you to reflect on the impact of such mentions and to contribute positively to the community atmosphere.
Thank you,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
You recently mentioned a Reddit user in a discussion. CrossTalk PM is dedicated to fostering a respectful online environment, emphasizing privacy and positive interactions. The Reddit user has been informed. Please remember to respect the privacy and feelings of others in your future interactions.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We noticed your recent mention of a Reddit username. As a community, CrossTalk PM values privacy and constructive dialogue. The mentioned individual has been alerted. We ask you to please consider the privacy implications of such mentions in the future and help us maintain a respectful environment.
Kind regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi {author_name},
Your recent activity involving the mention of a Reddit user has prompted this message. CrossTalk PM is committed to upholding privacy and fostering respectful interactions. The person mentioned has been notified. We encourage you to be mindful of others' privacy and to contribute to our community's positive culture.
Best,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings {author_name},
It has come to our attention that you mentioned a Reddit user in your comment. At CrossTalk PM, we strive for a culture of respect and privacy awareness. The Reddit user has been informed of this mention. We hope you understand the significance of privacy and respect in our community.
Regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
A recent mention of a Reddit username by you has led us to send this reminder. CrossTalk PM champions a respectful and privacy-conscious online space. The mentioned Reddit user has been made aware. We kindly ask you to consider the impact of your online actions on others' privacy and well-being.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hey {author_name},
We saw that you mentioned a Reddit user's name in one of your posts. Here at CrossTalk PM, we're all about respecting each other's privacy and fostering positive interactions. The mentioned user has been notified. Please remember to respect everyone's privacy and contribute positively to our community.
Take care,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
Your mention of a Reddit user has triggered this notification. We at CrossTalk PM hold the values of privacy and respect in high regard. The individual you mentioned has been alerted. We encourage you to reflect on the importance of privacy and to ensure your future communications are respectful and considerate.
Warmest regards,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hi again {author_name},
We're writing to address your mention of a Reddit username in a recent interaction. CrossTalk PM is dedicated to promoting an environment of respect and privacy protection. The Reddit user mentioned has been informed. We remind you to be thoughtful of privacy and to foster a positive community space.
Thank you,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Greetings once more {author_name},
Your recent mention of a Reddit username has prompted us to reach out. CrossTalk PM prioritizes the privacy and respect of individuals across the web. The mentioned Reddit user has been notified. Please join us in our efforts to create a respectful and welcoming environment for all.
Best wishes,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Hello {author_name},
We've noticed your mention of a Reddit username in a discussion. CrossTalk PM is built on respect for privacy and positive community interactions. The Reddit user has been informed of the mention. We invite you to reflect on the importance of privacy and to help us maintain a respectful community atmosphere.
Best,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---
Dear {author_name},
You've recently mentioned a Reddit user, which has led to this message. CrossTalk PM stands for respect, privacy, and positive online interactions. The individual mentioned has been alerted. We ask you to be considerate of others' privacy and to contribute to making our community a positive space for all.
Sincerely,
CrossTalk PM - Automated Message (Unmonitored Account)
---END---

View File

@ -0,0 +1,110 @@
Hi {username},
We noticed your Reddit username was mentioned on rDrama. CrossTalk PM values your privacy and wants to keep you informed while ensuring your well-being.
To check out the mention, manually enter: rdrama[dot]net{permalink}. Remember, your privacy is key, so think it over before visiting.
Concerns? Reach the rDrama team at rdrama[dot]net/contact for support.
Take care,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Dear {username},
A mention of your username has popped up on rDrama. At CrossTalk PM, we're all about respecting your space and privacy.
Curious? Modify this URL to visit: rdrama[dot]net{permalink}. Your discretion is advised.
For any discomfort or queries, kindly connect with rDrama's admins at rdrama[dot]net/contact.
Best,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Hello {username},
Your Reddit username surfaced in a conversation on rDrama. As advocates for privacy, CrossTalk PM is here to inform you, gently.
Should you wish to see the mention, adjust this link: rdrama[dot]net{permalink}. Please prioritize your privacy and safety.
For assistance, the rDrama admins are available at rdrama[dot]net/contact.
Yours,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Hey {username},
Just a heads-up: your username was mentioned on rDrama. CrossTalk PM respects your right to privacy and wants to keep you in the loop.
To view it, tweak this URL: rdrama[dot]net{permalink}. Think about your privacy first, though.
Got concerns? The rDrama admins can help: rdrama[dot]net/contact.
Cheers,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Greetings {username},
We've detected a mention of your username on rDrama. With CrossTalk PM, your privacy and peace of mind come first.
Interested in the details? Edit this path: rdrama[dot]net{permalink}, but remember, your safety is paramount.
For issues or worries, contact rDrama's support at rdrama[dot]net/contact.
Kind regards,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Hi there {username},
Your Reddit username got a shoutout on rDrama. CrossTalk PM is here to ensure you're informed with a focus on your privacy.
To dive in, adjust: rdrama[dot]net{permalink}. Yet, weigh your privacy options carefully.
If something bothers you, rDrama's admins are at rdrama[dot]net/contact.
All the best,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Dear {username},
We found your username in an rDrama discussion. As your privacy champions, CrossTalk PM brings this to your notice with care.
To explore, change: rdrama[dot]net{permalink} to a proper URL. Privacy comes first, so please be cautious.
Feel uneasy? Reach out to rDrama at rdrama[dot]net/contact.
Warmly,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Hello {username},
A mention of your Reddit handle has been spotted on rDrama. CrossTalk PM believes in keeping you informed while safeguarding your privacy.
If curious, here's how you can check it out: rdrama[dot]net{permalink}. As always, consider your privacy and well-being before proceeding.
Should you need to discuss this, the rDrama admin team is available at rdrama[dot]net/contact.
Sincerely,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Hey there {username},
We caught a mention of your username over on rDrama. CrossTalk PM is all about keeping you updated and respecting your online space.
For a peek, here's a tweakable link: rdrama[dot]net{permalink}. Please proceed with caution and your privacy in mind.
Questions or concerns? The friendly admins at rDrama are reachable at rdrama[dot]net/contact.
Best wishes,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---
Greetings {username},
Your Reddit username was mentioned in an rDrama thread. At CrossTalk PM, we're committed to your privacy and timely notifications.
To see the mention, here's a URL you can adjust: rdrama[dot]net{permalink}. Your privacy and security should always come first.
If you're concerned or have questions, don't hesitate to contact rDrama's admins at rdrama[dot]net/contact.
Respectfully,
The CrossTalk PM Team - Automated Message (Unmonitored Account)
---END---

View File

@ -0,0 +1,50 @@
/**
* Represents a comment from the r/Drama platform, containing details about the comment's author,
* content, status, and other metadata.
* @typedef {Object} Comment
* @property {number} author_id - The unique identifier for the author of the comment.
* @property {string} author_name - The display name of the author.
* @property {string} body - The raw text content of the comment.
* @property {string} body_html - The HTML-rendered version of the comment text.
* @property {number} created_utc - The UTC timestamp when the comment was created.
* @property {number} deleted_utc - The UTC timestamp when the comment was deleted, if applicable.
* @property {boolean} distinguished - Flag indicating if the comment is distinguished by the platform (e.g., admin posts).
* @property {number} downvotes - The number of downvotes the comment has received.
* @property {number} edited_utc - The UTC timestamp when the comment was last edited, if ever.
* @property {number} id - The unique identifier for the comment.
* @property {boolean} is_banned - Flag indicating if the author was banned at the time of the comment.
* @property {boolean} is_bot - Flag indicating if the comment was made by a bot.
* @property {boolean} is_nsfw - Flag indicating if the comment is marked as NSFW (Not Safe For Work).
* @property {number} level - The nesting level of the comment in the conversation thread.
* @property {string} permalink - The permanent link to the comment.
* @property {string} pinned - The name of the user who pinned the comment, if any.
* @property {number} post_id - The unique identifier of the post to which the comment belongs.
* @property {number[]} replies - An array of comment IDs representing the direct replies to this comment.
* @property {Record<string, unknown>} reports - An object containing any reports made on the comment.
* @property {number} score - The total score of the comment (upvotes - downvotes).
* @property {number} upvotes - The number of upvotes the comment has received.
*/
export type Comment = {
author_id: number;
author_name: string;
body: string;
body_html: string;
created_utc: number;
deleted_utc: number;
distinguished: boolean;
downvotes: number;
edited_utc: number;
id: number;
is_banned: boolean;
is_bot: boolean;
is_nsfw: boolean;
level: number;
permalink: string;
pinned: string;
post_id: number;
replies: number[];
reports: Record<string, unknown>;
score: number;
upvotes: number;
};

View File

@ -0,0 +1,30 @@
import SessionManager from '../session/SessionManager';
import { Comment } from '../models/Comment';
/**
* CommentFetcher is responsible for fetching comments from the r/Drama API.
* It utilizes a SessionManager instance to manage API requests with appropriate
* headers and configurations, including rate limiting and retries.
*/
export class CommentFetcher {
/**
* Fetches comments from the r/Drama API across multiple pages.
* Each page's fetch operation is timed for performance analysis.
*
* @returns {Promise<Comment[]>} A promise that resolves to an array of comments fetched from the API.
* @throws {Error} Throws an error if there is a failure in fetching comments from the API.
*/
static async fetchComments(page: number): Promise<Comment[]> {
console.time(`Fetching page: ${page}`);
try {
const axiosInstance = SessionManager.getInstance().axiosInstance;
const response = await axiosInstance.get(`/comments?page=${page}`);
console.timeEnd(`Fetching page: ${page}`);
return response.data.data;
} catch (error) {
console.error(`Failed to fetch comments for page ${page}:`, error);
console.timeEnd(`Fetching page: ${page}`);
throw error; //Rethrow
}
}
}

View File

@ -0,0 +1,28 @@
import { Comment } from '../models/Comment';
export class CommentParser {
/**
* Extracts Reddit usernames from the body of a single comment.
* @param comment A single Comment object to be processed.
* @returns An array of unique Reddit usernames found in the comment.
*/
public static extractUsernames(comment: Comment): string[] {
const regexPattern: RegExp = /(^|\s|\\r\\n|\\t|[".,;(){}\[\]!?@#])(\/?u\/[a-zA-Z0-9_]+)/g;
const foundUsernames: Set<string> = new Set();
const matches = comment.body.match(regexPattern);
if (matches) {
matches.forEach(match => {
// Ensure the username is captured in a standardized format
const usernameMatch = match.trim().match(/\/?u\/([a-zA-Z0-9_]+)/);
if (usernameMatch) {
// Standardize to "username" format
const username = `${usernameMatch[1].toLowerCase()}`;
foundUsernames.add(username);
}
});
}
return Array.from(foundUsernames);
}
}

View File

@ -0,0 +1,33 @@
import SessionManager from '../session/SessionManager';
import { Comment } from '../models/Comment';
import FormData from 'form-data';
export class CommentPoster {
/**
* Posts a comment as a reply to a given rdrama comment.
*
* @param parentId The ID of the parent comment to reply to. Expected format: 'c_{id}'.
* @param body The body of the comment to post.
* @returns A promise resolving to the Axios response.
*/
public static async postComment(parentId: string, body: string): Promise<Comment> {
const sessionManager = SessionManager.getInstance();
const formData = new FormData();
formData.append('parent_fullname', parentId);
formData.append('body', body);
try {
const response = await sessionManager.axiosInstance.post('/comment', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
console.log(`Comment posted successfully to ${parentId}`);
return response.data;
} catch (error) {
console.error(`Failed to post comment to ${parentId}:`, error);
throw error; // Rethrow for handling elsewhere
}
}
}

View File

@ -0,0 +1,46 @@
import { Comment } from '../models/Comment';
import { DatabaseService } from '../../db/services/Database';
import { CommentFetcher } from './CommentFetcher';
/**
* CommentProcessor handles the retrieval and processing of comments from the r/Drama API.
* It manages API requests through CommentFetcher, including rate limiting and retries, and coordinates
* with DatabaseService for checking the existence and persisting new comments.
*/
export class CommentProcessor {
/**
* Fetches comments from the r/Drama API across multiple pages, up to the specified maximum.
* Iterates through pages starting from the first page until the maximum page limit is reached
* or there are no more comments to fetch. Each page's fetch operation is timed for performance analysis.
*
* @returns {Promise<Comment[]>} A promise that resolves to an array of comments fetched from the API.
* @throws {Error} Throws an error if there is a failure in fetching comments from the API.
*/
static async processComments(maxPages: number = 10): Promise<Comment[]> {
let comments: Comment[] = [];
let stopFetching = false;
for (let page = 1; page <= maxPages && !stopFetching; page++) {
const newComments = await CommentFetcher.fetchComments(page)
// Check each new comment against the database and existing comments in this batch
for (const comment of newComments) {
// Check if the comment was already processed in this batch
if (comments.some(c => c.id === comment.id)) continue;
const exists = await DatabaseService.commentExists(comment.id.toString());
if (exists) {
stopFetching = true;
break; // Stop processing this batch of comments
}
await DatabaseService.insertComment(comment)
comments.push(comment);
}
if (newComments.length === 0) break; // No more comments to fetch
}
return comments;
}
}

View File

@ -0,0 +1,108 @@
import axios, { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig } from 'axios';
import axiosRetry from 'axios-retry';
import Bottleneck from 'bottleneck';
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
class SessionManager {
private static instance: SessionManager;
public readonly axiosInstance: AxiosInstance;
private limiter: Bottleneck;
private constructor() {
// Initialize the Bottleneck limiter
this.limiter = new Bottleneck({
id: "rDramaAPI-limiter",
datastore: "ioredis",
clearDatastore: false,
clientOptions: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD || undefined, // Use undefined if no password is set
enableOfflineQueue: true
},
maxConcurrent: 1, // Maximum number of concurrent requests
minTime: 1000 // Minimum time between dispatches of requests in milliseconds
});
this.axiosInstance = axios.create({
baseURL: 'https://rdrama.net/',
headers: {
'Content-Type': 'application/json',
},
});
// Wrap axios requests with the limiter
this.wrapAxiosInstance(this.axiosInstance);
axiosRetry(this.axiosInstance, {
retries: 3,
retryDelay: this.retryDelayStrategy,
retryCondition: this.retryCondition,
});
}
public static getInstance(): SessionManager {
if (!SessionManager.instance) {
SessionManager.instance = new SessionManager();
}
return SessionManager.instance;
}
public async shutdown(): Promise<void> {
await this.limiter.disconnect();
}
private retryDelayStrategy(retryCount: number, error: AxiosError): number {
const retryAfter = error.response?.headers['retry-after'];
if (retryAfter) {
console.log(`429 Retry After: ${retryAfter}`);
return +retryAfter * 1000;
}
return Math.pow(2, retryCount) * 2000;
}
private retryCondition(error: AxiosError): boolean {
const status = error.response?.status ?? 0;
return status === 429 || status >= 500;
}
private wrapAxiosInstance(instance: AxiosInstance): void {
// Wrap the get method
const originalGet = instance.get;
instance.get = <T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> => {
return this.limiter.schedule(() => originalGet.apply(instance, [url, config])) as Promise<R>;
};
// Wrap the post method
const originalPost = instance.post;
instance.post = <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: AxiosRequestConfig): Promise<R> => {
return this.limiter.schedule(() => originalPost.apply(instance, [url, data, config])) as Promise<R>;
};
// Wrap the put method
const originalPut = instance.put;
instance.put = <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: AxiosRequestConfig): Promise<R> => {
return this.limiter.schedule(() => originalPut.apply(instance, [url, data, config])) as Promise<R>;
};
// Wrap the delete method
const originalDelete = instance.delete;
instance.delete = <T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> => {
return this.limiter.schedule(() => originalDelete.apply(instance, [url, config])) as Promise<R>;
};
}
public setAuthorizationToken(token: string): void {
this.axiosInstance.defaults.headers.common['Authorization'] = `${token}`;
}
}
export default SessionManager;

View File

@ -0,0 +1,44 @@
export type Subreddit = {
default_set?: boolean;
user_is_contributor?: boolean;
banner_img?: string;
allowed_media_in_comments?: any[];
user_is_banned?: boolean;
free_form_reports?: boolean;
community_icon?: string | null;
show_media?: boolean;
icon_color?: string;
user_is_muted?: boolean | null;
display_name?: string;
header_img?: string | null;
title?: string;
coins?: number;
previous_names?: any[];
over_18?: boolean;
icon_size?: number[] | null;
primary_color?: string;
icon_img?: string;
description?: string;
submit_link_label?: string;
header_size?: number[] | null;
restrict_posting?: boolean;
restrict_commenting?: boolean;
subscribers?: number;
submit_text_label?: string;
is_default_icon?: boolean;
link_flair_position?: string;
display_name_prefixed?: string;
key_color?: string;
name?: string;
is_default_banner?: boolean;
url?: string;
quarantine?: boolean;
banner_size?: number[] | null;
user_is_moderator?: boolean;
accept_followers?: boolean;
public_description?: string;
link_flair_enabled?: boolean;
disable_contributor_requests?: boolean;
subreddit_type?: string;
user_is_subscriber?: boolean;
};

View File

@ -0,0 +1,71 @@
import { Subreddit } from "./Subreddit";
export type RedditUser = {
kind: string;
data: {
is_employee?: boolean;
has_visited_new_profile?: boolean;
is_friend: boolean;
pref_no_profanity?: boolean;
has_external_account?: boolean;
pref_geopopular?: string;
pref_show_trending?: boolean;
subreddit: Subreddit
pref_show_presence?: boolean;
snoovatar_img?: string;
snoovatar_size?: number[] | null;
gold_expiration?: null;
has_gold_subscription?: boolean;
is_sponsor?: boolean;
num_friends?: number;
features?: any;
can_edit_name?: boolean;
is_blocked?: boolean;
verified?: boolean;
new_modmail_exists?: null;
pref_autoplay?: boolean;
coins?: number;
has_paypal_subscription?: boolean;
has_subscribed_to_premium?: boolean;
id: string;
can_create_subreddit?: boolean;
over_18?: boolean;
is_gold?: boolean;
is_mod?: boolean;
awarder_karma?: number;
suspension_expiration_utc?: null;
has_stripe_subscription?: boolean;
is_suspended?: boolean;
pref_video_autoplay?: boolean;
in_chat?: boolean;
has_android_subscription?: boolean;
in_redesign_beta?: boolean;
icon_img: string;
has_mod_mail?: boolean;
pref_nightmode?: boolean;
awardee_karma?: number;
hide_from_robots?: boolean;
password_set?: boolean;
modhash?: null;
link_karma: number;
force_password_reset?: boolean;
total_karma: number;
inbox_count?: number;
pref_top_karma_subreddits?: boolean;
has_mail?: boolean;
pref_show_snoovatar?: boolean;
name: string;
pref_clickgadget?: number;
created: number;
has_verified_email: boolean;
gold_creddits?: number;
created_utc: number;
has_ios_subscription?: boolean;
pref_show_twitter?: boolean;
in_beta?: boolean;
comment_karma: number;
accept_followers: boolean;
has_subscribed: boolean;
accept_pms?: boolean;
}
};

View File

@ -0,0 +1,68 @@
import { RedditUser } from '../model/User';
import RedditSessionManager from '../session/SessionManager';
/**
* Provides services for interacting with Reddit user data and sending messages.
*/
export class RedditService {
/**
* Retrieves information about a Reddit user.
*
* @param {string} username - The username of the Reddit user to retrieve information for.
* @returns {Promise<RedditUser>} A promise that resolves with the RedditUser object containing user information.
* @throws {Error} Throws an error if fetching user information fails.
* @example
* RedditService.getUserInfo('exampleUser')
* .then(userInfo => console.log(userInfo))
* .catch(error => console.error(error));
*/
static async getUserInfo(username: string): Promise<RedditUser> {
try {
const redditSession = await RedditSessionManager.getInstance()
const response = await redditSession.axiosInstance.get(`/user/${username}/about`);
return response.data;
} catch (error) {
console.error('Error fetching user info:', error);
throw error;
}
}
/**
* Sends a private message to a Reddit user.
*
* @param {string} username - The username of the recipient Reddit user.
* @param {string} subject - The subject of the message.
* @param {string} message - The body text of the message.
* @returns {Promise<void>} A promise that resolves when the message is successfully sent.
* @throws {Error} Throws an error if sending the message fails.
* @example
* RedditService.sendMessage('exampleUser', 'Hello', 'This is a test message.')
* .then(() => console.log('Message sent successfully.'))
* .catch(error => console.error('Error sending message:', error));
*/
static async sendMessage(username: string, subject: string, message: string): Promise<void> {
try {
const redditSession = await RedditSessionManager.getInstance();
// Create a URLSearchParams object with your data
const params = new URLSearchParams();
params.append('api_type', 'json');
params.append('to', `u/${username}`);
params.append('subject', subject);
params.append('text', message);
// Use the params object directly in the data field
await redditSession.axiosInstance.post('/api/compose', params, {
headers: {
// Ensure the content type is set to application/x-www-form-urlencoded
'Content-Type': 'application/x-www-form-urlencoded'
}
});
console.log(JSON.stringify(params, null, 4))
console.log(`Message sent to ${username}`);
} catch (error) {
console.error('Error sending message:', error);
throw error;
}
}
}

View File

@ -0,0 +1,108 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
const qs = require('qs');
import dotenv from 'dotenv';
import axiosRetry from 'axios-retry';
import axiosThrottle from 'axios-request-throttle';
import { DatabaseService } from '../../db/services/Database';
dotenv.config();
class RedditSessionManager {
private static instance: RedditSessionManager;
public axiosInstance: AxiosInstance;
private constructor() {
axiosThrottle.use(axios, { requestsPerSecond: 1 }); // Throttle setup
this.axiosInstance = axios.create({
baseURL: 'https://oauth.reddit.com/', // Base URL for OAuth2 Reddit API
headers: {
'User-Agent': 'CrossTalk PM/0.1 by Whitneywisconson'
}
});
axiosRetry(this.axiosInstance, {
retries: 3,
retryDelay: this.retryDelayStrategy,
retryCondition: this.retryCondition,
});
}
public static async getInstance(): Promise<RedditSessionManager> {
if (!RedditSessionManager.instance) {
RedditSessionManager.instance = new RedditSessionManager();
await RedditSessionManager.instance.initializeAuthentication();
}
return RedditSessionManager.instance;
}
private async initializeAuthentication() {
// Check the database for an existing token
const currentToken = await DatabaseService.getCurrentOAuthToken(this.axiosInstance.defaults.baseURL as string);
if (currentToken && new Date() < new Date(currentToken.expiry_timestamp * 1000)) {
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${currentToken.access_token}`;
console.log('Using existing Reddit API token from database.');
return;
}
console.log('No current Reddit API token from database requesting one')
// Authenticate with Reddit API to get a new token
await this.authenticate();
}
private async authenticate() {
if (!process.env.redditUsername) throw 'No Reddit Username Found in .env'
if (!process.env.redditPassword) throw 'No Reddit Password Found in .env'
const redditUsername = process.env.redditUsername as string
const redditPassword = process.env.redditPassword as string
const credentials = qs.stringify({
grant_type: 'password',
username: redditUsername,
password: redditPassword,
});
const authString = `${process.env.redditClientId}:${process.env.redditSecret}`;
const buffer = Buffer.from(authString);
const base64AuthString = buffer.toString('base64');
try {
const response = await this.axiosInstance.post('https://www.reddit.com/api/v1/access_token', credentials, {
headers: {
'User-Agent': `CrossTalk PM/0.1 by ${redditUsername}`, //TODO Dynamically set app name here
'Authorization': `Basic ${base64AuthString}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
// Upsert the new token into the database
await DatabaseService.upsertOAuthToken(
this.axiosInstance.defaults.baseURL as string,
{
access_token: response.data.access_token,
token_type: response.data.token_type,
expires_in: response.data.expires_in,
scope: response.data.scope,
});
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${response.data.access_token}`;
console.log('Reddit API authenticated successfully.');
} catch (error) {
console.error('Error authenticating with Reddit API:', error);
}
}
private retryDelayStrategy(retryCount: number, error: AxiosError): number {
const retryAfter = error.response?.headers['retry-after'];
if (retryAfter) {
console.log(`429 Retry After: ${retryAfter}`);
return +retryAfter * 1000;
}
return Math.pow(2, retryCount) * 2000;
}
private retryCondition(error: AxiosError): boolean {
const status = error.response?.status ?? 0;
return status === 429 || status >= 400;
}
}
export default RedditSessionManager;

View File

@ -0,0 +1,96 @@
import fs from 'fs';
import path from 'path';
/**
* Manages the retrieval and formatting of messages stored in text files.
* This class provides functionality to load messages for rDrama and Reddit,
* select a random message, and replace placeholders within that message
* with specified values.
*/
export class MessageService {
/**
* Loads rDrama messages from a text file, splitting by a specific delimiter.
* Each message is separated by '---END---' in the text file.
*
* @example
* const rdramaMessages = MessageService.loadRdramaMessages();
*
* @returns {string[] | undefined} An array of rDrama messages, or undefined if there was an error loading the messages.
*/
private static loadRdramaMessages(): string[] | undefined {
try {
const rdramaMessagesPath = path.join(__dirname, '..', 'messages', 'rdrama_messages.txt');
return fs.readFileSync(rdramaMessagesPath, 'utf-8').split('---END---').filter(line => line.trim());
} catch (error) {
console.error('Failed to load rDrama messages:', error);
}
}
/**
* Loads Reddit messages from a text file, splitting by a specific delimiter.
* Each message is separated by '---END---' in the text file.
*
* @example
* const redditMessages = MessageService.loadRedditMessages();
*
* @returns {string[] | undefined} An array of Reddit messages, or undefined if there was an error loading the messages.
*/
private static loadRedditMessages(): string[] | undefined {
try {
const redditMessagesPath = path.join(__dirname, '..', 'messages', 'reddit_messages.txt');
return fs.readFileSync(redditMessagesPath, 'utf-8').split('---END---').filter(line => line.trim());
} catch (error) {
console.error('Failed to load Reddit messages:', error);
}
}
/**
* Selects a random Reddit message from the loaded messages and replaces placeholders within it.
*
* @example
* const message = MessageService.getRandomRedditMessage({ username: 'exampleUser' });
*
* @param {Object} placeholders - A mapping of placeholder names to their replacement values.
* @returns {string | undefined} A formatted Reddit message with placeholders replaced, or undefined if messages couldn't be loaded.
*/
public static getRandomRedditMessage(placeholders: { [key: string]: string }): string | undefined {
const redditMessages = this.loadRedditMessages()
if (!redditMessages) return
const message = redditMessages[Math.floor(Math.random() * redditMessages.length)];
return this.replacePlaceholders(message, placeholders);
}
/**
* Selects a random rDrama message from the loaded messages and replaces placeholders within it.
*
* @example
* const message = MessageService.getRandomRdramaMessage({ username: 'exampleUser' });
*
* @param {Object} placeholders - A mapping of placeholder names to their replacement values.
* @returns {string | undefined} A formatted rDrama message with placeholders replaced, or undefined if messages couldn't be loaded.
*/
public static getRandomRdramaMessage(placeholders: { [key: string]: string }): string | undefined {
const rdramaMessages = this.loadRdramaMessages()
if (!rdramaMessages) return
const message = rdramaMessages[Math.floor(Math.random() * rdramaMessages.length)];
return this.replacePlaceholders(message, placeholders);
}
/**
* Replaces placeholders in a message with values from a provided mapping.
*
* @example
* const formattedMessage = MessageService.replacePlaceholders('Hello, {username}!', { username: 'exampleUser' });
*
* @param {string} message - The message containing placeholders.
* @param {Object} placeholders - A mapping of placeholder names to their replacement values.
* @returns {string} The message with placeholders replaced by actual values.
*/
private static replacePlaceholders(message: string, placeholders: { [key: string]: string }): string {
return Object.keys(placeholders).reduce((acc, key) => {
const regex = new RegExp(`{${key}}`, 'g');
return acc.replace(regex, placeholders[key]);
}, message);
}
}

View File

@ -0,0 +1,66 @@
import dotenv from 'dotenv';
import { RedditService } from '../reddit/services/Reddit';
import { DatabaseService } from '../db/services/Database';
// Load environment variables from .env file
dotenv.config();
/**
* Determines whether a user should be notified based on various criteria.
*
* This function checks a Reddit user's information against a set of conditions
* defined by environment variables and user attributes. These conditions include
* whether the user is a moderator, an employee, accepts private messages, has
* karma below a certain threshold, and has not been notified before.
*
* @param {string} username - The Reddit username of the user to check.
* @returns {Promise<boolean>} - A promise that resolves to `true` if the user meets
* the criteria for notification, otherwise `false`.
*
* @throws {Error} Throws an error if there's a problem fetching user information
* from Reddit or checking the user's notification status in the database.
*
* @example
* // Example of checking if a user should be notified
* shouldNotifyUser('exampleUser').then(shouldNotify => {
* if (shouldNotify) {
* console.log('User should be notified.');
* } else {
* console.log('User should not be notified.');
* }
* }).catch(error => {
* console.error('Error checking if user should be notified:', error);
* });
*
* @example
* // Environment variables used in this function
* // .env file
* EXCLUDE_MODS=true
* EXCLUDE_EMPLOYEES=true
* KARMA_THRESHOLD=100000
*
* These environment variables control the behavior of the function, such as whether
* to exclude moderators or employees from notifications, and the karma threshold for
* notifications.
*/
export async function shouldNotifyUser(username: string): Promise<boolean> {
const userInfo = await RedditService.getUserInfo(username);
if (!userInfo) return false;
const { is_mod, is_employee, accept_pms, total_karma } = userInfo.data;
const excludeMods = process.env.EXCLUDE_MODS !== 'false'; // Defaults to true unless explicitly set to 'false'
const excludeEmployees = process.env.EXCLUDE_EMPLOYEES !== 'false'; // Defaults to true unless explicitly set to 'false'
const notifyAcceptPms = accept_pms !== false; // Notify if accept_pms is true or undefined
const karmaThreshold = parseInt(process.env.KARMA_THRESHOLD || '100000', 10);
const hasBeenNotifiedBefore = await DatabaseService.userMentionExists(username);
const meetsCriteria =
(!excludeMods || !is_mod) && // Notify unless we're excluding mods and the user is a mod
(!excludeEmployees || !is_employee) && // Notify unless we're excluding employees and the user is an employee
notifyAcceptPms &&
total_karma < karmaThreshold &&
!hasBeenNotifiedBefore;
return meetsCriteria;
}

View File

@ -0,0 +1,100 @@
import { CommentProcessor } from "../rdrama/services/CommentProcessor";
import { CommentParser } from "../rdrama/services/CommentParser";
import { CommentPoster } from "../rdrama/services/CommentPoster";
import { MessageService } from "../utils/MessageService";
import { DatabaseService } from "../db/services/Database";
import { RedditService } from "../reddit/services/Reddit";
import { shouldNotifyUser } from "../utils/ShouldNotify";
import { Comment } from "../rdrama/models/Comment";
class WorkflowOrchestrator {
/**
* Executes the defined workflow for processing comments.
*/
async executeWorkflow() {
try {
const comments = await this.fetchAndLogComments();
for (const comment of comments) {
await this.processComment(comment);
}
console.log('Workflow executed successfully.');
} catch (error) {
console.error('An error occurred during workflow execution:', error);
}
}
/**
* Fetches comments and logs the count.
* @returns {Promise<Array>} The fetched comments.
*/
async fetchAndLogComments(): Promise<Comment[]> {
const comments = await CommentProcessor.processComments();
console.log(`Fetched ${comments.length} comments`);
return comments;
}
/**
* Processes a single comment, including posting responses and sending notifications.
* @param {Object} comment The comment to process.
*/
async processComment(comment: Comment) {
const redditUsers = CommentParser.extractUsernames(comment);
if (redditUsers.length === 0) return;
console.log('found:', redditUsers);
for (const redditUser of redditUsers) {
await this.handleUserMention(comment, redditUser);
}
}
/**
* Handles a mention of a user in a comment, including checking for previous mentions, posting a response, and sending a notification.
* @param {Object} comment The comment mentioning the user.
* @param {string} redditUser The mentioned Reddit user's username.
*/
async handleUserMention(comment: Comment, redditUser: string) {
const userMentionExists = await DatabaseService.userMentionExists(redditUser);
if (userMentionExists) return;
const placeholdersRdrama = { author_name: comment.author_name };
const commentResponseRdrama = MessageService.getRandomRdramaMessage(placeholdersRdrama);
if (!commentResponseRdrama) throw new Error('No comments for Rdrama found');
await this.postCommentAndNotify(comment, redditUser, commentResponseRdrama);
}
/**
* Posts a comment response and sends a notification if the user should be notified.
* @param {Object} comment The original comment.
* @param {string} redditUser The Reddit user to notify.
* @param {string} commentResponseRdrama The response to post.
*/
async postCommentAndNotify(comment: Comment, redditUser: string, commentResponseRdrama: string) {
// Placeholder for posting a comment. Uncomment and implement as needed.
const postedComment = await CommentPoster.postComment(`c_${comment.id}`, `${commentResponseRdrama}`);
console.log(`Sent Comment to`, JSON.stringify(postedComment, null, 4));
const resultshouldNotifyUser = await shouldNotifyUser(redditUser);
if (!resultshouldNotifyUser) return;
const placeholdersReddit = {
author_name: comment.author_name,
username: redditUser,
permalink: comment.permalink
};
const redditMessage = MessageService.getRandomRedditMessage(placeholdersReddit);
if (!redditMessage) throw new Error('No comments for Reddit found');
await DatabaseService.insertUserMention({
rdrama_comment_id: comment.id,
username: redditUser,
message: redditMessage,
});
await RedditService.sendMessage(redditUser, 'Crosstalk PM Notification', redditMessage);
}
}
export default WorkflowOrchestrator;

View File

@ -0,0 +1,18 @@
import { CommentFetcher } from "../../src/rdrama/services/CommentFetcher";
import SessionManager from "../../src/rdrama/session/SessionManager"
main();
async function main(): Promise<void> {
console.log('1')
const axiosSession = SessionManager.getInstance()
console.log('2')
let maxPages = 5
for (let page = 1; page <= maxPages; page++) {
const newComments = await CommentFetcher.fetchComments(page)
}
console.log('3')
await axiosSession.shutdown()
console.log('4')
}

20
tsconfig.json 100644
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": [
"es2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"types": [
"node"
],
"moduleResolution": "node"
},
"include": [
"src/**/*"
]
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist-tests",
"rootDir": "./"
},
"include": [
"tests/**/*"
]
}