@hybrd/xmtp 1.4.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,369 +0,0 @@
1
- import { EventEmitter } from "node:events"
2
- import { beforeEach, describe, expect, it, vi } from "vitest"
3
- import { MessageListener } from "./message-listener.ts.old"
4
-
5
- /**
6
- * Check if content contains any of the supported agent mention patterns
7
- */
8
- function hasAgentMention(content: string | undefined): boolean {
9
- if (!content) return false
10
-
11
- const lowerContent = content.toLowerCase()
12
- const mentionPatterns = [
13
- "@agent",
14
- "@hybrid",
15
- "@hybrid.base.eth",
16
- "@hybrid.eth",
17
- "@agent.eth",
18
- "@agent.base.eth"
19
- ]
20
-
21
- return mentionPatterns.some((pattern) => lowerContent.includes(pattern))
22
- }
23
-
24
- // Mock the XmtpResolver
25
- vi.mock("./xmtp-resolver", () => ({
26
- XmtpResolver: vi.fn().mockImplementation(() => ({
27
- resolveAddress: vi.fn().mockResolvedValue("0x456789abcdef"),
28
- findRootMessage: vi.fn().mockResolvedValue(null),
29
- prePopulateCache: vi.fn().mockResolvedValue(undefined)
30
- }))
31
- }))
32
-
33
- // Mock the BasenameResolver
34
- vi.mock("./basename-resolver", () => ({
35
- BasenameResolver: vi.fn().mockImplementation(() => ({
36
- getBasename: vi.fn().mockResolvedValue("testuser.base.eth"),
37
- getBasenameAddress: vi.fn().mockResolvedValue("0x456789abcdef"),
38
- resolveBasenameProfile: vi.fn().mockResolvedValue({
39
- basename: "testuser.base.eth",
40
- avatar: "https://example.com/avatar.jpg",
41
- description: "Test user profile",
42
- twitter: "@testuser",
43
- github: "testuser",
44
- url: "https://testuser.com"
45
- })
46
- }))
47
- }))
48
-
49
- // Mock the ENSResolver
50
- vi.mock("./ens-resolver", () => ({
51
- ENSResolver: vi.fn().mockImplementation(() => ({
52
- resolveAddressToENS: vi.fn().mockResolvedValue(null),
53
- resolveENSName: vi.fn().mockResolvedValue(null),
54
- isENSName: vi.fn().mockReturnValue(false)
55
- }))
56
- }))
57
-
58
- // Mock the subjects
59
- vi.mock("./subjects", () => ({
60
- extractSubjects: vi.fn().mockResolvedValue({})
61
- }))
62
-
63
- // Mock the XMTP client
64
- const mockClient = {
65
- inboxId: "test-inbox-id",
66
- accountIdentifier: { identifier: "0x123" },
67
- conversations: {
68
- sync: vi.fn(),
69
- list: vi.fn().mockResolvedValue([]),
70
- streamAllMessages: vi.fn(),
71
- getConversationById: vi.fn()
72
- },
73
- preferences: {
74
- inboxStateFromInboxIds: vi.fn()
75
- }
76
- }
77
-
78
- describe("MessageListener", () => {
79
- let listener: MessageListener
80
-
81
- beforeEach(() => {
82
- vi.clearAllMocks()
83
- listener = new MessageListener({
84
- xmtpClient: mockClient as any,
85
- publicClient: {} as any,
86
- filter: ({ message }) => {
87
- const content = message.content as string
88
- return hasAgentMention(content)
89
- }
90
- })
91
- })
92
-
93
- it("should be an instance of EventEmitter", () => {
94
- expect(listener).toBeInstanceOf(EventEmitter)
95
- })
96
-
97
- it("should emit message events with enriched sender information", async () => {
98
- const mockMessage = {
99
- id: "test-message-id",
100
- content: "@agent test message",
101
- senderInboxId: "sender-inbox-id",
102
- conversationId: "conversation-id",
103
- sentAt: new Date(),
104
- contentType: { typeId: "text" }
105
- }
106
-
107
- // Mock the stream to emit our test message
108
- const mockStream = {
109
- async *[Symbol.asyncIterator]() {
110
- yield mockMessage
111
- }
112
- }
113
-
114
- mockClient.conversations.streamAllMessages.mockResolvedValue(mockStream)
115
- mockClient.conversations.getConversationById.mockResolvedValue({
116
- id: "conversation-id"
117
- })
118
-
119
- // Set up message event listener
120
- const messageHandler = vi.fn()
121
- listener.on("message", messageHandler)
122
-
123
- // Start the listener (but don't wait for it to complete since it runs indefinitely)
124
- const startPromise = listener.start()
125
-
126
- // Give it a moment to process the message
127
- await new Promise((resolve) => setTimeout(resolve, 100))
128
-
129
- expect(messageHandler).toHaveBeenCalledWith(
130
- expect.objectContaining({
131
- message: expect.objectContaining({
132
- id: "test-message-id",
133
- content: "@agent test message",
134
- senderInboxId: "sender-inbox-id",
135
- conversationId: "conversation-id"
136
- }),
137
- sender: expect.objectContaining({
138
- address: "0x456789abcdef",
139
- inboxId: "sender-inbox-id",
140
- basename: "testuser.base.eth",
141
- name: "testuser.base.eth"
142
- }),
143
- subjects: expect.any(Object),
144
- rootMessage: undefined
145
- })
146
- )
147
-
148
- listener.stop()
149
- })
150
-
151
- it("should handle messages without basenames gracefully", async () => {
152
- // Mock resolvers to return no basename
153
- const listenerWithoutBasename = new MessageListener({
154
- xmtpClient: mockClient as any,
155
- publicClient: {} as any,
156
- filter: ({ message }) => {
157
- const content = message.content as string
158
- return hasAgentMention(content)
159
- }
160
- })
161
-
162
- // Mock basename resolver to return null
163
- const mockBasenameResolver = (listenerWithoutBasename as any)
164
- .basenameResolver
165
- mockBasenameResolver.getBasename = vi.fn().mockResolvedValue(null)
166
-
167
- const mockMessage = {
168
- id: "test-message-id-2",
169
- content: "@agent test message 2",
170
- senderInboxId: "sender-inbox-id-2",
171
- conversationId: "conversation-id-2",
172
- sentAt: new Date(),
173
- contentType: { typeId: "text" }
174
- }
175
-
176
- const mockStream = {
177
- async *[Symbol.asyncIterator]() {
178
- yield mockMessage
179
- }
180
- }
181
-
182
- mockClient.conversations.streamAllMessages.mockResolvedValue(mockStream)
183
- mockClient.conversations.getConversationById.mockResolvedValue({
184
- id: "conversation-id-2"
185
- })
186
-
187
- const messageHandler = vi.fn()
188
- listenerWithoutBasename.on("message", messageHandler)
189
-
190
- const startPromise = listenerWithoutBasename.start()
191
- await new Promise((resolve) => setTimeout(resolve, 100))
192
-
193
- expect(messageHandler).toHaveBeenCalledWith(
194
- expect.objectContaining({
195
- sender: expect.objectContaining({
196
- address: "0x456789abcdef",
197
- inboxId: "sender-inbox-id-2",
198
- basename: undefined,
199
- name: expect.stringContaining("0x4567") // Should use truncated address
200
- }),
201
- subjects: expect.any(Object),
202
- rootMessage: undefined
203
- })
204
- )
205
-
206
- listenerWithoutBasename.stop()
207
- })
208
-
209
- it("should handle all supported agent mention patterns", async () => {
210
- const mentionPatterns = [
211
- "@agent test message",
212
- "@hybrid test message",
213
- "@hybrid.base.eth test message",
214
- "@hybrid.eth test message",
215
- "@agent.eth test message",
216
- "@agent.base.eth test message"
217
- ]
218
-
219
- for (const content of mentionPatterns) {
220
- const testListener = new MessageListener({
221
- xmtpClient: mockClient as any,
222
- publicClient: {} as any,
223
- filter: ({ message }) => {
224
- const messageContent = message.content as string
225
- return hasAgentMention(messageContent)
226
- }
227
- })
228
-
229
- const mockMessage = {
230
- id: `test-message-${content}`,
231
- content: content,
232
- senderInboxId: "sender-inbox-id",
233
- conversationId: "conversation-id",
234
- sentAt: new Date(),
235
- contentType: { typeId: "text" }
236
- }
237
-
238
- const mockStream = {
239
- async *[Symbol.asyncIterator]() {
240
- yield mockMessage
241
- }
242
- }
243
-
244
- mockClient.conversations.streamAllMessages.mockResolvedValue(mockStream)
245
- mockClient.conversations.getConversationById.mockResolvedValue({
246
- id: "conversation-id"
247
- })
248
-
249
- const messageHandler = vi.fn()
250
- testListener.on("message", messageHandler)
251
-
252
- const startPromise = testListener.start()
253
- await new Promise((resolve) => setTimeout(resolve, 100))
254
-
255
- expect(messageHandler).toHaveBeenCalledWith(
256
- expect.objectContaining({
257
- message: expect.objectContaining({
258
- content: content
259
- })
260
- })
261
- )
262
-
263
- testListener.stop()
264
- }
265
- })
266
-
267
- it("should allow replies without mention checking", async () => {
268
- const testListener = new MessageListener({
269
- xmtpClient: mockClient as any,
270
- publicClient: {} as any,
271
- filter: ({ message }) => {
272
- const contentTypeId = message.contentType?.typeId
273
- if (contentTypeId === "reply") {
274
- return true
275
- }
276
- if (contentTypeId === "text") {
277
- const messageContent = message.content as string
278
- return hasAgentMention(messageContent)
279
- }
280
- return false
281
- }
282
- })
283
-
284
- const mockReplyMessage = {
285
- id: "test-reply-message",
286
- content: { content: "yes I'm in" },
287
- senderInboxId: "sender-inbox-id",
288
- conversationId: "conversation-id",
289
- sentAt: new Date(),
290
- contentType: { typeId: "reply" }
291
- }
292
-
293
- const mockStream = {
294
- async *[Symbol.asyncIterator]() {
295
- yield mockReplyMessage
296
- }
297
- }
298
-
299
- mockClient.conversations.streamAllMessages.mockResolvedValue(mockStream)
300
- mockClient.conversations.getConversationById.mockResolvedValue({
301
- id: "conversation-id"
302
- })
303
-
304
- const messageHandler = vi.fn()
305
- testListener.on("message", messageHandler)
306
-
307
- const startPromise = testListener.start()
308
- await new Promise((resolve) => setTimeout(resolve, 100))
309
-
310
- expect(messageHandler).toHaveBeenCalledWith(
311
- expect.objectContaining({
312
- message: expect.objectContaining({
313
- content: expect.objectContaining({
314
- content: "yes I'm in"
315
- })
316
- })
317
- })
318
- )
319
-
320
- testListener.stop()
321
- })
322
-
323
- it("should properly clean up when stopped", () => {
324
- const removeAllListenersSpy = vi.spyOn(listener, "removeAllListeners")
325
-
326
- listener.stop()
327
-
328
- expect(removeAllListenersSpy).toHaveBeenCalled()
329
- })
330
-
331
- it("should get stats", () => {
332
- const stats = listener.getStats()
333
-
334
- expect(stats).toEqual({
335
- messageCount: 0,
336
- conversationCount: 0,
337
- isActive: false
338
- })
339
- })
340
-
341
- it("should emit started and stopped events", async () => {
342
- const startedHandler = vi.fn()
343
- const stoppedHandler = vi.fn()
344
-
345
- listener.on("started", startedHandler)
346
- listener.on("stopped", stoppedHandler)
347
-
348
- // Mock to prevent infinite stream
349
- mockClient.conversations.streamAllMessages.mockResolvedValue({
350
- async *[Symbol.asyncIterator]() {
351
- // Empty iterator that ends immediately
352
- }
353
- })
354
-
355
- await listener.start()
356
-
357
- // Give a moment for the events to be processed
358
- await new Promise((resolve) => setTimeout(resolve, 10))
359
-
360
- expect(startedHandler).toHaveBeenCalled()
361
-
362
- listener.stop()
363
-
364
- // Give a moment for the stop event to be processed
365
- await new Promise((resolve) => setTimeout(resolve, 10))
366
-
367
- expect(stoppedHandler).toHaveBeenCalled()
368
- })
369
- })
@@ -1,343 +0,0 @@
1
- import { EventEmitter } from "node:events"
2
- import { Reaction } from "@xmtp/content-type-reaction"
3
- import { Reply } from "@xmtp/content-type-reply"
4
- import { http, PublicClient, createPublicClient } from "viem"
5
- import { mainnet } from "viem/chains"
6
- import { Resolver } from "../resolver/resolver"
7
- import type { MessageEvent, XmtpClient, XmtpMessage } from "../types"
8
-
9
- // Configuration for the message listener
10
- export interface MessageListenerConfig {
11
- publicClient: PublicClient
12
- xmtpClient: XmtpClient
13
- /**
14
- * Filter function to determine which messages to process
15
- * Return true to process the message, false to skip
16
- */
17
- filter?: (
18
- event: Pick<MessageEvent, "conversation" | "message" | "rootMessage">
19
- ) => Promise<boolean> | boolean
20
- /**
21
- * Heartbeat interval in milliseconds (default: 5 minutes)
22
- */
23
- heartbeatInterval?: number
24
- /**
25
- * Conversation check interval in milliseconds (default: 30 seconds)
26
- */
27
- conversationCheckInterval?: number
28
- /**
29
- * Environment variable key for XMTP environment (default: "XMTP_ENV")
30
- */
31
- envKey?: string
32
- }
33
-
34
- // Enriched message data with resolved address
35
-
36
- // Define the event signature for type safety
37
- export interface MessageListenerEvents {
38
- message: [data: MessageEvent]
39
- error: [error: Error]
40
- started: []
41
- stopped: []
42
- heartbeat: [stats: { messageCount: number; conversationCount: number }]
43
- }
44
-
45
- /**
46
- * A flexible XMTP message listener that can be configured for different applications
47
- */
48
- export class MessageListener extends EventEmitter {
49
- private xmtpClient: XmtpClient
50
- private resolver: Resolver
51
- private filter?: (
52
- event: Pick<MessageEvent, "conversation" | "message" | "rootMessage">
53
- ) => Promise<boolean> | boolean
54
- private heartbeatInterval?: NodeJS.Timeout
55
- private fallbackCheckInterval?: NodeJS.Timeout
56
- private messageCount = 0
57
- private conversations: any[] = []
58
- private readonly config: Required<
59
- Pick<
60
- MessageListenerConfig,
61
- "heartbeatInterval" | "conversationCheckInterval" | "envKey"
62
- >
63
- >
64
-
65
- constructor(config: MessageListenerConfig) {
66
- super()
67
- this.xmtpClient = config.xmtpClient
68
-
69
- // Create mainnet client for ENS resolution
70
- const mainnetClient = createPublicClient({
71
- chain: mainnet,
72
- transport: http()
73
- })
74
-
75
- // Create unified resolver with all capabilities
76
- this.resolver = new Resolver({
77
- xmtpClient: this.xmtpClient,
78
- mainnetClient,
79
- baseClient: config.publicClient,
80
- maxCacheSize: 1000,
81
- cacheTtl: 86400000 // 24 hours
82
- })
83
-
84
- this.filter = config.filter
85
- this.config = {
86
- heartbeatInterval: config.heartbeatInterval ?? 300000, // 5 minutes
87
- conversationCheckInterval: config.conversationCheckInterval ?? 30000, // 30 seconds
88
- envKey: config.envKey ?? "XMTP_ENV"
89
- }
90
- }
91
-
92
- // Type-safe event emitter methods
93
- on<U extends keyof MessageListenerEvents>(
94
- event: U,
95
- listener: (...args: MessageListenerEvents[U]) => void
96
- ): this {
97
- return super.on(event, listener)
98
- }
99
-
100
- emit<U extends keyof MessageListenerEvents>(
101
- event: U,
102
- ...args: MessageListenerEvents[U]
103
- ): boolean {
104
- return super.emit(event, ...args)
105
- }
106
-
107
- async start(): Promise<void> {
108
- const XMTP_ENV = process.env[this.config.envKey]
109
-
110
- // Pre-populate address cache from existing conversations
111
- await this.resolver?.prePopulateAllCaches()
112
-
113
- console.log("📡 Syncing conversations...")
114
- await this.xmtpClient.conversations.sync()
115
-
116
- const address = this.xmtpClient.accountIdentifier?.identifier
117
-
118
- // List existing conversations for debugging
119
- this.conversations = await this.xmtpClient.conversations.list()
120
-
121
- console.log(`🤖 XMTP[${XMTP_ENV}] Listening on ${address} ...`)
122
-
123
- // Emit started event
124
- this.emit("started")
125
-
126
- // Stream all messages and emit events for processing
127
- try {
128
- const stream = await this.xmtpClient.conversations.streamAllMessages()
129
-
130
- // Add a heartbeat to show the listener is active
131
- this.heartbeatInterval = setInterval(() => {
132
- this.emit("heartbeat", {
133
- messageCount: this.messageCount,
134
- conversationCount: this.conversations.length
135
- })
136
- if (this.messageCount > 0) {
137
- console.log(`💓 Active - processed ${this.messageCount} messages`)
138
- }
139
- }, this.config.heartbeatInterval)
140
-
141
- // Check for new conversations
142
- this.fallbackCheckInterval = setInterval(async () => {
143
- try {
144
- const latestConversations = await this.xmtpClient.conversations.list()
145
- if (latestConversations.length > this.conversations.length) {
146
- console.log(
147
- `🆕 Detected ${latestConversations.length - this.conversations.length} new conversations`
148
- )
149
- this.conversations.push(
150
- ...latestConversations.slice(this.conversations.length)
151
- )
152
- }
153
- } catch (error) {
154
- console.error("❌ Error checking for new conversations:", error)
155
- this.emit("error", error as Error)
156
- }
157
- }, this.config.conversationCheckInterval)
158
-
159
- try {
160
- for await (const message of stream) {
161
- this.messageCount++
162
-
163
- try {
164
- // Skip messages from self or null messages
165
- if (
166
- !message ||
167
- message.senderInboxId.toLowerCase() ===
168
- this.xmtpClient.inboxId.toLowerCase()
169
- ) {
170
- continue
171
- }
172
-
173
- console.log(
174
- `📨 Received message "${JSON.stringify(message)}" in ${message.conversationId}`
175
- )
176
-
177
- // Get conversation details
178
- const conversation =
179
- await this.xmtpClient.conversations.getConversationById(
180
- message.conversationId
181
- )
182
-
183
- if (!conversation) {
184
- console.log("❌ Could not find conversation for message")
185
- continue
186
- }
187
-
188
- const contentTypeId = message.contentType?.typeId
189
-
190
- // Extract message content for processing
191
- let messageContent: string
192
- if (contentTypeId === "reply") {
193
- const replyContent = message.content as any
194
- messageContent = (replyContent?.content || "").toString()
195
- } else if (
196
- contentTypeId === "remoteStaticAttachment" ||
197
- contentTypeId === "attachment"
198
- ) {
199
- // For attachments, use the fallback message or filename
200
- messageContent =
201
- (message as any).fallback ||
202
- (message.content as any)?.filename ||
203
- "[Attachment]"
204
- } else if (contentTypeId === "reaction") {
205
- // For reactions, use a simple representation
206
- const reactionContent = message.content as Reaction
207
- messageContent = `[Reaction: ${reactionContent.content || ""}]`
208
- } else {
209
- // For text and other content types, safely convert to string
210
- messageContent = message.content ? String(message.content) : ""
211
- }
212
-
213
- // Find root message for replies and reactions
214
- let rootMessage: XmtpMessage | null = message
215
- let parentMessage: XmtpMessage | null = null
216
-
217
- if (contentTypeId === "reply") {
218
- const { reference } = message.content as Reply
219
- rootMessage = await this.resolver.findRootMessage(reference)
220
- parentMessage = await this.resolver.findMessage(reference)
221
- } else if (contentTypeId === "reaction") {
222
- const { reference } = message.content as Reaction
223
- rootMessage = await this.resolver.findRootMessage(reference)
224
- parentMessage = await this.resolver.findMessage(reference)
225
- } else {
226
- // For text messages and attachments, they are root messages
227
- rootMessage = message
228
- parentMessage = null
229
- }
230
-
231
- // Skip if we couldn't find the root message
232
- if (!rootMessage) {
233
- console.warn(
234
- `⚠️ [MessageListener] Could not find root message for: ${message.id}`
235
- )
236
- continue
237
- }
238
-
239
- // Apply custom message filter if provided
240
- if (this.filter) {
241
- const shouldProcess = await this.filter({
242
- conversation,
243
- message,
244
- rootMessage
245
- })
246
- if (!shouldProcess) {
247
- console.log("🔄 Skipping message:", message.id)
248
- continue
249
- }
250
- }
251
-
252
- // Create sender using unified resolver
253
- const sender = await this.resolver.createXmtpSender(
254
- message.senderInboxId,
255
- message.conversationId
256
- )
257
-
258
- // Extract and resolve subjects (basenames and ENS names mentioned in message)
259
- // TODO: Update extractSubjects to work with unified resolver
260
- const subjects = {}
261
-
262
- // Create enriched message with resolved address, name, subjects, root message, and parent message
263
- const messageEvent: MessageEvent = {
264
- conversation,
265
- message,
266
- rootMessage: rootMessage as XmtpMessage, // We already checked it's not null above
267
- parentMessage: parentMessage || undefined,
268
- sender,
269
- subjects
270
- }
271
-
272
- // Emit the enriched message
273
- this.emit("message", messageEvent)
274
- } catch (messageError) {
275
- console.error("❌ Error processing message:", messageError)
276
- this.emit("error", messageError as Error)
277
- // Continue processing other messages instead of crashing
278
- }
279
- }
280
- } catch (streamError) {
281
- console.error("❌ Error in message stream:", streamError)
282
- this.cleanup()
283
- this.emit("error", streamError as Error)
284
- console.log("🔄 Attempting to restart stream...")
285
-
286
- // Wait a bit before restarting to avoid tight restart loops
287
- await new Promise((resolve) => setTimeout(resolve, 5000))
288
-
289
- // Recursively restart the message listener
290
- return this.start()
291
- }
292
- } catch (streamSetupError) {
293
- console.error("❌ Error setting up message stream:", streamSetupError)
294
- this.emit("error", streamSetupError as Error)
295
- throw streamSetupError
296
- }
297
- }
298
-
299
- private cleanup() {
300
- if (this.heartbeatInterval) {
301
- clearInterval(this.heartbeatInterval)
302
- }
303
- if (this.fallbackCheckInterval) {
304
- clearInterval(this.fallbackCheckInterval)
305
- }
306
- }
307
-
308
- stop() {
309
- this.cleanup()
310
- this.emit("stopped")
311
- console.log("🛑 Message listener stopped")
312
- this.removeAllListeners()
313
- }
314
-
315
- /**
316
- * Get current statistics
317
- */
318
- getStats() {
319
- return {
320
- messageCount: this.messageCount,
321
- conversationCount: this.conversations.length,
322
- isActive: !!this.heartbeatInterval
323
- }
324
- }
325
- }
326
-
327
- /**
328
- * Helper function to start a message listener
329
- */
330
- export async function startMessageListener(
331
- config: MessageListenerConfig
332
- ): Promise<MessageListener> {
333
- const listener = new MessageListener(config)
334
- await listener.start()
335
- return listener
336
- }
337
-
338
- /**
339
- * Factory function to create a message listener with common filters
340
- */
341
- export function createMessageListener(config: MessageListenerConfig) {
342
- return new MessageListener(config)
343
- }