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 ran = True
__version__ = "3.0.0" __version__ = "2.3.0"
versionstring='Taurix TellMe v' + __version__ versionstring='Taurix TellMe v' + __version__
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)

View File

@@ -1,5 +1,13 @@
# Changelog # 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 ## [2.3.0] - 2026-05-04
### Added ### Added
- Matrix/Element support via matrix-nio - Matrix/Element support via matrix-nio

View File

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

View File

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

View File

@@ -2,3 +2,8 @@
2345555XE: 2345555XE:
transport: "simplex" transport: "simplex"
target: "#Bottest" 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 import AsyncClient, MatrixRoom, RoomMessageText
from nio.exceptions import OlmUnverifiedDeviceError from nio.exceptions import OlmUnverifiedDeviceError
import asyncio 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__ versionstring='Taurix TellMe server v' + __version__
log_dir = '/var/log/tellme' log_dir = '/var/log/tellme'
@@ -24,25 +28,41 @@ logging.basicConfig(
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
hooks = {}
config = {}
app = Flask(__name__) app = Flask(__name__)
# context = zmq.Context() # context = zmq.Context()
# socket = context.socket(zmq.REQ) # socket = context.socket(zmq.REQ)
# socket.connect("tcp://localhost:5555") # socket.connect("tcp://localhost:5555")
hooks = {}
config = {}
def read_configs(): def read_configs():
global hooks, config global hooks, config
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: with open(r'/etc/tellme/hooks.yml') as hooksfile:
hooks = yaml.load(hooksfile, Loader=yaml.FullLoader) 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'): if os.path.isfile('/etc/tellme/config.yml'):
try:
with open(r'/etc/tellme/config.yml') as configfile: with open(r'/etc/tellme/config.yml') as configfile:
config = yaml.load(configfile, Loader=yaml.FullLoader) 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): def send_smp_message(target, message):
@@ -58,19 +78,40 @@ def send_smp_message(target, message):
json_command = json.dumps(command) json_command = json.dumps(command)
uri = "ws://localhost:5080" uri = "ws://localhost:5080"
try:
ws = websocket.create_connection(uri) 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 ws.send(json_command) # Send message to WebSocket
responsejson = ws.recv() # Receive response responsejson = ws.recv() # Receive response
log.info("SimpleX raw response: %s" % (responsejson))
response = json.loads(responsejson) response = json.loads(responsejson)
ws.close()
if response is not None: if response and isinstance(response, dict):
log.info("Sent message to SimpleX with %s" % (response)) 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 return True
else: else:
log.error("Failed to send message to SimpleX with %s" % (response)) log.info("SimpleX response: %s" % (response))
return True
else:
log.error("Unexpected SimpleX response format: %s" % (response))
return False 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): async def matrix_login_and_send(homeserver, access_token, user_id, target, message):
@@ -79,31 +120,52 @@ async def matrix_login_and_send(homeserver, access_token, user_id, target, messa
invited_rooms = [] invited_rooms = []
async def auto_join_callback(room: MatrixRoom, event: RoomMessageText, client: AsyncClient): async def auto_join_callback(room: MatrixRoom, event: RoomMessageText):
pass 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) client.add_event_callback(auto_join_callback, RoomMessageText)
try: try:
response = await client.sync(full_state=True) await client.sync(full_state=True)
for room_id, invite_state in client.invited_rooms.items(): for room_id, invite_state in client.invited_rooms.items():
await client.join(room_id) await client.join(room_id)
invited_rooms.append(room_id) invited_rooms.append(room_id)
if target not in client.rooms and target not in invited_rooms: room = None
await client.join(target) room_id = None
room = client.rooms.get(target) if target.startswith('#'):
if room: 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( await client.room_send(
room_id=target, room_id=room_id,
message_type="m.room.message", message_type="m.room.message",
content={"msgtype": "m.text", "body": 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 return True
else: else:
log.error("Could not join Matrix room %s" % (target)) 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") log.error("Matrix credentials not configured")
return False 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( return asyncio.get_event_loop().run_until_complete(
matrix_login_and_send(homeserver, access_token, user_id, target, message) matrix_login_and_send(homeserver, access_token, user_id, target, message)
) )
@@ -133,8 +202,43 @@ def send_matrix_message(target, message):
return False 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): 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']) @app.route("/webhook/<id>", methods=['POST'])
@@ -166,10 +270,17 @@ def webhook_receiver(id):
if type == 'AlertStatus': if type == 'AlertStatus':
message = ("Alert %s: %s" % (data.get('AlertID'), data.get('LogEntry'))) 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) hook = get_hook(id)
if hook is None: 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') transport = hook.get('transport')
target = hook.get('target') target = hook.get('target')
@@ -179,9 +290,11 @@ def webhook_receiver(id):
if target is not None: if target is not None:
log.info(target) log.info(target)
if message is not None: 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: else:
log.error("No message, dropping") log.error("No message, dropping")
return jsonify({'message': 'No message, dropping'}), 400
else: else:
log.error("No target found, dropping message") log.error("No target found, dropping message")
return jsonify({'message': 'No target found, dropping message'}), 400 return jsonify({'message': 'No target found, dropping message'}), 400
@@ -189,9 +302,23 @@ def webhook_receiver(id):
if target is not None: if target is not None:
log.info(target) log.info(target)
if message is not None: 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: else:
log.error("No message, dropping") 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: else:
log.error("No target found, dropping message") log.error("No target found, dropping message")
return jsonify({'message': 'No target found, dropping message'}), 400 return jsonify({'message': 'No target found, dropping message'}), 400
@@ -205,7 +332,9 @@ def webhook_receiver(id):
return jsonify({'message': 'Webhook received successfully'}), 200 return jsonify({'message': 'Webhook received successfully'}), 200
read_configs()
log.info("Config loaded: %s" % (config))
if __name__ == '__main__': if __name__ == '__main__':
log.info("Started %s" % (versionstring)) log.info("Started %s" % (versionstring))
read_configs()
app.run() app.run()