Implement plans

This commit is contained in:
2026-03-04 02:19:22 +00:00
parent 34ee49410d
commit 0718a06c19
31 changed files with 3987 additions and 181 deletions

View File

@@ -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 []),
}
)