@wirecat/cli-core 0.355.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 +44 -0
- package/dist/cli/messenger/chats-command.js +3 -0
- package/dist/cli/messenger/context.js +1 -0
- package/dist/cli/messenger/observed.js +1 -1
- package/dist/cli/messenger/stored.d.ts +7 -1
- package/dist/cli/messenger/stored.js +54 -11
- package/dist/ports/messenger.d.ts +4 -2
- package/dist/sends/guarded.d.ts +5 -0
- package/dist/sends/guarded.js +5 -0
- package/dist/sends/polls.js +2 -2
- package/dist/services/admin.d.ts +1 -1
- package/dist/services/admin.js +39 -3
- package/dist/services/chats.js +2 -2
- package/dist/services/message-resolution.d.ts +4 -2
- package/dist/services/message-resolution.js +23 -5
- package/dist/services/messages.js +3 -3
- package/dist/store/keys.d.ts +1 -0
- package/dist/store/keys.js +1 -0
- package/dist/store/messages.d.ts +4 -0
- package/dist/store/messages.js +3 -3
- package/dist/store/sqlite/chat-rekey.js +6 -20
- package/dist/store/sqlite/chats.d.ts +17 -0
- package/dist/store/sqlite/chats.js +55 -1
- package/dist/store/types.d.ts +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ 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
|
+
|
|
10
54
|
## 0.355.0 — 19.10.2026
|
|
11
55
|
|
|
12
56
|
### Fixed
|
|
@@ -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),
|
|
@@ -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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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`.
|
|
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 {
|
package/dist/sends/guarded.d.ts
CHANGED
|
@@ -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;
|
package/dist/sends/guarded.js
CHANGED
|
@@ -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
|
package/dist/sends/polls.js
CHANGED
|
@@ -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) => (
|
|
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. */
|
package/dist/services/admin.d.ts
CHANGED
|
@@ -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
|
|
3
|
+
import { type ServiceDeps } from "./deps.js";
|
|
4
4
|
export interface NewGroup {
|
|
5
5
|
title: string;
|
|
6
6
|
/** As typed: ids, handles or names. */
|
package/dist/services/admin.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
package/dist/services/chats.js
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
const chatId = await
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
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) => (
|
|
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) => (
|
|
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),
|
package/dist/store/keys.d.ts
CHANGED
|
@@ -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;
|
package/dist/store/keys.js
CHANGED
|
@@ -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),
|
package/dist/store/messages.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/store/messages.js
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 =
|
|
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.
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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.
|
|
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",
|