@towns-labs/app-framework 4.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.
Files changed (42) hide show
  1. package/README.md +147 -0
  2. package/dist/app.d.ts +680 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +2324 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/app.test.d.ts +2 -0
  7. package/dist/app.test.d.ts.map +1 -0
  8. package/dist/app.test.js +2070 -0
  9. package/dist/app.test.js.map +1 -0
  10. package/dist/identity-types.d.ts +43 -0
  11. package/dist/identity-types.d.ts.map +1 -0
  12. package/dist/identity-types.js +2 -0
  13. package/dist/identity-types.js.map +1 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +9 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/modules/eventDedup.d.ts +73 -0
  19. package/dist/modules/eventDedup.d.ts.map +1 -0
  20. package/dist/modules/eventDedup.js +105 -0
  21. package/dist/modules/eventDedup.js.map +1 -0
  22. package/dist/modules/eventDedup.test.d.ts +2 -0
  23. package/dist/modules/eventDedup.test.d.ts.map +1 -0
  24. package/dist/modules/eventDedup.test.js +222 -0
  25. package/dist/modules/eventDedup.test.js.map +1 -0
  26. package/dist/modules/interaction-api.d.ts +101 -0
  27. package/dist/modules/interaction-api.d.ts.map +1 -0
  28. package/dist/modules/interaction-api.js +213 -0
  29. package/dist/modules/interaction-api.js.map +1 -0
  30. package/dist/modules/payments.d.ts +89 -0
  31. package/dist/modules/payments.d.ts.map +1 -0
  32. package/dist/modules/payments.js +139 -0
  33. package/dist/modules/payments.js.map +1 -0
  34. package/dist/modules/user.d.ts +17 -0
  35. package/dist/modules/user.d.ts.map +1 -0
  36. package/dist/modules/user.js +54 -0
  37. package/dist/modules/user.js.map +1 -0
  38. package/dist/snapshot-getter.d.ts +21 -0
  39. package/dist/snapshot-getter.d.ts.map +1 -0
  40. package/dist/snapshot-getter.js +27 -0
  41. package/dist/snapshot-getter.js.map +1 -0
  42. package/package.json +66 -0
