Files
GIA/core/clients/signal.py

367 lines
14 KiB
Python

import asyncio
import json
import aiohttp
from asgiref.sync import sync_to_async
from django.conf import settings
from django.urls import reverse
from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi
from core.lib.prompts.functions import delete_messages, truncate_and_summarize
from core.messaging import ai, history, natural, replies, utils
from core.models import Chat, Manipulation, PersonIdentifier, QueuedMessage
from core.util import logs
log = logs.get_logger("signalF")
if settings.DEBUG:
SIGNAL_HOST = "127.0.0.1"
else:
SIGNAL_HOST = "signal"
SIGNAL_PORT = 8080
SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}"
class NewSignalBot(SignalBot):
def __init__(self, ur, service, config):
self.ur = ur
self.service = service
self.signal_rest = config["signal_service"] # keep your own copy
self.phone_number = config["phone_number"]
super().__init__(config)
self.log = logs.get_logger("signalI")
self.bot_uuid = None
async def get_own_uuid(self) -> str | None:
async with aiohttp.ClientSession() as session:
# config may be "signal:8080" -- ensure http://
base = self.signal_rest
if not base.startswith("http"):
base = f"http://{base}"
uri = f"{base}/v1/contacts/{self.phone_number}"
try:
resp = await session.get(uri)
if resp.status != 200:
self.log.error(f"contacts lookup failed: {resp.status} {await resp.text()}")
return None
contacts_data = await resp.json()
if isinstance(contacts_data, list):
for contact in contacts_data:
if contact.get("number") == self.phone_number:
return contact.get("uuid")
return None
except Exception as e:
self.log.error(f"Failed to get UUID from contacts: {e}")
return None
async def initialize_bot(self):
"""Fetch bot's UUID and store it in self.bot_uuid."""
try:
self.bot_uuid = await self.get_own_uuid()
if self.bot_uuid:
self.log.info(f"Own UUID: {self.bot_uuid}")
else:
self.log.warning("Unable to fetch bot UUID.")
except Exception as e:
self.log.error(f"Failed to initialize bot UUID: {e}")
async def _async_post_init(self):
"""
Preserve SignalBot startup flow so protocol auto-detection runs.
This flips the client to plain HTTP/WS when HTTPS/WSS is unavailable.
"""
await self._check_signal_service()
await self.initialize_bot()
await self._detect_groups()
await self._resolve_commands()
await self._produce_consume_messages()
def start(self):
"""Start bot without blocking the caller's event loop."""
task = self._event_loop.create_task(
self._rerun_on_exception(self._async_post_init)
)
self._store_reference_to_task(task, self._running_tasks)
self.scheduler.start()
class HandleMessage(Command):
def __init__(self, ur, service, *args, **kwargs):
self.ur = ur
self.service = service
return super().__init__(*args, **kwargs)
async def handle(self, c: Context):
msg = {
"source": c.message.source,
"source_number": c.message.source_number,
"source_uuid": c.message.source_uuid,
"timestamp": c.message.timestamp,
"type": c.message.type.value,
"text": c.message.text,
"group": c.message.group,
"reaction": c.message.reaction,
"mentions": c.message.mentions,
"raw_message": c.message.raw_message
}
raw = json.loads(c.message.raw_message)
dest = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("destinationUuid")
account = raw.get("account", "")
source_name = raw.get("envelope", {}).get("sourceName", "")
source_number = c.message.source_number
source_uuid = c.message.source_uuid
text = c.message.text
ts = c.message.timestamp
# Message originating from us
same_recipient = source_uuid == dest
is_from_bot = source_uuid == c.bot.bot_uuid
is_to_bot = dest == c.bot.bot_uuid or dest is None
reply_to_self = same_recipient and is_from_bot # Reply
reply_to_others = is_to_bot and not same_recipient # Reply
is_outgoing_message = is_from_bot and not is_to_bot # Do not reply
# Determine the identifier to use
identifier_uuid = dest if is_from_bot else source_uuid
if not identifier_uuid:
log.warning("No Signal identifier available for message routing.")
return
# Handle attachments
attachments = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("attachments", [])
if not attachments:
attachments = raw.get("envelope", {}).get("dataMessage", {}).get("attachments", [])
attachment_list = []
for attachment in attachments:
attachment_list.append({
"id": attachment["id"],
"content_type": attachment["contentType"],
"filename": attachment["filename"],
"size": attachment["size"],
"width": attachment.get("width"),
"height": attachment.get("height"),
})
# Get users/person identifiers for this Signal sender/recipient.
identifiers = await sync_to_async(list)(
PersonIdentifier.objects.filter(
identifier=identifier_uuid,
service=self.service,
)
)
xmpp_attachments = []
# Asynchronously fetch all attachments
tasks = [signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list]
fetched_attachments = await asyncio.gather(*tasks)
log.info(f"ATTACHMENT LIST {attachment_list}")
for fetched, att in zip(fetched_attachments, attachment_list):
if not fetched:
log.warning(f"Failed to fetch attachment {att['id']} from Signal.")
continue
# Attach fetched file to XMPP
xmpp_attachments.append({
"content": fetched["content"],
"content_type": fetched["content_type"],
"filename": fetched["filename"],
"size": fetched["size"],
})
# Forward incoming Signal messages to XMPP and apply mutate rules.
for identifier in identifiers:
user = identifier.user
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:
for manip in mutate_manips:
prompt = replies.generate_mutate_reply_prompt(
text,
None,
manip,
None,
)
log.info("Running Signal mutate prompt")
result = await ai.run_prompt(prompt, manip.ai)
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
await self.ur.xmpp.client.send_from_external(
user,
identifier,
result,
is_outgoing_message,
attachments=xmpp_attachments,
)
else:
log.info(f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP.")
await self.ur.xmpp.client.send_from_external(
user,
identifier,
text,
is_outgoing_message,
attachments=xmpp_attachments,
)
# TODO: Permission checks
manips = await sync_to_async(list)(
Manipulation.objects.filter(enabled=True)
)
session_cache = {}
stored_messages = set()
for manip in manips:
try:
person_identifier = await sync_to_async(PersonIdentifier.objects.get)(
identifier=identifier_uuid,
user=manip.user,
service="signal",
person__in=manip.group.people.all(),
)
except PersonIdentifier.DoesNotExist:
log.warning(f"{manip.name}: Message from unknown identifier {identifier_uuid}.")
continue
# Find/create ChatSession once per user/person.
session_key = (manip.user.id, person_identifier.person.id)
if session_key in session_cache:
chat_session = session_cache[session_key]
else:
chat_session = await history.get_chat_session(manip.user, person_identifier)
session_cache[session_key] = chat_session
# Store each incoming/outgoing event once per session.
message_key = (chat_session.id, ts, source_uuid)
if message_key not in stored_messages:
log.info(f"Processing history store message {text}")
await history.store_message(
session=chat_session,
sender=source_uuid,
text=text,
ts=ts,
outgoing=is_from_bot,
)
stored_messages.add(message_key)
# Get the total history
chat_history = await history.get_chat_history(chat_session)
if replies.should_reply(
reply_to_self,
reply_to_others,
is_outgoing_message,
):
if manip.mode in ["silent", "mutate"]:
pass
elif manip.mode in ["active", "notify", "instant"]:
await utils.update_last_interaction(chat_session)
prompt = replies.generate_reply_prompt(
msg,
person_identifier.person,
manip,
chat_history
)
log.info("Running context prompt")
result = await ai.run_prompt(prompt, manip.ai)
if manip.mode == "active":
await history.store_own_message(
session=chat_session,
text=result,
ts=ts + 1,
)
await self.ur.xmpp.client.send_from_external(
manip.user,
person_identifier,
result,
is_outgoing_message=True,
)
await natural.natural_send_message(
result,
c.send,
c.start_typing,
c.stop_typing,
)
elif manip.mode == "notify":
title = f"[GIA] Suggested message to {person_identifier.person.name}"
manip.user.sendmsg(result, title=title)
elif manip.mode == "instant":
existing_queue = QueuedMessage.objects.filter(
user=chat_session.user,
session=chat_session,
manipulation=manip,
custom_author="BOT",
)
await delete_messages(existing_queue)
qm = await history.store_own_message(
session=chat_session,
text=result,
ts=ts + 1,
manip=manip,
queue=True,
)
accept = reverse(
"message_accept_api", kwargs={"message_id": qm.id}
)
reject = reverse(
"message_reject_api", kwargs={"message_id": qm.id}
)
url = settings.URL
content = (
f"{result}\n\n"
f"Accept: {url}{accept}\n"
f"Reject: {url}{reject}"
)
title = f"[GIA] Suggested message to {person_identifier.person.name}"
manip.user.sendmsg(content, title=title)
else:
log.error(f"Mode {manip.mode} is not implemented")
# Manage truncation & summarization
await truncate_and_summarize(chat_session, manip.ai)
await sync_to_async(Chat.objects.update_or_create)(
source_uuid=source_uuid,
defaults={
"source_number": source_number,
"source_name": source_name,
"account": account,
},
)
class SignalClient(ClientBase):
def __init__(self, ur, *args, **kwargs):
super().__init__(ur, *args, **kwargs)
self.client = NewSignalBot(
ur,
self.service,
{
"signal_service": SIGNAL_URL,
"phone_number": "+447490296227",
})
self.client.register(HandleMessage(self.ur, self.service))
def start(self):
self.log.info("Signal client starting...")
self.client._event_loop = self.loop
self.client.start()