Repository: python-telegram-bot/python-telegram-bot

Issue:
[FEATURE] run_monthly method for job_queue
#### Is your feature request related to a problem? Please describe.
A clear and concise description of what the problem is.
I want to schedule a monthly recurring message at a particular time. A simple use case would be when I want the bot to remind me to pay my credit card bills on a particular date for every month. 

#### Describe the solution you'd like
It would be nice to have a function along the lines of:
`job_queue.run_monthly(callback, day_of_month, time, context=None, name=None)` that a developer can just call to run monthly jobs.

#### Describe alternatives you've considered
The alternative would be to write the code for it yourself, which makes your code more lengthy. It would be helpful if it came along with this wrapper



Discussion:
I'd like to work on it!
Sounds like a reasonable thing. Please, PR away :) You can orientate yourself on `run_daily` and please make sure to also add tests for the new method.

As a workaround you can schedule a job via `run_once` that in turn schedules the next job.
Sure! :) 
Wanted to check with you guys - I encountered the following error when I ran the command `git commit -a -m 'message'`:

```
[INFO] Installing environment for git://github.com/pre-commit/mirrors-pylint.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
An unexpected error has occurred: CalledProcessError: command: ('/Users/davidlivingston/.cache/pre-commit/repoisChtT/py_env-python2.7/bin/python', u'/Users/davidlivingston/.cache/pre-commit/repoisChtT/py_env-python2.7/bin/pip', 'install', '.')
return code: 1
expected return code: 0
stdout:
    Processing /Users/davidlivingston/.cache/pre-commit/repoisChtT
    
stderr:
    DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. Please upgrade your Python as Python 2.7 won't be maintained after that date. A future version of pip will drop support for Python 2.7. More details about Python 2 support in pip, can be found at https://pip.pypa.io/en/latest/development/release-process/#python-2-support
    ERROR: Could not find a version that satisfies the requirement pylint==2.3.0 (from pre-commit-dummy-package==0.0.0) (from versions: 0.15.2, 0.16.0, 0.18.0, 0.18.1, 0.19.0, 0.20.0, 0.21.0, 0.21.1, 0.21.2, 0.21.3, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.25.1, 0.25.2, 0.26.0, 0.27.0, 0.28.0, 1.0.0, 1.1.0, 1.2.0, 1.2.1, 1.3.0, 1.3.1, 1.4.0, 1.4.1, 1.4.2, 1.4.3, 1.4.4, 1.4.5, 1.5.0, 1.5.1, 1.5.2, 1.5.3, 1.5.4, 1.5.5, 1.5.6, 1.6.0, 1.6.1, 1.6.2, 1.6.3, 1.6.4, 1.6.5, 1.7.0, 1.7.1, 1.7.2, 1.7.3, 1.7.4, 1.7.5, 1.7.6, 1.8.0, 1.8.1, 1.8.2, 1.8.3, 1.8.4, 1.9.0, 1.9.1, 1.9.2, 1.9.3, 1.9.4, 1.9.5)
    ERROR: No matching distribution found for pylint==2.3.0 (from pre-commit-dummy-package==0.0.0)
```
I ran the command `pip install -r requirements.txt` and it states that all the requirements are already satisfied. Please help!
@daviddl9 please note that next releases of this library won't support py2.7
@tsnoam Roger that sir, I also have python 3 installed - is there  way I can run the following with python 3 instead? I'm on macOS
@daviddl9 probably your default python is python2. (/usr/bin/python -&gt; python2)
make sure your default python is python3 (use venv if necessary) and reinstall pre-commit hook (follow the CONTRIBUTING guide)

Resolving patch:
diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py
--- a/telegram/ext/jobqueue.py
+++ b/telegram/ext/jobqueue.py
@@ -18,6 +18,7 @@
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
 """This module contains the classes JobQueue and Job."""
 
+import calendar
 import datetime
 import logging
 import time
@@ -221,6 +222,115 @@ def run_repeating(self, callback, interval, first=None, context=None, name=None)
         self._put(job, time_spec=first)
         return job
 
+    def run_monthly(self, callback, when, day, context=None, name=None, day_is_strict=True):
+        """Creates a new ``Job`` that runs on a monthly basis and adds it to the queue.
+
+        Args:
+            callback (:obj:`callable`): The callback function for this handler. Will be called when
+                :attr:`check_update` has determined that an update should be processed by this
+                 handler.
+                Callback signature for context based API:
+
+                ``def callback(update: Update, context: CallbackContext)``
+
+                The return value of the callback is usually ignored except for the special case of
+                :class:`telegram.ext.ConversationHandler`.
+            when (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
+                (``when.tzinfo``) is ``None``, UTC will be assumed. This will also implicitly
+                define ``Job.tzinfo``.
+            day (:obj:`int`): Defines the day of the month whereby the job would run. It should
+                be within the range of 1 and 31, inclusive.
+            context (:obj:`object`, optional): Additional data needed for the callback function.
+                Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
+            name (:obj:`str`, optional): The name of the new job. Defaults to
+                ``callback.__name__``.
+            day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick
+                the last day in the month. Defaults to ``True``.
+
+        Returns:
+            :class:`telegram.ext.Job`: The new ``Job`` instance that has been added to the job
+            queue.
+
+        """
+        tzinfo = when.tzinfo if isinstance(when, (datetime.datetime, datetime.time)) else None
+        if 1 <= day <= 31:
+            next_dt = self._get_next_month_date(day, day_is_strict, when, allow_now=True)
+            job = Job(callback, repeat=False, context=context, name=name, job_queue=self,
+                      is_monthly=True, day_is_strict=day_is_strict, tzinfo=tzinfo)
+            self._put(job, time_spec=next_dt)
+            return job
+        else:
+            raise ValueError("The elements of the 'day' argument should be from 1 up to"
+                             " and including 31")
+
+    def _get_next_month_date(self, day, day_is_strict, when, allow_now=False):
+        """This method returns the date that the next monthly job should be scheduled.
+
+        Args:
+           day (:obj:`int`): The day of the month the job should run.
+           day_is_strict (:obj:`bool`):
+               Specification as to whether the specified day of job should be strictly
+               respected. If day_is_strict is ``True`` it ignores months whereby the
+               specified date does not exist (e.g February 31st). If it set to ``False``,
+               it returns the last valid date of the month instead. For example,
+               if the user runs a job on the 31st of every month, and sets
+               the day_is_strict variable to ``False``, April, for example,
+               the job would run on April 30th.
+           when (:obj:`datetime.time`): Time of day at which the job should run. If the
+               timezone (``time.tzinfo``) is ``None``, UTC will be assumed.
+           allow_now (:obj:`bool`): Whether executing the job right now is a feasible options.
+               For stability reasons, this defaults to :obj:`False`, but it needs to be :obj:`True`
+               on initializing a job.
+
+        """
+        dt = datetime.datetime.now(tz=when.tzinfo or datetime.timezone.utc)
+        dt_time = dt.time().replace(tzinfo=when.tzinfo)
+        days_in_current_month = calendar.monthrange(dt.year, dt.month)[1]
+        days_till_months_end = days_in_current_month - dt.day
+        if days_in_current_month < day:
+            # if the day does not exist in the current month (e.g Feb 31st)
+            if day_is_strict is False:
+                # set day as last day of month instead
+                next_dt = dt + datetime.timedelta(days=days_till_months_end)
+            else:
+                # else set as day in subsequent month. Subsequent month is
+                # guaranteed to have the date, if current month does not have the date.
+                next_dt = dt + datetime.timedelta(days=days_till_months_end + day)
+        else:
+            # if the day exists in the current month
+            if dt.day < day:
+                # day is upcoming
+                next_dt = dt + datetime.timedelta(day - dt.day)
+            elif dt.day > day or (dt.day == day and ((not allow_now and dt_time >= when)
+                                                     or (allow_now and dt_time > when))):
+                # run next month if day has already passed
+                next_year = dt.year + 1 if dt.month == 12 else dt.year
+                next_month = 1 if dt.month == 12 else dt.month + 1
+                days_in_next_month = calendar.monthrange(next_year, next_month)[1]
+                next_month_has_date = days_in_next_month >= day
+                if next_month_has_date:
+                    next_dt = dt + datetime.timedelta(days=days_till_months_end + day)
+                elif day_is_strict:
+                    # schedule the subsequent month if day is strict
+                    next_dt = dt + datetime.timedelta(
+                        days=days_till_months_end + days_in_next_month + day)
+                else:
+                    # schedule in the next month last date if day is not strict
+                    next_dt = dt + datetime.timedelta(days=days_till_months_end
+                                                      + days_in_next_month)
+
+            else:
+                # day is today but time has not yet come
+                next_dt = dt
+
+        # Set the correct time
+        next_dt = next_dt.replace(hour=when.hour, minute=when.minute, second=when.second,
+                                  microsecond=when.microsecond)
+        # fold is new in Py3.6
+        if hasattr(next_dt, 'fold'):
+            next_dt = next_dt.replace(fold=when.fold)
+        return next_dt
+
     def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None):
         """Creates a new ``Job`` that runs on a daily basis and adds it to the queue.
 
@@ -318,6 +428,11 @@ def tick(self):
 
             if job.repeat and not job.removed:
                 self._put(job, previous_t=t)
+            elif job.is_monthly and not job.removed:
+                dt = datetime.datetime.now(tz=job.tzinfo)
+                dt_time = dt.time().replace(tzinfo=job.tzinfo)
+                self._put(job, time_spec=self._get_next_month_date(dt.day, job.day_is_strict,
+                                                                   dt_time))
             else:
                 job._set_next_t(None)
                 self.logger.debug('Dropping non-repeating or removed job %s', job.name)
@@ -386,6 +501,8 @@ class Job(object):
         callback (:obj:`callable`): The callback function that should be executed by the new job.
         context (:obj:`object`): Optional. Additional data needed for the callback function.
         name (:obj:`str`): Optional. The name of the new job.
+        is_monthly (:obj: `bool`): Optional. Indicates whether it is a monthly job.
+        day_is_strict (:obj: `bool`): Optional. Indicates whether the monthly jobs day is strict.
 
     Args:
         callback (:obj:`callable`): The callback function that should be executed by the new job.
@@ -412,6 +529,11 @@ class Job(object):
         tzinfo (:obj:`datetime.tzinfo`, optional): timezone associated to this job. Used when
             checking the day of the week to determine whether a job should run (only relevant when
             ``days is not Days.EVERY_DAY``). Defaults to UTC.
+        is_monthly (:obj:`bool`, optional): If this job is supposed to be a monthly scheduled job.
+            Defaults to ``False``.
+        day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick the
+            last day in the month. Defaults to ``True``. Only relevant when ``is_monthly`` is
+            ``True``.
     """
 
     def __init__(self,
@@ -422,7 +544,9 @@ def __init__(self,
                  days=Days.EVERY_DAY,
                  name=None,
                  job_queue=None,
-                 tzinfo=None):
+                 tzinfo=None,
+                 is_monthly=False,
+                 day_is_strict=True):
 
         self.callback = callback
         self.context = context
