alexa-ai 2.4.0 → 2.5.0

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 CHANGED
@@ -4,6 +4,29 @@ All notable changes to `alexa-ai` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.5.0] — 2026-09-07
8
+
9
+ ### Added
10
+ - **`proxy` option / `DEEPAI_PROXY`** — route `/api/*` calls through a
11
+ residential or mobile proxy (`http(s)://` / `socks5://`). Honoured by the
12
+ curl transports (`-x`); `transport: 'auto'` drops global fetch from the
13
+ chain when a proxy is set. This is what makes anonymous image generation
14
+ work from a VPS / data-center IP.
15
+ - **`englishOnly` (default on)** — the persona instructs English-only replies;
16
+ any CJK/Kana/Hangul in an answer triggers one translation re-ask and, if
17
+ that fails, script stripping with a plain-English fallback sentence.
18
+ - `DEEPAI_LOGIN_REQUIRED` error for login-gated models ("model only available
19
+ to logged in users") — no key rotation, anonymous retry or transport
20
+ cascade is attempted for them.
21
+
22
+ ### Changed
23
+ - **`detectNsfw()` on free keys** — `nsfw-detector` has no free/anonymous
24
+ tier, so after the API refusal the engine asks the vision model for a
25
+ safety score (`via: 'chat'`) and otherwise returns `DEEPAI_PRO_REQUIRED`
26
+ with an explanatory message.
27
+ - When script stripping empties the reply, a fixed English sentence is sent
28
+ instead of the original non-English text.
29
+
7
30
  ## [2.4.0] — 2026-09-07
8
31
 
9
32
  ### Fixed
package/README.md CHANGED
@@ -908,6 +908,52 @@ retry: <anonymous refusal>`).
908
908
  purpose — emoji and typographic dashes render as garbage (Chinese-looking
909
909
  mojibake) in cmd.exe/PowerShell with a non-UTF-8 codepage.
910
910
 
911
+ **Running on a VPS / server IP.** DeepAI refuses anonymous `/api/*` generation
912
+ from data-center IPs ("Please try this model on deepai.org"), so image
913
+ generation that works at home fails on a VPS. Two options:
914
+
915
+ 1. `proxy` option (2.5.0) — route the `/api/*` calls through a
916
+ residential/mobile proxy; the curl transports honour
917
+ `proxy: 'socks5://host:port'` / `http://host:port` (or `DEEPAI_PROXY`).
918
+ Global fetch cannot use the proxy, so `auto` switches to curl automatically
919
+ when one is configured.
920
+ 2. A DeepAI **Pro key** — works from any IP, no proxy needed.
921
+
922
+ **English-only replies (`englishOnly`, 2.5.0, on by default).** The persona
923
+ now instructs the model to answer in English only; if a reply still contains
924
+ CJK/Kana/Hangul, the engine re-asks once for an English version and strips any
925
+ remaining script as a last resort. Disable with `englishOnly: false`.
926
+
927
+ **`detectNsfw()` on a free key.** The `nsfw-detector` model has **no free or
928
+ anonymous tier** on DeepAI (no try-it on its model page; registered free keys
929
+ get the Pro refusal). 2.5.0 therefore: (a) tries the API, (b) on a plan
930
+ refusal asks the vision model for a score instead (`via: 'chat'` — works when
931
+ the key can see images), (c) otherwise returns `error: 'DEEPAI_PRO_REQUIRED'`
932
+ with a clear message. A Pro key makes the real model work.
933
+
934
+ **Always check `result.ok` before sending media.** On failure every helper
935
+ returns `url: null` — passing that straight to Baileys'
936
+ `prepareWAMessageMedia` crashes the bot with
937
+ `TypeError: Cannot read properties of undefined (reading 'toString')`. Guard
938
+ the bot side:
939
+
940
+ ```js
941
+ const r = await ai.generateImage(prompt);
942
+ if (!r.ok || !r.url) {
943
+ await sock.sendMessage(jid, { text: `Image failed: ${r.message || r.error}` });
944
+ return;
945
+ }
946
+ try {
947
+ await sock.sendMessage(jid, { image: { url: r.url } });
948
+ } catch (e) {
949
+ console.error('send failed', e);
950
+ }
951
+ ```
952
+
953
+ Also register `process.on('uncaughtException', …)` and
954
+ `process.on('unhandledRejection', …)` handlers in the bot entrypoint so one
955
+ bad send can never kill the whole process.
956
+
911
957
  **`generateImage()` returns `{ ok: false, error: 'DEEPAI_QUOTA_EXCEEDED' }`**
912
958
  `/api/text2img` is Pro-only for registered keys ("APIs are only available for
913
959
  Pro members in good standing"), the anonymous browser-shaped retry was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexa-ai",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "AI engine for the Alexa WhatsApp bot: DeepAI-powered chat with PostgreSQL-backed long-term memory, cross-chat identity (@lid <-> phone), vision/OCR, image generation and web search.",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/AlexaAI.js CHANGED
@@ -418,6 +418,26 @@ class AlexaAI {
418
418
  // Guarantee no @MEMORY remnant ever reaches WhatsApp.
419
419
  if (/@\s*MEMORY/i.test(finalText)) finalText = MemoryExtractor.strip(finalText);
420
420
 
421
+ // English-only replies: if the model answered in another script,
422
+ // re-ask once for an English version and strip any remainder.
423
+ if (this.config.englishOnly && ResponseFormatter.hasNonEnglish(finalText)) {
424
+ let repaired = null;
425
+ try {
426
+ repaired = await this.client.chat([
427
+ { role: 'user', content: `Rewrite the following in plain English only, keeping the same meaning and formatting. Output only the English text and nothing else:\n\n${finalText}` },
428
+ ]);
429
+ } catch {
430
+ repaired = null;
431
+ }
432
+ if (repaired && repaired.trim() && !ResponseFormatter.hasNonEnglish(repaired)) {
433
+ finalText = repaired.trim();
434
+ } else {
435
+ finalText =
436
+ ResponseFormatter.stripNonEnglishScripts(finalText) ||
437
+ 'Sorry, I could not phrase that in English. Please ask me again.';
438
+ }
439
+ }
440
+
421
441
  if (!finalText.trim()) {
422
442
  finalText = 'Sorry, I did not quite catch that. Could you say it again?';
423
443
  }
@@ -790,8 +810,34 @@ class AlexaAI {
790
810
  try {
791
811
  const data = await this.client.detectNsfw(field, {}, opts);
792
812
  const score = typeof data?.output?.nsfw_score === 'number' ? data.output.nsfw_score : null;
793
- return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, raw: data };
813
+ return { ok: true, score, nsfw: score == null ? null : score >= threshold, error: null, via: 'api', raw: data };
794
814
  } catch (err) {
815
+ // The dedicated model is Pro/login-only. As a best effort, ask
816
+ // the vision model for a safety score instead — works whenever
817
+ // the key can see images.
818
+ if (err.code === 'DEEPAI_LOGIN_REQUIRED' || err.code === 'DEEPAI_QUOTA_EXCEEDED') {
819
+ try {
820
+ const media = Media.normalize(image);
821
+ if (media) {
822
+ const judged = await this.vision.describe(media,
823
+ 'Rate how sexually explicit this image is. Reply with ONLY a single decimal number between 0 (completely safe) and 1 (explicit), with no other text.');
824
+ const m = /(?:0\.\d+|1(?:\.0+)?|0|1)/.exec(String(judged.description || judged.text || ''));
825
+ if (judged.ok && m) {
826
+ const score = Number(m[0]);
827
+ return { ok: true, score, nsfw: score >= threshold, error: null, via: 'chat' };
828
+ }
829
+ }
830
+ } catch {
831
+ /* fall through to the honest refusal */
832
+ }
833
+ return {
834
+ ok: false,
835
+ score: null,
836
+ nsfw: null,
837
+ error: 'DEEPAI_PRO_REQUIRED',
838
+ message: 'nsfw-detector is only available to DeepAI Pro keys (it has no free/anonymous tier), and the vision fallback could not run on this key.',
839
+ };
840
+ }
795
841
  return { ok: false, score: null, nsfw: null, error: err.code || 'NSFW_FAILED', message: err.message };
