@towns-labs/agent 2.0.1

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.
Files changed (46) hide show
  1. package/README.md +151 -0
  2. package/dist/agent.d.ts +480 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +1758 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/agent.test.d.ts +2 -0
  7. package/dist/agent.test.d.ts.map +1 -0
  8. package/dist/agent.test.js +1315 -0
  9. package/dist/agent.test.js.map +1 -0
  10. package/dist/eventDedup.d.ts +73 -0
  11. package/dist/eventDedup.d.ts.map +1 -0
  12. package/dist/eventDedup.js +105 -0
  13. package/dist/eventDedup.js.map +1 -0
  14. package/dist/eventDedup.test.d.ts +2 -0
  15. package/dist/eventDedup.test.d.ts.map +1 -0
  16. package/dist/eventDedup.test.js +222 -0
  17. package/dist/eventDedup.test.js.map +1 -0
  18. package/dist/identity-types.d.ts +43 -0
  19. package/dist/identity-types.d.ts.map +1 -0
  20. package/dist/identity-types.js +2 -0
  21. package/dist/identity-types.js.map +1 -0
  22. package/dist/index.d.ts +11 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +11 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/interaction-api.d.ts +61 -0
  27. package/dist/interaction-api.d.ts.map +1 -0
  28. package/dist/interaction-api.js +95 -0
  29. package/dist/interaction-api.js.map +1 -0
  30. package/dist/payments.d.ts +89 -0
  31. package/dist/payments.d.ts.map +1 -0
  32. package/dist/payments.js +144 -0
  33. package/dist/payments.js.map +1 -0
  34. package/dist/re-exports.d.ts +2 -0
  35. package/dist/re-exports.d.ts.map +1 -0
  36. package/dist/re-exports.js +2 -0
  37. package/dist/re-exports.js.map +1 -0
  38. package/dist/smart-account.d.ts +54 -0
  39. package/dist/smart-account.d.ts.map +1 -0
  40. package/dist/smart-account.js +132 -0
  41. package/dist/smart-account.js.map +1 -0
  42. package/dist/snapshot-getter.d.ts +21 -0
  43. package/dist/snapshot-getter.d.ts.map +1 -0
  44. package/dist/snapshot-getter.js +27 -0
  45. package/dist/snapshot-getter.js.map +1 -0
  46. package/package.json +67 -0
