@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.
- package/README.md +209 -319
- package/dist/index.cjs +138 -83
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +131 -78
- package/dist/index.js.map +1 -1
- package/package.json +10 -6
- package/src/client.ts +19 -39
- package/src/index.ts +8 -5
- package/src/lib/jwt.ts +16 -23
- package/src/lib/secret.ts +33 -0
- package/src/plugin.ts +98 -39
- package/src/index.ts.old +0 -145
- package/src/lib/message-listener.test.ts.old +0 -369
- package/src/lib/message-listener.ts.old +0 -343
- package/src/localStorage.ts.old +0 -203
- package/src/plugin.filters.test.ts +0 -158
- package/src/service-client.ts.old +0 -309
- package/src/transactionMonitor.ts.old +0 -275
- package/src/types.ts.old +0 -157
|
@@ -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
|
-
}
|