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
4 changes: 2 additions & 2 deletions .github/workflows/integration_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ jobs:
run: |
./occ app_api:daemon:register --net host manual_install "Manual Install" manual-install http localhost http://localhost:8080

- name: Setup python 3.10
- name: Setup python 3.11
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #v6.2.0
with:
python-version: '3.10'
python-version: '3.11'

- name: Install llm2 app
working-directory: llm2
Expand Down
4 changes: 2 additions & 2 deletions ex_app/lib/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from langchain_core.messages import ToolMessage, SystemMessage, AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda
from nc_py_api import Nextcloud
from nc_py_api import AsyncNextcloudApp
from nc_py_api.ex_app import persistent_storage

from ex_app.lib.signature import verify_signature
Expand Down Expand Up @@ -93,7 +93,7 @@ def export_conversation(checkpointer):
conversation_token = add_signature(serialized_state.decode('utf-8'), key)
return conversation_token

async def react(task, nc: Nextcloud):
async def react(task, nc: AsyncNextcloudApp):
safe_tools, dangerous_tools = await get_tools(nc)

model.bind_nextcloud(nc)
Expand Down
14 changes: 7 additions & 7 deletions ex_app/lib/all_tools/audio2text.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
from langchain_core.tools import tool
from nc_py_api import Nextcloud
from nc_py_api import AsyncNextcloudApp

from ex_app.lib.all_tools.lib.files import get_file_id_from_file_url
from ex_app.lib.all_tools.lib.task_processing import run_task
from ex_app.lib.all_tools.lib.decorator import safe_tool


async def get_tools(nc: Nextcloud):
async def get_tools(nc: AsyncNextcloudApp):

@tool
@safe_tool
def transcribe_file(file_url: str) -> str:
async def transcribe_file(file_url: str) -> str:
"""
Transcribe a media file stored inside Nextcloud
:param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example)
:param file_url: The file URL to the media file in nextcloud (e.g. https://host/index.php/f/1234 - The user can input this using the smart picker for example)
:return: the transcription result
"""

task_input = {
'input': get_file_id_from_file_url(file_url),
}
task_output = run_task(nc, "core:audio2text", task_input).output
task_output = (await run_task(nc, "core:audio2text", task_input)).output
return task_output['output']

return [
Expand All @@ -32,6 +32,6 @@ def transcribe_file(file_url: str) -> str:
def get_category_name():
return "Audio transcription"

def is_available(nc: Nextcloud):
tasktypes = nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes')['types'].keys()
async def is_available(nc: AsyncNextcloudApp):
tasktypes = (await nc.ocs('GET', '/ocs/v2.php/taskprocessing/tasktypes'))['types'].keys()
return 'core:audio2text' in tasktypes
206 changes: 112 additions & 94 deletions ex_app/lib/all_tools/calendar.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
import asyncio
import time
from datetime import datetime, timezone, timedelta
from time import sleep
from typing import Optional

from niquests import ConnectionError, Timeout
import pytz
from ics import Calendar, Event, Attendee, Organizer, Todo
from langchain_core.tools import tool
from nc_py_api import Nextcloud
from nc_py_api import AsyncNextcloudApp, NextcloudApp
from nc_py_api.ex_app import LogLvl
import xml.etree.ElementTree as ET
import vobject
Expand All @@ -18,37 +19,25 @@
from ex_app.lib.logger import log


async def get_tools(nc: Nextcloud):
async def get_tools(nc: AsyncNextcloudApp):
ncSync = NextcloudApp()
ncSync.set_user(await nc.user)

@tool
@safe_tool
def list_calendars():
"""
List all existing calendars by name
:return:
"""
principal = nc.cal.principal()
def list_calendars_sync():
principal = ncSync.cal.principal()
calendars = principal.calendars()
return ", ".join([cal.name for cal in calendars])

@tool
@dangerous_tool
def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]):
@safe_tool
async def list_calendars():
"""
Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event.
:param calendar_name: The name of the calendar to add the event to
:param title: The title of the event
:param description: The description of the event
:param start_date: the start date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param end_date: the end date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param attendees: the list of attendees to add to the event (as email addresses)
:param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM'
:param location: The location of the event
:param timezone: Timezone (e.g., 'America/New_York').
:return: bool
List all existing calendars by name
:return: a comma-separated list of calendar names
"""
return await asyncio.to_thread(list_calendars_sync)

