@wirecat/cli-core 0.478.0 → 0.480.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
@@ -5,7 +5,32 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.480.0 — 29.10.2026
9
+
10
+ ### Changed — may break callers
11
+
12
+ - **`StoredMessages.reactors` is required again.** 0.479.0 made it optional so a messenger that builds its own
13
+ `StoredMessages` kept compiling; whatsapp-cli now implements it, so every messenger must give it (`async () => []` for
14
+ one that stores no reactions). tg-cli and max-cli do not build a `StoredMessages` and are not affected.
15
+
16
+ ### Added
17
+
18
+ - **`reactions list` is in whatsapp-cli too** (`parity.json`: `tg` and `wa`; max-cli does not list who reacted).
19
+
20
+ ## 0.479.0 — 29.10.2026
21
+
22
+ ### Added
23
+
24
+ - **`StoredMessages.reactors(chatId, messageId)`** gives an adapter the reactions the store holds for a message, so a
25
+ messenger that sends reactions one person at a time (WhatsApp) can implement `MessageReactions.reactors` from the
26
+ store. One `Reactor` per person (`person`, `reaction`, `reactedAt`), newest first; a person's newest reaction wins
27
+ and one they took back is not listed. It is optional on the type, so an adapter that builds its own `StoredMessages` still compiles. It covers only what the store received. No schema change, store version stays 37.
28
+ - **A person's phone is saved in the store** (store version 37, additive, minCompatible stays 34). `contacts sync`
29
+ saves the phone an address book entry carries (`AddressBookEntry`, a `Member` with an optional `phone`), and an
30
+ online `contacts show` saves the phone the profile shows, so `contacts show --offline` answers it. A read that
31
+ shows no phone never erases a saved one.
32
+ - **A shared contact card's phone is a mention** of field `contact`, beside `file_text` and `transcript`:
33
+ `mentions list kind:phone` and `phone:` search find it, and `mentions rebuild` reads cards stored before.
9
34
 
10
35
  ## 0.478.0 — 29.10.2026
11
36
 
@@ -1,5 +1,5 @@
1
1
  import type { Command } from "commander";
2
- import type { AdminRight, GroupSettingKey, Id, Message, Page, PollVote, PrivacyKey, Provider } from "../../domain/models.js";
2
+ import type { AdminRight, GroupSettingKey, Id, Message, Page, PollVote, PrivacyKey, Provider, Reactor } from "../../domain/models.js";
3
3
  import type { MessengerAdapter } from "../../ports/messenger.js";
4
4
  import type { EventSink, Warn } from "../../runs/events.js";
5
5
  import { type Asker, type SendGuard } from "../../sends/guard.js";
@@ -31,6 +31,8 @@ export interface StoredMessages {
31
31
  wroteTo(personId: Id): Promise<boolean>;
32
32
  /** Each voter's newest vote in the poll the message carries, newest first; a retracted vote has no answers. */
33
33
  pollVotes(chatId: Id, messageId: Id): Promise<PollVote[]>;
34
+ /** Each person's newest reaction the store received for the message, newest first; a taken-back one is not listed. */
35
+ reactors(chatId: Id, messageId: Id): Promise<Reactor[]>;
34
36
  }
35
37
  /** `listen` opens a connection that receives updates — only `watch` asks; the rest stay quiet. */
36
38
  export interface ConnectOptions {
@@ -85,6 +85,10 @@ export const storedMessages = (provider, store, account) => {
85
85
  const owner = key();
86
86
  return owner ? (await store()).messages.pollVotes(owner, chatId, messageId) : [];
87
87
  },
88
+ reactors: async (chatId, messageId) => {
89
+ const owner = key();
90
+ return owner ? (await store()).messages.reactors(owner, chatId, messageId) : [];
91
+ },
88
92
  };
89
93
  };
90
94
  /**
@@ -574,6 +574,10 @@ export interface Contact {
574
574
  }
575
575
  /** Somebody in a chat, as much of them as a chat card shows. */
576
576
  export type Member = Pick<Contact, "id" | "name" | "username">;
