@towns-labs/agent 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +151 -0
  2. package/dist/agent.d.ts +480 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +1758 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/agent.test.d.ts +2 -0
  7. package/dist/agent.test.d.ts.map +1 -0
  8. package/dist/agent.test.js +1315 -0
  9. package/dist/agent.test.js.map +1 -0
  10. package/dist/eventDedup.d.ts +73 -0
  11. package/dist/eventDedup.d.ts.map +1 -0
  12. package/dist/eventDedup.js +105 -0
  13. package/dist/eventDedup.js.map +1 -0
  14. package/dist/eventDedup.test.d.ts +2 -0
  15. package/dist/eventDedup.test.d.ts.map +1 -0
  16. package/dist/eventDedup.test.js +222 -0
  17. package/dist/eventDedup.test.js.map +1 -0
  18. package/dist/identity-types.d.ts +43 -0
  19. package/dist/identity-types.d.ts.map +1 -0
  20. package/dist/identity-types.js +2 -0
  21. package/dist/identity-types.js.map +1 -0
  22. package/dist/index.d.ts +11 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +11 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/interaction-api.d.ts +61 -0
  27. package/dist/interaction-api.d.ts.map +1 -0
  28. package/dist/interaction-api.js +95 -0
  29. package/dist/interaction-api.js.map +1 -0
  30. package/dist/payments.d.ts +89 -0
  31. package/dist/payments.d.ts.map +1 -0
  32. package/dist/payments.js +144 -0
  33. package/dist/payments.js.map +1 -0
  34. package/dist/re-exports.d.ts +2 -0
  35. package/dist/re-exports.d.ts.map +1 -0
  36. package/dist/re-exports.js +2 -0
  37. package/dist/re-exports.js.map +1 -0
  38. package/dist/smart-account.d.ts +54 -0
  39. package/dist/smart-account.d.ts.map +1 -0
  40. package/dist/smart-account.js +132 -0
  41. package/dist/smart-account.js.map +1 -0
  42. package/dist/snapshot-getter.d.ts +21 -0
  43. package/dist/snapshot-getter.d.ts.map +1 -0
  44. package/dist/snapshot-getter.js +27 -0
  45. package/dist/snapshot-getter.js.map +1 -0
  46. package/package.json +67 -0
