batman
This commit is contained in:
commit
3c4fe53600
162
.gitignore
vendored
Normal file
162
.gitignore
vendored
Normal 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
45
README.md
Normal 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
0
conftest.py
Normal file
0
docs/index.md
Normal file
0
docs/index.md
Normal file
2
pyproject.toml
Normal file
2
pyproject.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[build]
|
||||
requires = ["setuptools", "build"]
|
13
requirements.txt
Normal file
13
requirements.txt
Normal 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
18
setup.cfg
Normal 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
|
0
src/ollama_slixmpp_omemo_bot/__init__.py
Normal file
0
src/ollama_slixmpp_omemo_bot/__init__.py
Normal file
66
src/ollama_slixmpp_omemo_bot/main.py
Normal file
66
src/ollama_slixmpp_omemo_bot/main.py
Normal 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()
|
204
src/ollama_slixmpp_omemo_bot/ollama_bot.py
Normal file
204
src/ollama_slixmpp_omemo_bot/ollama_bot.py
Normal 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
0
tests/__main__.py
Normal file
13
tests/test_ollama_bot.py
Normal file
13
tests/test_ollama_bot.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user