Fix all integrations
This commit is contained in:
@@ -280,6 +280,43 @@ def _extract_signal_delete(envelope):
|
||||
}
|
||||
|
||||
|
||||
def _extract_signal_group_identifiers(payload: dict) -> list[str]:
|
||||
envelope = payload.get("envelope") or {}
|
||||
if not isinstance(envelope, dict):
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
paths = [
|
||||
("group",),
|
||||
("groupV2",),
|
||||
("dataMessage", "groupInfo"),
|
||||
("dataMessage", "groupV2"),
|
||||
("syncMessage", "sentMessage", "groupInfo"),
|
||||
("syncMessage", "sentMessage", "groupV2"),
|
||||
("syncMessage", "sentMessage", "message", "groupInfo"),
|
||||
("syncMessage", "sentMessage", "message", "groupV2"),
|
||||
]
|
||||
key_names = ("id", "groupId", "groupID", "internal_id", "masterKey")
|
||||
for path in paths:
|
||||
node = _get_nested(envelope, path)
|
||||
if isinstance(node, str):
|
||||
candidates.append(node)
|
||||
continue
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
for key_name in key_names:
|
||||
value = str(node.get(key_name) or "").strip()
|
||||
if value:
|
||||
candidates.append(value)
|
||||
|
||||
unique = []
|
||||
for value in candidates:
|
||||
cleaned = str(value or "").strip()
|
||||
if cleaned and cleaned not in unique:
|
||||
unique.append(cleaned)
|
||||
return unique
|
||||
|
||||
|
||||
def _extract_signal_text(raw_payload, default_text=""):
|
||||
text = str(default_text or "").strip()
|
||||
if text:
|
||||
@@ -332,6 +369,22 @@ def _identifier_candidates(*values):
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_person_identifiers(rows):
|
||||
deduped = []
|
||||
seen = set()
|
||||
for row in rows or []:
|
||||
key = (
|
||||
int(getattr(row, "user_id", 0) or 0),
|
||||
str(getattr(row, "person_id", "") or ""),
|
||||
str(getattr(row, "service", "") or "").strip().lower(),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(row)
|
||||
return deduped
|
||||
|
||||
|
||||
def _digits_only(value):
|
||||
return re.sub(r"[^0-9]", "", str(value or "").strip())
|
||||
|
||||
@@ -762,6 +815,31 @@ class HandleMessage(Command):
|
||||
log.warning("Signal reaction relay to XMPP failed: %s", exc)
|
||||
return
|
||||
|
||||
if reply_to_self and str(text or "").strip().startswith("."):
|
||||
responded_user_ids = set()
|
||||
for identifier in identifiers:
|
||||
if identifier.user_id in responded_user_ids:
|
||||
continue
|
||||
responded_user_ids.add(identifier.user_id)
|
||||
gateway_replies = await self.ur.xmpp.client.execute_gateway_command(
|
||||
sender_user=identifier.user,
|
||||
body=text,
|
||||
service=self.service,
|
||||
channel_identifier=str(identifier.identifier or ""),
|
||||
sender_identifier=str(identifier.identifier or ""),
|
||||
local_message=None,
|
||||
message_meta={
|
||||
"signal": {
|
||||
"source_uuid": str(effective_source_uuid or ""),
|
||||
"source_number": str(effective_source_number or ""),
|
||||
"reply_to_self": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
for line in gateway_replies:
|
||||
await c.send(f"[>] {line}")
|
||||
return
|
||||
|
||||
# Handle attachments across multiple Signal payload variants.
|
||||
attachment_list = _extract_attachments(raw)
|
||||
xmpp_attachments = []
|
||||
@@ -1087,6 +1165,130 @@ class SignalClient(ClientBase):
|
||||
self.client.register(HandleMessage(self.ur, self.service))
|
||||
self._command_task = None
|
||||
self._raw_receive_task = None
|
||||
self._catalog_refresh_task = None
|
||||
|
||||
async def _signal_api_get_list(
|
||||
self, session: aiohttp.ClientSession, path: str
|
||||
) -> list[dict]:
|
||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
|
||||
"/"
|
||||
)
|
||||
try:
|
||||
async with session.get(f"{base}{path}") as response:
|
||||
if response.status != 200:
|
||||
body = str(await response.text()).strip()[:300]
|
||||
self.log.warning(
|
||||
"signal catalog fetch failed path=%s status=%s body=%s",
|
||||
path,
|
||||
response.status,
|
||||
body or "-",
|
||||
)
|
||||
return []
|
||||
payload = await response.json(content_type=None)
|
||||
except Exception as exc:
|
||||
self.log.warning("signal catalog fetch failed path=%s error=%s", path, exc)
|
||||
return []
|
||||
return payload if isinstance(payload, list) else []
|
||||
|
||||
async def _refresh_runtime_catalog(self) -> None:
|
||||
signal_number = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
|
||||
if not signal_number:
|
||||
return
|
||||
|
||||
encoded_account = quote_plus(signal_number)
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
identities = await self._signal_api_get_list(
|
||||
session, f"/v1/identities/{encoded_account}"
|
||||
)
|
||||
groups = await self._signal_api_get_list(
|
||||
session, f"/v1/groups/{encoded_account}"
|
||||
)
|
||||
|
||||
account_digits = _digits_only(signal_number)
|
||||
contact_rows = []
|
||||
seen_contacts = set()
|
||||
for identity in identities:
|
||||
if not isinstance(identity, dict):
|
||||
continue
|
||||
number = str(identity.get("number") or "").strip()
|
||||
uuid = str(identity.get("uuid") or "").strip()
|
||||
if account_digits and number and _digits_only(number) == account_digits:
|
||||
continue
|
||||
identifiers = []
|
||||
for candidate in (number, uuid):
|
||||
cleaned = str(candidate or "").strip()
|
||||
if cleaned and cleaned not in identifiers:
|
||||
identifiers.append(cleaned)
|
||||
if not identifiers:
|
||||
continue
|
||||
key = (
|
||||
f"uuid:{uuid.lower()}"
|
||||
if uuid
|
||||
else f"phone:{_digits_only(number) or number}"
|
||||
)
|
||||
if key in seen_contacts:
|
||||
continue
|
||||
seen_contacts.add(key)
|
||||
contact_rows.append(
|
||||
{
|
||||
"identifier": number or uuid,
|
||||
"identifiers": identifiers,
|
||||
"name": str(identity.get("name") or "").strip(),
|
||||
"number": number,
|
||||
"uuid": uuid,
|
||||
}
|
||||
)
|
||||
|
||||
group_rows = []
|
||||
seen_groups = set()
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
group_id = str(group.get("id") or "").strip()
|
||||
internal_id = str(group.get("internal_id") or "").strip()
|
||||
identifiers = []
|
||||
for candidate in (group_id, internal_id):
|
||||
cleaned = str(candidate or "").strip()
|
||||
if cleaned and cleaned not in identifiers:
|
||||
identifiers.append(cleaned)
|
||||
if not identifiers:
|
||||
continue
|
||||
key = group_id or internal_id
|
||||
if key in seen_groups:
|
||||
continue
|
||||
seen_groups.add(key)
|
||||
group_rows.append(
|
||||
{
|
||||
"identifier": group_id or internal_id,
|
||||
"identifiers": identifiers,
|
||||
"name": str(group.get("name") or "").strip(),
|
||||
"id": group_id,
|
||||
"internal_id": internal_id,
|
||||
}
|
||||
)
|
||||
|
||||
transport.update_runtime_state(
|
||||
self.service,
|
||||
accounts=[signal_number],
|
||||
contacts=contact_rows,
|
||||
groups=group_rows,
|
||||
catalog_refreshed_at=int(time.time()),
|
||||
catalog_error="",
|
||||
)
|
||||
|
||||
async def _refresh_runtime_catalog_safe(self) -> None:
|
||||
try:
|
||||
await self._refresh_runtime_catalog()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
transport.update_runtime_state(
|
||||
self.service,
|
||||
catalog_error=str(exc).strip()[:300],
|
||||
catalog_refreshed_at=int(time.time()),
|
||||
)
|
||||
self.log.warning("signal catalog refresh failed: %s", exc)
|
||||
|
||||
async def _drain_runtime_commands(self):
|
||||
"""Process queued runtime commands (e.g., web UI sends via composite router)."""
|
||||
@@ -1237,7 +1439,104 @@ class SignalClient(ClientBase):
|
||||
self.log.warning(f"Command loop error: {exc}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _resolve_signal_identifiers(self, source_uuid: str, source_number: str):
|
||||
async def _resolve_signal_group_identifiers(self, group_candidates: list[str]):
|
||||
unique_candidates = []
|
||||
for value in group_candidates or []:
|
||||
cleaned = str(value or "").strip()
|
||||
if cleaned and cleaned not in unique_candidates:
|
||||
unique_candidates.append(cleaned)
|
||||
if not unique_candidates:
|
||||
return []
|
||||
|
||||
runtime_groups = transport.get_runtime_state(self.service).get("groups") or []
|
||||
expanded = list(unique_candidates)
|
||||
for item in runtime_groups:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identifiers = []
|
||||
for candidate in item.get("identifiers") or []:
|
||||
cleaned = str(candidate or "").strip()
|
||||
if cleaned:
|
||||
identifiers.append(cleaned)
|
||||
if not identifiers:
|
||||
continue
|
||||
if not any(candidate in identifiers for candidate in unique_candidates):
|
||||
continue
|
||||
for candidate in identifiers:
|
||||
if candidate not in expanded:
|
||||
expanded.append(candidate)
|
||||
|
||||
exact_identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
service=self.service,
|
||||
identifier__in=expanded,
|
||||
).select_related("user", "person")
|
||||
)
|
||||
if exact_identifiers:
|
||||
return exact_identifiers
|
||||
|
||||
bare_candidates = []
|
||||
for candidate in expanded:
|
||||
bare = str(candidate or "").strip().split("@", 1)[0].strip()
|
||||
if bare and bare not in bare_candidates:
|
||||
bare_candidates.append(bare)
|
||||
|
||||
links = await sync_to_async(list)(
|
||||
PlatformChatLink.objects.filter(
|
||||
service=self.service,
|
||||
is_group=True,
|
||||
chat_identifier__in=bare_candidates,
|
||||
)
|
||||
.select_related("user", "person_identifier", "person")
|
||||
.order_by("id")
|
||||
)
|
||||
if not links:
|
||||
return []
|
||||
|
||||
results = []
|
||||
seen_ids = set()
|
||||
for link in links:
|
||||
if link.person_identifier_id:
|
||||
if link.person_identifier_id not in seen_ids:
|
||||
seen_ids.add(link.person_identifier_id)
|
||||
results.append(link.person_identifier)
|
||||
continue
|
||||
if not link.person_id:
|
||||
continue
|
||||
group_pi = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=link.user,
|
||||
person=link.person,
|
||||
service=self.service,
|
||||
identifier__in=expanded,
|
||||
)
|
||||
.select_related("user", "person")
|
||||
.first()
|
||||
)()
|
||||
if group_pi is None:
|
||||
group_pi = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=link.user,
|
||||
person=link.person,
|
||||
service=self.service,
|
||||
)
|
||||
.select_related("user", "person")
|
||||
.first()
|
||||
)()
|
||||
if group_pi is not None and group_pi.id not in seen_ids:
|
||||
seen_ids.add(group_pi.id)
|
||||
results.append(group_pi)
|
||||
return results
|
||||
|
||||
async def _resolve_signal_identifiers(
|
||||
self,
|
||||
source_uuid: str,
|
||||
source_number: str,
|
||||
group_candidates: list[str] | None = None,
|
||||
):
|
||||
group_rows = await self._resolve_signal_group_identifiers(group_candidates or [])
|
||||
if group_rows:
|
||||
return _dedupe_person_identifiers(group_rows)
|
||||
candidates = _identifier_candidates(source_uuid, source_number)
|
||||
if not candidates:
|
||||
return []
|
||||
@@ -1248,7 +1547,7 @@ class SignalClient(ClientBase):
|
||||
)
|
||||
)
|
||||
if identifiers:
|
||||
return identifiers
|
||||
return _dedupe_person_identifiers(identifiers)
|
||||
candidate_digits = {_digits_only(value) for value in candidates}
|
||||
candidate_digits = {value for value in candidate_digits if value}
|
||||
if not candidate_digits:
|
||||
@@ -1256,11 +1555,13 @@ class SignalClient(ClientBase):
|
||||
rows = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(service=self.service).select_related("user")
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in rows
|
||||
if _digits_only(getattr(row, "identifier", "")) in candidate_digits
|
||||
]
|
||||
return _dedupe_person_identifiers(
|
||||
[
|
||||
row
|
||||
for row in rows
|
||||
if _digits_only(getattr(row, "identifier", "")) in candidate_digits
|
||||
]
|
||||
)
|
||||
|
||||
async def _auto_link_single_user_signal_identifier(
|
||||
self, source_uuid: str, source_number: str
|
||||
@@ -1301,7 +1602,123 @@ class SignalClient(ClientBase):
|
||||
fallback_identifier,
|
||||
int(owner.id),
|
||||
)
|
||||
return [pi]
|
||||
return _dedupe_person_identifiers([pi])
|
||||
|
||||
async def _build_xmpp_relay_attachments(self, payload: dict):
|
||||
attachment_list = _extract_attachments(payload)
|
||||
xmpp_attachments = []
|
||||
compose_media_urls = []
|
||||
if not attachment_list:
|
||||
return xmpp_attachments, compose_media_urls
|
||||
|
||||
fetched_attachments = await asyncio.gather(
|
||||
*[signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list]
|
||||
)
|
||||
for fetched, att in zip(fetched_attachments, attachment_list):
|
||||
if not fetched:
|
||||
self.log.warning(
|
||||
"signal raw attachment fetch failed attachment_id=%s", att["id"]
|
||||
)
|
||||
continue
|
||||
xmpp_attachments.append(
|
||||
{
|
||||
"content": fetched["content"],
|
||||
"content_type": fetched["content_type"],
|
||||
"filename": fetched["filename"],
|
||||
"size": fetched["size"],
|
||||
}
|
||||
)
|
||||
blob_key = media_bridge.put_blob(
|
||||
service="signal",
|
||||
content=fetched["content"],
|
||||
filename=fetched["filename"],
|
||||
content_type=fetched["content_type"],
|
||||
)
|
||||
if blob_key:
|
||||
compose_media_urls.append(
|
||||
f"/compose/media/blob/?key={quote_plus(str(blob_key))}"
|
||||
)
|
||||
return xmpp_attachments, compose_media_urls
|
||||
|
||||
async def _relay_signal_inbound_to_xmpp(
|
||||
self,
|
||||
*,
|
||||
identifiers,
|
||||
relay_text,
|
||||
xmpp_attachments,
|
||||
compose_media_urls,
|
||||
source_uuid,
|
||||
source_number,
|
||||
ts,
|
||||
):
|
||||
resolved_text_by_session = {}
|
||||
for identifier in identifiers:
|
||||
user = identifier.user
|
||||
session_key = (identifier.user.id, identifier.person.id)
|
||||
mutate_manips = await sync_to_async(list)(
|
||||
Manipulation.objects.filter(
|
||||
group__people=identifier.person,
|
||||
user=identifier.user,
|
||||
mode="mutate",
|
||||
filter_enabled=True,
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
if mutate_manips:
|
||||
uploaded_urls = []
|
||||
for manip in mutate_manips:
|
||||
prompt = replies.generate_mutate_reply_prompt(
|
||||
relay_text,
|
||||
None,
|
||||
manip,
|
||||
None,
|
||||
)
|
||||
result = await ai.run_prompt(
|
||||
prompt,
|
||||
manip.ai,
|
||||
operation="signal_mutate",
|
||||
)
|
||||
uploaded_urls = await self.ur.xmpp.client.send_from_external(
|
||||
user,
|
||||
identifier,
|
||||
result,
|
||||
False,
|
||||
attachments=xmpp_attachments,
|
||||
source_ref={
|
||||
"upstream_message_id": "",
|
||||
"upstream_author": str(
|
||||
source_uuid or source_number or ""
|
||||
),
|
||||
"upstream_ts": int(ts or 0),
|
||||
},
|
||||
)
|
||||
resolved_text = relay_text
|
||||
if (not resolved_text) and uploaded_urls:
|
||||
resolved_text = "\n".join(uploaded_urls)
|
||||
elif (not resolved_text) and compose_media_urls:
|
||||
resolved_text = "\n".join(compose_media_urls)
|
||||
resolved_text_by_session[session_key] = resolved_text
|
||||
continue
|
||||
|
||||
uploaded_urls = await self.ur.xmpp.client.send_from_external(
|
||||
user,
|
||||
identifier,
|
||||
relay_text,
|
||||
False,
|
||||
attachments=xmpp_attachments,
|
||||
source_ref={
|
||||
"upstream_message_id": "",
|
||||
"upstream_author": str(source_uuid or source_number or ""),
|
||||
"upstream_ts": int(ts or 0),
|
||||
},
|
||||
)
|
||||
resolved_text = relay_text
|
||||
if (not resolved_text) and uploaded_urls:
|
||||
resolved_text = "\n".join(uploaded_urls)
|
||||
elif (not resolved_text) and compose_media_urls:
|
||||
resolved_text = "\n".join(compose_media_urls)
|
||||
resolved_text_by_session[session_key] = resolved_text
|
||||
return resolved_text_by_session
|
||||
|
||||
async def _process_raw_inbound_event(self, raw_message: str):
|
||||
try:
|
||||
@@ -1361,6 +1778,15 @@ class SignalClient(ClientBase):
|
||||
envelope = payload.get("envelope") or {}
|
||||
if not isinstance(envelope, dict):
|
||||
return
|
||||
group_candidates = _extract_signal_group_identifiers(payload)
|
||||
preferred_group_id = ""
|
||||
for candidate in group_candidates:
|
||||
cleaned = str(candidate or "").strip()
|
||||
if cleaned.startswith("group."):
|
||||
preferred_group_id = cleaned
|
||||
break
|
||||
if not preferred_group_id and group_candidates:
|
||||
preferred_group_id = str(group_candidates[0] or "").strip()
|
||||
sync_sent_message = _get_nested(envelope, ("syncMessage", "sentMessage")) or {}
|
||||
if isinstance(sync_sent_message, dict) and sync_sent_message:
|
||||
raw_text = sync_sent_message.get("message")
|
||||
@@ -1395,6 +1821,7 @@ class SignalClient(ClientBase):
|
||||
identifiers = await self._resolve_signal_identifiers(
|
||||
destination_uuid,
|
||||
destination_number,
|
||||
group_candidates,
|
||||
)
|
||||
if not identifiers:
|
||||
identifiers = await self._auto_link_single_user_signal_identifier(
|
||||
@@ -1532,7 +1959,12 @@ class SignalClient(ClientBase):
|
||||
or str(payload.get("account") or "").strip()
|
||||
or "self"
|
||||
)
|
||||
source_chat_id = destination_number or destination_uuid or sender_key
|
||||
source_chat_id = (
|
||||
preferred_group_id
|
||||
or destination_number
|
||||
or destination_uuid
|
||||
or sender_key
|
||||
)
|
||||
reply_ref = reply_sync.extract_reply_ref(self.service, payload)
|
||||
for identifier in identifiers:
|
||||
session = await history.get_chat_session(
|
||||
@@ -1599,7 +2031,11 @@ class SignalClient(ClientBase):
|
||||
):
|
||||
return
|
||||
|
||||
identifiers = await self._resolve_signal_identifiers(source_uuid, source_number)
|
||||
identifiers = await self._resolve_signal_identifiers(
|
||||
source_uuid,
|
||||
source_number,
|
||||
group_candidates,
|
||||
)
|
||||
reaction_payload = _extract_signal_reaction(envelope)
|
||||
edit_payload = _extract_signal_edit(envelope)
|
||||
delete_payload = _extract_signal_delete(envelope)
|
||||
@@ -1620,6 +2056,7 @@ class SignalClient(ClientBase):
|
||||
identifiers = await self._resolve_signal_identifiers(
|
||||
destination_uuid,
|
||||
destination_number,
|
||||
group_candidates,
|
||||
)
|
||||
if not identifiers:
|
||||
identifiers = await self._auto_link_single_user_signal_identifier(
|
||||
@@ -1730,7 +2167,13 @@ class SignalClient(ClientBase):
|
||||
text = _extract_signal_text(
|
||||
payload, str(data_message.get("message") or "").strip()
|
||||
)
|
||||
if not text:
|
||||
relay_text = text
|
||||
xmpp_attachments, compose_media_urls = await self._build_xmpp_relay_attachments(
|
||||
payload
|
||||
)
|
||||
if xmpp_attachments and _is_compose_blob_only_text(relay_text):
|
||||
relay_text = ""
|
||||
if not relay_text and not xmpp_attachments:
|
||||
return
|
||||
|
||||
ts_raw = (
|
||||
@@ -1753,8 +2196,17 @@ class SignalClient(ClientBase):
|
||||
or source_number
|
||||
or (identifiers[0].identifier if identifiers else "")
|
||||
)
|
||||
source_chat_id = source_number or source_uuid or sender_key
|
||||
source_chat_id = preferred_group_id or source_number or source_uuid or sender_key
|
||||
reply_ref = reply_sync.extract_reply_ref(self.service, payload)
|
||||
resolved_text_by_session = await self._relay_signal_inbound_to_xmpp(
|
||||
identifiers=identifiers,
|
||||
relay_text=relay_text,
|
||||
xmpp_attachments=xmpp_attachments,
|
||||
compose_media_urls=compose_media_urls,
|
||||
source_uuid=source_uuid,
|
||||
source_number=source_number,
|
||||
ts=ts,
|
||||
)
|
||||
|
||||
for identifier in identifiers:
|
||||
session = await history.get_chat_session(identifier.user, identifier)
|
||||
@@ -1773,10 +2225,14 @@ class SignalClient(ClientBase):
|
||||
)()
|
||||
if exists:
|
||||
continue
|
||||
message_text = resolved_text_by_session.get(
|
||||
(identifier.user.id, identifier.person.id),
|
||||
relay_text if relay_text else "\n".join(compose_media_urls),
|
||||
)
|
||||
local_message = await history.store_message(
|
||||
session=session,
|
||||
sender=sender_key,
|
||||
text=text,
|
||||
text=message_text,
|
||||
ts=ts,
|
||||
outgoing=False,
|
||||
source_service=self.service,
|
||||
@@ -1792,7 +2248,7 @@ class SignalClient(ClientBase):
|
||||
await self.ur.message_received(
|
||||
self.service,
|
||||
identifier=identifier,
|
||||
text=text,
|
||||
text=message_text,
|
||||
ts=ts,
|
||||
payload=payload,
|
||||
local_message=local_message,
|
||||
@@ -1809,16 +2265,45 @@ class SignalClient(ClientBase):
|
||||
if not signal_number:
|
||||
return
|
||||
uri = f"ws://{SIGNAL_URL}/v1/receive/{signal_number}"
|
||||
poll_uri = f"http://{SIGNAL_URL}/v1/receive/{signal_number}"
|
||||
use_http_polling = False
|
||||
while not self._stopping:
|
||||
try:
|
||||
transport.update_runtime_state(self.service, accounts=[signal_number])
|
||||
if use_http_polling:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(poll_uri) as response:
|
||||
response.raise_for_status()
|
||||
payload = await response.json(content_type=None)
|
||||
if isinstance(payload, dict):
|
||||
payload = [payload]
|
||||
if not isinstance(payload, list):
|
||||
payload = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
await self._process_raw_inbound_event(json.dumps(item))
|
||||
continue
|
||||
async with websockets.connect(uri, ping_interval=None) as websocket:
|
||||
async for raw_message in websocket:
|
||||
await self._process_raw_inbound_event(raw_message)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if (
|
||||
not use_http_polling
|
||||
and "server rejected WebSocket connection: HTTP 200" in str(exc)
|
||||
):
|
||||
self.log.info(
|
||||
"signal raw-receive switching to HTTP polling for %s",
|
||||
signal_number,
|
||||
)
|
||||
use_http_polling = True
|
||||
continue
|
||||
self.log.warning("signal raw-receive loop error: %s", exc)
|
||||
await asyncio.sleep(2)
|
||||
transport.update_runtime_state(self.service, accounts=[])
|
||||
|
||||
def start(self):
|
||||
self.log.info("Signal client starting...")
|
||||
@@ -1828,6 +2313,10 @@ class SignalClient(ClientBase):
|
||||
self._command_task = self.loop.create_task(self._command_loop())
|
||||
if not self._raw_receive_task or self._raw_receive_task.done():
|
||||
self._raw_receive_task = self.loop.create_task(self._raw_receive_loop())
|
||||
# Use direct websocket receive loop as primary ingestion path.
|
||||
# signalbot's internal receive consumer can compete for the same stream
|
||||
# and starve inbound events in this deployment, so we keep it disabled.
|
||||
if not self._catalog_refresh_task or self._catalog_refresh_task.done():
|
||||
self._catalog_refresh_task = self.loop.create_task(
|
||||
self._refresh_runtime_catalog_safe()
|
||||
)
|
||||
# Keep signalbot's internal receive consumer disabled to avoid competing
|
||||
# consumers. The raw loop adapts between websocket and HTTP polling
|
||||
# depending on the deployed signal-cli-rest-api behavior.
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
@@ -444,23 +445,6 @@ def list_accounts(service: str):
|
||||
Return account identifiers for service UI list.
|
||||
"""
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
import requests
|
||||
|
||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
|
||||
"/"
|
||||
)
|
||||
try:
|
||||
response = requests.get(f"{base}/v1/accounts", timeout=20)
|
||||
if not response.ok:
|
||||
return []
|
||||
payload = orjson.loads(response.text or "[]")
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
state = get_runtime_state(service_key)
|
||||
accounts = state.get("accounts") or []
|
||||
if service_key == "whatsapp" and not accounts:
|
||||
@@ -495,13 +479,24 @@ def _wipe_signal_cli_local_state() -> bool:
|
||||
Best-effort local signal-cli state reset for json-rpc deployments where
|
||||
REST account delete endpoints are unavailable.
|
||||
"""
|
||||
config_roots = (
|
||||
"/code/signal-cli-config",
|
||||
"/signal-cli-config",
|
||||
"/home/.local/share/signal-cli",
|
||||
config_roots = []
|
||||
base_dir = getattr(settings, "BASE_DIR", None)
|
||||
if base_dir:
|
||||
config_roots.append(str(Path(base_dir) / "signal-cli-config"))
|
||||
config_roots.extend(
|
||||
[
|
||||
"/code/signal-cli-config",
|
||||
"/signal-cli-config",
|
||||
"/home/.local/share/signal-cli",
|
||||
]
|
||||
)
|
||||
removed_any = False
|
||||
seen_roots = set()
|
||||
for root in config_roots:
|
||||
root = str(root or "").strip()
|
||||
if not root or root in seen_roots:
|
||||
continue
|
||||
seen_roots.add(root)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
try:
|
||||
@@ -554,7 +549,26 @@ def unlink_account(service: str, account: str) -> bool:
|
||||
continue
|
||||
if unlinked:
|
||||
return True
|
||||
return _wipe_signal_cli_local_state()
|
||||
wiped = _wipe_signal_cli_local_state()
|
||||
if not wiped:
|
||||
return False
|
||||
# Best-effort verification: if the REST API still reports the same account,
|
||||
# the runtime likely still holds active linked state and the UI should not
|
||||
# claim relink is ready yet.
|
||||
remaining_accounts = list_accounts("signal")
|
||||
for row in remaining_accounts:
|
||||
if isinstance(row, dict):
|
||||
candidate = (
|
||||
row.get("number")
|
||||
or row.get("id")
|
||||
or row.get("jid")
|
||||
or row.get("account")
|
||||
)
|
||||
else:
|
||||
candidate = row
|
||||
if _account_key(str(candidate or "")) == _account_key(account_value):
|
||||
return False
|
||||
return True
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
state = get_runtime_state(service_key)
|
||||
@@ -842,9 +856,7 @@ async def send_message_raw(
|
||||
runtime_result = await runtime_client.send_message_raw(
|
||||
recipient,
|
||||
text=text,
|
||||
attachments=await prepare_outbound_attachments(
|
||||
service_key, attachments or []
|
||||
),
|
||||
attachments=attachments or [],
|
||||
metadata=dict(metadata or {}),
|
||||
)
|
||||
if runtime_result is not False and runtime_result is not None:
|
||||
@@ -853,11 +865,8 @@ async def send_message_raw(
|
||||
log.warning("%s runtime send failed: %s", service_key, exc)
|
||||
# Web/UI process cannot access UR in-process runtime client directly.
|
||||
# Hand off send to UR via shared cache command queue.
|
||||
prepared_attachments = await prepare_outbound_attachments(
|
||||
service_key, attachments or []
|
||||
)
|
||||
command_attachments = []
|
||||
for att in prepared_attachments:
|
||||
for att in (attachments or []):
|
||||
row = dict(att or {})
|
||||
# Keep payload cache-friendly and avoid embedding raw bytes.
|
||||
for key in ("content",):
|
||||
@@ -1082,9 +1091,8 @@ async def fetch_attachment(service: str, attachment_ref: dict):
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
attachment_id = attachment_ref.get("id") or attachment_ref.get("attachment_id")
|
||||
if not attachment_id:
|
||||
return None
|
||||
return await signalapi.fetch_signal_attachment(attachment_id)
|
||||
if attachment_id:
|
||||
return await signalapi.fetch_signal_attachment(attachment_id)
|
||||
|
||||
runtime_client = get_runtime_client(service_key)
|
||||
if runtime_client and hasattr(runtime_client, "fetch_attachment"):
|
||||
@@ -1160,7 +1168,7 @@ def get_link_qr(service: str, device_name: str):
|
||||
response = requests.get(
|
||||
f"{base}/v1/qrcodelink",
|
||||
params={"device_name": device},
|
||||
timeout=20,
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
@@ -2105,45 +2105,28 @@ class WhatsAppClient(ClientBase):
|
||||
"""
|
||||
Extract user-visible text from diverse WhatsApp message payload shapes.
|
||||
"""
|
||||
candidates = (
|
||||
self._pluck(msg_obj, "conversation"),
|
||||
self._pluck(msg_obj, "Conversation"),
|
||||
self._pluck(msg_obj, "extendedTextMessage", "text"),
|
||||
self._pluck(msg_obj, "ExtendedTextMessage", "Text"),
|
||||
self._pluck(msg_obj, "extended_text_message", "text"),
|
||||
self._pluck(msg_obj, "imageMessage", "caption"),
|
||||
self._pluck(msg_obj, "videoMessage", "caption"),
|
||||
self._pluck(msg_obj, "documentMessage", "caption"),
|
||||
self._pluck(msg_obj, "ephemeralMessage", "message", "conversation"),
|
||||
self._pluck(
|
||||
msg_obj, "ephemeralMessage", "message", "extendedTextMessage", "text"
|
||||
),
|
||||
self._pluck(msg_obj, "viewOnceMessage", "message", "conversation"),
|
||||
self._pluck(
|
||||
msg_obj, "viewOnceMessage", "message", "extendedTextMessage", "text"
|
||||
),
|
||||
self._pluck(msg_obj, "viewOnceMessageV2", "message", "conversation"),
|
||||
self._pluck(
|
||||
msg_obj, "viewOnceMessageV2", "message", "extendedTextMessage", "text"
|
||||
),
|
||||
self._pluck(
|
||||
msg_obj, "viewOnceMessageV2Extension", "message", "conversation"
|
||||
),
|
||||
self._pluck(
|
||||
msg_obj,
|
||||
"viewOnceMessageV2Extension",
|
||||
"message",
|
||||
"extendedTextMessage",
|
||||
"text",
|
||||
),
|
||||
for candidate in self._iter_message_variants(msg_obj):
|
||||
for value in (
|
||||
self._pluck(candidate, "conversation"),
|
||||
self._pluck(candidate, "Conversation"),
|
||||
self._pluck(candidate, "extendedTextMessage", "text"),
|
||||
self._pluck(candidate, "ExtendedTextMessage", "Text"),
|
||||
self._pluck(candidate, "extended_text_message", "text"),
|
||||
self._pluck(candidate, "imageMessage", "caption"),
|
||||
self._pluck(candidate, "videoMessage", "caption"),
|
||||
self._pluck(candidate, "documentMessage", "caption"),
|
||||
):
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
for value in (
|
||||
self._pluck(event_obj, "message", "conversation"),
|
||||
self._pluck(event_obj, "message", "extendedTextMessage", "text"),
|
||||
self._pluck(event_obj, "Message", "conversation"),
|
||||
self._pluck(event_obj, "Message", "extendedTextMessage", "text"),
|
||||
self._pluck(event_obj, "conversation"),
|
||||
self._pluck(event_obj, "text"),
|
||||
)
|
||||
for value in candidates:
|
||||
):
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return text
|
||||
@@ -2318,7 +2301,40 @@ class WhatsAppClient(ClientBase):
|
||||
return str(user)
|
||||
return raw
|
||||
|
||||
def _is_media_message(self, message_obj):
|
||||
def _iter_message_variants(self, message_obj, max_depth: int = 8):
|
||||
wrapper_paths = (
|
||||
("deviceSentMessage", "message"),
|
||||
("DeviceSentMessage", "Message"),
|
||||
("ephemeralMessage", "message"),
|
||||
("EphemeralMessage", "Message"),
|
||||
("viewOnceMessage", "message"),
|
||||
("ViewOnceMessage", "Message"),
|
||||
("viewOnceMessageV2", "message"),
|
||||
("ViewOnceMessageV2", "Message"),
|
||||
("viewOnceMessageV2Extension", "message"),
|
||||
("ViewOnceMessageV2Extension", "Message"),
|
||||
("editedMessage", "message"),
|
||||
("EditedMessage", "Message"),
|
||||
)
|
||||
queue = [(message_obj, 0)]
|
||||
seen = set()
|
||||
while queue:
|
||||
current, depth = queue.pop(0)
|
||||
if current is None:
|
||||
continue
|
||||
marker = id(current)
|
||||
if marker in seen:
|
||||
continue
|
||||
seen.add(marker)
|
||||
yield current
|
||||
if depth >= max_depth:
|
||||
continue
|
||||
for path in wrapper_paths:
|
||||
nested = self._pluck(current, *path)
|
||||
if nested is not None:
|
||||
queue.append((nested, depth + 1))
|
||||
|
||||
def _direct_media_payload(self, message_obj):
|
||||
media_fields = (
|
||||
"imageMessage",
|
||||
"videoMessage",
|
||||
@@ -2334,8 +2350,17 @@ class WhatsAppClient(ClientBase):
|
||||
for field in media_fields:
|
||||
value = self._pluck(message_obj, field)
|
||||
if value:
|
||||
return True
|
||||
return False
|
||||
return value
|
||||
return None
|
||||
|
||||
def _resolve_media_message(self, message_obj):
|
||||
for candidate in self._iter_message_variants(message_obj):
|
||||
if self._direct_media_payload(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _is_media_message(self, message_obj):
|
||||
return self._resolve_media_message(message_obj) is not None
|
||||
|
||||
def _infer_media_content_type(self, message_obj):
|
||||
if self._pluck(message_obj, "imageMessage") or self._pluck(
|
||||
@@ -2439,13 +2464,14 @@ class WhatsAppClient(ClientBase):
|
||||
if not self._client:
|
||||
return []
|
||||
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
|
||||
if msg_obj is None or not self._is_media_message(msg_obj):
|
||||
media_msg = self._resolve_media_message(msg_obj)
|
||||
if media_msg is None:
|
||||
return []
|
||||
if not hasattr(self._client, "download_any"):
|
||||
return []
|
||||
|
||||
try:
|
||||
payload = await self._maybe_await(self._client.download_any(msg_obj))
|
||||
payload = await self._maybe_await(self._client.download_any(media_msg))
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp media download failed: %s", exc)
|
||||
return []
|
||||
@@ -2455,19 +2481,21 @@ class WhatsAppClient(ClientBase):
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
return []
|
||||
|
||||
filename = self._pluck(msg_obj, "documentMessage", "fileName") or self._pluck(
|
||||
msg_obj, "document_message", "file_name"
|
||||
filename = self._pluck(
|
||||
media_msg, "documentMessage", "fileName"
|
||||
) or self._pluck(
|
||||
media_msg, "document_message", "file_name"
|
||||
)
|
||||
content_type = (
|
||||
self._pluck(msg_obj, "documentMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "document_message", "mimetype")
|
||||
or self._pluck(msg_obj, "imageMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "image_message", "mimetype")
|
||||
or self._pluck(msg_obj, "videoMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "video_message", "mimetype")
|
||||
or self._pluck(msg_obj, "audioMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "audio_message", "mimetype")
|
||||
or self._infer_media_content_type(msg_obj)
|
||||
self._pluck(media_msg, "documentMessage", "mimetype")
|
||||
or self._pluck(media_msg, "document_message", "mimetype")
|
||||
or self._pluck(media_msg, "imageMessage", "mimetype")
|
||||
or self._pluck(media_msg, "image_message", "mimetype")
|
||||
or self._pluck(media_msg, "videoMessage", "mimetype")
|
||||
or self._pluck(media_msg, "video_message", "mimetype")
|
||||
or self._pluck(media_msg, "audioMessage", "mimetype")
|
||||
or self._pluck(media_msg, "audio_message", "mimetype")
|
||||
or self._infer_media_content_type(media_msg)
|
||||
)
|
||||
if not filename:
|
||||
ext = mimetypes.guess_extension(
|
||||
@@ -2651,6 +2679,38 @@ class WhatsAppClient(ClientBase):
|
||||
if not identifiers:
|
||||
return
|
||||
|
||||
is_self_chat = bool(
|
||||
is_from_me
|
||||
and str(sender or "").strip()
|
||||
and str(chat or "").strip()
|
||||
and str(sender).strip() == str(chat).strip()
|
||||
)
|
||||
if is_self_chat and str(text or "").strip().startswith("."):
|
||||
responded_user_ids = set()
|
||||
reply_target = str(chat or sender or "").strip()
|
||||
for identifier in identifiers:
|
||||
if identifier.user_id in responded_user_ids:
|
||||
continue
|
||||
responded_user_ids.add(identifier.user_id)
|
||||
replies = await self.ur.xmpp.client.execute_gateway_command(
|
||||
sender_user=identifier.user,
|
||||
body=text,
|
||||
service=self.service,
|
||||
channel_identifier=str(identifier.identifier or ""),
|
||||
sender_identifier=str(identifier.identifier or ""),
|
||||
local_message=None,
|
||||
message_meta={
|
||||
"whatsapp": {
|
||||
"sender": str(sender or ""),
|
||||
"chat": str(chat or ""),
|
||||
"self_chat": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
for line in replies:
|
||||
await self.send_message_raw(reply_target, f"[>] {line}")
|
||||
return
|
||||
|
||||
attachments = await self._download_event_media(event)
|
||||
xmpp_attachments = []
|
||||
if attachments:
|
||||
@@ -3186,28 +3246,20 @@ class WhatsAppClient(ClientBase):
|
||||
url = (attachment or {}).get("url")
|
||||
if url:
|
||||
safe_url = validate_attachment_url(url)
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(safe_url) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
payload = await response.read()
|
||||
filename, content_type = validate_attachment_metadata(
|
||||
filename=(attachment or {}).get("filename")
|
||||
or safe_url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
content_type=(attachment or {}).get("content_type")
|
||||
or response.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
size=len(payload),
|
||||
)
|
||||
return {
|
||||
"content": payload,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"size": len(payload),
|
||||
}
|
||||
filename, content_type = validate_attachment_metadata(
|
||||
filename=(attachment or {}).get("filename")
|
||||
or safe_url.rstrip("/").split("/")[-1]
|
||||
or "attachment.bin",
|
||||
content_type=(attachment or {}).get("content_type")
|
||||
or "application/octet-stream",
|
||||
size=(attachment or {}).get("size"),
|
||||
)
|
||||
return {
|
||||
"url": safe_url,
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"size": (attachment or {}).get("size"),
|
||||
}
|
||||
return None
|
||||
|
||||
async def send_message_raw(
|
||||
@@ -3364,18 +3416,26 @@ class WhatsAppClient(ClientBase):
|
||||
payload = await self._fetch_attachment_payload(attachment)
|
||||
if not payload:
|
||||
continue
|
||||
data = payload.get("content") or b""
|
||||
data = payload.get("content")
|
||||
source_url = str(payload.get("url") or "").strip()
|
||||
try:
|
||||
filename, mime = validate_attachment_metadata(
|
||||
filename=payload.get("filename") or "attachment.bin",
|
||||
content_type=payload.get("content_type")
|
||||
or "application/octet-stream",
|
||||
size=payload.get("size")
|
||||
or (len(data) if isinstance(data, (bytes, bytearray)) else 0),
|
||||
or (len(data) if isinstance(data, (bytes, bytearray)) else None),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp blocked attachment: %s", exc)
|
||||
continue
|
||||
file_arg = (
|
||||
data
|
||||
if isinstance(data, (bytes, bytearray))
|
||||
else source_url
|
||||
)
|
||||
if not file_arg:
|
||||
continue
|
||||
mime = str(mime).lower()
|
||||
attachment_target = jid_obj if jid_obj is not None else jid
|
||||
send_method = "document"
|
||||
@@ -3392,27 +3452,31 @@ class WhatsAppClient(ClientBase):
|
||||
send_method,
|
||||
mime,
|
||||
filename,
|
||||
len(data) if isinstance(data, (bytes, bytearray)) else 0,
|
||||
(
|
||||
len(data)
|
||||
if isinstance(data, (bytes, bytearray))
|
||||
else (payload.get("size") or 0)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
if mime.startswith("image/") and hasattr(self._client, "send_image"):
|
||||
response = await self._maybe_await(
|
||||
self._client.send_image(attachment_target, data, caption="")
|
||||
self._client.send_image(attachment_target, file_arg, caption="")
|
||||
)
|
||||
elif mime.startswith("video/") and hasattr(self._client, "send_video"):
|
||||
response = await self._maybe_await(
|
||||
self._client.send_video(attachment_target, data, caption="")
|
||||
self._client.send_video(attachment_target, file_arg, caption="")
|
||||
)
|
||||
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
|
||||
response = await self._maybe_await(
|
||||
self._client.send_audio(attachment_target, data)
|
||||
self._client.send_audio(attachment_target, file_arg)
|
||||
)
|
||||
elif hasattr(self._client, "send_document"):
|
||||
response = await self._maybe_await(
|
||||
self._client.send_document(
|
||||
attachment_target,
|
||||
data,
|
||||
file_arg,
|
||||
filename=filename,
|
||||
mimetype=mime,
|
||||
caption="",
|
||||
|
||||
1034
core/clients/xmpp.py
1034
core/clients/xmpp.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user