Compare commits

...

5 Commits

Author SHA1 Message Date
80ba03fdf9 Add Nextcloud Talk support 2026-05-14 13:14:44 +02:00
3c4acd0a47 Fix formatting 2026-05-13 14:24:37 +02:00
c8624e4799 Add support for Goalert rotations 2026-05-13 13:45:07 +02:00
4ab2bf4cac Fix version 2026-05-04 22:36:03 +02:00
f2fc79862d Fix config 2026-05-04 22:35:36 +02:00
6 changed files with 187 additions and 41 deletions

View File

@@ -39,7 +39,7 @@ def sendmessage(message):
ran = True
__version__ = "3.0.0"
__version__ = "2.3.0"
versionstring='Taurix TellMe v' + __version__
signal.signal(signal.SIGINT, signal_handler)

View File

@@ -1,5 +1,13 @@
# Changelog
## [2.5.0] - 2026-05-14
### Added
- Added Nextcloud Talk support via OCS Chat API
## [2.4.0] - 2026-05-13
### Added
- Added support for GoAlert Rotation shift changes
## [2.3.0] - 2026-05-04
### Added
- Matrix/Element support via matrix-nio

View File

@@ -1,6 +1,6 @@
{
"name": "TellMe Server",
"version": "2.3.0",
"version": "2.4.0",
"description": "TellMe Server",
"scripts": {
"dev": "webpack-dev-server --inline --hot"

View File

@@ -1,4 +1,8 @@
---
matrix_homeserver: ""
matrix_access_token": ""
matrix_access_token: ""
matrix_user_id: ""
nextcloud_server: ""
nextcloud_username: ""
nextcloud_password: ""

View File

@@ -2,3 +2,8 @@
2345555XE:
transport: "simplex"
target: "#Bottest"
# Nextcloud Talk example:
# myhook:
# transport: "nextcloud"
# target: "conversation_token_here"

View File

@@ -10,8 +10,12 @@ from pprint import pprint
from nio import AsyncClient, MatrixRoom, RoomMessageText
from nio.exceptions import OlmUnverifiedDeviceError
import asyncio
import urllib.request
import urllib.parse
import urllib.error
import base64
__version__ = "3.0.0"
__version__ = "2.5.0"
versionstring='Taurix TellMe server v' + __version__
log_dir = '/var/log/tellme'
@@ -24,25 +28,41 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
hooks = {}
config = {}
app = Flask(__name__)
# context = zmq.Context()
# socket = context.socket(zmq.REQ)
# socket.connect("tcp://localhost:5555")
hooks = {}
config = {}
def read_configs():
global hooks, config
with open(r'/etc/tellme/hooks.yml') as hooksfile:
hooks = yaml.load(hooksfile, Loader=yaml.FullLoader)
print("DEBUG: read_configs() called") # Temporary debug
log.info("read_configs() called")
if not os.path.isfile('/etc/tellme/hooks.yml'):
log.error("hooks.yml not found at /etc/tellme/hooks.yml")
else:
try:
with open(r'/etc/tellme/hooks.yml') as hooksfile:
loaded = yaml.load(hooksfile, Loader=yaml.FullLoader)
hooks = {str(k): v for k, v in loaded.items()} if loaded else {}
log.info("Loaded hooks: %s" % (list(hooks.keys()) if hooks else 'empty'))
except Exception as e:
log.error("Failed to load hooks.yml: %s" % (e))
if os.path.isfile('/etc/tellme/config.yml'):
with open(r'/etc/tellme/config.yml') as configfile:
config = yaml.load(configfile, Loader=yaml.FullLoader)
try:
with open(r'/etc/tellme/config.yml') as configfile:
config = yaml.load(configfile, Loader=yaml.FullLoader)
log.info("Loaded config")
except Exception as e:
log.error("Failed to load config.yml: %s" % (e))
else:
log.error("config.yml not found at /etc/tellme/config.yml")
def send_smp_message(target, message):
@@ -58,20 +78,41 @@ def send_smp_message(target, message):
json_command = json.dumps(command)
uri = "ws://localhost:5080"
ws = websocket.create_connection(uri)
ws.send(json_command) # Send message to WebSocket
responsejson = ws.recv() # Receive response
response = json.loads(responsejson)
ws.close()
if response is not None:
log.info("Sent message to SimpleX with %s" % (response))
return True
else:
log.error("Failed to send message to SimpleX with %s" % (response))
try:
ws = websocket.create_connection(uri)
except Exception as e:
log.error("Failed to connect to SimpleX WebSocket at %s: %s" % (uri, e))
return False
try:
log.info("Sending to SimpleX WebSocket: %s" % (json_command))
ws.send(json_command) # Send message to WebSocket
responsejson = ws.recv() # Receive response
log.info("SimpleX raw response: %s" % (responsejson))
response = json.loads(responsejson)
if response and isinstance(response, dict):
resp = response.get('resp', {})
if isinstance(resp, dict) and resp.get('type') == 'subscriptionStatus':
log.warning("SimpleX response indicates subscription status, not message delivery: %s" % (response))
elif resp and resp.get('type') == 'sent':
log.info("SimpleX message sent successfully")
return True
else:
log.info("SimpleX response: %s" % (response))
return True
else:
log.error("Unexpected SimpleX response format: %s" % (response))
return False
except Exception as e:
log.error("Error sending SimpleX message: %s" % (e))
return False
finally:
try:
ws.close()
except:
pass
async def matrix_login_and_send(homeserver, access_token, user_id, target, message):
client = AsyncClient(homeserver, user_id)
@@ -79,31 +120,52 @@ async def matrix_login_and_send(homeserver, access_token, user_id, target, messa
invited_rooms = []
async def auto_join_callback(room: MatrixRoom, event: RoomMessageText, client: AsyncClient):
async def auto_join_callback(room: MatrixRoom, event: RoomMessageText):
pass
async def invited_callback(room: MatrixRoom, event: RoomMessageText, client: AsyncClient):
invited_rooms.append(room.room_id)
client.add_event_callback(auto_join_callback, RoomMessageText)
try:
response = await client.sync(full_state=True)
await client.sync(full_state=True)
for room_id, invite_state in client.invited_rooms.items():
await client.join(room_id)
invited_rooms.append(room_id)
if target not in client.rooms and target not in invited_rooms:
await client.join(target)
room = None
room_id = None
room = client.rooms.get(target)
if room:
if target.startswith('#'):
for room_obj in client.rooms.values():
if hasattr(room_obj, 'canonical_alias') and room_obj.canonical_alias == target:
room = room_obj
room_id = room_obj.room_id
break
if not room or not room_id:
try:
join_response = await client.join(target)
log.info("Join response: %s" % (join_response))
if hasattr(join_response, 'room_id'):
room_id = join_response.room_id
await client.sync(full_state=True)
room = client.rooms.get(room_id)
elif hasattr(join_response, 'room'):
room_id = join_response.room.room_id
room = join_response.room
except Exception as join_error:
log.error("Join failed for %s: %s" % (target, join_error))
if not room_id:
log.error("Could not get room_id for %s" % (target))
return False
if room_id:
await client.room_send(
room_id=target,
room_id=room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": message}
)
log.info("Sent message to Matrix room %s" % (target))
log.info("Sent message to Matrix room %s (room_id: %s)" % (target, room_id))
return True
else:
log.error("Could not join Matrix room %s" % (target))
@@ -125,6 +187,13 @@ def send_matrix_message(target, message):
log.error("Matrix credentials not configured")
return False
return asyncio.run(
matrix_login_and_send(homeserver, access_token, user_id, target, message)
)
except Exception as e:
log.error("Failed to send message to Matrix: %s" % (e))
return False
return asyncio.get_event_loop().run_until_complete(
matrix_login_and_send(homeserver, access_token, user_id, target, message)
)
@@ -133,8 +202,43 @@ def send_matrix_message(target, message):
return False
def send_nextcloud_message(target, message):
try:
server = config.get('nextcloud_server')
username = config.get('nextcloud_username')
password = config.get('nextcloud_password')
if not server or not username or not password:
log.error("Nextcloud Talk credentials not configured")
return False
url = f"{server.rstrip('/')}/ocs/v2.php/apps/spreed/api/v1/chat/{target}"
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
data = urllib.parse.urlencode({'message': message}).encode()
req = urllib.request.Request(url, data=data)
req.add_header('OCS-APIRequest', 'true')
req.add_header('Content-Type', 'application/x-www-form-urlencoded')
req.add_header('Authorization', f'Basic {credentials}')
response = urllib.request.urlopen(req)
log.info("Nextcloud Talk message sent to conversation %s, response: %d" % (target, response.status))
return True
except urllib.error.HTTPError as e:
log.error("Nextcloud Talk HTTP error: %d %s" % (e.code, e.reason))
return False
except Exception as e:
log.error("Failed to send Nextcloud Talk message: %s" % (e))
return False
def get_hook(hook_id):
return hooks.get(str(hook_id))
global hooks
hook = hooks.get(str(hook_id))
log.info("Looking up hook_id=%s, found=%s, available_keys=%s" % (hook_id, hook, list(hooks.keys())))
return hook
@app.route("/webhook/<id>", methods=['POST'])
@@ -166,10 +270,17 @@ def webhook_receiver(id):
if type == 'AlertStatus':
message = ("Alert %s: %s" % (data.get('AlertID'), data.get('LogEntry')))
if type == 'ScheduleOnCallUsers':
message = ("On call rotation for schedule %s changed to user(s):" % (data.get('ScheduleName')))
users = data.get('Users')
for user in users:
message = ("%s %s" % (message, user.get('Name')))
hook = get_hook(id)
if hook is None:
return jsonify({'message': 'Hook not found'}), 404
log.error("Webhook %s found, dropping message" % (id))
return jsonify({'message': 'Hook not found'}), 400
transport = hook.get('transport')
target = hook.get('target')
@@ -179,9 +290,11 @@ def webhook_receiver(id):
if target is not None:
log.info(target)
if message is not None:
send_smp_message(target, message)
if not send_smp_message(target, message):
return jsonify({'message': 'Failed to send SimpleX message'}), 500
else:
log.error("No message, dropping")
return jsonify({'message': 'No message, dropping'}), 400
else:
log.error("No target found, dropping message")
return jsonify({'message': 'No target found, dropping message'}), 400
@@ -189,9 +302,23 @@ def webhook_receiver(id):
if target is not None:
log.info(target)
if message is not None:
send_matrix_message(target, message)
if not send_matrix_message(target, message):
return jsonify({'message': 'Failed to send Matrix message'}), 500
else:
log.error("No message, dropping")
return jsonify({'message': 'No message, dropping'}), 400
else:
log.error("No target found, dropping message")
return jsonify({'message': 'No target found, dropping message'}), 400
elif transport == 'nextcloud':
if target is not None:
log.info(target)
if message is not None:
if not send_nextcloud_message(target, message):
return jsonify({'message': 'Failed to send Nextcloud Talk message'}), 500
else:
log.error("No message, dropping")
return jsonify({'message': 'No message, dropping'}), 400
else:
log.error("No target found, dropping message")
return jsonify({'message': 'No target found, dropping message'}), 400
@@ -205,7 +332,9 @@ def webhook_receiver(id):
return jsonify({'message': 'Webhook received successfully'}), 200
read_configs()
log.info("Config loaded: %s" % (config))
if __name__ == '__main__':
log.info("Started %s" % (versionstring))
read_configs()
app.run()