@stage-labs/metro 0.1.0-beta.171 → 0.1.0-beta.172

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.171",
3
+ "version": "0.1.0-beta.172",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.171",
3
+ "version": "0.1.0-beta.172",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -1,20 +1,13 @@
1
+ import type { Message } from 'discord.js';
1
2
  import {
2
- ActivityType,
3
- type Message,
4
- type PresenceStatusData,
5
- } from 'discord.js';
6
- import {
7
- accountFor,
8
3
  accounts,
9
4
  encodeEmoji,
10
- lineOf,
11
5
  rest,
12
6
  routeOf,
13
7
  } from './accounts.js';
14
8
  import { emitOutbound, emitOutboundEdit, emitOutboundReact } from './format.js';
15
9
  import { respond } from './wire.js';
16
10
  import { normalizeDiscord } from '@metro-labs/core/stations/messaging-normalize';
17
- import { assertContentLength } from '@metro-labs/core/stations/attachments';
18
11
  import {
19
12
  appendFiles,
20
13
  outgoingFiles,
@@ -111,38 +104,6 @@ async function send(id: string, args: Record<string, unknown>): Promise<void> {
111
104
  });
112
105
  }
113
106
 
114
- function presence(id: string, args: Record<string, unknown>): void {
115
- const {
116
- text,
117
- status = 'online',
118
- account,
119
- } = args as {
120
- text?: string;
121
- status?: PresenceStatusData;
122
- account?: string;
123
- };
124
- const accountId = accountFor({ account });
125
- const acct = accounts.get(accountId);
126
- if (!acct) {
127
- respond(id, { error: `unknown account '${accountId}'` });
128
- return;
129
- }
130
- const client = acct.client;
131
- if (!client.user) {
132
- respond(id, { error: `gateway not ready for account '${accountId}'` });
133
- return;
134
- }
135
- client.user.setPresence({
136
- status,
137
- activities: text
138
- ? [{ name: 'Custom Status', type: ActivityType.Custom, state: text }]
139
- : [],
140
- });
141
- respond(id, {
142
- result: { ok: true, text: text ?? null, status, account: accountId },
143
- });
144
- }
145
-
146
107
  const profileUrl = (userId?: string): string | null =>
147
108
  userId === undefined ? null : `https://discord.com/users/${userId}`;
148
109
 
@@ -245,104 +206,6 @@ async function fetchMessages(
245
206
  respond(id, { result: { messages: msgs, account: accountId } });
246
207
  }
247
208
 
