# Directory Structure ``` ├── GCalendar │ ├── README.md │ ├── requirements.txt │ └── src │ ├── calendar_service.py │ ├── create_token.py │ ├── list_past_events.py │ ├── mcp_client.py │ ├── mcp_server.py │ └── renew_token.py ├── README.md ├── requirements.txt └── src ├── calendar_service.py ├── create_token.py ├── list_past_events.py ├── mcp_client.py ├── mcp_server.py └── renew_token.py ``` # Files -------------------------------------------------------------------------------- /GCalendar/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Google Calendar MCP Server - Python Installation Guide 2 | 3 | 📘 **Overview** 4 | A Model Context Protocol server that provides access to Google Calendar API with support for asynchronous operations. This implementation enables efficient calendar management through a standardized interface. 5 | 6 | 🔑 **Key Components** 7 | 8 | ### API Tools 9 | - list: Query calendar events (past 2 years to 1 year future) 10 | - create-event: Create new calendar entries 11 | - delete-duplicates: Remove duplicate events 12 | - delete-event: Delete specific calendar events 13 | 14 | ### Core Resources 15 | - Token Management System (credentials/*.json) 16 | - OAuth 2.0 authentication flow 17 | - Automatic token refresh handling 18 | - Runtime token discovery 19 | - Logging System (logs/*.log) 20 | - Operation logging with timestamps 21 | - Error tracking and debugging 22 | - Performance metrics collection 23 | 24 | 💡 **Implementation Steps** 25 | 26 | ### Prerequisites 27 | ```bash 28 | # Install required Python packages 29 | pip install -r requirements.txt 30 | ``` 31 | 32 | Dependencies: 33 | ```python 34 | google-auth-oauthlib==1.0.0 35 | google-auth-httplib2==0.1.0 36 | google-api-python-client==2.108.0 37 | aiohttp==3.8.5 38 | asyncio==3.4.3 39 | ``` 40 | 41 | ### Initial Setup 42 | ```bash 43 | # Clone repository 44 | git clone https://github.com/yourusername/GCalendar.git 45 | cd GCalendar 46 | 47 | # Create required directories 48 | mkdir -p credentials logs 49 | ``` 50 | 51 | ### Authentication Setup 52 | 1. Create Google Cloud Console project 53 | 2. Enable Google Calendar API 54 | 3. Download credentials.json to credentials/ 55 | 4. Run create_token.py: 56 | ```bash 57 | python src/create_token.py 58 | ``` 59 | 60 | ⚙️ **Technical Configuration** 61 | 62 | ### Server Configuration 63 | Add to `claude_desktop_config.json`: 64 | ```json 65 | { 66 | "mcpServers": { 67 | "gcalendar": { 68 | "command": "YOUR_CONDA_PATH/envs/mcp-gcalendar/bin/python", 69 | "args": [ 70 | "YOUR_PATH/GCalendar/src/mcp_server.py" 71 | ] 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | ### Path Configuration 78 | Replace placeholders: 79 | • YOUR_CONDA_PATH options: 80 | - M1/M2 Mac: `/opt/homebrew/Caskroom/miniforge/base` 81 | - Miniconda: `~/miniconda3` or `/opt/miniconda3` 82 | • YOUR_PATH: Full repository clone path 83 | 84 | ### Error Handling 85 | - Automatic token refresh on expiry 86 | - 60-second operation timeout 87 | - Comprehensive error logging 88 | - Auto-retry on transient errors 89 | 90 | ### Project Structure 91 | ``` 92 | GCalendar/ 93 | ├── credentials/ 94 | │ ├── credentials.json # From Google Cloud Console 95 | │ └── token.json # Generated by create_token.py 96 | ├── logs/ 97 | │ └── calendar_service.log 98 | ├── src/ 99 | │ ├── calendar_service.py # Core calendar operations 100 | │ ├── create_token.py # Token generation 101 | │ ├── list_past_events.py # Event listing utility 102 | │ ├── mcp_client.py # MCP client implementation 103 | │ ├── mcp_server.py # Main server implementation 104 | │ └── renew_token.py # Token renewal utility 105 | ├── requirements.txt 106 | └── README.md 107 | ``` 108 | 109 | ### System Requirements 110 | - Python 3.9 or higher 111 | - Google Calendar API enabled 112 | - Valid OAuth 2.0 credentials 113 | - Internet connectivity 114 | - Asia/Bangkok timezone 115 | 116 | ## License 117 | This project is licensed under the MIT License. See LICENSE file for details. 118 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- ``` 1 | google-auth-oauthlib==1.0.0 2 | google-auth-httplib2==0.1.0 3 | google-api-python-client==2.108.0 4 | aiohttp==3.8.5 5 | asyncio==3.4.3 6 | mcp ``` -------------------------------------------------------------------------------- /GCalendar/requirements.txt: -------------------------------------------------------------------------------- ``` 1 | google-auth-oauthlib==1.0.0 2 | google-auth-httplib2==0.1.0 3 | google-api-python-client==2.108.0 4 | aiohttp==3.8.5 5 | asyncio==3.4.3 6 | tzdata==2023.3 # For timezone database ``` -------------------------------------------------------------------------------- /GCalendar/src/mcp_client.py: -------------------------------------------------------------------------------- ```python 1 | from mcp import ClientSession, StdioServerParameters 2 | from mcp.client.stdio import stdio_client 3 | import asyncio 4 | import logging 5 | import os 6 | 7 | # Set up logging 8 | logging.basicConfig( 9 | level=logging.INFO, 10 | format='[%(asctime)s] %(levelname)s: %(message)s', 11 | datefmt='%Y-%m-%d %H:%M:%S' 12 | ) 13 | logger = logging.getLogger(__name__) 14 | 15 | async def run(): 16 | # Create server parameters 17 | server_params = StdioServerParameters( 18 | command="python", 19 | args=[os.path.join(os.path.dirname(__file__), "mcp_server.py")] 20 | ) 21 | 22 | try: 23 | async with stdio_client(server_params) as (read, write): 24 | async with ClientSession(read, write) as session: 25 | # Initialize connection 26 | await session.initialize() 27 | logger.info("Initialization successful") 28 | 29 | # List calendar events using tool 30 | result = await session.call_tool("list", {}) 31 | if result and hasattr(result, "content"): 32 | for content_item in result.content: 33 | if hasattr(content_item, "text"): 34 | print("\nCalendar Events:\n") 35 | print(content_item.text) 36 | else: 37 | logger.warning("No events data received") 38 | 39 | except Exception as e: 40 | logger.error(f"Error: {e}", exc_info=True) 41 | 42 | if __name__ == "__main__": 43 | try: 44 | asyncio.run(run()) 45 | except KeyboardInterrupt: 46 | logger.info("Client shutdown requested") ``` -------------------------------------------------------------------------------- /src/mcp_client.py: -------------------------------------------------------------------------------- ```python 1 | from mcp import ClientSession, StdioServerParameters 2 | from mcp.client.stdio import stdio_client 3 | import asyncio 4 | import logging 5 | import os 6 | 7 | # Set up logging 8 | logging.basicConfig( 9 | level=logging.INFO, 10 | format='[%(asctime)s] %(levelname)s: %(message)s', 11 | datefmt='%Y-%m-%d %H:%M:%S' 12 | ) 13 | logger = logging.getLogger(__name__) 14 | 15 | async def run(): 16 | # Create server parameters 17 | server_params = StdioServerParameters( 18 | command="python", 19 | args=[os.path.join(os.path.dirname(__file__), "mcp_server.py")] 20 | ) 21 | 22 | try: 23 | async with stdio_client(server_params) as (read, write): 24 | async with ClientSession(read, write) as session: 25 | # Initialize connection 26 | await session.initialize() 27 | logger.info("Initialization successful") 28 | 29 | # List calendar events using tool 30 | result = await session.call_tool("list", {}) 31 | if result and hasattr(result, "content"): 32 | for content_item in result.content: 33 | if hasattr(content_item, "text"): 34 | print("\nCalendar Events:\n") 35 | print(content_item.text) 36 | else: 37 | logger.warning("No events data received") 38 | 39 | except Exception as e: 40 | logger.error(f"Error: {e}", exc_info=True) 41 | 42 | if __name__ == "__main__": 43 | try: 44 | asyncio.run(run()) 45 | except KeyboardInterrupt: 46 | logger.info("Client shutdown requested") ``` -------------------------------------------------------------------------------- /GCalendar/src/mcp_server.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server import Server, NotificationOptions 2 | from mcp.server.models import InitializationOptions 3 | import mcp.server.stdio 4 | import mcp.types as types 5 | import logging 6 | import asyncio 7 | from calendar_service import CalendarService 8 | import os 9 | import signal 10 | from datetime import datetime 11 | 12 | # Set up logging 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='[%(asctime)s] %(levelname)s: %(message)s', 16 | datefmt='%Y-%m-%d %H:%M:%S' 17 | ) 18 | logger = logging.getLogger(__name__) 19 | 20 | # Create a server instance 21 | server = Server("gcalendar-server") 22 | calendar_service = None 23 | 24 | # Signal handler for graceful shutdown 25 | def signal_handler(signum, frame): 26 | logger.info(f"Received signal {signum}") 27 | if calendar_service: 28 | calendar_service.close() 29 | raise KeyboardInterrupt 30 | 31 | signal.signal(signal.SIGINT, signal_handler) 32 | signal.signal(signal.SIGTERM, signal_handler) 33 | 34 | @server.list_tools() 35 | async def handle_list_tools() -> list[types.Tool]: 36 | return [ 37 | types.Tool( 38 | name="list", 39 | description="List calendar events", 40 | arguments={}, 41 | inputSchema={ 42 | "type": "object", 43 | "properties": {} 44 | } 45 | ), 46 | types.Tool( 47 | name="create-event", 48 | description="Create a new calendar event", 49 | arguments={}, 50 | inputSchema={ 51 | "type": "object", 52 | "properties": { 53 | "summary": { 54 | "type": "string", 55 | "description": "Event title" 56 | }, 57 | "start_time": { 58 | "type": "string", 59 | "description": "Start time in YYYY-MM-DDTHH:MM:SS format or date in YYYY-MM-DD format" 60 | }, 61 | "end_time": { 62 | "type": "string", 63 | "description": "End time (optional). If not provided, event will be 1 hour long" 64 | }, 65 | "description": { 66 | "type": "string", 67 | "description": "Event description (optional)" 68 | } 69 | }, 70 | "required": ["summary", "start_time"] 71 | } 72 | ), 73 | types.Tool( 74 | name="delete-duplicates", 75 | description="Delete duplicate events on a specific date", 76 | arguments={}, 77 | inputSchema={ 78 | "type": "object", 79 | "properties": { 80 | "target_date": { 81 | "type": "string", 82 | "description": "Target date in YYYY-MM-DD format" 83 | }, 84 | "event_summary": { 85 | "type": "string", 86 | "description": "Event title to match" 87 | } 88 | }, 89 | "required": ["target_date", "event_summary"] 90 | } 91 | ), 92 | types.Tool( 93 | name="delete-event", 94 | description="Delete a single calendar event", 95 | arguments={}, 96 | inputSchema={ 97 | "type": "object", 98 | "properties": { 99 | "event_time": { 100 | "type": "string", 101 | "description": "Event time from list output" 102 | }, 103 | "event_summary": { 104 | "type": "string", 105 | "description": "Event title to match" 106 | } 107 | }, 108 | "required": ["event_time", "event_summary"] 109 | } 110 | ) 111 | ] 112 | 113 | @server.call_tool() 114 | async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]: 115 | try: 116 | if name == "list": 117 | formatted_text = await asyncio.wait_for( 118 | calendar_service.list_events(), 119 | timeout=60 120 | ) 121 | return [types.TextContent( 122 | type="text", 123 | text=formatted_text 124 | )] 125 | 126 | elif name == "create-event": 127 | if not arguments.get("summary"): 128 | return [types.TextContent( 129 | type="text", 130 | text="Error: Event title (summary) is required" 131 | )] 132 | 133 | if not arguments.get("start_time"): 134 | return [types.TextContent( 135 | type="text", 136 | text="Error: Start time is required" 137 | )] 138 | 139 | result = await asyncio.wait_for( 140 | calendar_service.create_event( 141 | summary=arguments["summary"], 142 | start_time=arguments["start_time"], 143 | end_time=arguments.get("end_time"), 144 | description=arguments.get("description") 145 | ), 146 | timeout=60 147 | ) 148 | 149 | return [types.TextContent( 150 | type="text", 151 | text=result 152 | )] 153 | 154 | elif name == "delete-duplicates": 155 | if not arguments.get("target_date"): 156 | return [types.TextContent( 157 | type="text", 158 | text="Error: Target date is required" 159 | )] 160 | 161 | if not arguments.get("event_summary"): 162 | return [types.TextContent( 163 | type="text", 164 | text="Error: Event title is required" 165 | )] 166 | 167 | result = await asyncio.wait_for( 168 | calendar_service.delete_duplicate_events( 169 | target_date=arguments["target_date"], 170 | event_summary=arguments["event_summary"] 171 | ), 172 | timeout=60 173 | ) 174 | 175 | return [types.TextContent( 176 | type="text", 177 | text=result 178 | )] 179 | 180 | elif name == "delete-event": 181 | if not arguments.get("event_time"): 182 | return [types.TextContent( 183 | type="text", 184 | text="Error: Event time is required" 185 | )] 186 | 187 | if not arguments.get("event_summary"): 188 | return [types.TextContent( 189 | type="text", 190 | text="Error: Event title is required" 191 | )] 192 | 193 | result = await asyncio.wait_for( 194 | calendar_service.delete_single_event( 195 | event_time=arguments["event_time"], 196 | event_summary=arguments["event_summary"] 197 | ), 198 | timeout=60 199 | ) 200 | 201 | return [types.TextContent( 202 | type="text", 203 | text=result 204 | )] 205 | 206 | else: 207 | return [types.TextContent( 208 | type="text", 209 | text=f"Unknown tool: {name}" 210 | )] 211 | 212 | except asyncio.TimeoutError: 213 | error_msg = f"Operation timed out while executing {name}" 214 | logger.error(error_msg) 215 | return [types.TextContent( 216 | type="text", 217 | text=error_msg 218 | )] 219 | except Exception as e: 220 | error_msg = f"Error executing {name}: {str(e)}" 221 | logger.error(error_msg) 222 | return [types.TextContent( 223 | type="text", 224 | text=error_msg 225 | )] 226 | 227 | @server.list_resources() 228 | async def handle_list_resources() -> list[types.Resource]: 229 | return [] 230 | 231 | @server.list_prompts() 232 | async def handle_list_prompts() -> list[types.Prompt]: 233 | return [] 234 | 235 | async def run(): 236 | global calendar_service 237 | 238 | try: 239 | # Initialize calendar service 240 | logger.info("Starting Calendar Server...") 241 | script_dir = os.path.dirname(os.path.abspath(__file__)) 242 | calendar_service = CalendarService( 243 | credentials_path=os.path.join(script_dir, "..", "credentials", "credentials.json"), 244 | token_path=os.path.join(script_dir, "..", "credentials", "token.json") 245 | ) 246 | 247 | # Authentication with longer timeout 248 | try: 249 | await asyncio.wait_for(calendar_service.authenticate(), timeout=60) 250 | logger.info("Authentication successful") 251 | except asyncio.TimeoutError: 252 | logger.error("Authentication timed out") 253 | raise 254 | 255 | # Run the server 256 | logger.info("Calendar Server is ready") 257 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 258 | await server.run( 259 | read_stream, 260 | write_stream, 261 | InitializationOptions( 262 | server_name="gcalendar", 263 | server_version="0.1.0", 264 | capabilities=server.get_capabilities( 265 | notification_options=NotificationOptions(), 266 | experimental_capabilities={}, 267 | ) 268 | ) 269 | ) 270 | except ConnectionError as e: 271 | logger.error(f"Connection error: {str(e)}") 272 | raise 273 | except IOError as e: 274 | logger.error(f"IO error: {str(e)}") 275 | raise 276 | except Exception as e: 277 | logger.error(f"Error in run: {str(e)}") 278 | raise 279 | finally: 280 | if calendar_service: 281 | logger.info("Closing calendar service...") 282 | calendar_service.close() 283 | 284 | async def main(): 285 | try: 286 | await run() 287 | except KeyboardInterrupt: 288 | logger.info("Server shutdown requested") 289 | except Exception as e: 290 | logger.error(f"Unexpected error: {str(e)}") 291 | finally: 292 | if calendar_service: 293 | calendar_service.close() 294 | 295 | if __name__ == "__main__": 296 | try: 297 | asyncio.run(main()) 298 | except KeyboardInterrupt: 299 | pass # Handle graceful shutdown ``` -------------------------------------------------------------------------------- /src/mcp_server.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server import Server, NotificationOptions 2 | from mcp.server.models import InitializationOptions 3 | import mcp.server.stdio 4 | import mcp.types as types 5 | import logging 6 | import asyncio 7 | from calendar_service import CalendarService 8 | import os 9 | import signal 10 | from datetime import datetime 11 | 12 | # Set up logging 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='[%(asctime)s] %(levelname)s: %(message)s', 16 | datefmt='%Y-%m-%d %H:%M:%S' 17 | ) 18 | logger = logging.getLogger(__name__) 19 | 20 | # Create a server instance 21 | server = Server("gcalendar-server") 22 | calendar_service = None 23 | 24 | # Signal handler for graceful shutdown 25 | def signal_handler(signum, frame): 26 | logger.info(f"Received signal {signum}") 27 | if calendar_service: 28 | calendar_service.close() 29 | raise KeyboardInterrupt 30 | 31 | signal.signal(signal.SIGINT, signal_handler) 32 | signal.signal(signal.SIGTERM, signal_handler) 33 | 34 | @server.list_tools() 35 | async def handle_list_tools() -> list[types.Tool]: 36 | return [ 37 | types.Tool( 38 | name="list", 39 | description="List calendar events", 40 | arguments={}, 41 | inputSchema={ 42 | "type": "object", 43 | "properties": {} 44 | } 45 | ), 46 | types.Tool( 47 | name="create-event", 48 | description="Create a new calendar event", 49 | arguments={}, 50 | inputSchema={ 51 | "type": "object", 52 | "properties": { 53 | "summary": { 54 | "type": "string", 55 | "description": "Event title" 56 | }, 57 | "start_time": { 58 | "type": "string", 59 | "description": "Start time in YYYY-MM-DDTHH:MM:SS format or date in YYYY-MM-DD format" 60 | }, 61 | "end_time": { 62 | "type": "string", 63 | "description": "End time (optional). If not provided, event will be 1 hour long" 64 | }, 65 | "description": { 66 | "type": "string", 67 | "description": "Event description (optional)" 68 | } 69 | }, 70 | "required": ["summary", "start_time"] 71 | } 72 | ), 73 | types.Tool( 74 | name="delete-duplicates", 75 | description="Delete duplicate events on a specific date", 76 | arguments={}, 77 | inputSchema={ 78 | "type": "object", 79 | "properties": { 80 | "target_date": { 81 | "type": "string", 82 | "description": "Target date in YYYY-MM-DD format" 83 | }, 84 | "event_summary": { 85 | "type": "string", 86 | "description": "Event title to match" 87 | } 88 | }, 89 | "required": ["target_date", "event_summary"] 90 | } 91 | ), 92 | types.Tool( 93 | name="delete-event", 94 | description="Delete a single calendar event", 95 | arguments={}, 96 | inputSchema={ 97 | "type": "object", 98 | "properties": { 99 | "event_time": { 100 | "type": "string", 101 | "description": "Event time from list output" 102 | }, 103 | "event_summary": { 104 | "type": "string", 105 | "description": "Event title to match" 106 | } 107 | }, 108 | "required": ["event_time", "event_summary"] 109 | } 110 | ) 111 | ] 112 | 113 | @server.call_tool() 114 | async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]: 115 | try: 116 | if name == "list": 117 | formatted_text = await asyncio.wait_for( 118 | calendar_service.list_events(), 119 | timeout=60 120 | ) 121 | return [types.TextContent( 122 | type="text", 123 | text=formatted_text 124 | )] 125 | 126 | elif name == "create-event": 127 | if not arguments.get("summary"): 128 | return [types.TextContent( 129 | type="text", 130 | text="Error: Event title (summary) is required" 131 | )] 132 | 133 | if not arguments.get("start_time"): 134 | return [types.TextContent( 135 | type="text", 136 | text="Error: Start time is required" 137 | )] 138 | 139 | result = await asyncio.wait_for( 140 | calendar_service.create_event( 141 | summary=arguments["summary"], 142 | start_time=arguments["start_time"], 143 | end_time=arguments.get("end_time"), 144 | description=arguments.get("description") 145 | ), 146 | timeout=60 147 | ) 148 | 149 | return [types.TextContent( 150 | type="text", 151 | text=result 152 | )] 153 | 154 | elif name == "delete-duplicates": 155 | if not arguments.get("target_date"): 156 | return [types.TextContent( 157 | type="text", 158 | text="Error: Target date is required" 159 | )] 160 | 161 | if not arguments.get("event_summary"): 162 | return [types.TextContent( 163 | type="text", 164 | text="Error: Event title is required" 165 | )] 166 | 167 | result = await asyncio.wait_for( 168 | calendar_service.delete_duplicate_events( 169 | target_date=arguments["target_date"], 170 | event_summary=arguments["event_summary"] 171 | ), 172 | timeout=60 173 | ) 174 | 175 | return [types.TextContent( 176 | type="text", 177 | text=result 178 | )] 179 | 180 | elif name == "delete-event": 181 | if not arguments.get("event_time"): 182 | return [types.TextContent( 183 | type="text", 184 | text="Error: Event time is required" 185 | )] 186 | 187 | if not arguments.get("event_summary"): 188 | return [types.TextContent( 189 | type="text", 190 | text="Error: Event title is required" 191 | )] 192 | 193 | result = await asyncio.wait_for( 194 | calendar_service.delete_single_event( 195 | event_time=arguments["event_time"], 196 | event_summary=arguments["event_summary"] 197 | ), 198 | timeout=60 199 | ) 200 | 201 | return [types.TextContent( 202 | type="text", 203 | text=result 204 | )] 205 | 206 | else: 207 | return [types.TextContent( 208 | type="text", 209 | text=f"Unknown tool: {name}" 210 | )] 211 | 212 | except asyncio.TimeoutError: 213 | error_msg = f"Operation timed out while executing {name}" 214 | logger.error(error_msg) 215 | return [types.TextContent( 216 | type="text", 217 | text=error_msg 218 | )] 219 | except Exception as e: 220 | error_msg = f"Error executing {name}: {str(e)}" 221 | logger.error(error_msg) 222 | return [types.TextContent( 223 | type="text", 224 | text=error_msg 225 | )] 226 | 227 | @server.list_resources() 228 | async def handle_list_resources() -> list[types.Resource]: 229 | return [] 230 | 231 | @server.list_prompts() 232 | async def handle_list_prompts() -> list[types.Prompt]: 233 | return [] 234 | 235 | async def run(): 236 | global calendar_service 237 | 238 | try: 239 | # Initialize calendar service 240 | logger.info("Starting Calendar Server...") 241 | script_dir = os.path.dirname(os.path.abspath(__file__)) 242 | calendar_service = CalendarService( 243 | credentials_path=os.path.join(script_dir, "..", "credentials", "credentials.json"), 244 | token_path=os.path.join(script_dir, "..", "credentials", "token.json") 245 | ) 246 | 247 | # Authentication with longer timeout 248 | try: 249 | await asyncio.wait_for(calendar_service.authenticate(), timeout=60) 250 | logger.info("Authentication successful") 251 | except asyncio.TimeoutError: 252 | logger.error("Authentication timed out") 253 | raise 254 | 255 | # Run the server 256 | logger.info("Calendar Server is ready") 257 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 258 | await server.run( 259 | read_stream, 260 | write_stream, 261 | InitializationOptions( 262 | server_name="gcalendar", 263 | server_version="0.1.0", 264 | capabilities=server.get_capabilities( 265 | notification_options=NotificationOptions(), 266 | experimental_capabilities={}, 267 | ) 268 | ) 269 | ) 270 | except ConnectionError as e: 271 | logger.error(f"Connection error: {str(e)}") 272 | raise 273 | except IOError as e: 274 | logger.error(f"IO error: {str(e)}") 275 | raise 276 | except Exception as e: 277 | logger.error(f"Error in run: {str(e)}") 278 | raise 279 | finally: 280 | if calendar_service: 281 | logger.info("Closing calendar service...") 282 | calendar_service.close() 283 | 284 | async def main(): 285 | try: 286 | await run() 287 | except KeyboardInterrupt: 288 | logger.info("Server shutdown requested") 289 | except Exception as e: 290 | logger.error(f"Unexpected error: {str(e)}") 291 | finally: 292 | if calendar_service: 293 | calendar_service.close() 294 | 295 | if __name__ == "__main__": 296 | try: 297 | asyncio.run(main()) 298 | except KeyboardInterrupt: 299 | pass # Handle graceful shutdown ``` -------------------------------------------------------------------------------- /src/calendar_service.py: -------------------------------------------------------------------------------- ```python 1 | from google.oauth2.credentials import Credentials 2 | from google.auth.transport.requests import Request 3 | from googleapiclient.discovery import build 4 | from googleapiclient.errors import HttpError 5 | from datetime import datetime, timedelta 6 | import logging 7 | import asyncio 8 | import os 9 | import json 10 | from zoneinfo import ZoneInfo 11 | 12 | # Set up logging 13 | log_formatter = logging.Formatter( 14 | '[%(asctime)s] %(levelname)s: %(message)s', 15 | datefmt='%Y-%m-%d %H:%M:%S' 16 | ) 17 | 18 | # Console handler 19 | console_handler = logging.StreamHandler() 20 | console_handler.setFormatter(log_formatter) 21 | 22 | # File handler 23 | log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'GCalendar/logs', 'calendar_service.log') 24 | # Create logs directory if it doesn't exist 25 | log_dir = os.path.dirname(log_file) 26 | os.makedirs(log_dir, exist_ok=True) 27 | 28 | file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8') 29 | file_handler.setFormatter(log_formatter) 30 | 31 | # Set up logger 32 | logger = logging.getLogger(__name__) 33 | logger.setLevel(logging.INFO) 34 | logger.addHandler(console_handler) 35 | logger.addHandler(file_handler) 36 | 37 | class CalendarService: 38 | SCOPES = ['https://www.googleapis.com/auth/calendar'] 39 | TIMEZONE = 'Asia/Bangkok' 40 | 41 | def __init__(self, credentials_path: str, token_path: str): 42 | self.credentials_path = credentials_path 43 | self.token_path = token_path 44 | self.creds = None 45 | self.service = None 46 | self.tz = ZoneInfo(self.TIMEZONE) 47 | self.events_cache = {} # Initialize events cache 48 | 49 | async def authenticate(self): 50 | """Authenticate with Google Calendar API""" 51 | try: 52 | self.creds = Credentials.from_authorized_user_file(self.token_path, self.SCOPES) 53 | 54 | # Check if credentials are expired and refresh if needed 55 | if self.creds and self.creds.expired and self.creds.refresh_token: 56 | logger.info("Token expired, refreshing...") 57 | self.creds.refresh(Request()) 58 | # Save refreshed credentials 59 | with open(self.token_path, 'w') as token: 60 | token.write(self.creds.to_json()) 61 | logger.info("Token refreshed and saved") 62 | 63 | self.service = build('calendar', 'v3', credentials=self.creds) 64 | logger.info("Authentication successful") 65 | return True 66 | except Exception as e: 67 | logger.error(f"Authentication error: {str(e)}") 68 | raise 69 | 70 | async def list_events(self, max_results: int = 1000): 71 | """List calendar events and cache their IDs""" 72 | try: 73 | if not self.service: 74 | await self.authenticate() 75 | 76 | logger.info("Fetching calendar events...") 77 | 78 | now = datetime.now(self.tz) 79 | two_years_ago = now - timedelta(days=730) 80 | one_year_later = now + timedelta(days=365) 81 | 82 | events_result = await asyncio.get_event_loop().run_in_executor( 83 | None, 84 | lambda: self.service.events().list( 85 | calendarId='primary', 86 | timeMin=two_years_ago.isoformat(), 87 | timeMax=one_year_later.isoformat(), 88 | maxResults=max_results, 89 | singleEvents=True, 90 | orderBy='startTime', 91 | timeZone=self.TIMEZONE 92 | ).execute() 93 | ) 94 | 95 | events = events_result.get('items', []) 96 | logger.info(f"Found {len(events)} events") 97 | 98 | if not events: 99 | return "No events found." 100 | 101 | # Reset and update cache 102 | self.events_cache = {} 103 | formatted_text = "" 104 | 105 | for event in events: 106 | start_time = self._format_event_time(event) 107 | summary = event.get('summary', 'No title') 108 | cache_key = f"{start_time} {summary}" 109 | 110 | formatted_text += f"{cache_key}\n" 111 | self.events_cache[cache_key] = event['id'] 112 | 113 | return formatted_text 114 | 115 | except HttpError as error: 116 | error_msg = f"An error occurred: {str(error)}" 117 | logger.error(error_msg) 118 | return error_msg 119 | 120 | def _format_event_time(self, event: dict) -> str: 121 | """Format event time consistently with timezone""" 122 | start = event['start'].get('dateTime', event['start'].get('date')) 123 | if 'T' in start: # This is a datetime 124 | dt = datetime.fromisoformat(start) 125 | if dt.tzinfo is None: # Add timezone if not present 126 | dt = dt.replace(tzinfo=self.tz) 127 | formatted_time = dt.strftime('%Y-%m-%dT%H:%M:%S%z') 128 | else: # This is a date 129 | formatted_time = start 130 | return formatted_time 131 | 132 | async def delete_single_event(self, event_time: str, event_summary: str): 133 | """ 134 | Delete a specific event by its time and summary 135 | 136 | Parameters: 137 | - event_time: Event time in format matching list output 138 | - event_summary: Event title to match 139 | 140 | Returns: 141 | - Status message 142 | """ 143 | try: 144 | if not self.service: 145 | await self.authenticate() 146 | 147 | # First update the cache 148 | await self.list_events() 149 | 150 | # Get event ID from cache 151 | cache_key = f"{event_time} {event_summary}" 152 | event_id = self.events_cache.get(cache_key) 153 | 154 | if not event_id: 155 | return f"Event not found: {cache_key}" 156 | 157 | # Delete the event 158 | await asyncio.get_event_loop().run_in_executor( 159 | None, 160 | lambda: self.service.events().delete( 161 | calendarId='primary', 162 | eventId=event_id 163 | ).execute() 164 | ) 165 | 166 | logger.info(f"Deleted event: {event_id} - {cache_key}") 167 | return f"Successfully deleted event: {cache_key}" 168 | 169 | except HttpError as error: 170 | error_msg = f"An error occurred: {str(error)}" 171 | logger.error(error_msg) 172 | return error_msg 173 | except Exception as error: 174 | error_msg = f"Unexpected error: {str(error)}" 175 | logger.error(error_msg) 176 | return error_msg 177 | 178 | async def create_event(self, summary: str, start_time: str, end_time: str = None, description: str = None): 179 | """ 180 | Create a new calendar event 181 | 182 | Parameters: 183 | - summary: Event title 184 | - start_time: Start time in YYYY-MM-DDTHH:MM:SS format or date in YYYY-MM-DD format 185 | - end_time: End time (optional). If not provided, event will be 1 hour long 186 | - description: Event description (optional) 187 | 188 | Returns: 189 | - Status message 190 | """ 191 | try: 192 | if not self.service: 193 | await self.authenticate() 194 | 195 | # Parse start time 196 | try: 197 | # Check if time is included 198 | if 'T' in start_time: 199 | start_dt = datetime.fromisoformat(start_time) 200 | is_datetime = True 201 | else: 202 | start_dt = datetime.strptime(start_time, '%Y-%m-%d') 203 | is_datetime = False 204 | except ValueError: 205 | return f"Invalid start time format: {start_time}. Use YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD" 206 | 207 | # Handle end time 208 | if end_time: 209 | try: 210 | if 'T' in end_time: 211 | end_dt = datetime.fromisoformat(end_time) 212 | else: 213 | end_dt = datetime.strptime(end_time, '%Y-%m-%d') 214 | if is_datetime: 215 | # If start has time but end doesn't, make end time 23:59:59 216 | end_dt = end_dt.replace(hour=23, minute=59, second=59) 217 | except ValueError: 218 | return f"Invalid end time format: {end_time}. Use YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD" 219 | else: 220 | # Default to 1 hour duration for datetime events, or same day for date events 221 | if is_datetime: 222 | end_dt = start_dt + timedelta(hours=1) 223 | else: 224 | end_dt = start_dt 225 | 226 | # Create event body 227 | event_body = { 228 | 'summary': summary, 229 | 'start': { 230 | 'dateTime' if is_datetime else 'date': start_dt.isoformat(), 231 | 'timeZone': self.TIMEZONE if is_datetime else None 232 | }, 233 | 'end': { 234 | 'dateTime' if is_datetime else 'date': end_dt.isoformat(), 235 | 'timeZone': self.TIMEZONE if is_datetime else None 236 | } 237 | } 238 | 239 | # Add optional description if provided 240 | if description: 241 | event_body['description'] = description 242 | 243 | # Create the event 244 | created_event = await asyncio.get_event_loop().run_in_executor( 245 | None, 246 | lambda: self.service.events().insert( 247 | calendarId='primary', 248 | body=event_body 249 | ).execute() 250 | ) 251 | 252 | logger.info(f"Created event: {created_event['id']} - {summary}") 253 | return f"Successfully created event: {summary}" 254 | 255 | except HttpError as error: 256 | error_msg = f"An error occurred: {str(error)}" 257 | logger.error(error_msg) 258 | return error_msg 259 | except Exception as error: 260 | error_msg = f"Unexpected error: {str(error)}" 261 | logger.error(error_msg) 262 | return error_msg 263 | 264 | async def delete_duplicate_events(self, target_date: str, event_summary: str): 265 | """ 266 | Delete duplicate events on a specific date 267 | 268 | Parameters: 269 | - target_date: Target date in YYYY-MM-DD format 270 | - event_summary: Event title to match 271 | 272 | Returns: 273 | - Status message 274 | """ 275 | try: 276 | if not self.service: 277 | await self.authenticate() 278 | 279 | # Parse target date 280 | try: 281 | target_dt = datetime.strptime(target_date, '%Y-%m-%d') 282 | except ValueError: 283 | return f"Invalid date format: {target_date}. Use YYYY-MM-DD" 284 | 285 | # Set time range for the target date 286 | start_dt = target_dt.replace(hour=0, minute=0, second=0, tzinfo=self.tz) 287 | end_dt = target_dt.replace(hour=23, minute=59, second=59, tzinfo=self.tz) 288 | 289 | # Get events for the target date 290 | events_result = await asyncio.get_event_loop().run_in_executor( 291 | None, 292 | lambda: self.service.events().list( 293 | calendarId='primary', 294 | timeMin=start_dt.isoformat(), 295 | timeMax=end_dt.isoformat(), 296 | singleEvents=True, 297 | orderBy='startTime', 298 | timeZone=self.TIMEZONE 299 | ).execute() 300 | ) 301 | 302 | events = events_result.get('items', []) 303 | 304 | # Find duplicate events 305 | duplicate_ids = [] 306 | seen_times = set() 307 | 308 | for event in events: 309 | if event.get('summary') == event_summary: 310 | start_time = self._format_event_time(event) 311 | if start_time in seen_times: 312 | duplicate_ids.append(event['id']) 313 | else: 314 | seen_times.add(start_time) 315 | 316 | if not duplicate_ids: 317 | return f"No duplicate events found for '{event_summary}' on {target_date}" 318 | 319 | # Delete duplicate events 320 | deleted_count = 0 321 | for event_id in duplicate_ids: 322 | try: 323 | await asyncio.get_event_loop().run_in_executor( 324 | None, 325 | lambda: self.service.events().delete( 326 | calendarId='primary', 327 | eventId=event_id 328 | ).execute() 329 | ) 330 | deleted_count += 1 331 | except HttpError as error: 332 | logger.error(f"Error deleting event {event_id}: {str(error)}") 333 | 334 | logger.info(f"Deleted {deleted_count} duplicate events for '{event_summary}' on {target_date}") 335 | return f"Successfully deleted {deleted_count} duplicate events for '{event_summary}' on {target_date}" 336 | 337 | except HttpError as error: 338 | error_msg = f"An error occurred: {str(error)}" 339 | logger.error(error_msg) 340 | return error_msg 341 | except Exception as error: 342 | error_msg = f"Unexpected error: {str(error)}" 343 | logger.error(error_msg) 344 | return error_msg 345 | 346 | async def renew_token(self): 347 | """Renew the authentication token""" 348 | try: 349 | if not self.creds: 350 | self.creds = Credentials.from_authorized_user_file(self.token_path, self.SCOPES) 351 | 352 | if self.creds and self.creds.expired and self.creds.refresh_token: 353 | logger.info("Token expired, refreshing...") 354 | self.creds.refresh(Request()) 355 | # Save refreshed credentials 356 | with open(self.token_path, 'w') as token: 357 | token.write(self.creds.to_json()) 358 | logger.info("Token refreshed and saved") 359 | return "Token renewed successfully" 360 | else: 361 | return "Token is still valid" 362 | except Exception as e: 363 | error_msg = f"Error renewing token: {str(e)}" 364 | logger.error(error_msg) 365 | return error_msg 366 | 367 | def close(self): 368 | """Clean up resources""" 369 | if self.service: 370 | logger.info("Closing calendar service") 371 | self.service.close() ``` -------------------------------------------------------------------------------- /GCalendar/src/calendar_service.py: -------------------------------------------------------------------------------- ```python 1 | from google.oauth2.credentials import Credentials 2 | from google.auth.transport.requests import Request 3 | from googleapiclient.discovery import build 4 | from googleapiclient.errors import HttpError 5 | from datetime import datetime, timedelta 6 | import logging 7 | import asyncio 8 | import os 9 | import json 10 | from zoneinfo import ZoneInfo 11 | import sys 12 | 13 | # Set up logging 14 | log_formatter = logging.Formatter( 15 | '[%(asctime)s] %(levelname)s: %(message)s', 16 | datefmt='%Y-%m-%d %H:%M:%S' 17 | ) 18 | 19 | # Console handler 20 | console_handler = logging.StreamHandler() 21 | console_handler.setFormatter(log_formatter) 22 | 23 | # File handler 24 | log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs') 25 | os.makedirs(log_dir, exist_ok=True) # Create logs directory if it doesn't exist 26 | log_file = os.path.join(log_dir, 'calendar_service.log') 27 | file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8') 28 | file_handler.setFormatter(log_formatter) 29 | 30 | # Set up logger 31 | logger = logging.getLogger(__name__) 32 | logger.setLevel(logging.INFO) 33 | logger.addHandler(console_handler) 34 | logger.addHandler(file_handler) 35 | 36 | class CalendarService: 37 | SCOPES = ['https://www.googleapis.com/auth/calendar'] 38 | # Try to use Bangkok timezone, but fallback to others if not available 39 | TIMEZONE = 'Asia/Bangkok' 40 | 41 | @classmethod 42 | def get_available_timezone(cls): 43 | """Get an available timezone closest to Bangkok, with fallbacks""" 44 | timezones_to_try = [ 45 | 'Asia/Bangkok', # First choice (UTC+7) 46 | 'Asia/Jakarta', # Second choice (UTC+7) 47 | 'Asia/Singapore', # Third choice (UTC+8) 48 | 'Asia/Kolkata', # Fourth choice (UTC+5:30) 49 | 'UTC' # Last resort 50 | ] 51 | 52 | for tz in timezones_to_try: 53 | try: 54 | ZoneInfo(tz) 55 | logger.info(f"Using timezone: {tz}") 56 | return tz 57 | except Exception as e: 58 | logger.warning(f"Timezone {tz} not available: {str(e)}") 59 | 60 | logger.error("No suitable timezone found. Please install tzdata package with: pip install tzdata") 61 | sys.exit(1) 62 | 63 | def __init__(self, credentials_path: str, token_path: str): 64 | self.credentials_path = credentials_path 65 | self.token_path = token_path 66 | self.creds = None 67 | self.service = None 68 | # Use the first available timezone 69 | self.TIMEZONE = self.get_available_timezone() 70 | self.tz = ZoneInfo(self.TIMEZONE) 71 | self.events_cache = {} # Initialize events cache 72 | 73 | async def authenticate(self): 74 | """Authenticate with Google Calendar API""" 75 | try: 76 | self.creds = Credentials.from_authorized_user_file(self.token_path, self.SCOPES) 77 | 78 | # Check if credentials are expired and refresh if needed 79 | if self.creds and self.creds.expired and self.creds.refresh_token: 80 | logger.info("Token expired, refreshing...") 81 | self.creds.refresh(Request()) 82 | # Save refreshed credentials 83 | with open(self.token_path, 'w') as token: 84 | token.write(self.creds.to_json()) 85 | logger.info("Token refreshed and saved") 86 | 87 | self.service = build('calendar', 'v3', credentials=self.creds) 88 | logger.info("Authentication successful") 89 | return True 90 | except Exception as e: 91 | logger.error(f"Authentication error: {str(e)}") 92 | raise 93 | 94 | async def list_events(self, max_results: int = 1000): 95 | """List calendar events and cache their IDs""" 96 | try: 97 | if not self.service: 98 | await self.authenticate() 99 | 100 | logger.info("Fetching calendar events...") 101 | 102 | now = datetime.now(self.tz) 103 | two_years_ago = now - timedelta(days=730) 104 | one_year_later = now + timedelta(days=365) 105 | 106 | events_result = await asyncio.get_event_loop().run_in_executor( 107 | None, 108 | lambda: self.service.events().list( 109 | calendarId='primary', 110 | timeMin=two_years_ago.isoformat(), 111 | timeMax=one_year_later.isoformat(), 112 | maxResults=max_results, 113 | singleEvents=True, 114 | orderBy='startTime', 115 | timeZone=self.TIMEZONE 116 | ).execute() 117 | ) 118 | 119 | events = events_result.get('items', []) 120 | logger.info(f"Found {len(events)} events") 121 | 122 | if not events: 123 | return "No events found." 124 | 125 | # Reset and update cache 126 | self.events_cache = {} 127 | formatted_text = "" 128 | 129 | for event in events: 130 | start_time = self._format_event_time(event) 131 | summary = event.get('summary', 'No title') 132 | cache_key = f"{start_time} {summary}" 133 | 134 | formatted_text += f"{cache_key}\n" 135 | self.events_cache[cache_key] = event['id'] 136 | 137 | return formatted_text 138 | 139 | except HttpError as error: 140 | error_msg = f"An error occurred: {str(error)}" 141 | logger.error(error_msg) 142 | return error_msg 143 | 144 | def _format_event_time(self, event: dict) -> str: 145 | """Format event time consistently with timezone""" 146 | start = event['start'].get('dateTime', event['start'].get('date')) 147 | if 'T' in start: # This is a datetime 148 | dt = datetime.fromisoformat(start) 149 | if dt.tzinfo is None: # Add timezone if not present 150 | dt = dt.replace(tzinfo=self.tz) 151 | formatted_time = dt.strftime('%Y-%m-%dT%H:%M:%S%z') 152 | else: # This is a date 153 | formatted_time = start 154 | return formatted_time 155 | 156 | async def delete_single_event(self, event_time: str, event_summary: str): 157 | """ 158 | Delete a specific event by its time and summary 159 | 160 | Parameters: 161 | - event_time: Event time in format matching list output 162 | - event_summary: Event title to match 163 | 164 | Returns: 165 | - Status message 166 | """ 167 | try: 168 | if not self.service: 169 | await self.authenticate() 170 | 171 | # First update the cache 172 | await self.list_events() 173 | 174 | # Get event ID from cache 175 | cache_key = f"{event_time} {event_summary}" 176 | event_id = self.events_cache.get(cache_key) 177 | 178 | if not event_id: 179 | return f"Event not found: {cache_key}" 180 | 181 | # Delete the event 182 | await asyncio.get_event_loop().run_in_executor( 183 | None, 184 | lambda: self.service.events().delete( 185 | calendarId='primary', 186 | eventId=event_id 187 | ).execute() 188 | ) 189 | 190 | logger.info(f"Deleted event: {event_id} - {cache_key}") 191 | return f"Successfully deleted event: {cache_key}" 192 | 193 | except HttpError as error: 194 | error_msg = f"An error occurred: {str(error)}" 195 | logger.error(error_msg) 196 | return error_msg 197 | except Exception as error: 198 | error_msg = f"Unexpected error: {str(error)}" 199 | logger.error(error_msg) 200 | return error_msg 201 | 202 | async def create_event(self, summary: str, start_time: str, end_time: str = None, description: str = None): 203 | """ 204 | Create a new calendar event 205 | 206 | Parameters: 207 | - summary: Event title 208 | - start_time: Start time in YYYY-MM-DDTHH:MM:SS format or date in YYYY-MM-DD format 209 | - end_time: End time (optional). If not provided, event will be 1 hour long 210 | - description: Event description (optional) 211 | 212 | Returns: 213 | - Status message 214 | """ 215 | try: 216 | if not self.service: 217 | await self.authenticate() 218 | 219 | # Parse start time 220 | try: 221 | # Check if time is included 222 | if 'T' in start_time: 223 | start_dt = datetime.fromisoformat(start_time) 224 | is_datetime = True 225 | else: 226 | start_dt = datetime.strptime(start_time, '%Y-%m-%d') 227 | is_datetime = False 228 | except ValueError: 229 | return f"Invalid start time format: {start_time}. Use YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD" 230 | 231 | # Handle end time 232 | if end_time: 233 | try: 234 | if 'T' in end_time: 235 | end_dt = datetime.fromisoformat(end_time) 236 | else: 237 | end_dt = datetime.strptime(end_time, '%Y-%m-%d') 238 | if is_datetime: 239 | # If start has time but end doesn't, make end time 23:59:59 240 | end_dt = end_dt.replace(hour=23, minute=59, second=59) 241 | except ValueError: 242 | return f"Invalid end time format: {end_time}. Use YYYY-MM-DDTHH:MM:SS or YYYY-MM-DD" 243 | else: 244 | # Default to 1 hour duration for datetime events, or same day for date events 245 | if is_datetime: 246 | end_dt = start_dt + timedelta(hours=1) 247 | else: 248 | end_dt = start_dt 249 | 250 | # Create event body 251 | event_body = { 252 | 'summary': summary, 253 | 'start': { 254 | 'dateTime' if is_datetime else 'date': start_dt.isoformat(), 255 | 'timeZone': self.TIMEZONE if is_datetime else None 256 | }, 257 | 'end': { 258 | 'dateTime' if is_datetime else 'date': end_dt.isoformat(), 259 | 'timeZone': self.TIMEZONE if is_datetime else None 260 | } 261 | } 262 | 263 | # Add optional description if provided 264 | if description: 265 | event_body['description'] = description 266 | 267 | # Create the event 268 | created_event = await asyncio.get_event_loop().run_in_executor( 269 | None, 270 | lambda: self.service.events().insert( 271 | calendarId='primary', 272 | body=event_body 273 | ).execute() 274 | ) 275 | 276 | logger.info(f"Created event: {created_event['id']} - {summary}") 277 | return f"Successfully created event: {summary}" 278 | 279 | except HttpError as error: 280 | error_msg = f"An error occurred: {str(error)}" 281 | logger.error(error_msg) 282 | return error_msg 283 | except Exception as error: 284 | error_msg = f"Unexpected error: {str(error)}" 285 | logger.error(error_msg) 286 | return error_msg 287 | 288 | async def delete_duplicate_events(self, target_date: str, event_summary: str): 289 | """ 290 | Delete duplicate events on a specific date 291 | 292 | Parameters: 293 | - target_date: Target date in YYYY-MM-DD format 294 | - event_summary: Event title to match 295 | 296 | Returns: 297 | - Status message 298 | """ 299 | try: 300 | if not self.service: 301 | await self.authenticate() 302 | 303 | # Parse target date 304 | try: 305 | target_dt = datetime.strptime(target_date, '%Y-%m-%d') 306 | except ValueError: 307 | return f"Invalid date format: {target_date}. Use YYYY-MM-DD" 308 | 309 | # Set time range for the target date 310 | start_dt = target_dt.replace(hour=0, minute=0, second=0, tzinfo=self.tz) 311 | end_dt = target_dt.replace(hour=23, minute=59, second=59, tzinfo=self.tz) 312 | 313 | # Get events for the target date 314 | events_result = await asyncio.get_event_loop().run_in_executor( 315 | None, 316 | lambda: self.service.events().list( 317 | calendarId='primary', 318 | timeMin=start_dt.isoformat(), 319 | timeMax=end_dt.isoformat(), 320 | singleEvents=True, 321 | orderBy='startTime', 322 | timeZone=self.TIMEZONE 323 | ).execute() 324 | ) 325 | 326 | events = events_result.get('items', []) 327 | 328 | # Find duplicate events 329 | duplicate_ids = [] 330 | seen_times = set() 331 | 332 | for event in events: 333 | if event.get('summary') == event_summary: 334 | start_time = self._format_event_time(event) 335 | if start_time in seen_times: 336 | duplicate_ids.append(event['id']) 337 | else: 338 | seen_times.add(start_time) 339 | 340 | if not duplicate_ids: 341 | return f"No duplicate events found for '{event_summary}' on {target_date}" 342 | 343 | # Delete duplicate events 344 | deleted_count = 0 345 | for event_id in duplicate_ids: 346 | try: 347 | await asyncio.get_event_loop().run_in_executor( 348 | None, 349 | lambda: self.service.events().delete( 350 | calendarId='primary', 351 | eventId=event_id 352 | ).execute() 353 | ) 354 | deleted_count += 1 355 | except HttpError as error: 356 | logger.error(f"Error deleting event {event_id}: {str(error)}") 357 | 358 | logger.info(f"Deleted {deleted_count} duplicate events for '{event_summary}' on {target_date}") 359 | return f"Successfully deleted {deleted_count} duplicate events for '{event_summary}' on {target_date}" 360 | 361 | except HttpError as error: 362 | error_msg = f"An error occurred: {str(error)}" 363 | logger.error(error_msg) 364 | return error_msg 365 | except Exception as error: 366 | error_msg = f"Unexpected error: {str(error)}" 367 | logger.error(error_msg) 368 | return error_msg 369 | 370 | async def renew_token(self): 371 | """Renew the authentication token""" 372 | try: 373 | if not self.creds: 374 | self.creds = Credentials.from_authorized_user_file(self.token_path, self.SCOPES) 375 | 376 | if self.creds and self.creds.expired and self.creds.refresh_token: 377 | logger.info("Token expired, refreshing...") 378 | self.creds.refresh(Request()) 379 | # Save refreshed credentials 380 | with open(self.token_path, 'w') as token: 381 | token.write(self.creds.to_json()) 382 | logger.info("Token refreshed and saved") 383 | return "Token renewed successfully" 384 | else: 385 | return "Token is still valid" 386 | except Exception as e: 387 | error_msg = f"Error renewing token: {str(e)}" 388 | logger.error(error_msg) 389 | return error_msg 390 | 391 | def close(self): 392 | """Clean up resources""" 393 | if self.service: 394 | logger.info("Closing calendar service") 395 | self.service.close() ```