577
+ /** A contact of the messenger's address book: the phone is there when the messenger shows it to the owner. */
578
+ export type AddressBookEntry = Member & {
579
+ phone?: string | null;
580
+ };
577
581
  /** A topic of a forum group; its messages carry this id as `threadId`. */
578
582
  export interface Topic {
579
583
  id: Id;
@@ -3,7 +3,7 @@ import type { CounterField, CounterObservations } from "../domain/counters.js";
3
3
  import type { HtmlFormatting, MarkdownFormatting, TextSpan } from "../domain/formatting.js";
4
4
  import type { Markup } from "../domain/markdown.js";
5
5
  import type { MessagePermalink } from "../domain/message-link.js";
6
- import type { Account, AccountSession, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PersonReaction, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, Reactor, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
6
+ import type { Account, AccountSession, AddressBookEntry, AdminRight, Button, CallRecord, Chat, ChatCard, ChatEvent, ChatEvents, ChatUpdate, Discussion, Folder, FolderChange, FolderRules, GroupCard, GroupChange, GroupMember, Id, InviteLink, InviteLinkChange, JoinRequest, LinkTarget, MediaKind, Member, Message, MessageEvent, MessageHit, OfficialChatStats, Page, PersonCard, PersonReaction, PhoneBookEntry, Poll, PollVote, PrivacySettings, ProfileFacts, ProviderMetadata, Reactor, SenderIdentity, Sticker, StickerSet, Topic, TopicChange, WindowedMessage } from "../domain/models.js";
7
7
  /** One attachment's bytes, fetched over the adapter's connection when `bytes` is read — so read it before the command closes. */
8
8
  export interface RemoteFile {
9
9
  kind: string;
@@ -502,7 +502,7 @@ export interface AccountTools {
502
502
  /** The person with this phone number, where their privacy lets the owner find them; `not_found` otherwise. */
503
503
  lookup(phone: string): Promise<Member>;
504
504
  /** The owner's contact list as the messenger keeps it — the address book, not the chats. */
505
- addressBook(): Promise<Member[]>;
505
+ addressBook(): Promise<AddressBookEntry[]>;
506
506
  }
507
507
  /** Everything the messenger says about one person — what `contacts show` shows. */
508
508
  export interface PersonProfiles {
@@ -1,5 +1,5 @@
1
1
  import { pickPerson } from "../resolve.js";
2
- import { storeIfOpen } from "./deps.js";
2
+ import { storeIfOpen, unlessBusy } from "./deps.js";
3
3
  /**
4
4
  * The person as the messenger describes them, and their stored activity in every chat shared with
5
5
  * them. Online, one profile read; `--offline`, or a messenger that cannot describe a person, what
@@ -10,13 +10,18 @@ import { storeIfOpen } from "./deps.js";
10
10
  export const personProfile = async (deps, person, known) => {
11
11
  const online = deps.offline ? undefined : await deps.connection();
12
12
  const held = await storeIfOpen(deps);
13
- const facts = online?.profile
13
+ const read = online?.profile
14
14
  ? await online.profile(person)
15
15
  : known
16
16
  ? factsOfCard(known)
17
17
  : await storedFacts(held ?? { store: await deps.store(), account: await deps.account() }, person);
18
18
  if (!held)
19
- return { ...facts, chats: quiet(facts), aliases: [] };
19
+ return { ...read, chats: quiet(read), aliases: [] };
20
+ if (online?.profile && read.phone) {
21
+ await unlessBusy(() => held.store.people.savePeople(held.account, [{ id: read.id, name: read.name, phone: read.phone }]));
22
+ }
23
+ const phone = read.phone ?? (await held.store.people.phoneOf(held.account, read.id));
24
+ const facts = phone ? { ...read, phone } : read;
20
25
  return {
21
26
  ...facts,
22
27
  chats: await activityIn(held.store, held.account, facts),
@@ -1,5 +1,5 @@
1
1
  import type { CounterObservations } from "../domain/counters.js";
2
- import type { Delivery, Id, Message, Page, PersonReaction, PollVote, Reactions, WindowedMessage } from "../domain/models.js";
2
+ import type { Delivery, Id, Message, Page, PersonReaction, PollVote, Reactions, Reactor, WindowedMessage } from "../domain/models.js";
3
3
  import { type DocumentLinkMessage } from "./sqlite/document-links.js";
4
4
  import type { StoreContext } from "./sqlite/open.js";
5
5
  import type { AccountKey, DeletionScope, IngestedVia, MessageFilter, SenderChatStats, StoredHit } from "./types.js";
@@ -118,6 +118,8 @@ export interface AccountMessages {
118
118
  saveVote(key: AccountKey, chatId: Id, messageId: Id, vote: PollVote): Promise<void>;
119
119
  /** Each voter's newest vote in the poll a message carries, newest first. */
120
120
  pollVotes(key: AccountKey, chatId: Id, messageId: Id): Promise<PollVote[]>;
121
+ /** Each person's newest reaction to a message, newest first; a person who took theirs back is not listed. */
122
+ reactors(key: AccountKey, chatId: Id, messageId: Id): Promise<Reactor[]>;
121
123
  /** One person's stored messages per chat of this account, newest first. */
122
124
  senderStats(key: AccountKey, senderId: Id): Promise<SenderChatStats[]>;
123
125
  /** What a voice message said, when it was heard before, and by what. */
@@ -110,6 +110,10 @@ export const accountMessagesOver = (context) => {
110
110
  const chatKey = chatKeyOf(key, chatId);
111
111
  return chatKey === undefined ? [] : messageWrites.pollVotes(context, chatKey, messageId);
112
112
  },
113
+ reactors: async (key, chatId, messageId) => {
114
+ const chatKey = chatKeyOf(key, chatId);
115
+ return chatKey === undefined ? [] : messageWrites.reactors(context, chatKey, messageId);
116
+ },
113
117
  senderStats: async (key, senderId) => {
114
118
  const accountKey = findAccountPk(key);
115
119
  return accountKey === undefined ? [] : reads.senderStats(context, accountKey, key.provider, senderId);
@@ -21,6 +21,8 @@ export interface AccountPeople {
21
21
  refreshRecency(key: AccountKey): Promise<void>;
22
22
  /** Every name and username this account's store recorded for one person, the current ones included. */
23
23
  personNames(key: AccountKey, personId: Id): Promise<PersonAlias[]>;
24
+ /** The phone saved for them, `null` when none was ever shown to the owner. */
25
+ phoneOf(key: AccountKey, personId: Id): Promise<string | null>;
24
26
  privateContact(key: AccountKey, personId: Id): Promise<PrivateContact>;
25
27
  setContactAlias(key: AccountKey, personId: Id, alias: string | null): Promise<{
26
28
  personId: Id;
@@ -39,6 +39,7 @@ export const accountPeopleOver = (context) => {
39
39
  const accountKey = findAccountPk(key);
40
40
  return accountKey === undefined ? [] : identities.namesOf(context, accountKey, key.provider, personId);
41
41
  },
42
+ phoneOf: async (key, personId) => identities.phoneOf(context, key.provider, personId),
42
43
  privateContact: async (key, person) => privatePeople.privateContact(context, key, person),
43
44
  setContactAlias: async (key, person, alias) => {
44
45
  return write(database, () => privatePeople.setAlias(context, key, person, alias));
@@ -45,4 +45,5 @@ export declare const refreshRecency: ({ orm }: StoreContext, accountKey: number)
45
45
  export declare const namesOf: ({ orm }: StoreContext, accountKey: number, provider: Provider, nativeId: Id) => PersonAlias[];
46
46
  /** Stable provider identity only; participant labels never resolve or merge other people. */
47
47
  export declare const meetingIdentityPk: (context: StoreContext, accountId: number, identity: IdentityInput) => number;
48
+ export declare const phoneOf: ({ orm }: StoreContext, provider: Provider, nativeId: Id) => string | null;
48
49
  export {};
@@ -13,6 +13,7 @@ const prepare = (orm) => ({
13
13
  username: identities.username,
14
14
  isBot: identities.bot,
15
15
  description: identities.description,
16
+ phone: identities.phone,
16
17
  updatedAt: identities.updatedAt,
17
18
  personId: identityLinks.personId,
18
19
  })
@@ -118,12 +119,14 @@ const saveIdentity = ({ orm, now, database }, provider, nativeId, name, facts, r
118
119
  username: facts.username ?? found.username,
119
120
  bot: flag(facts.isBot) ?? found.isBot,
120
121
  description: facts.description ?? found.description,
122
+ phone: facts.phone ?? found.phone,
121
123
  };
122
124
  // Only on a real change: the search trigger rewrites the index row on every update of `name`.
123
125
  if (changed.name !== found.name ||
124
126
  changed.username !== found.username ||
125
127
  changed.bot !== found.isBot ||
126
- changed.description !== found.description) {
128
+ changed.description !== found.description ||
129
+ changed.phone !== found.phone) {
127
130
  orm
128
131
  .update(identities)
129
132
  .set({ ...changed, updatedAt: now() })
@@ -150,6 +153,7 @@ const saveIdentity = ({ orm, now, database }, provider, nativeId, name, facts, r
150
153
  username: facts.username ?? null,
151
154
  bot: flag(facts.isBot),
152
155
  description: facts.description ?? null,
156
+ phone: facts.phone ?? null,
153
157
  createdAt: at,
154
158
  updatedAt: at,
155
159
  })
@@ -325,3 +329,8 @@ export const meetingIdentityPk = (context, accountId, identity) => {
325
329
  statementsOf(context.orm).seen.run({ accountId, identityId: saved.pk, createdAt: context.now() });
326
330
  return saved.pk;
327
331
  };
332
+ export const phoneOf = ({ orm }, provider, nativeId) => orm
333
+ .select({ phone: identities.phone })
334
+ .from(identities)
335
+ .where(and(eq(identities.provider, provider), eq(identities.externalId, nativeId)))
336
+ .get()?.phone ?? null;
@@ -37,6 +37,13 @@ export interface Corpus {
37
37
  */
38
38
  export declare const speech: (alias: string) => string;
39
39
  export declare const SPEECH: string;
40
+ /**
41
+ * What mentions are read from in an attachment: its text, or for a shared contact card its phone behind a phone
42
+ * word, which makes the number count as a phone whatever its country code. One expression for finding and reading
43
+ * back, so the offsets agree.
44
+ */
45
+ export declare const mentionText: (alias: string) => string;
46
+ export declare const mentionField: (alias: string) => string;
40
47
  export declare const quoted: (value: string) => string;
41
48
  /** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
42
49
  export declare const ftsMatch: (node: ResolvedPredicate) => string;
@@ -20,6 +20,13 @@ import { canonicalTagNamed } from "./tags.js";
20
20
  */
21
21
  export const speech = (alias) => `(coalesce(${alias}.extractor,'') LIKE 'speech:%' OR ${alias}.kind IN ('voice','video_note') OR coalesce(${alias}.mime,'')='audio/ogg')`;
22
22
  export const SPEECH = speech("att");
23
+ /**
24
+ * What mentions are read from in an attachment: its text, or for a shared contact card its phone behind a phone
25
+ * word, which makes the number count as a phone whatever its country code. One expression for finding and reading
26
+ * back, so the offsets agree.
27
+ */
28
+ export const mentionText = (alias) => `CASE WHEN coalesce(json_extract(${alias}.contact, '$.phone'), '') <> '' THEN 'phone: ' || json_extract(${alias}.contact, '$.phone') ELSE ${alias}.text END`;
29
+ export const mentionField = (alias) => `CASE WHEN coalesce(json_extract(${alias}.contact, '$.phone'), '') <> '' THEN 'contact' WHEN ${speech(alias)} THEN 'transcript' ELSE 'file_text' END`;
23
30
  export const quoted = (value) => `"${value.replaceAll('"', '""')}"`;
24
31
  /** A leaf's words as an FTS5 match: a phrase, or with `~N` its words at most N apart, in any order. */
25
32
  export const ftsMatch = (node) => {
@@ -48,6 +48,8 @@ export const MANIFEST = [
48
48
  { name: "20261027102259_version-35-calendar-neutral", version: 35, minCompatible: 34 },
49
49
  { name: "20261027102302_version-35-calendar-neutral-data", version: 35, minCompatible: 34 },
50
50
  { name: "20261029162435_version-36-attachment-contact", version: 36, minCompatible: 34 },
51
+ { name: "20261029180959_version-37-identity-phone", version: 37, minCompatible: 34 },
52
+ { name: "20261029181000_version-37-contact-mention-triggers", version: 37, minCompatible: 34 },
51
53
  ];
52
54
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
53
55
  const migrations = [];
@@ -1,7 +1,7 @@
1
1
  import { findMentions, MENTION_KINDS, MENTION_VERSIONS, oneTrackedLinkPerSite, telegramTarget, } from "../../domain/mentions.js";
2
2
  import { findNames, findUnknownNames, nameIndex } from "../../domain/person-names.js";
3
3
  import { createStemmer } from "../../search/stem.js";
4
- import { speech } from "./lucene-fields.js";
4
+ import { mentionField, mentionText } from "./lucene-fields.js";
5
5
  import { storePhoneCountries } from "./phone-countries.js";
6
6
  import { placeOf } from "./place-key.js";
7
7
  /** Mentions a store write finds before it commits: an older binary may have queued thousands. */
@@ -98,13 +98,13 @@ export const SOURCES = {
98
98
  },
99
99
  attachment: {
100
100
  count: "SELECT count(*) AS n FROM attachments a JOIN messages m ON m.id = a.attachable_id " +
101
- "WHERE a.attachable_type = 'message' AND a.text IS NOT NULL AND a.text <> '' AND m.deleted_at IS NULL",
102
- page: `SELECT a.id, a.text, CASE WHEN ${speech("a")} THEN 'transcript' ELSE 'file_text' END AS field, ` +
101
+ `WHERE a.attachable_type = 'message' AND coalesce(${mentionText("a")}, '') <> '' AND m.deleted_at IS NULL`,
102
+ page: `SELECT a.id, ${mentionText("a")} AS text, ${mentionField("a")} AS field, ` +
103
103
  "m.chat_id FROM attachments a JOIN messages m ON m.id = a.attachable_id WHERE a.id > ? AND a.attachable_type = 'message' " +
104
- "AND a.text IS NOT NULL AND a.text <> '' AND m.deleted_at IS NULL ORDER BY a.id LIMIT ?",
105
- one: `SELECT a.text, CASE WHEN ${speech("a")} THEN 'transcript' ELSE 'file_text' END AS field, ` +
104
+ `AND coalesce(${mentionText("a")}, '') <> '' AND m.deleted_at IS NULL ORDER BY a.id LIMIT ?`,
105
+ one: `SELECT ${mentionText("a")} AS text, ${mentionField("a")} AS field, ` +
106
106
  "m.chat_id FROM attachments a JOIN messages m ON m.id = a.attachable_id WHERE a.id = ? AND a.attachable_type = 'message' " +
107
- "AND a.text IS NOT NULL AND m.deleted_at IS NULL",
107
+ `AND ${mentionText("a")} IS NOT NULL AND m.deleted_at IS NULL`,
108
108
  },
109
109
  // Texts outside a chat: no one wrote them in a chat, so a name in them is never linked by rule.
110
110
  document: {
@@ -11,7 +11,7 @@ export interface StoredMention {
11
11
  domain: string | null;
12
12
  confidence: number;
13
13
  status: MentionStatus;
14
- /** Which text of the message: `text`, a file's `file_text`, or a voice note's `transcript`. */
14
+ /** Which text of the message: `text`, a file's `file_text`, a voice note's `transcript`, or a shared card's `contact`. */
15
15
  field: string;
16
16
  /** Who linked or dismissed it: `owner`, an agent by name, `rules` for a value that already had an owner. */
17
17
  author: string | null;
@@ -3,6 +3,7 @@ import { defineQuery } from "@wirecat/cli-base/query";
3
3
  import { MENTION_KINDS, siteOf } from "../../domain/mentions.js";
4
4
  import { findNames, nameIndex } from "../../domain/person-names.js";
5
5
  import { createStemmer } from "../../search/stem.js";
6
+ import { mentionText } from "./lucene-fields.js";
6
7
  import { knownPeople, participants, SOURCE_TYPES, SOURCES, sourceOf, telegramLink, writer, } from "./mention-drain.js";
7
8
  import { linkIdentityToPerson, personByUid } from "./person-links.js";
8
9
  import { valueMatch } from "./phone-countries.js";
@@ -94,7 +95,9 @@ export const mentionsState = (database) => {
94
95
  // A mention in a file or a voice note belongs to that attachment's message, and is listed with it.
95
96
  const SELECT = "SELECT mn.id, mn.kind, mn.value, mn.domain, mn.confidence, mn.status, mn.author, mn.source_field, " +
96
97
  "mn.target_type, mn.target_id, mn.created_at, " +
97
- "mn.start_offset, mn.end_offset, CASE WHEN mn.source_type = 'attachment' THEN att.text ELSE m.text END AS text, " +
98
+ "mn.start_offset, mn.end_offset, CASE WHEN mn.source_type = 'attachment' THEN " +
99
+ mentionText("att") +
100
+ " ELSE m.text END AS text, " +
98
101
  "m.external_id AS message_id, m.sent_at, m.chat_id AS chat_pk, c.external_id AS chat_id FROM mentions mn " +
99
102
  "LEFT JOIN attachments att ON mn.source_type = 'attachment' AND att.id = mn.source_id AND att.attachable_type = 'message' " +
100
103
  "JOIN messages m ON mn.source_type IN ('message', 'attachment') " +
@@ -1,4 +1,4 @@
1
- import type { Delivery, Id, Message, PersonReaction, PollVote, Provider, Reactions } from "../../domain/models.js";
1
+ import type { Delivery, Id, Message, PersonReaction, PollVote, Provider, Reactions, Reactor } from "../../domain/models.js";
2
2
  import type { AccountKey, DeletionScope } from "../types.js";
3
3
  import type { StoreContext } from "./open.js";
4
4
  export declare const upsertMessage: (context: StoreContext, key: AccountKey, accountKey: number, chatKey: number, message: Message, via: string, seenAt?: number) => boolean;
@@ -27,6 +27,7 @@ export declare const pollVotes: ({ database }: StoreContext, chatKey: number, me
27
27
  * messenger's own totals stand. A taken-back reaction still counts as held, so all of them taken back is zero.
28
28
  */
29
29
  export declare const derivedReactions: ({ database }: StoreContext, chatKey: number, messageId: Id) => Reactions | undefined;
30
+ export declare const reactors: ({ database }: StoreContext, chatKey: number, messageId: Id) => Reactor[];
30
31
  /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
31
32
  export declare const saveReaction: (context: StoreContext, accountKey: number, provider: Provider, chatKey: number, messageId: Id, { person, own, reaction, reactedAt }: PersonReaction) => void;
32
33
  export declare const writeDerivedReactions: (context: StoreContext, chatKey: number, messageId: Id) => void;
@@ -412,6 +412,16 @@ export const derivedReactions = ({ database }, chatKey, messageId) => {
412
412
  total: sorted.reduce((sum, [, { count }]) => sum + count, 0),
413
413
  };
414
414
  };
415
+ export const reactors = ({ database }, chatKey, messageId) => database
416
+ .prepare(`SELECT i.external_id AS id, i.name, i.username, r.reaction, r.reacted_at AS reactedAt
417
+ FROM message_reactions r JOIN identities i ON i.id = r.identity_id
418
+ WHERE r.chat_id = ? AND r.message_external_id = ? AND r.reaction IS NOT NULL
419
+ ORDER BY r.reacted_at DESC, r.id DESC`)
420
+ .all(chatKey, messageId).map(({ id, name, username, reaction, reactedAt }) => ({
421
+ person: { id, name, username },
422
+ reaction,
423
+ reactedAt: new Date(reactedAt).toISOString(),
424
+ }));
415
425
  /** A reaction older than the one held for that person is dropped, and so is any reaction to a deleted message. */
416
426
  export const saveReaction = (context, accountKey, provider, chatKey, messageId, { person, own, reaction, reactedAt }) => {
417
427
  const { database, now } = context;
@@ -784,5 +784,18 @@ export const GENERATED = [
784
784
  "statements": [
785
785
  "ALTER TABLE `attachments` ADD `contact` text;"
786
786
  ]
787
+ },
788
+ {
789
+ "name": "20261029180959_version-37-identity-phone",
790
+ "statements": [
791
+ "ALTER TABLE `identities` ADD `phone` text;"
792
+ ]
793
+ },
794
+ {
795
+ "name": "20261029181000_version-37-contact-mention-triggers",
796
+ "statements": [
797
+ "-- A shared contact card has no text, only a phone: queue it for mentions like an attachment's text.\n-- Cards stored before version 37 wait for `mentions rebuild`.\nCREATE TRIGGER mention_pending_attachment_contact_ai AFTER INSERT ON attachments\n WHEN new.attachable_type = 'message' AND new.contact IS NOT NULL BEGIN\n INSERT OR IGNORE INTO mention_pending (source_type, source_id) VALUES ('attachment', new.id);\nEND;",
798
+ "CREATE TRIGGER mention_pending_attachment_contact_au AFTER UPDATE OF contact ON attachments\n WHEN new.attachable_type = 'message' AND old.contact IS NOT new.contact BEGIN\n INSERT OR IGNORE INTO mention_pending (source_type, source_id) VALUES ('attachment', new.id);\nEND;"
799
+ ]
787
800
  }
788
801
  ];
@@ -1042,6 +1042,22 @@ export declare const identities: import("drizzle-orm/sqlite-core").SQLiteTableWi
1042
1042
  identity: undefined;
1043
1043
  generated: undefined;
1044
1044
  }, {}>;
1045
+ phone: import("drizzle-orm/sqlite-core").SQLiteColumn<{
1046
+ name: string;
1047
+ tableName: "identities";
1048
+ dataType: "string";
1049
+ data: string;
1050
+ driverParam: string;
1051
+ notNull: false;
1052
+ hasDefault: false;
1053
+ isPrimaryKey: false;
1054
+ isAutoincrement: false;
1055
+ hasRuntimeDefault: false;
1056
+ enumValues: [string, ...string[]];
1057
+ baseColumn: never;
1058
+ identity: undefined;
1059
+ generated: undefined;
1060
+ }, {}>;
1045
1061
  };
1046
1062
  dialect: 'sqlite';
1047
1063
  isAlias: false;
@@ -100,6 +100,7 @@ export const identities = sqliteTable("identities", {
100
100
  updatedAt: integer("updated_at").notNull(),
101
101
  description: text("description"),
102
102
  email: text("email"),
103
+ phone: text("phone"),
103
104
  }, (table) => [
104
105
  unique().on(table.provider, table.externalId),
105
106
  index("identities_by_email").on(table.email).where(sql `email IS NOT NULL`),
@@ -34,6 +34,8 @@ export interface PersonFacts {
34
34
  description?: string | null;
35
35
  /** An address the provider shows for them: they become one person with the mail contact of that exact address. */
36
36
  email?: string | null;
37
+ /** As the provider showed it, whole; only where it shows it to the owner. */
38
+ phone?: string | null;
37
39
  }
38
40
  /** One identity in one messenger, by the messenger's own id. `provider` is any string: `email` is one. */
39
41
  export interface IdentityRef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.478.0",
3
+ "version": "0.480.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",
package/parity.json CHANGED
@@ -3671,16 +3671,16 @@
3671
3671
  "in": "all"
3672
3672
  },
3673
3673
  "reactions list": {
3674
- "in": ["tg"],
3675
- "reason": "max-cli and whatsapp-cli do not list who reacted to a message",
3674
+ "in": ["tg", "wa"],
3675
+ "reason": "max-cli does not list who reacted to a message",
3676
3676
  "options": {
3677
3677
  "--cursor": {
3678
- "in": ["tg"],
3679
- "reason": "max-cli and whatsapp-cli do not list who reacted to a message"
3678
+ "in": ["tg", "wa"],
3679
+ "reason": "max-cli does not list who reacted to a message"
3680
3680
  },
3681
3681
  "--limit": {
3682
- "in": ["tg"],
3683
- "reason": "max-cli and whatsapp-cli do not list who reacted to a message"
3682
+ "in": ["tg", "wa"],
3683
+ "reason": "max-cli does not list who reacted to a message"
3684
3684
  }
3685
3685
  }
3686
3686
  },