@xuda.io/ai_module 1.1.5634 → 1.1.5636
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/index.mjs +668 -22
- package/index_ms.mjs +28 -0
- package/index_msa.mjs +28 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -161,13 +161,59 @@ const get_openai_codex_cli_command = function () {
|
|
|
161
161
|
return _conf.openai_codex_command || 'codex';
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
+
// Cheapest text model in the catalog (lowest input price). Used as the DEFAULT codex
|
|
165
|
+
// model when the user hasn't picked one. Returns a catalog CODE (e.g. 'gpt-1').
|
|
166
|
+
const get_cheapest_ai_model_code = function () {
|
|
167
|
+
const models = _conf?.ai_models || {};
|
|
168
|
+
let best = null;
|
|
169
|
+
for (const [code, e] of Object.entries(models)) {
|
|
170
|
+
if (!e || e.type !== 'text') continue;
|
|
171
|
+
const p = Number(e.input);
|
|
172
|
+
if (!Number.isFinite(p)) continue;
|
|
173
|
+
if (!best || p < best.p) best = { code, p };
|
|
174
|
+
}
|
|
175
|
+
return best?.code || _conf.default_ai_model || null;
|
|
176
|
+
};
|
|
177
|
+
|
|
164
178
|
const get_openai_codex_model = function () {
|
|
165
|
-
|
|
179
|
+
// Codex is user-selectable per request (req.codex_model); when nothing is picked
|
|
180
|
+
// the default is the CHEAPEST catalog model. Returns a catalog code (resolved to a
|
|
181
|
+
// real OpenAI id at exec time by get_openai_codex_exec_args).
|
|
182
|
+
return _conf.openai_codex_model || get_cheapest_ai_model_code();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Map a legacy ai_models key still stored on old profiles/agents (e.g. 'gpt-5.4') to
|
|
186
|
+
// its current catalog code (e.g. 'gpt-4') via _conf.ai_model_aliases. Current codes
|
|
187
|
+
// and unknown ids pass through. This makes the gpt-1..gpt-5 recode backward
|
|
188
|
+
// compatible with no data migration.
|
|
189
|
+
const canonical_ai_code = function (m) {
|
|
190
|
+
return _conf?.ai_model_aliases?.[m] || m;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Resolve a catalog code (the stable key that profiles/agents store, e.g.
|
|
194
|
+
// active_ai_model / agentConfig.agent_ai_model) to the real OpenAI model id to send
|
|
195
|
+
// to the API. Applies the legacy-key alias first, so old stored values keep working
|
|
196
|
+
// after a recode. Falls back to the input for literal ids that aren't catalog keys
|
|
197
|
+
// (e.g. 'gpt-image-1'). Apply ONLY at the OpenAI/Agent/Codex-CLI call boundary:
|
|
198
|
+
// billing (record_ai_usage) and the model-picker validation must keep the code.
|
|
199
|
+
const resolve_ai_model = function (m) {
|
|
200
|
+
const code = canonical_ai_code(m);
|
|
201
|
+
return _conf?.ai_models?.[code]?.model || _conf?.ai_models?.[m]?.model || m;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// A catalog code a user may pick for Codex generation: an OpenAI text model
|
|
205
|
+
// flagged codex:true in ai_models. Guards generate_site_draft's model param so a
|
|
206
|
+
// non-codex pick (image/voice/unknown) falls back to the default rather than
|
|
207
|
+
// failing the codex exec. Codex is OpenAI-only, so only these codes are valid.
|
|
208
|
+
const _is_codex_model = function (code) {
|
|
209
|
+
if (!code) return false;
|
|
210
|
+
const e = _conf?.ai_models?.[canonical_ai_code(code)];
|
|
211
|
+
return !!(e && e.codex === true);
|
|
166
212
|
};
|
|
167
213
|
|
|
168
214
|
const get_openai_codex_usage_model = function (codex_model, requested_codex_model) {
|
|
169
|
-
|
|
170
|
-
return
|
|
215
|
+
// Bill by the catalog code actually used (requested selection or cheapest default).
|
|
216
|
+
return codex_model || _conf.openai_codex_command || 'openai_codex_cli';
|
|
171
217
|
};
|
|
172
218
|
|
|
173
219
|
const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
@@ -180,7 +226,9 @@ const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
|
180
226
|
const bypass_approvals_and_sandbox = opts.sandbox ? false : typeof _conf.openai_codex_bypass_approvals_and_sandbox === 'undefined' ? true : _conf.openai_codex_bypass_approvals_and_sandbox;
|
|
181
227
|
|
|
182
228
|
if (codex_model) {
|
|
183
|
-
|
|
229
|
+
// codex_model is a catalog code (user pick or cheapest default); the Codex CLI
|
|
230
|
+
// needs the real OpenAI id.
|
|
231
|
+
args.push('--model', resolve_ai_model(codex_model));
|
|
184
232
|
}
|
|
185
233
|
|
|
186
234
|
if (bypass_approvals_and_sandbox) {
|
|
@@ -1704,7 +1752,10 @@ export const generate_site_draft = async (req, job_id) => {
|
|
|
1704
1752
|
When done, briefly summarize what you built.`;
|
|
1705
1753
|
|
|
1706
1754
|
const built_prompt = `[System]\n${gen_system}\n\n[User request]\n${prompt}`;
|
|
1707
|
-
|
|
1755
|
+
// Model pick from the generate screen (UI-13 #4): forward only a codex-eligible
|
|
1756
|
+
// catalog code; anything else (or none) uses the default model.
|
|
1757
|
+
const picked_model = _is_codex_model(data.model || data.codex_model) ? (data.model || data.codex_model) : null;
|
|
1758
|
+
const codex_ret = await execute_codex_request({ uid, ip: undefined, local_cwd: dir, prompt: built_prompt, stream: false, sandbox: 'workspace-write', codex_timeout: 240000, codex_model: picked_model });
|
|
1708
1759
|
if (!codex_ret || codex_ret.code < 0) {
|
|
1709
1760
|
// On a non-zero codex exit, codex_ret.data is an object whose real reason
|
|
1710
1761
|
// lives in `stderr` (not `message`); only the early guards return a plain
|
|
@@ -2291,7 +2342,7 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
|
|
|
2291
2342
|
let response;
|
|
2292
2343
|
try {
|
|
2293
2344
|
response = await client.images.generate({
|
|
2294
|
-
model,
|
|
2345
|
+
model: resolve_ai_model(model),
|
|
2295
2346
|
prompt,
|
|
2296
2347
|
size,
|
|
2297
2348
|
n,
|
|
@@ -4312,7 +4363,7 @@ export const submit_chat_gpt_prompt = async function (req) {
|
|
|
4312
4363
|
let response;
|
|
4313
4364
|
try {
|
|
4314
4365
|
let opt = {
|
|
4315
|
-
model,
|
|
4366
|
+
model: resolve_ai_model(model),
|
|
4316
4367
|
input: prompt,
|
|
4317
4368
|
tools,
|
|
4318
4369
|
text: {
|
|
@@ -4893,6 +4944,11 @@ export const submit_chat_conversation = async function (req, job_id, headers) {
|
|
|
4893
4944
|
break;
|
|
4894
4945
|
}
|
|
4895
4946
|
|
|
4947
|
+
case 'ticket': {
|
|
4948
|
+
ret = await contact_ticket_conversation(req, job_id, headers);
|
|
4949
|
+
break;
|
|
4950
|
+
}
|
|
4951
|
+
|
|
4896
4952
|
default:
|
|
4897
4953
|
ret = { code: -44, data: 'invalid conversation_type ' + conversation_doc.conversation_type };
|
|
4898
4954
|
break;
|
|
@@ -5152,7 +5208,24 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
5152
5208
|
email_attachments.push(await save_drive_file_to_disk(attachment));
|
|
5153
5209
|
}
|
|
5154
5210
|
|
|
5155
|
-
|
|
5211
|
+
// Wrap the body with the profile's selected email template (Email
|
|
5212
|
+
// Template tab). 'plain' or an unset style renders null, keeping the
|
|
5213
|
+
// original raw-text behavior.
|
|
5214
|
+
let template_html = null;
|
|
5215
|
+
try {
|
|
5216
|
+
const profile_doc = account_profile_info.account_profile_obj || {};
|
|
5217
|
+
const render_ret = await email_ms.render_profile_email({
|
|
5218
|
+
style: profile_doc.email_template?.style,
|
|
5219
|
+
body_text: body,
|
|
5220
|
+
profile_name: profile_doc.profile_name,
|
|
5221
|
+
signature: profile_doc.profile_signature,
|
|
5222
|
+
avatar_url: profile_doc.profile_picture || profile_doc.profile_avatar || '',
|
|
5223
|
+
brand_color: profile_doc.widget_config?.color || '',
|
|
5224
|
+
});
|
|
5225
|
+
if (render_ret?.code > 0 && render_ret.data) template_html = render_ret.data;
|
|
5226
|
+
} catch (err) {}
|
|
5227
|
+
|
|
5228
|
+
sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html, email_attachments);
|
|
5156
5229
|
if (!sent_email_result.success) {
|
|
5157
5230
|
throw new Error('error sending email');
|
|
5158
5231
|
}
|
|
@@ -5703,6 +5776,22 @@ const resolve_plugin_action = function (req, dashboard_context, prompt) {
|
|
|
5703
5776
|
// answers are specific to THIS project instead of generic. Terse on purpose to stay
|
|
5704
5777
|
// within the token budget; the snapshot is trusted for grounding only — the agent
|
|
5705
5778
|
// still calls CPI tools (which re-check ownership) for any actual read/write.
|
|
5779
|
+
// The 'ask_ai' access consent ("let the assistant read this server's current status").
|
|
5780
|
+
// Gated on the customer's own machines only: app_type 'vps' (full-stack is vps +
|
|
5781
|
+
// is_full_stack, so it is covered) and 'external_vps'. MIRRORS
|
|
5782
|
+
// app_module.CONSENT_ENFORCED_APP_TYPES — no cpi module imports app_module, so the
|
|
5783
|
+
// predicate is duplicated here on purpose; keep the copies in step.
|
|
5784
|
+
//
|
|
5785
|
+
// Unlike files/metrics/backup this does NOT return -91. This builder never touches the
|
|
5786
|
+
// server — it only copies facts off the already-loaded app doc into the system prompt —
|
|
5787
|
+
// and the chat does far more than answer server questions, so blocking it would be
|
|
5788
|
+
// wrong. Enforcement here is REDACTION: no consent → the assistant simply is not told
|
|
5789
|
+
// the server's address/status. Anything that actually reads the box goes through the
|
|
5790
|
+
// CPI tools, which carry their own gates (vps_fs_* → 'files').
|
|
5791
|
+
const _ASK_AI_CONSENT_TYPES = ['vps', 'external_vps'];
|
|
5792
|
+
const _ask_ai_consent_blocked = (o) =>
|
|
5793
|
+
!!o && _ASK_AI_CONSENT_TYPES.includes(o.app_type) && o?.deploy_data?.access_consents?.ask_ai?.granted !== true;
|
|
5794
|
+
|
|
5706
5795
|
const build_dashboard_state_context = function (app_obj, ctx) {
|
|
5707
5796
|
const lines = [];
|
|
5708
5797
|
if (app_obj) {
|
|
@@ -5712,7 +5801,7 @@ const build_dashboard_state_context = function (app_obj, ctx) {
|
|
|
5712
5801
|
const plan = app_obj.app_uId_plan || app_obj.membership_plan;
|
|
5713
5802
|
if (plan) lines.push(`- Plan: ${plan}`);
|
|
5714
5803
|
const vps = app_obj.app_hosting_server;
|
|
5715
|
-
if (vps && (vps.ip || vps.droplet?.id)) {
|
|
5804
|
+
if (vps && (vps.ip || vps.droplet?.id) && !_ask_ai_consent_blocked(app_obj)) {
|
|
5716
5805
|
lines.push(`- VPS: ${vps.ip || 'provisioned'}${vps.droplet?.status ? ` (${vps.droplet.status})` : ''}${vps.region ? `, ${vps.region}` : ''}`);
|
|
5717
5806
|
}
|
|
5718
5807
|
const sw = app_obj.static_website;
|
|
@@ -5746,7 +5835,7 @@ const build_dashboard_state_context = function (app_obj, ctx) {
|
|
|
5746
5835
|
const pick_dashboard_model = function (prompt, dashboard_context) {
|
|
5747
5836
|
const models = _conf.ai_models || {};
|
|
5748
5837
|
const fallback = _conf.default_ai_model;
|
|
5749
|
-
const strong = (_conf.strong_ai_model && models[_conf.strong_ai_model] && _conf.strong_ai_model) || (models['gpt-
|
|
5838
|
+
const strong = (_conf.strong_ai_model && models[_conf.strong_ai_model] && _conf.strong_ai_model) || (models['gpt-3'] && 'gpt-3') || fallback;
|
|
5750
5839
|
// Intent comes from the PROMPT only — folding in dashboard_context would let a
|
|
5751
5840
|
// tab named with an action verb (e.g. "deploy") force every question on that
|
|
5752
5841
|
// tab to the strong tier ("is my site live?" on the deploy tab is still a read).
|
|
@@ -6389,7 +6478,7 @@ ${CLARIFYING_QUESTIONS_INSTRUCTION}
|
|
|
6389
6478
|
Tab/context: "${dashboard_context || 'unknown'}" · app_id: "${target_app_id}"
|
|
6390
6479
|
Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_state_block ? '\n\n' + dashboard_state_block : ''}${action_steer ? '\n\n' + action_steer : ''}
|
|
6391
6480
|
`.trim(),
|
|
6392
|
-
model: ai_model,
|
|
6481
|
+
model: resolve_ai_model(ai_model),
|
|
6393
6482
|
tools: cpi_tools_ret.tools,
|
|
6394
6483
|
});
|
|
6395
6484
|
|
|
@@ -7170,7 +7259,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7170
7259
|
let prompt_suggestion_activated;
|
|
7171
7260
|
let chat_suggestion_activated;
|
|
7172
7261
|
let model = ai_model;
|
|
7173
|
-
if (!_conf.ai_models[model]) {
|
|
7262
|
+
if (!_conf.ai_models[canonical_ai_code(model)]) {
|
|
7174
7263
|
throw new Error('model not supported');
|
|
7175
7264
|
}
|
|
7176
7265
|
|
|
@@ -7517,7 +7606,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7517
7606
|
const agent = new Agent({
|
|
7518
7607
|
name: agent_name.substring(0, 55),
|
|
7519
7608
|
instructions: ai_agent_doc.agentConfig.agent_instructions + (reference_type === 'ai_agents' ? get_agent_instructions() : '') + (context?.has_full_stack_vps ? '\n\n' + get_full_stack_vps_instructions() : ''),
|
|
7520
|
-
model,
|
|
7609
|
+
model: resolve_ai_model(model),
|
|
7521
7610
|
tools,
|
|
7522
7611
|
metadata: {
|
|
7523
7612
|
agent_id: ai_agent_doc._id,
|
|
@@ -7674,7 +7763,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7674
7763
|
instructions: get_agent_instructions(),
|
|
7675
7764
|
// inputGuardrails: [contactGuardrail],
|
|
7676
7765
|
// outputGuardrails: [contactGuardrail],
|
|
7677
|
-
model,
|
|
7766
|
+
model: resolve_ai_model(model),
|
|
7678
7767
|
handoffs: agents, // this lets triageAgent forward to other agents
|
|
7679
7768
|
});
|
|
7680
7769
|
}
|
|
@@ -8002,7 +8091,7 @@ Do not mention that the reply is automated.
|
|
|
8002
8091
|
${conversation_type === 'chat' ? 'Return only the chat message text, ready to send.' : 'Return only the email body, ready to send.'}
|
|
8003
8092
|
If a signature is available, append it at the end:
|
|
8004
8093
|
${account_profile_doc.profile_signature || ''}`.trim(),
|
|
8005
|
-
model,
|
|
8094
|
+
model: resolve_ai_model(model),
|
|
8006
8095
|
metadata: { auto_response: true, default: true, ts: Date.now() },
|
|
8007
8096
|
}),
|
|
8008
8097
|
);
|
|
@@ -8047,7 +8136,7 @@ If a signature is available, append it at the end:
|
|
|
8047
8136
|
${account_profile_doc.profile_signature || ''}`
|
|
8048
8137
|
: ''
|
|
8049
8138
|
}${context?.has_full_stack_vps ? '\n\n' + get_full_stack_vps_instructions() : ''}`.trim(),
|
|
8050
|
-
model,
|
|
8139
|
+
model: resolve_ai_model(model),
|
|
8051
8140
|
tools: tools_ret.tools,
|
|
8052
8141
|
metadata: {
|
|
8053
8142
|
agent_id: ai_agent_doc._id,
|
|
@@ -8075,7 +8164,7 @@ ${account_profile_doc.profile_signature || ''}`
|
|
|
8075
8164
|
name: 'Auto Response Triage Agent',
|
|
8076
8165
|
instructions: `Select the best specialist agent to draft an automatic ${conversation_type === 'chat' ? 'chat' : 'email'} response.
|
|
8077
8166
|
Return only the final ${conversation_type === 'chat' ? 'chat message text' : 'email body text'}.`,
|
|
8078
|
-
model,
|
|
8167
|
+
model: resolve_ai_model(model),
|
|
8079
8168
|
handoffs: agents,
|
|
8080
8169
|
});
|
|
8081
8170
|
}
|
|
@@ -8143,6 +8232,18 @@ Return only the email body.`;
|
|
|
8143
8232
|
undefined,
|
|
8144
8233
|
undefined,
|
|
8145
8234
|
);
|
|
8235
|
+
} else if (conversation_type === 'ticket') {
|
|
8236
|
+
await contact_ticket_conversation(
|
|
8237
|
+
{
|
|
8238
|
+
profile_id,
|
|
8239
|
+
uid,
|
|
8240
|
+
prompt: response_text,
|
|
8241
|
+
conversation_doc,
|
|
8242
|
+
_auto_response: true,
|
|
8243
|
+
},
|
|
8244
|
+
undefined,
|
|
8245
|
+
undefined,
|
|
8246
|
+
);
|
|
8146
8247
|
} else {
|
|
8147
8248
|
await chat_email(
|
|
8148
8249
|
{
|
|
@@ -8792,7 +8893,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
8792
8893
|
let ai_avatar_response;
|
|
8793
8894
|
try {
|
|
8794
8895
|
ai_avatar_response = await client.images.edit({
|
|
8795
|
-
model,
|
|
8896
|
+
model: resolve_ai_model(model),
|
|
8796
8897
|
image: image_blob_ret.image_blob, //base photo
|
|
8797
8898
|
prompt,
|
|
8798
8899
|
background: 'transparent',
|
|
@@ -8903,7 +9004,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
8903
9004
|
let ai_avatar_response;
|
|
8904
9005
|
try {
|
|
8905
9006
|
ai_avatar_response = await client.images.edit({
|
|
8906
|
-
model,
|
|
9007
|
+
model: resolve_ai_model(model),
|
|
8907
9008
|
image: image_blob_ret.image_blob, //logo
|
|
8908
9009
|
prompt: `
|
|
8909
9010
|
Create a transparent PNG profile image for a business account featuring building branded as ${name}.
|
|
@@ -9528,7 +9629,7 @@ const create_ai_agent_image = async function (req, job_id, headers) {
|
|
|
9528
9629
|
let ai_avatar_response;
|
|
9529
9630
|
try {
|
|
9530
9631
|
ai_avatar_response = await client.images.edit({
|
|
9531
|
-
model,
|
|
9632
|
+
model: resolve_ai_model(model),
|
|
9532
9633
|
image: image_blob_ret.image_blob,
|
|
9533
9634
|
|
|
9534
9635
|
prompt,
|
|
@@ -15913,13 +16014,13 @@ const _ai_design = async (uid, src) => {
|
|
|
15913
16014
|
`;
|
|
15914
16015
|
let resp;
|
|
15915
16016
|
try {
|
|
15916
|
-
resp = await client.images.edit({ model, image: blob, prompt, background: 'transparent' });
|
|
16017
|
+
resp = await client.images.edit({ model: resolve_ai_model(model), image: blob, prompt, background: 'transparent' });
|
|
15917
16018
|
report_ai_status(model);
|
|
15918
16019
|
} catch (err) {
|
|
15919
16020
|
report_ai_status(model, err);
|
|
15920
16021
|
if (err?.code === 'moderation_blocked') {
|
|
15921
16022
|
const soft = 'Create a futuristic metallic humanoid robot portrait with a transparent ' + 'background. Head and shoulders only, centered, forward-facing. Fully robotic and ' + 'non-photorealistic, glowing blue and purple neon accents, advanced reflective materials.';
|
|
15922
|
-
resp = await client.images.edit({ model, image: blob, prompt: soft, background: 'transparent' });
|
|
16023
|
+
resp = await client.images.edit({ model: resolve_ai_model(model), image: blob, prompt: soft, background: 'transparent' });
|
|
15923
16024
|
report_ai_status(model);
|
|
15924
16025
|
} else {
|
|
15925
16026
|
throw err;
|
|
@@ -16204,3 +16305,548 @@ export const widget_get_messages = async function (req, job_id, headers) {
|
|
|
16204
16305
|
return { code: -1, data: err.message || String(err) };
|
|
16205
16306
|
}
|
|
16206
16307
|
};
|
|
16308
|
+
|
|
16309
|
+
////////////////////////////// WIDGET SETTINGS (profile setup Chat tab) //////////////////////////////
|
|
16310
|
+
|
|
16311
|
+
// Public origin that serves the embed loaders, iframe routes and /cpi for
|
|
16312
|
+
// embedded widgets/forms. On dev the config domain still says xuda.ai, so a
|
|
16313
|
+
// dev-generated snippet must point at the dev host or the profile lookup 404s.
|
|
16314
|
+
const embed_origin = function () {
|
|
16315
|
+
if (_conf.is_debug && process.env.XUDA_HOSTNAME) return `https://${process.env.XUDA_HOSTNAME}`;
|
|
16316
|
+
return `https://${_conf.domain || 'xuda.ai'}`;
|
|
16317
|
+
};
|
|
16318
|
+
|
|
16319
|
+
const WIDGET_CONFIG_KEYS = ['title', 'color', 'greeting', 'position'];
|
|
16320
|
+
const WIDGET_POSITIONS = ['br', 'bl', 'tr', 'tl'];
|
|
16321
|
+
|
|
16322
|
+
const _sanitize_widget_config = function (config) {
|
|
16323
|
+
const out = {};
|
|
16324
|
+
if (!config || typeof config !== 'object') return out;
|
|
16325
|
+
if (typeof config.title === 'string') out.title = config.title.replace(/[<>]/g, '').slice(0, 60);
|
|
16326
|
+
if (typeof config.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(config.color.trim())) out.color = config.color.trim();
|
|
16327
|
+
if (typeof config.greeting === 'string') out.greeting = config.greeting.replace(/[<>]/g, '').slice(0, 300);
|
|
16328
|
+
if (typeof config.position === 'string' && WIDGET_POSITIONS.includes(config.position)) out.position = config.position;
|
|
16329
|
+
return out;
|
|
16330
|
+
};
|
|
16331
|
+
|
|
16332
|
+
const _build_widget_snippet = function (widget_profile_id, config = {}) {
|
|
16333
|
+
const origin = embed_origin();
|
|
16334
|
+
const attrs = [`s.setAttribute('data-profile','${widget_profile_id}');`];
|
|
16335
|
+
if (config.position && WIDGET_POSITIONS.includes(config.position)) attrs.push(`s.setAttribute('data-position','${config.position}');`);
|
|
16336
|
+
if (config.color && /^#[0-9a-fA-F]{3,8}$/.test(config.color)) attrs.push(`s.setAttribute('data-color','${config.color}');`);
|
|
16337
|
+
return `<script>(function(){var d=document,s=d.createElement('script');s.src='${origin}/dist/runtime/js/chat-widget-loader.js';s.async=1;${attrs.join('')}d.head.appendChild(s);})();</script>`;
|
|
16338
|
+
};
|
|
16339
|
+
|
|
16340
|
+
// Read-only view of the profile's chat widget setup for the settings tab.
|
|
16341
|
+
// Unlike get_widget_embed_snippet this never mutates the profile doc.
|
|
16342
|
+
export const get_widget_settings = async function (req) {
|
|
16343
|
+
try {
|
|
16344
|
+
const { uid, profile_id } = req;
|
|
16345
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16346
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16347
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16348
|
+
const widget_profile_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16349
|
+
const config = _sanitize_widget_config(doc.widget_config);
|
|
16350
|
+
return {
|
|
16351
|
+
code: 1,
|
|
16352
|
+
data: {
|
|
16353
|
+
enabled: doc.widget_enabled !== false,
|
|
16354
|
+
config,
|
|
16355
|
+
widget_profile_id,
|
|
16356
|
+
iframe_url: `${embed_origin()}/chat-widget/${widget_profile_id}`,
|
|
16357
|
+
snippet: _build_widget_snippet(widget_profile_id, config),
|
|
16358
|
+
},
|
|
16359
|
+
};
|
|
16360
|
+
} catch (err) {
|
|
16361
|
+
return { code: -1, data: err.message || String(err) };
|
|
16362
|
+
}
|
|
16363
|
+
};
|
|
16364
|
+
|
|
16365
|
+
// Save the Chat tab: on/off plus skin (title, color, position) and greeting.
|
|
16366
|
+
export const update_widget_settings = async function (req) {
|
|
16367
|
+
try {
|
|
16368
|
+
const { uid, profile_id, enabled, config } = req;
|
|
16369
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16370
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16371
|
+
|
|
16372
|
+
const doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, account_profile_info.account_profile_id);
|
|
16373
|
+
if (doc.docType !== 'account_profile') return { code: -1, data: 'not_account_profile' };
|
|
16374
|
+
|
|
16375
|
+
if (typeof enabled === 'boolean') doc.widget_enabled = enabled;
|
|
16376
|
+
if (config && typeof config === 'object') {
|
|
16377
|
+
const clean = _sanitize_widget_config(config);
|
|
16378
|
+
doc.widget_config = { ...(doc.widget_config || {}), ...clean };
|
|
16379
|
+
// Explicit empty string clears a key back to the default.
|
|
16380
|
+
for (const key of WIDGET_CONFIG_KEYS) {
|
|
16381
|
+
if (config[key] === '') delete doc.widget_config[key];
|
|
16382
|
+
}
|
|
16383
|
+
}
|
|
16384
|
+
doc.ts = Date.now();
|
|
16385
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
16386
|
+
|
|
16387
|
+
return await get_widget_settings({ uid, profile_id });
|
|
16388
|
+
} catch (err) {
|
|
16389
|
+
return { code: -1, data: err.message || String(err) };
|
|
16390
|
+
}
|
|
16391
|
+
};
|
|
16392
|
+
|
|
16393
|
+
////////////////////////////// CONTACT FORM (embed) //////////////////////////////
|
|
16394
|
+
|
|
16395
|
+
// The form is a sibling of the chat widget: an iframe rendered by the router at
|
|
16396
|
+
// /contact-form/<uid>.<pid>, a loader script customers paste on their site, and
|
|
16397
|
+
// a public submit method. A submission adds the sender as a CONTACT on the
|
|
16398
|
+
// profile owner's side (no contact_connection team_request, so NOT a friend)
|
|
16399
|
+
// and opens a 'ticket' chat_conversation holding the message.
|
|
16400
|
+
|
|
16401
|
+
const CONTACT_FORM_TOKEN_TTL_MS = 2 * 60 * 60 * 1000;
|
|
16402
|
+
const CONTACT_FORM_MIN_AGE_MS = 3000;
|
|
16403
|
+
const CONTACT_FORM_FIELD_KEYS = ['phone', 'company', 'subject'];
|
|
16404
|
+
|
|
16405
|
+
// Human-detection token: HMAC over profile id + mint time. The key derives from
|
|
16406
|
+
// a fleet-shared secret so the router-rendered form and this module verify the
|
|
16407
|
+
// same signature without distributing a new secret.
|
|
16408
|
+
const _contact_form_hmac_key = function () {
|
|
16409
|
+
const seed = String(_conf?.gmail?.clientSecret || _conf?.domain || 'xuda');
|
|
16410
|
+
return crypto.createHash('sha256').update(`xuda_contact_form_v1:${seed}`).digest();
|
|
16411
|
+
};
|
|
16412
|
+
|
|
16413
|
+
export const mint_contact_form_token = function (widget_profile_id, ts = Date.now()) {
|
|
16414
|
+
const sig = crypto.createHmac('sha256', _contact_form_hmac_key()).update(`${widget_profile_id}.${ts}`).digest('hex').slice(0, 32);
|
|
16415
|
+
return `cft1.${ts}.${sig}`;
|
|
16416
|
+
};
|
|
16417
|
+
|
|
16418
|
+
const _verify_contact_form_token = function (widget_profile_id, token) {
|
|
16419
|
+
const parts = String(token || '').split('.');
|
|
16420
|
+
if (parts.length !== 3 || parts[0] !== 'cft1') return { ok: false, reason: 'bad_token' };
|
|
16421
|
+
const ts = Number(parts[1]);
|
|
16422
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: 'bad_token' };
|
|
16423
|
+
const expected = crypto.createHmac('sha256', _contact_form_hmac_key()).update(`${widget_profile_id}.${ts}`).digest('hex').slice(0, 32);
|
|
16424
|
+
let sig_ok = false;
|
|
16425
|
+
try {
|
|
16426
|
+
sig_ok = crypto.timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expected));
|
|
16427
|
+
} catch (err) {
|
|
16428
|
+
sig_ok = false;
|
|
16429
|
+
}
|
|
16430
|
+
if (!sig_ok) return { ok: false, reason: 'bad_token' };
|
|
16431
|
+
const age = Date.now() - ts;
|
|
16432
|
+
if (age > CONTACT_FORM_TOKEN_TTL_MS) return { ok: false, reason: 'token_expired' };
|
|
16433
|
+
if (age < CONTACT_FORM_MIN_AGE_MS) return { ok: false, reason: 'too_fast' };
|
|
16434
|
+
return { ok: true };
|
|
16435
|
+
};
|
|
16436
|
+
|
|
16437
|
+
const _sanitize_contact_form_config = function (config) {
|
|
16438
|
+
const out = {};
|
|
16439
|
+
if (!config || typeof config !== 'object') return out;
|
|
16440
|
+
const text = (v, max) => String(v).replace(/[<>]/g, '').slice(0, max);
|
|
16441
|
+
if (typeof config.title === 'string') out.title = text(config.title, 60);
|
|
16442
|
+
if (typeof config.intro === 'string') out.intro = text(config.intro, 300);
|
|
16443
|
+
if (typeof config.button_text === 'string') out.button_text = text(config.button_text, 40);
|
|
16444
|
+
if (typeof config.success_message === 'string') out.success_message = text(config.success_message, 300);
|
|
16445
|
+
if (typeof config.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(config.color.trim())) out.color = config.color.trim();
|
|
16446
|
+
if (config.fields && typeof config.fields === 'object') {
|
|
16447
|
+
out.fields = {};
|
|
16448
|
+
for (const key of CONTACT_FORM_FIELD_KEYS) {
|
|
16449
|
+
const f = config.fields[key];
|
|
16450
|
+
if (f && typeof f === 'object') out.fields[key] = { show: f.show === true, required: f.required === true };
|
|
16451
|
+
}
|
|
16452
|
+
}
|
|
16453
|
+
if (typeof config.notify_email === 'boolean') out.notify_email = config.notify_email;
|
|
16454
|
+
if (typeof config.auto_respond === 'boolean') out.auto_respond = config.auto_respond;
|
|
16455
|
+
return out;
|
|
16456
|
+
};
|
|
16457
|
+
|
|
16458
|
+
const _build_contact_form_snippet = function (widget_profile_id, config = {}) {
|
|
16459
|
+
const origin = embed_origin();
|
|
16460
|
+
const attrs = [`s.setAttribute('data-profile','${widget_profile_id}');`];
|
|
16461
|
+
if (config.color && /^#[0-9a-fA-F]{3,8}$/.test(config.color)) attrs.push(`s.setAttribute('data-color','${config.color}');`);
|
|
16462
|
+
return `<script>(function(){var d=document,s=d.createElement('script');s.src='${origin}/dist/runtime/js/contact-form-loader.js';s.async=1;${attrs.join('')}d.head.appendChild(s);})();</script>`;
|
|
16463
|
+
};
|
|
16464
|
+
|
|
16465
|
+
export const get_contact_form_settings = async function (req) {
|
|
16466
|
+
try {
|
|
16467
|
+
const { uid, profile_id } = req;
|
|
16468
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16469
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16470
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16471
|
+
const widget_profile_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16472
|
+
const config = _sanitize_contact_form_config(doc.contact_form_config);
|
|
16473
|
+
return {
|
|
16474
|
+
code: 1,
|
|
16475
|
+
data: {
|
|
16476
|
+
enabled: doc.contact_form_enabled === true,
|
|
16477
|
+
config,
|
|
16478
|
+
widget_profile_id,
|
|
16479
|
+
iframe_url: `${embed_origin()}/contact-form/${widget_profile_id}`,
|
|
16480
|
+
snippet: _build_contact_form_snippet(widget_profile_id, config),
|
|
16481
|
+
// Inline mount hint for the settings tab to display next to the snippet.
|
|
16482
|
+
container_snippet: '<div data-xuda-contact-form></div>',
|
|
16483
|
+
},
|
|
16484
|
+
};
|
|
16485
|
+
} catch (err) {
|
|
16486
|
+
return { code: -1, data: err.message || String(err) };
|
|
16487
|
+
}
|
|
16488
|
+
};
|
|
16489
|
+
|
|
16490
|
+
export const update_contact_form_settings = async function (req) {
|
|
16491
|
+
try {
|
|
16492
|
+
const { uid, profile_id, enabled, config } = req;
|
|
16493
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16494
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16495
|
+
|
|
16496
|
+
const doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, account_profile_info.account_profile_id);
|
|
16497
|
+
if (doc.docType !== 'account_profile') return { code: -1, data: 'not_account_profile' };
|
|
16498
|
+
|
|
16499
|
+
if (typeof enabled === 'boolean') doc.contact_form_enabled = enabled;
|
|
16500
|
+
if (config && typeof config === 'object') {
|
|
16501
|
+
const clean = _sanitize_contact_form_config(config);
|
|
16502
|
+
doc.contact_form_config = { ...(doc.contact_form_config || {}), ...clean };
|
|
16503
|
+
for (const key of ['title', 'intro', 'button_text', 'success_message', 'color']) {
|
|
16504
|
+
if (config[key] === '') delete doc.contact_form_config[key];
|
|
16505
|
+
}
|
|
16506
|
+
}
|
|
16507
|
+
doc.ts = Date.now();
|
|
16508
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
16509
|
+
|
|
16510
|
+
return await get_contact_form_settings({ uid, profile_id });
|
|
16511
|
+
} catch (err) {
|
|
16512
|
+
return { code: -1, data: err.message || String(err) };
|
|
16513
|
+
}
|
|
16514
|
+
};
|
|
16515
|
+
|
|
16516
|
+
// Router-side bootstrap for rendering the form iframe: resolves the profile,
|
|
16517
|
+
// enforces the enabled gate and returns display config plus a fresh token.
|
|
16518
|
+
// Broker-only (not in cpi_methods); the router calls it via ai_ms.
|
|
16519
|
+
export const get_contact_form_bootstrap = async function (req) {
|
|
16520
|
+
try {
|
|
16521
|
+
const { widget_profile_id } = req;
|
|
16522
|
+
const [owner_uid, pid] = String(widget_profile_id || '').split('.');
|
|
16523
|
+
if (!owner_uid || !pid) return { code: -404, data: 'invalid_profile' };
|
|
16524
|
+
|
|
16525
|
+
const account_profile_info = await get_active_account_profile_info(owner_uid, pid);
|
|
16526
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16527
|
+
if (doc.contact_form_enabled !== true) return { code: -404, data: 'contact_form_disabled' };
|
|
16528
|
+
|
|
16529
|
+
let avatar_url = '';
|
|
16530
|
+
let owner_name = '';
|
|
16531
|
+
try {
|
|
16532
|
+
const acct_name_ret = await account_ms.get_account_name({ uid_query: account_profile_info.uid });
|
|
16533
|
+
avatar_url = acct_name_ret?.data?.profile_picture || acct_name_ret?.data?.profile_avatar || doc.profile_picture || doc.profile_avatar || '';
|
|
16534
|
+
owner_name = acct_name_ret?.data?.full_name || '';
|
|
16535
|
+
} catch (err) {}
|
|
16536
|
+
|
|
16537
|
+
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16538
|
+
return {
|
|
16539
|
+
code: 1,
|
|
16540
|
+
data: {
|
|
16541
|
+
widget_profile_id: canonical_id,
|
|
16542
|
+
config: _sanitize_contact_form_config(doc.contact_form_config),
|
|
16543
|
+
profile_name: doc.profile_name || owner_name || '',
|
|
16544
|
+
avatar_url,
|
|
16545
|
+
token: mint_contact_form_token(canonical_id),
|
|
16546
|
+
},
|
|
16547
|
+
};
|
|
16548
|
+
} catch (err) {
|
|
16549
|
+
return { code: -404, data: err.message || String(err) };
|
|
16550
|
+
}
|
|
16551
|
+
};
|
|
16552
|
+
|
|
16553
|
+
// In-memory submit throttles. Per-process is fine: the goal is stopping dumb
|
|
16554
|
+
// bot floods, not building an accounting-grade limiter.
|
|
16555
|
+
const _contact_form_hits = { ip: {}, profile: {} };
|
|
16556
|
+
const _contact_form_allow = function (bucket, key, max, window_ms) {
|
|
16557
|
+
const now = Date.now();
|
|
16558
|
+
const store = _contact_form_hits[bucket];
|
|
16559
|
+
store[key] = (store[key] || []).filter((t) => now - t < window_ms);
|
|
16560
|
+
if (store[key].length >= max) return false;
|
|
16561
|
+
store[key].push(now);
|
|
16562
|
+
if (Object.keys(store).length > 5000) {
|
|
16563
|
+
for (const k of Object.keys(store)) {
|
|
16564
|
+
if (!store[k].length || now - store[k][store[k].length - 1] > window_ms) delete store[k];
|
|
16565
|
+
}
|
|
16566
|
+
}
|
|
16567
|
+
return true;
|
|
16568
|
+
};
|
|
16569
|
+
|
|
16570
|
+
// Open a 'ticket' chat_conversation on the owner's app for a form submission.
|
|
16571
|
+
// Every submission opens a fresh ticket (real ticket semantics), carrying the
|
|
16572
|
+
// message as the first inbound conversation item.
|
|
16573
|
+
const _contact_form_create_ticket = async function (account_profile_info, contact_id, subject, message, origin) {
|
|
16574
|
+
const owner_uid = account_profile_info.uid;
|
|
16575
|
+
const profile_id = account_profile_info.account_profile_id;
|
|
16576
|
+
|
|
16577
|
+
let conversation_obj = null;
|
|
16578
|
+
try {
|
|
16579
|
+
conversation_obj = await create_openai_conversation();
|
|
16580
|
+
} catch (err) {}
|
|
16581
|
+
|
|
16582
|
+
const d = Date.now();
|
|
16583
|
+
const conversation_doc = {
|
|
16584
|
+
_id: await _common.xuda_get_uuid('chat_conversation'),
|
|
16585
|
+
docType: 'chat_conversation',
|
|
16586
|
+
title: subject || getFirstNWords(message, 10),
|
|
16587
|
+
subject: subject || '',
|
|
16588
|
+
date_created_ts: d,
|
|
16589
|
+
ts: d,
|
|
16590
|
+
stat: 3,
|
|
16591
|
+
uid: owner_uid,
|
|
16592
|
+
messages: [],
|
|
16593
|
+
reference_type: 'contacts',
|
|
16594
|
+
reference_id: contact_id,
|
|
16595
|
+
conversation_obj,
|
|
16596
|
+
conversation_type: 'ticket',
|
|
16597
|
+
conversation_source: 'contact_form',
|
|
16598
|
+
contact_form_origin: origin || '',
|
|
16599
|
+
initiator_uid: owner_uid,
|
|
16600
|
+
direction: 'in',
|
|
16601
|
+
reference_conversation_id: conversation_obj?.id,
|
|
16602
|
+
account_profiles: [profile_id],
|
|
16603
|
+
prompt: message,
|
|
16604
|
+
rtl: _common.detectRTL(message),
|
|
16605
|
+
process_stat: 'full',
|
|
16606
|
+
};
|
|
16607
|
+
const conv_save = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
16608
|
+
if (conv_save.code < 0) throw new Error(conv_save.data || 'ticket_create_failed');
|
|
16609
|
+
|
|
16610
|
+
let conversation_item_reference_id;
|
|
16611
|
+
try {
|
|
16612
|
+
conversation_item_reference_id = await add_conversation_item(owner_uid, profile_id, conversation_doc._id, message, 'ticket', contact_id, { direction: 'in' });
|
|
16613
|
+
} catch (err) {}
|
|
16614
|
+
|
|
16615
|
+
const item_doc = {
|
|
16616
|
+
_id: await _common.xuda_get_uuid('chat_conversation_item'),
|
|
16617
|
+
stat: 3,
|
|
16618
|
+
docType: 'chat_conversation_item',
|
|
16619
|
+
uid: owner_uid,
|
|
16620
|
+
conversation_type: 'ticket',
|
|
16621
|
+
type: 'ticket',
|
|
16622
|
+
date_created_ts: d,
|
|
16623
|
+
ts: d,
|
|
16624
|
+
conversation_id: conversation_doc._id,
|
|
16625
|
+
text: message,
|
|
16626
|
+
subject: subject || '',
|
|
16627
|
+
reference_id: contact_id,
|
|
16628
|
+
conversation_item_reference_id,
|
|
16629
|
+
direction: 'in',
|
|
16630
|
+
role: 'user',
|
|
16631
|
+
contact_form_source: true,
|
|
16632
|
+
rtl: _common.detectRTL(message),
|
|
16633
|
+
};
|
|
16634
|
+
await db_module.save_app_couch_doc(account_profile_info.app_id, item_doc);
|
|
16635
|
+
|
|
16636
|
+
return conversation_doc;
|
|
16637
|
+
};
|
|
16638
|
+
|
|
16639
|
+
// Public endpoint behind the embedded form. Validates the human-detection
|
|
16640
|
+
// signals, adds the sender as a contact (not a friend) and opens the ticket.
|
|
16641
|
+
export const contact_form_submit = async function (req, job_id, headers) {
|
|
16642
|
+
try {
|
|
16643
|
+
const { profile_id, token, website, origin, visitor_lang } = req;
|
|
16644
|
+
let { name, email, subject, phone, company, message } = req;
|
|
16645
|
+
|
|
16646
|
+
// Honeypot: the hidden 'website' input must stay empty. Bots fill it;
|
|
16647
|
+
// answer with a generic ok so they learn nothing.
|
|
16648
|
+
if (typeof website === 'string' && website.trim() !== '') return { code: 1, data: { ok: true } };
|
|
16649
|
+
|
|
16650
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof message !== 'string') return { code: -1, data: 'invalid_input' };
|
|
16651
|
+
name = name.trim().slice(0, 120);
|
|
16652
|
+
email = email.toLowerCase().trim();
|
|
16653
|
+
message = message.trim().slice(0, 5000);
|
|
16654
|
+
subject = typeof subject === 'string' ? subject.trim().slice(0, 200) : '';
|
|
16655
|
+
phone = typeof phone === 'string' ? phone.trim().slice(0, 40) : '';
|
|
16656
|
+
company = typeof company === 'string' ? company.trim().slice(0, 120) : '';
|
|
16657
|
+
if (!name || !email || !message) return { code: -1, data: 'invalid_input' };
|
|
16658
|
+
if (!WIDGET_EMAIL_RE.test(email)) return { code: -1, data: 'invalid_email' };
|
|
16659
|
+
|
|
16660
|
+
const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
|
|
16661
|
+
if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
|
|
16662
|
+
|
|
16663
|
+
const account_profile_info = await get_active_account_profile_info(owner_uid_part, pid_part);
|
|
16664
|
+
const ap_doc = account_profile_info.account_profile_obj || {};
|
|
16665
|
+
if (ap_doc.contact_form_enabled !== true) return { code: -404, data: 'contact_form_disabled' };
|
|
16666
|
+
|
|
16667
|
+
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16668
|
+
const token_check = _verify_contact_form_token(canonical_id, token);
|
|
16669
|
+
if (!token_check.ok) return { code: -401, data: token_check.reason };
|
|
16670
|
+
|
|
16671
|
+
const client_info = extractClientInfo(headers) || {};
|
|
16672
|
+
const client_ip = client_info.ipAddress || 'unknown';
|
|
16673
|
+
if (!_contact_form_allow('ip', client_ip, 5, 10 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16674
|
+
if (!_contact_form_allow('profile', canonical_id, 200, 24 * 60 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16675
|
+
|
|
16676
|
+
const owner_uid = account_profile_info.uid;
|
|
16677
|
+
const config = _sanitize_contact_form_config(ap_doc.contact_form_config);
|
|
16678
|
+
|
|
16679
|
+
// Field requirements set by the owner are enforced server side too.
|
|
16680
|
+
for (const key of CONTACT_FORM_FIELD_KEYS) {
|
|
16681
|
+
if (config.fields?.[key]?.show && config.fields?.[key]?.required) {
|
|
16682
|
+
const val = key === 'phone' ? phone : key === 'company' ? company : subject;
|
|
16683
|
+
if (!val) return { code: -1, data: `missing_${key}` };
|
|
16684
|
+
}
|
|
16685
|
+
}
|
|
16686
|
+
|
|
16687
|
+
// All human-detection gates passed: ack the visitor immediately and run
|
|
16688
|
+
// the pipeline (contact enrichment, ticket, notification, auto-reply) in
|
|
16689
|
+
// the background. add_contact runs several AI enrichment steps and would
|
|
16690
|
+
// otherwise hold the visitor's spinner for many seconds.
|
|
16691
|
+
(async () => {
|
|
16692
|
+
try {
|
|
16693
|
+
// Contact, not friend: no contact_connection team_request is created.
|
|
16694
|
+
const add_ret = await account_ms.add_contact({
|
|
16695
|
+
uid: owner_uid,
|
|
16696
|
+
profile_id: account_profile_info.account_profile_id,
|
|
16697
|
+
name,
|
|
16698
|
+
email,
|
|
16699
|
+
source: 'contact_form',
|
|
16700
|
+
context: `Contact form submission${origin ? ` from ${String(origin).slice(0, 200)}` : ''}`,
|
|
16701
|
+
metadata: {
|
|
16702
|
+
subject,
|
|
16703
|
+
phone,
|
|
16704
|
+
company,
|
|
16705
|
+
origin: String(origin || '').slice(0, 200),
|
|
16706
|
+
visitor_lang: String(visitor_lang || '').slice(0, 16),
|
|
16707
|
+
contact_form: true,
|
|
16708
|
+
not_junk: true,
|
|
16709
|
+
},
|
|
16710
|
+
});
|
|
16711
|
+
const contact_id = add_ret.code > -1 ? add_ret.data?.id : add_ret.contact_id;
|
|
16712
|
+
if (!contact_id) throw new Error(add_ret.data || 'contact_create_failed');
|
|
16713
|
+
|
|
16714
|
+
const body_parts = [message];
|
|
16715
|
+
const detail_bits = [phone && `Phone: ${phone}`, company && `Company: ${company}`].filter(Boolean);
|
|
16716
|
+
if (detail_bits.length) body_parts.push(detail_bits.join(' | '));
|
|
16717
|
+
await _contact_form_create_ticket(account_profile_info, contact_id, subject, body_parts.join('\n\n'), origin);
|
|
16718
|
+
|
|
16719
|
+
// Owner notification email (platform template).
|
|
16720
|
+
if (config.notify_email !== false) {
|
|
16721
|
+
try {
|
|
16722
|
+
const owner_acct = await db_module.get_couch_doc('xuda_accounts', owner_uid);
|
|
16723
|
+
const owner_email = owner_acct?.data?.account_info?.email || owner_acct?.data?.email;
|
|
16724
|
+
if (owner_email) {
|
|
16725
|
+
const esc = (v) => String(v || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
16726
|
+
const dashboard_url = `${embed_origin()}/dashboard`;
|
|
16727
|
+
const body = `<h2 style="margin:0 0 12px">New contact form message</h2>
|
|
16728
|
+
<p style="margin:0 0 16px;color:#475569">Someone reached out through the contact form of your profile <b>${esc(ap_doc.profile_name || '')}</b>.</p>
|
|
16729
|
+
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;font-size:14px">
|
|
16730
|
+
<tr><td style="padding:6px 12px 6px 0;color:#64748b;white-space:nowrap;vertical-align:top">From</td><td style="padding:6px 0"><b>${esc(name)}</b> <${esc(email)}></td></tr>
|
|
16731
|
+
${subject ? `<tr><td style="padding:6px 12px 6px 0;color:#64748b;vertical-align:top">Subject</td><td style="padding:6px 0">${esc(subject)}</td></tr>` : ''}
|
|
16732
|
+
${phone ? `<tr><td style="padding:6px 12px 6px 0;color:#64748b;vertical-align:top">Phone</td><td style="padding:6px 0">${esc(phone)}</td></tr>` : ''}
|
|
16733
|
+
${company ? `<tr><td style="padding:6px 12px 6px 0;color:#64748b;vertical-align:top">Company</td><td style="padding:6px 0">${esc(company)}</td></tr>` : ''}
|
|
16734
|
+
</table>
|
|
16735
|
+
<div style="background:#f8fafc;border:1px solid #e6ebf1;border-radius:10px;padding:14px 16px;margin:14px 0;white-space:pre-wrap">${esc(message)}</div>
|
|
16736
|
+
<p style="margin:16px 0 0"><a href="${dashboard_url}" style="display:inline-block;background:#0091FF;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:600">Open the conversation</a></p>`;
|
|
16737
|
+
await email_ms.send_email({ email: owner_email, subject: `New contact form message from ${name}`, body, display_type: 'info' });
|
|
16738
|
+
}
|
|
16739
|
+
} catch (err) {
|
|
16740
|
+
console.error('[contact_form] owner notification failed', err?.message || err);
|
|
16741
|
+
}
|
|
16742
|
+
}
|
|
16743
|
+
|
|
16744
|
+
// Optional AI auto-reply, reusing the profile's auto-respond setup.
|
|
16745
|
+
if (config.auto_respond !== false && ap_doc.auto_respond === true) {
|
|
16746
|
+
auto_response(owner_uid, account_profile_info.account_profile_id, contact_id, 'ticket');
|
|
16747
|
+
}
|
|
16748
|
+
} catch (err) {
|
|
16749
|
+
console.error('[contact_form_submit] background pipeline failed', err?.message || err);
|
|
16750
|
+
}
|
|
16751
|
+
})();
|
|
16752
|
+
|
|
16753
|
+
return { code: 1, data: { ok: true } };
|
|
16754
|
+
} catch (err) {
|
|
16755
|
+
return { code: -1, data: err.message || String(err) };
|
|
16756
|
+
}
|
|
16757
|
+
};
|
|
16758
|
+
|
|
16759
|
+
// Owner-side reply to a 'ticket' conversation (submit_chat_conversation case).
|
|
16760
|
+
// Appends the outbound item and emails the contact: through the profile's
|
|
16761
|
+
// connected mailbox when one exists, otherwise through the platform mail
|
|
16762
|
+
// server with Reply-To pointing at the owner. The body is wrapped with the
|
|
16763
|
+
// profile's selected email template (Email Template tab) unless it is plain.
|
|
16764
|
+
const contact_ticket_conversation = async function (req, job_id, headers) {
|
|
16765
|
+
const { uid, profile_id, _auto_response = false } = req;
|
|
16766
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16767
|
+
let { prompt: body, conversation_doc } = req;
|
|
16768
|
+
try {
|
|
16769
|
+
const conversation_id = conversation_doc._id;
|
|
16770
|
+
const sender_app_id = account_profile_info.app_id;
|
|
16771
|
+
const contact_id = conversation_doc.reference_id;
|
|
16772
|
+
const contact_info = await get_contact_info(uid, null, contact_id);
|
|
16773
|
+
if (!contact_info?.email) throw new Error('empty contact email');
|
|
16774
|
+
|
|
16775
|
+
const subject_base = conversation_doc.subject || conversation_doc.title;
|
|
16776
|
+
const subject = subject_base ? `Re: ${subject_base}` : 'Re: your message';
|
|
16777
|
+
|
|
16778
|
+
const profile_doc = account_profile_info.account_profile_obj || {};
|
|
16779
|
+
let html = null;
|
|
16780
|
+
try {
|
|
16781
|
+
const render_ret = await email_ms.render_profile_email({
|
|
16782
|
+
style: profile_doc.email_template?.style,
|
|
16783
|
+
body_text: body,
|
|
16784
|
+
profile_name: profile_doc.profile_name,
|
|
16785
|
+
signature: profile_doc.profile_signature,
|
|
16786
|
+
avatar_url: profile_doc.profile_picture || profile_doc.profile_avatar || '',
|
|
16787
|
+
brand_color: profile_doc.widget_config?.color || profile_doc.contact_form_config?.color || '',
|
|
16788
|
+
});
|
|
16789
|
+
if (render_ret?.code > 0 && render_ret.data) html = render_ret.data;
|
|
16790
|
+
} catch (err) {}
|
|
16791
|
+
|
|
16792
|
+
let sent = false;
|
|
16793
|
+
if (profile_doc.email_account_id) {
|
|
16794
|
+
try {
|
|
16795
|
+
const email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, profile_doc.email_account_id);
|
|
16796
|
+
const send_ret = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, html);
|
|
16797
|
+
sent = !!send_ret?.success;
|
|
16798
|
+
} catch (err) {
|
|
16799
|
+
console.error('[ticket reply] profile mailbox send failed, falling back to platform mail', err?.message || err);
|
|
16800
|
+
}
|
|
16801
|
+
}
|
|
16802
|
+
if (!sent) {
|
|
16803
|
+
const owner_acct = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
16804
|
+
const owner_email = owner_acct?.data?.account_info?.email || owner_acct?.data?.email || '';
|
|
16805
|
+
const send_ret = await email_ms.send_profile_email({
|
|
16806
|
+
to: contact_info.email,
|
|
16807
|
+
subject,
|
|
16808
|
+
body_text: body,
|
|
16809
|
+
html,
|
|
16810
|
+
from_name: profile_doc.profile_name || owner_acct?.data?.account_info?.full_name || 'Xuda user',
|
|
16811
|
+
reply_to: owner_email,
|
|
16812
|
+
});
|
|
16813
|
+
sent = !!send_ret?.success || send_ret?.code > 0;
|
|
16814
|
+
if (!sent) throw new Error('error sending ticket reply email');
|
|
16815
|
+
}
|
|
16816
|
+
|
|
16817
|
+
let conversation_item_reference_id;
|
|
16818
|
+
try {
|
|
16819
|
+
conversation_item_reference_id = await add_conversation_item(uid, profile_id, conversation_id, body, 'ticket', contact_id, { direction: 'out' });
|
|
16820
|
+
} catch (err) {}
|
|
16821
|
+
|
|
16822
|
+
const now = Date.now();
|
|
16823
|
+
const out_conversation_item_obj = {
|
|
16824
|
+
_id: await _common.xuda_get_uuid('chat_conversation_item'),
|
|
16825
|
+
stat: 3,
|
|
16826
|
+
docType: 'chat_conversation_item',
|
|
16827
|
+
uid,
|
|
16828
|
+
conversation_type: 'ticket',
|
|
16829
|
+
type: 'ticket',
|
|
16830
|
+
date_created_ts: now,
|
|
16831
|
+
ts: now,
|
|
16832
|
+
conversation_id,
|
|
16833
|
+
text: body,
|
|
16834
|
+
subject,
|
|
16835
|
+
reference_id: contact_id,
|
|
16836
|
+
conversation_item_reference_id,
|
|
16837
|
+
direction: 'out',
|
|
16838
|
+
role: 'user',
|
|
16839
|
+
auto_response: _auto_response || undefined,
|
|
16840
|
+
rtl: _common.detectRTL(body),
|
|
16841
|
+
};
|
|
16842
|
+
const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
|
|
16843
|
+
|
|
16844
|
+
const fresh_conv = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
|
|
16845
|
+
fresh_conv.ts = Date.now();
|
|
16846
|
+
await db_module.save_app_couch_doc_native(sender_app_id, fresh_conv);
|
|
16847
|
+
|
|
16848
|
+
return save_ret;
|
|
16849
|
+
} catch (err) {
|
|
16850
|
+
return { code: -15, data: err.message };
|
|
16851
|
+
}
|
|
16852
|
+
};
|
package/index_ms.mjs
CHANGED
|
@@ -320,3 +320,31 @@ export const widget_send_message = async function (...args) {
|
|
|
320
320
|
export const widget_get_messages = async function (...args) {
|
|
321
321
|
return await broker.send_to_queue("widget_get_messages", ...args);
|
|
322
322
|
};
|
|
323
|
+
|
|
324
|
+
export const get_widget_settings = async function (...args) {
|
|
325
|
+
return await broker.send_to_queue("get_widget_settings", ...args);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export const update_widget_settings = async function (...args) {
|
|
329
|
+
return await broker.send_to_queue("update_widget_settings", ...args);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const mint_contact_form_token = async function (...args) {
|
|
333
|
+
return await broker.send_to_queue("mint_contact_form_token", ...args);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export const get_contact_form_settings = async function (...args) {
|
|
337
|
+
return await broker.send_to_queue("get_contact_form_settings", ...args);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const update_contact_form_settings = async function (...args) {
|
|
341
|
+
return await broker.send_to_queue("update_contact_form_settings", ...args);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const get_contact_form_bootstrap = async function (...args) {
|
|
345
|
+
return await broker.send_to_queue("get_contact_form_bootstrap", ...args);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export const contact_form_submit = async function (...args) {
|
|
349
|
+
return await broker.send_to_queue("contact_form_submit", ...args);
|
|
350
|
+
};
|
package/index_msa.mjs
CHANGED
|
@@ -320,3 +320,31 @@ export const widget_send_message = function (...args) {
|
|
|
320
320
|
export const widget_get_messages = function (...args) {
|
|
321
321
|
broker.send_to_queue_async("widget_get_messages", ...args);
|
|
322
322
|
};
|
|
323
|
+
|
|
324
|
+
export const get_widget_settings = function (...args) {
|
|
325
|
+
broker.send_to_queue_async("get_widget_settings", ...args);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
export const update_widget_settings = function (...args) {
|
|
329
|
+
broker.send_to_queue_async("update_widget_settings", ...args);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export const mint_contact_form_token = function (...args) {
|
|
333
|
+
broker.send_to_queue_async("mint_contact_form_token", ...args);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export const get_contact_form_settings = function (...args) {
|
|
337
|
+
broker.send_to_queue_async("get_contact_form_settings", ...args);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const update_contact_form_settings = function (...args) {
|
|
341
|
+
broker.send_to_queue_async("update_contact_form_settings", ...args);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const get_contact_form_bootstrap = function (...args) {
|
|
345
|
+
broker.send_to_queue_async("get_contact_form_bootstrap", ...args);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export const contact_form_submit = function (...args) {
|
|
349
|
+
broker.send_to_queue_async("contact_form_submit", ...args);
|
|
350
|
+
};
|