@wirecat/cli-core 0.477.0 → 0.479.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli/messenger/catch-up-answers.d.ts +2 -0
  3. package/dist/cli/messenger/catch-up-answers.js +2 -1
  4. package/dist/cli/messenger/context.d.ts +4 -1
  5. package/dist/cli/messenger/context.js +4 -0
  6. package/dist/cli/messenger/message-items.d.ts +1 -1
  7. package/dist/cli/messenger/message-items.js +2 -2
  8. package/dist/cli/messenger/search-answers.js +5 -1
  9. package/dist/domain/models.d.ts +6 -0
  10. package/dist/domain/models.js +2 -0
  11. package/dist/entities/hit.d.ts +2 -0
  12. package/dist/entities/hit.js +2 -0
  13. package/dist/entities/index.d.ts +8 -1
  14. package/dist/entities/index.js +2 -0
  15. package/dist/ports/messenger.d.ts +2 -2
  16. package/dist/services/person-profile.js +8 -3
  17. package/dist/services/pictures.d.ts +2 -1
  18. package/dist/services/pictures.js +2 -0
  19. package/dist/services/search-all.d.ts +3 -1
  20. package/dist/services/search-all.js +2 -0
  21. package/dist/store/messages.d.ts +3 -1
  22. package/dist/store/messages.js +4 -0
  23. package/dist/store/people.d.ts +2 -0
  24. package/dist/store/people.js +1 -0
  25. package/dist/store/sqlite/identities.d.ts +1 -0
  26. package/dist/store/sqlite/identities.js +10 -1
  27. package/dist/store/sqlite/lucene-fields.d.ts +7 -0
  28. package/dist/store/sqlite/lucene-fields.js +7 -0
  29. package/dist/store/sqlite/manifest.js +2 -0
  30. package/dist/store/sqlite/mention-drain.js +6 -6
  31. package/dist/store/sqlite/mentions.d.ts +1 -1
  32. package/dist/store/sqlite/mentions.js +4 -1
  33. package/dist/store/sqlite/messages.d.ts +2 -1
  34. package/dist/store/sqlite/messages.js +10 -0
  35. package/dist/store/sqlite/migrations.generated.js +13 -0
  36. package/dist/store/sqlite/schema.d.ts +16 -0
  37. package/dist/store/sqlite/schema.js +1 -0
  38. package/dist/store/types.d.ts +2 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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
9
+
10
+ ## 0.479.0 — 29.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`StoredMessages.reactors(chatId, messageId)`** gives an adapter the reactions the store holds for a message, so a
15
+ messenger that sends reactions one person at a time (WhatsApp) can implement `MessageReactions.reactors` from the
16
+ store. One `Reactor` per person (`person`, `reaction`, `reactedAt`), newest first; a person's newest reaction wins
17
+ 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.
18
+ - **A person's phone is saved in the store** (store version 37, additive, minCompatible stays 34). `contacts sync`
19
+ saves the phone an address book entry carries (`AddressBookEntry`, a `Member` with an optional `phone`), and an
20
+ online `contacts show` saves the phone the profile shows, so `contacts show --offline` answers it. A read that
21
+ shows no phone never erases a saved one.
22
+ - **A shared contact card's phone is a mention** of field `contact`, beside `file_text` and `transcript`:
23
+ `mentions list kind:phone` and `phone:` search find it, and `mentions rebuild` reads cards stored before.
24
+
25
+ ## 0.478.0 — 29.10.2026
26
+
27
+ ### Fixed
28
+
29
+ - **`sender_is_me` on `inbox`, `review`, `search messages` and `search all`**, as 0.473.0 promised. The catch-up
30
+ answers and the search hits are built field by field, not from `messageItem`, and left it out; only `messages list`
31
+ and the other `messageItem` answers had it. A `search all` hit carries it for a message or a mail, and the picture
32
+ lane's hits of `search messages` carry it too. Every answer applies the one rule: `outgoing`, or the sender id is
33
+ the reading account's own.
34
+
8
35
  ## 0.477.0 — 29.10.2026
