From 013a8704fb20b8d17fc679d64557870661d075b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 09:24:17 +0000 Subject: [PATCH 1/4] refactor(core): migrate event system to @moeru/eventa (Phases 1-3) Replace eventemitter3-based event bus with @moeru/eventa for type-safe events and RPC patterns in the core package. Phase 1: Define all Eventa events across 14 domain files in events/ Phase 2: Replace CoreContext.emitter with EventContext from Eventa Phase 3: Migrate all event handlers, services, and tests - Convert 13 implicit request-response pairs to defineInvokeEventa RPC - Convert ~40 fire-and-forget events to defineEventa - Update all 10 handler files, 6 service files, and 7 test files - Install @moeru/eventa in server and client packages Old CoreEventType enum and types/events.ts retained for server/client packages (to be removed in later phases). https://claude.ai/code/session_01JgqUCyTk9N37FSGY6nc97W --- apps/server/package.json | 1 + packages/client/package.json | 1 + packages/core/src/context.ts | 105 ++------ .../__test__/message-resolver.test.ts | 6 +- .../__test__/message-summary.test.ts | 33 +-- .../event-handlers/__test__/message.test.ts | 16 +- .../event-handlers/__test__/storage.test.ts | 31 +-- .../event-handlers/__test__/takeout.test.ts | 12 +- .../src/event-handlers/account-settings.ts | 16 +- packages/core/src/event-handlers/auth.ts | 6 +- packages/core/src/event-handlers/dialog.ts | 20 +- packages/core/src/event-handlers/entity.ts | 10 +- .../core/src/event-handlers/gram-events.ts | 6 +- packages/core/src/event-handlers/index.ts | 12 +- .../src/event-handlers/message-resolver.ts | 4 +- packages/core/src/event-handlers/message.ts | 92 +++---- packages/core/src/event-handlers/storage.ts | 236 +++++++++--------- packages/core/src/event-handlers/takeout.ts | 12 +- packages/core/src/events/auth.ts | 20 ++ packages/core/src/events/bot.ts | 22 ++ packages/core/src/events/config.ts | 15 ++ packages/core/src/events/dialog.ts | 41 +++ packages/core/src/events/entity.ts | 31 +++ packages/core/src/events/gram.ts | 11 + packages/core/src/events/index.ts | 13 + packages/core/src/events/instance.ts | 7 + packages/core/src/events/message.ts | 81 ++++++ packages/core/src/events/server.ts | 7 + packages/core/src/events/session.ts | 12 + packages/core/src/events/storage.ts | 60 +++++ packages/core/src/events/sync.ts | 18 ++ packages/core/src/events/takeout.ts | 34 +++ packages/core/src/index.ts | 1 + packages/core/src/instance.ts | 9 +- .../src/message-resolvers/avatar-resolver.ts | 14 +- .../core/src/services/__test__/entity.test.ts | 11 +- .../src/services/__test__/takeout.test.ts | 39 +-- .../core/src/services/account-settings.ts | 13 +- packages/core/src/services/account.ts | 4 +- packages/core/src/services/connection.ts | 30 +-- packages/core/src/services/dialog.ts | 6 +- packages/core/src/services/gram-events.ts | 6 +- .../core/src/services/message-resolver.ts | 12 +- packages/core/src/services/sync.ts | 14 +- packages/core/src/services/takeout.ts | 18 +- packages/core/src/utils/__test__/task.test.ts | 45 ++-- packages/core/src/utils/promise.ts | 17 +- packages/core/src/utils/task.ts | 8 +- pnpm-lock.yaml | 13 +- 49 files changed, 757 insertions(+), 494 deletions(-) create mode 100644 packages/core/src/events/auth.ts create mode 100644 packages/core/src/events/bot.ts create mode 100644 packages/core/src/events/config.ts create mode 100644 packages/core/src/events/dialog.ts create mode 100644 packages/core/src/events/entity.ts create mode 100644 packages/core/src/events/gram.ts create mode 100644 packages/core/src/events/index.ts create mode 100644 packages/core/src/events/instance.ts create mode 100644 packages/core/src/events/message.ts create mode 100644 packages/core/src/events/server.ts create mode 100644 packages/core/src/events/session.ts create mode 100644 packages/core/src/events/storage.ts create mode 100644 packages/core/src/events/sync.ts create mode 100644 packages/core/src/events/takeout.ts diff --git a/apps/server/package.json b/apps/server/package.json index d5bd645d2..c1b6bbe04 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -55,6 +55,7 @@ "dependencies": { "@electric-sql/pglite": "^0.3.15", "@guiiai/logg": "catalog:", + "@moeru/eventa": "1.0.0-alpha.11", "@node-rs/jieba": "^2.0.1", "@tg-search/bot": "workspace:*", "@tg-search/common": "workspace:*", diff --git a/packages/client/package.json b/packages/client/package.json index f93508449..7d9d7ce13 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@moeru/eventa": "1.0.0-alpha.11", "@tg-search/common": "workspace:*", "@tg-search/core": "workspace:*", "@tg-search/server": "workspace:*", diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index b30c80d89..0b716d11d 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -1,26 +1,21 @@ import type { Logger } from '@guiiai/logg' +import type { EventContext } from '@moeru/eventa' import type { CoreMetrics } from '@tg-search/common' import type { TelegramClient } from 'telegram' import type { CoreDB } from './db' import type { Models } from './models' import type { AccountSettings } from './types/account-settings' -import type { CoreEmitter, CoreEvent, CoreUserEntity, FromCoreEvent, ToCoreEvent } from './types/events' +import type { CoreUserEntity } from './types/events' import { useLogger } from '@guiiai/logg' -import { EventEmitter } from 'eventemitter3' +import { createContext } from '@moeru/eventa' -import { CoreEventType } from './types/events' -import { detectMemoryLeak } from './utils/memory-leak-detector' - -export type { CoreEmitter, CoreEvent, ExtractData, FromCoreEvent, ToCoreEvent } from './types/events' +import { coreErrorEvent } from './events' export interface CoreContext { - emitter: CoreEmitter - toCoreEvents: Set - fromCoreEvents: Set - wrapEmitterEmit: (emitter: CoreEmitter, fn?: (event: keyof FromCoreEvent) => void) => void - wrapEmitterOn: (emitter: CoreEmitter, fn?: (event: keyof ToCoreEvent) => void) => void + /** Eventa event context — the central hub for all event emission and subscription */ + ctx: EventContext setClient: (client: TelegramClient) => void getClient: () => TelegramClient setCurrentAccountId: (accountId: string) => void @@ -43,15 +38,15 @@ export interface CoreContext { export type Service = (ctx: CoreContext, logger: Logger) => T -function createErrorHandler(emitter: CoreEmitter, logger: Logger) { +function createErrorHandler(eventaCtx: EventContext, logger: Logger) { return (error: unknown, description?: string): Error => { // Unwrap nested errors if (error instanceof Error && 'cause' in error) { - return createErrorHandler(emitter, logger)(error.cause, description) + return createErrorHandler(eventaCtx, logger)(error.cause, description) } // Emit raw error for frontend to handle (i18n, UI, etc.) - emitter.emit(CoreEventType.CoreError, { error: error instanceof Error ? error.message : String(error), description }) + eventaCtx.emit(coreErrorEvent, { error: error instanceof Error ? error.message : String(error), description }) // Log error details if (error instanceof Error) { @@ -67,59 +62,12 @@ function createErrorHandler(emitter: CoreEmitter, logger: Logger) { } export function createCoreContext(db: () => CoreDB, models: Models, logger: Logger, metrics?: CoreMetrics): CoreContext { - const emitter = new EventEmitter() - const withError = createErrorHandler(emitter, logger) + const eventaCtx = createContext() + const withError = createErrorHandler(eventaCtx, logger) let telegramClient: TelegramClient let currentAccountId: string | undefined let myUser: CoreUserEntity | undefined - const toCoreEvents = new Set() - const fromCoreEvents = new Set() - - const wrapEmitterOn = (emitter: CoreEmitter, fn?: (event: keyof ToCoreEvent) => void) => { - const _on = emitter.on.bind(emitter) - - // eslint-disable-next-line sonarjs/no-invariant-returns - emitter.on = (event, listener) => { - const onFn = _on(event, async (...args) => { - try { - fn?.(event as keyof ToCoreEvent) - - logger.withFields({ event }).debug('Handle core event') - return await listener(...args) - } - catch (error) { - logger.withError(error instanceof Error ? (error.cause ?? error) : error).error('Failed to handle core event') - } - }) - - if (toCoreEvents.has(event as keyof ToCoreEvent)) { - return onFn - } - - logger.withFields({ event }).debug('Register to core event') - toCoreEvents.add(event as keyof ToCoreEvent) - return onFn - } - } - - const wrapEmitterEmit = (emitter: CoreEmitter, fn?: (event: keyof FromCoreEvent) => void) => { - const _emit = emitter.emit.bind(emitter) - - emitter.emit = (event, ...args) => { - if (fromCoreEvents.has(event as keyof FromCoreEvent)) { - return _emit(event, ...args) - } - - logger.withFields({ event }).debug('Register from core event') - - fromCoreEvents.add(event as keyof FromCoreEvent) - fn?.(event as keyof FromCoreEvent) - - return _emit(event, ...args) - } - } - function setClient(client: TelegramClient) { logger.debug('Set Telegram client') telegramClient = client @@ -171,9 +119,6 @@ export function createCoreContext(db: () => CoreDB, models: Models, logger: Logg await models.accountSettingsModels.updateAccountSettings(getDB(), getCurrentAccountId(), newSettings) } - // Setup memory leak detection and get cleanup function - const cleanupMemoryLeakDetector = detectMemoryLeak(emitter, logger) - function getDB(): CoreDB { const dbInstance = db() if (!dbInstance) { @@ -183,17 +128,7 @@ export function createCoreContext(db: () => CoreDB, models: Models, logger: Logg } function cleanup() { - logger.debug('Cleaning up CoreContext') - - // Clean up memory leak detector first - cleanupMemoryLeakDetector() - - // Remove all event listeners - emitter.removeAllListeners() - - // Clear event sets - toCoreEvents.clear() - fromCoreEvents.clear() + useLogger('core:context').debug('Cleaning up CoreContext') // Clear client reference // @ts-expect-error - Allow setting to undefined for cleanup @@ -202,23 +137,11 @@ export function createCoreContext(db: () => CoreDB, models: Models, logger: Logg // Clear account reference currentAccountId = undefined - logger.debug('CoreContext cleaned up') + useLogger('core:context').debug('CoreContext cleaned up') } - wrapEmitterOn(emitter, (event) => { - useLogger('core:event').withFields({ event }).debug('Core event received') - }) - - wrapEmitterEmit(emitter, (event) => { - useLogger('core:event').withFields({ event }).debug('Core event emitted') - }) - return { - emitter, - toCoreEvents, - fromCoreEvents, - wrapEmitterEmit, - wrapEmitterOn, + ctx: eventaCtx, setClient, getClient: ensureClient, setCurrentAccountId, diff --git a/packages/core/src/event-handlers/__test__/message-resolver.test.ts b/packages/core/src/event-handlers/__test__/message-resolver.test.ts index 58634c477..01c267b29 100644 --- a/packages/core/src/event-handlers/__test__/message-resolver.test.ts +++ b/packages/core/src/event-handlers/__test__/message-resolver.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' -import { CoreEventType } from '../../types/events' +import { messageProcessEvent } from '../../events' import { registerMessageResolverEventHandlers } from '../message-resolver' const models = {} as unknown as Models @@ -35,7 +35,7 @@ describe('message-resolver event handlers', () => { date: Math.floor(Date.now() / 1000), }) - ctx.emitter.emit(CoreEventType.MessageProcess, { + ctx.ctx.emit(messageProcessEvent, { messages: [telegramMessage], isTakeout: false, forceRefetch: true, @@ -71,7 +71,7 @@ describe('message-resolver event handlers', () => { date: Math.floor(Date.now() / 1000), }) - ctx.emitter.emit(CoreEventType.MessageProcess, { + ctx.ctx.emit(messageProcessEvent, { messages: [telegramMessage], isTakeout: true, forceRefetch: true, diff --git a/packages/core/src/event-handlers/__test__/message-summary.test.ts b/packages/core/src/event-handlers/__test__/message-summary.test.ts index 4abc9743f..4713b2297 100644 --- a/packages/core/src/event-handlers/__test__/message-summary.test.ts +++ b/packages/core/src/event-handlers/__test__/message-summary.test.ts @@ -4,12 +4,13 @@ import type { MessageService } from '../../services/message' import bigInt from 'big-integer' import { useLogger } from '@guiiai/logg' +import { defineInvoke } from '@moeru/eventa' import { Api } from 'telegram' import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' -import { CoreEventType } from '../../types/events' +import { messageFetchSummaryInvoke } from '../../events' import { registerMessageEventHandlers } from '../message' const models = {} as unknown as Models @@ -28,7 +29,7 @@ function createApiMessage(id: number, date: number, content: string) { } as unknown as Api.Message } -describe(CoreEventType.MessageFetchSummary, () => { +describe('messageFetchSummaryInvoke', () => { it('mode=unread should use fetchUnreadMessages', async () => { const ctx = createCoreContext(getMockEmptyDB, models, logger) @@ -49,17 +50,11 @@ describe(CoreEventType.MessageFetchSummary, () => { registerMessageEventHandlers(ctx, logger)(mockMessageService as unknown as MessageService) - const received: Array<{ mode: 'unread' | 'today' | 'last24h', count: number }> = [] - ctx.emitter.on(CoreEventType.MessageSummaryData, ({ mode, messages }) => { - received.push({ mode, count: messages.length }) - }) + const invoke = defineInvoke(ctx.ctx, messageFetchSummaryInvoke) + const result = await invoke({ chatId: '1', limit: 1000, mode: 'unread' }) - ctx.emitter.emit(CoreEventType.MessageFetchSummary, { chatId: '1', limit: 1000, mode: 'unread' }) - await new Promise(resolve => setTimeout(resolve, 50)) - - expect(received).toHaveLength(1) - expect(received[0].mode).toBe('unread') - expect(received[0].count).toBe(2) + expect(result.mode).toBe('unread') + expect(result.messages.length).toBe(2) expect(mockMessageService.fetchRecentMessagesByTimeRange).not.toHaveBeenCalled() }) @@ -83,17 +78,11 @@ describe(CoreEventType.MessageFetchSummary, () => { registerMessageEventHandlers(ctx, logger)(mockMessageService as unknown as MessageService) - const received: Array<{ mode: 'unread' | 'today' | 'last24h', count: number }> = [] - ctx.emitter.on(CoreEventType.MessageSummaryData, ({ mode, messages }) => { - received.push({ mode, count: messages.length }) - }) - - ctx.emitter.emit(CoreEventType.MessageFetchSummary, { chatId: '1', limit: 1000, mode: 'today' }) - await new Promise(resolve => setTimeout(resolve, 50)) + const invoke = defineInvoke(ctx.ctx, messageFetchSummaryInvoke) + const result = await invoke({ chatId: '1', limit: 1000, mode: 'today' }) - expect(received).toHaveLength(1) - expect(received[0].mode).toBe('today') - expect(received[0].count).toBe(2) + expect(result.mode).toBe('today') + expect(result.messages.length).toBe(2) expect(mockMessageService.fetchRecentMessagesByTimeRange).toHaveBeenCalledOnce() expect(mockMessageService.fetchUnreadMessages).not.toHaveBeenCalled() }) diff --git a/packages/core/src/event-handlers/__test__/message.test.ts b/packages/core/src/event-handlers/__test__/message.test.ts index ba54b22dc..eed9fed92 100644 --- a/packages/core/src/event-handlers/__test__/message.test.ts +++ b/packages/core/src/event-handlers/__test__/message.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' -import { CoreEventType } from '../../types/events' +import { coreErrorEvent, messageProcessEvent, messageReprocessEvent } from '../../events' import { registerMessageEventHandlers } from '../message' const models = {} as unknown as Models @@ -40,12 +40,12 @@ describe('message event handlers', () => { // Set up listener for message:process to capture forceRefetch flag let capturedForceRefetch: boolean | undefined - ctx.emitter.on(CoreEventType.MessageProcess, ({ forceRefetch }) => { + ctx.ctx.on(messageProcessEvent, ({ body: { forceRefetch } }) => { capturedForceRefetch = forceRefetch }) // Emit message:reprocess event - ctx.emitter.emit(CoreEventType.MessageReprocess, { + ctx.ctx.emit(messageReprocessEvent, { chatId: '789', messageIds: [123], resolvers: ['media'], @@ -75,12 +75,12 @@ describe('message event handlers', () => { // Set up listener for core:error const errors: any[] = [] - ctx.emitter.on(CoreEventType.CoreError, (error) => { - errors.push(error) + ctx.ctx.on(coreErrorEvent, ({ body }) => { + errors.push(body) }) // Emit message:reprocess event - ctx.emitter.emit(CoreEventType.MessageReprocess, { + ctx.ctx.emit(messageReprocessEvent, { chatId: '789', messageIds: [123], resolvers: ['media'], @@ -111,12 +111,12 @@ describe('message event handlers', () => { // Set up listener for message:process const processedMessages: Api.Message[] = [] - ctx.emitter.on(CoreEventType.MessageProcess, ({ messages }) => { + ctx.ctx.on(messageProcessEvent, ({ body: { messages } }) => { processedMessages.push(...messages) }) // Emit message:reprocess event - ctx.emitter.emit(CoreEventType.MessageReprocess, { + ctx.ctx.emit(messageReprocessEvent, { chatId: '789', messageIds: [123], resolvers: ['media'], diff --git a/packages/core/src/event-handlers/__test__/storage.test.ts b/packages/core/src/event-handlers/__test__/storage.test.ts index 5b65bce2d..f6aec28bf 100644 --- a/packages/core/src/event-handlers/__test__/storage.test.ts +++ b/packages/core/src/event-handlers/__test__/storage.test.ts @@ -2,12 +2,13 @@ import type { Models } from '../../models' import type { CoreDialog } from '../../types/dialog' import { useLogger } from '@guiiai/logg' +import { defineInvoke } from '@moeru/eventa' import { Ok } from '@unbird/result' import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' -import { CoreEventType } from '../../types/events' +import { coreErrorEvent, storageFetchDialogsInvoke, storageFetchMessagesInvoke, storageRecordDialogsEvent, storageSearchMessagesInvoke } from '../../events' import { registerStorageEventHandlers } from '../storage' const logger = useLogger() @@ -79,15 +80,8 @@ describe('storage event handlers - dialogs with accounts', () => { const ACCOUNT_ID = 'account-xyz' - const dialogsPromise = new Promise((resolve) => { - ctx.emitter.on(CoreEventType.StorageDialogs, ({ dialogs }) => { - resolve(dialogs) - }) - }) - - ctx.emitter.emit(CoreEventType.StorageFetchDialogs, { accountId: ACCOUNT_ID }) - - const dialogs = await dialogsPromise + const invoke = defineInvoke(ctx.ctx, storageFetchDialogsInvoke) + const { dialogs } = await invoke({ accountId: ACCOUNT_ID }) // Verify models were called with correct account id (first arg is db instance) expect(fetchChatsByAccountId).toHaveBeenCalledWith(expect.anything(), ACCOUNT_ID) @@ -123,7 +117,7 @@ describe('storage event handlers - dialogs with accounts', () => { }, ] - ctx.emitter.emit(CoreEventType.StorageRecordDialogs, { dialogs, accountId: ACCOUNT_ID }) + ctx.ctx.emit(storageRecordDialogsEvent, { dialogs, accountId: ACCOUNT_ID }) expect(recordChats).toHaveBeenCalledTimes(1) expect(recordChats).toHaveBeenCalledWith(expect.anything(), dialogs, ACCOUNT_ID) @@ -144,14 +138,18 @@ describe('storage event handlers - message access control', () => { ;(isChatAccessibleByAccount as unknown as ReturnType).mockResolvedValueOnce(Ok(false)) const errorPromise = new Promise((resolve) => { - ctx.emitter.on(CoreEventType.CoreError, ({ error }) => { + ctx.ctx.on(coreErrorEvent, ({ body: { error } }) => { resolve(error) }) }) - ctx.emitter.emit(CoreEventType.StorageFetchMessages, { + const invoke = defineInvoke(ctx.ctx, storageFetchMessagesInvoke) + // The invoke will throw or the error event will fire + invoke({ chatId: CHAT_ID, pagination: { limit: 20, offset: 0 }, + }).catch(() => { + // Expected to fail }) const error = await errorPromise @@ -174,16 +172,19 @@ describe('storage event handlers - message access control', () => { ;(isChatAccessibleByAccount as unknown as ReturnType).mockResolvedValueOnce(Ok(false)) const errorPromise = new Promise((resolve) => { - ctx.emitter.on(CoreEventType.CoreError, ({ error }) => { + ctx.ctx.on(coreErrorEvent, ({ body: { error } }) => { resolve(error) }) }) - ctx.emitter.emit(CoreEventType.StorageSearchMessages, { + const invoke = defineInvoke(ctx.ctx, storageSearchMessagesInvoke) + invoke({ chatId: CHAT_ID, content: 'test search', useVector: false, pagination: { limit: 20, offset: 0 }, + }).catch(() => { + // Expected to fail }) const error = await errorPromise diff --git a/packages/core/src/event-handlers/__test__/takeout.test.ts b/packages/core/src/event-handlers/__test__/takeout.test.ts index ec03d1134..cb65936f3 100644 --- a/packages/core/src/event-handlers/__test__/takeout.test.ts +++ b/packages/core/src/event-handlers/__test__/takeout.test.ts @@ -1,11 +1,12 @@ import type { Models } from '../../models' import { useLogger } from '@guiiai/logg' +import { defineInvoke } from '@moeru/eventa' import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' -import { CoreEventType } from '../../types/events' +import { takeoutRunEvent, takeoutStatsFetchInvoke, takeoutTaskAbortEvent } from '../../events' import { registerTakeoutEventHandlers } from '../takeout' const logger = useLogger() @@ -21,7 +22,7 @@ describe('takeout event handlers', () => { registerTakeoutEventHandlers(ctx, takeoutService) const params = { chatIds: ['123'], increase: false, syncOptions: {} } - ctx.emitter.emit(CoreEventType.TakeoutRun, params) + ctx.ctx.emit(takeoutRunEvent, params) expect(runTakeout).toHaveBeenCalledWith(params) }) @@ -33,19 +34,20 @@ describe('takeout event handlers', () => { registerTakeoutEventHandlers(ctx, takeoutService) - ctx.emitter.emit(CoreEventType.TakeoutTaskAbort, { taskId: 'task-1' }) + ctx.ctx.emit(takeoutTaskAbortEvent, { taskId: 'task-1' }) expect(abortTask).toHaveBeenCalledWith('task-1') }) it('takeout:stats:fetch should delegate to takeoutService.fetchChatSyncStats', async () => { const ctx = createCoreContext(getMockEmptyDB, models, logger) - const fetchChatSyncStats = vi.fn() + const fetchChatSyncStats = vi.fn(async () => ({ stats: [] })) const takeoutService = { fetchChatSyncStats } as any registerTakeoutEventHandlers(ctx, takeoutService) - ctx.emitter.emit(CoreEventType.TakeoutStatsFetch, { chatId: '123' }) + const invoke = defineInvoke(ctx.ctx, takeoutStatsFetchInvoke) + await invoke({ chatId: '123' }) expect(fetchChatSyncStats).toHaveBeenCalledWith('123') }) diff --git a/packages/core/src/event-handlers/account-settings.ts b/packages/core/src/event-handlers/account-settings.ts index da5374088..50e0de6a9 100644 --- a/packages/core/src/event-handlers/account-settings.ts +++ b/packages/core/src/event-handlers/account-settings.ts @@ -3,22 +3,24 @@ import type { Logger } from '@guiiai/logg' import type { CoreContext } from '../context' import type { AccountSettingsService } from '../services/account-settings' -import { CoreEventType } from '../types/events' +import { defineInvokeHandler } from '@moeru/eventa' + +import { configFetchInvoke, configUpdateInvoke } from '../events' export function registerAccountSettingsEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:account-settings:event') return (configService: AccountSettingsService) => { - ctx.emitter.on(CoreEventType.ConfigFetch, async () => { + defineInvokeHandler(ctx.ctx, configFetchInvoke, async () => { logger.verbose('Getting config') - - configService.fetchAccountSettings() + const accountSettings = await configService.fetchAccountSettings() + return { accountSettings } }) - ctx.emitter.on(CoreEventType.ConfigUpdate, async ({ accountSettings }) => { + defineInvokeHandler(ctx.ctx, configUpdateInvoke, async ({ accountSettings }) => { logger.verbose('Saving config') - - await configService.setAccountSettings(accountSettings) + const updated = await configService.setAccountSettings(accountSettings) + return { accountSettings: updated } }) } } diff --git a/packages/core/src/event-handlers/auth.ts b/packages/core/src/event-handlers/auth.ts index db76085c4..4f014999b 100644 --- a/packages/core/src/event-handlers/auth.ts +++ b/packages/core/src/event-handlers/auth.ts @@ -5,7 +5,7 @@ import type { ConnectionService } from '../services' import { StringSession } from 'telegram/sessions' -import { CoreEventType } from '../types/events' +import { authLoginEvent, authLogoutEvent } from '../events' export function registerAuthEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:auth:event') @@ -13,7 +13,7 @@ export function registerAuthEventHandlers(ctx: CoreContext, logger: Logger) { return ( configuredConnectionService: ConnectionService, ) => { - ctx.emitter.on(CoreEventType.AuthLogin, async ({ phoneNumber, session }) => { + ctx.ctx.on(authLoginEvent, async ({ body: { phoneNumber, session } }) => { if (phoneNumber) { return configuredConnectionService.loginWithPhone(phoneNumber) } @@ -24,7 +24,7 @@ export function registerAuthEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.emitter.on(CoreEventType.AuthLogout, async () => { + ctx.ctx.on(authLogoutEvent, async () => { logger.verbose('Logged out from Telegram') const client = ctx.getClient() if (client) { diff --git a/packages/core/src/event-handlers/dialog.ts b/packages/core/src/event-handlers/dialog.ts index bbb18ea33..fbf68b405 100644 --- a/packages/core/src/event-handlers/dialog.ts +++ b/packages/core/src/event-handlers/dialog.ts @@ -4,7 +4,9 @@ import type { CoreContext } from '../context' import type { Models } from '../models' import type { DialogService } from '../services' -import { CoreEventType } from '../types/events' +import { defineInvokeHandler } from '@moeru/eventa' + +import { dialogAvatarFetchEvent, dialogFetchInvoke, dialogFoldersFetchInvoke, storageRecordChatFoldersEvent, storageRecordDialogsEvent } from '../events' export async function fetchDialogs(ctx: CoreContext, logger: Logger, dbModels: Models, dialogService: DialogService) { logger.verbose('Fetching dialogs') @@ -34,29 +36,31 @@ export async function fetchDialogs(ctx: CoreContext, logger: Logger, dbModels: M } } - ctx.emitter.emit(CoreEventType.DialogData, { dialogs }) - ctx.emitter.emit(CoreEventType.StorageRecordDialogs, { dialogs, accountId }) + return { dialogs, accountId } } export function registerDialogEventHandlers(ctx: CoreContext, logger: Logger, dbModels: Models) { logger = logger.withContext('core:dialog:event') return (dialogService: DialogService) => { - ctx.emitter.on(CoreEventType.DialogFetch, async () => { - await fetchDialogs(ctx, logger, dbModels, dialogService) + defineInvokeHandler(ctx.ctx, dialogFetchInvoke, async () => { + const { dialogs, accountId } = await fetchDialogs(ctx, logger, dbModels, dialogService) + ctx.ctx.emit(storageRecordDialogsEvent, { dialogs, accountId }) + return { dialogs } }) - ctx.emitter.on(CoreEventType.DialogFoldersFetch, async () => { + defineInvokeHandler(ctx.ctx, dialogFoldersFetchInvoke, async () => { logger.verbose('Fetching chat folders') const folders = (await dialogService.fetchChatFolders()).expect('Failed to fetch chat folders') const accountId = ctx.getCurrentAccountId() - ctx.emitter.emit(CoreEventType.StorageRecordChatFolders, { folders, accountId }) + ctx.ctx.emit(storageRecordChatFoldersEvent, { folders, accountId }) + return { folders } }) // Prioritized single-avatar fetch for viewport-visible items - ctx.emitter.on(CoreEventType.DialogAvatarFetch, async ({ chatId }) => { + ctx.ctx.on(dialogAvatarFetchEvent, async ({ body: { chatId } }) => { logger.withFields({ chatId }).verbose('Fetching single dialog avatar') await dialogService.fetchSingleDialogAvatar(String(chatId)) }) diff --git a/packages/core/src/event-handlers/entity.ts b/packages/core/src/event-handlers/entity.ts index 36939442f..b737fe7cd 100644 --- a/packages/core/src/event-handlers/entity.ts +++ b/packages/core/src/event-handlers/entity.ts @@ -3,13 +3,13 @@ import type { Logger } from '@guiiai/logg' import type { CoreContext } from '../context' import type { EntityService } from '../services/entity' -import { CoreEventType } from '../types/events' +import { entityAvatarFetchEvent, entityAvatarPrimeCacheEvent, entityChatAvatarPrimeCacheEvent, entityProcessEvent } from '../events' export function registerEntityEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:entity:event') return (entityService: EntityService) => { - ctx.emitter.on(CoreEventType.EntityProcess, async ({ users, chats }) => { + ctx.ctx.on(entityProcessEvent, async ({ body: { users, chats } }) => { // GramJS entities are automatically handled by the client's internal entity cache // when we invoke any method, but we ALSO manually persist them to DB to ensure // we have persistent accessHash for future API calls. @@ -17,17 +17,17 @@ export function registerEntityEventHandlers(ctx: CoreContext, logger: Logger) { await entityService.processEntities(users, chats) }) - ctx.emitter.on(CoreEventType.EntityAvatarFetch, async ({ userId, fileId }) => { + ctx.ctx.on(entityAvatarFetchEvent, async ({ body: { userId, fileId } }) => { logger.withFields({ userId, fileId }).debug('Fetching user avatar') await entityService.fetchUserAvatar(userId, fileId) }) - ctx.emitter.on(CoreEventType.EntityAvatarPrimeCache, async ({ userId, fileId }) => { + ctx.ctx.on(entityAvatarPrimeCacheEvent, async ({ body: { userId, fileId } }) => { logger.withFields({ userId, fileId }).debug('Priming avatar cache') await entityService.primeUserAvatarCache(userId, fileId) }) - ctx.emitter.on(CoreEventType.EntityChatAvatarPrimeCache, async ({ chatId, fileId }) => { + ctx.ctx.on(entityChatAvatarPrimeCacheEvent, async ({ body: { chatId, fileId } }) => { logger.withFields({ chatId, fileId }).debug('Priming chat avatar cache') await entityService.primeChatAvatarCache(chatId, fileId) }) diff --git a/packages/core/src/event-handlers/gram-events.ts b/packages/core/src/event-handlers/gram-events.ts index 628300ecb..86c0ef7b3 100644 --- a/packages/core/src/event-handlers/gram-events.ts +++ b/packages/core/src/event-handlers/gram-events.ts @@ -7,13 +7,13 @@ import type { GramEventsService } from '../services/gram-events' import { Api } from 'telegram' -import { CoreEventType } from '../types/events' +import { gramMessageReceivedEvent, messageProcessEvent } from '../events' export function registerGramEventsEventHandlers(ctx: CoreContext, logger: Logger, accountModels: AccountModels, chatModels: ChatModels) { logger = logger.withContext('core:gram:event') return (_: GramEventsService) => { - ctx.emitter.on(CoreEventType.GramMessageReceived, async ({ message, pts, date, isChannel }) => { + ctx.ctx.on(gramMessageReceivedEvent, async ({ body: { message, pts, date, isChannel } }) => { const accountSettings = await ctx.getAccountSettings() const receiveSettings = accountSettings.messageProcessing?.receiveMessages @@ -30,7 +30,7 @@ export function registerGramEventsEventHandlers(ctx: CoreContext, logger: Logger logger.withFields({ message: message.id, fromId: message.fromId, content: message.text, pts, isChannel }).debug('Message received') - ctx.emitter.emit(CoreEventType.MessageProcess, { messages: [message], syncOptions }) + ctx.ctx.emit(messageProcessEvent, { messages: [message], syncOptions }) if (!pts) return diff --git a/packages/core/src/event-handlers/index.ts b/packages/core/src/event-handlers/index.ts index cf6b9bbb4..8189c0df5 100644 --- a/packages/core/src/event-handlers/index.ts +++ b/packages/core/src/event-handlers/index.ts @@ -6,6 +6,7 @@ import type { MediaBinaryProvider } from '../types/storage' import { useLogger } from '@guiiai/logg' +import { accountReadyEvent, authConnectedEvent, syncCatchUpEvent, syncResetEvent } from '../events' import { useMessageResolverRegistry } from '../message-resolvers' import { createAvatarResolver } from '../message-resolvers/avatar-resolver' import { createEmbeddingResolver } from '../message-resolvers/embedding-resolver' @@ -30,7 +31,6 @@ import { createMessageService } from '../services/message' import { createMessageResolverService } from '../services/message-resolver' import { createSyncService } from '../services/sync' import { createTakeoutService } from '../services/takeout' -import { CoreEventType } from '../types/events' import { registerAccountSettingsEventHandlers } from './account-settings' import { registerAuthEventHandlers } from './auth' import { fetchDialogs, registerDialogEventHandlers } from './dialog' @@ -92,7 +92,7 @@ export function afterConnectedEventHandler(ctx: CoreContext): EventHandler { const syncService = createSyncService(ctx, logger) const gramEventsService = createGramEventsService(ctx, logger) - ctx.emitter.once(CoreEventType.AuthConnected, async () => { + ctx.ctx.once(authConnectedEvent, async () => { // Register entity handlers first so we can establish currentAccountId. logger.verbose('Getting me info') const account = (await accountService.fetchMyAccount()).expect('Failed to get me info') @@ -105,10 +105,10 @@ export function afterConnectedEventHandler(ctx: CoreContext): EventHandler { ctx.setCurrentAccountId(dbAccount.id) // Trigger sync catch-up in background after account is identified - ctx.emitter.on(CoreEventType.SyncCatchUp, async () => { + ctx.ctx.on(syncCatchUpEvent, async () => { await syncService.catchUp() }) - ctx.emitter.on(CoreEventType.SyncReset, async () => { + ctx.ctx.on(syncResetEvent, async () => { await syncService.reset() }) void syncService.catchUp() @@ -121,10 +121,10 @@ export function afterConnectedEventHandler(ctx: CoreContext): EventHandler { logger.withFields({ accountId: dbAccount.id }).verbose('Set current account ID') - ctx.emitter.emit(CoreEventType.AccountReady, { accountId: dbAccount.id }) + ctx.ctx.emit(accountReadyEvent, { accountId: dbAccount.id }) }) - ctx.emitter.once(CoreEventType.AccountReady, ({ accountId }) => { + ctx.ctx.once(accountReadyEvent, ({ body: { accountId } }) => { logger = logger.withFields({ accountId }) registerEntityEventHandlers(ctx, logger)(entityService) diff --git a/packages/core/src/event-handlers/message-resolver.ts b/packages/core/src/event-handlers/message-resolver.ts index 549d47860..1f6abd47f 100644 --- a/packages/core/src/event-handlers/message-resolver.ts +++ b/packages/core/src/event-handlers/message-resolver.ts @@ -6,7 +6,7 @@ import type { MessageResolverService } from '../services/message-resolver' import { newQueue } from '@henrygd/queue' import { MESSAGE_RESOLVER_QUEUE_SIZE } from '../constants' -import { CoreEventType } from '../types/events' +import { messageProcessEvent } from '../events' export function registerMessageResolverEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:message-resolver:event') @@ -15,7 +15,7 @@ export function registerMessageResolverEventHandlers(ctx: CoreContext, logger: L const queue = newQueue(MESSAGE_RESOLVER_QUEUE_SIZE) // TODO: debounce, background tasks - ctx.emitter.on(CoreEventType.MessageProcess, ({ messages, isTakeout = false, syncOptions = {}, forceRefetch = false, batchId }) => { + ctx.ctx.on(messageProcessEvent, ({ body: { messages, isTakeout = false, syncOptions = {}, forceRefetch = false, batchId } }) => { logger.withFields({ count: messages.length, isTakeout, syncOptions, forceRefetch, batchId }).verbose('Processing messages') if (!isTakeout) { diff --git a/packages/core/src/event-handlers/message.ts b/packages/core/src/event-handlers/message.ts index 31bdd4e8d..bc26080d6 100644 --- a/packages/core/src/event-handlers/message.ts +++ b/packages/core/src/event-handlers/message.ts @@ -4,11 +4,12 @@ import type { CoreContext } from '../context' import type { MessageService } from '../services' import type { CoreMessage } from '../types/message' +import { defineInvokeHandler } from '@moeru/eventa' import { Api } from 'telegram/tl' import { v4 as uuidv4 } from 'uuid' import { MESSAGE_PROCESS_BATCH_SIZE } from '../constants' -import { CoreEventType } from '../types/events' +import { messageDataEvent, messageFetchEvent, messageFetchSpecificEvent, messageFetchSummaryInvoke, messageFetchUnreadInvoke, messageProcessEvent, messageReadEvent, messageReprocessEvent, messageSendEvent } from '../events' import { convertToCoreMessage } from '../utils/message' export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { @@ -21,7 +22,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { .map(result => result.unwrap()) } - ctx.emitter.on(CoreEventType.MessageFetch, async (opts) => { + ctx.ctx.on(messageFetchEvent, async ({ body: opts }) => { logger.withFields({ chatId: opts.chatId, minId: opts.minId, maxId: opts.maxId }).verbose('Fetching messages') let messages: Api.Message[] = [] @@ -35,17 +36,17 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { batchSize, }).debug('Processing message batch') - ctx.emitter.emit(CoreEventType.MessageProcess, { messages }) + ctx.ctx.emit(messageProcessEvent, { messages }) messages = [] } } if (messages.length > 0) { - ctx.emitter.emit(CoreEventType.MessageProcess, { messages }) + ctx.ctx.emit(messageProcessEvent, { messages }) } }) - ctx.emitter.on(CoreEventType.MessageFetchSpecific, async ({ chatId, messageIds }) => { + ctx.ctx.on(messageFetchSpecificEvent, async ({ body: { chatId, messageIds } }) => { logger.withFields({ chatId, count: messageIds.length }).verbose('Fetching specific messages for media') try { @@ -54,7 +55,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { if (messages.length > 0) { logger.withFields({ chatId, count: messages.length }).verbose('Fetched specific messages, processing for media') - ctx.emitter.emit(CoreEventType.MessageProcess, { messages }) + ctx.ctx.emit(messageProcessEvent, { messages }) } } catch (error) { @@ -62,7 +63,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.emitter.on(CoreEventType.MessageSend, async ({ chatId, content }) => { + ctx.ctx.on(messageSendEvent, async ({ body: { chatId, content } }) => { logger.withFields({ chatId, content }).verbose('Sending message') const updatedMessage = (await messageService.sendMessage(chatId, content)).unwrap() @@ -70,13 +71,13 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { case 'Updates': updatedMessage.updates.forEach((update) => { if ('message' in update && update.message instanceof Api.Message) { - ctx.emitter.emit(CoreEventType.MessageProcess, { messages: [update.message] }) + ctx.ctx.emit(messageProcessEvent, { messages: [update.message] }) } }) break case 'UpdateShortSentMessage': { const sender = ctx.getMyUser() - ctx.emitter.emit(CoreEventType.MessageData, { + ctx.ctx.emit(messageDataEvent, { messages: [{ uuid: uuidv4(), platform: 'telegram', @@ -100,7 +101,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { logger.withFields({ content }).verbose('Message sent') }) - ctx.emitter.on(CoreEventType.MessageReprocess, async ({ chatId, messageIds, resolvers }) => { + ctx.ctx.on(messageReprocessEvent, async ({ body: { chatId, messageIds, resolvers } }) => { // Validate input if (messageIds.length === 0) { logger.withFields({ chatId }).warn('Re-process called with empty messageIds array') @@ -129,7 +130,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { // // Force refetch to skip database cache and re-download from Telegram. // This is necessary when media files are missing from storage (404 errors). - ctx.emitter.emit(CoreEventType.MessageProcess, { messages, forceRefetch: true }) + ctx.ctx.emit(messageProcessEvent, { messages, forceRefetch: true }) } catch (error) { logger.withError(error as Error).warn('Failed to re-process messages') @@ -137,58 +138,47 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.emitter.on(CoreEventType.MessageFetchUnread, async ({ chatId, limit, startTime }) => { + defineInvokeHandler(ctx.ctx, messageFetchUnreadInvoke, async ({ chatId, limit, startTime }) => { logger.withFields({ chatId, limit, startTime }).verbose('Fetching unread messages') - try { - const messages = await messageService.fetchUnreadMessages(chatId, { limit, startTime }) - // Reverse to have chronological order (oldest first) which is better for LLM summary - // getMessages usually returns newest first. - messages.reverse() + const messages = await messageService.fetchUnreadMessages(chatId, { limit, startTime }) + // Reverse to have chronological order (oldest first) which is better for LLM summary + // getMessages usually returns newest first. + messages.reverse() - const coreMessages = toCoreMessages(messages) - ctx.emitter.emit(CoreEventType.MessageUnreadData, { messages: coreMessages }) - } - catch (e) { - ctx.withError(e, 'Failed to fetch unread messages') - } + return { messages: toCoreMessages(messages) } }) - ctx.emitter.on(CoreEventType.MessageFetchSummary, async ({ chatId, limit, mode, requestId }) => { + defineInvokeHandler(ctx.ctx, messageFetchSummaryInvoke, async ({ chatId, limit, mode, requestId }) => { logger.withFields({ chatId, limit, mode, requestId }).verbose('Fetching summary messages') - try { - if (mode === 'unread') { - const unread = await messageService.fetchUnreadMessages(chatId, { limit }) - unread.reverse() - ctx.emitter.emit(CoreEventType.MessageSummaryData, { - messages: toCoreMessages(unread), - mode: 'unread', - requestId, - }) - return + + if (mode === 'unread') { + const unread = await messageService.fetchUnreadMessages(chatId, { limit }) + unread.reverse() + return { + messages: toCoreMessages(unread), + mode: 'unread' as const, + requestId, } + } - const now = new Date() - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const startOfTodayTs = Math.floor(startOfToday.getTime() / 1000) - const startTime = mode === 'today' - ? startOfTodayTs - : Math.floor(Date.now() / 1000) - 24 * 60 * 60 + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const startOfTodayTs = Math.floor(startOfToday.getTime() / 1000) + const startTime = mode === 'today' + ? startOfTodayTs + : Math.floor(Date.now() / 1000) - 24 * 60 * 60 - const recent = await messageService.fetchRecentMessagesByTimeRange(chatId, { startTime, limit }) - recent.reverse() + const recent = await messageService.fetchRecentMessagesByTimeRange(chatId, { startTime, limit }) + recent.reverse() - ctx.emitter.emit(CoreEventType.MessageSummaryData, { - messages: toCoreMessages(recent), - mode, - requestId, - }) - } - catch (e) { - ctx.withError(e, 'Failed to fetch summary messages') + return { + messages: toCoreMessages(recent), + mode, + requestId, } }) - ctx.emitter.on(CoreEventType.MessageRead, async ({ chatId }) => { + ctx.ctx.on(messageReadEvent, async ({ body: { chatId } }) => { logger.withFields({ chatId }).verbose('Marking messages as read') await messageService.markAsRead(chatId) }) diff --git a/packages/core/src/event-handlers/storage.ts b/packages/core/src/event-handlers/storage.ts index f3e3015a6..e15420376 100644 --- a/packages/core/src/event-handlers/storage.ts +++ b/packages/core/src/event-handlers/storage.ts @@ -6,8 +6,21 @@ import type { DBRetrievalMessages } from '../models/utils/message' import type { CoreDialog } from '../types/dialog' import type { CoreMessage } from '../types/message' +import { defineInvokeHandler } from '@moeru/eventa' + +import { + messageFetchSpecificEvent, + storageChatNoteInvoke, + storageFetchDialogsInvoke, + storageFetchMessageContextInvoke, + storageFetchMessagesInvoke, + storageRecordChatFoldersEvent, + storageRecordDialogsEvent, + storageRecordMessagesEvent, + storageSearchMessagesInvoke, + storageSearchPhotosInvoke, +} from '../events' import { convertToCoreRetrievalMessages } from '../models/utils/message' -import { CoreEventType } from '../types/events' import { embedContents } from '../utils/embed' /** @@ -20,22 +33,21 @@ function hasNoMedia(message: CoreMessage): boolean { export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, dbModels: Models) { logger = logger.withContext('core:storage:event') - ctx.emitter.on(CoreEventType.StorageFetchMessages, async ({ chatId, pagination }) => { + defineInvokeHandler(ctx.ctx, storageFetchMessagesInvoke, async ({ chatId, pagination }) => { logger.withFields({ chatId, pagination }).verbose('Fetching messages') const accountId = ctx.getCurrentAccountId() const hasAccess = (await dbModels.chatModels.isChatAccessibleByAccount(ctx.getDB(), accountId, chatId)).expect('Failed to check chat access') if (!hasAccess) { - ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat messages') - return + throw ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat messages') } const messages = (await dbModels.chatMessageModels.fetchMessagesWithPhotos(ctx.getDB(), dbModels.photoModels, accountId, chatId, pagination)).unwrap() - ctx.emitter.emit(CoreEventType.StorageMessages, { messages }) + return { messages } }) - ctx.emitter.on(CoreEventType.StorageFetchMessageContext, async ({ chatId, messageId, before = 20, after = 20 }) => { + defineInvokeHandler(ctx.ctx, storageFetchMessageContextInvoke, async ({ chatId, messageId, before = 20, after = 20 }) => { const safeBefore = Math.max(0, before) const safeAfter = Math.max(0, after) @@ -45,8 +57,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d const hasAccess = (await dbModels.chatModels.isChatAccessibleByAccount(ctx.getDB(), accountId, chatId)).expect('Failed to check chat access') if (!hasAccess) { - ctx.withError('Unauthorized chat access', 'Account does not have access to requested message context') - return + throw ctx.withError('Unauthorized chat access', 'Account does not have access to requested message context') } const messages = (await dbModels.chatMessageModels.fetchMessageContextWithPhotos( @@ -56,9 +67,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d { chatId, messageId, before: safeBefore, after: safeAfter }, )).unwrap() - ctx.emitter.emit(CoreEventType.StorageMessagesContext, { chatId, messageId, messages }) - - // After emitting the initial messages, identify messages that might be missing media + // After returning the initial messages, identify messages that might be missing media // and trigger a fetch from Telegram to download them // We only fetch messages that have no media in the database, as media is optional // The media resolver will check if media already exists before downloading @@ -72,14 +81,13 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d // Fetch these specific messages from Telegram which will download any missing media // This is done asynchronously and will update the messages once media is downloaded - ctx.emitter.emit(CoreEventType.MessageFetchSpecific, { - chatId, - messageIds: messageIdsToFetch, - }) + ctx.ctx.emit(messageFetchSpecificEvent, { chatId, messageIds: messageIdsToFetch }) } + + return { chatId, messageId, messages } }) - ctx.emitter.on(CoreEventType.StorageRecordMessages, async ({ messages }) => { + ctx.ctx.on(storageRecordMessagesEvent, async ({ body: { messages } }) => { const accountId = ctx.getCurrentAccountId() await dbModels.chatMessageModels.recordMessages(ctx.getDB(), accountId, messages) @@ -87,7 +95,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d logger.withFields({ count: messages.length }).verbose('Messages recorded') }) - ctx.emitter.on(CoreEventType.StorageFetchDialogs, async (data) => { + defineInvokeHandler(ctx.ctx, storageFetchDialogsInvoke, async (data) => { logger.verbose('Fetching dialogs') const accountId = data?.accountId || ctx.getCurrentAccountId() @@ -112,10 +120,10 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d } satisfies CoreDialog }) - ctx.emitter.emit(CoreEventType.StorageDialogs, { dialogs }) + return { dialogs } }) - ctx.emitter.on(CoreEventType.StorageRecordDialogs, async ({ dialogs, accountId }) => { + ctx.ctx.on(storageRecordDialogsEvent, async ({ body: { dialogs, accountId } }) => { logger.withFields({ size: dialogs.length, users: dialogs.filter(d => d.type === 'user').length, @@ -132,7 +140,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d logger.withFields({ count: result.length }).verbose('Successfully recorded dialogs') }) - ctx.emitter.on(CoreEventType.StorageRecordChatFolders, async ({ folders, accountId }) => { + ctx.ctx.on(storageRecordChatFoldersEvent, async ({ body: { folders, accountId } }) => { logger.withFields({ count: folders.length }).verbose('Recording chat folders') const db = ctx.getDB() @@ -146,21 +154,20 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d logger.verbose('Successfully stored folder metadata') }) - ctx.emitter.on(CoreEventType.StorageSearchMessages, async (params) => { + defineInvokeHandler(ctx.ctx, storageSearchMessagesInvoke, async (params) => { logger.withFields({ params }).verbose('Searching messages') const accountId = ctx.getCurrentAccountId() if (params.content.length === 0) { - return + return { messages: [] } } if (params.chatId) { const hasAccess = (await dbModels.chatModels.isChatAccessibleByAccount(ctx.getDB(), accountId, params.chatId)).expect('Failed to check chat access') if (!hasAccess) { - ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat messages') - return + throw ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat messages') } } @@ -207,124 +214,115 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d const coreMessages = convertToCoreRetrievalMessages(dbMessages) - ctx.emitter.emit(CoreEventType.StorageSearchMessagesData, { messages: coreMessages }) + return { messages: coreMessages } }) - ctx.emitter.on(CoreEventType.StorageSearchPhotos, async (params) => { - try { - logger.withFields({ params }).log('StorageSearchPhotos event received') + defineInvokeHandler(ctx.ctx, storageSearchPhotosInvoke, async (params) => { + logger.withFields({ params }).log('StorageSearchPhotos event received') - if (params.content.length === 0) { - logger.verbose('Empty content, returning empty results') - ctx.emitter.emit(CoreEventType.StorageSearchPhotosData, { photos: [] }) - return - } + if (params.content.length === 0) { + logger.verbose('Empty content, returning empty results') + return { photos: [] } + } - const embeddingSettings = (await ctx.getAccountSettings()).embedding - const embeddingDimension = embeddingSettings.dimension - logger.withFields({ embeddingDimension }).verbose('Embedding settings loaded') - - let corePhotos: Array<{ - id: string - messageId: string | null - platformMessageId?: string - chatId?: string - chatName?: string - description: string - mimeType: string - createdAt: number - similarity?: number - }> = [] - - if (params.useVector) { - // Vector search - logger.verbose('Starting vector search for photos') - const embeddingResult = (await embedContents([params.content], embeddingSettings)).orUndefined() - if (!embeddingResult) { - logger.warn('Failed to generate embedding for photo search') - ctx.emitter.emit(CoreEventType.StorageSearchPhotosData, { photos: [] }) - return - } - - const embedding = embeddingResult.embeddings[0] - const limit = params.pagination?.limit || 10 - logger.withFields({ embeddingLength: embedding.length, limit }).verbose('Embedding generated, searching database') - - const results = (await dbModels.photoModels.searchPhotosByVector( - ctx.getDB(), - embedding, - embeddingDimension, - limit, - )).expect('Failed to search photos by vector') - - logger.withFields({ resultsCount: results.length }).verbose('Vector search completed') - - corePhotos = results.map(photo => ({ - id: photo.id, - messageId: photo.message_id, - platformMessageId: photo.platform_message_id, - chatId: photo.chat_id, - chatName: photo.chat_name || undefined, - description: photo.description, - mimeType: photo.image_mime_type, - createdAt: photo.created_at, - similarity: photo.similarity, - })) - } - else { - // Text search - logger.verbose('Starting text search for photos') - const limit = params.pagination?.limit || 10 - const results = (await dbModels.photoModels.searchPhotosByText( - ctx.getDB(), - params.content, - limit, - )).expect('Failed to search photos by text') - - logger.withFields({ resultsCount: results.length }).verbose('Text search completed') - - corePhotos = results.map(photo => ({ - id: photo.id, - messageId: photo.message_id, - platformMessageId: photo.platform_message_id, - chatId: photo.chat_id, - chatName: photo.chat_name || undefined, - description: photo.description, - mimeType: photo.image_mime_type, - createdAt: photo.created_at, - })) + const embeddingSettings = (await ctx.getAccountSettings()).embedding + const embeddingDimension = embeddingSettings.dimension + logger.withFields({ embeddingDimension }).verbose('Embedding settings loaded') + + let corePhotos: Array<{ + id: string + messageId: string | null + platformMessageId?: string + chatId?: string + chatName?: string + description: string + mimeType: string + createdAt: number + similarity?: number + }> = [] + + if (params.useVector) { + // Vector search + logger.verbose('Starting vector search for photos') + const embeddingResult = (await embedContents([params.content], embeddingSettings)).orUndefined() + if (!embeddingResult) { + logger.warn('Failed to generate embedding for photo search') + return { photos: [] } } - logger.withFields({ - corePhotosCount: corePhotos.length, - samplePhoto: corePhotos[0], - }).log('Emitting StorageSearchPhotosData event') - ctx.emitter.emit(CoreEventType.StorageSearchPhotosData, { photos: corePhotos }) + const embedding = embeddingResult.embeddings[0] + const limit = params.pagination?.limit || 10 + logger.withFields({ embeddingLength: embedding.length, limit }).verbose('Embedding generated, searching database') + + const results = (await dbModels.photoModels.searchPhotosByVector( + ctx.getDB(), + embedding, + embeddingDimension, + limit, + )).expect('Failed to search photos by vector') + + logger.withFields({ resultsCount: results.length }).verbose('Vector search completed') + + corePhotos = results.map(photo => ({ + id: photo.id, + messageId: photo.message_id, + platformMessageId: photo.platform_message_id, + chatId: photo.chat_id, + chatName: photo.chat_name || undefined, + description: photo.description, + mimeType: photo.image_mime_type, + createdAt: photo.created_at, + similarity: photo.similarity, + })) } - catch (error) { - logger.withError(error).error('Failed to search photos') - ctx.emitter.emit(CoreEventType.StorageSearchPhotosData, { photos: [] }) + else { + // Text search + logger.verbose('Starting text search for photos') + const limit = params.pagination?.limit || 10 + const results = (await dbModels.photoModels.searchPhotosByText( + ctx.getDB(), + params.content, + limit, + )).expect('Failed to search photos by text') + + logger.withFields({ resultsCount: results.length }).verbose('Text search completed') + + corePhotos = results.map(photo => ({ + id: photo.id, + messageId: photo.message_id, + platformMessageId: photo.platform_message_id, + chatId: photo.chat_id, + chatName: photo.chat_name || undefined, + description: photo.description, + mimeType: photo.image_mime_type, + createdAt: photo.created_at, + })) } + + logger.withFields({ + corePhotosCount: corePhotos.length, + samplePhoto: corePhotos[0], + }).log('Returning StorageSearchPhotos result') + return { photos: corePhotos } }) - ctx.emitter.on(CoreEventType.StorageChatNote, async ({ chatId, note, modify }) => { + defineInvokeHandler(ctx.ctx, storageChatNoteInvoke, async ({ chatId, note, modify }) => { logger.withFields({ chatId, note }).verbose('Recording chat note') const accountId = ctx.getCurrentAccountId() const hasAccess = (await dbModels.chatModels.isChatAccessibleByAccount(ctx.getDB(), accountId, chatId)).expect('Failed to check chat access') if (!hasAccess) { - ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat note') - return + throw ctx.withError('Unauthorized chat access', 'Account does not have access to requested chat note') } const note_result = await dbModels.chatModels.getOrModifyChatNote(ctx.getDB(), accountId, chatId, note, modify) if (note_result !== null) { logger.verbose('Successfully recorded chat note') - ctx.emitter.emit(CoreEventType.StorageChatNoteData, { chatId, note: note_result }) + return { chatId, note: note_result } } else { - ctx.withError('Failed to record chat note', 'Failed to record chat note') + throw ctx.withError('Failed to record chat note', 'Failed to record chat note') } }) } diff --git a/packages/core/src/event-handlers/takeout.ts b/packages/core/src/event-handlers/takeout.ts index 17e2c809b..48c84cf5f 100644 --- a/packages/core/src/event-handlers/takeout.ts +++ b/packages/core/src/event-handlers/takeout.ts @@ -1,18 +1,20 @@ import type { CoreContext } from '../context' import type { TakeoutService } from '../services' -import { CoreEventType } from '../types/events' +import { defineInvokeHandler } from '@moeru/eventa' + +import { takeoutRunEvent, takeoutStatsFetchInvoke, takeoutTaskAbortEvent } from '../events' export function registerTakeoutEventHandlers(ctx: CoreContext, takeoutService: TakeoutService) { - ctx.emitter.on(CoreEventType.TakeoutRun, async (params) => { + ctx.ctx.on(takeoutRunEvent, async ({ body: params }) => { await takeoutService.runTakeout(params) }) - ctx.emitter.on(CoreEventType.TakeoutTaskAbort, ({ taskId }) => { + ctx.ctx.on(takeoutTaskAbortEvent, ({ body: { taskId } }) => { takeoutService.abortTask(taskId) }) - ctx.emitter.on(CoreEventType.TakeoutStatsFetch, async ({ chatId }) => { - await takeoutService.fetchChatSyncStats(chatId) + defineInvokeHandler(ctx.ctx, takeoutStatsFetchInvoke, async ({ chatId }) => { + return await takeoutService.fetchChatSyncStats(chatId) }) } diff --git a/packages/core/src/events/auth.ts b/packages/core/src/events/auth.ts new file mode 100644 index 000000000..6dc8eb015 --- /dev/null +++ b/packages/core/src/events/auth.ts @@ -0,0 +1,20 @@ +import { defineEventa } from '@moeru/eventa' + +// ============================================================================ +// Auth commands (client → core) +// ============================================================================ + +export const authLoginEvent = defineEventa<{ phoneNumber?: string, session?: string }>('auth:login') +export const authLogoutEvent = defineEventa('auth:logout') +export const authCodeEvent = defineEventa<{ code: string }>('auth:code') +export const authPasswordEvent = defineEventa<{ password: string }>('auth:password') + +// ============================================================================ +// Auth notifications (core → client) +// ============================================================================ + +export const authCodeNeededEvent = defineEventa('auth:code:needed') +export const authPasswordNeededEvent = defineEventa('auth:password:needed') +export const authConnectedEvent = defineEventa('auth:connected') +export const authDisconnectedEvent = defineEventa('auth:disconnected') +export const authErrorEvent = defineEventa('auth:error') diff --git a/packages/core/src/events/bot.ts b/packages/core/src/events/bot.ts new file mode 100644 index 000000000..e4d144288 --- /dev/null +++ b/packages/core/src/events/bot.ts @@ -0,0 +1,22 @@ +import { defineEventa } from '@moeru/eventa' + +// ============================================================================ +// Bot commands (fire-and-forget) +// ============================================================================ + +/** Send a message via the Grammy bot */ +export const botSendMessageEvent = defineEventa<{ + chatId: string + content: string + parseMode?: 'HTML' | 'MarkdownV2' +}>('bot:send:message') + +// ============================================================================ +// Bot notifications (core → client) +// ============================================================================ + +/** Bot connection status update */ +export const botStatusEvent = defineEventa<{ + status: 'connected' | 'disconnected' | 'error' + botUsername?: string +}>('bot:status') diff --git a/packages/core/src/events/config.ts b/packages/core/src/events/config.ts new file mode 100644 index 000000000..ff80651cd --- /dev/null +++ b/packages/core/src/events/config.ts @@ -0,0 +1,15 @@ +import type { AccountSettings } from '../types/account-settings' + +import { defineInvokeEventa } from '@moeru/eventa' + +/** Fetch current account settings (RPC) */ +export const configFetchInvoke = defineInvokeEventa< + { accountSettings: AccountSettings }, + void +>('config:fetch') + +/** Update account settings (RPC) — returns the updated settings */ +export const configUpdateInvoke = defineInvokeEventa< + { accountSettings: AccountSettings }, + { accountSettings: AccountSettings } +>('config:update') diff --git a/packages/core/src/events/dialog.ts b/packages/core/src/events/dialog.ts new file mode 100644 index 000000000..bf51f2365 --- /dev/null +++ b/packages/core/src/events/dialog.ts @@ -0,0 +1,41 @@ +import type { CoreChatFolder, CoreDialog } from '../types/dialog' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +// ============================================================================ +// Dialog RPC +// ============================================================================ + +/** Fetch all dialogs from Telegram (RPC) */ +export const dialogFetchInvoke = defineInvokeEventa< + { dialogs: CoreDialog[] }, + void +>('dialog:fetch') + +/** Fetch chat folders from Telegram (RPC) */ +export const dialogFoldersFetchInvoke = defineInvokeEventa< + { folders: CoreChatFolder[] }, + void +>('dialog:folders:fetch') + +// ============================================================================ +// Dialog commands (fire-and-forget) +// ============================================================================ + +/** Request fetching a single dialog's avatar */ +export const dialogAvatarFetchEvent = defineEventa<{ chatId: number | string }>('dialog:avatar:fetch') + +/** Set or get a dialog note */ +export const dialogNoteEvent = defineEventa<{ chatId: string, note: string, modify: boolean }>('dialog:note') + +// ============================================================================ +// Dialog notifications (core → client) +// ============================================================================ + +/** Avatar data for a single dialog */ +export const dialogAvatarDataEvent = defineEventa<{ + chatId: number + byte: Uint8Array | { data: number[] } + mimeType: string + fileId?: string +}>('dialog:avatar:data') diff --git a/packages/core/src/events/entity.ts b/packages/core/src/events/entity.ts new file mode 100644 index 000000000..3cfb3da5b --- /dev/null +++ b/packages/core/src/events/entity.ts @@ -0,0 +1,31 @@ +import type { Api } from 'telegram' + +import { defineEventa } from '@moeru/eventa' + +// ============================================================================ +// Entity commands (fire-and-forget, mostly internal) +// ============================================================================ + +/** Process and cache user/chat entities from Telegram API */ +export const entityProcessEvent = defineEventa<{ users: Api.TypeUser[], chats: Api.TypeChat[] }>('entity:process') + +/** Lazy fetch of a user's avatar */ +export const entityAvatarFetchEvent = defineEventa<{ userId: string, fileId?: string }>('entity:avatar:fetch') + +/** Prime the core LRU cache with user avatar fileId from frontend IndexedDB */ +export const entityAvatarPrimeCacheEvent = defineEventa<{ userId: string, fileId: string }>('entity:avatar:prime-cache') + +/** Prime the core LRU cache with chat avatar fileId from frontend IndexedDB */ +export const entityChatAvatarPrimeCacheEvent = defineEventa<{ chatId: string, fileId: string }>('entity:chat-avatar:prime-cache') + +// ============================================================================ +// Entity notifications (core → client) +// ============================================================================ + +/** Avatar data for a single user */ +export const entityAvatarDataEvent = defineEventa<{ + userId: string + byte: Uint8Array | { data: number[] } + mimeType: string + fileId?: string +}>('entity:avatar:data') diff --git a/packages/core/src/events/gram.ts b/packages/core/src/events/gram.ts new file mode 100644 index 000000000..814019950 --- /dev/null +++ b/packages/core/src/events/gram.ts @@ -0,0 +1,11 @@ +import type { Api } from 'telegram' + +import { defineEventa } from '@moeru/eventa' + +/** Real-time message received from Telegram */ +export const gramMessageReceivedEvent = defineEventa<{ + message: Api.Message + pts?: number + date?: number + isChannel: boolean +}>('gram:message:received') diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts new file mode 100644 index 000000000..a82a18c25 --- /dev/null +++ b/packages/core/src/events/index.ts @@ -0,0 +1,13 @@ +export * from './auth' +export * from './bot' +export * from './config' +export * from './dialog' +export * from './entity' +export * from './gram' +export * from './instance' +export * from './message' +export * from './server' +export * from './session' +export * from './storage' +export * from './sync' +export * from './takeout' diff --git a/packages/core/src/events/instance.ts b/packages/core/src/events/instance.ts new file mode 100644 index 000000000..3fd7e37c7 --- /dev/null +++ b/packages/core/src/events/instance.ts @@ -0,0 +1,7 @@ +import { defineEventa } from '@moeru/eventa' + +/** Core cleanup request — signals all services to release resources */ +export const coreCleanupEvent = defineEventa('core:cleanup') + +/** Error notification — broadcast to all connected clients */ +export const coreErrorEvent = defineEventa<{ error: string, description?: string }>('core:error') diff --git a/packages/core/src/events/message.ts b/packages/core/src/events/message.ts new file mode 100644 index 000000000..95a4a0d57 --- /dev/null +++ b/packages/core/src/events/message.ts @@ -0,0 +1,81 @@ +import type { Api } from 'telegram' + +import type { FetchMessageOpts, FetchSummaryMessageOpts, FetchUnreadMessageOpts, SummaryMode, SyncOptions } from '../types/events' +import type { CoreMessage } from '../types/message' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +// ============================================================================ +// Message commands (fire-and-forget) +// ============================================================================ + +/** Fetch messages from Telegram with progress tracking */ +export const messageFetchEvent = defineEventa('message:fetch') + +/** Abort an ongoing message fetch task */ +export const messageFetchAbortEvent = defineEventa<{ taskId: string }>('message:fetch:abort') + +/** Fetch specific messages by ID from Telegram */ +export const messageFetchSpecificEvent = defineEventa<{ chatId: string, messageIds: number[] }>('message:fetch:specific') + +/** Send a message to a chat */ +export const messageSendEvent = defineEventa<{ chatId: string, content: string }>('message:send') + +/** Mark messages as read in a chat */ +export const messageReadEvent = defineEventa<{ chatId: string }>('message:read') + +/** Re-process specific messages to regenerate resolver outputs */ +export const messageReprocessEvent = defineEventa<{ chatId: string, messageIds: number[], resolvers?: string[] }>('message:reprocess') + +// ============================================================================ +// Message RPC +// ============================================================================ + +/** Fetch unread messages (RPC) */ +export const messageFetchUnreadInvoke = defineInvokeEventa< + { messages: CoreMessage[] }, + FetchUnreadMessageOpts +>('message:fetch:unread') + +/** Fetch message summary (RPC) */ +export const messageFetchSummaryInvoke = defineInvokeEventa< + { messages: CoreMessage[], mode: SummaryMode, requestId?: string }, + FetchSummaryMessageOpts +>('message:fetch:summary') + +// ============================================================================ +// Message notifications (core → client) +// ============================================================================ + +/** Message fetch progress update */ +export const messageFetchProgressEvent = defineEventa<{ taskId: string, progress: number }>('message:fetch:progress') + +/** New message data from Telegram (real-time or fetch result) */ +export const messageDataEvent = defineEventa<{ messages: CoreMessage[] }>('message:data') + +// ============================================================================ +// Internal message processing events +// ============================================================================ + +/** + * Process raw Telegram messages through resolvers. + * If `isTakeout` is true, suppresses browser-facing MessageData emissions. + */ +export const messageProcessEvent = defineEventa<{ + messages: Api.Message[] + isTakeout?: boolean + syncOptions?: SyncOptions + forceRefetch?: boolean + batchId?: string +}>('message:process') + +/** Batch processing completed notification */ +export const messageProcessedEvent = defineEventa<{ + batchId: string + count: number + resolverSpans: Array<{ + name: string + duration: number + count: number + }> +}>('message:processed') diff --git a/packages/core/src/events/server.ts b/packages/core/src/events/server.ts new file mode 100644 index 000000000..415048e53 --- /dev/null +++ b/packages/core/src/events/server.ts @@ -0,0 +1,7 @@ +import { defineEventa } from '@moeru/eventa' + +/** Server connection established — sent to client after WebSocket handshake */ +export const serverConnectedEvent = defineEventa<{ + sessionId: string + accountReady: boolean +}>('server:connected') diff --git a/packages/core/src/events/session.ts b/packages/core/src/events/session.ts new file mode 100644 index 000000000..9595020ed --- /dev/null +++ b/packages/core/src/events/session.ts @@ -0,0 +1,12 @@ +import type { CoreUserEntity } from '../types/events' + +import { defineEventa } from '@moeru/eventa' + +/** Session string updated — client should persist */ +export const sessionUpdateEvent = defineEventa<{ session: string }>('session:update') + +/** Account is fully initialized and ready */ +export const accountReadyEvent = defineEventa<{ accountId: string }>('account:ready') + +/** Current user entity data */ +export const entityMeDataEvent = defineEventa('entity:me:data') diff --git a/packages/core/src/events/storage.ts b/packages/core/src/events/storage.ts new file mode 100644 index 000000000..cfc271901 --- /dev/null +++ b/packages/core/src/events/storage.ts @@ -0,0 +1,60 @@ +import type { CorePagination } from '@tg-search/common' + +import type { CoreChatFolder, CoreDialog } from '../types/dialog' +import type { CoreMessageSearchParams, CorePhotoSearchParams, CoreRetrievalMessages, CoreRetrievalPhoto, StorageMessageContextParams } from '../types/events' +import type { CoreMessage } from '../types/message' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +// ============================================================================ +// Storage RPC (client-facing) +// ============================================================================ + +/** Fetch messages from DB (RPC) */ +export const storageFetchMessagesInvoke = defineInvokeEventa< + { messages: CoreMessage[] }, + { chatId: string, pagination: CorePagination } +>('storage:fetch:messages') + +/** Fetch dialogs from DB (RPC) */ +export const storageFetchDialogsInvoke = defineInvokeEventa< + { dialogs: CoreDialog[] }, + { accountId: string } +>('storage:fetch:dialogs') + +/** Search messages (RPC) */ +export const storageSearchMessagesInvoke = defineInvokeEventa< + { messages: CoreRetrievalMessages[] }, + CoreMessageSearchParams +>('storage:search:messages') + +/** Search photos (RPC) */ +export const storageSearchPhotosInvoke = defineInvokeEventa< + { photos: CoreRetrievalPhoto[] }, + CorePhotoSearchParams +>('storage:search:photos') + +/** Fetch message context — surrounding messages (RPC) */ +export const storageFetchMessageContextInvoke = defineInvokeEventa< + { messages: CoreMessage[] } & StorageMessageContextParams, + StorageMessageContextParams +>('storage:fetch:message-context') + +/** Get or modify a chat note (RPC) */ +export const storageChatNoteInvoke = defineInvokeEventa< + { chatId: string, note: string }, + { chatId: string, note: string, modify: boolean } +>('storage:chat-note') + +// ============================================================================ +// Storage commands (internal, fire-and-forget) +// ============================================================================ + +/** Record messages to DB */ +export const storageRecordMessagesEvent = defineEventa<{ messages: CoreMessage[] }>('storage:record:messages') + +/** Record dialogs to DB */ +export const storageRecordDialogsEvent = defineEventa<{ dialogs: CoreDialog[], accountId: string }>('storage:record:dialogs') + +/** Record chat folders to DB */ +export const storageRecordChatFoldersEvent = defineEventa<{ folders: CoreChatFolder[], accountId: string }>('storage:record:chat-folders') diff --git a/packages/core/src/events/sync.ts b/packages/core/src/events/sync.ts new file mode 100644 index 000000000..97ba307da --- /dev/null +++ b/packages/core/src/events/sync.ts @@ -0,0 +1,18 @@ +import { defineEventa } from '@moeru/eventa' + +// ============================================================================ +// Sync commands (fire-and-forget) +// ============================================================================ + +/** Catch up on missed updates via PTS/QTS */ +export const syncCatchUpEvent = defineEventa('sync:catch-up') + +/** Reset sync state */ +export const syncResetEvent = defineEventa('sync:reset') + +// ============================================================================ +// Sync notifications (core → client) +// ============================================================================ + +/** Sync status update */ +export const syncStatusEvent = defineEventa<{ status: 'idle' | 'syncing' | 'error', progress?: number }>('sync:status') diff --git a/packages/core/src/events/takeout.ts b/packages/core/src/events/takeout.ts new file mode 100644 index 000000000..83e03a333 --- /dev/null +++ b/packages/core/src/events/takeout.ts @@ -0,0 +1,34 @@ +import type { ChatSyncStats, SyncOptions, TakeoutMetrics } from '../types/events' +import type { CoreTaskData } from '../types/task' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +// ============================================================================ +// Takeout commands (fire-and-forget) +// ============================================================================ + +/** Start a takeout/export job */ +export const takeoutRunEvent = defineEventa<{ chatIds: string[], increase?: boolean, syncOptions?: SyncOptions }>('takeout:run') + +/** Abort a running takeout task */ +export const takeoutTaskAbortEvent = defineEventa<{ taskId: string }>('takeout:task:abort') + +// ============================================================================ +// Takeout RPC +// ============================================================================ + +/** Fetch sync statistics for a chat (RPC) */ +export const takeoutStatsFetchInvoke = defineInvokeEventa< + ChatSyncStats, + { chatId: string } +>('takeout:stats:fetch') + +// ============================================================================ +// Takeout notifications (core → client) +// ============================================================================ + +/** Task progress update */ +export const takeoutTaskProgressEvent = defineEventa>('takeout:task:progress') + +/** Task performance metrics */ +export const takeoutMetricsEvent = defineEventa('takeout:metrics') diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e50a0ac8b..83ef2a995 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export type * from './context' export { initDrizzle } from './db' export type { CoreDB, InitDrizzleResult } from './db' export type * from './event-handlers' +export * from './events' export { createCoreInstance, destroyCoreInstance } from './instance' export * from './models' export type * from './types' diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 867f99f42..2fec21999 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -9,8 +9,8 @@ import { useLogger } from '@guiiai/logg' import { createCoreContext } from './context' import { afterConnectedEventHandler, basicEventHandler, useEventHandler } from './event-handlers' +import { coreCleanupEvent } from './events' import { models } from './models' -import { CoreEventType } from './types/events' export function createCoreInstance( db: () => CoreDB, @@ -37,7 +37,7 @@ export function createCoreInstance( * It ensures complete cleanup of all resources to prevent memory leaks. * * Cleanup Sequence: - * 1. Emit CoreEventType.CoreCleanup event for backward compatibility + * 1. Emit coreCleanupEvent to notify all services * 2. Await registered handler cleanup hooks * 3. Disconnect Telegram Client * - Properly close the Telegram connection @@ -45,9 +45,8 @@ export function createCoreInstance( * * 4. Call ctx.cleanup() * - Removes ALL event listeners from emitter - * - Clears event tracking sets + * - Disposes Eventa context listeners * - Nullifies Telegram client reference - * - Stops memory leak detector interval (if in dev mode) * * Memory Safety: * - After this function, the CoreContext has no listeners, no timers, no references @@ -57,7 +56,7 @@ export function createCoreInstance( */ export async function destroyCoreInstance(ctx: CoreContext) { // Emit cleanup event to notify all services - ctx.emitter.emit(CoreEventType.CoreCleanup) + ctx.ctx.emit(coreCleanupEvent) // Give services time to cleanup // TODO: use Promise.allSettled to wait for all services to cleanup diff --git a/packages/core/src/message-resolvers/avatar-resolver.ts b/packages/core/src/message-resolvers/avatar-resolver.ts index c7bc0c2bd..1360c8e3e 100644 --- a/packages/core/src/message-resolvers/avatar-resolver.ts +++ b/packages/core/src/message-resolvers/avatar-resolver.ts @@ -14,7 +14,7 @@ import { Api } from 'telegram' import { lru } from 'tiny-lru' import { AVATAR_CACHE_TTL, AVATAR_DOWNLOAD_CONCURRENCY, MAX_AVATAR_CACHE_SIZE } from '../constants' -import { CoreEventType } from '../types/events' +import { dialogAvatarDataEvent, entityAvatarDataEvent } from '../events' /** * Shared avatar cache entry. @@ -230,7 +230,7 @@ function createAvatarHelper(ctx: CoreContext, logger: Logger) { const cachedEarly = cache.get(key) logger.withFields({ userId: key, expectedFileId: opts.expectedFileId, cachedFileId: cachedEarly?.fileId }).verbose('User avatar early cache validation') if (cachedEarly && cachedEarly.fileId === opts.expectedFileId && cachedEarly.byte && cachedEarly.mimeType) { - ctx.emitter.emit(CoreEventType.EntityAvatarData, { userId: key, byte: cachedEarly.byte, mimeType: cachedEarly.mimeType, fileId: opts.expectedFileId }) + ctx.ctx.emit(entityAvatarDataEvent, { userId: key, byte: cachedEarly.byte, mimeType: cachedEarly.mimeType, fileId: opts.expectedFileId }) return } } @@ -264,11 +264,11 @@ function createAvatarHelper(ctx: CoreContext, logger: Logger) { const cached = cache.get(key) if (cached && cached.byte && cached.mimeType && ((fileId && cached.fileId === fileId) || !fileId)) { if (isUser) { - ctx.emitter.emit(CoreEventType.EntityAvatarData, { userId: key, byte: cached.byte, mimeType: cached.mimeType, fileId }) + ctx.ctx.emit(entityAvatarDataEvent, { userId: key, byte: cached.byte, mimeType: cached.mimeType, fileId }) } else { const idNumCached = typeof idRaw === 'string' ? Number(idRaw) : idRaw - ctx.emitter.emit(CoreEventType.DialogAvatarData, { chatId: idNumCached, byte: cached.byte, mimeType: cached.mimeType, fileId }) + ctx.ctx.emit(dialogAvatarDataEvent, { chatId: idNumCached, byte: cached.byte, mimeType: cached.mimeType, fileId }) } return } @@ -304,11 +304,11 @@ function createAvatarHelper(ctx: CoreContext, logger: Logger) { } if (isUser) { - ctx.emitter.emit(CoreEventType.EntityAvatarData, { userId: key, byte: result.byte, mimeType: result.mimeType, fileId }) + ctx.ctx.emit(entityAvatarDataEvent, { userId: key, byte: result.byte, mimeType: result.mimeType, fileId }) } else { const idNum = typeof idRaw === 'string' ? Number(idRaw) : idRaw - ctx.emitter.emit(CoreEventType.DialogAvatarData, { chatId: idNum, byte: result.byte, mimeType: result.mimeType, fileId }) + ctx.ctx.emit(dialogAvatarDataEvent, { chatId: idNum, byte: result.byte, mimeType: result.mimeType, fileId }) } } catch (error) { @@ -408,7 +408,7 @@ function createAvatarHelper(ctx: CoreContext, logger: Logger) { chatAvatarCache.set(key, { fileId, mimeType: result.mimeType, byte: result.byte }) - ctx.emitter.emit(CoreEventType.DialogAvatarData, { chatId: id, byte: result.byte, mimeType: result.mimeType, fileId }) + ctx.ctx.emit(dialogAvatarDataEvent, { chatId: id, byte: result.byte, mimeType: result.mimeType, fileId }) } catch (error) { logger.withError(error as Error).warn('Failed to fetch avatar for dialog') diff --git a/packages/core/src/services/__test__/entity.test.ts b/packages/core/src/services/__test__/entity.test.ts index 940a819aa..86eac5b00 100644 --- a/packages/core/src/services/__test__/entity.test.ts +++ b/packages/core/src/services/__test__/entity.test.ts @@ -1,9 +1,10 @@ -import type { CoreContext, CoreEmitter } from '../../context' -import type { CoreUserEntity, FromCoreEvent, ToCoreEvent } from '../../types/events' +import type { CoreContext } from '../../context' +import type { CoreUserEntity } from '../../types/events' import bigInt from 'big-integer' import { useLogger } from '@guiiai/logg' +import { createContext } from '@moeru/eventa' import { eq } from 'drizzle-orm' import { Api } from 'telegram' import { describe, expect, it, vi } from 'vitest' @@ -32,11 +33,7 @@ function createMockCtx(db: any, client: any, accountId?: string) { const withError = vi.fn((error: unknown) => (error instanceof Error ? error : new Error(String(error)))) const ctx: CoreContext = { - emitter: { emit: vi.fn(), on: vi.fn() } as unknown as CoreEmitter, - toCoreEvents: new Set(), - fromCoreEvents: new Set(), - wrapEmitterEmit: () => {}, - wrapEmitterOn: () => {}, + ctx: createContext(), setClient: () => {}, getClient: () => client, setCurrentAccountId: () => {}, diff --git a/packages/core/src/services/__test__/takeout.test.ts b/packages/core/src/services/__test__/takeout.test.ts index 0f59ca549..b1f0fc5c4 100644 --- a/packages/core/src/services/__test__/takeout.test.ts +++ b/packages/core/src/services/__test__/takeout.test.ts @@ -1,15 +1,16 @@ -import type { CoreContext, CoreEmitter } from '../../context' +import type { CoreContext } from '../../context' import type { CoreDB } from '../../db' import type { AccountSettings } from '../../types/account-settings' -import type { CoreUserEntity, FromCoreEvent, ToCoreEvent } from '../../types/events' +import type { CoreUserEntity } from '../../types/events' import bigInt from 'big-integer' import { useLogger } from '@guiiai/logg' +import { createContext } from '@moeru/eventa' import { Api } from 'telegram' import { describe, expect, it, vi } from 'vitest' -import { CoreEventType } from '../../types/events' +import { messageProcessedEvent, messageProcessEvent } from '../../events' import { createTask as createCoreTask } from '../../utils/task' import { createTakeoutService } from '../takeout' @@ -35,30 +36,11 @@ vi.mock('../../utils/min-interval', () => { }) function createMockCtx(client: any) { + const eventaCtx = createContext() const withError = vi.fn((error: unknown) => (error instanceof Error ? error : new Error(String(error)))) - // Minimal event emitter stub for processMessageBatch/runTakeout flows. - const handlers = new Map void>>() - const emitter = { - on: vi.fn((event: string, handler: (...args: any[]) => void) => { - const set = handlers.get(event) ?? new Set() - set.add(handler) - handlers.set(event, set) - }), - off: vi.fn((event: string, handler: (...args: any[]) => void) => { - handlers.get(event)?.delete(handler) - }), - emit: vi.fn((event: string, payload: any) => { - handlers.get(event)?.forEach(fn => fn(payload)) - }), - } as unknown as CoreEmitter - const ctx: CoreContext = { - emitter, - toCoreEvents: new Set(), - fromCoreEvents: new Set(), - wrapEmitterEmit: () => {}, - wrapEmitterOn: () => {}, + ctx: eventaCtx, setClient: () => {}, getClient: () => client, setCurrentAccountId: () => {}, @@ -77,9 +59,8 @@ function createMockCtx(client: any) { } function createTask() { - // Minimal emitter stub for CoreTask -> task.ts only calls emitter.emit(...) - const emitter = { emit: vi.fn() } as unknown as CoreEmitter - return createCoreTask('takeout', { chatIds: ['123'] }, emitter, logger) + const eventaCtx = createContext() + return createCoreTask('takeout', { chatIds: ['123'] }, eventaCtx, logger) } describe('takeout service', () => { @@ -535,8 +516,8 @@ describe('takeout service', () => { const { ctx } = createMockCtx(client) // Auto-complete message processing batches to avoid hanging on pendingBatches. - ctx.emitter.on(CoreEventType.MessageProcess, ({ messages, batchId }) => { - ctx.emitter.emit(CoreEventType.MessageProcessed, { + ctx.ctx.on(messageProcessEvent, ({ body: { messages, batchId } }) => { + ctx.ctx.emit(messageProcessedEvent, { batchId: batchId ?? 'batch-id', count: messages.length, resolverSpans: [], diff --git a/packages/core/src/services/account-settings.ts b/packages/core/src/services/account-settings.ts index 62b82d5a0..18f9130ed 100644 --- a/packages/core/src/services/account-settings.ts +++ b/packages/core/src/services/account-settings.ts @@ -7,7 +7,6 @@ import { toSafePresenceFlag } from '@tg-search/common' import { safeParse } from 'valibot' import { accountSettingsSchema } from '../types' -import { CoreEventType } from '../types/events' export type AccountSettingsService = ReturnType @@ -27,15 +26,12 @@ export function createAccountSettingsService(ctx: CoreContext, logger: Logger) { } } - async function fetchAccountSettings() { + async function fetchAccountSettings(): Promise { logger.verbose('Fetching account settings') - - const accountSettings = await ctx.getAccountSettings() - - ctx.emitter.emit(CoreEventType.ConfigData, { accountSettings }) + return ctx.getAccountSettings() } - async function setAccountSettings(accountSettings: AccountSettings) { + async function setAccountSettings(accountSettings: AccountSettings): Promise { logger.withFields(toSettingsLogSummary(accountSettings)).verbose('Setting account settings') const parsedAccountSettings = safeParse(accountSettingsSchema, accountSettings) @@ -45,8 +41,7 @@ export function createAccountSettingsService(ctx: CoreContext, logger: Logger) { } await ctx.setAccountSettings(parsedAccountSettings.output) - - ctx.emitter.emit(CoreEventType.ConfigData, { accountSettings: parsedAccountSettings.output }) + return parsedAccountSettings.output } return { diff --git a/packages/core/src/services/account.ts b/packages/core/src/services/account.ts index 687067447..46b952d27 100644 --- a/packages/core/src/services/account.ts +++ b/packages/core/src/services/account.ts @@ -7,7 +7,7 @@ import type { CoreUserEntity } from '../types/events' import { withSpan } from '@tg-search/observability' import { Ok } from '@unbird/result' -import { CoreEventType } from '../types/events' +import { entityMeDataEvent } from '../events' import { resolveEntity } from '../utils/entity' export type AccountService = ReturnType @@ -21,7 +21,7 @@ export function createAccountService(ctx: CoreContext, logger: Logger) { const apiUser = await ctx.getClient().getMe() const result = resolveEntity(apiUser).expect('Failed to resolve entity') as CoreUserEntity - ctx.emitter.emit(CoreEventType.EntityMeData, result) + ctx.ctx.emit(entityMeDataEvent, result) return Ok(result) }) } diff --git a/packages/core/src/services/connection.ts b/packages/core/src/services/connection.ts index 4fe331cd8..0a934c5e2 100644 --- a/packages/core/src/services/connection.ts +++ b/packages/core/src/services/connection.ts @@ -11,7 +11,7 @@ import { Api, TelegramClient } from 'telegram' import { ConnectionTCPObfuscated } from 'telegram/network' import { StringSession } from 'telegram/sessions' -import { CoreEventType } from '../types/events' +import { authCodeEvent, authCodeNeededEvent, authConnectedEvent, authDisconnectedEvent, authErrorEvent, authPasswordEvent, authPasswordNeededEvent, sessionUpdateEvent } from '../events' import { waitForEvent } from '../utils/promise' export type ConnectionService = ReturnType @@ -131,8 +131,8 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option if (!isAuthorized) { // Surface this as an auth-specific error so the frontend can fall // back to manual login and optionally clear the stored session. - ctx.emitter.emit(CoreEventType.AuthError) - ctx.emitter.emit(CoreEventType.AuthDisconnected) + ctx.ctx.emit(authErrorEvent) + ctx.ctx.emit(authDisconnectedEvent) return Err(ctx.withError('User is not authorized')) } @@ -141,7 +141,7 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option logger.withFields({ hasSession: !!sessionString }).verbose('Forwarding session to client') // 1) Forward updated session to frontend so it can persist it. - ctx.emitter.emit(CoreEventType.SessionUpdate, { session: sessionString }) + ctx.ctx.emit(sessionUpdateEvent, { session: sessionString }) // 2) Attach client to context for subsequent services. ctx.setClient(client) @@ -149,14 +149,14 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option // 3) Finally signal that auth is connected; this will trigger // afterConnectedEventHandler, which will establish current // account ID and bootstrap dialogs/storage. - ctx.emitter.emit(CoreEventType.AuthConnected) + ctx.ctx.emit(authConnectedEvent) logger.log('Login with session successful') return Ok(client) } catch (error) { - ctx.emitter.emit(CoreEventType.AuthError) + ctx.ctx.emit(authErrorEvent) return Err(ctx.withError(error, 'Failed to login with session')) } } @@ -176,21 +176,21 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option logger.withFields({ hasSession: !!sessionString }).verbose('Forwarding session to client') // 1) Forward updated session - ctx.emitter.emit(CoreEventType.SessionUpdate, { session: sessionString }) + ctx.ctx.emit(sessionUpdateEvent, { session: sessionString }) // 2) Attach client ctx.setClient(client) // 3) Notify connected; afterConnectedEventHandler will establish // current account ID and bootstrap dialogs/storage. - ctx.emitter.emit(CoreEventType.AuthConnected) + ctx.ctx.emit(authConnectedEvent) logger.log('Login with phone successful') return Ok(client) } catch (error) { - ctx.emitter.emit(CoreEventType.AuthError) + ctx.ctx.emit(authErrorEvent) return Err(ctx.withError(error, 'Failed to login with phone')) } } @@ -206,18 +206,18 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option phoneNumber, phoneCode: async () => { logger.verbose('Waiting for code') - ctx.emitter.emit(CoreEventType.AuthCodeNeeded) - const { code } = await waitForEvent(ctx.emitter, CoreEventType.AuthCode) + ctx.ctx.emit(authCodeNeededEvent) + const { code } = await waitForEvent(ctx.ctx, authCodeEvent) return code }, password: async () => { logger.verbose('Waiting for password') - ctx.emitter.emit(CoreEventType.AuthPasswordNeeded) - const { password } = await waitForEvent(ctx.emitter, CoreEventType.AuthPassword) + ctx.ctx.emit(authPasswordNeededEvent) + const { password } = await waitForEvent(ctx.ctx, authPasswordEvent) return password }, onError: (error) => { - ctx.emitter.emit(CoreEventType.AuthError) + ctx.ctx.emit(authErrorEvent) reject(ctx.withError(error, 'Failed to sign in to Telegram')) }, }) @@ -230,7 +230,7 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option if (client.connected) { await client.invoke(new Api.auth.LogOut()) await client.disconnect() - ctx.emitter.emit(CoreEventType.AuthDisconnected) + ctx.ctx.emit(authDisconnectedEvent) } client.session.delete() diff --git a/packages/core/src/services/dialog.ts b/packages/core/src/services/dialog.ts index d0f063eb0..af98a24b7 100644 --- a/packages/core/src/services/dialog.ts +++ b/packages/core/src/services/dialog.ts @@ -12,8 +12,8 @@ import { withSpan } from '@tg-search/observability' import { Ok } from '@unbird/result' import { Api } from 'telegram' +import { entityProcessEvent } from '../events' import { useAvatarHelper } from '../message-resolvers/avatar-resolver' -import { CoreEventType } from '../types/events' import { getApiChatIdFromMtpPeer, resolveDialog } from '../utils/dialog' export type DialogService = ReturnType @@ -71,8 +71,6 @@ export function createDialogService(ctx: CoreContext, logger: Logger) { logger.withFields({ count: folders.length }).verbose('Fetched chat folders') - ctx.emitter.emit(CoreEventType.DialogFoldersData, { folders }) - return Ok(folders) }) } @@ -155,7 +153,7 @@ export function createDialogService(ctx: CoreContext, logger: Logger) { if (result instanceof Api.contacts.Contacts) { logger.withFields({ count: result.users.length }).verbose('Fetched contacts') // Process entities to save access hashes - ctx.emitter.emit(CoreEventType.EntityProcess, { users: result.users, chats: [] }) + ctx.ctx.emit(entityProcessEvent, { users: result.users, chats: [] }) } } catch (err) { diff --git a/packages/core/src/services/gram-events.ts b/packages/core/src/services/gram-events.ts index 50cdc40de..5098b16d6 100644 --- a/packages/core/src/services/gram-events.ts +++ b/packages/core/src/services/gram-events.ts @@ -6,7 +6,7 @@ import type { CoreContext } from '../context' import { Api } from 'telegram' import { NewMessage, NewMessageEvent } from 'telegram/events' -import { CoreEventType } from '../types/events' +import { coreCleanupEvent, gramMessageReceivedEvent } from '../events' export type GramEventsService = ReturnType @@ -51,7 +51,7 @@ export function createGramEventsService(ctx: CoreContext, logger: Logger) { isChannel = event.message.peerId instanceof Api.PeerChannel } - ctx.emitter.emit(CoreEventType.GramMessageReceived, { + ctx.ctx.emit(gramMessageReceivedEvent, { message: event.message, pts, date: event.message.date, @@ -83,7 +83,7 @@ export function createGramEventsService(ctx: CoreContext, logger: Logger) { } // Listen for cleanup event - ctx.emitter.once(CoreEventType.CoreCleanup, cleanup) + ctx.ctx.once(coreCleanupEvent, cleanup) return { registerGramEvents, diff --git a/packages/core/src/services/message-resolver.ts b/packages/core/src/services/message-resolver.ts index 0fbe17756..f4cabae7a 100644 --- a/packages/core/src/services/message-resolver.ts +++ b/packages/core/src/services/message-resolver.ts @@ -5,8 +5,8 @@ import type { CoreContext } from '../context' import type { MessageResolver, MessageResolverRegistryFn } from '../message-resolvers' import type { SyncOptions } from '../types/events' +import { messageDataEvent, messageProcessedEvent, storageRecordMessagesEvent } from '../events' import { chatMessageModels } from '../models/chat-message' -import { CoreEventType } from '../types/events' import { convertToCoreMessage } from '../utils/message' export type MessageResolverService = ReturnType @@ -49,7 +49,7 @@ export function createMessageResolverService( // Return the messages to client first. if (!options.takeout) { - ctx.emitter.emit(CoreEventType.MessageData, { messages: coreMessages }) + ctx.ctx.emit(messageDataEvent, { messages: coreMessages }) } // Storage the messages first and get the actual DB IDs @@ -94,16 +94,16 @@ export function createMessageResolverService( const result = (await resolver.run(opts)).unwrap() if (result.length > 0) { - ctx.emitter.emit(CoreEventType.StorageRecordMessages, { messages: result }) + ctx.ctx.emit(storageRecordMessagesEvent, { messages: result }) } } else if (resolver.stream) { for await (const message of resolver.stream(opts)) { if (!options.takeout) { - ctx.emitter.emit(CoreEventType.MessageData, { messages: [message] }) + ctx.ctx.emit(messageDataEvent, { messages: [message] }) } - ctx.emitter.emit(CoreEventType.StorageRecordMessages, { messages: [message] }) + ctx.ctx.emit(storageRecordMessagesEvent, { messages: [message] }) } } } @@ -154,7 +154,7 @@ export function createMessageResolverService( await Promise.allSettled(promises) if (options.batchId) { - ctx.emitter.emit(CoreEventType.MessageProcessed, { + ctx.ctx.emit(messageProcessedEvent, { batchId: options.batchId, count: coreMessages.length, resolverSpans, diff --git a/packages/core/src/services/sync.ts b/packages/core/src/services/sync.ts index 7e6bc60bf..c7307a7b0 100644 --- a/packages/core/src/services/sync.ts +++ b/packages/core/src/services/sync.ts @@ -4,8 +4,8 @@ import type { CoreContext } from '../context' import { Api } from 'telegram' +import { entityProcessEvent, messageProcessEvent, syncStatusEvent, takeoutRunEvent } from '../events' import { accountModels } from '../models/accounts' -import { CoreEventType } from '../types/events' export function createSyncService( ctx: CoreContext, @@ -73,7 +73,7 @@ export function createSyncService( gap: targetPts - account.pts, }).log('Starting catch-up sync') - ctx.emitter.emit(CoreEventType.SyncStatus, { status: 'syncing' }) + ctx.ctx.emit(syncStatusEvent, { status: 'syncing' }) let currentPts = account.pts let currentQts = account.qts @@ -106,7 +106,7 @@ export function createSyncService( lastSyncAt: Date.now(), }) - ctx.emitter.emit(CoreEventType.TakeoutRun, { chatIds: [], increase: true, syncOptions: {} }) + ctx.ctx.emit(takeoutRunEvent, { chatIds: [], increase: true, syncOptions: {} }) break } @@ -114,7 +114,7 @@ export function createSyncService( const users = 'users' in difference ? difference.users : [] const chats = 'chats' in difference ? difference.chats : [] if (users.length > 0 || chats.length > 0) { - ctx.emitter.emit(CoreEventType.EntityProcess, { users, chats }) + ctx.ctx.emit(entityProcessEvent, { users, chats }) } // Handle messages @@ -130,7 +130,7 @@ export function createSyncService( }).log('Syncing messages batch (Text only)') // TODO: sync media, with delete at - ctx.emitter.emit(CoreEventType.MessageProcess, { + ctx.ctx.emit(messageProcessEvent, { messages: validMessages, isTakeout: false, // Skip expensive side-effects during massive catch-up to avoid bans @@ -174,12 +174,12 @@ export function createSyncService( } } - ctx.emitter.emit(CoreEventType.SyncStatus, { status: 'idle' }) + ctx.ctx.emit(syncStatusEvent, { status: 'idle' }) logger.log('Sync process finished', { finalPts: currentPts }) } catch (error) { ctx.withError(error, 'Catch-up sync failed') - ctx.emitter.emit(CoreEventType.SyncStatus, { status: 'error' }) + ctx.ctx.emit(syncStatusEvent, { status: 'error' }) } finally { isSyncing = false diff --git a/packages/core/src/services/takeout.ts b/packages/core/src/services/takeout.ts index 5891b0daa..dcbaa7b0b 100644 --- a/packages/core/src/services/takeout.ts +++ b/packages/core/src/services/takeout.ts @@ -14,7 +14,7 @@ import { Err, Ok } from '@unbird/result' import { Api } from 'telegram' import { MESSAGE_PROCESS_BATCH_SIZE, TELEGRAM_HISTORY_INTERVAL_MS } from '../constants' -import { CoreEventType } from '../types/events' +import { messageProcessedEvent, messageProcessEvent, takeoutMetricsEvent } from '../events' import { createMinIntervalWaiter } from '../utils/min-interval' import { createTask } from '../utils/task' @@ -372,7 +372,7 @@ export function createTakeoutService( const downloadSpeed = elapsedSec > 0 ? downloadCount / elapsedSec : 0 const processSpeed = elapsedSec > 0 ? processedCount / elapsedSec : 0 - ctx.emitter.emit(CoreEventType.TakeoutMetrics, { + ctx.ctx.emit(takeoutMetricsEvent, { taskId: task.state.taskId, downloadSpeed, processSpeed, @@ -385,7 +385,7 @@ export function createTakeoutService( } } - ctx.emitter.on(CoreEventType.MessageProcessed, onMessageProcessed) + const unsubscribe = ctx.ctx.on(messageProcessedEvent, ({ body }) => onMessageProcessed(body)) try { for await (const message of generator) { @@ -408,7 +408,7 @@ export function createTakeoutService( const batchId = `${task.state.taskId}-${batchSeq++}` pendingBatches.add(batchId) - ctx.emitter.emit(CoreEventType.MessageProcess, { messages, isTakeout: true, syncOptions, batchId }) + ctx.ctx.emit(messageProcessEvent, { messages, isTakeout: true, syncOptions, batchId }) messages = [] // Update metrics (even if not processed yet, for download speed visibility) @@ -417,7 +417,7 @@ export function createTakeoutService( const downloadSpeed = elapsedSec > 0 ? downloadCount / elapsedSec : 0 const processSpeed = elapsedSec > 0 ? processedCount / elapsedSec : 0 - ctx.emitter.emit(CoreEventType.TakeoutMetrics, { + ctx.ctx.emit(takeoutMetricsEvent, { taskId: task.state.taskId, downloadSpeed, processSpeed, @@ -431,7 +431,7 @@ export function createTakeoutService( if (messages.length > 0 && !task.state.abortController.signal.aborted) { const batchId = `${task.state.taskId}-${batchSeq++}` pendingBatches.add(batchId) - ctx.emitter.emit(CoreEventType.MessageProcess, { messages, isTakeout: true, syncOptions, batchId }) + ctx.ctx.emit(messageProcessEvent, { messages, isTakeout: true, syncOptions, batchId }) } // Wait for all pending batches to complete @@ -440,7 +440,7 @@ export function createTakeoutService( } } finally { - ctx.emitter.off(CoreEventType.MessageProcessed, onMessageProcessed) + unsubscribe() } return !task.state.abortController.signal.aborted @@ -467,7 +467,7 @@ export function createTakeoutService( logger.withFields({ chatId, totalCount, hasStats: !!stats }).log('Starting takeout for chat') - const task = createTask('takeout', { chatIds: [chatId], totalMessages: totalCount }, ctx.emitter, logger) + const task = createTask('takeout', { chatIds: [chatId], totalMessages: totalCount }, ctx.ctx, logger) activeTasks.set(task.state.taskId, task) try { @@ -597,7 +597,7 @@ export function createTakeoutService( syncedRanges, } - ctx.emitter.emit(CoreEventType.TakeoutStatsData, chatSyncStats) + return chatSyncStats } catch (error) { logger.withError(error).error('Failed to fetch chat sync stats') diff --git a/packages/core/src/utils/__test__/task.test.ts b/packages/core/src/utils/__test__/task.test.ts index 396a9e43b..67f3c54a7 100644 --- a/packages/core/src/utils/__test__/task.test.ts +++ b/packages/core/src/utils/__test__/task.test.ts @@ -1,26 +1,24 @@ -import type { MockedFunction } from 'vitest' - -import type { CoreEmitter } from '../../context' - import { useLogger } from '@guiiai/logg' +import { createContext } from '@moeru/eventa' import { describe, expect, it, vi } from 'vitest' -import { CoreEventType } from '../../types/events' +import { takeoutTaskProgressEvent } from '../../events' import { createTask } from '../task' const logger = useLogger() describe('utils/task - createTask', () => { it('should emit takeout:task:progress on updateProgress for takeout task', () => { - const emitter = { emit: vi.fn() } as unknown as CoreEmitter + const eventaCtx = createContext() + const emitSpy = vi.spyOn(eventaCtx, 'emit') - const task = createTask('takeout', { chatIds: ['1', '2'] }, emitter, logger) + const task = createTask('takeout', { chatIds: ['1', '2'] }, eventaCtx, logger) task.updateProgress(10, 'hello') - expect(emitter.emit).toHaveBeenCalledTimes(1) - expect(emitter.emit).toHaveBeenCalledWith( - CoreEventType.TakeoutTaskProgress, + expect(emitSpy).toHaveBeenCalledTimes(1) + expect(emitSpy).toHaveBeenCalledWith( + takeoutTaskProgressEvent, expect.objectContaining({ taskId: expect.any(String), type: 'takeout', @@ -33,23 +31,24 @@ describe('utils/task - createTask', () => { ) // toJSON payload must not expose abortController - const payload = (emitter.emit as MockedFunction).mock.calls[0][1] + const payload = emitSpy.mock.calls[0][1] expect(payload).not.toHaveProperty('abortController') }) it('should set progress=-1 and emit on updateError for takeout task', () => { - const emitter = { emit: vi.fn() } as unknown as CoreEmitter + const eventaCtx = createContext() + const emitSpy = vi.spyOn(eventaCtx, 'emit') - const task = createTask('takeout', { chatIds: ['x'] }, emitter, logger) + const task = createTask('takeout', { chatIds: ['x'] }, eventaCtx, logger) task.updateError(new Error('boom')) expect(task.state.progress).toBe(-1) expect(task.state.lastError).toBe('boom') - expect(emitter.emit).toHaveBeenCalledTimes(1) - expect(emitter.emit).toHaveBeenCalledWith( - CoreEventType.TakeoutTaskProgress, + expect(emitSpy).toHaveBeenCalledTimes(1) + expect(emitSpy).toHaveBeenCalledWith( + takeoutTaskProgressEvent, expect.objectContaining({ type: 'takeout', progress: -1, @@ -59,9 +58,10 @@ describe('utils/task - createTask', () => { }) it('abort should abort signal and set error', () => { - const emitter = { emit: vi.fn() } as unknown as CoreEmitter + const eventaCtx = createContext() + const emitSpy = vi.spyOn(eventaCtx, 'emit') - const task = createTask('takeout', { chatIds: ['x'] }, emitter, logger) + const task = createTask('takeout', { chatIds: ['x'] }, eventaCtx, logger) task.abort() @@ -70,15 +70,16 @@ describe('utils/task - createTask', () => { expect(task.state.lastError).toBe('Task aborted') // abort internally calls updateError, which emits once - expect(emitter.emit).toHaveBeenCalledTimes(1) + expect(emitSpy).toHaveBeenCalledTimes(1) }) it('should not emit takeout progress events for non-takeout task types', () => { - const emitter = { emit: vi.fn() } as unknown as CoreEmitter + const eventaCtx = createContext() + const emitSpy = vi.spyOn(eventaCtx, 'emit') - const task = createTask('embed', undefined, emitter, logger) + const task = createTask('embed', undefined, eventaCtx, logger) task.updateProgress(1) - expect(emitter.emit).not.toHaveBeenCalled() + expect(emitSpy).not.toHaveBeenCalled() }) }) diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index 32d340ca8..d68cd1a20 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -1,15 +1,12 @@ -import type { CoreEmitter, CoreEvent, ExtractData } from '../context' +import type { Eventa, EventContext } from '@moeru/eventa' -export function waitForEvent( - emitter: CoreEmitter, - event: E, -): Promise> { +export function waitForEvent

