19

I'm writing a discord bot using discord.py rewrite, and I want to run a function every day at a certain time. I'm not experienced with async functions at all and I can't figure out how to run one without using "await." This is only a piece of my code which is why some things may not be defined.

async def send_channel():
    try:
        await active_channel.send('daily text here')
    except Exception:
        active_channel_id = None
        active_channel = None

async def timer():
    while True:
        schedule.run_pending()
        await asyncio.sleep(3)
        schedule.every().day.at("21:57").do(await send_channel())

@bot.event
async def on_ready():
    print("Logged in as")
    print(bot.user.name)
    print(bot.user.id)
    print("------")

    bot.loop.create_task(timer())

Using the schedule.every().day.at("00:00").do() function, I get this error when I put await send_channel() in the paramaters of .do():

self.job_func = functools.partial(job_func, *args, **kwargs) TypeError: the first argument must be callable

But when I don't use await, and I just have send_channel() as parameters, I get this error:

RuntimeWarning: coroutine 'send_channel' was never awaited

I'm not super good at programming so if someone could try to dumb it down for me that would be awesome.

Thanks

Michael Leonard
  • 240
  • 1
  • 3
  • 8

6 Answers6

9

The built-in solution to this in is to use the discord.ext.tasks extension. This lets you register a task to be called repeatedly at a specific interval. When the bots start, we'll delay the start of the loop until the target time, then run the task every 24 hours:

import asyncio
from discord.ext import commands, tasks
from datetime import datetime, timedelta

bot = commands.Bot("!")

@tasks.loop(hours=24)
async def my_task():
    ...

@my_task.before_loop
async def before_my_task():
    hour = 21
    minute = 57
    await bot.wait_until_ready()
    now = datetime.now()
    future = datetime.datetime(now.year, now.month, now.day, hour, minute)
    if now.hour >= hour and now.minute > minute:
        future += timedelta(days=1)
    await asyncio.sleep((future-now).seconds)

my_task.start()
J C
  • 3
  • 2
Patrick Haugh
  • 59,226
  • 13
  • 88
  • 96
8

Another option is to use apscheduler's AsyncIOScheduler, which works more naturally with async functions (such as send_channel). In your case, you can simply write something of the form:

scheduler = AsyncIOScheduler()
scheduler.add_job(send_channel, trigger=tr)
scheduler.start()

Where tr is a trigger object. You can use either IntervalTrigger with a 1-day interval and a start date at 21:57, or a CronTrigger.

Note that at the end of your program it is recommended to call shutdown() on the scheduler object.

Dean Gurvitz
  • 854
  • 1
  • 10
  • 24
7

What you're doing doesn't work because do takes a function (or another callable), but you're trying to await or call a function, and then pass it the result.

await send_channel() blocks until the send finishes and then gives you None, which isn't a function. send_channel() returns a coroutine that you can await later to do some work, and that isn't a function either.

If you passed it just send_channel, well, that is a function, but it's an ascynio coroutine function, which schedule won't know how to run.


Also, rather than trying to integrate schedule into the asyncio event loop, and figure out how to wrap async jobs up as schedule tasks and vice versa and so on, it would far easier to just give schedule its own thread.

There's a FAQ entry on this:

How to continuously run the scheduler without blocking the main thread?

Run the scheduler in a separate thread. Mrwhick wrote up a nice solution in to this problem here (look for run_continuously()).

The basic idea is simple. Change your timer function to this:

schedstop = threading.Event()
def timer():
    while not schedstop.is_set():
        schedule.run_pending()
        time.sleep(3)
schedthread = threading.Thread(target=timer)
schedthread.start()

Do this at the start of your program, before you even start your asyncio event loop.

At exit time, to stop the scheduler thread:

schedstop.set()

Now, to add a task, it doesn't matter whether you're in your top-level code, or in an async coroutine, or in a scheduler task, you just add it like this:

schedule.every().day.at("21:57").do(task)

Now, back to your first problem. The task you want to run isn't a normal function, it's an asyncio coroutine, which has to be run on the main thread as part of the main event loop.

But that's exactly what call_soon_threadsafe is for. What you want to call is:

bot.loop.call_soon_threadsafe(send_channel)

To ask scheduler to run that, you just pass bot.loop.call_soon_threadsafe as the function and send_channel as the argument.

So, putting it all together:

schedule.every().day.at("21:57").do(
    bot.loop.call_soon_threadsafe, send_channel)
abarnert
  • 354,177
  • 51
  • 601
  • 671
  • 2
    Is the threading part necessary? Because I couldn't get it to work and I'm not very familiar with threading (I know I should be.) In terms of "schedule.every().day.at("21:57").do( bot.loop.call_soon_threadsafe, send_channel)", that part wasn't working for me either. I'm still getting an error, coroutine 'send_channel' was never awaited. And I think the send_channel() function has to have async to work. – Michael Leonard Jul 29 '18 at 23:09
4

This is an old question, but I recently ran into the same issue. You can use run_coroutine_threadsafe to schedule a coroutine to the event loop (rather than a callback):

asyncio.run_coroutine_threadsafe(async_function(), bot.loop)
Salem
  • 13,516
  • 4
  • 51
  • 70
2

This is old question with many answers but I think there is better solution.

I think you can do it @tasks.loop(time=datetime.time)

It can be important to use correct timezone. Without timezone it will run with UTC.

from discord.ext import commands, tasks

# --- calculate correct time ---

#tz = datetime.timezone.utc                           # Europe/London (UTC)
#tz = datetime.timezone(datetime.timedelta(hours=2))  # Europe/Warsaw (CEST)(UTC+02:00)
tz = datetime.datetime.now().astimezone().tzinfo     # local timezone
print('timezone:', tz)

midnight = datetime.time(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
print('midnight:', midnight, midnight.tzinfo)

# --- loop ---

@tasks.loop(time=midnight)
async def send_channel():
    try:
        await active_channel.send('daily text here')
    except Exception:
        active_channel_id = None
        active_channel = None

# --- start loop ---

@bot.event
def on_ready()
    send_channel.start()   # without `await`

# ---

if __name__ == '__main__':
    bot.run(os.getenv('TOKEN'))

It can also use list @tasks.loop(time=[time1, time2, ...])

midnight = datetime.time(hour=0,  minute=0,  second=0, microsecond=0, tzinfo=tz)
noon     = datetime.time(hour=12, minute=0,  second=0, microsecond=0, tzinfo=tz)
at_21_57 = datetime.time(hour=21, minute=57, second=0, microsecond=0, tzinfo=tz)

@tasks.loop(time=[midnight, noon, at_21_57])
async def send_channel():
     # ...code...
furas
  • 134,197
  • 12
  • 106
  • 148
1

Going through the very same problem I found a solution that mixes some of the previous solutions:

import schedule
from discord.ext import tasks

@tasks.loop(hours=24)
async def send_channel():
    pass

and later just before the main thread I define

def event_starter(func):
    if not func.is_running():
        func.start()

schedstop = threading.Event()
def timer():
    while not schedstop.is_set():
        schedule.run_pending()
        sleep(1)
schedthread = threading.Thread(target=timer)
schedthread.start()

and finally, in the main thread:

if __name__ == "__main__":

    ...

    schedule.every().day.at('21:57:00').do(event_starter, send_channel)

    ...