@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.41

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 (249) hide show
  1. package/dist/api.js +131 -0
  2. package/dist/bedrock-eventstream.js +120 -0
  3. package/dist/bedrock-proxy.js +260 -0
  4. package/dist/bedrock-upstream.js +72 -0
  5. package/dist/bedrock.js +106 -0
  6. package/dist/claude.js +15 -0
  7. package/dist/cli.js +181 -207
  8. package/dist/control.js +173 -0
  9. package/dist/plugin.js +82 -0
  10. package/dist/prompt.js +61 -0
  11. package/dist/runtime.js +117 -0
  12. package/dist/serve.js +77 -0
  13. package/dist/store.js +93 -0
  14. package/dist/tail.js +120 -0
  15. package/dist/update.js +33 -0
  16. package/dist/version.js +97 -0
  17. package/package.json +34 -21
  18. package/runtime/node_modules/@metro-labs/discord-bot/package.json +28 -0
  19. package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +119 -0
  20. package/runtime/node_modules/@metro-labs/discord-bot/src/actions.ts +388 -0
  21. package/runtime/node_modules/@metro-labs/discord-bot/src/api-base.ts +1 -0
  22. package/runtime/node_modules/@metro-labs/discord-bot/src/attachments.ts +29 -0
  23. package/runtime/node_modules/@metro-labs/discord-bot/src/format.ts +210 -0
  24. package/runtime/node_modules/@metro-labs/discord-bot/src/group-actions.ts +50 -0
  25. package/runtime/node_modules/@metro-labs/discord-bot/src/group.ts +115 -0
  26. package/runtime/node_modules/@metro-labs/discord-bot/src/index.ts +127 -0
  27. package/runtime/node_modules/@metro-labs/discord-bot/src/members.ts +215 -0
  28. package/runtime/node_modules/@metro-labs/discord-bot/src/send-files.ts +43 -0
  29. package/runtime/node_modules/@metro-labs/discord-bot/src/station.ts +19 -0
  30. package/runtime/node_modules/@metro-labs/discord-bot/src/verify.ts +82 -0
  31. package/runtime/node_modules/@metro-labs/discord-bot/src/voice-speak.ts +195 -0
  32. package/runtime/node_modules/@metro-labs/discord-bot/src/voice-transcribe.ts +292 -0
  33. package/runtime/node_modules/@metro-labs/discord-bot/src/voice.ts +197 -0
  34. package/runtime/node_modules/@metro-labs/discord-bot/src/wire.ts +5 -0
  35. package/runtime/node_modules/@metro-labs/mcp/package.json +67 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/channels/dedupe.ts +24 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/channels/inbound.ts +367 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/channels/media-note.ts +85 -0
  39. package/runtime/node_modules/@metro-labs/mcp/src/channels/pending.ts +75 -0
  40. package/runtime/node_modules/@metro-labs/mcp/src/channels/relay.ts +129 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/channels/webhook-note.ts +51 -0
  42. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +311 -0
  43. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +334 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +76 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  47. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-error.ts +8 -0
  48. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +153 -0
  49. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-grant.ts +99 -0
  50. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-owner.ts +32 -0
  51. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +133 -0
  52. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +278 -0
  53. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +383 -0
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +143 -0
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +33 -0
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +315 -0
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-json.ts +21 -0
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +157 -0
  59. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +127 -0
  60. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-tools.ts +100 -0
  61. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +57 -0
  62. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-verify.ts +333 -0
  63. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cors.ts +29 -0
  64. package/runtime/node_modules/@metro-labs/mcp/src/daemon/crash-guard.ts +49 -0
  65. package/runtime/node_modules/@metro-labs/mcp/src/daemon/events.ts +117 -0
  66. package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +118 -0
  67. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +369 -0
  68. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +86 -0
  69. package/runtime/node_modules/@metro-labs/mcp/src/daemon/import-api.ts +67 -0
  70. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +56 -0
  71. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +187 -0
  72. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  73. package/runtime/node_modules/@metro-labs/mcp/src/daemon/log.ts +42 -0
  74. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  75. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-client.ts +162 -0
  76. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-discovery.ts +120 -0
  77. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +65 -0
  78. package/runtime/node_modules/@metro-labs/mcp/src/daemon/pair-codes.ts +58 -0
  79. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +145 -0
  80. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +214 -0
  81. package/runtime/node_modules/@metro-labs/mcp/src/daemon/protocol.ts +224 -0
  82. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +303 -0
  83. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +139 -0
  84. package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-source.ts +105 -0
  85. package/runtime/node_modules/@metro-labs/mcp/src/daemon/secure-fs.ts +57 -0
  86. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +27 -0
  87. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +63 -0
  88. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  89. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +218 -0
  90. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  91. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  92. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  93. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor-io.ts +97 -0
  94. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +320 -0
  95. package/runtime/node_modules/@metro-labs/mcp/src/daemon/train-call.ts +22 -0
  96. package/runtime/node_modules/@metro-labs/mcp/src/daemon/train-error.ts +53 -0
  97. package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +257 -0
  98. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +295 -0
  99. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-store.ts +181 -0
  100. package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +91 -0
  101. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +214 -0
  102. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  103. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-map.ts +84 -0
  104. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-scope.ts +64 -0
  105. package/runtime/node_modules/@metro-labs/mcp/src/db/client.ts +37 -0
  106. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-config.ts +147 -0
  107. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +126 -0
  108. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +326 -0
  109. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +319 -0
  110. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  111. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +21 -0
  112. package/runtime/node_modules/@metro-labs/mcp/src/db/key-map.ts +45 -0
  113. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +373 -0
  114. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +309 -0
  115. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +24 -0
  116. package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +105 -0
  117. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +118 -0
  118. package/runtime/node_modules/@metro-labs/mcp/src/db/token-carry.ts +45 -0
  119. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +72 -0
  120. package/runtime/node_modules/@metro-labs/mcp/src/db/whatsapp-login.ts +18 -0
  121. package/runtime/node_modules/@metro-labs/mcp/src/mcp/accounts.ts +141 -0
  122. package/runtime/node_modules/@metro-labs/mcp/src/mcp/call-tools.ts +203 -0
  123. package/runtime/node_modules/@metro-labs/mcp/src/mcp/channel-owner.ts +29 -0
  124. package/runtime/node_modules/@metro-labs/mcp/src/mcp/ctx.ts +62 -0
  125. package/runtime/node_modules/@metro-labs/mcp/src/mcp/event-store.ts +91 -0
  126. package/runtime/node_modules/@metro-labs/mcp/src/mcp/frame-scope.ts +21 -0
  127. package/runtime/node_modules/@metro-labs/mcp/src/mcp/group-tools.ts +129 -0
  128. package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +192 -0
  129. package/runtime/node_modules/@metro-labs/mcp/src/mcp/member-tools.ts +72 -0
  130. package/runtime/node_modules/@metro-labs/mcp/src/mcp/permission-relay.ts +66 -0
  131. package/runtime/node_modules/@metro-labs/mcp/src/mcp/raw-get-stream.ts +207 -0
  132. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +73 -0
  133. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-registry.ts +172 -0
  134. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +43 -0
  135. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session.ts +211 -0
  136. package/runtime/node_modules/@metro-labs/mcp/src/mcp/str.ts +5 -0
  137. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-dispatch.ts +212 -0
  138. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-schemas.ts +397 -0
  139. package/runtime/node_modules/@metro-labs/mcp/src/mcp/upload-tool.ts +66 -0
  140. package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +240 -0
  141. package/runtime/node_modules/@metro-labs/mcp/src/server.ts +2 -0
  142. package/runtime/node_modules/@metro-labs/mcp/src/stations/account-store.ts +80 -0
  143. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-inline.ts +100 -0
  144. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-interactive.ts +161 -0
  145. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-resolve.ts +200 -0
  146. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-xmtp.ts +111 -0
  147. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach.ts +182 -0
  148. package/runtime/node_modules/@metro-labs/mcp/src/stations/attachments.ts +196 -0
  149. package/runtime/node_modules/@metro-labs/mcp/src/stations/lines.ts +82 -0
  150. package/runtime/node_modules/@metro-labs/mcp/src/stations/messaging-normalize.ts +88 -0
  151. package/runtime/node_modules/@metro-labs/mcp/src/stations/registry.ts +35 -0
  152. package/runtime/node_modules/@metro-labs/mcp/src/stations/station-runtime.ts +64 -0
  153. package/runtime/node_modules/@metro-labs/mcp/src/stations/types.ts +107 -0
  154. package/runtime/node_modules/@metro-labs/telegram/package.json +28 -0
  155. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +81 -0
  156. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +254 -0
  157. package/runtime/node_modules/@metro-labs/telegram/src/attachments.ts +87 -0
  158. package/runtime/node_modules/@metro-labs/telegram/src/client.ts +77 -0
  159. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +141 -0
  160. package/runtime/node_modules/@metro-labs/telegram/src/group.ts +261 -0
  161. package/runtime/node_modules/@metro-labs/telegram/src/history.ts +52 -0
  162. package/runtime/node_modules/@metro-labs/telegram/src/inbound.ts +78 -0
  163. package/runtime/node_modules/@metro-labs/telegram/src/index.ts +46 -0
  164. package/runtime/node_modules/@metro-labs/telegram/src/login.ts +166 -0
  165. package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +80 -0
  166. package/runtime/node_modules/@metro-labs/telegram/src/members.ts +75 -0
  167. package/runtime/node_modules/@metro-labs/telegram/src/normalize.ts +22 -0
  168. package/runtime/node_modules/@metro-labs/telegram/src/reactions.ts +167 -0
  169. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +24 -0
  170. package/runtime/node_modules/@metro-labs/telegram/src/types.ts +7 -0
  171. package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +1 -0
  172. package/runtime/node_modules/@metro-labs/telegram-bot/package.json +25 -0
  173. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +120 -0
  174. package/runtime/node_modules/@metro-labs/telegram-bot/src/actions.ts +295 -0
  175. package/runtime/node_modules/@metro-labs/telegram-bot/src/attachments.ts +128 -0
  176. package/runtime/node_modules/@metro-labs/telegram-bot/src/format.ts +172 -0
  177. package/runtime/node_modules/@metro-labs/telegram-bot/src/index.ts +103 -0
  178. package/runtime/node_modules/@metro-labs/telegram-bot/src/media-actions.ts +166 -0
  179. package/runtime/node_modules/@metro-labs/telegram-bot/src/members.ts +47 -0
  180. package/runtime/node_modules/@metro-labs/telegram-bot/src/station.ts +17 -0
  181. package/runtime/node_modules/@metro-labs/telegram-bot/src/types.ts +48 -0
  182. package/runtime/node_modules/@metro-labs/telegram-bot/src/verify.ts +38 -0
  183. package/runtime/node_modules/@metro-labs/telegram-bot/src/wire.ts +3 -0
  184. package/runtime/node_modules/@metro-labs/webhook/package.json +23 -0
  185. package/runtime/node_modules/@metro-labs/webhook/src/station.ts +40 -0
  186. package/runtime/node_modules/@metro-labs/whatsapp/package.json +29 -0
  187. package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +67 -0
  188. package/runtime/node_modules/@metro-labs/whatsapp/src/ack.ts +101 -0
  189. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +178 -0
  190. package/runtime/node_modules/@metro-labs/whatsapp/src/attachments.ts +72 -0
  191. package/runtime/node_modules/@metro-labs/whatsapp/src/auth-state.ts +129 -0
  192. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +318 -0
  193. package/runtime/node_modules/@metro-labs/whatsapp/src/delivery.ts +49 -0
  194. package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +132 -0
  195. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +57 -0
  196. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +40 -0
  197. package/runtime/node_modules/@metro-labs/whatsapp/src/keys.ts +59 -0
  198. package/runtime/node_modules/@metro-labs/whatsapp/src/logger.ts +60 -0
  199. package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +176 -0
  200. package/runtime/node_modules/@metro-labs/whatsapp/src/media.ts +84 -0
  201. package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +22 -0
  202. package/runtime/node_modules/@metro-labs/whatsapp/src/outbox.ts +38 -0
  203. package/runtime/node_modules/@metro-labs/whatsapp/src/parse.ts +141 -0
  204. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +17 -0
  205. package/runtime/node_modules/@metro-labs/whatsapp/src/token-store.ts +98 -0
  206. package/runtime/node_modules/@metro-labs/whatsapp/src/types.ts +6 -0
  207. package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +5 -0
  208. package/runtime/node_modules/@metro-labs/xmtp/package.json +33 -0
  209. package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +113 -0
  210. package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +110 -0
  211. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +299 -0
  212. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +157 -0
  213. package/runtime/node_modules/@metro-labs/xmtp/src/actions-push.ts +87 -0
  214. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +375 -0
  215. package/runtime/node_modules/@metro-labs/xmtp/src/attachments.ts +107 -0
  216. package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +171 -0
  217. package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +239 -0
  218. package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +55 -0
  219. package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +333 -0
  220. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +78 -0
  221. package/runtime/node_modules/@metro-labs/xmtp/src/group-members.ts +34 -0
  222. package/runtime/node_modules/@metro-labs/xmtp/src/group.ts +56 -0
  223. package/runtime/node_modules/@metro-labs/xmtp/src/identity.ts +33 -0
  224. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +125 -0
  225. package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +157 -0
  226. package/runtime/node_modules/@metro-labs/xmtp/src/member-args.ts +40 -0
  227. package/runtime/node_modules/@metro-labs/xmtp/src/push.ts +349 -0
  228. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +13 -0
  229. package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +126 -0
  230. package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +212 -0
  231. package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +90 -0
  232. package/runtime/node_modules/@metro-labs/xmtp/src/verify.ts +84 -0
  233. package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +39 -0
  234. package/runtime/runtime.json +3 -0
  235. package/runtime/trains/.keep +0 -0
  236. package/LICENSE +0 -21
  237. package/README.md +0 -117
  238. package/dist/agents/claude.js +0 -207
  239. package/dist/agents/codex.js +0 -207
  240. package/dist/agents/types.js +0 -2
  241. package/dist/channels/discord.js +0 -104
  242. package/dist/channels/telegram.js +0 -189
  243. package/dist/helpers/scope-cache.js +0 -65
  244. package/dist/helpers/streaming.js +0 -209
  245. package/dist/helpers/telegram-format.js +0 -39
  246. package/dist/helpers/turn.js +0 -40
  247. package/dist/log.js +0 -10
  248. package/dist/orchestrator.js +0 -208
  249. package/dist/paths.js +0 -68
