Implement plans
This commit is contained in:
@@ -4,7 +4,6 @@ import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.messaging import ai as ai_runner
|
||||
@@ -12,21 +11,36 @@ from core.models import (
|
||||
AI,
|
||||
Chat,
|
||||
ChatTaskSource,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.providers import get_provider
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||
_BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE)
|
||||
_BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE)
|
||||
_PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-–—_>.,:;!/?\\|"
|
||||
_LIST_TASKS_RE = re.compile(
|
||||
r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNDO_TASK_RE = re.compile(
|
||||
r"^\s*\.undo(?:\s+(?:#)?(?P<reference>[A-Za-z0-9_-]+))?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EPIC_CREATE_RE = re.compile(
|
||||
r"^\s*(?:\.epic\b|epic)\s*[:\-]?\s*(?P<name>.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EPIC_TOKEN_RE = re.compile(r"\[\s*epic\s*:\s*([^\]]+?)\s*\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
@@ -57,27 +71,44 @@ def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
|
||||
|
||||
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
||||
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
||||
if str(message.source_service or "").strip().lower() == "signal":
|
||||
signal_value = str(message.source_chat_id or "").strip()
|
||||
if signal_value:
|
||||
companions = await sync_to_async(list)(
|
||||
lookup_service = str(message.source_service or "").strip().lower()
|
||||
variants = _channel_variants(lookup_service, message.source_chat_id or "")
|
||||
session_identifier = getattr(getattr(message, "session", None), "identifier", None)
|
||||
canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
|
||||
canonical_identifier = str(getattr(session_identifier, "identifier", "") or "").strip()
|
||||
if lookup_service == "web" and canonical_service and canonical_service != "web":
|
||||
lookup_service = canonical_service
|
||||
variants = _channel_variants(lookup_service, message.source_chat_id or "")
|
||||
for expanded in _channel_variants(lookup_service, canonical_identifier):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
elif canonical_service and canonical_identifier and canonical_service == lookup_service:
|
||||
for expanded in _channel_variants(canonical_service, canonical_identifier):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
if lookup_service == "signal":
|
||||
companions: list[str] = []
|
||||
for value in list(variants):
|
||||
signal_value = str(value or "").strip()
|
||||
if not signal_value:
|
||||
continue
|
||||
companions += await sync_to_async(list)(
|
||||
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True)
|
||||
)
|
||||
companions += await sync_to_async(list)(
|
||||
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
|
||||
)
|
||||
for candidate in companions:
|
||||
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
for candidate in companions:
|
||||
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
if not variants:
|
||||
return []
|
||||
return await sync_to_async(list)(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=message.user,
|
||||
enabled=True,
|
||||
service=message.source_service,
|
||||
service=lookup_service,
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
)
|
||||
@@ -107,6 +138,58 @@ def _parse_prefixes(raw) -> list[str]:
|
||||
return rows or ["task:", "todo:", "action:"]
|
||||
|
||||
|
||||
def _prefix_roots(prefixes: list[str]) -> list[str]:
|
||||
roots: list[str] = []
|
||||
for value in prefixes:
|
||||
token = str(value or "").strip().lower()
|
||||
if not token:
|
||||
continue
|
||||
token = token.lstrip(_PREFIX_HEAD_TRIM)
|
||||
match = re.match(r"([a-z0-9]+)", token)
|
||||
if not match:
|
||||
continue
|
||||
root = str(match.group(1) or "").strip()
|
||||
if root and root not in roots:
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def _has_task_prefix(text: str, prefixes: list[str]) -> bool:
|
||||
body = str(text or "").strip().lower()
|
||||
if not body:
|
||||
return False
|
||||
if any(body.startswith(prefix) for prefix in prefixes):
|
||||
return True
|
||||
trimmed = body.lstrip(_PREFIX_HEAD_TRIM)
|
||||
roots = _prefix_roots(prefixes)
|
||||
if not trimmed or not roots:
|
||||
return False
|
||||
for root in roots:
|
||||
if re.match(rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)", trimmed):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _strip_task_prefix(text: str, prefixes: list[str]) -> str:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
trimmed = body.lstrip(_PREFIX_HEAD_TRIM)
|
||||
roots = _prefix_roots(prefixes)
|
||||
if not trimmed or not roots:
|
||||
return body
|
||||
for root in roots:
|
||||
match = re.match(
|
||||
rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)(.+)$",
|
||||
trimmed,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
cleaned = str(match.group(1) or "").strip()
|
||||
return cleaned or body
|
||||
return body
|
||||
|
||||
|
||||
def _normalize_flags(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
return {
|
||||
@@ -157,7 +240,7 @@ def _is_task_candidate(text: str, flags: dict) -> bool:
|
||||
return False
|
||||
body_lower = body.lower()
|
||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
||||
has_prefix = any(body_lower.startswith(prefix) for prefix in prefixes)
|
||||
has_prefix = _has_task_prefix(body_lower, prefixes)
|
||||
if bool(flags.get("require_prefix")) and not has_prefix:
|
||||
return False
|
||||
mode = str(flags.get("match_mode") or "balanced").strip().lower()
|
||||
@@ -207,10 +290,13 @@ async def _derive_title(message: Message) -> str:
|
||||
|
||||
|
||||
async def _derive_title_with_flags(message: Message, flags: dict) -> str:
|
||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
||||
if not bool(flags.get("ai_title_enabled", True)):
|
||||
text = str(message.text or "").strip()
|
||||
text = _strip_task_prefix(str(message.text or "").strip(), prefixes)
|
||||
return (text or "Untitled task")[:255]
|
||||
return await _derive_title(message)
|
||||
title = await _derive_title(message)
|
||||
cleaned = _strip_task_prefix(str(title or "").strip(), prefixes)
|
||||
return (cleaned or title or "Untitled task")[:255]
|
||||
|
||||
|
||||
async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: str) -> None:
|
||||
@@ -221,36 +307,51 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
variants = _channel_variants(task.source_service or "", task.source_channel or "")
|
||||
person_identifier = None
|
||||
if variants:
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=task.user,
|
||||
service=task.source_service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)()
|
||||
external_chat_id = ""
|
||||
if person_identifier is not None:
|
||||
link = await sync_to_async(
|
||||
lambda: ExternalChatLink.objects.filter(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier)
|
||||
| Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)()
|
||||
if link is not None:
|
||||
external_chat_id = str(link.external_chat_id or "").strip()
|
||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
service=str(task.source_service or ""),
|
||||
channel=str(task.source_channel or ""),
|
||||
)
|
||||
cached_project = task._state.fields_cache.get("project")
|
||||
cached_epic = task._state.fields_cache.get("epic")
|
||||
project_name = str(getattr(cached_project, "name", "") or "")
|
||||
epic_name = str(getattr(cached_epic, "name", "") or "")
|
||||
request_payload = {
|
||||
"task_id": str(task.id),
|
||||
"reference_code": str(task.reference_code or ""),
|
||||
"title": str(task.title or ""),
|
||||
"external_key": str(task.external_key or ""),
|
||||
"project_name": project_name,
|
||||
"epic_name": epic_name,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""),
|
||||
"mode": "default",
|
||||
"payload": event.payload,
|
||||
}
|
||||
codex_run = await sync_to_async(CodexRun.objects.create)(
|
||||
user=task.user,
|
||||
task_id=task.id,
|
||||
derived_task_event_id=event.id,
|
||||
source_message_id=(event.source_message_id or task.origin_message_id),
|
||||
project_id=task.project_id,
|
||||
epic_id=task.epic_id,
|
||||
source_service=str(task.source_service or ""),
|
||||
source_channel=str(task.source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
request_payload={
|
||||
"action": action,
|
||||
"provider_payload": dict(request_payload),
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
result_payload={},
|
||||
error="",
|
||||
)
|
||||
request_payload["codex_run_id"] = str(codex_run.id)
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
@@ -264,16 +365,7 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
},
|
||||
"provider_payload": dict(request_payload),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
@@ -281,34 +373,11 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
return
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
result = provider.create_task(provider_settings, dict(request_payload))
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
result = provider.mark_complete(provider_settings, dict(request_payload))
|
||||
else:
|
||||
result = provider.append_update(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
})
|
||||
result = provider.append_update(provider_settings, dict(request_payload))
|
||||
|
||||
status = "ok" if result.ok else "failed"
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
@@ -323,6 +392,10 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"error": str(result.error or ""),
|
||||
},
|
||||
)
|
||||
codex_run.status = status
|
||||
codex_run.result_payload = dict(result.payload or {})
|
||||
codex_run.error = str(result.error or "")
|
||||
await sync_to_async(codex_run.save)(update_fields=["status", "result_payload", "error", "updated_at"])
|
||||
if result.ok and result.external_key and not task.external_key:
|
||||
task.external_key = str(result.external_key)
|
||||
await sync_to_async(task.save)(update_fields=["external_key"])
|
||||
@@ -338,6 +411,121 @@ async def _completion_regex(message: Message) -> re.Pattern:
|
||||
return re.compile(r"\\b(?:" + "|".join(re.escape(p) for p in phrases) + r")\\s*#([A-Za-z0-9_-]+)\\b", re.IGNORECASE)
|
||||
|
||||
|
||||
async def _send_scope_message(source: ChatTaskSource, message: Message, text: str) -> None:
|
||||
await send_message_raw(
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={"origin": "task_scope_command"},
|
||||
)
|
||||
|
||||
|
||||
async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
|
||||
if not sources:
|
||||
return False
|
||||
body = str(text or "").strip()
|
||||
source = sources[0]
|
||||
if _LIST_TASKS_RE.match(body):
|
||||
open_rows = await sync_to_async(list)(
|
||||
DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
)
|
||||
.exclude(status_snapshot="completed")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
if not open_rows:
|
||||
await _send_scope_message(source, message, "[task] no open tasks in this chat.")
|
||||
return True
|
||||
lines = ["[task] open tasks:"]
|
||||
for row in open_rows:
|
||||
lines.append(f"- #{row.reference_code} {row.title}")
|
||||
await _send_scope_message(source, message, "\n".join(lines))
|
||||
return True
|
||||
|
||||
undo_match = _UNDO_TASK_RE.match(body)
|
||||
if undo_match:
|
||||
reference = str(undo_match.group("reference") or "").strip()
|
||||
if reference:
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
reference_code=reference,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
else:
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
if task is None:
|
||||
await _send_scope_message(source, message, "[task] nothing to undo in this chat.")
|
||||
return True
|
||||
ref = str(task.reference_code or "")
|
||||
title = str(task.title or "")
|
||||
await sync_to_async(task.delete)()
|
||||
await _send_scope_message(source, message, f"[task] removed #{ref}: {title}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_epic_name_from_text(text: str) -> str:
|
||||
body = str(text or "")
|
||||
match = _EPIC_TOKEN_RE.search(body)
|
||||
if not match:
|
||||
return ""
|
||||
return str(match.group(1) or "").strip()
|
||||
|
||||
|
||||
def _strip_epic_token(text: str) -> str:
|
||||
body = str(text or "")
|
||||
cleaned = _EPIC_TOKEN_RE.sub("", body)
|
||||
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||
|
||||
|
||||
async def _handle_epic_create_command(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
|
||||
match = _EPIC_CREATE_RE.match(str(text or ""))
|
||||
if not match or not sources:
|
||||
return False
|
||||
name = str(match.group("name") or "").strip()
|
||||
if not name:
|
||||
return True
|
||||
source = sources[0]
|
||||
epic, created = await sync_to_async(TaskEpic.objects.get_or_create)(
|
||||
project=source.project,
|
||||
name=name,
|
||||
)
|
||||
state = "created" if created else "already exists"
|
||||
await _send_scope_message(
|
||||
source,
|
||||
message,
|
||||
(
|
||||
f"[epic] {state}: {epic.name}\n"
|
||||
"WhatsApp usage:\n"
|
||||
"- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
|
||||
"- add task to epic: task: <description> [epic:<Epic name>]\n"
|
||||
"- list tasks: .l list tasks\n"
|
||||
"- undo latest task: .undo"
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
@@ -350,6 +538,10 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
sources = await _resolve_source_mappings(message)
|
||||
if not sources:
|
||||
return
|
||||
if await _handle_scope_task_commands(message, sources, text):
|
||||
return
|
||||
if await _handle_epic_create_command(message, sources, text):
|
||||
return
|
||||
|
||||
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
||||
completion_rx = await _completion_regex(message) if completion_allowed else None
|
||||
@@ -399,21 +591,37 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
flags = _effective_flags(source)
|
||||
if not bool(flags.get("derive_enabled", True)):
|
||||
continue
|
||||
if not _is_task_candidate(text, flags):
|
||||
task_text = _strip_epic_token(text)
|
||||
if not _is_task_candidate(task_text, flags):
|
||||
continue
|
||||
title = await _derive_title_with_flags(message, flags)
|
||||
epic = source.epic
|
||||
epic_name = _extract_epic_name_from_text(text)
|
||||
if epic_name:
|
||||
epic, _ = await sync_to_async(TaskEpic.objects.get_or_create)(
|
||||
project=source.project,
|
||||
name=epic_name,
|
||||
)
|
||||
cloned_message = message
|
||||
if task_text != text:
|
||||
cloned_message = Message(
|
||||
user=message.user,
|
||||
text=task_text,
|
||||
source_service=message.source_service,
|
||||
source_chat_id=message.source_chat_id,
|
||||
)
|
||||
title = await _derive_title_with_flags(cloned_message, flags)
|
||||
reference = await sync_to_async(_next_reference)(message.user, source.project)
|
||||
task = await sync_to_async(DerivedTask.objects.create)(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
epic=source.epic,
|
||||
epic=epic,
|
||||
title=title,
|
||||
source_service=message.source_service or "web",
|
||||
source_channel=message.source_chat_id or "",
|
||||
source_service=source.service or message.source_service or "web",
|
||||
source_channel=source.channel_identifier or message.source_chat_id or "",
|
||||
origin_message=message,
|
||||
reference_code=reference,
|
||||
status_snapshot="open",
|
||||
immutable_payload={"origin_text": text, "flags": flags},
|
||||
immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags},
|
||||
)
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=task,
|
||||
@@ -426,8 +634,8 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if bool(flags.get("announce_task_id", False)):
|
||||
try:
|
||||
await send_message_raw(
|
||||
message.source_service or "web",
|
||||
message.source_chat_id or "",
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text=f"[task] Created #{task.reference_code}: {task.title}",
|
||||
attachments=[],
|
||||
metadata={"origin": "task_announce"},
|
||||
@@ -435,3 +643,22 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
except Exception:
|
||||
# Announcement is best-effort and should not block derivation.
|
||||
pass
|
||||
scope_count = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
).count()
|
||||
)()
|
||||
if scope_count > 0 and scope_count % 10 == 0:
|
||||
try:
|
||||
await send_message_raw(
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text="[task] tip: use .l list tasks to review tasks. use .undo to uncreate the latest task.",
|
||||
attachments=[],
|
||||
metadata={"origin": "task_reminder"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user