Simple Task complete listener: log, ping other server

I’m trying to get a simple custom app working that listens for when tasks are completed and does a couple things:

  1. logs the completion
  2. hits a custom server /track endpoint

I can enable it: I copy it into my apps/ dir and use occ app:enable task_tracker, but the handle() method isn’t called when I complete a task. I have some logging that never shows up, and the other server’s /track endpoint isn’t hit.

I’m using a 32.0.8 server, so I’m following the v32 developer manual.

composer.json
{
    "name": "meonkeys/task_tracker",
    "description": "Nextcloud app that tracks task completions, notifying external server.",
    "license": "AGPL-3.0-or-later",
    "require": {
        "php": "^8.0",
        "guzzlehttp/guzzle": "^7.0"
    },
    "autoload": {
        "psr-4": {
            "OCA\\TaskTracker\\": "lib/"
        }
    }
}
appinfo/info.xml
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
    <id>task_tracker</id>
    <name>Task Tracker</name>
    <summary>Sends completed tasks to a Flask API</summary>
    <version>1.0.0</version>
    <description>Listens for Nextcloud Task completions, logs them, and pings a server.</description>
    <licence>AGPL-3.0-or-later</licence>
    <author mail="haircut@gmail.com" homepage="https://adammonsen.com">Adam Monsen</author>
    <namespace>TaskTracker</namespace>
    <category>tools</category>
    <dependencies>
        <nextcloud min-version="32"/>
    </dependencies>
</info>
lib/AppInfo/Application.php
<?php

declare(strict_types=1);

namespace OCA\TaskTracker\AppInfo;

use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;

use OCA\Calendar\Event\CalendarObjectUpdatedEvent;
use OCA\TaskTracker\Listener\TaskCompletedListener;

class Application extends App implements IBootstrap {
    public const APP_ID = 'task_tracker';

    public function __construct(array $params = []) {
        parent::__construct(self::APP_ID, $params);
    }

    public function register(IRegistrationContext $context): void {
        $context->registerEventListener(
            CalendarObjectUpdatedEvent::class, 
            TaskCompletedListener::class
        );
    }

    public function boot(IBootContext $context): void {
        // Nothing needed at boot time
    }
}
lib/Listener/TaskCompletedListener.php
<?php

declare(strict_types=1);

namespace OCA\TaskTracker\Listener;

use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCA\Calendar\Event\CalendarObjectUpdatedEvent;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Reader;

// see also: https://github.com/nextcloud/spreed/blob/main/lib/Listener/CalDavEventListener.php

class TaskCompletedListener implements IEventListener {
    public function __construct(LoggerInterface $logger) {
        $logger->info('Constructed. 😌');
    }

    public function handle(Event $event): void {
        $this->logger->debug('😭 please just log something already');
        $this->logger->info('handle');
        if (!$event instanceof CalendarObjectUpdatedEvent) {
            return;
        }

        $calendarData = $event->getCalendardata();
        $vobj = Reader::read($calendarData);
        $this->logger->info('got vobj');

        // only look at tasks
        if (isset($vobj->VTODO)) {
            $this->logger->info('vobj is a task: [' . $vobj . ']');
            $todo = $vobj->VTODO;

            if (isset($todo->STATUS) && (string)$todo->STATUS === 'COMPLETED') {
                $this->logger->info('sending to multi-tracker server');
                $this->pingOtherServer($todo);
            }
        }
    }

    private function pingOtherServer($todo) {
        $this->logger->info('in pingOtherServer');

        $title = isset($todo->SUMMARY) ? (string)$todo->SUMMARY : 'Untitled Task';
        $detail = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : '';

        try {
            $this->logger->info('posting to multi-tracker server');
            // actual URL redacted
            $client->post('https://tracker.example.com/track', [
                'json' => [
                    'title'  => $title,
                    'detail' => $detail,
                    'src'    => 'nextcloud',
                    'tags'   => ['task']
                ],
                'timeout' => 2
            ]);
        } catch (\Exception $e) {
            $this->logger->error('Task Tracker failed: ' . $e->getMessage());
        }
    }
}

Any ideas? I’m super rusty at PHP and I’m just cobbling this all together so I’m surely doing something naive.

Hi @meonkeys,

You posted this in the wrong category (Support instead of Development) - I moved it for you.


ernolf

I spotted a few things that look like bugs – I may have missed something, but these seem worth checking:

  1. $this->logger is never assigned

    In your constructor, $logger is received as a parameter but never saved to the instance:

    public function __construct(LoggerInterface $logger) {
        $logger->info('Constructed.');  // works
        // but $this->logger is never set
    }
    

    Every call to $this->logger->… in handle() would then fail with a fatal error. PHP 8 property promotion would fix this:

    public function __construct(private LoggerInterface $logger) {
        $this->logger->info('Constructed.');
    }
    

  1. $client is never instantiated

    In pingOtherServer(), $client is used but never created – $client = new Client(); seems to be missing before the post() call.


  1. Possibly wrong event class

    OCA\Calendar\Event\CalendarObjectUpdatedEvent does not appear to exist. Looking at the DAV backend in NC32, the event dispatched for local calendar object updates seems to be:

    use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
    

    Note OCP instead of OCA, and Events (plural) instead of Event. The CachedCalendarObject* events in OCA\DAV\Events appear to fire only for subscribed (external) calendars.


  1. Do not bundle Guzzle – use Nextcloud’s HTTP client

    You do not need guzzlehttp/guzzle in your composer.json. Nextcloud already ships Guzzle 7 internally and exposes it through the public API via OCP\Http\Client\IClientService. Using your own bundled copy risks version conflicts and bypasses Nextcloud’s proxy and certificate configuration.

    Inject IClientService in your constructor and use it like this:

    use OCP\Http\Client\IClientService;
    
    public function __construct(
        private LoggerInterface $logger,
        private IClientService $clientService,
    ) {}
    
    // in pingOtherServer():
    $client = $this->clientService->newClient();
    $client->post('https://tracker.example.com/track', [
        'json'    => [...],
        'timeout' => 2,
    ]);
    

That said, I have not tested this myself, so your mileage may vary.
h.t.h.


ernolf

These are so helpful, thank you. They are all correct. The showstopper was using an incorrect namespace for CalendarObjectUpdatedEvent because it was a silent failure (that was AI slop! argh). Once I applied your fixed that it was easy to apply the other fixes you mentioned while debugging with log messages.