Coverage for src/refinire/agents/notification.py: 87%
283 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-15 18:51 +0900
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-15 18:51 +0900
1"""
2NotificationAgent implementation for sending notifications through various channels.
4NotificationAgentは様々なチャネルを通じて通知を送信するエージェントです。
5メール、Webhook、ログなどの複数の通知方法をサポートしています。
6"""
8import logging
9import json
10import smtplib
11from abc import ABC, abstractmethod
12from email.mime.text import MIMEText
13from email.mime.multipart import MIMEMultipart
14from typing import Any, List, Optional, Dict, Union
15from pydantic import BaseModel, Field, field_validator
16from datetime import datetime
17import urllib.request
18import urllib.parse
20from .flow.context import Context
21from .flow.step import Step
23logger = logging.getLogger(__name__)
26class NotificationChannel(ABC):
27 """
28 Abstract base class for notification channels.
29 通知チャネルの抽象基底クラス。
30 """
32 def __init__(self, name: str, enabled: bool = True):
33 """
34 Initialize notification channel.
35 通知チャネルを初期化します。
37 Args:
38 name: Channel name / チャネル名
39 enabled: Whether channel is enabled / チャネルが有効かどうか
40 """
41 self.name = name
42 self.enabled = enabled
44 @abstractmethod
45 async def send(self, message: str, subject: str = None, context: Context = None) -> bool:
46 """
47 Send notification through this channel.
48 このチャネルを通じて通知を送信します。
50 Args:
51 message: Notification message / 通知メッセージ
52 subject: Message subject / メッセージ件名
53 context: Execution context / 実行コンテキスト
55 Returns:
56 bool: True if sent successfully / 送信が成功した場合True
57 """
58 pass
61class LogChannel(NotificationChannel):
62 """
63 Notification channel that logs messages.
64 メッセージをログに記録する通知チャネル。
65 """
67 def __init__(self, name: str = "log_channel", log_level: str = "INFO"):
68 """
69 Initialize log channel.
70 ログチャネルを初期化します。
72 Args:
73 name: Channel name / チャネル名
74 log_level: Log level (DEBUG, INFO, WARNING, ERROR) / ログレベル
75 """
76 super().__init__(name)
77 self.log_level = log_level.upper()
79 async def send(self, message: str, subject: str = None, context: Context = None) -> bool:
80 """Send notification via logging."""
81 try:
82 log_message = f"[NOTIFICATION] {subject}: {message}" if subject else f"[NOTIFICATION] {message}"
84 if self.log_level == "DEBUG":
85 logger.debug(log_message)
86 elif self.log_level == "INFO":
87 logger.info(log_message)
88 elif self.log_level == "WARNING":
89 logger.warning(log_message)
90 elif self.log_level == "ERROR":
91 logger.error(log_message)
92 else:
93 logger.info(log_message)
95 return True
96 except Exception as e:
97 logger.error(f"Failed to send log notification: {e}")
98 return False
101class EmailChannel(NotificationChannel):
102 """
103 Notification channel for email delivery.
104 メール配信用の通知チャネル。
105 """
107 def __init__(self, name: str = "email_channel", smtp_server: str = None,
108 smtp_port: int = 587, username: str = None, password: str = None,
109 from_email: str = None, to_emails: List[str] = None,
110 use_tls: bool = True):
111 """
112 Initialize email channel.
113 メールチャネルを初期化します。
115 Args:
116 name: Channel name / チャネル名
117 smtp_server: SMTP server address / SMTPサーバーアドレス
118 smtp_port: SMTP server port / SMTPサーバーポート
119 username: SMTP username / SMTPユーザー名
120 password: SMTP password / SMTPパスワード
121 from_email: Sender email address / 送信者メールアドレス
122 to_emails: List of recipient email addresses / 受信者メールアドレスのリスト
123 use_tls: Whether to use TLS encryption / TLS暗号化を使用するかどうか
124 """
125 super().__init__(name)
126 self.smtp_server = smtp_server
127 self.smtp_port = smtp_port
128 self.username = username
129 self.password = password
130 self.from_email = from_email
131 self.to_emails = to_emails or []
132 self.use_tls = use_tls
134 async def send(self, message: str, subject: str = None, context: Context = None) -> bool:
135 """Send notification via email."""
136 if not self.smtp_server or not self.from_email or not self.to_emails:
137 logger.warning("Email channel not properly configured")
138 return False
140 try:
141 # Create message
142 msg = MIMEMultipart()
143 msg['From'] = self.from_email
144 msg['To'] = ', '.join(self.to_emails)
145 msg['Subject'] = subject or "Notification"
147 # Add message body
148 msg.attach(MIMEText(message, 'plain'))
150 # Connect to server and send
151 with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
152 if self.use_tls:
153 server.starttls()
155 if self.username and self.password:
156 server.login(self.username, self.password)
158 server.send_message(msg)
160 logger.info(f"Email notification sent to {len(self.to_emails)} recipients")
161 return True
163 except Exception as e:
164 logger.error(f"Failed to send email notification: {e}")
165 return False
168class WebhookChannel(NotificationChannel):
169 """
170 Notification channel for webhook delivery.
171 Webhook配信用の通知チャネル。
172 """
174 def __init__(self, name: str = "webhook_channel", webhook_url: str = None,
175 method: str = "POST", headers: Dict[str, str] = None,
176 payload_template: str = None):
177 """
178 Initialize webhook channel.
179 Webhookチャネルを初期化します。
181 Args:
182 name: Channel name / チャネル名
183 webhook_url: Webhook URL / Webhook URL
184 method: HTTP method (POST, PUT) / HTTPメソッド
185 headers: Additional HTTP headers / 追加HTTPヘッダー
186 payload_template: JSON payload template / JSONペイロードテンプレート
187 """
188 super().__init__(name)
189 self.webhook_url = webhook_url
190 self.method = method.upper()
191 self.headers = headers or {"Content-Type": "application/json"}
192 self.payload_template = payload_template or '{{"message": "{message}", "subject": "{subject}", "timestamp": "{timestamp}"}}'
194 async def send(self, message: str, subject: str = None, context: Context = None) -> bool:
195 """Send notification via webhook."""
196 if not self.webhook_url:
197 logger.warning("Webhook URL not configured")
198 return False
200 try:
201 # Prepare payload
202 timestamp = datetime.now().isoformat()
204 # Escape quotes in message and subject for JSON
205 escaped_message = message.replace('"', '\\"').replace('\n', '\\n')
206 escaped_subject = (subject or "").replace('"', '\\"').replace('\n', '\\n')
208 payload = self.payload_template.format(
209 message=escaped_message,
210 subject=escaped_subject,
211 timestamp=timestamp
212 )
214 # Validate JSON format
215 try:
216 json.loads(payload)
217 except json.JSONDecodeError as e:
218 logger.error(f"Invalid JSON payload: {e}, payload: {payload}")
219 return False
221 # Create request
222 data = payload.encode('utf-8')
223 req = urllib.request.Request(
224 self.webhook_url,
225 data=data,
226 headers=self.headers,
227 method=self.method
228 )
230 # Send request
231 with urllib.request.urlopen(req, timeout=30) as response:
232 if 200 <= response.status < 300:
233 logger.info(f"Webhook notification sent successfully to {self.webhook_url}")
234 return True
235 else:
236 logger.warning(f"Webhook responded with status {response.status}")
237 return False
239 except Exception as e:
240 logger.error(f"Failed to send webhook notification: {e}")
241 return False
244class SlackChannel(WebhookChannel):
245 """
246 Specialized webhook channel for Slack notifications.
247 Slack通知用の特化したWebhookチャネル。
248 """
250 def __init__(self, name: str = "slack_channel", webhook_url: str = None,
251 channel: str = None, username: str = "NotificationBot"):
252 """
253 Initialize Slack channel.
254 Slackチャネルを初期化します。
256 Args:
257 name: Channel name / チャネル名
258 webhook_url: Slack webhook URL / Slack webhook URL
259 channel: Slack channel name / Slackチャネル名
260 username: Bot username / ボットユーザー名
261 """
262 if channel:
263 slack_payload = '{{"text": "{{message}}", "channel": "{}", "username": "{}"}}'.format(channel, username)
264 else:
265 slack_payload = '{{"text": "{message}"}}'
267 super().__init__(
268 name=name,
269 webhook_url=webhook_url,
270 payload_template=slack_payload
271 )
274class TeamsChannel(WebhookChannel):
275 """
276 Specialized webhook channel for Microsoft Teams notifications.
277 Microsoft Teams通知用の特化したWebhookチャネル。
278 """
280 def __init__(self, name: str = "teams_channel", webhook_url: str = None):
281 """
282 Initialize Teams channel.
283 Teamsチャネルを初期化します。
285 Args:
286 name: Channel name / チャネル名
287 webhook_url: Teams webhook URL / Teams webhook URL
288 """
289 teams_payload = '''{{
290 "@type": "MessageCard",
291 "@context": "http://schema.org/extensions",
292 "themeColor": "0076D7",
293 "summary": "{subject}",
294 "sections": [{{
295 "activityTitle": "{subject}",
296 "text": "{message}"
297 }}]
298 }}'''
300 super().__init__(
301 name=name,
302 webhook_url=webhook_url,
303 payload_template=teams_payload
304 )
307class FileChannel(NotificationChannel):
308 """
309 Notification channel that writes to a file.
310 ファイルに書き込む通知チャネル。
311 """
313 def __init__(self, name: str = "file_channel", file_path: str = None,
314 append_mode: bool = True, include_timestamp: bool = True):
315 """
316 Initialize file channel.
317 ファイルチャネルを初期化します。
319 Args:
320 name: Channel name / チャネル名
321 file_path: Path to output file / 出力ファイルパス
322 append_mode: Whether to append to file / ファイルに追記するかどうか
323 include_timestamp: Whether to include timestamp / タイムスタンプを含めるかどうか
324 """
325 super().__init__(name)
326 self.file_path = file_path or "notifications.log"
327 self.append_mode = append_mode
328 self.include_timestamp = include_timestamp
330 async def send(self, message: str, subject: str = None, context: Context = None) -> bool:
331 """Send notification to file."""
332 try:
333 mode = "a" if self.append_mode else "w"
335 with open(self.file_path, mode, encoding='utf-8') as f:
336 timestamp = datetime.now().isoformat() if self.include_timestamp else ""
337 subject_part = f"[{subject}]" if subject else ""
339 if self.include_timestamp and subject:
340 line = f"{timestamp} {subject_part} {message}\n"
341 elif self.include_timestamp:
342 line = f"{timestamp} {message}\n"
343 elif subject:
344 line = f"{subject_part} {message}\n"
345 else:
346 line = f"{message}\n"
348 f.write(line)
350 logger.info(f"File notification written to {self.file_path}")
351 return True
353 except Exception as e:
354 logger.error(f"Failed to write file notification: {e}")
355 return False
358class NotificationResult:
359 """
360 Result of notification operation.
361 通知操作の結果。
362 """
364 def __init__(self, total_channels: int = 0, successful_channels: int = 0,
365 failed_channels: List[str] = None, errors: List[str] = None):
366 """
367 Initialize notification result.
368 通知結果を初期化します。
370 Args:
371 total_channels: Total number of channels / 総チャネル数
372 successful_channels: Number of successful channels / 成功したチャネル数
373 failed_channels: List of failed channel names / 失敗したチャネル名のリスト
374 errors: List of error messages / エラーメッセージのリスト
375 """
376 self.total_channels = total_channels
377 self.successful_channels = successful_channels
378 self.failed_channels = failed_channels or []
379 self.errors = errors or []
380 self.timestamp = datetime.now()
382 @property
383 def success_rate(self) -> float:
384 """Get success rate as percentage."""
385 if self.total_channels == 0:
386 return 100.0
387 return (self.successful_channels / self.total_channels) * 100
389 @property
390 def is_success(self) -> bool:
391 """Check if all notifications were successful."""
392 return self.successful_channels == self.total_channels
394 def add_error(self, channel_name: str, error: str):
395 """Add an error for a specific channel."""
396 self.failed_channels.append(channel_name)
397 self.errors.append(f"[{channel_name}] {error}")
399 def __str__(self) -> str:
400 return f"NotificationResult({self.successful_channels}/{self.total_channels} successful, {len(self.errors)} errors)"
403class NotificationConfig(BaseModel):
404 """
405 Configuration for NotificationAgent.
406 NotificationAgentの設定。
407 """
409 name: str = Field(description="Name of the notification agent / 通知エージェントの名前")
411 channels: List[Dict[str, Any]] = Field(
412 default=[],
413 description="List of notification channel configurations / 通知チャネル設定のリスト"
414 )
416 default_subject: str = Field(
417 default="Notification",
418 description="Default subject for notifications / 通知のデフォルト件名"
419 )
421 fail_fast: bool = Field(
422 default=False,
423 description="Stop on first channel failure / 最初のチャネル失敗で停止"
424 )
426 store_result: bool = Field(
427 default=True,
428 description="Store notification result in context / 通知結果をコンテキストに保存"
429 )
431 require_all_success: bool = Field(
432 default=False,
433 description="Require all channels to succeed / 全てのチャネルの成功を要求"
434 )
436 @field_validator("channels")
437 @classmethod
438 def channels_not_empty(cls, v):
439 """Validate that at least one channel is configured."""
440 if not v:
441 logger.warning("No notification channels configured")
442 return v
445class NotificationAgent(Step):
446 """
447 Notification agent for sending notifications through various channels.
448 様々なチャネルを通じて通知を送信する通知エージェント。
450 The NotificationAgent supports multiple notification channels including
451 email, webhooks, Slack, Teams, logging, and file output.
452 NotificationAgentはメール、webhook、Slack、Teams、ログ、ファイル出力を含む
453 複数の通知チャネルをサポートしています。
454 """
456 def __init__(self, config: NotificationConfig, custom_channels: List[NotificationChannel] = None):
457 """
458 Initialize NotificationAgent.
459 NotificationAgentを初期化します。
461 Args:
462 config: Notification configuration / 通知設定
463 custom_channels: Optional custom notification channels / オプションのカスタム通知チャネル
464 """
465 super().__init__(name=config.name)
466 self.config = config
467 self.notification_channels = self._build_notification_channels(custom_channels or [])
469 def _build_notification_channels(self, custom_channels: List[NotificationChannel]) -> List[NotificationChannel]:
470 """
471 Build notification channels from configuration and custom channels.
472 設定とカスタムチャネルから通知チャネルを構築します。
473 """
474 channels = list(custom_channels)
476 # Build channels from configuration
477 # 設定からチャネルを構築
478 for channel_config in self.config.channels:
479 channel_type = channel_config.get("type")
480 channel_name = channel_config.get("name", channel_type)
481 enabled = channel_config.get("enabled", True)
483 if channel_type == "log":
484 log_level = channel_config.get("log_level", "INFO")
485 channels.append(LogChannel(channel_name, log_level))
487 elif channel_type == "email":
488 email_channel = EmailChannel(
489 name=channel_name,
490 smtp_server=channel_config.get("smtp_server"),
491 smtp_port=channel_config.get("smtp_port", 587),
492 username=channel_config.get("username"),
493 password=channel_config.get("password"),
494 from_email=channel_config.get("from_email"),
495 to_emails=channel_config.get("to_emails", []),
496 use_tls=channel_config.get("use_tls", True)
497 )
498 email_channel.enabled = enabled
499 channels.append(email_channel)
501 elif channel_type == "webhook":
502 webhook_channel = WebhookChannel(
503 name=channel_name,
504 webhook_url=channel_config.get("webhook_url"),
505 method=channel_config.get("method", "POST"),
506 headers=channel_config.get("headers"),
507 payload_template=channel_config.get("payload_template")
508 )
509 webhook_channel.enabled = enabled
510 channels.append(webhook_channel)
512 elif channel_type == "slack":
513 slack_channel = SlackChannel(
514 name=channel_name,
515 webhook_url=channel_config.get("webhook_url"),
516 channel=channel_config.get("channel"),
517 username=channel_config.get("username", "NotificationBot")
518 )
519 slack_channel.enabled = enabled
520 channels.append(slack_channel)
522 elif channel_type == "teams":
523 teams_channel = TeamsChannel(
524 name=channel_name,
525 webhook_url=channel_config.get("webhook_url")
526 )
527 teams_channel.enabled = enabled
528 channels.append(teams_channel)
530 elif channel_type == "file":
531 file_channel = FileChannel(
532 name=channel_name,
533 file_path=channel_config.get("file_path"),
534 append_mode=channel_config.get("append_mode", True),
535 include_timestamp=channel_config.get("include_timestamp", True)
536 )
537 file_channel.enabled = enabled
538 channels.append(file_channel)
540 else:
541 logger.warning(f"Unknown channel type: {channel_type}")
543 return channels
545 async def run(self, user_input: Optional[str], ctx: Context) -> Context:
546 """
547 Execute the notification logic.
548 通知ロジックを実行します。
550 Args:
551 user_input: Notification message / 通知メッセージ
552 ctx: Execution context / 実行コンテキスト
554 Returns:
555 Context: Updated context with notification results / 通知結果を含む更新されたコンテキスト
556 """
557 # Update step info
558 # ステップ情報を更新
559 ctx.update_step_info(self.name)
561 try:
562 # Determine message to send
563 # 送信するメッセージを決定
564 message = user_input
565 if message is None:
566 message = ctx.get_user_input()
568 if not message:
569 logger.warning(f"No message provided for notification in {self.name}")
570 message = "Empty notification message"
572 # Get subject from context or use default
573 # コンテキストから件名を取得するか、デフォルトを使用
574 subject = ctx.shared_state.get(f"{self.name}_subject", self.config.default_subject)
576 # Send notifications
577 # 通知を送信
578 notification_result = await self._send_notifications(message, subject, ctx)
580 # Store result in context if requested
581 # 要求された場合は結果をコンテキストに保存
582 if self.config.store_result:
583 ctx.shared_state[f"{self.name}_result"] = {
584 "total_channels": notification_result.total_channels,
585 "successful_channels": notification_result.successful_channels,
586 "failed_channels": notification_result.failed_channels,
587 "errors": notification_result.errors,
588 "success_rate": notification_result.success_rate,
589 "timestamp": notification_result.timestamp.isoformat()
590 }
592 # Handle notification failure
593 # 通知失敗を処理
594 if self.config.require_all_success and not notification_result.is_success:
595 error_summary = f"Notification failed: {len(notification_result.failed_channels)} channels failed"
596 raise ValueError(error_summary)
598 if notification_result.is_success:
599 logger.info(f"NotificationAgent '{self.name}': All notifications sent successfully")
600 ctx.shared_state[f"{self.name}_status"] = "success"
601 else:
602 logger.warning(f"NotificationAgent '{self.name}': {notification_result.successful_channels}/{notification_result.total_channels} notifications sent")
603 ctx.shared_state[f"{self.name}_status"] = "partial_success"
605 # Store individual channel results for easy access
606 # 簡単なアクセスのために個別のチャネル結果を保存
607 ctx.shared_state[f"{self.name}_success_count"] = notification_result.successful_channels
608 ctx.shared_state[f"{self.name}_total_count"] = notification_result.total_channels
610 return ctx
612 except Exception as e:
613 logger.error(f"NotificationAgent '{self.name}' error: {e}")
615 if self.config.store_result:
616 ctx.shared_state[f"{self.name}_result"] = {
617 "total_channels": 0,
618 "successful_channels": 0,
619 "failed_channels": [],
620 "errors": [str(e)],
621 "success_rate": 0.0,
622 "timestamp": datetime.now().isoformat()
623 }
624 ctx.shared_state[f"{self.name}_status"] = "error"
626 if self.config.require_all_success:
627 raise
629 return ctx
631 async def _send_notifications(self, message: str, subject: str, context: Context) -> NotificationResult:
632 """
633 Send notifications through all configured channels.
634 設定された全てのチャネルを通じて通知を送信します。
635 """
636 enabled_channels = [ch for ch in self.notification_channels if ch.enabled]
637 result = NotificationResult(total_channels=len(enabled_channels))
639 for channel in enabled_channels:
640 try:
641 success = await channel.send(message, subject, context)
643 if success:
644 result.successful_channels += 1
645 logger.debug(f"Notification sent successfully via {channel.name}")
646 else:
647 result.add_error(channel.name, "Channel send method returned False")
649 if self.config.fail_fast:
650 break
652 except Exception as e:
653 error_message = f"Channel '{channel.name}' execution error: {e}"
654 result.add_error(channel.name, error_message)
655 logger.warning(error_message)
657 if self.config.fail_fast:
658 break
660 return result
662 def add_channel(self, channel: NotificationChannel):
663 """
664 Add a notification channel to the agent.
665 エージェントに通知チャネルを追加します。
666 """
667 self.notification_channels.append(channel)
669 def get_channels(self) -> List[NotificationChannel]:
670 """
671 Get all notification channels.
672 全ての通知チャネルを取得します。
673 """
674 return self.notification_channels.copy()
676 def set_subject(self, subject: str, context: Context):
677 """
678 Set notification subject in context for next notification.
679 次の通知用にコンテキストで通知件名を設定します。
680 """
681 context.shared_state[f"{self.name}_subject"] = subject
684# Utility functions for creating common notification agents
685# 一般的な通知エージェントを作成するためのユーティリティ関数
687def create_log_notifier(name: str = "log_notifier", log_level: str = "INFO") -> NotificationAgent:
688 """
689 Create a notification agent that logs messages.
690 メッセージをログに記録する通知エージェントを作成します。
691 """
692 config = NotificationConfig(
693 name=name,
694 channels=[{"type": "log", "name": "log_channel", "log_level": log_level}]
695 )
696 return NotificationAgent(config)
699def create_file_notifier(name: str = "file_notifier", file_path: str = "notifications.log") -> NotificationAgent:
700 """
701 Create a notification agent that writes to files.
702 ファイルに書き込む通知エージェントを作成します。
703 """
704 config = NotificationConfig(
705 name=name,
706 channels=[{"type": "file", "name": "file_channel", "file_path": file_path}]
707 )
708 return NotificationAgent(config)
711def create_webhook_notifier(name: str = "webhook_notifier", webhook_url: str = None) -> NotificationAgent:
712 """
713 Create a notification agent that sends webhooks.
714 Webhookを送信する通知エージェントを作成します。
715 """
716 config = NotificationConfig(
717 name=name,
718 channels=[{"type": "webhook", "name": "webhook_channel", "webhook_url": webhook_url}]
719 )
720 return NotificationAgent(config)
723def create_slack_notifier(name: str = "slack_notifier", webhook_url: str = None,
724 channel: str = None) -> NotificationAgent:
725 """
726 Create a notification agent for Slack.
727 Slack用の通知エージェントを作成します。
728 """
729 config = NotificationConfig(
730 name=name,
731 channels=[{
732 "type": "slack",
733 "name": "slack_channel",
734 "webhook_url": webhook_url,
735 "channel": channel
736 }]
737 )
738 return NotificationAgent(config)
741def create_teams_notifier(name: str = "teams_notifier", webhook_url: str = None) -> NotificationAgent:
742 """
743 Create a notification agent for Microsoft Teams.
744 Microsoft Teams用の通知エージェントを作成します。
745 """
746 config = NotificationConfig(
747 name=name,
748 channels=[{"type": "teams", "name": "teams_channel", "webhook_url": webhook_url}]
749 )
750 return NotificationAgent(config)
753def create_multi_channel_notifier(name: str = "multi_notifier",
754 channels: List[Dict[str, Any]] = None) -> NotificationAgent:
755 """
756 Create a notification agent with multiple channels.
757 複数チャネルを持つ通知エージェントを作成します。
758 """
759 config = NotificationConfig(
760 name=name,
761 channels=channels or [
762 {"type": "log", "name": "log", "log_level": "INFO"},
763 {"type": "file", "name": "file", "file_path": "notifications.log"}
764 ]
765 )
766 return NotificationAgent(config)