@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
@@ -0,0 +1,2070 @@
1
+ import { makeBaseProvider, makeUserStreamId, townsEnv, RiverTimelineEvent, Bot as SyncAgentTest, AppRegistryService, MessageType, genIdBlob, makeUniqueMediaStreamId, createApp, streamIdAsBytes, isDefined, genId, } from '@towns-labs/sdk';
2
+ import { waitFor } from '@towns-labs/sdk/src/tests/testUtils';
3
+ import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
4
+ import { bin_fromHexString, check, dlog } from '@towns-labs/utils';
5
+ import { makeTownsApp } from './app';
6
+ import { ethers } from 'ethers';
7
+ import { createPublicClient, http } from 'viem';
8
+ import { relayerActions } from '@towns-labs/relayer-client';
9
+ import { getAddressesWithFallback } from '@towns-labs/contracts/deployments';
10
+ import { z } from 'zod';
11
+ import { stringify as superjsonStringify } from 'superjson';
12
+ import { ForwardSettingValue, InteractionRequestPayload_Signature_SignatureType, InteractionRequestPayload_PreparedCalls_Permission_Type, MediaInfoSchema, PositionType, SupportedApi, } from '@towns-labs/proto';
13
+ import { ETH_ADDRESS } from '@towns-labs/web3';
14
+ import { serve } from '@hono/node-server';
15
+ import { randomUUID } from 'crypto';
16
+ import { nanoid } from 'nanoid';
17
+ import { create } from '@bufbuild/protobuf';
18
+ import { deriveKeyAndIV } from '@towns-labs/sdk-crypto';
19
+ const log = dlog('test:bot');
20
+ const WEBHOOK_URL = `http://localhost:${process.env.BOT_PORT}/webhook`;
21
+ const SLASH_COMMANDS = [
22
+ { name: 'help', description: 'Get help with bot commands' },
23
+ { name: 'status', description: 'Check bot status' },
24
+ { name: 'set_positions', description: 'Set positions for the caller' },
25
+ ];
26
+ describe('Bot', { sequential: true }, () => {
27
+ const subscriptions = [];
28
+ const townsConfig = townsEnv().makeTownsConfig();
29
+ const bob = new SyncAgentTest(undefined, townsConfig);
30
+ let bobClient;
31
+ const alice = new SyncAgentTest(undefined, townsConfig);
32
+ let aliceClient;
33
+ const carol = new SyncAgentTest(undefined, townsConfig);
34
+ let carolClient;
35
+ const BOT_USERNAME = `witness_${genId(8)}`;
36
+ const BOT_DISPLAY_NAME = 'Uber Test Bot';
37
+ const BOT_DESCRIPTION = 'I shall witness everything';
38
+ const bob_username = `bob_${genId(12)}`; // unique across multiple non-concurrent runs
39
+ let bot;
40
+ let channelId;
41
+ let botClientAddress;
42
+ let appPrivateData;
43
+ let jwtSecretBase64;
44
+ let appRegistryRpcClient;
45
+ let appAddress;
46
+ let bobDefaultGdm;
47
+ let ethersProvider;
48
+ beforeAll(async () => {
49
+ ethersProvider = makeBaseProvider(townsConfig);
50
+ await shouldInitializeBotOwner();
51
+ await shouldSetupBotUser();
52
+ await shouldRegisterBotInAppRegistry();
53
+ await appRegistryRpcClient.setUserName({
54
+ username: bob_username,
55
+ displayName: 'Bob Display',
56
+ });
57
+ await shouldRunBotServerAndRegisterWebhook();
58
+ await bobClient.riverConnection.call((client) => Promise.all([client.debugForceMakeMiniblock(channelId, { forceSnapshot: true })]));
59
+ });
60
+ afterEach(() => {
61
+ subscriptions.forEach((unsub) => unsub());
62
+ subscriptions.splice(0, subscriptions.length);
63
+ });
64
+ const setForwardSetting = async (forwardSetting) => {
65
+ const appId = bin_fromHexString(botClientAddress);
66
+ await appRegistryRpcClient.updateAppSettings({
67
+ appId,
68
+ forwardSetting,
69
+ updateMask: ['forward_setting'],
70
+ });
71
+ };
72
+ const shouldInitializeBotOwner = async () => {
73
+ await Promise.all([bob.fundWallet(), alice.fundWallet(), carol.fundWallet()]);
74
+ bobClient = await bob.makeSyncAgent();
75
+ aliceClient = await alice.makeSyncAgent();
76
+ carolClient = await carol.makeSyncAgent();
77
+ await Promise.all([bobClient.start(), aliceClient.start(), carolClient.start()]);
78
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([alice.userId, carol.userId]);
79
+ channelId = gdmId;
80
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
81
+ bobDefaultGdm = bobClient.gdms.getGdm(gdmId);
82
+ expect(channelId).toBeDefined();
83
+ };
84
+ const shouldSetupBotUser = async () => {
85
+ const result = await createAppForOwner(bobClient, {
86
+ username: BOT_USERNAME,
87
+ displayName: BOT_DISPLAY_NAME,
88
+ description: BOT_DESCRIPTION,
89
+ imageUrl: 'https://placehold.co/600x600',
90
+ slashCommands: SLASH_COMMANDS,
91
+ });
92
+ botClientAddress = result.appAddress;
93
+ appPrivateData = result.appPrivateData;
94
+ appAddress = result.appAddress;
95
+ jwtSecretBase64 = result.jwtSecretBase64;
96
+ expect(appPrivateData).toBeDefined();
97
+ expect(jwtSecretBase64).toBeDefined();
98
+ await bobClient.riverConnection.call((client) => client.joinUser(channelId, botClientAddress));
99
+ };
100
+ const createAppForOwner = async (ownerClient, metadata) => {
101
+ const chainId = townsConfig.base.chainConfig.chainId;
102
+ const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
103
+ if (!addresses?.accountProxy) {
104
+ throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
105
+ }
106
+ const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
107
+ const relayerClient = createPublicClient({
108
+ chain: {
109
+ id: chainId,
110
+ name: 'Test Chain',
111
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
112
+ rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
113
+ },
114
+ transport: http(townsConfig.base.rpcUrl),
115
+ }).extend(relayerActions({ relayerUrl }));
116
+ return createApp({
117
+ owner: ownerClient.riverConnection.signerContext,
118
+ metadata,
119
+ relayerClient,
120
+ accountProxy: addresses.accountProxy,
121
+ townsConfig,
122
+ });
123
+ };
124
+ const shouldRegisterBotInAppRegistry = async () => {
125
+ const appRegistryUrl = townsEnv().getAppRegistryUrl(process.env.RIVER_ENV);
126
+ const { appRegistryRpcClient: rpcClient } = await AppRegistryService.authenticateWithSigner(bob.userId, bob.signer, appRegistryUrl);
127
+ appRegistryRpcClient = rpcClient;
128
+ };
129
+ const shouldRunBotServerAndRegisterWebhook = async () => {
130
+ bot = await makeTownsApp(appPrivateData, {
131
+ jwtSecret: jwtSecretBase64,
132
+ commands: SLASH_COMMANDS,
133
+ });
134
+ expect(bot).toBeDefined();
135
+ expect(bot.agentUserId).toBe(botClientAddress);
136
+ expect(bot.appAddress).toBe(appAddress);
137
+ const app = bot.start();
138
+ serve({
139
+ port: Number(process.env.BOT_PORT),
140
+ fetch: app.fetch,
141
+ });
142
+ await appRegistryRpcClient.registerWebhook({
143
+ appId: bin_fromHexString(botClientAddress),
144
+ webhookUrl: WEBHOOK_URL,
145
+ });
146
+ const { isRegistered, validResponse } = await appRegistryRpcClient.getStatus({
147
+ appId: bin_fromHexString(botClientAddress),
148
+ });
149
+ expect(isRegistered).toBe(true);
150
+ expect(validResponse).toBe(true);
151
+ };
152
+ it('should have app_address defined in user stream for bot (app registry only)', async () => {
153
+ const botUserStreamId = makeUserStreamId(botClientAddress);
154
+ const streamView = await bobClient.riverConnection.call(async (client) => {
155
+ return await client.getStream(botUserStreamId);
156
+ });
157
+ const userStream = streamView.userContent.userStreamModel;
158
+ expect(userStream.appAddress).toBeDefined();
159
+ // expect(userStream.appAddress).toBe(botClientAddress)
160
+ });
161
+ it('should show bot in member list and apps set', async () => {
162
+ const channelStreamView = await bobClient.riverConnection.call(async (client) => {
163
+ return await client.getStream(channelId);
164
+ });
165
+ const { apps, joined } = channelStreamView.getMembers();
166
+ expect(apps.has(botClientAddress)).toBe(true);
167
+ expect(joined.has(botClientAddress)).toBe(true);
168
+ expect(joined.get(botClientAddress)?.appAddress).toBe(appAddress);
169
+ });
170
+ it('should receive a message forwarded', async () => {
171
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
172
+ const timeBeforeSendMessage = Date.now();
173
+ let receivedMessages = [];
174
+ subscriptions.push(bot.onMessage((_h, e) => {
175
+ receivedMessages.push(e);
176
+ }));
177
+ const TEST_MESSAGE = 'Hello bot!';
178
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE);
179
+ await waitFor(() => receivedMessages.length > 0, { timeoutMS: 15_000 });
180
+ const event = receivedMessages.find((x) => x.eventId === eventId);
181
+ expect(event?.message).toBe(TEST_MESSAGE);
182
+ expect(event?.createdAt).toBeDefined();
183
+ expect(event?.createdAt).toBeInstanceOf(Date);
184
+ expect(event?.createdAt.getTime()).toBeGreaterThanOrEqual(timeBeforeSendMessage);
185
+ receivedMessages = [];
186
+ });
187
+ it('should not receive messages when forwarding is set to no messages', async () => {
188
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_NO_MESSAGES);
189
+ const receivedMessages = [];
190
+ subscriptions.push(bot.onMessage((_h, e) => {
191
+ receivedMessages.push(e);
192
+ }));
193
+ const TEST_MESSAGE = 'This message should not be forwarded';
194
+ await bobDefaultGdm.sendMessage(TEST_MESSAGE);
195
+ await new Promise((resolve) => setTimeout(resolve, 2500));
196
+ expect(receivedMessages).toHaveLength(0);
197
+ });
198
+ it('should receive slash command messages', async () => {
199
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
200
+ const receivedMessages = [];
201
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
202
+ receivedMessages.push(e);
203
+ }));
204
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
205
+ appClientAddress: bot.agentUserId,
206
+ });
207
+ await waitFor(() => receivedMessages.length > 0);
208
+ const event = receivedMessages.find((x) => x.eventId === eventId);
209
+ expect(event?.command).toBe('help');
210
+ expect(event?.args).toStrictEqual([]);
211
+ });
212
+ it('should receive slash command in a thread', async () => {
213
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
214
+ const receivedMessages = [];
215
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
216
+ receivedMessages.push(e);
217
+ }));
218
+ const { eventId: threadId } = await bobDefaultGdm.sendMessage('starting a thread');
219
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
220
+ appClientAddress: bot.agentUserId,
221
+ threadId: threadId,
222
+ });
223
+ await waitFor(() => receivedMessages.length > 0);
224
+ const event = receivedMessages.find((x) => x.eventId === eventId);
225
+ expect(event?.command).toBe('help');
226
+ expect(event?.args).toStrictEqual([]);
227
+ expect(event?.threadId).toBe(threadId);
228
+ });
229
+ it('should receive slash command as a reply', async () => {
230
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
231
+ const receivedMessages = [];
232
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
233
+ receivedMessages.push(e);
234
+ }));
235
+ const { eventId: replyId } = await bobDefaultGdm.sendMessage('yo');
236
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
237
+ appClientAddress: bot.agentUserId,
238
+ replyId: replyId,
239
+ });
240
+ await waitFor(() => receivedMessages.length > 0);
241
+ const event = receivedMessages.find((x) => x.eventId === eventId);
242
+ expect(event?.command).toBe('help');
243
+ expect(event?.args).toStrictEqual([]);
244
+ expect(event?.replyId).toBe(replyId);
245
+ });
246
+ it('should receive slash command with arguments', async () => {
247
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
248
+ const receivedMessages = [];
249
+ subscriptions.push(bot.onSlashCommand('status', (_h, e) => {
250
+ receivedMessages.push(e);
251
+ }));
252
+ const { eventId } = await bobDefaultGdm.sendMessage('/status detailed info', {
253
+ appClientAddress: bot.agentUserId,
254
+ });
255
+ await waitFor(() => receivedMessages.length > 0);
256
+ const event = receivedMessages.find((x) => x.eventId === eventId);
257
+ expect(event?.command).toBe('status');
258
+ expect(event?.args).toStrictEqual(['detailed', 'info']);
259
+ });
260
+ it('should store positions from /set_positions and return them via onPositions', async () => {
261
+ // regression test, userIds were cased differently in events vs over the positions webhook request
262
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
263
+ await appRegistryRpcClient.updateAppSettings({
264
+ appId: bin_fromHexString(botClientAddress),
265
+ supportedApis: [SupportedApi.POSITIONS],
266
+ updateMask: ['supported_apis'],
267
+ });
268
+ const positionsByUserId = new Map();
269
+ let slashCommandUserId;
270
+ let onPositionsUserId;
271
+ subscriptions.push(bot.onSlashCommand('set_positions', (_h, e) => {
272
+ const value = e.args[0];
273
+ if (!value) {
274
+ return;
275
+ }
276
+ slashCommandUserId = e.userId;
277
+ positionsByUserId.set(e.userId, value);
278
+ }));
279
+ subscriptions.push(bot.onPositions((_h, e) => {
280
+ onPositionsUserId = e.userId;
281
+ const balanceUsd = positionsByUserId.get(e.userId);
282
+ return {
283
+ supported: true,
284
+ positions: balanceUsd
285
+ ? [
286
+ {
287
+ label: 'Stored Position',
288
+ symbol: 'TEST',
289
+ type: PositionType.PERPETUAL,
290
+ balanceUsd,
291
+ pnlUsd: '0',
292
+ },
293
+ ]
294
+ : [],
295
+ timestamp: BigInt(Date.now()),
296
+ };
297
+ }));
298
+ const storedBalanceUsd = '123.45';
299
+ await bobDefaultGdm.sendMessage(`/set_positions ${storedBalanceUsd}`, {
300
+ appClientAddress: bot.agentUserId,
301
+ });
302
+ await waitFor(() => positionsByUserId.size > 0);
303
+ const positionsResp = await appRegistryRpcClient.getAppPositions({
304
+ appId: bin_fromHexString(botClientAddress),
305
+ });
306
+ expect(positionsResp.positions?.supported).toBe(true);
307
+ expect(positionsResp.positions?.positions).toHaveLength(1);
308
+ expect(positionsResp.positions?.positions[0]?.balanceUsd).toBe(storedBalanceUsd);
309
+ expect(slashCommandUserId).toBeDefined();
310
+ expect(onPositionsUserId).toBeDefined();
311
+ expect(onPositionsUserId).toBe(slashCommandUserId);
312
+ });
313
+ it('onMessageEdit should be triggered when a message is edited', async () => {
314
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
315
+ const receivedEditEvents = [];
316
+ subscriptions.push(bot.onMessageEdit((_h, e) => {
317
+ receivedEditEvents.push(e);
318
+ }));
319
+ const originalMessage = 'Original message to delete';
320
+ const editedMessage = 'Edited message content';
321
+ const { eventId: originalMessageId } = await bobDefaultGdm.sendMessage(originalMessage);
322
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_Edit_Text(channelId, originalMessageId, {
323
+ content: {
324
+ body: editedMessage,
325
+ mentions: [],
326
+ attachments: [],
327
+ },
328
+ }));
329
+ await waitFor(() => receivedEditEvents.length > 0);
330
+ const editEvent = receivedEditEvents.find((e) => e.refEventId === originalMessageId);
331
+ expect(editEvent).toBeDefined();
332
+ expect(editEvent?.message).toBe(editedMessage);
333
+ });
334
+ it('onMessage should be triggered with threadId when a message is sent in a thread', async () => {
335
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
336
+ const receivedThreadMessages = [];
337
+ subscriptions.push(bot.onMessage((_h, e) => {
338
+ if (e.threadId) {
339
+ receivedThreadMessages.push(e);
340
+ }
341
+ }));
342
+ const initialMessage = 'Starting a thread';
343
+ const threadReply = 'Replying in thread';
344
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
345
+ const { eventId: replyEventId } = await bobDefaultGdm.sendMessage(threadReply, {
346
+ threadId: initialMessageId,
347
+ });
348
+ await waitFor(() => receivedThreadMessages.length > 0);
349
+ const threadEvent = receivedThreadMessages.find((e) => e.eventId === replyEventId);
350
+ expect(threadEvent).toBeDefined();
351
+ expect(threadEvent?.message).toBe(threadReply);
352
+ expect(threadEvent?.userId).toBe(bob.userId);
353
+ expect(threadEvent?.threadId).toBe(initialMessageId);
354
+ });
355
+ it('onMessage should be triggered with isMentioned when a bot is mentioned', async () => {
356
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
357
+ const receivedMentionedEvents = [];
358
+ subscriptions.push(bot.onMessage((_h, e) => {
359
+ if (e.isMentioned) {
360
+ receivedMentionedEvents.push(e);
361
+ }
362
+ }));
363
+ const TEST_MESSAGE = 'Hello @bot';
364
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
365
+ mentions: [
366
+ {
367
+ userId: bot.agentUserId,
368
+ displayName: BOT_DISPLAY_NAME,
369
+ mentionBehavior: { case: undefined, value: undefined },
370
+ },
371
+ ],
372
+ });
373
+ await waitFor(() => receivedMentionedEvents.length > 0);
374
+ const mentionedEvent = receivedMentionedEvents.find((x) => x.eventId === eventId);
375
+ expect(mentionedEvent).toBeDefined();
376
+ expect(mentionedEvent?.isMentioned).toBe(true);
377
+ expect(mentionedEvent?.mentions[0].userId).toBe(bot.agentUserId);
378
+ expect(mentionedEvent?.mentions[0].displayName).toBe(BOT_DISPLAY_NAME);
379
+ });
380
+ it('isMentioned should be false when someone else is mentioned', async () => {
381
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
382
+ const receivedMessages = [];
383
+ subscriptions.push(bot.onMessage((_h, e) => {
384
+ receivedMessages.push(e);
385
+ }));
386
+ const TEST_MESSAGE = 'Hello @alice';
387
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
388
+ mentions: [
389
+ {
390
+ userId: alice.userId,
391
+ displayName: 'alice',
392
+ mentionBehavior: { case: undefined, value: undefined },
393
+ },
394
+ ],
395
+ });
396
+ await waitFor(() => receivedMessages.length > 0);
397
+ const message = receivedMessages.find((x) => x.eventId === eventId);
398
+ expect(message).toBeDefined();
399
+ expect(message?.isMentioned).toBe(false);
400
+ });
401
+ it('onMessage should be triggered with both threadId and isMentioned when bot is mentioned in a thread', async () => {
402
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
403
+ const receivedMentionedInThreadEvents = [];
404
+ subscriptions.push(bot.onMessage((_h, e) => {
405
+ receivedMentionedInThreadEvents.push(e);
406
+ }));
407
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage('starting a thread');
408
+ const { eventId: threadMentionEventId } = await bobDefaultGdm.sendMessage('yo @bot check this thread', {
409
+ threadId: initialMessageId,
410
+ mentions: [
411
+ {
412
+ userId: bot.agentUserId,
413
+ displayName: bot.agentUserId,
414
+ mentionBehavior: { case: undefined, value: undefined },
415
+ },
416
+ ],
417
+ });
418
+ await waitFor(() => receivedMentionedInThreadEvents.length > 0);
419
+ const threadMentionEvent = receivedMentionedInThreadEvents.find((e) => e.eventId === threadMentionEventId);
420
+ expect(threadMentionEvent).toBeDefined();
421
+ expect(threadMentionEvent?.userId).toBe(bob.userId);
422
+ expect(threadMentionEvent?.threadId).toBe(initialMessageId);
423
+ expect(threadMentionEvent?.isMentioned).toBe(true);
424
+ });
425
+ it('thread message without bot mention should have isMentioned false', async () => {
426
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
427
+ const receivedMessages = [];
428
+ subscriptions.push(bot.onMessage((_h, e) => {
429
+ receivedMessages.push(e);
430
+ }));
431
+ const initialMessage = 'Starting another thread';
432
+ const threadMessageWithoutMention = 'Thread message without mention';
433
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
434
+ const { eventId: threadEventId } = await bobDefaultGdm.sendMessage(threadMessageWithoutMention, {
435
+ threadId: initialMessageId,
436
+ });
437
+ await waitFor(() => receivedMessages.length > 0);
438
+ const message = receivedMessages.find((x) => x.eventId === threadEventId);
439
+ expect(message).toBeDefined();
440
+ expect(message?.threadId).toBe(initialMessageId);
441
+ expect(message?.isMentioned).toBe(false);
442
+ });
443
+ it('onReaction should be triggered when a reaction is added', async () => {
444
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
445
+ const receivedReactionEvents = [];
446
+ subscriptions.push(bot.onReaction((_h, e) => {
447
+ receivedReactionEvents.push(e);
448
+ }));
449
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
450
+ const { eventId: reactionId } = await bobDefaultGdm.sendReaction(messageId, '👍');
451
+ await waitFor(() => receivedReactionEvents.length > 0);
452
+ expect(receivedReactionEvents.find((x) => x.eventId === reactionId)).toBeDefined();
453
+ expect(receivedReactionEvents.find((x) => x.reaction === '👍')).toBeDefined();
454
+ expect(receivedReactionEvents.find((x) => x.messageId === messageId)).toBeDefined();
455
+ expect(receivedReactionEvents.find((x) => x.userId === bob.userId)).toBeDefined();
456
+ });
457
+ it('onRedaction should be triggered when a message is redacted', async () => {
458
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
459
+ const receivedRedactionEvents = [];
460
+ subscriptions.push(bot.onRedaction((_h, e) => {
461
+ receivedRedactionEvents.push(e);
462
+ }));
463
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
464
+ const { eventId: redactionId } = await bobDefaultGdm.redact(messageId);
465
+ await waitFor(() => receivedRedactionEvents.length > 0);
466
+ expect(receivedRedactionEvents.find((x) => x.eventId === redactionId)).toBeDefined();
467
+ });
468
+ it('bot can redact his own message', async () => {
469
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
470
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello');
471
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
472
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
473
+ const { eventId: redactionId } = await bot.removeEvent(channelId, messageId);
474
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === redactionId)?.content
475
+ ?.kind).toBe(RiverTimelineEvent.RedactionActionEvent));
476
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
477
+ ?.kind).toBe(RiverTimelineEvent.RedactedEvent));
478
+ });
479
+ it('bot can mention bob', async () => {
480
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
481
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello @bob', {
482
+ mentions: [
483
+ {
484
+ userId: bob.userId,
485
+ displayName: 'bob',
486
+ },
487
+ ],
488
+ });
489
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
490
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
491
+ const channelMessage = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content;
492
+ expect(channelMessage.mentions).toBeDefined();
493
+ expect(channelMessage.mentions?.length).toBe(1);
494
+ expect(channelMessage.mentions?.[0].userId).toBe(bob.userId);
495
+ expect(channelMessage.mentions?.[0].displayName).toBe('bob');
496
+ });
497
+ it('bot.getUser returns entity name set via app registry', async () => {
498
+ const userInfo = await bot.getUser(bob.userId);
499
+ expect(userInfo).toBeDefined();
500
+ expect(userInfo.username).toBe(bob_username);
501
+ expect(userInfo.displayName).toBe('Bob Display');
502
+ });
503
+ it('bot can fetch existing decryption keys when sending a message', async () => {
504
+ // on a fresh boot the bot won't have any keys in cache, so it should fetch them from the app server if they exist
505
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
506
+ const { eventId: messageId1 } = await bot.sendMessage(channelId, 'Hello message 1');
507
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1)?.content
508
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
509
+ // DELETE OUTBOUND GROUP SESSIONS to simulate fresh server start
510
+ await bot['client'].crypto.cryptoStore.deleteHybridGroupSessions(channelId);
511
+ const { eventId: messageId2 } = await bot.sendMessage(channelId, 'Hello message 2');
512
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2)?.content
513
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
514
+ const event1 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1);
515
+ const event2 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2);
516
+ expect(event1?.sessionId).toEqual(event2?.sessionId);
517
+ });
518
+ it('bot shares new decrytion keys with users created while sending a message', async () => {
519
+ // the bot should almost never have to create a new key - usually they will get a message in the gdm first and can use that key to encrypt
520
+ // but in the case where they've never received one and want to send a message, they will create a new key and share it with the users in the gdm
521
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
522
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([alice.userId, bot.agentUserId]);
523
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
524
+ const bobGdm = bobClient.gdms.getGdm(gdmId);
525
+ // bot sends message to the gdm
526
+ const { eventId: messageId } = await bot.sendMessage(gdmId, 'Hello');
527
+ log('bot sends message to gdm', messageId);
528
+ // bob should see the DECRYPTED message
529
+ await waitFor(() => {
530
+ expect(bobGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
531
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage);
532
+ }, { timeoutMS: 20000 });
533
+ });
534
+ it.skip('TODO: FIX: onTip should be triggered when a tip is received', async () => {
535
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
536
+ const receivedTipEvents = [];
537
+ subscriptions.push(bot.onTip((_h, e) => {
538
+ receivedTipEvents.push(e);
539
+ }));
540
+ // Bot sends a message to the GDM
541
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Tip me!');
542
+ const balanceBefore = (await ethersProvider.getBalance(appAddress)).toBigInt();
543
+ // Bob tips the bot using GDM sendTip (type: 'any' tipping)
544
+ await bobDefaultGdm.sendTip(messageId, {
545
+ receiver: appAddress,
546
+ amount: ethers.utils.parseUnits('0.01').toBigInt(),
547
+ currency: ETH_ADDRESS,
548
+ chainId: townsConfig.base.chainConfig.chainId,
549
+ }, bob.signer);
550
+ // Verify the bot's balance increased (no protocol fee for type: 'any' tips)
551
+ const balanceAfter = (await ethersProvider.getBalance(appAddress)).toBigInt();
552
+ const expectedTipAmount = ethers.utils.parseUnits('0.01').toBigInt();
553
+ expect(balanceAfter).toEqual(balanceBefore + expectedTipAmount);
554
+ // Verify the onTip event was triggered
555
+ await waitFor(() => receivedTipEvents.length > 0);
556
+ const tipEvent = receivedTipEvents.find((x) => x.messageId === messageId);
557
+ expect(tipEvent).toBeDefined();
558
+ expect(tipEvent?.userId).toBe(bob.userId);
559
+ expect(tipEvent?.channelId).toBe(channelId);
560
+ expect(tipEvent?.amount).toBe(expectedTipAmount);
561
+ expect(tipEvent?.currency).toBe(ETH_ADDRESS);
562
+ expect(tipEvent?.senderAddress).toBe(bob.userId);
563
+ expect(tipEvent?.receiverAddress).toBe(appAddress);
564
+ });
565
+ it.skip('TODO: FIX: bot can use sendTip() to send tips using app balance', async () => {
566
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
567
+ const receivedMessages = [];
568
+ let tipPromise;
569
+ subscriptions.push(bot.onMessage((handler, event) => {
570
+ receivedMessages.push(event);
571
+ tipPromise = handler.sendTip({
572
+ userId: bob.userId,
573
+ amount: ethers.utils.parseUnits('0.005').toBigInt(),
574
+ messageId: event.eventId,
575
+ channelId: event.channelId,
576
+ });
577
+ }));
578
+ const bobBalanceBefore = (await ethersProvider.getBalance(bob.userId)).toBigInt();
579
+ // Bob sends a message asking for a tip
580
+ const { eventId: bobMessageId } = await bobDefaultGdm.sendMessage('Tip me please!');
581
+ await waitFor(() => receivedMessages.some((x) => x.eventId === bobMessageId));
582
+ expect(tipPromise).toBeDefined();
583
+ const result = await tipPromise;
584
+ expect(result.txHash).toBeDefined();
585
+ expect(result.eventId).toBeDefined();
586
+ // Verify bob's balance increased
587
+ const bobBalanceAfter = (await ethersProvider.getBalance(bob.userId)).toBigInt();
588
+ expect(bobBalanceAfter).toBeGreaterThan(bobBalanceBefore);
589
+ });
590
+ it('onEventRevoke (FORWARD_SETTING_ALL_MESSAGES) should be triggered when a message is revoked', async () => {
591
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
592
+ const receivedEventRevokeEvents = [];
593
+ subscriptions.push(bot.onEventRevoke((_h, e) => {
594
+ receivedEventRevokeEvents.push(e);
595
+ }));
596
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'hii');
597
+ await bobDefaultGdm.adminRedact(messageId);
598
+ await waitFor(() => receivedEventRevokeEvents.length > 0);
599
+ expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
600
+ });
601
+ it.fails('onEventRevoke (FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS) should be triggered when a message that mentions the bot is revoked', async () => {
602
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
603
+ const receivedEventRevokeEvents = [];
604
+ subscriptions.push(bot.onEventRevoke((_h, e) => {
605
+ receivedEventRevokeEvents.push(e);
606
+ }));
607
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('hii @bot', {
608
+ mentions: [
609
+ {
610
+ userId: bot.agentUserId,
611
+ displayName: bot.agentUserId,
612
+ mentionBehavior: { case: undefined, value: undefined },
613
+ },
614
+ ],
615
+ });
616
+ await bobDefaultGdm.adminRedact(messageId);
617
+ await waitFor(() => receivedEventRevokeEvents.length > 0);
618
+ expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
619
+ });
620
+ it('should send message with image attachment from URL with alt text', async () => {
621
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
622
+ const imageUrl = 'https://placehold.co/800x600.png';
623
+ const altText = 'A beautiful placeholder image';
624
+ const { eventId } = await bot.sendMessage(channelId, 'Image with alt text', {
625
+ attachments: [{ type: 'image', url: imageUrl, alt: altText }],
626
+ });
627
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
628
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
629
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
630
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
631
+ ? message?.content?.attachments
632
+ : undefined;
633
+ expect(attachments).toHaveLength(1);
634
+ expect(attachments?.[0].type).toBe('image');
635
+ const image = attachments?.[0].type === 'image' ? attachments?.[0] : undefined;
636
+ expect(image).toBeDefined();
637
+ expect(image?.info.url).toBe(imageUrl);
638
+ });
639
+ it('should gracefully handle non-image URL (skip with warning)', async () => {
640
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
641
+ // Use a URL that returns non-image content type
642
+ const nonImageUrl = 'https://httpbin.org/json';
643
+ const { eventId } = await bot.sendMessage(channelId, 'This should skip the attachment', {
644
+ attachments: [{ type: 'image', url: nonImageUrl }],
645
+ });
646
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
647
+ // Message should still be sent, just without the attachment
648
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
649
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
650
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
651
+ ? message?.content?.attachments
652
+ : undefined;
653
+ expect(attachments).toHaveLength(0);
654
+ });
655
+ it('should gracefully handle invalid URL (404)', async () => {
656
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
657
+ // Use a URL that returns 404
658
+ const invalidUrl = 'https://httpbin.org/status/404';
659
+ const { eventId } = await bot.sendMessage(channelId, 'This should handle 404 gracefully', {
660
+ attachments: [{ type: 'image', url: invalidUrl }],
661
+ });
662
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
663
+ // Message should still be sent, just without the attachment
664
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
665
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
666
+ });
667
+ function createTestPNG(width, height) {
668
+ // PNG signature
669
+ const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
670
+ // IHDR chunk
671
+ const ihdrData = new Uint8Array(13);
672
+ const view = new DataView(ihdrData.buffer);
673
+ view.setUint32(0, width, false); // Width
674
+ view.setUint32(4, height, false); // Height
675
+ ihdrData[8] = 8; // Bit depth
676
+ ihdrData[9] = 2; // Color type (truecolor)
677
+ ihdrData[10] = 0; // Compression
678
+ ihdrData[11] = 0; // Filter
679
+ ihdrData[12] = 0; // Interlace
680
+ // Create IHDR chunk with CRC
681
+ const ihdrChunk = new Uint8Array(12 + 13);
682
+ new DataView(ihdrChunk.buffer).setUint32(0, 13, false);
683
+ ihdrChunk.set([73, 72, 68, 82], 4); // 'IHDR'
684
+ ihdrChunk.set(ihdrData, 8);
685
+ new DataView(ihdrChunk.buffer).setUint32(21, 0, false); // CRC placeholder
686
+ // IDAT chunk (minimal data)
687
+ const idatData = new Uint8Array(100); // Minimal compressed data
688
+ const idatChunk = new Uint8Array(12 + 100);
689
+ new DataView(idatChunk.buffer).setUint32(0, 100, false);
690
+ idatChunk.set([73, 68, 65, 84], 4); // 'IDAT'
691
+ idatChunk.set(idatData, 8);
692
+ new DataView(idatChunk.buffer).setUint32(108, 0, false); // CRC placeholder
693
+ // IEND chunk
694
+ const iendChunk = new Uint8Array([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]);
695
+ // Combine all chunks
696
+ const png = new Uint8Array(signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length);
697
+ png.set(signature, 0);
698
+ png.set(ihdrChunk, signature.length);
699
+ png.set(idatChunk, signature.length + ihdrChunk.length);
700
+ png.set(iendChunk, signature.length + ihdrChunk.length + idatChunk.length);
701
+ return png;
702
+ }
703
+ it('should send chunked media with Uint8Array data', async () => {
704
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
705
+ const testData = createTestPNG(100, 100);
706
+ const { eventId } = await bot.sendMessage(channelId, 'Chunked media test', {
707
+ attachments: [
708
+ {
709
+ type: 'chunked',
710
+ data: testData,
711
+ filename: 'test.png',
712
+ mimetype: 'image/png',
713
+ width: 100,
714
+ height: 100,
715
+ },
716
+ ],
717
+ });
718
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
719
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
720
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
721
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
722
+ ? message?.content?.attachments
723
+ : undefined;
724
+ expect(attachments).toHaveLength(1);
725
+ expect(attachments?.[0].type).toBe('chunked_media');
726
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
727
+ expect(chunkedMedia).toBeDefined();
728
+ expect(chunkedMedia?.info.filename).toBe('test.png');
729
+ expect(chunkedMedia?.info.mimetype).toBe('image/png');
730
+ expect(chunkedMedia?.info.widthPixels).toBe(100);
731
+ expect(chunkedMedia?.info.heightPixels).toBe(100);
732
+ expect(chunkedMedia?.streamId).toBeDefined();
733
+ expect(chunkedMedia?.encryption).toBeDefined();
734
+ });
735
+ it('should send chunked media with Blob data and auto-detect dimensions', async () => {
736
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
737
+ const testData = createTestPNG(200, 150);
738
+ const blob = new Blob([testData.buffer], { type: 'image/png' });
739
+ const { eventId } = await bot.sendMessage(channelId, 'Blob test', {
740
+ attachments: [
741
+ {
742
+ type: 'chunked',
743
+ data: blob,
744
+ filename: 'blob-test.png',
745
+ },
746
+ ],
747
+ });
748
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
749
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
750
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
751
+ ? message?.content?.attachments
752
+ : undefined;
753
+ expect(attachments).toHaveLength(1);
754
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
755
+ expect(chunkedMedia?.info.widthPixels).toBe(200);
756
+ expect(chunkedMedia?.info.heightPixels).toBe(150);
757
+ });
758
+ it('should handle large chunked media (multiple chunks)', async () => {
759
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
760
+ // Create 2.5MB of data - will create mulitple chunks
761
+ const largeData = new Uint8Array(2500000);
762
+ for (let i = 0; i < largeData.length; i++) {
763
+ largeData[i] = i % 256;
764
+ }
765
+ const { eventId } = await bot.sendMessage(channelId, 'Large media test', {
766
+ attachments: [
767
+ {
768
+ type: 'chunked',
769
+ data: largeData,
770
+ filename: 'large-file.bin',
771
+ mimetype: 'application/octet-stream',
772
+ },
773
+ ],
774
+ });
775
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined(), { timeoutMS: 30000 });
776
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
777
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
778
+ ? message?.content?.attachments
779
+ : undefined;
780
+ expect(attachments).toHaveLength(1);
781
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
782
+ expect(chunkedMedia?.info.sizeBytes).toBe(BigInt(2500000));
783
+ });
784
+ // @miguel-nascimento 2025-12-08 flaky test
785
+ it.skip('should send mixed attachments (URL + chunked)', async () => {
786
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
787
+ const testData = createTestPNG(50, 50);
788
+ const imageUrl = 'https://placehold.co/100x100.png';
789
+ const { eventId } = await bot.sendMessage(channelId, 'Mixed attachments', {
790
+ attachments: [
791
+ { type: 'image', url: imageUrl },
792
+ {
793
+ type: 'chunked',
794
+ data: testData,
795
+ filename: 'generated.png',
796
+ mimetype: 'image/png',
797
+ },
798
+ ],
799
+ });
800
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
801
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
802
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
803
+ ? message?.content?.attachments
804
+ : undefined;
805
+ expect(attachments).toHaveLength(2);
806
+ expect(attachments?.[0].type).toBe('image');
807
+ expect(attachments?.[1].type).toBe('chunked_media');
808
+ });
809
+ it('should send and receive GM messages with schema validation', async () => {
810
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
811
+ const messageSchema = z.object({ text: z.string(), count: z.number() });
812
+ const receivedGmEvents = [];
813
+ subscriptions.push(bot.onGmMessage('test.typed.v1', messageSchema, (_h, e) => {
814
+ receivedGmEvents.push({ typeUrl: e.typeUrl, data: e.data });
815
+ }));
816
+ const testData = { text: 'Hello', count: 42 };
817
+ // Bob sends the message so bot receives it (bot filters its own messages)
818
+ const jsonString = superjsonStringify(testData);
819
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
820
+ content: {
821
+ typeUrl: 'test.typed.v1',
822
+ value: new TextEncoder().encode(jsonString),
823
+ },
824
+ }));
825
+ await waitFor(() => receivedGmEvents.length > 0);
826
+ const event = receivedGmEvents[0];
827
+ expect(event).toBeDefined();
828
+ expect(event.typeUrl).toBe('test.typed.v1');
829
+ expect(event.data).toEqual(testData);
830
+ });
831
+ it('should handle GM with Date objects (superjson serialization)', async () => {
832
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
833
+ const eventSchema = z.object({
834
+ eventType: z.string(),
835
+ timestamp: z.date(),
836
+ });
837
+ const testDate = new Date('2025-01-15T12:00:00Z');
838
+ const { eventId } = await bot.sendGM(channelId, 'test.date.v1', eventSchema, {
839
+ eventType: 'test',
840
+ timestamp: testDate,
841
+ });
842
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
843
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
844
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
845
+ const gmData = message?.content?.kind === RiverTimelineEvent.ChannelMessage &&
846
+ message?.content?.content.msgType === MessageType.GM
847
+ ? message?.content?.content.data
848
+ : undefined;
849
+ expect(gmData).toBeDefined();
850
+ expect(gmData).toStrictEqual(new TextEncoder().encode(superjsonStringify({ eventType: 'test', timestamp: testDate })));
851
+ });
852
+ it('should handle multiple handlers for different typeUrls', async () => {
853
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
854
+ const schema1 = z.object({ type: z.literal('type1'), value: z.number() });
855
+ const schema2 = z.object({ type: z.literal('type2'), text: z.string() });
856
+ const receivedType1 = [];
857
+ const receivedType2 = [];
858
+ subscriptions.push(bot.onGmMessage('test.multi.type1', schema1, (_h, e) => {
859
+ receivedType1.push(e.data);
860
+ }));
861
+ subscriptions.push(bot.onGmMessage('test.multi.type2', schema2, (_h, e) => {
862
+ receivedType2.push(e.data);
863
+ }));
864
+ const data1 = { type: 'type1', value: 123 };
865
+ const data2 = { type: 'type2', text: 'hello' };
866
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
867
+ content: {
868
+ typeUrl: 'test.multi.type1',
869
+ value: new TextEncoder().encode(superjsonStringify(data1)),
870
+ },
871
+ }));
872
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
873
+ content: {
874
+ typeUrl: 'test.multi.type2',
875
+ value: new TextEncoder().encode(superjsonStringify(data2)),
876
+ },
877
+ }));
878
+ await waitFor(() => receivedType1.length > 0 && receivedType2.length > 0);
879
+ expect(receivedType1[0]).toEqual(data1);
880
+ expect(receivedType2[0]).toEqual(data2);
881
+ });
882
+ it('should handle raw GM messages', async () => {
883
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
884
+ const receivedMessages = [];
885
+ subscriptions.push(bot.onRawGmMessage((_h, e) => {
886
+ receivedMessages.push({ typeUrl: e.typeUrl, message: e.message });
887
+ }));
888
+ const message = new TextEncoder().encode('Hello, world!');
889
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
890
+ content: {
891
+ typeUrl: 'test.raw.v1',
892
+ value: message,
893
+ },
894
+ }));
895
+ await waitFor(() => receivedMessages.length > 0);
896
+ expect(receivedMessages[0].typeUrl).toBe('test.raw.v1');
897
+ expect(receivedMessages[0].message).toEqual(message);
898
+ });
899
+ it('should log error and continue processing if throws an error when handling an event', async () => {
900
+ const consoleErrorSpy = vi.spyOn(console, 'error');
901
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
902
+ subscriptions.push(bot.onMessage(() => {
903
+ throw new Error('test error');
904
+ }));
905
+ await bobDefaultGdm.sendMessage('lol');
906
+ await waitFor(() => consoleErrorSpy.mock.calls.length > 0);
907
+ expect(consoleErrorSpy.mock.calls[0][0]).toContain('[@towns-labs/app-framework] Error while handling event');
908
+ consoleErrorSpy.mockRestore();
909
+ });
910
+ it('bot should be able to send encrypted interaction request and user should send encrypted response', async () => {
911
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
912
+ const requestId = randomUUID();
913
+ const interactionRequestContent = {
914
+ case: 'signature',
915
+ value: {
916
+ id: requestId,
917
+ data: '0x1234567890',
918
+ chainId: '1',
919
+ type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
920
+ },
921
+ };
922
+ const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
923
+ // Wait for Bob to receive the interaction request
924
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
925
+ // Wait for decryption to complete
926
+ await waitFor(() => {
927
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
928
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
929
+ return false;
930
+ }
931
+ return event?.content?.payload !== undefined;
932
+ });
933
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
934
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
935
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
936
+ throw new Error('Event is not an InteractionRequest');
937
+ }
938
+ const decryptedPayload = decryptedEvent.content.payload;
939
+ const encryptionDevice = decryptedPayload?.encryptionDevice;
940
+ expect(decryptedPayload).toBeDefined();
941
+ expect(encryptionDevice).toBeDefined();
942
+ expect(decryptedPayload?.content.case).toBe('signature');
943
+ if (decryptedPayload?.content.case === 'signature') {
944
+ expect(decryptedPayload.content.value.id).toBe(requestId);
945
+ expect(decryptedPayload.content.value.data).toBe('0x1234567890');
946
+ }
947
+ // bob should be able to send interaction response to the bot using the decrypted encryption device
948
+ const recipient = bin_fromHexString(botClientAddress);
949
+ const interactionResponsePayload = {
950
+ salt: genIdBlob(),
951
+ content: {
952
+ case: 'signature',
953
+ value: {
954
+ requestId: requestId,
955
+ signature: '0x123222222222',
956
+ },
957
+ },
958
+ };
959
+ const receivedInteractionResponses = [];
960
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
961
+ receivedInteractionResponses.push(e.response);
962
+ }));
963
+ await bobClient.riverConnection.call(async (client) => {
964
+ // from the client, to the channel, encrypted so that only the bot can read it
965
+ return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, encryptionDevice);
966
+ });
967
+ await waitFor(() => receivedInteractionResponses.length > 0);
968
+ expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
969
+ expect(receivedInteractionResponses[0].payload.content.value?.requestId).toEqual(requestId);
970
+ });
971
+ it('user should NOT be able to send interaction request', async () => {
972
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
973
+ const interactionRequestContent = {
974
+ case: 'signature',
975
+ value: {
976
+ id: randomUUID(),
977
+ data: '0x1234567890',
978
+ chainId: '1',
979
+ type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
980
+ },
981
+ };
982
+ await bobClient.riverConnection.call(async (client) => {
983
+ // Try to send an interaction request as a user (should fail)
984
+ // Note: Even if we try to use the proper sendInteractionRequest method,
985
+ // it should fail because only apps can send interaction requests
986
+ await expect(client.sendInteractionRequest(channelId, interactionRequestContent)).rejects.toThrow(/creator is not an app/);
987
+ });
988
+ });
989
+ it('bot should be able to send form interaction request', async () => {
990
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
991
+ const interactionRequestContent = {
992
+ case: 'form',
993
+ value: {
994
+ id: randomUUID(),
995
+ components: [
996
+ { id: '1', component: { case: 'button', value: { label: 'Button' } } },
997
+ {
998
+ id: '2',
999
+ component: { case: 'textInput', value: { placeholder: 'Text Input' } },
1000
+ },
1001
+ ],
1002
+ },
1003
+ };
1004
+ const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
1005
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1006
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1007
+ expect(message).toBeDefined();
1008
+ });
1009
+ it('bot should be able to send signature interaction request using flattened API', async () => {
1010
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1011
+ const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
1012
+ type: 'signature',
1013
+ data: '0xabcdef1234567890',
1014
+ chainId: '8453',
1015
+ method: 'personal_sign',
1016
+ signerWallet: botClientAddress,
1017
+ });
1018
+ // Wait for Bob to receive the interaction request
1019
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1020
+ // Wait for decryption to complete
1021
+ await waitFor(() => {
1022
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1023
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1024
+ return false;
1025
+ }
1026
+ return event?.content?.payload !== undefined;
1027
+ });
1028
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1029
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1030
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1031
+ throw new Error('Event is not an InteractionRequest');
1032
+ }
1033
+ const decryptedPayload = decryptedEvent.content.payload;
1034
+ expect(decryptedPayload).toBeDefined();
1035
+ expect(decryptedPayload?.content.case).toBe('signature');
1036
+ if (decryptedPayload?.content.case === 'signature') {
1037
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1038
+ expect(decryptedPayload.content.value.data).toBe('0xabcdef1234567890');
1039
+ expect(decryptedPayload.content.value.chainId).toBe('8453');
1040
+ expect(decryptedPayload.content.value.type).toBe(InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN);
1041
+ }
1042
+ });
1043
+ it('bot should be able to send form interaction request using flattened API', async () => {
1044
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1045
+ const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
1046
+ type: 'form',
1047
+ components: [
1048
+ { id: 'btn-1', type: 'button', label: 'Click Me' },
1049
+ { id: 'input-1', type: 'textInput', placeholder: 'Enter text here' },
1050
+ ],
1051
+ });
1052
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1053
+ // Wait for decryption to complete
1054
+ await waitFor(() => {
1055
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1056
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1057
+ return false;
1058
+ }
1059
+ return event?.content?.payload !== undefined;
1060
+ });
1061
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1062
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1063
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1064
+ throw new Error('Event is not an InteractionRequest');
1065
+ }
1066
+ const decryptedPayload = decryptedEvent.content.payload;
1067
+ expect(decryptedPayload).toBeDefined();
1068
+ expect(decryptedPayload?.content.case).toBe('form');
1069
+ if (decryptedPayload?.content.case === 'form') {
1070
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1071
+ expect(decryptedPayload.content.value.components).toHaveLength(2);
1072
+ expect(decryptedPayload.content.value.components[0].id).toBe('btn-1');
1073
+ expect(decryptedPayload.content.value.components[0].component.case).toBe('button');
1074
+ expect(decryptedPayload.content.value.components[1].id).toBe('input-1');
1075
+ expect(decryptedPayload.content.value.components[1].component.case).toBe('textInput');
1076
+ }
1077
+ });
1078
+ it('bot should be able to send transaction interaction request using flattened API', async () => {
1079
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1080
+ const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
1081
+ type: 'transaction',
1082
+ title: 'Send USDC',
1083
+ subtitle: 'Send 50 USDC to recipient',
1084
+ chainId: '8453',
1085
+ signerWallet: botClientAddress,
1086
+ txs: [
1087
+ {
1088
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
1089
+ value: '0',
1090
+ data: '0xa9059cbb', // transfer function selector
1091
+ },
1092
+ ],
1093
+ });
1094
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1095
+ // Wait for decryption to complete
1096
+ await waitFor(() => {
1097
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1098
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1099
+ return false;
1100
+ }
1101
+ return event?.content?.payload !== undefined;
1102
+ });
1103
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1104
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1105
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1106
+ throw new Error('Event is not an InteractionRequest');
1107
+ }
1108
+ const decryptedPayload = decryptedEvent.content.payload;
1109
+ expect(decryptedPayload).toBeDefined();
1110
+ expect(decryptedPayload?.content.case).toBe('transaction');
1111
+ if (decryptedPayload?.content.case === 'transaction') {
1112
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1113
+ expect(decryptedPayload.content.value.title).toBe('Send USDC');
1114
+ expect(decryptedPayload.content.value.subtitle).toBe('Send 50 USDC to recipient');
1115
+ expect(decryptedPayload.content.value.content.case).toBe('evm');
1116
+ if (decryptedPayload.content.value.content.case === 'evm') {
1117
+ const evmBatch = decryptedPayload.content.value.content.value;
1118
+ expect(evmBatch.chainId).toBe('8453');
1119
+ expect(evmBatch.transactions).toHaveLength(1);
1120
+ expect(evmBatch.transactions[0].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1121
+ expect(evmBatch.transactions[0].value).toBe('0');
1122
+ expect(evmBatch.transactions[0].data).toBe('0xa9059cbb');
1123
+ }
1124
+ }
1125
+ });
1126
+ it('bot should be able to send batched transaction interaction request', async () => {
1127
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1128
+ const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
1129
+ type: 'transaction',
1130
+ title: 'Batch Transaction',
1131
+ subtitle: 'Approve and transfer USDC',
1132
+ chainId: '8453',
1133
+ signerWallet: botClientAddress,
1134
+ txs: [
1135
+ {
1136
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
1137
+ value: '0',
1138
+ data: '0x095ea7b3', // approve function selector
1139
+ },
1140
+ {
1141
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
1142
+ value: '0',
1143
+ data: '0xa9059cbb', // transfer function selector
1144
+ },
1145
+ ],
1146
+ });
1147
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1148
+ // Wait for decryption to complete
1149
+ await waitFor(() => {
1150
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1151
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1152
+ return false;
1153
+ }
1154
+ return event?.content?.payload !== undefined;
1155
+ });
1156
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1157
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1158
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1159
+ throw new Error('Event is not an InteractionRequest');
1160
+ }
1161
+ const decryptedPayload = decryptedEvent.content.payload;
1162
+ expect(decryptedPayload).toBeDefined();
1163
+ expect(decryptedPayload?.content.case).toBe('transaction');
1164
+ if (decryptedPayload?.content.case === 'transaction') {
1165
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1166
+ expect(decryptedPayload.content.value.title).toBe('Batch Transaction');
1167
+ expect(decryptedPayload.content.value.content.case).toBe('evm');
1168
+ if (decryptedPayload.content.value.content.case === 'evm') {
1169
+ const evmBatch = decryptedPayload.content.value.content.value;
1170
+ expect(evmBatch.chainId).toBe('8453');
1171
+ expect(evmBatch.signerWallet).toBe(botClientAddress);
1172
+ expect(evmBatch.transactions).toHaveLength(2);
1173
+ // First tx: approve
1174
+ expect(evmBatch.transactions[0].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1175
+ expect(evmBatch.transactions[0].data).toBe('0x095ea7b3');
1176
+ // Second tx: transfer
1177
+ expect(evmBatch.transactions[1].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1178
+ expect(evmBatch.transactions[1].data).toBe('0xa9059cbb');
1179
+ }
1180
+ }
1181
+ });
1182
+ it('bot should be able to send preparedCalls interaction request using flattened API', async () => {
1183
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1184
+ const { eventId, requestId } = await bot.sendInteractionRequest(channelId, {
1185
+ type: 'preparedCalls',
1186
+ chainId: '8453',
1187
+ typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
1188
+ context: '{"some":"context"}',
1189
+ title: 'Approve Token Spend',
1190
+ subtitle: 'Allow the app to spend up to 100 USDC',
1191
+ authorizeKeys: [
1192
+ {
1193
+ expiry: '1700000000',
1194
+ type: 'secp256k1',
1195
+ role: 'normal',
1196
+ publicKey: '0x04abcdef',
1197
+ permissions: [
1198
+ {
1199
+ type: 'spend',
1200
+ token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
1201
+ limit: '100000000',
1202
+ period: '86400',
1203
+ },
1204
+ ],
1205
+ },
1206
+ ],
1207
+ });
1208
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1209
+ await waitFor(() => {
1210
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1211
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1212
+ return false;
1213
+ }
1214
+ return event?.content?.payload !== undefined;
1215
+ });
1216
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1217
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1218
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1219
+ throw new Error('Event is not an InteractionRequest');
1220
+ }
1221
+ const decryptedPayload = decryptedEvent.content.payload;
1222
+ expect(decryptedPayload).toBeDefined();
1223
+ expect(decryptedPayload?.content.case).toBe('preparedCalls');
1224
+ if (decryptedPayload?.content.case === 'preparedCalls') {
1225
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1226
+ expect(decryptedPayload.content.value.chainId).toBe('8453');
1227
+ expect(decryptedPayload.content.value.typedData).toBe('{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}');
1228
+ expect(decryptedPayload.content.value.context).toBe('{"some":"context"}');
1229
+ expect(decryptedPayload.content.value.title).toBe('Approve Token Spend');
1230
+ expect(decryptedPayload.content.value.subtitle).toBe('Allow the app to spend up to 100 USDC');
1231
+ expect(decryptedPayload.content.value.authorizeKeys).toHaveLength(1);
1232
+ const key = decryptedPayload.content.value.authorizeKeys[0];
1233
+ expect(key.expiry).toBe('1700000000');
1234
+ expect(key.type).toBe('secp256k1');
1235
+ expect(key.role).toBe('normal');
1236
+ expect(key.publicKey).toBe('0x04abcdef');
1237
+ expect(key.permissions).toHaveLength(1);
1238
+ expect(key.permissions[0].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.SPEND);
1239
+ expect(key.permissions[0].permission.case).toBe('spend');
1240
+ if (key.permissions[0].permission.case === 'spend') {
1241
+ expect(key.permissions[0].permission.value.token).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1242
+ expect(key.permissions[0].permission.value.limit).toBe('100000000');
1243
+ expect(key.permissions[0].permission.value.period).toBe('86400');
1244
+ }
1245
+ }
1246
+ });
1247
+ it('bot should be able to send preparedCalls with authorizeKeys', async () => {
1248
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1249
+ const { eventId } = await bot.sendInteractionRequest(channelId, {
1250
+ type: 'preparedCalls',
1251
+ chainId: '8453',
1252
+ typedData: '{"types":{},"primaryType":"","domain":{},"message":{}}',
1253
+ context: '{}',
1254
+ title: 'Authorize Session Key',
1255
+ authorizeKeys: [
1256
+ {
1257
+ expiry: '1700000000',
1258
+ type: 'secp256k1',
1259
+ role: 'normal',
1260
+ publicKey: '0x04abcdef',
1261
+ permissions: [
1262
+ {
1263
+ type: 'call',
1264
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
1265
+ selector: '0xa9059cbb',
1266
+ },
1267
+ {
1268
+ type: 'spend',
1269
+ token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
1270
+ limit: '1000000',
1271
+ period: 'day',
1272
+ },
1273
+ ],
1274
+ },
1275
+ ],
1276
+ });
1277
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1278
+ await waitFor(() => {
1279
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1280
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1281
+ return false;
1282
+ }
1283
+ return event?.content?.payload !== undefined;
1284
+ });
1285
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1286
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1287
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1288
+ throw new Error('Event is not an InteractionRequest');
1289
+ }
1290
+ const decryptedPayload = decryptedEvent.content.payload;
1291
+ expect(decryptedPayload).toBeDefined();
1292
+ expect(decryptedPayload?.content.case).toBe('preparedCalls');
1293
+ if (decryptedPayload?.content.case === 'preparedCalls') {
1294
+ expect(decryptedPayload.content.value.authorizeKeys).toHaveLength(1);
1295
+ const key = decryptedPayload.content.value.authorizeKeys[0];
1296
+ expect(key.expiry).toBe('1700000000');
1297
+ expect(key.type).toBe('secp256k1');
1298
+ expect(key.role).toBe('normal');
1299
+ expect(key.publicKey).toBe('0x04abcdef');
1300
+ expect(key.permissions).toHaveLength(2);
1301
+ expect(key.permissions[0].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.CALL);
1302
+ expect(key.permissions[0].permission.case).toBe('call');
1303
+ if (key.permissions[0].permission.case === 'call') {
1304
+ expect(key.permissions[0].permission.value.to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1305
+ expect(key.permissions[0].permission.value.selector).toBe('0xa9059cbb');
1306
+ }
1307
+ expect(key.permissions[1].type).toBe(InteractionRequestPayload_PreparedCalls_Permission_Type.SPEND);
1308
+ expect(key.permissions[1].permission.case).toBe('spend');
1309
+ if (key.permissions[1].permission.case === 'spend') {
1310
+ expect(key.permissions[1].permission.value.token).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1311
+ expect(key.permissions[1].permission.value.limit).toBe('1000000');
1312
+ expect(key.permissions[1].permission.value.period).toBe('day');
1313
+ }
1314
+ }
1315
+ });
1316
+ it('bot should be able to send addMember interaction request and user should respond', async () => {
1317
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1318
+ // Create a new GDM with bob, carol, and the bot (alice is NOT in it)
1319
+ const { streamId: addMemberGdmId } = await bobClient.gdms.createGDM([
1320
+ carol.userId,
1321
+ bot.agentUserId,
1322
+ ]);
1323
+ await bobClient.riverConnection.call((client) => client.waitForStream(addMemberGdmId));
1324
+ const bobAddMemberGdm = bobClient.gdms.getGdm(addMemberGdmId);
1325
+ // Bot sends addMember interaction request to bob
1326
+ const { eventId, requestId } = await bot.sendInteractionRequest(addMemberGdmId, {
1327
+ type: 'addMember',
1328
+ userId: alice.userId,
1329
+ message: 'Please add Alice to the group, she has great intel!',
1330
+ });
1331
+ // Wait for Bob to receive the interaction request
1332
+ await waitFor(() => expect(bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1333
+ // Wait for decryption to complete
1334
+ await waitFor(() => {
1335
+ const event = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
1336
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1337
+ return false;
1338
+ }
1339
+ return event?.content?.payload !== undefined;
1340
+ });
1341
+ // Bob decrypts the request and verifies
1342
+ const decryptedEvent = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
1343
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1344
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1345
+ throw new Error('Event is not an InteractionRequest');
1346
+ }
1347
+ const decryptedPayload = decryptedEvent.content.payload;
1348
+ expect(decryptedPayload).toBeDefined();
1349
+ expect(decryptedPayload?.content.case).toBe('addMember');
1350
+ if (decryptedPayload?.content.case === 'addMember') {
1351
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1352
+ expect(decryptedPayload.content.value.userId).toEqual(bin_fromHexString(alice.userId));
1353
+ expect(decryptedPayload.content.value.message).toBe('Please add Alice to the group, she has great intel!');
1354
+ }
1355
+ const encryptionDevice = decryptedPayload?.encryptionDevice;
1356
+ check(isDefined(encryptionDevice), 'encryptionDevice is defined');
1357
+ expect(encryptionDevice?.deviceKey).toBeDefined();
1358
+ expect(encryptionDevice?.fallbackKey).toBeDefined();
1359
+ // Bob sends addMember interaction response with accepted: true
1360
+ const recipient = bin_fromHexString(botClientAddress);
1361
+ const interactionResponsePayload = {
1362
+ salt: genIdBlob(),
1363
+ content: {
1364
+ case: 'addMember',
1365
+ value: {
1366
+ requestId: requestId,
1367
+ accepted: true,
1368
+ },
1369
+ },
1370
+ };
1371
+ const receivedInteractionResponses = [];
1372
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
1373
+ receivedInteractionResponses.push(e.response);
1374
+ }));
1375
+ // Bob calls joinUser() to add alice to the GDM
1376
+ await bobClient.riverConnection.call((client) => client.joinUser(addMemberGdmId, alice.userId));
1377
+ await bobClient.riverConnection.call(async (client) => {
1378
+ return await client.sendInteractionResponse(addMemberGdmId, recipient, interactionResponsePayload, encryptionDevice);
1379
+ });
1380
+ // Bot receives the response and verifies
1381
+ await waitFor(() => receivedInteractionResponses.length > 0);
1382
+ expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
1383
+ expect(receivedInteractionResponses[0].payload.content.case).toBe('addMember');
1384
+ if (receivedInteractionResponses[0].payload.content.case === 'addMember') {
1385
+ expect(receivedInteractionResponses[0].payload.content.value.requestId).toBe(requestId);
1386
+ expect(receivedInteractionResponses[0].payload.content.value.accepted).toBe(true);
1387
+ }
1388
+ // Verify alice is now a member of the GDM
1389
+ await waitFor(() => {
1390
+ expect(bobAddMemberGdm.members.value.userIds).toContain(alice.userId);
1391
+ });
1392
+ });
1393
+ it('bot should be able to send batch interaction request with mixed steps using flattened API', async () => {
1394
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1395
+ const { eventId, requestId: batchId } = await bot.sendInteractionRequest(channelId, {
1396
+ type: 'batch',
1397
+ title: 'Cross-chain Swap',
1398
+ subtitle: 'Approve on Base, swap on Mainnet, confirm signature',
1399
+ steps: [
1400
+ {
1401
+ type: 'preparedCalls',
1402
+ chainId: '8453',
1403
+ typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
1404
+ context: '{"step":"approve"}',
1405
+ title: 'Approve USDC on Base',
1406
+ },
1407
+ {
1408
+ type: 'preparedCalls',
1409
+ chainId: '1',
1410
+ typedData: '{"types":{"EIP712Domain":[]},"primaryType":"EIP712Domain","domain":{},"message":{}}',
1411
+ context: '{"step":"swap"}',
1412
+ title: 'Swap on Mainnet',
1413
+ },
1414
+ {
1415
+ type: 'signature',
1416
+ data: '0xdeadbeef',
1417
+ chainId: '1',
1418
+ method: 'typed_data',
1419
+ signerWallet: botClientAddress,
1420
+ title: 'Confirm swap',
1421
+ },
1422
+ ],
1423
+ });
1424
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1425
+ await waitFor(() => {
1426
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1427
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1428
+ return false;
1429
+ }
1430
+ return event?.content?.payload !== undefined;
1431
+ });
1432
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1433
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1434
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1435
+ throw new Error('Event is not an InteractionRequest');
1436
+ }
1437
+ const decryptedPayload = decryptedEvent.content.payload;
1438
+ expect(decryptedPayload).toBeDefined();
1439
+ expect(decryptedPayload?.content.case).toBe('batch');
1440
+ if (decryptedPayload?.content.case === 'batch') {
1441
+ expect(decryptedPayload.content.value.id).toBe(batchId);
1442
+ expect(decryptedPayload.content.value.title).toBe('Cross-chain Swap');
1443
+ expect(decryptedPayload.content.value.subtitle).toBe('Approve on Base, swap on Mainnet, confirm signature');
1444
+ expect(decryptedPayload.content.value.steps).toHaveLength(3);
1445
+ // Step 1: PreparedCalls on Base
1446
+ const step1 = decryptedPayload.content.value.steps[0];
1447
+ expect(step1.content.case).toBe('preparedCalls');
1448
+ if (step1.content.case === 'preparedCalls') {
1449
+ expect(step1.content.value.id).toEqual(expect.any(String));
1450
+ expect(step1.content.value.chainId).toBe('8453');
1451
+ expect(step1.content.value.context).toBe('{"step":"approve"}');
1452
+ expect(step1.content.value.title).toBe('Approve USDC on Base');
1453
+ }
1454
+ // Step 2: PreparedCalls on Mainnet
1455
+ const step2 = decryptedPayload.content.value.steps[1];
1456
+ expect(step2.content.case).toBe('preparedCalls');
1457
+ if (step2.content.case === 'preparedCalls') {
1458
+ expect(step2.content.value.id).toEqual(expect.any(String));
1459
+ expect(step2.content.value.chainId).toBe('1');
1460
+ expect(step2.content.value.context).toBe('{"step":"swap"}');
1461
+ expect(step2.content.value.title).toBe('Swap on Mainnet');
1462
+ }
1463
+ // Step 3: Signature on Mainnet
1464
+ const step3 = decryptedPayload.content.value.steps[2];
1465
+ expect(step3.content.case).toBe('signature');
1466
+ if (step3.content.case === 'signature') {
1467
+ expect(step3.content.value.id).toEqual(expect.any(String));
1468
+ expect(step3.content.value.data).toBe('0xdeadbeef');
1469
+ expect(step3.content.value.chainId).toBe('1');
1470
+ expect(step3.content.value.type).toBe(InteractionRequestPayload_Signature_SignatureType.TYPED_DATA);
1471
+ }
1472
+ }
1473
+ });
1474
+ it('joinUser should set appAddress when adding bot to GDM', async () => {
1475
+ // Create a GDM with bob and carol (no bot yet)
1476
+ const { streamId: testGdmId } = await bobClient.gdms.createGDM([carol.userId]);
1477
+ await bobClient.riverConnection.call((client) => client.waitForStream(testGdmId));
1478
+ // Add the bot to the GDM with appAddress
1479
+ await bobClient.riverConnection.call((client) => client.joinUser(testGdmId, botClientAddress));
1480
+ // Verify the bot's membership has appAddress set
1481
+ await waitFor(async () => {
1482
+ const gdmStreamView = await bobClient.riverConnection.call((client) => client.getStream(testGdmId));
1483
+ const { joined } = gdmStreamView.getMembers();
1484
+ expect(joined.get(botClientAddress)?.appAddress).toBe(appAddress);
1485
+ });
1486
+ });
1487
+ it('user should be able to send form interaction response', async () => {
1488
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
1489
+ const recipient = bin_fromHexString(botClientAddress);
1490
+ const interactionResponsePayload = {
1491
+ salt: genIdBlob(),
1492
+ content: {
1493
+ case: 'form',
1494
+ value: {
1495
+ requestId: randomUUID(),
1496
+ components: [
1497
+ { id: '1', component: { case: 'button', value: {} } },
1498
+ {
1499
+ id: '2',
1500
+ component: { case: 'textInput', value: { value: 'Text Input' } },
1501
+ },
1502
+ ],
1503
+ },
1504
+ },
1505
+ };
1506
+ const receivedInteractionResponses = [];
1507
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
1508
+ receivedInteractionResponses.push(e.response);
1509
+ }));
1510
+ await bobClient.riverConnection.call(async (client) => {
1511
+ return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, bot.getUserDevice());
1512
+ });
1513
+ await waitFor(() => receivedInteractionResponses.length > 0);
1514
+ });
1515
+ it('bot should be able to join another app to a GDM via joinUser', async () => {
1516
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([carol.userId, botClientAddress]);
1517
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
1518
+ const gdm = bobClient.gdms.getGdm(gdmId);
1519
+ const app2Result = await createAppForOwner(bobClient, {
1520
+ username: `app2_${genId(8)}`,
1521
+ displayName: 'App 2',
1522
+ description: 'Second app for joinUser test',
1523
+ imageUrl: 'https://placehold.co/600x600',
1524
+ });
1525
+ const app2Address = app2Result.appAddress;
1526
+ await bot.joinUser(gdmId, app2Address);
1527
+ await waitFor(() => {
1528
+ expect(gdm.members.value.userIds).toContain(app2Address);
1529
+ });
1530
+ const app2Id = bin_fromHexString(app2Address);
1531
+ await appRegistryRpcClient.updateAppSettings({
1532
+ appId: app2Id,
1533
+ forwardSetting: ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES,
1534
+ updateMask: ['forward_setting'],
1535
+ });
1536
+ const app2Agent = await makeTownsApp(app2Result.appPrivateData, {
1537
+ jwtSecret: app2Result.jwtSecretBase64,
1538
+ commands: [],
1539
+ });
1540
+ const app2Port = Number(process.env.BOT_PORT) + 2;
1541
+ const app2WebhookUrl = `http://localhost:${app2Port}/webhook`;
1542
+ const app2 = app2Agent.start();
1543
+ const app2Server = serve({
1544
+ port: app2Port,
1545
+ fetch: app2.fetch,
1546
+ });
1547
+ await appRegistryRpcClient.registerWebhook({
1548
+ appId: bin_fromHexString(app2Address),
1549
+ webhookUrl: app2WebhookUrl,
1550
+ });
1551
+ const receivedByApp2 = [];
1552
+ subscriptions.push(app2Agent.onMessage((_h, e) => {
1553
+ receivedByApp2.push(e);
1554
+ }));
1555
+ try {
1556
+ const messageText = 'Hi app2, welcome to the GDM.';
1557
+ const { eventId } = await gdm.sendMessage(messageText);
1558
+ await waitFor(() => receivedByApp2.length > 0, { timeoutMS: 15_000 });
1559
+ const receivedMessage = receivedByApp2.find((x) => x.eventId === eventId);
1560
+ expect(receivedMessage).toBeDefined();
1561
+ expect(receivedMessage?.message).toBe(messageText);
1562
+ expect(receivedMessage?.userId).toBe(bob.userId);
1563
+ }
1564
+ finally {
1565
+ app2Server.close();
1566
+ }
1567
+ });
1568
+ it('bot should be able to pin and unpin their own messages', async () => {
1569
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1570
+ const { eventId, envelope } = await bot.sendMessage(channelId, 'Hello');
1571
+ const parsedEvnet = await bot.client.unpackEnvelope(envelope);
1572
+ const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, parsedEvnet.event);
1573
+ log('pinned event', pinEventId);
1574
+ const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId);
1575
+ log('unpinned event', unpinEventId);
1576
+ });
1577
+ // @miguel-nascimento 2025-12-08 flaky test
1578
+ it.skip('bot should be able to pin and unpin other users messages', async () => {
1579
+ // Skipped: marked flaky in prior runs.
1580
+ /*
1581
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES)
1582
+ const { eventId } = await bobDefaultGdm.sendMessage('Hello')
1583
+ const receivedMessages: OnMessageType[] = []
1584
+ subscriptions.push(
1585
+ bot.onMessage((_h, e) => {
1586
+ receivedMessages.push(e)
1587
+ }),
1588
+ )
1589
+ await waitFor(() => receivedMessages.length > 0)
1590
+ const message = receivedMessages.find((x) => x.eventId === eventId)
1591
+ check(isDefined(message), 'message is defined')
1592
+ expect(message).toBeDefined()
1593
+ expect(message?.event).toBeDefined()
1594
+
1595
+ const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, message.event)
1596
+ log('pinned event', pinEventId)
1597
+ const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId)
1598
+ log('unpinned event', unpinEventId)
1599
+ */
1600
+ });
1601
+ it('bob (bot owner) should be able to update bot profile image', async () => {
1602
+ // Create mock chunked media info (following pattern from client.test.ts:1010-1047)
1603
+ const mediaStreamId = makeUniqueMediaStreamId();
1604
+ const image = create(MediaInfoSchema, {
1605
+ mimetype: 'image/png',
1606
+ filename: 'bot-avatar.png',
1607
+ });
1608
+ const { key, iv } = await deriveKeyAndIV(nanoid(128));
1609
+ const chunkedMediaInfo = {
1610
+ info: image,
1611
+ streamId: mediaStreamId,
1612
+ encryption: {
1613
+ case: 'aesgcm',
1614
+ value: { secretKey: key, iv },
1615
+ },
1616
+ thumbnail: undefined,
1617
+ };
1618
+ // Bob (bot owner) updates the bot's profile image using setUserProfileImageFor
1619
+ await bobClient.riverConnection.call(async (client) => {
1620
+ await client.setUserProfileImage(chunkedMediaInfo, botClientAddress);
1621
+ });
1622
+ await waitFor(async () => {
1623
+ // Verify the bot's profile image was updated
1624
+ // in waitFor because sometimes it takes a second before you can getStream on a media stream
1625
+ const decrypted = await bobClient.riverConnection.call(async (client) => {
1626
+ return await client.getUserProfileImage(botClientAddress);
1627
+ });
1628
+ expect(decrypted).toBeDefined();
1629
+ expect(decrypted?.info?.mimetype).toBe('image/png');
1630
+ expect(decrypted?.info?.filename).toBe('bot-avatar.png');
1631
+ });
1632
+ });
1633
+ it('agent should receive messages from another agent', async () => {
1634
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1635
+ // Get contract addresses for this environment
1636
+ const chainId = townsConfig.base.chainConfig.chainId;
1637
+ const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
1638
+ if (!addresses?.accountProxy) {
1639
+ throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
1640
+ }
1641
+ // Create relayer client for agent2
1642
+ const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
1643
+ const relayerClient = createPublicClient({
1644
+ chain: {
1645
+ id: chainId,
1646
+ name: 'Test Chain',
1647
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
1648
+ rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
1649
+ },
1650
+ transport: http(townsConfig.base.rpcUrl),
1651
+ }).extend(relayerActions({ relayerUrl }));
1652
+ // Create agent2 using relayer
1653
+ const agent2Result = await createApp({
1654
+ owner: bobClient.riverConnection.signerContext,
1655
+ metadata: {
1656
+ username: `agent2_${genId(8)}`,
1657
+ displayName: 'Agent 2',
1658
+ description: 'Second test agent',
1659
+ imageUrl: 'https://placehold.co/600x600',
1660
+ },
1661
+ relayerClient,
1662
+ accountProxy: addresses.accountProxy,
1663
+ townsConfig,
1664
+ });
1665
+ const agent2Address = agent2Result.appAddress;
1666
+ // Join agent2 to the channel
1667
+ await bobClient.riverConnection.call((client) => client.joinUser(channelId, agent2Address));
1668
+ // Set forward setting for agent2 (registration already done by createApp)
1669
+ const agent2Id = bin_fromHexString(agent2Address);
1670
+ await appRegistryRpcClient.updateAppSettings({
1671
+ appId: agent2Id,
1672
+ forwardSetting: ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES,
1673
+ updateMask: ['forward_setting'],
1674
+ });
1675
+ const agent2 = await makeTownsApp(agent2Result.appPrivateData, {
1676
+ jwtSecret: agent2Result.jwtSecretBase64,
1677
+ commands: [],
1678
+ });
1679
+ // Start agent2 server on a different port
1680
+ const agent2Port = Number(process.env.BOT_PORT) + 1;
1681
+ const agent2WebhookUrl = `http://localhost:${agent2Port}/webhook`;
1682
+ const app2 = agent2.start();
1683
+ serve({
1684
+ port: agent2Port,
1685
+ fetch: app2.fetch,
1686
+ });
1687
+ await appRegistryRpcClient.registerWebhook({
1688
+ appId: bin_fromHexString(agent2Address),
1689
+ webhookUrl: agent2WebhookUrl,
1690
+ });
1691
+ // Subscribe to messages on agent2
1692
+ const receivedByAgent2 = [];
1693
+ subscriptions.push(agent2.onMessage((_h, e) => {
1694
+ receivedByAgent2.push(e);
1695
+ }));
1696
+ // Agent1 sends a message mentioning Agent2
1697
+ const testMessage = 'Hey @agent2, can you help with this?';
1698
+ const { eventId } = await bot.sendMessage(channelId, testMessage, {
1699
+ mentions: [
1700
+ {
1701
+ userId: agent2Address,
1702
+ displayName: 'Agent 2',
1703
+ },
1704
+ ],
1705
+ });
1706
+ // Verify Agent2 received the message from Agent1
1707
+ await waitFor(() => receivedByAgent2.length > 0, { timeoutMS: 15_000 });
1708
+ const receivedMessage = receivedByAgent2.find((x) => x.eventId === eventId);
1709
+ expect(receivedMessage).toBeDefined();
1710
+ expect(receivedMessage?.message).toBe(testMessage);
1711
+ expect(receivedMessage?.userId).toBe(botClientAddress); // Sent by agent1
1712
+ expect(receivedMessage?.isMentioned).toBe(true); // Agent2 was mentioned
1713
+ });
1714
+ it('bot should send conversationSeed and bob should receive it', async () => {
1715
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1716
+ const testSeed = {
1717
+ id: 'seed-001',
1718
+ createdAtMs: BigInt(Date.now()),
1719
+ proposalTimeoutMs: 30000n,
1720
+ conciergeTimeoutMs: 60000n,
1721
+ conciergeId: new Uint8Array(20),
1722
+ version: 1n,
1723
+ query: 'What is the best swap for ETH to USDC?',
1724
+ responseText: 'Here are some options for swapping ETH to USDC.',
1725
+ proposals: [
1726
+ {
1727
+ id: 'prop-001',
1728
+ targetAgentId: new Uint8Array(20),
1729
+ targetAgentName: 'Swap Agent',
1730
+ capabilityName: 'swap',
1731
+ parameters: '{"from":"ETH","to":"USDC"}',
1732
+ explanation: 'Swap ETH to USDC via Uniswap',
1733
+ confidence: 0.95,
1734
+ expiresAtMs: 0n,
1735
+ warnings: [],
1736
+ title: 'Swap ETH to USDC',
1737
+ },
1738
+ ],
1739
+ invocations: [],
1740
+ invocationsUpdatedAtMs: 0n,
1741
+ proposalsByAgent: {},
1742
+ selectedProposalIds: [],
1743
+ clarifyingQuestions: ['How much ETH do you want to swap?'],
1744
+ conciergeRespondedAtMs: 0n,
1745
+ statusUpdates: [],
1746
+ userTimezone: 'America/New_York',
1747
+ conciergeFailedAtMs: 0n,
1748
+ };
1749
+ const { eventId } = await bot.sendConversationSeed(channelId, testSeed);
1750
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1751
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1752
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ConversationSeed);
1753
+ });
1754
+ it('bot should receive conversationSeedResponse sent by bob', async () => {
1755
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1756
+ const receivedResponses = [];
1757
+ subscriptions.push(bot.onConversationSeedResponse((_h, e) => {
1758
+ receivedResponses.push(e);
1759
+ }));
1760
+ // Bob sends a conversationSeedResponse with a userMessage
1761
+ const { eventId } = await bobClient.riverConnection.call((client) => client.sendChannelMessage_ConversationSeedResponse(channelId, {
1762
+ content: {
1763
+ seedId: 'seed-001',
1764
+ response: {
1765
+ case: 'userMessage',
1766
+ value: 'I want to swap 1 ETH',
1767
+ },
1768
+ },
1769
+ }));
1770
+ await waitFor(() => receivedResponses.length > 0);
1771
+ const event = receivedResponses.find((x) => x.eventId === eventId);
1772
+ expect(event).toBeDefined();
1773
+ expect(event?.seedId).toBe('seed-001');
1774
+ expect(event?.seed).toBeUndefined();
1775
+ expect(event?.response.case).toBe('userMessage');
1776
+ expect(event?.response.value).toBe('I want to swap 1 ETH');
1777
+ });
1778
+ it('bot should receive conversationSeedResponse with selectedProposal', async () => {
1779
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1780
+ const receivedResponses = [];
1781
+ subscriptions.push(bot.onConversationSeedResponse((_h, e) => {
1782
+ receivedResponses.push(e);
1783
+ }));
1784
+ // Bob sends a conversationSeedResponse selecting a proposal
1785
+ const { eventId } = await bobClient.riverConnection.call((client) => client.sendChannelMessage_ConversationSeedResponse(channelId, {
1786
+ content: {
1787
+ seedId: 'seed-001',
1788
+ response: {
1789
+ case: 'selectedProposal',
1790
+ value: {
1791
+ id: 'prop-001',
1792
+ targetAgentId: new Uint8Array(20),
1793
+ targetAgentName: 'Swap Agent',
1794
+ capabilityName: 'swap',
1795
+ parameters: '{"from":"ETH","to":"USDC"}',
1796
+ explanation: 'Swap ETH to USDC via Uniswap',
1797
+ confidence: 0.95,
1798
+ expiresAtMs: 0n,
1799
+ warnings: [],
1800
+ title: 'Swap ETH to USDC',
1801
+ },
1802
+ },
1803
+ },
1804
+ }));
1805
+ await waitFor(() => receivedResponses.length > 0);
1806
+ const event = receivedResponses.find((x) => x.eventId === eventId);
1807
+ expect(event).toBeDefined();
1808
+ expect(event?.seedId).toBe('seed-001');
1809
+ expect(event?.response.case).toBe('selectedProposal');
1810
+ if (event?.response.case === 'selectedProposal') {
1811
+ expect(event.response.value.id).toBe('prop-001');
1812
+ expect(event.response.value.targetAgentName).toBe('Swap Agent');
1813
+ expect(event.response.value.capabilityName).toBe('swap');
1814
+ }
1815
+ });
1816
+ it.skip('bot should handle proposals request and return proposals', async () => {
1817
+ const unsub = bot.onCapability('swap', (_handler, event) => {
1818
+ expect(event.capabilityName).toBe('swap');
1819
+ expect(event.userQuery).toBe('Swap 1 ETH to USDC');
1820
+ return [
1821
+ {
1822
+ title: 'Swap ETH to USDC',
1823
+ explanation: 'Swap 1 ETH for ~2000 USDC via Uniswap',
1824
+ confidence: 0.95,
1825
+ parameters: JSON.stringify({ from: 'ETH', to: 'USDC', amount: '1' }),
1826
+ warnings: ['Slippage may apply'],
1827
+ expectedOutcome: '~2000 USDC',
1828
+ },
1829
+ ];
1830
+ });
1831
+ subscriptions.push(unsub);
1832
+ const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
1833
+ query: 'Swap 1 ETH to USDC',
1834
+ });
1835
+ const resp = await appRegistryRpcClient.getBulkAgentProposals({
1836
+ conversationSeedId,
1837
+ requests: [
1838
+ {
1839
+ agentId: bin_fromHexString(botClientAddress),
1840
+ capabilityName: 'swap',
1841
+ parameters: '{"from":"ETH","to":"USDC","amount":"1"}',
1842
+ },
1843
+ ],
1844
+ });
1845
+ expect(resp.accepted).toBe(true);
1846
+ });
1847
+ it.skip('bot should return accepted for unknown capability in proposals request', async () => {
1848
+ const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
1849
+ query: 'Do something unknown',
1850
+ });
1851
+ const resp = await appRegistryRpcClient.getBulkAgentProposals({
1852
+ conversationSeedId,
1853
+ requests: [
1854
+ {
1855
+ agentId: bin_fromHexString(botClientAddress),
1856
+ capabilityName: 'nonexistent_capability',
1857
+ parameters: '{}',
1858
+ },
1859
+ ],
1860
+ });
1861
+ expect(resp.accepted).toBe(true);
1862
+ });
1863
+ it.skip('bot should return multiple proposals from a single capability handler', async () => {
1864
+ const unsub = bot.onCapability('multi_swap', (_handler, _event) => {
1865
+ return [
1866
+ {
1867
+ title: 'Uniswap Direct',
1868
+ explanation: 'Route A: Uniswap direct',
1869
+ confidence: 0.9,
1870
+ parameters: '{"route":"uniswap"}',
1871
+ },
1872
+ {
1873
+ title: '1inch Aggregator',
1874
+ explanation: 'Route B: 1inch aggregator',
1875
+ confidence: 0.85,
1876
+ parameters: '{"route":"1inch"}',
1877
+ },
1878
+ ];
1879
+ });
1880
+ subscriptions.push(unsub);
1881
+ const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
1882
+ query: 'Best route for ETH to USDC',
1883
+ });
1884
+ const resp = await appRegistryRpcClient.getBulkAgentProposals({
1885
+ conversationSeedId,
1886
+ requests: [
1887
+ {
1888
+ agentId: bin_fromHexString(botClientAddress),
1889
+ capabilityName: 'multi_swap',
1890
+ parameters: '{}',
1891
+ },
1892
+ ],
1893
+ });
1894
+ expect(resp.accepted).toBe(true);
1895
+ });
1896
+ it('bot should handle positions request and return positions via webhook', async () => {
1897
+ const unsub = bot.onPositions((_handler, event) => {
1898
+ expect(event.userId).toBeDefined();
1899
+ return {
1900
+ supported: true,
1901
+ positions: [
1902
+ {
1903
+ label: 'ETH-PERP Long',
1904
+ symbol: 'ETH',
1905
+ type: PositionType.PERPETUAL,
1906
+ balanceUsd: '5000.00',
1907
+ pnlUsd: '+250.00',
1908
+ description: '10x leverage',
1909
+ },
1910
+ ],
1911
+ timestamp: BigInt(Date.now()),
1912
+ };
1913
+ });
1914
+ subscriptions.push(unsub);
1915
+ // Enable positions API so app registry will forward positions requests
1916
+ const posAppId = bin_fromHexString(botClientAddress);
1917
+ await appRegistryRpcClient.updateAppSettings({
1918
+ appId: posAppId,
1919
+ supportedApis: [SupportedApi.POSITIONS],
1920
+ updateMask: ['supported_apis'],
1921
+ });
1922
+ // Request positions through app registry (which proxies to bot webhook)
1923
+ const result = await appRegistryRpcClient.getAppPositions({
1924
+ appId: bin_fromHexString(botClientAddress),
1925
+ });
1926
+ expect(result.positions).toBeDefined();
1927
+ expect(result.positions.supported).toBe(true);
1928
+ expect(result.positions.positions).toHaveLength(1);
1929
+ expect(result.positions.positions[0].label).toBe('ETH-PERP Long');
1930
+ expect(result.positions.positions[0].symbol).toBe('ETH');
1931
+ expect(result.positions.positions[0].balanceUsd).toBe('5000.00');
1932
+ expect(result.positions.positions[0].pnlUsd).toBe('+250.00');
1933
+ });
1934
+ it('getBulkAgentCapabilities should return capabilities for registered bot', async () => {
1935
+ // Register a capability on the bot
1936
+ const unsub = bot.onCapability('e2e_swap', (_handler, _event) => {
1937
+ return [
1938
+ {
1939
+ title: 'Test Swap',
1940
+ explanation: 'test',
1941
+ confidence: 1.0,
1942
+ parameters: '{}',
1943
+ },
1944
+ ];
1945
+ });
1946
+ subscriptions.push(unsub);
1947
+ // Update capabilities via the app registry
1948
+ await appRegistryRpcClient.updateAppMetadata({
1949
+ appId: bin_fromHexString(botClientAddress),
1950
+ metadata: {
1951
+ capabilities: [
1952
+ {
1953
+ name: 'e2e_swap',
1954
+ description: 'Swap tokens',
1955
+ inputSchema: '{"type":"object"}',
1956
+ examples: [{ userQuery: 'swap ETH', parameters: '{}' }],
1957
+ },
1958
+ ],
1959
+ },
1960
+ updateMask: ['capabilities'],
1961
+ });
1962
+ const resp = await appRegistryRpcClient.getBulkAgentCapabilities({
1963
+ agentIds: [bin_fromHexString(botClientAddress)],
1964
+ });
1965
+ expect(resp.manifests.length).toBe(1);
1966
+ const manifest = resp.manifests[0];
1967
+ expect(manifest.agentName).toBeTruthy();
1968
+ expect(manifest.capabilities.length).toBe(1);
1969
+ expect(manifest.capabilities[0].name).toBe('e2e_swap');
1970
+ expect(manifest.capabilities[0].description).toBe('Swap tokens');
1971
+ expect(manifest.capabilities[0].inputSchema).toBe('{"type":"object"}');
1972
+ });
1973
+ it.skip('getBulkAgentProposals should fan out to bot and return proposals', async () => {
1974
+ const unsub = bot.onCapability('e2e_propose', (_handler, event) => {
1975
+ expect(event.userQuery).toBe('What can you do?');
1976
+ return [
1977
+ {
1978
+ title: 'Demo Action',
1979
+ explanation: 'I can do many things',
1980
+ confidence: 0.9,
1981
+ parameters: JSON.stringify({ action: 'demo' }),
1982
+ warnings: ['This is a test'],
1983
+ },
1984
+ ];
1985
+ });
1986
+ subscriptions.push(unsub);
1987
+ // Update capabilities so the bot is discoverable
1988
+ await appRegistryRpcClient.updateAppMetadata({
1989
+ appId: bin_fromHexString(botClientAddress),
1990
+ metadata: {
1991
+ capabilities: [
1992
+ {
1993
+ name: 'e2e_propose',
1994
+ description: 'Demo capability',
1995
+ inputSchema: '{"type":"object"}',
1996
+ examples: [],
1997
+ },
1998
+ ],
1999
+ },
2000
+ updateMask: ['capabilities'],
2001
+ });
2002
+ const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
2003
+ query: 'What can you do?',
2004
+ });
2005
+ const resp = await appRegistryRpcClient.getBulkAgentProposals({
2006
+ conversationSeedId,
2007
+ requests: [
2008
+ {
2009
+ agentId: bin_fromHexString(botClientAddress),
2010
+ capabilityName: 'e2e_propose',
2011
+ parameters: '{"action":"demo"}',
2012
+ },
2013
+ ],
2014
+ });
2015
+ expect(resp.accepted).toBe(true);
2016
+ });
2017
+ it.skip('getBulkAgentProposals should return accepted for unknown capability', async () => {
2018
+ const { conversationSeedId } = await appRegistryRpcClient.queryConcierge({
2019
+ query: 'Test unknown',
2020
+ });
2021
+ const resp = await appRegistryRpcClient.getBulkAgentProposals({
2022
+ conversationSeedId,
2023
+ requests: [
2024
+ {
2025
+ agentId: bin_fromHexString(botClientAddress),
2026
+ capabilityName: 'nonexistent_capability',
2027
+ parameters: '{}',
2028
+ },
2029
+ ],
2030
+ });
2031
+ expect(resp.accepted).toBe(true);
2032
+ });
2033
+ it('agent should participate in key exchange', async () => {
2034
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
2035
+ // create a new gdm with bob and bot
2036
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([bot.agentUserId]);
2037
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
2038
+ const gdm = bobClient.gdms.getGdm(gdmId);
2039
+ // bob sends a message "hello world this is bob"
2040
+ const { eventId: messageId } = await gdm.sendMessage('hello world this is bob');
2041
+ // bob creates an invite link
2042
+ const { appRegistryRpcClient } = await AppRegistryService.authenticate(bobClient.riverConnection.signerContext, townsEnv().getAppRegistryUrl());
2043
+ const createResponse = await appRegistryRpcClient.createInviteLink({
2044
+ streamId: streamIdAsBytes(gdmId),
2045
+ inviteEncryptionData: await bobClient.riverConnection.call((client) => client.exportInviteEncryptionData(gdmId)),
2046
+ });
2047
+ const inviteCode = createResponse.inviteCode;
2048
+ expect(inviteCode).toBeDefined();
2049
+ expect(inviteCode.length).toBeGreaterThan(0);
2050
+ // bob leaves the gdm
2051
+ await gdm.leave();
2052
+ // delete the keys from the bot to simulate a server restart
2053
+ await bot['client'].crypto.cryptoStore.deleteHybridGroupSessions(gdmId);
2054
+ // alice joins the gdm
2055
+ const { appRegistryRpcClient: aliceAppRegistryRpcClient } = await AppRegistryService.authenticate(aliceClient.riverConnection.signerContext, townsEnv().getAppRegistryUrl());
2056
+ const redeemResponse = await aliceAppRegistryRpcClient.redeemInviteLink({ inviteCode });
2057
+ // NOTE - we redeemed the invite link, but we didn't import the encryption data into the bot
2058
+ // forcing alice to use key exchange to decrypt the message
2059
+ redeemResponse.inviteEncryptionData = undefined; // clear it to force key exchange
2060
+ log('joining gdm', gdmId);
2061
+ const stream = await aliceClient.joinStream(gdmId, {
2062
+ redeemedInvite: redeemResponse,
2063
+ });
2064
+ log('stream', stream);
2065
+ // alice sees the message decrypted in the gdm
2066
+ const aliceGdm = aliceClient.gdms.getGdm(gdmId);
2067
+ await waitFor(() => expect(aliceGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage));
2068
+ });
2069
+ });
2070
+ //# sourceMappingURL=app.test.js.map