@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.
- package/.cache/tsbuildinfo.json +1 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/README.md +380 -0
- package/biome.jsonc +4 -0
- package/package.json +46 -0
- package/scripts/generate-keys.ts +25 -0
- package/scripts/refresh-identity.ts +119 -0
- package/scripts/register-wallet.ts +95 -0
- package/scripts/revoke-all-installations.ts +91 -0
- package/scripts/revoke-installations.ts +94 -0
- package/src/abi/l2_resolver.ts +699 -0
- package/src/client.ts +940 -0
- package/src/constants.ts +6 -0
- package/src/index.ts +129 -0
- package/src/lib/message-listener.test.ts +369 -0
- package/src/lib/message-listener.ts +343 -0
- package/src/lib/subjects.ts +89 -0
- package/src/localStorage.ts.old +203 -0
- package/src/resolver/address-resolver.ts +221 -0
- package/src/resolver/basename-resolver.ts +585 -0
- package/src/resolver/ens-resolver.ts +324 -0
- package/src/resolver/index.ts +1 -0
- package/src/resolver/resolver.ts +336 -0
- package/src/resolver/xmtp-resolver.ts +436 -0
- package/src/service-client.ts +286 -0
- package/src/transactionMonitor.ts.old +275 -0
- package/src/types.ts +157 -0
- package/tsconfig.json +12 -0
|
@@ -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();
|