diff --git a/core/commands/engine.py b/core/commands/engine.py index c68bbf6..fb1ac8b 100644 --- a/core/commands/engine.py +++ b/core/commands/engine.py @@ -15,6 +15,22 @@ log = logs.get_logger("command_engine") _REGISTERED = False +def _channel_variants(service: str, channel_identifier: str) -> list[str]: + value = str(channel_identifier or "").strip() + if not value: + return [] + variants = [value] + svc = str(service or "").strip().lower() + if svc == "whatsapp": + bare = value.split("@", 1)[0].strip() + if bare and bare not in variants: + variants.append(bare) + group = f"{bare}@g.us" if bare else "" + if group and group not in variants: + variants.append(group) + return variants + + def ensure_handlers_registered(): global _REGISTERED if _REGISTERED: @@ -25,6 +41,9 @@ def ensure_handlers_registered(): async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]: def _load(): + direct_variants = _channel_variants(ctx.service, ctx.channel_identifier) + if not direct_variants: + return [] direct = list( CommandProfile.objects.filter( user_id=ctx.user_id, @@ -32,7 +51,7 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]: channel_bindings__enabled=True, channel_bindings__direction="ingress", channel_bindings__service=ctx.service, - channel_bindings__channel_identifier=ctx.channel_identifier, + channel_bindings__channel_identifier__in=direct_variants, ).distinct() ) if direct: @@ -49,7 +68,8 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]: identifier = getattr(getattr(trigger, "session", None), "identifier", None) fallback_service = str(getattr(identifier, "service", "") or "").strip().lower() fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip() - if not fallback_service or not fallback_identifier: + fallback_variants = _channel_variants(fallback_service, fallback_identifier) + if not fallback_service or not fallback_variants: return [] return list( CommandProfile.objects.filter( @@ -58,7 +78,7 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]: channel_bindings__enabled=True, channel_bindings__direction="ingress", channel_bindings__service=fallback_service, - channel_bindings__channel_identifier=fallback_identifier, + channel_bindings__channel_identifier__in=fallback_variants, ).distinct() ) diff --git a/core/commands/handlers/bp.py b/core/commands/handlers/bp.py index 60c6a04..7c5c37a 100644 --- a/core/commands/handlers/bp.py +++ b/core/commands/handlers/bp.py @@ -330,8 +330,17 @@ class BPCommandHandler(CommandHandler): ) fanout_stats = {"sent_bindings": 0, "failed_bindings": 0} + fanout_text = summary + if ai_warning: + warning_text = str(ai_warning or "").strip() + if len(warning_text) > 300: + warning_text = warning_text[:297].rstrip() + "..." + fanout_text = ( + "[bp] AI generation failed. Draft document was saved in fallback mode." + + (f"\nReason: {warning_text}" if warning_text else "") + ) if "post_result" in action_types: - fanout_stats = await self._fanout(run, summary) + fanout_stats = await self._fanout(run, fanout_text) if "status_in_source" == profile.visibility_mode: status_text = f"[bp] Generated business plan: {document.title}" diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 55ddd6f..ed4f4d1 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -3095,7 +3095,16 @@ } setCardLoading(card, true); try { - const response = await fetch(thread.dataset.draftsUrl + "?" + queryParams().toString(), { + const params = queryParams(); + try { + const current = new URL(window.location.href); + if (String(current.searchParams.get("nocache") || "").trim() === "1") { + params.set("nocache", "1"); + } + } catch (err) { + // Ignore URL parse errors. + } + const response = await fetch(thread.dataset.draftsUrl + "?" + params.toString(), { method: "GET", credentials: "same-origin", headers: { Accept: "application/json" } @@ -3109,6 +3118,12 @@ const drafts = Array.isArray(payload.drafts) ? payload.drafts : []; const container = card.querySelector(".compose-ai-content"); container.innerHTML = ""; + const sourceTag = document.createElement("p"); + sourceTag.className = "is-size-7 has-text-grey"; + const source = String(payload.source || "unknown"); + const cachedLabel = payload.cached ? "cache" : "live"; + sourceTag.textContent = "Source: " + source + " (" + cachedLabel + ")"; + container.appendChild(sourceTag); const engageButton = document.createElement("button"); engageButton.type = "button"; engageButton.className = "button is-link is-light compose-draft-option"; diff --git a/core/tests/test_phase1_command_reply.py b/core/tests/test_phase1_command_reply.py index fb1060d..58b2d6f 100644 --- a/core/tests/test_phase1_command_reply.py +++ b/core/tests/test_phase1_command_reply.py @@ -239,3 +239,36 @@ class Phase1CommandEngineTests(TestCase): ) ) self.assertEqual([], results) + + def test_eligible_profile_matches_whatsapp_group_identifier_variants(self): + self.profile.channel_bindings.all().delete() + CommandChannelBinding.objects.create( + profile=self.profile, + direction="ingress", + service="whatsapp", + channel_identifier="120363402761690215@g.us", + enabled=True, + ) + msg = Message.objects.create( + user=self.user, + session=self.session, + sender_uuid="", + text="#bp#", + ts=5000, + source_service="whatsapp", + source_chat_id="120363402761690215@g.us", + message_meta={}, + ) + results = async_to_sync(process_inbound_message)( + CommandContext( + service="whatsapp", + channel_identifier="120363402761690215", + message_id=str(msg.id), + user_id=self.user.id, + message_text="#bp#", + payload={}, + ) + ) + self.assertEqual(1, len(results)) + self.assertEqual("skipped", results[0].status) + self.assertEqual("reply_required", results[0].error) diff --git a/core/views/compose.py b/core/views/compose.py index 31abb45..8cb8dec 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -3346,9 +3346,16 @@ class ComposeDrafts(LoginRequiredMixin, View): { "ok": True, "cached": False, + "source": "fallback", "drafts": _fallback_drafts(), } ) + nocache = str(request.GET.get("nocache") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } last_ts = int(messages[-1].ts or 0) cache_key = _compose_ai_cache_key( @@ -3361,8 +3368,24 @@ class ComposeDrafts(LoginRequiredMixin, View): limit, ) cached = cache.get(cache_key) - if cached: - return JsonResponse({"ok": True, "cached": True, "drafts": cached}) + if cached and not nocache: + if isinstance(cached, dict): + return JsonResponse( + { + "ok": True, + "cached": True, + "source": str(cached.get("source") or "unknown"), + "drafts": list(cached.get("drafts") or []), + } + ) + return JsonResponse( + { + "ok": True, + "cached": True, + "source": "unknown", + "drafts": list(cached or []), + } + ) ai_obj = AI.objects.filter(user=request.user).first() transcript = messages_to_string( @@ -3373,6 +3396,7 @@ class ComposeDrafts(LoginRequiredMixin, View): }, ) drafts = _fallback_drafts() + source = "fallback" if ai_obj is not None: try: result = async_to_sync(ai_runner.run_prompt)( @@ -3386,11 +3410,18 @@ class ComposeDrafts(LoginRequiredMixin, View): parsed = _parse_draft_options(result) if parsed: drafts = parsed + source = "ai" except Exception: pass - cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL) - return JsonResponse({"ok": True, "cached": False, "drafts": drafts}) + cache.set( + cache_key, + {"source": source, "drafts": drafts}, + timeout=COMPOSE_AI_CACHE_TTL, + ) + return JsonResponse( + {"ok": True, "cached": False, "source": source, "drafts": drafts} + ) class ComposeSummary(LoginRequiredMixin, View):