Compare commits

..

7 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
4c6ed4c514 2.3.0 release with changelogs 2026-05-04 10:08:15 +02:00
ec7b330b90 Make bot auto-join rooms 2026-05-02 13:02:27 +02:00
10 changed files with 267 additions and 50 deletions

View File

@@ -1,11 +0,0 @@
TellMe Server (2.2.0)
* Moved the notifier into tellmesrv
TellMe Server (2.1.0)
* Added support for GoAlert messages
TellMe Server (2.0.0b2)
* Add logging
TellMe Server (2.0.0b1)
* First standalone bot with SimpleX support

11
client/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# Changelog
## [2.3.0] - 2026-05-04
### Added
- Client version 2.3.0 release
### Features
- Send messages: `-m "Your message"` - Send custom notifications
- Monitor processes: `-p <pid>` - Wait for a process to exit, then notify
- Watch commands: `-w "command"` - Run a command periodically and notify on output
- Ping hosts: `-P <host>` - Monitor host availability until it's reachable

View File

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

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)

37
server/CHANGELOG.md Normal file
View File

@@ -0,0 +1,37 @@
# 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
- Auto-join Matrix rooms on invite
- Matrix access token instructions in README
### Fixed
- Fixed Code typo in GoAlert verification message
## [2.2.0] - 2025-02-04
### Changed
- Moved the notifier into tellmesrv
### Added
- Support for GoAlert messages
## [2.1.0] - 2025-02-04
### Added
- Added support for GoAlert messages
## [2.0.0b2] - 2025-02-04
### Added
- Add logging
## [2.0.0b1] - 2025-02-04
### Added
- First standalone bot with SimpleX support

View File

@@ -1,6 +1,6 @@
{ {
"name": "TellMe Server", "name": "TellMe Server",
"version": "3.0.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

@@ -0,0 +1,8 @@
---
matrix_homeserver: ""
matrix_access_token: ""
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

@@ -1,4 +1,4 @@
flask flask
websocket-client websocket-client
pyyaml pyyaml
matrix-client matrix-nio

View File

@@ -7,9 +7,15 @@ import random
import logging import logging
import os import os
from pprint import pprint from pprint import pprint
from matrix_client.client import MatrixClient 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__ versionstring='Taurix TellMe server v' + __version__
log_dir = '/var/log/tellme' log_dir = '/var/log/tellme'
@@ -22,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):
@@ -56,19 +78,103 @@ 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):
client = AsyncClient(homeserver, user_id)
client.access_token = access_token
invited_rooms = []
async def auto_join_callback(room: MatrixRoom, event: RoomMessageText):
pass
client.add_event_callback(auto_join_callback, RoomMessageText)
try:
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)
room = None
room_id = None
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=room_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": message}
)
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))
return False
except Exception as e:
log.error("Failed to send Matrix message: %s" % (e))
return False
finally:
await client.close()
def send_matrix_message(target, message): def send_matrix_message(target, message):
@@ -81,22 +187,58 @@ def send_matrix_message(target, message):
log.error("Matrix credentials not configured") log.error("Matrix credentials not configured")
return False return False
client = MatrixClient(homeserver) return asyncio.run(
client.login(token=access_token, user_id=user_id) 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
room = client.join_room(target) return asyncio.get_event_loop().run_until_complete(
room.send_text(message) matrix_login_and_send(homeserver, access_token, user_id, target, message)
)
client.logout()
log.info("Sent message to Matrix room %s" % (target))
return True
except Exception as e: except Exception as e:
log.error("Failed to send message to Matrix: %s" % (e)) log.error("Failed to send message to Matrix: %s" % (e))
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'])
@@ -128,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')
@@ -141,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
@@ -151,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
@@ -167,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()