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,214 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Alexa AI — PostgreSQL schema
|
|
3
|
+
-- Safe to run repeatedly (idempotent).
|
|
4
|
+
--
|
|
5
|
+
-- Identity model
|
|
6
|
+
-- --------------
|
|
7
|
+
-- WhatsApp gives us two independent identifiers:
|
|
8
|
+
-- user : 78151912841263@lid or 94771234567@s.whatsapp.net
|
|
9
|
+
-- group : 120363413125431525@g.us
|
|
10
|
+
--
|
|
11
|
+
-- A user is ONE row in wa_users keyed by their jid. That row is shared across
|
|
12
|
+
-- every group and the DM, so "recognise user data in any group" works by
|
|
13
|
+
-- construction: memories hang off user_id, never off the group.
|
|
14
|
+
--
|
|
15
|
+
-- Conversations are separate threads (DM vs each group) so chat context never
|
|
16
|
+
-- bleeds between rooms, while the user's identity and memories stay global.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
-- ---------------------------------------------------------------- users -----
|
|
20
|
+
CREATE TABLE IF NOT EXISTS wa_users (
|
|
21
|
+
id BIGSERIAL PRIMARY KEY,
|
|
22
|
+
jid TEXT NOT NULL UNIQUE, -- canonical: 78151912841263@lid
|
|
23
|
+
jid_local TEXT NOT NULL, -- 78151912841263
|
|
24
|
+
jid_server TEXT NOT NULL, -- lid | s.whatsapp.net
|
|
25
|
+
jid_type TEXT NOT NULL DEFAULT 'user', -- lid | user
|
|
26
|
+
phone TEXT, -- NULL for @lid (privacy id)
|
|
27
|
+
push_name TEXT, -- WhatsApp display name
|
|
28
|
+
display_name TEXT, -- name Alexa learned/prefers
|
|
29
|
+
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
30
|
+
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
31
|
+
locale TEXT,
|
|
32
|
+
message_count BIGINT NOT NULL DEFAULT 0,
|
|
33
|
+
token_estimate BIGINT NOT NULL DEFAULT 0,
|
|
34
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
35
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
36
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
37
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
38
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_wa_users_phone ON wa_users (phone) WHERE phone IS NOT NULL;
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_wa_users_last_seen ON wa_users (last_seen_at DESC);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_wa_users_local ON wa_users (jid_local);
|
|
44
|
+
|
|
45
|
+
-- ----------------------------------------------------- user identities ------
|
|
46
|
+
-- WhatsApp hands the SAME human different jids depending on where they write:
|
|
47
|
+
-- DM : 94771234567@s.whatsapp.net (phone number jid)
|
|
48
|
+
-- Group : 78151912841263@lid (privacy / LID jid)
|
|
49
|
+
-- Without a mapping the bot creates two rows and "forgets" the user the moment
|
|
50
|
+
-- they speak in a group. Every jid a person is ever seen under is recorded
|
|
51
|
+
-- here and points at ONE wa_users row, so memories follow the human, not the
|
|
52
|
+
-- address they happen to be using.
|
|
53
|
+
CREATE TABLE IF NOT EXISTS wa_user_identities (
|
|
54
|
+
id BIGSERIAL PRIMARY KEY,
|
|
55
|
+
user_id BIGINT NOT NULL REFERENCES wa_users(id) ON DELETE CASCADE,
|
|
56
|
+
jid TEXT NOT NULL UNIQUE, -- 78151912841263@lid
|
|
57
|
+
jid_local TEXT NOT NULL,
|
|
58
|
+
jid_server TEXT NOT NULL,
|
|
59
|
+
jid_type TEXT NOT NULL DEFAULT 'user', -- lid | user
|
|
60
|
+
phone TEXT, -- NULL for @lid
|
|
61
|
+
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
|
62
|
+
source TEXT, -- how the link was learned
|
|
63
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
64
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_wa_identities_user ON wa_user_identities (user_id);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_wa_identities_phone ON wa_user_identities (phone) WHERE phone IS NOT NULL;
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_wa_identities_local ON wa_user_identities (jid_local);
|
|
70
|
+
|
|
71
|
+
-- Backfill: every existing user is their own primary identity.
|
|
72
|
+
INSERT INTO wa_user_identities (user_id, jid, jid_local, jid_server, jid_type, phone, is_primary, source)
|
|
73
|
+
SELECT u.id, u.jid, u.jid_local, u.jid_server, u.jid_type, u.phone, TRUE, 'backfill'
|
|
74
|
+
FROM wa_users u
|
|
75
|
+
WHERE NOT EXISTS (SELECT 1 FROM wa_user_identities i WHERE i.jid = u.jid);
|
|
76
|
+
|
|
77
|
+
-- --------------------------------------------------------------- groups -----
|
|
78
|
+
CREATE TABLE IF NOT EXISTS wa_groups (
|
|
79
|
+
id BIGSERIAL PRIMARY KEY,
|
|
80
|
+
jid TEXT NOT NULL UNIQUE, -- 120363413125431525@g.us
|
|
81
|
+
subject TEXT, -- group title
|
|
82
|
+
description TEXT,
|
|
83
|
+
is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- AI on/off per group
|
|
84
|
+
message_count BIGINT NOT NULL DEFAULT 0,
|
|
85
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
86
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
87
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
88
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
89
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_wa_groups_last_seen ON wa_groups (last_seen_at DESC);
|
|
93
|
+
|
|
94
|
+
-- ------------------------------------------------------- group membership ---
|
|
95
|
+
-- Tracks which user was seen in which group (per-room stats, admin flags).
|
|
96
|
+
-- Identity still lives in wa_users, so memories remain global.
|
|
97
|
+
CREATE TABLE IF NOT EXISTS wa_group_members (
|
|
98
|
+
id BIGSERIAL PRIMARY KEY,
|
|
99
|
+
group_id BIGINT NOT NULL REFERENCES wa_groups(id) ON DELETE CASCADE,
|
|
100
|
+
user_id BIGINT NOT NULL REFERENCES wa_users(id) ON DELETE CASCADE,
|
|
101
|
+
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
102
|
+
message_count BIGINT NOT NULL DEFAULT 0,
|
|
103
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
104
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
105
|
+
UNIQUE (group_id, user_id)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_wa_group_members_user ON wa_group_members (user_id);
|
|
109
|
+
|
|
110
|
+
-- -------------------------------------------------------- conversations -----
|
|
111
|
+
-- One row per thread. DM => group_id NULL. Group => (group_id, user_id).
|
|
112
|
+
CREATE TABLE IF NOT EXISTS wa_conversations (
|
|
113
|
+
id BIGSERIAL PRIMARY KEY,
|
|
114
|
+
context_key TEXT NOT NULL UNIQUE, -- dm:<jid> | group:<gjid>:<ujid>
|
|
115
|
+
user_id BIGINT REFERENCES wa_users(id) ON DELETE CASCADE,
|
|
116
|
+
group_id BIGINT REFERENCES wa_groups(id) ON DELETE CASCADE,
|
|
117
|
+
kind TEXT NOT NULL DEFAULT 'dm', -- dm | group
|
|
118
|
+
title TEXT,
|
|
119
|
+
message_count BIGINT NOT NULL DEFAULT 0,
|
|
120
|
+
last_message_at TIMESTAMPTZ,
|
|
121
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
122
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
123
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_wa_conversations_user ON wa_conversations (user_id);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_wa_conversations_group ON wa_conversations (group_id);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_wa_conversations_last ON wa_conversations (last_message_at DESC NULLS LAST);
|
|
129
|
+
|
|
130
|
+
-- ------------------------------------------------------------- messages -----
|
|
131
|
+
CREATE TABLE IF NOT EXISTS wa_messages (
|
|
132
|
+
id BIGSERIAL PRIMARY KEY,
|
|
133
|
+
conversation_id BIGINT NOT NULL REFERENCES wa_conversations(id) ON DELETE CASCADE,
|
|
134
|
+
user_id BIGINT REFERENCES wa_users(id) ON DELETE SET NULL,
|
|
135
|
+
role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
|
|
136
|
+
content TEXT NOT NULL,
|
|
137
|
+
has_media BOOLEAN NOT NULL DEFAULT FALSE,
|
|
138
|
+
media_type TEXT,
|
|
139
|
+
wa_message_id TEXT, -- WhatsApp msg id (dedupe)
|
|
140
|
+
tokens INTEGER,
|
|
141
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
142
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
-- Primary read path: newest N messages of a thread.
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_wa_messages_convo_time
|
|
147
|
+
ON wa_messages (conversation_id, created_at DESC, id DESC);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_wa_messages_user ON wa_messages (user_id);
|
|
149
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_wa_messages_wa_id
|
|
150
|
+
ON wa_messages (conversation_id, wa_message_id) WHERE wa_message_id IS NOT NULL;
|
|
151
|
+
|
|
152
|
+
-- ------------------------------------------------------------- memories -----
|
|
153
|
+
-- Long-term facts, keyed to the USER (never the group) so Alexa recognises the
|
|
154
|
+
-- same person in a DM and in every group. `key` is unique per user: re-learning
|
|
155
|
+
-- "name" overwrites rather than duplicating.
|
|
156
|
+
CREATE TABLE IF NOT EXISTS wa_memories (
|
|
157
|
+
id BIGSERIAL PRIMARY KEY,
|
|
158
|
+
user_id BIGINT NOT NULL REFERENCES wa_users(id) ON DELETE CASCADE,
|
|
159
|
+
key TEXT NOT NULL,
|
|
160
|
+
value TEXT NOT NULL,
|
|
161
|
+
source TEXT NOT NULL DEFAULT 'auto', -- auto | manual | import
|
|
162
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
163
|
+
hit_count INTEGER NOT NULL DEFAULT 0, -- times injected into a prompt
|
|
164
|
+
learned_in TEXT, -- context_key where learned
|
|
165
|
+
expires_at TIMESTAMPTZ, -- NULL = permanent
|
|
166
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
167
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
168
|
+
UNIQUE (user_id, key)
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_wa_memories_user ON wa_memories (user_id, updated_at DESC);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_wa_memories_expires ON wa_memories (expires_at) WHERE expires_at IS NOT NULL;
|
|
173
|
+
|
|
174
|
+
-- ---------------------------------------------------------------- audit ------
|
|
175
|
+
CREATE TABLE IF NOT EXISTS wa_ai_usage (
|
|
176
|
+
id BIGSERIAL PRIMARY KEY,
|
|
177
|
+
user_id BIGINT REFERENCES wa_users(id) ON DELETE SET NULL,
|
|
178
|
+
conversation_id BIGINT REFERENCES wa_conversations(id) ON DELETE SET NULL,
|
|
179
|
+
model TEXT,
|
|
180
|
+
ok BOOLEAN NOT NULL DEFAULT TRUE,
|
|
181
|
+
error_code TEXT,
|
|
182
|
+
latency_ms INTEGER,
|
|
183
|
+
prompt_chars INTEGER,
|
|
184
|
+
reply_chars INTEGER,
|
|
185
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
CREATE INDEX IF NOT EXISTS idx_wa_ai_usage_time ON wa_ai_usage (created_at DESC);
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_wa_ai_usage_user ON wa_ai_usage (user_id);
|
|
190
|
+
|
|
191
|
+
-- --------------------------------------------------- updated_at triggers -----
|
|
192
|
+
CREATE OR REPLACE FUNCTION wa_touch_updated_at() RETURNS TRIGGER AS $$
|
|
193
|
+
BEGIN
|
|
194
|
+
NEW.updated_at = NOW();
|
|
195
|
+
RETURN NEW;
|
|
196
|
+
END;
|
|
197
|
+
$$ LANGUAGE plpgsql;
|
|
198
|
+
|
|
199
|
+
DO $$
|
|
200
|
+
DECLARE t TEXT;
|
|
201
|
+
BEGIN
|
|
202
|
+
FOREACH t IN ARRAY ARRAY['wa_users','wa_groups','wa_conversations','wa_memories']
|
|
203
|
+
LOOP
|
|
204
|
+
IF NOT EXISTS (
|
|
205
|
+
SELECT 1 FROM pg_trigger
|
|
206
|
+
WHERE tgname = 'trg_touch_' || t
|
|
207
|
+
) THEN
|
|
208
|
+
EXECUTE format(
|
|
209
|
+
'CREATE TRIGGER trg_touch_%1$s BEFORE UPDATE ON %1$s
|
|
210
|
+
FOR EACH ROW EXECUTE FUNCTION wa_touch_updated_at()', t);
|
|
211
|
+
END IF;
|
|
212
|
+
END LOOP;
|
|
213
|
+
END;
|
|
214
|
+
$$;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const JidParser = require('../utils/JidParser');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ConversationRepository
|
|
7
|
+
* ----------------------
|
|
8
|
+
* Threads and their messages.
|
|
9
|
+
*
|
|
10
|
+
* A DM and each group are separate threads so chat context never leaks between
|
|
11
|
+
* rooms — while user identity/memory stays global (see MemoryRepository).
|
|
12
|
+
*/
|
|
13
|
+
class ConversationRepository {
|
|
14
|
+
/** @param {import('../db/Database')} db */
|
|
15
|
+
constructor(db) {
|
|
16
|
+
this.db = db;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find-or-create the thread for this (user, group) pair.
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {string} params.contextKey
|
|
23
|
+
* @param {number} params.userId
|
|
24
|
+
* @param {number|null} [params.groupId]
|
|
25
|
+
* @param {string} [params.title]
|
|
26
|
+
* @returns {Promise<object>}
|
|
27
|
+
*/
|
|
28
|
+
async upsertConversation({ contextKey, userId, groupId = null, title = null }) {
|
|
29
|
+
const kind = groupId ? 'group' : 'dm';
|
|
30
|
+
return this.db.one(
|
|
31
|
+
`INSERT INTO wa_conversations (context_key, user_id, group_id, kind, title)
|
|
32
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
33
|
+
ON CONFLICT (context_key) DO UPDATE
|
|
34
|
+
SET updated_at = NOW(),
|
|
35
|
+
title = COALESCE(EXCLUDED.title, wa_conversations.title)
|
|
36
|
+
RETURNING *`,
|
|
37
|
+
[contextKey, userId, groupId, kind, title]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append a message.
|
|
43
|
+
* @param {object} params
|
|
44
|
+
* @param {number} params.conversationId
|
|
45
|
+
* @param {number|null} params.userId
|
|
46
|
+
* @param {'user'|'assistant'|'system'} params.role
|
|
47
|
+
* @param {string} params.content
|
|
48
|
+
* @param {boolean} [params.hasMedia]
|
|
49
|
+
* @param {string} [params.mediaType]
|
|
50
|
+
* @param {string} [params.waMessageId]
|
|
51
|
+
* @param {object} [params.metadata]
|
|
52
|
+
* @returns {Promise<object|null>} null when deduped
|
|
53
|
+
*/
|
|
54
|
+
async addMessage({
|
|
55
|
+
conversationId,
|
|
56
|
+
userId = null,
|
|
57
|
+
role,
|
|
58
|
+
content,
|
|
59
|
+
hasMedia = false,
|
|
60
|
+
mediaType = null,
|
|
61
|
+
waMessageId = null,
|
|
62
|
+
metadata = {},
|
|
63
|
+
}) {
|
|
64
|
+
const text = String(content ?? '');
|
|
65
|
+
if (!conversationId || !text.trim()) return null;
|
|
66
|
+
|
|
67
|
+
// ON CONFLICT DO NOTHING relies on the partial unique index over
|
|
68
|
+
// (conversation_id, wa_message_id) — WhatsApp redelivers on reconnect.
|
|
69
|
+
const row = await this.db.one(
|
|
70
|
+
`INSERT INTO wa_messages
|
|
71
|
+
(conversation_id, user_id, role, content, has_media, media_type, wa_message_id, tokens, metadata)
|
|
72
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
|
73
|
+
ON CONFLICT DO NOTHING
|
|
74
|
+
RETURNING *`,
|
|
75
|
+
[
|
|
76
|
+
conversationId,
|
|
77
|
+
userId,
|
|
78
|
+
role,
|
|
79
|
+
text,
|
|
80
|
+
Boolean(hasMedia),
|
|
81
|
+
mediaType,
|
|
82
|
+
waMessageId,
|
|
83
|
+
Math.ceil(text.length / 4),
|
|
84
|
+
JSON.stringify(metadata || {}),
|
|
85
|
+
]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (row) {
|
|
89
|
+
await this.db.query(
|
|
90
|
+
`UPDATE wa_conversations
|
|
91
|
+
SET message_count = message_count + 1,
|
|
92
|
+
last_message_at = NOW(),
|
|
93
|
+
updated_at = NOW()
|
|
94
|
+
WHERE id = $1`,
|
|
95
|
+
[conversationId]
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return row;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Newest `limit` messages in chronological order, ready for the model.
|
|
103
|
+
* @param {number} conversationId
|
|
104
|
+
* @param {number} [limit=14]
|
|
105
|
+
* @returns {Promise<Array<{role:string, content:string}>>}
|
|
106
|
+
*/
|
|
107
|
+
async getHistory(conversationId, limit = 14) {
|
|
108
|
+
if (!conversationId) return [];
|
|
109
|
+
const rows = await this.db.many(
|
|
110
|
+
`SELECT role, content, created_at
|
|
111
|
+
FROM (
|
|
112
|
+
SELECT role, content, created_at, id
|
|
113
|
+
FROM wa_messages
|
|
114
|
+
WHERE conversation_id = $1
|
|
115
|
+
AND role IN ('user','assistant')
|
|
116
|
+
ORDER BY created_at DESC, id DESC
|
|
117
|
+
LIMIT $2
|
|
118
|
+
) recent
|
|
119
|
+
ORDER BY created_at ASC, id ASC`,
|
|
120
|
+
[conversationId, limit]
|
|
121
|
+
);
|
|
122
|
+
return rows.map((r) => ({ role: r.role, content: r.content }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async findByContextKey(contextKey) {
|
|
126
|
+
return this.db.one('SELECT * FROM wa_conversations WHERE context_key = $1', [contextKey]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Wipe a thread's messages (keeps the user and their memories). */
|
|
130
|
+
async clearHistory(contextKey) {
|
|
131
|
+
const convo = await this.findByContextKey(contextKey);
|
|
132
|
+
if (!convo) return 0;
|
|
133
|
+
const { rowCount } = await this.db.query('DELETE FROM wa_messages WHERE conversation_id = $1', [
|
|
134
|
+
convo.id,
|
|
135
|
+
]);
|
|
136
|
+
await this.db.query(
|
|
137
|
+
'UPDATE wa_conversations SET message_count = 0, last_message_at = NULL WHERE id = $1',
|
|
138
|
+
[convo.id]
|
|
139
|
+
);
|
|
140
|
+
return rowCount;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Trim a thread to its newest `keep` messages.
|
|
145
|
+
* Called opportunistically so tables stay bounded on busy groups.
|
|
146
|
+
*/
|
|
147
|
+
async trim(conversationId, keep = 200) {
|
|
148
|
+
if (!conversationId) return 0;
|
|
149
|
+
const { rowCount } = await this.db.query(
|
|
150
|
+
`DELETE FROM wa_messages
|
|
151
|
+
WHERE conversation_id = $1
|
|
152
|
+
AND id NOT IN (
|
|
153
|
+
SELECT id FROM wa_messages
|
|
154
|
+
WHERE conversation_id = $1
|
|
155
|
+
ORDER BY created_at DESC, id DESC
|
|
156
|
+
LIMIT $2
|
|
157
|
+
)`,
|
|
158
|
+
[conversationId, keep]
|
|
159
|
+
);
|
|
160
|
+
return rowCount;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* All threads a user participates in (DM + every group).
|
|
165
|
+
* Accepts ANY address the person is known under, not just their canonical
|
|
166
|
+
* jid, so a group `@lid` finds the threads created from their DM.
|
|
167
|
+
*/
|
|
168
|
+
async listForUser(rawJid) {
|
|
169
|
+
const jid = JidParser.normalize(rawJid);
|
|
170
|
+
return this.db.many(
|
|
171
|
+
`SELECT c.*, g.subject AS group_subject
|
|
172
|
+
FROM wa_conversations c
|
|
173
|
+
JOIN wa_users u ON u.id = c.user_id
|
|
174
|
+
LEFT JOIN wa_groups g ON g.id = c.group_id
|
|
175
|
+
WHERE u.jid = $1
|
|
176
|
+
OR u.id = (SELECT user_id FROM wa_user_identities WHERE jid = $1)
|
|
177
|
+
ORDER BY c.last_message_at DESC NULLS LAST`,
|
|
178
|
+
[jid]
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Audit row for observability. */
|
|
183
|
+
async logUsage({
|
|
184
|
+
userId = null,
|
|
185
|
+
conversationId = null,
|
|
186
|
+
model = null,
|
|
187
|
+
ok = true,
|
|
188
|
+
errorCode = null,
|
|
189
|
+
latencyMs = null,
|
|
190
|
+
promptChars = null,
|
|
191
|
+
replyChars = null,
|
|
192
|
+
}) {
|
|
193
|
+
try {
|
|
194
|
+
await this.db.query(
|
|
195
|
+
`INSERT INTO wa_ai_usage
|
|
196
|
+
(user_id, conversation_id, model, ok, error_code, latency_ms, prompt_chars, reply_chars)
|
|
197
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
|
198
|
+
[userId, conversationId, model, ok, errorCode, latencyMs, promptChars, replyChars]
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
// Telemetry must never break a reply.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = ConversationRepository;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const JidParser = require('../utils/JidParser');
|
|
4
|
+
const { ValidationError } = require('../core/errors');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* IdentityRepository
|
|
8
|
+
* ------------------
|
|
9
|
+
* The alias graph that makes "one human = one row" true.
|
|
10
|
+
*
|
|
11
|
+
* THE BUG THIS FIXES
|
|
12
|
+
* ------------------
|
|
13
|
+
* WhatsApp addresses the same person differently depending on the surface:
|
|
14
|
+
*
|
|
15
|
+
* DM -> 94771234567@s.whatsapp.net (phone jid)
|
|
16
|
+
* Group -> 78151912841263@lid (privacy jid, LID addressing)
|
|
17
|
+
*
|
|
18
|
+
* The engine used to key `wa_users` on the jid alone, so the person who
|
|
19
|
+
* introduced themselves in a DM was a *different* row in a group — and Alexa
|
|
20
|
+
* answered "sorry, as a bot I can't remember you". Now every jid a person is
|
|
21
|
+
* seen under is stored in `wa_user_identities` and resolves to one user id;
|
|
22
|
+
* when two rows turn out to be the same human they are merged, memories and
|
|
23
|
+
* transcripts included.
|
|
24
|
+
*/
|
|
25
|
+
class IdentityRepository {
|
|
26
|
+
/** @param {import('../db/Database')} db */
|
|
27
|
+
constructor(db) {
|
|
28
|
+
this.db = db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The user a jid belongs to, following the alias graph.
|
|
33
|
+
* @param {string} rawJid
|
|
34
|
+
* @returns {Promise<object|null>} wa_users row
|
|
35
|
+
*/
|
|
36
|
+
async findUserByJid(rawJid) {
|
|
37
|
+
const jid = JidParser.normalize(rawJid);
|
|
38
|
+
if (!jid) return null;
|
|
39
|
+
return this.db.one(
|
|
40
|
+
`SELECT u.* FROM wa_users u
|
|
41
|
+
JOIN wa_user_identities i ON i.user_id = u.id
|
|
42
|
+
WHERE i.jid = $1
|
|
43
|
+
LIMIT 1`,
|
|
44
|
+
[jid]
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The user that owns a phone number, whatever jid shape it was seen as. */
|
|
49
|
+
async findUserByPhone(phone) {
|
|
50
|
+
const digits = String(phone || '').replace(/\D/g, '');
|
|
51
|
+
if (digits.length < 6) return null;
|
|
52
|
+
return this.db.one(
|
|
53
|
+
`SELECT u.* FROM wa_users u
|
|
54
|
+
JOIN wa_user_identities i ON i.user_id = u.id
|
|
55
|
+
WHERE i.phone = $1
|
|
56
|
+
ORDER BY i.is_primary DESC, i.first_seen_at ASC
|
|
57
|
+
LIMIT 1`,
|
|
58
|
+
[digits]
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every jid known for a user (primary first). */
|
|
63
|
+
async aliasesFor(userId) {
|
|
64
|
+
if (!userId) return [];
|
|
65
|
+
return this.db.many(
|
|
66
|
+
`SELECT jid, jid_type, phone, is_primary, source, last_seen_at
|
|
67
|
+
FROM wa_user_identities
|
|
68
|
+
WHERE user_id = $1
|
|
69
|
+
ORDER BY is_primary DESC, first_seen_at ASC`,
|
|
70
|
+
[userId]
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record `rawJid` as an alias of `userId`.
|
|
76
|
+
* If the jid already belongs to someone else the two users are merged
|
|
77
|
+
* (unless `merge:false`), because a jid can only ever be one human.
|
|
78
|
+
*
|
|
79
|
+
* @param {number} userId
|
|
80
|
+
* @param {string} rawJid
|
|
81
|
+
* @param {object} [opts]
|
|
82
|
+
* @param {boolean} [opts.primary=false]
|
|
83
|
+
* @param {string} [opts.source='observed']
|
|
84
|
+
* @param {boolean} [opts.merge=true]
|
|
85
|
+
* @returns {Promise<{linked:boolean, merged:boolean, userId:number}>}
|
|
86
|
+
*/
|
|
87
|
+
async link(userId, rawJid, opts = {}) {
|
|
88
|
+
const parsed = JidParser.parse(rawJid);
|
|
89
|
+
if (!userId || !parsed.valid || parsed.isGroup) return { linked: false, merged: false, userId };
|
|
90
|
+
|
|
91
|
+
const existing = await this.db.one('SELECT * FROM wa_user_identities WHERE jid = $1', [parsed.jid]);
|
|
92
|
+
|
|
93
|
+
if (existing && Number(existing.user_id) !== Number(userId)) {
|
|
94
|
+
if (opts.merge === false) return { linked: false, merged: false, userId };
|
|
95
|
+
const keep = await this.merge(userId, existing.user_id);
|
|
96
|
+
return { linked: true, merged: true, userId: keep.id };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.db.query(
|
|
100
|
+
`INSERT INTO wa_user_identities (user_id, jid, jid_local, jid_server, jid_type, phone, is_primary, source)
|
|
101
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
102
|
+
ON CONFLICT (jid) DO UPDATE
|
|
103
|
+
SET user_id = EXCLUDED.user_id,
|
|
104
|
+
last_seen_at = NOW(),
|
|
105
|
+
phone = COALESCE(wa_user_identities.phone, EXCLUDED.phone),
|
|
106
|
+
is_primary = wa_user_identities.is_primary OR EXCLUDED.is_primary,
|
|
107
|
+
source = COALESCE(wa_user_identities.source, EXCLUDED.source)`,
|
|
108
|
+
[
|
|
109
|
+
userId,
|
|
110
|
+
parsed.jid,
|
|
111
|
+
parsed.local,
|
|
112
|
+
parsed.server,
|
|
113
|
+
parsed.type,
|
|
114
|
+
parsed.phone,
|
|
115
|
+
Boolean(opts.primary),
|
|
116
|
+
opts.source || 'observed',
|
|
117
|
+
]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// A phone jid teaches us the person's number even when the row was
|
|
121
|
+
// first created from an anonymous @lid.
|
|
122
|
+
if (parsed.phone) {
|
|
123
|
+
await this.db.query('UPDATE wa_users SET phone = COALESCE(phone, $2) WHERE id = $1', [
|
|
124
|
+
userId,
|
|
125
|
+
parsed.phone,
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
return { linked: true, merged: false, userId };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fold `loserId` into `winnerId`: memories, transcripts, group membership,
|
|
133
|
+
* usage rows and aliases all move across, then the loser row is deleted.
|
|
134
|
+
* The OLDER row always wins so the longest-lived history survives.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Promise<object>} the surviving wa_users row
|
|
137
|
+
*/
|
|
138
|
+
async merge(winnerId, loserId) {
|
|
139
|
+
const a = Number(winnerId);
|
|
140
|
+
const b = Number(loserId);
|
|
141
|
+
if (!a || !b) throw new ValidationError('merge() needs two user ids');
|
|
142
|
+
if (a === b) return this.db.one('SELECT * FROM wa_users WHERE id = $1', [a]);
|
|
143
|
+
|
|
144
|
+
const rows = await this.db.many('SELECT * FROM wa_users WHERE id = ANY($1::bigint[])', [[a, b]]);
|
|
145
|
+
if (rows.length < 2) {
|
|
146
|
+
return this.db.one('SELECT * FROM wa_users WHERE id = $1', [rows[0]?.id || a]);
|
|
147
|
+
}
|
|
148
|
+
const [older, newer] = rows.sort(
|
|
149
|
+
(x, y) => new Date(x.first_seen_at) - new Date(y.first_seen_at) || Number(x.id) - Number(y.id)
|
|
150
|
+
);
|
|
151
|
+
const keepId = Number(older.id);
|
|
152
|
+
const dropId = Number(newer.id);
|
|
153
|
+
|
|
154
|
+
return this.db.transaction(async (client) => {
|
|
155
|
+
// --- memories: newest value of each key wins -------------------
|
|
156
|
+
await client.query(
|
|
157
|
+
`DELETE FROM wa_memories k
|
|
158
|
+
USING wa_memories d
|
|
159
|
+
WHERE k.user_id = $1 AND d.user_id = $2
|
|
160
|
+
AND k.key = d.key
|
|
161
|
+
AND d.updated_at > k.updated_at`,
|
|
162
|
+
[keepId, dropId]
|
|
163
|
+
);
|
|
164
|
+
await client.query(
|
|
165
|
+
`UPDATE wa_memories SET user_id = $1
|
|
166
|
+
WHERE user_id = $2
|
|
167
|
+
AND key NOT IN (SELECT key FROM wa_memories WHERE user_id = $1)`,
|
|
168
|
+
[keepId, dropId]
|
|
169
|
+
);
|
|
170
|
+
await client.query('DELETE FROM wa_memories WHERE user_id = $1', [dropId]);
|
|
171
|
+
|
|
172
|
+
// --- group membership ------------------------------------------
|
|
173
|
+
await client.query(
|
|
174
|
+
`UPDATE wa_group_members SET user_id = $1
|
|
175
|
+
WHERE user_id = $2
|
|
176
|
+
AND group_id NOT IN (SELECT group_id FROM wa_group_members WHERE user_id = $1)`,
|
|
177
|
+
[keepId, dropId]
|
|
178
|
+
);
|
|
179
|
+
await client.query('DELETE FROM wa_group_members WHERE user_id = $1', [dropId]);
|
|
180
|
+
|
|
181
|
+
// --- threads, messages, telemetry -------------------------------
|
|
182
|
+
await client.query('UPDATE wa_conversations SET user_id = $1 WHERE user_id = $2', [keepId, dropId]);
|
|
183
|
+
await client.query('UPDATE wa_messages SET user_id = $1 WHERE user_id = $2', [keepId, dropId]);
|
|
184
|
+
await client.query('UPDATE wa_ai_usage SET user_id = $1 WHERE user_id = $2', [keepId, dropId]);
|
|
185
|
+
|
|
186
|
+
// --- aliases -----------------------------------------------------
|
|
187
|
+
await client.query(
|
|
188
|
+
'UPDATE wa_user_identities SET user_id = $1, is_primary = FALSE WHERE user_id = $2',
|
|
189
|
+
[keepId, dropId]
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// --- roll the counters and the best-known profile up ------------
|
|
193
|
+
await client.query(
|
|
194
|
+
`UPDATE wa_users k SET
|
|
195
|
+
message_count = k.message_count + d.message_count,
|
|
196
|
+
token_estimate = k.token_estimate + d.token_estimate,
|
|
197
|
+
push_name = COALESCE(k.push_name, d.push_name),
|
|
198
|
+
display_name = COALESCE(k.display_name, d.display_name),
|
|
199
|
+
phone = COALESCE(k.phone, d.phone),
|
|
200
|
+
locale = COALESCE(k.locale, d.locale),
|
|
201
|
+
is_blocked = k.is_blocked OR d.is_blocked,
|
|
202
|
+
is_admin = k.is_admin OR d.is_admin,
|
|
203
|
+
metadata = d.metadata || k.metadata,
|
|
204
|
+
last_seen_at = GREATEST(k.last_seen_at, d.last_seen_at),
|
|
205
|
+
updated_at = NOW()
|
|
206
|
+
FROM wa_users d
|
|
207
|
+
WHERE k.id = $1 AND d.id = $2`,
|
|
208
|
+
[keepId, dropId]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
await client.query('DELETE FROM wa_users WHERE id = $1', [dropId]);
|
|
212
|
+
await client.query('UPDATE wa_user_identities SET is_primary = TRUE WHERE user_id = $1 AND jid = (SELECT jid FROM wa_users WHERE id = $1)', [keepId]);
|
|
213
|
+
|
|
214
|
+
const { rows: kept } = await client.query('SELECT * FROM wa_users WHERE id = $1', [keepId]);
|
|
215
|
+
return kept[0];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Make `rawJid` the canonical address of a user (used for context keys). */
|
|
220
|
+
async setPrimary(userId, rawJid) {
|
|
221
|
+
const jid = JidParser.normalize(rawJid);
|
|
222
|
+
if (!userId || !jid) return null;
|
|
223
|
+
await this.db.query('UPDATE wa_user_identities SET is_primary = (jid = $2) WHERE user_id = $1', [
|
|
224
|
+
userId,
|
|
225
|
+
jid,
|
|
226
|
+
]);
|
|
227
|
+
return this.db.one('UPDATE wa_users SET jid = $2 WHERE id = $1 RETURNING *', [userId, jid]).catch(() => null);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The stable jid used to build conversation keys for this user. */
|
|
231
|
+
async primaryJid(userId, fallback = null) {
|
|
232
|
+
if (!userId) return fallback;
|
|
233
|
+
const row = await this.db.one(
|
|
234
|
+
`SELECT jid FROM wa_user_identities
|
|
235
|
+
WHERE user_id = $1
|
|
236
|
+
ORDER BY is_primary DESC, first_seen_at ASC
|
|
237
|
+
LIMIT 1`,
|
|
238
|
+
[userId]
|
|
239
|
+
);
|
|
240
|
+
return row?.jid || fallback;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = IdentityRepository;
|