( + ctx: EventContext, + event: Eventa

, +): Promise

{ return new Promise((resolve) => { - // emitter.once(event, (data) => { - // resolve(data) - - emitter.once(event, (...args) => { - resolve(args[0] as ExtractData) + ctx.once(event, ({ body }) => { + resolve(body) }) }) } diff --git a/packages/core/src/utils/task.ts b/packages/core/src/utils/task.ts index 39320530b..55ea985f4 100644 --- a/packages/core/src/utils/task.ts +++ b/packages/core/src/utils/task.ts @@ -1,11 +1,11 @@ import type { Logger } from '@guiiai/logg' +import type { EventContext } from '@moeru/eventa' -import type { CoreEmitter } from '../context' import type { CoreTask, CoreTaskData, CoreTasks, CoreTaskType } from '../types/task' import { v4 as uuidv4 } from 'uuid' -import { CoreEventType } from '../types/events' +import { takeoutTaskProgressEvent } from '../events' /** * Create a task that manages its own state @@ -13,7 +13,7 @@ import { CoreEventType } from '../types/events' export function createTask( type: T, metadata: CoreTasks[T], - emitter: CoreEmitter, + ctx: EventContext, logger: Logger, ): CoreTask { logger = logger.withContext('core:task') @@ -32,7 +32,7 @@ export function createTask( const emitUpdate = () => { if (type === 'takeout') { - emitter.emit(CoreEventType.TakeoutTaskProgress, task.toJSON() as any) + ctx.emit(takeoutTaskProgressEvent, task.toJSON() as any) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4df003630..771282279 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@guiiai/logg': specifier: 'catalog:' version: 1.2.11 + '@moeru/eventa': + specifier: 1.0.0-alpha.11 + version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@node-rs/jieba': specifier: ^2.0.1 version: 2.0.1 @@ -350,6 +353,9 @@ importers: packages/client: dependencies: + '@moeru/eventa': + specifier: 1.0.0-alpha.11 + version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@tg-search/common': specifier: workspace:* version: link:../common @@ -419,7 +425,7 @@ importers: version: 1.2.0 '@moeru/eventa': specifier: 1.0.0-alpha.11 - version: 1.0.0-alpha.11 + version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@moeru/std': specifier: 0.1.0-beta.15 version: 0.1.0-beta.15 @@ -6812,6 +6818,7 @@ packages: unplugin-vue-router@0.19.2: resolution: {integrity: sha512-u5dgLBarxE5cyDK/hzJGfpCTLIAyiTXGlo85COuD4Nssj6G7NxS+i9mhCWz/1p/ud1eMwdcUbTXehQe41jYZUA==} + deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html' peerDependencies: '@vue/compiler-sfc': ^3.5.17 vue-router: ^4.6.0 @@ -8798,10 +8805,12 @@ snapshots: - supports-color - typescript - '@moeru/eventa@1.0.0-alpha.11': + '@moeru/eventa@1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)))': dependencies: nanoid: 5.1.6 picomatch: 4.0.3 + optionalDependencies: + h3: 2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)) '@moeru/std@0.1.0-beta.15': {} From c4386ea3461486888075acf3cd10bd20066239f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 10:18:26 +0000 Subject: [PATCH 2/4] refactor(core, server, bot, client): complete Eventa migration Phase 4 Migrate server WebSocket bridge, bot package, and client core-bridge to use @moeru/eventa event system, completing the Phase 4 refactor: - Create shared event dispatch maps (fireAndForgetEvents, rpcEvents, notificationEvents) in packages/core/src/events/dispatch.ts - Refactor server app.ts and event-dispatch.ts to use shared maps for dispatching WS messages to Eventa events - Refactor server account.ts with notification event broadcasting via typed Eventa event objects - Migrate bot bridge.ts, export.ts, and summary.ts to Eventa events (summary.ts uses defineInvoke for RPC) - Fix client core-bridge.ts to use Eventa context (ctx.ctx) instead of removed emitter property - Add onEvent/onceEvent/waitForEvent helpers in utils/promise.ts for safe body unwrapping (Eventa wraps payload in { body?: P }) - Fix CoreContext.ctx type to EventContext for InvocableEventContext compatibility - Fix void event emits to pass explicit undefined - Remove unused memory-leak-detector.ts - Simplify server events.ts types with Parameters<> pattern - All event handler files updated to use onEvent helper - All tests passing, typecheck clean across all packages https://claude.ai/code/session_01JgqUCyTk9N37FSGY6nc97W --- apps/server/src/account.ts | 104 +++++++++++---- apps/server/src/app.ts | 49 ++----- apps/server/src/event-dispatch.ts | 45 +++++++ apps/server/src/events.ts | 55 +++++--- packages/bot/package.json | 1 + packages/bot/src/bridge.ts | 8 +- packages/bot/src/commands/export.ts | 4 +- packages/bot/src/commands/summary.ts | 57 +------- packages/client/src/adapters/core-bridge.ts | 50 +++++-- packages/core/src/context.ts | 4 +- .../event-handlers/__test__/message.test.ts | 7 +- .../event-handlers/__test__/storage.test.ts | 5 +- packages/core/src/event-handlers/auth.ts | 3 +- packages/core/src/event-handlers/dialog.ts | 3 +- packages/core/src/event-handlers/entity.ts | 9 +- .../core/src/event-handlers/gram-events.ts | 3 +- packages/core/src/event-handlers/index.ts | 3 +- .../src/event-handlers/message-resolver.ts | 3 +- packages/core/src/event-handlers/message.ts | 11 +- packages/core/src/event-handlers/storage.ts | 7 +- packages/core/src/event-handlers/takeout.ts | 5 +- packages/core/src/events/dispatch.ts | 124 ++++++++++++++++++ packages/core/src/events/index.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/instance.ts | 2 +- .../src/services/__test__/takeout.test.ts | 3 +- packages/core/src/services/connection.ts | 20 +-- packages/core/src/services/takeout.ts | 2 +- .../core/src/utils/memory-leak-detector.ts | 54 -------- packages/core/src/utils/promise.ts | 27 +++- packages/core/src/utils/task.ts | 2 +- pnpm-lock.yaml | 3 + 32 files changed, 423 insertions(+), 252 deletions(-) create mode 100644 apps/server/src/event-dispatch.ts create mode 100644 packages/core/src/events/dispatch.ts delete mode 100644 packages/core/src/utils/memory-leak-detector.ts diff --git a/apps/server/src/account.ts b/apps/server/src/account.ts index e0bcc379c..4061a9e05 100644 --- a/apps/server/src/account.ts +++ b/apps/server/src/account.ts @@ -1,12 +1,37 @@ +import type { Eventa } from '@moeru/eventa' import type { Config } from '@tg-search/common' -import type { CoreContext, CoreEmitter, FromCoreEvent } from '@tg-search/core' +import type { CoreContext } from '@tg-search/core' import type { Peer } from 'crossws' import { useLogger } from '@guiiai/logg' import { attachBotToContext, getBotRegistry } from '@tg-search/bot' -import { CoreEventType, createCoreInstance } from '@tg-search/core' -import { coreMessageBatchesProcessedTotal, coreMessagesProcessedTotal, coreMetrics, withSpan } from '@tg-search/observability' - +import { + // Notification events from core (broadcast to all peers) + accountReadyEvent, + authCodeNeededEvent, + authConnectedEvent, + authDisconnectedEvent, + authErrorEvent, + authPasswordNeededEvent, + botStatusEvent, + coreErrorEvent, + createCoreInstance, + dialogAvatarDataEvent, + entityAvatarDataEvent, + entityMeDataEvent, + gramMessageReceivedEvent, + messageDataEvent, + messageFetchProgressEvent, + messageProcessEvent, + onEvent, + sessionUpdateEvent, + syncStatusEvent, + takeoutMetricsEvent, + takeoutTaskProgressEvent, +} from '@tg-search/core' +import { coreMessageBatchesProcessedTotal, coreMessagesProcessedTotal, coreMetrics } from '@tg-search/observability' + +import { sendWsEvent } from './events' import { getDB } from './storage/drizzle' import { getMediaStorage } from './storage/media' @@ -20,8 +45,6 @@ import { getMediaStorage } from './storage/media' * Risks: * - Long-lived accounts increase memory usage; monitor active account count. */ -export type CoreEventListener = (...args: unknown[]) => void - export interface AccountState { ctx: CoreContext @@ -30,11 +53,6 @@ export interface AccountState { */ accountReady: boolean - /** - * Core event listeners (registered once, shared by all WebSocket connections) - */ - coreEventListeners: Map - /** * Active WebSocket peers for this account */ @@ -51,24 +69,52 @@ export const accountStates = new Map() // Ephemeral per-peer bookkeeping. export const peerToAccountId = new Map() -// We need to track peer objects for broadcasting +// NOTICE: peerObjects is populated in app.ts (WebSocket open/close handlers). +// The sonarjs/no-empty-collection rule cannot track cross-file mutations. + export const peerObjects = new Map() -function bindTracingMetaToSpan(emitter: CoreEmitter) { - // Ensure tracingId from incoming meta is bound into active span for all core handlers - const originalOn = emitter.on.bind(emitter) - emitter.on = ((event, listener) => { - return originalOn(event, (...args: Parameters) => { - return withSpan(String(event), () => listener(...args)) - }) - }) as CoreEmitter['on'] +/** + * All notification events from core that should be broadcast to WebSocket peers. + * Each entry maps a typed Eventa event to its wire protocol event name. + */ +const notificationEvents: Array<{ event: Eventa, wireName: string }> = [ + { event: coreErrorEvent, wireName: 'core:error' }, + { event: authCodeNeededEvent, wireName: 'auth:code:needed' }, + { event: authPasswordNeededEvent, wireName: 'auth:password:needed' }, + { event: authConnectedEvent, wireName: 'auth:connected' }, + { event: authDisconnectedEvent, wireName: 'auth:disconnected' }, + { event: authErrorEvent, wireName: 'auth:error' }, + { event: sessionUpdateEvent, wireName: 'session:update' }, + { event: accountReadyEvent, wireName: 'account:ready' }, + { event: messageDataEvent, wireName: 'message:data' }, + { event: messageFetchProgressEvent, wireName: 'message:fetch:progress' }, + { event: dialogAvatarDataEvent, wireName: 'dialog:avatar:data' }, + { event: entityMeDataEvent, wireName: 'entity:me:data' }, + { event: entityAvatarDataEvent, wireName: 'entity:avatar:data' }, + { event: takeoutTaskProgressEvent, wireName: 'takeout:task:progress' }, + { event: takeoutMetricsEvent, wireName: 'takeout:metrics' }, + { event: gramMessageReceivedEvent, wireName: 'gram:message:received' }, + { event: botStatusEvent, wireName: 'bot:status' }, + { event: syncStatusEvent, wireName: 'sync:status' }, +] - const originalOnce = emitter.once.bind(emitter) - emitter.once = ((event, listener) => { - return originalOnce(event, (...args: Parameters) => { - return withSpan(String(event), () => listener(...args)) +/** + * Register all notification event listeners on the core context. + * Each notification is broadcast to all active WebSocket peers for this account. + */ +function registerNotificationListeners(account: AccountState) { + for (const { event, wireName } of notificationEvents) { + onEvent(account.ctx.ctx, event, (body) => { + account.activePeers.forEach((peerId) => { + // eslint-disable-next-line sonarjs/no-empty-collection -- peerObjects is populated in app.ts + const targetPeer = peerObjects.get(peerId) + if (targetPeer) { + sendWsEvent(targetPeer, wireName, body) + } + }) }) - }) as CoreEmitter['once'] + } } export function getOrCreateAccount(accountId: string, config: Config): AccountState { @@ -79,19 +125,19 @@ export function getOrCreateAccount(accountId: string, config: Config): AccountSt const ctx = createCoreInstance(getDB, config, getMediaStorage(), logger, coreMetrics) - bindTracingMetaToSpan(ctx.emitter) - const account: AccountState = { ctx, accountReady: false, - coreEventListeners: new Map(), activePeers: new Set(), createdAt: Date.now(), lastActive: Date.now(), } + // Register all notification listeners upfront (replaces server:event:register protocol) + registerNotificationListeners(account) + // Instrument core message processing for this account - ctx.emitter.on(CoreEventType.MessageProcess, ({ messages, isTakeout }) => { + onEvent(ctx.ctx, messageProcessEvent, ({ messages, isTakeout }) => { const source = isTakeout ? 'takeout' : 'realtime' coreMessageBatchesProcessedTotal.add(1, { source }) coreMessagesProcessedTotal.add(messages.length, { source }) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 5ace4d5ae..d044bda0f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -11,55 +11,32 @@ import type { Logger } from '@guiiai/logg' import type { Config } from '@tg-search/common' -import type { ExtractData, FromCoreEvent, ToCoreEvent } from '@tg-search/core' import type { H3 } from 'h3' -import type { AccountState, CoreEventListener } from './account' -import type { WsEventToClientData, WsMessageToServer } from './events' +import type { AccountState } from './account' +import type { WsMessageToServer } from './events' import { useLogger } from '@guiiai/logg' -import { CoreEventType, destroyCoreInstance } from '@tg-search/core' +import { accountReadyEvent, authLoginEvent, authLogoutEvent, destroyCoreInstance } from '@tg-search/core' import { coreEventsInTotal, wsConnectionsActive } from '@tg-search/observability' import { defineWebSocketHandler, HTTPError } from 'h3' import { v4 as uuidv4 } from 'uuid' import { accountStates, getOrCreateAccount, peerObjects, peerToAccountId } from './account' +import { dispatchClientEvent, isCoreEvent } from './event-dispatch' import { sendWsEvent } from './events' const WS_MODE_LABEL = 'server' as const -export function registerCoreEventListeners(logger: Logger, account: AccountState, accountId: string, eventName: keyof FromCoreEvent) { - if (eventName.startsWith('server:')) { - return - } - - if (!account.coreEventListeners.has(eventName)) { - const listener: CoreEventListener = (...args: unknown[]) => { - const data = args[0] as WsEventToClientData - account.activePeers.forEach((peerId) => { - const targetPeer = peerObjects.get(peerId) - if (targetPeer) { - sendWsEvent(targetPeer, eventName, data) - } - }) - } - - account.ctx.emitter.on(eventName, listener) - account.coreEventListeners.set(eventName, listener) - - logger.withFields({ eventName, accountId }).debug('Registered shared core event listener') - } -} - -export async function updateAccountState(logger: Logger, account: AccountState, accountId: string, eventName: keyof ToCoreEvent) { +export async function updateAccountState(logger: Logger, account: AccountState, accountId: string, eventName: string) { // Update account state based on events switch (eventName) { - case CoreEventType.AuthLogin: - account.ctx.emitter.once(CoreEventType.AccountReady, () => { + case authLoginEvent.id: + account.ctx.ctx.once(accountReadyEvent, () => { account.accountReady = true }) break - case CoreEventType.AuthLogout: + case authLogoutEvent.id: account.accountReady = false logger.withFields({ accountId }).log('User logged out, destroying account') await destroyCoreInstance(account.ctx) @@ -122,8 +99,8 @@ export function setupWsRoutes(app: H3, config: Config) { const event = message.json() try { + // Skip the old server:event:register protocol — notifications are registered upfront if (event.type === 'server:event:register') { - registerCoreEventListeners(logger, account, accountId, event.data.event as keyof FromCoreEvent) return } @@ -131,14 +108,14 @@ export function setupWsRoutes(app: H3, config: Config) { logger.withFields({ type: event.type, accountId, tracingId }).verbose('Message received') - if (!event.type.startsWith('server:')) { + if (isCoreEvent(event.type)) { coreEventsInTotal.add(1, { event_name: event.type }) } - // Emit to core context (meta.tracingId is re-bound via emitter on/once wrappers) - account.ctx.emitter.emit(event.type, { ...event.data, meta: { tracingId } } as ExtractData) + // Dispatch to core via Eventa events + await dispatchClientEvent(account.ctx, event.type, event.data, peer) - updateAccountState(logger, account, accountId, event.type as keyof ToCoreEvent) + updateAccountState(logger, account, accountId, event.type) } catch (error) { logger.withError(error).error('Handle websocket message failed') diff --git a/apps/server/src/event-dispatch.ts b/apps/server/src/event-dispatch.ts new file mode 100644 index 000000000..07b569c78 --- /dev/null +++ b/apps/server/src/event-dispatch.ts @@ -0,0 +1,45 @@ +/** + * Server-side event dispatch for bridging JSON-over-WebSocket messages to Eventa events. + * + * Uses shared event maps from @tg-search/core and adds server-specific + * dispatch logic (invoking RPCs and sending responses back to the calling peer). + */ +import type { CoreContext } from '@tg-search/core' +import type { Peer } from 'crossws' + +import { defineInvoke } from '@moeru/eventa' +import { fireAndForgetEvents, isCoreEvent, rpcEvents } from '@tg-search/core' + +import { sendWsEvent } from './events' + +export { isCoreEvent } + +/** + * Dispatch an incoming client event to the core Eventa context. + * + * - Fire-and-forget events are emitted directly. + * - RPC invokes are awaited and their response is sent back to the calling peer. + */ +export async function dispatchClientEvent( + ctx: CoreContext, + eventName: string, + data: any, + peer: Peer, +): Promise { + // Try RPC invoke first + const rpc = rpcEvents.get(eventName) + if (rpc) { + const invoke = defineInvoke(ctx.ctx, rpc.invoke) + const result = await invoke(data) + sendWsEvent(peer, rpc.responseEvent, result) + return + } + + // Try fire-and-forget + const event = fireAndForgetEvents.get(eventName) + if (event) { + ctx.ctx.emit(event, data) + } + + // Unknown event — skip silently (may be server-only) +} diff --git a/apps/server/src/events.ts b/apps/server/src/events.ts index 3a0f52332..9774413e3 100644 --- a/apps/server/src/events.ts +++ b/apps/server/src/events.ts @@ -8,65 +8,80 @@ export interface WsEventMeta { tracingId: string } +// ============================================================================ +// Compatibility types for packages/client (Phase 5 will remove these) +// ============================================================================ + export interface WsEventFromServer { 'server:connected': (data: { sessionId: string, accountReady: boolean }) => void } export interface WsEventFromClient { - 'server:event:register': (data: { event: keyof WsEventToClient }) => void + 'server:event:register': (data: { event: string }) => void } export type WsEventToServer = ToCoreEvent & WsEventFromClient export type WsEventToClient = FromCoreEvent & WsEventFromServer -export type WsEventToServerData = Parameters[0] +/** Extract data type from a WsEventToClient entry */ export type WsEventToClientData = Parameters[0] +/** Extract data type from a WsEventToServer entry */ +export type WsEventToServerData = Parameters[0] + +/** + * Wire protocol message from server → client. + * Uses typed event names for client compatibility (Phase 5 will simplify). + */ export type WsMessageToClient = { - [T in keyof WsEventToClient]: { - type: T - data: WsEventToClientData + [K in keyof WsEventToClient]: { + type: K + data: WsEventToClientData } }[keyof WsEventToClient] +/** + * Wire protocol message from client → server. + * Uses typed event names for client compatibility (Phase 5 will simplify). + */ export type WsMessageToServer = { - [T in keyof WsEventToServer]: { - type: T - data: WsEventToServerData + [K in keyof WsEventToServer]: { + type: K + data?: WsEventToServerData meta?: WsEventMeta } }[keyof WsEventToServer] -export function sendWsEvent( +export function sendWsEvent( peer: Peer, - event: T, - data: WsEventToClientData, + event: string, + data: unknown, ) { peer.send(createWsMessage(event, data)) } -export function createWsMessage( - type: T, - data: WsEventToClientData, -): Extract { +export function createWsMessage( + type: string, + data: unknown, +): WsMessageToClient { if (!data) - return { type, data: undefined } as Extract + return { type, data: undefined } as unknown as WsMessageToClient try { - // ensure args[0] can be stringified + // ensure payload can be stringified and is within size limits const stringifiedData = JSON.stringify(data) if (stringifiedData.length > 1024 * 1024) { useLogger().withFields({ type, size: stringifiedData.length }).warn('Dropped event data') wsSendFailTotal.add(1, { reason: 'payload_too_large' }) - return { type, data: undefined } as Extract + return { type, data: undefined } as unknown as WsMessageToClient } - return { type, data } as Extract + return { type, data } as unknown as WsMessageToClient } catch (err) { useLogger().withFields({ type }).withError(err).warn('Dropped event data') wsSendFailTotal.add(1, { reason: 'stringify_error' }) - return { type, data: undefined } as Extract + return { type, data: undefined } as unknown as WsMessageToClient } } diff --git a/packages/bot/package.json b/packages/bot/package.json index e0dd81fcb..be0f662cb 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@guiiai/logg": "catalog:", + "@moeru/eventa": "1.0.0-alpha.11", "@tg-search/common": "workspace:*", "@tg-search/core": "workspace:*", "croner": "^10.0.1", diff --git a/packages/bot/src/bridge.ts b/packages/bot/src/bridge.ts index 0d9edaf9d..85570b26a 100644 --- a/packages/bot/src/bridge.ts +++ b/packages/bot/src/bridge.ts @@ -1,11 +1,9 @@ import type { Logger } from '@guiiai/logg' -import type { CoreContext, ExtractData } from '@tg-search/core' +import type { CoreContext } from '@tg-search/core' import type { BotRegistry } from './registry' -import { CoreEventType } from '@tg-search/core' - -type BotSendMessageData = ExtractData<(data: { chatId: string, content: string, parseMode?: 'HTML' | 'MarkdownV2' }) => void> +import { botSendMessageEvent, onEvent } from '@tg-search/core' const attachedContexts = new WeakSet() @@ -30,7 +28,7 @@ export function attachBotToContext( attachedContexts.add(ctx) const scopedLogger = logger.withContext('bot:bridge') - ctx.emitter.on(CoreEventType.BotSendMessage, async (data: BotSendMessageData) => { + onEvent(ctx.ctx, botSendMessageEvent, async (data) => { try { await registry.sendMessage(data.chatId, data.content, data.parseMode) scopedLogger.withFields({ accountId, chatId: data.chatId }).debug('Bot message sent via bridge') diff --git a/packages/bot/src/commands/export.ts b/packages/bot/src/commands/export.ts index a6ce5f62e..067c0b235 100644 --- a/packages/bot/src/commands/export.ts +++ b/packages/bot/src/commands/export.ts @@ -2,7 +2,7 @@ import type { Bot } from 'grammy' import type { BotCommandContext } from '.' -import { CoreEventType } from '@tg-search/core' +import { takeoutRunEvent } from '@tg-search/core' import { InlineKeyboard } from 'grammy' import { createChatPicker } from './chat-picker' @@ -95,7 +95,7 @@ export function registerExportCommand(bot: Bot, ctx: BotCommandContext) { const chatIds = state.chatId === '__ALL__' ? [] : [state.chatId] const increase = mode === 'incremental' - coreCtx.emitter.emit(CoreEventType.TakeoutRun, { + coreCtx.ctx.emit(takeoutRunEvent, { chatIds, increase, syncOptions: {}, diff --git a/packages/bot/src/commands/summary.ts b/packages/bot/src/commands/summary.ts index 64f7b5066..61b7f5dd5 100644 --- a/packages/bot/src/commands/summary.ts +++ b/packages/bot/src/commands/summary.ts @@ -1,11 +1,9 @@ -import type { CoreContext, CoreMessage } from '@tg-search/core' import type { Bot } from 'grammy' import type { BotCommandContext } from '.' -import { randomUUID } from 'node:crypto' - -import { CoreEventType } from '@tg-search/core' +import { defineInvoke } from '@moeru/eventa' +import { messageFetchSummaryInvoke } from '@tg-search/core' import { InlineKeyboard } from 'grammy' import { streamText } from 'xsai' @@ -248,7 +246,8 @@ export function registerSummaryCommand(bot: Bot, ctx: BotCommandContext) { } /** - * Fetch messages for the given time range and optional chat filter + * Fetch messages for the given time range and optional chat filter. + * Uses the messageFetchSummaryInvoke RPC — the core handler returns the response directly. */ async function fetchMessagesForSummary( ctx: BotCommandContext, @@ -265,8 +264,8 @@ async function fetchMessagesForSummary( throw new Error('Account session not ready. Please log in via the web interface first.') } - const requestId = randomUUID() - const summaryMessages = await waitForSummaryData(coreCtx, { chatId, mode, requestId }) + const invoke = defineInvoke(coreCtx.ctx, messageFetchSummaryInvoke) + const { messages: summaryMessages } = await invoke({ chatId, mode, limit: 1000 }) const db = ctx.getDB() const chatsResult = await ctx.models.chatModels.fetchChatsByAccountId(db, accountId) @@ -287,50 +286,6 @@ async function fetchMessagesForSummary( }) } -const SUMMARY_FETCH_TIMEOUT_MS = 60_000 - -async function waitForSummaryData( - coreCtx: CoreContext, - request: { chatId: string, mode: SummaryMode, requestId: string }, -): Promise { - return new Promise((resolve, reject) => { - let timeoutId: ReturnType | undefined - let settled = false - - const cleanup = (listener: (data: { messages: CoreMessage[], mode: SummaryMode, requestId?: string }) => void) => { - if (settled) { - return - } - settled = true - if (timeoutId) { - clearTimeout(timeoutId) - } - coreCtx.emitter.off(CoreEventType.MessageSummaryData, listener) - } - - const onSummary = (data: { messages: CoreMessage[], mode: SummaryMode, requestId?: string }) => { - if (data.requestId !== request.requestId) { - return - } - cleanup(onSummary) - resolve(data.messages) - } - - timeoutId = setTimeout(() => { - cleanup(onSummary) - reject(new Error('Summary request timed out.')) - }, SUMMARY_FETCH_TIMEOUT_MS) - - coreCtx.emitter.on(CoreEventType.MessageSummaryData, onSummary) - coreCtx.emitter.emit(CoreEventType.MessageFetchSummary, { - chatId: request.chatId, - mode: request.mode, - limit: 1000, - requestId: request.requestId, - }) - }) -} - /** * Stream LLM summary generation */ diff --git a/packages/client/src/adapters/core-bridge.ts b/packages/client/src/adapters/core-bridge.ts index dd8b8384f..169c0d614 100644 --- a/packages/client/src/adapters/core-bridge.ts +++ b/packages/client/src/adapters/core-bridge.ts @@ -1,11 +1,12 @@ import type { Config } from '@tg-search/common' -import type { ExtractData, FromCoreEvent, ToCoreEvent } from '@tg-search/core' import type { WsEventToClient, WsEventToClientData, WsEventToServer, WsEventToServerData, WsMessageToClient } from '@tg-search/server/types' import type { ClientEventHandlerMap, ClientEventHandlerQueueMap } from '../event-handlers' import { useLogger } from '@guiiai/logg' +import { defineInvoke } from '@moeru/eventa' import { deepClone, generateDefaultConfig } from '@tg-search/common' +import { fireAndForgetEvents, notificationEvents, rpcEvents } from '@tg-search/core' import { useLocalStorage } from '@vueuse/core' import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia' import { ref, watch } from 'vue' @@ -56,24 +57,47 @@ export const useCoreBridgeAdapter = defineStore('core-bridge-adapter', () => { try { if (event === 'server:event:register') { - data = data as WsEventToServerData<'server:event:register'> - const eventName = data.event as keyof FromCoreEvent + const registerData = data as WsEventToServerData<'server:event:register'> + const eventName = registerData.event as string if (!eventName.startsWith('server:')) { - const fn = (payload: WsEventToClientData) => { - logger.withFields({ eventName }).debug('Sending event to client') - const message = { - type: eventName as unknown as WsMessageToClient['type'], - data: payload, - } as WsMessageToClient - sendWsEvent(message) + // Subscribe to Eventa notification event and forward to client + const eventObj = notificationEvents.get(eventName) + if (eventObj) { + ctx.ctx.on(eventObj, ({ body }) => { + logger.withFields({ eventName }).debug('Sending event to client') + sendWsEvent({ + type: eventName as unknown as WsMessageToClient['type'], + data: body, + } as WsMessageToClient) + }) } - ctx.emitter.on(eventName, fn as (...args: unknown[]) => void) } } else { - logger.withFields({ event, data }).debug('Emit event to core') - ctx.emitter.emit(event, deepClone(data) as ExtractData) + const eventStr = event as string + logger.withFields({ event: eventStr, data }).debug('Emit event to core') + + // Try RPC invoke first — response sent back via sendWsEvent + const rpc = rpcEvents.get(eventStr) + if (rpc) { + const invoke = defineInvoke(ctx.ctx, rpc.invoke) + invoke(deepClone(data)).then((result) => { + sendWsEvent({ + type: rpc.responseEvent as unknown as WsMessageToClient['type'], + data: result, + } as WsMessageToClient) + }).catch((error) => { + logger.withError(error).error('RPC invoke failed') + }) + return + } + + // Fire-and-forget + const eventObj = fireAndForgetEvents.get(eventStr) + if (eventObj) { + ctx.ctx.emit(eventObj, deepClone(data)) + } } } catch (error) { diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts index 0b716d11d..50e85a9cc 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -15,7 +15,7 @@ import { coreErrorEvent } from './events' export interface CoreContext { /** Eventa event context — the central hub for all event emission and subscription */ - ctx: EventContext + ctx: EventContext setClient: (client: TelegramClient) => void getClient: () => TelegramClient setCurrentAccountId: (accountId: string) => void @@ -38,7 +38,7 @@ export interface CoreContext { export type Service = (ctx: CoreContext, logger: Logger) => T -function createErrorHandler(eventaCtx: EventContext, logger: Logger) { +function createErrorHandler(eventaCtx: EventContext, logger: Logger) { return (error: unknown, description?: string): Error => { // Unwrap nested errors if (error instanceof Error && 'cause' in error) { diff --git a/packages/core/src/event-handlers/__test__/message.test.ts b/packages/core/src/event-handlers/__test__/message.test.ts index eed9fed92..4e13db74a 100644 --- a/packages/core/src/event-handlers/__test__/message.test.ts +++ b/packages/core/src/event-handlers/__test__/message.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' import { coreErrorEvent, messageProcessEvent, messageReprocessEvent } from '../../events' +import { onEvent } from '../../utils/promise' import { registerMessageEventHandlers } from '../message' const models = {} as unknown as Models @@ -40,7 +41,7 @@ describe('message event handlers', () => { // Set up listener for message:process to capture forceRefetch flag let capturedForceRefetch: boolean | undefined - ctx.ctx.on(messageProcessEvent, ({ body: { forceRefetch } }) => { + onEvent(ctx.ctx, messageProcessEvent, ({ forceRefetch }) => { capturedForceRefetch = forceRefetch }) @@ -75,7 +76,7 @@ describe('message event handlers', () => { // Set up listener for core:error const errors: any[] = [] - ctx.ctx.on(coreErrorEvent, ({ body }) => { + onEvent(ctx.ctx, coreErrorEvent, (body) => { errors.push(body) }) @@ -111,7 +112,7 @@ describe('message event handlers', () => { // Set up listener for message:process const processedMessages: Api.Message[] = [] - ctx.ctx.on(messageProcessEvent, ({ body: { messages } }) => { + onEvent(ctx.ctx, messageProcessEvent, ({ messages }) => { processedMessages.push(...messages) }) diff --git a/packages/core/src/event-handlers/__test__/storage.test.ts b/packages/core/src/event-handlers/__test__/storage.test.ts index f6aec28bf..47ae19e93 100644 --- a/packages/core/src/event-handlers/__test__/storage.test.ts +++ b/packages/core/src/event-handlers/__test__/storage.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { getMockEmptyDB } from '../../../mock' import { createCoreContext } from '../../context' import { coreErrorEvent, storageFetchDialogsInvoke, storageFetchMessagesInvoke, storageRecordDialogsEvent, storageSearchMessagesInvoke } from '../../events' +import { onEvent } from '../../utils/promise' import { registerStorageEventHandlers } from '../storage' const logger = useLogger() @@ -138,7 +139,7 @@ describe('storage event handlers - message access control', () => { ;(isChatAccessibleByAccount as unknown as ReturnType).mockResolvedValueOnce(Ok(false)) const errorPromise = new Promise((resolve) => { - ctx.ctx.on(coreErrorEvent, ({ body: { error } }) => { + onEvent(ctx.ctx, coreErrorEvent, ({ error }) => { resolve(error) }) }) @@ -172,7 +173,7 @@ describe('storage event handlers - message access control', () => { ;(isChatAccessibleByAccount as unknown as ReturnType).mockResolvedValueOnce(Ok(false)) const errorPromise = new Promise((resolve) => { - ctx.ctx.on(coreErrorEvent, ({ body: { error } }) => { + onEvent(ctx.ctx, coreErrorEvent, ({ error }) => { resolve(error) }) }) diff --git a/packages/core/src/event-handlers/auth.ts b/packages/core/src/event-handlers/auth.ts index 4f014999b..8d4942a8b 100644 --- a/packages/core/src/event-handlers/auth.ts +++ b/packages/core/src/event-handlers/auth.ts @@ -6,6 +6,7 @@ import type { ConnectionService } from '../services' import { StringSession } from 'telegram/sessions' import { authLoginEvent, authLogoutEvent } from '../events' +import { onEvent } from '../utils/promise' export function registerAuthEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:auth:event') @@ -13,7 +14,7 @@ export function registerAuthEventHandlers(ctx: CoreContext, logger: Logger) { return ( configuredConnectionService: ConnectionService, ) => { - ctx.ctx.on(authLoginEvent, async ({ body: { phoneNumber, session } }) => { + onEvent(ctx.ctx, authLoginEvent, async ({ phoneNumber, session }) => { if (phoneNumber) { return configuredConnectionService.loginWithPhone(phoneNumber) } diff --git a/packages/core/src/event-handlers/dialog.ts b/packages/core/src/event-handlers/dialog.ts index fbf68b405..739030a3d 100644 --- a/packages/core/src/event-handlers/dialog.ts +++ b/packages/core/src/event-handlers/dialog.ts @@ -7,6 +7,7 @@ import type { DialogService } from '../services' import { defineInvokeHandler } from '@moeru/eventa' import { dialogAvatarFetchEvent, dialogFetchInvoke, dialogFoldersFetchInvoke, storageRecordChatFoldersEvent, storageRecordDialogsEvent } from '../events' +import { onEvent } from '../utils/promise' export async function fetchDialogs(ctx: CoreContext, logger: Logger, dbModels: Models, dialogService: DialogService) { logger.verbose('Fetching dialogs') @@ -60,7 +61,7 @@ export function registerDialogEventHandlers(ctx: CoreContext, logger: Logger, db }) // Prioritized single-avatar fetch for viewport-visible items - ctx.ctx.on(dialogAvatarFetchEvent, async ({ body: { chatId } }) => { + onEvent(ctx.ctx, dialogAvatarFetchEvent, async ({ chatId }) => { logger.withFields({ chatId }).verbose('Fetching single dialog avatar') await dialogService.fetchSingleDialogAvatar(String(chatId)) }) diff --git a/packages/core/src/event-handlers/entity.ts b/packages/core/src/event-handlers/entity.ts index b737fe7cd..2e9c566ce 100644 --- a/packages/core/src/event-handlers/entity.ts +++ b/packages/core/src/event-handlers/entity.ts @@ -4,12 +4,13 @@ import type { CoreContext } from '../context' import type { EntityService } from '../services/entity' import { entityAvatarFetchEvent, entityAvatarPrimeCacheEvent, entityChatAvatarPrimeCacheEvent, entityProcessEvent } from '../events' +import { onEvent } from '../utils/promise' export function registerEntityEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:entity:event') return (entityService: EntityService) => { - ctx.ctx.on(entityProcessEvent, async ({ body: { users, chats } }) => { + onEvent(ctx.ctx, entityProcessEvent, async ({ users, chats }) => { // GramJS entities are automatically handled by the client's internal entity cache // when we invoke any method, but we ALSO manually persist them to DB to ensure // we have persistent accessHash for future API calls. @@ -17,17 +18,17 @@ export function registerEntityEventHandlers(ctx: CoreContext, logger: Logger) { await entityService.processEntities(users, chats) }) - ctx.ctx.on(entityAvatarFetchEvent, async ({ body: { userId, fileId } }) => { + onEvent(ctx.ctx, entityAvatarFetchEvent, async ({ userId, fileId }) => { logger.withFields({ userId, fileId }).debug('Fetching user avatar') await entityService.fetchUserAvatar(userId, fileId) }) - ctx.ctx.on(entityAvatarPrimeCacheEvent, async ({ body: { userId, fileId } }) => { + onEvent(ctx.ctx, entityAvatarPrimeCacheEvent, async ({ userId, fileId }) => { logger.withFields({ userId, fileId }).debug('Priming avatar cache') await entityService.primeUserAvatarCache(userId, fileId) }) - ctx.ctx.on(entityChatAvatarPrimeCacheEvent, async ({ body: { chatId, fileId } }) => { + onEvent(ctx.ctx, entityChatAvatarPrimeCacheEvent, async ({ chatId, fileId }) => { logger.withFields({ chatId, fileId }).debug('Priming chat avatar cache') await entityService.primeChatAvatarCache(chatId, fileId) }) diff --git a/packages/core/src/event-handlers/gram-events.ts b/packages/core/src/event-handlers/gram-events.ts index 86c0ef7b3..23398db00 100644 --- a/packages/core/src/event-handlers/gram-events.ts +++ b/packages/core/src/event-handlers/gram-events.ts @@ -8,12 +8,13 @@ import type { GramEventsService } from '../services/gram-events' import { Api } from 'telegram' import { gramMessageReceivedEvent, messageProcessEvent } from '../events' +import { onEvent } from '../utils/promise' export function registerGramEventsEventHandlers(ctx: CoreContext, logger: Logger, accountModels: AccountModels, chatModels: ChatModels) { logger = logger.withContext('core:gram:event') return (_: GramEventsService) => { - ctx.ctx.on(gramMessageReceivedEvent, async ({ body: { message, pts, date, isChannel } }) => { + onEvent(ctx.ctx, gramMessageReceivedEvent, async ({ message, pts, date, isChannel }) => { const accountSettings = await ctx.getAccountSettings() const receiveSettings = accountSettings.messageProcessing?.receiveMessages diff --git a/packages/core/src/event-handlers/index.ts b/packages/core/src/event-handlers/index.ts index 8189c0df5..f2880ac38 100644 --- a/packages/core/src/event-handlers/index.ts +++ b/packages/core/src/event-handlers/index.ts @@ -31,6 +31,7 @@ import { createMessageService } from '../services/message' import { createMessageResolverService } from '../services/message-resolver' import { createSyncService } from '../services/sync' import { createTakeoutService } from '../services/takeout' +import { onceEvent } from '../utils/promise' import { registerAccountSettingsEventHandlers } from './account-settings' import { registerAuthEventHandlers } from './auth' import { fetchDialogs, registerDialogEventHandlers } from './dialog' @@ -124,7 +125,7 @@ export function afterConnectedEventHandler(ctx: CoreContext): EventHandler { ctx.ctx.emit(accountReadyEvent, { accountId: dbAccount.id }) }) - ctx.ctx.once(accountReadyEvent, ({ body: { accountId } }) => { + onceEvent(ctx.ctx, accountReadyEvent, ({ accountId }) => { logger = logger.withFields({ accountId }) registerEntityEventHandlers(ctx, logger)(entityService) diff --git a/packages/core/src/event-handlers/message-resolver.ts b/packages/core/src/event-handlers/message-resolver.ts index 1f6abd47f..b6f3830cf 100644 --- a/packages/core/src/event-handlers/message-resolver.ts +++ b/packages/core/src/event-handlers/message-resolver.ts @@ -7,6 +7,7 @@ import { newQueue } from '@henrygd/queue' import { MESSAGE_RESOLVER_QUEUE_SIZE } from '../constants' import { messageProcessEvent } from '../events' +import { onEvent } from '../utils/promise' export function registerMessageResolverEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:message-resolver:event') @@ -15,7 +16,7 @@ export function registerMessageResolverEventHandlers(ctx: CoreContext, logger: L const queue = newQueue(MESSAGE_RESOLVER_QUEUE_SIZE) // TODO: debounce, background tasks - ctx.ctx.on(messageProcessEvent, ({ body: { messages, isTakeout = false, syncOptions = {}, forceRefetch = false, batchId } }) => { + onEvent(ctx.ctx, messageProcessEvent, ({ messages, isTakeout = false, syncOptions = {}, forceRefetch = false, batchId }) => { logger.withFields({ count: messages.length, isTakeout, syncOptions, forceRefetch, batchId }).verbose('Processing messages') if (!isTakeout) { diff --git a/packages/core/src/event-handlers/message.ts b/packages/core/src/event-handlers/message.ts index bc26080d6..18cd5aba6 100644 --- a/packages/core/src/event-handlers/message.ts +++ b/packages/core/src/event-handlers/message.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid' import { MESSAGE_PROCESS_BATCH_SIZE } from '../constants' import { messageDataEvent, messageFetchEvent, messageFetchSpecificEvent, messageFetchSummaryInvoke, messageFetchUnreadInvoke, messageProcessEvent, messageReadEvent, messageReprocessEvent, messageSendEvent } from '../events' import { convertToCoreMessage } from '../utils/message' +import { onEvent } from '../utils/promise' export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { logger = logger.withContext('core:message:event') @@ -22,7 +23,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { .map(result => result.unwrap()) } - ctx.ctx.on(messageFetchEvent, async ({ body: opts }) => { + onEvent(ctx.ctx, messageFetchEvent, async (opts) => { logger.withFields({ chatId: opts.chatId, minId: opts.minId, maxId: opts.maxId }).verbose('Fetching messages') let messages: Api.Message[] = [] @@ -46,7 +47,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.ctx.on(messageFetchSpecificEvent, async ({ body: { chatId, messageIds } }) => { + onEvent(ctx.ctx, messageFetchSpecificEvent, async ({ chatId, messageIds }) => { logger.withFields({ chatId, count: messageIds.length }).verbose('Fetching specific messages for media') try { @@ -63,7 +64,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.ctx.on(messageSendEvent, async ({ body: { chatId, content } }) => { + onEvent(ctx.ctx, messageSendEvent, async ({ chatId, content }) => { logger.withFields({ chatId, content }).verbose('Sending message') const updatedMessage = (await messageService.sendMessage(chatId, content)).unwrap() @@ -101,7 +102,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { logger.withFields({ content }).verbose('Message sent') }) - ctx.ctx.on(messageReprocessEvent, async ({ body: { chatId, messageIds, resolvers } }) => { + onEvent(ctx.ctx, messageReprocessEvent, async ({ chatId, messageIds, resolvers }) => { // Validate input if (messageIds.length === 0) { logger.withFields({ chatId }).warn('Re-process called with empty messageIds array') @@ -178,7 +179,7 @@ export function registerMessageEventHandlers(ctx: CoreContext, logger: Logger) { } }) - ctx.ctx.on(messageReadEvent, async ({ body: { chatId } }) => { + onEvent(ctx.ctx, messageReadEvent, async ({ chatId }) => { logger.withFields({ chatId }).verbose('Marking messages as read') await messageService.markAsRead(chatId) }) diff --git a/packages/core/src/event-handlers/storage.ts b/packages/core/src/event-handlers/storage.ts index e15420376..4ca77f694 100644 --- a/packages/core/src/event-handlers/storage.ts +++ b/packages/core/src/event-handlers/storage.ts @@ -22,6 +22,7 @@ import { } from '../events' import { convertToCoreRetrievalMessages } from '../models/utils/message' import { embedContents } from '../utils/embed' +import { onEvent } from '../utils/promise' /** * Check if a message has no media attached @@ -87,7 +88,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d return { chatId, messageId, messages } }) - ctx.ctx.on(storageRecordMessagesEvent, async ({ body: { messages } }) => { + onEvent(ctx.ctx, storageRecordMessagesEvent, async ({ messages }) => { const accountId = ctx.getCurrentAccountId() await dbModels.chatMessageModels.recordMessages(ctx.getDB(), accountId, messages) @@ -123,7 +124,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d return { dialogs } }) - ctx.ctx.on(storageRecordDialogsEvent, async ({ body: { dialogs, accountId } }) => { + onEvent(ctx.ctx, storageRecordDialogsEvent, async ({ dialogs, accountId }) => { logger.withFields({ size: dialogs.length, users: dialogs.filter(d => d.type === 'user').length, @@ -140,7 +141,7 @@ export function registerStorageEventHandlers(ctx: CoreContext, logger: Logger, d logger.withFields({ count: result.length }).verbose('Successfully recorded dialogs') }) - ctx.ctx.on(storageRecordChatFoldersEvent, async ({ body: { folders, accountId } }) => { + onEvent(ctx.ctx, storageRecordChatFoldersEvent, async ({ folders, accountId }) => { logger.withFields({ count: folders.length }).verbose('Recording chat folders') const db = ctx.getDB() diff --git a/packages/core/src/event-handlers/takeout.ts b/packages/core/src/event-handlers/takeout.ts index 48c84cf5f..b28323816 100644 --- a/packages/core/src/event-handlers/takeout.ts +++ b/packages/core/src/event-handlers/takeout.ts @@ -4,13 +4,14 @@ import type { TakeoutService } from '../services' import { defineInvokeHandler } from '@moeru/eventa' import { takeoutRunEvent, takeoutStatsFetchInvoke, takeoutTaskAbortEvent } from '../events' +import { onEvent } from '../utils/promise' export function registerTakeoutEventHandlers(ctx: CoreContext, takeoutService: TakeoutService) { - ctx.ctx.on(takeoutRunEvent, async ({ body: params }) => { + onEvent(ctx.ctx, takeoutRunEvent, async (params) => { await takeoutService.runTakeout(params) }) - ctx.ctx.on(takeoutTaskAbortEvent, ({ body: { taskId } }) => { + onEvent(ctx.ctx, takeoutTaskAbortEvent, ({ taskId }) => { takeoutService.abortTask(taskId) }) diff --git a/packages/core/src/events/dispatch.ts b/packages/core/src/events/dispatch.ts new file mode 100644 index 000000000..0765ac2e6 --- /dev/null +++ b/packages/core/src/events/dispatch.ts @@ -0,0 +1,124 @@ +/** + * Shared event dispatch maps for bridging string-based wire protocol + * to typed Eventa event objects. + * + * Used by both the server WebSocket bridge and the client core-bridge adapter. + */ +import type { Eventa, InvokeEventa } from '@moeru/eventa' + +import { authCodeEvent, authCodeNeededEvent, authConnectedEvent, authDisconnectedEvent, authErrorEvent, authLoginEvent, authLogoutEvent, authPasswordEvent, authPasswordNeededEvent } from './auth' +import { botSendMessageEvent, botStatusEvent } from './bot' +import { configFetchInvoke, configUpdateInvoke } from './config' +import { dialogAvatarDataEvent, dialogAvatarFetchEvent, dialogFetchInvoke, dialogFoldersFetchInvoke } from './dialog' +import { entityAvatarDataEvent, entityAvatarFetchEvent, entityAvatarPrimeCacheEvent, entityChatAvatarPrimeCacheEvent, entityProcessEvent } from './entity' +import { gramMessageReceivedEvent } from './gram' +import { coreErrorEvent } from './instance' +import { messageDataEvent, messageFetchAbortEvent, messageFetchEvent, messageFetchProgressEvent, messageFetchSpecificEvent, messageFetchSummaryInvoke, messageFetchUnreadInvoke, messageProcessedEvent, messageReadEvent, messageReprocessEvent, messageSendEvent } from './message' +import { accountReadyEvent, entityMeDataEvent, sessionUpdateEvent } from './session' +import { storageChatNoteInvoke, storageFetchDialogsInvoke, storageFetchMessageContextInvoke, storageFetchMessagesInvoke, storageRecordChatFoldersEvent, storageRecordDialogsEvent, storageRecordMessagesEvent, storageSearchMessagesInvoke, storageSearchPhotosInvoke } from './storage' +import { syncCatchUpEvent, syncResetEvent, syncStatusEvent } from './sync' +import { takeoutMetricsEvent, takeoutRunEvent, takeoutStatsFetchInvoke, takeoutTaskAbortEvent, takeoutTaskProgressEvent } from './takeout' + +// ─── Fire-and-forget event mapping ──────────────────────────────────────────── +// Maps wire event name → Eventa event object for plain emit() + +export const fireAndForgetEvents = new Map>([ + // Auth + ['auth:login', authLoginEvent], + ['auth:logout', authLogoutEvent], + ['auth:code', authCodeEvent], + ['auth:password', authPasswordEvent], + // Message commands + ['message:fetch', messageFetchEvent], + ['message:fetch:abort', messageFetchAbortEvent], + ['message:fetch:specific', messageFetchSpecificEvent], + ['message:send', messageSendEvent], + ['message:read', messageReadEvent], + ['message:reprocess', messageReprocessEvent], + // Dialog commands + ['dialog:avatar:fetch', dialogAvatarFetchEvent], + // Entity commands + ['entity:process', entityProcessEvent], + ['entity:avatar:fetch', entityAvatarFetchEvent], + ['entity:avatar:prime-cache', entityAvatarPrimeCacheEvent], + ['entity:chat-avatar:prime-cache', entityChatAvatarPrimeCacheEvent], + // Storage commands + ['storage:record:messages', storageRecordMessagesEvent], + ['storage:record:dialogs', storageRecordDialogsEvent], + ['storage:record:chat-folders', storageRecordChatFoldersEvent], + // Takeout commands + ['takeout:run', takeoutRunEvent], + ['takeout:task:abort', takeoutTaskAbortEvent], + // Bot commands + ['bot:send:message', botSendMessageEvent], + // Sync commands + ['sync:catch-up', syncCatchUpEvent], + ['sync:reset', syncResetEvent], +]) + +// ─── RPC invoke event mapping ───────────────────────────────────────────────── +// Maps wire event name → { invoke event, response wire event name } + +export interface RpcEntry { + invoke: InvokeEventa + responseEvent: string +} + +export const rpcEvents = new Map([ + ['storage:fetch:messages', { invoke: storageFetchMessagesInvoke, responseEvent: 'storage:messages' }], + ['storage:fetch:dialogs', { invoke: storageFetchDialogsInvoke, responseEvent: 'storage:dialogs' }], + ['storage:search:messages', { invoke: storageSearchMessagesInvoke, responseEvent: 'storage:search:messages:data' }], + ['storage:search:photos', { invoke: storageSearchPhotosInvoke, responseEvent: 'storage:search:photos:data' }], + ['storage:fetch:message-context', { invoke: storageFetchMessageContextInvoke, responseEvent: 'storage:messages:context' }], + ['storage:record:dialog-note', { invoke: storageChatNoteInvoke, responseEvent: 'storage:dialog-note' }], + ['config:fetch', { invoke: configFetchInvoke, responseEvent: 'config:data' }], + ['config:update', { invoke: configUpdateInvoke, responseEvent: 'config:data' }], + ['dialog:fetch', { invoke: dialogFetchInvoke, responseEvent: 'dialog:data' }], + ['dialog:folders:fetch', { invoke: dialogFoldersFetchInvoke, responseEvent: 'dialog:folders:data' }], + ['takeout:stats:fetch', { invoke: takeoutStatsFetchInvoke, responseEvent: 'takeout:stats:data' }], + ['message:fetch:unread', { invoke: messageFetchUnreadInvoke, responseEvent: 'message:unread-data' }], + ['message:fetch:summary', { invoke: messageFetchSummaryInvoke, responseEvent: 'message:summary-data' }], +]) + +// ─── Notification event mapping ─────────────────────────────────────────────── +// Maps wire event name → Eventa event object for subscribing to core notifications. +// These are "fromCore" events broadcast to all connected peers/listeners. + +export const notificationEvents = new Map>([ + // Core lifecycle + ['core:error', coreErrorEvent], + // Auth state + ['auth:code:needed', authCodeNeededEvent], + ['auth:password:needed', authPasswordNeededEvent], + ['auth:connected', authConnectedEvent], + ['auth:disconnected', authDisconnectedEvent], + ['auth:error', authErrorEvent], + // Session + ['session:update', sessionUpdateEvent], + ['account:ready', accountReadyEvent], + // Message + ['message:data', messageDataEvent], + ['message:fetch:progress', messageFetchProgressEvent], + ['message:processed', messageProcessedEvent], + // Dialog + ['dialog:avatar:data', dialogAvatarDataEvent], + // Entity + ['entity:me:data', entityMeDataEvent], + ['entity:avatar:data', entityAvatarDataEvent], + // Takeout + ['takeout:task:progress', takeoutTaskProgressEvent], + ['takeout:metrics', takeoutMetricsEvent], + // Gram + ['gram:message:received', gramMessageReceivedEvent], + // Bot + ['bot:status', botStatusEvent], + // Sync + ['sync:status', syncStatusEvent], +]) + +/** + * Check if an event name is a known core event (fire-and-forget, RPC, or notification). + */ +export function isCoreEvent(eventName: string): boolean { + return fireAndForgetEvents.has(eventName) || rpcEvents.has(eventName) +} diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index a82a18c25..7cf5e7979 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -2,6 +2,7 @@ export * from './auth' export * from './bot' export * from './config' export * from './dialog' +export * from './dispatch' export * from './entity' export * from './gram' export * from './instance' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83ef2a995..6192d3bc2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from './models' export type * from './types' export { CoreEventType } from './types/events' export { generateDefaultAccountSettings } from './utils/account-settings' +export { onceEvent, onEvent, waitForEvent } from './utils/promise' diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 2fec21999..1ec9dbaf6 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -56,7 +56,7 @@ export function createCoreInstance( */ export async function destroyCoreInstance(ctx: CoreContext) { // Emit cleanup event to notify all services - ctx.ctx.emit(coreCleanupEvent) + ctx.ctx.emit(coreCleanupEvent, undefined) // Give services time to cleanup // TODO: use Promise.allSettled to wait for all services to cleanup diff --git a/packages/core/src/services/__test__/takeout.test.ts b/packages/core/src/services/__test__/takeout.test.ts index b1f0fc5c4..07f2c3a8d 100644 --- a/packages/core/src/services/__test__/takeout.test.ts +++ b/packages/core/src/services/__test__/takeout.test.ts @@ -11,6 +11,7 @@ import { Api } from 'telegram' import { describe, expect, it, vi } from 'vitest' import { messageProcessedEvent, messageProcessEvent } from '../../events' +import { onEvent } from '../../utils/promise' import { createTask as createCoreTask } from '../../utils/task' import { createTakeoutService } from '../takeout' @@ -516,7 +517,7 @@ describe('takeout service', () => { const { ctx } = createMockCtx(client) // Auto-complete message processing batches to avoid hanging on pendingBatches. - ctx.ctx.on(messageProcessEvent, ({ body: { messages, batchId } }) => { + onEvent(ctx.ctx, messageProcessEvent, ({ messages, batchId }) => { ctx.ctx.emit(messageProcessedEvent, { batchId: batchId ?? 'batch-id', count: messages.length, diff --git a/packages/core/src/services/connection.ts b/packages/core/src/services/connection.ts index 0a934c5e2..ef45a082c 100644 --- a/packages/core/src/services/connection.ts +++ b/packages/core/src/services/connection.ts @@ -131,8 +131,8 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option if (!isAuthorized) { // Surface this as an auth-specific error so the frontend can fall // back to manual login and optionally clear the stored session. - ctx.ctx.emit(authErrorEvent) - ctx.ctx.emit(authDisconnectedEvent) + ctx.ctx.emit(authErrorEvent, undefined) + ctx.ctx.emit(authDisconnectedEvent, undefined) return Err(ctx.withError('User is not authorized')) } @@ -149,14 +149,14 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option // 3) Finally signal that auth is connected; this will trigger // afterConnectedEventHandler, which will establish current // account ID and bootstrap dialogs/storage. - ctx.ctx.emit(authConnectedEvent) + ctx.ctx.emit(authConnectedEvent, undefined) logger.log('Login with session successful') return Ok(client) } catch (error) { - ctx.ctx.emit(authErrorEvent) + ctx.ctx.emit(authErrorEvent, undefined) return Err(ctx.withError(error, 'Failed to login with session')) } } @@ -183,14 +183,14 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option // 3) Notify connected; afterConnectedEventHandler will establish // current account ID and bootstrap dialogs/storage. - ctx.ctx.emit(authConnectedEvent) + ctx.ctx.emit(authConnectedEvent, undefined) logger.log('Login with phone successful') return Ok(client) } catch (error) { - ctx.ctx.emit(authErrorEvent) + ctx.ctx.emit(authErrorEvent, undefined) return Err(ctx.withError(error, 'Failed to login with phone')) } } @@ -206,18 +206,18 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option phoneNumber, phoneCode: async () => { logger.verbose('Waiting for code') - ctx.ctx.emit(authCodeNeededEvent) + ctx.ctx.emit(authCodeNeededEvent, undefined) const { code } = await waitForEvent(ctx.ctx, authCodeEvent) return code }, password: async () => { logger.verbose('Waiting for password') - ctx.ctx.emit(authPasswordNeededEvent) + ctx.ctx.emit(authPasswordNeededEvent, undefined) const { password } = await waitForEvent(ctx.ctx, authPasswordEvent) return password }, onError: (error) => { - ctx.ctx.emit(authErrorEvent) + ctx.ctx.emit(authErrorEvent, undefined) reject(ctx.withError(error, 'Failed to sign in to Telegram')) }, }) @@ -230,7 +230,7 @@ export function createConnectionService(ctx: CoreContext, logger: Logger, option if (client.connected) { await client.invoke(new Api.auth.LogOut()) await client.disconnect() - ctx.ctx.emit(authDisconnectedEvent) + ctx.ctx.emit(authDisconnectedEvent, undefined) } client.session.delete() diff --git a/packages/core/src/services/takeout.ts b/packages/core/src/services/takeout.ts index dcbaa7b0b..cd2c0fc8e 100644 --- a/packages/core/src/services/takeout.ts +++ b/packages/core/src/services/takeout.ts @@ -385,7 +385,7 @@ export function createTakeoutService( } } - const unsubscribe = ctx.ctx.on(messageProcessedEvent, ({ body }) => onMessageProcessed(body)) + const unsubscribe = ctx.ctx.on(messageProcessedEvent, ({ body }) => onMessageProcessed(body!)) try { for await (const message of generator) { diff --git a/packages/core/src/utils/memory-leak-detector.ts b/packages/core/src/utils/memory-leak-detector.ts deleted file mode 100644 index 409fefaf9..000000000 --- a/packages/core/src/utils/memory-leak-detector.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Logger } from '@guiiai/logg' - -import type { CoreEmitter } from '../context' - -/** - * Detect memory leaks in development mode - * Returns a cleanup function to clear the interval - */ -export function detectMemoryLeak(emitter: CoreEmitter, logger: Logger): () => void { - logger = logger.withContext('core:memory-leak') - - // Memory leak detection in development mode - // eslint-disable-next-line node/prefer-global/process - const isDevelopment = typeof process !== 'undefined' && process.env?.NODE_ENV === 'development' - - let checkInterval: NodeJS.Timeout | undefined - - if (isDevelopment) { - checkInterval = setInterval(() => { - const eventNames = emitter.eventNames() - const listenerCounts: Record = {} - - eventNames.forEach((event) => { - const count = emitter.listenerCount(event as any) - if (count > 0) { - listenerCounts[event as string] = count - } - }) - - const totalListeners = Object.values(listenerCounts).reduce((sum, count) => sum + count, 0) - - if (totalListeners > 100) { - logger.withFields({ - totalListeners, - listenerCounts, - }).warn('High number of event listeners detected - potential memory leak') - } - else { - logger.withFields({ - totalListeners, - listenerCounts, - }).debug('Event listener count check') - } - }, 60000) // Check every minute - } - - // Return cleanup function - return () => { - if (checkInterval) { - clearInterval(checkInterval) - checkInterval = undefined - } - } -} diff --git a/packages/core/src/utils/promise.ts b/packages/core/src/utils/promise.ts index d68cd1a20..e95bb47fd 100644 --- a/packages/core/src/utils/promise.ts +++ b/packages/core/src/utils/promise.ts @@ -1,12 +1,35 @@ import type { Eventa, EventContext } from '@moeru/eventa' +/** + * Type-safe wrappers around Eventa's `on`/`once` that unwrap the `body` payload. + * + * Eventa's `Eventa

` interface marks `body` as optional (`body?: P`), but body + * is always present when events are emitted with a payload. These helpers eliminate + * the need for non-null assertions (`body!`) at every handler site. + */ +export function onEvent

( + ctx: EventContext, + event: Eventa

, + handler: (body: P) => unknown, +): () => void { + return ctx.on(event, payload => handler(payload.body as P)) +} + +export function onceEvent

( + ctx: EventContext, + event: Eventa

, + handler: (body: P) => unknown, +): () => void { + return ctx.once(event, payload => handler(payload.body as P)) +} + export function waitForEvent

( - ctx: EventContext, + ctx: EventContext, event: Eventa

, ): Promise

{ return new Promise((resolve) => { ctx.once(event, ({ body }) => { - resolve(body) + resolve(body as P) }) }) } diff --git a/packages/core/src/utils/task.ts b/packages/core/src/utils/task.ts index 55ea985f4..1f9ab5732 100644 --- a/packages/core/src/utils/task.ts +++ b/packages/core/src/utils/task.ts @@ -13,7 +13,7 @@ import { takeoutTaskProgressEvent } from '../events' export function createTask( type: T, metadata: CoreTasks[T], - ctx: EventContext, + ctx: EventContext, logger: Logger, ): CoreTask { logger = logger.withContext('core:task') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771282279..0b0d7d66d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: '@guiiai/logg': specifier: 'catalog:' version: 1.2.11 + '@moeru/eventa': + specifier: 1.0.0-alpha.11 + version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@tg-search/common': specifier: workspace:* version: link:../common From c4ae8a7cd21a157897b1cd6c677d6fe0516bd52d Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Mon, 2 Mar 2026 23:44:47 +0800 Subject: [PATCH 3/4] refactor: catalog --- apps/server/package.json | 2 +- packages/bot/package.json | 2 +- packages/client/package.json | 2 +- packages/core/package.json | 2 +- pnpm-lock.yaml | 25 ++++++++++++++----------- pnpm-workspace.yaml | 1 + 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index c1b6bbe04..f0bf6c953 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -55,7 +55,7 @@ "dependencies": { "@electric-sql/pglite": "^0.3.15", "@guiiai/logg": "catalog:", - "@moeru/eventa": "1.0.0-alpha.11", + "@moeru/eventa": "catalog:", "@node-rs/jieba": "^2.0.1", "@tg-search/bot": "workspace:*", "@tg-search/common": "workspace:*", diff --git a/packages/bot/package.json b/packages/bot/package.json index be0f662cb..cde229059 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@guiiai/logg": "catalog:", - "@moeru/eventa": "1.0.0-alpha.11", + "@moeru/eventa": "catalog:", "@tg-search/common": "workspace:*", "@tg-search/core": "workspace:*", "croner": "^10.0.1", diff --git a/packages/client/package.json b/packages/client/package.json index 7d9d7ce13..f10971862 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@moeru/eventa": "1.0.0-alpha.11", + "@moeru/eventa": "catalog:", "@tg-search/common": "workspace:*", "@tg-search/core": "workspace:*", "@tg-search/server": "workspace:*", diff --git a/packages/core/package.json b/packages/core/package.json index 1c6917a9f..211d0782e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,7 +14,7 @@ "@electric-sql/pglite": "^0.3.15", "@guiiai/logg": "catalog:", "@henrygd/queue": "^1.2.0", - "@moeru/eventa": "1.0.0-alpha.11", + "@moeru/eventa": "catalog:", "@moeru/std": "0.1.0-beta.15", "@node-rs/jieba": "^2.0.1", "@proj-airi/drizzle-orm-browser-migrator": "^0.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b0d7d66d..b42fa32bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@guiiai/logg': specifier: ^1.2.11 version: 1.2.11 + '@moeru/eventa': + specifier: ^1.0.0-beta.1 + version: 1.0.0-beta.1 '@unbird/result': specifier: ^1.1.1 version: 1.1.1 @@ -91,8 +94,8 @@ importers: specifier: 'catalog:' version: 1.2.11 '@moeru/eventa': - specifier: 1.0.0-alpha.11 - version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) + specifier: 'catalog:' + version: 1.0.0-beta.1(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@node-rs/jieba': specifier: ^2.0.1 version: 2.0.1 @@ -336,8 +339,8 @@ importers: specifier: 'catalog:' version: 1.2.11 '@moeru/eventa': - specifier: 1.0.0-alpha.11 - version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) + specifier: 'catalog:' + version: 1.0.0-beta.1(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@tg-search/common': specifier: workspace:* version: link:../common @@ -357,8 +360,8 @@ importers: packages/client: dependencies: '@moeru/eventa': - specifier: 1.0.0-alpha.11 - version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) + specifier: 'catalog:' + version: 1.0.0-beta.1(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@tg-search/common': specifier: workspace:* version: link:../common @@ -427,8 +430,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 '@moeru/eventa': - specifier: 1.0.0-alpha.11 - version: 1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) + specifier: 'catalog:' + version: 1.0.0-beta.1(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1))) '@moeru/std': specifier: 0.1.0-beta.15 version: 0.1.0-beta.15 @@ -1932,8 +1935,8 @@ packages: eslint-plugin-oxlint: optional: true - '@moeru/eventa@1.0.0-alpha.11': - resolution: {integrity: sha512-5i2LYA19E9siAdqhobVm386dqkV4EZw0+uGDEwLiGqfopE/3yX1zdgOkdvEoT+YJf0SIUqDwuMZoYXkvfWV/kA==} + '@moeru/eventa@1.0.0-beta.1': + resolution: {integrity: sha512-4VPs+FlTdIMtlNdBvERvF+e9pnSbz+PN7dgztADtaETCQsSDk9ptHJuzajRBhOlr364k4TotroZa9rrIoVelRQ==} peerDependencies: electron: '>=30' h3: 2.0.0-beta.1 @@ -8808,7 +8811,7 @@ snapshots: - supports-color - typescript - '@moeru/eventa@1.0.0-alpha.11(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)))': + '@moeru/eventa@1.0.0-beta.1(h3@2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)))': dependencies: nanoid: 5.1.6 picomatch: 4.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8c2391a4d..5715bae5e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ catalog: '@xsai-ext/providers-local': ^0.4.0-beta.12 '@xsai/embed': ^0.4.2 '@xsai/tool': ^0.4.2 + '@moeru/eventa': ^1.0.0-beta.1 tsdown: ^0.20.1 vue-sonner: ^2.0.9 xsai: ^0.4.2 From d2a5cb5b2a6a714efec4d437bbdbacb06b4c674b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:46:43 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- pnpm-workspace.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5715bae5e..73b0c6a13 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,12 +5,12 @@ packages: catalog: '@guiiai/logg': ^1.2.11 + '@moeru/eventa': ^1.0.0-beta.1 '@unbird/result': ^1.1.1 '@xsai-ext/providers-cloud': ^0.4.0-beta.12 '@xsai-ext/providers-local': ^0.4.0-beta.12 '@xsai/embed': ^0.4.2 '@xsai/tool': ^0.4.2 - '@moeru/eventa': ^1.0.0-beta.1 tsdown: ^0.20.1 vue-sonner: ^2.0.9 xsai: ^0.4.2