Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The call summary bot posts an overview message after the call listing all partic
- `- topic`
- `- top`
- You can also post multiple tasks in a single message, just put each on its own line starting with a keyword
- When the call starts, the bot posts the agenda as a checklist. Moderators can tick the items that were discussed during the call.
All agenda items which were not ticked when the call ends are automatically put on the agenda for the next call.

![Screenshot showing the agenda being posted when the call was started](docs/agenda.png)

Expand Down
33 changes: 33 additions & 0 deletions lib/Listener/BotInvokeListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use OCA\CallSummaryBot\Service\SummaryService;
use OCA\Talk\Events\BotInvokeEvent;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\ICommentsManager;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
Expand All @@ -33,6 +35,7 @@ public function __construct(
protected SummaryService $summaryService,
protected IConfig $config,
protected LoggerInterface $logger,
protected ICommentsManager $commentsManager,
) {
}

Expand Down Expand Up @@ -184,11 +187,41 @@ public function receiveWebhook(string $lang, BotInvokeEvent $event): void {
return;
}

$this->summaryService->carryOverAgenda($data['target']['id']);
$this->logEntryMapper->deleteByConversation($data['target']['id']);
} elseif ($data['object']['name'] === 'message_edited') {
if (!$this->logEntryMapper->hasActiveCall($data['target']['id'])) {
return;
}

$agendaMessage = $this->getEditedBotMessage((string)$data['object']['id'], $event->getBotUrl());
if ($agendaMessage !== null) {
$this->summaryService->updateAgendaFromMessage($data['target']['id'], $agendaMessage);
}
}
}
}

/**
* Get the message of this bot that was edited (e.g. by ticking a checkbox
* of the agenda) based on the "message_edited" system message
*/
protected function getEditedBotMessage(string $systemMessageId, string $botUrl): ?string {
try {
$systemMessage = $this->commentsManager->get($systemMessageId);
$editedMessage = $this->commentsManager->get($systemMessage->getParentId());
} catch (NotFoundException) {
return null;
}

if ($editedMessage->getActorType() !== 'bots'
|| $editedMessage->getActorId() !== 'bot-' . sha1($botUrl)) {
return null;
}

return $editedMessage->getMessage();
}

protected function getAuthorDisplayName(string $name, string $id, string $lang): ?string {
$displayName = $name;
if (str_starts_with($id, 'guests/') || str_starts_with($id, 'emails/')) {
Expand Down
4 changes: 4 additions & 0 deletions lib/Model/LogEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class LogEntry extends Entity {
public const TYPE_REPORT = 'report';
public const TYPE_DECISION = 'decision';
public const TYPE_AGENDA = 'agenda';
// Agenda items that were posted at the start of the currently running call
public const TYPE_AGENDA_POSTED = 'agenda_posted';
// Agenda items of the currently running call which were ticked in the agenda message
public const TYPE_AGENDA_DONE = 'agenda_done';
public const TYPE_SETTING_IGNORE_SILENT = 'setting_ignore_silent';

public const DETAILS_IGNORE_SILENT_MUTED = 'muted';
Expand Down
36 changes: 31 additions & 5 deletions lib/Model/LogEntryMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public function findByConversation(string $token): array {
$query->select('*')
->from($this->getTableName())
->where($query->expr()->eq('server', $query->createNamedParameter('local')))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)));
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)))
->orderBy('id', 'ASC');
return $this->findEntities($query);
}

Expand Down Expand Up @@ -77,16 +78,41 @@ public function deleteSetting(string $token, string $setting): void {
$query->executeStatement();
}

public function deleteByConversation(string $token, bool $keepAgenda = true): void {
public function deleteByConversation(string $token): void {
$query = $this->db->getQueryBuilder();
$query->delete($this->getTableName())
->where($query->expr()->eq('server', $query->createNamedParameter('local')))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)))
->andWhere($query->expr()->neq('type', $query->createNamedParameter(LogEntry::TYPE_SETTING_IGNORE_SILENT)));
->andWhere($query->expr()->neq('type', $query->createNamedParameter(LogEntry::TYPE_SETTING_IGNORE_SILENT)))
->andWhere($query->expr()->neq('type', $query->createNamedParameter(LogEntry::TYPE_AGENDA)));
$query->executeStatement();
}

