@wirecat/cli-core 0.354.0 → 0.356.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/CHANGELOG.md CHANGED
@@ -7,6 +7,65 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.356.0 — 19.10.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A recipient-list entry saved by an id with no stored chat follows the messenger's re-key.** `serve` and `watch`
15
+ told the adapter's `storedChats` only the store's chat ids, so a WhatsApp recipient added by phone number before
16
+ any chat was stored under it was never re-keyed to its LID, and the send guard refused it until it was added
17
+ again. `storedChats(ids, recipients?)` now takes, as an optional second argument, the recipient-list ids (chat and
18
+ partner) the store does not hold: typed ids an adapter maps from local data only, never looks up or records. The
19
+ re-key the messenger sends back moves the entry as before, with or without a chat row. An unreadable list is a
20
+ warning, and the store's ids are still handed over. Additive: the one-argument signature is unchanged.
21
+ - **A write by a stored chat's old id reaches the chat the messenger now names**. For a messenger whose
22
+ history is pushed (`history: "store"`), `resolve` answered a stored chat named by its exact id from the store and
23
+ never asked the adapter, so WhatsApp's phone-form id kept taking deletes, stars and votes while sends landed under
24
+ the LID: a deleted text stayed searchable. Such an id now goes to the adapter's `resolve`; an answer under another
25
+ id re-keys the stored chat to it (as a pushed `rekeys` entry does, recipient list included), within the account,
26
+ so one-shot commands close the split without a `serve`. An adapter `not_found` keeps the stored chat.
27
+ - **A sent message is saved in the chat the messenger answered, not the one typed**: `messages send`, `polls
28
+ create` and `messages forward` save the message, and its chat row, under the `chatId` of the message the adapter
29
+ returns. The id that was typed is used only when that `chatId` is empty. A chat row made for the answered id takes
30
+ the typed chat's title, kind, search visibility and scope; an answered chat that already exists takes the
31
+ stricter search visibility and scope of the two, by the same rule as a re-keyed chat. Rows of another account are
32
+ never read. `messages edit` keeps the message in the chat row that already holds it. When the two ids differ, the
33
+ send journal (`sends list`) of a send, a poll or a forward records the answered one as `result_chat_id` beside
34
+ `chat_id`, and the run record's `messages.send` response names the answered chat. The JSON answer already showed
35
+ the answered id. No new field: an adapter fills `Message.chatId` with the id its messenger answered
36
+ ([ADAPTERS.md](docs/dev/ADAPTERS.md#send-ids-and-an-unknown-outcome)). Per messenger: tg answers with Telegram's
37
+ own id for the same chat, so nothing changes; max echoes the typed id, so nothing changes; wa maps a known
38
+ person's phone-form id to their LID chat, so a send to a re-keyed person's old phone number no longer creates a
39
+ second, phone-form chat.
40
+ - **`chats leave` and `chats join` now update the store, for every messenger.** After a leave that succeeds, the chat
41
+ is marked left in the store, so `store clear --left` finds it; a leave that fails changes nothing. After a join
42
+ that gets in, the left mark comes off, and a chat stored as archived stays archived. A join that waits for an
43
+ admin's approval changes nothing. Only the running account's chat changes. When the store cannot record a leave or
44
+ join that went through, the error says it went through and not to run it again, keeps its operation id, and says
45
+ what corrects the store: for a join, an online `chats list` that shows the chat; for a leave, an online
46
+ `chats list --all` that names every chat, until which the chat may show as joined. Where the chat list is read from
47
+ the store, the messenger's own report of the change corrects it, and `chats show` says when it has.
48
+ - **`chats show` on a chat you left shows it**, with `membership_state: "left"`, and says that you left it and that
49
+ `<tool> <profile> store clear --left --dry-run` lists the chats you left. Where the history is read from the store,
50
+ it said "nothing stored for this chat yet — keep … serve running", which was wrong; `messages list` and `messages
51
+ context` of such a chat said the same and now answer with its stored messages, or, when it holds none, say that
52
+ you left it. `chats list` still leaves left chats out, and search still finds their messages.
53
+
54
+ ## 0.355.0 — 19.10.2026
55
+
56
+ ### Fixed
57
+
58
+ - **A failed scheduled run no longer keeps a chat title or a name in `last.json`** (privacy). Its `reason` was the
59
+ last line the run printed, which is the whole error object, message included: "no chat matches …" and the list of
60
+ chats or people an ambiguous name matches reached the file and the "scheduled … failing" notice. `last.json` now
61
+ keeps the error's `code` as the new `errorCode` and a `reason` of the code and its reason id, such as
62
+ `validation_error (input.invalid)`; a last line that is not an error object, such as an agent harness's, is kept
63
+ only as `exit <n>`. The full message stays on stderr and in the run's log, whose path `schedule list` now prints
64
+ after a failing schedule's reason. `last.json` is written atomically with mode 0600 even over an older file.
65
+ `reasonOf(stderr, exitCode?)` follows and always answers a string, and `recordRun` takes the optional `errorCode`.
66
+ A background `store` sync job's file likewise keeps only the error's code and names `store jobs show <id>`, which
67
+ prints the path of the job's log.
68
+
10
69
  ## 0.354.0 — 19.10.2026
11
70
 
12
71
  ### Fixed
@@ -25,15 +25,26 @@ export interface LastRun {
25
25
  output: string;
26
26
  /** Shared by every command the run started (`TRACEPARENT`), so `runs list` names them. */
27
27
  traceId?: string;
28
- /** Why a failed run failed, in one line: the last thing it said, not its whole output. */
28
+ /** Why a failed run failed, in one line: the error's code and reason id, never its message. */
29
29
  reason?: string;
30
+ /** The `code` of the error the run printed, as run records keep it. */
31
+ errorCode?: string;
30
32
  /** The first failure of the failures in a row, so "failing since" survives every retry. */
31
33
  failingSince?: string;
32
34
  /** Failed runs in a row; absent once a run succeeds. */
33
35
  failures?: number;
34
36
  }
35
- /** The last line a failed run printed, which is where a CLI says why it stopped. */
36
- export declare const reasonOf: (output: string) => string | undefined;
37
+ /**
38
+ * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
39
+ * message, which can name a chat or a person; the run's log keeps that. Any other line is another program's
40
+ * text and is not kept either.
41
+ */
42
+ export declare const failureOf: (stderr: string, exitCode: number) => {
43
+ reason: string;
44
+ errorCode?: string;
45
+ };
46
+ /** The one-line reason `failureOf` keeps, for a timer that reads a run's stderr itself. */
47
+ export declare const reasonOf: (stderr: string, exitCode?: number) => string;
37
48
  /**
38
49
  * Writes one run's result to `path`, carrying the failure streak over from the run before. Any CLI's own
39
50
  * timer can call this, so `failingNotice` reads every tool's background runs the same way.
@@ -44,6 +55,7 @@ export declare const recordRun: (path: string, run: {
44
55
  output: string;
45
56
  traceId?: string;
46
57
  reason?: string;
58
+ errorCode?: string;
47
59
  }) => LastRun;
48
60
  /**
49
61
  * One stderr line per failing background job, or nothing: `tg: scheduled digest failing since … (3 runs):
@@ -1,20 +1,30 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { CliError, writeSecurely } from "@wirecat/cli-base";
4
+ import { CliError, errorCodes, writeSecurely } from "@wirecat/cli-base";
5
5
  import { readOnlyLockOf } from "../ports/app.js";
6
6
  import { recordLogFor } from "../runs/record-log.js";
7
7
  import { childTrace } from "../runs/scope.js";
8
- const REASON_CHARS = 200;
9
- /** The last line a failed run printed, which is where a CLI says why it stopped. */
10
- export const reasonOf = (output) => {
11
- const line = output
12
- .split("\n")
13
- .map((text) => text.trim())
14
- .filter(Boolean)
15
- .at(-1);
16
- return line === undefined ? undefined : line.length > REASON_CHARS ? `${line.slice(0, REASON_CHARS - 1)}…` : line;
8
+ const REASON_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/;
9
+ /**
10
+ * Why a failed run failed, from the error object the CLI printed last: its code and reason id. Never the
11
+ * message, which can name a chat or a person; the run's log keeps that. Any other line is another program's
12
+ * text and is not kept either.
13
+ */
14
+ export const failureOf = (stderr, exitCode) => {
15
+ for (const line of stderr.split("\n").reverse()) {
16
+ try {
17
+ const { code, reason } = JSON.parse(line).error ?? {};
18
+ if (errorCodes.includes(code) && typeof reason === "string" && REASON_ID.test(reason)) {
19
+ return { reason: `${code} (${reason})`, errorCode: code };
20
+ }
21
+ }
22
+ catch { }
23
+ }
24
+ return { reason: `exit ${exitCode}` };
17
25
  };
26
+ /** The one-line reason `failureOf` keeps, for a timer that reads a run's stderr itself. */
27
+ export const reasonOf = (stderr, exitCode = 1) => failureOf(stderr, exitCode).reason;
18
28
  const readRun = (path) => {
19
29
  try {
20
30
  return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined;
@@ -29,17 +39,17 @@ const readRun = (path) => {
29
39
  */
30
40
  export const recordRun = (path, run) => {
31
41
  const before = readRun(path);
32
- const { reason, ...rest } = run;
42
+ const { reason, errorCode, ...rest } = run;
33
43
  const last = run.exitCode === 0
34
44
  ? rest
35
45
  : {
36
46
  ...rest,
37
47
  ...(reason === undefined ? {} : { reason }),
48
+ ...(errorCode === undefined ? {} : { errorCode }),
38
49
  failingSince: before?.exitCode ? (before.failingSince ?? before.at) : run.at,
39
50
  failures: before?.exitCode ? (before.failures ?? 1) + 1 : 1,
40
51
  };
41
- mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
42
- writeFileSync(path, `${JSON.stringify(last, null, 2)}\n`, { mode: 0o600 });
52
+ writeSecurely(path, `${JSON.stringify(last, null, 2)}\n`, 0o600);
43
53
  return last;
44
54
  };
45
55
  const shortTime = (iso) => {
@@ -371,13 +381,12 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
371
381
  exitCode,
372
382
  durationMs: ended.getTime() - began,
373
383
  });
374
- const said = exitCode === 0 ? undefined : reasonOf(readFileSync(errors, "utf8"));
375
384
  const last = recordRun(join(folder, "last.json"), {
376
385
  at: ended.toISOString(),
377
386
  exitCode,
378
387
  output,
379
388
  traceId: trace.traceId,
380
- ...(said === undefined ? {} : { reason: said }),
389
+ ...(exitCode === 0 ? {} : failureOf(readFileSync(errors, "utf8"), exitCode)),
381
390
  });
382
391
  if (agent && exitCode === 0)
383
392
  copyFileSync(output, join(folder, "latest.md"));
@@ -175,7 +175,8 @@ export const syncCommand = (messenger) => {
175
175
  catch (error) {
176
176
  if (jobId) {
177
177
  const code = isCliFailure(error) ? error.code : "generic_failure";
178
- const message = error instanceof Error ? error.message : String(error);
178
+ // The job file outlives the run; the message can name a chat, so only the job's log keeps it.
179
+ const message = `${code} — \`store jobs show ${jobId}\` names the log with the whole error`;
179
180
  finishJob(messenger.app, jobs, jobId, { finishedAt: new Date().toISOString(), error: { code, message } });
180
181
  }
181
182
  throw error;
@@ -1,5 +1,6 @@
1
1
  import { annotate } from "@wirecat/cli-base/commands";
2
2
  import { Command } from "commander";
3
+ import { typedCommand } from "../../runs/scope.js";
3
4
  import { EVENTS_DAYS } from "../../services/chats.js";
4
5
  import { LOCAL_READ, REMOTE_READ } from "../effects.js";
5
6
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
@@ -61,6 +62,8 @@ export const chatsCommand = (messenger) => {
61
62
  account: await services.chats.account(),
62
63
  }));
63
64
  renderAnswer(this, context, chatShown(account, card));
65
+ if (card.membershipState === "left")
66
+ context.renderer.note(`you left this chat; its stored messages are kept — \`${typedCommand(messenger.app.command, "store clear --left --dry-run", context.settings.profile)}\` lists the chats you left and deletes nothing`);
64
67
  }));
65
68
  if (messenger.sendAs !== false)
66
69
  annotate(chats.command("send-as"), { effects: REMOTE_READ, offline: false })
@@ -206,6 +206,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
206
206
  announcing,
207
207
  ...(deletedWithoutChat ? { deletedWithoutChat } : {}),
208
208
  rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
209
+ recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
209
210
  ...(history === "store" ? { namesFromStore: savedChatId ? { savedChatId } : {} } : {}),
210
211
  });
