diff --git a/.gitignore b/.gitignore index c9100f76..098e29d3 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,5 @@ Thumbs.db .cache/ .pnpm-store + +dump.rdb \ No newline at end of file diff --git a/packages/api/memory.db b/packages/api/memory.db index 4c1affdc..1742b31e 100644 Binary files a/packages/api/memory.db and b/packages/api/memory.db differ diff --git a/packages/platform-telegram/package.json b/packages/platform-telegram/package.json index cda104dc..ed4c0eeb 100644 --- a/packages/platform-telegram/package.json +++ b/packages/platform-telegram/package.json @@ -22,7 +22,8 @@ "@memohome/platform": "workspace:*", "dotenv": "^16.4.7", "ioredis": "^5.9.1", - "telegraf": "^4.16.3" + "telegraf": "^4.16.3", + "zod": "^4.3.5" }, "devDependencies": { "@types/node": "^22.10.5" diff --git a/packages/platform-telegram/src/auth.ts b/packages/platform-telegram/src/auth.ts index c76ee33f..c40bc08d 100644 --- a/packages/platform-telegram/src/auth.ts +++ b/packages/platform-telegram/src/auth.ts @@ -8,17 +8,12 @@ import { getTokenStorage } from './storage' * Usage: /login username password */ export async function handleLogin(ctx: Context) { - const telegramUserId = ctx.from?.id.toString() - if (!telegramUserId) { - await ctx.reply('โŒ Unable to identify user') - return - } // Parse command arguments - const args = ctx.message && 'text' in ctx.message - ? ctx.message.text.split(' ').slice(1) + const args = ctx.message && 'text' in ctx.message + ? ctx.message.text.split(' ').slice(1) : [] - + if (args.length !== 2) { await ctx.reply( 'โŒ Invalid format\n\n' + @@ -30,23 +25,24 @@ export async function handleLogin(ctx: Context) { const [username, password] = args - const storage = await getTokenStorage(telegramUserId) + const storage = await getTokenStorage(ctx) - // Attempt login - const result = await login({ username, password }, { storage }) + // Attempt login + const result = await login({ username, password }, { storage }) - if (result.success && result.user) { + if (result.success && result.user) { + storage.setUserId(result.user.id) - await ctx.reply( - 'โœ… Login successful!\n\n' + - `๐Ÿ‘ค Username: ${result.user.username}\n` + - `๐ŸŽญ Role: ${result.user.role}\n` + - `๐Ÿ”‘ User ID: ${result.user.id}\n\n` + - 'You can now use the bot to interact with MemoHome.' - ) - } else { - await ctx.reply('โŒ Login failed: Invalid response from server') - } + await ctx.reply( + 'โœ… Login successful!\n\n' + + `๐Ÿ‘ค Username: ${result.user.username}\n` + + `๐ŸŽญ Role: ${result.user.role}\n` + + `๐Ÿ”‘ User ID: ${result.user.id}\n\n` + + 'You can now use the bot to interact with MemoHome.' + ) + } else { + await ctx.reply('โŒ Login failed: Invalid response from server') + } } /** @@ -54,14 +50,8 @@ export async function handleLogin(ctx: Context) { * Usage: /logout */ export async function handleLogout(ctx: Context) { - const telegramUserId = ctx.from?.id.toString() - if (!telegramUserId) { - await ctx.reply('โŒ Unable to identify user') - return - } - try { - const storage = await getTokenStorage(telegramUserId) + const storage = await getTokenStorage(ctx) await logout({ storage }) await ctx.reply('โœ… Logged out successfully') @@ -76,17 +66,11 @@ export async function handleLogout(ctx: Context) { * Usage: /whoami */ export async function handleWhoami(ctx: Context) { - const telegramUserId = ctx.from?.id.toString() - if (!telegramUserId) { - await ctx.reply('โŒ Unable to identify user') - return - } - try { - const storage = await getTokenStorage(telegramUserId) + const storage = await getTokenStorage(ctx) const isLogged = await isLoggedIn({ storage }) - + if (!isLogged) { await ctx.reply( 'โŒ You are not logged in\n\n' + @@ -102,7 +86,7 @@ export async function handleWhoami(ctx: Context) { `Username: ${user.username}\n` + `Role: ${user.role}\n` + `User ID: ${user.id}\n` + - `Telegram ID: ${telegramUserId}` + `Telegram ID: ${storage.getChatId()}` ) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' @@ -116,16 +100,10 @@ export async function handleWhoami(ctx: Context) { */ export function requireAuth() { return async (ctx: Context, next: () => Promise) => { - const telegramUserId = ctx.from?.id.toString() - if (!telegramUserId) { - await ctx.reply('โŒ Unable to identify user') - return - } - - const storage = await getTokenStorage(telegramUserId) + const storage = await getTokenStorage(ctx) const isLogged = await isLoggedIn({ storage }) - + if (!isLogged) { await ctx.reply( 'โŒ You need to login first\n\n' + diff --git a/packages/platform-telegram/src/bot.ts b/packages/platform-telegram/src/bot.ts index b9aba249..ff0fcdb2 100644 --- a/packages/platform-telegram/src/bot.ts +++ b/packages/platform-telegram/src/bot.ts @@ -25,10 +25,8 @@ async function main() { const platform = new TelegramPlatform() try { - await platform.start({ + platform.serve({ botToken, - redisUrl, - apiUrl, }) console.log('โœ… Bot is running...') diff --git a/packages/platform-telegram/src/index.ts b/packages/platform-telegram/src/index.ts index 3a1a7b46..a30d89e0 100644 --- a/packages/platform-telegram/src/index.ts +++ b/packages/platform-telegram/src/index.ts @@ -1,7 +1,10 @@ import { Telegraf, type Context } from 'telegraf' -import { BasePlatform } from '@memohome/platform' +import { BasePlatform, SendSchema } from '@memohome/platform' import { handleLogin, handleLogout, handleWhoami, requireAuth } from './auth' import { chatStreamAsync, type StreamEvent } from '@memohome/client' +import { getTokenStorage } from './storage' +import z from 'zod' +import Redis from 'ioredis' export interface TelegramPlatformConfig { botToken: string @@ -12,8 +15,12 @@ export interface TelegramPlatformConfig { export class TelegramPlatform extends BasePlatform { name = 'telegram' description = 'Telegram Bot platform for MemoHome' + config = z.object({ + botToken: z.string(), + }) private bot?: Telegraf + redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379') // private storage?: TelegramRedisStorage async start(config: Record): Promise { @@ -51,6 +58,42 @@ export class TelegramPlatform extends BasePlatform { // } } + async send({ userId, message }: z.infer): Promise { + const pattern = 'memohome:telegram:*:userId' + let cursor = '0' + let telegramUserId: string | null = null + + do { + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 100 + ) + cursor = nextCursor + + // ๆฃ€ๆŸฅๆฏไธช key ็š„ๅ€ผๆ˜ฏๅฆๅŒน้…็›ฎๆ ‡ userId + for (const key of keys) { + const storedUserId = await this.redis.get(key) + if (storedUserId === userId) { + // ไปŽ key ไธญๆๅ– telegramUserId: memohome:telegram:{telegramUserId}:userId + const match = key.match(/^memohome:telegram:(.+):userId$/) + if (match) { + telegramUserId = match[1] + break + } + } + } + } while (cursor !== '0') + if (telegramUserId) { + const chatId = await this.redis.get(`memohome:telegram:${telegramUserId}:chatId`) + if (chatId && this.bot) { + await this.bot.telegram.sendMessage(chatId, message) + } + } + } + private registerCommands(): void { if (!this.bot) { throw new Error('Bot or storage not initialized') @@ -123,6 +166,7 @@ export class TelegramPlatform extends BasePlatform { try { // Send typing indicator await ctx.sendChatAction('typing') + await getTokenStorage(ctx) let responseText = '' let lastUpdateTime = Date.now() diff --git a/packages/platform-telegram/src/storage.ts b/packages/platform-telegram/src/storage.ts index aa7bfcc2..8f252faa 100644 --- a/packages/platform-telegram/src/storage.ts +++ b/packages/platform-telegram/src/storage.ts @@ -1,19 +1,86 @@ import type { TokenStorage } from '@memohome/client' import Redis from 'ioredis' +import { Context } from 'telegraf' -export const getTokenStorage = async (telegramUserId: string): Promise => { +export interface TelegramTokenStorage extends TokenStorage { + getUserId: () => string | null + setUserId: (userId: string) => void + getChatId: () => string | null + setChatId: (chatId: string) => void + getTelegramIdByUserId: (userId: string) => Promise +} + +export const getTokenStorage = async (ctx: Context): Promise => { + const telegramUserId = ctx.from?.id.toString() + if (!telegramUserId) { + throw new Error('Unable to identify user') + } const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379') - const isExists = await redis.exists(`memohome:telegram:${telegramUserId}:token`) - const token = isExists ? await redis.get(`memohome:telegram:${telegramUserId}:token`) : null + const isTokenExists = await redis.exists(`memohome:telegram:${telegramUserId}:token`) + const token = isTokenExists ? await redis.get(`memohome:telegram:${telegramUserId}:token`) : null + const isUserIdExists = await redis.exists(`memohome:telegram:${telegramUserId}:userId`) + const userId = isUserIdExists ? await redis.get(`memohome:telegram:${telegramUserId}:userId`) : null + const chatId = ctx.chat?.id.toString() ?? null + if (chatId) await redis.set(`memohome:telegram:${telegramUserId}:chatId`, chatId) return { + getChatId: () => chatId, + setChatId: (chatId: string) => { + redis.set(`memohome:telegram:${telegramUserId}:chatId`, chatId) + .then(() => { + redis.save() + }) + }, getApiUrl: () => process.env.API_URL || 'http://localhost:7002', setApiUrl: () => {}, getToken: () => token, setToken: (token: string) => { redis.set(`memohome:telegram:${telegramUserId}:token`, token) + .then(() => { + redis.save() + }) }, clearToken: () => { redis.del(`memohome:telegram:${telegramUserId}:token`) + .then(() => { + redis.save() + }) + }, + getUserId: () => userId, + setUserId: (userId: string) => { + redis.set(`memohome:telegram:${telegramUserId}:userId`, userId) + .then(() => { + redis.save() + }) + }, + getTelegramIdByUserId: async (userId: string) => { + // ๆ‰ซๆๆ‰€ๆœ‰ memohome:telegram:*:userId ็š„ key + const pattern = 'memohome:telegram:*:userId' + let cursor = '0' + + do { + const [nextCursor, keys] = await redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 100 + ) + cursor = nextCursor + + // ๆฃ€ๆŸฅๆฏไธช key ็š„ๅ€ผๆ˜ฏๅฆๅŒน้…็›ฎๆ ‡ userId + for (const key of keys) { + const storedUserId = await redis.get(key) + if (storedUserId === userId) { + // ไปŽ key ไธญๆๅ– telegramUserId: memohome:telegram:{telegramUserId}:userId + const match = key.match(/^memohome:telegram:(.+):userId$/) + if (match) { + return match[1] + } + } + } + } while (cursor !== '0') + + return null }, } } diff --git a/packages/platform-telegram/test/send.test.ts b/packages/platform-telegram/test/send.test.ts new file mode 100644 index 00000000..cf8490f7 --- /dev/null +++ b/packages/platform-telegram/test/send.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" + +describe('Telegram Platform', () => { + it('should send a message to a user', async () => { + const response = await fetch('http://localhost:7003/send', { + method: 'POST', + body: JSON.stringify({ + userId: '66392e42-333a-4ee9-9276-1b216d3400b1', + message: 'Hello, world!', + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + console.log(await response.json()) + expect(response.status).toBe(200) + }) +}) \ No newline at end of file diff --git a/packages/platform/package.json b/packages/platform/package.json index a739ca63..6092244b 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -14,6 +14,8 @@ "license": "ISC", "packageManager": "pnpm@10.27.0", "dependencies": { - "elysia": "^1.4.21" + "@elysiajs/cors": "^1.4.1", + "elysia": "^1.4.21", + "zod": "^4.3.5" } } diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 5a953ddf..2b38549d 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,17 +1,70 @@ import { Elysia } from 'elysia' +import { cors } from '@elysiajs/cors' +import { z } from 'zod' + +export const SendSchema = z.object({ + message: z.string(), + userId: z.string(), +}) export class BasePlatform { name: string = 'base' description: string = 'Base platform' + started: boolean = false + port: number = 7003 + + config = z.object() // eslint-disable-next-line @typescript-eslint/no-unused-vars - async start(config: Record): Promise {} + async start(config: z.infer): Promise {} async stop(): Promise {} - async send(): Promise {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async send(data: z.infer): Promise {} - // serve(): void { - // const app = new Elysia() - // } + serve>(config?: T): void { + new Elysia() + .use(cors({ + origin: '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + credentials: true, + })) + .onStart(() => { + if (config) { + this.started = true + this.start(config) + } + }) + .post('/start', async ({ body }) => { + if (!this.started) { + this.start(body) + this.started = true + } + return { + success: true, + } + }, { + body: this.config, + }) + .post('/stop', async () => { + if (this.started) { + this.stop() + this.started = false + } + return { + success: true, + } + }) + .post('/send', async ({ body }) => { + await this.send(body) + return { + success: true, + } + }, { + body: SendSchema, + }) + .listen(this.port) + } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb50891..291097e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,9 +237,15 @@ importers: packages/platform: dependencies: + '@elysiajs/cors': + specifier: ^1.4.1 + version: 1.4.1(elysia@1.4.21(@sinclair/typebox@0.34.47)(@types/bun@1.3.5)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)) elysia: specifier: ^1.4.21 version: 1.4.21(@sinclair/typebox@0.34.47)(@types/bun@1.3.5)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3) + zod: + specifier: ^4.3.5 + version: 4.3.5 packages/platform-telegram: dependencies: @@ -249,15 +255,22 @@ importers: '@memohome/platform': specifier: workspace:* version: link:../platform - elysia: - specifier: ^1.4.21 - version: 1.4.21(@sinclair/typebox@0.34.47)(@types/bun@1.3.5)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 ioredis: specifier: ^5.9.1 version: 5.9.1 telegraf: specifier: ^4.16.3 version: 4.16.3(encoding@0.1.13) + zod: + specifier: ^4.3.5 + version: 4.3.5 + devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.19.5 packages/shared: {} @@ -2541,6 +2554,10 @@ packages: digest-fetch@1.3.0: resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -6856,6 +6873,8 @@ snapshots: base-64: 0.1.0 md5: 2.3.0 + dotenv@16.6.1: {} + dotenv@17.2.3: {} drizzle-kit@0.31.8: