From 62c762cb6bdd35c02dbf7b5e82fea42a525f43e0 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 6 Jul 2025 21:12:40 -0400 Subject: [PATCH] Added Lark Support (#1361) --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/lark.py | 194 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 2 +- test/test_plugin_lark.py | 76 +++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 apprise/plugins/lark.py create mode 100644 test/test_plugin_lark.py diff --git a/KEYWORDS b/KEYWORDS index 21c9ab3c..dfd0795d 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -40,6 +40,7 @@ Kavenegar KODI Kumulos LaMetric +Lark Line MacOSX Mailgun diff --git a/README.md b/README.md index cea77dbb..b6e0cd95 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ The table below identifies the services this tool supports and some example serv | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Kumulos](https://github.com/caronc/apprise/wiki/Notify_kumulos) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey | [LaMetric Time](https://github.com/caronc/apprise/wiki/Notify_lametric) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr
lametric://apikey@hostname:port
lametric://client_id@client_secret +| [Lark](https://github.com/caronc/apprise/wiki/Notify_lark) | lark:// | (TCP) 443 | lark://BotToken | [Line](https://github.com/caronc/apprise/wiki/Notify_line) | line:// | (TCP) 443 | line://Token@User
line://Token/User1/User2/UserN | [Mailgun](https://github.com/caronc/apprise/wiki/Notify_mailgun) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User" | [Mastodon](https://github.com/caronc/apprise/wiki/Notify_mastodon) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname
mastodon://access_key@hostname/@user
mastodon://access_key@hostname/@user1/@user2/@userN diff --git a/apprise/plugins/lark.py b/apprise/plugins/lark.py new file mode 100644 index 00000000..59470193 --- /dev/null +++ b/apprise/plugins/lark.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Details at: +# https://open.larksuite.com/document/client-docs/bot-v3/add-bot + +import re +import requests +import json + +from ..utils.parse import validate_regex +from ..url import PrivacyMode +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..common import NotifyType + + +class NotifyLark(NotifyBase): + """ + A wrapper for Lark (Feishu) Notifications via Webhook + """ + + # The default descriptive name associated with the Notification + service_name = _('Lark (Feishu)') + + service_url = 'https://open.larksuite.com/' + + # The default protocol + secure_protocol = 'lark' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_lark' + + # This is the static part of the webhook URL; only the token varies. + notify_url = 'https://open.larksuite.com/open-apis/bot/v2/hook/' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Bot Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + }) + + def __init__(self, token, **kwargs): + """ + Initialize Email Object + + The smtp_host and secure_mode can be automatically detected depending + on how the URL was built + """ + super().__init__(**kwargs) + + # The token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Lark Bot Token token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.webhook_url = f'{self.notify_url}{self.token}' + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + params = self.url_parameters(privacy=privacy, *args, **kwargs) + return '{schema}://{token}/?{params}'.format( + schema=self.secure_protocol, + token=self.pprint(self.token, privacy, mode=PrivacyMode.Secret), + params=NotifyLark.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Returns all of the identifiers that make this URL unique from + another similar one. Targets or end points should never be identified + here. + """ + self.throttle() + + payload = { + 'msg_type': 'text', + 'content': { + 'text': f'{title}\n{body}' if title else body + } + } + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.webhook_url, + headers=headers, + data=json.dumps(payload), + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + self.logger.warning( + 'Lark notification failed: %d - %s', + r.status_code, r.text) + return False + + except requests.RequestException as e: + self.logger.warning(f'Lark Exception: {e}') + return False + + self.logger.info('Lark notification sent successfully.') + return True + + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another similar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.token) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Set our token if found as an argument + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyLark.unquote(results['qsd']['token']) + + else: + # Fall back to hose (if defined here) + results['token'] = NotifyLark.unquote(results['host']) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://open.larksuite.com/open-apis/bot/v2/hook//WEBHOOK_TOKEN + """ + match = re.match( + r'^https://open\.larksuite\.com/open-apis/bot/v2/hook/([\w-]+)$', + url, re.I) + if not match: + return None + + return NotifyLark.parse_url('{schema}://{token}'.format( + schema=NotifyLark.secure_protocol, token=match.group(1))) diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 77f22d29..7aa7f700 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -42,7 +42,7 @@ it easy to access: Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, BlueSky, Burst SMS, BulkSMS, BulkVS, Chanify, Clickatell, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home -Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, +Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Lark, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal, diff --git a/test/test_plugin_lark.py b/test/test_plugin_lark.py new file mode 100644 index 00000000..108d127d --- /dev/null +++ b/test/test_plugin_lark.py @@ -0,0 +1,76 @@ +import requests +from apprise.plugins.lark import NotifyLark +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('lark://', { + # Teams Token missing + 'instance': TypeError, + }), + ('lark://:@/', { + # We don't have strict host checking on for lark, so this URL + # actually becomes parseable and :@ becomes a hostname. + # The below errors because a second token wasn't found + 'instance': TypeError, + }), + ('lark://{}'.format('abcd-1234'), { + # token provided - we're good + 'instance': NotifyLark, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'lark://****/', + }), + ('lark://{}'.format('abcd-1234'), { + # token provided - we're good + 'instance': NotifyLark, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'lark://****/', + }), + ('lark://?token={}'.format('abcd-1234'), { + # token provided - we're good + 'instance': NotifyLark, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'lark://****/', + }), + # Support Native URLs with arguments + ('https://open.larksuite.com/open-apis/bot/v2/hook/{}'.format( + 'abcd-1234'), { + # token provided - we're good + 'instance': NotifyLark, + }), + ('lark://{}'.format('abcd-1234'), { + 'instance': NotifyLark, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('lark://{}'.format('abcd-1234'), { + 'instance': NotifyLark, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('lark://{}'.format('a' * 80), { + 'instance': NotifyLark, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_lark_urls(): + """ + NotifyLark() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all()