package/dist/agent.js ADDED
@@ -0,0 +1,1758 @@
1
+ import { create, fromBinary, fromJsonString, toBinary } from '@bufbuild/protobuf';
2
+ import { utils, ethers } from 'ethers';
3
+ import { SpaceDapp, SpaceAddressFromSpaceId, TipRecipientType, ETH_ADDRESS, } from '@towns-labs/web3';
4
+ import { stringify as superjsonStringify, parse as superjsonParse } from 'superjson';
5
+ import tippingFacetAbi from '@towns-labs/generated/dev/abis/ITipping.abi';
6
+ import { getRefEventIdFromChannelMessage, isChannelStreamId, make_ChannelPayload_Message, createTownsClient, streamIdAsString, make_MemberPayload_KeySolicitation, make_UserMetadataPayload_EncryptionDevice, logNever, userIdFromAddress, makeUserMetadataStreamId, unsafe_makeTags, townsEnv, spaceIdFromChannelId, parseAppPrivateData, makeEvent, make_MediaPayload_Inception, make_MediaPayload_Chunk, makeUniqueMediaStreamId, streamIdAsBytes, addressFromUserId, make_payload_InteractionRequest, userIdToAddress, unpackEnvelope, make_UserPayload_BlockchainTransaction, makeUserStreamId, make_MemberPayload_Pin, make_MemberPayload_Unpin, isDMChannelStreamId, isGDMChannelStreamId, make_DMChannelPayload_Message, make_GDMChannelPayload_Message, } from '@towns-labs/sdk';
7
+ import { Hono } from 'hono';
8
+ import { logger } from 'hono/logger';
9
+ import { createMiddleware } from 'hono/factory';
10
+ import { default as jwt } from 'jsonwebtoken';
11
+ import { createNanoEvents } from 'nanoevents';
12
+ import imageSize from 'image-size';
13
+ import { ChannelMessageSchema, AppServiceRequestSchema, AppServiceResponseSchema, MembershipOp, MessageInteractionType, ChannelMessage_Post_Content_ImageSchema, ChannelMessage_Post_Content_Image_InfoSchema, ChunkedMediaSchema, CreationCookieSchema, BlockchainTransactionSchema, InteractionRequestPayloadSchema, InteractionResponsePayloadSchema, ChannelMessage_Post_AttachmentSchema, ChannelMessage_Post_MentionSchema, } from '@towns-labs/proto';
14
+ import { bin_equal, bin_fromBase64, bin_fromHexString, bin_toHexString, check, dlog, } from '@towns-labs/utils';
15
+ import { encryptChunkedAESGCM } from '@towns-labs/sdk-crypto';
16
+ import { EventDedup } from './eventDedup';
17
+ import { isFlattenedRequest, flattenedToPayloadContent, } from './interaction-api';
18
+ import { chainIdToNetwork, createPaymentRequest } from './payments';
19
+ import { useFacilitator } from 'x402/verify';
20
+ import { http, createWalletClient, encodeAbiParameters, zeroAddress, parseEventLogs, formatUnits, erc20Abi, } from 'viem';
21
+ import { readContract, waitForTransactionReceipt, writeContract } from 'viem/actions';
22
+ import { base, baseSepolia, foundry } from 'viem/chains';
23
+ import packageJson from '../package.json' with { type: 'json' };
24
+ import { privateKeyToAccount } from 'viem/accounts';
25
+ import appRegistryAbi from '@towns-labs/generated/dev/abis/IAppRegistry.abi';
26
+ import { getSmartAccountFromUserIdImpl } from './smart-account';
27
+ import { EmptySchema } from '@bufbuild/protobuf/wkt';
28
+ const debug = dlog('csb:agent');
29
+ export class Agent {
30
+ client;
31
+ appAddress;
32
+ agentUserId;
33
+ viem;
34
+ jwtSecret;
35
+ currentMessageTags;
36
+ emitter = createNanoEvents();
37
+ slashCommandHandlers = new Map();
38
+ gmTypedHandlers = new Map();
39
+ commands;
40
+ identityConfig;
41
+ eventDedup;
42
+ // Payment related members
43
+ paymentConfig;
44
+ pendingPayments = new Map();
45
+ paymentCommands = new Map();
46
+ constructor(clientV2, viem, jwtSecretBase64, appAddress, commands, identityConfig, dedupConfig, paymentConfig) {
47
+ this.client = clientV2;
48
+ this.agentUserId = clientV2.userId;
49
+ this.viem = viem;
50
+ this.jwtSecret = bin_fromBase64(jwtSecretBase64);
51
+ this.currentMessageTags = undefined;
52
+ this.commands = commands;
53
+ this.appAddress = appAddress;
54
+ this.identityConfig = identityConfig;
55
+ this.eventDedup = new EventDedup(dedupConfig);
56
+ this.paymentConfig = paymentConfig;
57
+ if (commands && paymentConfig) {
58
+ for (const cmd of commands) {
59
+ if (cmd.paid?.price) {
60
+ this.paymentCommands.set(cmd.name, cmd.paid);
61
+ }
62
+ }
63
+ }
64
+ this.onInteractionResponse(this.handlePaymentResponse.bind(this));
65
+ }
66
+ start() {
67
+ const jwtMiddleware = createMiddleware(this.jwtMiddleware.bind(this));
68
+ const handler = this.webhookHandler.bind(this);
69
+ const app = new Hono();
70
+ app.use(logger());
71
+ app.post('/webhook', jwtMiddleware, handler);
72
+ app.get('/.well-known/agent-metadata.json', async (c) => {
73
+ return c.json(await this.getIdentityMetadata());
74
+ });
75
+ debug('init');
76
+ return app;
77
+ }
78
+ async jwtMiddleware(c, next) {
79
+ const authHeader = c.req.header('Authorization');
80
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
81
+ return c.text('Unauthorized: Missing or malformed token', 401);
82
+ }
83
+ const tokenString = authHeader.substring(7);
84
+ try {
85
+ const agentAddressBytes = bin_fromHexString(this.agentUserId);
86
+ const expectedAudience = bin_toHexString(agentAddressBytes);
87
+ jwt.verify(tokenString, Buffer.from(this.jwtSecret), {
88
+ algorithms: ['HS256'],
89
+ audience: expectedAudience,
90
+ });
91
+ }
92
+ catch (err) {
93
+ let errorMessage = 'Unauthorized: Token verification failed';
94
+ if (err instanceof jwt.TokenExpiredError) {
95
+ errorMessage = 'Unauthorized: Token expired';
96
+ }
97
+ else if (err instanceof jwt.JsonWebTokenError) {
98
+ errorMessage = `Unauthorized: Invalid token (${err.message})`;
99
+ }
100
+ return c.text(errorMessage, 401);
101
+ }
102
+ await next();
103
+ }
104
+ async webhookHandler(c) {
105
+ const body = await c.req.arrayBuffer();
106
+ const encryptionDevice = this.client.crypto.getUserDevice();
107
+ const request = fromBinary(AppServiceRequestSchema, new Uint8Array(body));
108
+ debug('webhook', request);
109
+ const statusResponse = create(AppServiceResponseSchema, {
110
+ payload: {
111
+ case: 'status',
112
+ value: {
113
+ frameworkVersion: 1,
114
+ clientVersion: `javascript:${packageJson.name}:${packageJson.version}`,
115
+ deviceKey: encryptionDevice.deviceKey,
116
+ fallbackKey: encryptionDevice.fallbackKey,
117
+ },
118
+ },
119
+ });
120
+ let response = statusResponse;
121
+ if (request.payload.case === 'initialize') {
122
+ response = create(AppServiceResponseSchema, {
123
+ payload: {
124
+ case: 'initialize',
125
+ value: {
126
+ encryptionDevice,
127
+ },
128
+ },
129
+ });
130
+ }
131
+ else if (request.payload.case === 'events') {
132
+ for (const event of request.payload.value.events) {
133
+ try {
134
+ await this.handleEvent(event);
135
+ }
136
+ catch (err) {
137
+ // eslint-disable-next-line no-console
138
+ console.error('[@towns-labs/agent] Error while handling event', err);
139
+ }
140
+ }
141
+ response = statusResponse;
142
+ }
143
+ else if (request.payload.case === 'status') {
144
+ response = statusResponse;
145
+ }
146
+ c.header('Content-Type', 'application/x-protobuf');
147
+ return c.body(toBinary(AppServiceResponseSchema, response), 200);
148
+ }
149
+ async handleEvent(appEvent) {
150
+ if (!appEvent.payload.case || !appEvent.payload.value)
151
+ return;
152
+ const streamId = streamIdAsString(appEvent.payload.value.streamId);
153
+ if (appEvent.payload.case === 'messages') {
154
+ const groupEncryptionSessionsMessages = await this.client
155
+ .unpackEnvelopes(appEvent.payload.value.groupEncryptionSessionsMessages)
156
+ .then((x) => x.flatMap((e) => {
157
+ if (e.event.payload.case === 'userInboxPayload' &&
158
+ e.event.payload.value.content.case === 'groupEncryptionSessions') {
159
+ return e.event.payload.value.content.value;
160
+ }
161
+ return [];
162
+ }));
163
+ const events = await this.client.unpackEnvelopes(appEvent.payload.value.messages);
164
+ const zip = events.map((m, i) => [m, groupEncryptionSessionsMessages[i]]);
165
+ for (const [parsed, groupEncryptionSession] of zip) {
166
+ if (parsed.creatorUserId === this.client.userId) {
167
+ continue;
168
+ }
169
+ if (!parsed.event.payload.case) {
170
+ continue;
171
+ }
172
+ // Skip duplicate events (App Registry may replay events during restarts)
173
+ if (this.eventDedup.checkAndAdd(streamId, parsed.hashStr)) {
174
+ continue;
175
+ }
176
+ const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
177
+ this.currentMessageTags = parsed.event.tags;
178
+ debug('emit:streamEvent', {
179
+ userId: userIdFromAddress(parsed.event.creatorAddress),
180
+ channelId: streamId,
181
+ eventId: parsed.hashStr,
182
+ });
183
+ this.emitter.emit('streamEvent', this.client, {
184
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
185
+ parsed: parsed,
186
+ });
187
+ switch (parsed.event.payload.case) {
188
+ case 'channelPayload':
189
+ case 'dmChannelPayload':
190
+ case 'gdmChannelPayload': {
191
+ if (!parsed.event.payload.value.content.case)
192
+ return;
193
+ if (parsed.event.payload.value.content.case === 'message') {
194
+ await this.client.importGroupEncryptionSessions({
195
+ streamId,
196
+ sessions: groupEncryptionSession,
197
+ });
198
+ const eventCleartext = await this.client.crypto.decryptGroupEvent(streamId, parsed.event.payload.value.content.value);
199
+ let channelMessage;
200
+ if (typeof eventCleartext === 'string') {
201
+ channelMessage = fromJsonString(ChannelMessageSchema, eventCleartext);
202
+ }
203
+ else {
204
+ channelMessage = fromBinary(ChannelMessageSchema, eventCleartext);
205
+ }
206
+ await this.handleChannelMessage(streamId, parsed, channelMessage);
207
+ }
208
+ else if (parsed.event.payload.value.content.case === 'redaction') {
209
+ const refEventId = bin_toHexString(parsed.event.payload.value.content.value.eventId);
210
+ debug('emit:eventRevoke', {
211
+ userId: userIdFromAddress(parsed.event.creatorAddress),
212
+ channelId: streamId,
213
+ refEventId,
214
+ });
215
+ this.emitter.emit('eventRevoke', this.client, {
216
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
217
+ refEventId,
218
+ });
219
+ }
220
+ else if (parsed.event.payload.value.content.case === 'channelProperties') {
221
+ // TODO: currently, no support for channel properties (update name, topic)
222
+ }
223
+ else if (parsed.event.payload.value.content.case === 'inception') {
224
+ // TODO: is there any use case for this?
225
+ }
226
+ else if (parsed.event.payload.value.content.case === 'custom') {
227
+ // TODO: what to do with custom payload for agent?
228
+ }
229
+ else if (parsed.event.payload.value.content.case === 'interactionRequest') {
230
+ // ignored for agentss
231
+ }
232
+ else if (parsed.event.payload.value.content.case === 'interactionResponse') {
233
+ const payload = parsed.event.payload.value.content.value;
234
+ if (!bin_equal(payload.recipient, bin_fromHexString(this.agentUserId))) {
235
+ continue;
236
+ }
237
+ if (!payload.encryptedData) {
238
+ continue;
239
+ }
240
+ if (payload.encryptedData.deviceKey !== this.getUserDevice().deviceKey) {
241
+ continue;
242
+ }
243
+ const decryptedBase64 = await this.client.crypto.decryptWithDeviceKey(payload.encryptedData.ciphertext, payload.encryptedData.senderKey);
244
+ const decrypted = bin_fromBase64(decryptedBase64);
245
+ const response = fromBinary(InteractionResponsePayloadSchema, decrypted);
246
+ this.emitter.emit('interactionResponse', this.client, {
247
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
248
+ response: {
249
+ recipient: payload.recipient,
250
+ payload: response,
251
+ },
252
+ threadId: payload.threadId
253
+ ? bin_toHexString(payload.threadId)
254
+ : undefined,
255
+ });
256
+ }
257
+ else {
258
+ logNever(parsed.event.payload.value.content);
259
+ }
260
+ break;
261
+ }
262
+ case 'memberPayload': {
263
+ switch (parsed.event.payload.value.content.case) {
264
+ case 'membership':
265
+ {
266
+ const membership = parsed.event.payload.value.content.value;
267
+ const isChannel = isChannelStreamId(streamId);
268
+ // TODO: do we want agent to listen to onSpaceJoin/onSpaceLeave?
269
+ if (!isChannel)
270
+ continue;
271
+ if (membership.op === MembershipOp.SO_JOIN) {
272
+ debug('emit:channelJoin', {
273
+ userId: userIdFromAddress(membership.userAddress),
274
+ channelId: streamId,
275
+ eventId: parsed.hashStr,
276
+ });
277
+ this.emitter.emit('channelJoin', this.client, {
278
+ ...createBasePayload(userIdFromAddress(membership.userAddress), streamId, parsed.hashStr, createdAt, parsed.event),
279
+ });
280
+ }
281
+ if (membership.op === MembershipOp.SO_LEAVE) {
282
+ debug('emit:channelLeave', {
283
+ userId: userIdFromAddress(membership.userAddress),
284
+ channelId: streamId,
285
+ eventId: parsed.hashStr,
286
+ });
287
+ this.emitter.emit('channelLeave', this.client, {
288
+ ...createBasePayload(userIdFromAddress(membership.userAddress), streamId, parsed.hashStr, createdAt, parsed.event),
289
+ });
290
+ }
291
+ }
292
+ break;
293
+ case 'memberBlockchainTransaction':
294
+ {
295
+ const transactionContent = parsed.event.payload.value.content.value.transaction
296
+ ?.content;
297
+ const fromUserAddress = parsed.event.payload.value.content.value.fromUserAddress;
298
+ switch (transactionContent?.case) {
299
+ case 'spaceReview':
300
+ break;
301
+ case 'tokenTransfer':
302
+ break;
303
+ case 'tip':
304
+ {
305
+ const tipEvent = transactionContent.value.event;
306
+ if (!tipEvent) {
307
+ return;
308
+ }
309
+ const currency = utils.getAddress(bin_toHexString(tipEvent.currency));
310
+ const senderAddress = utils.getAddress(bin_toHexString(tipEvent.sender));
311
+ const receiverAddress = utils.getAddress(bin_toHexString(tipEvent.receiver));
312
+ const senderUserId = userIdFromAddress(fromUserAddress);
313
+ const receiverUserId = userIdFromAddress(transactionContent.value.toUserAddress);
314
+ debug('emit:tip', {
315
+ senderAddress,
316
+ senderUserId,
317
+ receiverAddress,
318
+ receiverUserId,
319
+ amount: tipEvent.amount.toString(),
320
+ currency,
321
+ messageId: bin_toHexString(tipEvent.messageId),
322
+ });
323
+ this.emitter.emit('tip', this.client, {
324
+ ...createBasePayload(senderUserId, streamId, parsed.hashStr, createdAt, parsed.event),
325
+ amount: tipEvent.amount,
326
+ currency: currency,
327
+ senderAddress,
328
+ receiverAddress,
329
+ receiverUserId,
330
+ messageId: bin_toHexString(tipEvent.messageId),
331
+ });
332
+ }
333
+ break;
334
+ case undefined:
335
+ break;
336
+ default:
337
+ logNever(transactionContent);
338
+ }
339
+ }
340
+ break;
341
+ case 'keySolicitation':
342
+ case 'keyFulfillment':
343
+ case 'displayName':
344
+ case 'username':
345
+ case 'ensAddress':
346
+ case 'nft':
347
+ case 'pin':
348
+ case 'unpin':
349
+ case 'encryptionAlgorithm':
350
+ break;
351
+ case undefined:
352
+ break;
353
+ default:
354
+ logNever(parsed.event.payload.value.content);
355
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+ else if (appEvent.payload.case === 'solicitation') {
361
+ const missingSessionIds = appEvent.payload.value.sessionIds.filter((sessionId) => sessionId !== '');
362
+ await this.client.sendKeySolicitation(streamId, missingSessionIds);
363
+ }
364
+ else {
365
+ logNever(appEvent.payload);
366
+ }
367
+ }
368
+ async handleChannelMessage(streamId, parsed, { payload }) {
369
+ if (!payload.case) {
370
+ return;
371
+ }
372
+ const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
373
+ switch (payload.case) {
374
+ case 'post': {
375
+ if (payload.value.content.case === 'text') {
376
+ const userId = userIdFromAddress(parsed.event.creatorAddress);
377
+ const replyId = payload.value.replyId;
378
+ const threadId = payload.value.threadId;
379
+ const mentions = parseMentions(payload.value.content.value.mentions);
380
+ const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
381
+ const forwardPayload = {
382
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
383
+ message: payload.value.content.value.body,
384
+ mentions,
385
+ isMentioned,
386
+ replyId,
387
+ threadId,
388
+ };
389
+ if (parsed.event.tags?.messageInteractionType ===
390
+ MessageInteractionType.SLASH_COMMAND) {
391
+ const { command, args } = parseSlashCommand(payload.value.content.value.body);
392
+ const handler = this.slashCommandHandlers.get(command);
393
+ if (handler) {
394
+ void handler(this.client, {
395
+ ...forwardPayload,
396
+ command: command,
397
+ args,
398
+ replyId,
399
+ threadId,
400
+ });
401
+ }
402
+ }
403
+ else {
404
+ debug('emit:message', forwardPayload);
405
+ this.emitter.emit('message', this.client, forwardPayload);
406
+ }
407
+ }
408
+ else if (payload.value.content.case === 'gm') {
409
+ const userId = userIdFromAddress(parsed.event.creatorAddress);
410
+ const gmContent = payload.value.content.value;
411
+ const { typeUrl, value } = gmContent;
412
+ this.emitter.emit('rawGmMessage', this.client, {
413
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
414
+ typeUrl,
415
+ message: value ?? new Uint8Array(),
416
+ });
417
+ const typedHandler = this.gmTypedHandlers.get(typeUrl);
418
+ if (typedHandler) {
419
+ try {
420
+ const possibleJsonString = new TextDecoder().decode(value);
421
+ const deserializedData = superjsonParse(possibleJsonString);
422
+ const result = await typedHandler.schema['~standard'].validate(deserializedData);
423
+ if ('issues' in result && result.issues) {
424
+ debug('GM validation failed', { typeUrl, issues: result.issues });
425
+ }
426
+ else {
427
+ debug('emit:gmMessage', { userId, channelId: streamId });
428
+ void typedHandler.handler(this.client, {
429
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event),
430
+ typeUrl,
431
+ data: result.value,
432
+ });
433
+ }
434
+ }
435
+ catch (error) {
436
+ debug('GM handler error', { typeUrl, error });
437
+ }
438
+ }
439
+ }
440
+ break;
441
+ }
442
+ case 'reaction': {
443
+ debug('emit:reaction', {
444
+ userId: userIdFromAddress(parsed.event.creatorAddress),
445
+ channelId: streamId,
446
+ reaction: payload.value.reaction,
447
+ messageId: payload.value.refEventId,
448
+ });
449
+ this.emitter.emit('reaction', this.client, {
450
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
451
+ reaction: payload.value.reaction,
452
+ messageId: payload.value.refEventId,
453
+ });
454
+ break;
455
+ }
456
+ case 'edit': {
457
+ // TODO: framework doesnt handle non-text edits
458
+ if (payload.value.post?.content.case !== 'text')
459
+ break;
460
+ const mentions = parseMentions(payload.value.post?.content.value.mentions);
461
+ const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
462
+ debug('emit:messageEdit', {
463
+ userId: userIdFromAddress(parsed.event.creatorAddress),
464
+ channelId: streamId,
465
+ refEventId: payload.value.refEventId,
466
+ messagePreview: payload.value.post?.content.value.body.substring(0, 50),
467
+ isMentioned,
468
+ });
469
+ this.emitter.emit('messageEdit', this.client, {
470
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
471
+ refEventId: payload.value.refEventId,
472
+ message: payload.value.post?.content.value.body,
473
+ mentions,
474
+ isMentioned,
475
+ replyId: payload.value.post?.replyId,
476
+ threadId: payload.value.post?.threadId,
477
+ });
478
+ break;
479
+ }
480
+ case 'redaction': {
481
+ debug('emit:redaction', {
482
+ userId: userIdFromAddress(parsed.event.creatorAddress),
483
+ channelId: streamId,
484
+ refEventId: payload.value.refEventId,
485
+ });
486
+ this.emitter.emit('redaction', this.client, {
487
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event),
488
+ refEventId: payload.value.refEventId,
489
+ });
490
+ break;
491
+ }
492
+ default:
493
+ logNever(payload);
494
+ }
495
+ }
496
+ async handlePaymentResponse(handler, event) {
497
+ if (!this.paymentConfig)
498
+ return;
499
+ const { response, channelId } = event;
500
+ // Check if this is a signature response
501
+ if (response.payload?.content?.case !== 'signature') {
502
+ return;
503
+ }
504
+ const signatureId = response.payload.content.value?.requestId ?? '';
505
+ const signature = (response.payload.content.value?.signature ?? '');
506
+ if (!signatureId || !signature) {
507
+ return;
508
+ }
509
+ // Check if this is a pending payment
510
+ const pending = this.pendingPayments.get(signatureId);
511
+ if (!pending) {
512
+ return; // Not a payment signature
513
+ }
514
+ // Remove from pending
515
+ this.pendingPayments.delete(signatureId);
516
+ const facilitator = useFacilitator(this.paymentConfig);
517
+ // Build PaymentPayload for x402
518
+ const paymentPayload = {
519
+ x402Version: 1,
520
+ scheme: 'exact',
521
+ network: chainIdToNetwork(this.viem.chain.id),
522
+ payload: {
523
+ signature: signature,
524
+ authorization: {
525
+ from: pending.params.from,
526
+ to: pending.params.to,
527
+ value: pending.params.value.toString(),
528
+ validAfter: pending.params.validAfter.toString(),
529
+ validBefore: pending.params.validBefore.toString(),
530
+ nonce: pending.params.nonce,
531
+ },
532
+ },
533
+ };
534
+ // Build PaymentRequirements for x402
535
+ const paymentRequirements = {
536
+ scheme: 'exact',
537
+ network: paymentPayload.network,
538
+ maxAmountRequired: pending.params.value.toString(),
539
+ resource: `https://towns.com/command/${pending.command}`,
540
+ description: `Payment for /${pending.command}`,
541
+ mimeType: 'application/json',
542
+ payTo: pending.params.to,
543
+ maxTimeoutSeconds: 300,
544
+ asset: pending.params.verifyingContract,
545
+ };
546
+ // Single status message that gets updated through the flow
547
+ const statusMsg = await handler.sendMessage(channelId, '🔍 Verifying payment...');
548
+ // Track settlement state to distinguish payment failures from post-payment failures
549
+ let settlementCompleted = false;
550
+ let transactionHash;
551
+ try {
552
+ const verifyResult = await facilitator.verify(paymentPayload, paymentRequirements);
553
+ if (!verifyResult.isValid) {
554
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment verification failed: ${verifyResult.invalidReason || 'Unknown error'}`);
555
+ await handler.removeEvent(channelId, pending.interactionEventId);
556
+ return;
557
+ }
558
+ // Update status: settling
559
+ await handler.editMessage(channelId, statusMsg.eventId, `✅ Verified • Settling $${formatUnits(pending.params.value, 6)} USDC...`);
560
+ const settleResult = await facilitator.settle(paymentPayload, paymentRequirements);
561
+ if (!settleResult.success) {
562
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Settlement failed: ${settleResult.errorReason || 'Unknown error'}`);
563
+ await handler.removeEvent(channelId, pending.interactionEventId);
564
+ return;
565
+ }
566
+ // Mark settlement as complete - funds have been transferred
567
+ settlementCompleted = true;
568
+ transactionHash = settleResult.transaction;
569
+ // Final success - show receipt
570
+ await handler.editMessage(channelId, statusMsg.eventId, `✅ **Payment Complete**\n` +
571
+ `/${pending.command} • $${formatUnits(pending.params.value, 6)} USDC\n` +
572
+ `Tx: \`${transactionHash}\``);
573
+ // Delete the signature request now that payment is complete
574
+ await handler.removeEvent(channelId, pending.interactionEventId);
575
+ // Execute the original command handler (stored with __paid_ prefix)
576
+ const actualHandlerKey = `__paid_${pending.command}`;
577
+ const originalHandler = this.slashCommandHandlers.get(actualHandlerKey);
578
+ if (originalHandler) {
579
+ await originalHandler(this.client, pending.event);
580
+ }
581
+ }
582
+ catch (error) {
583
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
584
+ if (settlementCompleted) {
585
+ // Payment succeeded but command handler failed - DO NOT suggest retry
586
+ await handler.editMessage(channelId, statusMsg.eventId, `⚠️ **Payment succeeded but command failed**\n` +
587
+ `Your payment of $${formatUnits(pending.params.value, 6)} USDC was processed.\n` +
588
+ `Tx: \`${transactionHash}\`\n\n` +
589
+ `Error: ${errorMessage}\n` +
590
+ `Please contact support - do NOT retry to avoid double charges.`);
591
+ // Don't remove interaction event - payment already processed
592
+ }
593
+ else {
594
+ // Actual payment failure (verify or settle threw)
595
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment failed: ${errorMessage}`);
596
+ await handler.removeEvent(channelId, pending.interactionEventId);
597
+ }
598
+ }
599
+ }
600
+ /**
601
+ * get the public device key of the agent
602
+ * @returns the public device key of the agent
603
+ */
604
+ getUserDevice() {
605
+ return this.client.crypto.getUserDevice();
606
+ }
607
+ /**
608
+ * Send a message to a stream
609
+ * @param streamId - Id of the stream. Usually channelId or userId
610
+ * @param message - The cleartext of the message
611
+ * @param opts - The options for the message
612
+ */
613
+ async sendMessage(streamId, message, opts) {
614
+ const result = await this.client.sendMessage(streamId, message, opts, this.currentMessageTags);
615
+ this.currentMessageTags = undefined;
616
+ return result;
617
+ }
618
+ /**
619
+ * Send a reaction to a stream
620
+ * @param streamId - Id of the stream. Usually channelId or userId
621
+ * @param refEventId - The eventId of the event to react to
622
+ * @param reaction - The reaction to send
623
+ */
624
+ async sendReaction(streamId, refEventId, reaction) {
625
+ const result = await this.client.sendReaction(streamId, refEventId, reaction, this.currentMessageTags);
626
+ this.currentMessageTags = undefined;
627
+ return result;
628
+ }
629
+ /**
630
+ * Remove an specific event from a stream
631
+ * @param streamId - Id of the stream. Usually channelId or userId
632
+ * @param refEventId - The eventId of the event to remove
633
+ */
634
+ async removeEvent(streamId, refEventId) {
635
+ const result = await this.client.removeEvent(streamId, refEventId, this.currentMessageTags);
636
+ this.currentMessageTags = undefined;
637
+ return result;
638
+ }
639
+ /**
640
+ * Edit an specific message from a stream
641
+ * @param streamId - Id of the stream. Usually channelId or userId
642
+ * @param messageId - The eventId of the message to edit
643
+ * @param message - The new message text
644
+ */
645
+ async editMessage(streamId, messageId, message, opts) {
646
+ const result = await this.client.editMessage(streamId, messageId, message, opts, this.currentMessageTags);
647
+ this.currentMessageTags = undefined;
648
+ return result;
649
+ }
650
+ /**
651
+ * Send a GM (generic message) to a stream with schema validation
652
+ * @param streamId - Id of the stream. Usually channelId or userId
653
+ * @param typeUrl - The type URL identifying the message format
654
+ * @param schema - StandardSchema for validation
655
+ * @param data - Data to validate and send
656
+ */
657
+ async sendGM(streamId, typeUrl, schema, data, opts) {
658
+ const result = await this.client.sendGM(streamId, typeUrl, schema, data, opts, this.currentMessageTags);
659
+ this.currentMessageTags = undefined;
660
+ return result;
661
+ }
662
+ /**
663
+ * Send a raw GM (generic message) to a stream without schema validation
664
+ * @param streamId - Id of the stream. Usually channelId or userId
665
+ * @param typeUrl - The type URL identifying the message format
666
+ * @param message - Optional raw message data as bytes
667
+ * @param opts - The options for the message
668
+ */
669
+ async sendRawGM(streamId, typeUrl, message, opts) {
670
+ const result = await this.client.sendRawGM(streamId, typeUrl, message, opts, this.currentMessageTags);
671
+ this.currentMessageTags = undefined;
672
+ return result;
673
+ }
674
+ // Implementation
675
+ async sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts) {
676
+ const tags = this.currentMessageTags;
677
+ this.currentMessageTags = undefined;
678
+ if (isFlattenedRequest(contentOrPayload)) {
679
+ // New flattened format: (streamId, payload, opts?)
680
+ return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, tags);
681
+ }
682
+ else {
683
+ // Old format: (streamId, content, recipient?, opts?)
684
+ return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts, tags);
685
+ }
686
+ }
687
+ async pinMessage(streamId, eventId, streamEvent) {
688
+ return this.client.pinMessage(streamId, eventId, streamEvent);
689
+ }
690
+ async unpinMessage(streamId, eventId) {
691
+ return this.client.unpinMessage(streamId, eventId);
692
+ }
693
+ /** Sends a tip to a user by looking up their smart account.
694
+ * Tip will always get funds from the app account balance.
695
+ * @param params - Tip parameters including userId, amount, messageId, channelId, currency.
696
+ * @returns The transaction hash and event ID
697
+ */
698
+ async sendTip(params) {
699
+ const result = await this.client.sendTip(params, this.currentMessageTags);
700
+ this.currentMessageTags = undefined;
701
+ return result;
702
+ }
703
+ /**
704
+ * Triggered when someone sends a message.
705
+ * This is triggered for all messages, including direct messages and group messages.
706
+ */
707
+ onMessage(fn) {
708
+ return this.emitter.on('message', fn);
709
+ }
710
+ onRedaction(fn) {
711
+ return this.emitter.on('redaction', fn);
712
+ }
713
+ /**
714
+ * Triggered when a message gets edited
715
+ */
716
+ onMessageEdit(fn) {
717
+ return this.emitter.on('messageEdit', fn);
718
+ }
719
+ /**
720
+ * Triggered when someone reacts to a message
721
+ */
722
+ onReaction(fn) {
723
+ return this.emitter.on('reaction', fn);
724
+ }
725
+ /**
726
+ * Triggered when a message is revoked by a moderator
727
+ */
728
+ onEventRevoke(fn) {
729
+ return this.emitter.on('eventRevoke', fn);
730
+ }
731
+ /**
732
+ * Triggered when someone tips the agent
733
+ */
734
+ onTip(fn) {
735
+ return this.emitter.on('tip', fn);
736
+ }
737
+ /**
738
+ * Triggered when someone joins a channel
739
+ */
740
+ onChannelJoin(fn) {
741
+ return this.emitter.on('channelJoin', fn);
742
+ }
743
+ /**
744
+ * Triggered when someone leaves a channel
745
+ */
746
+ onChannelLeave(fn) {
747
+ return this.emitter.on('channelLeave', fn);
748
+ }
749
+ onStreamEvent(fn) {
750
+ return this.emitter.on('streamEvent', fn);
751
+ }
752
+ onSlashCommand(command, fn) {
753
+ const paymentConfig = this.paymentCommands.get(command);
754
+ if (!paymentConfig || !this.paymentConfig) {
755
+ this.slashCommandHandlers.set(command, fn);
756
+ const unset = () => {
757
+ if (this.slashCommandHandlers.has(command) &&
758
+ this.slashCommandHandlers.get(command) === fn) {
759
+ this.slashCommandHandlers.delete(command);
760
+ }
761
+ };
762
+ return unset;
763
+ }
764
+ this.slashCommandHandlers.set(command, async (handler, event) => {
765
+ try {
766
+ const chainId = this.viem.chain.id;
767
+ const smartAccountAddress = await getSmartAccountFromUserIdImpl(this.client.config.base.chainConfig.addresses.spaceFactory, this.viem, event.userId);
768
+ const { signatureId, params, eventId } = await createPaymentRequest(handler, event, chainId, smartAccountAddress, this.appAddress, paymentConfig, command);
769
+ // Store pending payment
770
+ this.pendingPayments.set(signatureId, {
771
+ command: command,
772
+ channelId: event.channelId,
773
+ userId: event.userId,
774
+ interactionEventId: eventId,
775
+ event: event,
776
+ params: params,
777
+ });
778
+ }
779
+ catch (error) {
780
+ await handler.sendMessage(event.channelId, `❌ Failed to request payment: ${error instanceof Error ? error.message : 'Unknown error'}`);
781
+ }
782
+ });
783
+ const actualHandlerKey = `__paid_${command}`;
784
+ this.slashCommandHandlers.set(actualHandlerKey, fn);
785
+ const unset = () => {
786
+ if (this.slashCommandHandlers.has(command) &&
787
+ this.slashCommandHandlers.get(command) === fn) {
788
+ this.slashCommandHandlers.delete(command);
789
+ }
790
+ };
791
+ return unset;
792
+ }
793
+ /**
794
+ * Triggered when someone sends a GM (generic message) with type validation using StandardSchema
795
+ * @param typeUrl - The type URL to listen for
796
+ * @param schema - The StandardSchema to validate the message data
797
+ * @param handler - The handler function to call when a message is received
798
+ */
799
+ onGmMessage(typeUrl, schema, handler) {
800
+ this.gmTypedHandlers.set(typeUrl, { schema, handler: handler });
801
+ const unset = () => {
802
+ if (this.gmTypedHandlers.has(typeUrl) &&
803
+ this.gmTypedHandlers.get(typeUrl)?.handler === handler) {
804
+ this.gmTypedHandlers.delete(typeUrl);
805
+ }
806
+ };
807
+ return unset;
808
+ }
809
+ onRawGmMessage(handler) {
810
+ return this.emitter.on('rawGmMessage', handler);
811
+ }
812
+ /**
813
+ * Triggered when someone sends an interaction response
814
+ * @param fn - The handler function to call when an interaction response is received
815
+ */
816
+ onInteractionResponse(fn) {
817
+ return this.emitter.on('interactionResponse', fn);
818
+ }
819
+ /**
820
+ * Get the stream view for a stream
821
+ * Stream views contain contextual information about the stream (space, channel, etc)
822
+ * Stream views contain member data for all streams - you can iterate over all members in a channel via: `streamView.getMembers().joined.keys()`
823
+ * note: potentially expensive operation because streams can be large, fine to use in small streams
824
+ * @param streamId - The stream ID to get the view for
825
+ * @returns The stream view
826
+ */
827
+ async getStreamView(streamId) {
828
+ return this.client.getStream(streamId);
829
+ }
830
+ /**
831
+ * Get the ERC-8004 compliant metadata JSON
832
+ * This should be hosted at /.well-known/agent-metadata.json
833
+ * Fetches metadata from the App Registry and merges with local config
834
+ * @returns The ERC-8004 compliant metadata object or null
835
+ */
836
+ async getIdentityMetadata() {
837
+ // Fetch metadata from App Registry
838
+ let appMetadata;
839
+ try {
840
+ const appRegistry = await this.client.appServiceClient();
841
+ const response = await appRegistry.getAppMetadata({
842
+ appId: bin_fromHexString(this.agentUserId),
843
+ });
844
+ appMetadata = response.metadata;
845
+ }
846
+ catch (err) {
847
+ // eslint-disable-next-line no-console
848
+ console.warn('[@towns-labs/agent] Failed to fetch app metadata', err);
849
+ }
850
+ // If no config and no fetched metadata, return null
851
+ if (!this.identityConfig && !appMetadata)
852
+ return null;
853
+ const endpoints = [];
854
+ if (this.identityConfig?.endpoints) {
855
+ endpoints.push(...this.identityConfig.endpoints);
856
+ }
857
+ const hasAgentWallet = endpoints.some((e) => e.name === 'agentWallet');
858
+ if (!hasAgentWallet) {
859
+ const chainId = this.viem.chain.id;
860
+ endpoints.push({
861
+ name: 'agentWallet',
862
+ endpoint: `eip155:${chainId}:${this.appAddress}`,
863
+ });
864
+ }
865
+ const domain = this.identityConfig?.domain;
866
+ if (domain && !endpoints.some((e) => e.name === 'A2A')) {
867
+ const origin = domain.startsWith('http') ? domain : `https://${domain}`;
868
+ endpoints.push({
869
+ name: 'A2A',
870
+ endpoint: `${origin}/.well-known/agent-card.json`,
871
+ version: '0.3.0',
872
+ });
873
+ }
874
+ // Merge app metadata with identity config, preferring identity config
875
+ const name = this.identityConfig?.name || appMetadata?.displayName || 'Unknown Agent';
876
+ const description = this.identityConfig?.description || appMetadata?.description || '';
877
+ const image = this.identityConfig?.image || appMetadata?.avatarUrl || appMetadata?.imageUrl || '';
878
+ const motto = this.identityConfig?.motto || appMetadata?.motto;
879
+ return {
880
+ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
881
+ name,
882
+ description,
883
+ image,
884
+ endpoints,
885
+ registrations: this.identityConfig?.registrations || [],
886
+ supportedTrust: this.identityConfig?.supportedTrust,
887
+ motto,
888
+ capabilities: this.commands?.map((c) => c.name) || [],
889
+ version: packageJson.version,
890
+ framework: `javascript:${packageJson.name}:${packageJson.version}`,
891
+ attributes: this.identityConfig?.attributes,
892
+ };
893
+ }
894
+ /**
895
+ * Get the tokenURI that would be used for ERC-8004 registration
896
+ * Returns null if no domain is configured
897
+ * @returns The .well-known URL or null
898
+ */
899
+ getTokenURI() {
900
+ if (!this.identityConfig?.domain)
901
+ return null;
902
+ const origin = this.identityConfig.domain.startsWith('http')
903
+ ? this.identityConfig.domain
904
+ : `https://${this.identityConfig.domain}`;
905
+ return `${origin}/.well-known/agent-metadata.json`;
906
+ }
907
+ }
908
+ export const makeTownsAgent = async (appPrivateData, jwtSecretBase64, opts = {}) => {
909
+ const { baseRpcUrl, ...clientOpts } = opts;
910
+ let appAddress;
911
+ const { privateKey, encryptionDevice, env, appAddress: appAddressFromPrivateData, } = parseAppPrivateData(appPrivateData);
912
+ if (!env) {
913
+ throw new Error('Failed to parse APP_PRIVATE_DATA');
914
+ }
915
+ if (appAddressFromPrivateData) {
916
+ appAddress = appAddressFromPrivateData;
917
+ }
918
+ const account = privateKeyToAccount(privateKey);
919
+ const baseConfig = townsEnv().makeBaseChainConfig(env);
920
+ const getChain = (chainId) => {
921
+ if (chainId === base.id)
922
+ return base;
923
+ if (chainId === foundry.id)
924
+ return foundry;
925
+ return baseSepolia;
926
+ };
927
+ const chain = getChain(baseConfig.chainConfig.chainId);
928
+ const viem = createWalletClient({
929
+ account,
930
+ transport: baseRpcUrl
931
+ ? http(baseRpcUrl, { batch: true })
932
+ : http(baseConfig.rpcUrl, { batch: true }),
933
+ chain,
934
+ });
935
+ const spaceDapp = new SpaceDapp(baseConfig.chainConfig, new ethers.providers.JsonRpcProvider(baseRpcUrl || baseConfig.rpcUrl));
936
+ if (!appAddress) {
937
+ appAddress = await readContract(viem, {
938
+ address: baseConfig.chainConfig.addresses.appRegistry,
939
+ abi: appRegistryAbi,
940
+ functionName: 'getAppByClient',
941
+ args: [account.address],
942
+ });
943
+ }
944
+ const client = await createTownsClient({
945
+ privateKey,
946
+ env,
947
+ encryptionDevice: {
948
+ fromExportedDevice: encryptionDevice,
949
+ },
950
+ ...clientOpts,
951
+ }).then((x) => x.extend((townsClient) => buildAgentActions(townsClient, viem, spaceDapp, appAddress)));
952
+ if (opts.commands) {
953
+ client
954
+ .appServiceClient()
955
+ .then((appRegistryClient) => appRegistryClient
956
+ .updateAppMetadata({
957
+ appId: bin_fromHexString(account.address),
958
+ updateMask: ['slash_commands'],
959
+ metadata: {
960
+ slashCommands: opts.commands,
961
+ },
962
+ })
963
+ .catch((err) => {
964
+ // eslint-disable-next-line no-console
965
+ console.warn('[@towns-labs/agent] failed to update slash commands', err);
966
+ }))
967
+ .catch((err) => {
968
+ // eslint-disable-next-line no-console
969
+ console.warn('[@towns-labs/agent] failed to get app registry rpc client', err);
970
+ });
971
+ }
972
+ await client.uploadDeviceKeys();
973
+ return new Agent(client, viem, jwtSecretBase64, appAddress, opts.commands, opts.identity, opts.dedup, opts.paymentConfig);
974
+ };
975
+ const buildAgentActions = (client, viem, spaceDapp, appAddress) => {
976
+ const CHUNK_SIZE = 1200000; // 1.2MB max per chunk (including auth tag)
977
+ const createChunkedMediaAttachment = async (attachment) => {
978
+ let data;
979
+ let mimetype;
980
+ if (attachment.data instanceof Blob) {
981
+ const buffer = await attachment.data.arrayBuffer();
982
+ data = new Uint8Array(buffer);
983
+ mimetype = attachment.data.type;
984
+ }
985
+ else {
986
+ data = attachment.data;
987
+ if ('mimetype' in attachment) {
988
+ mimetype = attachment.mimetype;
989
+ }
990
+ else {
991
+ throw new Error('mimetype is required for Uint8Array data');
992
+ }
993
+ }
994
+ let width = attachment.width || 0;
995
+ let height = attachment.height || 0;
996
+ if (mimetype.startsWith('image/') && (!width || !height)) {
997
+ const dimensions = imageSize(data);
998
+ width = dimensions.width || 0;
999
+ height = dimensions.height || 0;
1000
+ }
1001
+ const { chunks, secretKey } = await encryptChunkedAESGCM(data, CHUNK_SIZE);
1002
+ const chunkCount = chunks.length;
1003
+ if (chunkCount === 0) {
1004
+ throw new Error('No media chunks generated');
1005
+ }
1006
+ // TODO: Implement thumbnail generation with sharp
1007
+ const thumbnail = undefined;
1008
+ const streamId = makeUniqueMediaStreamId();
1009
+ const events = await Promise.all([
1010
+ makeEvent(client.signerContext, make_MediaPayload_Inception({
1011
+ streamId: streamIdAsBytes(streamId),
1012
+ userId: addressFromUserId(client.userId),
1013
+ chunkCount,
1014
+ perChunkEncryption: true,
1015
+ })),
1016
+ makeEvent(client.signerContext, make_MediaPayload_Chunk({
1017
+ data: chunks[0].ciphertext,
1018
+ chunkIndex: 0,
1019
+ iv: chunks[0].iv,
1020
+ })),
1021
+ ]);
1022
+ const mediaStreamResponse = await client.rpc.createMediaStream({
1023
+ events,
1024
+ streamId: streamIdAsBytes(streamId),
1025
+ });
1026
+ if (!mediaStreamResponse?.nextCreationCookie) {
1027
+ throw new Error('Failed to create media stream');
1028
+ }
1029
+ if (chunkCount > 1) {
1030
+ let cc = create(CreationCookieSchema, mediaStreamResponse.nextCreationCookie);
1031
+ for (let chunkIndex = 1; chunkIndex < chunkCount; chunkIndex++) {
1032
+ const chunkEvent = await makeEvent(client.signerContext, make_MediaPayload_Chunk({
1033
+ data: chunks[chunkIndex].ciphertext,
1034
+ chunkIndex: chunkIndex,
1035
+ iv: chunks[chunkIndex].iv,
1036
+ }), cc.prevMiniblockHash);
1037
+ const result = await client.rpc.addMediaEvent({
1038
+ event: chunkEvent,
1039
+ creationCookie: cc,
1040
+ last: chunkIndex === chunkCount - 1,
1041
+ });
1042
+ if (!result?.creationCookie) {
1043
+ throw new Error('Failed to send media chunk');
1044
+ }
1045
+ cc = create(CreationCookieSchema, result.creationCookie);
1046
+ }
1047
+ }
1048
+ const mediaStreamInfo = { creationCookie: mediaStreamResponse.nextCreationCookie };
1049
+ return {
1050
+ content: {
1051
+ case: 'chunkedMedia',
1052
+ value: create(ChunkedMediaSchema, {
1053
+ info: {
1054
+ filename: attachment.filename,
1055
+ mimetype: mimetype,
1056
+ widthPixels: width,
1057
+ heightPixels: height,
1058
+ sizeBytes: BigInt(data.length),
1059
+ },
1060
+ streamId: streamIdAsString(mediaStreamInfo.creationCookie.streamId),
1061
+ encryption: {
1062
+ case: 'aesgcm',
1063
+ value: {
1064
+ iv: new Uint8Array(0),
1065
+ secretKey: secretKey,
1066
+ },
1067
+ },
1068
+ thumbnail,
1069
+ }),
1070
+ },
1071
+ };
1072
+ };
1073
+ const createImageAttachmentFromURL = async (attachment) => {
1074
+ try {
1075
+ const response = await fetch(attachment.url);
1076
+ if (!response.ok) {
1077
+ return null;
1078
+ }
1079
+ const contentType = response.headers.get('content-type');
1080
+ if (!contentType || !contentType.startsWith('image/')) {
1081
+ // eslint-disable-next-line no-console
1082
+ console.warn(`A non-image URL attachment was provided. ${attachment.url} (Content-Type: ${contentType || 'unknown'})`);
1083
+ return null;
1084
+ }
1085
+ const bytes = await response.bytes();
1086
+ const dimensions = imageSize(bytes);
1087
+ const width = dimensions.width || 0;
1088
+ const height = dimensions.height || 0;
1089
+ const image = create(ChannelMessage_Post_Content_ImageSchema, {
1090
+ title: attachment.alt || '',
1091
+ info: create(ChannelMessage_Post_Content_Image_InfoSchema, {
1092
+ url: attachment.url,
1093
+ mimetype: contentType,
1094
+ width,
1095
+ height,
1096
+ }),
1097
+ });
1098
+ return {
1099
+ content: {
1100
+ case: 'image',
1101
+ value: image,
1102
+ },
1103
+ };
1104
+ }
1105
+ catch {
1106
+ return null;
1107
+ }
1108
+ };
1109
+ const createLinkAttachment = (attachment) => {
1110
+ return create(ChannelMessage_Post_AttachmentSchema, {
1111
+ content: {
1112
+ case: 'unfurledUrl',
1113
+ value: {
1114
+ url: attachment.url,
1115
+ image: attachment.image,
1116
+ title: attachment.title ?? '',
1117
+ description: attachment.description ?? '',
1118
+ },
1119
+ },
1120
+ });
1121
+ };
1122
+ const createTickerAttachment = (attachment) => {
1123
+ return create(ChannelMessage_Post_AttachmentSchema, {
1124
+ content: {
1125
+ case: 'ticker',
1126
+ value: {
1127
+ address: attachment.address,
1128
+ chainId: attachment.chainId,
1129
+ },
1130
+ },
1131
+ });
1132
+ };
1133
+ const createMiniAppAttachment = (attachment) => {
1134
+ return create(ChannelMessage_Post_AttachmentSchema, {
1135
+ content: {
1136
+ case: 'miniapp',
1137
+ value: { url: attachment.url },
1138
+ },
1139
+ });
1140
+ };
1141
+ const ensureOutboundSession = async (streamId, encryptionAlgorithm, toUserIds, miniblockInfo) => {
1142
+ if (!(await client.crypto.hasOutboundSession(streamId, encryptionAlgorithm))) {
1143
+ // ATTEMPT 1: Get session from app service
1144
+ const appService = await client.appServiceClient();
1145
+ try {
1146
+ const sessionResp = await appService.getSession({
1147
+ appId: userIdToAddress(client.userId),
1148
+ identifier: {
1149
+ case: 'streamId',
1150
+ value: streamIdAsBytes(streamId),
1151
+ },
1152
+ });
1153
+ if (sessionResp.groupEncryptionSessions) {
1154
+ const parsedEvent = await unpackEnvelope(sessionResp.groupEncryptionSessions, client.unpackEnvelopeOpts);
1155
+ check(parsedEvent.event.payload.case === 'userInboxPayload' &&
1156
+ parsedEvent.event.payload.value.content.case ===
1157
+ 'groupEncryptionSessions', 'invalid event payload');
1158
+ await client.importGroupEncryptionSessions({
1159
+ streamId,
1160
+ sessions: parsedEvent.event.payload.value.content.value,
1161
+ });
1162
+ // EARLY RETURN
1163
+ return;
1164
+ }
1165
+ }
1166
+ catch {
1167
+ // ignore error (should log)
1168
+ }
1169
+ // ATTEMPT 2: Create new session
1170
+ await client.crypto.ensureOutboundSession(streamId, encryptionAlgorithm, {
1171
+ shareShareSessionTimeoutMs: 5000,
1172
+ priorityUserIds: [client.userId, ...toUserIds],
1173
+ miniblockInfo,
1174
+ });
1175
+ }
1176
+ };
1177
+ const sendMessageEvent = async ({ streamId, payload, tags, ephemeral, }) => {
1178
+ const miniblockInfo = await client.getMiniblockInfo(streamId);
1179
+ const eventTags = {
1180
+ ...unsafe_makeTags(payload),
1181
+ participatingUserAddresses: tags?.participatingUserAddresses || [],
1182
+ threadId: tags?.threadId || undefined,
1183
+ };
1184
+ const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
1185
+ ? miniblockInfo.encryptionAlgorithm.algorithm
1186
+ : client.defaultGroupEncryptionAlgorithm;
1187
+ await ensureOutboundSession(streamId, encryptionAlgorithm, Array.from(new Set([
1188
+ ...eventTags.participatingUserAddresses.map((x) => userIdFromAddress(x)),
1189
+ ...eventTags.mentionedUserAddresses.map((x) => userIdFromAddress(x)),
1190
+ ])), miniblockInfo);
1191
+ const message = await client.crypto.encryptGroupEvent(streamId, toBinary(ChannelMessageSchema, payload), encryptionAlgorithm);
1192
+ message.refEventId = getRefEventIdFromChannelMessage(payload);
1193
+ let eventPayload;
1194
+ if (isChannelStreamId(streamId)) {
1195
+ eventPayload = make_ChannelPayload_Message(message);
1196
+ }
1197
+ else if (isDMChannelStreamId(streamId)) {
1198
+ eventPayload = make_DMChannelPayload_Message(message);
1199
+ }
1200
+ else if (isGDMChannelStreamId(streamId)) {
1201
+ eventPayload = make_GDMChannelPayload_Message(message);
1202
+ }
1203
+ else {
1204
+ throw new Error(`Invalid stream ID type: ${streamId}`);
1205
+ }
1206
+ return client.sendEvent(streamId, eventPayload, eventTags, ephemeral);
1207
+ };
1208
+ const sendKeySolicitation = async (streamId, sessionIds) => {
1209
+ const encryptionDevice = client.crypto.getUserDevice();
1210
+ const missingSessionIds = sessionIds.filter((sessionId) => sessionId !== '');
1211
+ return client.sendEvent(streamId, make_MemberPayload_KeySolicitation({
1212
+ deviceKey: encryptionDevice.deviceKey,
1213
+ fallbackKey: encryptionDevice.fallbackKey,
1214
+ isNewDevice: missingSessionIds.length === 0,
1215
+ sessionIds: missingSessionIds,
1216
+ }));
1217
+ };
1218
+ const uploadDeviceKeys = async () => {
1219
+ const streamId = makeUserMetadataStreamId(client.userId);
1220
+ const encryptionDevice = client.crypto.getUserDevice();
1221
+ return client.sendEvent(streamId, make_UserMetadataPayload_EncryptionDevice({
1222
+ ...encryptionDevice,
1223
+ }));
1224
+ };
1225
+ const sendMessage = async (streamId, message, opts, tags) => {
1226
+ const processedAttachments = [];
1227
+ if (opts?.attachments && opts.attachments.length > 0) {
1228
+ for (const attachment of opts.attachments) {
1229
+ switch (attachment.type) {
1230
+ case 'image': {
1231
+ const result = await createImageAttachmentFromURL(attachment);
1232
+ processedAttachments.push(result);
1233
+ break;
1234
+ }
1235
+ case 'chunked': {
1236
+ const result = await createChunkedMediaAttachment(attachment);
1237
+ processedAttachments.push(result);
1238
+ break;
1239
+ }
1240
+ case 'link': {
1241
+ const result = createLinkAttachment(attachment);
1242
+ processedAttachments.push(result);
1243
+ break;
1244
+ }
1245
+ case 'ticker': {
1246
+ const result = createTickerAttachment(attachment);
1247
+ processedAttachments.push(result);
1248
+ break;
1249
+ }
1250
+ case 'miniapp': {
1251
+ const result = createMiniAppAttachment(attachment);
1252
+ processedAttachments.push(result);
1253
+ break;
1254
+ }
1255
+ default:
1256
+ logNever(attachment);
1257
+ }
1258
+ }
1259
+ }
1260
+ const payload = create(ChannelMessageSchema, {
1261
+ payload: {
1262
+ case: 'post',
1263
+ value: {
1264
+ threadId: opts?.threadId,
1265
+ replyId: opts?.replyId,
1266
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1267
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1268
+ content: {
1269
+ case: 'text',
1270
+ value: {
1271
+ body: message,
1272
+ attachments: processedAttachments.filter((x) => x !== null),
1273
+ mentions: processMentions(opts?.mentions),
1274
+ },
1275
+ },
1276
+ },
1277
+ },
1278
+ });
1279
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1280
+ };
1281
+ const editMessage = async (streamId, messageId, message, opts, tags) => {
1282
+ const processedAttachments = [];
1283
+ if (opts?.attachments && opts.attachments.length > 0) {
1284
+ for (const attachment of opts.attachments) {
1285
+ switch (attachment.type) {
1286
+ case 'image': {
1287
+ const result = await createImageAttachmentFromURL(attachment);
1288
+ processedAttachments.push(result);
1289
+ break;
1290
+ }
1291
+ case 'chunked': {
1292
+ const result = await createChunkedMediaAttachment(attachment);
1293
+ processedAttachments.push(result);
1294
+ break;
1295
+ }
1296
+ case 'link': {
1297
+ const result = createLinkAttachment(attachment);
1298
+ processedAttachments.push(result);
1299
+ break;
1300
+ }
1301
+ case 'ticker': {
1302
+ const result = createTickerAttachment(attachment);
1303
+ processedAttachments.push(result);
1304
+ break;
1305
+ }
1306
+ case 'miniapp': {
1307
+ const result = createMiniAppAttachment(attachment);
1308
+ processedAttachments.push(result);
1309
+ break;
1310
+ }
1311
+ default:
1312
+ logNever(attachment);
1313
+ }
1314
+ }
1315
+ }
1316
+ const payload = create(ChannelMessageSchema, {
1317
+ payload: {
1318
+ case: 'edit',
1319
+ value: {
1320
+ refEventId: messageId,
1321
+ post: {
1322
+ threadId: opts?.threadId,
1323
+ replyId: opts?.replyId,
1324
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1325
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1326
+ content: {
1327
+ case: 'text',
1328
+ value: {
1329
+ body: message,
1330
+ mentions: processMentions(opts?.mentions),
1331
+ attachments: processedAttachments.filter((x) => x !== null),
1332
+ },
1333
+ },
1334
+ },
1335
+ },
1336
+ },
1337
+ });
1338
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1339
+ };
1340
+ const sendReaction = async (streamId, messageId, reaction, tags) => {
1341
+ const payload = create(ChannelMessageSchema, {
1342
+ payload: { case: 'reaction', value: { refEventId: messageId, reaction } },
1343
+ });
1344
+ return sendMessageEvent({ streamId, payload, tags });
1345
+ };
1346
+ /**
1347
+ * Used to send a typed message into a channel stream.
1348
+ * The message will be serialized to JSON using superjson and then encoded to bytes.
1349
+ * Clients can agree on the schema to deserialize the message by the typeUrl.
1350
+ * @param streamId - The stream ID to send the message to.
1351
+ * @param typeUrl - A schema type URL for the message
1352
+ * @param message - The message to send as raw bytes.
1353
+ * @param tags - The tags to send with the message.
1354
+ * @returns The event ID of the sent message.
1355
+ */
1356
+ async function sendGM(streamId, typeUrl, schema, data, opts, tags) {
1357
+ const result = await schema['~standard'].validate(data);
1358
+ if ('issues' in result && result.issues) {
1359
+ throw new Error(`Schema validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`);
1360
+ }
1361
+ const jsonString = superjsonStringify(result.value);
1362
+ const jsonBytesMessage = new TextEncoder().encode(jsonString);
1363
+ const payload = create(ChannelMessageSchema, {
1364
+ payload: {
1365
+ case: 'post',
1366
+ value: {
1367
+ threadId: opts?.threadId,
1368
+ replyId: opts?.replyId,
1369
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1370
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1371
+ content: { case: 'gm', value: { typeUrl: typeUrl, value: jsonBytesMessage } },
1372
+ },
1373
+ },
1374
+ });
1375
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1376
+ }
1377
+ /**
1378
+ * Used to send a custom message into a channel stream.
1379
+ * The messages will be a raw bytes.
1380
+ * Clients can agree on the schema to deserialize the message by the typeUrl.
1381
+ * @param streamId - The stream ID to send the message to.
1382
+ * @param typeUrl - A schema type URL for the message
1383
+ * @param message - The message to send as raw bytes.
1384
+ * @param tags - The tags to send with the message.
1385
+ * @returns The event ID of the sent message.
1386
+ */
1387
+ const sendRawGM = async (streamId, typeUrl, message, opts, tags) => {
1388
+ const payload = create(ChannelMessageSchema, {
1389
+ payload: {
1390
+ case: 'post',
1391
+ value: {
1392
+ threadId: opts?.threadId,
1393
+ replyId: opts?.replyId,
1394
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1395
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1396
+ content: { case: 'gm', value: { typeUrl: typeUrl, value: message } },
1397
+ },
1398
+ },
1399
+ });
1400
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1401
+ };
1402
+ const removeEvent = async (streamId, messageId, tags) => {
1403
+ const payload = create(ChannelMessageSchema, {
1404
+ payload: { case: 'redaction', value: { refEventId: messageId } },
1405
+ });
1406
+ return sendMessageEvent({ streamId, payload, tags });
1407
+ };
1408
+ /**
1409
+ * Pin a message to a stream
1410
+ * @param streamId - The stream ID to pin the message to
1411
+ * @param eventId - The event ID of the message to pin
1412
+ * @param streamEvent - The stream event to pin
1413
+ * @returns The event ID of the pinned message
1414
+ */
1415
+ const pinMessage = async (streamId, eventId, streamEvent) => {
1416
+ return client.sendEvent(streamId, make_MemberPayload_Pin(bin_fromHexString(eventId), streamEvent));
1417
+ };
1418
+ /**
1419
+ * Unpin a message from a stream
1420
+ * @param streamId - The stream ID to unpin the message from
1421
+ * @param eventId - The event ID of the message to unpin
1422
+ * @returns The event ID of the unpinned message
1423
+ */
1424
+ const unpinMessage = async (streamId, eventId) => {
1425
+ return client.sendEvent(streamId, make_MemberPayload_Unpin(bin_fromHexString(eventId)));
1426
+ };
1427
+ const getChannelSettings = async (channelId) => {
1428
+ const spaceId = spaceIdFromChannelId(channelId);
1429
+ const streamView = await client.getStream(spaceId);
1430
+ const channel = streamView.spaceContent.spaceChannelsMetadata[channelId];
1431
+ return channel;
1432
+ };
1433
+ // Implementation
1434
+ async function sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, optsOrTags, maybeTags) {
1435
+ // Detect which format is being used
1436
+ let content;
1437
+ let recipient;
1438
+ let opts;
1439
+ let tags;
1440
+ if (isFlattenedRequest(contentOrPayload)) {
1441
+ // New flattened format
1442
+ content = flattenedToPayloadContent(contentOrPayload);
1443
+ recipient = contentOrPayload.recipient
1444
+ ? bin_fromHexString(contentOrPayload.recipient)
1445
+ : undefined;
1446
+ opts = recipientOrOpts;
1447
+ tags = optsOrTags;
1448
+ }
1449
+ else {
1450
+ // Old format
1451
+ content = contentOrPayload;
1452
+ recipient = recipientOrOpts;
1453
+ opts = optsOrTags;
1454
+ tags = maybeTags;
1455
+ }
1456
+ // Get encryption settings (same as sendMessageEvent)
1457
+ const miniblockInfo = await client.getMiniblockInfo(streamId);
1458
+ const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
1459
+ ? miniblockInfo.encryptionAlgorithm.algorithm
1460
+ : client.defaultGroupEncryptionAlgorithm;
1461
+ await ensureOutboundSession(streamId, encryptionAlgorithm, recipient ? [userIdFromAddress(recipient)] : [], miniblockInfo);
1462
+ // Create payload with content and encryption device for response
1463
+ const payload = create(InteractionRequestPayloadSchema, {
1464
+ encryptionDevice: client.crypto.getUserDevice(),
1465
+ content: content,
1466
+ });
1467
+ // Encrypt using group encryption (same as messages)
1468
+ const encryptedData = await client.crypto.encryptGroupEvent(streamId, toBinary(InteractionRequestPayloadSchema, payload), encryptionAlgorithm);
1469
+ // Create the request matching InteractionResponse structure
1470
+ const request = {
1471
+ recipient: recipient,
1472
+ encryptedData: encryptedData,
1473
+ threadId: opts?.threadId ? bin_fromHexString(opts.threadId) : undefined,
1474
+ };
1475
+ // Send as InteractionRequest
1476
+ const eventPayload = make_payload_InteractionRequest(streamId, request);
1477
+ return client.sendEvent(streamId, eventPayload, tags, opts?.ephemeral);
1478
+ }
1479
+ /**
1480
+ * Send a blockchain transaction to the stream
1481
+ * @param streamId - The stream ID to send the transaction to
1482
+ * @param chainId - The chain ID where the transaction occurred
1483
+ * @param receipt - The transaction receipt from the blockchain
1484
+ * @param content - The transaction content (tip, transfer, etc.)
1485
+ * @returns The transaction hash and event ID
1486
+ */
1487
+ const sendBlockchainTransaction = async (chainId, receipt, content, tags) => {
1488
+ const transaction = create(BlockchainTransactionSchema, {
1489
+ receipt: {
1490
+ chainId: BigInt(chainId),
1491
+ transactionHash: bin_fromHexString(receipt.transactionHash),
1492
+ blockNumber: receipt.blockNumber,
1493
+ to: bin_fromHexString(receipt.to || zeroAddress),
1494
+ from: bin_fromHexString(receipt.from),
1495
+ logs: receipt.logs.map((log) => ({
1496
+ address: bin_fromHexString(log.address),
1497
+ topics: log.topics.map((topic) => bin_fromHexString(topic)),
1498
+ data: bin_fromHexString(log.data),
1499
+ })),
1500
+ },
1501
+ solanaReceipt: undefined,
1502
+ content: content ?? { case: undefined },
1503
+ });
1504
+ const result = await client.sendEvent(makeUserStreamId(client.userId), make_UserPayload_BlockchainTransaction(transaction), tags);
1505
+ return { txHash: receipt.transactionHash, eventId: result.eventId };
1506
+ };
1507
+ /** Sends a tip to a user.
1508
+ * Tip will always get funds from the app account balance.
1509
+ * @param params - Tip parameters including recipient, amount, messageId, channelId, currency.
1510
+ * @returns The transaction hash and event ID
1511
+ */
1512
+ const sendTipImpl = async (params, tags) => {
1513
+ const currency = params.currency ?? ETH_ADDRESS;
1514
+ const isEth = currency === ETH_ADDRESS;
1515
+ const { receiver, amount, messageId, channelId } = params;
1516
+ const isDm = isDMChannelStreamId(channelId);
1517
+ const isGdm = isGDMChannelStreamId(channelId);
1518
+ const accountModulesAddress = client.config.base.chainConfig.addresses.accountModules;
1519
+ if ((isDm || isGdm) && !accountModulesAddress) {
1520
+ throw new Error('AccountModules address is not configured for DM/GDM tips');
1521
+ }
1522
+ let recipientType;
1523
+ let encodedData;
1524
+ let targetContract;
1525
+ let tokenId;
1526
+ if (isDm || isGdm) {
1527
+ // DM tips use AnyTipParams sent to AccountModules contract
1528
+ recipientType = TipRecipientType.Any;
1529
+ const sender = appAddress; // msg.sender when contract executes
1530
+ const data = encodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }], [`0x${messageId}`, `0x${channelId}`]);
1531
+ encodedData = encodeAbiParameters([
1532
+ {
1533
+ type: 'tuple',
1534
+ components: [
1535
+ { name: 'currency', type: 'address' },
1536
+ { name: 'sender', type: 'address' },
1537
+ { name: 'receiver', type: 'address' },
1538
+ { name: 'amount', type: 'uint256' },
1539
+ { name: 'data', type: 'bytes' },
1540
+ ],
1541
+ },
1542
+ ], [
1543
+ {
1544
+ currency,
1545
+ sender,
1546
+ receiver,
1547
+ amount,
1548
+ data,
1549
+ },
1550
+ ]);
1551
+ targetContract = accountModulesAddress;
1552
+ tokenId = undefined;
1553
+ }
1554
+ else {
1555
+ // Member tips use MembershipTipParams sent to Space contract
1556
+ recipientType = TipRecipientType.Member;
1557
+ const spaceId = spaceIdFromChannelId(channelId);
1558
+ tokenId = await spaceDapp.getTokenIdOfOwner(spaceId, receiver);
1559
+ if (!tokenId) {
1560
+ throw new Error(`No token ID found for user ${receiver} in space ${spaceId}`);
1561
+ }
1562
+ const metadataData = encodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }, { type: 'uint256' }], [`0x${messageId}`, `0x${channelId}`, BigInt(tokenId)]);
1563
+ encodedData = encodeAbiParameters([
1564
+ {
1565
+ type: 'tuple',
1566
+ components: [
1567
+ { name: 'receiver', type: 'address' },
1568
+ { name: 'tokenId', type: 'uint256' },
1569
+ { name: 'currency', type: 'address' },
1570
+ { name: 'amount', type: 'uint256' },
1571
+ {
1572
+ name: 'metadata',
1573
+ type: 'tuple',
1574
+ components: [
1575
+ { name: 'messageId', type: 'bytes32' },
1576
+ { name: 'channelId', type: 'bytes32' },
1577
+ { name: 'data', type: 'bytes' },
1578
+ ],
1579
+ },
1580
+ ],
1581
+ },
1582
+ ], [
1583
+ {
1584
+ receiver,
1585
+ tokenId: BigInt(tokenId),
1586
+ currency,
1587
+ amount,
1588
+ metadata: {
1589
+ messageId: `0x${messageId}`,
1590
+ channelId: `0x${channelId}`,
1591
+ data: metadataData,
1592
+ },
1593
+ },
1594
+ ]);
1595
+ targetContract = SpaceAddressFromSpaceId(spaceId);
1596
+ }
1597
+ let hash;
1598
+ if (isEth) {
1599
+ hash = await writeContract(viem, {
1600
+ address: targetContract,
1601
+ abi: tippingFacetAbi,
1602
+ functionName: 'sendTip',
1603
+ args: [recipientType, encodedData],
1604
+ value: amount,
1605
+ });
1606
+ }
1607
+ else {
1608
+ const approveHash = await writeContract(viem, {
1609
+ address: currency,
1610
+ abi: erc20Abi,
1611
+ functionName: 'approve',
1612
+ args: [targetContract, amount],
1613
+ });
1614
+ await waitForTransactionReceipt(viem, {
1615
+ hash: approveHash,
1616
+ confirmations: 3,
1617
+ });
1618
+ hash = await writeContract(viem, {
1619
+ address: targetContract,
1620
+ abi: tippingFacetAbi,
1621
+ functionName: 'sendTip',
1622
+ args: [recipientType, encodedData],
1623
+ });
1624
+ }
1625
+ const receipt = await waitForTransactionReceipt(viem, { hash, confirmations: 3 });
1626
+ if (receipt.status !== 'success') {
1627
+ throw new Error(`Tip transaction failed: ${hash}`);
1628
+ }
1629
+ const tipEvent = parseEventLogs({
1630
+ abi: tippingFacetAbi,
1631
+ logs: receipt.logs,
1632
+ eventName: 'TipSent',
1633
+ })[0];
1634
+ return sendBlockchainTransaction(viem.chain.id, receipt, {
1635
+ case: 'tip',
1636
+ value: {
1637
+ event: {
1638
+ tokenId: tokenId ? BigInt(tokenId) : undefined,
1639
+ currency: bin_fromHexString(tipEvent.args.currency),
1640
+ sender: bin_fromHexString(tipEvent.args.sender),
1641
+ receiver: bin_fromHexString(tipEvent.args.receiver),
1642
+ amount: tipEvent.args.amount,
1643
+ messageId: bin_fromHexString(messageId),
1644
+ channelId: bin_fromHexString(channelId),
1645
+ },
1646
+ toUserAddress: bin_fromHexString(params.receiverUserId),
1647
+ },
1648
+ }, {
1649
+ groupMentionTypes: tags?.groupMentionTypes || [],
1650
+ mentionedUserAddresses: tags?.mentionedUserAddresses || [],
1651
+ threadId: tags?.threadId,
1652
+ appClientAddress: tags?.appClientAddress,
1653
+ messageInteractionType: MessageInteractionType.TIP,
1654
+ participatingUserAddresses: [bin_fromHexString(params.receiverUserId)],
1655
+ });
1656
+ };
1657
+ /** Sends a tip to a user by looking up their smart account by userId.
1658
+ * Tip will always get funds from the app account balance.
1659
+ * @param params - Tip parameters including userId, amount, messageId, channelId, currency.
1660
+ * @returns The transaction hash and event ID
1661
+ */
1662
+ const sendTip = async (params, tags) => {
1663
+ const smartAccountAddress = await getSmartAccountFromUserIdImpl(client.config.base.chainConfig.addresses.spaceFactory, viem, params.userId);
1664
+ return sendTipImpl({
1665
+ ...params,
1666
+ receiver: smartAccountAddress ?? params.userId,
1667
+ receiverUserId: params.userId,
1668
+ }, tags);
1669
+ };
1670
+ return {
1671
+ sendMessage,
1672
+ editMessage,
1673
+ sendReaction,
1674
+ sendInteractionRequest,
1675
+ sendGM,
1676
+ sendRawGM,
1677
+ removeEvent,
1678
+ sendKeySolicitation,
1679
+ uploadDeviceKeys,
1680
+ pinMessage,
1681
+ unpinMessage,
1682
+ getChannelSettings,
1683
+ sendBlockchainTransaction,
1684
+ sendTip,
1685
+ };
1686
+ };
1687
+ /**
1688
+ * Given a slash command message, returns the command and the arguments
1689
+ * @example
1690
+ * ```
1691
+ * /help
1692
+ * args: []
1693
+ * ```
1694
+ * ```
1695
+ * /sum 1 2
1696
+ * args: ['1', '2']
1697
+ * ```
1698
+ */
1699
+ const parseSlashCommand = (message) => {
1700
+ const parts = message.split(' ');
1701
+ const commandWithSlash = parts[0];
1702
+ const command = commandWithSlash.substring(1);
1703
+ const args = parts.slice(1);
1704
+ return { command, args };
1705
+ };
1706
+ const parseMentions = (mentions) =>
1707
+ // Agents doesn't care about @channel or @role mentions
1708
+ mentions.flatMap((m) => m.mentionBehavior.case === undefined
1709
+ ? [{ userId: m.userId, displayName: m.displayName }]
1710
+ : []);
1711
+ const processMentions = (mentions) => {
1712
+ if (!mentions) {
1713
+ return [];
1714
+ }
1715
+ return mentions.map((mention) => {
1716
+ if ('userId' in mention) {
1717
+ return create(ChannelMessage_Post_MentionSchema, {
1718
+ userId: mention.userId,
1719
+ displayName: mention.displayName,
1720
+ });
1721
+ }
1722
+ else if ('roleId' in mention) {
1723
+ return create(ChannelMessage_Post_MentionSchema, {
1724
+ mentionBehavior: {
1725
+ case: 'atRole',
1726
+ value: {
1727
+ roleId: mention.roleId,
1728
+ },
1729
+ },
1730
+ });
1731
+ }
1732
+ else if ('atChannel' in mention) {
1733
+ return create(ChannelMessage_Post_MentionSchema, {
1734
+ mentionBehavior: {
1735
+ case: 'atChannel',
1736
+ value: create(EmptySchema, {}),
1737
+ },
1738
+ });
1739
+ }
1740
+ else {
1741
+ throw new Error(`Invalid mention type: ${JSON.stringify(mention)}`);
1742
+ }
1743
+ });
1744
+ };
1745
+ const createBasePayload = (userId, streamId, eventId, createdAt, event) => {
1746
+ const isDm = isDMChannelStreamId(streamId);
1747
+ const isGdm = isGDMChannelStreamId(streamId);
1748
+ return {
1749
+ userId,
1750
+ channelId: streamId,
1751
+ eventId,
1752
+ createdAt,
1753
+ event,
1754
+ isDm,
1755
+ isGdm,
1756
+ };
1757
+ };
1758
+ //# sourceMappingURL=agent.js.map