@stage-labs/metro 0.1.0-beta.67 → 0.1.0-beta.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +10 -0
  3. package/runtime/node_modules/@metro-labs/mcp/src/channels/addressed.ts +25 -0
  4. package/runtime/node_modules/@metro-labs/mcp/src/channels/inbound.ts +10 -0
  5. package/runtime/node_modules/@metro-labs/mcp/src/daemon/events.ts +3 -0
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -0
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/protocol.ts +2 -0
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/terminal-api.ts +1 -1
  9. package/runtime/node_modules/@metro-labs/mcp/src/mcp/call-tools.ts +16 -3
  10. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session.ts +4 -0
  11. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-dispatch.ts +13 -10
  12. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-schemas.ts +4 -1
  13. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +15 -0
  14. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +1 -0
  15. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +29 -0
  16. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +6 -0
  17. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +10 -0
  18. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +11 -2
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +6 -0
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/parse.ts +44 -0
  21. package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +2 -0
  22. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +3 -1
  23. package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +14 -0
  24. package/runtime/runtime.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.67",
3
+ "version": "0.1.0-beta.69",
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": {
@@ -65,6 +65,15 @@ function tagFor(att: {
65
65
  return `[${kind}: ${att.name ?? kind}]`;
66
66
  }
67
67
 
68
+ function addressing(accountId: string, m: Message): { mentions_self: boolean; reply_to_self: boolean } {
69
+ const me = accounts.get(accountId)?.client.user ?? null;
70
+ if (me === null) return { mentions_self: false, reply_to_self: false };
71
+ return {
72
+ mentions_self: m.mentions.has(me, { ignoreRepliedUser: true, ignoreEveryone: true }),
73
+ reply_to_self: m.mentions.repliedUser?.id === me.id,
74
+ };
75
+ }
76
+
68
77
  export function messageEnvelope(
69
78
  accountId: string,
70
79
  m: Message,
@@ -112,6 +121,7 @@ export function messageEnvelope(
112
121
  text,
113
122
  is_private: m.guildId == null,
114
123
  reply_to: m.reference?.messageId ?? undefined,
124
+ ...addressing(accountId, m),
115
125
  ...(m.reference?.messageId
116
126
  ? { event: { type: 'reply', replyTo: m.reference.messageId } }
117
127
  : {}),
@@ -0,0 +1,25 @@
1
+ import { str } from '../mcp/str.js';
2
+
3
+ export type Addressed = 'direct' | 'mention' | 'reply';
4
+
5
+ interface AddressFacts {
6
+ isPrivate?: unknown;
7
+ mentionsSelf?: unknown;
8
+ replyToSelf?: unknown;
9
+ }
10
+
11
+ export function addressedBy(facts: AddressFacts, replyTo: string, sent: ReadonlySet<string>): Addressed | undefined {
12
+ if (facts.isPrivate === true) return 'direct';
13
+ if (facts.mentionsSelf === true) return 'mention';
14
+ if (facts.replyToSelf === true || (replyTo !== '' && sent.has(replyTo))) return 'reply';
15
+ return undefined;
16
+ }
17
+
18
+ export function replyMeta(ev: Record<string, unknown>, sent: ReadonlySet<string>): Record<string, string> {
19
+ const replyTo = str(ev.replyTo);
20
+ const addressed = addressedBy(ev, replyTo, sent);
21
+ return {
22
+ ...(replyTo ? { reply_to: replyTo } : {}),
23
+ ...(addressed === undefined ? {} : { addressed }),
24
+ };
25
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type SavedMedia,
9
9
  } from './media-note.js';
10
10
  import { buildWebhookNote } from './webhook-note.js';
11
+ import { replyMeta } from './addressed.js';
11
12
  import {
12
13
  capSet,
13
14
  displayNameMeta,
@@ -31,6 +32,7 @@ const DEDUPE_TTL_MS = 30_000;
31
32
  const DEDUPE_MAX = 2_000;
32
33
  const ALLOWED_LINES_MAX = 2_000;
33
34
  const PENDING_PERMISSIONS_MAX = 500;
35
+ const SENT_IDS_MAX = 2_000;
34
36
 
35
37
  const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
36
38
 
@@ -43,6 +45,7 @@ export class InboundRelay {
43
45
  private readonly seenEvents = new Map<string, number>();
44
46
  private readonly allowedLines = new Set<string>();
45
47
  private readonly pendingPermissions = new Set<string>();
48
+ private readonly sentIds = new Set<string>();
46
49
  private lastLine: string | undefined;
47
50
 
48
51
  constructor(deps: InboundDeps) {
@@ -58,6 +61,12 @@ export class InboundRelay {
58
61
  capSet(this.pendingPermissions, PENDING_PERMISSIONS_MAX);
59
62
  }
60
63
 
64
+ noteSent(messageId: string): void {
65
+ if (!messageId) return;
66
+ this.sentIds.add(messageId);
67
+ capSet(this.sentIds, SENT_IDS_MAX);
68
+ }
69
+
61
70
  private notify(method: string, params: Record<string, unknown>): Promise<void> {
62
71
  return this.deps.mcp.notification({ method, params });
63
72
  }
@@ -318,6 +327,7 @@ export class InboundRelay {
318
327
  line_name: str(ev.lineName),
319
328
  from_name: str(ev.fromName),
320
329
  ...displayNameMeta(ev.fromDisplayName),
330
+ ...replyMeta(ev, this.sentIds),
321
331
  },
322
332
  });
323
333
  }
@@ -24,6 +24,9 @@ export interface MetroEvent {
24
24
  text?: string;
25
25
  messageId?: string;
26
26
  replyTo?: string;
27
+ isPrivate?: boolean;
28
+ mentionsSelf?: boolean;
29
+ replyToSelf?: boolean;
27
30
  payload?: unknown;
28
31
  display?: string;
29
32
  event?: StructuredEvent;
@@ -128,6 +128,17 @@ function eventText(env: TrainEvent): string | undefined {
128
128
  return env.emoji ? `[react ${env.emoji}]` : undefined;
129
129
  }
130
130
 
131
+ function addressingFacts(
132
+ env: TrainEvent,
133
+ isPrivate: boolean,
134
+ ): Pick<MetroEvent, 'isPrivate' | 'mentionsSelf' | 'replyToSelf'> {
135
+ return {
136
+ ...(isPrivate ? { isPrivate: true } : {}),
137
+ ...(env.mentions_self === true ? { mentionsSelf: true } : {}),
138
+ ...(env.reply_to_self === true ? { replyToSelf: true } : {}),
139
+ };
140
+ }
141
+
131
142
  export function trainEventToMetroEvent(
132
143
  env: TrainEvent,
133
144
  trainName: string,
@@ -155,6 +166,7 @@ export function trainEventToMetroEvent(
155
166
  text,
156
167
  messageId: env.message_id,
157
168
  replyTo: env.reply_to,
169
+ ...addressingFacts(env, isPrivate),
158
170
  payload: env.payload,
159
171
  };
160
172
  }
@@ -37,6 +37,8 @@ export type TrainEvent = {
37
37
  message_id?: string;
38
38
  reply_to?: string;
39
39
  is_private?: boolean;
40
+ mentions_self?: boolean;
41
+ reply_to_self?: boolean;
40
42
  text?: string;
41
43
  emoji?: string;
42
44
  payload?: unknown;
@@ -10,7 +10,7 @@ const TICKETS = `${PREFIX}/tickets`;
10
10
  export const TMUX_SESSION = 'metro';
11
11
  const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
12
12
 
13
- export const tmuxCommand = (session: string): string[] => ['tmux', 'new-session', '-A', '-D', '-s', session];
13
+ export const tmuxCommand = (session: string): string[] => ['tmux', 'new-session', '-A', '-D', '-s', session, ';', 'set-option', '-g', 'mouse', 'on'];
14
14
 
15
15
  export interface TerminalApiDeps {
16
16
  authorize: (subject: string) => void;
@@ -16,6 +16,7 @@ interface MessageArgs {
16
16
  line: string;
17
17
  a: Record<string, unknown>;
18
18
  ctx: ReturnType<typeof makeCtx>;
19
+ onSent?: (messageId: string) => void;
19
20
  station: Station;
20
21
  }
21
22
 
@@ -105,6 +106,10 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
105
106
  .map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
106
107
  .join(', ')}); send a link in \`text\` instead`;
107
108
 
109
+ function noteSent(m: MessageArgs, messageId: string | undefined): void {
110
+ if (messageId !== undefined) m.onSent?.(messageId);
111
+ }
112
+
108
113
  async function handleSend(m: MessageArgs): Promise<ToolResult> {
109
114
  const text = m.a.text as string | undefined;
110
115
  const replyTo = m.a.reply_to as string | undefined;
@@ -125,6 +130,7 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
125
130
  : await sendForwarded(m, text, replyTo, atts);
126
131
  if (!sent.labels.length)
127
132
  return errResult('send requires `text` or `attachments`');
133
+ noteSent(m, sent.messageId);
128
134
  return ok(withId(`sent: ${sent.labels.join(', ')}`, sent.messageId));
129
135
  } finally {
130
136
  await cleanupAttachments(atts);
@@ -158,7 +164,7 @@ const MESSAGE_VERBS: Record<string, VerbSpec> = {
158
164
  };
159
165
 
160
166
  function makeVerbHandler(verb: string, spec: VerbSpec): MessageHandler {
161
- return async ({ line, a, ctx }) => {
167
+ return async ({ line, a, ctx, onSent }) => {
162
168
  const payload: Record<string, unknown> = { line };
163
169
  for (const [snake, camel] of spec.args) {
164
170
  const value = str(a[snake]);
@@ -169,7 +175,9 @@ function makeVerbHandler(verb: string, spec: VerbSpec): MessageHandler {
169
175
  payload[camel] = value;
170
176
  }
171
177
  const response = await ctx.call(verb, payload);
172
- return ok(withId(spec.success, messageIdOf(response)));
178
+ const messageId = messageIdOf(response);
179
+ if (verb === 'reply' && messageId !== undefined) onSent?.(messageId);
180
+ return ok(withId(spec.success, messageId));
173
181
  };
174
182
  }
175
183
 
@@ -181,9 +189,14 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
181
189
  ),
182
190
  };
183
191
 
192
+ export interface ToolHooks {
193
+ onSent?: (messageId: string) => void;
194
+ }
195
+
184
196
  export async function dispatchMessageTool(
185
197
  name: string,
186
198
  a: Record<string, unknown>,
199
+ hooks: ToolHooks = {},
187
200
  ): Promise<ToolResult> {
188
201
  const line = str(a.line);
189
202
  if (!line) return errResult(`${name} requires \`line\``);
@@ -196,7 +209,7 @@ export async function dispatchMessageTool(
196
209
  const handler = MESSAGE_HANDLERS[name];
197
210
  if (!handler) return errResult(`unknown tool: ${name}`);
198
211
  try {
199
- return await handler({ line, a, ctx: makeCtx(station.name), station });
212
+ return await handler({ line, a, ctx: makeCtx(station.name), station, onSent: hooks.onSent });
200
213
  } catch (e) {
201
214
  return toErr(name, e);
202
215
  }
@@ -99,6 +99,10 @@ export class McpSession {
99
99
  deliver: (send): void => {
100
100
  this.deliverSchemaNotice(send, 'request');
101
101
  },
102
+ }, {
103
+ onSent: (id): void => {
104
+ this.relay.noteSent(id);
105
+ },
102
106
  });
103
107
  this.relay = new InboundRelay({
104
108
  mcp: this.server,
@@ -12,7 +12,7 @@ import {
12
12
  import type { Station, StationTool, ToolResult } from '../stations/types.js';
13
13
  import { COMMON_TOOLS, LIST_ACCOUNTS_TOOL } from './tool-schemas.js';
14
14
  import { errResult, makeCtx, okJson, toErr } from './ctx.js';
15
- import { dispatchMessageTool } from './call-tools.js';
15
+ import { dispatchMessageTool, type ToolHooks } from './call-tools.js';
16
16
  import { dispatchListMembers } from './member-tools.js';
17
17
  import {
18
18
  dispatchAddMembers,
@@ -142,9 +142,10 @@ function logToolFailure(
142
142
  );
143
143
  }
144
144
 
145
- async function runTool(req: {
146
- params: { name: string; arguments?: Record<string, unknown> };
147
- }): Promise<ToolResult> {
145
+ async function runTool(
146
+ req: { params: { name: string; arguments?: Record<string, unknown> } },
147
+ hooks: ToolHooks,
148
+ ): Promise<ToolResult> {
148
149
  const name = req.params.name;
149
150
  const a = req.params.arguments ?? {};
150
151
 
@@ -166,13 +167,14 @@ async function runTool(req: {
166
167
 
167
168
  if (name === 'list_accounts') return handleListAccounts(identity);
168
169
 
169
- return dispatchMessageTool(name, a);
170
+ return dispatchMessageTool(name, a, hooks);
170
171
  }
171
172
 
172
- export async function callToolHandler(req: {
173
- params: { name: string; arguments?: Record<string, unknown> };
174
- }): Promise<ToolResult> {
175
- const result = await runTool(req);
173
+ export async function callToolHandler(
174
+ req: { params: { name: string; arguments?: Record<string, unknown> } },
175
+ hooks: ToolHooks = {},
176
+ ): Promise<ToolResult> {
177
+ const result = await runTool(req, hooks);
176
178
  if (result.isError === true)
177
179
  logToolFailure(req.params.name, req.params.arguments ?? {}, result);
178
180
  return result;
@@ -192,6 +194,7 @@ interface NotifyingExtra {
192
194
  export function registerToolHandlers(
193
195
  server: Server,
194
196
  notice?: SchemaNotice,
197
+ hooks: ToolHooks = {},
195
198
  ): void {
196
199
  server.setRequestHandler(ListToolsRequestSchema, () => {
197
200
  notice?.markCurrent();
@@ -206,7 +209,7 @@ export function registerToolHandlers(
206
209
  notice?.deliver(() =>
207
210
  extra.sendNotification({ method: TOOL_LIST_CHANGED }),
208
211
  );
209
- return callToolHandler(req);
212
+ return callToolHandler(req, hooks);
210
213
  }) as Parameters<typeof server.setRequestHandler>[1],
211
214
  );
212
215
  }
@@ -394,4 +394,7 @@ export const MCP_INSTRUCTIONS =
394
394
  'straight to metro and never through this conversation), then `send` with ' +
395
395
  'attachments:[{upload:"<upload_id>"}]. Inline `data` is for tiny files only, `url` only for ' +
396
396
  'an already-public file, and `path` is read on the DAEMON host, not yours. Tool-approval ' +
397
- 'prompts are relayed to the same chat - answer "yes <id>"/"no <id>".';
397
+ 'prompts are relayed to the same chat - answer "yes <id>"/"no <id>". A message meant for you ' +
398
+ 'carries addressed="direct" (a private chat), "mention" (you were named) or "reply" (it ' +
399
+ 'answers one of your own messages); one without `addressed` merely happened in a room you ' +
400
+ 'watch, so treat it as context and do not answer unless the person plainly wants you.';
@@ -45,6 +45,20 @@ export function isOwnEcho(m: Message): boolean {
45
45
  return isUser(sender) && sender.isSelf;
46
46
  }
47
47
 
48
+ function replyMeta(m: Message): Record<string, unknown> {
49
+ const replied = m.replyToMessage;
50
+ const target = replied?.id;
51
+ if (replied === null || typeof target !== 'number') return {};
52
+ if (m.isTopicMessage && target === replied.threadId) return {};
53
+ const id = String(target);
54
+ const sender = replied.sender;
55
+ return {
56
+ reply_to: id,
57
+ event: { type: 'reply', replyTo: id },
58
+ reply_to_self: sender?.type === 'user' && sender.isSelf,
59
+ };
60
+ }
61
+
48
62
  export function envelope(
49
63
  accountId: string,
50
64
  m: Message,
@@ -71,6 +85,7 @@ export function envelope(
71
85
  text: projectText(m),
72
86
  is_private: isPrivate,
73
87
  has_media: media !== null,
88
+ ...replyMeta(m),
74
89
  payload: {
75
90
  account: accountId,
76
91
  message_id: String(m.id),
@@ -42,6 +42,7 @@ export interface Account {
42
42
  api: string;
43
43
  fileApi: string;
44
44
  offset: number;
45
+ username?: string;
45
46
  }
46
47
  export const accounts = new Map<string, Account>();
47
48
 
@@ -44,6 +44,34 @@ function projectText(m: TgMsg): string {
44
44
  return [m.text ?? m.caption, ...tags].filter(Boolean).join(' ');
45
45
  }
46
46
 
47
+ const botIdOf = (accountId: string): number | null => {
48
+ const token = accounts.get(accountId)?.cfg.token ?? '';
49
+ const id = Number(token.split(':')[0]);
50
+ return Number.isInteger(id) && id > 0 ? id : null;
51
+ };
52
+
53
+ function mentionsBot(m: TgMsg, botId: number | null, username: string | undefined): boolean {
54
+ const text = m.text ?? m.caption ?? '';
55
+ const handle = username === undefined ? null : `@${username.toLowerCase()}`;
56
+ return [...(m.entities ?? []), ...(m.caption_entities ?? [])].some((e) => {
57
+ if (e.type === 'text_mention') return botId !== null && e.user?.id === botId;
58
+ if (e.type !== 'mention' || handle === null) return false;
59
+ return text.slice(e.offset, e.offset + e.length).toLowerCase() === handle;
60
+ });
61
+ }
62
+
63
+ function addressing(accountId: string, m: TgMsg): Record<string, unknown> {
64
+ const botId = botIdOf(accountId);
65
+ const topicRoot = m.is_topic_message === true && m.reply_to_message?.message_id === m.message_thread_id;
66
+ const replied = topicRoot ? undefined : m.reply_to_message;
67
+ const replyTo = replied === undefined ? undefined : String(replied.message_id);
68
+ return {
69
+ ...(replyTo === undefined ? {} : { reply_to: replyTo, event: { type: 'reply', replyTo } }),
70
+ mentions_self: mentionsBot(m, botId, accounts.get(accountId)?.username),
71
+ reply_to_self: botId !== null && replied?.from?.id === botId,
72
+ };
73
+ }
74
+
47
75
  export function envelope(accountId: string, m: TgMsg): Record<string, unknown> {
48
76
  const { line } = lineForMsg(accountId, m);
49
77
  return {
@@ -60,6 +88,7 @@ export function envelope(accountId: string, m: TgMsg): Record<string, unknown> {
60
88
  text: projectText(m),
61
89
  payload: m,
62
90
  is_private: m.chat.type === 'private',
91
+ ...addressing(accountId, m),
63
92
  };
64
93
  }
65
94
 
@@ -48,6 +48,12 @@ async function runAccount(acct: Account): Promise<void> {
48
48
  `telegram-bot[${id}] deleteWebhook: ${errMsg(err)}\n`,
49
49
  );
50
50
  }
51
+ try {
52
+ const me = await tg<{ username?: string }>(id, 'getMe', {});
53
+ if (typeof me.username === 'string') acct.username = me.username;
54
+ } catch (err) {
55
+ process.stderr.write(`telegram-bot[${id}] getMe: ${errMsg(err)}\n`);
56
+ }
51
57
 
52
58
  for (;;) {
53
59
  try {
@@ -1,4 +1,14 @@
1
+ export interface TgEntity {
2
+ type: string;
3
+ offset: number;
4
+ length: number;
5
+ user?: { id: number };
6
+ }
7
+
1
8
  export interface TgMsg {
9
+ entities?: TgEntity[];
10
+ caption_entities?: TgEntity[];
11
+ reply_to_message?: { message_id: number; from?: { id: number; username?: string; is_bot?: boolean } };
2
12
  message_id: number;
3
13
  date: number;
4
14
  chat: { id: number; type: string; title?: string; first_name?: string };
@@ -1,4 +1,5 @@
1
1
  import makeWASocket, {
2
+ jidNormalizedUser,
2
3
  Browsers,
3
4
  DisconnectReason,
4
5
  fetchLatestWaWebVersion,
@@ -11,7 +12,7 @@ import { TrainError } from '@metro-labs/mcp/train-error';
11
12
  import { errMsg } from '@metro-labs/mcp/log';
12
13
  import type { WhatsAppAccount } from './types.js';
13
14
  import type { InboundMessage, ReactionInput } from './format.js';
14
- import { toInbound, toReaction, type ReactionEvent } from './parse.js';
15
+ import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
15
16
  import { baileysLogger } from './logger.js';
16
17
  import { useAccountAuthState } from './auth-state.js';
17
18
  import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
@@ -72,13 +73,21 @@ function resetGate(st: State): void {
72
73
  });
73
74
  }
74
75
 
76
+ function selfRef(st: State, sock: WASocket): SelfRef {
77
+ const jids = new Set<string>();
78
+ const me = sock.user;
79
+ if (me?.id) jids.add(jidNormalizedUser(me.id));
80
+ if (me?.lid) jids.add(jidNormalizedUser(me.lid));
81
+ return { jids, sentByMe: (jid, id) => st.keys.lookup(jid, id)?.fromMe === true };
82
+ }
83
+
75
84
  function bindInbound(st: State, sock: WASocket): void {
76
85
  sock.ev.on('messages.upsert', ({ messages, type }) => {
77
86
  for (const m of messages) st.keys.remember(m.key);
78
87
  if (type !== 'notify' || !st.handlers) return;
79
88
  for (const m of messages) {
80
89
  if (m.key.fromMe) continue;
81
- const inbound = toInbound(st.account.id, m);
90
+ const inbound = toInbound(st.account.id, m, selfRef(st, sock));
82
91
  if (inbound) st.handlers.onMessage(inbound, m);
83
92
  }
84
93
  });
@@ -13,6 +13,9 @@ export interface InboundMessage {
13
13
  isPrivate: boolean;
14
14
  pushName?: string;
15
15
  media?: WAMediaRef;
16
+ replyTo?: string;
17
+ mentionsSelf?: boolean;
18
+ replyToSelf?: boolean;
16
19
  }
17
20
 
18
21
  export interface ReactionInput {
@@ -48,6 +51,9 @@ export function envelope(m: InboundMessage): Record<string, unknown> {
48
51
  message_id: m.messageId,
49
52
  text: m.text,
50
53
  is_private: m.isPrivate,
54
+ ...(m.replyTo === undefined ? {} : { reply_to: m.replyTo, event: { type: 'reply', replyTo: m.replyTo } }),
55
+ ...(m.mentionsSelf === true ? { mentions_self: true } : {}),
56
+ ...(m.replyToSelf === true ? { reply_to_self: true } : {}),
51
57
  payload: {
52
58
  account: m.accountId,
53
59
  message_id: m.messageId,
@@ -80,9 +80,52 @@ function senderJidOf(key: WAMessageKey, chatJid: string): string {
80
80
  return chatJid;
81
81
  }
82
82
 
83
+ export interface SelfRef {
84
+ jids: ReadonlySet<string>;
85
+ sentByMe: (chatJid: string, id: string) => boolean;
86
+ }
87
+
88
+ const QUOTING_NODES = [
89
+ 'extendedTextMessage',
90
+ 'imageMessage',
91
+ 'videoMessage',
92
+ 'documentMessage',
93
+ 'audioMessage',
94
+ 'stickerMessage',
95
+ ] as const;
96
+
97
+ function contextInfoOf(inner: proto.IMessage): proto.IContextInfo | undefined {
98
+ for (const node of QUOTING_NODES) {
99
+ const ctx = inner[node]?.contextInfo;
100
+ if (ctx) return ctx;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function quoteFacts(ctx: proto.IContextInfo, replyTo: string | undefined, chatJid: string, self: SelfRef): Addressing {
106
+ const quotedBy = typeof ctx.participant === 'string' ? ctx.participant : '';
107
+ const mentioned = (ctx.mentionedJid ?? []).some((jid) => self.jids.has(jid));
108
+ const replyToSelf = replyTo !== undefined && (self.jids.has(quotedBy) || self.sentByMe(chatJid, replyTo));
109
+ return {
110
+ ...(mentioned ? { mentionsSelf: true } : {}),
111
+ ...(replyToSelf ? { replyToSelf: true } : {}),
112
+ };
113
+ }
114
+
115
+ type Addressing = Pick<InboundMessage, 'replyTo' | 'mentionsSelf' | 'replyToSelf'>;
116
+
117
+ function addressing(inner: proto.IMessage, chatJid: string, self: SelfRef | undefined): Addressing {
118
+ const ctx = contextInfoOf(inner);
119
+ if (ctx === undefined) return {};
120
+ const replyTo = typeof ctx.stanzaId === 'string' && ctx.stanzaId !== '' ? ctx.stanzaId : undefined;
121
+ const base: Addressing = replyTo === undefined ? {} : { replyTo };
122
+ return self === undefined ? base : { ...base, ...quoteFacts(ctx, replyTo, chatJid, self) };
123
+ }
124
+
83
125
  export function toInbound(
84
126
  accountId: string,
85
127
  m: WAMessage,
128
+ self?: SelfRef,
86
129
  ): InboundMessage | undefined {
87
130
  const chatJid = m.key.remoteJid;
88
131
  const messageId = m.key.id;
@@ -102,6 +145,7 @@ export function toInbound(
102
145
  isPrivate: isPrivateJid(chatJid),
103
146
  ...(m.pushName ? { pushName: m.pushName } : {}),
104
147
  ...(media ? { media } : {}),
148
+ ...addressing(inner, chatJid, self),
105
149
  };
106
150
  }
107
151
 
@@ -8,6 +8,7 @@ import {
8
8
  type RemoteEntry,
9
9
  } from './attachments.js';
10
10
  import { emitInbound, emitAttachmentSaved } from './emit-core.js';
11
+ import { sentByMe } from './wire.js';
11
12
 
12
13
  export interface EnvelopeCtx {
13
14
  accountId: string;
@@ -150,6 +151,7 @@ function replyPayload(
150
151
  ? c.content
151
152
  : `[reply with ${c.contentType?.typeId ?? 'unknown'}]`,
152
153
  event: { type: 'reply', replyTo: c.reference },
154
+ ...(sentByMe(c.reference) ? { reply_to_self: true } : {}),
153
155
  payload: {
154
156
  contentType: typeId,
155
157
  replyTo: c.reference,
@@ -1,6 +1,6 @@
1
1
  import type { Conversation, DecodedMessage } from '@xmtp/node-sdk';
2
2
  import { lineOf, parseLine } from './accounts.js';
3
- import { emit, mintId, rememberUid, SELF_URI } from './wire.js';
3
+ import { emit, mintId, rememberSent, rememberUid, SELF_URI } from './wire.js';
4
4
  import { fcmPushToAll } from './push.js';
5
5
  import { emitInbound, emitAttachmentSaved } from './emit-core.js';
6
6
  import { typedEnvelope, type EnvelopeCtx } from './emit-payloads.js';
@@ -23,6 +23,7 @@ export function envelope(
23
23
  line,
24
24
  from: `metro://xmtp/${accountId}/user/${msg.senderInboxId}`,
25
25
  message_id: msg.id,
26
+ is_private: typeof (conv as unknown as { peerInboxId?: unknown }).peerInboxId === 'function',
26
27
  };
27
28
  rememberUid(base.id, msg.id);
28
29
  if (typeof c === 'string')
@@ -53,6 +54,7 @@ export function emitOutbound(
53
54
  ): void {
54
55
  const uid = mintId();
55
56
  rememberUid(uid, messageId);
57
+ rememberSent(messageId);
56
58
  emit({
57
59
  kind: 'outbound',
58
60
  id: uid,
@@ -37,3 +37,17 @@ export function resolveMsgId(rawId: string): string {
37
37
  '(not seen by this train; pass the raw xmtp message_id)',
38
38
  );
39
39
  }
40
+
41
+ const SENT_MAX = 5000;
42
+ const sentIds = new Set<string>();
43
+
44
+ export function rememberSent(xmtpId: string | undefined): void {
45
+ if (!xmtpId) return;
46
+ sentIds.add(xmtpId);
47
+ if (sentIds.size > SENT_MAX) {
48
+ const oldest = sentIds.values().next().value;
49
+ if (oldest !== undefined) sentIds.delete(oldest);
50
+ }
51
+ }
52
+
53
+ export const sentByMe = (xmtpId: string): boolean => sentIds.has(xmtpId);
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.67"
2
+ "version": "0.1.0-beta.69"
3
3
  }