@@ -0,0 +1,197 @@
1
+ import { errMsg } from '@metro-labs/mcp/log';
2
+ import {
3
+ joinVoiceChannel,
4
+ getVoiceConnection,
5
+ VoiceConnectionStatus,
6
+ entersState,
7
+ type VoiceConnection,
8
+ } from '@discordjs/voice';
9
+ import type { Client, VoiceBasedChannel } from 'discord.js';
10
+ import { accountFor, accounts } from './accounts.js';
11
+ import { respond } from './wire.js';
12
+ import {
13
+ startTranscription,
14
+ stopTranscription,
15
+ setTranscription,
16
+ } from './voice-transcribe.js';
17
+
18
+ const DEFAULT_USERNAME = 'bonustrack_';
19
+
20
+ function clientFor(account?: string): { accountId: string; client: Client } {
21
+ const accountId = accountFor({ account });
22
+ const acct = accounts.get(accountId);
23
+ if (!acct) throw new Error(`unknown account '${accountId}'`);
24
+ return { accountId, client: acct.client };
25
+ }
26
+
27
+ function voiceStateMatches(
28
+ vs: { id: string; member?: { user: { username?: string } } | null },
29
+ opts: { userId?: string; username?: string },
30
+ ): boolean {
31
+ if (opts.userId && vs.id === opts.userId) return true;
32
+ return Boolean(
33
+ opts.username &&
34
+ vs.member?.user.username?.toLowerCase() === opts.username.toLowerCase(),
35
+ );
36
+ }
37
+
38
+ function findUserVoiceChannel(
39
+ client: Client,
40
+ opts: { userId?: string; username?: string },
41
+ ): VoiceBasedChannel | null {
42
+ for (const guild of client.guilds.cache.values()) {
43
+ for (const vs of guild.voiceStates.cache.values()) {
44
+ if (!vs.channelId) continue;
45
+ if (voiceStateMatches(vs, opts)) return vs.channel ?? null;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ async function resolveTarget(
52
+ client: Client,
53
+ args: {
54
+ guildId?: string;
55
+ channelId?: string;
56
+ userId?: string;
57
+ username?: string;
58
+ },
59
+ ): Promise<VoiceBasedChannel> {
60
+ if (args.channelId) {
61
+ const ch = await client.channels.fetch(args.channelId);
62
+ if (!ch || !('guild' in ch) || !ch.isVoiceBased()) {
63
+ throw new Error(`channel ${args.channelId} is not a voice channel`);
64
+ }
65
+ return ch;
66
+ }
67
+ const ch = findUserVoiceChannel(client, {
68
+ userId: args.userId,
69
+ username: args.userId ? undefined : (args.username ?? DEFAULT_USERNAME),
70
+ });
71
+ if (!ch) {
72
+ const who = args.userId ?? args.username ?? DEFAULT_USERNAME;
73
+ throw new Error(
74
+ `could not find a voice channel for '${who}' — ` +
75
+ 'is the user connected to voice in a guild the bot shares, and is the ' +
76
+ 'GuildVoiceStates intent enabled? (full daemon restart needed after intent change)',
77
+ );
78
+ }
79
+ return ch;
80
+ }
81
+
82
+ export async function joinVoice(
83
+ id: string,
84
+ rawArgs: Record<string, unknown>,
85
+ ): Promise<void> {
86
+ const args = rawArgs as {
87
+ account?: string;
88
+ guildId?: string;
89
+ channelId?: string;
90
+ userId?: string;
91
+ username?: string;
92
+ };
93
+ const { accountId, client } = clientFor(args.account);
94
+ if (!client.isReady()) {
95
+ respond(id, { error: `gateway not ready for '${accountId}'` });
96
+ return;
97
+ }
98
+
99
+ const channel = await resolveTarget(client, args);
100
+ const guild = channel.guild;
101
+
102
+ const connection: VoiceConnection = joinVoiceChannel({
103
+ channelId: channel.id,
104
+ guildId: guild.id,
105
+ adapterCreator: guild.voiceAdapterCreator,
106
+ selfDeaf: false,
107
+ selfMute: true,
108
+ });
109
+
110
+ try {
111
+ await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
112
+ } catch (err) {
113
+ connection.destroy();
114
+ throw new Error(
115
+ `voice connection failed to become Ready: ${errMsg(err)}`,
116
+ );
117
+ }
118
+
119
+ try {
120
+ startTranscription(guild.id, channel.id, accountId, client, connection);
121
+ } catch (err) {
122
+ process.stderr.write(
123
+ `voice transcription arm failed: ${errMsg(err)}\n`,
124
+ );
125
+ }
126
+
127
+ respond(id, {
128
+ result: {
129
+ ok: true,
130
+ account: accountId,
131
+ guildId: guild.id,
132
+ guildName: guild.name,
133
+ channelId: channel.id,
134
+ channelName: channel.name,
135
+ status: connection.state.status,
136
+ transcribing: true,
137
+ },
138
+ });
139
+ }
140
+
141
+ export function voiceDebug(id: string, rawArgs: Record<string, unknown>): void {
142
+ const args = rawArgs as { account?: string };
143
+ const { accountId, client } = clientFor(args.account);
144
+ const guilds = [...client.guilds.cache.values()].map((g) => {
145
+ const voiceChannels = [...g.channels.cache.values()]
146
+ .filter((c) => c.isVoiceBased())
147
+ .map((c) => ({ id: c.id, name: c.name }));
148
+ const occupants = [...g.voiceStates.cache.values()]
149
+ .filter((vs) => vs.channelId)
150
+ .map((vs) => ({
151
+ channelId: vs.channelId,
152
+ channelName: vs.channel?.name ?? null,
153
+ userId: vs.id,
154
+ username: vs.member?.user.username ?? null,
155
+ }));
156
+ return { id: g.id, name: g.name, voiceChannels, occupants };
157
+ });
158
+ respond(id, {
159
+ result: { ok: true, account: accountId, ready: client.isReady(), guilds },
160
+ });
161
+ }
162
+
163
+ export function leaveVoice(id: string, rawArgs: Record<string, unknown>): void {
164
+ const args = rawArgs as { account?: string; guildId?: string };
165
+ const { accountId, client } = clientFor(args.account);
166
+
167
+ const guildIds = args.guildId
168
+ ? [args.guildId]
169
+ : [...client.guilds.cache.keys()];
170
+ const left: string[] = [];
171
+ for (const gid of guildIds) {
172
+ const conn = getVoiceConnection(gid);
173
+ if (conn) {
174
+ stopTranscription(gid);
175
+ conn.destroy();
176
+ left.push(gid);
177
+ }
178
+ }
179
+ respond(id, { result: { ok: true, account: accountId, leftGuilds: left } });
180
+ }
181
+
182
+ export function voiceTranscribe(
183
+ id: string,
184
+ rawArgs: Record<string, unknown>,
185
+ ): void {
186
+ const args = rawArgs as { account?: string; guildId?: string; on?: boolean };
187
+ const { accountId, client } = clientFor(args.account);
188
+ const on = args.on !== false;
189
+ const guildIds = args.guildId
190
+ ? [args.guildId]
191
+ : [...client.guilds.cache.keys()];
192
+ const toggled: string[] = [];
193
+ for (const gid of guildIds) if (setTranscription(gid, on)) toggled.push(gid);
194
+ respond(id, {
195
+ result: { ok: true, account: accountId, on, toggledGuilds: toggled },
196
+ });
197
+ }
@@ -0,0 +1,5 @@
1
+ export { emit, respond, mintId } from '@metro-labs/mcp/stations/station-runtime';
2
+
3
+ export const SELF_URI =
4
+ process.env.METRO_SELF_URI ??
5
+ 'metro://claude/user/8a1857f3-4039-4da6-a4e1-611b432d2082';
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@metro-labs/mcp",
3
+ "version": "0.1.0-beta.15",
4
+ "private": true,
5
+ "description": "Event-interception wire. Supervises train subprocesses in ~/.metro/trains/, multiplexes their JSON event stream onto stdout, and routes outbound action calls back via stdin. Per-platform code is written by the agent (or user) as train scripts — metro core is pure transport.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bonustrack/metro.git"
10
+ },
11
+ "homepage": "https://github.com/bonustrack/metro#readme",
12
+ "bugs": "https://github.com/bonustrack/metro/issues",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "bin": {
18
+ "metro-daemon": "./dist/server.js"
19
+ },
20
+ "exports": {
21
+ ".": "./src/mcp/index.ts",
22
+ "./server": "./dist/server.js",
23
+ "./log": "./src/daemon/log.ts",
24
+ "./train-error": "./src/daemon/train-error.ts",
25
+ "./secure-fs": "./src/daemon/secure-fs.ts",
26
+ "./lines": "./src/stations/lines.ts",
27
+ "./events": "./src/daemon/events.ts",
28
+ "./endpoints": "./src/daemon/tunnel.ts",
29
+ "./trains/protocol": "./src/daemon/protocol.ts",
30
+ "./stations/types": "./src/stations/types.ts",
31
+ "./stations/station-runtime": "./src/stations/station-runtime.ts",
32
+ "./stations/account-store": "./src/stations/account-store.ts",
33
+ "./stations/attachments": "./src/stations/attachments.ts",
34
+ "./stations/messaging-normalize": "./src/stations/messaging-normalize.ts",
35
+ "./db/whatsapp-login": "./src/db/whatsapp-login.ts"
36
+ },
37
+ "scripts": {
38
+ "start": "bun src/server.ts",
39
+ "build": "tsc",
40
+ "db:generate": "drizzle-kit generate",
41
+ "db:migrate": "drizzle-kit migrate",
42
+ "test": "tsc --noEmit && DYLD_FALLBACK_LIBRARY_PATH=/usr/lib METRO_STATE_DIR=\"$(mktemp -d /tmp/metro-test.XXXXXX)\" bun test test/"
43
+ },
44
+ "dependencies": {
45
+ "@metro-labs/discord-bot": "workspace:*",
46
+ "@metro-labs/telegram": "workspace:*",
47
+ "@metro-labs/telegram-bot": "workspace:*",
48
+ "@metro-labs/webhook": "workspace:*",
49
+ "@metro-labs/whatsapp": "workspace:*",
50
+ "@metro-labs/xmtp": "workspace:*",
51
+ "@modelcontextprotocol/sdk": "^1.12.0",
52
+ "drizzle-kit": "^0.31.0",
53
+ "drizzle-orm": "^0.44.0",
54
+ "pino": "^9.5.0",
55
+ "pino-pretty": "^13.1.3",
56
+ "postgres": "^3.4.5",
57
+ "viem": "2.52.2",
58
+ "zod": "^3.23.8"
59
+ },
60
+ "devDependencies": {
61
+ "@stage-labs/config": "0.1.0-beta.2",
62
+ "eslint": "^10.3.0",
63
+ "knip": "^6.15.0",
64
+ "madge": "^8.0.0",
65
+ "typescript": "^5"
66
+ }
67
+ }
@@ -0,0 +1,24 @@
1
+ import { accountFromLine, agentIdForAccount } from '../db/agent-map.js';
2
+
3
+ const accountStrippedLine = (line: string): string => {
4
+ const parts = line.split('/');
5
+ if (parts.length < 5) return line;
6
+ return [parts[0], parts[1], parts[2], ...parts.slice(4)].join('/');
7
+ };
8
+
9
+ export const dedupeOwner = (line: string): string => {
10
+ const acct = accountFromLine(line);
11
+ if (!acct) return 'unowned';
12
+ const agentId = agentIdForAccount(acct.station, acct.accountId);
13
+ return agentId === undefined
14
+ ? `account:${acct.accountId}`
15
+ : `agent:${agentId}`;
16
+ };
17
+
18
+ export const dedupeKey = (
19
+ station: string,
20
+ line: string,
21
+ kind: string,
22
+ messageId: string,
23
+ ): string =>
24
+ `${dedupeOwner(line)} ${station} ${accountStrippedLine(line)} ${kind} ${messageId}`;
@@ -0,0 +1,367 @@
1
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { str } from '../mcp/str.js';
3
+ import { dedupeKey } from './dedupe.js';
4
+ import {
5
+ buildMediaFailureNote,
6
+ buildMediaNote,
7
+ type MediaNote,
8
+ type SavedMedia,
9
+ } from './media-note.js';
10
+ import { buildWebhookNote } from './webhook-note.js';
11
+ import {
12
+ capSet,
13
+ displayNameMeta,
14
+ senderMeta,
15
+ tsMeta,
16
+ takeMediaCtx,
17
+ type MediaCtx,
18
+ type PendingAtt,
19
+ type PendingMsg,
20
+ } from './pending.js';
21
+
22
+ interface InboundDeps {
23
+ mcp: Server;
24
+ log: (...a: unknown[]) => void;
25
+ getStations: () => Set<string>;
26
+ senderAllowed: (from: string, line: string) => boolean;
27
+ }
28
+
29
+ const ATTACH_TIMEOUT_MS = 15_000;
30
+ const DEDUPE_TTL_MS = 30_000;
31
+ const DEDUPE_MAX = 2_000;
32
+ const ALLOWED_LINES_MAX = 2_000;
33
+ const PENDING_PERMISSIONS_MAX = 500;
34
+
35
+ const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
36
+
37
+ const shortId = (id: string): string =>
38
+ id.length > 10 ? `${id.slice(0, 6)}…` : id;
39
+
40
+ export class InboundRelay {
41
+ private readonly deps: InboundDeps;
42
+ private readonly pendingAttachments = new Map<string, PendingMsg>();
43
+ private readonly seenEvents = new Map<string, number>();
44
+ private readonly allowedLines = new Set<string>();
45
+ private readonly pendingPermissions = new Set<string>();
46
+ private lastLine: string | undefined;
47
+
48
+ constructor(deps: InboundDeps) {
49
+ this.deps = deps;
50
+ }
51
+
52
+ get knownLine(): string | undefined {
53
+ return this.lastLine;
54
+ }
55
+
56
+ registerPermission(requestId: string): void {
57
+ this.pendingPermissions.add(requestId);
58
+ capSet(this.pendingPermissions, PENDING_PERMISSIONS_MAX);
59
+ }
60
+
61
+ private notify(method: string, params: Record<string, unknown>): Promise<void> {
62
+ return this.deps.mcp.notification({ method, params });
63
+ }
64
+
65
+ private isDuplicate(
66
+ station: string,
67
+ line: string,
68
+ kind: string,
69
+ messageId: string,
70
+ ): boolean {
71
+ if (!messageId) return false;
72
+ const key = dedupeKey(station, line, kind, messageId);
73
+ const now = Date.now();
74
+ if (this.seenEvents.size >= DEDUPE_MAX) {
75
+ for (const [k, t] of this.seenEvents) {
76
+ if (now - t > DEDUPE_TTL_MS) this.seenEvents.delete(k);
77
+ }
78
+ }
79
+ const prev = this.seenEvents.get(key);
80
+ if (prev !== undefined && now - prev < DEDUPE_TTL_MS) return true;
81
+ this.seenEvents.set(key, now);
82
+ return false;
83
+ }
84
+
85
+ private surfaceNote(
86
+ ctx: MediaCtx,
87
+ p: SavedMedia,
88
+ note: MediaNote,
89
+ extra: Record<string, unknown>,
90
+ ): Promise<void> {
91
+ return this.notify('notifications/claude/channel', {
92
+ content: note.content,
93
+ meta: {
94
+ line: ctx.line,
95
+ from: ctx.from,
96
+ station: ctx.station,
97
+ ...senderMeta(ctx),
98
+ kind: note.kind,
99
+ mime: p.mime ?? '',
100
+ name: note.name,
101
+ ...extra,
102
+ },
103
+ });
104
+ }
105
+
106
+ private async surfaceMedia(ctx: MediaCtx, p: SavedMedia): Promise<void> {
107
+ const note = await buildMediaNote(p, ctx.text ?? '');
108
+ if (!note) return;
109
+ await this.surfaceNote(ctx, p, note, {
110
+ ...(p.url ? { url: p.url } : {}),
111
+ local_path: note.path,
112
+ });
113
+ }
114
+
115
+ private surfaceMediaFailure(ctx: MediaCtx, p: SavedMedia): Promise<void> {
116
+ return this.surfaceNote(ctx, p, buildMediaFailureNote(p, ctx.text ?? ''), {
117
+ attachment_error: p.reason ?? '',
118
+ });
119
+ }
120
+
121
+ private async flushPendingFallback(id: string): Promise<void> {
122
+ const e = this.pendingAttachments.get(id);
123
+ if (!e) return;
124
+ this.pendingAttachments.delete(id);
125
+ const missing = e.attachments.filter((_, i) => !e.saved.has(i));
126
+ if (!missing.length) return;
127
+ const names = missing
128
+ .map((a) => a.name ?? a.kind ?? 'attachment')
129
+ .join(', ');
130
+ await this.notify('notifications/claude/channel', {
131
+ content:
132
+ (e.text ? `${e.text}\n` : '') +
133
+ `[attachment(s) could not be fetched in time: ${names}]`,
134
+ meta: {
135
+ line: e.line,
136
+ from: e.from,
137
+ station: e.station,
138
+ ...tsMeta(e.ts),
139
+ message_id: e.messageId,
140
+ line_name: e.lineName,
141
+ from_name: e.fromName,
142
+ ...displayNameMeta(e.fromDisplayName),
143
+ },
144
+ });
145
+ }
146
+
147
+ private mediaCtxFor(
148
+ ev: Record<string, unknown>,
149
+ payload: SavedMedia,
150
+ ): MediaCtx | null {
151
+ const forId = str(payload.attachmentFor);
152
+ const buf = forId ? this.pendingAttachments.get(forId) : undefined;
153
+ if (buf) {
154
+ buf.saved.add(typeof payload.index === 'number' ? payload.index : 0);
155
+ const ctx = takeMediaCtx(buf);
156
+ if (buf.saved.size >= buf.attachments.length) {
157
+ clearTimeout(buf.timer);
158
+ this.pendingAttachments.delete(forId);
159
+ }
160
+ return ctx;
161
+ }
162
+ const line = str(ev.line);
163
+ if (!line || !this.allowedLines.has(line)) return null;
164
+ return {
165
+ line,
166
+ from: 'metro://attachment',
167
+ station: str(ev.station) || 'xmtp',
168
+ ts: str(ev.ts),
169
+ };
170
+ }
171
+
172
+ private async routeAttachment(ev: Record<string, unknown>): Promise<boolean> {
173
+ const p = ev.payload as SavedMedia | undefined;
174
+ if (!p) return false;
175
+ const type = p.contentType;
176
+ if (type !== 'attachmentSaved' && type !== 'attachmentFailed') return false;
177
+ const ctx = this.mediaCtxFor(ev, p);
178
+ if (!ctx) return true;
179
+ if (type === 'attachmentFailed') await this.surfaceMediaFailure(ctx, p);
180
+ else await this.surfaceMedia(ctx, p);
181
+ return true;
182
+ }
183
+
184
+ private bufferAttachments(
185
+ ev: Record<string, unknown>,
186
+ base: { line: string; from: string; station: string; text: string },
187
+ atts: PendingAtt[],
188
+ ): void {
189
+ const id = str(ev.id);
190
+ if (!id) return;
191
+ const existing = this.pendingAttachments.get(id);
192
+ if (existing) clearTimeout(existing.timer);
193
+ this.pendingAttachments.set(id, {
194
+ ...base,
195
+ ts: str(ev.ts),
196
+ messageId: str(ev.messageId),
197
+ lineName: str(ev.lineName),
198
+ fromName: str(ev.fromName),
199
+ fromDisplayName: str(ev.fromDisplayName),
200
+ attachments: atts.map((a) => ({ kind: a.kind, name: a.name })),
201
+ saved: new Set<number>(),
202
+ timer: setTimeout(() => {
203
+ this.flushPendingFallback(id).catch((err: unknown) => {
204
+ this.deps.log(
205
+ 'inbound: attachment fallback not delivered',
206
+ 'id',
207
+ id,
208
+ 'line',
209
+ base.line,
210
+ err,
211
+ );
212
+ });
213
+ }, ATTACH_TIMEOUT_MS),
214
+ });
215
+ }
216
+
217
+ private async handleReact(
218
+ ev: Record<string, unknown>,
219
+ base: { line: string; from: string; station: string; text: string },
220
+ ): Promise<void> {
221
+ const re = ev.event as { emoji?: unknown; targetId?: string };
222
+ const emoji = reactionEmoji(re.emoji);
223
+ const target = re.targetId ?? str(ev.messageId);
224
+ const removed =
225
+ (ev.payload as { removed?: boolean } | undefined)?.removed === true ||
226
+ / \(removed\)\]?$/.test(base.text);
227
+ const verb = removed ? 'removed from' : 'reacted to';
228
+ const label = removed ? emoji || 'reaction' : emoji || 'reacted';
229
+ const content = `${label} ${verb} message ${shortId(target)}`.trim();
230
+ await this.notify('notifications/claude/channel', {
231
+ content,
232
+ meta: {
233
+ line: base.line,
234
+ from: base.from,
235
+ station: base.station,
236
+ ...tsMeta(ev.ts),
237
+ message_id: str(ev.messageId),
238
+ line_name: str(ev.lineName),
239
+ from_name: str(ev.fromName),
240
+ ...displayNameMeta(ev.fromDisplayName),
241
+ reaction: emoji,
242
+ target_id: target,
243
+ },
244
+ });
245
+ }
246
+
247
+ private async handlePermissionReply(text: string): Promise<boolean> {
248
+ const m = PERMISSION_REPLY_RE.exec(text);
249
+ if (m?.[1] === undefined || m[2] === undefined || !this.pendingPermissions.size)
250
+ return false;
251
+ const id = m[2].toLowerCase();
252
+ if (!this.pendingPermissions.has(id)) return false;
253
+ this.pendingPermissions.delete(id);
254
+ await this.notify('notifications/claude/channel/permission', {
255
+ request_id: id,
256
+ behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
257
+ });
258
+ return true;
259
+ }
260
+
261
+ private droppedSender(from: string, line: string): boolean {
262
+ if (
263
+ from.startsWith('metro://claude') ||
264
+ from === 'metro://user' ||
265
+ !from.startsWith('metro://')
266
+ )
267
+ return true;
268
+ if (!this.deps.senderAllowed(from, line)) {
269
+ this.deps.log('drop: sender not allowed', from);
270
+ return true;
271
+ }
272
+ return false;
273
+ }
274
+
275
+ private routable(
276
+ ev: Record<string, unknown>,
277
+ replay: boolean,
278
+ ): EventBase | null {
279
+ const rawType = ev.event ? (ev.event as { type?: string }).type : 'msg';
280
+ const evType = rawType === 'reply' ? 'msg' : rawType;
281
+ if (evType !== 'msg' && evType !== 'react' && evType !== 'system')
282
+ return null;
283
+ const station = str(ev.station);
284
+ if (!this.deps.getStations().has(station)) return null;
285
+ const from = str(ev.from);
286
+ const line = str(ev.line);
287
+ if (this.droppedSender(from, line)) return null;
288
+ const text = str(ev.text);
289
+ if (!replay && this.isDuplicate(station, line, evType, str(ev.messageId))) {
290
+ this.deps.log(
291
+ 'drop: duplicate (per-account) event',
292
+ evType,
293
+ station,
294
+ str(ev.messageId),
295
+ );
296
+ return null;
297
+ }
298
+ return { evType, station, from, line, text };
299
+ }
300
+
301
+ private async emitMessage(
302
+ ev: Record<string, unknown>,
303
+ base: EventBase,
304
+ ): Promise<void> {
305
+ if (base.evType === 'msg' && (await this.handlePermissionReply(base.text)))
306
+ return;
307
+ await this.notify('notifications/claude/channel', {
308
+ content:
309
+ base.evType === 'system'
310
+ ? buildWebhookNote(base.text, str(ev.lineName), ev.payload)
311
+ : base.text,
312
+ meta: {
313
+ line: base.line,
314
+ from: base.from,
315
+ station: base.station,
316
+ ...tsMeta(ev.ts),
317
+ message_id: str(ev.messageId),
318
+ line_name: str(ev.lineName),
319
+ from_name: str(ev.fromName),
320
+ ...displayNameMeta(ev.fromDisplayName),
321
+ },
322
+ });
323
+ }
324
+
325
+ async handleEvent(
326
+ ev: Record<string, unknown>,
327
+ replay = false,
328
+ ): Promise<void> {
329
+ if (await this.routeAttachment(ev)) return;
330
+
331
+ const base = this.routable(ev, replay);
332
+ if (!base) return;
333
+ if (base.evType !== 'system') this.lastLine = base.line;
334
+ if (base.line) {
335
+ this.allowedLines.add(base.line);
336
+ capSet(this.allowedLines, ALLOWED_LINES_MAX);
337
+ }
338
+
339
+ const atts = (ev.payload as { attachments?: PendingAtt[] } | undefined)
340
+ ?.attachments;
341
+ if (Array.isArray(atts) && atts.length) {
342
+ this.bufferAttachments(ev, base, atts);
343
+ return;
344
+ }
345
+
346
+ if (base.evType === 'react') {
347
+ await this.handleReact(ev, base);
348
+ return;
349
+ }
350
+
351
+ await this.emitMessage(ev, base);
352
+ }
353
+ }
354
+
355
+ interface EventBase {
356
+ evType: string;
357
+ station: string;
358
+ from: string;
359
+ line: string;
360
+ text: string;
361
+ }
362
+
363
+ function reactionEmoji(raw: unknown): string {
364
+ if (typeof raw === 'string') return raw;
365
+ const obj = raw as { name?: string; reaction?: string } | undefined;
366
+ return obj?.name ?? obj?.reaction ?? '';
367
+ }