@@ -433,6 +557,8 @@ def __init__(self,
         self.interval = interval
         self._next_t = None
         self.repeat = repeat
+        self.is_monthly = is_monthly
+        self.day_is_strict = day_is_strict
 
         self._days = None
         self.days = days


Test patch:
diff --git a/tests/test_jobqueue.py b/tests/test_jobqueue.py
--- a/tests/test_jobqueue.py
+++ b/tests/test_jobqueue.py
@@ -16,7 +16,7 @@
 #
 # You should have received a copy of the GNU Lesser Public License
 # along with this program.  If not, see [http://www.gnu.org/licenses/].
-
+import calendar
 import datetime as dtm
 import os
 import sys
@@ -26,7 +26,6 @@
 
 import pytest
 from flaky import flaky
-
 from telegram.ext import JobQueue, Updater, Job, CallbackContext
 from telegram.utils.deprecate import TelegramDeprecationWarning
 
@@ -288,6 +287,69 @@ def test_run_daily_with_timezone(self, job_queue):
         assert self.result == 1
         assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
 
+    def test_run_monthly(self, job_queue):
+        delta, now = 0.1, time.time()
+        date_time = dtm.datetime.utcfromtimestamp(now)
+        time_of_day = (date_time + dtm.timedelta(seconds=delta)).time()
+        expected_reschedule_time = now + delta
+
+        day = date_time.day
+        expected_reschedule_time += calendar.monthrange(date_time.year,
+                                                        date_time.month)[1] * 24 * 60 * 60
+
+        job_queue.run_monthly(self.job_run_once, time_of_day, day)
+        sleep(0.2)
+        assert self.result == 1
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
+
+    def test_run_monthly_and_not_strict(self, job_queue):
+        # This only really tests something in months with < 31 days.
+        # But the trouble of patching datetime is probably not worth it
+
+        delta, now = 0.1, time.time()
+        date_time = dtm.datetime.utcfromtimestamp(now)
+        time_of_day = (date_time + dtm.timedelta(seconds=delta)).time()
+        expected_reschedule_time = now + delta
+
+        day = date_time.day
+        date_time += dtm.timedelta(calendar.monthrange(date_time.year,
+                                                       date_time.month)[1] - day)
+        # next job should be scheduled on last day of month if day_is_strict is False
+        expected_reschedule_time += (calendar.monthrange(date_time.year,
+                                                         date_time.month)[1] - day) * 24 * 60 * 60
+
+        job_queue.run_monthly(self.job_run_once, time_of_day, 31, day_is_strict=False)
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
+
+    def test_run_monthly_with_timezone(self, job_queue):
+        """test that the day is retrieved based on the job's timezone
+        We set a job to run at the current UTC time of day (plus a small delay buffer) with a
+        timezone that is---approximately (see below)---UTC +24, and set it to run on the weekday
+        after the current UTC weekday. The job should therefore be executed now (because in UTC+24,
+        the time of day is the same as the current weekday is the one after the current UTC
+        weekday).
+        """
+        now = time.time()
+        utcnow = dtm.datetime.utcfromtimestamp(now)
+        delta = 0.1
+
+        # must subtract one minute because the UTC offset has to be strictly less than 24h
+        # thus this test will xpass if run in the interval [00:00, 00:01) UTC time
+        # (because target time will be 23:59 UTC, so local and target weekday will be the same)
+        target_tzinfo = dtm.timezone(dtm.timedelta(days=1, minutes=-1))
+        target_datetime = (utcnow + dtm.timedelta(days=1, minutes=-1, seconds=delta)).replace(
+            tzinfo=target_tzinfo)
+        target_time = target_datetime.timetz()
+        target_day = target_datetime.day
+        expected_reschedule_time = now + delta
+        expected_reschedule_time += calendar.monthrange(target_datetime.year,
+                                                        target_datetime.month)[1] * 24 * 60 * 60
+
+        job_queue.run_monthly(self.job_run_once, target_time, target_day)
+        sleep(delta + 0.1)
+        assert self.result == 1
+        assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
+
     def test_warnings(self, job_queue):
         j = Job(self.job_run_once, repeat=False)
         with pytest.raises(ValueError, match='can not be set to'):
@@ -384,8 +446,8 @@ def test_job_set_next_t(self, job_queue):
         assert job.next_t == t.astimezone(job.tzinfo)
 
     def test_passing_tzinfo_to_job(self, job_queue):
-        """Test that tzinfo is correctly passed to job with run_once, run_daily
-        and run_repeating methods"""
+        """Test that tzinfo is correctly passed to job with run_once, run_daily, run_repeating
+        and run_monthly methods"""
 
         when_dt_tz_specific = dtm.datetime.now(
             tz=dtm.timezone(dtm.timedelta(hours=12))
@@ -426,6 +488,9 @@ def test_passing_tzinfo_to_job(self, job_queue):
         job_daily1 = job_queue.run_daily(self.job_run_once, time_tz_specific)
         job_daily2 = job_queue.run_daily(self.job_run_once, time_tz_utc)
 
+        job_monthly1 = job_queue.run_monthly(self.job_run_once, time_tz_specific, 1)
+        job_monthly2 = job_queue.run_monthly(self.job_run_once, time_tz_utc, 1)
+
         assert job_once1.tzinfo == when_dt_tz_specific.tzinfo
         assert job_once2.tzinfo == dtm.timezone.utc
         assert job_once3.tzinfo == when_time_tz_specific.tzinfo
@@ -436,3 +501,5 @@ def test_passing_tzinfo_to_job(self, job_queue):
         assert job_repeating4.tzinfo == dtm.timezone.utc
         assert job_daily1.tzinfo == time_tz_specific.tzinfo
         assert job_daily2.tzinfo == dtm.timezone.utc
+        assert job_monthly1.tzinfo == time_tz_specific.tzinfo
+        assert job_monthly2.tzinfo == dtm.timezone.utc