@xuda.io/ai_module 1.1.5641 → 1.1.5642

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.
Files changed (2) hide show
  1. package/index.mjs +181 -2
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -337,6 +337,8 @@ const drive_ms = await import(`${module_path}/drive_module/index_ms.mjs`);
337
337
  const jobs_ms = await import(`${module_path}/jobs_module/index_ms.mjs`);
338
338
  const team_ms = await import(`${module_path}/team_module/index_ms.mjs`);
339
339
  const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
340
+ // Profile broadcasts send SMS through the same primitive Phone Manager uses.
341
+ const voice_ms = await import(`${module_path}/voice_module/index_ms.mjs`);
340
342
  const api_ms = await import(`${module_path}/api_module/index_ms.mjs`);
341
343
  const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
342
344
 
@@ -5272,13 +5274,31 @@ const _tl_voice_rows = async function (uid, account_profile_info, contact_id, us
5272
5274
  privilege: true,
5273
5275
  });
5274
5276
 
5277
+ // What the call was actually about, so the row says something beyond its duration.
5278
+ // The caller's LONGEST turn, because the opening turns are "Hello?" and whatever noise
5279
+ // the speech recogniser picked up before the greeting, while the request itself is the
5280
+ // one sentence they actually spoke at length.
5281
+ const call_gist = (d) => {
5282
+ const turns = Array.isArray(d.transcript) ? d.transcript : [];
5283
+ const said = turns
5284
+ .filter((t) => t.role === 'caller' || t.role === 'user')
5285
+ .map((t) => String(t.text || '').replace(/\s+/g, ' ').trim())
5286
+ .filter(Boolean);
5287
+ const text = said.sort((a, b) => b.length - a.length)[0] || '';
5288
+ return text.length > 140 ? `${text.slice(0, 139)}…` : text;
5289
+ };
5290
+ // Stored as an object ({ type, id }) by the realtime path but sent as a plain string on
5291
+ // the socket event, and the UI compares it to 'ai'. Normalize to the string form here so
5292
+ // a row reads the same however the call was recorded.
5293
+ const handled_kind = (d) => (typeof d.handled_by === 'object' && d.handled_by ? d.handled_by.type || '' : d.handled_by || '');
5275
5294
  const call_rows = (calls?.docs || []).filter((d) => d.status !== 'ringing' && mine(d)).map((d) =>
5276
5295
  row('call', d, d.direction === 'inbound' ? 'Incoming call' : 'Outgoing call', {
5277
5296
  status: d.status,
5278
5297
  duration_sec: d.duration_sec || 0,
5279
5298
  has_recording: !!d.recording,
5280
5299
  transcript_turns: (d.transcript || []).length,
5281
- handled_by: d.handled_by || '',
5300
+ transcript_preview: call_gist(d),
5301
+ handled_by: handled_kind(d),
5282
5302
  }),
5283
5303
  );
5284
5304
  const sms_rows = (messages?.docs || []).filter(mine).map((d) => row('sms', d, d.body || '', { status: d.status, segments: d.segments || 1 }));
@@ -5403,7 +5423,23 @@ export const submit_chat_conversation = async function (req, job_id, headers) {
5403
5423
  }
5404
5424
 
5405
5425
  case 'account_profiles': {
5406
- ret = await ai_chat_conversation(req, job_id, headers);
5426
+ // This used to call ai_chat_conversation unconditionally, so Note, Chat, SMS and
5427
+ // Email on a profile all silently became AI chats. A profile thread is a group
5428
+ // thread: each mode records one item here, and the broadcast modes also deliver
5429
+ // to every member. See profile_thread_post.
5430
+ switch (conversation_doc.conversation_type) {
5431
+ case 'note':
5432
+ case 'chat':
5433
+ case 'sms':
5434
+ case 'email': {
5435
+ ret = await profile_thread_post(req, job_id, headers, conversation_doc.conversation_type);
5436
+ break;
5437
+ }
5438
+ default: {
5439
+ ret = await ai_chat_conversation(req, job_id, headers);
5440
+ break;
5441
+ }
5442
+ }
5407
5443
  break;
5408
5444
  }
