Implement XMPP relaying

This commit is contained in:
2025-02-21 21:34:47 +00:00
parent 018d2f87c7
commit 8d2f28f571
17 changed files with 941 additions and 77 deletions

View File

@@ -48,3 +48,8 @@ if DEBUG:
SETTINGS_EXPORT = ["BILLING_ENABLED"] SETTINGS_EXPORT = ["BILLING_ENABLED"]
SIGNAL_NUMBER = getenv("SIGNAL_NUMBER") SIGNAL_NUMBER = getenv("SIGNAL_NUMBER")
XMPP_ADDRESS = getenv("XMPP_ADDRESS")
XMPP_JID = getenv("XMPP_JID")
XMPP_PORT = getenv("XMPP_PORT")
XMPP_SECRET = getenv("XMPP_SECRET")

View File

@@ -211,4 +211,24 @@ urlpatterns = [
# Queues # Queues
path("api/v1/queue/message/accept/<str:message_id>/", queues.AcceptMessageAPI.as_view(), name="message_accept_api"), path("api/v1/queue/message/accept/<str:message_id>/", queues.AcceptMessageAPI.as_view(), name="message_accept_api"),
path("api/v1/queue/message/reject/<str:message_id>/", queues.RejectMessageAPI.as_view(), name="message_reject_api"), path("api/v1/queue/message/reject/<str:message_id>/", queues.RejectMessageAPI.as_view(), name="message_reject_api"),
path(
"queue/<str:type>/",
queues.QueueList.as_view(),
name="queues",
),
path(
"queue/<str:type>/create/",
queues.QueueCreate.as_view(),
name="queue_create",
),
path(
"queue/<str:type>/update/<str:pk>/",
queues.QueueUpdate.as_view(),
name="queue_update",
),
path(
"queue/<str:type>/delete/<str:pk>/",
queues.QueueDelete.as_view(),
name="queue_delete",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

75
auth_django.py Executable file
View File

@@ -0,0 +1,75 @@
# Create a debug log to confirm script execution
import sys
import django
import os
LOG_PATH = "auth_debug.log"
def log(data):
with open(LOG_PATH, "a") as f:
f.write(f"{data}\n")
# Set up Django environment
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed
django.setup()
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
def check_credentials(username, password):
"""Authenticate user via Django"""
user = authenticate(username=username, password=password)
return user is not None and user.is_active
def main():
"""Process authentication requests from Prosody"""
while True:
try:
# Read a single line from stdin
line = sys.stdin.readline().strip()
if not line:
break # Exit if input is empty (EOF)
# Log received command (for debugging)
# log(f"Received: {line}")
parts = line.split(":")
if len(parts) < 3:
log("Sending 0")
print("0", flush=True) # Invalid format, return failure
continue
command, username, domain = parts[:3]
password = ":".join(parts[3:]) if len(parts) > 3 else None # Reconstruct password
if command == "auth":
if password and check_credentials(username, password):
log(f"Authentication success")
log("Sent 1")
print("1", flush=True) # Success
else:
log(f"Authentication failure")
log("Sent 0")
print("0", flush=True) # Failure
elif command == "isuser":
if User.objects.filter(username=username).exists():
print("1", flush=True) # User exists
else:
print("0", flush=True) # User does not exist
elif command == "setpass":
print("0", flush=True) # Not supported
else:
print("0", flush=True) # Unknown command, return failure
except Exception as e:
# Log any unexpected errors
log(f"Error: {str(e)}\n")
print("0", flush=True) # Return failure for any error
if __name__ == "__main__":
main()

2
auth_django.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
podman exec -i gia sh -c "cd /code && . /venv/bin/activate && python auth_django.py"

View File

@@ -6,36 +6,23 @@ from rest_framework import status
from django.http import HttpResponse from django.http import HttpResponse
from core.models import QueuedMessage, Message from core.models import QueuedMessage, Message
import requests import requests
from requests.exceptions import RequestException
import orjson import orjson
from django.conf import settings from django.conf import settings
from core.messaging import natural from core.messaging import natural
import aiohttp import aiohttp
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from core.clients import signalapi
async def start_typing(uuid):
url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
data = {"recipient": uuid}
async with aiohttp.ClientSession() as session:
async with session.put(url, json=data) as response:
return await response.text() # Optional: Return response content
async def stop_typing(uuid):
url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
data = {"recipient": uuid}
async with aiohttp.ClientSession() as session:
async with session.delete(url, json=data) as response:
return await response.text() # Optional: Return response content
async def send_message(db_obj): async def send_message(db_obj):
recipient_uuid = db_obj.session.identifier.identifier recipient_uuid = db_obj.session.identifier.identifier
text = db_obj.text text = db_obj.text
send = lambda x: send_message_raw(recipient_uuid, x) # returns ts send = lambda x: signalapi.send_message_raw(recipient_uuid, x) # returns ts
start_t = lambda: start_typing(recipient_uuid) start_t = lambda: signalapi.start_typing(recipient_uuid)
stop_t = lambda: stop_typing(recipient_uuid) stop_t = lambda: signalapi.stop_typing(recipient_uuid)
tss = await natural.natural_send_message( tss = await natural.natural_send_message(
text, text,
@@ -56,24 +43,3 @@ async def send_message(db_obj):
ts=ts1, # use that time in db ts=ts1, # use that time in db
) )
async def send_message_raw(recipient_uuid, text):
url = "http://signal:8080/v2/send"
data = {
"recipients": [recipient_uuid],
"message": text,
"number": settings.SIGNAL_NUMBER,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=data) as response:
response_text = await response.text()
response_status = response.status
if response_status == status.HTTP_201_CREATED:
ts = orjson.loads(response_text).get("timestamp", None)
if not ts:
return False
return ts
else:
return False

80
core/clients/signalapi.py Normal file
View File

@@ -0,0 +1,80 @@
from rest_framework import status
import requests
from requests.exceptions import RequestException
import orjson
from django.conf import settings
import aiohttp
async def start_typing(uuid):
url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
data = {"recipient": uuid}
async with aiohttp.ClientSession() as session:
async with session.put(url, json=data) as response:
return await response.text() # Optional: Return response content
async def stop_typing(uuid):
url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
data = {"recipient": uuid}
async with aiohttp.ClientSession() as session:
async with session.delete(url, json=data) as response:
return await response.text() # Optional: Return response content
async def send_message_raw(recipient_uuid, text):
url = "http://signal:8080/v2/send"
data = {
"recipients": [recipient_uuid],
"message": text,
"number": settings.SIGNAL_NUMBER,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=data) as response:
response_text = await response.text()
response_status = response.status
if response_status == status.HTTP_201_CREATED:
ts = orjson.loads(response_text).get("timestamp", None)
if not ts:
return False
return ts
else:
return False
def send_message_raw_sync(recipient_uuid, text):
"""
Sends a message using the Signal REST API in a synchronous manner.
Args:
recipient_uuid (str): The UUID of the recipient.
text (str): The message to send.
Returns:
int | bool: Timestamp if successful, False otherwise.
"""
url = "http://signal:8080/v2/send"
data = {
"recipients": [recipient_uuid],
"message": text,
"number": settings.SIGNAL_NUMBER,
}
try:
response = requests.post(url, json=data, timeout=10) # 10s timeout for safety
response.raise_for_status() # Raise an error for non-200 responses
except RequestException as e:
return False # Network or request error
if response.status_code == status.HTTP_201_CREATED: # Signal server returns 201 on success
try:
ts = orjson.loads(response.text).get("timestamp")
return ts if ts else False
except orjson.JSONDecodeError:
return False
return False # If response status is not 201

View File

@@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm
from django.forms import ModelForm from django.forms import ModelForm
from mixins.restrictions import RestrictedFormMixin from mixins.restrictions import RestrictedFormMixin
from .models import NotificationSettings, User, AI, PersonIdentifier, Person, Group, Persona, Manipulation, ChatSession, Message from .models import NotificationSettings, User, AI, PersonIdentifier, Person, Group, Persona, Manipulation, ChatSession, Message, QueuedMessage
# Create your forms here. # Create your forms here.
@@ -162,3 +162,13 @@ class MessageForm(RestrictedFormMixin, forms.ModelForm):
"text": "Content of the message.", "text": "Content of the message.",
"custom_author": "For detecting USER and BOT messages.", "custom_author": "For detecting USER and BOT messages.",
} }
class QueueForm(RestrictedFormMixin, forms.ModelForm):
class Meta:
model = QueuedMessage
fields = ("session", "manipulation", "text")
help_texts = {
"session": "Chat session this message will be sent in.",
"manipulation": "Manipulation that generated the message.",
"text": "Content of the proposed message.",
}

