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.
- package/CHANGELOG.md +136 -0
- package/LICENSE +15 -0
- package/README.md +862 -0
- package/examples/bot-ai.js +531 -0
- package/examples/demo.js +147 -0
- package/index.js +75 -0
- package/package.json +50 -0
- package/src/AlexaAI.js +1099 -0
- package/src/core/Config.js +249 -0
- package/src/core/DeepAIClient.js +789 -0
- package/src/core/Endpoints.js +74 -0
- package/src/core/Persona.js +102 -0
- package/src/core/StreamParser.js +157 -0
- package/src/core/SystemPrompt.js +7 -0
- package/src/core/errors.js +51 -0
- package/src/db/Database.js +161 -0
- package/src/db/schema.sql +214 -0
- package/src/repositories/ConversationRepository.js +206 -0
- package/src/repositories/IdentityRepository.js +244 -0
- package/src/repositories/MemoryRepository.js +215 -0
- package/src/repositories/UserRepository.js +275 -0
- package/src/services/AmnesiaGuard.js +176 -0
- package/src/services/FactMiner.js +151 -0
- package/src/services/IdentityGuard.js +203 -0
- package/src/services/IdentityResolver.js +179 -0
- package/src/services/ImageDescriber.js +335 -0
- package/src/services/MathDetector.js +64 -0
- package/src/services/MemoryExtractor.js +142 -0
- package/src/services/PromptBuilder.js +216 -0
- package/src/services/ResponseFormatter.js +121 -0
- package/src/services/TriggerDetector.js +182 -0
- package/src/services/WebAnswer.js +573 -0
- package/src/utils/JidParser.js +148 -0
- package/src/utils/Media.js +235 -0
|
@@ -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;
|