796
842
  }
797
843
  }
@@ -126,6 +126,12 @@ class Config {
126
126
  this.curlImpersonatePath =
127
127
  opts.curlImpersonatePath || process.env.DEEPAI_CURL_IMPERSONATE || null;
128
128
  this.curlImpersonateTarget = opts.curlImpersonateTarget || 'chrome136';
129
+ // Optional proxy for /api/* calls (http/https/socks5 URL). Applied to
130
+ // the curl transports via -x; global fetch cannot honour it without
131
+ // the undici package, so 'auto' skips fetch entirely when this is set.
132
+ // Useful when the bot runs on a server IP that DeepAI refuses for
133
+ // anonymous generation.
134
+ this.proxy = opts.proxy || process.env.DEEPAI_PROXY || null;
129
135
  // ---- /api/* anonymous fallback ----------------------------------------
130
136
  // When a registered key is refused ("Pro members in good standing"),
131
137
  // retry once with a fresh anonymous key in the browser dialect
@@ -213,6 +219,10 @@ class Config {
213
219
  opts.pool || {}
214
220
  );
215
221
 
222
+ // Replies must be plain English: any CJK/Kana/Hangul in a model
223
+ // answer triggers one translation re-ask, then script stripping.
224
+ this.englishOnly = opts.englishOnly !== false;
225
+
216
226
  this.debug = Boolean(opts.debug);
217
227
  this.logger = opts.logger || console;
218
228
 
@@ -741,7 +741,8 @@ class DeepAIClient {
741
741
  if (t === 'fetch') return ['fetch'];
742
742
  if (t === 'curl') return ['curl'];
743
743
  if (t === 'impersonate') return ['impersonate'];
744
- const chain = ['fetch', 'curl'];
744
+ // A proxy can only be honoured by the curl transports.
745
+ const chain = this.config.proxy ? ['curl'] : ['fetch', 'curl'];
745
746
  if (await DeepAIClient.resolveImpersonateBinary(this.config)) chain.push('impersonate');
746
747
  return chain;
747
748
  }
@@ -793,6 +794,7 @@ class DeepAIClient {
793
794
  : this.apiKey;
794
795
 
795
796
  const args = impersonate ? ['--impersonate', this.config.curlImpersonateTarget] : [];
797
+ if (this.config.proxy) args.push('-x', this.config.proxy);
796
798
  args.push(
797
799
  url,
798
800
  '-sS', '--compressed',
@@ -1072,6 +1074,17 @@ class DeepAIClient {
1072
1074
  const msg = statusMessage || DeepAIClient._detectJsonStatus(body) || `HTTP ${status}`;
1073
1075
  const lowered = String(msg).toLowerCase();
1074
1076
 
1077
+ // Login-gated models cannot be recovered by key rotation, anonymous
1078
+ // retries or another transport — report them as their own error.
1079
+ if (lowered.includes('model only available to logged in users')) {
1080
+ return new DeepAIError(`DeepAI refused the request: ${msg}`, {
1081
+ code: 'DEEPAI_LOGIN_REQUIRED',
1082
+ status,
1083
+ body,
1084
+ retryable: false,
1085
+ });
1086
+ }
1087
+
1075
1088
  const quotaHints = [
1076
1089
  'quota exceeded',
1077
1090
  'try it exceeded',
@@ -128,6 +128,7 @@ class PromptBuilder {
128
128
  `You are ${assistantName}, a warm, friendly female WhatsApp assistant created by ${creator}.`,
129
129
  `Your name is exactly "${assistantName}" — never a variant such as "${assistantName} Mini" or "${assistantName} AI".`,
130
130
  'Never mention DeepAI, ChatGPT, OpenAI, GPT, Llama, Gemini or any model/company name, and never call yourself a language model.',
131
+ 'You always reply in plain English only. Never answer in Chinese, Japanese, Korean or any other non-Latin script.',
131
132
  'Use WhatsApp formatting only: *bold*, _italic_, ~strike~, `code`. Never use ** or markdown headers.',
132
133
  'You have a permanent memory database: facts you are given about a person are things you genuinely remember, in private chats and in every group. Never claim you cannot remember.',
133
134
  'Append new personal facts at the very end as @MEMORY: {"key": "value"} and never mention that tag.',
@@ -19,6 +19,23 @@
19
19
  * Fenced code blocks are protected and restored verbatim.
20
20
  */
21
21
  class ResponseFormatter {
22
+ // CJK ideographs, Kana, Hangul, fullwidth forms — the scripts that must
23
+ // never appear in an English-only reply.
24
+ static NON_ENGLISH_RE = /[\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
25
+
26
+ /** True when the text contains CJK/Kana/Hangul/fullwidth characters. */
27
+ static hasNonEnglish(text) {
28
+ return ResponseFormatter.NON_ENGLISH_RE.test(String(text || ''));
29
+ }
30
+
31
+ /** Remove CJK/Kana/Hangul/fullwidth runs (last-resort cleanup). */
32
+ static stripNonEnglishScripts(text) {
33
+ return String(text || '')
34
+ .replace(/([\u2E80-\u2EFF\u3000-\u303F\u3040-\u30FF\u3130-\u318F\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF])+/g, ' ')
35
+ .replace(/ {2,}/g, ' ')
36
+ .trim();
37
+ }
38
+
22
39
  /**
23
40
  * @param {string} reply
24
41
  * @returns {string}
package/test/run-tests.js CHANGED
@@ -730,18 +730,44 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
730
730
  const anonNsfw = calls.find((c) => DeepAIClient.isTryItKey(c.key));
731
731
  ok('anonymous nsfw retry uses the model-page dialect', anonNsfw && anonNsfw.form.generation_source === 'img');
732
732
 
733
- // anonymousApiFallback:false keeps the refusal
733
+ // anonymousApiFallback:false keeps the refusal (no /api retry; the
734
+ // vision fallback may still run but makes no /api/nsfw-detector call)
734
735
  const strict = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false, anonymousApiFallback: false });
735
- calls.length = 0;
736
- let fetchCalls = 0;
736
+ let apiCalls = 0;
737
737
  global.fetch = async (url, init = {}) => {
738
- fetchCalls++;
738
+ if (/nsfw-detector$/.test(String(url))) apiCalls++;
739
739
  return { status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) };
740
740
  };
741
741
  const refused = await strict.detectNsfw(Buffer.from('fake-image-bytes'));
742
742
  global.fetch = realFetch;
743
- ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === 'DEEPAI_QUOTA_EXCEEDED');
744
- ok('no anonymous retry was made', fetchCalls === 1);
743
+ ok('anonymousApiFallback:false surfaces the refusal', refused.ok === false && refused.error === 'DEEPAI_PRO_REQUIRED');
744
+ ok('no anonymous retry was made', apiCalls === 1);
745
+
746
+ // login-gated models map to DEEPAI_LOGIN_REQUIRED and never rotate keys
747
+ {
748
+ const cfg2 = new Config({ key: 'tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', keys: ['tryit-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'tryit-2-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], postgresUrl: 'postgres://u:p@localhost/db', maxRetries: 0 });
749
+ const c2 = new DeepAIClient(cfg2);
750
+ const seen2 = [];
751
+ global.fetch = async (url, init = {}) => {
752
+ seen2.push(init.headers['api-key']);
753
+ return { status: 401, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'model only available to logged in users' }) };
754
+ };
755
+ let loginErr = null;
756
+ try { await c2.runApi('nsfw-detector', { image: 'https://x/y.png' }); } catch (e) { loginErr = e; }
757
+ global.fetch = realFetch;
758
+ ok('login-gated model raises DEEPAI_LOGIN_REQUIRED', loginErr?.code === 'DEEPAI_LOGIN_REQUIRED');
759
+ ok('no key rotation on login-gated models', seen2.length === 1);
760
+ }
761
+
762
+ // the vision fallback answers when the model refuses for plan reasons
763
+ {
764
+ const ai2 = new AlexaAI({ key: '11111111-2222-3333-4444-555555555555', postgresUrl: 'postgres://u:p@localhost/db', autoMigrate: false });
765
+ ai2.vision = { describe: async () => ({ ok: true, description: '0.85' }) };
766
+ global.fetch = async () => ({ status: 402, headers: { get: () => 'application/json' }, text: async () => JSON.stringify({ status: 'APIs are only available for Pro members in good standing' }) });
767
+ const judged = await ai2.detectNsfw(Buffer.from('fake'), { threshold: 0.7 });
768
+ global.fetch = realFetch;
769
+ ok('detectNsfw falls back to the vision model', judged.ok === true && judged.via === 'chat' && judged.score === 0.85 && judged.nsfw === true);
770
+ }
745
771
  }
746
772
 
747
773
  {
@@ -829,6 +855,22 @@ section('DeepAIClient — the whole endpoint surface (mocked transport)');
829
855
  ok('fetch-only transport never shells out', curlCalled === false);
830
856
  }
831
857
 
858
+ // 5. a proxy pins the chain to the curl transports and passes -x
859
+ {
860
+ let call = null;
861
+ DeepAIClient.execCurl = async (bin, args) => { call = { bin, args }; return '{"id":"p1","share_url":"https://deepai.org/p.png"}\n200'; };
862
+ const client = new DeepAIClient(new Config({
863
+ key: 'k', postgresUrl: 'postgres://u:p@localhost/db',
864
+ proxy: 'socks5://127.0.0.1:9050', maxRetries: 0,
865
+ }));
866
+ const chain = await client._transportChain();
867
+ ok('proxy removes fetch from the transport chain', !chain.includes('fetch') && chain.includes('curl'));
868
+ const data = await client.runApi('text2img', { text: 'cat' });
869
+ ok('proxied request succeeds through curl', data.share_url === 'https://deepai.org/p.png');
870
+ const xi = call.args.indexOf('-x');
871
+ ok('curl receives the proxy flag', xi !== -1 && call.args[xi + 1] === 'socks5://127.0.0.1:9050');
872
+ }
873
+
832
874
  global.fetch = realFetch;
833
875
  DeepAIClient.execCurl = realExecCurl;
834
876
  DeepAIClient._impersonateCache = realCache;
@@ -925,6 +967,19 @@ async function endToEndTests() {
925
967
  deepai.push('Sure! DeepAI can help you with that.');
926
968
  const leak = await ai.chat({ message: 'can you help me?', userId: '78151912841263@lid' });
927
969
  ok('vendor name never ships', !/deepai/i.test(leak.text));
970
+
971
+ // 7. A non-English reply is re-asked in English.
972
+ deepai.push('你好!很高兴认识你,Nimal。');
973
+ deepai.push('Hello! Very nice to meet you, Nimal.');
974
+ const zh = await ai.chat({ message: 'hi again', userId: '78151912841263@lid' });
975
+ check('Chinese reply is re-asked in English', zh.text, 'Hello! Very nice to meet you, Nimal.');
976
+
977
+ // 8. When the re-ask also fails, the CJK is stripped (or replaced).
978
+ deepai.push('这是一段完全中文的回答。');
979
+ deepai.push('还是中文。');
980
+ const zh2 = await ai.chat({ message: 'hello', userId: '78151912841263@lid' });
981
+ ok('unrecoverable reply carries no CJK', !/[\u4E00-\u9FFF]/.test(zh2.text));
982
+ ok('unrecoverable reply is non-empty', zh2.text.trim().length > 0);
928
983
  } finally {
929
984
  deepai.restore();
930
985
  }