@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.
- package/README.md +151 -0
- package/dist/agent.d.ts +480 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +1758 -0
- package/dist/agent.js.map +1 -0
- package/dist/agent.test.d.ts +2 -0
- package/dist/agent.test.d.ts.map +1 -0
- package/dist/agent.test.js +1315 -0
- package/dist/agent.test.js.map +1 -0
- package/dist/eventDedup.d.ts +73 -0
- package/dist/eventDedup.d.ts.map +1 -0
- package/dist/eventDedup.js +105 -0
- package/dist/eventDedup.js.map +1 -0
- package/dist/eventDedup.test.d.ts +2 -0
- package/dist/eventDedup.test.d.ts.map +1 -0
- package/dist/eventDedup.test.js +222 -0
- package/dist/eventDedup.test.js.map +1 -0
- package/dist/identity-types.d.ts +43 -0
- package/dist/identity-types.d.ts.map +1 -0
- package/dist/identity-types.js +2 -0
- package/dist/identity-types.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction-api.d.ts +61 -0
- package/dist/interaction-api.d.ts.map +1 -0
- package/dist/interaction-api.js +95 -0
- package/dist/interaction-api.js.map +1 -0
- package/dist/payments.d.ts +89 -0
- package/dist/payments.d.ts.map +1 -0
- package/dist/payments.js +144 -0
- package/dist/payments.js.map +1 -0
- package/dist/re-exports.d.ts +2 -0
- package/dist/re-exports.d.ts.map +1 -0
- package/dist/re-exports.js +2 -0
- package/dist/re-exports.js.map +1 -0
- package/dist/smart-account.d.ts +54 -0
- package/dist/smart-account.d.ts.map +1 -0
- package/dist/smart-account.js +132 -0
- package/dist/smart-account.js.map +1 -0
- package/dist/snapshot-getter.d.ts +21 -0
- package/dist/snapshot-getter.d.ts.map +1 -0
- package/dist/snapshot-getter.js +27 -0
- package/dist/snapshot-getter.js.map +1 -0
- 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
|