9
36
 
10
37
  ### Added
@@ -20,6 +20,7 @@ export declare const inboxAnswer: (inbox: Inbox, hearing: Hearing | undefined, r
20
20
  chat_title: string | null | undefined;
21
21
  timestamp: string;
22
22
  sender_name: string | null;
23
+ sender_is_me: boolean;
23
24
  outgoing: boolean | null;
24
25
  text: string;
25
26
  transcript?: string | undefined;
@@ -74,6 +75,7 @@ export declare const reviewAnswer: (found: Review, hearing: Hearing | undefined,
74
75
  chat_title: string | null | undefined;
75
76
  timestamp: string;
76
77
  sender_name: string | null;
78
+ sender_is_me: boolean;
77
79
  outgoing: boolean | null;
78
80
  text: string;
79
81
  transcript?: string | undefined;
@@ -34,13 +34,14 @@ export const catchUpQuery = (words, registry, command) => {
34
34
  };
35
35
  };
36
36
  const summaryOf = (message, reader) => {
37
- const { ref, chat_id, chat_title, timestamp, sender_name, outgoing, text, transcript, attachments } = messageItem(message, reader);
37
+ const { ref, chat_id, chat_title, timestamp, sender_name, sender_is_me, outgoing, text, transcript, attachments } = messageItem(message, reader);
38
38
  return {
39
39
  ref,
40
40
  chat_id,
41
41
  chat_title,
42
42
  timestamp,
43
43
  sender_name,
44
+ sender_is_me,
44
45
  outgoing,
45
46
  text,
46
47
  ...(transcript === undefined ? {} : { transcript }),
@@ -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,9 @@ 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
+ * Optional so an adapter that builds its own `StoredMessages` still compiles. */
36
+ reactors?(chatId: Id, messageId: Id): Promise<Reactor[]>;
34
37
  }
35
38
  /** `listen` opens a connection that receives updates — only `watch` asks; the rest stay quiet. */
36
39
  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
  /**
@@ -1,5 +1,5 @@
1
1
  import type * as v from "valibot";
2
- import type { Message, MessageHit } from "../../domain/models.js";
2
+ import { type Message, type MessageHit } from "../../domain/models.js";
3
3
  import type { message as messageSchema } from "../../entities/message.js";
4
4
  import type { MessengerAdapter } from "../../ports/messenger.js";
5
5
  import type { AccountKey } from "../../store/store.js";
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { formatLocator, parseLocator } from "../../domain/locator.js";
3
+ import { senderIsMe, } from "../../domain/models.js";
3
4
  const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined));
4
5
  const attachmentItem = (attachment) => defined({
5
6
  kind: attachment.kind,
@@ -50,8 +51,7 @@ export const messageItem = (message, reader) => {
50
51
  sender_name: message.senderName,
51
52
  sender_username: message.senderUsername,
52
53
  sender_is_chat: message.senderIsChat,
53
- // MAX reports the owner's own messages with no sender id, so `outgoing` counts as well as the id.
54
- sender_is_me: message.outgoing === true || (message.senderId !== null && String(message.senderId) === account),
54
+ sender_is_me: senderIsMe(message, account),
55
55
  mentions: message.mentions,
56
56
  timestamp: message.timestamp,
57
57
  edited_at: message.editedAt,
@@ -1,4 +1,5 @@
1
1
  import { formatLocator, parseLocator } from "../../domain/locator.js";
2
+ import { senderIsMe } from "../../domain/models.js";
2
3
  import { DEFAULT_TEXT_MODEL } from "../../embeddings/models.js";
3
4
  import { excerpt } from "../../services/excerpt.js";
4
5
  import { MAX_SEARCH_OFFSET } from "../../services/messages-archive-search.js";
@@ -28,6 +29,7 @@ export const messageHitItem = (hit, query = "") => {
28
29
  chat_title: hit.chatTitle ?? undefined,
29
30
  timestamp: hit.timestamp,
30
31
  sender_name: hit.senderName,
32
+ sender_is_me: senderIsMe(hit, at.account),
31
33
  excerpt: hit.excerpt ?? excerpt(hit.text, query),
32
34
  matched_in: hit.matchedIn,
33
35
  score: hit.score ?? undefined,
@@ -165,10 +167,11 @@ const pictureItems = (found) => {
165
167
  const shown = new Set(found.items.map(({ locator }) => locator));
166
168
  return (found.pictures ?? [])
167
169
  .filter(({ locator }) => !shown.has(locator))
168
- .map(({ locator, timestamp, senderName, score }) => ({
170
+ .map(({ locator, timestamp, senderName, senderIsMe, score }) => ({
169
171
  ref: locator,
170
172
  timestamp,
171
173
  sender_name: senderName,
174
+ sender_is_me: senderIsMe,
172
175
  matched_in: "picture",
173
176
  score,
174
177
  untrusted: ["sender_name"],
@@ -226,6 +229,7 @@ export const allHitItem = (item) => item.kind === "meeting"
226
229
  url: item.url,
227
230
  excerpt: item.text,
228
231
  sender_name: item.from?.name,
232
+ sender_is_me: item.senderIsMe,
229
233
  sender_ref: item.from?.ref,
230
234
  found_by: item.foundBy,
231
235
  untrusted: ALL_HIT_UNTRUSTED[item.kind],
@@ -366,6 +366,8 @@ export interface Message {
366
366
  */
367
367
  delivery?: Delivery;
368
368
  }
369
+ /** MAX reports the owner's own messages with no sender id, so `outgoing` counts as well as the id. */
370
+ export declare const senderIsMe: (message: Pick<Message, "outgoing" | "senderId">, account: string) => boolean;
369
371
  /** `delivered` reached the other person's phone; `read` they opened it (a voice note or view-once file played counts). */
370
372
  export type Delivery = "delivered" | "read";
371
373
  /**
@@ -572,6 +574,10 @@ export interface Contact {
572
574
  }
573
575
  /** Somebody in a chat, as much of them as a chat card shows. */
574
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
+ };
575
581
  /** A topic of a forum group; its messages carry this id as `threadId`. */
