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