Compare commits
7 Commits
4033d214ff
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ba03fdf9 | |||
| 3c4acd0a47 | |||
| c8624e4799 | |||
| 4ab2bf4cac | |||
| f2fc79862d | |||
| 4c6ed4c514 | |||
| ec7b330b90 |
11
Changelog
11
Changelog
@@ -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
11
client/CHANGELOG.md
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
37
server/CHANGELOG.md
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
matrix_homeserver: ""
|
||||||
|
matrix_access_token: ""
|
||||||
|
matrix_user_id: ""
|
||||||
|
|
||||||
|
nextcloud_server: ""
|
||||||
|
nextcloud_username: ""
|
||||||
|
nextcloud_password: ""
|
||||||
@@ -2,3 +2,8 @@
|
|||||||
2345555XE:
|
2345555XE:
|
||||||
transport: "simplex"
|
transport: "simplex"
|
||||||
target: "#Bottest"
|
target: "#Bottest"
|
||||||
|
|
||||||
|
# Nextcloud Talk example:
|
||||||
|
# myhook:
|
||||||
|
# transport: "nextcloud"
|
||||||
|
# target: "conversation_token_here"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
flask
|
flask
|
||||||
websocket-client
|
websocket-client
|
||||||
pyyaml
|
pyyaml
|
||||||
matrix-client
|
matrix-nio
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
with open(r'/etc/tellme/hooks.yml') as hooksfile:
|
print("DEBUG: read_configs() called") # Temporary debug
|
||||||
hooks = yaml.load(hooksfile, Loader=yaml.FullLoader)
|
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'):
|
if os.path.isfile('/etc/tellme/config.yml'):
|
||||||
with open(r'/etc/tellme/config.yml') as configfile:
|
try:
|
||||||
config = yaml.load(configfile, Loader=yaml.FullLoader)
|
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):
|
def send_smp_message(target, message):
|
||||||
@@ -56,20 +78,104 @@ def send_smp_message(target, message):
|
|||||||
json_command = json.dumps(command)
|
json_command = json.dumps(command)
|
||||||
|
|
||||||
uri = "ws://localhost:5080"
|
uri = "ws://localhost:5080"
|
||||||
ws = websocket.create_connection(uri)
|
try:
|
||||||
|
ws = websocket.create_connection(uri)
|
||||||
ws.send(json_command) # Send message to WebSocket
|
except Exception as e:
|
||||||
responsejson = ws.recv() # Receive response
|
log.error("Failed to connect to SimpleX WebSocket at %s: %s" % (uri, e))
|
||||||
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))
|
|
||||||
return False
|
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)
|
||||||
|
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):
|
||||||
try:
|
try:
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user