This commit is contained in:
Matt Freeman 2024-06-29 18:49:26 -04:00
commit 3c4fe53600
12 changed files with 523 additions and 0 deletions

162
.gitignore vendored Normal file
View File

@ -0,0 +1,162 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
src/ollama_slixmpp_omemo_bot/omemo/*

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# Ollama Slixmpp Bot with OMEMO
A basic echo-bot built with slixmpp and slixmpp-omemo that relays your messages to a locally running ollama server.
## Dependancies
- [ollama 0.1.14](https://ollama.com/download/linux)
- [LLM](https://ollama.com/library)
- [llama3](https://ollama.com/library/llama3)
## Installation
```bash
git clone --bare https://github.com/user/repo_name
cd repo_name; git worktree add main; cd main
python -m venv .venv; ./.venv/bin/activate
pip install -r requirements.txt
# There is a httpx dependancy conflict. These must be installed in this order.
pip install ollama; pip install ollama-python
```
## Usage
First Terminal instance
```bash
ollama serve
```
Second Terminal instance
```bash
./ollama_slixmpp_omemo_bot/src/ollama_slixmpp_omemo_bot/ $ PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python python main.py
# Enter JID: service-account@example-server.im
# Enter Password:
```
With OMEMO and Blind Trust enabled, message service-account@example-server.im from another account.
Recommended and tested clients:
- Conversations
- Profanity
## Development
Read the [CONTRIBUTING.md](CONTRIBUTING.md) file.

0
conftest.py Normal file
View File

0
docs/index.md Normal file
View File

2
pyproject.toml Normal file
View File

@ -0,0 +1,2 @@
[build]
requires = ["setuptools", "build"]

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
black
build
flake8
mypy
mypy-extensions
pyproject_hooks
pytest
pytest-cov
pytest-mypy-plugins
result
slixmpp
slixmpp_omemo
twine

18
setup.cfg Normal file
View File

@ -0,0 +1,18 @@
[metadata]
name = ollama_slixmpp_omemo_bot
version = 0.0.1
[options]
package_dir=
=src
packages=find:
python_requires >= 3.11
install requires =
result
ollama-python
slixmpp
slixmpp-python
[options.packages.find]
where=src

View File

View File

@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
import os
import sys
import logging
from getpass import getpass
from argparse import ArgumentParser
import slixmpp_omemo
from ollama_bot import OllamaBot
log = logging.getLogger(__name__)
if __name__ == "__main__":
parser = ArgumentParser(description=OllamaBot.__doc__)
parser.add_argument(
"-q",
"--quiet",
help="set logging to ERROR",
action="store_const",
dest="loglevel",
const=logging.ERROR,
default=logging.INFO,
)
parser.add_argument(
"-d",
"--debug",
help="set logging to DEBUG",
action="store_const",
dest="loglevel",
const=logging.DEBUG,
default=logging.INFO,
)
parser.add_argument("-j", "--jid", dest="jid", help="JID to use")
parser.add_argument("-p", "--password", dest="password", help="password to use")
DATA_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"omemo",
)
parser.add_argument(
"--data-dir", dest="data_dir", help="data directory", default=DATA_DIR
)
args = parser.parse_args()
logging.basicConfig(level=args.loglevel, format="%(levelname)-8s %(message)s")
if args.jid is None:
args.jid = input("JID: ")
if args.password is None:
args.password = getpass("Password: ")
os.makedirs(args.data_dir, exist_ok=True)
xmpp = OllamaBot(args.jid, args.password)
xmpp.register_plugin("xep_0030") # Service Discovery
xmpp.register_plugin("xep_0199") # XMPP Ping
xmpp.register_plugin("xep_0380") # Explicit Message Encryption
try:
xmpp.register_plugin(
"xep_0384",
{
"data_dir": args.data_dir,
},
module=slixmpp_omemo,
)
except slixmpp_omemo.PluginCouldNotLoad:
log.exception("And error occured when loading the omemo plugin.")
sys.exit(1)
xmpp.connect()
xmpp.process()

View File

@ -0,0 +1,204 @@
import re
from typing import Dict, Optional
import ollama
from slixmpp import ClientXMPP, JID
from slixmpp.exceptions import IqTimeout, IqError
from slixmpp.stanza import Message
from slixmpp.types import JidStr, MessageTypes
from slixmpp.xmlstream.handler import CoroutineCallback
from slixmpp.xmlstream.matcher import MatchXPath
from slixmpp_omemo import (
EncryptionPrepareException,
MissingOwnKey,
NoAvailableSession,
UndecidedException,
UntrustedException,
)
from omemo.exceptions import MissingBundleException
LEVEL_DEBUG: int = 0
LEVEL_ERROR: int = 1
class OllamaBot(ClientXMPP):
eme_ns: str = "eu.siacs.conversations.axolotl"
cmd_prefix: str = "!"
debug_level: int = LEVEL_DEBUG
def __init__(self, jid: JidStr, password: str):
ClientXMPP.__init__(self, jid, password)
self.prefix_re: re.Pattern = re.compile(r"^%s" % self.cmd_prefix)
self.cmd_re: re.Pattern = re.compile(
r"^%s(?P<command>\w+)(?:\s+(?P<args>.*))?" % self.cmd_prefix
)
self.add_event_handler("session_start", self.start)
self.register_handler(
CoroutineCallback(
"Messages",
MatchXPath(f"{{{self.default_ns}}}message"),
self.message_handler,
)
)
def start(self, _: Dict) -> None:
self.send_presence()
self.get_roster()
def is_command(self, body: str) -> bool:
return self.prefix_re.match(body) is not None
async def handle_command(
self, mto: JID, mtype: Optional[MessageTypes], body: Optional[str]
) -> None:
match = self.cmd_re.match(body)
if match is None:
return None
groups = match.groupdict()
cmd: str = groups["command"]
# args = groups['args']
if cmd == "help":
await self.cmd_help(mto, mtype)
elif cmd == "verbose":
await self.cmd_verbose(mto, mtype)
elif cmd == "error":
await self.cmd_error(mto, mtype)
return None
async def cmd_help(self, mto: JID, mtype: Optional[MessageTypes]) -> None:
body = (
"I'm the slixmpp-omemo ollama bot! "
"The following commands are available:\n"
f"{self.cmd_prefix}verbose Send message or reply with log messages\n"
f"{self.cmd_prefix}error Send message or reply only on error\n"
)
return await self.encrypted_reply(mto, mtype, body)
async def cmd_verbose(self, mto: JID, mtype: Optional[MessageTypes]) -> None:
self.debug_level: int = LEVEL_DEBUG
body: str = """Debug level set to 'verbose'."""
return await self.encrypted_reply(mto, mtype, body)
async def cmd_error(self, mto: JID, mtype: Optional[MessageTypes]) -> None:
self.debug_level: int = LEVEL_ERROR
body: str = """Debug level set to 'error'."""
return await self.encrypted_reply(mto, mtype, body)
async def message_handler(
self, msg: Message, allow_untrusted: bool = False
) -> None:
mfrom: JID = msg["from"]
mto: JID = msg["from"]
mtype: Optional[MessageTypes] = msg["type"]
if mtype not in ("chat", "normal"):
return None
if not self["xep_0384"].is_encrypted(msg):
if self.debug_level == LEVEL_DEBUG:
await self.plain_reply(
mto, mtype, f"Echo unencrypted message: {msg['body']}"
)
return None
try:
encrypted = msg["omemo_encrypted"]
body: Optional[str] = await self["xep_0384"].decrypt_message(
encrypted, mfrom, allow_untrusted
)
if body is not None:
decoded = body.decode("utf8")
if self.is_command(decoded):
await self.handle_command(mto, mtype, decoded)
elif self.debug_level == LEVEL_DEBUG:
ollama_server_response = self.message_to_ollama_server(decoded)
await self.encrypted_reply(mto, mtype, f"{ollama_server_response}")
except MissingOwnKey:
await self.plain_reply(
mto,
mtype,
"Error: Message not encrypted for me.",
)
except NoAvailableSession:
await self.encrypted_reply(
mto,
mtype,
"Error: Message uses an encrypted " "session I don't know about.",
)
except (UndecidedException, UntrustedException) as exn:
await self.plain_reply(
mto,
mtype,
f"Error: Your device '{exn.device}' is not in my trusted devices.",
)
await self.message_handler(msg, allow_untrusted=True)
except (EncryptionPrepareException,):
await self.plain_reply(
mto, mtype, "Error: I was not able to decrypt the message."
)
except (Exception,) as exn:
await self.plain_reply(
mto,
mtype,
"Error: Exception occured while attempting decryption.\n%r" % exn,
)
raise
return None
async def plain_reply(self, mto: JID, mtype: Optional[MessageTypes], body):
msg = self.make_message(mto=mto, mtype=mtype)
msg["body"] = body
return msg.send()
async def encrypted_reply(self, mto: JID, mtype: Optional[MessageTypes], body):
msg = self.make_message(mto=mto, mtype=mtype)
msg["eme"]["namespace"] = self.eme_ns
msg["eme"]["name"] = self["xep_0380"].mechanisms[self.eme_ns]
expect_problems: Optional[dict[JID, list[int]]] = {}
while True:
try:
recipients = [mto]
encrypt = await self["xep_0384"].encrypt_message(
body, recipients, expect_problems
)
msg.append(encrypt)
return msg.send()
except UndecidedException as exn:
await self["xep_0384"].trust(exn.bare_jid, exn.device, exn.ik)
except EncryptionPrepareException as exn:
for error in exn.errors:
if isinstance(error, MissingBundleException):
await self.plain_reply(
mto,
mtype,
f'Could not find keys for device "{error.device}"'
f' of recipient "{error.bare_jid}". Skipping.',
)
jid: JID = JID(error.bare_jid)
device_list = expect_problems.setdefault(jid, [])
device_list.append(error.device)
except (IqError, IqTimeout) as exn:
await self.plain_reply(
mto,
mtype,
"An error occured while fetching information on a recipient.\n%r"
% exn,
)
return None
except Exception as exn:
await self.plain_reply(
mto,
mtype,
"An error occured while attempting to encrypt.\n%r" % exn,
)
raise
def message_to_ollama_server(self, msg: str) -> str:
response = ollama.chat(
model="llama3",
messages=[
{
"role": "user",
"content": f"{msg}",
},
],
)
return response["message"]["content"]

0
tests/__main__.py Normal file
View File

13
tests/test_ollama_bot.py Normal file
View File

@ -0,0 +1,13 @@
from getpass import getpass
import pytest
from src.ollama_slixmpp_omemo_bot import ollama_bot
class TestOllamaSlixmppOmemoBot:
def setup_method(self):
"""Assemble common resources to be acted upon"""
def test_authentication(self):
jid = input("JID: ")
pw = getpass()
assert ollama_bot.OllamaBot(jid, pw).jid == jid