feat: auto-send of tg bot

This commit is contained in:
Acbox
2026-01-11 23:33:07 +08:00
parent 0339c3e384
commit 6cd95bcaf7
11 changed files with 245 additions and 63 deletions
+2
View File
@@ -91,3 +91,5 @@ Thumbs.db
.cache/ .cache/
.pnpm-store .pnpm-store
dump.rdb
Binary file not shown.
+2 -1
View File
@@ -22,7 +22,8 @@
"@memohome/platform": "workspace:*", "@memohome/platform": "workspace:*",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ioredis": "^5.9.1", "ioredis": "^5.9.1",
"telegraf": "^4.16.3" "telegraf": "^4.16.3",
"zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5" "@types/node": "^22.10.5"
+24 -46
View File
@@ -8,17 +8,12 @@ import { getTokenStorage } from './storage'
* Usage: /login username password * Usage: /login username password
*/ */
export async function handleLogin(ctx: Context) { 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 // Parse command arguments
const args = ctx.message && 'text' in ctx.message const args = ctx.message && 'text' in ctx.message
? ctx.message.text.split(' ').slice(1) ? ctx.message.text.split(' ').slice(1)
: [] : []
if (args.length !== 2) { if (args.length !== 2) {
await ctx.reply( await ctx.reply(
'❌ Invalid format\n\n' + '❌ Invalid format\n\n' +
@@ -30,23 +25,24 @@ export async function handleLogin(ctx: Context) {
const [username, password] = args const [username, password] = args
const storage = await getTokenStorage(telegramUserId) const storage = await getTokenStorage(ctx)
// Attempt login // Attempt login
const result = await login({ username, password }, { storage }) 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( await ctx.reply(
'✅ Login successful!\n\n' + '✅ Login successful!\n\n' +
`👤 Username: ${result.user.username}\n` + `👤 Username: ${result.user.username}\n` +
`🎭 Role: ${result.user.role}\n` + `🎭 Role: ${result.user.role}\n` +
`🔑 User ID: ${result.user.id}\n\n` + `🔑 User ID: ${result.user.id}\n\n` +
'You can now use the bot to interact with MemoHome.' 'You can now use the bot to interact with MemoHome.'
) )
} else { } else {
await ctx.reply('❌ Login failed: Invalid response from server') await ctx.reply('❌ Login failed: Invalid response from server')
} }
} }
/** /**
@@ -54,14 +50,8 @@ export async function handleLogin(ctx: Context) {
* Usage: /logout * Usage: /logout
*/ */
export async function handleLogout(ctx: Context) { export async function handleLogout(ctx: Context) {
const telegramUserId = ctx.from?.id.toString()
if (!telegramUserId) {
await ctx.reply('❌ Unable to identify user')
return
}
try { try {
const storage = await getTokenStorage(telegramUserId) const storage = await getTokenStorage(ctx)
await logout({ storage }) await logout({ storage })
await ctx.reply('✅ Logged out successfully') await ctx.reply('✅ Logged out successfully')
@@ -76,17 +66,11 @@ export async function handleLogout(ctx: Context) {
* Usage: /whoami * Usage: /whoami
*/ */
export async function handleWhoami(ctx: Context) { export async function handleWhoami(ctx: Context) {
const telegramUserId = ctx.from?.id.toString()
if (!telegramUserId) {
await ctx.reply('❌ Unable to identify user')
return
}
try { try {
const storage = await getTokenStorage(telegramUserId) const storage = await getTokenStorage(ctx)
const isLogged = await isLoggedIn({ storage }) const isLogged = await isLoggedIn({ storage })
if (!isLogged) { if (!isLogged) {
await ctx.reply( await ctx.reply(
'❌ You are not logged in\n\n' + '❌ You are not logged in\n\n' +
@@ -102,7 +86,7 @@ export async function handleWhoami(ctx: Context) {
`Username: ${user.username}\n` + `Username: ${user.username}\n` +
`Role: ${user.role}\n` + `Role: ${user.role}\n` +
`User ID: ${user.id}\n` + `User ID: ${user.id}\n` +
`Telegram ID: ${telegramUserId}` `Telegram ID: ${storage.getChatId()}`
) )
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error' const message = error instanceof Error ? error.message : 'Unknown error'
@@ -116,16 +100,10 @@ export async function handleWhoami(ctx: Context) {
*/ */
export function requireAuth() { export function requireAuth() {
return async (ctx: Context, next: () => Promise<void>) => { return async (ctx: Context, next: () => Promise<void>) => {
const telegramUserId = ctx.from?.id.toString() const storage = await getTokenStorage(ctx)
if (!telegramUserId) {
await ctx.reply('❌ Unable to identify user')
return
}
const storage = await getTokenStorage(telegramUserId)
const isLogged = await isLoggedIn({ storage }) const isLogged = await isLoggedIn({ storage })
if (!isLogged) { if (!isLogged) {
await ctx.reply( await ctx.reply(
'❌ You need to login first\n\n' + '❌ You need to login first\n\n' +
+1 -3
View File
@@ -25,10 +25,8 @@ async function main() {
const platform = new TelegramPlatform() const platform = new TelegramPlatform()
try { try {
await platform.start({ platform.serve({
botToken, botToken,
redisUrl,
apiUrl,
}) })
console.log('✅ Bot is running...') console.log('✅ Bot is running...')
+45 -1
View File
@@ -1,7 +1,10 @@
import { Telegraf, type Context } from 'telegraf' 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 { handleLogin, handleLogout, handleWhoami, requireAuth } from './auth'
import { chatStreamAsync, type StreamEvent } from '@memohome/client' import { chatStreamAsync, type StreamEvent } from '@memohome/client'
import { getTokenStorage } from './storage'
import z from 'zod'
import Redis from 'ioredis'
export interface TelegramPlatformConfig { export interface TelegramPlatformConfig {
botToken: string botToken: string
@@ -12,8 +15,12 @@ export interface TelegramPlatformConfig {
export class TelegramPlatform extends BasePlatform { export class TelegramPlatform extends BasePlatform {
name = 'telegram' name = 'telegram'
description = 'Telegram Bot platform for MemoHome' description = 'Telegram Bot platform for MemoHome'
config = z.object({
botToken: z.string(),
})
private bot?: Telegraf private bot?: Telegraf
redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')
// private storage?: TelegramRedisStorage // private storage?: TelegramRedisStorage
async start(config: Record<string, unknown>): Promise<void> { async start(config: Record<string, unknown>): Promise<void> {
@@ -51,6 +58,42 @@ export class TelegramPlatform extends BasePlatform {
// } // }
} }
async send({ userId, message }: z.infer<typeof SendSchema>): Promise<void> {
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 { private registerCommands(): void {
if (!this.bot) { if (!this.bot) {
throw new Error('Bot or storage not initialized') throw new Error('Bot or storage not initialized')
@@ -123,6 +166,7 @@ export class TelegramPlatform extends BasePlatform {
try { try {
// Send typing indicator // Send typing indicator
await ctx.sendChatAction('typing') await ctx.sendChatAction('typing')
await getTokenStorage(ctx)
let responseText = '' let responseText = ''
let lastUpdateTime = Date.now() let lastUpdateTime = Date.now()
+70 -3
View File
@@ -1,19 +1,86 @@
import type { TokenStorage } from '@memohome/client' import type { TokenStorage } from '@memohome/client'
import Redis from 'ioredis' import Redis from 'ioredis'
import { Context } from 'telegraf'
export const getTokenStorage = async (telegramUserId: string): Promise<TokenStorage> => { export interface TelegramTokenStorage extends TokenStorage {
getUserId: () => string | null
setUserId: (userId: string) => void
getChatId: () => string | null
setChatId: (chatId: string) => void
getTelegramIdByUserId: (userId: string) => Promise<string | null>
}
export const getTokenStorage = async (ctx: Context): Promise<TelegramTokenStorage> => {
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 redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')
const isExists = await redis.exists(`memohome:telegram:${telegramUserId}:token`) const isTokenExists = await redis.exists(`memohome:telegram:${telegramUserId}:token`)
const token = isExists ? await redis.get(`memohome:telegram:${telegramUserId}:token`) : null 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 { return {
getChatId: () => chatId,
setChatId: (chatId: string) => {
redis.set(`memohome:telegram:${telegramUserId}:chatId`, chatId)
.then(() => {
redis.save()
})
},
getApiUrl: () => process.env.API_URL || 'http://localhost:7002', getApiUrl: () => process.env.API_URL || 'http://localhost:7002',
setApiUrl: () => {}, setApiUrl: () => {},
getToken: () => token, getToken: () => token,
setToken: (token: string) => { setToken: (token: string) => {
redis.set(`memohome:telegram:${telegramUserId}:token`, token) redis.set(`memohome:telegram:${telegramUserId}:token`, token)
.then(() => {
redis.save()
})
}, },
clearToken: () => { clearToken: () => {
redis.del(`memohome:telegram:${telegramUserId}:token`) 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
}, },
} }
} }
@@ -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)
})
})
+3 -1
View File
@@ -14,6 +14,8 @@
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"dependencies": { "dependencies": {
"elysia": "^1.4.21" "@elysiajs/cors": "^1.4.1",
"elysia": "^1.4.21",
"zod": "^4.3.5"
} }
} }
+58 -5
View File
@@ -1,17 +1,70 @@
import { Elysia } from 'elysia' 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 { export class BasePlatform {
name: string = 'base' name: string = 'base'
description: string = 'Base platform' description: string = 'Base platform'
started: boolean = false
port: number = 7003
config = z.object()
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async start(config: Record<string, unknown>): Promise<void> {} async start(config: z.infer<typeof this.config>): Promise<void> {}
async stop(): Promise<void> {} async stop(): Promise<void> {}
async send(): Promise<void> {} // eslint-disable-next-line @typescript-eslint/no-unused-vars
async send(data: z.infer<typeof SendSchema>): Promise<void> {}
// serve(): void { serve<T extends z.infer<typeof this.config>>(config?: T): void {
// const app = new Elysia() 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)
}
} }
+22 -3
View File
@@ -237,9 +237,15 @@ importers:
packages/platform: packages/platform:
dependencies: 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: elysia:
specifier: ^1.4.21 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) 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: packages/platform-telegram:
dependencies: dependencies:
@@ -249,15 +255,22 @@ importers:
'@memohome/platform': '@memohome/platform':
specifier: workspace:* specifier: workspace:*
version: link:../platform version: link:../platform
elysia: dotenv:
specifier: ^1.4.21 specifier: ^16.4.7
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) version: 16.6.1
ioredis: ioredis:
specifier: ^5.9.1 specifier: ^5.9.1
version: 5.9.1 version: 5.9.1
telegraf: telegraf:
specifier: ^4.16.3 specifier: ^4.16.3
version: 4.16.3(encoding@0.1.13) 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: {} packages/shared: {}
@@ -2541,6 +2554,10 @@ packages:
digest-fetch@1.3.0: digest-fetch@1.3.0:
resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==} resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.2.3: dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6856,6 +6873,8 @@ snapshots:
base-64: 0.1.0 base-64: 0.1.0
md5: 2.3.0 md5: 2.3.0
dotenv@16.6.1: {}
dotenv@17.2.3: {} dotenv@17.2.3: {}
drizzle-kit@0.31.8: drizzle-kit@0.31.8: