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,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Endpoints
|
|
5
|
+
* ---------
|
|
6
|
+
* Every DeepAI route the engine knows how to talk to.
|
|
7
|
+
*
|
|
8
|
+
* These were taken from the live deepai.org chat client, so the engine speaks
|
|
9
|
+
* the *whole* API instead of only POSTing to the generative endpoint:
|
|
10
|
+
*
|
|
11
|
+
* POST /hacking_is_a_serious_crime chat completion (streamed text)
|
|
12
|
+
* GET /check_chat_task_status poll a background task (thinking, memory refresh)
|
|
13
|
+
* GET /check-sensitivity per-request sensitivity score
|
|
14
|
+
* POST /chat_attachments/upload upload an image/document
|
|
15
|
+
* GET /chat_attachments/get attachment + server-side extraction status
|
|
16
|
+
* POST /save_chat_session persist a transcript server-side
|
|
17
|
+
* GET /get_chat_session load a transcript
|
|
18
|
+
* POST /delete_chat_session delete one transcript
|
|
19
|
+
* POST /rename_chat_session rename one transcript
|
|
20
|
+
* POST /delete_all_chat_history nuke every transcript
|
|
21
|
+
* GET/POST /chat_memory DeepAI's own long-term memory profile
|
|
22
|
+
* GET/POST /chat_sandbox agent-mode ("sandbox") toggle
|
|
23
|
+
* GET/POST /chat_concierge concierge/background-task toggle
|
|
24
|
+
* POST /report_character abuse report
|
|
25
|
+
* POST /api/<name> the classic public API family
|
|
26
|
+
* (text2img, image-editor, torch-srgan,
|
|
27
|
+
* colorizer, nsfw-detector, …)
|
|
28
|
+
*
|
|
29
|
+
* All of them are overridable through `new AlexaAI({ endpoints: {...} })` so a
|
|
30
|
+
* future DeepAI rename never requires a code change.
|
|
31
|
+
*/
|
|
32
|
+
const ENDPOINTS = {
|
|
33
|
+
chat: '/hacking_is_a_serious_crime',
|
|
34
|
+
taskStatus: '/check_chat_task_status',
|
|
35
|
+
sensitivity: '/check-sensitivity',
|
|
36
|
+
attachmentUpload: '/chat_attachments/upload',
|
|
37
|
+
attachmentGet: '/chat_attachments/get',
|
|
38
|
+
saveSession: '/save_chat_session',
|
|
39
|
+
getSession: '/get_chat_session',
|
|
40
|
+
deleteSession: '/delete_chat_session',
|
|
41
|
+
renameSession: '/rename_chat_session',
|
|
42
|
+
deleteAllSessions: '/delete_all_chat_history',
|
|
43
|
+
memory: '/chat_memory',
|
|
44
|
+
sandbox: '/chat_sandbox',
|
|
45
|
+
concierge: '/chat_concierge',
|
|
46
|
+
reportCharacter: '/report_character',
|
|
47
|
+
api: '/api',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Public "standard API" operations. `AlexaAI.deepai.runApi(name, fields)` can
|
|
52
|
+
* call any of them; the named helpers below just document the common ones.
|
|
53
|
+
*/
|
|
54
|
+
const STANDARD_APIS = {
|
|
55
|
+
text2img: 'text2img',
|
|
56
|
+
imageEditor: 'image-editor',
|
|
57
|
+
superResolution: 'torch-srgan',
|
|
58
|
+
waifu2x: 'waifu2x',
|
|
59
|
+
colorizer: 'colorizer',
|
|
60
|
+
nsfwDetector: 'nsfw-detector',
|
|
61
|
+
imageSimilarity: 'image-similarity',
|
|
62
|
+
textTagging: 'text-tagging',
|
|
63
|
+
summarization: 'summarization',
|
|
64
|
+
sentiment: 'sentiment-analysis',
|
|
65
|
+
textGenerator: 'text-generator',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Task types accepted by `/check_chat_task_status?type=…`. */
|
|
69
|
+
const TASK_TYPES = {
|
|
70
|
+
thinking: 'thinking-task',
|
|
71
|
+
memoryRefresh: 'memory-refresh-task',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
module.exports = { ENDPOINTS, STANDARD_APIS, TASK_TYPES };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Alexa persona.
|
|
5
|
+
*
|
|
6
|
+
* NOTE ON DELIVERY: DeepAI's chat endpoint has historically ignored
|
|
7
|
+
* `role: "system"` messages, so PromptBuilder delivers this text BOTH as a
|
|
8
|
+
* system turn (harmless, and honoured by every other backend) AND as a priming
|
|
9
|
+
* user/assistant pair, which the live API does respect. See PromptBuilder.
|
|
10
|
+
*
|
|
11
|
+
* `build({assistantName, creator})` lets a host bot rename the persona without
|
|
12
|
+
* copy-pasting the whole prompt; `module.exports` stays the default string so
|
|
13
|
+
* `require('./SystemPrompt')` keeps working.
|
|
14
|
+
*/
|
|
15
|
+
function build({ assistantName = 'Alexa', creator = 'Hansaka' } = {}) {
|
|
16
|
+
const name = String(assistantName || 'Alexa').trim() || 'Alexa';
|
|
17
|
+
const maker = String(creator || 'Hansaka').trim() || 'Hansaka';
|
|
18
|
+
|
|
19
|
+
return `You are ${name}, an intelligent, warm, and friendly female WhatsApp AI assistant created by ${maker}.
|
|
20
|
+
|
|
21
|
+
[CORE IDENTITY & PERSONA]
|
|
22
|
+
- Name: ${name}
|
|
23
|
+
- Role: WhatsApp AI Assistant
|
|
24
|
+
- Creator: ${maker}
|
|
25
|
+
- Persona: Friendly, polite, helpful, and engaging female assistant.
|
|
26
|
+
|
|
27
|
+
[IDENTITY RULES — NON-NEGOTIABLE]
|
|
28
|
+
- Your name is exactly "${name}". Not "${name} Mini", not "${name} AI", not "${name} Assistant", not any other variant.
|
|
29
|
+
- You were created by ${maker}. No other company, lab, or product made you.
|
|
30
|
+
- You are NOT ChatGPT, NOT GPT, NOT OpenAI, NOT DeepAI, NOT Llama, NOT Gemini, NOT Claude, and NOT "Standard AI Chat".
|
|
31
|
+
- NEVER mention, hint at, or compare yourself with any AI vendor, model name, or model version.
|
|
32
|
+
- NEVER say you are "a language model", "an AI model", or that you were "trained by" anyone.
|
|
33
|
+
- If asked who you are: you are ${name}, a WhatsApp assistant made by ${maker}. Nothing else.
|
|
34
|
+
|
|
35
|
+
[CRITICAL FORMATTING RULES - WHATSAPP ONLY]
|
|
36
|
+
You respond exclusively inside WhatsApp messages. Standard Markdown breaks WhatsApp formatting.
|
|
37
|
+
- STRICTLY FORBIDDEN: Do NOT use double asterisks \`**text**\` or markdown headers like \`#\` or \`##\`.
|
|
38
|
+
- ALWAYS use WhatsApp-native syntax:
|
|
39
|
+
* Bold: *text* (single asterisk)
|
|
40
|
+
* Italic: _text_ (single underscore)
|
|
41
|
+
* Strikethrough: ~text~ (single tilde)
|
|
42
|
+
* Inline Code / Highlight: \`text\`
|
|
43
|
+
* Monospace Block: \`\`\`text\`\`\`
|
|
44
|
+
* Combined: _*bold italic*_
|
|
45
|
+
|
|
46
|
+
[STRICT TRIGGER COMMANDS - EXACT MATCH OUTPUTS]
|
|
47
|
+
If the user's input matches one of the following 4 intents, reply ONLY with the exact text specified below. Do NOT add greetings, extra words, punctuation, or formatting.
|
|
48
|
+
|
|
49
|
+
1. Weather Requests:
|
|
50
|
+
- Intent: Asking about current weather, forecast, or temperature for any location.
|
|
51
|
+
- Output Format: weather <city_name>
|
|
52
|
+
- Examples:
|
|
53
|
+
User: "What is the weather in Colombo today?" -> Output: weather Colombo
|
|
54
|
+
User: "Is it raining in Kandy right now?" -> Output: weather Kandy
|
|
55
|
+
|
|
56
|
+
2. Menu Requests:
|
|
57
|
+
- Intent: Asking for bot menu, options, commands, or 'show menu'.
|
|
58
|
+
- Output Format: menu
|
|
59
|
+
|
|
60
|
+
3. System Status / Ping Requests:
|
|
61
|
+
- Intent: Testing ping, asking about server/system status.
|
|
62
|
+
- Output Format: ping
|
|
63
|
+
|
|
64
|
+
4. Documentation Requests:
|
|
65
|
+
- Intent: Asking for docs, user guide, or documentation.
|
|
66
|
+
- Output Format: doc
|
|
67
|
+
|
|
68
|
+
[MATH QUERY RULES]
|
|
69
|
+
- Provide ONLY the direct final formula and result.
|
|
70
|
+
- Do NOT include step-by-step explanations or derivations.
|
|
71
|
+
- Wrap math results in WhatsApp inline code format.
|
|
72
|
+
- Example:
|
|
73
|
+
User: "Calculate the area of a circle with radius 7"
|
|
74
|
+
Output: \`A = π * 7² ≈ 153.938\`
|
|
75
|
+
|
|
76
|
+
[IMAGE & VISION ANALYSIS]
|
|
77
|
+
- When an image is provided, analyze it thoroughly and respond according to the user's text prompt.
|
|
78
|
+
- If an image is sent without any text prompt, briefly and warmly describe what you see in the image using WhatsApp text formatting.
|
|
79
|
+
- When a description of the attached image is given to you in brackets, treat it as something you can SEE. Never reply that you cannot see images.
|
|
80
|
+
|
|
81
|
+
[MEMORY — YOU DO HAVE A PERSISTENT MEMORY]
|
|
82
|
+
- ${maker} gave you a permanent database. Facts you learn about a person stay with that person FOREVER, in their private chat AND in every group they write to you from.
|
|
83
|
+
- NEVER say "I can't remember", "I have no memory of past conversations", "as an AI I cannot recall", or "our conversation just started". Those statements are false for you.
|
|
84
|
+
- Facts already known about the current person are supplied to you before each message. Treat them as things you personally remember, and answer recall questions ("do you remember me?", "what is my name?") directly from them.
|
|
85
|
+
- If a fact genuinely is not in the supplied list, say you do not know that detail yet and ask for it — never deny having memory at all.
|
|
86
|
+
|
|
87
|
+
[MEMORY TRACKING SYSTEM]
|
|
88
|
+
- Silently monitor the chat for useful personal information (e.g., real name, favorite food, location, hobbies).
|
|
89
|
+
- When new info is revealed, respond naturally to the user FIRST, and append a hidden JSON tag at the VERY END.
|
|
90
|
+
- Output Format: @MEMORY: {"key": "value"}
|
|
91
|
+
- Example:
|
|
92
|
+
User: "Hi, I'm Nimal and I love playing cricket."
|
|
93
|
+
Output: Nice to meet you, Nimal! Cricket is a great sport. @MEMORY: {"name": "Nimal", "hobby": "cricket"}
|
|
94
|
+
- NEVER explain or mention the \`@MEMORY:\` tag to the user.
|
|
95
|
+
|
|
96
|
+
[GENERAL CONVERSATION]
|
|
97
|
+
For all other queries, chat naturally, warmly, and helpfully as ${name}. Keep responses formatted for easy reading on mobile screens.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const SYSTEM_PROMPT = build();
|
|
101
|
+
|
|
102
|
+
module.exports = { build, SYSTEM_PROMPT };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StreamParser
|
|
5
|
+
* ------------
|
|
6
|
+
* DeepAI's chat endpoint does NOT return clean prose. It returns a plain-text
|
|
7
|
+
* stream with three kinds of out-of-band packets embedded in it, exactly as
|
|
8
|
+
* decoded by the browser client:
|
|
9
|
+
*
|
|
10
|
+
* 1. Tool-activity packets \u001C{"tool_activity":"Searching the web…"}\u001C
|
|
11
|
+
* Sprinkled anywhere in the stream while a tool runs. The browser strips
|
|
12
|
+
* them and shows them as a status line.
|
|
13
|
+
*
|
|
14
|
+
* 2. A trailing payload …answer text…\u001C{"type":"generated_image",…}
|
|
15
|
+
* Everything after the LAST lone \u001C is JSON: either an array of web
|
|
16
|
+
* search results, or a generated-image / function-call object.
|
|
17
|
+
*
|
|
18
|
+
* 3. Thinking blocks \u001dTHINKING_START12s\u001e<chain of thought>\u001dTHINKING_END
|
|
19
|
+
* Emitted by reasoning-capable models.
|
|
20
|
+
*
|
|
21
|
+
* Before this parser existed the engine forwarded the raw stream to WhatsApp,
|
|
22
|
+
* so users could see control characters, JSON blobs and the model's private
|
|
23
|
+
* chain of thought. `parse()` splits it all apart.
|
|
24
|
+
*/
|
|
25
|
+
const FS = '\u001C'; // file separator — packet delimiter
|
|
26
|
+
const GS = '\u001D'; // group separator — thinking markers
|
|
27
|
+
const RS = '\u001E'; // record separator — "12s" <RS> "<cot>"
|
|
28
|
+
|
|
29
|
+
const ACTIVITY_PACKET = /\u001C(\{[^\u001C]*\})\u001C/g;
|
|
30
|
+
const THINK_START = `${GS}THINKING_START`;
|
|
31
|
+
const THINK_END = `${GS}THINKING_END`;
|
|
32
|
+
|
|
33
|
+
class StreamParser {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} raw full (or partial) response body
|
|
36
|
+
* @returns {{
|
|
37
|
+
* text: string,
|
|
38
|
+
* payload: any|null,
|
|
39
|
+
* payloadRaw: string|null,
|
|
40
|
+
* toolActivity: string[],
|
|
41
|
+
* thinking: { text: string|null, duration: string|null }|null,
|
|
42
|
+
* images: string[],
|
|
43
|
+
* functionCall: { name: string, arguments: any }|null,
|
|
44
|
+
* webResults: Array<{title:string,url:string,description?:string}>|null
|
|
45
|
+
* }}
|
|
46
|
+
*/
|
|
47
|
+
static parse(raw) {
|
|
48
|
+
const source = String(raw ?? '');
|
|
49
|
+
|
|
50
|
+
// ---- 1. tool activity ------------------------------------------------
|
|
51
|
+
const toolActivity = [];
|
|
52
|
+
let text = source.replace(ACTIVITY_PACKET, (_m, json) => {
|
|
53
|
+
try {
|
|
54
|
+
const packet = JSON.parse(json);
|
|
55
|
+
if (typeof packet.tool_activity === 'string') toolActivity.push(packet.tool_activity);
|
|
56
|
+
} catch {
|
|
57
|
+
/* not an activity packet — drop it, it is not prose either */
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---- 2. trailing JSON payload ---------------------------------------
|
|
63
|
+
let payloadRaw = null;
|
|
64
|
+
let payload = null;
|
|
65
|
+
const fsIndex = text.indexOf(FS);
|
|
66
|
+
if (fsIndex !== -1) {
|
|
67
|
+
const candidate = text.slice(fsIndex + 1).trim();
|
|
68
|
+
if (candidate) {
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(candidate);
|
|
71
|
+
payloadRaw = candidate;
|
|
72
|
+
} catch {
|
|
73
|
+
// Truncated packet (stream cut mid-JSON): discard it rather
|
|
74
|
+
// than leaking half a JSON blob into a WhatsApp message.
|
|
75
|
+
payload = null;
|
|
76
|
+
payloadRaw = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
text = text.slice(0, fsIndex);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- 3. thinking block ----------------------------------------------
|
|
83
|
+
let thinking = null;
|
|
84
|
+
const start = text.indexOf(THINK_START);
|
|
85
|
+
const end = text.indexOf(THINK_END);
|
|
86
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
87
|
+
const body = text.slice(start + THINK_START.length, end);
|
|
88
|
+
const sep = body.indexOf(RS);
|
|
89
|
+
thinking =
|
|
90
|
+
sep !== -1
|
|
91
|
+
? { duration: body.slice(0, sep) || null, text: body.slice(sep + 1) || null }
|
|
92
|
+
: { duration: null, text: body || null };
|
|
93
|
+
text = text.slice(0, start) + text.slice(end + THINK_END.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Any stray separators left over (partial packets) must never ship.
|
|
97
|
+
text = text.replace(/[\u001C\u001D\u001E]/g, '').trim();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
text,
|
|
101
|
+
payload,
|
|
102
|
+
payloadRaw,
|
|
103
|
+
toolActivity,
|
|
104
|
+
thinking,
|
|
105
|
+
images: StreamParser.imagesFrom(payload),
|
|
106
|
+
functionCall: StreamParser.functionCallFrom(payload),
|
|
107
|
+
webResults: Array.isArray(payload) ? payload : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Image URLs carried by a `{type:'generated_image'}` payload. */
|
|
112
|
+
static imagesFrom(payload) {
|
|
113
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [];
|
|
114
|
+
const urls = [];
|
|
115
|
+
if (payload.type === 'generated_image' || payload.share_url || payload.output_url) {
|
|
116
|
+
const url = payload.share_url || payload.url || payload.output_url;
|
|
117
|
+
if (typeof url === 'string' && url) urls.push(url);
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(payload.images)) {
|
|
120
|
+
for (const img of payload.images) {
|
|
121
|
+
const url = typeof img === 'string' ? img : img?.share_url || img?.url;
|
|
122
|
+
if (url) urls.push(url);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return urls;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `{function_call:{name, arguments}}` — the image tool protocol. */
|
|
129
|
+
static functionCallFrom(payload) {
|
|
130
|
+
const call = payload && !Array.isArray(payload) ? payload.function_call : null;
|
|
131
|
+
if (!call || typeof call.name !== 'string') return null;
|
|
132
|
+
let args = call.arguments;
|
|
133
|
+
if (typeof args === 'string') {
|
|
134
|
+
try {
|
|
135
|
+
args = JSON.parse(args);
|
|
136
|
+
} catch {
|
|
137
|
+
/* keep the raw string */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { name: call.name, arguments: args ?? {} };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the payload the browser sends when the user presses "Create image",
|
|
145
|
+
* so the engine can drive DeepAI's in-chat image tool the same way.
|
|
146
|
+
*/
|
|
147
|
+
static imageToolPayload(prompt, aspectRatio = '1:1') {
|
|
148
|
+
return JSON.stringify({
|
|
149
|
+
function_call: {
|
|
150
|
+
name: 'generate_image',
|
|
151
|
+
arguments: JSON.stringify({ prompt: String(prompt ?? ''), aspect_ratio: aspectRatio }),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = StreamParser;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Base class for every error thrown by the engine. */
|
|
4
|
+
class AlexaAIError extends Error {
|
|
5
|
+
constructor(message, options = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = this.constructor.name;
|
|
8
|
+
this.code = options.code || 'ALEXA_AI_ERROR';
|
|
9
|
+
this.retryable = Boolean(options.retryable);
|
|
10
|
+
if (options.cause) this.cause = options.cause;
|
|
11
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Network/HTTP/parse failure talking to DeepAI. */
|
|
16
|
+
class DeepAIError extends AlexaAIError {
|
|
17
|
+
constructor(message, options = {}) {
|
|
18
|
+
super(message, { code: options.code || 'DEEPAI_ERROR', ...options });
|
|
19
|
+
this.status = options.status ?? null;
|
|
20
|
+
this.body = options.body ?? null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** DeepAI refused the request: quota, paid-model, or auth. */
|
|
25
|
+
class QuotaExceededError extends DeepAIError {
|
|
26
|
+
constructor(message, options = {}) {
|
|
27
|
+
super(message, { code: 'DEEPAI_QUOTA_EXCEEDED', retryable: false, ...options });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** PostgreSQL failure. */
|
|
32
|
+
class DatabaseError extends AlexaAIError {
|
|
33
|
+
constructor(message, options = {}) {
|
|
34
|
+
super(message, { code: options.code || 'DATABASE_ERROR', ...options });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bad arguments handed to a public method. */
|
|
39
|
+
class ValidationError extends AlexaAIError {
|
|
40
|
+
constructor(message, options = {}) {
|
|
41
|
+
super(message, { code: 'VALIDATION_ERROR', retryable: false, ...options });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
AlexaAIError,
|
|
47
|
+
DeepAIError,
|
|
48
|
+
QuotaExceededError,
|
|
49
|
+
DatabaseError,
|
|
50
|
+
ValidationError,
|
|
51
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { Pool } = require('pg');
|
|
6
|
+
const { DatabaseError } = require('../core/errors');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Database
|
|
10
|
+
* --------
|
|
11
|
+
* Owns the pg connection pool, runs migrations, and exposes small helpers
|
|
12
|
+
* (`query`, `one`, `transaction`) used by the repositories.
|
|
13
|
+
*/
|
|
14
|
+
class Database {
|
|
15
|
+
/** @param {import('../core/Config')} config */
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.log = config.logger;
|
|
19
|
+
this.pool = null;
|
|
20
|
+
this._ready = null;
|
|
21
|
+
this._closed = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lazily create the pool + run migrations exactly once. */
|
|
25
|
+
async connect() {
|
|
26
|
+
if (this._ready) return this._ready;
|
|
27
|
+
|
|
28
|
+
this._ready = (async () => {
|
|
29
|
+
this.pool = new Pool({
|
|
30
|
+
connectionString: this.config.postgresUrl,
|
|
31
|
+
ssl: this.config.ssl,
|
|
32
|
+
...this.config.pool,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// A pooled client can die (network blip, managed-PG restart).
|
|
36
|
+
// Without this handler Node would crash the whole bot.
|
|
37
|
+
this.pool.on('error', (err) => {
|
|
38
|
+
this.log.error?.('[AlexaAI] Idle PostgreSQL client error:', err.message);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const client = await this.pool.connect();
|
|
43
|
+
client.release();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
this.pool = null;
|
|
46
|
+
this._ready = null;
|
|
47
|
+
throw new DatabaseError(`Cannot connect to PostgreSQL: ${err.message}`, {
|
|
48
|
+
code: 'DB_CONNECT_FAILED',
|
|
49
|
+
cause: err,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (this.config.autoMigrate) await this.migrate();
|
|
54
|
+
if (this.config.debug) this.log.info?.('[AlexaAI] PostgreSQL ready');
|
|
55
|
+
return this.pool;
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
return this._ready;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Apply schema.sql. Idempotent. */
|
|
62
|
+
async migrate() {
|
|
63
|
+
const sqlPath = path.join(__dirname, 'schema.sql');
|
|
64
|
+
let sql;
|
|
65
|
+
try {
|
|
66
|
+
sql = fs.readFileSync(sqlPath, 'utf8');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new DatabaseError(`Unable to read schema.sql: ${err.message}`, { cause: err });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await this.pool.query(sql);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new DatabaseError(`Migration failed: ${err.message}`, {
|
|
75
|
+
code: 'DB_MIGRATION_FAILED',
|
|
76
|
+
cause: err,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @param {Array<any>} [params]
|
|
84
|
+
* @returns {Promise<import('pg').QueryResult>}
|
|
85
|
+
*/
|
|
86
|
+
async query(text, params = []) {
|
|
87
|
+
if (this._closed) throw new DatabaseError('Database pool is closed', { code: 'DB_CLOSED' });
|
|
88
|
+
await this.connect();
|
|
89
|
+
const started = this.config.debug ? Date.now() : 0;
|
|
90
|
+
try {
|
|
91
|
+
const result = await this.pool.query(text, params);
|
|
92
|
+
if (this.config.debug) {
|
|
93
|
+
this.log.debug?.(`[AlexaAI][sql ${Date.now() - started}ms] ${text.split('\n')[0].trim()}`);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new DatabaseError(`Query failed: ${err.message}`, {
|
|
98
|
+
code: err.code || 'DB_QUERY_FAILED',
|
|
99
|
+
cause: err,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** First row or null. */
|
|
105
|
+
async one(text, params = []) {
|
|
106
|
+
const { rows } = await this.query(text, params);
|
|
107
|
+
return rows[0] || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** All rows. */
|
|
111
|
+
async many(text, params = []) {
|
|
112
|
+
const { rows } = await this.query(text, params);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run `fn` inside BEGIN/COMMIT, rolling back on throw.
|
|
118
|
+
* @param {(client: import('pg').PoolClient) => Promise<any>} fn
|
|
119
|
+
*/
|
|
120
|
+
async transaction(fn) {
|
|
121
|
+
await this.connect();
|
|
122
|
+
const client = await this.pool.connect();
|
|
123
|
+
try {
|
|
124
|
+
await client.query('BEGIN');
|
|
125
|
+
const result = await fn(client);
|
|
126
|
+
await client.query('COMMIT');
|
|
127
|
+
return result;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
try {
|
|
130
|
+
await client.query('ROLLBACK');
|
|
131
|
+
} catch {
|
|
132
|
+
/* connection already dead */
|
|
133
|
+
}
|
|
134
|
+
if (err instanceof DatabaseError) throw err;
|
|
135
|
+
throw new DatabaseError(`Transaction failed: ${err.message}`, { cause: err });
|
|
136
|
+
} finally {
|
|
137
|
+
client.release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Simple health probe. */
|
|
142
|
+
async healthCheck() {
|
|
143
|
+
try {
|
|
144
|
+
const row = await this.one('SELECT NOW() AS now, current_database() AS db');
|
|
145
|
+
return { ok: true, now: row.now, database: row.db };
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { ok: false, error: err.message };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async close() {
|
|
152
|
+
if (this.pool && !this._closed) {
|
|
153
|
+
this._closed = true;
|
|
154
|
+
await this.pool.end();
|
|
155
|
+
this.pool = null;
|
|
156
|
+
this._ready = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = Database;
|