From 962b942e2a958f6457e185f319bdd02bfef72b35 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:29:21 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(bot-services):=20add=20credit?= =?UTF-8?q?=20and=20gift=20services=20for=20Matrix=20bots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CreditService with balance checking and consumption - Add GiftService for gift code creation and redemption - Add i18n support for credit/gift messages (DE/EN) - Add Matrix bot mixins for credit and gift commands --- .../bot-services/src/credit/credit.service.ts | 158 +++++++ packages/bot-services/src/credit/index.ts | 4 + packages/bot-services/src/credit/types.ts | 59 +++ packages/bot-services/src/gift/gift.module.ts | 52 +++ .../bot-services/src/gift/gift.service.ts | 405 ++++++++++++++++ packages/bot-services/src/gift/index.ts | 15 + packages/bot-services/src/gift/types.ts | 173 +++++++ .../bot-services/src/i18n/i18n.service.ts | 17 + packages/bot-services/src/i18n/locales/de.ts | 160 +++++++ packages/bot-services/src/i18n/locales/en.ts | 160 +++++++ packages/bot-services/src/i18n/types.ts | 65 +++ packages/bot-services/src/index.ts | 20 + .../src/credit/credit-commands.mixin.ts | 304 ++++++++++++ .../matrix-bot-common/src/credit/index.ts | 8 + .../src/gift/gift-commands.mixin.ts | 431 ++++++++++++++++++ packages/matrix-bot-common/src/gift/index.ts | 7 + packages/matrix-bot-common/src/index.ts | 19 + 17 files changed, 2057 insertions(+) create mode 100644 packages/bot-services/src/gift/gift.module.ts create mode 100644 packages/bot-services/src/gift/gift.service.ts create mode 100644 packages/bot-services/src/gift/index.ts create mode 100644 packages/bot-services/src/gift/types.ts create mode 100644 packages/matrix-bot-common/src/credit/credit-commands.mixin.ts create mode 100644 packages/matrix-bot-common/src/credit/index.ts create mode 100644 packages/matrix-bot-common/src/gift/gift-commands.mixin.ts create mode 100644 packages/matrix-bot-common/src/gift/index.ts diff --git a/packages/bot-services/src/credit/credit.service.ts b/packages/bot-services/src/credit/credit.service.ts index 66490cfb1..81a44d7ed 100644 --- a/packages/bot-services/src/credit/credit.service.ts +++ b/packages/bot-services/src/credit/credit.service.ts @@ -6,6 +6,9 @@ import { CreditModuleOptions, CreditStatusMessage, CreditErrorCode, + CreditPackage, + PaymentLinkResult, + PurchaseStatusResult, CREDIT_MODULE_OPTIONS, } from './types'; @@ -312,4 +315,159 @@ export class CreditService { return { text, html }; } + + // ============================================================================ + // PACKAGE & PAYMENT LINK METHODS (for bot credit purchasing) + // ============================================================================ + + /** + * Get available credit packages + * + * @returns List of available credit packages + */ + async getPackages(): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/credits/packages`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.warn(`Failed to get packages: ${response.status}`); + return []; + } + + const packages = (await response.json()) as Array<{ + id: string; + name: string; + credits: number; + priceEuroCents: number; + sortOrder: number; + }>; + + return packages.map((pkg) => ({ + id: pkg.id, + name: pkg.name, + credits: pkg.credits, + priceEuroCents: pkg.priceEuroCents, + formattedPrice: this.formatPrice(pkg.priceEuroCents), + sortOrder: pkg.sortOrder, + })); + } catch (error) { + this.logger.error('Error getting packages:', error); + return []; + } + } + + /** + * Create a payment link for purchasing credits + * + * @param token - User's JWT token + * @param packageId - ID of the package to purchase + * @param roomId - Optional Matrix room ID for notification after payment + * @returns Payment link result with URL and expiration + */ + async createPaymentLink( + token: string, + packageId: string, + roomId?: string + ): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/credits/payment-link`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + packageId, + roomId, + }), + }); + + if (!response.ok) { + this.logger.warn(`Failed to create payment link: ${response.status}`); + return null; + } + + const result = (await response.json()) as { + url: string; + purchaseId: string; + expiresAt: string; + package: { + name: string; + credits: number; + priceEuroCents: number; + }; + }; + + return { + url: result.url, + purchaseId: result.purchaseId, + expiresAt: new Date(result.expiresAt), + package: result.package, + }; + } catch (error) { + this.logger.error('Error creating payment link:', error); + return null; + } + } + + /** + * Get purchase status + * + * @param token - User's JWT token + * @param purchaseId - Purchase ID to check + * @returns Purchase status or null if not found + */ + async getPurchaseStatus(token: string, purchaseId: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/credits/purchase/${purchaseId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.warn(`Failed to get purchase status: ${response.status}`); + return null; + } + + const result = (await response.json()) as { + id: string; + status: 'pending' | 'completed' | 'failed'; + credits: number; + priceEuroCents: number; + createdAt: string; + completedAt?: string; + }; + + return { + id: result.id, + status: result.status, + credits: result.credits, + priceEuroCents: result.priceEuroCents, + createdAt: new Date(result.createdAt), + completedAt: result.completedAt ? new Date(result.completedAt) : undefined, + }; + } catch (error) { + this.logger.error('Error getting purchase status:', error); + return null; + } + } + + /** + * Format price in euro cents to human-readable format + * + * @param priceEuroCents - Price in euro cents + * @returns Formatted price (e.g., "4,99 €") + */ + private formatPrice(priceEuroCents: number): string { + const euros = priceEuroCents / 100; + return `${euros.toFixed(2).replace('.', ',')} €`; + } } diff --git a/packages/bot-services/src/credit/index.ts b/packages/bot-services/src/credit/index.ts index 6d046dffc..119cc6761 100644 --- a/packages/bot-services/src/credit/index.ts +++ b/packages/bot-services/src/credit/index.ts @@ -6,5 +6,9 @@ export type { CreditConsumeResult, CreditModuleOptions, CreditStatusMessage, + CreditPackage, + PaymentLinkResult, + PurchaseStatus, + PurchaseStatusResult, } from './types'; export { CREDIT_MODULE_OPTIONS, CreditErrorCode } from './types'; diff --git a/packages/bot-services/src/credit/types.ts b/packages/bot-services/src/credit/types.ts index 8962d3ca0..c9ece375d 100644 --- a/packages/bot-services/src/credit/types.ts +++ b/packages/bot-services/src/credit/types.ts @@ -73,3 +73,62 @@ export interface CreditStatusMessage { /** HTML formatted message (for Matrix) */ html: string; } + +/** + * Credit package available for purchase + */ +export interface CreditPackage { + /** Package ID */ + id: string; + /** Package name (e.g., "Starter", "Standard", "Premium") */ + name: string; + /** Number of credits in package */ + credits: number; + /** Price in euro cents */ + priceEuroCents: number; + /** Human-readable price (e.g., "4,99 €") */ + formattedPrice: string; + /** Display order */ + sortOrder: number; +} + +/** + * Result of creating a payment link + */ +export interface PaymentLinkResult { + /** Stripe Checkout URL */ + url: string; + /** Purchase ID for tracking */ + purchaseId: string; + /** When the link expires */ + expiresAt: Date; + /** Package details */ + package: { + name: string; + credits: number; + priceEuroCents: number; + }; +} + +/** + * Purchase status + */ +export type PurchaseStatus = 'pending' | 'completed' | 'failed'; + +/** + * Purchase status result + */ +export interface PurchaseStatusResult { + /** Purchase ID */ + id: string; + /** Current status */ + status: PurchaseStatus; + /** Credits in package */ + credits: number; + /** Price in euro cents */ + priceEuroCents: number; + /** When purchase was created */ + createdAt: Date; + /** When purchase was completed (if completed) */ + completedAt?: Date; +} diff --git a/packages/bot-services/src/gift/gift.module.ts b/packages/bot-services/src/gift/gift.module.ts new file mode 100644 index 000000000..b435028d5 --- /dev/null +++ b/packages/bot-services/src/gift/gift.module.ts @@ -0,0 +1,52 @@ +import { Module, DynamicModule, Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { GiftService } from './gift.service'; +import { GiftModuleOptions, GIFT_MODULE_OPTIONS } from './types'; + +@Module({}) +export class GiftModule { + /** + * Register the gift module with options + * + * @param options - Gift module options + * @returns Dynamic module + * + * @example + * ```typescript + * GiftModule.register({ authUrl: 'http://mana-core-auth:3001' }) + * ``` + */ + static register(options?: GiftModuleOptions): DynamicModule { + const optionsProvider: Provider = { + provide: GIFT_MODULE_OPTIONS, + useValue: options || {}, + }; + + return { + module: GiftModule, + imports: [ConfigModule], + providers: [optionsProvider, GiftService], + exports: [GiftService], + }; + } + + /** + * Register the gift module with default configuration + * Uses ConfigService to get auth URL + * + * @returns Dynamic module + * + * @example + * ```typescript + * GiftModule.forRoot() + * ``` + */ + static forRoot(): DynamicModule { + return { + module: GiftModule, + imports: [ConfigModule], + providers: [GiftService], + exports: [GiftService], + }; + } +} diff --git a/packages/bot-services/src/gift/gift.service.ts b/packages/bot-services/src/gift/gift.service.ts new file mode 100644 index 000000000..bb3e6d086 --- /dev/null +++ b/packages/bot-services/src/gift/gift.service.ts @@ -0,0 +1,405 @@ +import { Injectable, Inject, Logger, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + CreateGiftOptions, + CreateGiftResult, + GiftCodeInfo, + RedeemGiftResult, + CreatedGiftItem, + ReceivedGiftItem, + GiftModuleOptions, + GiftStatusMessage, + GIFT_MODULE_OPTIONS, +} from './types'; + +/** + * Shared gift code management service for Matrix bots + * + * Provides gift code creation, redemption, and listing + * for gifting credits between users via Matrix chat. + * + * @example + * ```typescript + * // In NestJS module + * imports: [GiftModule.register({ authUrl: 'http://mana-core-auth:3001' })] + * + * // In service/controller + * const gift = await giftService.createGift(token, 50, { message: 'Happy birthday!' }); + * const result = await giftService.redeemGift(token, 'ABC123'); + * ``` + */ +@Injectable() +export class GiftService { + private readonly logger = new Logger(GiftService.name); + private readonly authUrl: string; + + constructor( + @Optional() private configService: ConfigService, + @Optional() @Inject(GIFT_MODULE_OPTIONS) private options?: GiftModuleOptions + ) { + // Priority: module options > config > environment > default + this.authUrl = + options?.authUrl || + this.configService?.get('auth.url') || + this.configService?.get('MANA_CORE_AUTH_URL') || + 'http://localhost:3001'; + + this.logger.log(`Gift service initialized with auth URL: ${this.authUrl}`); + } + + /** + * Create a new gift code + * + * @param token - User's JWT token + * @param credits - Total credits to gift + * @param options - Gift options (type, portions, message, etc.) + * @returns Created gift code info + */ + async createGift( + token: string, + credits: number, + options?: CreateGiftOptions + ): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/gifts`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + credits, + ...options, + sourceAppId: 'matrix-bot', + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + this.logger.warn(`Failed to create gift: ${response.status}`, error); + return null; + } + + return (await response.json()) as CreateGiftResult; + } catch (error) { + this.logger.error('Error creating gift:', error); + return null; + } + } + + /** + * Get gift code info (public, for preview) + * + * @param code - The gift code + * @returns Gift code info or null if not found + */ + async getGiftInfo(code: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/gifts/${code}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + this.logger.warn(`Failed to get gift info: ${response.status}`); + return null; + } + + return (await response.json()) as GiftCodeInfo; + } catch (error) { + this.logger.error('Error getting gift info:', error); + return null; + } + } + + /** + * Redeem a gift code + * + * @param token - User's JWT token + * @param code - The gift code to redeem + * @param answer - Riddle answer (if required) + * @param matrixUserId - Matrix user ID (for personalized gifts) + * @returns Redemption result + */ + async redeemGift( + token: string, + code: string, + answer?: string, + matrixUserId?: string + ): Promise { + try { + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + + if (matrixUserId) { + headers['X-Matrix-User-Id'] = matrixUserId; + } + + const response = await fetch(`${this.authUrl}/api/v1/gifts/${code}/redeem`, { + method: 'POST', + headers, + body: JSON.stringify({ + answer, + sourceAppId: 'matrix-bot', + }), + }); + + if (!response.ok && response.status !== 200) { + const errorData = (await response.json().catch(() => ({}))) as { message?: string }; + return { + success: false, + error: errorData.message || 'Failed to redeem gift code', + }; + } + + return (await response.json()) as RedeemGiftResult; + } catch (error) { + this.logger.error('Error redeeming gift:', error); + return { + success: false, + error: 'Service temporarily unavailable', + }; + } + } + + /** + * Cancel a gift code and get refund + * + * @param token - User's JWT token + * @param codeId - Gift code ID to cancel + * @returns Refunded credits amount + */ + async cancelGift(token: string, codeId: string): Promise<{ refundedCredits: number } | null> { + try { + const response = await fetch(`${this.authUrl}/api/v1/gifts/${codeId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.warn(`Failed to cancel gift: ${response.status}`); + return null; + } + + return (await response.json()) as { refundedCredits: number }; + } catch (error) { + this.logger.error('Error cancelling gift:', error); + return null; + } + } + + /** + * List gift codes created by the user + * + * @param token - User's JWT token + * @returns List of created gifts + */ + async listCreatedGifts(token: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/gifts/me/created`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.warn(`Failed to list created gifts: ${response.status}`); + return []; + } + + return (await response.json()) as CreatedGiftItem[]; + } catch (error) { + this.logger.error('Error listing created gifts:', error); + return []; + } + } + + /** + * List gifts received by the user + * + * @param token - User's JWT token + * @returns List of received gifts + */ + async listReceivedGifts(token: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/gifts/me/received`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.warn(`Failed to list received gifts: ${response.status}`); + return []; + } + + return (await response.json()) as ReceivedGiftItem[]; + } catch (error) { + this.logger.error('Error listing received gifts:', error); + return []; + } + } + + // ============================================================================ + // MESSAGE FORMATTING HELPERS + // ============================================================================ + + /** + * Format a gift created success message + */ + formatGiftCreatedMessage(gift: CreateGiftResult): GiftStatusMessage { + const lines: string[] = []; + const htmlLines: string[] = []; + + lines.push('🎁 **Geschenk erstellt!**'); + htmlLines.push('🎁 Geschenk erstellt!'); + + lines.push(''); + htmlLines.push('
'); + + lines.push(`Code: \`${gift.code}\``); + htmlLines.push(`Code: ${gift.code}`); + + lines.push(`Credits: ${gift.creditsPerPortion}${gift.totalPortions > 1 ? ` × ${gift.totalPortions}` : ''}`); + htmlLines.push(`Credits: ${gift.creditsPerPortion}${gift.totalPortions > 1 ? ` × ${gift.totalPortions}` : ''}`); + + lines.push(''); + htmlLines.push('
'); + + lines.push(`Link: ${gift.url}`); + htmlLines.push(`Link: ${gift.url}`); + + return { + text: lines.join('\n'), + html: htmlLines.join('
'), + }; + } + + /** + * Format a gift redeemed success message + */ + formatGiftRedeemedMessage( + credits: number, + newBalance: number, + message?: string + ): GiftStatusMessage { + const lines: string[] = []; + const htmlLines: string[] = []; + + lines.push('🎁 **Geschenk eingelöst!**'); + htmlLines.push('🎁 Geschenk eingelöst!'); + + lines.push(`+${credits} Credits`); + htmlLines.push(`+${credits} Credits`); + + if (message) { + lines.push(''); + lines.push(`"${message}"`); + htmlLines.push('
'); + htmlLines.push(`"${message}"`); + } + + lines.push(''); + lines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`); + htmlLines.push('
'); + htmlLines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`); + + return { + text: lines.join('\n'), + html: htmlLines.join('
'), + }; + } + + /** + * Format gift list message + */ + formatGiftListMessage(gifts: CreatedGiftItem[]): GiftStatusMessage { + const lines: string[] = []; + const htmlLines: string[] = []; + + lines.push('🎁 **Deine Geschenke:**'); + htmlLines.push('🎁 Deine Geschenke:'); + + lines.push(''); + htmlLines.push('
'); + + if (gifts.length === 0) { + lines.push('Keine aktiven Geschenke.'); + htmlLines.push('Keine aktiven Geschenke.'); + } else { + gifts.forEach((gift, index) => { + const statusIcon = gift.status === 'active' ? '✅' : gift.status === 'depleted' ? '✓' : '❌'; + const claimed = `${gift.claimedPortions}/${gift.totalPortions}`; + + lines.push(`${index + 1}. \`${gift.code}\` ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`); + htmlLines.push(`${index + 1}. ${gift.code} ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`); + }); + } + + return { + text: lines.join('\n'), + html: htmlLines.join('
'), + }; + } + + /** + * Format gift code info message (for preview before redeeming) + */ + formatGiftInfoMessage(info: GiftCodeInfo): GiftStatusMessage { + const lines: string[] = []; + const htmlLines: string[] = []; + + lines.push('🎁 **Geschenk-Info:**'); + htmlLines.push('🎁 Geschenk-Info:'); + + lines.push(`Credits: ${info.creditsPerPortion}`); + htmlLines.push(`Credits: ${info.creditsPerPortion}`); + + if (info.totalPortions > 1) { + lines.push(`Verfügbar: ${info.remainingPortions}/${info.totalPortions}`); + htmlLines.push(`Verfügbar: ${info.remainingPortions}/${info.totalPortions}`); + } + + if (info.message) { + lines.push(''); + lines.push(`"${info.message}"`); + htmlLines.push('
'); + htmlLines.push(`"${info.message}"`); + } + + if (info.hasRiddle) { + lines.push(''); + lines.push(`❓ ${info.riddleQuestion}`); + lines.push('Antworte mit: `!einloesen CODE antwort`'); + htmlLines.push('
'); + htmlLines.push(`❓ ${info.riddleQuestion}`); + htmlLines.push('Antworte mit: !einloesen CODE antwort'); + } + + if (info.creatorName) { + lines.push(''); + lines.push(`Von: ${info.creatorName}`); + htmlLines.push('
'); + htmlLines.push(`Von: ${info.creatorName}`); + } + + return { + text: lines.join('\n'), + html: htmlLines.join('
'), + }; + } +} diff --git a/packages/bot-services/src/gift/index.ts b/packages/bot-services/src/gift/index.ts new file mode 100644 index 000000000..ec5e15790 --- /dev/null +++ b/packages/bot-services/src/gift/index.ts @@ -0,0 +1,15 @@ +export { GiftService } from './gift.service'; +export { GiftModule } from './gift.module'; +export type { + GiftCodeType, + GiftCodeStatus, + CreateGiftOptions, + CreateGiftResult, + GiftCodeInfo, + RedeemGiftResult, + CreatedGiftItem, + ReceivedGiftItem, + GiftModuleOptions, + GiftStatusMessage, +} from './types'; +export { GIFT_MODULE_OPTIONS } from './types'; diff --git a/packages/bot-services/src/gift/types.ts b/packages/bot-services/src/gift/types.ts new file mode 100644 index 000000000..eb93ee910 --- /dev/null +++ b/packages/bot-services/src/gift/types.ts @@ -0,0 +1,173 @@ +/** + * Types for gift code management in Matrix bots + */ + +/** + * Gift code types + */ +export type GiftCodeType = 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; + +/** + * Gift code status + */ +export type GiftCodeStatus = 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded'; + +/** + * Options for creating a gift code + */ +export interface CreateGiftOptions { + /** Gift type (default: 'simple') */ + type?: GiftCodeType; + /** Number of portions for split/first_come (default: 1) */ + portions?: number; + /** Target email for personalized gifts */ + targetEmail?: string; + /** Target Matrix ID for personalized gifts */ + targetMatrixId?: string; + /** Riddle question */ + riddleQuestion?: string; + /** Riddle answer */ + riddleAnswer?: string; + /** Optional message */ + message?: string; + /** Expiration date (ISO string) */ + expiresAt?: string; +} + +/** + * Result of creating a gift code + */ +export interface CreateGiftResult { + /** Gift code ID */ + id: string; + /** The gift code (6 chars) */ + code: string; + /** Short URL (mana.how/g/CODE) */ + url: string; + /** Total credits reserved */ + totalCredits: number; + /** Credits per portion */ + creditsPerPortion: number; + /** Total number of portions */ + totalPortions: number; + /** Gift type */ + type: GiftCodeType; + /** Expiration date */ + expiresAt?: string; +} + +/** + * Gift code info (public, for preview) + */ +export interface GiftCodeInfo { + /** The gift code */ + code: string; + /** Gift type */ + type: GiftCodeType; + /** Current status */ + status: GiftCodeStatus; + /** Credits per portion */ + creditsPerPortion: number; + /** Total portions */ + totalPortions: number; + /** Claimed portions */ + claimedPortions: number; + /** Remaining portions */ + remainingPortions: number; + /** Optional message */ + message?: string; + /** Riddle question (if riddle type) */ + riddleQuestion?: string; + /** Whether gift has a riddle */ + hasRiddle: boolean; + /** Whether gift is for a specific person */ + isPersonalized: boolean; + /** Expiration date */ + expiresAt?: string; + /** Creator name */ + creatorName?: string; +} + +/** + * Result of redeeming a gift code + */ +export interface RedeemGiftResult { + /** Whether redemption was successful */ + success: boolean; + /** Credits received (if successful) */ + credits?: number; + /** Gift message (if any) */ + message?: string; + /** Error message (if failed) */ + error?: string; + /** New credit balance */ + newBalance?: number; +} + +/** + * Gift code in user's created list + */ +export interface CreatedGiftItem { + /** Gift code ID */ + id: string; + /** The gift code */ + code: string; + /** Short URL */ + url: string; + /** Gift type */ + type: GiftCodeType; + /** Current status */ + status: GiftCodeStatus; + /** Total credits reserved */ + totalCredits: number; + /** Credits per portion */ + creditsPerPortion: number; + /** Total portions */ + totalPortions: number; + /** Claimed portions */ + claimedPortions: number; + /** Optional message */ + message?: string; + /** Expiration date */ + expiresAt?: string; + /** Creation date */ + createdAt: string; +} + +/** + * Gift in user's received list + */ +export interface ReceivedGiftItem { + /** Redemption ID */ + id: string; + /** The gift code */ + code: string; + /** Credits received */ + credits: number; + /** Gift message */ + message?: string; + /** Creator name */ + creatorName?: string; + /** When redeemed */ + redeemedAt: string; +} + +/** + * Gift module configuration options + */ +export interface GiftModuleOptions { + /** Mana Core Auth URL */ + authUrl?: string; +} + +export const GIFT_MODULE_OPTIONS = 'GIFT_MODULE_OPTIONS'; + +/** + * Formatted gift message for Matrix bots + */ +export interface GiftStatusMessage { + /** Plain text message */ + text: string; + /** HTML formatted message (for Matrix) */ + html: string; +} diff --git a/packages/bot-services/src/i18n/i18n.service.ts b/packages/bot-services/src/i18n/i18n.service.ts index 240c86db8..a11ba43df 100644 --- a/packages/bot-services/src/i18n/i18n.service.ts +++ b/packages/bot-services/src/i18n/i18n.service.ts @@ -7,6 +7,7 @@ import { CalendarTranslations, ContactsTranslations, ClockTranslations, + GiftTranslations, I18nOptions, } from './types'; import { de } from './locales/de'; @@ -178,6 +179,17 @@ export class I18nService { return (key, params) => this.interpolate(t[key], params); } + /** + * Get a translator function for gift commands + */ + async getGiftTranslator( + userId: string + ): Promise<(key: keyof GiftTranslations, params?: Record) => string> { + const lang = await this.getLanguage(userId); + const t = translations[lang].gift; + return (key, params) => this.interpolate(t[key], params); + } + /** * Get translations directly for a bot type */ @@ -201,6 +213,11 @@ export class I18nService { return translations[lang].clock; } + async getGiftTranslations(userId: string): Promise { + const lang = await this.getLanguage(userId); + return translations[lang].gift; + } + /** * Interpolate placeholders in a string * diff --git a/packages/bot-services/src/i18n/locales/de.ts b/packages/bot-services/src/i18n/locales/de.ts index 181c10055..7cb2a2f01 100644 --- a/packages/bot-services/src/i18n/locales/de.ts +++ b/packages/bot-services/src/i18n/locales/de.ts @@ -19,6 +19,19 @@ export const de: BotTranslations = { insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', buyCredits: 'Credits kaufen: https://mana.how/credits', + // Credit purchasing + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', + // Sync synced: 'Synchronisiert', localStorage: 'Lokaler Speicher', @@ -62,6 +75,17 @@ export const de: BotTranslations = { creditsRemaining: '{amount} verbleibend', insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', buyCredits: 'Credits kaufen: https://mana.how/credits', + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', synced: 'Synchronisiert', localStorage: 'Lokaler Speicher', status: 'Status', @@ -146,6 +170,17 @@ export const de: BotTranslations = { creditsRemaining: '{amount} verbleibend', insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', buyCredits: 'Credits kaufen: https://mana.how/credits', + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', synced: 'Synchronisiert', localStorage: 'Lokaler Speicher', status: 'Status', @@ -230,6 +265,17 @@ export const de: BotTranslations = { creditsRemaining: '{amount} verbleibend', insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', buyCredits: 'Credits kaufen: https://mana.how/credits', + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', synced: 'Synchronisiert', localStorage: 'Lokaler Speicher', status: 'Status', @@ -314,6 +360,17 @@ export const de: BotTranslations = { creditsRemaining: '{amount} verbleibend', insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', buyCredits: 'Credits kaufen: https://mana.how/credits', + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', synced: 'Synchronisiert', localStorage: 'Lokaler Speicher', status: 'Status', @@ -387,4 +444,107 @@ export const de: BotTranslations = { couldNotParseDuration: 'Konnte Zeit nicht verstehen.', couldNotParseTime: 'Konnte Uhrzeit nicht verstehen.', }, + + gift: { + // Inherit common + error: 'Fehler', + errorOccurred: 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.', + notLoggedIn: 'Du bist nicht angemeldet.', + loginRequired: 'Bitte melde dich zuerst an mit `!login email passwort`', + loginSuccess: 'Erfolgreich angemeldet als **{email}**', + loginFailed: 'Anmeldung fehlgeschlagen: {error}', + logoutSuccess: 'Erfolgreich abgemeldet.', + invalidCommand: 'Unbekannter Befehl: {command}', + helpHint: 'Sag "hilfe" für alle Befehle.', + credits: 'Credits', + creditsRemaining: '{amount} verbleibend', + insufficientCredits: 'Nicht genügend Credits. Benötigt: {required}, Verfügbar: {available}', + buyCredits: 'Credits kaufen: https://mana.how/credits', + creditBalance: 'Dein Guthaben: **{balance}** Credits', + creditPackagesTitle: '**Credit-Pakete:**', + creditPackageLine: '{num}. {name} · {credits} Credits · {price}', + creditBuyHelp: 'Kaufen: `!kaufen {num}` oder `!buy {num}`', + creditPaymentLink: 'Klicke hier um zu bezahlen:', + creditLinkValid: 'Link gültig für 24 Stunden.', + creditPaymentSuccess: '✓ Zahlung erfolgreich! **{credits}** Credits wurden deinem Konto gutgeschrieben.', + creditNewBalance: 'Neuer Kontostand: **{balance}** Credits', + creditPackageNotFound: 'Paket nicht gefunden. Nutze `!pakete` um verfügbare Pakete anzuzeigen.', + creditPurchaseError: 'Fehler beim Erstellen des Zahlungslinks. Bitte versuche es später erneut.', + creditNoPackages: 'Keine Credit-Pakete verfügbar.', + synced: 'Synchronisiert', + localStorage: 'Lokaler Speicher', + status: 'Status', + online: 'Online', + offline: 'Offline', + loggedInAs: 'Angemeldet als: {email}', + notLoggedInStatus: 'Nicht angemeldet', + languageChanged: 'Sprache geändert zu: **{language}**', + currentLanguage: 'Aktuelle Sprache: **{language}**', + availableLanguages: 'Verfügbare Sprachen: {languages}', + today: 'Heute', + tomorrow: 'Morgen', + dayAfterTomorrow: 'Übermorgen', + created: 'Erstellt', + deleted: 'Gelöscht', + updated: 'Aktualisiert', + completed: 'Erledigt', + + // Gift creation + giftCreated: '🎁 **Geschenk erstellt!**', + giftCreatedCode: 'Code: `{code}`', + giftCreatedCredits: 'Credits: {credits}', + giftCreatedLink: 'Link: {url}', + giftCreatedSplit: 'Credits: {credits} × {portions}', + giftInvalidCredits: 'Bitte gib eine gültige Credit-Anzahl an (1-10000).', + giftInvalidSyntax: 'Ungültige Syntax. Beispiel: `!geschenk 50` oder `!geschenk 100 /5`', + giftInsufficientCredits: 'Nicht genug Credits. Verfügbar: {available}', + + // Gift redemption + giftRedeemed: '🎁 **Geschenk eingelöst!**', + giftRedeemedCredits: '+{credits} Credits', + giftRedeemedMessage: '"{message}"', + giftInvalidCode: 'Geschenkcode nicht gefunden.', + giftExpired: 'Dieser Geschenkcode ist abgelaufen.', + giftDepleted: 'Dieser Geschenkcode wurde bereits vollständig eingelöst.', + giftAlreadyClaimed: 'Du hast dieses Geschenk bereits eingelöst.', + giftWrongUser: 'Dieser Geschenkcode ist für eine bestimmte Person.', + giftWrongAnswer: 'Falsche Antwort. Versuche es erneut.', + giftRiddleRequired: 'Bitte gib die Antwort auf das Rätsel an.', + giftRiddleQuestion: '❓ {question}', + + // Gift list + giftListTitle: '🎁 **Deine Geschenke:**', + giftListEmpty: 'Keine aktiven Geschenke.', + giftListItem: '{num}. `{code}` {status} {credits} Cr · {claimed}/{total}', + giftReceivedListTitle: '🎁 **Erhaltene Geschenke:**', + giftReceivedListEmpty: 'Keine erhaltenen Geschenke.', + + // Gift info + giftInfoTitle: '🎁 **Geschenk-Info:**', + giftInfoCredits: 'Credits: {credits}', + giftInfoAvailable: 'Verfügbar: {remaining}/{total}', + giftInfoFrom: 'Von: {name}', + + // Gift help + giftHelpTitle: 'Geschenke - Hilfe', + giftHelpCommands: `**Befehle:** +• \`!geschenk [credits]\` - Geschenkcode erstellen +• \`!geschenk [credits] /[anzahl]\` - Split-Geschenk +• \`!geschenk [credits] @email\` - Personalisiert +• \`!geschenk [credits] ?="antwort"\` - Mit Rätsel +• \`!einloesen [code]\` - Code einlösen +• \`!meine-geschenke\` - Deine Geschenke anzeigen`, + giftHelpSyntax: `**Syntax:** +\`!geschenk 50\` - Einfaches Geschenk +\`!geschenk 100 /5\` - 5 Portionen à 20 Cr +\`!geschenk 50 "Alles Gute!"\` - Mit Nachricht`, + giftHelpExamples: `**Beispiele:** +• \`!geschenk 50\` +• \`!geschenk 100 /5 Teilt euch!\` +• \`!einloesen ABC123\``, + + // Gift cancellation + giftCancelled: 'Geschenk storniert.', + giftRefunded: '{credits} Credits zurückerstattet.', + }, }; diff --git a/packages/bot-services/src/i18n/locales/en.ts b/packages/bot-services/src/i18n/locales/en.ts index 8a453e404..43e45b561 100644 --- a/packages/bot-services/src/i18n/locales/en.ts +++ b/packages/bot-services/src/i18n/locales/en.ts @@ -19,6 +19,19 @@ export const en: BotTranslations = { insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', buyCredits: 'Buy credits: https://mana.how/credits', + // Credit purchasing + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', + // Sync synced: 'Synced', localStorage: 'Local storage', @@ -62,6 +75,17 @@ export const en: BotTranslations = { creditsRemaining: '{amount} remaining', insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', buyCredits: 'Buy credits: https://mana.how/credits', + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', synced: 'Synced', localStorage: 'Local storage', status: 'Status', @@ -146,6 +170,17 @@ export const en: BotTranslations = { creditsRemaining: '{amount} remaining', insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', buyCredits: 'Buy credits: https://mana.how/credits', + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', synced: 'Synced', localStorage: 'Local storage', status: 'Status', @@ -230,6 +265,17 @@ export const en: BotTranslations = { creditsRemaining: '{amount} remaining', insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', buyCredits: 'Buy credits: https://mana.how/credits', + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', synced: 'Synced', localStorage: 'Local storage', status: 'Status', @@ -314,6 +360,17 @@ export const en: BotTranslations = { creditsRemaining: '{amount} remaining', insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', buyCredits: 'Buy credits: https://mana.how/credits', + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', synced: 'Synced', localStorage: 'Local storage', status: 'Status', @@ -387,4 +444,107 @@ export const en: BotTranslations = { couldNotParseDuration: 'Could not parse duration.', couldNotParseTime: 'Could not parse time.', }, + + gift: { + // Inherit common + error: 'Error', + errorOccurred: 'An error occurred. Please try again.', + notLoggedIn: 'You are not logged in.', + loginRequired: 'Please log in first with `!login email password`', + loginSuccess: 'Successfully logged in as **{email}**', + loginFailed: 'Login failed: {error}', + logoutSuccess: 'Successfully logged out.', + invalidCommand: 'Unknown command: {command}', + helpHint: 'Say "help" for all commands.', + credits: 'Credits', + creditsRemaining: '{amount} remaining', + insufficientCredits: 'Insufficient credits. Required: {required}, Available: {available}', + buyCredits: 'Buy credits: https://mana.how/credits', + creditBalance: 'Your balance: **{balance}** credits', + creditPackagesTitle: '**Credit packages:**', + creditPackageLine: '{num}. {name} · {credits} credits · {price}', + creditBuyHelp: 'Purchase: `!buy {num}` or `!kaufen {num}`', + creditPaymentLink: 'Click here to pay:', + creditLinkValid: 'Link valid for 24 hours.', + creditPaymentSuccess: '✓ Payment successful! **{credits}** credits have been added to your account.', + creditNewBalance: 'New balance: **{balance}** credits', + creditPackageNotFound: 'Package not found. Use `!packages` to view available packages.', + creditPurchaseError: 'Error creating payment link. Please try again later.', + creditNoPackages: 'No credit packages available.', + synced: 'Synced', + localStorage: 'Local storage', + status: 'Status', + online: 'Online', + offline: 'Offline', + loggedInAs: 'Logged in as: {email}', + notLoggedInStatus: 'Not logged in', + languageChanged: 'Language changed to: **{language}**', + currentLanguage: 'Current language: **{language}**', + availableLanguages: 'Available languages: {languages}', + today: 'Today', + tomorrow: 'Tomorrow', + dayAfterTomorrow: 'Day after tomorrow', + created: 'Created', + deleted: 'Deleted', + updated: 'Updated', + completed: 'Completed', + + // Gift creation + giftCreated: '🎁 **Gift created!**', + giftCreatedCode: 'Code: `{code}`', + giftCreatedCredits: 'Credits: {credits}', + giftCreatedLink: 'Link: {url}', + giftCreatedSplit: 'Credits: {credits} × {portions}', + giftInvalidCredits: 'Please enter a valid credit amount (1-10000).', + giftInvalidSyntax: 'Invalid syntax. Example: `!gift 50` or `!gift 100 /5`', + giftInsufficientCredits: 'Insufficient credits. Available: {available}', + + // Gift redemption + giftRedeemed: '🎁 **Gift redeemed!**', + giftRedeemedCredits: '+{credits} credits', + giftRedeemedMessage: '"{message}"', + giftInvalidCode: 'Gift code not found.', + giftExpired: 'This gift code has expired.', + giftDepleted: 'This gift code has been fully claimed.', + giftAlreadyClaimed: 'You have already claimed this gift.', + giftWrongUser: 'This gift code is for a specific person.', + giftWrongAnswer: 'Wrong answer. Try again.', + giftRiddleRequired: 'Please provide the answer to the riddle.', + giftRiddleQuestion: '❓ {question}', + + // Gift list + giftListTitle: '🎁 **Your gifts:**', + giftListEmpty: 'No active gifts.', + giftListItem: '{num}. `{code}` {status} {credits} Cr · {claimed}/{total}', + giftReceivedListTitle: '🎁 **Received gifts:**', + giftReceivedListEmpty: 'No received gifts.', + + // Gift info + giftInfoTitle: '🎁 **Gift info:**', + giftInfoCredits: 'Credits: {credits}', + giftInfoAvailable: 'Available: {remaining}/{total}', + giftInfoFrom: 'From: {name}', + + // Gift help + giftHelpTitle: 'Gifts - Help', + giftHelpCommands: `**Commands:** +• \`!gift [credits]\` - Create gift code +• \`!gift [credits] /[count]\` - Split gift +• \`!gift [credits] @email\` - Personalized +• \`!gift [credits] ?="answer"\` - With riddle +• \`!redeem [code]\` - Redeem code +• \`!my-gifts\` - Show your gifts`, + giftHelpSyntax: `**Syntax:** +\`!gift 50\` - Simple gift +\`!gift 100 /5\` - 5 portions of 20 Cr +\`!gift 50 "Happy birthday!"\` - With message`, + giftHelpExamples: `**Examples:** +• \`!gift 50\` +• \`!gift 100 /5 Share this!\` +• \`!redeem ABC123\``, + + // Gift cancellation + giftCancelled: 'Gift cancelled.', + giftRefunded: '{credits} credits refunded.', + }, }; diff --git a/packages/bot-services/src/i18n/types.ts b/packages/bot-services/src/i18n/types.ts index 18775f94d..9da430173 100644 --- a/packages/bot-services/src/i18n/types.ts +++ b/packages/bot-services/src/i18n/types.ts @@ -24,6 +24,19 @@ export interface CommonTranslations { insufficientCredits: string; buyCredits: string; + // Credit purchasing + creditBalance: string; + creditPackagesTitle: string; + creditPackageLine: string; + creditBuyHelp: string; + creditPaymentLink: string; + creditLinkValid: string; + creditPaymentSuccess: string; + creditNewBalance: string; + creditPackageNotFound: string; + creditPurchaseError: string; + creditNoPackages: string; + // Sync synced: string; localStorage: string; @@ -216,6 +229,57 @@ export interface ClockTranslations extends CommonTranslations { couldNotParseTime: string; } +/** + * Gift translations + */ +export interface GiftTranslations extends CommonTranslations { + // Gift creation + giftCreated: string; + giftCreatedCode: string; + giftCreatedCredits: string; + giftCreatedLink: string; + giftCreatedSplit: string; + giftInvalidCredits: string; + giftInvalidSyntax: string; + giftInsufficientCredits: string; + + // Gift redemption + giftRedeemed: string; + giftRedeemedCredits: string; + giftRedeemedMessage: string; + giftInvalidCode: string; + giftExpired: string; + giftDepleted: string; + giftAlreadyClaimed: string; + giftWrongUser: string; + giftWrongAnswer: string; + giftRiddleRequired: string; + giftRiddleQuestion: string; + + // Gift list + giftListTitle: string; + giftListEmpty: string; + giftListItem: string; + giftReceivedListTitle: string; + giftReceivedListEmpty: string; + + // Gift info + giftInfoTitle: string; + giftInfoCredits: string; + giftInfoAvailable: string; + giftInfoFrom: string; + + // Gift help + giftHelpTitle: string; + giftHelpCommands: string; + giftHelpSyntax: string; + giftHelpExamples: string; + + // Gift cancellation + giftCancelled: string; + giftRefunded: string; +} + /** * All bot translations combined */ @@ -225,6 +289,7 @@ export interface BotTranslations { calendar: CalendarTranslations; contacts: ContactsTranslations; clock: ClockTranslations; + gift: GiftTranslations; } /** diff --git a/packages/bot-services/src/index.ts b/packages/bot-services/src/index.ts index deaac67c8..074af65e7 100644 --- a/packages/bot-services/src/index.ts +++ b/packages/bot-services/src/index.ts @@ -134,8 +134,27 @@ export type { CreditConsumeResult, CreditModuleOptions, CreditStatusMessage, + CreditPackage, + PaymentLinkResult, + PurchaseStatus, + PurchaseStatusResult, } from './credit/index.js'; +// Gift (Gift code management for Matrix bots) +export { GiftModule, GiftService, GIFT_MODULE_OPTIONS } from './gift/index.js'; +export type { + GiftCodeType, + GiftCodeStatus, + CreateGiftOptions, + CreateGiftResult, + GiftCodeInfo, + RedeemGiftResult, + CreatedGiftItem, + ReceivedGiftItem, + GiftModuleOptions, + GiftStatusMessage, +} from './gift/index.js'; + // I18n (Multi-language support for Matrix bots) export { I18nModule, I18nService, I18N_OPTIONS, LANGUAGE_NAMES } from './i18n/index.js'; export type { @@ -147,6 +166,7 @@ export type { CalendarTranslations, ContactsTranslations, ClockTranslations, + GiftTranslations, } from './i18n/index.js'; export { de as deTranslations, en as enTranslations } from './i18n/index.js'; diff --git a/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts b/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts new file mode 100644 index 000000000..6b69e5bab --- /dev/null +++ b/packages/matrix-bot-common/src/credit/credit-commands.mixin.ts @@ -0,0 +1,304 @@ +import { CreditService, I18nService, SessionService, type CreditPackage } from '@manacore/bot-services'; +import { type MatrixRoomEvent } from '../base/types'; + +/** + * Commands that the credit mixin handles + */ +export const CREDIT_COMMANDS = [ + 'credits', + 'guthaben', + 'packages', + 'pakete', + 'buy', + 'kaufen', +] as const; + +export type CreditCommand = (typeof CREDIT_COMMANDS)[number]; + +/** + * Check if a command is a credit command + */ +export function isCreditCommand(command: string): command is CreditCommand { + return CREDIT_COMMANDS.includes(command.toLowerCase() as CreditCommand); +} + +/** + * Interface for classes that can use the credit commands mixin + * + * Bots implementing this interface should expose their protected + * sendMessage/sendReply methods via these wrapper methods. + */ +export interface CreditCommandsHost { + creditService: CreditService; + i18nService: I18nService; + sessionService: SessionService; + + /** + * Send a message to a room (for credit notifications) + */ + sendCreditMessage(roomId: string, message: string): Promise; + + /** + * Send a reply to an event (for credit commands) + */ + sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise; +} + +/** + * Credit commands mixin for Matrix bots + * + * Provides handlers for credit-related commands: + * - !credits / !guthaben - Show credit balance + * - !packages / !pakete - Show available packages + * - !buy N / !kaufen N - Purchase a package + * + * @example + * ```typescript + * // In your MatrixService class: + * + * @Injectable() + * export class MatrixService extends BaseMatrixService implements CreditCommandsHost { + * public creditService: CreditService; + * public i18nService: I18nService; + * public sessionService: SessionService; + * + * constructor( + * configService: ConfigService, + * creditService: CreditService, + * i18nService: I18nService, + * sessionService: SessionService, + * ) { + * super(configService); + * this.creditService = creditService; + * this.i18nService = i18nService; + * this.sessionService = sessionService; + * } + * + * async executeCommand(roomId, event, userId, command, args) { + * // Handle credit commands first + * if (await handleCreditCommand(this, roomId, event, userId, command, args)) { + * return; + * } + * // Then handle bot-specific commands + * // ... + * } + * } + * ``` + */ + +/** + * Handle a credit command if applicable + * @returns true if the command was handled, false otherwise + */ +export async function handleCreditCommand( + host: CreditCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string, + command: string, + args: string +): Promise { + const cmd = command.toLowerCase(); + + switch (cmd) { + case 'credits': + case 'guthaben': + await handleCreditsCommand(host, roomId, event, userId); + return true; + + case 'packages': + case 'pakete': + await handlePackagesCommand(host, roomId, event, userId); + return true; + + case 'buy': + case 'kaufen': + await handleBuyCommand(host, roomId, event, userId, args); + return true; + + default: + return false; + } +} + +/** + * Handle !credits / !guthaben command - show balance + */ +async function handleCreditsCommand( + host: CreditCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string +): Promise { + const token = await host.sessionService.getToken(userId); + const t = await host.i18nService.getTodoTranslator(userId); + + if (!token) { + await sendReply(host, roomId, event, t('loginRequired')); + return; + } + + const balance = await host.creditService.getBalance(token); + const message = t('creditBalance', { balance: balance.balance.toFixed(2) }); + + await sendReply(host, roomId, event, message); +} + +/** + * Handle !packages / !pakete command - show available packages + */ +async function handlePackagesCommand( + host: CreditCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string +): Promise { + const t = await host.i18nService.getTodoTranslator(userId); + + // Packages are public, no token needed + const packages = await host.creditService.getPackages(); + + if (packages.length === 0) { + await sendReply(host, roomId, event, t('creditNoPackages')); + return; + } + + // Store packages for reference in buy command + packageCache.set(userId, packages); + + const lines: string[] = [t('creditPackagesTitle'), '']; + + packages.forEach((pkg, index) => { + lines.push( + t('creditPackageLine', { + num: String(index + 1), + name: pkg.name, + credits: String(pkg.credits), + price: pkg.formattedPrice, + }) + ); + }); + + lines.push(''); + lines.push(t('creditBuyHelp', { num: '1' })); + + await sendReply(host, roomId, event, lines.join('\n')); +} + +/** + * Handle !buy N / !kaufen N command - purchase a package + */ +async function handleBuyCommand( + host: CreditCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string, + args: string +): Promise { + const token = await host.sessionService.getToken(userId); + const t = await host.i18nService.getTodoTranslator(userId); + + if (!token) { + await sendReply(host, roomId, event, t('loginRequired')); + return; + } + + // Parse package number + const packageNumber = parseInt(args.trim(), 10); + if (isNaN(packageNumber) || packageNumber < 1) { + await sendReply(host, roomId, event, t('creditPackageNotFound')); + return; + } + + // Get cached packages or fetch new ones + let packages = packageCache.get(userId); + if (!packages) { + packages = await host.creditService.getPackages(); + packageCache.set(userId, packages); + } + + // Get selected package + const selectedPackage = packages[packageNumber - 1]; + if (!selectedPackage) { + await sendReply(host, roomId, event, t('creditPackageNotFound')); + return; + } + + // Create payment link + const result = await host.creditService.createPaymentLink(token, selectedPackage.id, roomId); + + if (!result) { + await sendReply(host, roomId, event, t('creditPurchaseError')); + return; + } + + // Format success message + const lines = [ + `**${selectedPackage.name}** (${selectedPackage.credits} Credits)`, + '', + t('creditPaymentLink'), + result.url, + '', + t('creditLinkValid'), + ]; + + await sendReply(host, roomId, event, lines.join('\n')); +} + +/** + * Send a payment success notification to a room + * Called after webhook confirms payment + */ +export async function sendPaymentSuccessNotification( + host: CreditCommandsHost, + roomId: string, + userId: string, + credits: number, + newBalance: number +): Promise { + const t = await host.i18nService.getTodoTranslator(userId); + + const lines = [ + t('creditPaymentSuccess', { credits: String(credits) }), + t('creditNewBalance', { balance: newBalance.toFixed(2) }), + ]; + + await sendMessage(host, roomId, lines.join('\n')); +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** + * Simple cache for packages (per-user) + * Cleared after 5 minutes + */ +const packageCache = new Map(); + +// Clear cache entries after 5 minutes +setInterval( + () => { + packageCache.clear(); + }, + 5 * 60 * 1000 +); + +/** + * Send a message to a room + */ +async function sendMessage(host: CreditCommandsHost, roomId: string, message: string): Promise { + await host.sendCreditMessage(roomId, message); +} + +/** + * Send a reply to an event + */ +async function sendReply( + host: CreditCommandsHost, + roomId: string, + event: MatrixRoomEvent, + message: string +): Promise { + await host.sendCreditReply(roomId, event, message); +} diff --git a/packages/matrix-bot-common/src/credit/index.ts b/packages/matrix-bot-common/src/credit/index.ts new file mode 100644 index 000000000..8dab6d8f0 --- /dev/null +++ b/packages/matrix-bot-common/src/credit/index.ts @@ -0,0 +1,8 @@ +export { + handleCreditCommand, + sendPaymentSuccessNotification, + isCreditCommand, + CREDIT_COMMANDS, + type CreditCommand, + type CreditCommandsHost, +} from './credit-commands.mixin.js'; diff --git a/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts b/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts new file mode 100644 index 000000000..6451e85f4 --- /dev/null +++ b/packages/matrix-bot-common/src/gift/gift-commands.mixin.ts @@ -0,0 +1,431 @@ +import { + GiftService, + I18nService, + SessionService, + type CreateGiftOptions, + type GiftCodeType, +} from '@manacore/bot-services'; +import { type MatrixRoomEvent } from '../base/types'; + +/** + * Commands that the gift mixin handles + */ +export const GIFT_COMMANDS = [ + 'geschenk', + 'gift', + 'einloesen', + 'redeem', + 'meine-geschenke', + 'my-gifts', +] as const; + +export type GiftCommand = (typeof GIFT_COMMANDS)[number]; + +/** + * Check if a command is a gift command + */ +export function isGiftCommand(command: string): command is GiftCommand { + return GIFT_COMMANDS.includes(command.toLowerCase() as GiftCommand); +} + +/** + * Interface for classes that can use the gift commands mixin + */ +export interface GiftCommandsHost { + giftService: GiftService; + i18nService: I18nService; + sessionService: SessionService; + + /** + * Send a message to a room (for gift notifications) + */ + sendGiftMessage(roomId: string, message: string): Promise; + + /** + * Send a reply to an event (for gift commands) + */ + sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise; +} + +/** + * Parsed gift command input + */ +interface ParsedGiftInput { + credits: number; + type: GiftCodeType; + portions?: number; + targetEmail?: string; + targetMatrixId?: string; + riddleQuestion?: string; + riddleAnswer?: string; + message?: string; + expiresAt?: string; +} + +/** + * Parse gift command syntax + * + * Syntax examples: + * - `!geschenk 50` - Simple, 50 credits + * - `!geschenk 100 /5` - Split: 5 portions of 20 credits + * - `!geschenk 50 x3` - First come: first 3 get 50 each + * - `!geschenk 50 @user@email.com` - Personalized + * - `!geschenk 50 @morgen` - Expires tomorrow + * - `!geschenk 50 ?="answer"` - With riddle + * - `!geschenk 50 "message"` - With message + */ +function parseGiftInput(input: string): ParsedGiftInput | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + // Extract credits (first number) + const creditsMatch = trimmed.match(/^(\d+)/); + if (!creditsMatch) return null; + + const credits = parseInt(creditsMatch[1], 10); + if (isNaN(credits) || credits < 1 || credits > 10000) return null; + + const result: ParsedGiftInput = { + credits, + type: 'simple', + }; + + const rest = trimmed.substring(creditsMatch[0].length).trim(); + + // Check for split: /N + const splitMatch = rest.match(/\/(\d+)/); + if (splitMatch) { + result.type = 'split'; + result.portions = parseInt(splitMatch[1], 10); + } + + // Check for first come: xN + const firstComeMatch = rest.match(/x(\d+)/i); + if (firstComeMatch) { + result.type = 'first_come'; + result.portions = parseInt(firstComeMatch[1], 10); + } + + // Check for personalized: @email + const emailMatch = rest.match(/@([\w.+-]+@[\w.-]+\.\w+)/); + if (emailMatch) { + result.type = 'personalized'; + result.targetEmail = emailMatch[1]; + } + + // Check for Matrix ID: @user:server + const matrixMatch = rest.match(/@(@[\w.-]+:[.\w-]+)/); + if (matrixMatch && !emailMatch) { + result.type = 'personalized'; + result.targetMatrixId = matrixMatch[1]; + } + + // Check for riddle: ?="answer" + const riddleMatch = rest.match(/\?="([^"]+)"/); + if (riddleMatch) { + result.type = 'riddle'; + result.riddleAnswer = riddleMatch[1]; + // Riddle question will be the remaining text before the riddle + const riddleQuestionMatch = rest.match(/\?([^=]+)="[^"]+"/); + if (riddleQuestionMatch) { + result.riddleQuestion = riddleQuestionMatch[1].trim(); + } + } + + // Check for expiration: @morgen, @tomorrow + const dateKeywords: Record = { + morgen: 1, + tomorrow: 1, + übermorgen: 2, + 'day after tomorrow': 2, + }; + for (const [keyword, days] of Object.entries(dateKeywords)) { + if (rest.toLowerCase().includes(`@${keyword}`)) { + const date = new Date(); + date.setDate(date.getDate() + days); + date.setHours(23, 59, 59, 999); + result.expiresAt = date.toISOString(); + break; + } + } + + // Check for message: "message" + const messageMatch = rest.match(/"([^"]+)"/); + if (messageMatch && !riddleMatch) { + result.message = messageMatch[1]; + } + + return result; +} + +/** + * Handle a gift command if applicable + * @returns true if the command was handled, false otherwise + */ +export async function handleGiftCommand( + host: GiftCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string, + command: string, + args: string +): Promise { + const cmd = command.toLowerCase(); + + switch (cmd) { + case 'geschenk': + case 'gift': + await handleCreateGift(host, roomId, event, userId, args); + return true; + + case 'einloesen': + case 'redeem': + await handleRedeemGift(host, roomId, event, userId, args); + return true; + + case 'meine-geschenke': + case 'my-gifts': + await handleListGifts(host, roomId, event, userId); + return true; + + default: + return false; + } +} + +/** + * Handle !geschenk / !gift command - create a gift + */ +async function handleCreateGift( + host: GiftCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string, + args: string +): Promise { + const token = await host.sessionService.getToken(userId); + const t = await host.i18nService.getGiftTranslator(userId); + + if (!token) { + await sendReply(host, roomId, event, t('loginRequired')); + return; + } + + // Parse input + const parsed = parseGiftInput(args); + if (!parsed) { + await sendReply(host, roomId, event, t('giftInvalidSyntax')); + return; + } + + // Build options + const options: CreateGiftOptions = { + type: parsed.type, + portions: parsed.portions, + targetEmail: parsed.targetEmail, + targetMatrixId: parsed.targetMatrixId, + riddleQuestion: parsed.riddleQuestion, + riddleAnswer: parsed.riddleAnswer, + message: parsed.message, + expiresAt: parsed.expiresAt, + }; + + // Create gift + const result = await host.giftService.createGift(token, parsed.credits, options); + + if (!result) { + await sendReply(host, roomId, event, t('giftInsufficientCredits', { available: '?' })); + return; + } + + // Format response + const lines: string[] = [t('giftCreated'), '']; + + lines.push(t('giftCreatedCode', { code: result.code })); + + if (result.totalPortions > 1) { + lines.push( + t('giftCreatedSplit', { + credits: String(result.creditsPerPortion), + portions: String(result.totalPortions), + }) + ); + } else { + lines.push(t('giftCreatedCredits', { credits: String(result.totalCredits) })); + } + + lines.push(''); + lines.push(t('giftCreatedLink', { url: result.url })); + + await sendReply(host, roomId, event, lines.join('\n')); +} + +/** + * Handle !einloesen / !redeem command - redeem a gift + */ +async function handleRedeemGift( + host: GiftCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string, + args: string +): Promise { + const token = await host.sessionService.getToken(userId); + const t = await host.i18nService.getGiftTranslator(userId); + + if (!token) { + await sendReply(host, roomId, event, t('loginRequired')); + return; + } + + // Parse args: CODE [answer] + const parts = args.trim().split(/\s+/); + const code = parts[0]?.toUpperCase(); + const answer = parts.slice(1).join(' '); + + if (!code) { + await sendReply(host, roomId, event, t('giftInvalidSyntax')); + return; + } + + // Check if it looks like a gift code + if (code.length !== 6 || !/^[A-Z0-9]+$/.test(code)) { + // Try to extract code from URL + const urlMatch = args.match(/\/g\/([A-Z0-9]{6})/i); + if (urlMatch) { + // Recurse with extracted code + await handleRedeemGift(host, roomId, event, userId, urlMatch[1]); + return; + } + + await sendReply(host, roomId, event, t('giftInvalidCode')); + return; + } + + // First, get gift info to check if riddle required + if (!answer) { + const info = await host.giftService.getGiftInfo(code); + if (info?.hasRiddle && info.riddleQuestion) { + // Show riddle question + const lines: string[] = [ + t('giftInfoTitle'), + t('giftRiddleQuestion', { question: info.riddleQuestion }), + '', + `\`!einloesen ${code} [antwort]\``, + ]; + await sendReply(host, roomId, event, lines.join('\n')); + return; + } + } + + // Redeem gift + // Get Matrix user ID from event sender + const matrixUserId = event.sender; + + const result = await host.giftService.redeemGift(token, code, answer || undefined, matrixUserId); + + if (!result.success) { + // Map error to translation + const errorMessages: Record = { + 'Gift code not found': t('giftInvalidCode'), + 'This gift code has expired': t('giftExpired'), + 'This gift code has been fully claimed': t('giftDepleted'), + 'You have already claimed this gift': t('giftAlreadyClaimed'), + 'This gift code is for a specific person': t('giftWrongUser'), + 'Incorrect answer': t('giftWrongAnswer'), + 'Please provide the answer to the riddle': t('giftRiddleRequired'), + }; + + const errorMsg = errorMessages[result.error || ''] || result.error || t('errorOccurred'); + await sendReply(host, roomId, event, errorMsg); + return; + } + + // Format success response + const lines: string[] = [t('giftRedeemed')]; + lines.push(t('giftRedeemedCredits', { credits: String(result.credits) })); + + if (result.message) { + lines.push(''); + lines.push(t('giftRedeemedMessage', { message: result.message })); + } + + if (result.newBalance !== undefined) { + lines.push(''); + lines.push(t('creditNewBalance', { balance: result.newBalance.toFixed(2) })); + } + + await sendReply(host, roomId, event, lines.join('\n')); +} + +/** + * Handle !meine-geschenke / !my-gifts command - list user's gifts + */ +async function handleListGifts( + host: GiftCommandsHost, + roomId: string, + event: MatrixRoomEvent, + userId: string +): Promise { + const token = await host.sessionService.getToken(userId); + const t = await host.i18nService.getGiftTranslator(userId); + + if (!token) { + await sendReply(host, roomId, event, t('loginRequired')); + return; + } + + const gifts = await host.giftService.listCreatedGifts(token); + + const lines: string[] = [t('giftListTitle'), '']; + + if (gifts.length === 0) { + lines.push(t('giftListEmpty')); + } else { + // Show only active or recently depleted + const relevantGifts = gifts.filter( + (g) => g.status === 'active' || g.status === 'depleted' + ); + + relevantGifts.forEach((gift, index) => { + const statusIcon = + gift.status === 'active' ? '✅' : gift.status === 'depleted' ? '✓' : '❌'; + + lines.push( + t('giftListItem', { + num: String(index + 1), + code: gift.code, + status: statusIcon, + credits: String(gift.creditsPerPortion), + claimed: String(gift.claimedPortions), + total: String(gift.totalPortions), + }) + ); + }); + } + + await sendReply(host, roomId, event, lines.join('\n')); +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +/** + * Send a message to a room + */ +async function sendMessage(host: GiftCommandsHost, roomId: string, message: string): Promise { + await host.sendGiftMessage(roomId, message); +} + +/** + * Send a reply to an event + */ +async function sendReply( + host: GiftCommandsHost, + roomId: string, + event: MatrixRoomEvent, + message: string +): Promise { + await host.sendGiftReply(roomId, event, message); +} diff --git a/packages/matrix-bot-common/src/gift/index.ts b/packages/matrix-bot-common/src/gift/index.ts new file mode 100644 index 000000000..ba1718734 --- /dev/null +++ b/packages/matrix-bot-common/src/gift/index.ts @@ -0,0 +1,7 @@ +export { + handleGiftCommand, + isGiftCommand, + GIFT_COMMANDS, + type GiftCommand, + type GiftCommandsHost, +} from './gift-commands.mixin.js'; diff --git a/packages/matrix-bot-common/src/index.ts b/packages/matrix-bot-common/src/index.ts index 8b2702657..90d4fe78c 100644 --- a/packages/matrix-bot-common/src/index.ts +++ b/packages/matrix-bot-common/src/index.ts @@ -62,3 +62,22 @@ export { SessionHelper, createSessionHelper } from './session/index.js'; // List Mapper export { UserListMapper, UserIdListMapper } from './list-mapper/index.js'; + +// Credit Commands +export { + handleCreditCommand, + sendPaymentSuccessNotification, + isCreditCommand, + CREDIT_COMMANDS, + type CreditCommand, + type CreditCommandsHost, +} from './credit/index.js'; + +// Gift Commands +export { + handleGiftCommand, + isGiftCommand, + GIFT_COMMANDS, + type GiftCommand, + type GiftCommandsHost, +} from './gift/index.js';