@hybrd/xmtp 1.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.
@@ -0,0 +1,343 @@
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
+ }
@@ -0,0 +1,89 @@
1
+ import type { BasenameResolver } from "../resolver/basename-resolver"
2
+ import type { ENSResolver } from "../resolver/ens-resolver"
3
+
4
+ /**
5
+ * Extract basenames/ENS names from message content using @mention pattern
6
+ * @param content The message content to parse
7
+ * @returns Array of unique names found in the message
8
+ */
9
+ export function extractMentionedNames(content: string): string[] {
10
+ // Match @basename.eth and @basename.base.eth patterns (case insensitive)
11
+ const nameRegex = /@([a-zA-Z0-9-_]+\.(?:base\.)?eth)\b/gi
12
+ const matches = content.match(nameRegex)
13
+
14
+ if (!matches) {
15
+ return []
16
+ }
17
+
18
+ // Remove @ symbol and deduplicate
19
+ const names = matches.map((match) => match.slice(1).toLowerCase())
20
+ return [...new Set(names)]
21
+ }
22
+
23
+ /**
24
+ * Resolve mentioned names to addresses and return as subjects object
25
+ * @param mentionedNames Array of names to resolve
26
+ * @param basenameResolver Basename resolver instance
27
+ * @param ensResolver ENS resolver instance
28
+ * @returns Promise that resolves to subjects object mapping names to addresses
29
+ */
30
+ export async function resolveSubjects(
31
+ mentionedNames: string[],
32
+ basenameResolver: BasenameResolver,
33
+ ensResolver: ENSResolver
34
+ ): Promise<Record<string, `0x${string}`>> {
35
+ const subjects: Record<string, `0x${string}`> = {}
36
+
37
+ if (mentionedNames.length === 0) {
38
+ return subjects
39
+ }
40
+
41
+ console.log(
42
+ `🔍 Found ${mentionedNames.length} name mentions:`,
43
+ mentionedNames
44
+ )
45
+
46
+ for (const mentionedName of mentionedNames) {
47
+ try {
48
+ let resolvedAddress: string | null = null
49
+
50
+ // Check if it's an ENS name (.eth but not .base.eth)
51
+ if (ensResolver.isENSName(mentionedName)) {
52
+ console.log(`🔍 Resolving ENS name: ${mentionedName}`)
53
+ resolvedAddress = await ensResolver.resolveENSName(mentionedName)
54
+ } else {
55
+ // It's a basename (.base.eth or other format)
56
+ console.log(`🔍 Resolving basename: ${mentionedName}`)
57
+ resolvedAddress =
58
+ await basenameResolver.getBasenameAddress(mentionedName)
59
+ }
60
+
61
+ if (resolvedAddress) {
62
+ subjects[mentionedName] = resolvedAddress as `0x${string}`
63
+ console.log(`✅ Resolved ${mentionedName} → ${resolvedAddress}`)
64
+ } else {
65
+ console.log(`❌ Could not resolve address for: ${mentionedName}`)
66
+ }
67
+ } catch (error) {
68
+ console.error(`❌ Error resolving ${mentionedName}:`, error)
69
+ }
70
+ }
71
+
72
+ return subjects
73
+ }
74
+
75
+ /**
76
+ * Extract subjects from message content (combines extraction and resolution)
77
+ * @param content The message content to parse
78
+ * @param basenameResolver Basename resolver instance
79
+ * @param ensResolver ENS resolver instance
80
+ * @returns Promise that resolves to subjects object mapping names to addresses
81
+ */
82
+ export async function extractSubjects(
83
+ content: string,
84
+ basenameResolver: BasenameResolver,
85
+ ensResolver: ENSResolver
86
+ ): Promise<Record<string, `0x${string}`>> {
87
+ const mentionedNames = extractMentionedNames(content)
88
+ return await resolveSubjects(mentionedNames, basenameResolver, ensResolver)
89
+ }
@@ -0,0 +1,203 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { validateEnvironment } from "./client";
5
+ import type { WalletInfo, WalletStorage } from "./walletService";
6
+
7
+ const { XMTP_NETWORK_ID } = validateEnvironment(["XMTP_NETWORK_ID"]);
8
+ export const STORAGE_DIRS = {
9
+ WALLET: ".data/wallet_data",
10
+ XMTP: ".data/xmtp",
11
+ };
12
+
13
+ /**
14
+ * Generic file-based storage service
15
+ */
16
+ export class FileStorage implements WalletStorage {
17
+ private initialized = false;
18
+
19
+ constructor(private baseDirs = STORAGE_DIRS) {
20
+ this.initialize();
21
+ }
22
+
23
+ /**
24
+ * Initialize storage directories
25
+ */
26
+ public initialize(): void {
27
+ if (this.initialized) return;
28
+
29
+ Object.values(this.baseDirs).forEach((dir) => {
30
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
31
+ });
32
+
33
+ this.initialized = true;
34
+ }
35
+
36
+ /**
37
+ * File operations - save/read/delete
38
+ */
39
+ private async saveToFile(
40
+ directory: string,
41
+ identifier: string,
42
+ data: string,
43
+ ): Promise<boolean> {
44
+ const key = `${identifier}-${XMTP_NETWORK_ID}`;
45
+ try {
46
+ await fs.writeFile(path.join(directory, `${key}.json`), data);
47
+ return true;
48
+ } catch (error) {
49
+ console.error(`Error writing to file ${key}:`, error);
50
+ return false;
51
+ }
52
+ }
53
+
54
+ private async readFromFile<T>(
55
+ directory: string,
56
+ identifier: string,
57
+ ): Promise<T | null> {
58
+ try {
59
+ const key = `${identifier}-${XMTP_NETWORK_ID}`;
60
+ const data = await fs.readFile(
61
+ path.join(directory, `${key}.json`),
62
+ "utf-8",
63
+ );
64
+ return JSON.parse(data) as T;
65
+ } catch (error) {
66
+ if (
67
+ error instanceof Error &&
68
+ (error.message.includes("ENOENT") ||
69
+ error.message.includes("no such file or directory"))
70
+ ) {
71
+ return null;
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ public async deleteFile(directory: string, key: string): Promise<boolean> {
78
+ try {
79
+ await fs.unlink(path.join(directory, `${key}.json`));
80
+ return true;
81
+ } catch (error) {
82
+ console.error(`Error deleting file ${key}:`, error);
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Generic data operations
89
+ */
90
+ public async saveData(
91
+ category: string,
92
+ id: string,
93
+ data: unknown,
94
+ ): Promise<boolean> {
95
+ if (!this.initialized) this.initialize();
96
+
97
+ // Make sure the directory exists
98
+ const directory = path.join(".data", category);
99
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
100
+
101
+ return await this.saveToFile(directory, id, JSON.stringify(data));
102
+ }
103
+
104
+ public async getData<T>(category: string, id: string): Promise<T | null> {
105
+ if (!this.initialized) this.initialize();
106
+
107
+ const directory = path.join(".data", category);
108
+ return this.readFromFile<T>(directory, id);
109
+ }
110
+
111
+ public async listData<T>(category: string): Promise<T[]> {
112
+ if (!this.initialized) this.initialize();
113
+
114
+ try {
115
+ const directory = path.join(".data", category);
116
+ if (!existsSync(directory)) return [];
117
+
118
+ const files = await fs.readdir(directory);
119
+ const items: T[] = [];
120
+
121
+ for (const file of files.filter((f) => f.endsWith(".json"))) {
122
+ const id = file.replace(`-${XMTP_NETWORK_ID}.json`, "");
123
+ const data = await this.getData<T>(category, id);
124
+ if (data) items.push(data);
125
+ }
126
+
127
+ return items;
128
+ } catch (error) {
129
+ console.error(`Error listing data in ${category}:`, error);
130
+ return [];
131
+ }
132
+ }
133
+
134
+ public async deleteData(category: string, id: string): Promise<boolean> {
135
+ if (!this.initialized) this.initialize();
136
+
137
+ try {
138
+ const directory = path.join(".data", category);
139
+ const key = `${id}-${XMTP_NETWORK_ID}`;
140
+ return await this.deleteFile(directory, key);
141
+ } catch (error) {
142
+ console.error(`Error deleting data ${id} from ${category}:`, error);
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Wallet Storage implementation
149
+ */
150
+ public async saveWallet(userId: string, walletData: string): Promise<void> {
151
+ if (!this.initialized) this.initialize();
152
+ await this.saveToFile(this.baseDirs.WALLET, userId, walletData);
153
+ }
154
+
155
+ public async getWallet(userId: string): Promise<WalletInfo | null> {
156
+ if (!this.initialized) this.initialize();
157
+ return this.readFromFile(this.baseDirs.WALLET, userId);
158
+ }
159
+
160
+ public async getWalletByAddress(address: string): Promise<WalletInfo | null> {
161
+ if (!this.initialized) this.initialize();
162
+ try {
163
+ const directory = this.baseDirs.WALLET;
164
+ if (!existsSync(directory)) return null;
165
+
166
+ const files = await fs.readdir(directory);
167
+
168
+ for (const file of files.filter((f) => f.endsWith(".json"))) {
169
+ try {
170
+ const data = await fs.readFile(path.join(directory, file), "utf-8");
171
+ const walletData = JSON.parse(data) as WalletInfo;
172
+
173
+ // Check if this wallet has the target address
174
+ if (walletData.address.toLowerCase() === address.toLowerCase()) {
175
+ return walletData;
176
+ }
177
+ } catch (err) {
178
+ console.error(`Error parsing wallet data from ${file}:`, err);
179
+ // Skip files with parsing errors
180
+ continue;
181
+ }
182
+ }
183
+
184
+ return null;
185
+ } catch (error) {
186
+ console.error(`Error finding wallet by address ${address}:`, error);
187
+ return null;
188
+ }
189
+ }
190
+
191
+ public async getWalletCount(): Promise<number> {
192
+ try {
193
+ const files = await fs.readdir(this.baseDirs.WALLET);
194
+ return files.filter((file) => file.endsWith(".json")).length;
195
+ } catch (error) {
196
+ console.error("Error getting wallet count:", error);
197
+ return 0;
198
+ }
199
+ }
200
+ }
201
+
202
+ // Export a single global instance
203
+ export const storage = new FileStorage();