211
212
  const passives = history === "store" && self !== null && !watching
@@ -81,7 +81,7 @@ export const observed = (messenger, events) => {
81
81
  ...(options.replyTo ? { reply: options.replyTo } : {}),
82
82
  ...(options.threadId ? { thread: options.threadId } : {}),
83
83
  },
84
- }, () => messenger.send(chatId, text, options), (sent) => ({ ids: { message_id: sent.message.id } })),
84
+ }, () => messenger.send(chatId, text, options), (sent) => ({ ids: { chat: sent.message.chatId || chatId, message_id: sent.message.id } })),
85
85
  ...(watch
86
86
  ? {
87
87
  watch: (onEvent, signal, onReady) => counted("messages.watch", onEvent, (counting) => watch.call(messenger, counting, signal, onReady), byEvent),
@@ -185,7 +185,7 @@ export const scheduleCommand = (messenger) => {
185
185
  ? `last ${last.at.slice(0, 16).replace("T", " ")} exit ${last.exitCode}` +
186
186
  (last.failures
187
187
  ? ` — failing since ${(last.failingSince ?? last.at).slice(0, 16).replace("T", " ")}, ` +
188
- `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""}`
188
+ `${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
189
189
  : "")
190
190
  : "not run yet",
191
191
  ].join(" "))
@@ -21,6 +21,12 @@ export interface Saving {
21
21
  };
22
22
  /** After a re-key is saved: what outside the store spells the chat id follows it. */
23
23
  rekeyed?: (from: Id, to: Id) => void | Promise<void>;
24
+ /**
25
+ * Chat and partner ids this account's recipient list names, told to `storedChats` apart from the stored ones: an
26
+ * entry saved before any chat was stored under its id would otherwise never be re-keyed, and the send guard would
27
+ * refuse it.
28
+ */
29
+ recipientIds?: () => Id[];
24
30
  /**
25
31
  * Set for a messenger whose history is pushed into the store (`history: "store"`): chat and person names, and the
26
32
  * titles `watch` leaves out, are answered from the store first. Its server has no directory to ask.
@@ -35,4 +41,4 @@ export interface Saving {
35
41
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
36
42
  * known to be whole, and `saveMembers` replaces the list.
37
43
  */
38
- export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore }: Saving) => MessengerAdapter;
44
+ export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
@@ -11,7 +11,7 @@ import { chatIdIn } from "../../services/message-resolution.js";
11
11
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
12
12
  * known to be whole, and `saveMembers` replaces the list.
13
13
  */
14
- export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore }) => {
14
+ export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, namesFromStore, recipientIds, }) => {
15
15
  let warned = false;
16
16
  let recorded = profile === undefined;
17
17
  const attempt = async (operation, write) => {
@@ -40,6 +40,29 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
40
40
  return saving.finally(() => pending?.delete(saving));
41
41
  };
42
42
  const { chats, history, around, people } = messenger;
43
+ /**
44
+ * A chat stored under an id the messenger no longer names it by (WhatsApp: a phone-form chat whose LID it now knows)
45
+ * would take every write by that id, while what the messenger answers lands under the new one. So a stored id still
46
+ * goes to the messenger, and an answer under another id re-keys the chat: a one-shot command closes the split too.
47
+ */
48
+ const followed = async (opened, scoped, held) => {
49
+ let answered;
50
+ try {
51
+ answered = await messenger.resolve(held.id);
52
+ }
53
+ catch (error) {
54
+ if (codeOf(error) === "not_found")
55
+ return undefined;
56
+ throw error;
57
+ }
58
+ if (answered.id === held.id)
59
+ return undefined;
60
+ await attempt("history.rekey", async (store) => {
61
+ await store.chats.rekeyChat(scoped, held.id, answered.id);
62
+ await rekeyed?.(held.id, answered.id);
63
+ });
64
+ return (await storedChat(opened, scoped, namesFromStore ?? {}, answered.id)).held ?? answered;
65
+ };
43
66
  const titles = new Map();
44
67
  const storedPeople = async () => {
45
68
  try {
@@ -150,6 +173,8 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
150
173
  return remote ?? (namesFromStore ? chats[0] : messenger.resolve(chats[0].id));
151
174
  }
152
175
  }
176
+ if (held && held.id === reference.trim())
177
+ return (await followed(opened, scoped, held)) ?? held;
153
178
  return held ?? messenger.resolve(id ?? reference);
154
179
  },
155
180
  chat: (reference) => messenger.chat(reference),
@@ -248,7 +273,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
248
273
  ? {
249
274
  feed: (onBatch, signal) => {
250
275
  if (messenger.storedChats) {
251
- const announced = announceStoredChats(messenger, store, account, events, warn);
276
+ const announced = announceStoredChats(messenger, store, account, events, warn, recipientIds);
252
277
  announcing?.add(announced);
253
278
  void announced.finally(() => announcing?.delete(announced));
254
279
  }
@@ -266,7 +291,10 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
266
291
  edit: async (chatId, messageId, text, options) => {
267
292
  const seenAt = Date.now();
268
293
  const edited = await capability(messenger, "edit", "edit")(chatId, messageId, text, options);
269
- await save("messages.edit", (opened) => opened.messages.saveMessages(account, chatId, [edited], { via: "update", seenAt }));
294
+ await save("messages.edit", async (opened) => {
295
+ const held = (await opened.messages.message(account, messageId, { chatId })) !== undefined;
296
+ await saveSent(opened, account, chatId, edited, { via: "update", seenAt }, held ? chatId : undefined);
297
+ });
270
298
  return edited;
271
299
  },
272
300
  }
@@ -276,7 +304,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
276
304
  // A messenger that never echoes a device's own message back would otherwise never hold the poll.
277
305
  createPoll: async (chatId, poll, options) => {
278
306
  const sent = await capability(messenger, "createPoll", "create a poll")(chatId, poll, options);
279
- await save("polls.create", (opened) => opened.messages.saveMessages(account, chatId, [sent.message], { via: "send" }));
307
+ await save("polls.create", (opened) => saveSent(opened, account, chatId, sent.message, { via: "send" }));
280
308
  return sent;
281
309
  },
282
310
  }
@@ -308,7 +336,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
308
336
  ? {
309
337
  forward: async (fromChatId, messageId, toChatId, options) => {
310
338
  const copy = await capability(messenger, "forward", "forward")(fromChatId, messageId, toChatId, options);
311
- await save("messages.forward", (opened) => opened.messages.saveMessages(account, toChatId, [copy], { via: "send" }));
339
+ await save("messages.forward", (opened) => saveSent(opened, account, toChatId, copy, { via: "send" }));
312
340
  return copy;
313
341
  },
314
342
  }
@@ -326,12 +354,18 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
326
354
  const sent = await messenger.send(chatId, text, options);
327
355
  // A scheduled message is not in the chat yet, and it will arrive under another id.
328
356
  if (options.at === undefined) {
329
- await save("messages.send", (opened) => opened.messages.saveMessages(account, chatId, [sent.message], { via: "send" }));
357
+ await save("messages.send", (opened) => saveSent(opened, account, chatId, sent.message, { via: "send" }));
330
358
  }
331
359
  return sent;
332
360
  },
333
361
  });
334
362
  };
363
+ /**
364
+ * A write is saved under the chat the messenger answered, not the one typed: WhatsApp answers a re-keyed person's
365
+ * phone-form id with the LID chat, and saving under the typed id would split the chat until the next re-key.
366
+ * `into` overrides it for an edit, whose message stays in the row that already holds it.
367
+ */
368
+ const saveSent = (opened, account, typed, message, options, into = message.chatId || typed) => opened.messages.saveMessages(account, into, [message], { ...options, typedChatId: typed });
335
369
  /**
336
370
  * The chat id a name or id names, as the store reads it, and the stored chat when there is one. Neither when the
337
371
  * store knows no such name; an error when several stored chats match it.
@@ -358,13 +392,22 @@ const personIn = (people, reference) => {
358
392
  throw error;
359
393
  }
360
394
  };
361
- const announceStoredChats = async (messenger, store, account, events, warn) => {
395
+ const announceStoredChats = async (messenger, store, account, events, warn, recipientIds) => {
396
+ let listed = [];
397
+ try {
398
+ listed = recipientIds?.() ?? [];
399
+ }
400
+ catch (error) {
401
+ warn(`the messenger was not told the recipient list: ${error instanceof Error ? error.message : String(error)}`);
402
+ }
362
403
  try {
363
404
  const opened = await store();
364
- if (!opened)
365
- return;
366
- const ids = (await opened.forAccount(account).chats.chats({})).items.map((chat) => chat.id);
367
- await messenger.storedChats?.(ids);
405
+ const held = opened ? (await opened.forAccount(account).chats.chats({})).items.map((chat) => chat.id) : [];
406
+ const recipients = [...new Set(listed)].filter((id) => !held.includes(id));
407
+ if (recipients.length > 0)
408
+ await messenger.storedChats?.(held, recipients);
409
+ else if (opened)
410
+ await messenger.storedChats?.(held);
368
411
  }
369
412
  catch (error) {
370
413
  events({ event: "warning", code: "stored_chats_failed", operation: "history.stored_chats" });
@@ -367,9 +367,11 @@ export interface PushedHistory {
367
367
  feed(onBatch: (batch: HistoryBatch) => void, signal: AbortSignal): Promise<void>;
368
368
  /**
369
369
  * Every chat id the store holds for this account, handed over once as `feed` starts, so a chat nothing names again
370
- * can still be re-keyed through a later batch's `rekeys`. A reject is a warning; `feed` goes on.
370
+ * can still be re-keyed through a later batch's `rekeys`. `recipients`, when given, are ids the profile's recipient
371
+ * list names and the store does not hold: typed by the owner, to be mapped from local data only, never looked up
372
+ * over the network or recorded. A reject is a warning; `feed` goes on.
371
373
  */
372
- storedChats?(ids: readonly Id[]): void | Promise<void>;
374
+ storedChats?(ids: readonly Id[], recipients?: readonly Id[]): void | Promise<void>;
373
375
  }
374
376
  /** A message's files and its speech, fetched from the messenger. */
375
377
  export interface MessageMedia {
@@ -9,6 +9,11 @@ type Attempt = Omit<SendEntry, "at" | "profile" | "outcome"> & {
9
9
  key?: PermissionKey;
10
10
  personIds?: Id[];
11
11
  };
12
+ /** The sent message's id, and the chat it landed in when the messenger answered another id than the one typed. */
13
+ export declare const sentIds: (typed: Id, message: {
14
+ id: Id;
15
+ chatId: Id;
16
+ }) => Pick<Attempt, "messageId" | "resultChatId">;
12
17
  /** A write's answer, with the id its journal lines and run events carry. */
13
18
  export type Operated<T> = T & {
14
19
  operationId: string;
@@ -1,5 +1,10 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { CliError, isCliError } from "@wirecat/cli-base";
3
+ /** The sent message's id, and the chat it landed in when the messenger answered another id than the one typed. */
4
+ export const sentIds = (typed, message) => ({
5
+ messageId: message.id,
6
+ ...(message.chatId && message.chatId !== typed ? { resultChatId: message.chatId } : {}),
7
+ });
3
8
  const current = new AsyncLocalStorage();
4
9
  /**
5
10
  * The writes in flight inside one command's deadline (`withDeadline`). A deadline that ends the
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { threadIdOf } from "../domain/thread.js";
3
3
  import { capability } from "../ports/messenger.js";
4
- import { guardedWrite } from "./guarded.js";
4
+ import { guardedWrite, sentIds } from "./guarded.js";
5
5
  import { sendAsCheck } from "./send-as.js";
6
6
  import { newOperationId, newSendId } from "./send-id.js";
7
7
  /** The poll is read first: nobody, the owner included, may see who voted in an anonymous one. */
@@ -65,7 +65,7 @@ export const guardedCreatePoll = async (guard, connection, { chat, poll, silent,
65
65
  ...(silent ? { silent } : {}),
66
66
  ...(threadId === undefined ? {} : { threadId }),
67
67
  ...(sendAs === undefined ? {} : { sendAs }),
68
- }), (done) => ({ messageId: done.message.id }), validate === undefined || threadId === undefined ? undefined : () => validate(chatId, threadId, {}));
68
+ }), (done) => sentIds(chatId, done.message), validate === undefined || threadId === undefined ? undefined : () => validate(chatId, threadId, {}));
69
69
  return { ...sent, operationId: id };
70
70
  };
71
71
  /** `--quiz --correct <n> --solution <text>` as typed, `n` from 1; refused when they do not go together. */
@@ -1,6 +1,6 @@
1
1
  import type { AdminRight, GroupCard, GroupChange, Id, InviteLink, JoinRequest, Page } from "../domain/models.js";
2
2
  import { type Operated } from "../sends/guarded.js";
3
- import type { ServiceDeps } from "./deps.js";
3
+ import { type ServiceDeps } from "./deps.js";
4
4
  export interface NewGroup {
5
5
  title: string;
6
6
  /** As typed: ids, handles or names. */
@@ -1,8 +1,28 @@
1
- import { CliError } from "@wirecat/cli-base";
1
+ import { CliError, isCliError } from "@wirecat/cli-base";
2
2
  import { sendTime } from "../domain/send-time.js";
3
3
  import { capability } from "../ports/messenger.js";
4
+ import { typedCommand } from "../runs/scope.js";
4
5
  import { guardedWrite } from "../sends/guarded.js";
5
6
  import { newOperationId } from "../sends/send-id.js";
7
+ import { accountStoreIfOpen } from "./deps.js";
8
+ /**
9
+ * The store's record of a leave or join the messenger already carried out. A failure says it happened and is not
10
+ * safe to repeat: the messenger refuses a second leave, and a second join may wait for an admin.
11
+ */
12
+ const recordMembership = async (deps, operationId, done, change, fix, write) => {
13
+ try {
14
+ const held = await accountStoreIfOpen(deps);
15
+ if (held)
16
+ await write(held);
17
+ }
18
+ catch (error) {
19
+ const reason = error instanceof Error ? error.message : String(error);
20
+ throw new CliError(isCliError(error) ? error.code : "invalid_response", `${done}, but the local store could not ${change} (${reason}) — do not run it again; ${fix}`, { operation_id: operationId, retryable: false });
21
+ }
22
+ };
23
+ /** Where the chat list is read from the store, only the messenger's own report of the change corrects it. */
24
+ const reportedFix = (deps, chatId, shows, action) => `it may show as ${shows} until the messenger reports the ${action}; ` +
25
+ `\`${typedCommand(deps.messenger.app.command, `chats show ${chatId}`)}\` says when it has`;
6
26
  export const adminService = (deps) => {
7
27
  const online = async (command) => {
8
28
  if (deps.offline)
@@ -25,14 +45,30 @@ export const adminService = (deps) => {
25
45
  const join = capability(connection, "join", "join a chat by its link");
26
46
  const operationId = newOperationId();
27
47
  const joined = await guardedWrite(deps.guard, { operationId, chatId: null, kind: "chat", action: "join" }, () => join(link.trim()), (answer) => ("requested" in answer ? {} : { chatId: answer.id }));
28
- return "requested" in joined ? { operationId, requested: true } : { operationId, chat: joined };
48
+ if ("requested" in joined)
49
+ return { operationId, requested: true };
50
+ // Every online chat list page takes the left mark off the chats it names, complete or not.
51
+ const fix = deps.reads === "store"
52
+ ? reportedFix(deps, joined.id, "left", "join")
53
+ : `an online \`${typedCommand(deps.messenger.app.command, "chats list")}\` that shows the chat corrects it`;
54
+ await recordMembership(deps, operationId, `you joined chat ${joined.id}`, "take its left mark off", fix, (held) => held.chats.rejoinChats([joined.id]));
55
+ return { operationId, chat: joined };
29
56
  },
30
57
  leave: async (chat) => {
31
58
  const connection = await online("chats leave");
32
59
  const leave = capability(connection, "leave", "leave a chat");
33
- const { id: chatId } = await connection.resolve(chat);
60
+ const { id: chatId, kind } = await connection.resolve(chat);
34
61
  const operationId = newOperationId();
35
62
  await guardedWrite(deps.guard, { operationId, chatId, kind: "chat", action: "leave" }, () => leave(chatId));
63
+ // Only a chat list that names every chat marks the others left, and a messenger may never answer with all of them.
64
+ const fix = deps.reads === "store"
65
+ ? reportedFix(deps, chatId, "joined", "leave")
66
+ : `it may show as joined until an online \`${typedCommand(deps.messenger.app.command, "chats list --all")}\` names every chat`;
67
+ await recordMembership(deps, operationId, `you left chat ${chatId}`, "mark it left", fix, async (held) =>
68
+ // A save always writes the kind, and a resolve may know less of it than the store does.
69
+ held.chats.saveChats([
70
+ { id: chatId, kind: (await held.chats.chatKind(chatId)) ?? kind, membershipState: "left" },
71
+ ]));
36
72
  return { operationId, chatId };
37
73
  },
38
74
  deleteChat: async (chat) => {
@@ -68,10 +68,10 @@ export const chatsService = (deps) => ({
68
68
  const store = await accountStore(deps);
69
69
  const pushed = deps.reads === "store";
70
70
  const missing = (id) => new CliError("not_found", pushed ? nothingStored(deps.messenger) : `no stored chat ${id}`);
71
- const id = await chatIdIn(deps.messenger, chat, store).catch((error) => {
71
+ const id = await chatIdIn(deps.messenger, chat, store, { includeLeft: true }).catch((error) => {
72
72
  throw pushed && codeOf(error) === "not_found" ? missing(chat) : error;
73
73
  });
74
- const found = (await store.chats.chats({})).items.find((one) => one.id === id);
74
+ const found = (await store.chats.chats({ includeLeft: true })).items.find((one) => one.id === id);
75
75
  if (!found)
76
76
  throw missing(id);
77
77
  return { ...found, members: await storedMembers(store, found.id) };
@@ -12,8 +12,10 @@ export type Saved = Partial<Pick<MessengerTraits, "savedChatId" | "app">>;
12
12
  export declare const readChatId: (deps: ServiceDeps, reference: string, store: MessageStore, account: AccountKey) => Promise<string>;
13
13
  /** A chat as typed, found among the stored chats the way an adapter finds it among its own. */
14
14
  export declare const storedChatId: (messenger: Saved, reference: string, store: MessageStore, account: AccountKey) => Promise<string>;
15
- /** `storedChatId`, among the chats of the account the store is bound to. */
16
- export declare const chatIdIn: (messenger: Saved, reference: string, store: AccountStore) => Promise<string>;
15
+ /** `storedChatId`, among the chats of the account the store is bound to; `includeLeft` tries the left ones last. */
16
+ export declare const chatIdIn: (messenger: Saved, reference: string, store: AccountStore, { includeLeft }?: {
17
+ includeLeft?: boolean;
18
+ }) => Promise<string>;
17
19
  /** A chat as `--chat` names it, in whichever of the accounts holds it — one of them, or it is an error. */
18
20
  export declare const chatAmong: (messenger: Saved, store: MessageStore, accounts: AccountKey[], reference: string) => Promise<{
19
21
  account: AccountKey;
@@ -8,21 +8,39 @@ import { nothingStored } from "./deps.js";
8
8
  */
9
9
  export const readChatId = async (deps, reference, store, account) => {
10
10
  if (deps.reads !== "store")
11
- return storedChatId(deps.messenger, reference, store, account);
12
- const chatId = await storedChatId(deps.messenger, reference, store, account).catch((error) => {
11
+ return keptChatId(deps.messenger, reference, store, account);
12
+ const chatId = await keptChatId(deps.messenger, reference, store, account).catch((error) => {
13
13
  if (codeOf(error) === "not_found")
14
14
  throw new CliError("not_found", nothingStored(deps.messenger));
15
15
  throw error;
16
16
  });
17
17
  if ((await store.messages.countMessages(account, chatId)) === 0) {
18
- throw new CliError("not_found", nothingStored(deps.messenger), { chat: chatId });
18
+ const all = (await store.chats.chats(account, { includeLeft: true })).items;
19
+ const left = all.some((one) => one.id === chatId && one.membershipState === "left");
20
+ throw new CliError("not_found", left ? LEFT_EMPTY : nothingStored(deps.messenger), { chat: chatId });
19
21
  }
20
22
  return chatId;
21
23
  };
24
+ const LEFT_EMPTY = "you left this chat, and the store holds none of its messages";
22
25
  /** A chat as typed, found among the stored chats the way an adapter finds it among its own. */
23
26
  export const storedChatId = async (messenger, reference, store, account) => chatIdAmong(messenger, reference, account, () => store.chats.chats(account, {}));
24
- /** `storedChatId`, among the chats of the account the store is bound to. */
25
- export const chatIdIn = (messenger, reference, store) => chatIdAmong(messenger, reference, store.account, () => store.chats.chats({}));
27
+ /** `storedChatId`, then among the chats the account left: their history is kept, so a read of it still finds them. */
28
+ const keptChatId = (messenger, reference, store, account) => storedChatId(messenger, reference, store, account).catch((error) => {
29
+ if (codeOf(error) !== "not_found")
30
+ throw error;
31
+ return chatIdAmong(messenger, reference, account, () => store.chats.chats(account, { includeLeft: true }));
32
+ });
33
+ /** `storedChatId`, among the chats of the account the store is bound to; `includeLeft` tries the left ones last. */
34
+ export const chatIdIn = (messenger, reference, store, { includeLeft = false } = {}) => {
35
+ const among = chatIdAmong(messenger, reference, store.account, () => store.chats.chats({}));
36
+ if (!includeLeft)
37
+ return among;
38
+ return among.catch((error) => {
39
+ if (codeOf(error) !== "not_found")
40
+ throw error;
41
+ return chatIdAmong(messenger, reference, store.account, () => store.chats.chats({ includeLeft: true }));
42
+ });
43
+ };
26
44
  const chatIdAmong = async (messenger, reference, account, held) => {
27
45
  const trimmed = reference.trim();
28
46
  if (messenger.savedChatId && ["me", "self", "saved"].includes(trimmed.toLowerCase())) {
@@ -5,7 +5,7 @@ import { messageLinkTarget, validatePermalink } from "../domain/message-link.js"
5
5
  import { pinDurationsText } from "../domain/pin-duration.js";
6
6
  import { threadIdOf } from "../domain/thread.js";
7
7
  import { capability } from "../ports/messenger.js";
8
- import { codeOf, guardedWrite } from "../sends/guarded.js";
8
+ import { codeOf, guardedWrite, sentIds } from "../sends/guarded.js";
9
9
  import { sendAsCheck } from "../sends/send-as.js";
10
10
  import { newOperationId, newSendId } from "../sends/send-id.js";
11
11
  import { uploadSize } from "../sends/upload.js";
@@ -314,7 +314,7 @@ export const messagesService = (deps) => {
314
314
  ...(stickerFile === undefined ? {} : { stickerFile }),
315
315
  ...media,
316
316
  ...(sendAs === undefined ? {} : { sendAs }),
317
- }), (sent) => ({ messageId: sent.message.id }), validate === undefined || threadId === undefined
317
+ }), (sent) => sentIds(chatId, sent.message), validate === undefined || threadId === undefined
318
318
  ? undefined
319
319
  : () => validate(chatId, threadId, { ...(replyTo === undefined ? {} : { replyTo }) }));
320
320
  return { ...done, operationId: id };
@@ -374,7 +374,7 @@ export const messagesService = (deps) => {
374
374
  ...(silent ? { silent } : {}),
375
375
  ...(sendAs === undefined ? {} : { sendAs }),
376
376
  ...(threadId === undefined ? {} : { threadId }),
377
- }), (done) => ({ messageId: done.id }), validate === undefined || threadId === undefined ? undefined : () => validate(toChatId, threadId, {}));
377
+ }), (done) => sentIds(toChatId, done), validate === undefined || threadId === undefined ? undefined : () => validate(toChatId, threadId, {}));
378
378
  return { operationId: id, sendId: id, message: forwarded };
379
379
  },
380
380
  pin: ({ notify, duration, ...target }) => pinning(target, true, notify, duration),
@@ -8,6 +8,7 @@ export declare const keysOver: (context: StoreContext) => {
8
8
  findChatPk: (accountKey: number, chatId: Id) => number | undefined;
9
9
  chatKeyOf: (key: AccountKey, chatId: Id) => number | undefined;
10
10
  chatPkFor: (accountKey: number, chatId: Id) => number;
11
+ answeredChatPk: (accountKey: number, chatId: Id, typed: Id) => number;
11
12
  identityPk: (accountKey: number, provider: Provider, nativeId: Id, name: string | null, facts?: Omit<PersonFacts, "id" | "name">) => number;
12
13
  upsertMessage: (key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => void;
13
14
  writeMembers: (key: AccountKey, accountKey: number, chatId: Id, memberIds: Id[]) => void;
@@ -16,6 +16,7 @@ export const keysOver = (context) => {
16
16
  return accountKey === undefined ? undefined : findChatPk(accountKey, chatId);
17
17
  },
18
18
  chatPkFor: (accountKey, chatId) => chatQueries.chatPkFor(context, accountKey, chatId),
19
+ answeredChatPk: (accountKey, chatId, typed) => chatQueries.answeredChatPk(context, accountKey, chatId, typed),
19
20
  identityPk: (accountKey, provider, nativeId, name, facts) => identities.identityPk(context, accountKey, provider, nativeId, name, facts),
20
21
  upsertMessage: (key, accountKey, chatKey, message, via, seenAt) => messageWrites.upsertMessage(context, key, accountKey, chatKey, message, via, seenAt),
21
22
  writeMembers: (key, accountKey, chatId, memberIds) => chatQueries.writeMembers(context, key, accountKey, chatId, memberIds),
@@ -11,6 +11,9 @@ export interface AccountMessages {
11
11
  * tombstone older than that is lifted. A tombstone set after it stays — the deletion is newer.
12
12
  *
13
13
  * `votes` and `stars` are saved after the messages in the same transaction, as `saveVote` and `saveStar` save each.
14
+ *
15
+ * `typedChatId` is the id the owner wrote to when the messenger answered `chatId`: a chat row made for `chatId`
16
+ * takes the typed row's title, kind and privacy, and is never more visible than it.
14
17
  */
15
18
  saveMessages(key: AccountKey, chatId: Id, messages: Message[], options: {
16
19
  via: IngestedVia;
@@ -23,6 +26,7 @@ export interface AccountMessages {
23
26
  messageId: Id;
24
27
  starred: boolean;
25
28
  }[];
29
+ typedChatId?: Id;
26
30
  }): Promise<void>;
27
31
  /** Oldest to newest, like a provider's history page. */
28
32
  /**
@@ -9,11 +9,11 @@ import { write } from "./sqlite/transaction.js";
9
9
  import * as transcripts from "./sqlite/transcripts.js";
10
10
  export const accountMessagesOver = (context) => {
11
11
  const { database } = context;
12
- const { accountPk, findAccountPk, findChatPk, chatKeyOf, chatPkFor, upsertMessage } = keysOver(context);
12
+ const { accountPk, findAccountPk, findChatPk, chatKeyOf, chatPkFor, answeredChatPk, upsertMessage } = keysOver(context);
13
13
  return {
14
- saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], stars = [] }) => write(database, () => {
14
+ saveMessages: async (key, chatId, messages, { via, seenAt, votes = [], stars = [], typedChatId }) => write(database, () => {
15
15
  const accountKey = accountPk(key);
16
- const chatKey = chatPkFor(accountKey, chatId);
16
+ const chatKey = typedChatId === undefined ? chatPkFor(accountKey, chatId) : answeredChatPk(accountKey, chatId, typedChatId);
17
17
  for (const message of messages) {
18
18
  if (message.scheduledFor === undefined)
19
19
  upsertMessage(key, accountKey, chatKey, message, via, seenAt);
@@ -1,6 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator } from "../../domain/locator.js";
3
- import { purgeChats } from "./chats.js";
3
+ import { purgeChats, stricterPrivacy } from "./chats.js";
4
4
  import { pointerPairs } from "./entity-types.js";
5
5
  import { tombstone } from "./messages.js";
6
6
  import { targetKey } from "./notes.js";
@@ -143,24 +143,10 @@ const latest = (a, b) => {
143
143
  const [one, other] = [time(a), time(b)];
144
144
  return one === null ? other : other === null ? one : Math.max(one, other);
145
145
  };
146
- /**
147
- * The owner's word on a chat must not loosen because a stray twin merged in: hidden wins, and when the two chats sit
148
- * on different sides of the owner's life, the personal side wins, since a work chat reaches work views and meeting
149
- * preparation a personal one does not. A scope either half pinned stays pinned, so a later change to the account's
150
- * default cannot loosen the merged chat.
151
- */
152
- const strictest = (context, accountKey, old, twin) => {
153
- const account = context.database.prepare("SELECT scope FROM accounts WHERE id = ?").get(accountKey);
154
- const effective = (row) => scalar(row.scope) ?? scalar(account?.scope);
155
- return {
156
- searchable: Math.min(Number(old.searchable), Number(twin.searchable)),
157
- scope: scalar(old.scope) === null && scalar(twin.scope) === null
158
- ? null
159
- : effective(old) === effective(twin)
160
- ? effective(old)
161
- : "personal",
162
- };
163
- };
146
+ const privacyOf = (row) => ({
147
+ searchable: Number(row.searchable),
148
+ scope: typeof row.scope === "string" ? row.scope : null,
149
+ });
164
150
  const MESSENGER_FIELDS = [
165
151
  "title",
166
152
  "unread_count",
@@ -184,7 +170,7 @@ export const rekeyChat = (context, key, accountKey, from, to) => {
184
170
  if (twin) {
185
171
  if (old.kind !== twin.kind && old.kind !== "unknown" && twin.kind !== "unknown")
186
172
  throw new CliError("validation_error", `a ${String(old.kind)} and a ${String(twin.kind)} are not one chat`);
187
- const strict = strictest(context, accountKey, old, twin);
173
+ const strict = stricterPrivacy(context, accountKey, privacyOf(old), privacyOf(twin));
188
174
  moved = absorb(context, kept, Number(twin.id));
189
175
  const fields = MESSENGER_FIELDS.map((field) => `${field} = coalesce(?, ${field})`).join(", ");
190
176
  context.database
@@ -7,6 +7,22 @@ export declare const chatOf: ({ orm }: StoreContext, accountKey: number, chatKey
7
7
  export declare const findChatPk: ({ orm }: StoreContext, accountKey: number, chatId: Id) => number | undefined;
8
8
  /** A chat known only from its messages, until the chat list names it. */
9
9
  export declare const chatPkFor: (context: StoreContext, accountKey: number, chatId: Id) => number;
10
+ interface ChatPrivacy {
11
+ searchable: number;
12
+ scope: string | null;
13
+ }
14
+ /**
15
+ * The owner's word on a chat must not loosen because another row joins it: hidden wins, and when the two chats sit
16
+ * on different sides of the owner's life, the personal side wins, since a work chat reaches work views and meeting
17
+ * preparation a personal one does not. Scopes compare as every reader applies them, the row's or else the account's.
18
+ * A scope either row pinned stays pinned, so a later change to the account's default cannot loosen the chat.
19
+ */
20
+ export declare const stricterPrivacy: ({ database }: StoreContext, accountKey: number, kept: ChatPrivacy, other: ChatPrivacy) => ChatPrivacy;
21
+ /**
22
+ * The row a send to `typed` lands in when the messenger answered `chatId`. A new row takes the typed row's title,
23
+ * kind and privacy; an existing one takes the stricter privacy of the two. Neither ever becomes more visible.
24
+ */
25
+ export declare const answeredChatPk: (context: StoreContext, accountKey: number, chatId: Id, typed: Id) => number;
10
26
  export declare const upsertChat: (context: StoreContext, accountKey: number, chat: ChatUpdate) => void;
11
27
  /** Replaces who is in the chat, whole. */
12
28
  export declare const writeMembers: (context: StoreContext, key: AccountKey, accountKey: number, chatId: Id, memberIds: Id[]) => void;
@@ -83,3 +99,4 @@ export declare const metadata: (context: StoreContext, key: AccountKey, chatId:
83
99
  export declare const saveMetadata: (context: StoreContext, key: AccountKey, entry: Omit<ChatMetadata, "fetchedAt">) => ChatMetadata;
84
100
  /** Called inside the store's transaction: claims and effective search tags change together. */
85
101
  export declare const replaceAutoTags: (context: StoreContext, key: AccountKey, chatId: string, algorithm: string, matches: ChannelTagMatch[]) => void;
102
+ export {};
@@ -29,6 +29,60 @@ export const chatPkFor = (context, accountKey, chatId) => findChatPk(context, ac
29
29
  })
30
30
  .returning({ pk: chats.id })
31
31
  .get()?.pk);
32
+ /**
33
+ * The owner's word on a chat must not loosen because another row joins it: hidden wins, and when the two chats sit
34
+ * on different sides of the owner's life, the personal side wins, since a work chat reaches work views and meeting
35
+ * preparation a personal one does not. Scopes compare as every reader applies them, the row's or else the account's.
36
+ * A scope either row pinned stays pinned, so a later change to the account's default cannot loosen the chat.
37
+ */
38
+ export const stricterPrivacy = ({ database }, accountKey, kept, other) => {
39
+ const account = database.prepare("SELECT scope FROM accounts WHERE id = ?").get(accountKey);
40
+ const effective = ({ scope }) => scope ?? (typeof account?.scope === "string" ? account.scope : null);
41
+ return {
42
+ searchable: Math.min(kept.searchable, other.searchable),
43
+ scope: kept.scope === null && other.scope === null
44
+ ? null
45
+ : effective(kept) === effective(other)
46
+ ? effective(kept)
47
+ : "personal",
48
+ };
49
+ };
50
+ /**
51
+ * The row a send to `typed` lands in when the messenger answered `chatId`. A new row takes the typed row's title,
52
+ * kind and privacy; an existing one takes the stricter privacy of the two. Neither ever becomes more visible.
53
+ */
54
+ export const answeredChatPk = (context, accountKey, chatId, typed) => {
55
+ const { orm, now } = context;
56
+ const like = typed === chatId
57
+ ? undefined
58
+ : orm
59
+ .select({ kind: chats.kind, title: chats.title, searchable: chats.searchable, scope: chats.scope })
60
+ .from(chats)
61
+ .where(and(eq(chats.accountId, accountKey), eq(chats.externalId, typed)))
62
+ .get();
63
+ if (like === undefined)
64
+ return chatPkFor(context, accountKey, chatId);
65
+ const found = orm
66
+ .select({ pk: chats.id, searchable: chats.searchable, scope: chats.scope })
67
+ .from(chats)
68
+ .where(and(eq(chats.accountId, accountKey), eq(chats.externalId, chatId)))
69
+ .get();
70
+ if (found !== undefined) {
71
+ const strict = stricterPrivacy(context, accountKey, found, like);
72
+ if (strict.searchable !== found.searchable || strict.scope !== found.scope)
73
+ orm
74
+ .update(chats)
75
+ .set({ ...strict, updatedAt: now() })
76
+ .where(eq(chats.id, found.pk))
77
+ .run();
78
+ return found.pk;
79
+ }
80
+ return Number(orm
81
+ .insert(chats)
82
+ .values({ accountId: accountKey, externalId: chatId, ...like, createdAt: now(), updatedAt: now() })
83
+ .returning({ pk: chats.id })
84
+ .get()?.pk);
85
+ };
32
86
  export const upsertChat = (context, accountKey, chat) => {
33
87
  const { orm, now } = context;
34
88
  const parent = chat.parentChatId == null ? null : chatPkFor(context, accountKey, chat.parentChatId);
@@ -208,7 +262,7 @@ export const chatsWith = ({ orm }, accountKey, key, memberId) => {
208
262
  .map(({ chat }) => chat);
209
263
  return toChats(orm, rows);
210
264
  };
211
- const chatsWhere = (accountKey, { where }) => and(eq(chats.accountId, accountKey), notLeft, where === undefined ? undefined : bound(where));
265
+ const chatsWhere = (accountKey, { where, includeLeft }) => and(eq(chats.accountId, accountKey), includeLeft ? undefined : notLeft, where === undefined ? undefined : bound(where));
212
266
  /**
213
267
  * How the store answers a chat list's query. `title:` goes through the trigram index, so it matches part of a
214
268
  * title, three characters or more, whatever the case.
@@ -90,6 +90,8 @@ export interface Delta {
90
90
  /** A chat list's query, compiled over `CHAT_COLUMNS`. */
91
91
  export interface StoredChatFilter {
92
92
  where?: Where;
93
+ /** Chats the account left are kept but left out of a list unless this asks for them. */
94
+ includeLeft?: boolean;
93
95
  }
94
96
  /** One sender's stored messages in one chat. */
95
97
  export interface SenderChatStats {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.354.0",
3
+ "version": "0.356.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",