Implement plans
This commit is contained in:
@@ -26,10 +26,12 @@ from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients import transport
|
||||
from core.assist.engine import process_inbound_assist
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.commands.policies import ensure_variant_policies_for_profile
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging import history
|
||||
from core.messaging import media_bridge
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
@@ -1792,6 +1794,86 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
|
||||
return payload
|
||||
|
||||
|
||||
def _parse_bool(value, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
raw = str(value).strip().lower()
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _reaction_actor_key(user_id, service: str) -> str:
|
||||
return f"web:{int(user_id)}:{str(service or '').strip().lower()}"
|
||||
|
||||
|
||||
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
|
||||
service_key = _default_service(service)
|
||||
source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
|
||||
sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
|
||||
source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
|
||||
delivered_ts = int(getattr(message, "delivered_ts", 0) or 0)
|
||||
local_ts = int(getattr(message, "ts", 0) or 0)
|
||||
|
||||
if service_key == "signal":
|
||||
target_ts = 0
|
||||
if source_message_id.isdigit():
|
||||
target_ts = int(source_message_id)
|
||||
if not target_ts:
|
||||
bridge_ref = _latest_signal_bridge_ref(message)
|
||||
upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||
if upstream_id.isdigit():
|
||||
target_ts = int(upstream_id)
|
||||
if not target_ts:
|
||||
target_ts = int(bridge_ref.get("upstream_ts") or 0)
|
||||
if not target_ts:
|
||||
target_ts = delivered_ts or local_ts
|
||||
if target_ts <= 0:
|
||||
return {"error": "signal_target_unresolvable"}
|
||||
|
||||
target_author = sender_uuid
|
||||
if not target_author:
|
||||
bridge_ref = _latest_signal_bridge_ref(message)
|
||||
target_author = str(bridge_ref.get("upstream_author") or "").strip()
|
||||
if (
|
||||
str(getattr(message, "custom_author", "") or "").strip().upper()
|
||||
in {"USER", "BOT"}
|
||||
):
|
||||
target_author = (
|
||||
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or target_author
|
||||
)
|
||||
if not target_author:
|
||||
target_author = source_chat_id or str(channel_identifier or "").strip()
|
||||
if not target_author:
|
||||
return {"error": "signal_target_author_unresolvable"}
|
||||
|
||||
return {
|
||||
"target_message_id": "",
|
||||
"target_ts": int(target_ts),
|
||||
"target_author": target_author,
|
||||
}
|
||||
|
||||
if service_key == "whatsapp":
|
||||
target_message_id = source_message_id
|
||||
target_ts = delivered_ts or local_ts
|
||||
if not target_message_id:
|
||||
bridge_ref = _latest_whatsapp_bridge_ref(message)
|
||||
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||
if not target_ts:
|
||||
target_ts = int(bridge_ref.get("upstream_ts") or 0)
|
||||
if not target_message_id:
|
||||
return {"error": "whatsapp_target_unresolvable"}
|
||||
return {
|
||||
"target_message_id": target_message_id,
|
||||
"target_ts": int(target_ts or 0),
|
||||
"target_author": "",
|
||||
}
|
||||
|
||||
return {"error": "service_not_supported"}
|
||||
|
||||
|
||||
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
@@ -1818,7 +1900,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
defaults={
|
||||
"name": "Business Plan",
|
||||
"enabled": True,
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"reply_required": True,
|
||||
"exact_match_only": True,
|
||||
"window_scope": "conversation",
|
||||
@@ -1828,6 +1910,9 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".bp":
|
||||
profile.trigger_token = ".bp"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
for action_type, position in (
|
||||
("extract_bp", 0),
|
||||
("save_document", 1),
|
||||
@@ -1845,6 +1930,29 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
return profile
|
||||
|
||||
|
||||
def _ensure_codex_profile(user) -> CommandProfile:
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user=user,
|
||||
slug="codex",
|
||||
defaults={
|
||||
"name": "Codex",
|
||||
"enabled": True,
|
||||
"trigger_token": ".codex",
|
||||
"reply_required": False,
|
||||
"exact_match_only": False,
|
||||
"window_scope": "conversation",
|
||||
"visibility_mode": "status_in_source",
|
||||
},
|
||||
)
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
return profile
|
||||
|
||||
|
||||
def _toggle_command_for_channel(
|
||||
*,
|
||||
user,
|
||||
@@ -1860,6 +1968,8 @@ def _toggle_command_for_channel(
|
||||
|
||||
if slug == "bp":
|
||||
profile = _ensure_bp_profile_and_actions(user)
|
||||
elif slug == "codex":
|
||||
profile = _ensure_codex_profile(user)
|
||||
else:
|
||||
profile = (
|
||||
CommandProfile.objects.filter(user=user, slug=slug)
|
||||
@@ -1916,7 +2026,15 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
user=user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
trigger_token="#bp#",
|
||||
trigger_token=".bp",
|
||||
enabled=True,
|
||||
)
|
||||
if "codex" not in by_slug:
|
||||
by_slug["codex"] = CommandProfile(
|
||||
user=user,
|
||||
slug="codex",
|
||||
name="Codex",
|
||||
trigger_token=".codex",
|
||||
enabled=True,
|
||||
)
|
||||
slugs = sorted(by_slug.keys())
|
||||
@@ -1945,7 +2063,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp",
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1956,7 +2074,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set",
|
||||
"trigger_token": "#bp set#",
|
||||
"trigger_token": ".bp set",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1967,7 +2085,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set_range",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set range",
|
||||
"trigger_token": "#bp set range#",
|
||||
"trigger_token": ".bp set range",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -4484,6 +4602,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
async_to_sync(process_inbound_assist)(created_message)
|
||||
async_to_sync(process_inbound_translation)(created_message)
|
||||
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
||||
if base["service"] in {"signal", "whatsapp"}:
|
||||
@@ -4516,3 +4635,116 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
level="success",
|
||||
panel_id=panel_id,
|
||||
)
|
||||
|
||||
|
||||
class ComposeReact(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service, identifier, person = _request_scope(request, "POST")
|
||||
service_key = _default_service(service)
|
||||
if service_key not in {"signal", "whatsapp"}:
|
||||
return JsonResponse({"ok": False, "error": "service_not_supported"})
|
||||
if not identifier and person is None:
|
||||
return JsonResponse({"ok": False, "error": "missing_scope"})
|
||||
|
||||
message_id = str(request.POST.get("message_id") or "").strip()
|
||||
emoji = str(request.POST.get("emoji") or "").strip()
|
||||
remove_raw = request.POST.get("remove")
|
||||
remove_forced = remove_raw is not None and str(remove_raw).strip() != ""
|
||||
if not message_id:
|
||||
return JsonResponse({"ok": False, "error": "message_id_required"})
|
||||
if not emoji:
|
||||
return JsonResponse({"ok": False, "error": "emoji_required"})
|
||||
|
||||
base = _context_base(request.user, service_key, identifier, person)
|
||||
person_identifier = base.get("person_identifier")
|
||||
if person_identifier is None:
|
||||
return JsonResponse({"ok": False, "error": "message_scope_unresolvable"})
|
||||
|
||||
session = ChatSession.objects.filter(
|
||||
user=request.user,
|
||||
identifier=person_identifier,
|
||||
).first()
|
||||
if session is None:
|
||||
return JsonResponse({"ok": False, "error": "session_not_found"})
|
||||
|
||||
message = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
if message is None:
|
||||
return JsonResponse({"ok": False, "error": "message_not_found"})
|
||||
|
||||
target = _resolve_reaction_target(
|
||||
message=message,
|
||||
service=service_key,
|
||||
channel_identifier=str(base.get("identifier") or "").strip(),
|
||||
)
|
||||
if target.get("error"):
|
||||
return JsonResponse({"ok": False, "error": str(target.get("error"))})
|
||||
|
||||
actor_key = _reaction_actor_key(request.user.id, service_key)
|
||||
remove = _parse_bool(remove_raw, default=False)
|
||||
if not remove_forced:
|
||||
existing_rows = list((message.receipt_payload or {}).get("reactions") or [])
|
||||
has_same_reaction = False
|
||||
for row in existing_rows:
|
||||
item = dict(row or {})
|
||||
if bool(item.get("removed")):
|
||||
continue
|
||||
if str(item.get("emoji") or "").strip() != emoji:
|
||||
continue
|
||||
if str(item.get("source_service") or "").strip().lower() != service_key:
|
||||
continue
|
||||
if str(item.get("actor") or "").strip() != actor_key:
|
||||
continue
|
||||
has_same_reaction = True
|
||||
break
|
||||
remove = bool(has_same_reaction)
|
||||
|
||||
target_ts = int(target.get("target_ts") or 0)
|
||||
target_message_id = str(target.get("target_message_id") or "").strip()
|
||||
sent = async_to_sync(transport.send_reaction)(
|
||||
service_key,
|
||||
str(base.get("identifier") or "").strip(),
|
||||
emoji=emoji,
|
||||
target_message_id=target_message_id,
|
||||
target_timestamp=target_ts if target_ts > 0 else None,
|
||||
target_author=str(target.get("target_author") or "").strip(),
|
||||
remove=bool(remove),
|
||||
)
|
||||
if not sent:
|
||||
return JsonResponse({"ok": False, "error": "reaction_send_failed"})
|
||||
|
||||
updated = async_to_sync(history.apply_reaction)(
|
||||
request.user,
|
||||
person_identifier,
|
||||
target_message_id=target_message_id,
|
||||
target_ts=target_ts,
|
||||
emoji=emoji,
|
||||
source_service=service_key,
|
||||
actor=actor_key,
|
||||
remove=bool(remove),
|
||||
payload={
|
||||
"source": "compose_react",
|
||||
"message_id": str(message.id),
|
||||
},
|
||||
)
|
||||
if updated is None:
|
||||
updated = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
serialized = _serialize_message(updated or message)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message_id": str(message.id),
|
||||
"emoji": emoji,
|
||||
"remove": bool(remove),
|
||||
"target_upstream_ts": target_ts,
|
||||
"target_upstream_id": target_message_id,
|
||||
"reactions": list(serialized.get("reactions") or []),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user