5409
5445
 
@@ -5575,6 +5611,149 @@ const chat_note = async function (req, job_id, headers) {
5575
5611
  }
5576
5612
  };
5577
5613
 
5614
+ // ── Account-profile group thread ────────────────────────────────────────────────────────
5615
+ // A profile conversation is ONE group thread, not a set of per-member threads (Boaz,
5616
+ // 2026-07-31: "only on the profile like group chat"). Every mode records a single item on
5617
+ // the profile conversation. SMS and email additionally deliver that text to each member,
5618
+ // but those sends are a delivery detail: nothing is written to a member's own contact
5619
+ // timeline, and inbound replies belong back on this same profile conversation.
5620
+
5621
+ // Resolve the profile's members to one delivery address each, then send per member.
5622
+ // Returns a per-recipient outcome rather than a bare boolean, because a broadcast that
5623
+ // only half-succeeded must not be recorded as if it had reached everyone.
5624
+ const profile_broadcast_recipients = async function (owner_uid, profile_id, kind) {
5625
+ const member_uids = await account_ms.get_account_profile_group_member_uids(owner_uid, profile_id);
5626
+ // The owner is not a member of their own profile and must not receive their own broadcast.
5627
+ const uids = [...new Set((member_uids || []).filter((u) => u && u !== owner_uid))];
5628
+ const out = [];
5629
+ for (const member_uid of uids) {
5630
+ let doc = null;
5631
+ try {
5632
+ doc = await db_module.get_couch_doc_native('xuda_accounts', member_uid);
5633
+ } catch (_) {
5634
+ doc = null;
5635
+ }
5636
+ const info = doc?.account_info || {};
5637
+ const name = [info.name, info.last_name].filter(Boolean).join(' ') || info.email || member_uid;
5638
+ const address = kind === 'sms' ? info.phone_number : info.email;
5639
+ out.push({ uid: member_uid, name, address: address || null });
5640
+ }
5641
+ return out;
5642
+ };
5643
+
5644
+ const profile_broadcast = async function (uid, account_profile_info, profile_id, kind, body) {
5645
+ const recipients = await profile_broadcast_recipients(uid, profile_id, kind);
5646
+ const results = [];
5647
+
5648
+ // One SMS-capable number for the whole broadcast, resolved once. Without a number there
5649
+ // is nothing to send from, and that is a configuration error worth surfacing rather than
5650
+ // reporting every member as individually unreachable.
5651
+ let voice_number_id = null;
5652
+ if (kind === 'sms') {
5653
+ const numbers = await voice_ms.list_voice_numbers({ uid, profile_id });
5654
+ const usable = (numbers?.data || []).find((n) => n.capabilities?.SMS === true || n.capabilities?.sms === true);
5655
+ if (!usable) throw new Error('This profile has no phone number that can send SMS');
5656
+ voice_number_id = usable.id;
5657
+ }
5658
+
5659
+ const profile_name = account_profile_info.account_profile_obj?.profile_name || 'your team';
5660
+ for (const recipient of recipients) {
5661
+ if (!recipient.address) {
5662
+ results.push({ ...recipient, ok: false, error: kind === 'sms' ? 'no phone number on file' : 'no email address on file' });
5663
+ continue;
5664
+ }
5665
+ try {
5666
+ if (kind === 'sms') {
5667
+ const ret = await voice_ms.send_voice_message({ uid, profile_id, voice_number_id, to: recipient.address, body });
5668
+ if (ret?.code < 0) throw new Error(ret.data || 'send failed');
5669
+ } else {
5670
+ await email_ms.send_email({
5671
+ email: recipient.address,
5672
+ subject: `Message from ${profile_name}`,
5673
+ body,
5674
+ display_type: 'info',
5675
+ app_id: account_profile_info.app_id,
5676
+ });
5677
+ }
5678
+ results.push({ ...recipient, ok: true });
5679
+ } catch (err) {
5680
+ results.push({ ...recipient, ok: false, error: err.message });
5681
+ }
5682
+ }
5683
+
5684
+ return { results, sent: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length };
5685
+ };
5686
+
5687
+ // `kind` is the composer mode: note | chat | sms | email. AI chat does not come through
5688
+ // here, it keeps going to ai_chat_conversation.
5689
+ const profile_thread_post = async function (req, job_id, headers, kind) {
5690
+ const { uid, profile_id } = req;
5691
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
5692
+ const { prompt: body, conversation_doc, attachments = [] } = req;
5693
+ try {
5694
+ const conversation_id = conversation_doc._id;
5695
+ const app_id = account_profile_info.app_id;
5696
+ const text = String(body || '').trim();
5697
+ if (!text) throw new Error('A message body is required');
5698
+
5699
+ await attachment_handler(uid, app_id, conversation_id, attachments, account_profile_info);
5700
+
5701
+ // Deliver BEFORE recording. A thread item is a claim that the message went out, so a
5702
+ // broadcast that reached nobody has to fail loudly instead of leaving a row that reads
5703
+ // as sent. A partial success is recorded, with the per-recipient detail on the item.
5704
+ let delivery = null;
5705
+ if (kind === 'sms' || kind === 'email') {
5706
+ delivery = await profile_broadcast(uid, account_profile_info, conversation_doc.reference_id, kind, text);
5707
+ if (!delivery.sent) {
5708
+ const why = delivery.results.find((r) => r.error)?.error;
5709
+ throw new Error(delivery.failed ? `Could not deliver to any member (${why})` : 'This profile has no members to send to');
5710
+ }
5711
+ }
5712
+
5713
+ let conversation_item_reference_id;
5714
+ try {
5715
+ conversation_item_reference_id = await add_conversation_item(uid, profile_id, conversation_id, text, kind, conversation_doc.reference_id, {});
5716
+ report_ai_status('conversations');
5717
+ } catch (err) {
5718
+ report_ai_status('conversations', err);
5719
+ throw err;
5720
+ }
5721
+
5722
+ const out_conversation_item_obj = {
5723
+ _id: await _common.xuda_get_uuid('chat_conversation_item'),
5724
+ stat: 3,
5725
+ docType: 'chat_conversation_item',
5726
+ uid,
5727
+ conversation_type: kind,
5728
+ type: kind,
5729
+ date_created_ts: Date.now(),
5730
+ ts: Date.now(),
5731
+ conversation_id,
5732
+ text,
5733
+ reference_id: conversation_doc.reference_id,
5734
+ conversation_item_reference_id,
5735
+ direction: 'out',
5736
+ role: 'user',
5737
+ read: { [uid]: Date.now() },
5738
+ rtl: _common.detectRTL(text),
5739
+ ...(delivery
5740
+ ? {
5741
+ broadcast: {
5742
+ channel: kind,
5743
+ sent: delivery.sent,
5744
+ failed: delivery.failed,
5745
+ recipients: delivery.results.map((r) => ({ uid: r.uid, name: r.name, ok: r.ok, ...(r.error ? { error: r.error } : {}) })),
5746
+ },
5747
+ }
5748
+ : {}),
5749
+ };
5750
+
5751
+ return await db_module.save_app_couch_doc(app_id, out_conversation_item_obj);
5752
+ } catch (err) {
5753
+ return { code: -15, data: err.message };
5754
+ }
5755
+ };
5756
+
5578
5757
  const chat_email = async function (req, job_id, headers) {
5579
5758
  const { profile_id, uid, email_id, perform_ai_execution = true, from_mailbox, _thread_reentry, direction } = req;
5580
5759
  const account_profile_info = await get_active_account_profile_info(uid, profile_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5641",
3
+ "version": "1.1.5642",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",