@@ -0,0 +1,1315 @@
1
+ import { makeBaseProvider, makeUserStreamId, townsEnv, RiverTimelineEvent, waitFor, Bot as SyncAgentTest, AppRegistryService, MessageType, genIdBlob, makeUniqueMediaStreamId, createApp, } from '@towns-labs/sdk';
2
+ import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
3
+ import { bin_fromHexString, dlog } from '@towns-labs/utils';
4
+ import { makeTownsAgent } from './agent';
5
+ import { ethers } from 'ethers';
6
+ import { createPublicClient, http } from 'viem';
7
+ import { relayerActions } from '@towns-labs/relayer-client';
8
+ import { getAddressesWithFallback } from '@towns-labs/contracts/deployments';
9
+ import { z } from 'zod';
10
+ import { stringify as superjsonStringify } from 'superjson';
11
+ import { ForwardSettingValue, InteractionRequestPayload_Signature_SignatureType, MediaInfoSchema, } from '@towns-labs/proto';
12
+ import { ETH_ADDRESS } from '@towns-labs/web3';
13
+ import { createServer } from 'node:http2';
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 = `https://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
+ ];
25
+ describe('Bot', { sequential: true }, () => {
26
+ const subscriptions = [];
27
+ const townsConfig = townsEnv().makeTownsConfig();
28
+ const bob = new SyncAgentTest(undefined, townsConfig);
29
+ let bobClient;
30
+ const alice = new SyncAgentTest(undefined, townsConfig);
31
+ let aliceClient;
32
+ const carol = new SyncAgentTest(undefined, townsConfig);
33
+ let carolClient;
34
+ const BOT_USERNAME = `bot-witness-of-infinity-${randomUUID()}`;
35
+ const BOT_DISPLAY_NAME = 'Uber Test Bot';
36
+ const BOT_DESCRIPTION = 'I shall witness everything';
37
+ let bot;
38
+ let channelId;
39
+ let botClientAddress;
40
+ let appPrivateData;
41
+ let jwtSecretBase64;
42
+ let appRegistryRpcClient;
43
+ let appAddress;
44
+ let bobDefaultGdm;
45
+ let ethersProvider;
46
+ beforeAll(async () => {
47
+ ethersProvider = makeBaseProvider(townsConfig);
48
+ await shouldInitializeBotOwner();
49
+ await shouldSetupBotUser();
50
+ await shouldRegisterBotInAppRegistry();
51
+ await shouldRunBotServerAndRegisterWebhook();
52
+ await bobClient.riverConnection.call((client) => Promise.all([client.debugForceMakeMiniblock(channelId, { forceSnapshot: true })]));
53
+ });
54
+ afterEach(() => {
55
+ subscriptions.forEach((unsub) => unsub());
56
+ subscriptions.splice(0, subscriptions.length);
57
+ });
58
+ const setForwardSetting = async (forwardSetting) => {
59
+ await appRegistryRpcClient.setAppSettings({
60
+ appId: bin_fromHexString(botClientAddress),
61
+ settings: { forwardSetting },
62
+ });
63
+ };
64
+ const shouldInitializeBotOwner = async () => {
65
+ await Promise.all([bob.fundWallet(), alice.fundWallet(), carol.fundWallet()]);
66
+ bobClient = await bob.makeSyncAgent();
67
+ aliceClient = await alice.makeSyncAgent();
68
+ carolClient = await carol.makeSyncAgent();
69
+ await Promise.all([bobClient.start(), aliceClient.start(), carolClient.start()]);
70
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([
71
+ { userId: alice.userId },
72
+ { userId: carol.userId },
73
+ ]);
74
+ channelId = gdmId;
75
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
76
+ bobDefaultGdm = bobClient.gdms.getGdm(gdmId);
77
+ expect(channelId).toBeDefined();
78
+ };
79
+ const shouldSetupBotUser = async () => {
80
+ const chainId = townsConfig.base.chainConfig.chainId;
81
+ const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
82
+ if (!addresses?.accountProxy) {
83
+ throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
84
+ }
85
+ const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
86
+ const relayerClient = createPublicClient({
87
+ chain: {
88
+ id: chainId,
89
+ name: 'Test Chain',
90
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
91
+ rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
92
+ },
93
+ transport: http(townsConfig.base.rpcUrl),
94
+ }).extend(relayerActions({ relayerUrl }));
95
+ const result = await createApp({
96
+ owner: bobClient.riverConnection.signerContext,
97
+ metadata: {
98
+ username: BOT_USERNAME,
99
+ displayName: BOT_DISPLAY_NAME,
100
+ description: BOT_DESCRIPTION,
101
+ imageUrl: 'https://placehold.co/600x600',
102
+ slashCommands: SLASH_COMMANDS,
103
+ },
104
+ relayerClient,
105
+ accountProxy: addresses.accountProxy,
106
+ townsConfig,
107
+ });
108
+ botClientAddress = result.appAddress;
109
+ appPrivateData = result.appPrivateData;
110
+ appAddress = result.appAddress;
111
+ jwtSecretBase64 = result.jwtSecretBase64;
112
+ expect(appPrivateData).toBeDefined();
113
+ expect(jwtSecretBase64).toBeDefined();
114
+ await bobClient.riverConnection.call((client) => client.joinUser(channelId, botClientAddress));
115
+ };
116
+ const shouldRegisterBotInAppRegistry = async () => {
117
+ const appRegistryUrl = townsEnv().getAppRegistryUrl(process.env.RIVER_ENV);
118
+ const { appRegistryRpcClient: rpcClient } = await AppRegistryService.authenticateWithSigner(bob.userId, bob.signer, appRegistryUrl);
119
+ appRegistryRpcClient = rpcClient;
120
+ };
121
+ const shouldRunBotServerAndRegisterWebhook = async () => {
122
+ bot = await makeTownsAgent(appPrivateData, jwtSecretBase64, { commands: SLASH_COMMANDS });
123
+ expect(bot).toBeDefined();
124
+ expect(bot.agentUserId).toBe(botClientAddress);
125
+ expect(bot.appAddress).toBe(appAddress);
126
+ const app = bot.start();
127
+ serve({
128
+ port: Number(process.env.BOT_PORT),
129
+ fetch: app.fetch,
130
+ createServer,
131
+ });
132
+ await appRegistryRpcClient.registerWebhook({
133
+ appId: bin_fromHexString(botClientAddress),
134
+ webhookUrl: WEBHOOK_URL,
135
+ });
136
+ const { isRegistered, validResponse } = await appRegistryRpcClient.getStatus({
137
+ appId: bin_fromHexString(botClientAddress),
138
+ });
139
+ expect(isRegistered).toBe(true);
140
+ expect(validResponse).toBe(true);
141
+ };
142
+ it('should have app_address defined in user stream for bot (app registry only)', async () => {
143
+ const botUserStreamId = makeUserStreamId(botClientAddress);
144
+ const streamView = await bobClient.riverConnection.call(async (client) => {
145
+ return await client.getStream(botUserStreamId);
146
+ });
147
+ const userStream = streamView.userContent.userStreamModel;
148
+ expect(userStream.appAddress).toBeDefined();
149
+ // expect(userStream.appAddress).toBe(botClientAddress)
150
+ });
151
+ it('should show bot in member list and apps set', async () => {
152
+ const channelStreamView = await bobClient.riverConnection.call(async (client) => {
153
+ return await client.getStream(channelId);
154
+ });
155
+ const { apps, joined } = channelStreamView.getMembers();
156
+ expect(apps.has(botClientAddress)).toBe(true);
157
+ expect(joined.has(botClientAddress)).toBe(true);
158
+ expect(joined.get(botClientAddress)?.appAddress).toBe(appAddress);
159
+ });
160
+ it('should receive a message forwarded', async () => {
161
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
162
+ const timeBeforeSendMessage = Date.now();
163
+ let receivedMessages = [];
164
+ subscriptions.push(bot.onMessage((_h, e) => {
165
+ receivedMessages.push(e);
166
+ }));
167
+ const TEST_MESSAGE = 'Hello bot!';
168
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE);
169
+ await waitFor(() => receivedMessages.length > 0, { timeoutMS: 15_000 });
170
+ const event = receivedMessages.find((x) => x.eventId === eventId);
171
+ expect(event?.message).toBe(TEST_MESSAGE);
172
+ expect(event?.createdAt).toBeDefined();
173
+ expect(event?.createdAt).toBeInstanceOf(Date);
174
+ expect(event?.createdAt.getTime()).toBeGreaterThanOrEqual(timeBeforeSendMessage);
175
+ receivedMessages = [];
176
+ });
177
+ it('should not receive messages when forwarding is set to no messages', async () => {
178
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_NO_MESSAGES);
179
+ const receivedMessages = [];
180
+ subscriptions.push(bot.onMessage((_h, e) => {
181
+ receivedMessages.push(e);
182
+ }));
183
+ const TEST_MESSAGE = 'This message should not be forwarded';
184
+ await bobDefaultGdm.sendMessage(TEST_MESSAGE);
185
+ await new Promise((resolve) => setTimeout(resolve, 2500));
186
+ expect(receivedMessages).toHaveLength(0);
187
+ });
188
+ it('should receive slash command messages', async () => {
189
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
190
+ const receivedMessages = [];
191
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
192
+ receivedMessages.push(e);
193
+ }));
194
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
195
+ appClientAddress: bot.agentUserId,
196
+ });
197
+ await waitFor(() => receivedMessages.length > 0);
198
+ const event = receivedMessages.find((x) => x.eventId === eventId);
199
+ expect(event?.command).toBe('help');
200
+ expect(event?.args).toStrictEqual([]);
201
+ });
202
+ it('should receive slash command in a thread', async () => {
203
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
204
+ const receivedMessages = [];
205
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
206
+ receivedMessages.push(e);
207
+ }));
208
+ const { eventId: threadId } = await bobDefaultGdm.sendMessage('starting a thread');
209
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
210
+ appClientAddress: bot.agentUserId,
211
+ threadId: threadId,
212
+ });
213
+ await waitFor(() => receivedMessages.length > 0);
214
+ const event = receivedMessages.find((x) => x.eventId === eventId);
215
+ expect(event?.command).toBe('help');
216
+ expect(event?.args).toStrictEqual([]);
217
+ expect(event?.threadId).toBe(threadId);
218
+ });
219
+ it('should receive slash command as a reply', async () => {
220
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
221
+ const receivedMessages = [];
222
+ subscriptions.push(bot.onSlashCommand('help', (_h, e) => {
223
+ receivedMessages.push(e);
224
+ }));
225
+ const { eventId: replyId } = await bobDefaultGdm.sendMessage('yo');
226
+ const { eventId } = await bobDefaultGdm.sendMessage('/help', {
227
+ appClientAddress: bot.agentUserId,
228
+ replyId: replyId,
229
+ });
230
+ await waitFor(() => receivedMessages.length > 0);
231
+ const event = receivedMessages.find((x) => x.eventId === eventId);
232
+ expect(event?.command).toBe('help');
233
+ expect(event?.args).toStrictEqual([]);
234
+ expect(event?.replyId).toBe(replyId);
235
+ });
236
+ it('should receive slash command with arguments', async () => {
237
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
238
+ const receivedMessages = [];
239
+ subscriptions.push(bot.onSlashCommand('status', (_h, e) => {
240
+ receivedMessages.push(e);
241
+ }));
242
+ const { eventId } = await bobDefaultGdm.sendMessage('/status detailed info', {
243
+ appClientAddress: bot.agentUserId,
244
+ });
245
+ await waitFor(() => receivedMessages.length > 0);
246
+ const event = receivedMessages.find((x) => x.eventId === eventId);
247
+ expect(event?.command).toBe('status');
248
+ expect(event?.args).toStrictEqual(['detailed', 'info']);
249
+ });
250
+ it('onMessageEdit should be triggered when a message is edited', async () => {
251
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
252
+ const receivedEditEvents = [];
253
+ subscriptions.push(bot.onMessageEdit((_h, e) => {
254
+ receivedEditEvents.push(e);
255
+ }));
256
+ const originalMessage = 'Original message to delete';
257
+ const editedMessage = 'Edited message content';
258
+ const { eventId: originalMessageId } = await bobDefaultGdm.sendMessage(originalMessage);
259
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_Edit_Text(channelId, originalMessageId, {
260
+ content: {
261
+ body: editedMessage,
262
+ mentions: [],
263
+ attachments: [],
264
+ },
265
+ }));
266
+ await waitFor(() => receivedEditEvents.length > 0);
267
+ const editEvent = receivedEditEvents.find((e) => e.refEventId === originalMessageId);
268
+ expect(editEvent).toBeDefined();
269
+ expect(editEvent?.message).toBe(editedMessage);
270
+ });
271
+ it('onMessage should be triggered with threadId when a message is sent in a thread', async () => {
272
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
273
+ const receivedThreadMessages = [];
274
+ subscriptions.push(bot.onMessage((_h, e) => {
275
+ if (e.threadId) {
276
+ receivedThreadMessages.push(e);
277
+ }
278
+ }));
279
+ const initialMessage = 'Starting a thread';
280
+ const threadReply = 'Replying in thread';
281
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
282
+ const { eventId: replyEventId } = await bobDefaultGdm.sendMessage(threadReply, {
283
+ threadId: initialMessageId,
284
+ });
285
+ await waitFor(() => receivedThreadMessages.length > 0);
286
+ const threadEvent = receivedThreadMessages.find((e) => e.eventId === replyEventId);
287
+ expect(threadEvent).toBeDefined();
288
+ expect(threadEvent?.message).toBe(threadReply);
289
+ expect(threadEvent?.userId).toBe(bob.userId);
290
+ expect(threadEvent?.threadId).toBe(initialMessageId);
291
+ });
292
+ it('onMessage should be triggered with isMentioned when a bot is mentioned', async () => {
293
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
294
+ const receivedMentionedEvents = [];
295
+ subscriptions.push(bot.onMessage((_h, e) => {
296
+ if (e.isMentioned) {
297
+ receivedMentionedEvents.push(e);
298
+ }
299
+ }));
300
+ const TEST_MESSAGE = 'Hello @bot';
301
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
302
+ mentions: [
303
+ {
304
+ userId: bot.agentUserId,
305
+ displayName: BOT_DISPLAY_NAME,
306
+ mentionBehavior: { case: undefined, value: undefined },
307
+ },
308
+ ],
309
+ });
310
+ await waitFor(() => receivedMentionedEvents.length > 0);
311
+ const mentionedEvent = receivedMentionedEvents.find((x) => x.eventId === eventId);
312
+ expect(mentionedEvent).toBeDefined();
313
+ expect(mentionedEvent?.isMentioned).toBe(true);
314
+ expect(mentionedEvent?.mentions[0].userId).toBe(bot.agentUserId);
315
+ expect(mentionedEvent?.mentions[0].displayName).toBe(BOT_DISPLAY_NAME);
316
+ });
317
+ it('isMentioned should be false when someone else is mentioned', async () => {
318
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
319
+ const receivedMessages = [];
320
+ subscriptions.push(bot.onMessage((_h, e) => {
321
+ receivedMessages.push(e);
322
+ }));
323
+ const TEST_MESSAGE = 'Hello @alice';
324
+ const { eventId } = await bobDefaultGdm.sendMessage(TEST_MESSAGE, {
325
+ mentions: [
326
+ {
327
+ userId: alice.userId,
328
+ displayName: 'alice',
329
+ mentionBehavior: { case: undefined, value: undefined },
330
+ },
331
+ ],
332
+ });
333
+ await waitFor(() => receivedMessages.length > 0);
334
+ const message = receivedMessages.find((x) => x.eventId === eventId);
335
+ expect(message).toBeDefined();
336
+ expect(message?.isMentioned).toBe(false);
337
+ });
338
+ it('onMessage should be triggered with both threadId and isMentioned when bot is mentioned in a thread', async () => {
339
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
340
+ const receivedMentionedInThreadEvents = [];
341
+ subscriptions.push(bot.onMessage((_h, e) => {
342
+ receivedMentionedInThreadEvents.push(e);
343
+ }));
344
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage('starting a thread');
345
+ const { eventId: threadMentionEventId } = await bobDefaultGdm.sendMessage('yo @bot check this thread', {
346
+ threadId: initialMessageId,
347
+ mentions: [
348
+ {
349
+ userId: bot.agentUserId,
350
+ displayName: bot.agentUserId,
351
+ mentionBehavior: { case: undefined, value: undefined },
352
+ },
353
+ ],
354
+ });
355
+ await waitFor(() => receivedMentionedInThreadEvents.length > 0);
356
+ const threadMentionEvent = receivedMentionedInThreadEvents.find((e) => e.eventId === threadMentionEventId);
357
+ expect(threadMentionEvent).toBeDefined();
358
+ expect(threadMentionEvent?.userId).toBe(bob.userId);
359
+ expect(threadMentionEvent?.threadId).toBe(initialMessageId);
360
+ expect(threadMentionEvent?.isMentioned).toBe(true);
361
+ });
362
+ it('thread message without bot mention should have isMentioned false', async () => {
363
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
364
+ const receivedMessages = [];
365
+ subscriptions.push(bot.onMessage((_h, e) => {
366
+ receivedMessages.push(e);
367
+ }));
368
+ const initialMessage = 'Starting another thread';
369
+ const threadMessageWithoutMention = 'Thread message without mention';
370
+ const { eventId: initialMessageId } = await bobDefaultGdm.sendMessage(initialMessage);
371
+ const { eventId: threadEventId } = await bobDefaultGdm.sendMessage(threadMessageWithoutMention, {
372
+ threadId: initialMessageId,
373
+ });
374
+ await waitFor(() => receivedMessages.length > 0);
375
+ const message = receivedMessages.find((x) => x.eventId === threadEventId);
376
+ expect(message).toBeDefined();
377
+ expect(message?.threadId).toBe(initialMessageId);
378
+ expect(message?.isMentioned).toBe(false);
379
+ });
380
+ it('onReaction should be triggered when a reaction is added', async () => {
381
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
382
+ const receivedReactionEvents = [];
383
+ subscriptions.push(bot.onReaction((_h, e) => {
384
+ receivedReactionEvents.push(e);
385
+ }));
386
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
387
+ const { eventId: reactionId } = await bobDefaultGdm.sendReaction(messageId, '👍');
388
+ await waitFor(() => receivedReactionEvents.length > 0);
389
+ expect(receivedReactionEvents.find((x) => x.eventId === reactionId)).toBeDefined();
390
+ expect(receivedReactionEvents.find((x) => x.reaction === '👍')).toBeDefined();
391
+ expect(receivedReactionEvents.find((x) => x.messageId === messageId)).toBeDefined();
392
+ expect(receivedReactionEvents.find((x) => x.userId === bob.userId)).toBeDefined();
393
+ });
394
+ it('onRedaction should be triggered when a message is redacted', async () => {
395
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
396
+ const receivedRedactionEvents = [];
397
+ subscriptions.push(bot.onRedaction((_h, e) => {
398
+ receivedRedactionEvents.push(e);
399
+ }));
400
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('Hello');
401
+ const { eventId: redactionId } = await bobDefaultGdm.redact(messageId);
402
+ await waitFor(() => receivedRedactionEvents.length > 0);
403
+ expect(receivedRedactionEvents.find((x) => x.eventId === redactionId)).toBeDefined();
404
+ });
405
+ it('bot can redact his own message', async () => {
406
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
407
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello');
408
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
409
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
410
+ const { eventId: redactionId } = await bot.removeEvent(channelId, messageId);
411
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === redactionId)?.content
412
+ ?.kind).toBe(RiverTimelineEvent.RedactionActionEvent));
413
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
414
+ ?.kind).toBe(RiverTimelineEvent.RedactedEvent));
415
+ });
416
+ it('bot can mention bob', async () => {
417
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
418
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Hello @bob', {
419
+ mentions: [
420
+ {
421
+ userId: bob.userId,
422
+ displayName: 'bob',
423
+ },
424
+ ],
425
+ });
426
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
427
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
428
+ const channelMessage = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content;
429
+ expect(channelMessage.mentions).toBeDefined();
430
+ expect(channelMessage.mentions?.length).toBe(1);
431
+ expect(channelMessage.mentions?.[0].userId).toBe(bob.userId);
432
+ expect(channelMessage.mentions?.[0].displayName).toBe('bob');
433
+ });
434
+ it('bot can fetch existing decryption keys when sending a message', async () => {
435
+ // 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
436
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
437
+ const { eventId: messageId1 } = await bot.sendMessage(channelId, 'Hello message 1');
438
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1)?.content
439
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
440
+ // DELETE OUTBOUND GROUP SESSIONS to simulate fresh server start
441
+ await bot['client'].crypto.cryptoStore.deleteOutboundGrounpSessions(channelId);
442
+ await bot['client'].crypto.cryptoStore.deleteHybridGroupSessions(channelId);
443
+ const { eventId: messageId2 } = await bot.sendMessage(channelId, 'Hello message 2');
444
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2)?.content
445
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage));
446
+ const event1 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId1);
447
+ const event2 = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === messageId2);
448
+ expect(event1?.sessionId).toEqual(event2?.sessionId);
449
+ });
450
+ it('bot shares new decrytion keys with users created while sending a message', async () => {
451
+ // 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
452
+ // 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
453
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
454
+ const { streamId: gdmId } = await bobClient.gdms.createGDM([
455
+ { userId: alice.userId },
456
+ { userId: bot.agentUserId, appAddress: bot.appAddress },
457
+ ]);
458
+ await bobClient.riverConnection.call((client) => client.waitForStream(gdmId));
459
+ const bobGdm = bobClient.gdms.getGdm(gdmId);
460
+ // bot sends message to the gdm
461
+ const { eventId: messageId } = await bot.sendMessage(gdmId, 'Hello');
462
+ log('bot sends message to gdm', messageId);
463
+ // bob should see the DECRYPTED message
464
+ await waitFor(() => {
465
+ expect(bobGdm.timeline.events.value.find((x) => x.eventId === messageId)?.content
466
+ ?.kind).toBe(RiverTimelineEvent.ChannelMessage);
467
+ }, { timeoutMS: 20000 });
468
+ });
469
+ it.skip('TODO: FIX: onTip should be triggered when a tip is received', async () => {
470
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
471
+ const receivedTipEvents = [];
472
+ subscriptions.push(bot.onTip((_h, e) => {
473
+ receivedTipEvents.push(e);
474
+ }));
475
+ // Bot sends a message to the GDM
476
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'Tip me!');
477
+ const balanceBefore = (await ethersProvider.getBalance(appAddress)).toBigInt();
478
+ // Bob tips the bot using GDM sendTip (type: 'any' tipping)
479
+ await bobDefaultGdm.sendTip(messageId, {
480
+ receiver: appAddress,
481
+ amount: ethers.utils.parseUnits('0.01').toBigInt(),
482
+ currency: ETH_ADDRESS,
483
+ chainId: townsConfig.base.chainConfig.chainId,
484
+ }, bob.signer);
485
+ // Verify the bot's balance increased (no protocol fee for type: 'any' tips)
486
+ const balanceAfter = (await ethersProvider.getBalance(appAddress)).toBigInt();
487
+ const expectedTipAmount = ethers.utils.parseUnits('0.01').toBigInt();
488
+ expect(balanceAfter).toEqual(balanceBefore + expectedTipAmount);
489
+ // Verify the onTip event was triggered
490
+ await waitFor(() => receivedTipEvents.length > 0);
491
+ const tipEvent = receivedTipEvents.find((x) => x.messageId === messageId);
492
+ expect(tipEvent).toBeDefined();
493
+ expect(tipEvent?.userId).toBe(bob.userId);
494
+ expect(tipEvent?.channelId).toBe(channelId);
495
+ expect(tipEvent?.amount).toBe(expectedTipAmount);
496
+ expect(tipEvent?.currency).toBe(ETH_ADDRESS);
497
+ expect(tipEvent?.senderAddress).toBe(bob.userId);
498
+ expect(tipEvent?.receiverAddress).toBe(appAddress);
499
+ });
500
+ it.skip('TODO: FIX: bot can use sendTip() to send tips using app balance', async () => {
501
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
502
+ const receivedMessages = [];
503
+ let tipPromise;
504
+ subscriptions.push(bot.onMessage((handler, event) => {
505
+ receivedMessages.push(event);
506
+ tipPromise = handler.sendTip({
507
+ userId: bob.userId,
508
+ amount: ethers.utils.parseUnits('0.005').toBigInt(),
509
+ messageId: event.eventId,
510
+ channelId: event.channelId,
511
+ });
512
+ }));
513
+ const bobBalanceBefore = (await ethersProvider.getBalance(bob.userId)).toBigInt();
514
+ // Bob sends a message asking for a tip
515
+ const { eventId: bobMessageId } = await bobDefaultGdm.sendMessage('Tip me please!');
516
+ await waitFor(() => receivedMessages.some((x) => x.eventId === bobMessageId));
517
+ expect(tipPromise).toBeDefined();
518
+ const result = await tipPromise;
519
+ expect(result.txHash).toBeDefined();
520
+ expect(result.eventId).toBeDefined();
521
+ // Verify bob's balance increased
522
+ const bobBalanceAfter = (await ethersProvider.getBalance(bob.userId)).toBigInt();
523
+ expect(bobBalanceAfter).toBeGreaterThan(bobBalanceBefore);
524
+ });
525
+ it('onEventRevoke (FORWARD_SETTING_ALL_MESSAGES) should be triggered when a message is revoked', async () => {
526
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
527
+ const receivedEventRevokeEvents = [];
528
+ subscriptions.push(bot.onEventRevoke((_h, e) => {
529
+ receivedEventRevokeEvents.push(e);
530
+ }));
531
+ const { eventId: messageId } = await bot.sendMessage(channelId, 'hii');
532
+ await bobDefaultGdm.adminRedact(messageId);
533
+ await waitFor(() => receivedEventRevokeEvents.length > 0);
534
+ expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
535
+ });
536
+ it.fails('onEventRevoke (FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS) should be triggered when a message that mentions the bot is revoked', async () => {
537
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
538
+ const receivedEventRevokeEvents = [];
539
+ subscriptions.push(bot.onEventRevoke((_h, e) => {
540
+ receivedEventRevokeEvents.push(e);
541
+ }));
542
+ const { eventId: messageId } = await bobDefaultGdm.sendMessage('hii @bot', {
543
+ mentions: [
544
+ {
545
+ userId: bot.agentUserId,
546
+ displayName: bot.agentUserId,
547
+ mentionBehavior: { case: undefined, value: undefined },
548
+ },
549
+ ],
550
+ });
551
+ await bobDefaultGdm.adminRedact(messageId);
552
+ await waitFor(() => receivedEventRevokeEvents.length > 0);
553
+ expect(receivedEventRevokeEvents.find((x) => x.refEventId === messageId)).toBeDefined();
554
+ });
555
+ it('should send message with image attachment from URL with alt text', async () => {
556
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
557
+ const imageUrl = 'https://placehold.co/800x600.png';
558
+ const altText = 'A beautiful placeholder image';
559
+ const { eventId } = await bot.sendMessage(channelId, 'Image with alt text', {
560
+ attachments: [{ type: 'image', url: imageUrl, alt: altText }],
561
+ });
562
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
563
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
564
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
565
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
566
+ ? message?.content?.attachments
567
+ : undefined;
568
+ expect(attachments).toHaveLength(1);
569
+ expect(attachments?.[0].type).toBe('image');
570
+ const image = attachments?.[0].type === 'image' ? attachments?.[0] : undefined;
571
+ expect(image).toBeDefined();
572
+ expect(image?.info.url).toBe(imageUrl);
573
+ });
574
+ it('should gracefully handle non-image URL (skip with warning)', async () => {
575
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
576
+ // Use a URL that returns non-image content type
577
+ const nonImageUrl = 'https://httpbin.org/json';
578
+ const { eventId } = await bot.sendMessage(channelId, 'This should skip the attachment', {
579
+ attachments: [{ type: 'image', url: nonImageUrl }],
580
+ });
581
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
582
+ // Message should still be sent, just without the attachment
583
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
584
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
585
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
586
+ ? message?.content?.attachments
587
+ : undefined;
588
+ expect(attachments).toHaveLength(0);
589
+ });
590
+ it('should gracefully handle invalid URL (404)', async () => {
591
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
592
+ // Use a URL that returns 404
593
+ const invalidUrl = 'https://httpbin.org/status/404';
594
+ const { eventId } = await bot.sendMessage(channelId, 'This should handle 404 gracefully', {
595
+ attachments: [{ type: 'image', url: invalidUrl }],
596
+ });
597
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
598
+ // Message should still be sent, just without the attachment
599
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
600
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
601
+ });
602
+ function createTestPNG(width, height) {
603
+ // PNG signature
604
+ const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
605
+ // IHDR chunk
606
+ const ihdrData = new Uint8Array(13);
607
+ const view = new DataView(ihdrData.buffer);
608
+ view.setUint32(0, width, false); // Width
609
+ view.setUint32(4, height, false); // Height
610
+ ihdrData[8] = 8; // Bit depth
611
+ ihdrData[9] = 2; // Color type (truecolor)
612
+ ihdrData[10] = 0; // Compression
613
+ ihdrData[11] = 0; // Filter
614
+ ihdrData[12] = 0; // Interlace
615
+ // Create IHDR chunk with CRC
616
+ const ihdrChunk = new Uint8Array(12 + 13);
617
+ new DataView(ihdrChunk.buffer).setUint32(0, 13, false);
618
+ ihdrChunk.set([73, 72, 68, 82], 4); // 'IHDR'
619
+ ihdrChunk.set(ihdrData, 8);
620
+ new DataView(ihdrChunk.buffer).setUint32(21, 0, false); // CRC placeholder
621
+ // IDAT chunk (minimal data)
622
+ const idatData = new Uint8Array(100); // Minimal compressed data
623
+ const idatChunk = new Uint8Array(12 + 100);
624
+ new DataView(idatChunk.buffer).setUint32(0, 100, false);
625
+ idatChunk.set([73, 68, 65, 84], 4); // 'IDAT'
626
+ idatChunk.set(idatData, 8);
627
+ new DataView(idatChunk.buffer).setUint32(108, 0, false); // CRC placeholder
628
+ // IEND chunk
629
+ const iendChunk = new Uint8Array([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]);
630
+ // Combine all chunks
631
+ const png = new Uint8Array(signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length);
632
+ png.set(signature, 0);
633
+ png.set(ihdrChunk, signature.length);
634
+ png.set(idatChunk, signature.length + ihdrChunk.length);
635
+ png.set(iendChunk, signature.length + ihdrChunk.length + idatChunk.length);
636
+ return png;
637
+ }
638
+ it('should send chunked media with Uint8Array data', async () => {
639
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
640
+ const testData = createTestPNG(100, 100);
641
+ const { eventId } = await bot.sendMessage(channelId, 'Chunked media test', {
642
+ attachments: [
643
+ {
644
+ type: 'chunked',
645
+ data: testData,
646
+ filename: 'test.png',
647
+ mimetype: 'image/png',
648
+ width: 100,
649
+ height: 100,
650
+ },
651
+ ],
652
+ });
653
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
654
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
655
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
656
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
657
+ ? message?.content?.attachments
658
+ : undefined;
659
+ expect(attachments).toHaveLength(1);
660
+ expect(attachments?.[0].type).toBe('chunked_media');
661
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
662
+ expect(chunkedMedia).toBeDefined();
663
+ expect(chunkedMedia?.info.filename).toBe('test.png');
664
+ expect(chunkedMedia?.info.mimetype).toBe('image/png');
665
+ expect(chunkedMedia?.info.widthPixels).toBe(100);
666
+ expect(chunkedMedia?.info.heightPixels).toBe(100);
667
+ expect(chunkedMedia?.streamId).toBeDefined();
668
+ expect(chunkedMedia?.encryption).toBeDefined();
669
+ });
670
+ it('should send chunked media with Blob data and auto-detect dimensions', async () => {
671
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
672
+ const testData = createTestPNG(200, 150);
673
+ const blob = new Blob([testData.buffer], { type: 'image/png' });
674
+ const { eventId } = await bot.sendMessage(channelId, 'Blob test', {
675
+ attachments: [
676
+ {
677
+ type: 'chunked',
678
+ data: blob,
679
+ filename: 'blob-test.png',
680
+ },
681
+ ],
682
+ });
683
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
684
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
685
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
686
+ ? message?.content?.attachments
687
+ : undefined;
688
+ expect(attachments).toHaveLength(1);
689
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
690
+ expect(chunkedMedia?.info.widthPixels).toBe(200);
691
+ expect(chunkedMedia?.info.heightPixels).toBe(150);
692
+ });
693
+ it('should handle large chunked media (multiple chunks)', async () => {
694
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
695
+ // Create 2.5MB of data - will create mulitple chunks
696
+ const largeData = new Uint8Array(2500000);
697
+ for (let i = 0; i < largeData.length; i++) {
698
+ largeData[i] = i % 256;
699
+ }
700
+ const { eventId } = await bot.sendMessage(channelId, 'Large media test', {
701
+ attachments: [
702
+ {
703
+ type: 'chunked',
704
+ data: largeData,
705
+ filename: 'large-file.bin',
706
+ mimetype: 'application/octet-stream',
707
+ },
708
+ ],
709
+ });
710
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined(), { timeoutMS: 30000 });
711
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
712
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
713
+ ? message?.content?.attachments
714
+ : undefined;
715
+ expect(attachments).toHaveLength(1);
716
+ const chunkedMedia = attachments?.[0].type === 'chunked_media' ? attachments?.[0] : undefined;
717
+ expect(chunkedMedia?.info.sizeBytes).toBe(BigInt(2500000));
718
+ });
719
+ // @miguel-nascimento 2025-12-08 flaky test
720
+ it.skip('should send mixed attachments (URL + chunked)', async () => {
721
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
722
+ const testData = createTestPNG(50, 50);
723
+ const imageUrl = 'https://placehold.co/100x100.png';
724
+ const { eventId } = await bot.sendMessage(channelId, 'Mixed attachments', {
725
+ attachments: [
726
+ { type: 'image', url: imageUrl },
727
+ {
728
+ type: 'chunked',
729
+ data: testData,
730
+ filename: 'generated.png',
731
+ mimetype: 'image/png',
732
+ },
733
+ ],
734
+ });
735
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
736
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
737
+ const attachments = message?.content?.kind === RiverTimelineEvent.ChannelMessage
738
+ ? message?.content?.attachments
739
+ : undefined;
740
+ expect(attachments).toHaveLength(2);
741
+ expect(attachments?.[0].type).toBe('image');
742
+ expect(attachments?.[1].type).toBe('chunked_media');
743
+ });
744
+ it('should send and receive GM messages with schema validation', async () => {
745
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
746
+ const messageSchema = z.object({ text: z.string(), count: z.number() });
747
+ const receivedGmEvents = [];
748
+ subscriptions.push(bot.onGmMessage('test.typed.v1', messageSchema, (_h, e) => {
749
+ receivedGmEvents.push({ typeUrl: e.typeUrl, data: e.data });
750
+ }));
751
+ const testData = { text: 'Hello', count: 42 };
752
+ // Bob sends the message so bot receives it (bot filters its own messages)
753
+ const jsonString = superjsonStringify(testData);
754
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
755
+ content: {
756
+ typeUrl: 'test.typed.v1',
757
+ value: new TextEncoder().encode(jsonString),
758
+ },
759
+ }));
760
+ await waitFor(() => receivedGmEvents.length > 0);
761
+ const event = receivedGmEvents[0];
762
+ expect(event).toBeDefined();
763
+ expect(event.typeUrl).toBe('test.typed.v1');
764
+ expect(event.data).toEqual(testData);
765
+ });
766
+ it('should handle GM with Date objects (superjson serialization)', async () => {
767
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
768
+ const eventSchema = z.object({
769
+ eventType: z.string(),
770
+ timestamp: z.date(),
771
+ });
772
+ const testDate = new Date('2025-01-15T12:00:00Z');
773
+ const { eventId } = await bot.sendGM(channelId, 'test.date.v1', eventSchema, {
774
+ eventType: 'test',
775
+ timestamp: testDate,
776
+ });
777
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
778
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
779
+ expect(message?.content?.kind).toBe(RiverTimelineEvent.ChannelMessage);
780
+ const gmData = message?.content?.kind === RiverTimelineEvent.ChannelMessage &&
781
+ message?.content?.content.msgType === MessageType.GM
782
+ ? message?.content?.content.data
783
+ : undefined;
784
+ expect(gmData).toBeDefined();
785
+ expect(gmData).toStrictEqual(new TextEncoder().encode(superjsonStringify({ eventType: 'test', timestamp: testDate })));
786
+ });
787
+ it('should handle multiple handlers for different typeUrls', async () => {
788
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
789
+ const schema1 = z.object({ type: z.literal('type1'), value: z.number() });
790
+ const schema2 = z.object({ type: z.literal('type2'), text: z.string() });
791
+ const receivedType1 = [];
792
+ const receivedType2 = [];
793
+ subscriptions.push(bot.onGmMessage('test.multi.type1', schema1, (_h, e) => {
794
+ receivedType1.push(e.data);
795
+ }));
796
+ subscriptions.push(bot.onGmMessage('test.multi.type2', schema2, (_h, e) => {
797
+ receivedType2.push(e.data);
798
+ }));
799
+ const data1 = { type: 'type1', value: 123 };
800
+ const data2 = { type: 'type2', text: 'hello' };
801
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
802
+ content: {
803
+ typeUrl: 'test.multi.type1',
804
+ value: new TextEncoder().encode(superjsonStringify(data1)),
805
+ },
806
+ }));
807
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
808
+ content: {
809
+ typeUrl: 'test.multi.type2',
810
+ value: new TextEncoder().encode(superjsonStringify(data2)),
811
+ },
812
+ }));
813
+ await waitFor(() => receivedType1.length > 0 && receivedType2.length > 0);
814
+ expect(receivedType1[0]).toEqual(data1);
815
+ expect(receivedType2[0]).toEqual(data2);
816
+ });
817
+ it('should handle raw GM messages', async () => {
818
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
819
+ const receivedMessages = [];
820
+ subscriptions.push(bot.onRawGmMessage((_h, e) => {
821
+ receivedMessages.push({ typeUrl: e.typeUrl, message: e.message });
822
+ }));
823
+ const message = new TextEncoder().encode('Hello, world!');
824
+ await bobClient.riverConnection.call((client) => client.sendChannelMessage_GM(channelId, {
825
+ content: {
826
+ typeUrl: 'test.raw.v1',
827
+ value: message,
828
+ },
829
+ }));
830
+ await waitFor(() => receivedMessages.length > 0);
831
+ expect(receivedMessages[0].typeUrl).toBe('test.raw.v1');
832
+ expect(receivedMessages[0].message).toEqual(message);
833
+ });
834
+ it('should log error and continue processing if throws an error when handling an event', async () => {
835
+ const consoleErrorSpy = vi.spyOn(console, 'error');
836
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
837
+ subscriptions.push(bot.onMessage(() => {
838
+ throw new Error('test error');
839
+ }));
840
+ await bobDefaultGdm.sendMessage('lol');
841
+ await waitFor(() => consoleErrorSpy.mock.calls.length > 0);
842
+ expect(consoleErrorSpy.mock.calls[0][0]).toContain('[@towns-labs/agent] Error while handling event');
843
+ consoleErrorSpy.mockRestore();
844
+ });
845
+ it('bot should be able to send encrypted interaction request and user should send encrypted response', async () => {
846
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
847
+ const requestId = randomUUID();
848
+ const interactionRequestContent = {
849
+ case: 'signature',
850
+ value: {
851
+ id: requestId,
852
+ data: '0x1234567890',
853
+ chainId: '1',
854
+ type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
855
+ },
856
+ };
857
+ const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
858
+ // Wait for Bob to receive the interaction request
859
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
860
+ // Wait for decryption to complete
861
+ await waitFor(() => {
862
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
863
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
864
+ return false;
865
+ }
866
+ return event?.content?.payload !== undefined;
867
+ });
868
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
869
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
870
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
871
+ throw new Error('Event is not an InteractionRequest');
872
+ }
873
+ const decryptedPayload = decryptedEvent.content.payload;
874
+ const encryptionDevice = decryptedPayload?.encryptionDevice;
875
+ expect(decryptedPayload).toBeDefined();
876
+ expect(encryptionDevice).toBeDefined();
877
+ expect(decryptedPayload?.content.case).toBe('signature');
878
+ if (decryptedPayload?.content.case === 'signature') {
879
+ expect(decryptedPayload.content.value.id).toBe(requestId);
880
+ expect(decryptedPayload.content.value.data).toBe('0x1234567890');
881
+ }
882
+ // bob should be able to send interaction response to the bot using the decrypted encryption device
883
+ const recipient = bin_fromHexString(botClientAddress);
884
+ const interactionResponsePayload = {
885
+ salt: genIdBlob(),
886
+ content: {
887
+ case: 'signature',
888
+ value: {
889
+ requestId: requestId,
890
+ signature: '0x123222222222',
891
+ },
892
+ },
893
+ };
894
+ const receivedInteractionResponses = [];
895
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
896
+ receivedInteractionResponses.push(e.response);
897
+ }));
898
+ await bobClient.riverConnection.call(async (client) => {
899
+ // from the client, to the channel, encrypted so that only the bot can read it
900
+ return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, encryptionDevice);
901
+ });
902
+ await waitFor(() => receivedInteractionResponses.length > 0);
903
+ expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
904
+ expect(receivedInteractionResponses[0].payload.content.value?.requestId).toEqual(requestId);
905
+ });
906
+ it('user should NOT be able to send interaction request', async () => {
907
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
908
+ const interactionRequestContent = {
909
+ case: 'signature',
910
+ value: {
911
+ id: randomUUID(),
912
+ data: '0x1234567890',
913
+ chainId: '1',
914
+ type: InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN,
915
+ },
916
+ };
917
+ await bobClient.riverConnection.call(async (client) => {
918
+ // Try to send an interaction request as a user (should fail)
919
+ // Note: Even if we try to use the proper sendInteractionRequest method,
920
+ // it should fail because only apps can send interaction requests
921
+ await expect(client.sendInteractionRequest(channelId, interactionRequestContent)).rejects.toThrow(/creator is not an app/);
922
+ });
923
+ });
924
+ it('bot should be able to send form interaction request', async () => {
925
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
926
+ const interactionRequestContent = {
927
+ case: 'form',
928
+ value: {
929
+ id: randomUUID(),
930
+ components: [
931
+ { id: '1', component: { case: 'button', value: { label: 'Button' } } },
932
+ {
933
+ id: '2',
934
+ component: { case: 'textInput', value: { placeholder: 'Text Input' } },
935
+ },
936
+ ],
937
+ },
938
+ };
939
+ const { eventId } = await bot.sendInteractionRequest(channelId, interactionRequestContent);
940
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
941
+ const message = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
942
+ expect(message).toBeDefined();
943
+ });
944
+ it('bot should be able to send signature interaction request using flattened API', async () => {
945
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
946
+ const requestId = randomUUID();
947
+ const { eventId } = await bot.sendInteractionRequest(channelId, {
948
+ type: 'signature',
949
+ id: requestId,
950
+ data: '0xabcdef1234567890',
951
+ chainId: '8453',
952
+ method: 'personal_sign',
953
+ signerWallet: botClientAddress,
954
+ });
955
+ // Wait for Bob to receive the interaction request
956
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
957
+ // Wait for decryption to complete
958
+ await waitFor(() => {
959
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
960
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
961
+ return false;
962
+ }
963
+ return event?.content?.payload !== undefined;
964
+ });
965
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
966
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
967
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
968
+ throw new Error('Event is not an InteractionRequest');
969
+ }
970
+ const decryptedPayload = decryptedEvent.content.payload;
971
+ expect(decryptedPayload).toBeDefined();
972
+ expect(decryptedPayload?.content.case).toBe('signature');
973
+ if (decryptedPayload?.content.case === 'signature') {
974
+ expect(decryptedPayload.content.value.id).toBe(requestId);
975
+ expect(decryptedPayload.content.value.data).toBe('0xabcdef1234567890');
976
+ expect(decryptedPayload.content.value.chainId).toBe('8453');
977
+ expect(decryptedPayload.content.value.type).toBe(InteractionRequestPayload_Signature_SignatureType.PERSONAL_SIGN);
978
+ }
979
+ });
980
+ it('bot should be able to send form interaction request using flattened API', async () => {
981
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
982
+ const requestId = randomUUID();
983
+ const { eventId } = await bot.sendInteractionRequest(channelId, {
984
+ type: 'form',
985
+ id: requestId,
986
+ components: [
987
+ { id: 'btn-1', type: 'button', label: 'Click Me' },
988
+ { id: 'input-1', type: 'textInput', placeholder: 'Enter text here' },
989
+ ],
990
+ });
991
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
992
+ // Wait for decryption to complete
993
+ await waitFor(() => {
994
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
995
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
996
+ return false;
997
+ }
998
+ return event?.content?.payload !== undefined;
999
+ });
1000
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1001
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1002
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1003
+ throw new Error('Event is not an InteractionRequest');
1004
+ }
1005
+ const decryptedPayload = decryptedEvent.content.payload;
1006
+ expect(decryptedPayload).toBeDefined();
1007
+ expect(decryptedPayload?.content.case).toBe('form');
1008
+ if (decryptedPayload?.content.case === 'form') {
1009
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1010
+ expect(decryptedPayload.content.value.components).toHaveLength(2);
1011
+ expect(decryptedPayload.content.value.components[0].id).toBe('btn-1');
1012
+ expect(decryptedPayload.content.value.components[0].component.case).toBe('button');
1013
+ expect(decryptedPayload.content.value.components[1].id).toBe('input-1');
1014
+ expect(decryptedPayload.content.value.components[1].component.case).toBe('textInput');
1015
+ }
1016
+ });
1017
+ it('bot should be able to send transaction interaction request using flattened API', async () => {
1018
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1019
+ const requestId = randomUUID();
1020
+ const { eventId } = await bot.sendInteractionRequest(channelId, {
1021
+ type: 'transaction',
1022
+ id: requestId,
1023
+ title: 'Send USDC',
1024
+ subtitle: 'Send 50 USDC to recipient',
1025
+ tx: {
1026
+ chainId: '8453',
1027
+ to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base
1028
+ value: '0',
1029
+ data: '0xa9059cbb', // transfer function selector
1030
+ signerWallet: botClientAddress,
1031
+ },
1032
+ });
1033
+ await waitFor(() => expect(bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1034
+ // Wait for decryption to complete
1035
+ await waitFor(() => {
1036
+ const event = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1037
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1038
+ return false;
1039
+ }
1040
+ return event?.content?.payload !== undefined;
1041
+ });
1042
+ const decryptedEvent = bobDefaultGdm.timeline.events.value.find((x) => x.eventId === eventId);
1043
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1044
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1045
+ throw new Error('Event is not an InteractionRequest');
1046
+ }
1047
+ const decryptedPayload = decryptedEvent.content.payload;
1048
+ expect(decryptedPayload).toBeDefined();
1049
+ expect(decryptedPayload?.content.case).toBe('transaction');
1050
+ if (decryptedPayload?.content.case === 'transaction') {
1051
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1052
+ expect(decryptedPayload.content.value.title).toBe('Send USDC');
1053
+ expect(decryptedPayload.content.value.subtitle).toBe('Send 50 USDC to recipient');
1054
+ expect(decryptedPayload.content.value.content.case).toBe('evm');
1055
+ if (decryptedPayload.content.value.content.case === 'evm') {
1056
+ expect(decryptedPayload.content.value.content.value.chainId).toBe('8453');
1057
+ expect(decryptedPayload.content.value.content.value.to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
1058
+ expect(decryptedPayload.content.value.content.value.value).toBe('0');
1059
+ expect(decryptedPayload.content.value.content.value.data).toBe('0xa9059cbb');
1060
+ }
1061
+ }
1062
+ });
1063
+ it('bot should be able to send addMember interaction request and user should respond', async () => {
1064
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1065
+ // Create a new GDM with bob, carol, and the bot (alice is NOT in it)
1066
+ const { streamId: addMemberGdmId } = await bobClient.gdms.createGDM([
1067
+ { userId: carol.userId },
1068
+ { userId: bot.agentUserId, appAddress: bot.appAddress },
1069
+ ]);
1070
+ await bobClient.riverConnection.call((client) => client.waitForStream(addMemberGdmId));
1071
+ const bobAddMemberGdm = bobClient.gdms.getGdm(addMemberGdmId);
1072
+ const requestId = randomUUID();
1073
+ // Bot sends addMember interaction request to bob
1074
+ const { eventId } = await bot.sendInteractionRequest(addMemberGdmId, {
1075
+ type: 'addMember',
1076
+ id: requestId,
1077
+ userId: alice.userId,
1078
+ message: 'Please add Alice to the group, she has great intel!',
1079
+ });
1080
+ // Wait for Bob to receive the interaction request
1081
+ await waitFor(() => expect(bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId)).toBeDefined());
1082
+ // Wait for decryption to complete
1083
+ await waitFor(() => {
1084
+ const event = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
1085
+ if (event?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1086
+ return false;
1087
+ }
1088
+ return event?.content?.payload !== undefined;
1089
+ });
1090
+ // Bob decrypts the request and verifies
1091
+ const decryptedEvent = bobAddMemberGdm.timeline.events.value.find((x) => x.eventId === eventId);
1092
+ expect(decryptedEvent?.content?.kind).toBe(RiverTimelineEvent.InteractionRequest);
1093
+ if (decryptedEvent?.content?.kind !== RiverTimelineEvent.InteractionRequest) {
1094
+ throw new Error('Event is not an InteractionRequest');
1095
+ }
1096
+ const decryptedPayload = decryptedEvent.content.payload;
1097
+ expect(decryptedPayload).toBeDefined();
1098
+ expect(decryptedPayload?.content.case).toBe('addMember');
1099
+ if (decryptedPayload?.content.case === 'addMember') {
1100
+ expect(decryptedPayload.content.value.id).toBe(requestId);
1101
+ expect(decryptedPayload.content.value.userId).toEqual(bin_fromHexString(alice.userId));
1102
+ expect(decryptedPayload.content.value.message).toBe('Please add Alice to the group, she has great intel!');
1103
+ }
1104
+ const encryptionDevice = decryptedPayload?.encryptionDevice;
1105
+ expect(encryptionDevice).toBeDefined();
1106
+ expect(encryptionDevice?.deviceKey).toBeDefined();
1107
+ expect(encryptionDevice?.fallbackKey).toBeDefined();
1108
+ // Bob sends addMember interaction response with accepted: true
1109
+ const recipient = bin_fromHexString(botClientAddress);
1110
+ const interactionResponsePayload = {
1111
+ salt: genIdBlob(),
1112
+ content: {
1113
+ case: 'addMember',
1114
+ value: {
1115
+ requestId: requestId,
1116
+ accepted: true,
1117
+ },
1118
+ },
1119
+ };
1120
+ const receivedInteractionResponses = [];
1121
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
1122
+ receivedInteractionResponses.push(e.response);
1123
+ }));
1124
+ // Bob calls joinUser() to add alice to the GDM
1125
+ await bobClient.riverConnection.call((client) => client.joinUser(addMemberGdmId, alice.userId));
1126
+ await bobClient.riverConnection.call(async (client) => {
1127
+ return await client.sendInteractionResponse(addMemberGdmId, recipient, interactionResponsePayload, encryptionDevice);
1128
+ });
1129
+ // Bot receives the response and verifies
1130
+ await waitFor(() => receivedInteractionResponses.length > 0);
1131
+ expect(receivedInteractionResponses[0].recipient).toEqual(recipient);
1132
+ expect(receivedInteractionResponses[0].payload.content.case).toBe('addMember');
1133
+ if (receivedInteractionResponses[0].payload.content.case === 'addMember') {
1134
+ expect(receivedInteractionResponses[0].payload.content.value.requestId).toBe(requestId);
1135
+ expect(receivedInteractionResponses[0].payload.content.value.accepted).toBe(true);
1136
+ }
1137
+ // Verify alice is now a member of the GDM
1138
+ await waitFor(() => {
1139
+ expect(bobAddMemberGdm.members.data.userIds).toContain(alice.userId);
1140
+ });
1141
+ });
1142
+ it('user should be able to send form interaction response', async () => {
1143
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_MENTIONS_REPLIES_REACTIONS);
1144
+ const recipient = bin_fromHexString(botClientAddress);
1145
+ const interactionResponsePayload = {
1146
+ salt: genIdBlob(),
1147
+ content: {
1148
+ case: 'form',
1149
+ value: {
1150
+ requestId: randomUUID(),
1151
+ components: [
1152
+ { id: '1', component: { case: 'button', value: {} } },
1153
+ {
1154
+ id: '2',
1155
+ component: { case: 'textInput', value: { value: 'Text Input' } },
1156
+ },
1157
+ ],
1158
+ },
1159
+ },
1160
+ };
1161
+ const receivedInteractionResponses = [];
1162
+ subscriptions.push(bot.onInteractionResponse((_h, e) => {
1163
+ receivedInteractionResponses.push(e.response);
1164
+ }));
1165
+ await bobClient.riverConnection.call(async (client) => {
1166
+ return await client.sendInteractionResponse(channelId, recipient, interactionResponsePayload, bot.getUserDevice());
1167
+ });
1168
+ await waitFor(() => receivedInteractionResponses.length > 0);
1169
+ });
1170
+ it('bot should be able to pin and unpin their own messages', async () => {
1171
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1172
+ const { eventId, envelope } = await bot.sendMessage(channelId, 'Hello');
1173
+ const parsedEvnet = await bot.client.unpackEnvelope(envelope);
1174
+ const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, parsedEvnet.event);
1175
+ log('pinned event', pinEventId);
1176
+ const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId);
1177
+ log('unpinned event', unpinEventId);
1178
+ });
1179
+ // @miguel-nascimento 2025-12-08 flaky test
1180
+ it.skip('bot should be able to pin and unpin other users messages', async () => {
1181
+ // Skipped: marked flaky in prior runs.
1182
+ /*
1183
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES)
1184
+ const { eventId } = await bobDefaultGdm.sendMessage('Hello')
1185
+ const receivedMessages: OnMessageType[] = []
1186
+ subscriptions.push(
1187
+ bot.onMessage((_h, e) => {
1188
+ receivedMessages.push(e)
1189
+ }),
1190
+ )
1191
+ await waitFor(() => receivedMessages.length > 0)
1192
+ const message = receivedMessages.find((x) => x.eventId === eventId)
1193
+ check(isDefined(message), 'message is defined')
1194
+ expect(message).toBeDefined()
1195
+ expect(message?.event).toBeDefined()
1196
+
1197
+ const { eventId: pinEventId } = await bot.pinMessage(channelId, eventId, message.event)
1198
+ log('pinned event', pinEventId)
1199
+ const { eventId: unpinEventId } = await bot.unpinMessage(channelId, eventId)
1200
+ log('unpinned event', unpinEventId)
1201
+ */
1202
+ });
1203
+ it('bob (bot owner) should be able to update bot profile image', async () => {
1204
+ // Create mock chunked media info (following pattern from client.test.ts:1010-1047)
1205
+ const mediaStreamId = makeUniqueMediaStreamId();
1206
+ const image = create(MediaInfoSchema, {
1207
+ mimetype: 'image/png',
1208
+ filename: 'bot-avatar.png',
1209
+ });
1210
+ const { key, iv } = await deriveKeyAndIV(nanoid(128));
1211
+ const chunkedMediaInfo = {
1212
+ info: image,
1213
+ streamId: mediaStreamId,
1214
+ encryption: {
1215
+ case: 'aesgcm',
1216
+ value: { secretKey: key, iv },
1217
+ },
1218
+ thumbnail: undefined,
1219
+ };
1220
+ // Bob (bot owner) updates the bot's profile image using setUserProfileImageFor
1221
+ await bobClient.riverConnection.call(async (client) => {
1222
+ await client.setUserProfileImage(chunkedMediaInfo, botClientAddress);
1223
+ });
1224
+ await waitFor(async () => {
1225
+ // Verify the bot's profile image was updated
1226
+ // in waitFor because sometimes it takes a second before you can getStream on a media stream
1227
+ const decrypted = await bobClient.riverConnection.call(async (client) => {
1228
+ return await client.getUserProfileImage(botClientAddress);
1229
+ });
1230
+ expect(decrypted).toBeDefined();
1231
+ expect(decrypted?.info?.mimetype).toBe('image/png');
1232
+ expect(decrypted?.info?.filename).toBe('bot-avatar.png');
1233
+ });
1234
+ });
1235
+ it('agent should receive messages from another agent', async () => {
1236
+ await setForwardSetting(ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES);
1237
+ // Get contract addresses for this environment
1238
+ const chainId = townsConfig.base.chainConfig.chainId;
1239
+ const addresses = getAddressesWithFallback(townsConfig.environmentId, chainId);
1240
+ if (!addresses?.accountProxy) {
1241
+ throw new Error(`No accountProxy address found for ${townsConfig.environmentId}/${chainId}`);
1242
+ }
1243
+ // Create relayer client for agent2
1244
+ const relayerUrl = process.env.RELAYER_URL ?? 'http://127.0.0.1:8787';
1245
+ const relayerClient = createPublicClient({
1246
+ chain: {
1247
+ id: chainId,
1248
+ name: 'Test Chain',
1249
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
1250
+ rpcUrls: { default: { http: [townsConfig.base.rpcUrl] } },
1251
+ },
1252
+ transport: http(townsConfig.base.rpcUrl),
1253
+ }).extend(relayerActions({ relayerUrl }));
1254
+ // Create agent2 using relayer
1255
+ const agent2Result = await createApp({
1256
+ owner: bobClient.riverConnection.signerContext,
1257
+ metadata: {
1258
+ username: `agent2-${randomUUID()}`,
1259
+ displayName: 'Agent 2',
1260
+ description: 'Second test agent',
1261
+ imageUrl: 'https://placehold.co/600x600',
1262
+ },
1263
+ relayerClient,
1264
+ accountProxy: addresses.accountProxy,
1265
+ townsConfig,
1266
+ });
1267
+ const agent2Address = agent2Result.appAddress;
1268
+ // Join agent2 to the channel
1269
+ await bobClient.riverConnection.call((client) => client.joinUser(channelId, agent2Address));
1270
+ // Set forward setting for agent2 (registration already done by createApp)
1271
+ await appRegistryRpcClient.setAppSettings({
1272
+ appId: bin_fromHexString(agent2Address),
1273
+ settings: { forwardSetting: ForwardSettingValue.FORWARD_SETTING_ALL_MESSAGES },
1274
+ });
1275
+ const agent2 = await makeTownsAgent(agent2Result.appPrivateData, agent2Result.jwtSecretBase64, {
1276
+ commands: [],
1277
+ });
1278
+ // Start agent2 server on a different port
1279
+ const agent2Port = Number(process.env.BOT_PORT) + 1;
1280
+ const agent2WebhookUrl = `https://localhost:${agent2Port}/webhook`;
1281
+ const app2 = agent2.start();
1282
+ serve({
1283
+ port: agent2Port,
1284
+ fetch: app2.fetch,
1285
+ createServer,
1286
+ });
1287
+ await appRegistryRpcClient.registerWebhook({
1288
+ appId: bin_fromHexString(agent2Address),
1289
+ webhookUrl: agent2WebhookUrl,
1290
+ });
1291
+ // Subscribe to messages on agent2
1292
+ const receivedByAgent2 = [];
1293
+ subscriptions.push(agent2.onMessage((_h, e) => {
1294
+ receivedByAgent2.push(e);
1295
+ }));
1296
+ // Agent1 sends a message mentioning Agent2
1297
+ const testMessage = 'Hey @agent2, can you help with this?';
1298
+ const { eventId } = await bot.sendMessage(channelId, testMessage, {
1299
+ mentions: [
1300
+ {
1301
+ userId: agent2Address,
1302
+ displayName: 'Agent 2',
1303
+ },
1304
+ ],
1305
+ });
1306
+ // Verify Agent2 received the message from Agent1
1307
+ await waitFor(() => receivedByAgent2.length > 0, { timeoutMS: 15_000 });
1308
+ const receivedMessage = receivedByAgent2.find((x) => x.eventId === eventId);
1309
+ expect(receivedMessage).toBeDefined();
1310
+ expect(receivedMessage?.message).toBe(testMessage);
1311
+ expect(receivedMessage?.userId).toBe(botClientAddress); // Sent by agent1
1312
+ expect(receivedMessage?.isMentioned).toBe(true); // Agent2 was mentioned
1313
+ });
1314
+ });
1315
+ //# sourceMappingURL=agent.test.js.map