576
582
  export interface Topic {
577
583
  id: Id;
@@ -9,5 +9,7 @@ export const FOLDER_KINDS = ["contacts", "non-contacts", "groups", "channels", "
9
9
  export const FOLDER_SKIPS = ["muted", "read", "archived"];
10
10
  /** What an admin may do, in max-cli's words; a messenger maps them onto its own. */
11
11
  export const ADMIN_RIGHTS = ["read", "members", "admins", "info", "pin", "link", "post", "edit", "delete"];
12
+ /** MAX reports the owner's own messages with no sender id, so `outgoing` counts as well as the id. */
13
+ export const senderIsMe = (message, account) => message.outgoing === true || (message.senderId !== null && String(message.senderId) === account);
12
14
  /** Kinds of attachment `chats media` filters by. */
13
15
  export const MEDIA_KINDS = ["photo", "video", "file", "audio", "link"];
@@ -13,6 +13,8 @@ export declare const hit: v.StrictObjectSchema<{
13
13
  readonly timestamp: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
14
14
  readonly excerpt: v.StringSchema<undefined>;
15
15
  readonly sender_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
16
+ /** A message's or a mail's: whether the owner wrote it. */
17
+ readonly sender_is_me: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
16
18
  /** The writer's `person:` reference, where the store has one. */
17
19
  readonly sender_ref: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
18
20
  readonly found_by: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<["title", "words", "meaning"], undefined>, undefined>, undefined>;
@@ -14,6 +14,8 @@ export const hit = v.strictObject({
14
14
  timestamp: v.nullable(time),
15
15
  excerpt: text,
16
16
  sender_name: v.optional(v.nullable(text)),
17
+ /** A message's or a mail's: whether the owner wrote it. */
18
+ sender_is_me: v.optional(v.boolean()),
17
19
  /** The writer's `person:` reference, where the store has one. */
18
20
  sender_ref: v.optional(v.nullable(reference)),
19
21
  found_by: v.optional(v.array(v.picklist(["title", "words", "meaning"]))),
@@ -1141,6 +1141,7 @@ export declare const ENTITIES: {
1141
1141
  readonly ref: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>;
1142
1142
  readonly chat_title: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
1143
1143
  readonly sender_name: v.NullableSchema<v.StringSchema<undefined>, undefined>;
1144
+ readonly sender_is_me: v.BooleanSchema<undefined>;
1144
1145
  readonly timestamp: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>;
1145
1146
  readonly excerpt: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
1146
1147
  readonly matched_in: v.OptionalSchema<v.PicklistSchema<["text", "transcript", "filename", "file", "context", "picture"], undefined>, undefined>;
@@ -1623,12 +1624,13 @@ export declare const ENTITIES: {
1623
1624
  }, undefined>, undefined>;
1624
1625
  }, undefined>, undefined>;
1625
1626
  readonly provider_metadata: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.UnknownSchema, undefined>, undefined>;
1626
- }, "attachments" | "chat_id" | "chat_title" | "outgoing" | "ref" | "sender_name" | "text" | "timestamp" | "transcript">;
1627
+ }, "attachments" | "chat_id" | "chat_title" | "outgoing" | "ref" | "sender_is_me" | "sender_name" | "text" | "timestamp" | "transcript">;
1627
1628
  readonly "~standard": v.StandardProps<{
1628
1629
  ref: string;
1629
1630
  chat_id: string;
1630
1631
  chat_title?: string | null | undefined;
1631
1632
  sender_name: string | null;
1633
+ sender_is_me: boolean;
1632
1634
  timestamp: string;
1633
1635
  text: string;
1634
1636
  transcript?: string | undefined;
@@ -1671,6 +1673,7 @@ export declare const ENTITIES: {
1671
1673
  chat_id: string;
1672
1674
  chat_title?: string | null | undefined;
1673
1675
  sender_name: string | null;
1676
+ sender_is_me: boolean;
1674
1677
  timestamp: string;
1675
1678
  text: string;
1676
1679
  transcript?: string | undefined;
@@ -1714,6 +1717,7 @@ export declare const ENTITIES: {
1714
1717
  chat_id: string;
1715
1718
  chat_title?: string | null | undefined;
1716
1719
  sender_name: string | null;
1720
+ sender_is_me: boolean;
1717
1721
  timestamp: string;
1718
1722
  text: string;
1719
1723
  transcript?: string | undefined;
@@ -1758,6 +1762,7 @@ export declare const ENTITIES: {
1758
1762
  chat_id: string;
1759
1763
  chat_title?: string | null | undefined;
1760
1764
  sender_name: string | null;
1765
+ sender_is_me: boolean;
1761
1766
  timestamp: string;
1762
1767
  text: string;
1763
1768
  transcript?: string | undefined;
@@ -1801,6 +1806,7 @@ export declare const ENTITIES: {
1801
1806
  chat_id: string;
1802
1807
  chat_title?: string | null | undefined;
1803
1808
  sender_name: string | null;
1809
+ sender_is_me: boolean;
1804
1810
  timestamp: string;
1805
1811
  text: string;
1806
1812
  transcript?: string | undefined;
@@ -4469,6 +4475,7 @@ export declare const ENTITIES: {
4469
4475
  readonly timestamp: v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.IsoTimestampAction<string, "an ISO 8601 time in UTC">, v.EndsWithAction<string, "Z", "an ISO 8601 time in UTC, ending in `Z`">]>, undefined>;
4470
4476
  readonly excerpt: v.StringSchema<undefined>;
4471
4477
  readonly sender_name: v.OptionalSchema<v.NullableSchema<v.StringSchema<undefined>, undefined>, undefined>;
4478
+ readonly sender_is_me: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
4472
4479
  readonly sender_ref: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, "a reference such as `note:42`">]>, undefined>, undefined>;
4473
4480
  readonly found_by: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<["title", "words", "meaning"], undefined>, undefined>, undefined>;
4474
4481
  readonly offset_ms: v.OptionalSchema<v.NullableSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>, undefined>;
@@ -108,6 +108,7 @@ export const ENTITIES = {
108
108
  "chat_title",
109
109
  "timestamp",
110
110
  "sender_name",
111
+ "sender_is_me",
111
112
  "excerpt",
112
113
  "matched_in",
113
114
  "score",
@@ -126,6 +127,7 @@ export const ENTITIES = {
126
127
  "chat_title",
127
128
  "timestamp",
128
129
  "sender_name",
130
+ "sender_is_me",
129
131
  "outgoing",
130
132
  "text",
131
133
  "transcript",
@@ -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,4 +1,4 @@
1
- import type { Id } from "../domain/models.js";
1
+ import { type Id } from "../domain/models.js";
2
2
  import type { ServiceDeps } from "./deps.js";
3
3
  import type { MessagesService } from "./messages.js";
4
4
  /** A bigger file is no photo a chat sends: skipping it costs a vector, reading it costs memory. */
@@ -20,6 +20,7 @@ export interface PictureHit {
20
20
  messageId: Id;
21
21
  timestamp: string;
22
22
  senderName: string | null;
23
+ senderIsMe: boolean;
23
24
  score: number;
24
25
  }
25
26
  export interface PictureLane {
@@ -3,6 +3,7 @@ import { readFile, stat } from "node:fs/promises";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
  import { CliError } from "@wirecat/cli-base";
5
5
  import { formatLocator } from "../domain/locator.js";
6
+ import { senderIsMe } from "../domain/models.js";
6
7
  import { DEFAULT_IMAGE_MODEL, imageModel, imageModelsDirectory, isImageModelInstalled, } from "../images/picture/models.js";
7
8
  import { capability } from "../ports/messenger.js";
8
9
  import { refreshedFiles } from "./file-download.js";
@@ -218,6 +219,7 @@ export const picturesService = (deps) => {
218
219
  messageId,
219
220
  timestamp: message.timestamp,
220
221
  senderName: message.senderName,
222
+ senderIsMe: senderIsMe(message, account.account),
221
223
  score: Number(score.toFixed(4)),
222
224
  },
223
225
  ];
@@ -1,5 +1,5 @@
1
1
  import { type ErrorCode } from "@wirecat/cli-base";
2
- import type { MailFields } from "../domain/models.js";
2
+ import { type MailFields } from "../domain/models.js";
3
3
  import type { MessengerTraits } from "../ports/traits.js";
4
4
  import type { AccountKey, MessageStore } from "../store/store.js";
5
5
  import type { SearchFound, SearchQuery } from "./messages.js";
@@ -25,6 +25,8 @@ export interface SearchAllItem {
25
25
  name: string | null;
26
26
  ref: string | null;
27
27
  };
28
+ /** A message's or a mail's: whether the owner wrote it. */
29
+ senderIsMe?: boolean;
28
30
  /** A mail's people, labels and thread, as `search mail` gives them. */
29
31
  email?: MailFields;
30
32
  /** Which search found it: words, meaning or both. */
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { parseLucene } from "@wirecat/cli-base/query";
3
3
  import { parseLocator } from "../domain/locator.js";
4
+ import { senderIsMe } from "../domain/models.js";
4
5
  import { INTERNAL_NOTES, kindIn } from "../domain/providers.js";
5
6
  import { runEmbedders } from "../embeddings/embed.js";
6
7
  import { DEFAULT_TEXT_MODEL } from "../embeddings/models.js";
@@ -96,6 +97,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
96
97
  from: hit.email?.from
97
98
  ? { name: hit.email.from.name ?? hit.email.from.address, ref: hit.email.from.ref }
98
99
  : { name: hit.senderName, ref: null },
100
+ senderIsMe: senderIsMe(hit, at.account),
99
101
  ...(hit.email ? { email: hit.email } : {}),
100
102
  foundBy,
101
103
  };
@@ -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.477.0",
3
+ "version": "0.479.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",