def schedule_event_sync(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]):
# Parse date and times
start_date = datetime.strptime(start_date, "%Y-%m-%d")
end_date = datetime.strptime(end_date, "%Y-%m-%d")
Expand Down Expand Up @@ -88,15 +77,15 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date:
i = 0
while i < 20:
try:
json = nc.ocs('GET', '/ocs/v2.php/cloud/user')
json = ncSync.ocs('GET', '/ocs/v2.php/cloud/user')
break
except (
ConnectionError,
Timeout
) as e:
log(nc, LogLvl.DEBUG, "Ignored error during task polling")
log(ncSync, LogLvl.DEBUG, "Ignored error during task polling")
i += 1
sleep(1)
time.sleep(1)
continue

# ...and set the organizer
Expand All @@ -105,48 +94,58 @@ def schedule_event(calendar_name: str, title: str, description: str, start_date:
# Add event to calendar
c.events.add(e)

principal = nc.cal.principal()
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendar.add_event(str(c))

return True


@tool
@safe_tool
def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]):
@dangerous_tool
async def schedule_event(calendar_name: str, title: str, description: str, start_date: str, end_date: str, attendees: Optional[list[str]], start_time: Optional[str], end_time: Optional[str], location: Optional[str], timezone: Optional[str]):
"""
Finds a free time slot where all participants have time
:param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts)
:param slot_duration: How long the time slot should be in hours, defaults to one hour
:param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31)
:param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31)
:return:
Crete a new event or meeting in a calendar. Omit start_time and end_time parameters to create an all-day event.
:param calendar_name: The name of the calendar to add the event to
:param title: The title of the event
:param description: The description of the event
:param start_date: the start date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param end_date: the end date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param attendees: the list of attendees to add to the event (as email addresses)
:param start_time: the start time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param end_time: the start time in the following form: HH:MM AM/PM e.g. '4:00 PM'
:param location: The location of the event
:param timezone: Timezone (e.g., 'America/New_York').
:return: bool
"""
await asyncio.to_thread(schedule_event_sync, calendar_name, title, description, start_date, end_date, attendees, start_time, end_time, location, timezone)

me = nc.ocs('GET', '/ocs/v2.php/cloud/user')
return True

attendees = 'ORGANIZER:mailto:'+me['email']+'\n'
attendees += 'ATTENDEE:mailto:'+me['email']+'\n'
for attendee in participants:
attendees += f"ATTENDEE:mailto:{attendee}\n"
def find_free_time_slot_in_calendar_sync(participants: list[str], slot_duration: Optional[float],
start_time: Optional[str], end_time: Optional[str]):
me = ncSync.ocs('GET', '/ocs/v2.php/cloud/user')

if start_time is None:
start_time = round_to_nearest_half_hour(datetime.now(timezone.utc))
else:
start_time = datetime.combine(datetime.strptime(start_time, "%Y-%m-%d").date(), datetime.min.time(), timezone.utc)
if end_time is None:
end_time = start_time + timedelta(days=7)
else:
end_time = datetime.combine(datetime.strptime(end_time, "%Y-%m-%d").date(), datetime.min.time(), timezone.utc)
if start_time >= end_time:
attendees = 'ORGANIZER:mailto:' + me['email'] + '\n'
attendees += 'ATTENDEE:mailto:' + me['email'] + '\n'
for attendee in participants:
attendees += f"ATTENDEE:mailto:{attendee}\n"

if start_time is None:
start_time = round_to_nearest_half_hour(datetime.now(timezone.utc))
else:
start_time = datetime.combine(datetime.strptime(start_time, "%Y-%m-%d").date(), datetime.min.time(),
timezone.utc)
if end_time is None:
end_time = start_time + timedelta(days=7)
else:
end_time = datetime.combine(datetime.strptime(end_time, "%Y-%m-%d").date(), datetime.min.time(),
timezone.utc)
if start_time >= end_time:
end_time = start_time + timedelta(days=7)

dtstart = start_time.strftime("%Y%m%dT%H%M%SZ")
dtend = end_time.strftime("%Y%m%dT%H%M%SZ")
dtstart = start_time.strftime("%Y%m%dT%H%M%SZ")
dtend = end_time.strftime("%Y%m%dT%H%M%SZ")