if ($keepAgenda) {
$query->andWhere($query->expr()->neq('type', $query->createNamedParameter(LogEntry::TYPE_AGENDA)));
/**
* Change the type of all entries of a conversation from one type to another
*/
public function changeType(string $token, string $fromType, string $toType): void {
$query = $this->db->getQueryBuilder();
$query->update($this->getTableName())
->set('type', $query->createNamedParameter($toType))
->where($query->expr()->eq('server', $query->createNamedParameter('local')))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token)))
->andWhere($query->expr()->eq('type', $query->createNamedParameter($fromType)));
$query->executeStatement();
}

/**
* @param int[] $ids
*/
public function changeTypeOfEntries(array $ids, string $toType): void {
if (empty($ids)) {
return;
}

$query = $this->db->getQueryBuilder();
$query->update($this->getTableName())
->set('type', $query->createNamedParameter($toType))
->where($query->expr()->in('id', $query->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
$query->executeStatement();
}
}
52 changes: 51 additions & 1 deletion lib/Service/SummaryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ protected function saveTask(string $token, string $text, string $type): void {
*/
public function summarize(string $token, string $roomName, string $lang = 'en'): ?array {
$logEntries = $this->logEntryMapper->findByConversation($token);
$this->carryOverAgenda($token);
$this->logEntryMapper->deleteByConversation($token);

$libL10N = $this->l10nFactory->get('lib', $lang);
Expand Down Expand Up @@ -315,8 +316,20 @@ public function summarize(string $token, string $roomName, string $lang = 'en'):
* @return ?string
*/
public function agenda(string $token, string $lang = 'en', bool $preview = false): ?string {
if (!$preview) {
// Remove left-overs of previous calls that did not end properly
$this->carryOverAgenda($token);
$this->logEntryMapper->deleteByConversation($token);
}

$logEntries = $this->logEntryMapper->findByConversation($token);
$this->logEntryMapper->deleteByConversation($token, $preview);
if ($preview) {
$this->logEntryMapper->deleteByConversation($token);
} else {
// Remember which agenda items are posted, so the ones that are
// not marked as done can be carried over to the next call
$this->logEntryMapper->changeType($token, LogEntry::TYPE_AGENDA, LogEntry::TYPE_AGENDA_POSTED);
}

$agenda = [];
foreach ($logEntries as $logEntry) {
Expand All @@ -339,6 +352,43 @@ public function agenda(string $token, string $lang = 'en', bool $preview = false
return $summary;
}

/**
* Update the done state of the agenda items posted for the current call
* based on the checkboxes in the (edited) agenda message
*/
public function updateAgendaFromMessage(string $token, string $message): void {
$done = $open = [];
foreach ($this->logEntryMapper->findByConversation($token) as $logEntry) {
if ($logEntry->getType() !== LogEntry::TYPE_AGENDA_POSTED
&& $logEntry->getType() !== LogEntry::TYPE_AGENDA_DONE) {
continue;
}

$pattern = '/^[-*]\s\[([ x])]\s' . preg_quote((string)$logEntry->getDetails(), '/') . '$/mi';
if (!preg_match($pattern, $message, $matches)) {
continue;
}

$isDone = $matches[1] !== ' ';
if ($isDone && $logEntry->getType() === LogEntry::TYPE_AGENDA_POSTED) {
$done[] = $logEntry->getId();
} elseif (!$isDone && $logEntry->getType() === LogEntry::TYPE_AGENDA_DONE) {
$open[] = $logEntry->getId();
}
}

$this->logEntryMapper->changeTypeOfEntries($done, LogEntry::TYPE_AGENDA_DONE);
$this->logEntryMapper->changeTypeOfEntries($open, LogEntry::TYPE_AGENDA_POSTED);
}

/**
* Agenda items which were posted at the start of the call but not marked
* as done, are put back on the agenda for the next call
*/
public function carryOverAgenda(string $token): void {
$this->logEntryMapper->changeType($token, LogEntry::TYPE_AGENDA_POSTED, LogEntry::TYPE_AGENDA);
}

protected function getTitle(IL10N $l, string $roomName): string {
if ($this->isOneToOne($roomName)) {
return $l->t('Call summary');
Expand Down
126 changes: 126 additions & 0 deletions tests/Service/SummaryServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,130 @@ public function testGetTitle(string $roomName, string $title): void {

self::assertEquals($title, self::invokePrivate($service, 'getTitle', [$l, $roomName]));
}

protected function createLogEntry(int $id, string $type, string $details): LogEntry {
$logEntry = new LogEntry();
$logEntry->setId($id);
$logEntry->setType($type);
$logEntry->setDetails($details);
return $logEntry;
}

public static function dataUpdateAgendaFromMessage(): array {
$agenda = '# Agenda' . "\n\n";
return [
'nothing ticked' => [
$agenda . '- [ ] Item 1' . "\n" . '- [ ] Item 2' . "\n",
[],
[],
],
'one ticked' => [
$agenda . '- [x] Item 1' . "\n" . '- [ ] Item 2' . "\n",
[1],
[],
],
'upper case and asterisk' => [
$agenda . '* [X] Item 1' . "\n" . '- [x] Item 2',
[1, 2],
[],
],
'unticked again' => [
$agenda . '- [ ] Item 1' . "\n" . '- [ ] Item 2' . "\n" . '- [ ] Item 3' . "\n",
[],
[3],
],
'prefix of other item is not matched' => [
$agenda . '- [x] Item 1 extended' . "\n" . '- [ ] Item 2' . "\n",
[],
[],
],
'multi-line item' => [
$agenda . '- [x] Item 1' . "\n" . '- [x] Item 4' . "\n" . 'second line' . "\n",
[1, 4],
[],
],
];
}

/**
* @dataProvider dataUpdateAgendaFromMessage
*/
public function testUpdateAgendaFromMessage(string $message, array $done, array $open): void {
$this->mapper->method('findByConversation')
->with('t0k3n')
->willReturn([
$this->createLogEntry(1, LogEntry::TYPE_AGENDA_POSTED, 'Item 1'),
$this->createLogEntry(2, LogEntry::TYPE_AGENDA_POSTED, 'Item 2'),
$this->createLogEntry(3, LogEntry::TYPE_AGENDA_DONE, 'Item 3'),
$this->createLogEntry(4, LogEntry::TYPE_AGENDA_POSTED, 'Item 4' . "\n" . 'second line'),
$this->createLogEntry(5, LogEntry::TYPE_AGENDA, 'Item 5'),
$this->createLogEntry(6, LogEntry::TYPE_TODO, 'Item 6'),
]);

$this->mapper->expects($this->exactly(2))
->method('changeTypeOfEntries')
->willReturnCallback(function (array $ids, string $type) use ($done, $open): void {
if ($type === LogEntry::TYPE_AGENDA_DONE) {
self::assertEquals($done, $ids);
} else {
self::assertEquals(LogEntry::TYPE_AGENDA_POSTED, $type);
self::assertEquals($open, $ids);
}
});

$this->getService()->updateAgendaFromMessage('t0k3n', $message);
}

public function testAgendaMarksItemsAsPosted(): void {
$l = $this->createMock(IL10N::class);
$l->method('t')
->willReturnArgument(0);
$this->l10nFactory->method('get')
->willReturn($l);

$this->mapper->method('findByConversation')
->willReturn([
$this->createLogEntry(1, LogEntry::TYPE_AGENDA, 'Item 1'),
$this->createLogEntry(2, LogEntry::TYPE_AGENDA, 'Item 2'),
]);

$changes = [];
$this->mapper->expects($this->exactly(2))
->method('changeType')
->willReturnCallback(function (string $token, string $from, string $to) use (&$changes): void {
$changes[] = [$from, $to];
});

self::assertEquals(
'# Agenda' . "\n\n" . '- [ ] Item 1' . "\n" . '- [ ] Item 2' . "\n",
$this->getService()->agenda('t0k3n')
);
self::assertEquals([
[LogEntry::TYPE_AGENDA_POSTED, LogEntry::TYPE_AGENDA],
[LogEntry::TYPE_AGENDA, LogEntry::TYPE_AGENDA_POSTED],
], $changes);
}

public function testAgendaPreviewKeepsAgenda(): void {
$l = $this->createMock(IL10N::class);
$l->method('t')
->willReturnArgument(0);
$this->l10nFactory->method('get')
->willReturn($l);

$this->mapper->method('findByConversation')
->willReturn([
$this->createLogEntry(1, LogEntry::TYPE_AGENDA, 'Item 1'),
]);
$this->mapper->expects($this->never())
->method('changeType');
$this->mapper->expects($this->once())
->method('deleteByConversation')
->with('t0k3n');

self::assertEquals(
'# Agenda' . "\n\n" . '- [ ] Item 1' . "\n",
$this->getService()->agenda('t0k3n', 'en', true)
);
}
}
Loading