@vex-chat/libvex 6.6.4 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Client.ts CHANGED
@@ -606,6 +606,8 @@ export interface Message {
606
606
  decrypted: boolean;
607
607
  /** Whether this message was received or sent by the current client. */
608
608
  direction: "incoming" | "outgoing";
609
+ /** Optional encrypted client metadata attached to this message. */
610
+ extra?: null | string | undefined;
609
611
  /** `true` when this message was forwarded to another owned device. */
610
612
  forward: boolean;
611
613
  /** Channel ID for group messages; `null` for direct messages. */
@@ -625,7 +627,7 @@ export interface Message {
625
627
  * with {@link ClientOptions.localMessageRetentionDays} to pick the
626
628
  * shorter local retention window. Ignored when absent.
627
629
  */
628
- retentionHintDays?: number;
630
+ retentionHintDays?: number | undefined;
629
631
  /** Sender device ID. */
630
632
  sender: string;
631
633
  /** Time the message was created/received. */
@@ -850,6 +852,7 @@ const messageSchema: z.ZodType<Message> = z.object({
850
852
  authorID: z.string(),
851
853
  decrypted: z.boolean(),
852
854
  direction: z.enum(["incoming", "outgoing"]),
855
+ extra: z.string().nullable().optional(),
853
856
  forward: z.boolean(),
854
857
  group: z.string().nullable(),
855
858
  mailID: z.string(),
@@ -857,10 +860,94 @@ const messageSchema: z.ZodType<Message> = z.object({
857
860
  nonce: z.string(),
858
861
  readerID: z.string(),
859
862
  recipient: z.string(),
863
+ retentionHintDays: z.number().optional(),
860
864
  sender: z.string(),
861
865
  timestamp: z.string(),
862
866
  });
863
867
 
868
+ const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
869
+
870
+ interface DecodedMessagePlaintext {
871
+ extra?: null | string | undefined;
872
+ message: string;
873
+ retentionHintDays?: number | undefined;
874
+ }
875
+
876
+ function decodeMessageBlob(body: string): DecodedMessagePlaintext {
877
+ if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
878
+ return { message: body };
879
+ }
880
+
881
+ try {
882
+ const raw = JSON.parse(
883
+ body.slice(MESSAGE_BLOB_PREFIX.length),
884
+ ) as unknown;
885
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
886
+ return { message: body };
887
+ }
888
+ const message = Reflect.get(raw, "message");
889
+ if (typeof message !== "string") {
890
+ return { message: body };
891
+ }
892
+ const extra = Reflect.get(raw, "extra");
893
+ return {
894
+ ...(extra === null || typeof extra === "string" ? { extra } : {}),
895
+ message,
896
+ };
897
+ } catch {
898
+ return { message: body };
899
+ }
900
+ }
901
+
902
+ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
903
+ const stripped = stripVexRetentionEnvelope(plaintext);
904
+ const blob = decodeMessageBlob(stripped.body);
905
+ return stripped.retentionHintDays !== undefined
906
+ ? {
907
+ ...blob,
908
+ retentionHintDays: stripped.retentionHintDays,
909
+ }
910
+ : blob;
911
+ }
912
+
913
+ function encodeMessagePlaintext(
914
+ message: string,
915
+ opts?: MessageSendOptions,
916
+ ): string {
917
+ const body =
918
+ opts?.extra === undefined
919
+ ? message
920
+ : MESSAGE_BLOB_PREFIX +
921
+ JSON.stringify({
922
+ extra: opts.extra,
923
+ message,
924
+ });
925
+ return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
926
+ }
927
+
928
+ function messageFromDecodedPlaintext(
929
+ decoded: DecodedMessagePlaintext,
930
+ ): Pick<Message, "extra" | "message" | "retentionHintDays"> {
931
+ return {
932
+ ...(decoded.extra !== undefined ? { extra: decoded.extra } : {}),
933
+ message: decoded.message,
934
+ ...(decoded.retentionHintDays !== undefined
935
+ ? { retentionHintDays: decoded.retentionHintDays }
936
+ : {}),
937
+ };
938
+ }
939
+
940
+ function normalizeForwardedMessage(message: Message): Message {
941
+ const decoded = decodeMessagePlaintext(message.message);
942
+ return {
943
+ ...message,
944
+ ...messageFromDecodedPlaintext({
945
+ ...decoded,
946
+ extra: decoded.extra !== undefined ? decoded.extra : message.extra,
947
+ }),
948
+ };
949
+ }
950
+
864
951
  /** Zod schema for a single inbox entry from getMail: [header, mailBody, timestamp]. */
865
952
  const mailInboxEntry = z.tuple([
866
953
  z.custom<Uint8Array>((val) => val instanceof Uint8Array),
@@ -929,7 +1016,7 @@ export interface Messages {
929
1016
  group: (
930
1017
  channelID: string,
931
1018
  message: string,
932
- opts?: { retentionHintDays?: number },
1019
+ opts?: MessageSendOptions,
933
1020
  ) => Promise<void>;
934
1021
  /** Deletes all locally stored message history. */
935
1022
  purge: () => Promise<void>;
@@ -941,10 +1028,17 @@ export interface Messages {
941
1028
  send: (
942
1029
  userID: string,
943
1030
  message: string,
944
- opts?: { retentionHintDays?: number },
1031
+ opts?: MessageSendOptions,
945
1032
  ) => Promise<void>;
946
1033
  }
947
1034
 
1035
+ export interface MessageSendOptions {
1036
+ /** Optional encrypted client metadata for message-level features. */
1037
+ extra?: null | string | undefined;
1038
+ /** Optional peer hint (1-30 days) for local history retention. */
1039
+ retentionHintDays?: number | undefined;
1040
+ }
1041
+
948
1042
  /**
949
1043
  * @ignore
950
1044
  */
@@ -1275,7 +1369,7 @@ export class Client {
1275
1369
  group: (
1276
1370
  channelID: string,
1277
1371
  message: string,
1278
- opts?: { retentionHintDays?: number },
1372
+ opts?: MessageSendOptions,
1279
1373
  ) => this.sendGroupMessage(channelID, message, opts),
1280
1374
  purge: this.purgeHistory.bind(this),
1281
1375
  /**
@@ -1297,11 +1391,8 @@ export class Client {
1297
1391
  * @param userID - The user to send a message to.
1298
1392
  * @param message - The message to send.
1299
1393
  */
1300
- send: (
1301
- userID: string,
1302
- message: string,
1303
- opts?: { retentionHintDays?: number },
1304
- ) => this.sendMessage(userID, message, opts),
1394
+ send: (userID: string, message: string, opts?: MessageSendOptions) =>
1395
+ this.sendMessage(userID, message, opts),
1305
1396
  };
1306
1397
  /**
1307
1398
  * Server moderation helper methods.
@@ -2704,7 +2795,7 @@ export class Client {
2704
2795
  : null;
2705
2796
  const shouldEmitHandshakeMessage = forward || message.length > 0;
2706
2797
  const emitMsg: Message = forwardedMsg
2707
- ? { ...forwardedMsg, forward: true }
2798
+ ? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
2708
2799
  : {
2709
2800
  authorID: mail.authorID,
2710
2801
  decrypted: true,
@@ -2712,7 +2803,9 @@ export class Client {
2712
2803
  forward: mail.forward,
2713
2804
  group: mail.group ? uuid.stringify(mail.group) : null,
2714
2805
  mailID: mail.mailID,
2715
- message: XUtils.encodeUTF8(message),
2806
+ ...messageFromDecodedPlaintext(
2807
+ decodeMessagePlaintext(XUtils.encodeUTF8(message)),
2808
+ ),
2716
2809
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2717
2810
  readerID: mail.readerID,
2718
2811
  recipient: mail.recipient,
@@ -4098,39 +4191,19 @@ export class Client {
4098
4191
  if (!mail.forward) {
4099
4192
  plaintext = XUtils.encodeUTF8(unsealed);
4100
4193
  }
4101
- let incomingPlain = plaintext;
4102
- let hintFromEnvelope: number | undefined;
4103
- if (!mail.forward && plaintext.length > 0) {
4104
- const stripped =
4105
- stripVexRetentionEnvelope(plaintext);
4106
- incomingPlain = stripped.body;
4107
- hintFromEnvelope = stripped.retentionHintDays;
4108
- }
4194
+ const decodedPlaintext = mail.forward
4195
+ ? null
4196
+ : decodeMessagePlaintext(plaintext);
4109
4197
 
4110
4198
  // emit the message
4111
4199
  const fwdMsg1 = mail.forward
4112
4200
  ? messageSchema.parse(msgpack.decode(unsealed))
4113
4201
  : null;
4114
4202
  const message: Message = fwdMsg1
4115
- ? (() => {
4116
- const stripped =
4117
- stripVexRetentionEnvelope(
4118
- fwdMsg1.message,
4119
- );
4120
- const base: Message = {
4121
- ...fwdMsg1,
4122
- forward: true,
4123
- message: stripped.body,
4124
- };
4125
- return stripped.retentionHintDays !==
4126
- undefined
4127
- ? {
4128
- ...base,
4129
- retentionHintDays:
4130
- stripped.retentionHintDays,
4131
- }
4132
- : base;
4133
- })()
4203
+ ? {
4204
+ ...normalizeForwardedMessage(fwdMsg1),
4205
+ forward: true,
4206
+ }
4134
4207
  : {
4135
4208
  authorID: mail.authorID,
4136
4209
  decrypted: true,
@@ -4140,18 +4213,16 @@ export class Client {
4140
4213
  ? uuid.stringify(mail.group)
4141
4214
  : null,
4142
4215
  mailID: mail.mailID,
4143
- message: incomingPlain,
4216
+ ...messageFromDecodedPlaintext(
4217
+ decodedPlaintext ?? {
4218
+ message: plaintext,
4219
+ },
4220
+ ),
4144
4221
  nonce: XUtils.encodeHex(
4145
4222
  new Uint8Array(mail.nonce),
4146
4223
  ),
4147
4224
  readerID: mail.readerID,
4148
4225
  recipient: mail.recipient,
4149
- ...(hintFromEnvelope !== undefined
4150
- ? {
4151
- retentionHintDays:
4152
- hintFromEnvelope,
4153
- }
4154
- : {}),
4155
4226
  sender: mail.sender,
4156
4227
  timestamp: timestamp,
4157
4228
  };
@@ -4391,34 +4462,14 @@ export class Client {
4391
4462
  ? messageSchema.parse(msgpack.decode(decrypted))
4392
4463
  : null;
4393
4464
  const rawIncoming = XUtils.encodeUTF8(decrypted);
4394
- let bodyIncoming = rawIncoming;
4395
- let hintIncoming: number | undefined;
4396
- if (!mail.forward) {
4397
- const stripped =
4398
- stripVexRetentionEnvelope(rawIncoming);
4399
- bodyIncoming = stripped.body;
4400
- hintIncoming = stripped.retentionHintDays;
4401
- }
4465
+ const decodedPlaintext = mail.forward
4466
+ ? null
4467
+ : decodeMessagePlaintext(rawIncoming);
4402
4468
  const message: Message = fwdMsg2
4403
- ? (() => {
4404
- const stripped =
4405
- stripVexRetentionEnvelope(
4406
- fwdMsg2.message,
4407
- );
4408
- const base: Message = {
4409
- ...fwdMsg2,
4410
- forward: true,
4411
- message: stripped.body,
4412
- };
4413
- return stripped.retentionHintDays !==
4414
- undefined
4415
- ? {
4416
- ...base,
4417
- retentionHintDays:
4418
- stripped.retentionHintDays,
4419
- }
4420
- : base;
4421
- })()
4469
+ ? {
4470
+ ...normalizeForwardedMessage(fwdMsg2),
4471
+ forward: true,
4472
+ }
4422
4473
  : {
4423
4474
  authorID: mail.authorID,
4424
4475
  decrypted: true,
@@ -4428,15 +4479,16 @@ export class Client {
4428
4479
  ? uuid.stringify(mail.group)
4429
4480
  : null,
4430
4481
  mailID: mail.mailID,
4431
- message: bodyIncoming,
4482
+ ...messageFromDecodedPlaintext(
4483
+ decodedPlaintext ?? {
4484
+ message: rawIncoming,
4485
+ },
4486
+ ),
4432
4487
  nonce: XUtils.encodeHex(
4433
4488
  new Uint8Array(mail.nonce),
4434
4489
  ),
4435
4490
  readerID: mail.readerID,
4436
4491
  recipient: mail.recipient,
4437
- ...(hintIncoming !== undefined
4438
- ? { retentionHintDays: hintIncoming }
4439
- : {}),
4440
4492
  sender: mail.sender,
4441
4493
  timestamp: timestamp,
4442
4494
  };
@@ -4848,7 +4900,7 @@ export class Client {
4848
4900
  private async sendGroupMessage(
4849
4901
  channelID: string,
4850
4902
  message: string,
4851
- opts?: { retentionHintDays?: number },
4903
+ opts?: MessageSendOptions,
4852
4904
  ): Promise<void> {
4853
4905
  const userList = await this.getUserList(channelID);
4854
4906
  for (const user of userList) {
@@ -4856,10 +4908,7 @@ export class Client {
4856
4908
  }
4857
4909
 
4858
4910
  const mailID = uuid.v4();
4859
- const payload = formatVexRetentionEnvelope(
4860
- message,
4861
- opts?.retentionHintDays,
4862
- );
4911
+ const payload = encodeMessagePlaintext(message, opts);
4863
4912
  const msgBytes = XUtils.decodeUTF8(payload);
4864
4913
  const myUserID = this.getUser().userID;
4865
4914
  const peerUserIDs = [...new Set(userList.map((u) => u.userID))].filter(
@@ -4890,6 +4939,7 @@ export class Client {
4890
4939
  if (targetDevices.size === 0) {
4891
4940
  const dev = this.getDevice();
4892
4941
  const nonce = xMakeNonce();
4942
+ const decodedPlaintext = decodeMessagePlaintext(payload);
4893
4943
  this.emitter.emit("message", {
4894
4944
  authorID: myUserID,
4895
4945
  decrypted: true,
@@ -4897,7 +4947,7 @@ export class Client {
4897
4947
  forward: false,
4898
4948
  group: channelID,
4899
4949
  mailID,
4900
- message,
4950
+ ...messageFromDecodedPlaintext(decodedPlaintext),
4901
4951
  nonce: XUtils.encodeHex(nonce),
4902
4952
  readerID: myUserID,
4903
4953
  recipient: dev.deviceID,
@@ -5043,25 +5093,8 @@ export class Client {
5043
5093
  const fwdOut = forward
5044
5094
  ? messageSchema.parse(msgpack.decode(msg))
5045
5095
  : null;
5046
- const rawUtf8 = XUtils.encodeUTF8(msg);
5047
- const strippedOut = stripVexRetentionEnvelope(rawUtf8);
5048
5096
  const outMsg: Message = fwdOut
5049
- ? (() => {
5050
- const stripped = stripVexRetentionEnvelope(
5051
- fwdOut.message,
5052
- );
5053
- const base: Message = {
5054
- ...fwdOut,
5055
- forward: true,
5056
- message: stripped.body,
5057
- };
5058
- return stripped.retentionHintDays !== undefined
5059
- ? {
5060
- ...base,
5061
- retentionHintDays: stripped.retentionHintDays,
5062
- }
5063
- : base;
5064
- })()
5097
+ ? { ...normalizeForwardedMessage(fwdOut), forward: true }
5065
5098
  : {
5066
5099
  authorID: mail.authorID,
5067
5100
  decrypted: true,
@@ -5069,16 +5102,12 @@ export class Client {
5069
5102
  forward: mail.forward,
5070
5103
  group: mail.group ? uuid.stringify(mail.group) : null,
5071
5104
  mailID: mail.mailID,
5072
- message: strippedOut.body,
5105
+ ...messageFromDecodedPlaintext(
5106
+ decodeMessagePlaintext(XUtils.encodeUTF8(msg)),
5107
+ ),
5073
5108
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
5074
5109
  readerID: mail.readerID,
5075
5110
  recipient: mail.recipient,
5076
- ...(strippedOut.retentionHintDays !== undefined
5077
- ? {
5078
- retentionHintDays:
5079
- strippedOut.retentionHintDays,
5080
- }
5081
- : {}),
5082
5111
  sender: mail.sender,
5083
5112
  timestamp: new Date().toISOString(),
5084
5113
  };
@@ -5172,12 +5201,9 @@ export class Client {
5172
5201
  private async sendMessage(
5173
5202
  userID: string,
5174
5203
  message: string,
5175
- opts?: { retentionHintDays?: number },
5204
+ opts?: MessageSendOptions,
5176
5205
  ): Promise<void> {
5177
- const payload = formatVexRetentionEnvelope(
5178
- message,
5179
- opts?.retentionHintDays,
5180
- );
5206
+ const payload = encodeMessagePlaintext(message, opts);
5181
5207
  try {
5182
5208
  const [userEntry, err] = await this.fetchUser(userID);
5183
5209
  if (err) {
@@ -91,6 +91,32 @@ export function platformSuite(
91
91
  expect(msg.message).toBe("platform-test");
92
92
  });
93
93
 
94
+ test("send and receive DM with encrypted extra", async () => {
95
+ const me = client.me.user();
96
+ const extra = JSON.stringify({
97
+ reactionEvent: {
98
+ action: "toggle",
99
+ emoji: { kind: "unicode", value: "👍" },
100
+ targetMailID: "mail-target",
101
+ },
102
+ version: 1,
103
+ });
104
+ const msgPromise = waitForMessage(
105
+ client,
106
+ (m) =>
107
+ m.direction === "incoming" &&
108
+ m.decrypted &&
109
+ m.extra === extra,
110
+ `[${platformName}] self-DM extra`,
111
+ );
112
+
113
+ await client.messages.send(me.userID, "", { extra });
114
+ const msg = await msgPromise;
115
+
116
+ expect(msg.message).toBe("");
117
+ expect(msg.extra).toBe(extra);
118
+ });
119
+
94
120
  test("message history retrieve + delete", async () => {
95
121
  const me = client.me.user();
96
122
  const body = "history-test";
@@ -265,6 +291,14 @@ export function platformSuite(
265
291
  );
266
292
 
267
293
  // user1 sends group message, user2 receives it
294
+ const extra = JSON.stringify({
295
+ reactionEvent: {
296
+ action: "toggle",
297
+ emoji: { kind: "unicode", value: "🎉" },
298
+ targetMailID: "group-mail-target",
299
+ },
300
+ version: 1,
301
+ });
268
302
  const msgPromise = waitForMessage(
269
303
  client2,
270
304
  (m) =>
@@ -275,10 +309,13 @@ export function platformSuite(
275
309
  15_000,
276
310
  );
277
311
  await withTransientRetry(() =>
278
- client.messages.group(channel.channelID, "hello channel"),
312
+ client.messages.group(channel.channelID, "hello channel", {
313
+ extra,
314
+ }),
279
315
  );
280
316
  const msg = await msgPromise;
281
317
  expect(msg.message).toBe("hello channel");
318
+ expect(msg.extra).toBe(extra);
282
319
 
283
320
  // Cleanup
284
321
  await client.servers.delete(server.serverID);
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export type {
22
22
  Me,
23
23
  Message,
24
24
  Messages,
25
+ MessageSendOptions,
25
26
  Moderation,
26
27
  NotificationSubscription,
27
28
  NotificationSubscriptionInput,
@@ -58,6 +58,7 @@ interface MessagesTable {
58
58
  authorID: string;
59
59
  decrypted: number;
60
60
  direction: string;
61
+ extra: null | string;
61
62
  forward: number;
62
63
  group: null | string;
63
64
  mailID: string;
@@ -54,6 +54,8 @@ import { type Kysely, sql } from "kysely";
54
54
  import { effectiveMessageRetentionHintDays } from "../retention.js";
55
55
  import { parseSkippedKeysStrict } from "../utils/ratchet.js";
56
56
 
57
+ const STORAGE_MESSAGE_BLOB_PREFIX = "vex-storage-message:1\n";
58
+
57
59
  export class SqliteStorage extends EventEmitter implements Storage {
58
60
  public ready = false;
59
61
  /** 32-byte AES-256 (or nacl) key for local at-rest `secretbox` (see `XUtils.deriveLocalAtRestAesKey`). */
@@ -323,6 +325,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
323
325
  .addColumn("sender", "text")
324
326
  .addColumn("recipient", "text")
325
327
  .addColumn("group", "text")
328
+ .addColumn("extra", "text")
326
329
  .addColumn("mailID", "text")
327
330
  .addColumn("message", "text")
328
331
  .addColumn("direction", "text")
@@ -370,6 +373,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
370
373
  )
371
374
  .execute();
372
375
  await this.ensureSessionRatchetColumns();
376
+ await this.ensureMessageExtraColumn();
373
377
  await this.ensureRetentionHintColumn();
374
378
  await this.ensureMessageMailIdIndex();
375
379
 
@@ -534,16 +538,17 @@ export class SqliteStorage extends EventEmitter implements Storage {
534
538
  return;
535
539
  }
536
540
 
537
- // Encrypt plaintext with at-rest key before saving to disk
541
+ // Encrypt plaintext with at-rest key before saving to disk.
542
+ const storedPlaintext = encodeStoredMessagePlaintext(message);
538
543
  const fips = getCryptoProfile() === "fips";
539
544
  const ct = fips
540
545
  ? await xSecretboxAsync(
541
- XUtils.decodeUTF8(message.message),
546
+ XUtils.decodeUTF8(storedPlaintext),
542
547
  XUtils.decodeHex(message.nonce),
543
548
  this.atRestAesKey,
544
549
  )
545
550
  : xSecretbox(
546
- XUtils.decodeUTF8(message.message),
551
+ XUtils.decodeUTF8(storedPlaintext),
547
552
  XUtils.decodeHex(message.nonce),
548
553
  this.atRestAesKey,
549
554
  );
@@ -559,6 +564,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
559
564
  authorID: message.authorID,
560
565
  decrypted: message.decrypted ? 1 : 0,
561
566
  direction: message.direction,
567
+ extra: null,
562
568
  forward: message.forward ? 1 : 0,
563
569
  group: message.group ?? null,
564
570
  mailID: message.mailID,
@@ -733,6 +739,10 @@ export class SqliteStorage extends EventEmitter implements Storage {
733
739
  }
734
740
  const direction =
735
741
  msg.direction === "incoming" ? "incoming" : "outgoing";
742
+ const storedPlaintext = decodeStoredMessagePlaintext(
743
+ plaintext,
744
+ msg.extra,
745
+ );
736
746
  const rowMessage: Message = {
737
747
  authorID: msg.authorID,
738
748
  decrypted: decryptedFlag,
@@ -740,7 +750,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
740
750
  forward: msg.forward !== 0,
741
751
  group: msg.group,
742
752
  mailID: msg.mailID,
743
- message: plaintext,
753
+ ...storedPlaintext,
744
754
  nonce: msg.nonce,
745
755
  readerID: msg.readerID,
746
756
  recipient: msg.recipient,
@@ -773,6 +783,16 @@ export class SqliteStorage extends EventEmitter implements Storage {
773
783
  };
774
784
  }
775
785
 
786
+ private async ensureMessageExtraColumn(): Promise<void> {
787
+ try {
788
+ await sql
789
+ .raw("ALTER TABLE messages ADD COLUMN extra text")
790
+ .execute(this.db);
791
+ } catch {
792
+ // Existing databases may already have this column.
793
+ }
794
+ }
795
+
776
796
  /** Speeds up mailID existence checks for saveMessage deduplication. */
777
797
  private async ensureMessageMailIdIndex(): Promise<void> {
778
798
  try {
@@ -965,3 +985,61 @@ export class SqliteStorage extends EventEmitter implements Storage {
965
985
  });
966
986
  }
967
987
  }
988
+
989
+ function decodeStoredMessagePlaintext(
990
+ plaintext: string,
991
+ rowExtra: null | string,
992
+ ): Pick<Message, "extra" | "message"> {
993
+ if (!plaintext.startsWith(STORAGE_MESSAGE_BLOB_PREFIX)) {
994
+ return {
995
+ ...(rowExtra !== null ? { extra: rowExtra } : {}),
996
+ message: plaintext,
997
+ };
998
+ }
999
+
1000
+ try {
1001
+ const raw = JSON.parse(
1002
+ plaintext.slice(STORAGE_MESSAGE_BLOB_PREFIX.length),
1003
+ ) as unknown;
1004
+ if (!isJsonRecord(raw)) {
1005
+ return {
1006
+ ...(rowExtra !== null ? { extra: rowExtra } : {}),
1007
+ message: plaintext,
1008
+ };
1009
+ }
1010
+ const message = raw["message"];
1011
+ if (typeof message !== "string") {
1012
+ return {
1013
+ ...(rowExtra !== null ? { extra: rowExtra } : {}),
1014
+ message: plaintext,
1015
+ };
1016
+ }
1017
+ const extra = raw["extra"];
1018
+ return {
1019
+ ...(extra === null || typeof extra === "string" ? { extra } : {}),
1020
+ message,
1021
+ };
1022
+ } catch {
1023
+ return {
1024
+ ...(rowExtra !== null ? { extra: rowExtra } : {}),
1025
+ message: plaintext,
1026
+ };
1027
+ }
1028
+ }
1029
+
1030
+ function encodeStoredMessagePlaintext(message: Message): string {
1031
+ if (message.extra === undefined) {
1032
+ return message.message;
1033
+ }
1034
+ return (
1035
+ STORAGE_MESSAGE_BLOB_PREFIX +
1036
+ JSON.stringify({
1037
+ extra: message.extra,
1038
+ message: message.message,
1039
+ })
1040
+ );
1041
+ }
1042
+
1043
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
1044
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1045
+ }