|
|
@@ -0,0 +1,225 @@
|
|
|
+import { createStreamingAPIClient, createRestAPIClient, mastodon } from "masto";
|
|
|
+import { toZonedTime, fromZonedTime, formatInTimeZone } from "date-fns-tz";
|
|
|
+
|
|
|
+import config from "../../config";
|
|
|
+import Emojis from "../../enums/emojis";
|
|
|
+import LogLevels from "../../enums/log-levels";
|
|
|
+import RedisClient from "../../libs/redis-client";
|
|
|
+
|
|
|
+import { ContentFilter } from "./filters";
|
|
|
+import { ContentModerator } from "./moderation";
|
|
|
+import { ProcessedMessage, RateLimitInfo } from "./interfaces";
|
|
|
+
|
|
|
+export default class Anonybot {
|
|
|
+ private readonly _mastodonStreamingClient: mastodon.streaming.Client;
|
|
|
+ private readonly _mastodonRestClient: mastodon.rest.Client;
|
|
|
+ private readonly _redisClient: RedisClient;
|
|
|
+ private readonly _contentFilter: ContentFilter;
|
|
|
+ private readonly _moderator: ContentModerator;
|
|
|
+ private readonly _botUsername: string = "anonybot";
|
|
|
+
|
|
|
+ constructor(accessToken = config.MASTODON_TEST_ACCESS_TOKEN) {
|
|
|
+ this._mastodonStreamingClient = createStreamingAPIClient({
|
|
|
+ streamingApiUrl: config.MASTODON_STREAMING_URL,
|
|
|
+ accessToken
|
|
|
+ });
|
|
|
+ this._mastodonRestClient = createRestAPIClient({
|
|
|
+ url: config.MASTODON_API_URL,
|
|
|
+ accessToken
|
|
|
+ });
|
|
|
+ this._redisClient = new RedisClient();
|
|
|
+ this._contentFilter = new ContentFilter(this._botUsername);
|
|
|
+ this._moderator = new ContentModerator(config.ANONYBOT_MODERATION_LEVEL);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async checkRateLimit(account: string): Promise<RateLimitInfo> {
|
|
|
+ const key = `anonybot:rate:${account}`;
|
|
|
+ const messageCount = await this._redisClient.retrieve(key);
|
|
|
+ const count = messageCount ? Number(messageCount) : 0;
|
|
|
+ const maxMessages = Number(config.ANONYBOT_MAX_MESSAGES_PER_HOUR);
|
|
|
+
|
|
|
+ if (count >= maxMessages) {
|
|
|
+ // Default to 1 hour if we can't get TTL
|
|
|
+ return {
|
|
|
+ allowed: false,
|
|
|
+ messageCount: count,
|
|
|
+ timeRemaining: 3600
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return { allowed: true, messageCount: count };
|
|
|
+ }
|
|
|
+
|
|
|
+ private async incrementRateLimit(account: string): Promise<void> {
|
|
|
+ const key = `anonybot:rate:${account}`;
|
|
|
+ const currentCount = await this._redisClient.retrieve(key);
|
|
|
+
|
|
|
+ if (currentCount === null) {
|
|
|
+ await this._redisClient.store(key, "1", { EX: Number(config.ANONYBOT_RATE_LIMIT_HOURS) * 3600 });
|
|
|
+ } else {
|
|
|
+ await this._redisClient.store(key, String(Number(currentCount) + 1), {
|
|
|
+ XX: true,
|
|
|
+ EX: Number(config.ANONYBOT_RATE_LIMIT_HOURS) * 3600
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async processMessage(content: string): Promise<ProcessedMessage> {
|
|
|
+ try {
|
|
|
+ // Step 1: Extract clean content
|
|
|
+ const cleanContent = this._contentFilter.extractMessageContent(content);
|
|
|
+
|
|
|
+ // Step 2: Basic validation
|
|
|
+ if (!cleanContent) {
|
|
|
+ return { success: false, reason: 'No se pudo extraer contenido del mensaje' };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 3: Length validation
|
|
|
+ const lengthCheck = this._contentFilter.validateLength(cleanContent);
|
|
|
+ if (!lengthCheck.valid) {
|
|
|
+ return { success: false, reason: lengthCheck.reason };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 4: Remove PII
|
|
|
+ const sanitizedContent = this._contentFilter.detectAndRemovePII(cleanContent);
|
|
|
+
|
|
|
+ // Step 5: Content moderation
|
|
|
+ const moderationResult = await this._moderator.filterContent(sanitizedContent);
|
|
|
+ if (!moderationResult.allowed) {
|
|
|
+ return { success: false, reason: moderationResult.reason };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Step 6: Detect sensitive topics for CW
|
|
|
+ const cw = this._contentFilter.detectSensitiveTopics(sanitizedContent);
|
|
|
+
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ processedMessage: this._contentFilter.sanitizeMessage(sanitizedContent),
|
|
|
+ contentWarning: cw || undefined
|
|
|
+ };
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error("Error processing message:", error);
|
|
|
+ return { success: false, reason: 'Error interno al procesar el mensaje' };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatAnonymousMessage(message: string, contentWarning?: string): string {
|
|
|
+ let formattedMessage = `🎭 Alguien dijo:\n\n${message}\n\n#confesión`;
|
|
|
+
|
|
|
+ if (contentWarning) {
|
|
|
+ formattedMessage = `${contentWarning}\n\n${formattedMessage}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return formattedMessage;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async reply(inReplyToId: string | undefined, user: string, visibility: mastodon.v1.StatusVisibility, status: string): Promise<void> {
|
|
|
+ let result: mastodon.v1.Status;
|
|
|
+
|
|
|
+ console.log(`Sending reply to ${user}\n`, status);
|
|
|
+ result = await this._mastodonRestClient.v1.statuses.create({ inReplyToId, status, visibility });
|
|
|
+
|
|
|
+ if (config.LOG_LEVEL === LogLevels.DEBUG) {
|
|
|
+ console.debug("Reply result", result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async publishAnonymous(message: string, contentWarning?: string): Promise<void> {
|
|
|
+ const formattedMessage = this.formatAnonymousMessage(message, contentWarning);
|
|
|
+ const publishOptions: any = { status: formattedMessage };
|
|
|
+
|
|
|
+ if (contentWarning) {
|
|
|
+ publishOptions.spoilerText = contentWarning;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log("Publishing anonymous message:\n", formattedMessage);
|
|
|
+ const result = await this._mastodonRestClient.v1.statuses.create(publishOptions);
|
|
|
+
|
|
|
+ if (config.LOG_LEVEL === LogLevels.DEBUG) {
|
|
|
+ console.debug("Publish result", result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatTimeRemaining(seconds: number): string {
|
|
|
+ const hours = Math.floor(seconds / 3600);
|
|
|
+ const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
+
|
|
|
+ if (hours > 0) {
|
|
|
+ return `${hours}h ${minutes}min`;
|
|
|
+ }
|
|
|
+ return `${minutes}min`;
|
|
|
+ }
|
|
|
+
|
|
|
+ public async subscribe(): Promise<void> {
|
|
|
+ console.log("Anonybot listening to incoming events...");
|
|
|
+
|
|
|
+ for await (const event of this._mastodonStreamingClient.user.notification.subscribe()) {
|
|
|
+ switch (event.event) {
|
|
|
+ case "notification":
|
|
|
+ if (event.payload.type == "mention") {
|
|
|
+ const { id, createdAt, visibility, url, content } = event.payload.status ?? {};
|
|
|
+ const account = event.payload.account.acct;
|
|
|
+
|
|
|
+ if (config.DEVELOP) {
|
|
|
+ console.log("Event received\n", event.payload);
|
|
|
+ } else {
|
|
|
+ console.log("Event received\n", { id, createdAt, visibility, url, content: content || "", account });
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Check rate limit first
|
|
|
+ const rateLimit = await this.checkRateLimit(account);
|
|
|
+ if (!rateLimit.allowed) {
|
|
|
+ const timeRemaining = this.formatTimeRemaining(rateLimit.timeRemaining || 0);
|
|
|
+ const replyMessage = `@${account} ${Emojis.HOURGLASS} Has alcanzado el límite de ${config.ANONYBOT_MAX_MESSAGES_PER_HOUR} mensajes por hora. Podrás enviar otro mensaje en ${timeRemaining}.`;
|
|
|
+ await this.reply(id, account, visibility, replyMessage);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Process the message
|
|
|
+ const processed = await this.processMessage(content || "");
|
|
|
+
|
|
|
+ if (!processed.success) {
|
|
|
+ let replyMessage = `@${account} ${Emojis.CROSS_MARK} No se pudo publicar tu mensaje anónimo.`;
|
|
|
+
|
|
|
+ if (processed.reason) {
|
|
|
+ replyMessage += ` Razón: ${processed.reason}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ replyMessage += `\n\n${Emojis.INFO} Si crees que es un error, contacta al administrador del bot.`;
|
|
|
+
|
|
|
+ await this.reply(id, account, visibility, replyMessage);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Publish successfully processed message
|
|
|
+ await this.publishAnonymous(processed.processedMessage!, processed.contentWarning);
|
|
|
+
|
|
|
+ // Increment rate limit
|
|
|
+ await this.incrementRateLimit(account);
|
|
|
+
|
|
|
+ // Send confirmation
|
|
|
+ let confirmMessage = `@${account} ${Emojis.CHECK} Tu mensaje anónimo ha sido publicado exitosamente ${Emojis.PARTY_POPPER}`;
|
|
|
+
|
|
|
+ if (processed.contentWarning) {
|
|
|
+ confirmMessage += `\n\n${Emojis.INFO} Se ha añadido un Content Warning debido al tema sensible del mensaje.`;
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.reply(id, account, visibility, confirmMessage);
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error("Error processing notification:", error);
|
|
|
+ const errorMessage = `@${account} ${Emojis.CROSS_MARK} Ha ocurrido un error inesperado. Por favor, inténtalo más tarde.`;
|
|
|
+ await this.reply(id, account, visibility, errorMessage);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+try {
|
|
|
+ new Anonybot(config.DEVELOP ? config.MASTODON_TEST_ACCESS_TOKEN : config.MASTODON_KEY_ANONYBOT).subscribe();
|
|
|
+} catch (error) {
|
|
|
+ console.error(error);
|
|
|
+}
|