mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
✨ feat(bot-services): add credit and gift services for Matrix bots
- 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
This commit is contained in:
parent
92c6dc83ee
commit
962b942e2a
17 changed files with 2057 additions and 0 deletions
|
|
@ -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<CreditPackage[]> {
|
||||
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<PaymentLinkResult | null> {
|
||||
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<PurchaseStatusResult | null> {
|
||||
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('.', ',')} €`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,9 @@ export type {
|
|||
CreditConsumeResult,
|
||||
CreditModuleOptions,
|
||||
CreditStatusMessage,
|
||||
CreditPackage,
|
||||
PaymentLinkResult,
|
||||
PurchaseStatus,
|
||||
PurchaseStatusResult,
|
||||
} from './types';
|
||||
export { CREDIT_MODULE_OPTIONS, CreditErrorCode } from './types';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
52
packages/bot-services/src/gift/gift.module.ts
Normal file
52
packages/bot-services/src/gift/gift.module.ts
Normal file
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
405
packages/bot-services/src/gift/gift.service.ts
Normal file
405
packages/bot-services/src/gift/gift.service.ts
Normal file
|
|
@ -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<string>('auth.url') ||
|
||||
this.configService?.get<string>('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<CreateGiftResult | null> {
|
||||
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<GiftCodeInfo | null> {
|
||||
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<RedeemGiftResult> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
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<CreatedGiftItem[]> {
|
||||
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<ReceivedGiftItem[]> {
|
||||
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('🎁 <b>Geschenk erstellt!</b>');
|
||||
|
||||
lines.push('');
|
||||
htmlLines.push('<br>');
|
||||
|
||||
lines.push(`Code: \`${gift.code}\``);
|
||||
htmlLines.push(`Code: <code>${gift.code}</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('<br>');
|
||||
|
||||
lines.push(`Link: ${gift.url}`);
|
||||
htmlLines.push(`Link: <a href="${gift.url}">${gift.url}</a>`);
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('🎁 <b>Geschenk eingelöst!</b>');
|
||||
|
||||
lines.push(`+${credits} Credits`);
|
||||
htmlLines.push(`+${credits} Credits`);
|
||||
|
||||
if (message) {
|
||||
lines.push('');
|
||||
lines.push(`"${message}"`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`<i>"${message}"</i>`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`Neuer Kontostand: ${newBalance.toFixed(2)} Credits`);
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format gift list message
|
||||
*/
|
||||
formatGiftListMessage(gifts: CreatedGiftItem[]): GiftStatusMessage {
|
||||
const lines: string[] = [];
|
||||
const htmlLines: string[] = [];
|
||||
|
||||
lines.push('🎁 **Deine Geschenke:**');
|
||||
htmlLines.push('🎁 <b>Deine Geschenke:</b>');
|
||||
|
||||
lines.push('');
|
||||
htmlLines.push('<br>');
|
||||
|
||||
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}. <code>${gift.code}</code> ${statusIcon} ${gift.creditsPerPortion} Cr · ${claimed}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('🎁 <b>Geschenk-Info:</b>');
|
||||
|
||||
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('<br>');
|
||||
htmlLines.push(`<i>"${info.message}"</i>`);
|
||||
}
|
||||
|
||||
if (info.hasRiddle) {
|
||||
lines.push('');
|
||||
lines.push(`❓ ${info.riddleQuestion}`);
|
||||
lines.push('Antworte mit: `!einloesen CODE antwort`');
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`❓ ${info.riddleQuestion}`);
|
||||
htmlLines.push('Antworte mit: <code>!einloesen CODE antwort</code>');
|
||||
}
|
||||
|
||||
if (info.creatorName) {
|
||||
lines.push('');
|
||||
lines.push(`Von: ${info.creatorName}`);
|
||||
htmlLines.push('<br>');
|
||||
htmlLines.push(`Von: ${info.creatorName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
text: lines.join('\n'),
|
||||
html: htmlLines.join('<br>'),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
packages/bot-services/src/gift/index.ts
Normal file
15
packages/bot-services/src/gift/index.ts
Normal file
|
|
@ -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';
|
||||
173
packages/bot-services/src/gift/types.ts
Normal file
173
packages/bot-services/src/gift/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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, string | number>) => 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<GiftTranslations> {
|
||||
const lang = await this.getLanguage(userId);
|
||||
return translations[lang].gift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate placeholders in a string
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
304
packages/matrix-bot-common/src/credit/credit-commands.mixin.ts
Normal file
304
packages/matrix-bot-common/src/credit/credit-commands.mixin.ts
Normal file
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Send a reply to an event (for credit commands)
|
||||
*/
|
||||
sendCreditReply(roomId: string, event: MatrixRoomEvent, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, CreditPackage[]>();
|
||||
|
||||
// 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<void> {
|
||||
await host.sendCreditMessage(roomId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply to an event
|
||||
*/
|
||||
async function sendReply(
|
||||
host: CreditCommandsHost,
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await host.sendCreditReply(roomId, event, message);
|
||||
}
|
||||
8
packages/matrix-bot-common/src/credit/index.ts
Normal file
8
packages/matrix-bot-common/src/credit/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
handleCreditCommand,
|
||||
sendPaymentSuccessNotification,
|
||||
isCreditCommand,
|
||||
CREDIT_COMMANDS,
|
||||
type CreditCommand,
|
||||
type CreditCommandsHost,
|
||||
} from './credit-commands.mixin.js';
|
||||
431
packages/matrix-bot-common/src/gift/gift-commands.mixin.ts
Normal file
431
packages/matrix-bot-common/src/gift/gift-commands.mixin.ts
Normal file
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Send a reply to an event (for gift commands)
|
||||
*/
|
||||
sendGiftReply(roomId: string, event: MatrixRoomEvent, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number> = {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
'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<void> {
|
||||
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<void> {
|
||||
await host.sendGiftMessage(roomId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply to an event
|
||||
*/
|
||||
async function sendReply(
|
||||
host: GiftCommandsHost,
|
||||
roomId: string,
|
||||
event: MatrixRoomEvent,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await host.sendGiftReply(roomId, event, message);
|
||||
}
|
||||
7
packages/matrix-bot-common/src/gift/index.ts
Normal file
7
packages/matrix-bot-common/src/gift/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
handleGiftCommand,
|
||||
isGiftCommand,
|
||||
GIFT_COMMANDS,
|
||||
type GiftCommand,
|
||||
type GiftCommandsHost,
|
||||
} from './gift-commands.mixin.js';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue