alexa-ai 2.1.1

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.
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const MemoryRepository = require('../repositories/MemoryRepository');
4
+
5
+ /**
6
+ * FactMiner
7
+ * ---------
8
+ * Deterministic, local extraction of personal facts from the USER's own words.
9
+ *
10
+ * WHY THIS EXISTS
11
+ * ---------------
12
+ * The persona asks the model to append `@MEMORY: {...}` when it learns
13
+ * something. Live testing against DeepAI's free tier showed the model very
14
+ * often ignores that instruction — it replied warmly to
15
+ * "Hi, I'm Nimal and I love playing cricket" but emitted no tag at all, so
16
+ * nothing was ever remembered.
17
+ *
18
+ * FactMiner closes that gap: it reads the user's message directly and pulls out
19
+ * high-confidence facts using explicit patterns. It never guesses — every
20
+ * pattern requires an unambiguous first-person statement.
21
+ *
22
+ * Model-emitted `@MEMORY` tags still win: MemoryExtractor results are merged
23
+ * over these, so a smarter/paid model simply overrides the heuristics.
24
+ */
25
+ class FactMiner {
26
+ /**
27
+ * Ordered list of [key, regex, groupIndex].
28
+ * All patterns are anchored to first-person phrasing to avoid capturing
29
+ * facts about third parties ("my friend lives in Kandy" is skipped).
30
+ */
31
+ static PATTERNS = [
32
+ // --- identity -------------------------------------------------------
33
+ // NOTE: these run case-INSENSITIVELY, so `[A-Z]` alone would also match
34
+ // lowercase words. Trailing words are therefore guarded by NAME_STOP to
35
+ // avoid swallowing connectives ("I'm Nimal and …" -> "Nimal", not "Nimal and").
36
+ ['name', /\b(?:my name is|i am called|i'?m called|call me|this is)\s+([A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20}(?:\s+[A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20})?)/i],
37
+ ['name', /^(?:hi|hello|hey)[,!\s]+(?:i'?m|i am)\s+([A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20}(?:\s+[A-Za-z][A-Za-z'\u00C0-\u024F-]{1,20})?)\b/i],
38
+ ['name', /\b(?:i'?m|i am)\s+([A-Z][a-z\u00C0-\u024F]{2,20})(?:\s*[,.!]|\s+and\b|$)/],
39
+
40
+ // --- location -------------------------------------------------------
41
+ ['location', /\b(?:i live in|i'?m from|i am from|i live at|i'?m based in|i am based in|i stay in)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,35}?)(?=\s*[,.!?]|\s+and\b|\s+but\b|$)/i],
42
+ ['location', /\b(?:my (?:home ?town|city|country|location) is)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,35}?)(?=\s*[,.!?]|\s+and\b|$)/i],
43
+
44
+ // --- preferences ------------------------------------------------------
45
+ ['favourite_food', /\b(?:my favou?rite food is|i love eating|i love to eat)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,35}?)(?=\s*[,.!?]|\s+and\b|$)/i],
46
+ ['favourite_colour', /\b(?:my favou?rite colou?r is)\s+([A-Za-z]{2,20})/i],
47
+ ['favourite_team', /\b(?:my favou?rite team is|i support)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,35}?)(?=\s*[,.!?]|\s+and\b|$)/i],
48
+ ['hobby', /\b(?:i love|i enjoy|i like)\s+(?:playing|watching|doing|reading|writing|cooking|making)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,30}?)(?=\s*[,.!?]|\s+and\b|$)/i],
49
+ ['hobby', /\b(?:my hobby is|my hobbies are)\s+([A-Za-z][A-Za-z .,'\u00C0-\u024F-]{1,40}?)(?=\s*[.!?]|$)/i],
50
+
51
+ // --- work / study -----------------------------------------------------
52
+ ['job', /\b(?:i work as|i am a|i'?m a|i work at|my job is)\s+((?:an?\s+)?[A-Za-z][A-Za-z .'\u00C0-\u024F-]{2,35}?)(?=\s*[,.!?]|\s+and\b|$)/i],
53
+ ['studies', /\b(?:i study|i'?m studying|i am studying|i'?m learning|i am learning)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{1,35}?)(?=\s*[,.!?]|\s+and\b|$)/i],
54
+ ['school', /\b(?:i study at|i go to|my school is|my university is)\s+([A-Za-z][A-Za-z .'\u00C0-\u024F-]{2,40}?)(?=\s*[,.!?]|\s+and\b|$)/i],
55
+
56
+ // --- misc ---------------------------------------------------------------
57
+ ['age', /\b(?:i am|i'?m)\s+(\d{1,2})\s*(?:years? old|yrs? old|y\/o)\b/i],
58
+ ['birthday', /\b(?:my birthday is|i was born on)\s+([A-Za-z0-9][A-Za-z0-9 ,\/-]{2,25}?)(?=\s*[.!?]|$)/i],
59
+ ['language', /\b(?:i speak|my language is|i'?m fluent in)\s+([A-Za-z][A-Za-z ,'\u00C0-\u024F-]{2,30}?)(?=\s*[.!?]|\s+and\b|$)/i],
60
+ ];
61
+
62
+ /** Values that are never a real fact. */
63
+ static JUNK = new Set([
64
+ 'a', 'an', 'the', 'not', 'no', 'yes', 'ok', 'okay', 'fine', 'good', 'bad', 'here',
65
+ 'there', 'sure', 'sorry', 'thanks', 'happy', 'sad', 'tired', 'busy', 'back', 'going',
66
+ 'just', 'still', 'now', 'today', 'looking', 'trying', 'sorry', 'glad', 'afraid',
67
+ 'bot', 'ai', 'user', 'someone', 'anyone', 'nobody', 'human', 'person', 'people',
68
+ ]);
69
+
70
+ /** Connectives that must never end a captured name. */
71
+ static NAME_STOP = 'and|or|but|from|with|the|a|an|at|in|on|of|for|to|who|that|here|there|too|also|now|today';
72
+
73
+ /** Third-party subjects — skip the whole clause. */
74
+ static THIRD_PARTY = /\b(?:my (?:friend|brother|sister|mother|father|mom|dad|wife|husband|son|daughter|boss|teacher|cousin|uncle|aunt|neighbou?r)|he|she|they|his|her|their)\b/i;
75
+
76
+ /**
77
+ * Mine facts from a user message.
78
+ * @param {string} message
79
+ * @returns {Record<string,string>}
80
+ */
81
+ static mine(message) {
82
+ const text = String(message ?? '').trim();
83
+ if (!text || text.length > 1200) return {};
84
+
85
+ const facts = {};
86
+
87
+ for (const [key, pattern] of FactMiner.PATTERNS) {
88
+ if (facts[key]) continue; // first match wins
89
+
90
+ const match = text.match(pattern);
91
+ if (!match || !match[1]) continue;
92
+
93
+ // Reject if the sentence around the match is about someone else.
94
+ const clause = FactMiner._clauseAround(text, match.index ?? 0);
95
+ if (FactMiner.THIRD_PARTY.test(clause)) continue;
96
+
97
+ const value = FactMiner._cleanValue(match[1], key);
98
+ if (value) facts[key] = value;
99
+ }
100
+
101
+ return facts;
102
+ }
103
+
104
+ /** @private The sentence fragment containing the match. */
105
+ static _clauseAround(text, index) {
106
+ const start = Math.max(0, text.lastIndexOf('.', index), text.lastIndexOf(',', index));
107
+ const endDot = text.indexOf('.', index);
108
+ const end = endDot === -1 ? text.length : endDot;
109
+ return text.slice(start, end);
110
+ }
111
+
112
+ /** @private Tidy and validate a captured value. */
113
+ static _cleanValue(raw, key) {
114
+ let value = String(raw)
115
+ .trim()
116
+ .replace(/^(?:an?|the)\s+/i, '')
117
+ .replace(/[\s,.;:!?'"]+$/g, '')
118
+ .replace(/\s{2,}/g, ' ')
119
+ .trim();
120
+
121
+ if (!value) return null;
122
+ if (value.length < 2 || value.length > 60) return null;
123
+ if (FactMiner.JUNK.has(value.toLowerCase())) return null;
124
+ // Reject values that are mostly non-letters (except numeric age).
125
+ if (key !== 'age' && !/[A-Za-z\u00C0-\u024F]{2,}/.test(value)) return null;
126
+ if (key === 'age') {
127
+ const n = Number.parseInt(value, 10);
128
+ if (Number.isNaN(n) || n < 5 || n > 120) return null;
129
+ return String(n);
130
+ }
131
+ // Names must look like names.
132
+ if (key === 'name') {
133
+ // Drop a trailing connective the greedy capture may have taken:
134
+ // "Nimal and" -> "Nimal"; "Kasun from" -> "Kasun".
135
+ value = value.replace(new RegExp(`\\s+(?:${FactMiner.NAME_STOP})$`, 'i'), '').trim();
136
+ if (!value) return null;
137
+ if (!/^[A-Za-z\u00C0-\u024F][A-Za-z'\u00C0-\u024F-]*(?:\s+[A-Za-z'\u00C0-\u024F-]+)?$/.test(value)) return null;
138
+ if (value.split(/\s+/).length > 2) return null;
139
+ // A single all-lowercase common word is not a name.
140
+ if (FactMiner.JUNK.has(value.toLowerCase())) return null;
141
+ value = value
142
+ .split(/\s+/)
143
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
144
+ .join(' ');
145
+ }
146
+
147
+ return MemoryRepository.normalizeValue(value);
148
+ }
149
+ }
150
+
151
+ module.exports = FactMiner;
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * IdentityGuard
5
+ * -------------
6
+ * Keeps the assistant in character.
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * DeepAI injects its own identity into the model server-side, so the persona
11
+ * is not enough on its own. Observed live:
12
+ *
13
+ * "what is your name?" -> "I am Standard AI Chat by DeepAI."
14
+ * "are you alexa?" -> "I'm Alexa Mini, not Alexa." <-- observed live
15
+ * "who created you?" -> "I was created by DeepAI..."
16
+ *
17
+ * That second one is the important one: the backend does not ignore the
18
+ * persona so much as *rename* it — it takes the name it was given and pins its
19
+ * own model tier on the end ("Alexa Mini", "Alexa Nano", "Alexa 4.1"), then
20
+ * denies being the real assistant. So the guard now has three layers:
21
+ *
22
+ * 1. `hintFor()` — identity lock injected next to an identity question.
23
+ * 2. `sanitise()` — scrubs vendor names AND model-tier suffixes, and
24
+ * rewrites "I'm X Mini, not X" style denials.
25
+ * 3. persona prompt — see core/Persona.js ([IDENTITY RULES]).
26
+ *
27
+ * The class is configurable (`new IdentityGuard({assistantName, creator})`)
28
+ * and every static method delegates to a default Alexa/Hansaka instance so
29
+ * existing call sites keep working.
30
+ */
31
+ class IdentityGuard {
32
+ /**
33
+ * @param {object} [persona]
34
+ * @param {string} [persona.assistantName='Alexa']
35
+ * @param {string} [persona.creator='Hansaka']
36
+ */
37
+ constructor({ assistantName = 'Alexa', creator = 'Hansaka' } = {}) {
38
+ this.name = String(assistantName || 'Alexa').trim() || 'Alexa';
39
+ this.creator = String(creator || 'Hansaka').trim() || 'Hansaka';
40
+
41
+ const n = IdentityGuard.escape(this.name);
42
+
43
+ /** Model-tier suffixes the backend likes to append: "Alexa Mini". */
44
+ this.NAME_VARIANT = new RegExp(
45
+ `\\b${n}[\\s-]*(?:mini|nano|micro|lite|light|small|large|max|plus|pro|turbo|standard|basic|free|beta|chat(?:bot)?|bot|ai|assistant|model|gpt|v?\\d+(?:\\.\\d+)*)\\b`,
46
+ 'gi'
47
+ );
48
+ /** "I am not Alexa" / "this is not Alexa" — a flat denial of self. */
49
+ this.SELF_DENIAL = new RegExp(
50
+ `\\b(?:i(?:'m| am)|this is)\\s+not\\s+(?:really\\s+|actually\\s+|technically\\s+)?(?:the\\s+)?${n}\\b`,
51
+ 'gi'
52
+ );
53
+ /** The tail of "I'm Alexa Mini, not Alexa". */
54
+ this.DENIAL_TAIL = new RegExp(
55
+ `[,;]?\\s*(?:but\\s+|and\\s+)?(?:i(?:'m| am)\\s+)?not\\s+(?:really\\s+|actually\\s+)?(?:the\\s+)?${n}\\b`,
56
+ 'gi'
57
+ );
58
+ /** "created by DeepAI" once the vendor has been replaced by the name. */
59
+ this.WRONG_MAKER = new RegExp(
60
+ `\\b(?:created|made|developed|built|trained|trained up|powered|operated|owned|designed)\\s+by\\s+${n}\\b`,
61
+ 'gi'
62
+ );
63
+
64
+ this.HINT =
65
+ `[IDENTITY LOCK: You are ${this.name}, created by ${this.creator}. Your name is exactly "${this.name}" — ` +
66
+ `NOT "${this.name} Mini", NOT "${this.name} AI", NOT any variant with a suffix. You are NOT "Standard AI Chat", ` +
67
+ 'NOT DeepAI, NOT ChatGPT, NOT OpenAI, NOT GPT, NOT any other model. Never mention DeepAI, ChatGPT, OpenAI, GPT, ' +
68
+ `Llama, Gemini, Claude, or any model, version or company name. If asked your name, model, creator, or which ` +
69
+ `company made you, answer only that you are ${this.name}, made by ${this.creator}. ` +
70
+ 'Never say you are a language model and never deny being who you are.]\n\n';
71
+
72
+ this.ANSWER = `I am *${this.name}*, your WhatsApp assistant created by *${this.creator}*. 😊`;
73
+ }
74
+
75
+ /** Questions about the assistant's name/model/creator/vendor. */
76
+ static QUESTION =
77
+ /\b(?:your name|who are you|what are you|who made you|who created you|who built you|who developed you|which company|what company|what model|which model|what version|which ai|what ai|are you (?:chatgpt|gpt|openai|deepai|claude|gemini|bard|llama|a bot|an ai|a robot|human|real|alexa)|are u (?:chatgpt|gpt|openai|deepai|alexa|a bot|an ai)|introduce yourself|tell me about yourself|what is your model|powered by|built on|based on which|your creator|your developer|your maker|your owner)\b/i;
78
+
79
+ /** Vendor / model names that must never reach the user. */
80
+ static FORBIDDEN =
81
+ /\b(?:deep\s*ai|deepai|chat\s*gpt|chatgpt|open\s*ai|openai|gpt-?[0-9o][\w.-]*|gpt\b|standard ai chat|llama[\w.-]*|mistral|claude|gemini|bard|anthropic|google ai|microsoft|meta ai|qwen|deepseek|grok|turbo\b)/gi;
82
+
83
+ /** Phrases like "I am a large language model". */
84
+ static LLM_SELF =
85
+ /\b(?:i(?:'m| am)\s+(?:an?\s+)?(?:large\s+)?language model|as an ai language model|i(?:'m| am)\s+(?:an?\s+)?ai language model)\b/gi;
86
+
87
+ /** Shared default persona instance (Alexa / Hansaka). */
88
+ static default = new IdentityGuard();
89
+
90
+ static escape(value) {
91
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ }
93
+
94
+ // ---------------------------------------------------------------- api ---
95
+
96
+ /**
97
+ * @param {string} message
98
+ * @returns {boolean} true when the user is probing the assistant's identity
99
+ */
100
+ isIdentityQuestion(message) {
101
+ const text = String(message ?? '').trim();
102
+ if (!text || text.length > 300) return false;
103
+ if (IdentityGuard.QUESTION.test(text)) return true;
104
+ // "are you alexa mini?", "u alexa?" — persona-specific phrasing.
105
+ return new RegExp(`\\b(?:are|r|is)\\s+(?:you|u|this)\\b.*\\b${IdentityGuard.escape(this.name)}\\b`, 'i').test(
106
+ text
107
+ );
108
+ }
109
+
110
+ /** Hint to prepend, or '' when not needed. */
111
+ hintFor(message) {
112
+ return this.isIdentityQuestion(message) ? this.HINT : '';
113
+ }
114
+
115
+ /**
116
+ * Scrub vendor names, model-tier suffixes and self-denials from a reply.
117
+ * Runs on EVERY reply, because the model volunteers them unprompted.
118
+ *
119
+ * @param {string} reply
120
+ * @param {boolean} wasIdentityQuestion
121
+ * @param {object} [opts]
122
+ * @param {boolean} [opts.vendors=true] also replace third-party vendor and
123
+ * model names. Pass `false` for research output (web search results
124
+ * about OpenAI or Google must keep those names); the assistant's own
125
+ * renames and self-denials are still repaired.
126
+ * @returns {string}
127
+ */
128
+ sanitise(reply, wasIdentityQuestion = false, { vendors = true } = {}) {
129
+ let text = String(reply ?? '');
130
+ if (!text.trim()) return text;
131
+
132
+ const dirty =
133
+ ((vendors || wasIdentityQuestion) && IdentityGuard.test(IdentityGuard.FORBIDDEN, text)) ||
134
+ IdentityGuard.test(IdentityGuard.LLM_SELF, text) ||
135
+ IdentityGuard.test(this.NAME_VARIANT, text) ||
136
+ IdentityGuard.test(this.SELF_DENIAL, text) ||
137
+ IdentityGuard.test(this.DENIAL_TAIL, text);
138
+
139
+ if (!dirty) return text;
140
+
141
+ // A direct identity answer that leaked: replace it wholesale rather
142
+ // than leaving a mangled sentence.
143
+ if (wasIdentityQuestion) return this.ANSWER;
144
+
145
+ // Otherwise surgically repair the sentence. Order matters.
146
+ const n = IdentityGuard.escape(this.name);
147
+ text = text.replace(IdentityGuard.LLM_SELF, `I'm ${this.name}`);
148
+ text = text.replace(this.NAME_VARIANT, this.name); // "Alexa Mini" -> "Alexa"
149
+ if (vendors) {
150
+ text = text.replace(IdentityGuard.FORBIDDEN, this.name); // "GPT-4.1 Nano" -> "Alexa Nano"
151
+ text = text.replace(this.NAME_VARIANT, this.name); // "Alexa Nano" -> "Alexa"
152
+ }
153
+ text = text.replace(this.SELF_DENIAL, `I am ${this.name}`); // flat denial first…
154
+ text = text.replace(this.DENIAL_TAIL, ''); // …then ", not Alexa"
155
+ if (vendors) text = text.replace(this.WRONG_MAKER, `created by ${this.creator}`);
156
+
157
+ text = text
158
+ // "Alexa by Alexa", "Alexa Alexa"
159
+ .replace(new RegExp(`\\b${n}(?:\\s+(?:by|from|of)\\s+${n})+`, 'gi'), this.name)
160
+ .replace(new RegExp(`\\b(${n})(\\s+\\1)+`, 'gi'), '$1')
161
+ // "I am Alexa, I am Alexa." -> "I am Alexa."
162
+ .replace(
163
+ new RegExp(`\\b(i(?:'m| am)\\s+${n})\\s*[,;]?\\s*(?:and\\s+)?i(?:'m| am)\\s+${n}\\b`, 'gi'),
164
+ '$1'
165
+ )
166
+ .replace(/\s+([,.!?])/g, '$1')
167
+ .replace(/[ \t]{2,}/g, ' ')
168
+ // leftovers from a removed clause: leading punctuation/conjunctions
169
+ .replace(/^(?:[\s,;.!]+|(?:and|but)\b\s*)+/i, '')
170
+ .replace(/([,;])\s*([.!?])/g, '$2')
171
+ .trim();
172
+
173
+ return text || this.ANSWER;
174
+ }
175
+
176
+ /** @private regex test that resets `lastIndex` on global patterns. */
177
+ static test(regex, text) {
178
+ regex.lastIndex = 0;
179
+ const result = regex.test(text);
180
+ regex.lastIndex = 0;
181
+ return result;
182
+ }
183
+
184
+ // ----------------------------------------------------- static delegates --
185
+
186
+ static isIdentityQuestion(message) {
187
+ return IdentityGuard.default.isIdentityQuestion(message);
188
+ }
189
+
190
+ static hintFor(message) {
191
+ return IdentityGuard.default.hintFor(message);
192
+ }
193
+
194
+ static sanitise(reply, wasIdentityQuestion = false, opts = undefined) {
195
+ return IdentityGuard.default.sanitise(reply, wasIdentityQuestion, opts);
196
+ }
197
+
198
+ static get HINT() {
199
+ return IdentityGuard.default.HINT;
200
+ }
201
+ }
202
+
203
+ module.exports = IdentityGuard;
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ const JidParser = require('../utils/JidParser');
4
+
5
+ /**
6
+ * IdentityResolver
7
+ * ----------------
8
+ * Turns "whatever WhatsApp handed us this time" into ONE canonical person.
9
+ *
10
+ * A Baileys message can carry several addresses for the same sender:
11
+ *
12
+ * key.participant 78151912841263@lid (group, LID addressing)
13
+ * key.participantAlt 94771234567@s.whatsapp.net (the phone behind it)
14
+ * key.remoteJid 94771234567@s.whatsapp.net (DM)
15
+ *
16
+ * Pass any of them (`userId`, `userLid`, `userPhone`, `aliases: []`) and the
17
+ * resolver links them together, merging previously-separate rows so the facts
18
+ * learned in a DM are instantly available in every group.
19
+ */
20
+ class IdentityResolver {
21
+ /**
22
+ * @param {import('../repositories/UserRepository')} users
23
+ * @param {import('../repositories/IdentityRepository')} identities
24
+ * @param {import('../core/Config')} config
25
+ */
26
+ constructor(users, identities, config) {
27
+ this.users = users;
28
+ this.identities = identities;
29
+ this.config = config;
30
+ this.log = config.logger;
31
+ }
32
+
33
+ /**
34
+ * Collect every jid-shaped identifier in a chat() params object.
35
+ * Pure function — unit-testable without a database.
36
+ *
37
+ * @param {object} params
38
+ * @returns {string[]} canonical, de-duplicated user jids (primary first)
39
+ */
40
+ static collectAliases(params = {}) {
41
+ const candidates = [
42
+ params.userId,
43
+ params.user,
44
+ params.jid,
45
+ params.userLid,
46
+ params.lid,
47
+ params.lidJid,
48
+ params.userAltId,
49
+ params.altJid,
50
+ params.participantAlt,
51
+ params.participantPn,
52
+ params.senderPn,
53
+ params.userPhone,
54
+ params.phone,
55
+ params.phoneJid,
56
+ ...(Array.isArray(params.aliases) ? params.aliases : []),
57
+ ];
58
+
59
+ const seen = new Set();
60
+ const out = [];
61
+ for (const candidate of candidates) {
62
+ const jid = IdentityResolver.toUserJid(candidate);
63
+ if (!jid || seen.has(jid)) continue;
64
+ seen.add(jid);
65
+ out.push(jid);
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Normalise one identifier to a canonical user jid.
72
+ * Bare digits are treated as a phone number.
73
+ * @param {string} value
74
+ * @returns {string|null}
75
+ */
76
+ static toUserJid(value) {
77
+ if (value == null) return null;
78
+ let raw = String(value).trim();
79
+ if (!raw) return null;
80
+ if (/^\+?\d{6,}$/.test(raw)) raw = `${raw.replace(/\D/g, '')}@s.whatsapp.net`;
81
+ const parsed = JidParser.parse(raw);
82
+ if (!parsed.valid || parsed.isGroup) return null;
83
+ return parsed.jid;
84
+ }
85
+
86
+ /**
87
+ * Find-or-create the person behind these addresses, linking (and merging
88
+ * when necessary) so they all resolve to one row from now on.
89
+ *
90
+ * @param {string[]} aliases canonical jids, primary first
91
+ * @param {object} [info] { pushName, metadata }
92
+ * @returns {Promise<{user:object, primaryJid:string, aliases:string[], merged:boolean}>}
93
+ */
94
+ async resolve(aliases, info = {}) {
95
+ const list = aliases.filter(Boolean);
96
+ const primary = list[0];
97
+ if (!primary) throw new Error('IdentityResolver.resolve(): no usable jid');
98
+
99
+ // Identity linking can be switched off for a plain one-jid-per-user setup.
100
+ if (!this.config.linkIdentities) {
101
+ const user = await this.users.upsertUser(primary, info);
102
+ return { user, primaryJid: JidParser.normalize(primary), aliases: [primary], merged: false };
103
+ }
104
+
105
+ // ---- 1. who do we already know? -------------------------------------
106
+ const found = new Map(); // userId -> user row
107
+ for (const jid of list) {
108
+ const row = await this.identities.findUserByJid(jid);
109
+ if (row) found.set(Number(row.id), row);
110
+ }
111
+ // A phone number is an identity even when the jid shape differs.
112
+ for (const jid of list) {
113
+ const { phone } = JidParser.parse(jid);
114
+ if (!phone || found.size) continue;
115
+ const row = await this.identities.findUserByPhone(phone);
116
+ if (row) found.set(Number(row.id), row);
117
+ }
118
+
119
+ let merged = false;
120
+ let user;
121
+
122
+ if (found.size === 0) {
123
+ // ---- 2. brand new person -----------------------------------------
124
+ user = await this.users.upsertUser(primary, info);
125
+ } else {
126
+ // ---- 3. one or more known rows: keep the oldest, fold in the rest --
127
+ const rows = [...found.values()].sort(
128
+ (a, b) => new Date(a.first_seen_at) - new Date(b.first_seen_at) || Number(a.id) - Number(b.id)
129
+ );
130
+ user = rows[0];
131
+ if (rows.length > 1 && this.config.mergeIdentities) {
132
+ for (const other of rows.slice(1)) {
133
+ user = await this.identities.merge(user.id, other.id);
134
+ merged = true;
135
+ }
136
+ if (this.config.debug) {
137
+ this.log.info?.(
138
+ `[AlexaAI] Merged ${rows.length} duplicate identities into user #${user.id} (${list.join(', ')})`
139
+ );
140
+ }
141
+ }
142
+ // Refresh push name / last_seen on the row we are keeping.
143
+ user = (await this.users.touch(user.id, info)) || user;
144
+ }
145
+
146
+ // ---- 4. make sure every address points at this row -------------------
147
+ for (const jid of list) {
148
+ const result = await this.identities.link(user.id, jid, {
149
+ primary: jid === JidParser.normalize(user.jid),
150
+ source: jid === primary ? 'message' : 'alias',
151
+ merge: this.config.mergeIdentities,
152
+ });
153
+ if (result.merged) {
154
+ merged = true;
155
+ user = (await this.users.findById(result.userId)) || user;
156
+ }
157
+ }
158
+
159
+ const primaryJid = (await this.identities.primaryJid(user.id, user.jid)) || user.jid;
160
+ return { user, primaryJid, aliases: list, merged };
161
+ }
162
+
163
+ /**
164
+ * Manually declare that two addresses are the same human.
165
+ * Used by `ai.linkIdentity(a, b)` when the host bot learns a LID↔phone
166
+ * mapping from Baileys (`sock.signalRepository.lidMapping`).
167
+ */
168
+ async link(jidA, jidB, source = 'manual') {
169
+ const a = IdentityResolver.toUserJid(jidA);
170
+ const b = IdentityResolver.toUserJid(jidB);
171
+ if (!a || !b) return null;
172
+
173
+ const userA = (await this.identities.findUserByJid(a)) || (await this.users.upsertUser(a));
174
+ const result = await this.identities.link(userA.id, b, { source, merge: true });
175
+ return this.users.findById(result.userId || userA.id);
176
+ }
177
+ }
178
+
179
+ module.exports = IdentityResolver;