@wirecat/cli-core 0.345.0 → 0.346.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 +20 -0
- package/dist/cli/messenger/chat-answers.js +1 -1
- package/dist/domain/models.d.ts +6 -1
- package/dist/kit/contract.d.ts +2 -2
- package/dist/kit/contract.js +36 -0
- package/dist/kit/fake.d.ts +3 -2
- package/dist/kit/fake.js +12 -0
- package/dist/store/sqlite/chats.js +25 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.346.0 — 18.10.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The contract kit checks `listen`** when the adapter has it: on a signal already aborted it resolves at once
|
|
15
|
+
and does not reject, and otherwise it keeps listening until the signal aborts, then resolves. `OPTIONAL_METHODS`
|
|
16
|
+
lists `listen`, and `fakeAdapter(seed, { listen: true })` has it. The cases cannot see whether `listen` connects.
|
|
17
|
+
|
|
18
|
+
### Changed — may break callers
|
|
19
|
+
|
|
20
|
+
- **Saving a chat the store already holds merges its `providerMetadata` key by key, and `parentChatId` keeps or
|
|
21
|
+
clears the same way.** A metadata key the new chat leaves out keeps its stored value; a key set to `null` is
|
|
22
|
+
removed, and metadata with no key left is stored as none. `parentChatId` left out keeps the stored parent, `null`
|
|
23
|
+
clears it (it could never be cleared before), and `Chat.parentChatId` is now `Id | null`. Before, the new
|
|
24
|
+
metadata replaced the stored one whole, so a push that left a key out erased it: a WhatsApp community lost
|
|
25
|
+
`community: true` on a push that did not say, and max-cli's login erased each dialog's `partnerId`. **Adapters
|
|
26
|
+
must send `null` for a key that is gone, or a flag that turned false; an absent key now means unknown.** An
|
|
27
|
+
adapter that leaves such a key out keeps the old value in the store, and a stale `username` still answers to
|
|
28
|
+
`@name` until the chat is saved with `username: null`. Chats the store already holds are not changed.
|
|
29
|
+
|
|
10
30
|
## 0.345.0 — 18.10.2026
|
|
11
31
|
|
|
12
32
|
### Added
|
|
@@ -30,7 +30,7 @@ export const chatAnswer = (account, chat) => defined({
|
|
|
30
30
|
muted: chat.muted,
|
|
31
31
|
archived: chat.archived,
|
|
32
32
|
membership_state: chat.membershipState,
|
|
33
|
-
parent_chat_id: chat.parentChatId,
|
|
33
|
+
parent_chat_id: chat.parentChatId ?? undefined,
|
|
34
34
|
scope: chat.scope,
|
|
35
35
|
provider_metadata: chat.providerMetadata,
|
|
36
36
|
description: chat.description,
|
package/dist/domain/models.d.ts
CHANGED
|
@@ -19,7 +19,8 @@ export type ChatKind = "dialog" | "group" | "channel" | "saved" | "unknown";
|
|
|
19
19
|
/** Where the owner stands in a chat. Kept apart from whether its messages are searchable: a chat left keeps them. */
|
|
20
20
|
export type MembershipState = "joined" | "left" | "public" | "imported" | "archived" | "external";
|
|
21
21
|
export interface Chat {
|
|
22
|
-
|
|
22
|
+
/** Saving a chat: absent keeps the stored parent, `null` clears it. */
|
|
23
|
+
parentChatId?: Id | null;
|
|
23
24
|
scope?: "personal" | "work";
|
|
24
25
|
id: Id;
|
|
25
26
|
title: string | null;
|
|
@@ -36,6 +37,10 @@ export interface Chat {
|
|
|
36
37
|
unreadMentions?: number;
|
|
37
38
|
/** Absent where the messenger does not say. */
|
|
38
39
|
membershipState?: MembershipState;
|
|
40
|
+
/**
|
|
41
|
+
* Saving a chat merges this into the stored one key by key: a key left out keeps its stored value, a key set to
|
|
42
|
+
* `null` is removed. So a key that is gone, or a flag that turned false, is sent as `null`.
|
|
43
|
+
*/
|
|
39
44
|
providerMetadata?: ProviderMetadata;
|
|
40
45
|
}
|
|
41
46
|
/** A group's switches; `null` where the messenger does not say, or has no such switch. */
|
package/dist/kit/contract.d.ts
CHANGED
|
@@ -16,13 +16,13 @@ export interface ContractOptions {
|
|
|
16
16
|
ids?: IdMaker;
|
|
17
17
|
/** As the messenger's `fetching.orderBy`: whether `history`'s `before` is a message id or an ISO time. */
|
|
18
18
|
orderBy?: "id" | "time";
|
|
19
|
-
/** How long `watch` may take to say it listens, and to end once aborted. */
|
|
19
|
+
/** How long `watch` may take to say it listens, and `watch`, `listen` and `feed` to end once aborted. */
|
|
20
20
|
waitMs?: number;
|
|
21
21
|
/** As the messenger's `history`: a `"server"` messenger's adapter must have every `ServerReads` method. */
|
|
22
22
|
history?: "server" | "store";
|
|
23
23
|
}
|
|
24
24
|
/** Every method of the optional groups, which `capability` hands over or refuses. */
|
|
25
|
-
export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
|
|
25
|
+
export declare const OPTIONAL_METHODS: readonly ["fetchCounters", "chats", "history", "around", "contact", "historyAfter", "historyBefore", "topics", "inspect", "edit", "forward", "delete", "pin", "unpin", "star", "react", "markRead", "markTopicRead", "poll", "vote", "closePoll", "createPoll", "watch", "listen", "feed", "download", "transcribe", "scheduled", "members", "admins", "chatEvents", "sessions", "lookup", "addressBook", "people", "createGroup", "join", "leave", "group", "updateGroup", "resetInviteLink", "addMembers", "removeMembers", "addAdmin", "removeAdmin", "folders", "createFolder", "updateFolder", "deleteFolder", "addContact", "removeContact", "block", "unblock", "renameContact", "importContacts", "updateProfile", "endOtherSessions"];
|
|
26
26
|
/**
|
|
27
27
|
* The port's promises as cases any test runner can run: each connects through `connect`, checks
|
|
28
28
|
* with `node:assert/strict` and closes the adapter. None needs a live service.
|
package/dist/kit/contract.js
CHANGED
|
@@ -28,6 +28,7 @@ export const OPTIONAL_METHODS = [
|
|
|
28
28
|
"closePoll",
|
|
29
29
|
"createPoll",
|
|
30
30
|
"watch",
|
|
31
|
+
"listen",
|
|
31
32
|
"feed",
|
|
32
33
|
"download",
|
|
33
34
|
"transcribe",
|
|
@@ -82,6 +83,17 @@ const within = async (work, failure, ms) => {
|
|
|
82
83
|
clearTimeout(timer);
|
|
83
84
|
}
|
|
84
85
|
};
|
|
86
|
+
const settledSoon = async (work) => {
|
|
87
|
+
let settled = false;
|
|
88
|
+
work.then(() => {
|
|
89
|
+
settled = true;
|
|
90
|
+
}, () => {
|
|
91
|
+
settled = true;
|
|
92
|
+
});
|
|
93
|
+
for (let turn = 0; turn < 5; turn += 1)
|
|
94
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
95
|
+
return settled;
|
|
96
|
+
};
|
|
85
97
|
const idsOf = (messages) => messages.map((message) => message.id);
|
|
86
98
|
/** Every `id`, `chatId` and `senderId` anywhere in `value`, so a number among them is caught. */
|
|
87
99
|
const everyId = (value, found = []) => {
|
|
@@ -394,6 +406,30 @@ export const contractCases = ({ connect, ids = wordIds, orderBy = "id", waitMs =
|
|
|
394
406
|
},
|
|
395
407
|
"`connect` opens a connection that listens, as `{ listen: true }` does",
|
|
396
408
|
],
|
|
409
|
+
[
|
|
410
|
+
"listen on a signal already aborted resolves at once and does not reject",
|
|
411
|
+
async (adapter) => {
|
|
412
|
+
const skipped = lacking(adapter, "listen");
|
|
413
|
+
if (skipped)
|
|
414
|
+
return skipped;
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
controller.abort();
|
|
417
|
+
await within(Promise.resolve(adapter.listen?.(() => { }, controller.signal)), "listen did not resolve on a signal already aborted", waitMs);
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
[
|
|
421
|
+
"listen keeps listening until the signal aborts, then resolves",
|
|
422
|
+
async (adapter) => {
|
|
423
|
+
const skipped = lacking(adapter, "listen");
|
|
424
|
+
if (skipped)
|
|
425
|
+
return skipped;
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const listening = Promise.resolve(adapter.listen?.(() => { }, controller.signal));
|
|
428
|
+
assert.equal(await settledSoon(listening), false, "listen ended before the signal aborted");
|
|
429
|
+
controller.abort();
|
|
430
|
+
await within(listening, "listen did not end when the signal aborted", waitMs);
|
|
431
|
+
},
|
|
432
|
+
],
|
|
397
433
|
[
|
|
398
434
|
"feed pushes only the seed's chats and messages, with ids as strings, and ends when the signal aborts",
|
|
399
435
|
async (adapter, seed) => {
|
package/dist/kit/fake.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { AccountEditing, AccountTools, ChatFolders, ChatReading, ContactBook, GroupAdmin, GroupModeration, LiveUpdates, MessageEditing, MessageMedia, MessagePins, MessagePolls, MessageReactions, MessageStars, MessengerCore, PushedHistory, ReadState, ScheduledMessages, ServerReads, TopicReadState } from "../ports/messenger.js";
|
|
1
|
+
import type { AccountEditing, AccountTools, ChatFolders, ChatReading, ContactBook, GroupAdmin, GroupModeration, LiveUpdates, MessageEditing, MessageMedia, MessagePins, MessagePolls, MessageReactions, MessageStars, MessengerCore, PassiveUpdates, PushedHistory, ReadState, ScheduledMessages, ServerReads, TopicReadState } from "../ports/messenger.js";
|
|
2
2
|
import { type Seed } from "./seed.js";
|
|
3
3
|
/** Every group of the port, over memory. */
|
|
4
|
-
export type FakeAdapter = MessengerCore & ServerReads & ChatReading & MessageEditing & MessagePins & MessageStars & MessageReactions & ReadState & TopicReadState & MessagePolls & LiveUpdates & MessageMedia & ScheduledMessages & GroupModeration & AccountTools & GroupAdmin & ChatFolders & ContactBook & AccountEditing & Partial<PushedHistory>;
|
|
4
|
+
export type FakeAdapter = MessengerCore & ServerReads & ChatReading & MessageEditing & MessagePins & MessageStars & MessageReactions & ReadState & TopicReadState & MessagePolls & LiveUpdates & MessageMedia & ScheduledMessages & GroupModeration & AccountTools & GroupAdmin & ChatFolders & ContactBook & AccountEditing & Partial<PushedHistory> & Partial<PassiveUpdates>;
|
|
5
5
|
export declare const fakeAdapter: (seed?: Seed, options?: {
|
|
6
6
|
feed?: boolean;
|
|
7
|
+
listen?: boolean;
|
|
7
8
|
}) => FakeAdapter;
|
package/dist/kit/fake.js
CHANGED
|
@@ -28,6 +28,7 @@ const missing = (what) => new CliError("not_found", `the fake adapter has no ${w
|
|
|
28
28
|
*
|
|
29
29
|
* With `{ feed: true }` it also pushes its history, as a messenger with `history: "store"` does:
|
|
30
30
|
* the chats and people in one batch, then each chat's messages in a batch of their own.
|
|
31
|
+
* With `{ listen: true }` it also has `listen`, which hands on what `watch` does.
|
|
31
32
|
*/
|
|
32
33
|
const withRules = (folder, { emoji, include, skip, exclude, pin }) => {
|
|
33
34
|
if (emoji !== undefined)
|
|
@@ -311,6 +312,17 @@ export const fakeAdapter = (seed = contractSeed(), options = {}) => {
|
|
|
311
312
|
await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
312
313
|
listeners.delete(onEvent);
|
|
313
314
|
},
|
|
315
|
+
...(options.listen
|
|
316
|
+
? {
|
|
317
|
+
listen: async (onEvent, signal) => {
|
|
318
|
+
if (signal.aborted)
|
|
319
|
+
return;
|
|
320
|
+
listeners.add(onEvent);
|
|
321
|
+
await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
322
|
+
listeners.delete(onEvent);
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
: {}),
|
|
314
326
|
...(options.feed
|
|
315
327
|
? {
|
|
316
328
|
feed: async (onBatch, signal) => {
|
|
@@ -4,7 +4,7 @@ import { identityPk } from "./identities.js";
|
|
|
4
4
|
import { bound } from "./query-filter.js";
|
|
5
5
|
import { chatEventPeople, chatEvents, chatMembers, chats, chunks, fetchLeases, identities, involvements, memberCounts, memberObservationMembers, memberObservations, memberStays, messageRevisions, messages, messageTranscripts, pollVotes, syncRanges, } from "./schema.js";
|
|
6
6
|
import { ensureTag } from "./tags.js";
|
|
7
|
-
import {
|
|
7
|
+
import { parsed, present, toIso, toMs } from "./values.js";
|
|
8
8
|
/** The chat's own id, when the store key is a chat of this account. */
|
|
9
9
|
export const chatOf = ({ orm }, accountKey, chatKey) => orm
|
|
10
10
|
.select({ id: chats.externalId })
|
|
@@ -31,7 +31,12 @@ export const chatPkFor = (context, accountKey, chatId) => findChatPk(context, ac
|
|
|
31
31
|
.get()?.pk);
|
|
32
32
|
export const upsertChat = (context, accountKey, chat) => {
|
|
33
33
|
const { orm, now } = context;
|
|
34
|
-
const parent = chat.parentChatId
|
|
34
|
+
const parent = chat.parentChatId == null ? null : chatPkFor(context, accountKey, chat.parentChatId);
|
|
35
|
+
const stored = orm
|
|
36
|
+
.select({ metadata: chats.metadata })
|
|
37
|
+
.from(chats)
|
|
38
|
+
.where(and(eq(chats.accountId, accountKey), eq(chats.externalId, chat.id)))
|
|
39
|
+
.get();
|
|
35
40
|
orm
|
|
36
41
|
.insert(chats)
|
|
37
42
|
.values({
|
|
@@ -44,7 +49,7 @@ export const upsertChat = (context, accountKey, chat) => {
|
|
|
44
49
|
unreadCount: chat.unreadCount,
|
|
45
50
|
lastMessageAt: toMs(chat.lastMessageAt),
|
|
46
51
|
participantsCount: chat.participantsCount,
|
|
47
|
-
metadata:
|
|
52
|
+
metadata: mergedMetadata(stored?.metadata ?? null, chat.providerMetadata),
|
|
48
53
|
membershipState: chat.membershipState ?? null,
|
|
49
54
|
createdAt: now(),
|
|
50
55
|
updatedAt: now(),
|
|
@@ -53,7 +58,7 @@ export const upsertChat = (context, accountKey, chat) => {
|
|
|
53
58
|
target: [chats.accountId, chats.externalId],
|
|
54
59
|
set: {
|
|
55
60
|
kind: sql `excluded.kind`,
|
|
56
|
-
parentChatId: sql `
|
|
61
|
+
...(chat.parentChatId === undefined ? {} : { parentChatId: sql `excluded.parent_chat_id` }),
|
|
57
62
|
scope: sql `coalesce(excluded.scope, ${chats.scope})`,
|
|
58
63
|
title: sql `excluded.title`,
|
|
59
64
|
unreadCount: sql `excluded.unread_count`,
|
|
@@ -68,6 +73,22 @@ export const upsertChat = (context, accountKey, chat) => {
|
|
|
68
73
|
})
|
|
69
74
|
.run();
|
|
70
75
|
};
|
|
76
|
+
const mergedMetadata = (stored, incoming) => {
|
|
77
|
+
if (incoming === undefined)
|
|
78
|
+
return stored;
|
|
79
|
+
const merged = Object.entries({ ...metadataObject(stored), ...incoming }).filter(([, value]) => value !== null);
|
|
80
|
+
return merged.length === 0 ? null : JSON.stringify(Object.fromEntries(merged));
|
|
81
|
+
};
|
|
82
|
+
// Stored metadata that is not an object has no keys to keep.
|
|
83
|
+
const metadataObject = (stored) => {
|
|
84
|
+
try {
|
|
85
|
+
const value = stored === null ? null : JSON.parse(stored);
|
|
86
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
};
|
|
71
92
|
/** Replaces who is in the chat, whole. */
|
|
72
93
|
export const writeMembers = (context, key, accountKey, chatId, memberIds) => {
|
|
73
94
|
const chatKey = chatPkFor(context, accountKey, chatId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.346.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",
|