freebusyRequest = """
freebusyRequest = """
BEGIN:VCALENDAR
PRODID:-//IDN nextcloud.com//Calendar app 5.1.0-beta.2//EN
CALSCALE:GREGORIAN
Expand All @@ -160,45 +159,46 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti
{ATTENDEES}END:VFREEBUSY
END:VCALENDAR
""".replace('{ATTENDEES}', attendees).replace('{DTSTART}', dtstart).replace('{DTEND}', dtend)
username = nc._session.user
response = nc._session._create_adapter(True).request('POST', f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{username}/outbox/", headers={
"Content-Type": "text/calendar; charset=utf-8",
"Depth": "0",
}, content=freebusyRequest)
print(freebusyRequest)
print(response.text)

# Parse the XML response to extract vCard data
namespace = {"CAL": "urn:ietf:params:xml:ns:caldav"} # Define the namespace
root = ET.fromstring(response.text)
vcal_elements = root.findall(".//CAL:calendar-data", namespace)
# Parse vcal strings into dictionaries
busy_times = []
for vcal_element in vcal_elements:
vcal_text = vcal_element.text.strip()
vcal = vobject.readOne(vcal_text)
for fb in vcal.vfreebusy.contents.get("freebusy", []):
busy_times.append(fb.value[0])
print('busy times', busy_times)
available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration))
print('available_slots', available_slots)
return available_slots

username = ncSync._session.user
response = ncSync._session._create_adapter(True).request('POST',
f"{nc.app_cfg.endpoint}/remote.php/dav/calendars/{username}/outbox/",
headers={
"Content-Type": "text/calendar; charset=utf-8",
"Depth": "0",
}, content=freebusyRequest)
print(freebusyRequest)
print(response.text)

# Parse the XML response to extract vCard data
namespace = {"CAL": "urn:ietf:params:xml:ns:caldav"} # Define the namespace
root = ET.fromstring(response.text)
vcal_elements = root.findall(".//CAL:calendar-data", namespace)
# Parse vcal strings into dictionaries
busy_times = []
for vcal_element in vcal_elements:
vcal_text = vcal_element.text.strip()
vcal = vobject.readOne(vcal_text)
for fb in vcal.vfreebusy.contents.get("freebusy", []):
busy_times.append(fb.value[0])
available_slots = find_available_slots(start_time, end_time, busy_times, timedelta(hours=slot_duration))
return available_slots

@tool
@dangerous_tool
def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str],):
@safe_tool
async def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Optional[float], start_time: Optional[str], end_time: Optional[str]):
"""
Crete a new task in a calendar.
:param calendar_name: The name of the calendar to add the task to
:param title: The title of the task
:param description: The description of the task
:param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param timezone: Timezone (e.g., 'America/New_York'). Is required if there is a specified due date.
:return: bool
Finds a free time slot where all participants have time
:param participants: The list of participants to find a free slot for (These should be email addresses. If possible use the email addresses from contacts)
:param slot_duration: How long the time slot should be in hours, defaults to one hour
:param start_time: the start time of the range within which to check for free slots (by default this will be now; use the following format: 2025-01-31)
:param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31)
:return:
"""
available_slots = asyncio.to_thread(find_free_time_slot_in_calendar_sync, participants, slot_duration, start_time, end_time)
return available_slots

def add_task_sync(calendar_name: str, title: str, description: str, due_date: Optional[str],
due_time: Optional[str], timezone: Optional[str]):
description_with_ai_note = f"{description}\n\n---\n\nThis task was scheduled by Nextcloud AI Assistant."

# Create task
Expand All @@ -225,17 +225,35 @@ def add_task(calendar_name: str, title: str, description: str, due_date: Optiona
due_datetime = tz.localize(due_datetime)

t.due = due_datetime

# Add event to calendar
c.todos.add(t)

principal = nc.cal.principal()
principal = ncSync.cal.principal()
calendars = principal.calendars()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendar.add_todo(t.serialize())

return True

@tool
@dangerous_tool
async def add_task(calendar_name: str, title: str, description: str, due_date: Optional[str], due_time: Optional[str], timezone: Optional[str]):
"""
Crete a new task in a calendar.
:param calendar_name: The name of the calendar to add the task to
:param title: The title of the task
:param description: The description of the task
:param due_date: the due date of the event in the following form: YYYY-MM-DD e.g. '2024-12-01'
:param due_time: the due time in the following form: HH:MM AM/PM e.g. '3:00 PM'
:param timezone: Timezone (e.g., 'America/New_York'). Is required if there is a specified due date.
:return: bool
"""

asyncio.to_thread(add_task_sync, calendar_name, title, description, due_date, due_time, timezone)

return True

return [
list_calendars,
schedule_event,
Expand All @@ -246,5 +264,5 @@ def add_task(calendar_name: str, title: str, description: str, due_date: Optiona
def get_category_name():
return "Calendar and Tasks"

def is_available(nc: Nextcloud):
async def is_available(nc: AsyncNextcloudApp):
return True
Loading
Loading