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,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MemoryRepository
|
|
5
|
+
* ----------------
|
|
6
|
+
* Long-term facts about a person.
|
|
7
|
+
*
|
|
8
|
+
* Design rule: memories are keyed to `user_id` ONLY — never to a group. That
|
|
9
|
+
* is what makes Alexa recognise the same person's details in a DM and in any
|
|
10
|
+
* group. `UNIQUE (user_id, key)` means re-learning a key
|
|
11
|
+
* (e.g. the user moves city) overwrites instead of piling up duplicates.
|
|
12
|
+
*/
|
|
13
|
+
class MemoryRepository {
|
|
14
|
+
/** @param {import('../db/Database')} db */
|
|
15
|
+
constructor(db) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reserved keys that must never be stored as "facts". */
|
|
20
|
+
static BLOCKED_KEYS = new Set(['', 'null', 'undefined', 'none', 'n/a', 'memory', 'key', 'value']);
|
|
21
|
+
|
|
22
|
+
static MAX_KEY = 64;
|
|
23
|
+
static MAX_VALUE = 512;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Insert or update one fact.
|
|
27
|
+
* @param {number} userId
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @param {string} value
|
|
30
|
+
* @param {object} [opts]
|
|
31
|
+
* @param {string} [opts.source='auto']
|
|
32
|
+
* @param {string} [opts.learnedIn]
|
|
33
|
+
* @param {number} [opts.confidence=1]
|
|
34
|
+
* @param {Date|null} [opts.expiresAt]
|
|
35
|
+
* @returns {Promise<object|null>}
|
|
36
|
+
*/
|
|
37
|
+
async remember(userId, key, value, opts = {}) {
|
|
38
|
+
const normalisedKey = MemoryRepository.normalizeKey(key);
|
|
39
|
+
const normalisedValue = MemoryRepository.normalizeValue(value);
|
|
40
|
+
if (!userId || !normalisedKey || !normalisedValue) return null;
|
|
41
|
+
|
|
42
|
+
return this.db.one(
|
|
43
|
+
`INSERT INTO wa_memories (user_id, key, value, source, learned_in, confidence, expires_at)
|
|
44
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
45
|
+
ON CONFLICT (user_id, key) DO UPDATE
|
|
46
|
+
SET value = EXCLUDED.value,
|
|
47
|
+
source = EXCLUDED.source,
|
|
48
|
+
learned_in = COALESCE(EXCLUDED.learned_in, wa_memories.learned_in),
|
|
49
|
+
confidence = EXCLUDED.confidence,
|
|
50
|
+
expires_at = EXCLUDED.expires_at,
|
|
51
|
+
updated_at = NOW()
|
|
52
|
+
RETURNING *`,
|
|
53
|
+
[
|
|
54
|
+
userId,
|
|
55
|
+
normalisedKey,
|
|
56
|
+
normalisedValue,
|
|
57
|
+
opts.source || 'auto',
|
|
58
|
+
opts.learnedIn || null,
|
|
59
|
+
typeof opts.confidence === 'number' ? opts.confidence : 1.0,
|
|
60
|
+
opts.expiresAt || null,
|
|
61
|
+
]
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Store many facts at once (one round-trip, single transaction).
|
|
67
|
+
* @param {number} userId
|
|
68
|
+
* @param {Record<string,any>} facts
|
|
69
|
+
* @param {object} [opts]
|
|
70
|
+
* @returns {Promise<object[]>}
|
|
71
|
+
*/
|
|
72
|
+
async rememberMany(userId, facts, opts = {}) {
|
|
73
|
+
if (!userId || !facts || typeof facts !== 'object') return [];
|
|
74
|
+
|
|
75
|
+
const entries = Object.entries(facts)
|
|
76
|
+
.map(([k, v]) => [MemoryRepository.normalizeKey(k), MemoryRepository.normalizeValue(v)])
|
|
77
|
+
.filter(([k, v]) => k && v);
|
|
78
|
+
|
|
79
|
+
if (!entries.length) return [];
|
|
80
|
+
|
|
81
|
+
// Cap per-turn writes so a malformed model reply can't flood the table.
|
|
82
|
+
const limited = entries.slice(0, 12);
|
|
83
|
+
|
|
84
|
+
return this.db.transaction(async (client) => {
|
|
85
|
+
const saved = [];
|
|
86
|
+
for (const [key, value] of limited) {
|
|
87
|
+
const { rows } = await client.query(
|
|
88
|
+
`INSERT INTO wa_memories (user_id, key, value, source, learned_in, confidence)
|
|
89
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
90
|
+
ON CONFLICT (user_id, key) DO UPDATE
|
|
91
|
+
SET value = EXCLUDED.value,
|
|
92
|
+
source = EXCLUDED.source,
|
|
93
|
+
learned_in = COALESCE(EXCLUDED.learned_in, wa_memories.learned_in),
|
|
94
|
+
updated_at = NOW()
|
|
95
|
+
RETURNING *`,
|
|
96
|
+
[
|
|
97
|
+
userId,
|
|
98
|
+
key,
|
|
99
|
+
value,
|
|
100
|
+
opts.source || 'auto',
|
|
101
|
+
opts.learnedIn || null,
|
|
102
|
+
typeof opts.confidence === 'number' ? opts.confidence : 1.0,
|
|
103
|
+
]
|
|
104
|
+
);
|
|
105
|
+
saved.push(rows[0]);
|
|
106
|
+
}
|
|
107
|
+
return saved;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* All live memories for a user (expired rows filtered out).
|
|
113
|
+
* @param {number} userId
|
|
114
|
+
* @param {number} [limit=25]
|
|
115
|
+
*/
|
|
116
|
+
async getAll(userId, limit = 25) {
|
|
117
|
+
if (!userId) return [];
|
|
118
|
+
return this.db.many(
|
|
119
|
+
`SELECT * FROM wa_memories
|
|
120
|
+
WHERE user_id = $1
|
|
121
|
+
AND (expires_at IS NULL OR expires_at > NOW())
|
|
122
|
+
ORDER BY updated_at DESC
|
|
123
|
+
LIMIT $2`,
|
|
124
|
+
[userId, limit]
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Plain `{key: value}` map for prompt injection. */
|
|
129
|
+
async getMap(userId, limit = 25) {
|
|
130
|
+
const rows = await this.getAll(userId, limit);
|
|
131
|
+
const map = {};
|
|
132
|
+
for (const row of rows) map[row.key] = row.value;
|
|
133
|
+
return map;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async get(userId, key) {
|
|
137
|
+
const normalisedKey = MemoryRepository.normalizeKey(key);
|
|
138
|
+
if (!userId || !normalisedKey) return null;
|
|
139
|
+
return this.db.one(
|
|
140
|
+
`SELECT * FROM wa_memories
|
|
141
|
+
WHERE user_id = $1 AND key = $2
|
|
142
|
+
AND (expires_at IS NULL OR expires_at > NOW())`,
|
|
143
|
+
[userId, normalisedKey]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async forget(userId, key) {
|
|
148
|
+
const normalisedKey = MemoryRepository.normalizeKey(key);
|
|
149
|
+
if (!userId || !normalisedKey) return false;
|
|
150
|
+
const { rowCount } = await this.db.query(
|
|
151
|
+
'DELETE FROM wa_memories WHERE user_id = $1 AND key = $2',
|
|
152
|
+
[userId, normalisedKey]
|
|
153
|
+
);
|
|
154
|
+
return rowCount > 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async forgetAll(userId) {
|
|
158
|
+
if (!userId) return 0;
|
|
159
|
+
const { rowCount } = await this.db.query('DELETE FROM wa_memories WHERE user_id = $1', [userId]);
|
|
160
|
+
return rowCount;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Bump usage counters for memories that were injected into a prompt. */
|
|
164
|
+
async markUsed(userId, keys) {
|
|
165
|
+
if (!userId || !Array.isArray(keys) || !keys.length) return;
|
|
166
|
+
await this.db.query(
|
|
167
|
+
'UPDATE wa_memories SET hit_count = hit_count + 1 WHERE user_id = $1 AND key = ANY($2::text[])',
|
|
168
|
+
[userId, keys]
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Housekeeping: drop expired rows. */
|
|
173
|
+
async pruneExpired() {
|
|
174
|
+
const { rowCount } = await this.db.query(
|
|
175
|
+
'DELETE FROM wa_memories WHERE expires_at IS NOT NULL AND expires_at <= NOW()'
|
|
176
|
+
);
|
|
177
|
+
return rowCount;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------ helpers ---
|
|
181
|
+
|
|
182
|
+
/** `Favourite Food ` -> `favourite_food` */
|
|
183
|
+
static normalizeKey(key) {
|
|
184
|
+
if (key == null) return null;
|
|
185
|
+
const cleaned = String(key)
|
|
186
|
+
.trim()
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.replace(/[\s-]+/g, '_')
|
|
189
|
+
.replace(/[^a-z0-9_]/g, '')
|
|
190
|
+
.replace(/_{2,}/g, '_')
|
|
191
|
+
.replace(/^_|_$/g, '');
|
|
192
|
+
if (!cleaned || MemoryRepository.BLOCKED_KEYS.has(cleaned)) return null;
|
|
193
|
+
return cleaned.slice(0, MemoryRepository.MAX_KEY);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Objects/arrays are JSON-stringified so nested model output still stores. */
|
|
197
|
+
static normalizeValue(value) {
|
|
198
|
+
if (value == null) return null;
|
|
199
|
+
let str;
|
|
200
|
+
if (typeof value === 'object') {
|
|
201
|
+
try {
|
|
202
|
+
str = JSON.stringify(value);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
str = String(value);
|
|
208
|
+
}
|
|
209
|
+
str = str.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trim();
|
|
210
|
+
if (!str || MemoryRepository.BLOCKED_KEYS.has(str.toLowerCase())) return null;
|
|
211
|
+
return str.slice(0, MemoryRepository.MAX_VALUE);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = MemoryRepository;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const JidParser = require('../utils/JidParser');
|
|
4
|
+
const { ValidationError } = require('../core/errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* UserRepository
|
|
8
|
+
* --------------
|
|
9
|
+
* Users and groups. One `wa_users` row per person, keyed by canonical jid, so
|
|
10
|
+
* the same human is recognised in a DM and in every group.
|
|
11
|
+
*/
|
|
12
|
+
class UserRepository {
|
|
13
|
+
/** @param {import('../db/Database')} db */
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Find-or-create a user, refreshing `last_seen_at` and push name.
|
|
20
|
+
* @param {string} rawJid
|
|
21
|
+
* @param {object} [info]
|
|
22
|
+
* @param {string} [info.pushName]
|
|
23
|
+
* @param {object} [info.metadata]
|
|
24
|
+
* @returns {Promise<object>} user row
|
|
25
|
+
*/
|
|
26
|
+
async upsertUser(rawJid, info = {}) {
|
|
27
|
+
const parsed = JidParser.parse(rawJid);
|
|
28
|
+
if (!parsed.valid || parsed.isGroup) {
|
|
29
|
+
throw new ValidationError(`Invalid user jid: ${JSON.stringify(rawJid)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pushName = UserRepository._clean(info.pushName, 128);
|
|
33
|
+
const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
|
|
34
|
+
|
|
35
|
+
// If this jid is already a known ALIAS of somebody, that person is who
|
|
36
|
+
// is writing — creating a second row here is exactly the bug that made
|
|
37
|
+
// Alexa forget people between a DM and a group.
|
|
38
|
+
const known = await this.db.one(
|
|
39
|
+
`SELECT u.* FROM wa_users u
|
|
40
|
+
JOIN wa_user_identities i ON i.user_id = u.id
|
|
41
|
+
WHERE i.jid = $1
|
|
42
|
+
LIMIT 1`,
|
|
43
|
+
[parsed.jid]
|
|
44
|
+
);
|
|
45
|
+
if (known) {
|
|
46
|
+
await this.db.query('UPDATE wa_user_identities SET last_seen_at = NOW() WHERE jid = $1', [parsed.jid]);
|
|
47
|
+
return (await this.touch(known.id, { pushName, metadata })) || known;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// COALESCE keeps a previously-known push name if this event lacks one.
|
|
51
|
+
const user = await this.db.one(
|
|
52
|
+
`INSERT INTO wa_users (jid, jid_local, jid_server, jid_type, phone, push_name, metadata, last_seen_at)
|
|
53
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW())
|
|
54
|
+
ON CONFLICT (jid) DO UPDATE
|
|
55
|
+
SET last_seen_at = NOW(),
|
|
56
|
+
push_name = COALESCE(NULLIF(EXCLUDED.push_name, ''), wa_users.push_name),
|
|
57
|
+
phone = COALESCE(wa_users.phone, EXCLUDED.phone),
|
|
58
|
+
metadata = wa_users.metadata || EXCLUDED.metadata
|
|
59
|
+
RETURNING *`,
|
|
60
|
+
[
|
|
61
|
+
parsed.jid,
|
|
62
|
+
parsed.local,
|
|
63
|
+
parsed.server,
|
|
64
|
+
parsed.type,
|
|
65
|
+
parsed.phone,
|
|
66
|
+
pushName,
|
|
67
|
+
JSON.stringify(metadata),
|
|
68
|
+
]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Every user is their own primary identity. DO NOTHING on conflict:
|
|
72
|
+
// re-pointing a jid at another person is IdentityRepository's job, and
|
|
73
|
+
// it merges instead of silently stealing the alias.
|
|
74
|
+
await this.db.query(
|
|
75
|
+
`INSERT INTO wa_user_identities (user_id, jid, jid_local, jid_server, jid_type, phone, is_primary, source)
|
|
76
|
+
VALUES ($1, $2, $3, $4, $5, $6, TRUE, 'primary')
|
|
77
|
+
ON CONFLICT (jid) DO UPDATE SET last_seen_at = NOW()`,
|
|
78
|
+
[user.id, parsed.jid, parsed.local, parsed.server, parsed.type, parsed.phone]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return user;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Refresh `last_seen_at` / push name on a known row. */
|
|
85
|
+
async touch(userId, info = {}) {
|
|
86
|
+
if (!userId) return null;
|
|
87
|
+
const pushName = UserRepository._clean(info.pushName, 128);
|
|
88
|
+
const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
|
|
89
|
+
return this.db.one(
|
|
90
|
+
`UPDATE wa_users
|
|
91
|
+
SET last_seen_at = NOW(),
|
|
92
|
+
push_name = COALESCE(NULLIF($2, ''), push_name),
|
|
93
|
+
metadata = metadata || $3::jsonb
|
|
94
|
+
WHERE id = $1
|
|
95
|
+
RETURNING *`,
|
|
96
|
+
[userId, pushName, JSON.stringify(metadata)]
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async findById(userId) {
|
|
101
|
+
if (!userId) return null;
|
|
102
|
+
return this.db.one('SELECT * FROM wa_users WHERE id = $1', [userId]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Find-or-create a group.
|
|
107
|
+
* @param {string} rawJid
|
|
108
|
+
* @param {object} [info]
|
|
109
|
+
* @param {string} [info.subject]
|
|
110
|
+
* @returns {Promise<object|null>}
|
|
111
|
+
*/
|
|
112
|
+
async upsertGroup(rawJid, info = {}) {
|
|
113
|
+
if (!rawJid) return null;
|
|
114
|
+
const parsed = JidParser.parse(rawJid);
|
|
115
|
+
if (!parsed.valid || !parsed.isGroup) {
|
|
116
|
+
throw new ValidationError(`Invalid group jid: ${JSON.stringify(rawJid)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const subject = UserRepository._clean(info.subject, 256);
|
|
120
|
+
const metadata = info.metadata && typeof info.metadata === 'object' ? info.metadata : {};
|
|
121
|
+
|
|
122
|
+
return this.db.one(
|
|
123
|
+
`INSERT INTO wa_groups (jid, subject, metadata, last_seen_at)
|
|
124
|
+
VALUES ($1, $2, $3::jsonb, NOW())
|
|
125
|
+
ON CONFLICT (jid) DO UPDATE
|
|
126
|
+
SET last_seen_at = NOW(),
|
|
127
|
+
subject = COALESCE(NULLIF(EXCLUDED.subject, ''), wa_groups.subject),
|
|
128
|
+
metadata = wa_groups.metadata || EXCLUDED.metadata
|
|
129
|
+
RETURNING *`,
|
|
130
|
+
[parsed.jid, subject, JSON.stringify(metadata)]
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Record that `userId` is present in `groupId`. */
|
|
135
|
+
async linkMember(groupId, userId, isAdmin = false) {
|
|
136
|
+
if (!groupId || !userId) return null;
|
|
137
|
+
return this.db.one(
|
|
138
|
+
`INSERT INTO wa_group_members (group_id, user_id, is_admin, last_seen_at)
|
|
139
|
+
VALUES ($1, $2, $3, NOW())
|
|
140
|
+
ON CONFLICT (group_id, user_id) DO UPDATE
|
|
141
|
+
SET last_seen_at = NOW(),
|
|
142
|
+
message_count = wa_group_members.message_count + 1,
|
|
143
|
+
is_admin = EXCLUDED.is_admin OR wa_group_members.is_admin
|
|
144
|
+
RETURNING *`,
|
|
145
|
+
[groupId, userId, Boolean(isAdmin)]
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Look a user up by ANY address they are known under — the row itself or
|
|
151
|
+
* one of their linked aliases (`@lid` <-> phone jid).
|
|
152
|
+
*/
|
|
153
|
+
async findByJid(rawJid) {
|
|
154
|
+
const jid = JidParser.normalize(rawJid);
|
|
155
|
+
if (!jid) return null;
|
|
156
|
+
return this.db.one(
|
|
157
|
+
`SELECT u.* FROM wa_users u
|
|
158
|
+
WHERE u.jid = $1
|
|
159
|
+
UNION
|
|
160
|
+
SELECT u.* FROM wa_users u
|
|
161
|
+
JOIN wa_user_identities i ON i.user_id = u.id
|
|
162
|
+
WHERE i.jid = $1
|
|
163
|
+
LIMIT 1`,
|
|
164
|
+
[jid]
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async findGroupByJid(rawJid) {
|
|
169
|
+
const jid = JidParser.normalize(rawJid);
|
|
170
|
+
if (!jid) return null;
|
|
171
|
+
return this.db.one('SELECT * FROM wa_groups WHERE jid = $1', [jid]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async incrementMessageCount(userId, chars = 0) {
|
|
175
|
+
if (!userId) return;
|
|
176
|
+
await this.db.query(
|
|
177
|
+
`UPDATE wa_users
|
|
178
|
+
SET message_count = message_count + 1,
|
|
179
|
+
token_estimate = token_estimate + $2
|
|
180
|
+
WHERE id = $1`,
|
|
181
|
+
[userId, Math.ceil(chars / 4)]
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Block/unblock by ANY address the person is known under.
|
|
187
|
+
*
|
|
188
|
+
* Previously this matched `wa_users.jid` only, so blocking someone by the
|
|
189
|
+
* `@lid` seen in a group silently did nothing (returned null) when their
|
|
190
|
+
* row had been created from a DM phone jid — and vice versa. It now walks
|
|
191
|
+
* the alias graph, and when blocking someone the bot has never seen it
|
|
192
|
+
* creates the row so the block is already in force on their first message.
|
|
193
|
+
*/
|
|
194
|
+
async setBlocked(rawJid, blocked = true) {
|
|
195
|
+
const parsed = JidParser.parse(rawJid);
|
|
196
|
+
if (!parsed.valid || parsed.isGroup) {
|
|
197
|
+
throw new ValidationError(`Invalid user jid: ${JSON.stringify(rawJid)}`);
|
|
198
|
+
}
|
|
199
|
+
let user = await this.findByJid(parsed.jid);
|
|
200
|
+
if (!user) {
|
|
201
|
+
if (!blocked) return null; // nothing to unblock
|
|
202
|
+
user = await this.upsertUser(parsed.jid);
|
|
203
|
+
}
|
|
204
|
+
return this.db.one('UPDATE wa_users SET is_blocked = $2 WHERE id = $1 RETURNING *', [
|
|
205
|
+
user.id,
|
|
206
|
+
Boolean(blocked),
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async isBlocked(rawJid) {
|
|
211
|
+
const user = await this.findByJid(rawJid);
|
|
212
|
+
return Boolean(user?.is_blocked);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Enable/disable the AI in a group. Creates the group row when the bot
|
|
217
|
+
* has not seen the group yet (an admin usually disables Alexa *before*
|
|
218
|
+
* she has answered there), instead of returning null and doing nothing.
|
|
219
|
+
*/
|
|
220
|
+
async setGroupEnabled(rawJid, enabled = true) {
|
|
221
|
+
const parsed = JidParser.parse(rawJid);
|
|
222
|
+
if (!parsed.valid || !parsed.isGroup) {
|
|
223
|
+
throw new ValidationError(`Invalid group jid: ${JSON.stringify(rawJid)}`);
|
|
224
|
+
}
|
|
225
|
+
const group = (await this.findGroupByJid(parsed.jid)) || (await this.upsertGroup(parsed.jid));
|
|
226
|
+
return this.db.one('UPDATE wa_groups SET is_enabled = $2 WHERE id = $1 RETURNING *', [
|
|
227
|
+
group.id,
|
|
228
|
+
Boolean(enabled),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Name Alexa should use: learned name > WhatsApp push name > fallback. */
|
|
233
|
+
async resolveDisplayName(userId, fallback = 'there') {
|
|
234
|
+
const row = await this.db.one(
|
|
235
|
+
`SELECT COALESCE(
|
|
236
|
+
u.display_name,
|
|
237
|
+
(SELECT m.value FROM wa_memories m
|
|
238
|
+
WHERE m.user_id = u.id AND m.key IN ('name','full_name','nickname')
|
|
239
|
+
ORDER BY (m.key = 'name') DESC, m.updated_at DESC LIMIT 1),
|
|
240
|
+
u.push_name
|
|
241
|
+
) AS name
|
|
242
|
+
FROM wa_users u WHERE u.id = $1`,
|
|
243
|
+
[userId]
|
|
244
|
+
);
|
|
245
|
+
return UserRepository._clean(row?.name, 64) || fallback;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async setDisplayName(rawJid, name) {
|
|
249
|
+
const jid = JidParser.normalize(rawJid);
|
|
250
|
+
return this.db.one('UPDATE wa_users SET display_name = $2 WHERE jid = $1 RETURNING *', [
|
|
251
|
+
jid,
|
|
252
|
+
UserRepository._clean(name, 64),
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async stats() {
|
|
257
|
+
return this.db.one(`
|
|
258
|
+
SELECT (SELECT COUNT(*) FROM wa_users) AS users,
|
|
259
|
+
(SELECT COUNT(*) FROM wa_groups) AS groups,
|
|
260
|
+
(SELECT COUNT(*) FROM wa_conversations) AS conversations,
|
|
261
|
+
(SELECT COUNT(*) FROM wa_messages) AS messages,
|
|
262
|
+
(SELECT COUNT(*) FROM wa_memories) AS memories,
|
|
263
|
+
(SELECT COUNT(*) FROM wa_users WHERE last_seen_at > NOW() - INTERVAL '24 hours') AS active_24h
|
|
264
|
+
`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
static _clean(value, max) {
|
|
268
|
+
if (value == null) return null;
|
|
269
|
+
const str = String(value).replace(/[\u0000-\u001F\u007F]/g, '').trim();
|
|
270
|
+
if (!str) return null;
|
|
271
|
+
return str.length > max ? str.slice(0, max) : str;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = UserRepository;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AmnesiaGuard
|
|
5
|
+
* ------------
|
|
6
|
+
* Stops the assistant from denying a memory it demonstrably has.
|
|
7
|
+
*
|
|
8
|
+
* THE BUG THIS FIXES
|
|
9
|
+
* ------------------
|
|
10
|
+
* The engine stores facts per human and injects them into every prompt, but the
|
|
11
|
+
* free DeepAI tier still loves to answer:
|
|
12
|
+
*
|
|
13
|
+
* user (in a group): "do you remember me?"
|
|
14
|
+
* model: "Unfortunately, as a bot I can't remember you."
|
|
15
|
+
*
|
|
16
|
+
* …while the database is holding `name=Nimal, location=Galle, hobby=cricket`.
|
|
17
|
+
* The reply is simply false, and it is the single most damaging thing the bot
|
|
18
|
+
* can say.
|
|
19
|
+
*
|
|
20
|
+
* Two layers:
|
|
21
|
+
* 1. `directiveFor()` — a short, explicit instruction (plus the answer the
|
|
22
|
+
* model should give) placed immediately before a recall question.
|
|
23
|
+
* 2. `repair()` — if the reply still denies having a memory while we
|
|
24
|
+
* hold facts, the denial is rewritten from the database. Deterministic:
|
|
25
|
+
* no second round-trip, no extra latency, and it can never be wrong.
|
|
26
|
+
*/
|
|
27
|
+
class AmnesiaGuard {
|
|
28
|
+
/** "do you remember me", "what's my name", "who am I", … */
|
|
29
|
+
static RECALL_QUESTION =
|
|
30
|
+
/\b(?:do (?:you|u) (?:still )?(?:remember|know|recall)|remember me|remember my|you remember|what(?:'s| is)? my (?:name|age|city|town|country|job|hobby|favou?rite)|who am i|do you know (?:me|my|who i am)|mata mathakada|mage nama)\b/i;
|
|
31
|
+
|
|
32
|
+
/** Denials of having any memory at all. */
|
|
33
|
+
static DENIAL =
|
|
34
|
+
/(?:\b(?:i|we)\s+(?:really\s+)?(?:can(?:'|no)?t|cannot|can not|do(?:n'?t| not)|am unable to|are unable to|have no (?:way|ability)|don'?t have (?:the )?(?:ability|capability|memory|access)|lack the ability)\s+(?:to\s+)?(?:really\s+)?(?:remember|recall|retain|store|save|access|keep track of)\b)|(?:\bas an? (?:ai|bot|assistant|language model)[^.!?]{0,60}(?:remember|recall|memory|retain)\b)|(?:\bi\s+(?:have|hold|retain)\s+no\s+(?:memory|memories|record|recollection)\b)|(?:\bno memory of (?:you|our|previous|past|earlier)\b)|(?:\bi\s+don'?t\s+(?:have|keep|retain|store)\s+(?:any\s+)?(?:memory|memories|records?|information about you)\b)|(?:\b(?:our|this) conversation (?:has )?just started\b)|(?:\bi don'?t have access to (?:previous|past|prior|earlier) (?:conversations|chats|messages)\b)|(?:\bevery (?:conversation|chat) (?:starts|begins) (?:fresh|anew)\b)|(?:\bi start(?: over)? fresh\b)/i;
|
|
35
|
+
|
|
36
|
+
/** Human-friendly labels for the keys we store most often. */
|
|
37
|
+
static LABELS = {
|
|
38
|
+
name: 'your name is',
|
|
39
|
+
full_name: 'your full name is',
|
|
40
|
+
nickname: 'you also go by',
|
|
41
|
+
age: "you're",
|
|
42
|
+
location: "you're from",
|
|
43
|
+
city: "you're from",
|
|
44
|
+
country: "you're from",
|
|
45
|
+
hobby: 'you love',
|
|
46
|
+
favourite_food: 'your favourite food is',
|
|
47
|
+
favorite_food: 'your favourite food is',
|
|
48
|
+
favourite_team: 'you support',
|
|
49
|
+
favorite_team: 'you support',
|
|
50
|
+
job: 'you work as',
|
|
51
|
+
occupation: 'you work as',
|
|
52
|
+
studies: "you're studying",
|
|
53
|
+
language: 'you speak',
|
|
54
|
+
birthday: 'your birthday is',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} [persona]
|
|
59
|
+
* @param {string} [persona.assistantName='Alexa']
|
|
60
|
+
*/
|
|
61
|
+
constructor({ assistantName = 'Alexa' } = {}) {
|
|
62
|
+
this.name = String(assistantName || 'Alexa').trim() || 'Alexa';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is the user asking the assistant to recall something about them? */
|
|
66
|
+
static isRecallQuestion(message) {
|
|
67
|
+
const text = String(message ?? '').trim();
|
|
68
|
+
if (!text || text.length > 400) return false;
|
|
69
|
+
return AmnesiaGuard.RECALL_QUESTION.test(text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Does this reply deny having a memory? */
|
|
73
|
+
static isDenial(reply) {
|
|
74
|
+
const text = String(reply ?? '');
|
|
75
|
+
if (!text.trim()) return false;
|
|
76
|
+
return AmnesiaGuard.DENIAL.test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Instruction injected right before a recall question, carrying the answer.
|
|
81
|
+
* @param {object} params
|
|
82
|
+
* @param {Record<string,string>} params.memories
|
|
83
|
+
* @param {string} [params.displayName]
|
|
84
|
+
* @param {boolean} params.isRecall
|
|
85
|
+
* @param {boolean} [params.isGroup]
|
|
86
|
+
* @returns {string} '' when nothing needs saying
|
|
87
|
+
*/
|
|
88
|
+
directiveFor({ memories = {}, displayName = null, isRecall = false, isGroup = false } = {}) {
|
|
89
|
+
const keys = Object.keys(memories || {});
|
|
90
|
+
if (!isRecall) return '';
|
|
91
|
+
|
|
92
|
+
if (!keys.length) {
|
|
93
|
+
return (
|
|
94
|
+
'[MEMORY CHECK: You DO have a permanent memory, but you have not saved any facts about this ' +
|
|
95
|
+
'person yet. Say warmly that you do not have their details saved yet and ask them to tell you. ' +
|
|
96
|
+
'Do NOT say you are unable to remember things.]\n\n'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const facts = keys.map((k) => `${k.replace(/_/g, ' ')}: ${memories[k]}`).join('; ');
|
|
101
|
+
const where = isGroup
|
|
102
|
+
? 'You know this person from their private chat with you, and this message is in a group — the same person, same memory. '
|
|
103
|
+
: '';
|
|
104
|
+
return (
|
|
105
|
+
`[MEMORY CHECK: You DO remember this person. ${where}Saved facts: ${facts}. ` +
|
|
106
|
+
`Answer their question directly and warmly using these facts${displayName ? `, greeting them as ${displayName}` : ''}. ` +
|
|
107
|
+
'NEVER say you cannot remember, have no memory, or that the conversation just started.]\n\n'
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Rewrite a reply that denies having a memory.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} reply
|
|
115
|
+
* @param {object} params
|
|
116
|
+
* @param {Record<string,string>} params.memories
|
|
117
|
+
* @param {string} [params.displayName]
|
|
118
|
+
* @param {boolean} [params.isRecall]
|
|
119
|
+
* @returns {{ text: string, repaired: boolean }}
|
|
120
|
+
*/
|
|
121
|
+
repair(reply, { memories = {}, displayName = null, isRecall = false } = {}) {
|
|
122
|
+
const text = String(reply ?? '');
|
|
123
|
+
if (!AmnesiaGuard.isDenial(text)) return { text, repaired: false };
|
|
124
|
+
|
|
125
|
+
const known = Object.keys(memories || {}).length > 0;
|
|
126
|
+
|
|
127
|
+
// Drop only the sentences that contain the denial; keep the rest.
|
|
128
|
+
const sentences = AmnesiaGuard.splitSentences(text);
|
|
129
|
+
const kept = sentences.filter((s) => !AmnesiaGuard.DENIAL.test(s)).join(' ').trim();
|
|
130
|
+
|
|
131
|
+
if (known) {
|
|
132
|
+
const recall = this.recallSentence(memories, displayName);
|
|
133
|
+
if (isRecall || !kept) return { text: recall, repaired: true };
|
|
134
|
+
return { text: `${recall} ${kept}`.trim(), repaired: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const honest = displayName
|
|
138
|
+
? `I don't have any details saved about you yet, ${displayName} — tell me and I'll remember. 😊`
|
|
139
|
+
: "I don't have any details saved about you yet — tell me and I'll remember. 😊";
|
|
140
|
+
return { text: kept ? `${honest} ${kept}`.trim() : honest, repaired: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A warm, WhatsApp-formatted sentence built from stored facts.
|
|
145
|
+
* @param {Record<string,string>} memories
|
|
146
|
+
* @param {string} [displayName]
|
|
147
|
+
*/
|
|
148
|
+
recallSentence(memories, displayName = null) {
|
|
149
|
+
const entries = Object.entries(memories || {}).filter(([, v]) => v);
|
|
150
|
+
const name = memories.name || memories.full_name || displayName;
|
|
151
|
+
|
|
152
|
+
const parts = [];
|
|
153
|
+
for (const [key, value] of entries.slice(0, 6)) {
|
|
154
|
+
if (key === 'name' || key === 'full_name') continue;
|
|
155
|
+
const label = AmnesiaGuard.LABELS[key] || `your ${key.replace(/_/g, ' ')} is`;
|
|
156
|
+
parts.push(`${label} _${value}_`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const opener = name ? `Of course I remember you, *${name}*! 😊` : 'Of course I remember you! 😊';
|
|
160
|
+
if (!parts.length) return opener;
|
|
161
|
+
|
|
162
|
+
const list =
|
|
163
|
+
parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
|
|
164
|
+
return `${opener} I remember that ${list}.`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @private naive but dependable sentence splitter. */
|
|
168
|
+
static splitSentences(text) {
|
|
169
|
+
return String(text)
|
|
170
|
+
.split(/(?<=[.!?])\s+/)
|
|
171
|
+
.map((s) => s.trim())
|
|
172
|
+
.filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = AmnesiaGuard;
|