View File

@@ -1,25 +1,35 @@
# Deferred processing library # Deferred processing library
from core.util import logs from core.util import logs
from pydantic import BaseModel from pydantic import BaseModel
from typing import Annotated from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
from pydantic import ValidationError from pydantic import ValidationError
from core.models import QueuedMessage, Message from core.models import QueuedMessage, Message, PersonIdentifier, User
from core.clients import signal from core.clients import signal
from core.lib.prompts.functions import delete_messages from core.lib.prompts.functions import delete_messages
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from django.conf import settings
log = logs.get_logger("deferred") log = logs.get_logger("deferred")
class DeferredDetail(BaseModel):
reply_to_self: bool
reply_to_others: bool
is_outgoing_message: bool
class DeferredRequest(BaseModel): class DeferredRequest(BaseModel):
type: str type: str
method: str method: str
user_id: int user_id: Optional[int] = None
message_id: Annotated[str, UUID] message_id: Optional[Annotated[str, UUID]] = None
identifier: Optional[str] = None
msg: Optional[str] = None
service: Optional[str] = None
detail: Optional[DeferredDetail] = None
async def process_deferred(data: dict):
async def process_deferred(data: dict, **kwargs):
try: try:
validated_data = DeferredRequest(**data) validated_data = DeferredRequest(**data)
log.info(f"Validated Data: {validated_data}") log.info(f"Validated Data: {validated_data}")
@@ -32,6 +42,8 @@ async def process_deferred(data: dict):
user_id = validated_data.user_id user_id = validated_data.user_id
message_id = validated_data.message_id message_id = validated_data.message_id
if method == "accept_message":
try: try:
message = await sync_to_async(QueuedMessage.objects.get)( message = await sync_to_async(QueuedMessage.objects.get)(
user_id=user_id, user_id=user_id,
@@ -43,12 +55,27 @@ async def process_deferred(data: dict):
return return
if message.session.identifier.service == "signal": if message.session.identifier.service == "signal":
log.info(f"Is sisngla")
if method == "accept_message":
await signal.send_message(message) await signal.send_message(message)
else:
log.warning(f"Method not yet supported: {method}")
return
else: else:
log.warning(f"Protocol not supported: {message.session.identifier.service}") log.warning(f"Protocol not supported: {message.session.identifier.service}")
return return
elif method == "xmpp":
xmpp = kwargs.get("xmpp")
service = validated_data.service
msg = validated_data.msg
# Get User from identifier
identifiers = PersonIdentifier.objects.filter(
identifier=validated_data.identifier,
service=service,
)
for identifier in identifiers:
# Fair is fair, we can have multiple
log.info(f"Sending {msg} from {identifier}")
xmpp.send_from_external(identifier, msg, validated_data.detail)
else:
log.warning(f"Method not yet supported: {method}")
return

View File

@@ -0,0 +1,454 @@
from core.util import logs
from django.core.management.base import BaseCommand
from slixmpp.componentxmpp import ComponentXMPP
from django.conf import settings
from core.models import User, Person, PersonIdentifier
from redis import asyncio as aioredis
import asyncio
import msgpack
from core.lib import deferred
from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Paused, Inactive, Gone
from slixmpp.stanza import Message
log = logs.get_logger("component")
redis = aioredis.from_url("unix://var/run/gia-redis.sock", db=10)
class EchoComponent(ComponentXMPP):
"""
A simple Slixmpp component that echoes messages.
"""
def __init__(self, jid, secret, server, port):
super().__init__(jid, secret, server, port)
# Register chat state plugins
register_stanza_plugin(Message, Active)
register_stanza_plugin(Message, Composing)
register_stanza_plugin(Message, Paused)
register_stanza_plugin(Message, Inactive)
register_stanza_plugin(Message, Gone)
self.add_event_handler("session_start", self.session_start)
self.add_event_handler("disconnected", self.on_disconnected)
self.add_event_handler("message", self.message)
# Presence event handlers
self.add_event_handler("presence_available", self.on_presence_available)
self.add_event_handler("presence_dnd", self.on_presence_dnd)
self.add_event_handler("presence_xa", self.on_presence_xa)
self.add_event_handler("presence_chat", self.on_presence_chat)
self.add_event_handler("presence_away", self.on_presence_away)
self.add_event_handler("presence_unavailable", self.on_presence_unavailable)
self.add_event_handler("presence_subscribe", self.on_presence_subscribe)
self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
self.add_event_handler("roster_subscription_request", self.on_roster_subscription_request)
# Chat state handlers
self.add_event_handler("chatstate_active", self.on_chatstate_active)
self.add_event_handler("chatstate_composing", self.on_chatstate_composing)
self.add_event_handler("chatstate_paused", self.on_chatstate_paused)
self.add_event_handler("chatstate_inactive", self.on_chatstate_inactive)
self.add_event_handler("chatstate_gone", self.on_chatstate_gone)
def get_identifier(self, msg):
# Extract sender JID (full format: user@domain/resource)
sender_jid = str(msg["from"])
# Split into username@domain and optional resource
sender_parts = sender_jid.split("/", 1)
sender_bare_jid = sender_parts[0] # Always present: user@domain
sender_username, sender_domain = sender_bare_jid.split("@", 1)
sender_resource = sender_parts[1] if len(sender_parts) > 1 else None # Extract resource if present
# Extract recipient JID (should match component JID format)
recipient_jid = str(msg["to"])
if "@" in recipient_jid:
recipient_username, recipient_domain = recipient_jid.split("@", 1)
else:
recipient_username = recipient_jid
recipient_domain = recipient_jid
# Extract message body
body = msg["body"] if msg["body"] else "[No Body]"
# Parse recipient_name and recipient_service (e.g., "mark|signal")
if "|" in recipient_username:
person_name, service = recipient_username.split("|")
person_name = person_name.title() # Capitalize for consistency
else:
person_name = recipient_username.title()
service = None
try:
# Lookup user in Django
log.info(f"User {sender_username}")
user = User.objects.get(username=sender_username)
# Find Person object with name=person_name.lower()
log.info(f"Name {person_name.title()}")
person = Person.objects.get(user=user, name=person_name.title())
# Ensure a PersonIdentifier exists for this user, person, and service
log.info(f"Identifier {service}")
identifier = PersonIdentifier.objects.get(user=user, person=person, service=service)
return identifier
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
# If any lookup fails, reject the subscription
return None
def on_chatstate_active(self, msg):
"""
Handle when a user is actively engaged in the chat.
"""
log.info(f"Chat state: Active from {msg['from']}.")
identifier = self.get_identifier(msg)
def on_chatstate_composing(self, msg):
"""
Handle when a user is typing a message.
"""
log.info(f"Chat state: Composing from {msg['from']}.")
identifier = self.get_identifier(msg)
def on_chatstate_paused(self, msg):
"""
Handle when a user has paused typing.
"""
log.info(f"Chat state: Paused from {msg['from']}.")
identifier = self.get_identifier(msg)
def on_chatstate_inactive(self, msg):
"""
Handle when a user is inactive in the chat.
"""
log.info(f"Chat state: Inactive from {msg['from']}.")
identifier = self.get_identifier(msg)
def on_chatstate_gone(self, msg):
"""
Handle when a user has left the chat.
"""
log.info(f"Chat state: Gone from {msg['from']}.")
identifier = self.get_identifier(msg)
def on_presence_available(self, pres):
"""
Handle when a user becomes available.
"""
log.info(f"Presence available from {pres['from']}")
def on_presence_dnd(self, pres):
"""
Handle when a user sets 'Do Not Disturb' status.
"""
log.info(f"User {pres['from']} is now in 'Do Not Disturb' mode.")
def on_presence_xa(self, pres):
"""
Handle when a user sets 'Extended Away' status.
"""
log.info(f"User {pres['from']} is now 'Extended Away'.")
def on_presence_chat(self, pres):
"""
Handle when a user is actively available for chat.
"""
log.info(f"User {pres['from']} is now available for chat.")
def on_presence_away(self, pres):
"""
Handle when a user sets 'Away' status.
"""
log.info(f"User {pres['from']} is now 'Away'.")
def on_presence_unavailable(self, pres):
"""
Handle when a user goes offline or unavailable.
"""
log.info(f"User {pres['from']} is now unavailable.")
def on_presence_subscribe(self, pres):
"""
Handle incoming presence subscription requests.
Accept only if the recipient has a contact matching the sender.
"""
sender_jid = str(pres['from']).split('/')[0] # Bare JID (user@domain)
recipient_jid = str(pres['to']).split('/')[0]
log.info(f"Received subscription request from {sender_jid} to {recipient_jid}")
try:
# Extract sender and recipient usernames
user_username, _ = sender_jid.split("@", 1)
recipient_username, _ = recipient_jid.split("@", 1)
# Parse recipient_name and recipient_service (e.g., "mark|signal")
if "|" in recipient_username:
person_name, service = recipient_username.split("|")
person_name = person_name.title() # Capitalize for consistency
else:
person_name = recipient_username.title()
service = None
# Lookup user in Django
log.info(f"User {user_username}")
user = User.objects.get(username=user_username)
# Find Person object with name=person_name.lower()
log.info(f"Name {person_name.title()}")
person = Person.objects.get(user=user, name=person_name.title())
# Ensure a PersonIdentifier exists for this user, person, and service
log.info(f"Identifier {service}")
PersonIdentifier.objects.get(user=user, person=person, service=service)
# If all checks pass, accept the subscription
self.send_presence(ptype="subscribed", pto=sender_jid)
log.info(f"Subscription request from {sender_jid} accepted for {recipient_jid}.")
except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
# If any lookup fails, reject the subscription
log.warning(f"Subscription request from {sender_jid} rejected (recipient does not have this contact).")
self.send_presence(ptype="unsubscribed", pto=sender_jid)
def on_presence_subscribed(self, pres):
"""
Handle successful subscription confirmations.
"""
log.info(f"Subscription to {pres['from']} was accepted.")
def on_presence_unsubscribe(self, pres):
"""
Handle when a user unsubscribes from presence updates.
"""
log.info(f"User {pres['from']} has unsubscribed from presence updates.")
def on_presence_unsubscribed(self, pres):
"""
Handle when a user's unsubscription request is confirmed.
"""
log.info(f"Unsubscription from {pres['from']} confirmed.")
def on_roster_subscription_request(self, pres):
"""
Handle roster subscription requests.
"""
log.info(f"New roster subscription request from {pres['from']}.")
def session_start(self, *args):
log.info("XMPP session started")
def on_disconnected(self, *args):
"""
Handles XMPP disconnection and triggers a reconnect loop.
"""
log.warning("XMPP disconnected, attempting to reconnect...")
self.connect()
def session_start(self, *args):
log.info(f"START {args}")
def message(self, msg):
"""
Process incoming XMPP messages.
"""
sym = lambda x: msg.reply(f"[>] {x}").send()
# log.info(f"Received message: {msg}")
# Extract sender JID (full format: user@domain/resource)
sender_jid = str(msg["from"])
# Split into username@domain and optional resource
sender_parts = sender_jid.split("/", 1)
sender_bare_jid = sender_parts[0] # Always present: user@domain
sender_username, sender_domain = sender_bare_jid.split("@", 1)
sender_resource = sender_parts[1] if len(sender_parts) > 1 else None # Extract resource if present
# Extract recipient JID (should match component JID format)
recipient_jid = str(msg["to"])
if "@" in recipient_jid:
recipient_username, recipient_domain = recipient_jid.split("@", 1)
else:
recipient_username = recipient_jid
recipient_domain = recipient_jid
# Extract message body
body = msg["body"] if msg["body"] else "[No Body]"
# Log extracted information with variable name annotations
log_message = (
f"Sender JID: {sender_jid}, Sender Username: {sender_username}, Sender Domain: {sender_domain}, "
f"Sender Resource: {sender_resource if sender_resource else '[No Resource]'}, "
f"Recipient JID: {recipient_jid}, Recipient Username: {recipient_username}, Recipient Domain: {recipient_domain}, "
f"Body: {body}"
)
log.info(log_message)
# Ensure recipient domain matches our configured component
expected_domain = settings.XMPP_JID # 'jews.zm.is' in your config
if recipient_domain != expected_domain:
log.warning(f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}")
return
# Lookup sender in Django's User model
try:
sender_user = User.objects.get(username=sender_username)
except User.DoesNotExist:
log.warning(f"Unknown sender: {sender_username}")
return
if recipient_jid == settings.XMPP_JID:
log.info("Message to JID")
if body.startswith("."):
# Messaging the gateway directly
if body == ".contacts":
# Lookup Person objects linked to sender
persons = Person.objects.filter(user=sender_user)
if not persons.exists():
log.info(f"No contacts found for {sender_username}")
sym("No contacts found.")
return
# Construct contact list response
contact_names = [person.name for person in persons]
response_text = f"Contacts: " + ", ".join(contact_names)
sym(response_text)
elif body == ".whoami":
sym(str(sender_user.__dict__))
else:
sym("No such command")
else:
log.info("Other message")
if "|" in recipient_username:
recipient_name, recipient_service = recipient_username.split("|")
recipient_name = recipient_name.title()
else:
recipient_name = recipient_username
recipient_service = None
recipient_name = recipient_name.title()
try:
person = Person.objects.get(user=sender_user, name=recipient_name)
except Person.DoesNotExist:
sym("This person does not exist.")
if recipient_service:
try:
identifier = PersonIdentifier.objects.get(user=sender_user,
person=person,
service=recipient_service)
except PersonIdentifier.DoesNotExist:
sym("This service identifier does not exist.")
else:
# Get a random identifier
identifier = PersonIdentifier.objects.filter(user=sender_user,
person=person).first()
recipient_service = identifier.service
# sym(str(person.__dict__))
# sym(f"Service: {recipient_service}")
identifier.send(body)
def send_from_external(self, person_identifier, text, detail):
"""
This method will send an incoming external message to the correct XMPP user.
"""
sender_jid = f"{person_identifier.person.name.lower()}|{person_identifier.service}@{settings.XMPP_JID}"
recipient_jid = f"{person_identifier.user.username}@{settings.XMPP_ADDRESS}"
if detail.is_outgoing_message:
carbon_msg = f"""
<message xmlns="jabber:client" type="chat" from="{recipient_jid}" to="{sender_jid}">
<received xmlns="urn:xmpp:carbons:2">
<forwarded xmlns="urn:xmpp:forward:0">
<message from="{recipient_jid}" to="{sender_jid}" type="chat">
<body>{text}</body>
</message>
</forwarded>
</received>
</message>
"""
log.info(f"Sending Carbon: {carbon_msg}")
self.send_raw(carbon_msg)
else:
log.info(f"Forwarding message from external service: {sender_jid} -> {recipient_jid}: {text}")
self.send_message(mto=recipient_jid, mfrom=sender_jid, mbody=text, mtype="chat")
async def stream(**kwargs):
pubsub = redis.pubsub()
await pubsub.subscribe("component")
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True)
if message is not None:
try:
log.info("GOT", message)
data = message["data"]
unpacked = msgpack.unpackb(data, raw=False)
log.info(f"Unpacked: {unpacked}")
except TypeError:
log.info(f"FAILED {message}")
continue
if "type" in unpacked.keys():
if unpacked["type"] == "def":
await deferred.process_deferred(
unpacked,
**kwargs
)
await asyncio.sleep(0.01)
class Command(BaseCommand):
def handle(self, *args, **options):
xmpp = EchoComponent(
jid=settings.XMPP_JID,
secret=settings.XMPP_SECRET,
server=settings.XMPP_ADDRESS,
port=settings.XMPP_PORT,
)
xmpp.register_plugin('xep_0030') # Service Discovery
xmpp.register_plugin('xep_0004') # Data Forms
xmpp.register_plugin('xep_0060') # PubSub
xmpp.register_plugin('xep_0199') # XMPP Ping
xmpp.register_plugin("xep_0085") # Chat State Notifications
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(stream(xmpp=xmpp))
# Connect to the XMPP server and start processing XMPP stanzas.
xmpp.connect()
xmpp.process()
try:
while True:
pass # Keep the component running
except (KeyboardInterrupt, SystemExit):
log.info("XMPP Component terminating")

View File

@@ -22,6 +22,9 @@ SIGNAL_URL = "signal:8080"
log = logs.get_logger("processing") log = logs.get_logger("processing")
redis = aioredis.from_url("unix://var/run/gia-redis.sock", db=10)
class HandleMessage(Command): class HandleMessage(Command):
async def handle(self, c: Context): async def handle(self, c: Context):
msg = { msg = {
@@ -36,9 +39,18 @@ class HandleMessage(Command):
"mentions": c.message.mentions, "mentions": c.message.mentions,
"raw_message": c.message.raw_message "raw_message": c.message.raw_message
} }
dest = c.message.raw_message.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("destinationUuid") log.info("1")
account = c.message.raw_message.get("account", "") raw = json.loads(c.message.raw_message)
source_name = msg["raw_message"].get("envelope", {}).get("sourceName", "") print(json.dumps(c.message.raw_message, indent=2))
#dest = c.message.raw_message.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("destinationUuid")
dest = raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}).get("destinationUuid")
#account = c.message.raw_message.get("account", "")
account = raw.get("account", "")
log.info("2")
#source_name = msg["raw_message"].get("envelope", {}).get("sourceName", "")
source_name = raw.get("envelope", {}).get("sourceName", "")
log.info("3")
source_number = c.message.source_number source_number = c.message.source_number
source_uuid = c.message.source_uuid source_uuid = c.message.source_uuid
@@ -58,6 +70,21 @@ class HandleMessage(Command):
# Determine the identifier to use # Determine the identifier to use
identifier_uuid = dest if is_from_bot else source_uuid identifier_uuid = dest if is_from_bot else source_uuid
cast = {
"type": "def",
"method": "xmpp",
"service": "signal",
# "sender": source_uuid,
"identifier": identifier_uuid,
"msg": text,
"detail": {
"reply_to_self": reply_to_self,
"reply_to_others": reply_to_others,
"is_outgoing_message": is_outgoing_message,
}
}
packed = msgpack.packb(cast, use_bin_type=True)
await redis.publish("component", packed)
# TODO: Permission checks # TODO: Permission checks
manips = await sync_to_async(list)( manips = await sync_to_async(list)(
@@ -116,8 +143,14 @@ class HandleMessage(Command):
text=result, text=result,
ts=ts + 1, ts=ts + 1,
) )
log.info("NOT SENDING CHECK CODE IS OK") # log.info("NOT SENDING CHECK CODE IS OK")
# await natural.natural_send_message(c, result) # await natural.natural_send_message(c, result)
tss = await natural.natural_send_message(
result,
c.send,
c.start_typing,
c.stop_typing,
)
elif manip.mode == "notify": elif manip.mode == "notify":
title = f"[GIA] Suggested message to {person_identifier.person.name}" title = f"[GIA] Suggested message to {person_identifier.person.name}"
manip.user.sendmsg(result, title=title) manip.user.sendmsg(result, title=title)
@@ -180,8 +213,7 @@ class HandleMessage(Command):
) )
# #
async def pubsub(): async def stream():
redis = aioredis.from_url("unix://var/run/gia-redis.sock", db=10)
pubsub = redis.pubsub() pubsub = redis.pubsub()
await pubsub.subscribe("processing") await pubsub.subscribe("processing")
@@ -211,7 +243,7 @@ class Command(BaseCommand):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
bot._event_loop = loop bot._event_loop = loop
loop.create_task(pubsub()) loop.create_task(stream())
bot.start() bot.start()
try: try:
loop.run_forever() loop.run_forever()

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from core.lib.notify import raw_sendmsg from core.lib.notify import raw_sendmsg
from core.clients import signalapi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -118,6 +119,20 @@ class PersonIdentifier(models.Model):
def __str__(self): def __str__(self):
return f"{self.person} ({self.service})" return f"{self.person} ({self.service})"
def send(self, text):
"""
Send this contact a text.
"""
if self.service == "signal":
ts = signalapi.send_message_raw_sync(
self.identifier,
text
)
return ts
else:
raise NotImplementedError(f"Service not implemented: {self.service}")
class ChatSession(models.Model): class ChatSession(models.Model):
"""Represents an ongoing chat session, stores summarized history.""" """Represents an ongoing chat session, stores summarized history."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -244,6 +259,9 @@ class Manipulation(models.Model):
blank=True, null=True blank=True, null=True
) )
def __str__(self):
return f"{self.name} [{self.group}]"
# class Perms(models.Model): # class Perms(models.Model):
# class Meta: # class Meta:

View File

@@ -277,13 +277,16 @@
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">
History Storage
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'sessions' type='page' %}"> <a class="navbar-item" href="{% url 'sessions' type='page' %}">
Sessions Sessions
</a> </a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queued Messages
</a>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,71 @@
{% load cache %}
{% load cachalot cache %}
{% get_last_invalidation 'core.QueuedMessage' as last %}
{% include 'mixins/partials/notify.html' %}
{% cache 600 objects_queue request.user.id object_list type last %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>id</th>
<th>session</th>
<th>manipulation</th>
<th>ts</th>
<th>text</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.session }}</td>
<td>{{ item.manipulation }}</td>
<td>{{ item.ts }}</td>
<td>{{ item.text.length }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'queue_update' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-pencil"></i>
</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{% url 'queue_delete' type=type pk=item.id %}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete {{ item.id }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</table>
{% endcache %}

View File

@@ -5,12 +5,21 @@ from rest_framework import status
from django.http import HttpResponse from django.http import HttpResponse
from core.models import QueuedMessage, Message from core.models import QueuedMessage, Message
from core.forms import QueueForm
import requests import requests
import orjson import orjson
from django.conf import settings from django.conf import settings
import redis import redis
import msgpack import msgpack
from mixins.views import (
ObjectCreate,
ObjectDelete,
ObjectList,
ObjectUpdate,
)
# def start_typing(uuid): # def start_typing(uuid):
# url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}" # url = f"http://signal:8080/v1/typing_indicator/{settings.SIGNAL_NUMBER}"
# data = { # data = {
@@ -55,3 +64,31 @@ class RejectMessageAPI(LoginRequiredMixin, APIView):
message.delete() message.delete()
return HttpResponse(status=status.HTTP_200_OK) return HttpResponse(status=status.HTTP_200_OK)
class QueueList(LoginRequiredMixin, ObjectList):
list_template = "partials/queue-list.html"
model = QueuedMessage
page_title = "Queues"
list_url_name = "queues"
list_url_args = ["type"]
submit_url_name = "queue_create"
class QueueCreate(LoginRequiredMixin, ObjectCreate):
model = QueuedMessage
form_class = QueueForm
submit_url_name = "queue_create"
class QueueUpdate(LoginRequiredMixin, ObjectUpdate):
model = QueuedMessage
form_class = QueueForm
submit_url_name = "queue_update"
class QueueDelete(LoginRequiredMixin, ObjectDelete):
model = QueuedMessage

View File

@@ -33,7 +33,6 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
url = f"http://signal:8080/v1/accounts" url = f"http://signal:8080/v1/accounts"
response = requests.get(url) response = requests.get(url)
accounts = orjson.loads(response.text) accounts = orjson.loads(response.text)
print("ACCOUNTS", accounts)
return accounts return accounts
@@ -49,8 +48,6 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
# url = signal:8080/v1/accounts # url = signal:8080/v1/accounts
print("GET", self.request.GET)
print("KWARGS", self.kwargs)
# /v1/configuration/{number}/settings # /v1/configuration/{number}/settings
# /v1/identities/{number} # /v1/identities/{number}
# /v1/contacts/{number} # /v1/contacts/{number}
@@ -63,9 +60,6 @@ class SignalContactsList(SuperUserRequiredMixin, ObjectList):
response = requests.get(f"http://signal:8080/v1/contacts/{self.kwargs['pk']}") response = requests.get(f"http://signal:8080/v1/contacts/{self.kwargs['pk']}")
contacts = orjson.loads(response.text) contacts = orjson.loads(response.text)
print("identities", identities)
print("contacts", contacts)
# add identities to contacts # add identities to contacts
for contact in contacts: for contact in contacts:
for identity in identities: for identity in identities:

View File

@@ -31,6 +31,10 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -98,6 +102,59 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
depends_on:
redis:
condition: service_healthy
migration:
condition: service_started
collectstatic:
condition: service_started
# deploy:
# resources:
# limits:
# cpus: '0.25'
# memory: 0.25G
#network_mode: host
component:
image: xf/gia:prod
container_name: component_gia
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python manage.py component'
volumes:
- ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- type: bind
source: /code/vrun
target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -143,6 +200,10 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -187,6 +248,10 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
# deploy: # deploy:
# resources: # resources:
# limits: # limits:
@@ -224,6 +289,10 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
XMPP_ADDRESS: "${XMPP_ADDRESS}"
XMPP_JID: "${XMPP_JID}"
XMPP_PORT: "${XMPP_PORT}"
XMPP_SECRET: "${XMPP_SECRET}"
# deploy: # deploy:
# resources: # resources:
# limits: # limits:

View File

@@ -35,3 +35,4 @@ signalbot
openai openai
aiograpi aiograpi
aiomysql aiomysql
slixmpp