diff --git a/motioneye/config.py b/motioneye/config.py index 8a1bbf427..65557c673 100644 --- a/motioneye/config.py +++ b/motioneye/config.py @@ -845,6 +845,7 @@ def main_dict_to_ui(data): def motion_camera_ui_to_dict(ui, prev_config=None): + prev_config = dict(prev_config or {}) main_config = get_main() # needed for surveillance password @@ -930,6 +931,12 @@ def motion_camera_ui_to_dict(ui, prev_config=None): 'mask_file': '', 'picture_output_motion': ui['create_debug_media'], 'movie_output_motion': ui['create_debug_media'], + # telegram notifications + '@telegram_notifications_enabled': ui['telegram_notifications_enabled'], + '@telegram_notifications_api' : ui['telegram_notifications_api'], + '@telegram_notifications_chat_id' : ui['telegram_notifications_chat_id'], + '@telegram_notifications_max_pictures' : int(ui['telegram_notifications_max_pictures']), + # working schedule '@working_schedule': '', # events @@ -1179,7 +1186,7 @@ def motion_camera_ui_to_dict(ui, prev_config=None): data['@working_schedule_type'] = ui['working_schedule_type'] # event start - on_event_start = [f"{meyectl.find_command('relayevent')} start %t"] + on_event_start = [f"{meyectl.find_command('relayevent')}" + " start %t"] if ui['email_notifications_enabled']: emails = sub('\\s', '', ui['email_notifications_addresses']) @@ -1201,18 +1208,6 @@ def motion_camera_ui_to_dict(ui, prev_config=None): } ) - on_event_start.append(line) - if ui['telegram_notifications_enabled']: - line = ( - "%(script)s '%(api)s' '%(chatid)s' '%%t' '%%Y-%%m-%%dT%%H:%%M:%%S' '%(timespan)s'" - % { - 'script': meyectl.find_command('sendtelegram'), - 'api': ui['telegram_notifications_api'], - 'chatid': ui['telegram_notifications_chat_id'], - 'timespan': ui['telegram_notifications_picture_time_span'], - } - ) - on_event_start.append(line) if ui['web_hook_notifications_enabled']: @@ -1232,7 +1227,7 @@ def motion_camera_ui_to_dict(ui, prev_config=None): data['on_event_start'] = '; '.join(on_event_start) # event end - on_event_end = [f"{meyectl.find_command('relayevent')} stop %t"] + on_event_end = [f"{meyectl.find_command('relayevent')}" + " stop %t ' ' %{eventid} '%Y-%m-%dT%H:%M:%S'"] if ui['web_hook_end_notifications_enabled']: url = sub(r'\s', '+', ui['web_hook_end_notifications_url']) @@ -1271,7 +1266,7 @@ def motion_camera_ui_to_dict(ui, prev_config=None): data['on_movie_end'] = '; '.join(on_movie_end) # picture save - on_picture_save = [f"{meyectl.find_command('relayevent')} picture_save %t %f"] + on_picture_save = [f"{meyectl.find_command('relayevent')}" + " picture_save %t %f %{eventid} '%Y-%m-%dT%H:%M:%S'"] if ui['web_hook_storage_enabled']: url = sub('\\s', '+', ui['web_hook_storage_url']) @@ -1311,6 +1306,7 @@ def motion_camera_ui_to_dict(ui, prev_config=None): def motion_camera_dict_to_ui(data): + ui = { # device 'name': data['camera_name'], @@ -1404,11 +1400,15 @@ def motion_camera_dict_to_ui(data): or data['picture_output_motion'], # motion notifications 'email_notifications_enabled': False, - 'telegram_notifications_enabled': False, 'web_hook_notifications_enabled': False, 'web_hook_end_notifications_enabled': False, 'command_notifications_enabled': False, 'command_end_notifications_enabled': False, + # telegram notifications + 'telegram_notifications_enabled': data['@telegram_notifications_enabled'], + 'telegram_notifications_api' : data['@telegram_notifications_api'], + 'telegram_notifications_chat_id' : data['@telegram_notifications_chat_id'], + 'telegram_notifications_max_pictures' : data['@telegram_notifications_max_pictures'], # working schedule 'working_schedule': False, 'working_schedule_type': 'during', @@ -1673,7 +1673,6 @@ def motion_camera_dict_to_ui(data): on_event_start = utils.split_semicolon(on_event_start) ui['email_notifications_picture_time_span'] = 0 - ui['telegram_notifications_picture_time_span'] = 0 command_notifications = [] for e in on_event_start: if ' sendmail ' in e: @@ -1702,21 +1701,6 @@ def motion_camera_dict_to_ui(data): except (TypeError, ValueError): ui['email_notifications_picture_time_span'] = 0 - elif ' sendtelegram ' in e: - e = split(e) - - if len(e) < 6: - continue - - ui['telegram_notifications_enabled'] = True - ui['telegram_notifications_api'] = e[-5] - ui['telegram_notifications_chat_id'] = e[-4] - try: - ui['telegram_notifications_picture_time_span'] = int(e[-1]) - - except (TypeError, ValueError): - ui['telegram_notifications_picture_time_span'] = 0 - elif ' webhook ' in e: e = split(e) @@ -2279,6 +2263,11 @@ def _set_default_motion_camera(camera_id, data): data.setdefault('movie_output', False) data.setdefault('movie_passthrough', False) + data.setdefault('@telegram_notifications_enabled', False) + data.setdefault('@telegram_notifications_max_pictures', '2') + data.setdefault('@telegram_notifications_api', '') + data.setdefault('@telegram_notifications_chat_id', '') + if motionctl.has_h264_omx_support(): data.setdefault('movie_codec', 'mp4:h264_omx') # will use h264 codec diff --git a/motioneye/handlers/config.py b/motioneye/handlers/config.py index 70425b227..a71ae5cd3 100644 --- a/motioneye/handlers/config.py +++ b/motioneye/handlers/config.py @@ -721,27 +721,21 @@ async def test(self, camera_id): return self.finish_json({'error': str(msg)}) elif what == 'telegram': - from motioneye import sendtelegram + from motioneye.handlers import telegram logging.debug('testing telegram notification') try: - message = 'This is a test of motionEye\'s telegram messaging' - sendtelegram.send_message( - data['api'], int(data['chatid']), message=message, files=[] - ) - - self.finish_json() + th = telegram.TelegramHandler.get_instance() + await th.send_test_message(api_key = data["api"], chat_id = data["chatid"]) logging.debug('telegram notification test succeeded') + self.finish_json() except Exception as e: msg = str(e) msg_lower = msg.lower() - logging.error( - 'telegram notification test failed: %s' % msg, exc_info=True - ) self.finish_json({'error': str(msg)}) elif what == 'network_share': diff --git a/motioneye/handlers/relay_event.py b/motioneye/handlers/relay_event.py index 9f6c2ddc9..6dc85b5cf 100644 --- a/motioneye/handlers/relay_event.py +++ b/motioneye/handlers/relay_event.py @@ -18,6 +18,7 @@ import logging from motioneye import config, mediafiles, motionctl, tasks, uploadservices, utils +from motioneye.handlers import telegram from motioneye.handlers.base import BaseHandler __all__ = ('RelayEventHandler',) @@ -25,7 +26,7 @@ class RelayEventHandler(BaseHandler): @BaseHandler.auth(admin=True) - def post(self): + async def post(self): event = self.get_argument('event') motion_camera_id = int(self.get_argument('motion_camera_id')) @@ -50,6 +51,10 @@ def post(self): ) return self.finish_json() + # needed for specific motion event recognition + event_id = self.get_argument('event_id') + moment = self.get_argument('moment') + if event == 'start': if not camera_config['@motion_detection']: logging.debug( @@ -63,6 +68,9 @@ def post(self): elif event == 'stop': motionctl.set_motion_detected(camera_id, False) + # notify telegram handler event stop + await self.handle_telegram_notification(camera_id, camera_config, moment, event, event_id, "") + elif event == 'movie_end': filename = self.get_argument('filename') @@ -86,6 +94,9 @@ def post(self): if camera_config['@upload_enabled'] and camera_config['@upload_picture']: self.upload_media_file(filename, camera_id, camera_config) + # send media to telegram + await self.handle_telegram_notification(camera_id, camera_config, moment, event, event_id, filename) + else: logging.warning('unknown event %s' % event) @@ -105,3 +116,17 @@ def upload_media_file(self, filename, camera_id, camera_config): and camera_config['target_dir'], filename=filename, ) + + async def handle_telegram_notification(self, camera_id, camera_config, moment, event, event_id, filename): + + # telegram notifications should only be triggered when motion detects well, motion :) + # below checks should allow media only when capture mode in GUI is set to "Motion Triggered" and "Motion Triggered (one picture)" + + if (camera_config["picture_output"] != False and camera_config['emulate_motion'] != True and camera_config['snapshot_interval'] == 0): + if (camera_config['@telegram_notifications_enabled']): + if (camera_config['@telegram_notifications_api'] and camera_config['@telegram_notifications_chat_id']): + th = telegram.TelegramHandler.get_instance() + await th.add_media({"camera_id" : camera_id, "camera_config" : camera_config, "moment" : moment, "event" : event, "event_id" : event_id, "file_name" : filename}) + else: + logging.warning("telegram notifications are enabled, but some of telegram_notifications parameters are not set") + return self.finish_json() diff --git a/motioneye/handlers/telegram.py b/motioneye/handlers/telegram.py new file mode 100644 index 000000000..65bcd391a --- /dev/null +++ b/motioneye/handlers/telegram.py @@ -0,0 +1,252 @@ +# Copyright (c) 2013 Calin Crisan +# This file is part of motionEye. +# +# motionEye is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +import datetime +import json +import logging +import os +import socket +from datetime import datetime +from io import BytesIO + +import httpx +from tornado import queues +from tornado.ioloop import IOLoop + +from motioneye import config, settings +from motioneye.controls import tzctl + +class TelegramHandler: + + _instance = None + + def __init__(self): + if TelegramHandler._instance != None: + raise Exception("TelegramHandler instance exists already!") + self.queue = queues.Queue() + self.events = self.Event() + TelegramHandler._instance = self + + @staticmethod + def get_instance(): + if TelegramHandler._instance is None: + TelegramHandler() + return TelegramHandler._instance + + def start(self): + logging.debug("starting media_handle_loop") + io_loop = IOLoop.current() + io_loop.add_callback(self.media_handle_loop) + + async def add_media(self, media): + if (media["event"] == "picture_save"): + logging.debug(f"adding new media: {media['file_name']}") + await self.queue.put(media) + + async def media_handle_loop(self): + logging.debug("started media_handle_loop") + + while True: + + # get new item from queue and initialize needed vars + item = await self.queue.get() + camera_id = item["camera_id"] + event = item["event"] + event_id = item["event_id"] + moment = item["moment"] + camera_config = config.get_camera(camera_id) + max_pictures = camera_config["@telegram_notifications_max_pictures"] + + if event == "picture_save": + self.events.add_event(item) + + elif event == "stop": + try: + files = self.events.get_files(event_id, max_pictures) + num_files = len(files) + logging.debug(f"[{event_id}] got files: {num_files}, limiting to { num_files if num_files <= max_pictures else max_pictures} per settings") + + msg_data = self.prepare_message(camera_config, files, event_id, moment) + + response = await self.send_message(msg_data) + + self.check_response(response) + + self.events.do_stop(event_id) + + self.queue.task_done() + + except KeyError as e: + # no such key exists, happens when "stop" event is received before "picture_save" one + logging.warning(f"[{event_id}] - readding item, exception: {repr(e)}") + + self.queue.task_done() + + # wait before another attempt to "stop" this specific event + await asyncio.sleep(5) + + # and readd item + await self.queue.put(item) + + except Exception as e: + logging.exception(repr(e)) + + def prepare_message(self, camera_config, files, event_id = None, moment = None, test = False): + + logging.debug("creating telegram message") + api_key = camera_config["@telegram_notifications_api"] + chat_id = camera_config["@telegram_notifications_chat_id"] + telegram_url = f"https://api.telegram.org/bot{api_key}/sendMediaGroup" + + # as per telegram bot api documentation - 10 MB + TELEGRAM_API_PHOTO_SIZE_LIMIT = 10 * 1024 * 1024 + + def check_file(file): + status = False + try: + size = os.path.getsize(file) + # todo choose right unit KB, MB + logging.debug(f"checking file: {file} - {round(size / (1024*1024), 1)} [MB]") + + if(size <= TELEGRAM_API_PHOTO_SIZE_LIMIT): + status = True + else: + logging.warning(f"file exceeds max size limit: {file} - {round(size / (1024*1024), 1)} > {round(TELEGRAM_API_PHOTO_SIZE_LIMIT / (1024*1024), 1)} [MB]") + + except Exception as e: + logging.exception(repr(e)) + + finally: + return status + + + # check files for existence and their size + _files = [file for file in files if check_file(file)] + + # append placeholder image to send with telegram api test, nice pic btw :) + if test: + _files.append(settings.STATIC_PATH + "/img/motioneye-icon.jpg") + + bytes = {} + media = [] + for i, img in enumerate(_files): + with BytesIO() as output, open(img, "+rb") as fh: + output.seek(0) + name = f"img-{i}" + bytes[name] = fh.read() + media.append(dict(type="photo", media=f"attach://{name}")) + + text = "" + + if not test: + # set message strings + timezone = tzctl.get_time_zone() if settings.LOCAL_TIME_FILE else "local time" + camera_name = camera_config["camera_name"] + message = f"Motion has been detected by: {camera_name} / {socket.gethostname()}\n" + message += f"At: {datetime.fromisoformat(moment).strftime('%Y-%m-%d %H:%M:%S')} / {timezone}" + # additional debug information + if (settings.LOG_LEVEL == logging.DEBUG): + message += f"\nEvent_id: {event_id}\n" + + if (len(media) > 0): + # apply caption on the first image + media[0]["caption"] = message + else: + # this is not a test and there are no pictures (max_pictures set to 0) api url change is needed + telegram_url = f"https://api.telegram.org/bot{api_key}/sendMessage" + text = message + media = "" + bytes = "" + else: + # telegram api test case + media[0]["caption"] = 'This is a test of motionEye\'s telegram messaging' + + return {"chat_id" : chat_id, "text" : text, "media": media, "files": bytes, "url" : telegram_url} + + async def send_message(self, msg_data): + + try: + # used httpx library, imho it's much simplier + async with httpx.AsyncClient() as client: + timeout = httpx.Timeout(float(settings.REMOTE_REQUEST_TIMEOUT)) + response = await client.post(msg_data["url"], + data={"chat_id": msg_data["chat_id"], "text" : msg_data["text"], "media": json.dumps(msg_data["media"])}, + files=msg_data["files"], + timeout=timeout) + return response.json() + + except Exception as err: + # sometimes telegram bot api returns invalid response however, the message is delivered... + # in such case, description is set to None : None + return {"ok" : False, "description" : err} + + async def send_test_message(self, api_key, chat_id): + + message = self.prepare_message({"@telegram_notifications_api" : api_key, "@telegram_notifications_chat_id" : chat_id }, files = [], test = True ) + response = await self.send_message(message) + + if not self.check_response(response): + raise Exception(response) + + def check_response(self, response): + # perform parsing of response data, only informational purposes + if response["ok"]: + logging.info("telegram succesfully sent") + return True + else: + # not acting on error, logging only + logging.error(f"failed to send telegram: \"{response}\"") + return False + + class Event(): + + def __init__(self) -> None: + self.events = dict() + + def add_event(self, item): + event_id = item["event_id"] + file_name = item["file_name"] + moment = item["moment"] + + if not self.event_exist(event_id): + self.events[event_id] = {"start": moment, "files": [file_name], "stop" : False} + + # append only unique files to the list + elif not self.file_exist(event_id, file_name): + if (self.is_stopped(event_id)): + logging.warning(f"[{event_id}] Attempt to add picture after STOP event!!") + else: + self.add_new_file(event_id, file_name) + + def add_new_file(self, event_id, file_name): + self.events[event_id]["files"].append(file_name) + + def get_files(self, event_id, limit): + return self.events[event_id]["files"][0:limit] + + def file_exist(self, event_id, file_name): + return file_name in self.events[event_id]["files"] + + def event_exist(self, event_id): + return event_id in self.events + + def is_stopped(self, event_id): + return self.events[event_id]["stop"] + + def do_stop(self, event_id): + self.events[event_id]["stop"] = True + self.events[event_id]["files"] = [] \ No newline at end of file diff --git a/motioneye/locale/en/LC_MESSAGES/motioneye.po b/motioneye/locale/en/LC_MESSAGES/motioneye.po index 7c68b6d2a..8ea748a74 100644 --- a/motioneye/locale/en/LC_MESSAGES/motioneye.po +++ b/motioneye/locale/en/LC_MESSAGES/motioneye.po @@ -1515,17 +1515,14 @@ msgstr "" #: motioneye/templates/main.html:1119 msgid "Alkroĉitaj Bildoj Tempo" -msgstr "Attached Images Time" +msgstr "Max images per event" #: motioneye/templates/main.html:1121 msgid "difinas la bildan serĉtempan intervalon por krei telegramajn aldonojn (pli altaj valoroj generas pli da bildoj koste de pliigita sciiga prokrasto); agordi al 0 por malebligi bildajn aldonaĵojn; vi devas ankaŭ ebligi Senmovajn Bildojn por ke ĉi tio funkciu; vi volos ludi per ĉi tiu numero ĝis bildo estos sendita. Bona komenca numero estas 30 se vi agordas senmovajn bildojn al unu bildmoviĝo ekigita. Por norma movado ekigita, starigu ĉi tion multe pli malalte." msgstr "" -"defines the image search time interval to create telegram attachments " -"(higher values generate more images at the cost of increased notification " -"delay); set to 0 to disable image attachments; you must also enable Still " -"Images for this to work; you will want to play with this number until an " -"image is sent. A good starting number is 30 if you set still images to one " -"motion triggered. For a standard move triggered, set this much lower." +"defines maximum number of images that will be attached in the telegram notification. " +"Maximum number of pictures allowed is 10. Value of 0 disables attaching pictures " +"and enables text only notifications." #: motioneye/templates/main.html:1124 msgid "API-Informoj" diff --git a/motioneye/scripts/relayevent.sh b/motioneye/scripts/relayevent.sh index d6cc98229..e3252d5b7 100755 --- a/motioneye/scripts/relayevent.sh +++ b/motioneye/scripts/relayevent.sh @@ -26,9 +26,11 @@ fi event=$2 motion_camera_id=$3 filename=$4 +event_id=$5 +moment=$6 uri="/_relay_event/?_username=$username&event=$event&motion_camera_id=$motion_camera_id" -data="{\"filename\": \"$filename\"}" +data="{\"filename\" : \"$filename\",\"event_id\" : \"$event_id\",\"moment\" : \"$moment\"}" signature=$(printf '%s' "POST:$uri:$data:$password" | sha1sum | cut -d ' ' -f 1) curl -sSfm "$timeout" -H 'Content-Type: application/json' -X POST "http://127.0.0.1:$port$uri&_signature=$signature" -d "$data" -o /dev/null diff --git a/motioneye/server.py b/motioneye/server.py index ed2598c8f..ce1e4222b 100644 --- a/motioneye/server.py +++ b/motioneye/server.py @@ -29,6 +29,7 @@ from motioneye import settings, template from motioneye.controls import smbctl, v4l2ctl +from motioneye.handlers import telegram from motioneye.handlers.action import ActionHandler from motioneye.handlers.base import ManifestHandler, NotFoundHandler from motioneye.handlers.config import ConfigHandler @@ -364,6 +365,7 @@ def start_motion(): # add a motion running checker def checker(): + if ( not motionctl.running() and motionctl.started() @@ -416,7 +418,7 @@ def make_app(debug: bool = False) -> Application: def run(): import motioneye from motioneye import cleanup, mjpgclient, motionctl, tasks, wsswitch - from motioneye.controls import smbctl + from motioneye.controls import smbctl configure_signals() logging.info(_('saluton! ĉi tio estas motionEye-servilo ') + motioneye.VERSION) @@ -459,6 +461,10 @@ def run(): static_path=settings.STATIC_PATH, static_url_prefix='/static/', ) + + # starts media handler loop listening for new media per event_id + th = telegram.TelegramHandler.get_instance() + th.start() application.listen(settings.PORT, settings.LISTEN) logging.info(_('servilo komenciĝis')) diff --git a/motioneye/static/img/motioneye-icon.jpg b/motioneye/static/img/motioneye-icon.jpg new file mode 100644 index 000000000..0727c627b Binary files /dev/null and b/motioneye/static/img/motioneye-icon.jpg differ diff --git a/motioneye/static/js/main.js b/motioneye/static/js/main.js index 4357a2331..178c9ae1d 100644 --- a/motioneye/static/js/main.js +++ b/motioneye/static/js/main.js @@ -2050,7 +2050,7 @@ function cameraUi2Dict() { 'telegram_notifications_enabled': $('#telegramNotificationsEnabledSwitch')[0].checked, 'telegram_notifications_api': $('#telegramAPIEntry').val(), 'telegram_notifications_chat_id': $('#telegramCIDEntry').val(), - 'telegram_notifications_picture_time_span': $('#telegramPictureTimeSpanEntry').val(), + 'telegram_notifications_max_pictures': $('#telegramMaxNumPictureEntry').val(), 'web_hook_notifications_enabled': $('#webHookNotificationsEnabledSwitch')[0].checked, 'web_hook_notifications_url': $('#webHookNotificationsUrlEntry').val(), 'web_hook_notifications_http_method': $('#webHookNotificationsHttpMethodSelect').val(), @@ -2431,7 +2431,7 @@ function dict2CameraUi(dict) { $('#telegramNotificationsEnabledSwitch')[0].checked = dict['telegram_notifications_enabled']; markHideIfNull('telegram_notifications_enabled', 'telegramNotificationsEnabledSwitch'); $('#telegramAPIEntry').val(dict['telegram_notifications_api']); $('#telegramCIDEntry').val(dict['telegram_notifications_chat_id']); - $('#telegramPictureTimeSpanEntry').val(dict['telegram_notifications_picture_time_span']); + $('#telegramMaxNumPictureEntry').val(dict['telegram_notifications_max_pictures']); $('#webHookNotificationsEnabledSwitch')[0].checked = dict['web_hook_notifications_enabled']; markHideIfNull('web_hook_notifications_enabled', 'webHookNotificationsEnabledSwitch'); $('#webHookNotificationsUrlEntry').val(dict['web_hook_notifications_url']); diff --git a/motioneye/templates/main.html b/motioneye/templates/main.html index 0c5b5cc85..b7a280f78 100644 --- a/motioneye/templates/main.html +++ b/motioneye/templates/main.html @@ -1115,9 +1115,9 @@ ? - + {{ _("Alkroĉitaj Bildoj Tempo") }} - seconds + ?