package/dist/app.js ADDED
@@ -0,0 +1,2324 @@
1
+ import { create, fromBinary, fromJsonString, toBinary, toJsonString } from '@bufbuild/protobuf';
2
+ import { utils } from 'ethers';
3
+ import { ETH_ADDRESS } from '@towns-labs/web3';
4
+ import { stringify as superjsonStringify, parse as superjsonParse } from 'superjson';
5
+ var TipRecipientType;
6
+ (function (TipRecipientType) {
7
+ TipRecipientType[TipRecipientType["Member"] = 0] = "Member";
8
+ TipRecipientType[TipRecipientType["Bot"] = 1] = "Bot";
9
+ TipRecipientType[TipRecipientType["Any"] = 2] = "Any";
10
+ })(TipRecipientType || (TipRecipientType = {}));
11
+ const tippingFacetAbi = [
12
+ {
13
+ type: 'function',
14
+ name: 'sendTip',
15
+ inputs: [
16
+ { name: 'recipientType', type: 'uint8' },
17
+ { name: 'data', type: 'bytes' },
18
+ ],
19
+ outputs: [],
20
+ stateMutability: 'payable',
21
+ },
22
+ {
23
+ type: 'event',
24
+ name: 'TipSent',
25
+ inputs: [
26
+ { name: 'sender', type: 'address', indexed: true },
27
+ { name: 'receiver', type: 'address', indexed: true },
28
+ { name: 'recipientType', type: 'uint8', indexed: true },
29
+ { name: 'currency', type: 'address', indexed: false },
30
+ { name: 'amount', type: 'uint256', indexed: false },
31
+ { name: 'data', type: 'bytes', indexed: false },
32
+ ],
33
+ anonymous: false,
34
+ },
35
+ ];
36
+ import { getRefEventIdFromChannelMessage, createTownsClient, streamIdAsString, make_MemberPayload_KeyFulfillment, make_MemberPayload_KeySolicitation, make_UserInboxPayload_GroupEncryptionSessions, make_UserMetadataPayload_EncryptionDevice, logNever, makeUserInboxStreamId, userIdFromAddress, makeUserMetadataStreamId, unsafe_makeTags, townsEnv, parseAppPrivateData, makeEvent, make_MediaPayload_Inception, make_MediaPayload_Chunk, makeUniqueMediaStreamId, streamIdAsBytes, addressFromUserId, make_payload_InteractionRequest, userIdToAddress, unpackEnvelope, make_UserPayload_BlockchainTransaction, make_UserPayload_UserMembershipAction, makeUserStreamId, make_MemberPayload_Pin, make_MemberPayload_Unpin, isGDMChannelStreamId, make_GDMChannelPayload_Message, } from '@towns-labs/sdk';
37
+ import { Hono } from 'hono';
38
+ import { logger } from 'hono/logger';
39
+ import { createMiddleware } from 'hono/factory';
40
+ import { default as jwt } from 'jsonwebtoken';
41
+ import { createNanoEvents } from 'nanoevents';
42
+ import imageSize from 'image-size';
43
+ import { ChannelMessageSchema, AppServiceRequestSchema, AppServiceResponseSchema, MembershipOp, MessageInteractionType, ChannelMessage_Post_Content_ImageSchema, ChannelMessage_Post_Content_Image_InfoSchema, ChannelMessage_Post_Content_InfoCardSchema, ChannelMessage_Post_Content_InfoCard_LabelSchema, ChannelMessage_Post_Content_InfoCard_FieldSchema, ChannelMessage_Post_Content_InfoCard_UserRefSchema, ChannelMessage_Post_Content_InfoCard_TokenAmountSchema, ChannelMessage_Post_Content_InfoCard_ContractRefSchema, ChannelMessage_Post_Content_InfoCard_MarketItemSchema, ChannelMessage_Post_Content_InfoCard_MarketItem_OutcomeSchema, ChannelMessage_Post_Content_InfoCard_Label_Icon, ChannelMessage_Post_Content_InfoCard_Label_Badge_Variant, ChunkedMediaSchema, CreationCookieSchema, SessionKeysSchema, BlockchainTransactionSchema, InteractionRequestPayloadSchema, InteractionResponsePayloadSchema, ChannelMessage_Post_AttachmentSchema, ChannelMessage_Post_MentionSchema, ConciergeFinishStatus, } from '@towns-labs/proto';
44
+ import { bin_equal, bin_fromBase64, bin_fromHexString, bin_toHexString, check, dlog, } from '@towns-labs/utils';
45
+ import { encryptChunkedAESGCM } from '@towns-labs/sdk-crypto';
46
+ import { EventDedup } from './modules/eventDedup';
47
+ import { UserInfoCache, getUserBulk } from './modules/user';
48
+ import { isFlattenedRequest, flattenedToPayloadContent, } from './modules/interaction-api';
49
+ import { chainIdToNetwork, createPaymentRequest } from './modules/payments';
50
+ import { useFacilitator } from 'x402/verify';
51
+ import { http, createWalletClient, encodeAbiParameters, zeroAddress, parseEventLogs, formatUnits, erc20Abi, } from 'viem';
52
+ import { readContract, waitForTransactionReceipt, writeContract } from 'viem/actions';
53
+ import { base, baseSepolia, foundry } from 'viem/chains';
54
+ import packageJson from '../package.json' with { type: 'json' };
55
+ import { privateKeyToAccount } from 'viem/accounts';
56
+ const appRegistryAbi = [
57
+ {
58
+ type: 'function',
59
+ name: 'getAppByClient',
60
+ inputs: [{ name: 'client', type: 'address' }],
61
+ outputs: [{ name: '', type: 'address' }],
62
+ stateMutability: 'view',
63
+ },
64
+ ];
65
+ import { EmptySchema } from '@bufbuild/protobuf/wkt';
66
+ const debug = dlog('csb:agent');
67
+ export class App {
68
+ client;
69
+ appAddress;
70
+ agentUserId;
71
+ viem;
72
+ jwtSecret;
73
+ currentMessageTags;
74
+ emitter = createNanoEvents();
75
+ slashCommandHandlers = new Map();
76
+ gmTypedHandlers = new Map();
77
+ commands;
78
+ capabilities;
79
+ capabilityHandlers = new Map();
80
+ conciergeRequestHandler;
81
+ positionsHandler;
82
+ metadata;
83
+ identityConfig;
84
+ eventDedup;
85
+ // Payment related members
86
+ paymentConfig;
87
+ pendingPayments = new Map();
88
+ paymentCommands = new Map();
89
+ // User info cache
90
+ userInfoCache = new UserInfoCache();
91
+ constructor(clientV2, viem, jwtSecretBase64, appAddress, commands, capabilities, identityConfig, dedupConfig, paymentConfig, metadata = {}) {
92
+ this.client = clientV2;
93
+ this.agentUserId = clientV2.userId;
94
+ this.viem = viem;
95
+ this.jwtSecret = bin_fromBase64(jwtSecretBase64);
96
+ this.currentMessageTags = undefined;
97
+ this.commands = commands;
98
+ this.capabilities = capabilities;
99
+ this.appAddress = appAddress;
100
+ this.identityConfig = identityConfig;
101
+ this.eventDedup = new EventDedup(dedupConfig);
102
+ this.paymentConfig = paymentConfig;
103
+ this.metadata = metadata;
104
+ if (commands && paymentConfig) {
105
+ for (const cmd of commands) {
106
+ if (cmd.paid?.price) {
107
+ this.paymentCommands.set(cmd.name, cmd.paid);
108
+ }
109
+ }
110
+ }
111
+ this.onInteractionResponse(this.handlePaymentResponse.bind(this));
112
+ }
113
+ async getUser(userIdOrIds) {
114
+ if (Array.isArray(userIdOrIds)) {
115
+ return getUserBulk(userIdOrIds, this.userInfoCache, () => this.client.appServiceClient());
116
+ }
117
+ const result = await getUserBulk([userIdOrIds], this.userInfoCache, () => this.client.appServiceClient());
118
+ return result.get(userIdOrIds);
119
+ }
120
+ start() {
121
+ const jwtMiddleware = createMiddleware(this.jwtMiddleware.bind(this));
122
+ const handler = this.webhookHandler.bind(this);
123
+ const app = new Hono();
124
+ app.use(logger());
125
+ app.post('/webhook', jwtMiddleware, handler);
126
+ app.get('/.well-known/agent-metadata.json', async (c) => {
127
+ return c.json(await this.getIdentityMetadata());
128
+ });
129
+ debug('init');
130
+ return app;
131
+ }
132
+ async jwtMiddleware(c, next) {
133
+ const authHeader = c.req.header('Authorization');
134
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
135
+ return c.text('Unauthorized: Missing or malformed token', 401);
136
+ }
137
+ const tokenString = authHeader.substring(7);
138
+ try {
139
+ const agentAddressBytes = bin_fromHexString(this.agentUserId);
140
+ const expectedAudience = bin_toHexString(agentAddressBytes);
141
+ jwt.verify(tokenString, Buffer.from(this.jwtSecret), {
142
+ algorithms: ['HS256'],
143
+ audience: expectedAudience,
144
+ });
145
+ }
146
+ catch (err) {
147
+ let errorMessage = 'Unauthorized: Token verification failed';
148
+ if (err instanceof jwt.TokenExpiredError) {
149
+ errorMessage = 'Unauthorized: Token expired';
150
+ }
151
+ else if (err instanceof jwt.JsonWebTokenError) {
152
+ errorMessage = `Unauthorized: Invalid token (${err.message})`;
153
+ }
154
+ return c.text(errorMessage, 401);
155
+ }
156
+ await next();
157
+ }
158
+ async webhookHandler(c) {
159
+ const body = await c.req.arrayBuffer();
160
+ const encryptionDevice = this.client.crypto.getUserDevice();
161
+ const request = fromBinary(AppServiceRequestSchema, new Uint8Array(body));
162
+ debug('webhook', request);
163
+ const statusResponse = create(AppServiceResponseSchema, {
164
+ payload: {
165
+ case: 'status',
166
+ value: {
167
+ frameworkVersion: 1,
168
+ clientVersion: `javascript:${packageJson.name}:${packageJson.version}`,
169
+ deviceKey: encryptionDevice.deviceKey,
170
+ fallbackKey: encryptionDevice.fallbackKey,
171
+ },
172
+ },
173
+ });
174
+ let response = statusResponse;
175
+ if (request.payload.case === 'initialize') {
176
+ response = create(AppServiceResponseSchema, {
177
+ payload: {
178
+ case: 'initialize',
179
+ value: {
180
+ encryptionDevice,
181
+ },
182
+ },
183
+ });
184
+ }
185
+ else if (request.payload.case === 'events') {
186
+ for (const event of request.payload.value.events) {
187
+ try {
188
+ await this.handleEvent(event);
189
+ }
190
+ catch (err) {
191
+ // eslint-disable-next-line no-console
192
+ console.error('[@towns-labs/app-framework] Error while handling event', err);
193
+ }
194
+ }
195
+ response = statusResponse;
196
+ }
197
+ else if (request.payload.case === 'status') {
198
+ response = statusResponse;
199
+ }
200
+ else if (request.payload.case === 'proposals') {
201
+ const proposalsRequest = request.payload.value;
202
+ // Fire-and-forget: process proposals async, respond accepted immediately
203
+ this.processProposals(proposalsRequest).catch((err) =>
204
+ // eslint-disable-next-line no-console
205
+ console.error('[@towns-labs/app-framework] proposals processing error', err));
206
+ response = create(AppServiceResponseSchema, {
207
+ payload: {
208
+ case: 'proposals',
209
+ value: { accepted: true },
210
+ },
211
+ });
212
+ }
213
+ else if (request.payload.case === 'conciergeRequest') {
214
+ const conciergeReq = request.payload.value;
215
+ if (this.conciergeRequestHandler) {
216
+ // Fire-and-forget: process concierge request async, respond accepted
217
+ // immediately. Once the handler resolves, persist the concierge output
218
+ // back to the seed via FinishConversationSeed.
219
+ this.conciergeRequestHandler(this.client, {
220
+ conversationSeedId: conciergeReq.conversationSeedId,
221
+ userQuery: conciergeReq.userQuery,
222
+ context: conciergeReq.context,
223
+ externalRef: conciergeReq.externalRef,
224
+ proposalTimeoutMs: conciergeReq.proposalTimeoutMs,
225
+ conciergeTimeoutMs: conciergeReq.conciergeTimeoutMs,
226
+ userTimezone: conciergeReq.userTimezone,
227
+ })
228
+ .then(async (seedUpdate) => {
229
+ if (seedUpdate) {
230
+ try {
231
+ const appService = await this.client.appServiceClient();
232
+ await appService.finishConversationSeed({
233
+ conversationSeedId: conciergeReq.conversationSeedId,
234
+ responseText: seedUpdate.responseText ?? '',
235
+ selectedProposalIds: seedUpdate.selectedProposalIds ?? [],
236
+ clarifyingQuestions: seedUpdate.clarifyingQuestions ?? [],
237
+ });
238
+ }
239
+ catch (finishErr) {
240
+ // eslint-disable-next-line no-console
241
+ console.error('[@towns-labs/app-framework] failed to persist concierge result', finishErr);
242
+ }
243
+ }
244
+ })
245
+ .catch(async (err) => {
246
+ // eslint-disable-next-line no-console
247
+ console.error('[@towns-labs/app-framework] conciergeRequest error', err);
248
+ try {
249
+ const appService = await this.client.appServiceClient();
250
+ await appService.finishConversationSeed({
251
+ conversationSeedId: conciergeReq.conversationSeedId,
252
+ responseText: 'Something went wrong. Please try again.',
253
+ selectedProposalIds: [],
254
+ clarifyingQuestions: [],
255
+ status: ConciergeFinishStatus.FAILURE,
256
+ });
257
+ }
258
+ catch (finishErr) {
259
+ // eslint-disable-next-line no-console
260
+ console.error('[@towns-labs/app-framework] failed to finalize seed after error', finishErr);
261
+ }
262
+ });
263
+ }
264
+ response = create(AppServiceResponseSchema, {
265
+ payload: {
266
+ case: 'conciergeResponse',
267
+ value: { accepted: true },
268
+ },
269
+ });
270
+ }
271
+ else if (request.payload.case === 'positions') {
272
+ const positionsRequest = request.payload.value;
273
+ if (this.positionsHandler) {
274
+ const result = await this.positionsHandler(this.client, {
275
+ userId: userIdFromAddress(positionsRequest.userId),
276
+ });
277
+ response = create(AppServiceResponseSchema, {
278
+ payload: {
279
+ case: 'positions',
280
+ value: result,
281
+ },
282
+ });
283
+ }
284
+ else {
285
+ response = create(AppServiceResponseSchema, {
286
+ payload: {
287
+ case: 'positions',
288
+ value: {
289
+ supported: false,
290
+ positions: [],
291
+ timestamp: BigInt(Date.now()),
292
+ },
293
+ },
294
+ });
295
+ }
296
+ }
297
+ c.header('Content-Type', 'application/x-protobuf');
298
+ return c.body(toBinary(AppServiceResponseSchema, response), 200);
299
+ }
300
+ async processProposals(proposalsRequest) {
301
+ const proposals = [];
302
+ const errors = [];
303
+ const tasks = proposalsRequest.invocations.map(async (invocation) => {
304
+ const capabilityName = invocation.capabilityName;
305
+ const handler = this.capabilityHandlers.get(capabilityName);
306
+ if (!handler) {
307
+ return {
308
+ ok: false,
309
+ capabilityName,
310
+ error: `Unknown capability: ${capabilityName}`,
311
+ };
312
+ }
313
+ try {
314
+ const parsed = JSON.parse(invocation.parameters || '{}');
315
+ const results = await handler(this.client, {
316
+ capabilityName,
317
+ parameters: parsed,
318
+ userQuery: proposalsRequest.userQuery,
319
+ context: proposalsRequest.context,
320
+ timeoutMs: proposalsRequest.timeoutMs,
321
+ });
322
+ return { ok: true, capabilityName, invocation, results };
323
+ }
324
+ catch (err) {
325
+ return {
326
+ ok: false,
327
+ capabilityName,
328
+ error: err instanceof Error ? err.message : 'Unknown error',
329
+ };
330
+ }
331
+ });
332
+ const settled = await Promise.all(tasks);
333
+ let proposalIndex = 0;
334
+ for (const outcome of settled) {
335
+ if (!outcome.ok) {
336
+ errors.push({
337
+ agentId: bin_fromHexString(this.agentUserId),
338
+ error: outcome.error,
339
+ });
340
+ }
341
+ else {
342
+ try {
343
+ for (const r of outcome.results) {
344
+ proposals.push({
345
+ id: `${outcome.capabilityName}_${proposalIndex++}`,
346
+ targetAgentId: bin_fromHexString(this.agentUserId),
347
+ targetAgentName: this.metadata.displayName,
348
+ capabilityName: outcome.capabilityName,
349
+ parameters: r.parameters ?? outcome.invocation.parameters,
350
+ explanation: r.explanation,
351
+ confidence: r.confidence,
352
+ expiresAtMs: r.expiresAt ? BigInt(r.expiresAt.getTime()) : 0n,
353
+ warnings: r.warnings ?? [],
354
+ expectedOutcome: r.expectedOutcome,
355
+ title: r.title,
356
+ });
357
+ }
358
+ }
359
+ catch (err) {
360
+ errors.push({
361
+ agentId: bin_fromHexString(this.agentUserId),
362
+ error: err instanceof Error ? err.message : 'Unknown error',
363
+ });
364
+ }
365
+ }
366
+ }
367
+ const appService = await this.client.appServiceClient();
368
+ await appService.submitAgentProposals({
369
+ conversationSeedId: proposalsRequest.conversationSeedId,
370
+ proposals,
371
+ errors,
372
+ });
373
+ }
374
+ async handleEvent(appEvent) {
375
+ if (!appEvent.payload.case || !appEvent.payload.value)
376
+ return;
377
+ const streamId = streamIdAsString(appEvent.payload.value.streamId);
378
+ if (appEvent.payload.case === 'messages') {
379
+ const groupEncryptionSessionsPayloads = await this.client
380
+ .unpackEnvelopes(appEvent.payload.value.groupEncryptionSessionsMessages)
381
+ .then((x) => x.flatMap((e) => {
382
+ if (e.event.payload.case === 'userInboxPayload' &&
383
+ e.event.payload.value.content.case === 'groupEncryptionSessions') {
384
+ return e.event.payload.value.content.value;
385
+ }
386
+ return [];
387
+ }));
388
+ for (const sessions of groupEncryptionSessionsPayloads) {
389
+ await this.client.importGroupEncryptionSessions({
390
+ streamId,
391
+ sessions,
392
+ });
393
+ }
394
+ const events = await this.client.unpackEnvelopes(appEvent.payload.value.messages);
395
+ for (const parsed of events) {
396
+ if (parsed.creatorUserId === this.client.userId) {
397
+ continue;
398
+ }
399
+ if (!parsed.event.payload.case) {
400
+ continue;
401
+ }
402
+ // Skip duplicate events (App Registry may replay events during restarts)
403
+ if (this.eventDedup.checkAndAdd(streamId, parsed.hashStr)) {
404
+ continue;
405
+ }
406
+ const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
407
+ this.currentMessageTags = parsed.event.tags;
408
+ const creatorUserId = userIdFromAddress(parsed.event.creatorAddress);
409
+ const creatorUser = (await this.getUser(creatorUserId)) ?? {
410
+ id: creatorUserId,
411
+ displayName: '',
412
+ username: '',
413
+ };
414
+ debug('emit:streamEvent', {
415
+ userId: creatorUserId,
416
+ channelId: streamId,
417
+ eventId: parsed.hashStr,
418
+ });
419
+ this.emitter.emit('streamEvent', this.client, {
420
+ ...createBasePayload(creatorUserId, streamId, parsed.hashStr, createdAt, parsed.event, creatorUser),
421
+ parsed: parsed,
422
+ });
423
+ switch (parsed.event.payload.case) {
424
+ case 'gdmChannelPayload': {
425
+ if (!parsed.event.payload.value.content.case)
426
+ return;
427
+ if (parsed.event.payload.value.content.case === 'message') {
428
+ const eventCleartext = await this.client.crypto.decryptGroupEvent(streamId, parsed.event.payload.value.content.value);
429
+ if (eventCleartext === undefined) {
430
+ debug('skipping event with unrecognized encryption algorithm', streamId);
431
+ return;
432
+ }
433
+ let channelMessage;
434
+ if (typeof eventCleartext === 'string') {
435
+ channelMessage = fromJsonString(ChannelMessageSchema, eventCleartext);
436
+ }
437
+ else {
438
+ channelMessage = fromBinary(ChannelMessageSchema, eventCleartext);
439
+ }
440
+ await this.handleChannelMessage(streamId, parsed, channelMessage, creatorUser);
441
+ }
442
+ else if (parsed.event.payload.value.content.case === 'redaction') {
443
+ const refEventId = bin_toHexString(parsed.event.payload.value.content.value.eventId);
444
+ debug('emit:eventRevoke', {
445
+ userId: userIdFromAddress(parsed.event.creatorAddress),
446
+ channelId: streamId,
447
+ refEventId,
448
+ });
449
+ this.emitter.emit('eventRevoke', this.client, {
450
+ ...createBasePayload(creatorUserId, streamId, parsed.hashStr, createdAt, parsed.event, creatorUser),
451
+ refEventId,
452
+ });
453
+ }
454
+ else if (parsed.event.payload.value.content.case === 'channelProperties') {
455
+ // TODO: currently, no support for channel properties (update name, topic)
456
+ }
457
+ else if (parsed.event.payload.value.content.case === 'inception') {
458
+ // TODO: is there any use case for this?
459
+ }
460
+ else if (parsed.event.payload.value.content.case === 'custom') {
461
+ // TODO: what to do with custom payload for agent?
462
+ }
463
+ else if (parsed.event.payload.value.content.case === 'interactionRequest') {
464
+ // ignored for agentss
465
+ }
466
+ else if (parsed.event.payload.value.content.case === 'interactionResponse') {
467
+ const payload = parsed.event.payload.value.content.value;
468
+ if (!bin_equal(payload.recipient, bin_fromHexString(this.agentUserId))) {
469
+ continue;
470
+ }
471
+ if (!payload.encryptedData) {
472
+ continue;
473
+ }
474
+ if (payload.encryptedData.deviceKey !== this.getUserDevice().deviceKey) {
475
+ continue;
476
+ }
477
+ const decryptedBase64 = await this.client.crypto.decryptWithDeviceKey(payload.encryptedData.ciphertext, payload.encryptedData.senderKey);
478
+ const decrypted = bin_fromBase64(decryptedBase64);
479
+ const response = fromBinary(InteractionResponsePayloadSchema, decrypted);
480
+ this.emitter.emit('interactionResponse', this.client, {
481
+ ...createBasePayload(creatorUserId, streamId, parsed.hashStr, createdAt, parsed.event, creatorUser),
482
+ response: {
483
+ recipient: payload.recipient,
484
+ payload: response,
485
+ },
486
+ threadId: payload.threadId
487
+ ? bin_toHexString(payload.threadId)
488
+ : undefined,
489
+ });
490
+ }
491
+ else {
492
+ logNever(parsed.event.payload.value.content);
493
+ }
494
+ break;
495
+ }
496
+ case 'memberPayload': {
497
+ switch (parsed.event.payload.value.content.case) {
498
+ case 'membership':
499
+ {
500
+ const membership = parsed.event.payload.value.content.value;
501
+ const isGdm = isGDMChannelStreamId(streamId);
502
+ // TODO: do we want agent to listen to onSpaceJoin/onSpaceLeave?
503
+ if (!isGdm)
504
+ continue;
505
+ const memberUserId = userIdFromAddress(membership.userAddress);
506
+ const memberUser = (await this.getUser(memberUserId)) ?? {
507
+ id: memberUserId,
508
+ displayName: '',
509
+ username: '',
510
+ };
511
+ if (membership.op === MembershipOp.SO_JOIN) {
512
+ debug('emit:channelJoin', {
513
+ userId: memberUserId,
514
+ channelId: streamId,
515
+ eventId: parsed.hashStr,
516
+ });
517
+ this.emitter.emit('channelJoin', this.client, {
518
+ ...createBasePayload(memberUserId, streamId, parsed.hashStr, createdAt, parsed.event, memberUser),
519
+ });
520
+ }
521
+ if (membership.op === MembershipOp.SO_LEAVE) {
522
+ debug('emit:channelLeave', {
523
+ userId: memberUserId,
524
+ channelId: streamId,
525
+ eventId: parsed.hashStr,
526
+ });
527
+ this.emitter.emit('channelLeave', this.client, {
528
+ ...createBasePayload(memberUserId, streamId, parsed.hashStr, createdAt, parsed.event, memberUser),
529
+ });
530
+ }
531
+ }
532
+ break;
533
+ case 'memberBlockchainTransaction':
534
+ {
535
+ const transactionContent = parsed.event.payload.value.content.value.transaction
536
+ ?.content;
537
+ const fromUserAddress = parsed.event.payload.value.content.value.fromUserAddress;
538
+ switch (transactionContent?.case) {
539
+ case 'spaceReview':
540
+ break;
541
+ case 'tokenTransfer':
542
+ break;
543
+ case 'tip':
544
+ {
545
+ const tipEvent = transactionContent.value.event;
546
+ if (!tipEvent) {
547
+ return;
548
+ }
549
+ const currency = utils.getAddress(bin_toHexString(tipEvent.currency));
550
+ const senderAddress = utils.getAddress(bin_toHexString(tipEvent.sender));
551
+ const receiverAddress = utils.getAddress(bin_toHexString(tipEvent.receiver));
552
+ const senderUserId = userIdFromAddress(fromUserAddress);
553
+ const receiverUserId = userIdFromAddress(transactionContent.value.toUserAddress);
554
+ debug('emit:tip', {
555
+ senderAddress,
556
+ senderUserId,
557
+ receiverAddress,
558
+ receiverUserId,
559
+ amount: tipEvent.amount.toString(),
560
+ currency,
561
+ messageId: bin_toHexString(tipEvent.messageId),
562
+ });
563
+ const tipUser = (await this.getUser(senderUserId)) ?? {
564
+ id: senderUserId,
565
+ displayName: '',
566
+ username: '',
567
+ };
568
+ this.emitter.emit('tip', this.client, {
569
+ ...createBasePayload(senderUserId, streamId, parsed.hashStr, createdAt, parsed.event, tipUser),
570
+ amount: tipEvent.amount,
571
+ currency: currency,
572
+ senderAddress,
573
+ receiverAddress,
574
+ receiverUserId,
575
+ messageId: bin_toHexString(tipEvent.messageId),
576
+ });
577
+ }
578
+ break;
579
+ case undefined:
580
+ break;
581
+ default:
582
+ logNever(transactionContent);
583
+ }
584
+ }
585
+ break;
586
+ case 'keySolicitation':
587
+ await this.handleKeySolicitation(streamId, parsed);
588
+ break;
589
+ case 'keyFulfillment':
590
+ case 'pin':
591
+ case 'unpin':
592
+ case 'encryptionAlgorithm':
593
+ case 'setAppAddress':
594
+ case 'unsetAppAddress':
595
+ break;
596
+ case undefined:
597
+ break;
598
+ default:
599
+ logNever(parsed.event.payload.value.content);
600
+ }
601
+ }
602
+ }
603
+ }
604
+ }
605
+ else if (appEvent.payload.case === 'solicitation') {
606
+ const missingSessionIds = appEvent.payload.value.sessionIds.filter((sessionId) => sessionId !== '');
607
+ await this.client.sendKeySolicitation(streamId, missingSessionIds);
608
+ }
609
+ else {
610
+ logNever(appEvent.payload);
611
+ }
612
+ }
613
+ isGroupEncryptionSession(session) {
614
+ if (typeof session !== 'object' || session === null) {
615
+ return false;
616
+ }
617
+ const maybeSession = session;
618
+ return (typeof maybeSession.streamId === 'string' &&
619
+ typeof maybeSession.sessionId === 'string' &&
620
+ typeof maybeSession.sessionKey === 'string' &&
621
+ maybeSession.algorithm !== undefined);
622
+ }
623
+ async handleKeySolicitation(streamId, parsed) {
624
+ if (parsed.event.payload.case !== 'memberPayload' ||
625
+ parsed.event.payload.value.content.case !== 'keySolicitation') {
626
+ return;
627
+ }
628
+ const solicitation = parsed.event.payload.value.content.value;
629
+ const requesterUserId = userIdFromAddress(parsed.event.creatorAddress);
630
+ const myDevice = this.getUserDevice();
631
+ // Ignore our own solicitation.
632
+ if (solicitation.deviceKey === myDevice.deviceKey) {
633
+ return;
634
+ }
635
+ if (!solicitation.deviceKey || !solicitation.fallbackKey) {
636
+ return;
637
+ }
638
+ const requestedSessionIds = Array.from(new Set((Array.isArray(solicitation.sessionIds) ? solicitation.sessionIds : []).filter((sessionId) => typeof sessionId === 'string' && sessionId !== ''))).sort();
639
+ if (requestedSessionIds.length === 0) {
640
+ return;
641
+ }
642
+ const availableSessions = new Map();
643
+ for (const sessionId of requestedSessionIds) {
644
+ const localSession = await this.client.crypto.exportGroupSession(streamId, sessionId);
645
+ const session = this.isGroupEncryptionSession(localSession)
646
+ ? localSession
647
+ : undefined;
648
+ if (session) {
649
+ availableSessions.set(sessionId, session);
650
+ }
651
+ }
652
+ if (availableSessions.size === 0) {
653
+ return;
654
+ }
655
+ const fulfilledSessionIds = Array.from(availableSessions.keys()).sort();
656
+ await this.client.sendEvent(streamId, make_MemberPayload_KeyFulfillment({
657
+ userAddress: parsed.event.creatorAddress,
658
+ deviceKey: solicitation.deviceKey,
659
+ sessionIds: fulfilledSessionIds,
660
+ }), undefined, parsed.ephemeral);
661
+ const sessionsByAlgorithm = new Map();
662
+ for (const session of availableSessions.values()) {
663
+ const list = sessionsByAlgorithm.get(session.algorithm) ?? [];
664
+ list.push(session);
665
+ sessionsByAlgorithm.set(session.algorithm, list);
666
+ }
667
+ for (const [algorithm, sessions] of sessionsByAlgorithm.entries()) {
668
+ sessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
669
+ const ciphertexts = await this.client.crypto.encryptWithDeviceKeys(toJsonString(SessionKeysSchema, create(SessionKeysSchema, {
670
+ keys: sessions.map((session) => session.sessionKey),
671
+ })), [
672
+ {
673
+ deviceKey: solicitation.deviceKey,
674
+ fallbackKey: solicitation.fallbackKey,
675
+ },
676
+ ]);
677
+ if (Object.keys(ciphertexts).length === 0) {
678
+ continue;
679
+ }
680
+ await this.client.sendEvent(makeUserInboxStreamId(requesterUserId), make_UserInboxPayload_GroupEncryptionSessions({
681
+ streamId: streamIdAsBytes(streamId),
682
+ senderKey: myDevice.deviceKey,
683
+ sessionIds: sessions.map((session) => session.sessionId),
684
+ ciphertexts,
685
+ algorithm,
686
+ }));
687
+ }
688
+ }
689
+ async handleChannelMessage(streamId, parsed, { payload }, user) {
690
+ if (!payload.case) {
691
+ return;
692
+ }
693
+ const createdAt = new Date(Number(parsed.event.createdAtEpochMs));
694
+ switch (payload.case) {
695
+ case 'post': {
696
+ if (payload.value.content.case === 'text') {
697
+ const userId = userIdFromAddress(parsed.event.creatorAddress);
698
+ const replyId = payload.value.replyId;
699
+ const threadId = payload.value.threadId;
700
+ const mentions = parseMentions(payload.value.content.value.mentions);
701
+ const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
702
+ const forwardPayload = {
703
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event, user),
704
+ message: payload.value.content.value.body,
705
+ mentions,
706
+ isMentioned,
707
+ replyId,
708
+ threadId,
709
+ };
710
+ if (parsed.event.tags?.messageInteractionType ===
711
+ MessageInteractionType.SLASH_COMMAND) {
712
+ const { command, args } = parseSlashCommand(payload.value.content.value.body);
713
+ const handler = this.slashCommandHandlers.get(command);
714
+ if (handler) {
715
+ void handler(this.client, {
716
+ ...forwardPayload,
717
+ command: command,
718
+ args,
719
+ replyId,
720
+ threadId,
721
+ });
722
+ }
723
+ }
724
+ else {
725
+ debug('emit:message', forwardPayload);
726
+ this.emitter.emit('message', this.client, forwardPayload);
727
+ }
728
+ }
729
+ else if (payload.value.content.case === 'gm') {
730
+ const userId = userIdFromAddress(parsed.event.creatorAddress);
731
+ const gmContent = payload.value.content.value;
732
+ const { typeUrl, value } = gmContent;
733
+ this.emitter.emit('rawGmMessage', this.client, {
734
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event, user),
735
+ typeUrl,
736
+ message: value ?? new Uint8Array(),
737
+ });
738
+ const typedHandler = this.gmTypedHandlers.get(typeUrl);
739
+ if (typedHandler) {
740
+ try {
741
+ const possibleJsonString = new TextDecoder().decode(value);
742
+ const deserializedData = superjsonParse(possibleJsonString);
743
+ const result = await typedHandler.schema['~standard'].validate(deserializedData);
744
+ if ('issues' in result && result.issues) {
745
+ debug('GM validation failed', { typeUrl, issues: result.issues });
746
+ }
747
+ else {
748
+ debug('emit:gmMessage', { userId, channelId: streamId });
749
+ void typedHandler.handler(this.client, {
750
+ ...createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event, user),
751
+ typeUrl,
752
+ data: result.value,
753
+ });
754
+ }
755
+ }
756
+ catch (error) {
757
+ debug('GM handler error', { typeUrl, error });
758
+ }
759
+ }
760
+ }
761
+ else if (payload.value.content.case === 'conversationSeedResponse') {
762
+ const userId = userIdFromAddress(parsed.event.creatorAddress);
763
+ const seedResponse = payload.value.content.value;
764
+ debug('emit:conversationSeedResponse', {
765
+ userId,
766
+ channelId: streamId,
767
+ seedId: seedResponse.seedId,
768
+ });
769
+ if (seedResponse.response.case === undefined) {
770
+ return;
771
+ }
772
+ const basePayload = createBasePayload(userId, streamId, parsed.hashStr, createdAt, parsed.event, user);
773
+ this.emitter.emit('conversationSeedResponse', this.client, {
774
+ ...basePayload,
775
+ seedId: seedResponse.seedId,
776
+ seed: seedResponse.seed,
777
+ response: seedResponse.response,
778
+ replyId: payload.value.replyId,
779
+ threadId: payload.value.threadId,
780
+ });
781
+ if (seedResponse.response.case === 'selectedProposal' &&
782
+ `0x${bin_toHexString(seedResponse.response.value.targetAgentId)}`.toLowerCase() === this.appAddress.toLowerCase()) {
783
+ debug('emit:selectedProposal', {
784
+ userId,
785
+ channelId: streamId,
786
+ seedId: seedResponse.seedId,
787
+ proposalId: seedResponse.response.value.id,
788
+ });
789
+ this.emitter.emit('selectedProposal', this.client, {
790
+ ...basePayload,
791
+ seedId: seedResponse.seedId,
792
+ seed: seedResponse.seed,
793
+ proposal: seedResponse.response.value,
794
+ replyId: payload.value.replyId,
795
+ threadId: payload.value.threadId,
796
+ });
797
+ }
798
+ }
799
+ break;
800
+ }
801
+ case 'reaction': {
802
+ debug('emit:reaction', {
803
+ userId: userIdFromAddress(parsed.event.creatorAddress),
804
+ channelId: streamId,
805
+ reaction: payload.value.reaction,
806
+ messageId: payload.value.refEventId,
807
+ });
808
+ this.emitter.emit('reaction', this.client, {
809
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event, user),
810
+ reaction: payload.value.reaction,
811
+ messageId: payload.value.refEventId,
812
+ });
813
+ break;
814
+ }
815
+ case 'edit': {
816
+ // TODO: framework doesnt handle non-text edits
817
+ if (payload.value.post?.content.case !== 'text')
818
+ break;
819
+ const mentions = parseMentions(payload.value.post?.content.value.mentions);
820
+ const isMentioned = mentions.some((m) => m.userId.toLowerCase() === this.agentUserId.toLowerCase());
821
+ debug('emit:messageEdit', {
822
+ userId: userIdFromAddress(parsed.event.creatorAddress),
823
+ channelId: streamId,
824
+ refEventId: payload.value.refEventId,
825
+ messagePreview: payload.value.post?.content.value.body.substring(0, 50),
826
+ isMentioned,
827
+ });
828
+ this.emitter.emit('messageEdit', this.client, {
829
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event, user),
830
+ refEventId: payload.value.refEventId,
831
+ message: payload.value.post?.content.value.body,
832
+ mentions,
833
+ isMentioned,
834
+ replyId: payload.value.post?.replyId,
835
+ threadId: payload.value.post?.threadId,
836
+ });
837
+ break;
838
+ }
839
+ case 'redaction': {
840
+ debug('emit:redaction', {
841
+ userId: userIdFromAddress(parsed.event.creatorAddress),
842
+ channelId: streamId,
843
+ refEventId: payload.value.refEventId,
844
+ });
845
+ this.emitter.emit('redaction', this.client, {
846
+ ...createBasePayload(userIdFromAddress(parsed.event.creatorAddress), streamId, parsed.hashStr, createdAt, parsed.event, user),
847
+ refEventId: payload.value.refEventId,
848
+ });
849
+ break;
850
+ }
851
+ default:
852
+ logNever(payload);
853
+ }
854
+ }
855
+ async handlePaymentResponse(handler, event) {
856
+ if (!this.paymentConfig)
857
+ return;
858
+ const { response, channelId } = event;
859
+ // Check if this is a signature response
860
+ if (response.payload?.content?.case !== 'signature') {
861
+ return;
862
+ }
863
+ const signatureId = response.payload.content.value?.requestId ?? '';
864
+ const signature = (response.payload.content.value?.signature ?? '');
865
+ if (!signatureId || !signature) {
866
+ return;
867
+ }
868
+ // Check if this is a pending payment
869
+ const pending = this.pendingPayments.get(signatureId);
870
+ if (!pending) {
871
+ return; // Not a payment signature
872
+ }
873
+ // Remove from pending
874
+ this.pendingPayments.delete(signatureId);
875
+ const facilitator = useFacilitator(this.paymentConfig);
876
+ // Build PaymentPayload for x402
877
+ const paymentPayload = {
878
+ x402Version: 1,
879
+ scheme: 'exact',
880
+ network: chainIdToNetwork(this.viem.chain.id),
881
+ payload: {
882
+ signature: signature,
883
+ authorization: {
884
+ from: pending.params.from,
885
+ to: pending.params.to,
886
+ value: pending.params.value.toString(),
887
+ validAfter: pending.params.validAfter.toString(),
888
+ validBefore: pending.params.validBefore.toString(),
889
+ nonce: pending.params.nonce,
890
+ },
891
+ },
892
+ };
893
+ // Build PaymentRequirements for x402
894
+ const paymentRequirements = {
895
+ scheme: 'exact',
896
+ network: paymentPayload.network,
897
+ maxAmountRequired: pending.params.value.toString(),
898
+ resource: `https://towns.com/command/${pending.command}`,
899
+ description: `Payment for /${pending.command}`,
900
+ mimeType: 'application/json',
901
+ payTo: pending.params.to,
902
+ maxTimeoutSeconds: 300,
903
+ asset: pending.params.verifyingContract,
904
+ };
905
+ // Single status message that gets updated through the flow
906
+ const statusMsg = await handler.sendMessage(channelId, '🔍 Verifying payment...');
907
+ // Track settlement state to distinguish payment failures from post-payment failures
908
+ let settlementCompleted = false;
909
+ let transactionHash;
910
+ try {
911
+ const verifyResult = await facilitator.verify(paymentPayload, paymentRequirements);
912
+ if (!verifyResult.isValid) {
913
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment verification failed: ${verifyResult.invalidReason || 'Unknown error'}`);
914
+ await handler.removeEvent(channelId, pending.interactionEventId);
915
+ return;
916
+ }
917
+ // Update status: settling
918
+ await handler.editMessage(channelId, statusMsg.eventId, `✅ Verified • Settling $${formatUnits(pending.params.value, 6)} USDC...`);
919
+ const settleResult = await facilitator.settle(paymentPayload, paymentRequirements);
920
+ if (!settleResult.success) {
921
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Settlement failed: ${settleResult.errorReason || 'Unknown error'}`);
922
+ await handler.removeEvent(channelId, pending.interactionEventId);
923
+ return;
924
+ }
925
+ // Mark settlement as complete - funds have been transferred
926
+ settlementCompleted = true;
927
+ transactionHash = settleResult.transaction;
928
+ // Final success - show receipt
929
+ await handler.editMessage(channelId, statusMsg.eventId, `✅ **Payment Complete**\n` +
930
+ `/${pending.command} • $${formatUnits(pending.params.value, 6)} USDC\n` +
931
+ `Tx: \`${transactionHash}\``);
932
+ // Delete the signature request now that payment is complete
933
+ await handler.removeEvent(channelId, pending.interactionEventId);
934
+ // Execute the original command handler (stored with __paid_ prefix)
935
+ const actualHandlerKey = `__paid_${pending.command}`;
936
+ const originalHandler = this.slashCommandHandlers.get(actualHandlerKey);
937
+ if (originalHandler) {
938
+ await originalHandler(this.client, pending.event);
939
+ }
940
+ }
941
+ catch (error) {
942
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
943
+ if (settlementCompleted) {
944
+ // Payment succeeded but command handler failed - DO NOT suggest retry
945
+ await handler.editMessage(channelId, statusMsg.eventId, `⚠️ **Payment succeeded but command failed**\n` +
946
+ `Your payment of $${formatUnits(pending.params.value, 6)} USDC was processed.\n` +
947
+ `Tx: \`${transactionHash}\`\n\n` +
948
+ `Error: ${errorMessage}\n` +
949
+ `Please contact support - do NOT retry to avoid double charges.`);
950
+ // Don't remove interaction event - payment already processed
951
+ }
952
+ else {
953
+ // Actual payment failure (verify or settle threw)
954
+ await handler.editMessage(channelId, statusMsg.eventId, `❌ Payment failed: ${errorMessage}`);
955
+ await handler.removeEvent(channelId, pending.interactionEventId);
956
+ }
957
+ }
958
+ }
959
+ /**
960
+ * get the public device key of the agent
961
+ * @returns the public device key of the agent
962
+ */
963
+ getUserDevice() {
964
+ return this.client.crypto.getUserDevice();
965
+ }
966
+ /**
967
+ * Send a message to a stream
968
+ * @param streamId - Id of the stream. Usually channelId or userId
969
+ * @param message - The cleartext of the message
970
+ * @param opts - The options for the message
971
+ */
972
+ async sendMessage(streamId, message, opts) {
973
+ const result = await this.client.sendMessage(streamId, message, opts, this.currentMessageTags);
974
+ this.currentMessageTags = undefined;
975
+ return result;
976
+ }
977
+ /**
978
+ * Send a reaction to a stream
979
+ * @param streamId - Id of the stream. Usually channelId or userId
980
+ * @param refEventId - The eventId of the event to react to
981
+ * @param reaction - The reaction to send
982
+ */
983
+ async sendReaction(streamId, refEventId, reaction) {
984
+ const result = await this.client.sendReaction(streamId, refEventId, reaction, this.currentMessageTags);
985
+ this.currentMessageTags = undefined;
986
+ return result;
987
+ }
988
+ /**
989
+ * Remove an specific event from a stream
990
+ * @param streamId - Id of the stream. Usually channelId or userId
991
+ * @param refEventId - The eventId of the event to remove
992
+ */
993
+ async removeEvent(streamId, refEventId) {
994
+ const result = await this.client.removeEvent(streamId, refEventId, this.currentMessageTags);
995
+ this.currentMessageTags = undefined;
996
+ return result;
997
+ }
998
+ /**
999
+ * Add a user (or app) to a stream via user membership action.
1000
+ * Requires the agent to already be a member of the stream.
1001
+ * @param streamId - Id of the stream to join (channel, space, gdm)
1002
+ * @param userId - User ID to add
1003
+ */
1004
+ async joinUser(streamId, userId) {
1005
+ return this.client.sendEvent(makeUserStreamId(this.agentUserId), make_UserPayload_UserMembershipAction({
1006
+ op: MembershipOp.SO_JOIN,
1007
+ userId: addressFromUserId(userId),
1008
+ streamId: streamIdAsBytes(streamId),
1009
+ }));
1010
+ }
1011
+ /**
1012
+ * Edit an specific message from a stream
1013
+ * @param streamId - Id of the stream. Usually channelId or userId
1014
+ * @param messageId - The eventId of the message to edit
1015
+ * @param message - The new message text
1016
+ */
1017
+ async editMessage(streamId, messageId, message, opts) {
1018
+ const result = await this.client.editMessage(streamId, messageId, message, opts, this.currentMessageTags);
1019
+ this.currentMessageTags = undefined;
1020
+ return result;
1021
+ }
1022
+ /**
1023
+ * Send a GM (generic message) to a stream with schema validation
1024
+ * @param streamId - Id of the stream. Usually channelId or userId
1025
+ * @param typeUrl - The type URL identifying the message format
1026
+ * @param schema - StandardSchema for validation
1027
+ * @param data - Data to validate and send
1028
+ */
1029
+ async sendGM(streamId, typeUrl, schema, data, opts) {
1030
+ const result = await this.client.sendGM(streamId, typeUrl, schema, data, opts, this.currentMessageTags);
1031
+ this.currentMessageTags = undefined;
1032
+ return result;
1033
+ }
1034
+ /**
1035
+ * Send a raw GM (generic message) to a stream without schema validation
1036
+ * @param streamId - Id of the stream. Usually channelId or userId
1037
+ * @param typeUrl - The type URL identifying the message format
1038
+ * @param message - Optional raw message data as bytes
1039
+ * @param opts - The options for the message
1040
+ */
1041
+ async sendRawGM(streamId, typeUrl, message, opts) {
1042
+ const result = await this.client.sendRawGM(streamId, typeUrl, message, opts, this.currentMessageTags);
1043
+ this.currentMessageTags = undefined;
1044
+ return result;
1045
+ }
1046
+ /**
1047
+ * Send a conversation seed to a stream
1048
+ * @param streamId - Id of the stream. Usually channelId
1049
+ * @param seed - The conversation seed payload
1050
+ * @param opts - The options for the message
1051
+ */
1052
+ async sendConversationSeed(streamId, seed, opts) {
1053
+ const result = await this.client.sendConversationSeed(streamId, seed, opts, this.currentMessageTags);
1054
+ this.currentMessageTags = undefined;
1055
+ return result;
1056
+ }
1057
+ // Implementation
1058
+ async sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts) {
1059
+ const tags = this.currentMessageTags;
1060
+ this.currentMessageTags = undefined;
1061
+ if (isFlattenedRequest(contentOrPayload)) {
1062
+ // New flattened format: (streamId, payload, opts?)
1063
+ return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, tags);
1064
+ }
1065
+ else {
1066
+ // Old format: (streamId, content, recipient?, opts?)
1067
+ return this.client.sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, maybeOpts, tags);
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Send an AddMember interaction request to add a user to a GDM
1072
+ * @param streamId - Id of the GDM stream
1073
+ * @param userId - The user ID to add to the GDM
1074
+ * @param opts - Optional message and other options
1075
+ * @returns The eventId and requestId of the interaction request
1076
+ */
1077
+ async sendAddMemberRequest(streamId, userId, opts) {
1078
+ const { message, ...messageOpts } = opts ?? {};
1079
+ return this.sendInteractionRequest(streamId, {
1080
+ type: 'addMember',
1081
+ userId,
1082
+ message,
1083
+ }, messageOpts);
1084
+ }
1085
+ async pinMessage(streamId, eventId, streamEvent) {
1086
+ return this.client.pinMessage(streamId, eventId, streamEvent);
1087
+ }
1088
+ async unpinMessage(streamId, eventId) {
1089
+ return this.client.unpinMessage(streamId, eventId);
1090
+ }
1091
+ /** Sends a tip to a user by looking up their smart account.
1092
+ * Tip will always get funds from the app account balance.
1093
+ * @param params - Tip parameters including userId, amount, messageId, channelId, currency.
1094
+ * @returns The transaction hash and event ID
1095
+ */
1096
+ async sendTip(params) {
1097
+ const result = await this.client.sendTip(params, this.currentMessageTags);
1098
+ this.currentMessageTags = undefined;
1099
+ return result;
1100
+ }
1101
+ /**
1102
+ * Triggered when someone sends a message.
1103
+ * This is triggered for all messages, including direct messages and group messages.
1104
+ */
1105
+ onMessage(fn) {
1106
+ return this.emitter.on('message', fn);
1107
+ }
1108
+ onRedaction(fn) {
1109
+ return this.emitter.on('redaction', fn);
1110
+ }
1111
+ /**
1112
+ * Triggered when a message gets edited
1113
+ */
1114
+ onMessageEdit(fn) {
1115
+ return this.emitter.on('messageEdit', fn);
1116
+ }
1117
+ /**
1118
+ * Triggered when someone reacts to a message
1119
+ */
1120
+ onReaction(fn) {
1121
+ return this.emitter.on('reaction', fn);
1122
+ }
1123
+ /**
1124
+ * Triggered when a message is revoked by a moderator
1125
+ */
1126
+ onEventRevoke(fn) {
1127
+ return this.emitter.on('eventRevoke', fn);
1128
+ }
1129
+ /**
1130
+ * Triggered when someone tips the agent
1131
+ */
1132
+ onTip(fn) {
1133
+ return this.emitter.on('tip', fn);
1134
+ }
1135
+ /**
1136
+ * Triggered when someone joins a channel
1137
+ */
1138
+ onChannelJoin(fn) {
1139
+ return this.emitter.on('channelJoin', fn);
1140
+ }
1141
+ /**
1142
+ * Triggered when someone leaves a channel
1143
+ */
1144
+ onChannelLeave(fn) {
1145
+ return this.emitter.on('channelLeave', fn);
1146
+ }
1147
+ onStreamEvent(fn) {
1148
+ return this.emitter.on('streamEvent', fn);
1149
+ }
1150
+ onSlashCommand(command, fn) {
1151
+ const paymentConfig = this.paymentCommands.get(command);
1152
+ if (!paymentConfig || !this.paymentConfig) {
1153
+ this.slashCommandHandlers.set(command, fn);
1154
+ const unset = () => {
1155
+ if (this.slashCommandHandlers.has(command) &&
1156
+ this.slashCommandHandlers.get(command) === fn) {
1157
+ this.slashCommandHandlers.delete(command);
1158
+ }
1159
+ };
1160
+ return unset;
1161
+ }
1162
+ this.slashCommandHandlers.set(command, async (handler, event) => {
1163
+ try {
1164
+ const chainId = this.viem.chain.id;
1165
+ const { signatureId, params, eventId } = await createPaymentRequest(handler, event, chainId, event.userId, this.appAddress, paymentConfig, command);
1166
+ // Store pending payment
1167
+ this.pendingPayments.set(signatureId, {
1168
+ command: command,
1169
+ channelId: event.channelId,
1170
+ userId: event.userId,
1171
+ interactionEventId: eventId,
1172
+ event: event,
1173
+ params: params,
1174
+ });
1175
+ }
1176
+ catch (error) {
1177
+ await handler.sendMessage(event.channelId, `❌ Failed to request payment: ${error instanceof Error ? error.message : 'Unknown error'}`);
1178
+ }
1179
+ });
1180
+ const actualHandlerKey = `__paid_${command}`;
1181
+ this.slashCommandHandlers.set(actualHandlerKey, fn);
1182
+ const unset = () => {
1183
+ if (this.slashCommandHandlers.has(command) &&
1184
+ this.slashCommandHandlers.get(command) === fn) {
1185
+ this.slashCommandHandlers.delete(command);
1186
+ }
1187
+ };
1188
+ return unset;
1189
+ }
1190
+ onCapability(name, fn) {
1191
+ this.capabilityHandlers.set(name, fn);
1192
+ return () => {
1193
+ if (this.capabilityHandlers.get(name) === fn) {
1194
+ this.capabilityHandlers.delete(name);
1195
+ }
1196
+ };
1197
+ }
1198
+ onPositions(handler) {
1199
+ this.positionsHandler = handler;
1200
+ return () => {
1201
+ if (this.positionsHandler === handler) {
1202
+ this.positionsHandler = undefined;
1203
+ }
1204
+ };
1205
+ }
1206
+ onConciergeRequest(fn) {
1207
+ this.conciergeRequestHandler = fn;
1208
+ return () => {
1209
+ if (this.conciergeRequestHandler === fn) {
1210
+ this.conciergeRequestHandler = undefined;
1211
+ }
1212
+ };
1213
+ }
1214
+ /**
1215
+ * Triggered when someone sends a GM (generic message) with type validation using StandardSchema
1216
+ * @param typeUrl - The type URL to listen for
1217
+ * @param schema - The StandardSchema to validate the message data
1218
+ * @param handler - The handler function to call when a message is received
1219
+ */
1220
+ onGmMessage(typeUrl, schema, handler) {
1221
+ this.gmTypedHandlers.set(typeUrl, { schema, handler: handler });
1222
+ const unset = () => {
1223
+ if (this.gmTypedHandlers.has(typeUrl) &&
1224
+ this.gmTypedHandlers.get(typeUrl)?.handler === handler) {
1225
+ this.gmTypedHandlers.delete(typeUrl);
1226
+ }
1227
+ };
1228
+ return unset;
1229
+ }
1230
+ onRawGmMessage(handler) {
1231
+ return this.emitter.on('rawGmMessage', handler);
1232
+ }
1233
+ /**
1234
+ * Triggered when someone sends an interaction response
1235
+ * @param fn - The handler function to call when an interaction response is received
1236
+ */
1237
+ onInteractionResponse(fn) {
1238
+ return this.emitter.on('interactionResponse', fn);
1239
+ }
1240
+ /**
1241
+ * Triggered when someone sends a conversation seed response
1242
+ */
1243
+ onConversationSeedResponse(fn) {
1244
+ return this.emitter.on('conversationSeedResponse', fn);
1245
+ }
1246
+ /**
1247
+ * Triggered when a user selects a proposal that targets this bot.
1248
+ * Only fires for selectedProposal responses where targetAgentId matches this bot's appAddress.
1249
+ */
1250
+ onSelectedProposal(fn) {
1251
+ return this.emitter.on('selectedProposal', fn);
1252
+ }
1253
+ /**
1254
+ * Get the stream view for a stream
1255
+ * Stream views contain contextual information about the stream (space, channel, etc)
1256
+ * Stream views contain member data for all streams - you can iterate over all members in a channel via: `streamView.getMembers().joined.keys()`
1257
+ * note: potentially expensive operation because streams can be large, fine to use in small streams
1258
+ * @param streamId - The stream ID to get the view for
1259
+ * @returns The stream view
1260
+ */
1261
+ async getStreamView(streamId) {
1262
+ return this.client.getStream(streamId);
1263
+ }
1264
+ /**
1265
+ * Get the ERC-8004 compliant metadata JSON
1266
+ * This should be hosted at /.well-known/agent-metadata.json
1267
+ * Merges cached app metadata with local identity config
1268
+ * @returns The ERC-8004 compliant metadata object or null
1269
+ */
1270
+ async getIdentityMetadata() {
1271
+ if (!this.identityConfig && !this.metadata.displayName)
1272
+ return null;
1273
+ const endpoints = [];
1274
+ if (this.identityConfig?.endpoints) {
1275
+ endpoints.push(...this.identityConfig.endpoints);
1276
+ }
1277
+ const hasAgentWallet = endpoints.some((e) => e.name === 'agentWallet');
1278
+ if (!hasAgentWallet) {
1279
+ const chainId = this.viem.chain.id;
1280
+ endpoints.push({
1281
+ name: 'agentWallet',
1282
+ endpoint: `eip155:${chainId}:${this.appAddress}`,
1283
+ });
1284
+ }
1285
+ const domain = this.identityConfig?.domain;
1286
+ if (domain && !endpoints.some((e) => e.name === 'A2A')) {
1287
+ const origin = domain.startsWith('http') ? domain : `https://${domain}`;
1288
+ endpoints.push({
1289
+ name: 'A2A',
1290
+ endpoint: `${origin}/.well-known/agent-card.json`,
1291
+ version: '0.3.0',
1292
+ });
1293
+ }
1294
+ // Merge app metadata with identity config, preferring identity config
1295
+ const name = this.identityConfig?.name || this.metadata.displayName || 'Unknown Agent';
1296
+ const description = this.identityConfig?.description || this.metadata.description || '';
1297
+ const image = this.identityConfig?.image || this.metadata.imageUrl || '';
1298
+ const motto = this.identityConfig?.motto || this.metadata.motto;
1299
+ return {
1300
+ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
1301
+ name,
1302
+ description,
1303
+ image,
1304
+ endpoints,
1305
+ registrations: this.identityConfig?.registrations || [],
1306
+ supportedTrust: this.identityConfig?.supportedTrust,
1307
+ motto,
1308
+ capabilities: [
1309
+ ...(this.commands?.map((c) => c.name) || []),
1310
+ ...(this.capabilities?.map((c) => c.name) || []),
1311
+ ],
1312
+ version: packageJson.version,
1313
+ framework: `javascript:${packageJson.name}:${packageJson.version}`,
1314
+ attributes: this.identityConfig?.attributes,
1315
+ };
1316
+ }
1317
+ /**
1318
+ * Get the tokenURI that would be used for ERC-8004 registration
1319
+ * Returns null if no domain is configured
1320
+ * @returns The .well-known URL or null
1321
+ */
1322
+ getTokenURI() {
1323
+ if (!this.identityConfig?.domain)
1324
+ return null;
1325
+ const origin = this.identityConfig.domain.startsWith('http')
1326
+ ? this.identityConfig.domain
1327
+ : `https://${this.identityConfig.domain}`;
1328
+ return `${origin}/.well-known/agent-metadata.json`;
1329
+ }
1330
+ }
1331
+ export const makeTownsApp = async (appPrivateData, opts = {}) => {
1332
+ const { baseRpcUrl, jwtSecret: jwtSecretBase64, ...clientOpts } = opts;
1333
+ let appAddress;
1334
+ const { privateKey, encryptionDevice, env, appAddress: appAddressFromPrivateData, jwtSecret: jwtSecretFromPrivateData, } = parseAppPrivateData(appPrivateData);
1335
+ const resolvedJwtSecret = jwtSecretBase64 || process.env.JWT_SECRET || jwtSecretFromPrivateData;
1336
+ if (!resolvedJwtSecret) {
1337
+ throw new Error('JWT secret is required: provide it via opts.jwtSecret or the JWT_SECRET environment variable');
1338
+ }
1339
+ if (!env) {
1340
+ throw new Error('Failed to parse APP_PRIVATE_DATA');
1341
+ }
1342
+ if (appAddressFromPrivateData) {
1343
+ appAddress = appAddressFromPrivateData;
1344
+ }
1345
+ const account = privateKeyToAccount(privateKey);
1346
+ const baseConfig = townsEnv().makeBaseChainConfig(env);
1347
+ const getChain = (chainId) => {
1348
+ if (chainId === base.id)
1349
+ return base;
1350
+ if (chainId === foundry.id)
1351
+ return foundry;
1352
+ return baseSepolia;
1353
+ };
1354
+ const chain = getChain(baseConfig.chainConfig.chainId);
1355
+ const viem = createWalletClient({
1356
+ account,
1357
+ transport: baseRpcUrl
1358
+ ? http(baseRpcUrl, { batch: true })
1359
+ : http(baseConfig.rpcUrl, { batch: true }),
1360
+ chain,
1361
+ });
1362
+ if (!appAddress) {
1363
+ appAddress = await readContract(viem, {
1364
+ address: baseConfig.chainConfig.addresses.appRegistry,
1365
+ abi: appRegistryAbi,
1366
+ functionName: 'getAppByClient',
1367
+ args: [account.address],
1368
+ });
1369
+ }
1370
+ const client = await createTownsClient({
1371
+ privateKey,
1372
+ env,
1373
+ encryptionDevice: {
1374
+ fromExportedDevice: encryptionDevice,
1375
+ },
1376
+ ...clientOpts,
1377
+ }).then((x) => x.extend((townsClient) => buildAgentActions(townsClient, viem, appAddress)));
1378
+ {
1379
+ const updateMask = [];
1380
+ const metadata = {};
1381
+ if (opts.commands) {
1382
+ updateMask.push('slash_commands');
1383
+ metadata.slashCommands = opts.commands;
1384
+ }
1385
+ if (opts.capabilities) {
1386
+ updateMask.push('capabilities');
1387
+ metadata.capabilities = opts.capabilities.map((cap) => ({
1388
+ name: cap.name,
1389
+ description: cap.description,
1390
+ inputSchema: extractJsonSchema(cap.input),
1391
+ examples: cap.examples?.map((ex) => ({
1392
+ userQuery: ex.userQuery,
1393
+ parameters: ex.parameters,
1394
+ })) ?? [],
1395
+ }));
1396
+ }
1397
+ if (updateMask.length > 0) {
1398
+ client
1399
+ .appServiceClient()
1400
+ .then((appRegistryClient) => appRegistryClient
1401
+ .updateAppMetadata({
1402
+ appId: bin_fromHexString(account.address),
1403
+ updateMask,
1404
+ metadata,
1405
+ })
1406
+ .catch((err) => {
1407
+ // eslint-disable-next-line no-console
1408
+ console.warn('[@towns-labs/app-framework] failed to update app metadata', err);
1409
+ }))
1410
+ .catch((err) => {
1411
+ // eslint-disable-next-line no-console
1412
+ console.warn('[@towns-labs/app-framework] failed to get app registry rpc client', err);
1413
+ });
1414
+ }
1415
+ }
1416
+ await client.uploadDeviceKeys();
1417
+ let appMetadata;
1418
+ try {
1419
+ const appRegistry = await client.appServiceClient();
1420
+ const response = await appRegistry.getAppMetadata({
1421
+ appId: bin_fromHexString(account.address),
1422
+ });
1423
+ appMetadata = response.metadata;
1424
+ }
1425
+ catch (err) {
1426
+ // eslint-disable-next-line no-console
1427
+ console.warn('[@towns-labs/app-framework] Failed to fetch app metadata', err);
1428
+ }
1429
+ return new App(client, viem, resolvedJwtSecret, appAddress, opts.commands, opts.capabilities, opts.identity, opts.dedup, opts.paymentConfig, appMetadata);
1430
+ };
1431
+ function extractJsonSchema(schema) {
1432
+ try {
1433
+ return JSON.stringify(schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }));
1434
+ }
1435
+ catch (error) {
1436
+ try {
1437
+ return JSON.stringify(schema['~standard'].jsonSchema.input({ target: 'draft-07' }));
1438
+ }
1439
+ catch (fallbackError) {
1440
+ throw new Error('extractJsonSchema: schema["~standard"].jsonSchema.input() failed for both draft-2020-12 and draft-07 targets', { cause: { draft202012Error: error, draft07Error: fallbackError } });
1441
+ }
1442
+ }
1443
+ }
1444
+ const buildAgentActions = (client, viem, appAddress) => {
1445
+ const CHUNK_SIZE = 1200000; // 1.2MB max per chunk (including auth tag)
1446
+ const createChunkedMediaAttachment = async (attachment) => {
1447
+ let data;
1448
+ let mimetype;
1449
+ if (attachment.data instanceof Blob) {
1450
+ const buffer = await attachment.data.arrayBuffer();
1451
+ data = new Uint8Array(buffer);
1452
+ mimetype = attachment.data.type;
1453
+ }
1454
+ else {
1455
+ data = attachment.data;
1456
+ if ('mimetype' in attachment) {
1457
+ mimetype = attachment.mimetype;
1458
+ }
1459
+ else {
1460
+ throw new Error('mimetype is required for Uint8Array data');
1461
+ }
1462
+ }
1463
+ let width = attachment.width || 0;
1464
+ let height = attachment.height || 0;
1465
+ if (mimetype.startsWith('image/') && (!width || !height)) {
1466
+ const dimensions = imageSize(data);
1467
+ width = dimensions.width || 0;
1468
+ height = dimensions.height || 0;
1469
+ }
1470
+ const { chunks, secretKey } = await encryptChunkedAESGCM(data, CHUNK_SIZE);
1471
+ const chunkCount = chunks.length;
1472
+ if (chunkCount === 0) {
1473
+ throw new Error('No media chunks generated');
1474
+ }
1475
+ // TODO: Implement thumbnail generation with sharp
1476
+ const thumbnail = undefined;
1477
+ const streamId = makeUniqueMediaStreamId();
1478
+ const events = await Promise.all([
1479
+ makeEvent(client.signerContext, make_MediaPayload_Inception({
1480
+ streamId: streamIdAsBytes(streamId),
1481
+ userId: addressFromUserId(client.userId),
1482
+ chunkCount,
1483
+ perChunkEncryption: true,
1484
+ })),
1485
+ makeEvent(client.signerContext, make_MediaPayload_Chunk({
1486
+ data: chunks[0].ciphertext,
1487
+ chunkIndex: 0,
1488
+ iv: chunks[0].iv,
1489
+ })),
1490
+ ]);
1491
+ const mediaStreamResponse = await client.rpc.createMediaStream({
1492
+ events,
1493
+ streamId: streamIdAsBytes(streamId),
1494
+ });
1495
+ if (!mediaStreamResponse?.nextCreationCookie) {
1496
+ throw new Error('Failed to create media stream');
1497
+ }
1498
+ if (chunkCount > 1) {
1499
+ let cc = create(CreationCookieSchema, mediaStreamResponse.nextCreationCookie);
1500
+ for (let chunkIndex = 1; chunkIndex < chunkCount; chunkIndex++) {
1501
+ const chunkEvent = await makeEvent(client.signerContext, make_MediaPayload_Chunk({
1502
+ data: chunks[chunkIndex].ciphertext,
1503
+ chunkIndex: chunkIndex,
1504
+ iv: chunks[chunkIndex].iv,
1505
+ }), cc.prevMiniblockHash);
1506
+ const result = await client.rpc.addMediaEvent({
1507
+ event: chunkEvent,
1508
+ creationCookie: cc,
1509
+ last: chunkIndex === chunkCount - 1,
1510
+ });
1511
+ if (!result?.creationCookie) {
1512
+ throw new Error('Failed to send media chunk');
1513
+ }
1514
+ cc = create(CreationCookieSchema, result.creationCookie);
1515
+ }
1516
+ }
1517
+ const mediaStreamInfo = { creationCookie: mediaStreamResponse.nextCreationCookie };
1518
+ return {
1519
+ content: {
1520
+ case: 'chunkedMedia',
1521
+ value: create(ChunkedMediaSchema, {
1522
+ info: {
1523
+ filename: attachment.filename,
1524
+ mimetype: mimetype,
1525
+ widthPixels: width,
1526
+ heightPixels: height,
1527
+ sizeBytes: BigInt(data.length),
1528
+ },
1529
+ streamId: streamIdAsString(mediaStreamInfo.creationCookie.streamId),
1530
+ encryption: {
1531
+ case: 'aesgcm',
1532
+ value: {
1533
+ iv: new Uint8Array(0),
1534
+ secretKey: secretKey,
1535
+ },
1536
+ },
1537
+ thumbnail,
1538
+ }),
1539
+ },
1540
+ };
1541
+ };
1542
+ const createImageAttachmentFromURL = async (attachment) => {
1543
+ try {
1544
+ const response = await fetch(attachment.url);
1545
+ if (!response.ok) {
1546
+ return null;
1547
+ }
1548
+ const contentType = response.headers.get('content-type');
1549
+ if (!contentType || !contentType.startsWith('image/')) {
1550
+ // eslint-disable-next-line no-console
1551
+ console.warn(`A non-image URL attachment was provided. ${attachment.url} (Content-Type: ${contentType || 'unknown'})`);
1552
+ return null;
1553
+ }
1554
+ const bytes = await response.bytes();
1555
+ const dimensions = imageSize(bytes);
1556
+ const width = dimensions.width || 0;
1557
+ const height = dimensions.height || 0;
1558
+ const image = create(ChannelMessage_Post_Content_ImageSchema, {
1559
+ title: attachment.alt || '',
1560
+ info: create(ChannelMessage_Post_Content_Image_InfoSchema, {
1561
+ url: attachment.url,
1562
+ mimetype: contentType,
1563
+ width,
1564
+ height,
1565
+ }),
1566
+ });
1567
+ return {
1568
+ content: {
1569
+ case: 'image',
1570
+ value: image,
1571
+ },
1572
+ };
1573
+ }
1574
+ catch {
1575
+ return null;
1576
+ }
1577
+ };
1578
+ const createLinkAttachment = (attachment) => {
1579
+ return create(ChannelMessage_Post_AttachmentSchema, {
1580
+ content: {
1581
+ case: 'unfurledUrl',
1582
+ value: {
1583
+ url: attachment.url,
1584
+ image: attachment.image,
1585
+ title: attachment.title ?? '',
1586
+ description: attachment.description ?? '',
1587
+ },
1588
+ },
1589
+ });
1590
+ };
1591
+ const createTickerAttachment = (attachment) => {
1592
+ return create(ChannelMessage_Post_AttachmentSchema, {
1593
+ content: {
1594
+ case: 'ticker',
1595
+ value: {
1596
+ address: attachment.address,
1597
+ chainId: attachment.chainId,
1598
+ },
1599
+ },
1600
+ });
1601
+ };
1602
+ const createMiniAppAttachment = (attachment) => {
1603
+ return create(ChannelMessage_Post_AttachmentSchema, {
1604
+ content: {
1605
+ case: 'miniapp',
1606
+ value: { url: attachment.url },
1607
+ },
1608
+ });
1609
+ };
1610
+ const mapIcon = (icon) => {
1611
+ if (!icon)
1612
+ return undefined;
1613
+ switch (icon) {
1614
+ case 'check':
1615
+ return ChannelMessage_Post_Content_InfoCard_Label_Icon.CHECK;
1616
+ case 'x':
1617
+ return ChannelMessage_Post_Content_InfoCard_Label_Icon.X;
1618
+ case 'warning':
1619
+ return ChannelMessage_Post_Content_InfoCard_Label_Icon.WARNING;
1620
+ case 'info':
1621
+ return ChannelMessage_Post_Content_InfoCard_Label_Icon.INFO;
1622
+ }
1623
+ };
1624
+ const mapBadgeVariant = (variant) => {
1625
+ if (!variant)
1626
+ return undefined;
1627
+ switch (variant) {
1628
+ case 'positive':
1629
+ return ChannelMessage_Post_Content_InfoCard_Label_Badge_Variant.POSITIVE;
1630
+ case 'negative':
1631
+ return ChannelMessage_Post_Content_InfoCard_Label_Badge_Variant.NEGATIVE;
1632
+ }
1633
+ };
1634
+ const createInfoCardAttachment = (attachment) => {
1635
+ const title = create(ChannelMessage_Post_Content_InfoCard_LabelSchema, {
1636
+ icon: mapIcon(attachment.title.icon),
1637
+ text: attachment.title.text,
1638
+ badge: attachment.title.badge
1639
+ ? {
1640
+ text: attachment.title.badge.text,
1641
+ variant: mapBadgeVariant(attachment.title.badge.variant),
1642
+ }
1643
+ : undefined,
1644
+ });
1645
+ const fields = attachment.fields.map((field) => {
1646
+ let value;
1647
+ switch (field.value.type) {
1648
+ case 'text':
1649
+ value = {
1650
+ case: 'text',
1651
+ value: create(ChannelMessage_Post_Content_InfoCard_LabelSchema, {
1652
+ icon: mapIcon(field.value.icon),
1653
+ text: field.value.text,
1654
+ badge: field.value.badge
1655
+ ? {
1656
+ text: field.value.badge.text,
1657
+ variant: mapBadgeVariant(field.value.badge.variant),
1658
+ }
1659
+ : undefined,
1660
+ }),
1661
+ };
1662
+ break;
1663
+ case 'user':
1664
+ value = {
1665
+ case: 'user',
1666
+ value: create(ChannelMessage_Post_Content_InfoCard_UserRefSchema, {
1667
+ userId: field.value.userId,
1668
+ }),
1669
+ };
1670
+ break;
1671
+ case 'token':
1672
+ value = {
1673
+ case: 'token',
1674
+ value: create(ChannelMessage_Post_Content_InfoCard_TokenAmountSchema, {
1675
+ chainId: field.value.chainId,
1676
+ address: field.value.address,
1677
+ amount: field.value.amount,
1678
+ }),
1679
+ };
1680
+ break;
1681
+ case 'contract':
1682
+ value = {
1683
+ case: 'contract',
1684
+ value: create(ChannelMessage_Post_Content_InfoCard_ContractRefSchema, {
1685
+ chainId: field.value.chainId,
1686
+ address: field.value.address,
1687
+ }),
1688
+ };
1689
+ break;
1690
+ case 'marketItem':
1691
+ value = {
1692
+ case: 'marketItem',
1693
+ value: create(ChannelMessage_Post_Content_InfoCard_MarketItemSchema, {
1694
+ name: field.value.name,
1695
+ price: field.value.price,
1696
+ iconUrl: field.value.iconUrl,
1697
+ subtitle: field.value.subtitle,
1698
+ change: field.value.change,
1699
+ changePct: field.value.changePct,
1700
+ direction: mapBadgeVariant(field.value.direction),
1701
+ outcomes: field.value.outcomes?.map((o) => create(ChannelMessage_Post_Content_InfoCard_MarketItem_OutcomeSchema, {
1702
+ label: o.label,
1703
+ value: o.value,
1704
+ variant: mapBadgeVariant(o.variant),
1705
+ })) ?? [],
1706
+ }),
1707
+ };
1708
+ break;
1709
+ }
1710
+ return create(ChannelMessage_Post_Content_InfoCard_FieldSchema, {
1711
+ label: field.label,
1712
+ value,
1713
+ });
1714
+ });
1715
+ return create(ChannelMessage_Post_AttachmentSchema, {
1716
+ content: {
1717
+ case: 'infoCard',
1718
+ value: create(ChannelMessage_Post_Content_InfoCardSchema, {
1719
+ title,
1720
+ fields,
1721
+ }),
1722
+ },
1723
+ });
1724
+ };
1725
+ const ensureOutboundSession = async (streamId, encryptionAlgorithm, toUserIds, miniblockInfo) => {
1726
+ if (!(await client.crypto.hasOutboundSession(streamId, encryptionAlgorithm))) {
1727
+ // ATTEMPT 1: Get session from app service
1728
+ const appService = await client.appServiceClient();
1729
+ try {
1730
+ const sessionResp = await appService.getSession({
1731
+ appId: userIdToAddress(client.userId),
1732
+ identifier: {
1733
+ case: 'streamId',
1734
+ value: streamIdAsBytes(streamId),
1735
+ },
1736
+ });
1737
+ if (sessionResp.groupEncryptionSessions) {
1738
+ const parsedEvent = await unpackEnvelope(sessionResp.groupEncryptionSessions, client.unpackEnvelopeOpts);
1739
+ check(parsedEvent.event.payload.case === 'userInboxPayload' &&
1740
+ parsedEvent.event.payload.value.content.case ===
1741
+ 'groupEncryptionSessions', 'invalid event payload');
1742
+ await client.importGroupEncryptionSessions({
1743
+ streamId,
1744
+ sessions: parsedEvent.event.payload.value.content.value,
1745
+ });
1746
+ // EARLY RETURN
1747
+ return;
1748
+ }
1749
+ }
1750
+ catch {
1751
+ // ignore error (should log)
1752
+ }
1753
+ // ATTEMPT 2: Create new session
1754
+ await client.crypto.ensureOutboundSession(streamId, encryptionAlgorithm, {
1755
+ shareShareSessionTimeoutMs: 5000,
1756
+ priorityUserIds: [client.userId, ...toUserIds],
1757
+ miniblockInfo,
1758
+ });
1759
+ }
1760
+ };
1761
+ const sendMessageEvent = async ({ streamId, payload, tags, ephemeral, }) => {
1762
+ const miniblockInfo = await client.getMiniblockInfo(streamId);
1763
+ const eventTags = {
1764
+ ...unsafe_makeTags(payload),
1765
+ participatingUserAddresses: tags?.participatingUserAddresses || [],
1766
+ threadId: tags?.threadId || undefined,
1767
+ };
1768
+ const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
1769
+ ? miniblockInfo.encryptionAlgorithm.algorithm
1770
+ : client.defaultGroupEncryptionAlgorithm;
1771
+ await ensureOutboundSession(streamId, encryptionAlgorithm, Array.from(new Set([
1772
+ ...eventTags.participatingUserAddresses.map((x) => userIdFromAddress(x)),
1773
+ ...eventTags.mentionedUserAddresses.map((x) => userIdFromAddress(x)),
1774
+ ])), miniblockInfo);
1775
+ const message = await client.crypto.encryptGroupEvent(streamId, toBinary(ChannelMessageSchema, payload), encryptionAlgorithm);
1776
+ message.refEventId = getRefEventIdFromChannelMessage(payload);
1777
+ if (!isGDMChannelStreamId(streamId)) {
1778
+ throw new Error(`Invalid stream ID type for channel message: ${streamId}`);
1779
+ }
1780
+ const eventPayload = make_GDMChannelPayload_Message(message);
1781
+ return client.sendEvent(streamId, eventPayload, eventTags, ephemeral);
1782
+ };
1783
+ const sendKeySolicitation = async (streamId, sessionIds) => {
1784
+ const encryptionDevice = client.crypto.getUserDevice();
1785
+ const missingSessionIds = sessionIds.filter((sessionId) => sessionId !== '');
1786
+ return client.sendEvent(streamId, make_MemberPayload_KeySolicitation({
1787
+ deviceKey: encryptionDevice.deviceKey,
1788
+ fallbackKey: encryptionDevice.fallbackKey,
1789
+ isNewDevice: missingSessionIds.length === 0,
1790
+ sessionIds: missingSessionIds,
1791
+ }));
1792
+ };
1793
+ const uploadDeviceKeys = async () => {
1794
+ const streamId = makeUserMetadataStreamId(client.userId);
1795
+ const encryptionDevice = client.crypto.getUserDevice();
1796
+ return client.sendEvent(streamId, make_UserMetadataPayload_EncryptionDevice({
1797
+ ...encryptionDevice,
1798
+ }));
1799
+ };
1800
+ const sendMessage = async (streamId, message, opts, tags) => {
1801
+ const processedAttachments = [];
1802
+ if (opts?.attachments && opts.attachments.length > 0) {
1803
+ for (const attachment of opts.attachments) {
1804
+ switch (attachment.type) {
1805
+ case 'image': {
1806
+ const result = await createImageAttachmentFromURL(attachment);
1807
+ processedAttachments.push(result);
1808
+ break;
1809
+ }
1810
+ case 'chunked': {
1811
+ const result = await createChunkedMediaAttachment(attachment);
1812
+ processedAttachments.push(result);
1813
+ break;
1814
+ }
1815
+ case 'link': {
1816
+ const result = createLinkAttachment(attachment);
1817
+ processedAttachments.push(result);
1818
+ break;
1819
+ }
1820
+ case 'ticker': {
1821
+ const result = createTickerAttachment(attachment);
1822
+ processedAttachments.push(result);
1823
+ break;
1824
+ }
1825
+ case 'miniapp': {
1826
+ const result = createMiniAppAttachment(attachment);
1827
+ processedAttachments.push(result);
1828
+ break;
1829
+ }
1830
+ case 'infoCard': {
1831
+ const result = createInfoCardAttachment(attachment);
1832
+ processedAttachments.push(result);
1833
+ break;
1834
+ }
1835
+ default:
1836
+ logNever(attachment);
1837
+ }
1838
+ }
1839
+ }
1840
+ const payload = create(ChannelMessageSchema, {
1841
+ payload: {
1842
+ case: 'post',
1843
+ value: {
1844
+ threadId: opts?.threadId,
1845
+ replyId: opts?.replyId,
1846
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1847
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1848
+ content: {
1849
+ case: 'text',
1850
+ value: {
1851
+ body: message,
1852
+ attachments: processedAttachments.filter((x) => x !== null),
1853
+ mentions: processMentions(opts?.mentions),
1854
+ },
1855
+ },
1856
+ },
1857
+ },
1858
+ });
1859
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1860
+ };
1861
+ const editMessage = async (streamId, messageId, message, opts, tags) => {
1862
+ const processedAttachments = [];
1863
+ if (opts?.attachments && opts.attachments.length > 0) {
1864
+ for (const attachment of opts.attachments) {
1865
+ switch (attachment.type) {
1866
+ case 'image': {
1867
+ const result = await createImageAttachmentFromURL(attachment);
1868
+ processedAttachments.push(result);
1869
+ break;
1870
+ }
1871
+ case 'chunked': {
1872
+ const result = await createChunkedMediaAttachment(attachment);
1873
+ processedAttachments.push(result);
1874
+ break;
1875
+ }
1876
+ case 'link': {
1877
+ const result = createLinkAttachment(attachment);
1878
+ processedAttachments.push(result);
1879
+ break;
1880
+ }
1881
+ case 'ticker': {
1882
+ const result = createTickerAttachment(attachment);
1883
+ processedAttachments.push(result);
1884
+ break;
1885
+ }
1886
+ case 'miniapp': {
1887
+ const result = createMiniAppAttachment(attachment);
1888
+ processedAttachments.push(result);
1889
+ break;
1890
+ }
1891
+ case 'infoCard': {
1892
+ const result = createInfoCardAttachment(attachment);
1893
+ processedAttachments.push(result);
1894
+ break;
1895
+ }
1896
+ default:
1897
+ logNever(attachment);
1898
+ }
1899
+ }
1900
+ }
1901
+ const payload = create(ChannelMessageSchema, {
1902
+ payload: {
1903
+ case: 'edit',
1904
+ value: {
1905
+ refEventId: messageId,
1906
+ post: {
1907
+ threadId: opts?.threadId,
1908
+ replyId: opts?.replyId,
1909
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1910
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1911
+ content: {
1912
+ case: 'text',
1913
+ value: {
1914
+ body: message,
1915
+ mentions: processMentions(opts?.mentions),
1916
+ attachments: processedAttachments.filter((x) => x !== null),
1917
+ },
1918
+ },
1919
+ },
1920
+ },
1921
+ },
1922
+ });
1923
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1924
+ };
1925
+ const sendReaction = async (streamId, messageId, reaction, tags) => {
1926
+ const payload = create(ChannelMessageSchema, {
1927
+ payload: { case: 'reaction', value: { refEventId: messageId, reaction } },
1928
+ });
1929
+ return sendMessageEvent({ streamId, payload, tags });
1930
+ };
1931
+ /**
1932
+ * Used to send a typed message into a channel stream.
1933
+ * The message will be serialized to JSON using superjson and then encoded to bytes.
1934
+ * Clients can agree on the schema to deserialize the message by the typeUrl.
1935
+ * @param streamId - The stream ID to send the message to.
1936
+ * @param typeUrl - A schema type URL for the message
1937
+ * @param message - The message to send as raw bytes.
1938
+ * @param tags - The tags to send with the message.
1939
+ * @returns The event ID of the sent message.
1940
+ */
1941
+ async function sendGM(streamId, typeUrl, schema, data, opts, tags) {
1942
+ const result = await schema['~standard'].validate(data);
1943
+ if ('issues' in result && result.issues) {
1944
+ throw new Error(`Schema validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`);
1945
+ }
1946
+ const jsonString = superjsonStringify(result.value);
1947
+ const jsonBytesMessage = new TextEncoder().encode(jsonString);
1948
+ const payload = create(ChannelMessageSchema, {
1949
+ payload: {
1950
+ case: 'post',
1951
+ value: {
1952
+ threadId: opts?.threadId,
1953
+ replyId: opts?.replyId,
1954
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1955
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1956
+ content: { case: 'gm', value: { typeUrl: typeUrl, value: jsonBytesMessage } },
1957
+ },
1958
+ },
1959
+ });
1960
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1961
+ }
1962
+ /**
1963
+ * Used to send a custom message into a channel stream.
1964
+ * The messages will be a raw bytes.
1965
+ * Clients can agree on the schema to deserialize the message by the typeUrl.
1966
+ * @param streamId - The stream ID to send the message to.
1967
+ * @param typeUrl - A schema type URL for the message
1968
+ * @param message - The message to send as raw bytes.
1969
+ * @param tags - The tags to send with the message.
1970
+ * @returns The event ID of the sent message.
1971
+ */
1972
+ const sendRawGM = async (streamId, typeUrl, message, opts, tags) => {
1973
+ const payload = create(ChannelMessageSchema, {
1974
+ payload: {
1975
+ case: 'post',
1976
+ value: {
1977
+ threadId: opts?.threadId,
1978
+ replyId: opts?.replyId,
1979
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1980
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1981
+ content: { case: 'gm', value: { typeUrl: typeUrl, value: message } },
1982
+ },
1983
+ },
1984
+ });
1985
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
1986
+ };
1987
+ const sendConversationSeed = async (streamId, seed, opts, tags) => {
1988
+ const payload = create(ChannelMessageSchema, {
1989
+ payload: {
1990
+ case: 'post',
1991
+ value: {
1992
+ threadId: opts?.threadId,
1993
+ replyId: opts?.replyId,
1994
+ replyPreview: opts?.replyId ? '🙈' : undefined,
1995
+ threadPreview: opts?.threadId ? '🙉' : undefined,
1996
+ content: { case: 'conversationSeed', value: seed },
1997
+ },
1998
+ },
1999
+ });
2000
+ return sendMessageEvent({ streamId, payload, tags, ephemeral: opts?.ephemeral });
2001
+ };
2002
+ const removeEvent = async (streamId, messageId, tags) => {
2003
+ const payload = create(ChannelMessageSchema, {
2004
+ payload: { case: 'redaction', value: { refEventId: messageId } },
2005
+ });
2006
+ return sendMessageEvent({ streamId, payload, tags });
2007
+ };
2008
+ /**
2009
+ * Pin a message to a stream
2010
+ * @param streamId - The stream ID to pin the message to
2011
+ * @param eventId - The event ID of the message to pin
2012
+ * @param streamEvent - The stream event to pin
2013
+ * @returns The event ID of the pinned message
2014
+ */
2015
+ const pinMessage = async (streamId, eventId, streamEvent) => {
2016
+ return client.sendEvent(streamId, make_MemberPayload_Pin(bin_fromHexString(eventId), streamEvent));
2017
+ };
2018
+ /**
2019
+ * Unpin a message from a stream
2020
+ * @param streamId - The stream ID to unpin the message from
2021
+ * @param eventId - The event ID of the message to unpin
2022
+ * @returns The event ID of the unpinned message
2023
+ */
2024
+ const unpinMessage = async (streamId, eventId) => {
2025
+ return client.sendEvent(streamId, make_MemberPayload_Unpin(bin_fromHexString(eventId)));
2026
+ };
2027
+ const getChannelSettings = async (_channelId) => {
2028
+ throw new Error('Channel settings are not supported for GDM streams');
2029
+ };
2030
+ // Implementation
2031
+ async function sendInteractionRequest(streamId, contentOrPayload, recipientOrOpts, optsOrTags, maybeTags) {
2032
+ // Detect which format is being used
2033
+ let content;
2034
+ let recipient;
2035
+ let opts;
2036
+ let tags;
2037
+ let requestId;
2038
+ if (isFlattenedRequest(contentOrPayload)) {
2039
+ // New flattened format
2040
+ const result = flattenedToPayloadContent(contentOrPayload);
2041
+ content = result.content;
2042
+ requestId = result.requestId;
2043
+ recipient = contentOrPayload.recipient
2044
+ ? bin_fromHexString(contentOrPayload.recipient)
2045
+ : undefined;
2046
+ opts = recipientOrOpts;
2047
+ tags = optsOrTags;
2048
+ }
2049
+ else {
2050
+ // Old format
2051
+ content = contentOrPayload;
2052
+ requestId = content.value?.id ?? crypto.randomUUID();
2053
+ if (content.value && content.value.id !== requestId) {
2054
+ content.value.id = requestId;
2055
+ }
2056
+ recipient = recipientOrOpts;
2057
+ opts = optsOrTags;
2058
+ tags = maybeTags;
2059
+ }
2060
+ // Get encryption settings (same as sendMessageEvent)
2061
+ const miniblockInfo = await client.getMiniblockInfo(streamId);
2062
+ const encryptionAlgorithm = miniblockInfo.encryptionAlgorithm?.algorithm
2063
+ ? miniblockInfo.encryptionAlgorithm.algorithm
2064
+ : client.defaultGroupEncryptionAlgorithm;
2065
+ await ensureOutboundSession(streamId, encryptionAlgorithm, recipient ? [userIdFromAddress(recipient)] : [], miniblockInfo);
2066
+ // Create payload with content and encryption device for response
2067
+ const payload = create(InteractionRequestPayloadSchema, {
2068
+ encryptionDevice: client.crypto.getUserDevice(),
2069
+ content: content,
2070
+ });
2071
+ // Encrypt using group encryption (same as messages)
2072
+ const encryptedData = await client.crypto.encryptGroupEvent(streamId, toBinary(InteractionRequestPayloadSchema, payload), encryptionAlgorithm);
2073
+ // Create the request matching InteractionResponse structure
2074
+ const request = {
2075
+ recipient: recipient,
2076
+ encryptedData: encryptedData,
2077
+ threadId: opts?.threadId ? bin_fromHexString(opts.threadId) : undefined,
2078
+ };
2079
+ // Send as InteractionRequest
2080
+ const eventPayload = make_payload_InteractionRequest(streamId, request);
2081
+ const { eventId } = await client.sendEvent(streamId, eventPayload, tags, opts?.ephemeral);
2082
+ return { eventId, requestId };
2083
+ }
2084
+ /**
2085
+ * Send a blockchain transaction to the stream
2086
+ * @param streamId - The stream ID to send the transaction to
2087
+ * @param chainId - The chain ID where the transaction occurred
2088
+ * @param receipt - The transaction receipt from the blockchain
2089
+ * @param content - The transaction content (tip, transfer, etc.)
2090
+ * @returns The transaction hash and event ID
2091
+ */
2092
+ const sendBlockchainTransaction = async (chainId, receipt, content, tags) => {
2093
+ const transaction = create(BlockchainTransactionSchema, {
2094
+ receipt: {
2095
+ chainId: BigInt(chainId),
2096
+ transactionHash: bin_fromHexString(receipt.transactionHash),
2097
+ blockNumber: receipt.blockNumber,
2098
+ to: bin_fromHexString(receipt.to || zeroAddress),
2099
+ from: bin_fromHexString(receipt.from),
2100
+ logs: receipt.logs.map((log) => ({
2101
+ address: bin_fromHexString(log.address),
2102
+ topics: log.topics.map((topic) => bin_fromHexString(topic)),
2103
+ data: bin_fromHexString(log.data),
2104
+ })),
2105
+ },
2106
+ solanaReceipt: undefined,
2107
+ content: content ?? { case: undefined },
2108
+ });
2109
+ const result = await client.sendEvent(makeUserStreamId(client.userId), make_UserPayload_BlockchainTransaction(transaction), tags);
2110
+ return { txHash: receipt.transactionHash, eventId: result.eventId };
2111
+ };
2112
+ /** Sends a tip to a user.
2113
+ * Tip will always get funds from the app account balance.
2114
+ * @param params - Tip parameters including recipient, amount, messageId, channelId, currency.
2115
+ * @returns The transaction hash and event ID
2116
+ */
2117
+ const sendTipImpl = async (params, tags) => {
2118
+ const currency = params.currency ?? ETH_ADDRESS;
2119
+ const isEth = currency === ETH_ADDRESS;
2120
+ const { receiver, amount, messageId, channelId } = params;
2121
+ const isGdm = isGDMChannelStreamId(channelId);
2122
+ const accountModulesAddress = client.config.base.chainConfig.addresses.accountModules;
2123
+ if (!isGdm) {
2124
+ throw new Error(`Unsupported stream type for tips: ${channelId}`);
2125
+ }
2126
+ if (!accountModulesAddress) {
2127
+ throw new Error('AccountModules address is not configured for GDM tips');
2128
+ }
2129
+ const recipientType = TipRecipientType.Any;
2130
+ const sender = appAddress; // msg.sender when contract executes
2131
+ const tokenId = undefined;
2132
+ const data = encodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }], [`0x${messageId}`, `0x${channelId}`]);
2133
+ const encodedData = encodeAbiParameters([
2134
+ {
2135
+ type: 'tuple',
2136
+ components: [
2137
+ { name: 'currency', type: 'address' },
2138
+ { name: 'sender', type: 'address' },
2139
+ { name: 'receiver', type: 'address' },
2140
+ { name: 'amount', type: 'uint256' },
2141
+ { name: 'data', type: 'bytes' },
2142
+ ],
2143
+ },
2144
+ ], [
2145
+ {
2146
+ currency,
2147
+ sender,
2148
+ receiver,
2149
+ amount,
2150
+ data,
2151
+ },
2152
+ ]);
2153
+ const targetContract = accountModulesAddress;
2154
+ let hash;
2155
+ if (isEth) {
2156
+ hash = await writeContract(viem, {
2157
+ address: targetContract,
2158
+ abi: tippingFacetAbi,
2159
+ functionName: 'sendTip',
2160
+ args: [recipientType, encodedData],
2161
+ value: amount,
2162
+ });
2163
+ }
2164
+ else {
2165
+ const approveHash = await writeContract(viem, {
2166
+ address: currency,
2167
+ abi: erc20Abi,
2168
+ functionName: 'approve',
2169
+ args: [targetContract, amount],
2170
+ });
2171
+ await waitForTransactionReceipt(viem, {
2172
+ hash: approveHash,
2173
+ confirmations: 3,
2174
+ });
2175
+ hash = await writeContract(viem, {
2176
+ address: targetContract,
2177
+ abi: tippingFacetAbi,
2178
+ functionName: 'sendTip',
2179
+ args: [recipientType, encodedData],
2180
+ });
2181
+ }
2182
+ const receipt = await waitForTransactionReceipt(viem, { hash, confirmations: 3 });
2183
+ if (receipt.status !== 'success') {
2184
+ throw new Error(`Tip transaction failed: ${hash}`);
2185
+ }
2186
+ const tipEvent = parseEventLogs({
2187
+ abi: tippingFacetAbi,
2188
+ logs: receipt.logs,
2189
+ eventName: 'TipSent',
2190
+ })[0];
2191
+ return sendBlockchainTransaction(viem.chain.id, receipt, {
2192
+ case: 'tip',
2193
+ value: {
2194
+ event: {
2195
+ tokenId: tokenId ? BigInt(tokenId) : undefined,
2196
+ currency: bin_fromHexString(tipEvent.args.currency),
2197
+ sender: bin_fromHexString(tipEvent.args.sender),
2198
+ receiver: bin_fromHexString(tipEvent.args.receiver),
2199
+ amount: tipEvent.args.amount,
2200
+ messageId: bin_fromHexString(messageId),
2201
+ channelId: bin_fromHexString(channelId),
2202
+ },
2203
+ toUserAddress: bin_fromHexString(params.receiverUserId),
2204
+ },
2205
+ }, {
2206
+ groupMentionTypes: tags?.groupMentionTypes || [],
2207
+ mentionedUserAddresses: tags?.mentionedUserAddresses || [],
2208
+ threadId: tags?.threadId,
2209
+ appClientAddress: tags?.appClientAddress,
2210
+ messageInteractionType: MessageInteractionType.TIP,
2211
+ participatingUserAddresses: [bin_fromHexString(params.receiverUserId)],
2212
+ });
2213
+ };
2214
+ /** Sends a tip to a user by looking up their smart account by userId.
2215
+ * Tip will always get funds from the app account balance.
2216
+ * @param params - Tip parameters including userId, amount, messageId, channelId, currency.
2217
+ * @returns The transaction hash and event ID
2218
+ */
2219
+ const sendTip = async (params, tags) => {
2220
+ return sendTipImpl({
2221
+ ...params,
2222
+ receiver: params.userId,
2223
+ receiverUserId: params.userId,
2224
+ }, tags);
2225
+ };
2226
+ const joinUser = async (streamId, userId) => {
2227
+ return client.sendEvent(makeUserStreamId(client.userId), make_UserPayload_UserMembershipAction({
2228
+ op: MembershipOp.SO_JOIN,
2229
+ userId: addressFromUserId(userId),
2230
+ streamId: streamIdAsBytes(streamId),
2231
+ }));
2232
+ };
2233
+ return {
2234
+ sendMessage,
2235
+ editMessage,
2236
+ sendReaction,
2237
+ sendInteractionRequest,
2238
+ sendGM,
2239
+ sendRawGM,
2240
+ sendConversationSeed,
2241
+ removeEvent,
2242
+ sendKeySolicitation,
2243
+ uploadDeviceKeys,
2244
+ pinMessage,
2245
+ unpinMessage,
2246
+ getChannelSettings,
2247
+ sendBlockchainTransaction,
2248
+ sendTip,
2249
+ joinUser,
2250
+ };
2251
+ };
2252
+ /**
2253
+ * Given a slash command message, returns the command and the arguments
2254
+ * @example
2255
+ * ```
2256
+ * /help
2257
+ * args: []
2258
+ * ```
2259
+ * ```
2260
+ * /sum 1 2
2261
+ * args: ['1', '2']
2262
+ * ```
2263
+ */
2264
+ const parseSlashCommand = (message) => {
2265
+ const parts = message.split(' ');
2266
+ const commandWithSlash = parts[0];
2267
+ const command = commandWithSlash.substring(1);
2268
+ const args = parts.slice(1);
2269
+ return { command, args };
2270
+ };
2271
+ const parseMentions = (mentions) =>
2272
+ // Agents doesn't care about @channel or @role mentions
2273
+ mentions.flatMap((m) => m.mentionBehavior.case === undefined
2274
+ ? [{ userId: m.userId, displayName: m.displayName }]
2275
+ : []);
2276
+ const processMentions = (mentions) => {
2277
+ if (!mentions) {
2278
+ return [];
2279
+ }
2280
+ return mentions.map((mention) => {
2281
+ if ('userId' in mention) {
2282
+ return create(ChannelMessage_Post_MentionSchema, {
2283
+ userId: mention.userId,
2284
+ displayName: mention.displayName,
2285
+ });
2286
+ }
2287
+ else if ('roleId' in mention) {
2288
+ return create(ChannelMessage_Post_MentionSchema, {
2289
+ mentionBehavior: {
2290
+ case: 'atRole',
2291
+ value: {
2292
+ roleId: mention.roleId,
2293
+ },
2294
+ },
2295
+ });
2296
+ }
2297
+ else if ('atChannel' in mention) {
2298
+ return create(ChannelMessage_Post_MentionSchema, {
2299
+ mentionBehavior: {
2300
+ case: 'atChannel',
2301
+ value: create(EmptySchema, {}),
2302
+ },
2303
+ });
2304
+ }
2305
+ else {
2306
+ throw new Error(`Invalid mention type: ${JSON.stringify(mention)}`);
2307
+ }
2308
+ });
2309
+ };
2310
+ const createBasePayload = (userId, streamId, eventId, createdAt, event, user) => {
2311
+ const isGdm = isGDMChannelStreamId(streamId);
2312
+ const isDm = false;
2313
+ return {
2314
+ userId,
2315
+ channelId: streamId,
2316
+ eventId,
2317
+ createdAt,
2318
+ event,
2319
+ isDm,
2320
+ isGdm,
2321
+ user,
2322
+ };
2323
+ };
2324
+ //# sourceMappingURL=app.js.map