@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,436 @@
1
+ import type { XmtpClient, XmtpMessage } from "../types"
2
+
3
+ interface XmtpResolverOptions {
4
+ /**
5
+ * Maximum number of addresses to cache
6
+ * @default 1000
7
+ */
8
+ maxCacheSize?: number
9
+ /**
10
+ * Cache TTL in milliseconds
11
+ * @default 86400000 (24 hours)
12
+ */
13
+ cacheTtl?: number
14
+ /**
15
+ * Maximum number of messages to cache
16
+ * @default 1000
17
+ */
18
+ maxMessageCacheSize?: number
19
+ /**
20
+ * Message cache TTL in milliseconds
21
+ * @default 3600000 (1 hour)
22
+ */
23
+ messageCacheTtl?: number
24
+ }
25
+
26
+ interface AddressCacheEntry {
27
+ address: string
28
+ timestamp: number
29
+ }
30
+
31
+ interface MessageCacheEntry {
32
+ message: XmtpMessage | null
33
+ timestamp: number
34
+ }
35
+
36
+ export class XmtpResolver {
37
+ private addressCache = new Map<string, AddressCacheEntry>()
38
+ private messageCache = new Map<string, MessageCacheEntry>()
39
+ private readonly maxCacheSize: number
40
+ private readonly cacheTtl: number
41
+ private readonly maxMessageCacheSize: number
42
+ private readonly messageCacheTtl: number
43
+
44
+ constructor(
45
+ private client: XmtpClient,
46
+ options: XmtpResolverOptions = {}
47
+ ) {
48
+ this.maxCacheSize = options.maxCacheSize ?? 1000
49
+ this.cacheTtl = options.cacheTtl ?? 86400000 // 24 hours
50
+ this.maxMessageCacheSize = options.maxMessageCacheSize ?? 1000
51
+ this.messageCacheTtl = options.messageCacheTtl ?? 3600000 // 1 hour
52
+ }
53
+
54
+ /**
55
+ * Resolve user address from inbox ID with caching
56
+ */
57
+ async resolveAddress(
58
+ inboxId: string,
59
+ conversationId?: string
60
+ ): Promise<`0x${string}` | null> {
61
+ // Check cache first (fastest)
62
+ const cached = this.getCachedAddress(inboxId)
63
+ if (cached) {
64
+ console.log(
65
+ `✅ [XmtpResolver] Resolved user address from cache: ${cached}`
66
+ )
67
+ return cached
68
+ }
69
+
70
+ let userAddress = undefined
71
+
72
+ try {
73
+ // Try conversation members lookup first (faster than network call)
74
+ if (conversationId) {
75
+ const conversation =
76
+ await this.client.conversations.getConversationById(conversationId)
77
+ if (conversation) {
78
+ userAddress = await this.resolveFromConversation(
79
+ conversation,
80
+ inboxId
81
+ )
82
+ if (userAddress) {
83
+ this.setCachedAddress(inboxId, userAddress)
84
+ console.log(
85
+ `✅ [XmtpResolver] Resolved user address: ${userAddress}`
86
+ )
87
+ return userAddress
88
+ }
89
+ }
90
+ }
91
+
92
+ // Fallback to inboxStateFromInboxIds
93
+ userAddress = await this.resolveFromInboxState(inboxId)
94
+ if (userAddress) {
95
+ this.setCachedAddress(inboxId, userAddress)
96
+ console.log(
97
+ `✅ [XmtpResolver] Resolved user address via fallback: ${userAddress}`
98
+ )
99
+ return userAddress
100
+ }
101
+
102
+ console.log(`⚠️ [XmtpResolver] No identifiers found for inbox ${inboxId}`)
103
+ return null
104
+ } catch (error) {
105
+ console.error(
106
+ `❌ [XmtpResolver] Error resolving user address for ${inboxId}:`,
107
+ error
108
+ )
109
+ return null
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Find any message by ID with caching
115
+ */
116
+ async findMessage(messageId: string): Promise<XmtpMessage | null> {
117
+ // Check cache first
118
+ const cached = this.getCachedMessage(messageId)
119
+ if (cached !== undefined) {
120
+ console.log(
121
+ cached
122
+ ? `✅ [XmtpResolver] Found message from cache: ${cached.id}`
123
+ : `✅ [XmtpResolver] Found cached null message for: ${messageId}`
124
+ )
125
+ return cached
126
+ }
127
+
128
+ try {
129
+ console.log(`🔍 [XmtpResolver] Finding message: ${messageId}`)
130
+ const message = await this.client.conversations.getMessageById(messageId)
131
+
132
+ if (message) {
133
+ this.setCachedMessage(messageId, message)
134
+ console.log(`✅ [XmtpResolver] Found and cached message: ${message.id}`)
135
+ return message
136
+ }
137
+
138
+ console.log(`⚠️ [XmtpResolver] Message not found: ${messageId}`)
139
+ this.setCachedMessage(messageId, null)
140
+ return null
141
+ } catch (error) {
142
+ console.error(
143
+ `❌ [XmtpResolver] Error finding message ${messageId}:`,
144
+ error
145
+ )
146
+ this.setCachedMessage(messageId, null)
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Find root message with caching
153
+ */
154
+ async findRootMessage(messageId: string): Promise<XmtpMessage | null> {
155
+ // Check if we already have the root cached with a special key
156
+ const rootCacheKey = `root:${messageId}`
157
+ const cached = this.getCachedMessage(rootCacheKey)
158
+ if (cached !== undefined) {
159
+ console.log(
160
+ cached
161
+ ? `✅ [XmtpResolver] Found root message from cache: ${cached.id}`
162
+ : `✅ [XmtpResolver] Found cached null root for: ${messageId}`
163
+ )
164
+ return cached
165
+ }
166
+
167
+ try {
168
+ console.log(`🔍 [XmtpResolver] Finding root message for: ${messageId}`)
169
+ const rootMessage = await this.findRootMessageRecursive(messageId)
170
+
171
+ if (rootMessage) {
172
+ this.setCachedMessage(rootCacheKey, rootMessage)
173
+ console.log(
174
+ `✅ [XmtpResolver] Found and cached root message: ${rootMessage.id}`
175
+ )
176
+ return rootMessage
177
+ }
178
+
179
+ console.log(`⚠️ [XmtpResolver] No root message found for: ${messageId}`)
180
+ this.setCachedMessage(rootCacheKey, null)
181
+ return null
182
+ } catch (error) {
183
+ console.error(
184
+ `❌ [XmtpResolver] Error finding root message for ${messageId}:`,
185
+ error
186
+ )
187
+ this.setCachedMessage(rootCacheKey, null)
188
+ return null
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Recursively finds the root message in a reply chain by following reply references
194
+ */
195
+ private async findRootMessageRecursive(
196
+ messageId: string,
197
+ visitedIds = new Set<string>()
198
+ ): Promise<XmtpMessage | null> {
199
+ // Prevent infinite loops
200
+ if (visitedIds.has(messageId)) {
201
+ console.warn(
202
+ `⚠️ Circular reference detected in message chain at ${messageId}`
203
+ )
204
+ return null
205
+ }
206
+ visitedIds.add(messageId)
207
+
208
+ const message = await this.client.conversations.getMessageById(messageId)
209
+
210
+ if (!message) {
211
+ console.warn(`⚠️ [findRootMessage] Message not found: ${messageId}`)
212
+ return null
213
+ }
214
+
215
+ // Debug: Log the raw message structure as returned by XMTP client
216
+ console.log(`🔍 [findRootMessage] Raw message ${messageId}:`, {
217
+ id: message.id,
218
+ contentType: message.contentType,
219
+ content: message.content,
220
+ sentAt: message.sentAt
221
+ })
222
+
223
+ // Method 1: Try the parameters (as seen in webhook data)
224
+ if ((message as any).content?.reference) {
225
+ return this.findRootMessageRecursive(
226
+ (message as any).content.reference,
227
+ visitedIds
228
+ )
229
+ }
230
+
231
+ return message
232
+ }
233
+
234
+ /**
235
+ * Resolve address from conversation members
236
+ */
237
+ private async resolveFromConversation(
238
+ conversation: any,
239
+ inboxId: string
240
+ ): Promise<`0x${string}` | null> {
241
+ try {
242
+ const members = await conversation.members()
243
+ const sender = members.find(
244
+ (member: any) => member.inboxId.toLowerCase() === inboxId.toLowerCase()
245
+ )
246
+
247
+ if (sender) {
248
+ const ethIdentifier = sender.accountIdentifiers.find(
249
+ (id: any) => id.identifierKind === 0 // IdentifierKind.Ethereum
250
+ )
251
+ if (ethIdentifier) {
252
+ return ethIdentifier.identifier
253
+ } else {
254
+ console.log(
255
+ `⚠️ [XmtpResolver] No Ethereum identifier found for inbox ${inboxId}`
256
+ )
257
+ }
258
+ } else {
259
+ console.log(
260
+ `⚠️ [XmtpResolver] Sender not found in conversation members for inbox ${inboxId}`
261
+ )
262
+ }
263
+ } catch (error) {
264
+ console.error(
265
+ `❌ [XmtpResolver] Error resolving from conversation members:`,
266
+ error
267
+ )
268
+ }
269
+
270
+ return null
271
+ }
272
+
273
+ /**
274
+ * Resolve address from inbox state (network fallback)
275
+ */
276
+ private async resolveFromInboxState(
277
+ inboxId: string
278
+ ): Promise<`0x${string}` | null> {
279
+ try {
280
+ const inboxState = await this.client.preferences.inboxStateFromInboxIds([
281
+ inboxId
282
+ ])
283
+ const firstState = inboxState?.[0]
284
+ if (firstState?.identifiers && firstState.identifiers.length > 0) {
285
+ const firstIdentifier = firstState.identifiers[0]
286
+ return firstIdentifier?.identifier as `0x${string}`
287
+ }
288
+ } catch (error) {
289
+ console.error(
290
+ `❌ [XmtpResolver] Error resolving from inbox state:`,
291
+ error
292
+ )
293
+ }
294
+
295
+ return null
296
+ }
297
+
298
+ /**
299
+ * Get cached address if not expired
300
+ */
301
+ private getCachedAddress(inboxId: string): `0x${string}` | null {
302
+ const entry = this.addressCache.get(inboxId)
303
+ if (!entry) return null
304
+
305
+ const now = Date.now()
306
+ if (now - entry.timestamp > this.cacheTtl) {
307
+ this.addressCache.delete(inboxId)
308
+ return null
309
+ }
310
+
311
+ return entry.address as `0x${string}`
312
+ }
313
+
314
+ /**
315
+ * Cache address with LRU eviction
316
+ */
317
+ private setCachedAddress(inboxId: string, address: `0x${string}`): void {
318
+ // Simple LRU: if cache is full, remove oldest entry
319
+ if (this.addressCache.size >= this.maxCacheSize) {
320
+ const firstKey = this.addressCache.keys().next().value
321
+ if (firstKey) {
322
+ this.addressCache.delete(firstKey)
323
+ }
324
+ }
325
+
326
+ this.addressCache.set(inboxId, {
327
+ address,
328
+ timestamp: Date.now()
329
+ })
330
+ }
331
+
332
+ /**
333
+ * Get cached message if not expired
334
+ */
335
+ private getCachedMessage(messageId: string): XmtpMessage | null | undefined {
336
+ const entry = this.messageCache.get(messageId)
337
+ if (!entry) return undefined
338
+
339
+ const now = Date.now()
340
+ if (now - entry.timestamp > this.messageCacheTtl) {
341
+ this.messageCache.delete(messageId)
342
+ return undefined
343
+ }
344
+
345
+ return entry.message
346
+ }
347
+
348
+ /**
349
+ * Cache message with LRU eviction
350
+ */
351
+ private setCachedMessage(
352
+ messageId: string,
353
+ message: XmtpMessage | null
354
+ ): void {
355
+ // Simple LRU: if cache is full, remove oldest entry
356
+ if (this.messageCache.size >= this.maxMessageCacheSize) {
357
+ const firstKey = this.messageCache.keys().next().value
358
+ if (firstKey) {
359
+ this.messageCache.delete(firstKey)
360
+ }
361
+ }
362
+
363
+ this.messageCache.set(messageId, {
364
+ message,
365
+ timestamp: Date.now()
366
+ })
367
+ }
368
+
369
+ /**
370
+ * Pre-populate address cache from existing conversations
371
+ */
372
+ async prePopulateCache(): Promise<void> {
373
+ console.log("🔄 [XmtpResolver] Pre-populating address cache...")
374
+ try {
375
+ const conversations = await this.client.conversations.list()
376
+ let cachedCount = 0
377
+
378
+ for (const conversation of conversations) {
379
+ try {
380
+ const members = await conversation.members()
381
+ for (const member of members) {
382
+ const ethIdentifier = member.accountIdentifiers.find(
383
+ (id: any) => id.identifierKind === 0 // IdentifierKind.Ethereum
384
+ )
385
+ if (ethIdentifier) {
386
+ this.setCachedAddress(
387
+ member.inboxId,
388
+ ethIdentifier.identifier as `0x${string}`
389
+ )
390
+ cachedCount++
391
+ }
392
+ }
393
+ } catch (error) {
394
+ console.error(
395
+ "[XmtpResolver] Error pre-caching conversation members:",
396
+ error
397
+ )
398
+ }
399
+ }
400
+
401
+ console.log(
402
+ `✅ [XmtpResolver] Pre-cached ${cachedCount} address mappings`
403
+ )
404
+ } catch (error) {
405
+ console.error("[XmtpResolver] Error pre-populating cache:", error)
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Clear all caches
411
+ */
412
+ clearCache(): void {
413
+ this.addressCache.clear()
414
+ this.messageCache.clear()
415
+ console.log("🗑️ [XmtpResolver] All caches cleared")
416
+ }
417
+
418
+ /**
419
+ * Get cache statistics
420
+ */
421
+ getCacheStats(): {
422
+ address: { size: number; maxSize: number }
423
+ message: { size: number; maxSize: number }
424
+ } {
425
+ return {
426
+ address: {
427
+ size: this.addressCache.size,
428
+ maxSize: this.maxCacheSize
429
+ },
430
+ message: {
431
+ size: this.messageCache.size,
432
+ maxSize: this.maxMessageCacheSize
433
+ }
434
+ }
435
+ }
436
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @fileoverview XMTP Service Client Library
3
+ *
4
+ * Clean, reusable client for making HTTP calls to the XMTP listener service.
5
+ * Handles authentication, request formatting, and error handling.
6
+ *
7
+ * This is different from the direct XMTP client - this is for external services
8
+ * talking to our XMTP listener service.
9
+ */
10
+
11
+ import type {
12
+ GetMessageParams,
13
+ SendMessageParams,
14
+ SendMessageResponse,
15
+ SendReactionParams,
16
+ SendReactionResponse,
17
+ SendReplyParams,
18
+ SendReplyResponse,
19
+ SendTransactionParams,
20
+ SendTransactionResponse,
21
+ XmtpServiceClientConfig,
22
+ XmtpServiceMessage,
23
+ XmtpServiceResponse
24
+ } from "./types.js"
25
+
26
+ export class XmtpServiceClient {
27
+ private config: XmtpServiceClientConfig
28
+
29
+ constructor(config: XmtpServiceClientConfig) {
30
+ this.config = config
31
+ }
32
+
33
+ private async request<T = unknown>(
34
+ endpoint: string,
35
+ body?: Record<string, unknown>,
36
+ method: "GET" | "POST" = "POST"
37
+ ): Promise<XmtpServiceResponse<T>> {
38
+ try {
39
+ const baseUrl = this.config.serviceUrl.replace(/\/+$/, "")
40
+
41
+ // Use Authorization header for xmtp-tools endpoints, query parameter for others
42
+ const isXmtpToolsEndpoint = endpoint.startsWith("/xmtp-tools/")
43
+ const url = `${baseUrl}${endpoint}?token=${this.config.serviceToken}`
44
+
45
+ const headers: Record<string, string> = {
46
+ "Content-Type": "application/json"
47
+ }
48
+
49
+ // Add Authorization header for xmtp-tools endpoints
50
+ if (isXmtpToolsEndpoint) {
51
+ headers.Authorization = `Bearer ${this.config.serviceToken}`
52
+ }
53
+
54
+ const fetchOptions: RequestInit = {
55
+ method,
56
+ headers
57
+ }
58
+
59
+ if (method === "POST" && body) {
60
+ fetchOptions.body = JSON.stringify(body)
61
+ }
62
+
63
+ const response = await fetch(url, fetchOptions)
64
+
65
+ if (!response.ok) {
66
+ let errorMessage = `HTTP ${response.status}`
67
+ try {
68
+ const responseText = await response.text()
69
+ try {
70
+ const errorData = JSON.parse(responseText) as { error?: string }
71
+ errorMessage = errorData.error || errorMessage
72
+ } catch {
73
+ errorMessage = responseText || errorMessage
74
+ }
75
+ } catch {
76
+ // If we can't read the response at all, use the status
77
+ }
78
+ throw new Error(errorMessage)
79
+ }
80
+
81
+ return {
82
+ success: true,
83
+ data: (await response.json()) as T
84
+ }
85
+ } catch (error) {
86
+ console.error(
87
+ `❌ [XmtpServiceClient] Request to ${endpoint} failed:`,
88
+ error
89
+ )
90
+ return {
91
+ success: false,
92
+ error: error instanceof Error ? error.message : "Unknown error"
93
+ }
94
+ }
95
+ }
96
+
97
+ async sendMessage(
98
+ params: SendMessageParams
99
+ ): Promise<XmtpServiceResponse<SendMessageResponse>> {
100
+ return this.request<SendMessageResponse>("/xmtp-tools/send", {
101
+ content: params.content
102
+ })
103
+ }
104
+
105
+ async sendReply(
106
+ params: SendReplyParams
107
+ ): Promise<XmtpServiceResponse<SendReplyResponse>> {
108
+ return this.request<SendReplyResponse>("/xmtp-tools/reply", {
109
+ content: params.content,
110
+ messageId: params.messageId
111
+ })
112
+ }
113
+
114
+ async sendReaction(
115
+ params: SendReactionParams
116
+ ): Promise<XmtpServiceResponse<SendReactionResponse>> {
117
+ return this.request<SendReactionResponse>("/xmtp-tools/react", {
118
+ messageId: params.messageId,
119
+ emoji: params.emoji,
120
+ action: params.action
121
+ })
122
+ }
123
+
124
+ async sendTransaction(
125
+ params: SendTransactionParams
126
+ ): Promise<XmtpServiceResponse<SendTransactionResponse>> {
127
+ return this.request<SendTransactionResponse>("/xmtp-tools/transaction", {
128
+ fromAddress: params.fromAddress,
129
+ chainId: params.chainId,
130
+ calls: params.calls.map((call) => ({
131
+ to: call.to,
132
+ data: call.data,
133
+ ...(call.gas && { gas: call.gas }),
134
+ value: call.value || "0x0",
135
+ metadata: {
136
+ ...call.metadata,
137
+ chainId: params.chainId,
138
+ from: params.fromAddress,
139
+ version: "1"
140
+ }
141
+ }))
142
+ })
143
+ }
144
+
145
+ /**
146
+ * Get a single message by ID
147
+ */
148
+ async getMessage(
149
+ params: GetMessageParams
150
+ ): Promise<XmtpServiceResponse<XmtpServiceMessage>> {
151
+ return this.request<XmtpServiceMessage>(
152
+ `/xmtp-tools/messages/${params.messageId}`,
153
+ undefined,
154
+ "GET"
155
+ )
156
+ }
157
+
158
+ // getConversationMessages removed - superseded by thread-based approach
159
+ }
160
+
161
+ /**
162
+ * Create an XMTP service client from runtime context
163
+ * Expects the runtime context to have xmtpServiceUrl and xmtpServiceToken
164
+ */
165
+ export function createXmtpServiceClient(
166
+ serviceUrl: string,
167
+ serviceToken: string
168
+ ): XmtpServiceClient {
169
+ if (!serviceUrl || !serviceToken) {
170
+ throw new Error("Missing XMTP service URL or token from runtime context")
171
+ }
172
+
173
+ return new XmtpServiceClient({
174
+ serviceUrl,
175
+ serviceToken
176
+ })
177
+ }
178
+
179
+ export interface XmtpAuthConfig {
180
+ serviceUrl: string
181
+ serviceToken: string
182
+ source: "callback" | "environment"
183
+ }
184
+
185
+ /**
186
+ * Get XMTP authentication configuration from multiple sources
187
+ * Priority: callback credentials > environment credentials
188
+ */
189
+ export function getXmtpAuthConfig(
190
+ callbackUrl?: string,
191
+ callbackToken?: string
192
+ ): XmtpAuthConfig | null {
193
+ // Priority 1: Use callback credentials if available
194
+ if (callbackUrl && callbackToken) {
195
+ console.log("🔑 [XmtpAuth] Using callback-provided credentials")
196
+ return {
197
+ serviceUrl: callbackUrl,
198
+ serviceToken: callbackToken,
199
+ source: "callback"
200
+ }
201
+ }
202
+
203
+ // Priority 2: Use environment credentials
204
+ const envUrl = process.env.XMTP_HOST
205
+ const envToken = process.env.XMTP_API_KEY
206
+
207
+ if (envUrl && envToken) {
208
+ console.log("🔑 [XmtpAuth] Using environment credentials")
209
+ return {
210
+ serviceUrl: envUrl,
211
+ serviceToken: envToken,
212
+ source: "environment"
213
+ }
214
+ }
215
+
216
+ // No valid credentials found
217
+ console.error(
218
+ "❌ [XmtpAuth] No XMTP credentials found in callback or environment"
219
+ )
220
+ console.error(
221
+ "💡 [XmtpAuth] Expected: XMTP_HOST + XMTP_API_KEY or callback credentials"
222
+ )
223
+ return null
224
+ }
225
+
226
+ /**
227
+ * Create an authenticated XMTP service client
228
+ * Handles both callback and environment credential sources
229
+ */
230
+ export function createAuthenticatedXmtpClient(
231
+ callbackUrl?: string,
232
+ callbackToken?: string
233
+ ): XmtpServiceClient {
234
+ const authConfig = getXmtpAuthConfig(callbackUrl, callbackToken)
235
+
236
+ if (!authConfig) {
237
+ throw new Error("No XMTP credentials found")
238
+ }
239
+
240
+ console.log(
241
+ `🔗 [XmtpAuth] Creating XMTP client (${authConfig.source} credentials)`
242
+ )
243
+
244
+ return createXmtpServiceClient(authConfig.serviceUrl, authConfig.serviceToken)
245
+ }
246
+
247
+ /**
248
+ * Constructs a URL for XMTP tools API endpoints with token authentication
249
+ *
250
+ * @param {string} baseUrl - The base URL of the XMTP service (e.g., "https://api.example.com")
251
+ * @param {string} action - The specific action/endpoint to call (e.g., "send", "receive", "status")
252
+ * @param {string} token - Authentication token (either JWT or API key)
253
+ * @returns {string} Complete URL with token as query parameter
254
+ *
255
+ * @description
256
+ * Builds URLs for XMTP tools endpoints using query parameter authentication.
257
+ * The token is appended as a query parameter for GET request authentication,
258
+ * following the pattern: `/xmtp-tools/{action}?token={token}`
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * const url = getXMTPToolsUrl(
263
+ * "https://api.hybrid.dev",
264
+ * "send",
265
+ * "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
266
+ * );
267
+ * // Returns: "https://api.hybrid.dev/xmtp-tools/send?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
268
+ * ```
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * // Using with API key
273
+ * const url = getXMTPToolsUrl(
274
+ * process.env.XMTP_BASE_URL,
275
+ * "status",
276
+ * process.env.XMTP_API_KEY
277
+ * );
278
+ * ```
279
+ */
280
+ export function getXMTPToolsUrl(
281
+ baseUrl: string,
282
+ action: string,
283
+ token: string
284
+ ): string {
285
+ return `${baseUrl}/xmtp-tools/${action}?token=${token}`
286
+ }