@xuda.io/ai_module 1.1.5634 → 1.1.5635
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 +654 -21
- package/index_ms.mjs +28 -0
- package/index_msa.mjs +28 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -161,13 +161,49 @@ 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;
|
|
166
202
|
};
|
|
167
203
|
|
|
168
204
|
const get_openai_codex_usage_model = function (codex_model, requested_codex_model) {
|
|
169
|
-
|
|
170
|
-
return
|
|
205
|
+
// Bill by the catalog code actually used (requested selection or cheapest default).
|
|
206
|
+
return codex_model || _conf.openai_codex_command || 'openai_codex_cli';
|
|
171
207
|
};
|
|
172
208
|
|
|
173
209
|
const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
@@ -180,7 +216,9 @@ const get_openai_codex_exec_args = function (codex_model, opts = {}) {
|
|
|
180
216
|
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
217
|
|
|
182
218
|
if (codex_model) {
|
|
183
|
-
|
|
219
|
+
// codex_model is a catalog code (user pick or cheapest default); the Codex CLI
|
|
220
|
+
// needs the real OpenAI id.
|
|
221
|
+
args.push('--model', resolve_ai_model(codex_model));
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
if (bypass_approvals_and_sandbox) {
|
|
@@ -2291,7 +2329,7 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
|
|
|
2291
2329
|
let response;
|
|
2292
2330
|
try {
|
|
2293
2331
|
response = await client.images.generate({
|
|
2294
|
-
model,
|
|
2332
|
+
model: resolve_ai_model(model),
|
|
2295
2333
|
prompt,
|
|
2296
2334
|
size,
|
|
2297
2335
|
n,
|
|
@@ -4312,7 +4350,7 @@ export const submit_chat_gpt_prompt = async function (req) {
|
|
|
4312
4350
|
let response;
|
|
4313
4351
|
try {
|
|
4314
4352
|
let opt = {
|
|
4315
|
-
model,
|
|
4353
|
+
model: resolve_ai_model(model),
|
|
4316
4354
|
input: prompt,
|
|
4317
4355
|
tools,
|
|
4318
4356
|
text: {
|
|
@@ -4893,6 +4931,11 @@ export const submit_chat_conversation = async function (req, job_id, headers) {
|
|
|
4893
4931
|
break;
|
|
4894
4932
|
}
|
|
4895
4933
|
|
|
4934
|
+
case 'ticket': {
|
|
4935
|
+
ret = await contact_ticket_conversation(req, job_id, headers);
|
|
4936
|
+
break;
|
|
4937
|
+
}
|
|
4938
|
+
|
|
4896
4939
|
default:
|
|
4897
4940
|
ret = { code: -44, data: 'invalid conversation_type ' + conversation_doc.conversation_type };
|
|
4898
4941
|
break;
|
|
@@ -5152,7 +5195,24 @@ const chat_email = async function (req, job_id, headers) {
|
|
|
5152
5195
|
email_attachments.push(await save_drive_file_to_disk(attachment));
|
|
5153
5196
|
}
|
|
5154
5197
|
|
|
5155
|
-
|
|
5198
|
+
// Wrap the body with the profile's selected email template (Email
|
|
5199
|
+
// Template tab). 'plain' or an unset style renders null, keeping the
|
|
5200
|
+
// original raw-text behavior.
|
|
5201
|
+
let template_html = null;
|
|
5202
|
+
try {
|
|
5203
|
+
const profile_doc = account_profile_info.account_profile_obj || {};
|
|
5204
|
+
const render_ret = await email_ms.render_profile_email({
|
|
5205
|
+
style: profile_doc.email_template?.style,
|
|
5206
|
+
body_text: body,
|
|
5207
|
+
profile_name: profile_doc.profile_name,
|
|
5208
|
+
signature: profile_doc.profile_signature,
|
|
5209
|
+
avatar_url: profile_doc.profile_picture || profile_doc.profile_avatar || '',
|
|
5210
|
+
brand_color: profile_doc.widget_config?.color || '',
|
|
5211
|
+
});
|
|
5212
|
+
if (render_ret?.code > 0 && render_ret.data) template_html = render_ret.data;
|
|
5213
|
+
} catch (err) {}
|
|
5214
|
+
|
|
5215
|
+
sent_email_result = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, template_html, email_attachments);
|
|
5156
5216
|
if (!sent_email_result.success) {
|
|
5157
5217
|
throw new Error('error sending email');
|
|
5158
5218
|
}
|
|
@@ -5703,6 +5763,22 @@ const resolve_plugin_action = function (req, dashboard_context, prompt) {
|
|
|
5703
5763
|
// answers are specific to THIS project instead of generic. Terse on purpose to stay
|
|
5704
5764
|
// within the token budget; the snapshot is trusted for grounding only — the agent
|
|
5705
5765
|
// still calls CPI tools (which re-check ownership) for any actual read/write.
|
|
5766
|
+
// The 'ask_ai' access consent ("let the assistant read this server's current status").
|
|
5767
|
+
// Gated on the customer's own machines only: app_type 'vps' (full-stack is vps +
|
|
5768
|
+
// is_full_stack, so it is covered) and 'external_vps'. MIRRORS
|
|
5769
|
+
// app_module.CONSENT_ENFORCED_APP_TYPES — no cpi module imports app_module, so the
|
|
5770
|
+
// predicate is duplicated here on purpose; keep the copies in step.
|
|
5771
|
+
//
|
|
5772
|
+
// Unlike files/metrics/backup this does NOT return -91. This builder never touches the
|
|
5773
|
+
// server — it only copies facts off the already-loaded app doc into the system prompt —
|
|
5774
|
+
// and the chat does far more than answer server questions, so blocking it would be
|
|
5775
|
+
// wrong. Enforcement here is REDACTION: no consent → the assistant simply is not told
|
|
5776
|
+
// the server's address/status. Anything that actually reads the box goes through the
|
|
5777
|
+
// CPI tools, which carry their own gates (vps_fs_* → 'files').
|
|
5778
|
+
const _ASK_AI_CONSENT_TYPES = ['vps', 'external_vps'];
|
|
5779
|
+
const _ask_ai_consent_blocked = (o) =>
|
|
5780
|
+
!!o && _ASK_AI_CONSENT_TYPES.includes(o.app_type) && o?.deploy_data?.access_consents?.ask_ai?.granted !== true;
|
|
5781
|
+
|
|
5706
5782
|
const build_dashboard_state_context = function (app_obj, ctx) {
|
|
5707
5783
|
const lines = [];
|
|
5708
5784
|
if (app_obj) {
|
|
@@ -5712,7 +5788,7 @@ const build_dashboard_state_context = function (app_obj, ctx) {
|
|
|
5712
5788
|
const plan = app_obj.app_uId_plan || app_obj.membership_plan;
|
|
5713
5789
|
if (plan) lines.push(`- Plan: ${plan}`);
|
|
5714
5790
|
const vps = app_obj.app_hosting_server;
|
|
5715
|
-
if (vps && (vps.ip || vps.droplet?.id)) {
|
|
5791
|
+
if (vps && (vps.ip || vps.droplet?.id) && !_ask_ai_consent_blocked(app_obj)) {
|
|
5716
5792
|
lines.push(`- VPS: ${vps.ip || 'provisioned'}${vps.droplet?.status ? ` (${vps.droplet.status})` : ''}${vps.region ? `, ${vps.region}` : ''}`);
|
|
5717
5793
|
}
|
|
5718
5794
|
const sw = app_obj.static_website;
|
|
@@ -5746,7 +5822,7 @@ const build_dashboard_state_context = function (app_obj, ctx) {
|
|
|
5746
5822
|
const pick_dashboard_model = function (prompt, dashboard_context) {
|
|
5747
5823
|
const models = _conf.ai_models || {};
|
|
5748
5824
|
const fallback = _conf.default_ai_model;
|
|
5749
|
-
const strong = (_conf.strong_ai_model && models[_conf.strong_ai_model] && _conf.strong_ai_model) || (models['gpt-
|
|
5825
|
+
const strong = (_conf.strong_ai_model && models[_conf.strong_ai_model] && _conf.strong_ai_model) || (models['gpt-3'] && 'gpt-3') || fallback;
|
|
5750
5826
|
// Intent comes from the PROMPT only — folding in dashboard_context would let a
|
|
5751
5827
|
// tab named with an action verb (e.g. "deploy") force every question on that
|
|
5752
5828
|
// tab to the strong tier ("is my site live?" on the deploy tab is still a read).
|
|
@@ -6389,7 +6465,7 @@ ${CLARIFYING_QUESTIONS_INSTRUCTION}
|
|
|
6389
6465
|
Tab/context: "${dashboard_context || 'unknown'}" · app_id: "${target_app_id}"
|
|
6390
6466
|
Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_state_block ? '\n\n' + dashboard_state_block : ''}${action_steer ? '\n\n' + action_steer : ''}
|
|
6391
6467
|
`.trim(),
|
|
6392
|
-
model: ai_model,
|
|
6468
|
+
model: resolve_ai_model(ai_model),
|
|
6393
6469
|
tools: cpi_tools_ret.tools,
|
|
6394
6470
|
});
|
|
6395
6471
|
|
|
@@ -7170,7 +7246,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7170
7246
|
let prompt_suggestion_activated;
|
|
7171
7247
|
let chat_suggestion_activated;
|
|
7172
7248
|
let model = ai_model;
|
|
7173
|
-
if (!_conf.ai_models[model]) {
|
|
7249
|
+
if (!_conf.ai_models[canonical_ai_code(model)]) {
|
|
7174
7250
|
throw new Error('model not supported');
|
|
7175
7251
|
}
|
|
7176
7252
|
|
|
@@ -7517,7 +7593,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7517
7593
|
const agent = new Agent({
|
|
7518
7594
|
name: agent_name.substring(0, 55),
|
|
7519
7595
|
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,
|
|
7596
|
+
model: resolve_ai_model(model),
|
|
7521
7597
|
tools,
|
|
7522
7598
|
metadata: {
|
|
7523
7599
|
agent_id: ai_agent_doc._id,
|
|
@@ -7674,7 +7750,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
|
|
|
7674
7750
|
instructions: get_agent_instructions(),
|
|
7675
7751
|
// inputGuardrails: [contactGuardrail],
|
|
7676
7752
|
// outputGuardrails: [contactGuardrail],
|
|
7677
|
-
model,
|
|
7753
|
+
model: resolve_ai_model(model),
|
|
7678
7754
|
handoffs: agents, // this lets triageAgent forward to other agents
|
|
7679
7755
|
});
|
|
7680
7756
|
}
|
|
@@ -8002,7 +8078,7 @@ Do not mention that the reply is automated.
|
|
|
8002
8078
|
${conversation_type === 'chat' ? 'Return only the chat message text, ready to send.' : 'Return only the email body, ready to send.'}
|
|
8003
8079
|
If a signature is available, append it at the end:
|
|
8004
8080
|
${account_profile_doc.profile_signature || ''}`.trim(),
|
|
8005
|
-
model,
|
|
8081
|
+
model: resolve_ai_model(model),
|
|
8006
8082
|
metadata: { auto_response: true, default: true, ts: Date.now() },
|
|
8007
8083
|
}),
|
|
8008
8084
|
);
|
|
@@ -8047,7 +8123,7 @@ If a signature is available, append it at the end:
|
|
|
8047
8123
|
${account_profile_doc.profile_signature || ''}`
|
|
8048
8124
|
: ''
|
|
8049
8125
|
}${context?.has_full_stack_vps ? '\n\n' + get_full_stack_vps_instructions() : ''}`.trim(),
|
|
8050
|
-
model,
|
|
8126
|
+
model: resolve_ai_model(model),
|
|
8051
8127
|
tools: tools_ret.tools,
|
|
8052
8128
|
metadata: {
|
|
8053
8129
|
agent_id: ai_agent_doc._id,
|
|
@@ -8075,7 +8151,7 @@ ${account_profile_doc.profile_signature || ''}`
|
|
|
8075
8151
|
name: 'Auto Response Triage Agent',
|
|
8076
8152
|
instructions: `Select the best specialist agent to draft an automatic ${conversation_type === 'chat' ? 'chat' : 'email'} response.
|
|
8077
8153
|
Return only the final ${conversation_type === 'chat' ? 'chat message text' : 'email body text'}.`,
|
|
8078
|
-
model,
|
|
8154
|
+
model: resolve_ai_model(model),
|
|
8079
8155
|
handoffs: agents,
|
|
8080
8156
|
});
|
|
8081
8157
|
}
|
|
@@ -8143,6 +8219,18 @@ Return only the email body.`;
|
|
|
8143
8219
|
undefined,
|
|
8144
8220
|
undefined,
|
|
8145
8221
|
);
|
|
8222
|
+
} else if (conversation_type === 'ticket') {
|
|
8223
|
+
await contact_ticket_conversation(
|
|
8224
|
+
{
|
|
8225
|
+
profile_id,
|
|
8226
|
+
uid,
|
|
8227
|
+
prompt: response_text,
|
|
8228
|
+
conversation_doc,
|
|
8229
|
+
_auto_response: true,
|
|
8230
|
+
},
|
|
8231
|
+
undefined,
|
|
8232
|
+
undefined,
|
|
8233
|
+
);
|
|
8146
8234
|
} else {
|
|
8147
8235
|
await chat_email(
|
|
8148
8236
|
{
|
|
@@ -8792,7 +8880,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
8792
8880
|
let ai_avatar_response;
|
|
8793
8881
|
try {
|
|
8794
8882
|
ai_avatar_response = await client.images.edit({
|
|
8795
|
-
model,
|
|
8883
|
+
model: resolve_ai_model(model),
|
|
8796
8884
|
image: image_blob_ret.image_blob, //base photo
|
|
8797
8885
|
prompt,
|
|
8798
8886
|
background: 'transparent',
|
|
@@ -8903,7 +8991,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
|
|
|
8903
8991
|
let ai_avatar_response;
|
|
8904
8992
|
try {
|
|
8905
8993
|
ai_avatar_response = await client.images.edit({
|
|
8906
|
-
model,
|
|
8994
|
+
model: resolve_ai_model(model),
|
|
8907
8995
|
image: image_blob_ret.image_blob, //logo
|
|
8908
8996
|
prompt: `
|
|
8909
8997
|
Create a transparent PNG profile image for a business account featuring building branded as ${name}.
|
|
@@ -9528,7 +9616,7 @@ const create_ai_agent_image = async function (req, job_id, headers) {
|
|
|
9528
9616
|
let ai_avatar_response;
|
|
9529
9617
|
try {
|
|
9530
9618
|
ai_avatar_response = await client.images.edit({
|
|
9531
|
-
model,
|
|
9619
|
+
model: resolve_ai_model(model),
|
|
9532
9620
|
image: image_blob_ret.image_blob,
|
|
9533
9621
|
|
|
9534
9622
|
prompt,
|
|
@@ -15913,13 +16001,13 @@ const _ai_design = async (uid, src) => {
|
|
|
15913
16001
|
`;
|
|
15914
16002
|
let resp;
|
|
15915
16003
|
try {
|
|
15916
|
-
resp = await client.images.edit({ model, image: blob, prompt, background: 'transparent' });
|
|
16004
|
+
resp = await client.images.edit({ model: resolve_ai_model(model), image: blob, prompt, background: 'transparent' });
|
|
15917
16005
|
report_ai_status(model);
|
|
15918
16006
|
} catch (err) {
|
|
15919
16007
|
report_ai_status(model, err);
|
|
15920
16008
|
if (err?.code === 'moderation_blocked') {
|
|
15921
16009
|
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' });
|
|
16010
|
+
resp = await client.images.edit({ model: resolve_ai_model(model), image: blob, prompt: soft, background: 'transparent' });
|
|
15923
16011
|
report_ai_status(model);
|
|
15924
16012
|
} else {
|
|
15925
16013
|
throw err;
|
|
@@ -16204,3 +16292,548 @@ export const widget_get_messages = async function (req, job_id, headers) {
|
|
|
16204
16292
|
return { code: -1, data: err.message || String(err) };
|
|
16205
16293
|
}
|
|
16206
16294
|
};
|
|
16295
|
+
|
|
16296
|
+
////////////////////////////// WIDGET SETTINGS (profile setup Chat tab) //////////////////////////////
|
|
16297
|
+
|
|
16298
|
+
// Public origin that serves the embed loaders, iframe routes and /cpi for
|
|
16299
|
+
// embedded widgets/forms. On dev the config domain still says xuda.ai, so a
|
|
16300
|
+
// dev-generated snippet must point at the dev host or the profile lookup 404s.
|
|
16301
|
+
const embed_origin = function () {
|
|
16302
|
+
if (_conf.is_debug && process.env.XUDA_HOSTNAME) return `https://${process.env.XUDA_HOSTNAME}`;
|
|
16303
|
+
return `https://${_conf.domain || 'xuda.ai'}`;
|
|
16304
|
+
};
|
|
16305
|
+
|
|
16306
|
+
const WIDGET_CONFIG_KEYS = ['title', 'color', 'greeting', 'position'];
|
|
16307
|
+
const WIDGET_POSITIONS = ['br', 'bl', 'tr', 'tl'];
|
|
16308
|
+
|
|
16309
|
+
const _sanitize_widget_config = function (config) {
|
|
16310
|
+
const out = {};
|
|
16311
|
+
if (!config || typeof config !== 'object') return out;
|
|
16312
|
+
if (typeof config.title === 'string') out.title = config.title.replace(/[<>]/g, '').slice(0, 60);
|
|
16313
|
+
if (typeof config.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(config.color.trim())) out.color = config.color.trim();
|
|
16314
|
+
if (typeof config.greeting === 'string') out.greeting = config.greeting.replace(/[<>]/g, '').slice(0, 300);
|
|
16315
|
+
if (typeof config.position === 'string' && WIDGET_POSITIONS.includes(config.position)) out.position = config.position;
|
|
16316
|
+
return out;
|
|
16317
|
+
};
|
|
16318
|
+
|
|
16319
|
+
const _build_widget_snippet = function (widget_profile_id, config = {}) {
|
|
16320
|
+
const origin = embed_origin();
|
|
16321
|
+
const attrs = [`s.setAttribute('data-profile','${widget_profile_id}');`];
|
|
16322
|
+
if (config.position && WIDGET_POSITIONS.includes(config.position)) attrs.push(`s.setAttribute('data-position','${config.position}');`);
|
|
16323
|
+
if (config.color && /^#[0-9a-fA-F]{3,8}$/.test(config.color)) attrs.push(`s.setAttribute('data-color','${config.color}');`);
|
|
16324
|
+
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>`;
|
|
16325
|
+
};
|
|
16326
|
+
|
|
16327
|
+
// Read-only view of the profile's chat widget setup for the settings tab.
|
|
16328
|
+
// Unlike get_widget_embed_snippet this never mutates the profile doc.
|
|
16329
|
+
export const get_widget_settings = async function (req) {
|
|
16330
|
+
try {
|
|
16331
|
+
const { uid, profile_id } = req;
|
|
16332
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16333
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16334
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16335
|
+
const widget_profile_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16336
|
+
const config = _sanitize_widget_config(doc.widget_config);
|
|
16337
|
+
return {
|
|
16338
|
+
code: 1,
|
|
16339
|
+
data: {
|
|
16340
|
+
enabled: doc.widget_enabled !== false,
|
|
16341
|
+
config,
|
|
16342
|
+
widget_profile_id,
|
|
16343
|
+
iframe_url: `${embed_origin()}/chat-widget/${widget_profile_id}`,
|
|
16344
|
+
snippet: _build_widget_snippet(widget_profile_id, config),
|
|
16345
|
+
},
|
|
16346
|
+
};
|
|
16347
|
+
} catch (err) {
|
|
16348
|
+
return { code: -1, data: err.message || String(err) };
|
|
16349
|
+
}
|
|
16350
|
+
};
|
|
16351
|
+
|
|
16352
|
+
// Save the Chat tab: on/off plus skin (title, color, position) and greeting.
|
|
16353
|
+
export const update_widget_settings = async function (req) {
|
|
16354
|
+
try {
|
|
16355
|
+
const { uid, profile_id, enabled, config } = req;
|
|
16356
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16357
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16358
|
+
|
|
16359
|
+
const doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, account_profile_info.account_profile_id);
|
|
16360
|
+
if (doc.docType !== 'account_profile') return { code: -1, data: 'not_account_profile' };
|
|
16361
|
+
|
|
16362
|
+
if (typeof enabled === 'boolean') doc.widget_enabled = enabled;
|
|
16363
|
+
if (config && typeof config === 'object') {
|
|
16364
|
+
const clean = _sanitize_widget_config(config);
|
|
16365
|
+
doc.widget_config = { ...(doc.widget_config || {}), ...clean };
|
|
16366
|
+
// Explicit empty string clears a key back to the default.
|
|
16367
|
+
for (const key of WIDGET_CONFIG_KEYS) {
|
|
16368
|
+
if (config[key] === '') delete doc.widget_config[key];
|
|
16369
|
+
}
|
|
16370
|
+
}
|
|
16371
|
+
doc.ts = Date.now();
|
|
16372
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
16373
|
+
|
|
16374
|
+
return await get_widget_settings({ uid, profile_id });
|
|
16375
|
+
} catch (err) {
|
|
16376
|
+
return { code: -1, data: err.message || String(err) };
|
|
16377
|
+
}
|
|
16378
|
+
};
|
|
16379
|
+
|
|
16380
|
+
////////////////////////////// CONTACT FORM (embed) //////////////////////////////
|
|
16381
|
+
|
|
16382
|
+
// The form is a sibling of the chat widget: an iframe rendered by the router at
|
|
16383
|
+
// /contact-form/<uid>.<pid>, a loader script customers paste on their site, and
|
|
16384
|
+
// a public submit method. A submission adds the sender as a CONTACT on the
|
|
16385
|
+
// profile owner's side (no contact_connection team_request, so NOT a friend)
|
|
16386
|
+
// and opens a 'ticket' chat_conversation holding the message.
|
|
16387
|
+
|
|
16388
|
+
const CONTACT_FORM_TOKEN_TTL_MS = 2 * 60 * 60 * 1000;
|
|
16389
|
+
const CONTACT_FORM_MIN_AGE_MS = 3000;
|
|
16390
|
+
const CONTACT_FORM_FIELD_KEYS = ['phone', 'company', 'subject'];
|
|
16391
|
+
|
|
16392
|
+
// Human-detection token: HMAC over profile id + mint time. The key derives from
|
|
16393
|
+
// a fleet-shared secret so the router-rendered form and this module verify the
|
|
16394
|
+
// same signature without distributing a new secret.
|
|
16395
|
+
const _contact_form_hmac_key = function () {
|
|
16396
|
+
const seed = String(_conf?.gmail?.clientSecret || _conf?.domain || 'xuda');
|
|
16397
|
+
return crypto.createHash('sha256').update(`xuda_contact_form_v1:${seed}`).digest();
|
|
16398
|
+
};
|
|
16399
|
+
|
|
16400
|
+
export const mint_contact_form_token = function (widget_profile_id, ts = Date.now()) {
|
|
16401
|
+
const sig = crypto.createHmac('sha256', _contact_form_hmac_key()).update(`${widget_profile_id}.${ts}`).digest('hex').slice(0, 32);
|
|
16402
|
+
return `cft1.${ts}.${sig}`;
|
|
16403
|
+
};
|
|
16404
|
+
|
|
16405
|
+
const _verify_contact_form_token = function (widget_profile_id, token) {
|
|
16406
|
+
const parts = String(token || '').split('.');
|
|
16407
|
+
if (parts.length !== 3 || parts[0] !== 'cft1') return { ok: false, reason: 'bad_token' };
|
|
16408
|
+
const ts = Number(parts[1]);
|
|
16409
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: 'bad_token' };
|
|
16410
|
+
const expected = crypto.createHmac('sha256', _contact_form_hmac_key()).update(`${widget_profile_id}.${ts}`).digest('hex').slice(0, 32);
|
|
16411
|
+
let sig_ok = false;
|
|
16412
|
+
try {
|
|
16413
|
+
sig_ok = crypto.timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expected));
|
|
16414
|
+
} catch (err) {
|
|
16415
|
+
sig_ok = false;
|
|
16416
|
+
}
|
|
16417
|
+
if (!sig_ok) return { ok: false, reason: 'bad_token' };
|
|
16418
|
+
const age = Date.now() - ts;
|
|
16419
|
+
if (age > CONTACT_FORM_TOKEN_TTL_MS) return { ok: false, reason: 'token_expired' };
|
|
16420
|
+
if (age < CONTACT_FORM_MIN_AGE_MS) return { ok: false, reason: 'too_fast' };
|
|
16421
|
+
return { ok: true };
|
|
16422
|
+
};
|
|
16423
|
+
|
|
16424
|
+
const _sanitize_contact_form_config = function (config) {
|
|
16425
|
+
const out = {};
|
|
16426
|
+
if (!config || typeof config !== 'object') return out;
|
|
16427
|
+
const text = (v, max) => String(v).replace(/[<>]/g, '').slice(0, max);
|
|
16428
|
+
if (typeof config.title === 'string') out.title = text(config.title, 60);
|
|
16429
|
+
if (typeof config.intro === 'string') out.intro = text(config.intro, 300);
|
|
16430
|
+
if (typeof config.button_text === 'string') out.button_text = text(config.button_text, 40);
|
|
16431
|
+
if (typeof config.success_message === 'string') out.success_message = text(config.success_message, 300);
|
|
16432
|
+
if (typeof config.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(config.color.trim())) out.color = config.color.trim();
|
|
16433
|
+
if (config.fields && typeof config.fields === 'object') {
|
|
16434
|
+
out.fields = {};
|
|
16435
|
+
for (const key of CONTACT_FORM_FIELD_KEYS) {
|
|
16436
|
+
const f = config.fields[key];
|
|
16437
|
+
if (f && typeof f === 'object') out.fields[key] = { show: f.show === true, required: f.required === true };
|
|
16438
|
+
}
|
|
16439
|
+
}
|
|
16440
|
+
if (typeof config.notify_email === 'boolean') out.notify_email = config.notify_email;
|
|
16441
|
+
if (typeof config.auto_respond === 'boolean') out.auto_respond = config.auto_respond;
|
|
16442
|
+
return out;
|
|
16443
|
+
};
|
|
16444
|
+
|
|
16445
|
+
const _build_contact_form_snippet = function (widget_profile_id, config = {}) {
|
|
16446
|
+
const origin = embed_origin();
|
|
16447
|
+
const attrs = [`s.setAttribute('data-profile','${widget_profile_id}');`];
|
|
16448
|
+
if (config.color && /^#[0-9a-fA-F]{3,8}$/.test(config.color)) attrs.push(`s.setAttribute('data-color','${config.color}');`);
|
|
16449
|
+
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>`;
|
|
16450
|
+
};
|
|
16451
|
+
|
|
16452
|
+
export const get_contact_form_settings = async function (req) {
|
|
16453
|
+
try {
|
|
16454
|
+
const { uid, profile_id } = req;
|
|
16455
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16456
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16457
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16458
|
+
const widget_profile_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16459
|
+
const config = _sanitize_contact_form_config(doc.contact_form_config);
|
|
16460
|
+
return {
|
|
16461
|
+
code: 1,
|
|
16462
|
+
data: {
|
|
16463
|
+
enabled: doc.contact_form_enabled === true,
|
|
16464
|
+
config,
|
|
16465
|
+
widget_profile_id,
|
|
16466
|
+
iframe_url: `${embed_origin()}/contact-form/${widget_profile_id}`,
|
|
16467
|
+
snippet: _build_contact_form_snippet(widget_profile_id, config),
|
|
16468
|
+
// Inline mount hint for the settings tab to display next to the snippet.
|
|
16469
|
+
container_snippet: '<div data-xuda-contact-form></div>',
|
|
16470
|
+
},
|
|
16471
|
+
};
|
|
16472
|
+
} catch (err) {
|
|
16473
|
+
return { code: -1, data: err.message || String(err) };
|
|
16474
|
+
}
|
|
16475
|
+
};
|
|
16476
|
+
|
|
16477
|
+
export const update_contact_form_settings = async function (req) {
|
|
16478
|
+
try {
|
|
16479
|
+
const { uid, profile_id, enabled, config } = req;
|
|
16480
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16481
|
+
if (account_profile_info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
|
|
16482
|
+
|
|
16483
|
+
const doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, account_profile_info.account_profile_id);
|
|
16484
|
+
if (doc.docType !== 'account_profile') return { code: -1, data: 'not_account_profile' };
|
|
16485
|
+
|
|
16486
|
+
if (typeof enabled === 'boolean') doc.contact_form_enabled = enabled;
|
|
16487
|
+
if (config && typeof config === 'object') {
|
|
16488
|
+
const clean = _sanitize_contact_form_config(config);
|
|
16489
|
+
doc.contact_form_config = { ...(doc.contact_form_config || {}), ...clean };
|
|
16490
|
+
for (const key of ['title', 'intro', 'button_text', 'success_message', 'color']) {
|
|
16491
|
+
if (config[key] === '') delete doc.contact_form_config[key];
|
|
16492
|
+
}
|
|
16493
|
+
}
|
|
16494
|
+
doc.ts = Date.now();
|
|
16495
|
+
await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
|
|
16496
|
+
|
|
16497
|
+
return await get_contact_form_settings({ uid, profile_id });
|
|
16498
|
+
} catch (err) {
|
|
16499
|
+
return { code: -1, data: err.message || String(err) };
|
|
16500
|
+
}
|
|
16501
|
+
};
|
|
16502
|
+
|
|
16503
|
+
// Router-side bootstrap for rendering the form iframe: resolves the profile,
|
|
16504
|
+
// enforces the enabled gate and returns display config plus a fresh token.
|
|
16505
|
+
// Broker-only (not in cpi_methods); the router calls it via ai_ms.
|
|
16506
|
+
export const get_contact_form_bootstrap = async function (req) {
|
|
16507
|
+
try {
|
|
16508
|
+
const { widget_profile_id } = req;
|
|
16509
|
+
const [owner_uid, pid] = String(widget_profile_id || '').split('.');
|
|
16510
|
+
if (!owner_uid || !pid) return { code: -404, data: 'invalid_profile' };
|
|
16511
|
+
|
|
16512
|
+
const account_profile_info = await get_active_account_profile_info(owner_uid, pid);
|
|
16513
|
+
const doc = account_profile_info.account_profile_obj || {};
|
|
16514
|
+
if (doc.contact_form_enabled !== true) return { code: -404, data: 'contact_form_disabled' };
|
|
16515
|
+
|
|
16516
|
+
let avatar_url = '';
|
|
16517
|
+
let owner_name = '';
|
|
16518
|
+
try {
|
|
16519
|
+
const acct_name_ret = await account_ms.get_account_name({ uid_query: account_profile_info.uid });
|
|
16520
|
+
avatar_url = acct_name_ret?.data?.profile_picture || acct_name_ret?.data?.profile_avatar || doc.profile_picture || doc.profile_avatar || '';
|
|
16521
|
+
owner_name = acct_name_ret?.data?.full_name || '';
|
|
16522
|
+
} catch (err) {}
|
|
16523
|
+
|
|
16524
|
+
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16525
|
+
return {
|
|
16526
|
+
code: 1,
|
|
16527
|
+
data: {
|
|
16528
|
+
widget_profile_id: canonical_id,
|
|
16529
|
+
config: _sanitize_contact_form_config(doc.contact_form_config),
|
|
16530
|
+
profile_name: doc.profile_name || owner_name || '',
|
|
16531
|
+
avatar_url,
|
|
16532
|
+
token: mint_contact_form_token(canonical_id),
|
|
16533
|
+
},
|
|
16534
|
+
};
|
|
16535
|
+
} catch (err) {
|
|
16536
|
+
return { code: -404, data: err.message || String(err) };
|
|
16537
|
+
}
|
|
16538
|
+
};
|
|
16539
|
+
|
|
16540
|
+
// In-memory submit throttles. Per-process is fine: the goal is stopping dumb
|
|
16541
|
+
// bot floods, not building an accounting-grade limiter.
|
|
16542
|
+
const _contact_form_hits = { ip: {}, profile: {} };
|
|
16543
|
+
const _contact_form_allow = function (bucket, key, max, window_ms) {
|
|
16544
|
+
const now = Date.now();
|
|
16545
|
+
const store = _contact_form_hits[bucket];
|
|
16546
|
+
store[key] = (store[key] || []).filter((t) => now - t < window_ms);
|
|
16547
|
+
if (store[key].length >= max) return false;
|
|
16548
|
+
store[key].push(now);
|
|
16549
|
+
if (Object.keys(store).length > 5000) {
|
|
16550
|
+
for (const k of Object.keys(store)) {
|
|
16551
|
+
if (!store[k].length || now - store[k][store[k].length - 1] > window_ms) delete store[k];
|
|
16552
|
+
}
|
|
16553
|
+
}
|
|
16554
|
+
return true;
|
|
16555
|
+
};
|
|
16556
|
+
|
|
16557
|
+
// Open a 'ticket' chat_conversation on the owner's app for a form submission.
|
|
16558
|
+
// Every submission opens a fresh ticket (real ticket semantics), carrying the
|
|
16559
|
+
// message as the first inbound conversation item.
|
|
16560
|
+
const _contact_form_create_ticket = async function (account_profile_info, contact_id, subject, message, origin) {
|
|
16561
|
+
const owner_uid = account_profile_info.uid;
|
|
16562
|
+
const profile_id = account_profile_info.account_profile_id;
|
|
16563
|
+
|
|
16564
|
+
let conversation_obj = null;
|
|
16565
|
+
try {
|
|
16566
|
+
conversation_obj = await create_openai_conversation();
|
|
16567
|
+
} catch (err) {}
|
|
16568
|
+
|
|
16569
|
+
const d = Date.now();
|
|
16570
|
+
const conversation_doc = {
|
|
16571
|
+
_id: await _common.xuda_get_uuid('chat_conversation'),
|
|
16572
|
+
docType: 'chat_conversation',
|
|
16573
|
+
title: subject || getFirstNWords(message, 10),
|
|
16574
|
+
subject: subject || '',
|
|
16575
|
+
date_created_ts: d,
|
|
16576
|
+
ts: d,
|
|
16577
|
+
stat: 3,
|
|
16578
|
+
uid: owner_uid,
|
|
16579
|
+
messages: [],
|
|
16580
|
+
reference_type: 'contacts',
|
|
16581
|
+
reference_id: contact_id,
|
|
16582
|
+
conversation_obj,
|
|
16583
|
+
conversation_type: 'ticket',
|
|
16584
|
+
conversation_source: 'contact_form',
|
|
16585
|
+
contact_form_origin: origin || '',
|
|
16586
|
+
initiator_uid: owner_uid,
|
|
16587
|
+
direction: 'in',
|
|
16588
|
+
reference_conversation_id: conversation_obj?.id,
|
|
16589
|
+
account_profiles: [profile_id],
|
|
16590
|
+
prompt: message,
|
|
16591
|
+
rtl: _common.detectRTL(message),
|
|
16592
|
+
process_stat: 'full',
|
|
16593
|
+
};
|
|
16594
|
+
const conv_save = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
|
|
16595
|
+
if (conv_save.code < 0) throw new Error(conv_save.data || 'ticket_create_failed');
|
|
16596
|
+
|
|
16597
|
+
let conversation_item_reference_id;
|
|
16598
|
+
try {
|
|
16599
|
+
conversation_item_reference_id = await add_conversation_item(owner_uid, profile_id, conversation_doc._id, message, 'ticket', contact_id, { direction: 'in' });
|
|
16600
|
+
} catch (err) {}
|
|
16601
|
+
|
|
16602
|
+
const item_doc = {
|
|
16603
|
+
_id: await _common.xuda_get_uuid('chat_conversation_item'),
|
|
16604
|
+
stat: 3,
|
|
16605
|
+
docType: 'chat_conversation_item',
|
|
16606
|
+
uid: owner_uid,
|
|
16607
|
+
conversation_type: 'ticket',
|
|
16608
|
+
type: 'ticket',
|
|
16609
|
+
date_created_ts: d,
|
|
16610
|
+
ts: d,
|
|
16611
|
+
conversation_id: conversation_doc._id,
|
|
16612
|
+
text: message,
|
|
16613
|
+
subject: subject || '',
|
|
16614
|
+
reference_id: contact_id,
|
|
16615
|
+
conversation_item_reference_id,
|
|
16616
|
+
direction: 'in',
|
|
16617
|
+
role: 'user',
|
|
16618
|
+
contact_form_source: true,
|
|
16619
|
+
rtl: _common.detectRTL(message),
|
|
16620
|
+
};
|
|
16621
|
+
await db_module.save_app_couch_doc(account_profile_info.app_id, item_doc);
|
|
16622
|
+
|
|
16623
|
+
return conversation_doc;
|
|
16624
|
+
};
|
|
16625
|
+
|
|
16626
|
+
// Public endpoint behind the embedded form. Validates the human-detection
|
|
16627
|
+
// signals, adds the sender as a contact (not a friend) and opens the ticket.
|
|
16628
|
+
export const contact_form_submit = async function (req, job_id, headers) {
|
|
16629
|
+
try {
|
|
16630
|
+
const { profile_id, token, website, origin, visitor_lang } = req;
|
|
16631
|
+
let { name, email, subject, phone, company, message } = req;
|
|
16632
|
+
|
|
16633
|
+
// Honeypot: the hidden 'website' input must stay empty. Bots fill it;
|
|
16634
|
+
// answer with a generic ok so they learn nothing.
|
|
16635
|
+
if (typeof website === 'string' && website.trim() !== '') return { code: 1, data: { ok: true } };
|
|
16636
|
+
|
|
16637
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof message !== 'string') return { code: -1, data: 'invalid_input' };
|
|
16638
|
+
name = name.trim().slice(0, 120);
|
|
16639
|
+
email = email.toLowerCase().trim();
|
|
16640
|
+
message = message.trim().slice(0, 5000);
|
|
16641
|
+
subject = typeof subject === 'string' ? subject.trim().slice(0, 200) : '';
|
|
16642
|
+
phone = typeof phone === 'string' ? phone.trim().slice(0, 40) : '';
|
|
16643
|
+
company = typeof company === 'string' ? company.trim().slice(0, 120) : '';
|
|
16644
|
+
if (!name || !email || !message) return { code: -1, data: 'invalid_input' };
|
|
16645
|
+
if (!WIDGET_EMAIL_RE.test(email)) return { code: -1, data: 'invalid_email' };
|
|
16646
|
+
|
|
16647
|
+
const [owner_uid_part, pid_part] = String(profile_id || '').split('.');
|
|
16648
|
+
if (!owner_uid_part || !pid_part) return { code: -404, data: 'invalid_profile' };
|
|
16649
|
+
|
|
16650
|
+
const account_profile_info = await get_active_account_profile_info(owner_uid_part, pid_part);
|
|
16651
|
+
const ap_doc = account_profile_info.account_profile_obj || {};
|
|
16652
|
+
if (ap_doc.contact_form_enabled !== true) return { code: -404, data: 'contact_form_disabled' };
|
|
16653
|
+
|
|
16654
|
+
const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
|
|
16655
|
+
const token_check = _verify_contact_form_token(canonical_id, token);
|
|
16656
|
+
if (!token_check.ok) return { code: -401, data: token_check.reason };
|
|
16657
|
+
|
|
16658
|
+
const client_info = extractClientInfo(headers) || {};
|
|
16659
|
+
const client_ip = client_info.ipAddress || 'unknown';
|
|
16660
|
+
if (!_contact_form_allow('ip', client_ip, 5, 10 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16661
|
+
if (!_contact_form_allow('profile', canonical_id, 200, 24 * 60 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
|
|
16662
|
+
|
|
16663
|
+
const owner_uid = account_profile_info.uid;
|
|
16664
|
+
const config = _sanitize_contact_form_config(ap_doc.contact_form_config);
|
|
16665
|
+
|
|
16666
|
+
// Field requirements set by the owner are enforced server side too.
|
|
16667
|
+
for (const key of CONTACT_FORM_FIELD_KEYS) {
|
|
16668
|
+
if (config.fields?.[key]?.show && config.fields?.[key]?.required) {
|
|
16669
|
+
const val = key === 'phone' ? phone : key === 'company' ? company : subject;
|
|
16670
|
+
if (!val) return { code: -1, data: `missing_${key}` };
|
|
16671
|
+
}
|
|
16672
|
+
}
|
|
16673
|
+
|
|
16674
|
+
// All human-detection gates passed: ack the visitor immediately and run
|
|
16675
|
+
// the pipeline (contact enrichment, ticket, notification, auto-reply) in
|
|
16676
|
+
// the background. add_contact runs several AI enrichment steps and would
|
|
16677
|
+
// otherwise hold the visitor's spinner for many seconds.
|
|
16678
|
+
(async () => {
|
|
16679
|
+
try {
|
|
16680
|
+
// Contact, not friend: no contact_connection team_request is created.
|
|
16681
|
+
const add_ret = await account_ms.add_contact({
|
|
16682
|
+
uid: owner_uid,
|
|
16683
|
+
profile_id: account_profile_info.account_profile_id,
|
|
16684
|
+
name,
|
|
16685
|
+
email,
|
|
16686
|
+
source: 'contact_form',
|
|
16687
|
+
context: `Contact form submission${origin ? ` from ${String(origin).slice(0, 200)}` : ''}`,
|
|
16688
|
+
metadata: {
|
|
16689
|
+
subject,
|
|
16690
|
+
phone,
|
|
16691
|
+
company,
|
|
16692
|
+
origin: String(origin || '').slice(0, 200),
|
|
16693
|
+
visitor_lang: String(visitor_lang || '').slice(0, 16),
|
|
16694
|
+
contact_form: true,
|
|
16695
|
+
not_junk: true,
|
|
16696
|
+
},
|
|
16697
|
+
});
|
|
16698
|
+
const contact_id = add_ret.code > -1 ? add_ret.data?.id : add_ret.contact_id;
|
|
16699
|
+
if (!contact_id) throw new Error(add_ret.data || 'contact_create_failed');
|
|
16700
|
+
|
|
16701
|
+
const body_parts = [message];
|
|
16702
|
+
const detail_bits = [phone && `Phone: ${phone}`, company && `Company: ${company}`].filter(Boolean);
|
|
16703
|
+
if (detail_bits.length) body_parts.push(detail_bits.join(' | '));
|
|
16704
|
+
await _contact_form_create_ticket(account_profile_info, contact_id, subject, body_parts.join('\n\n'), origin);
|
|
16705
|
+
|
|
16706
|
+
// Owner notification email (platform template).
|
|
16707
|
+
if (config.notify_email !== false) {
|
|
16708
|
+
try {
|
|
16709
|
+
const owner_acct = await db_module.get_couch_doc('xuda_accounts', owner_uid);
|
|
16710
|
+
const owner_email = owner_acct?.data?.account_info?.email || owner_acct?.data?.email;
|
|
16711
|
+
if (owner_email) {
|
|
16712
|
+
const esc = (v) => String(v || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
16713
|
+
const dashboard_url = `${embed_origin()}/dashboard`;
|
|
16714
|
+
const body = `<h2 style="margin:0 0 12px">New contact form message</h2>
|
|
16715
|
+
<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>
|
|
16716
|
+
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;font-size:14px">
|
|
16717
|
+
<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>
|
|
16718
|
+
${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>` : ''}
|
|
16719
|
+
${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>` : ''}
|
|
16720
|
+
${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>` : ''}
|
|
16721
|
+
</table>
|
|
16722
|
+
<div style="background:#f8fafc;border:1px solid #e6ebf1;border-radius:10px;padding:14px 16px;margin:14px 0;white-space:pre-wrap">${esc(message)}</div>
|
|
16723
|
+
<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>`;
|
|
16724
|
+
await email_ms.send_email({ email: owner_email, subject: `New contact form message from ${name}`, body, display_type: 'info' });
|
|
16725
|
+
}
|
|
16726
|
+
} catch (err) {
|
|
16727
|
+
console.error('[contact_form] owner notification failed', err?.message || err);
|
|
16728
|
+
}
|
|
16729
|
+
}
|
|
16730
|
+
|
|
16731
|
+
// Optional AI auto-reply, reusing the profile's auto-respond setup.
|
|
16732
|
+
if (config.auto_respond !== false && ap_doc.auto_respond === true) {
|
|
16733
|
+
auto_response(owner_uid, account_profile_info.account_profile_id, contact_id, 'ticket');
|
|
16734
|
+
}
|
|
16735
|
+
} catch (err) {
|
|
16736
|
+
console.error('[contact_form_submit] background pipeline failed', err?.message || err);
|
|
16737
|
+
}
|
|
16738
|
+
})();
|
|
16739
|
+
|
|
16740
|
+
return { code: 1, data: { ok: true } };
|
|
16741
|
+
} catch (err) {
|
|
16742
|
+
return { code: -1, data: err.message || String(err) };
|
|
16743
|
+
}
|
|
16744
|
+
};
|
|
16745
|
+
|
|
16746
|
+
// Owner-side reply to a 'ticket' conversation (submit_chat_conversation case).
|
|
16747
|
+
// Appends the outbound item and emails the contact: through the profile's
|
|
16748
|
+
// connected mailbox when one exists, otherwise through the platform mail
|
|
16749
|
+
// server with Reply-To pointing at the owner. The body is wrapped with the
|
|
16750
|
+
// profile's selected email template (Email Template tab) unless it is plain.
|
|
16751
|
+
const contact_ticket_conversation = async function (req, job_id, headers) {
|
|
16752
|
+
const { uid, profile_id, _auto_response = false } = req;
|
|
16753
|
+
const account_profile_info = await get_active_account_profile_info(uid, profile_id);
|
|
16754
|
+
let { prompt: body, conversation_doc } = req;
|
|
16755
|
+
try {
|
|
16756
|
+
const conversation_id = conversation_doc._id;
|
|
16757
|
+
const sender_app_id = account_profile_info.app_id;
|
|
16758
|
+
const contact_id = conversation_doc.reference_id;
|
|
16759
|
+
const contact_info = await get_contact_info(uid, null, contact_id);
|
|
16760
|
+
if (!contact_info?.email) throw new Error('empty contact email');
|
|
16761
|
+
|
|
16762
|
+
const subject_base = conversation_doc.subject || conversation_doc.title;
|
|
16763
|
+
const subject = subject_base ? `Re: ${subject_base}` : 'Re: your message';
|
|
16764
|
+
|
|
16765
|
+
const profile_doc = account_profile_info.account_profile_obj || {};
|
|
16766
|
+
let html = null;
|
|
16767
|
+
try {
|
|
16768
|
+
const render_ret = await email_ms.render_profile_email({
|
|
16769
|
+
style: profile_doc.email_template?.style,
|
|
16770
|
+
body_text: body,
|
|
16771
|
+
profile_name: profile_doc.profile_name,
|
|
16772
|
+
signature: profile_doc.profile_signature,
|
|
16773
|
+
avatar_url: profile_doc.profile_picture || profile_doc.profile_avatar || '',
|
|
16774
|
+
brand_color: profile_doc.widget_config?.color || profile_doc.contact_form_config?.color || '',
|
|
16775
|
+
});
|
|
16776
|
+
if (render_ret?.code > 0 && render_ret.data) html = render_ret.data;
|
|
16777
|
+
} catch (err) {}
|
|
16778
|
+
|
|
16779
|
+
let sent = false;
|
|
16780
|
+
if (profile_doc.email_account_id) {
|
|
16781
|
+
try {
|
|
16782
|
+
const email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, profile_doc.email_account_id);
|
|
16783
|
+
const send_ret = await email_ms.sendEmailFromAccount(email_account_doc, contact_info.email, subject, body, html);
|
|
16784
|
+
sent = !!send_ret?.success;
|
|
16785
|
+
} catch (err) {
|
|
16786
|
+
console.error('[ticket reply] profile mailbox send failed, falling back to platform mail', err?.message || err);
|
|
16787
|
+
}
|
|
16788
|
+
}
|
|
16789
|
+
if (!sent) {
|
|
16790
|
+
const owner_acct = await db_module.get_couch_doc('xuda_accounts', uid);
|
|
16791
|
+
const owner_email = owner_acct?.data?.account_info?.email || owner_acct?.data?.email || '';
|
|
16792
|
+
const send_ret = await email_ms.send_profile_email({
|
|
16793
|
+
to: contact_info.email,
|
|
16794
|
+
subject,
|
|
16795
|
+
body_text: body,
|
|
16796
|
+
html,
|
|
16797
|
+
from_name: profile_doc.profile_name || owner_acct?.data?.account_info?.full_name || 'Xuda user',
|
|
16798
|
+
reply_to: owner_email,
|
|
16799
|
+
});
|
|
16800
|
+
sent = !!send_ret?.success || send_ret?.code > 0;
|
|
16801
|
+
if (!sent) throw new Error('error sending ticket reply email');
|
|
16802
|
+
}
|
|
16803
|
+
|
|
16804
|
+
let conversation_item_reference_id;
|
|
16805
|
+
try {
|
|
16806
|
+
conversation_item_reference_id = await add_conversation_item(uid, profile_id, conversation_id, body, 'ticket', contact_id, { direction: 'out' });
|
|
16807
|
+
} catch (err) {}
|
|
16808
|
+
|
|
16809
|
+
const now = Date.now();
|
|
16810
|
+
const out_conversation_item_obj = {
|
|
16811
|
+
_id: await _common.xuda_get_uuid('chat_conversation_item'),
|
|
16812
|
+
stat: 3,
|
|
16813
|
+
docType: 'chat_conversation_item',
|
|
16814
|
+
uid,
|
|
16815
|
+
conversation_type: 'ticket',
|
|
16816
|
+
type: 'ticket',
|
|
16817
|
+
date_created_ts: now,
|
|
16818
|
+
ts: now,
|
|
16819
|
+
conversation_id,
|
|
16820
|
+
text: body,
|
|
16821
|
+
subject,
|
|
16822
|
+
reference_id: contact_id,
|
|
16823
|
+
conversation_item_reference_id,
|
|
16824
|
+
direction: 'out',
|
|
16825
|
+
role: 'user',
|
|
16826
|
+
auto_response: _auto_response || undefined,
|
|
16827
|
+
rtl: _common.detectRTL(body),
|
|
16828
|
+
};
|
|
16829
|
+
const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
|
|
16830
|
+
|
|
16831
|
+
const fresh_conv = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
|
|
16832
|
+
fresh_conv.ts = Date.now();
|
|
16833
|
+
await db_module.save_app_couch_doc_native(sender_app_id, fresh_conv);
|
|
16834
|
+
|
|
16835
|
+
return save_ret;
|
|
16836
|
+
} catch (err) {
|
|
16837
|
+
return { code: -15, data: err.message };
|
|
16838
|
+
}
|
|
16839
|
+
};
|
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
|
+
};
|