248
- async function download(
249
- id: string,
250
- args: Record<string, unknown>,
251
- ): Promise<void> {
252
- const {
253
- line,
254
- messageId,
255
- outDir = '/tmp',
256
- account,
257
- } = args as {
258
- line: string;
259
- messageId: string;
260
- outDir?: string;
261
- account?: string;
262
- };
263
- const { accountId, channelId } = routeOf(line, account);
264
- const msg = await rest<{
265
- attachments: { url: string; content_type?: string; filename: string }[];
266
- }>(accountId, 'GET', `/channels/${channelId}/messages/${messageId}`);
267
- const files: { path: string; mediaType: string }[] = [];
268
- for (const att of msg.attachments) {
269
- const res = await fetch(att.url, { signal: AbortSignal.timeout(60_000) });
270
- if (!res.ok)
271
- throw new Error(`discord-bot download ${res.status} for ${att.url}`);
272
- assertContentLength(res.headers.get('content-length'));
273
- const buf = await res.arrayBuffer();
274
- const path = `${outDir}/${messageId}-${att.filename}`;
275
- await Bun.write(path, buf);
276
- files.push({
277
- path,
278
- mediaType: att.content_type ?? 'application/octet-stream',
279
- });
280
- }
281
- respond(id, { result: { files, account: accountId } });
282
- }
283
-
284
- async function threadCreate(
285
- id: string,
286
- args: Record<string, unknown>,
287
- ): Promise<void> {
288
- const {
289
- line,
290
- messageId,
291
- name,
292
- autoArchiveDuration = 1440,
293
- account,
294
- } = args as {
295
- line: string;
296
- messageId?: string;
297
- name: string;
298
- autoArchiveDuration?: number;
299
- account?: string;
300
- };
301
- const { accountId, channelId } = routeOf(line, account);
302
- const path = messageId
303
- ? `/channels/${channelId}/messages/${messageId}/threads`
304
- : `/channels/${channelId}/threads`;
305
- const res = await rest<{ id: string }>(accountId, 'POST', path, {
306
- name,
307
- auto_archive_duration: autoArchiveDuration,
308
- });
309
- respond(id, {
310
- result: {
311
- threadId: res.id,
312
- line: lineOf(accountId, res.id),
313
- account: accountId,
314
- },
315
- });
316
- }
317
-
318
- async function pin(id: string, args: Record<string, unknown>): Promise<void> {
319
- const { line, messageId, account } = args as {
320
- line: string;
321
- messageId: string;
322
- account?: string;
323
- };
324
- const { accountId, channelId } = routeOf(line, account);
325
- await rest(accountId, 'PUT', `/channels/${channelId}/pins/${messageId}`);
326
- respond(id, { result: { ok: true, account: accountId } });
327
- }
328
-
329
- async function typing(id: string, args: Record<string, unknown>): Promise<void> {
330
- const { line, account } = args as { line: string; account?: string };
331
- const { accountId, channelId } = routeOf(line, account);
332
- await rest(accountId, 'POST', `/channels/${channelId}/typing`);
333
- respond(id, { result: { ok: true, account: accountId } });
334
- }
335
-
336
- async function channel(
337
- id: string,
338
- args: Record<string, unknown>,
339
- ): Promise<void> {
340
- const { line, account } = args as { line: string; account?: string };
341
- const { accountId, channelId } = routeOf(line, account);
342
- const res = await rest(accountId, 'GET', `/channels/${channelId}`);
343
- respond(id, { result: res });
344
- }
345
-
346
209
  async function listMembers(
347
210
  id: string,
348
211
  args: Record<string, unknown>,
@@ -369,13 +232,7 @@ const HANDLERS: Record<string, StationHandler> = {
369
232
  edit,
370
233
  delete: remove,
371
234
  fetch: fetchMessages,
372
- download,
373
- thread_create: threadCreate,
374
- pin,
375
- typing,
376
- channel,
377
235
  listMembers,
378
- set_presence: presence,
379
236
  set_profile: setProfile,
380
237
  profile: readProfile,
381
238
  joinVoice,
@@ -1,4 +1,4 @@
1
- import { accountFor, accounts, tg, targetOf } from './accounts.js';
1
+ import { accounts, tg, targetOf } from './accounts.js';
2
2
  import { respond } from './wire.js';
3
3
  import { errMsg } from '@metro-labs/core/log';
4
4
  import { normalizeTelegram } from '@metro-labs/core/stations/messaging-normalize';
@@ -13,21 +13,14 @@ import {
13
13
  type StationHandler,
14
14
  } from '@metro-labs/core/stations/station-runtime';
15
15
  import { mediaKindOf } from './attachments.js';
16
- import { assertContentLength } from '@metro-labs/core/stations/attachments';
17
16
  import {
18
17
  emitOutbound,
19
18
  finishSend,
20
- media,
21
19
  MEDIA_METHOD_FIELD,
22
- sendDice,
23
- sendLocation,
24
20
  sendMedia,
25
21
  } from './media-actions.js';
26
22
  import { readProfile, setProfile } from './profile.js';
27
23
 
28
-
29
-
30
-
31
24
  export type { CallMsg };
32
25
 
33
26
  const meCache = new Map<string, { id: number; username: string | null }>();
@@ -211,34 +204,6 @@ async function remove(id: string, args: Record<string, unknown>): Promise<void>
211
204
  respond(id, { result: { ok: true, account: accountId } });
212
205
  }
213
206
 
214
-
215
-
216
- async function download(id: string, args: Record<string, unknown>): Promise<void> {
217
- const {
218
- fileId,
219
- outDir = '/tmp',
220
- account,
221
- } = args as { fileId: string; outDir?: string; account?: string };
222
- const accountId = accountFor({ account });
223
- const acct = accounts.get(accountId);
224
- if (!acct) throw new Error(`unknown account '${accountId}'`);
225
- const meta = await tg<{ file_path: string }>(accountId, 'getFile', {
226
- file_id: fileId,
227
- });
228
- const res = await fetch(`${acct.fileApi}/${meta.file_path}`, {
229
- signal: AbortSignal.timeout(60_000),
230
- });
231
- if (!res.ok) throw new Error(`telegram-bot download ${res.status}`);
232
- assertContentLength(res.headers.get('content-length'));
233
- const data = await res.arrayBuffer();
234
- const filename = meta.file_path.split('/').pop() ?? `${fileId}.bin`;
235
- const path = `${outDir}/${Date.now()}-${filename}`;
236
- await Bun.write(path, data);
237
- respond(id, {
238
- result: { path, fileSize: data.byteLength, account: accountId },
239
- });
240
- }
241
-
242
207
  async function listMembers(
243
208
  id: string,
244
209
  args: Record<string, unknown>,
@@ -274,15 +239,6 @@ const HANDLERS: Record<string, StationHandler> = {
274
239
  listMembers,
275
240
  set_profile: setProfile,
276
241
  profile: readProfile,
277
- send_photo: (id, args) =>
278
- media(id, 'sendPhoto', 'photo', ((args.caption as string) ?? '') + ' [image]', args),
279
- send_document: (id, args) =>
280
- media(id, 'sendDocument', 'document', ((args.caption as string) ?? '') + ' [file]', args),
281
- send_voice: (id, args) => media(id, 'sendVoice', 'voice', '[voice]', args),
282
- send_sticker: (id, args) => media(id, 'sendSticker', 'sticker', '[sticker]', args),
283
- send_dice: sendDice,
284
- send_location: sendLocation,
285
- download,
286
242
  };
287
243
 
288
244
  export const handleCall = makeStation({
@@ -1,4 +1,4 @@
1
- import { tg, tgForm, targetOf } from './accounts.js';
1
+ import { tgForm, targetOf } from './accounts.js';
2
2
  import { emit, mintId, respond, SELF_URI } from './wire.js';
3
3
  import { appendFile } from '@metro-labs/core/stations/attachments';
4
4
 
@@ -82,25 +82,6 @@ export async function sendMedia(
82
82
  return { accountId, message_id: r.message_id };
83
83
  }
84
84
 
85
- export async function media(
86
- id: string,
87
- method: string,
88
- field: string,
89
- label: string,
90
- args: Record<string, unknown>,
91
- ): Promise<void> {
92
- const { accountId, message_id } = await sendMedia(method, field, args);
93
- const line = (args as { line: string }).line;
94
- finishSend(
95
- id,
96
- accountId,
97
- line,
98
- String(message_id),
99
- label,
100
- args.replyTo as string | undefined,
101
- );
102
- }
103
-
104
85
  export const MEDIA_METHOD_FIELD: Record<
105
86
  string,
106
87
  { method: string; field: string }
@@ -111,56 +92,3 @@ export const MEDIA_METHOD_FIELD: Record<
111
92
  video: { method: 'sendVideo', field: 'video' },
112
93
  document: { method: 'sendDocument', field: 'document' },
113
94
  };
114
-
115
- export async function sendDice(
116
- id: string,
117
- args: Record<string, unknown>,
118
- ): Promise<void> {
119
- const {
120
- line,
121
- emoji = '\U0001F3B2',
122
- account,
123
- } = args as { line: string; emoji?: string; account?: string };
124
- const { accountId, chatId, topicId } = targetOf(line, account);
125
- const body: Record<string, unknown> = { chat_id: chatId, emoji };
126
- if (topicId !== undefined) body.message_thread_id = topicId;
127
- const r = await tg<{ message_id: number; dice?: { value: number } }>(
128
- accountId,
129
- 'sendDice',
130
- body,
131
- );
132
- finishSend(
133
- id,
134
- accountId,
135
- line,
136
- String(r.message_id),
137
- `[dice ${emoji} = ${r.dice?.value ?? '?'}]`,
138
- undefined,
139
- { value: r.dice?.value },
140
- );
141
- }
142
-
143
- export async function sendLocation(
144
- id: string,
145
- args: Record<string, unknown>,
146
- ): Promise<void> {
147
- const { line, latitude, longitude, account } = args as {
148
- line: string;
149
- latitude: number;
150
- longitude: number;
151
- account?: string;
152
- };
153
- const { accountId, chatId } = targetOf(line, account);
154
- const r = await tg<{ message_id: number }>(accountId, 'sendLocation', {
155
- chat_id: chatId,
156
- latitude,
157
- longitude,
158
- });
159
- finishSend(
160
- id,
161
- accountId,
162
- line,
163
- String(r.message_id),
164
- `[location: ${latitude}, ${longitude}]`,
165
- );
166
- }
@@ -18,7 +18,6 @@
18
18
  "@xmtp/content-type-reaction": "^2.0.2",
19
19
  "@xmtp/content-type-remote-attachment": "^2.0.4",
20
20
  "@xmtp/content-type-reply": "^2.0.3",
21
- "@xmtp/content-type-wallet-send-calls": "^2.0.0",
22
21
  "@xmtp/node-bindings": "1.9.1",
23
22
  "@xmtp/node-sdk": "^6.0.0",
24
23
  "@zerodev/ecdsa-validator": "5.4.9",
@@ -1,24 +1,11 @@
1
1
  import { IdentifierKind } from '@xmtp/node-sdk';
2
- import {
3
- accounts,
4
- accountForCall,
5
- convOf,
6
- lineOf,
7
- parseLine,
8
- type Account,
9
- } from './accounts.js';
2
+ import { accountForCall, convOf, lineOf, parseLine } from './accounts.js';
10
3
  import { respond } from './wire.js';
11
4
  import { TrainError } from '@metro-labs/core/train-error';
12
- import { pushHandlers } from './actions-push.js';
13
- import { cleanLabels, labelsBlob } from './labels.js';
14
5
  import {
15
- applyMemberOp,
16
6
  buildGroupInfo,
17
7
  buildMemberList,
18
- createGroupWithMembers,
19
8
  ethIdentifiers,
20
- parseMemberArgs,
21
- resolveMembers,
22
9
  warmGroupName,
23
10
  } from './conv-helpers.js';
24
11
  import { closeGroup } from './actions-close.js';
@@ -27,17 +14,10 @@ import {
27
14
  groupCreate,
28
15
  groupRemoveMembers,
29
16
  } from './group.js';
30
- import {
31
- updateChannelMeta,
32
- applyChannelMeta,
33
- resolveLine,
34
- setGithub,
35
- setPreview,
36
- } from './actions-meta.js';
17
+ import { updateChannelMeta } from './actions-meta.js';
37
18
 
38
19
  type Args = Record<string, unknown>;
39
20
  type Handler = (id: string, args: Args) => Promise<void>;
40
- type Conv = Awaited<ReturnType<typeof convOf>>['conv'] & object;
41
21
 
42
22
  async function newDm(id: string, args: Args): Promise<void> {
43
23
  const { address } = args as { address: string };
@@ -79,72 +59,6 @@ async function newGroup(id: string, args: Args): Promise<void> {
79
59
  });
80
60
  }
81
61
 
82
- async function createRequestGroup(id: string, args: Args): Promise<void> {
83
- const { name, description, labels } = args as {
84
- name: string;
85
- description?: string;
86
- labels?: string[];
87
- };
88
- const acct = accountForCall(args);
89
- if (!name || typeof name !== 'string')
90
- throw new TrainError('INVALID_ARGS', 'createRequestGroup requires a `name`');
91
- const { addrs, inboxes } = resolveMembers(args);
92
- if (addrs.length === 0 && inboxes.length === 0)
93
- throw new TrainError(
94
- 'INVALID_ARGS',
95
- 'createRequestGroup requires addresses[] or inboxIds[]',
96
- );
97
- const opts: {
98
- groupName: string;
99
- groupDescription?: string;
100
- appData?: string;
101
- } = { groupName: name };
102
- if (description) opts.groupDescription = description;
103
- if (Array.isArray(labels) && labels.length)
104
- opts.appData = labelsBlob(undefined, labels);
105
-
106
- const group = await createGroupWithMembers(acct, addrs, inboxes, opts);
107
- warmGroupName(group.id, name);
108
- respond(id, {
109
- result: {
110
- line: lineOf(acct.cfg.id, group.id),
111
- id: group.id,
112
- account: acct.cfg.id,
113
- name,
114
- description: description ?? '',
115
- labels: cleanLabels(labels ?? []),
116
- },
117
- });
118
- }
119
-
120
- async function setLabels(id: string, args: Args): Promise<void> {
121
- const { labels, setName, setDescription, setGithub } = args as {
122
- line?: string;
123
- groupId?: string;
124
- labels: string[];
125
- setName?: string;
126
- setDescription?: string;
127
- setGithub?: string;
128
- };
129
- const resolvedLine = resolveLine(args, 'setLabels');
130
- if (!Array.isArray(labels))
131
- throw new TrainError('INVALID_ARGS', 'setLabels requires a `labels` array');
132
- const appData: Record<string, unknown> = { labels };
133
- if (typeof setGithub === 'string') appData.github = setGithub;
134
- const result = await applyChannelMeta(
135
- { line: resolvedLine, name: setName, description: setDescription, appData },
136
- 'setLabels',
137
- );
138
- respond(id, {
139
- result: {
140
- line: result.line,
141
- id: result.id,
142
- account: result.account,
143
- labels: result.labels,
144
- },
145
- });
146
- }
147
-
148
62
  async function query(id: string, args: Args): Promise<void> {
149
63
  const { line, limit } = args as { line: string; limit?: number };
150
64
  const { conv } = await convOf(line);
@@ -178,30 +92,6 @@ async function query(id: string, args: Args): Promise<void> {
178
92
  respond(id, { result: { line, count: messages.length, messages } });
179
93
  }
180
94
 
181
- async function addMembers(id: string, args: Args): Promise<void> {
182
- const { line, addrs, inboxes } = parseMemberArgs(args, 'addMembers');
183
- const { acct, conv } = await convOf(line);
184
- if (!conv)
185
- throw new TrainError('NOT_FOUND', `conversation not found for ${line}`);
186
- await applyMemberOp(conv, addrs, inboxes, 'add');
187
- const info = await buildGroupInfo(line, acct, conv);
188
- respond(id, {
189
- result: { ...info, added: { addresses: addrs, inboxIds: inboxes } },
190
- });
191
- }
192
-
193
- async function removeMembers(id: string, args: Args): Promise<void> {
194
- const { line, addrs, inboxes } = parseMemberArgs(args, 'removeMembers');
195
- const { acct, conv } = await convOf(line);
196
- if (!conv)
197
- throw new TrainError('NOT_FOUND', `conversation not found for ${line}`);
198
- await applyMemberOp(conv, addrs, inboxes, 'remove');
199
- const info = await buildGroupInfo(line, acct, conv);
200
- respond(id, {
201
- result: { ...info, removed: { addresses: addrs, inboxIds: inboxes } },
202
- });
203
- }
204
-
205
95
  async function groupInfo(id: string, args: Args): Promise<void> {
206
96
  const { line } = args as { line: string };
207
97
  const { acct, conv } = await convOf(line);
@@ -218,74 +108,14 @@ async function listMembers(id: string, args: Args): Promise<void> {
218
108
  respond(id, { result: await buildMemberList(acct, conv) });
219
109
  }
220
110
 
221
- async function summarizeConv(acct: Account, c: Conv): Promise<unknown> {
222
- const recent = await c.messages({ limit: 1, direction: 1 }).catch(() => []);
223
- const last = recent[0];
224
- let preview = '';
225
- if (last) {
226
- const cc: unknown = last.content;
227
- preview =
228
- typeof cc === 'string'
229
- ? cc.slice(0, 80)
230
- : `[${last.contentType?.typeId ?? 'unknown'}]`;
231
- }
232
- const isDm =
233
- typeof (c as unknown as { peerInboxId?: unknown }).peerInboxId ===
234
- 'function';
235
- const gn = (c as unknown as { name?: string | (() => Promise<string>) }).name;
236
- const resolvedName =
237
- typeof gn === 'function' ? await gn().catch(() => '') : (gn ?? '');
238
- return {
239
- line: lineOf(acct.cfg.id, c.id),
240
- id: c.id,
241
- account: acct.cfg.id,
242
- version: isDm ? 'dm' : 'group',
243
- name: resolvedName ?? '',
244
- lastTs: last
245
- ? new Date(Number(last.sentAtNs / 1_000_000n)).toISOString()
246
- : null,
247
- lastPreview: preview,
248
- };
249
- }
250
-
251
- async function listConvs(id: string, args: Args): Promise<void> {
252
- const { limit, account } = args as { limit?: number; account?: string };
253
- const lim = Math.min(Math.max(1, limit ?? 50), 200);
254
- const targets = account
255
- ? [accounts.get(account)].filter((a): a is Account => a !== undefined)
256
- : [...accounts.values()];
257
- const summaries: unknown[] = [];
258
- for (const acct of targets) {
259
- await acct.client.conversations.syncAll();
260
- const all = await acct.client.conversations.list();
261
- for (const c of all.slice(0, lim))
262
- summaries.push(await summarizeConv(acct, c));
263
- }
264
- summaries.sort((a, b) =>
265
- ((b as { lastTs?: string }).lastTs ?? '').localeCompare(
266
- (a as { lastTs?: string }).lastTs ?? '',
267
- ),
268
- );
269
- respond(id, {
270
- result: { count: summaries.length, conversations: summaries.slice(0, lim) },
271
- });
272
- }
273
-
274
111
  export const convHandlers: Record<string, Handler> = {
275
112
  newDm,
276
113
  newGroup,
277
- createRequestGroup,
278
- addMembers,
279
- removeMembers,
280
- setLabels,
281
- setGithub,
282
- setPreview,
283
114
  updateChannelMeta,
284
115
  closeGroup,
285
116
  query,
286
117
  groupInfo,
287
118
  listMembers,
288
- listConvs,
289
119
  groupCreate: async (id, args) => {
290
120
  respond(id, { result: await groupCreate(args) });
291
121
  },
@@ -295,5 +125,4 @@ export const convHandlers: Record<string, Handler> = {
295
125
  groupRemoveMembers: async (id, args) => {
296
126
  respond(id, { result: await groupRemoveMembers(args) });
297
127
  },
298
- ...pushHandlers,
299
128
  };
@@ -6,7 +6,7 @@ import { TrainError } from '@metro-labs/core/train-error';
6
6
 
7
7
  type Args = Record<string, unknown>;
8
8
 
9
- export function resolveLine(args: Args, verb: string): string {
9
+ function resolveLine(args: Args, verb: string): string {
10
10
  const line = (args as { line?: string }).line;
11
11
  if (line) return line;
12
12
  const groupId = (args as { groupId?: string }).groupId;
@@ -53,7 +53,7 @@ async function applyMergedAppData(
53
53
  return readAppData(group.appData);
54
54
  }
55
55
 
56
- export async function applyChannelMeta(
56
+ async function applyChannelMeta(
57
57
  args: {
58
58
  line: string;
59
59
  name?: string;
@@ -116,42 +116,3 @@ export async function updateChannelMeta(id: string, args: Args): Promise<void> {
116
116
  );
117
117
  respond(id, { result });
118
118
  }
119
-
120
- export async function setGithub(id: string, args: Args): Promise<void> {
121
- const line = resolveLine(args, 'setGithub');
122
- const { url } = args as { url: string };
123
- if (typeof url !== 'string')
124
- throw new Error('setGithub requires a `url` string');
125
- const result = await applyChannelMeta(
126
- { line, appData: { github: url } },
127
- 'setGithub',
128
- );
129
- respond(id, {
130
- result: {
131
- line: result.line,
132
- id: result.id,
133
- account: result.account,
134
- github: result.github,
135
- },
136
- });
137
- }
138
-
139
- export async function setPreview(id: string, args: Args): Promise<void> {
140
- const line = resolveLine(args, 'setPreview');
141
- const a = args as { preview?: unknown; url?: unknown };
142
- const value = typeof a.preview === 'string' ? a.preview : a.url;
143
- if (typeof value !== 'string')
144
- throw new Error('setPreview requires a `preview` string');
145
- const result = await applyChannelMeta(
146
- { line, appData: { preview: value } },
147
- 'setPreview',
148
- );
149
- respond(id, {
150
- result: {
151
- line: result.line,
152
- id: result.id,
153
- account: result.account,
154
- preview: result.preview,
155
- },
156
- });
157
- }
@@ -4,20 +4,10 @@ import {
4
4
  AttachmentCodec,
5
5
  type Attachment,
6
6
  } from '@xmtp/content-type-remote-attachment';
7
- import {
8
- WalletSendCallsCodec,
9
- type WalletSendCallsParams,
10
- } from '@xmtp/content-type-wallet-send-calls';
11
- import { toHex } from 'viem';
12
7
  import { convOf } from './accounts.js';
13
8
  import { resolveMsgId, respond } from './wire.js';
14
9
  import { emitOutbound } from './emit.js';
15
- import {
16
- PollCodec,
17
- buildPollContent,
18
- SignatureRequestCodec,
19
- type SignatureRequestContent,
20
- } from './codecs.js';
10
+ import { PollCodec, buildPollContent } from './codecs.js';
21
11
  import { convHandlers } from './actions-conv.js';
22
12
  import { normalizeXmtp } from '@metro-labs/core/stations/messaging-normalize';
23
13
  import { TrainError } from '@metro-labs/core/train-error';
@@ -215,121 +205,6 @@ async function sendImage(id: string, args: Args): Promise<void> {
215
205
  respond(id, { result: { messageId: sentId } });
216
206
  }
217
207
 
218
- interface TxRequestArgs {
219
- to: string;
220
- amountEth?: number;
221
- data?: string;
222
- note?: string;
223
- chainId?: number;
224
- }
225
-
226
- function validateTxAmount(amountEth: number | undefined, hasData: boolean): void {
227
- if (!hasData && (typeof amountEth !== 'number' || !(amountEth > 0))) {
228
- throw badArgs(
229
- 'sendTxRequest requires a positive `amountEth` (or `data` for a contract call)',
230
- );
231
- }
232
- if (amountEth != null && (typeof amountEth !== 'number' || amountEth < 0)) {
233
- throw badArgs('sendTxRequest `amountEth` must be a non-negative number');
234
- }
235
- }
236
-
237
- function validateTxRequest(a: TxRequestArgs): boolean {
238
- const { to, amountEth, data } = a;
239
- if (!to || typeof to !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(to)) {
240
- throw badArgs('sendTxRequest requires a valid 0x `to` address');
241
- }
242
- const hasData = data != null;
243
- if (
244
- hasData &&
245
- (typeof data !== 'string' || !/^0x([0-9a-fA-F]{2})*$/.test(data))
246
- ) {
247
- throw badArgs('sendTxRequest `data` must be 0x-prefixed hex calldata');
248
- }
249
- validateTxAmount(amountEth, hasData);
250
- return hasData;
251
- }
252
-
253
- function buildTxContent(
254
- a: TxRequestArgs,
255
- from: string,
256
- hasData: boolean,
257
- ): WalletSendCallsParams {
258
- const { to, amountEth, data, note, chainId } = a;
259
- const weiHex = amountEth
260
- ? '0x' + BigInt(Math.round(amountEth * 1e18)).toString(16)
261
- : '0x0';
262
- return {
263
- version: '1.0',
264
- chainId: toHex(chainId ?? 1),
265
- from: from as `0x${string}`,
266
- calls: [
267
- {
268
- to: to as `0x${string}`,
269
- value: weiHex as `0x${string}`,
270
- ...(hasData ? { data: data as `0x${string}` } : {}),
271
- metadata: {
272
- description: note ?? (hasData ? 'Contract call' : 'Payment request'),
273
- transactionType: 'transfer',
274
- },
275
- },
276
- ],
277
- };
278
- }
279
-
280
- function txRequestLabel(a: TxRequestArgs, hasData: boolean): string {
281
- const { amountEth, note } = a;
282
- return hasData
283
- ? `📝 ${note ?? 'Contract call'}${amountEth ? ` (${amountEth} ETH)` : ''}`
284
- : `💸 ${note ?? 'Payment request'} (${amountEth} ETH)`;
285
- }
286
-
287
- async function sendTxRequest(id: string, args: Args): Promise<void> {
288
- const { line } = args as { line: string };
289
- const txArgs = args as Args & TxRequestArgs;
290
- const { acct, conv } = await convOf(line);
291
- if (!conv) throw noConv(line);
292
- const hasData = validateTxRequest(txArgs);
293
- const content = buildTxContent(txArgs, acct.address, hasData);
294
- const sentId = await conv.send(new WalletSendCallsCodec().encode(content));
295
- emitOutbound(acct.cfg.id, line, sentId, txRequestLabel(txArgs, hasData));
296
- respond(id, { result: { messageId: sentId } });
297
- }
298
-
299
- async function sendSignatureRequest(id: string, args: Args): Promise<void> {
300
- const { line, kind, eip712, message, description } = args as {
301
- line: string;
302
- kind?: 'eip712' | 'personal';
303
- eip712?: unknown;
304
- message?: string;
305
- description?: string;
306
- };
307
- const { acct, conv } = await convOf(line);
308
- if (!conv) throw noConv(line);
309
- const k: 'eip712' | 'personal' = kind === 'eip712' ? 'eip712' : 'personal';
310
- if (k === 'eip712' && !eip712)
311
- throw badArgs(
312
- 'sendSignatureRequest eip712 requires an `eip712` typed-data object',
313
- );
314
- if (k === 'personal' && (!message || typeof message !== 'string')) {
315
- throw badArgs('sendSignatureRequest personal requires a `message` string');
316
- }
317
- const content: SignatureRequestContent = {
318
- id: 'sig_' + Date.now().toString(36),
319
- kind: k,
320
- ...(k === 'eip712' ? { eip712 } : { message }),
321
- description,
322
- };
323
- const sentId = await conv.send(new SignatureRequestCodec().encode(content));
324
- emitOutbound(
325
- acct.cfg.id,
326
- line,
327
- sentId,
328
- `✍️ ${description ?? 'Signature request'}`,
329
- );
330
- respond(id, { result: { messageId: sentId } });
331
- }
332
-
333
208
  async function accountsAction(id: string): Promise<void> {
334
209
  const { accounts } = await import('./accounts.js');
335
210
  respond(id, {
@@ -362,13 +237,10 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
362
237
  profile: profileAction,
363
238
  send,
364
239
  ask,
365
- sendPoll: ask,
366
240
  react,
367
241
  reply,
368
242
  sendAttachment,
369
243
  sendImage,
370
- sendTxRequest,
371
- sendSignatureRequest,
372
244
  edit: (id) => unsupportedVerb(id, 'edit'),
373
245
  delete: (id) => unsupportedVerb(id, 'delete'),
374
246
  ...convHandlers,
@@ -1,7 +1,6 @@
1
1
  import type { Conversation, DecodedMessage } from '@xmtp/node-sdk';
2
- import { lineOf, parseLine } from './accounts.js';
2
+ import { lineOf } from './accounts.js';
3
3
  import { emit, mintId, rememberSent, rememberUid, SELF_URI } from './wire.js';
4
- import { fcmPushToAll } from './push.js';
5
4
  import { emitInbound, emitAttachmentSaved } from './emit-core.js';
6
5
  import { typedEnvelope, type EnvelopeCtx } from './emit-payloads.js';
7
6
  import type { StructuredEvent } from '@metro-labs/core/events';
@@ -69,12 +68,4 @@ export function emitOutbound(
69
68
  ...(event ? { event } : {}),
70
69
  payload: { account: accountId },
71
70
  });
72
- void (async (): Promise<void> => {
73
- const data: Record<string, string> = { line, messageId };
74
- {
75
- const p = parseLine(line);
76
- if (p) data.convId = p.convId.toLowerCase();
77
- }
78
- await fcmPushToAll(accountId, data);
79
- })().catch(() => undefined);
80
71
  }
@@ -9,7 +9,6 @@ import {
9
9
  import { emitInbound, envelope } from './emit.js';
10
10
  import { groupNameFor } from './conv-helpers.js';
11
11
  import { senderFieldsNow } from './sender.js';
12
- import { handleControlDm, pushInbound } from './push.js';
13
12
  import { readCalls } from '@metro-labs/core/trains/protocol';
14
13
  import { handleCall } from './actions.js';
15
14
 
@@ -60,7 +59,6 @@ async function handleStreamMessage(
60
59
  const { id } = acct.cfg;
61
60
  if (msg.senderInboxId === acct.client.inboxId) return;
62
61
  if (SILENT_TYPES.has(msg.contentType?.typeId ?? '')) return;
63
- if (handleControlDm(id, msg)) return;
64
62
  const conv = await acct.client.conversations.getConversationById(
65
63
  msg.conversationId,
66
64
  );
@@ -75,7 +73,6 @@ async function handleStreamMessage(
75
73
  env.payload = { ...p, lineName: name };
76
74
  }
77
75
  emitInbound(id, env);
78
- pushInbound(id, env, msg, conv);
79
76
  }
80
77
 
81
78
  async function runAccount(acct: Account): Promise<void> {
@@ -11,7 +11,7 @@ export interface GroupLike {
11
11
  const MAX_LABELS = 16;
12
12
  const MAX_LABEL_LEN = 24;
13
13
 
14
- export function cleanLabels(raw: unknown): string[] {
14
+ function cleanLabels(raw: unknown): string[] {
15
15
  if (!Array.isArray(raw)) return [];
16
16
  const out: string[] = [];
17
17
  const seen = new Set<string>();
@@ -41,24 +41,6 @@ function parseAppDataObject(
41
41
  return {};
42
42
  }
43
43
 
44
- export function labelsBlob(
45
- existingAppData: string | undefined,
46
- labels: string[],
47
- github?: string,
48
- ): string {
49
- const existing = parseAppDataObject(existingAppData);
50
- const blob: Record<string, unknown> = {
51
- ...existing,
52
- v: 1,
53
- labels: cleanLabels(labels),
54
- };
55
- if (typeof github === 'string') {
56
- if (github.trim()) blob.github = github.trim();
57
- else delete blob.github;
58
- }
59
- return JSON.stringify(blob);
60
- }
61
-
62
44
  function trimmedString(v: unknown): string | undefined {
63
45
  return typeof v === 'string' && v.trim() ? v.trim() : undefined;
64
46
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.171"
2
+ "version": "0.1.0-beta.172"
3
3
  }
@@ -12,7 +12,6 @@
12
12
  "@xmtp/content-type-reaction": "^2.0.2",
13
13
  "@xmtp/content-type-remote-attachment": "^2.0.4",
14
14
  "@xmtp/content-type-reply": "^2.0.3",
15
- "@xmtp/content-type-wallet-send-calls": "^2.0.0",
16
15
  "@xmtp/node-bindings": "1.9.1",
17
16
  "@xmtp/node-sdk": "^6.0.0",
18
17
  "@zerodev/ecdsa-validator": "5.4.9",
@@ -1,87 +0,0 @@
1
- import { accounts } from './accounts.js';
2
- import { respond } from './wire.js';
3
- import {
4
- fcmPushToAll,
5
- loadPushTokens,
6
- removePushToken,
7
- savePushTokens,
8
- storePushToken,
9
- } from './push.js';
10
-
11
- type Args = Record<string, unknown>;
12
- type Handler = (id: string, args: Args) => Promise<void>;
13
-
14
- function registerPush(id: string, args: Args): Promise<void> {
15
- const { token, account, platform, inboxId } = args as {
16
- token?: string;
17
- account?: string;
18
- platform?: string;
19
- inboxId?: string;
20
- };
21
- if (!token || typeof token !== 'string' || token.length < 20) {
22
- throw new Error('register-push requires a non-empty FCM device token');
23
- }
24
- const total = storePushToken({ token, account, platform, inboxId });
25
- respond(id, { result: { stored: true, total, account: account ?? null } });
26
- return Promise.resolve();
27
- }
28
-
29
- function listPush(id: string): Promise<void> {
30
- const tokens = loadPushTokens();
31
- respond(id, {
32
- result: {
33
- count: tokens.length,
34
- tokens: tokens.map((t) => ({
35
- token: `${t.token.slice(0, 12)}…${t.token.slice(-6)}`,
36
- registeredAt: t.registeredAt,
37
- lastSeenAt: t.lastSeenAt ?? null,
38
- account: t.account ?? null,
39
- platform: t.platform ?? null,
40
- inboxId: t.inboxId ?? null,
41
- })),
42
- },
43
- });
44
- return Promise.resolve();
45
- }
46
-
47
- async function testPush(id: string, args: Args): Promise<void> {
48
- const { account } = args as { account?: string };
49
- const acctId =
50
- account ??
51
- (accounts.size === 1 ? [...accounts.keys()][0] : 'default') ??
52
- 'default';
53
- await fcmPushToAll(acctId, { channelId: 'xmtp', source: 'test-push' });
54
- const sent = loadPushTokens().filter(
55
- (t) => !t.account || t.account === acctId,
56
- ).length;
57
- respond(id, { result: { sent, account: acctId } });
58
- }
59
-
60
- function unregisterPush(id: string, args: Args): Promise<void> {
61
- const { token } = args as { token: string };
62
- savePushTokens(loadPushTokens().filter((t) => t.token !== token));
63
- respond(id, { result: { removed: true } });
64
- return Promise.resolve();
65
- }
66
-
67
- function disablePush(id: string, args: Args): Promise<void> {
68
- const { token } = args as { token?: string };
69
- if (!token || typeof token !== 'string')
70
- throw new Error('disable-push requires a token');
71
- const remaining = removePushToken(token);
72
- respond(id, {
73
- result: {
74
- removed: remaining !== -1,
75
- remaining: remaining === -1 ? null : remaining,
76
- },
77
- });
78
- return Promise.resolve();
79
- }
80
-
81
- export const pushHandlers: Record<string, Handler> = {
82
- 'register-push': registerPush,
83
- 'list-push': (id) => listPush(id),
84
- 'test-push': testPush,
85
- 'unregister-push': unregisterPush,
86
- 'disable-push': disablePush,
87
- };
@@ -1,349 +0,0 @@
1
- import { errMsg } from '@metro-labs/core/log';
2
- import { type DecodedMessage } from '@xmtp/node-sdk';
3
- import { existsSync, readFileSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
- import { readJson, writeJson } from '@metro-labs/core/secure-fs';
6
- import { parseLine } from './accounts.js';
7
-
8
- const FCM_SVC_PATH = `${process.env.HOME}/.config/metro/firebase-service-account.json`;
9
- const FCM_TOKENS_PATH = `${process.env.HOME}/.cache/metro/xmtp-push-tokens.json`;
10
- interface FcmServiceAccount {
11
- client_email: string;
12
- private_key: string;
13
- project_id: string;
14
- token_uri: string;
15
- }
16
-
17
- interface StoredPushToken {
18
- token: string;
19
- registeredAt: string;
20
- account?: string;
21
- inboxId?: string;
22
- inboxIds?: string[];
23
- platform?: string;
24
- lastSeenAt?: string;
25
- }
26
-
27
- function loadFcmSvc(): FcmServiceAccount | null {
28
- if (!existsSync(FCM_SVC_PATH)) return null;
29
- try {
30
- return JSON.parse(readFileSync(FCM_SVC_PATH, 'utf8')) as FcmServiceAccount;
31
- } catch (err) {
32
- process.stderr.write(
33
- `fcm: bad service account: ${errMsg(err)}\n`,
34
- );
35
- return null;
36
- }
37
- }
38
- export function loadPushTokens(): StoredPushToken[] {
39
- return readJson<StoredPushToken[]>(FCM_TOKENS_PATH, []);
40
- }
41
- export function savePushTokens(tokens: StoredPushToken[]): void {
42
- mkdirSync(dirname(FCM_TOKENS_PATH), { recursive: true });
43
- writeJson(FCM_TOKENS_PATH, tokens);
44
- }
45
-
46
- function collectInboxIds(
47
- existing: StoredPushToken | undefined,
48
- entryInboxId: string | undefined,
49
- ): Set<string> {
50
- const inboxIds = new Set<string>(existing?.inboxIds ?? []);
51
- if (existing?.inboxId) inboxIds.add(existing.inboxId);
52
- if (entryInboxId) inboxIds.add(entryInboxId);
53
- return inboxIds;
54
- }
55
-
56
- export function storePushToken(entry: {
57
- token: string;
58
- account?: string;
59
- inboxId?: string;
60
- platform?: string;
61
- }): number {
62
- const now = new Date().toISOString();
63
- const all = loadPushTokens();
64
- const existing = all.find((t) => t.token === entry.token);
65
- const remaining = all.filter((t) => t.token !== entry.token);
66
- const inboxIds = collectInboxIds(existing, entry.inboxId);
67
- const row: StoredPushToken = {
68
- token: entry.token,
69
- registeredAt: existing?.registeredAt ?? now,
70
- lastSeenAt: now,
71
- };
72
- if (entry.account) row.account = entry.account;
73
- if (entry.inboxId) row.inboxId = entry.inboxId;
74
- if (inboxIds.size) row.inboxIds = [...inboxIds];
75
- if (entry.platform) row.platform = entry.platform;
76
- remaining.push(row);
77
- savePushTokens(remaining);
78
- return remaining.length;
79
- }
80
-
81
- let cachedAccessToken: { token: string; expiresAt: number } | null = null;
82
- async function fcmAccessToken(): Promise<string | null> {
83
- const svc = loadFcmSvc();
84
- if (!svc) return null;
85
- if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 60_000)
86
- return cachedAccessToken.token;
87
- const { createSign } = await import('node:crypto');
88
- const enc = (o: unknown): string =>
89
- Buffer.from(JSON.stringify(o)).toString('base64url');
90
- const now = Math.floor(Date.now() / 1000);
91
- const header = { alg: 'RS256', typ: 'JWT' };
92
- const payload = {
93
- iss: svc.client_email,
94
- scope: 'https://www.googleapis.com/auth/firebase.messaging',
95
- aud: svc.token_uri,
96
- iat: now,
97
- exp: now + 3600,
98
- };
99
- const sigInput = `${enc(header)}.${enc(payload)}`;
100
- const signer = createSign('RSA-SHA256');
101
- signer.update(sigInput);
102
- signer.end();
103
- const sig = signer.sign(svc.private_key).toString('base64url');
104
- const jwt = `${sigInput}.${sig}`;
105
- const grant = encodeURIComponent(
106
- 'urn:ietf:params:oauth:grant-type:jwt-bearer',
107
- );
108
- const res = await fetch(svc.token_uri, {
109
- method: 'POST',
110
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
111
- body: `grant_type=${grant}&assertion=${jwt}`,
112
- signal: AbortSignal.timeout(15_000),
113
- });
114
- if (!res.ok) {
115
- process.stderr.write(
116
- `fcm token exchange ${res.status}: ${await res.text()}\n`,
117
- );
118
- return null;
119
- }
120
- const json = (await res.json()) as {
121
- access_token: string;
122
- expires_in: number;
123
- };
124
- cachedAccessToken = {
125
- token: json.access_token,
126
- expiresAt: Date.now() + json.expires_in * 1000,
127
- };
128
- return cachedAccessToken.token;
129
- }
130
-
131
- async function fcmPushTo(
132
- deviceToken: string,
133
- data: Record<string, string> = {},
134
- ): Promise<void> {
135
- const svc = loadFcmSvc();
136
- if (!svc) return;
137
- const at = await fcmAccessToken();
138
- if (!at) return;
139
- const url = `https://fcm.googleapis.com/v1/projects/${svc.project_id}/messages:send`;
140
- const payloadData: Record<string, string> = { channelId: 'xmtp', ...data };
141
- const res = await fetch(url, {
142
- method: 'POST',
143
- headers: {
144
- 'Content-Type': 'application/json',
145
- Authorization: `Bearer ${at}`,
146
- },
147
- body: JSON.stringify({
148
- message: {
149
- token: deviceToken,
150
- android: { priority: 'HIGH' },
151
- data: payloadData,
152
- },
153
- }),
154
- signal: AbortSignal.timeout(15_000),
155
- });
156
- if (!res.ok) {
157
- const txt = await res.text().catch(() => '');
158
- const dead =
159
- res.status === 404 ||
160
- txt.includes('UNREGISTERED') ||
161
- txt.includes('NOT_FOUND') ||
162
- txt.includes('registration-token-not-registered') ||
163
- txt.includes('NotRegistered');
164
- if (dead) {
165
- removePushToken(deviceToken);
166
- process.stderr.write(
167
- `fcm: pruned stale token ${deviceToken.slice(0, 12)}…\n`,
168
- );
169
- return;
170
- }
171
- process.stderr.write(`fcm push ${res.status}: ${txt}\n`);
172
- }
173
- }
174
-
175
- function freshestPerAccount(tokens: StoredPushToken[]): StoredPushToken[] {
176
- const freshness = (t: StoredPushToken): number =>
177
- new Date(t.lastSeenAt ?? t.registeredAt ?? 0).getTime();
178
- const byAccount = new Map<string, StoredPushToken>();
179
- const unscoped: StoredPushToken[] = [];
180
- for (const t of tokens) {
181
- if (!t.account) {
182
- unscoped.push(t);
183
- continue;
184
- }
185
- const cur = byAccount.get(t.account);
186
- if (!cur || freshness(t) >= freshness(cur)) byAccount.set(t.account, t);
187
- }
188
- return [...byAccount.values(), ...unscoped];
189
- }
190
-
191
- export async function fcmPushToAll(
192
- accountId: string,
193
- data: Record<string, string> = {},
194
- excludeInboxId?: string,
195
- ): Promise<void> {
196
- const tokens = loadPushTokens().filter((t) => {
197
- if (t.account && t.account !== accountId) return false;
198
- if (
199
- excludeInboxId &&
200
- (t.inboxId === excludeInboxId || t.inboxIds?.includes(excludeInboxId))
201
- )
202
- return false;
203
- return true;
204
- });
205
- const deduped = freshestPerAccount(tokens);
206
- if (deduped.length === 0) return;
207
- await Promise.all(
208
- deduped.map((t) =>
209
- fcmPushTo(t.token, { ...data, account: accountId }).catch(
210
- () => undefined,
211
- ),
212
- ),
213
- );
214
- }
215
-
216
- const METRO_CTRL_PREFIX = 'METRO_CTRL:';
217
- const CTRL_REGISTER_PUSH = 'register-push';
218
- const CTRL_DISABLE_PUSH = 'disable-push';
219
-
220
- export function removePushToken(token: string): number {
221
- const all = loadPushTokens();
222
- const remaining = all.filter((t) => t.token !== token);
223
- if (remaining.length === all.length) return -1;
224
- savePushTokens(remaining);
225
- return remaining.length;
226
- }
227
-
228
- function isMetroControlBody(text: unknown): text is string {
229
- return typeof text === 'string' && text.startsWith(METRO_CTRL_PREFIX);
230
- }
231
-
232
- interface RegisterPushPayload {
233
- v?: number;
234
- token: string;
235
- platform?: string;
236
- address?: string;
237
- inboxId?: string;
238
- }
239
- interface DisablePushPayload {
240
- v?: number;
241
- token: string;
242
- address?: string;
243
- inboxId?: string;
244
- }
245
-
246
- function isValidPushToken(token: unknown): token is string {
247
- return typeof token === 'string' && token.length >= 20;
248
- }
249
-
250
- function handleRegisterPushCtrl(
251
- accountId: string,
252
- msg: DecodedMessage,
253
- arg: string,
254
- ): void {
255
- const obj = JSON.parse(arg) as Partial<RegisterPushPayload>;
256
- if (!obj || !isValidPushToken(obj.token)) {
257
- process.stderr.write(
258
- `xmtp[${accountId}]: register-push (ctrl-dm) ignored — bad/short token\n`,
259
- );
260
- return;
261
- }
262
- const total = storePushToken({
263
- token: obj.token,
264
- account: accountId,
265
- inboxId: msg.senderInboxId,
266
- platform: typeof obj.platform === 'string' ? obj.platform : undefined,
267
- });
268
- process.stderr.write(
269
- `xmtp[${accountId}]: register-push (ctrl-dm) stored token ${obj.token.slice(0, 12)}… ` +
270
- `from inbox ${msg.senderInboxId.slice(0, 10)}… (v=${obj.v ?? '?'}, ${total} total)\n`,
271
- );
272
- }
273
-
274
- function handleDisablePushCtrl(accountId: string, arg: string): void {
275
- const obj = JSON.parse(arg) as Partial<DisablePushPayload>;
276
- if (!obj || !isValidPushToken(obj.token)) {
277
- process.stderr.write(
278
- `xmtp[${accountId}]: disable-push (ctrl-dm) ignored — bad/short token\n`,
279
- );
280
- return;
281
- }
282
- const remaining = removePushToken(obj.token);
283
- process.stderr.write(
284
- `xmtp[${accountId}]: disable-push (ctrl-dm) token ${obj.token.slice(0, 12)}… ` +
285
- (remaining === -1
286
- ? 'not found (already gone)\n'
287
- : `removed (v=${obj.v ?? '?'}, ${remaining} remain)\n`),
288
- );
289
- }
290
-
291
- export function handleControlDm(
292
- accountId: string,
293
- msg: DecodedMessage,
294
- ): boolean {
295
- const body = msg.content;
296
- if (!isMetroControlBody(body)) return false;
297
- const rest = body.slice(METRO_CTRL_PREFIX.length);
298
- const sep = rest.indexOf(':');
299
- const verb = sep === -1 ? rest : rest.slice(0, sep);
300
- const arg = sep === -1 ? '' : rest.slice(sep + 1);
301
- try {
302
- if (verb === CTRL_REGISTER_PUSH) {
303
- handleRegisterPushCtrl(accountId, msg, arg);
304
- } else if (verb === CTRL_DISABLE_PUSH) {
305
- handleDisablePushCtrl(accountId, arg);
306
- } else {
307
- process.stderr.write(
308
- `xmtp[${accountId}]: unknown control verb '${verb}' — swallowed\n`,
309
- );
310
- }
311
- } catch (err) {
312
- process.stderr.write(
313
- `xmtp[${accountId}]: control DM '${verb}' FAILED: ${errMsg(err)}\n`,
314
- );
315
- }
316
- return true;
317
- }
318
-
319
- export function pushInbound(
320
- accountId: string,
321
- env: Record<string, unknown>,
322
- msg: DecodedMessage,
323
- conv?: unknown,
324
- ): void {
325
- const line = typeof env.line === 'string' ? env.line : '';
326
- const messageId =
327
- typeof env.message_id === 'string'
328
- ? env.message_id
329
- : typeof env.id === 'string'
330
- ? env.id
331
- : '';
332
- void (async (): Promise<void> => {
333
- const data: Record<string, string> = {
334
- line,
335
- messageId,
336
- account: accountId,
337
- };
338
- {
339
- const p = parseLine(line);
340
- if (p) data.convId = p.convId.toLowerCase();
341
- }
342
- if (conv) {
343
- const isDm =
344
- typeof (conv as { peerInboxId?: unknown }).peerInboxId === 'function';
345
- if (!isDm) data.isGroup = 'true';
346
- }
347
- await fcmPushToAll(accountId, data, msg.senderInboxId);
348
- })().catch(() => undefined);
349
- }