wingman-ai 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- share/wingman/node_listener/package-lock.json +1785 -0
- share/wingman/node_listener/package.json +50 -0
- share/wingman/node_listener/src/index.ts +108 -0
- share/wingman/node_listener/src/ipc.ts +70 -0
- share/wingman/node_listener/src/messageHandler.ts +135 -0
- share/wingman/node_listener/src/socket.ts +244 -0
- share/wingman/node_listener/src/types.d.ts +13 -0
- share/wingman/node_listener/tsconfig.json +19 -0
- wingman/__init__.py +4 -0
- wingman/__main__.py +6 -0
- wingman/cli/__init__.py +5 -0
- wingman/cli/commands/__init__.py +1 -0
- wingman/cli/commands/auth.py +90 -0
- wingman/cli/commands/config.py +109 -0
- wingman/cli/commands/init.py +71 -0
- wingman/cli/commands/logs.py +84 -0
- wingman/cli/commands/start.py +111 -0
- wingman/cli/commands/status.py +84 -0
- wingman/cli/commands/stop.py +33 -0
- wingman/cli/commands/uninstall.py +113 -0
- wingman/cli/main.py +50 -0
- wingman/cli/wizard.py +356 -0
- wingman/config/__init__.py +31 -0
- wingman/config/paths.py +153 -0
- wingman/config/personality.py +155 -0
- wingman/config/registry.py +343 -0
- wingman/config/settings.py +294 -0
- wingman/core/__init__.py +16 -0
- wingman/core/agent.py +257 -0
- wingman/core/ipc_handler.py +124 -0
- wingman/core/llm/__init__.py +5 -0
- wingman/core/llm/client.py +77 -0
- wingman/core/memory/__init__.py +6 -0
- wingman/core/memory/context.py +109 -0
- wingman/core/memory/models.py +213 -0
- wingman/core/message_processor.py +277 -0
- wingman/core/policy/__init__.py +5 -0
- wingman/core/policy/evaluator.py +265 -0
- wingman/core/process_manager.py +135 -0
- wingman/core/safety/__init__.py +8 -0
- wingman/core/safety/cooldown.py +63 -0
- wingman/core/safety/quiet_hours.py +75 -0
- wingman/core/safety/rate_limiter.py +58 -0
- wingman/core/safety/triggers.py +117 -0
- wingman/core/transports/__init__.py +14 -0
- wingman/core/transports/base.py +106 -0
- wingman/core/transports/imessage/__init__.py +5 -0
- wingman/core/transports/imessage/db_listener.py +280 -0
- wingman/core/transports/imessage/sender.py +162 -0
- wingman/core/transports/imessage/transport.py +140 -0
- wingman/core/transports/whatsapp.py +180 -0
- wingman/daemon/__init__.py +5 -0
- wingman/daemon/manager.py +303 -0
- wingman/installer/__init__.py +5 -0
- wingman/installer/node_installer.py +253 -0
- wingman_ai-1.0.0.dist-info/METADATA +553 -0
- wingman_ai-1.0.0.dist-info/RECORD +60 -0
- wingman_ai-1.0.0.dist-info/WHEEL +4 -0
- wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
- wingman_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Policy evaluation engine for determining response behavior."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from wingman.config.registry import (
|
|
10
|
+
ContactProfile,
|
|
11
|
+
ContactRole,
|
|
12
|
+
GroupCategory,
|
|
13
|
+
GroupConfig,
|
|
14
|
+
ReplyPolicy,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MessageContext:
|
|
22
|
+
"""Context information for a message being evaluated."""
|
|
23
|
+
|
|
24
|
+
# Message details
|
|
25
|
+
chat_id: str
|
|
26
|
+
sender_id: str
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
# Chat type
|
|
30
|
+
is_dm: bool
|
|
31
|
+
is_group: bool
|
|
32
|
+
|
|
33
|
+
# Resolved profiles
|
|
34
|
+
contact: ContactProfile
|
|
35
|
+
|
|
36
|
+
# Optional fields with defaults
|
|
37
|
+
group: GroupConfig | None = None
|
|
38
|
+
platform: str = "whatsapp"
|
|
39
|
+
is_reply_to_bot: bool = False
|
|
40
|
+
is_mentioned: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def role(self) -> ContactRole:
|
|
44
|
+
"""Get the sender's role."""
|
|
45
|
+
return self.contact.role
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def group_category(self) -> GroupCategory | None:
|
|
49
|
+
"""Get the group category if in a group."""
|
|
50
|
+
return self.group.category if self.group else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class PolicyDecision:
|
|
55
|
+
"""Result of policy evaluation."""
|
|
56
|
+
|
|
57
|
+
should_respond: bool
|
|
58
|
+
reason: str
|
|
59
|
+
rule_name: str | None = None
|
|
60
|
+
action: ReplyPolicy = ReplyPolicy.SELECTIVE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class PolicyRule:
|
|
65
|
+
"""A single policy rule."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
conditions: dict
|
|
69
|
+
action: ReplyPolicy
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: dict) -> "PolicyRule":
|
|
73
|
+
"""Create a PolicyRule from config dict."""
|
|
74
|
+
return cls(
|
|
75
|
+
name=data.get("name", "unnamed"),
|
|
76
|
+
conditions=data.get("conditions", {}),
|
|
77
|
+
action=ReplyPolicy(data.get("action", "selective")),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PolicyEvaluator:
|
|
82
|
+
"""
|
|
83
|
+
Evaluates policy rules against message context to determine
|
|
84
|
+
whether the bot should respond.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, config_path: Path | None = None, bot_name: str = "Maximus"):
|
|
88
|
+
self._rules: list[PolicyRule] = []
|
|
89
|
+
self._fallback_action = ReplyPolicy.SELECTIVE
|
|
90
|
+
self._bot_name = bot_name.lower()
|
|
91
|
+
|
|
92
|
+
if config_path and config_path.exists():
|
|
93
|
+
self._load_config(config_path)
|
|
94
|
+
|
|
95
|
+
def _load_config(self, config_path: Path) -> None:
|
|
96
|
+
"""Load policy configuration from YAML file."""
|
|
97
|
+
try:
|
|
98
|
+
with open(config_path) as f:
|
|
99
|
+
config = yaml.safe_load(f) or {}
|
|
100
|
+
|
|
101
|
+
# Load rules
|
|
102
|
+
rules_data = config.get("rules", [])
|
|
103
|
+
for rule_data in rules_data:
|
|
104
|
+
try:
|
|
105
|
+
rule = PolicyRule.from_dict(rule_data)
|
|
106
|
+
self._rules.append(rule)
|
|
107
|
+
logger.debug(f"Loaded policy rule: {rule.name}")
|
|
108
|
+
except (ValueError, KeyError) as e:
|
|
109
|
+
logger.warning(f"Invalid policy rule: {e}")
|
|
110
|
+
|
|
111
|
+
# Load fallback
|
|
112
|
+
fallback_data = config.get("fallback", {})
|
|
113
|
+
if fallback_data:
|
|
114
|
+
self._fallback_action = ReplyPolicy(fallback_data.get("action", "selective"))
|
|
115
|
+
|
|
116
|
+
logger.info(f"Loaded {len(self._rules)} policy rules from {config_path}")
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Failed to load policies config: {e}")
|
|
120
|
+
|
|
121
|
+
def _check_mentioned(self, text: str) -> bool:
|
|
122
|
+
"""Check if the bot is mentioned in the text."""
|
|
123
|
+
if not text:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
text_lower = text.lower()
|
|
127
|
+
|
|
128
|
+
# Check for @mention or name mention
|
|
129
|
+
if f"@{self._bot_name}" in text_lower:
|
|
130
|
+
return True
|
|
131
|
+
if self._bot_name in text_lower:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def _matches_conditions(self, rule: PolicyRule, context: MessageContext) -> bool:
|
|
137
|
+
"""Check if a rule's conditions match the message context."""
|
|
138
|
+
conditions = rule.conditions
|
|
139
|
+
|
|
140
|
+
# Check platform
|
|
141
|
+
if "platform" in conditions:
|
|
142
|
+
if conditions["platform"] != context.platform:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Check is_dm
|
|
146
|
+
if "is_dm" in conditions:
|
|
147
|
+
if conditions["is_dm"] != context.is_dm:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Check is_group
|
|
151
|
+
if "is_group" in conditions:
|
|
152
|
+
if conditions["is_group"] != context.is_group:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
# Check role
|
|
156
|
+
if "role" in conditions:
|
|
157
|
+
if conditions["role"] != context.role.value:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# Check group_category
|
|
161
|
+
if "group_category" in conditions:
|
|
162
|
+
if context.group_category is None:
|
|
163
|
+
return False
|
|
164
|
+
if conditions["group_category"] != context.group_category.value:
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
# Check is_reply_to_bot
|
|
168
|
+
if "is_reply_to_bot" in conditions:
|
|
169
|
+
if conditions["is_reply_to_bot"] != context.is_reply_to_bot:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
# Check is_mentioned
|
|
173
|
+
if "is_mentioned" in conditions:
|
|
174
|
+
if conditions["is_mentioned"] != context.is_mentioned:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def evaluate(self, context: MessageContext) -> PolicyDecision:
|
|
180
|
+
"""
|
|
181
|
+
Evaluate policy rules against the message context.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
context: The message context to evaluate
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
PolicyDecision with should_respond and reason
|
|
188
|
+
"""
|
|
189
|
+
# Check if bot is mentioned (for selective policies)
|
|
190
|
+
is_mentioned = self._check_mentioned(context.text)
|
|
191
|
+
# Update context with mention status
|
|
192
|
+
context.is_mentioned = is_mentioned
|
|
193
|
+
|
|
194
|
+
logger.debug(
|
|
195
|
+
f"Evaluating policy: platform={context.platform}, is_dm={context.is_dm}, "
|
|
196
|
+
f"role={context.role.value}, "
|
|
197
|
+
f"group_category={context.group_category.value if context.group_category else 'N/A'}, "
|
|
198
|
+
f"is_mentioned={is_mentioned}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Evaluate rules in order
|
|
202
|
+
for rule in self._rules:
|
|
203
|
+
if self._matches_conditions(rule, context):
|
|
204
|
+
logger.debug(f"Rule matched: {rule.name} -> {rule.action.value}")
|
|
205
|
+
|
|
206
|
+
should_respond = self._should_respond_for_action(
|
|
207
|
+
rule.action, is_mentioned, context.is_reply_to_bot
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return PolicyDecision(
|
|
211
|
+
should_respond=should_respond,
|
|
212
|
+
reason=f"rule:{rule.name}",
|
|
213
|
+
rule_name=rule.name,
|
|
214
|
+
action=rule.action,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# No rule matched, use fallback
|
|
218
|
+
logger.debug(f"No rule matched, using fallback: {self._fallback_action.value}")
|
|
219
|
+
should_respond = self._should_respond_for_action(
|
|
220
|
+
self._fallback_action, is_mentioned, context.is_reply_to_bot
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return PolicyDecision(
|
|
224
|
+
should_respond=should_respond,
|
|
225
|
+
reason="fallback",
|
|
226
|
+
rule_name=None,
|
|
227
|
+
action=self._fallback_action,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _should_respond_for_action(
|
|
231
|
+
self, action: ReplyPolicy, is_mentioned: bool, is_reply_to_bot: bool
|
|
232
|
+
) -> bool:
|
|
233
|
+
"""Determine if should respond based on action type."""
|
|
234
|
+
if action == ReplyPolicy.ALWAYS:
|
|
235
|
+
return True
|
|
236
|
+
elif action == ReplyPolicy.NEVER:
|
|
237
|
+
return False
|
|
238
|
+
elif action == ReplyPolicy.SELECTIVE:
|
|
239
|
+
# Respond if mentioned or replying to bot
|
|
240
|
+
return is_mentioned or is_reply_to_bot
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def create_context(
|
|
244
|
+
self,
|
|
245
|
+
chat_id: str,
|
|
246
|
+
sender_id: str,
|
|
247
|
+
text: str,
|
|
248
|
+
is_group: bool,
|
|
249
|
+
contact: ContactProfile,
|
|
250
|
+
group: GroupConfig | None = None,
|
|
251
|
+
is_reply_to_bot: bool = False,
|
|
252
|
+
platform: str = "whatsapp",
|
|
253
|
+
) -> MessageContext:
|
|
254
|
+
"""Helper to create a MessageContext."""
|
|
255
|
+
return MessageContext(
|
|
256
|
+
chat_id=chat_id,
|
|
257
|
+
sender_id=sender_id,
|
|
258
|
+
text=text,
|
|
259
|
+
is_dm=not is_group,
|
|
260
|
+
is_group=is_group,
|
|
261
|
+
platform=platform,
|
|
262
|
+
contact=contact,
|
|
263
|
+
group=group,
|
|
264
|
+
is_reply_to_bot=is_reply_to_bot,
|
|
265
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Node.js subprocess management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .ipc_handler import IPCCommand, IPCHandler
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NodeProcessManager:
|
|
13
|
+
"""Manages the Node.js listener subprocess."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, node_dir: Path, auth_state_dir: Path | None = None):
|
|
16
|
+
self.node_dir = node_dir
|
|
17
|
+
self.auth_state_dir = auth_state_dir
|
|
18
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
19
|
+
self.ipc: IPCHandler | None = None
|
|
20
|
+
self._stderr_task: asyncio.Task | None = None
|
|
21
|
+
|
|
22
|
+
async def start(self) -> IPCHandler:
|
|
23
|
+
"""Start the Node.js subprocess and return IPC handler."""
|
|
24
|
+
node_script = self.node_dir / "dist" / "index.js"
|
|
25
|
+
|
|
26
|
+
if not node_script.exists():
|
|
27
|
+
raise FileNotFoundError(
|
|
28
|
+
f"Node.js script not found: {node_script}\n"
|
|
29
|
+
"Run 'npm run build' in node_listener directory first."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger.info(f"Starting Node.js subprocess: {node_script}")
|
|
33
|
+
|
|
34
|
+
# Build environment with optional auth state directory
|
|
35
|
+
env = None
|
|
36
|
+
if self.auth_state_dir:
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
env = os.environ.copy()
|
|
40
|
+
env["AUTH_STATE_DIR"] = str(self.auth_state_dir)
|
|
41
|
+
|
|
42
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
43
|
+
"node",
|
|
44
|
+
str(node_script),
|
|
45
|
+
stdin=asyncio.subprocess.PIPE,
|
|
46
|
+
stdout=asyncio.subprocess.PIPE,
|
|
47
|
+
stderr=asyncio.subprocess.PIPE,
|
|
48
|
+
cwd=str(self.node_dir),
|
|
49
|
+
env=env,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not self.process.stdin or not self.process.stdout:
|
|
53
|
+
raise RuntimeError("Failed to create subprocess pipes")
|
|
54
|
+
|
|
55
|
+
# Create IPC handler
|
|
56
|
+
self.ipc = IPCHandler(stdin=self.process.stdin, stdout=self.process.stdout)
|
|
57
|
+
|
|
58
|
+
# Start stderr reader for logging
|
|
59
|
+
self._stderr_task = asyncio.create_task(self._read_stderr())
|
|
60
|
+
|
|
61
|
+
logger.info(f"Node.js subprocess started (PID: {self.process.pid})")
|
|
62
|
+
return self.ipc
|
|
63
|
+
|
|
64
|
+
async def _read_stderr(self) -> None:
|
|
65
|
+
"""Read and log Node.js stderr output."""
|
|
66
|
+
if not self.process or not self.process.stderr:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
line = await self.process.stderr.readline()
|
|
72
|
+
if not line:
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
# Node logs JSON to stderr
|
|
76
|
+
log_line = line.decode("utf-8").strip()
|
|
77
|
+
if log_line:
|
|
78
|
+
# Check if it's a QR code line (contains block characters)
|
|
79
|
+
if any(c in log_line for c in ["▄", "█", "▀", "="]):
|
|
80
|
+
# Print QR directly to stderr for user to see
|
|
81
|
+
print(log_line, flush=True)
|
|
82
|
+
else:
|
|
83
|
+
logger.debug(f"[Node] {log_line}")
|
|
84
|
+
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
break
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Error reading stderr: {e}")
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
async def stop(self) -> None:
|
|
92
|
+
"""Stop the Node.js subprocess gracefully."""
|
|
93
|
+
if not self.process:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
logger.info("Stopping Node.js subprocess...")
|
|
97
|
+
|
|
98
|
+
# Cancel stderr reader
|
|
99
|
+
if self._stderr_task:
|
|
100
|
+
self._stderr_task.cancel()
|
|
101
|
+
try:
|
|
102
|
+
await self._stderr_task
|
|
103
|
+
except asyncio.CancelledError:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Try graceful shutdown first
|
|
107
|
+
if self.ipc:
|
|
108
|
+
try:
|
|
109
|
+
await asyncio.wait_for(
|
|
110
|
+
self.ipc.send_command(IPCCommand(action="shutdown")), timeout=2.0
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning(f"Failed to send shutdown command: {e}")
|
|
114
|
+
|
|
115
|
+
# Wait for process to exit
|
|
116
|
+
try:
|
|
117
|
+
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
|
118
|
+
logger.info("Node.js subprocess exited gracefully")
|
|
119
|
+
except asyncio.TimeoutError:
|
|
120
|
+
logger.warning("Node.js subprocess did not exit, terminating...")
|
|
121
|
+
self.process.terminate()
|
|
122
|
+
try:
|
|
123
|
+
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
logger.error("Node.js subprocess did not terminate, killing...")
|
|
126
|
+
self.process.kill()
|
|
127
|
+
await self.process.wait()
|
|
128
|
+
|
|
129
|
+
self.process = None
|
|
130
|
+
self.ipc = None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_running(self) -> bool:
|
|
134
|
+
"""Check if the subprocess is running."""
|
|
135
|
+
return self.process is not None and self.process.returncode is None
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Safety features for Wingman."""
|
|
2
|
+
|
|
3
|
+
from .cooldown import CooldownManager
|
|
4
|
+
from .quiet_hours import QuietHoursChecker
|
|
5
|
+
from .rate_limiter import RateLimiter
|
|
6
|
+
from .triggers import TriggerDetector
|
|
7
|
+
|
|
8
|
+
__all__ = ["RateLimiter", "CooldownManager", "QuietHoursChecker", "TriggerDetector"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Per-chat cooldown management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CooldownManager:
|
|
10
|
+
"""
|
|
11
|
+
Manages per-chat cooldowns to prevent rapid-fire responses.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, default_cooldown_seconds: int = 60):
|
|
15
|
+
self.default_cooldown = default_cooldown_seconds
|
|
16
|
+
self._last_reply: dict[str, float] = {}
|
|
17
|
+
self._custom_cooldowns: dict[str, int] = {}
|
|
18
|
+
|
|
19
|
+
def set_cooldown(self, chat_id: str, seconds: int) -> None:
|
|
20
|
+
"""Set a custom cooldown for a specific chat."""
|
|
21
|
+
self._custom_cooldowns[chat_id] = seconds
|
|
22
|
+
logger.debug(f"Set custom cooldown for {chat_id}: {seconds}s")
|
|
23
|
+
|
|
24
|
+
def get_cooldown(self, chat_id: str) -> int:
|
|
25
|
+
"""Get the cooldown duration for a chat."""
|
|
26
|
+
return self._custom_cooldowns.get(chat_id, self.default_cooldown)
|
|
27
|
+
|
|
28
|
+
def is_on_cooldown(self, chat_id: str) -> bool:
|
|
29
|
+
"""Check if a chat is currently on cooldown."""
|
|
30
|
+
last_reply = self._last_reply.get(chat_id)
|
|
31
|
+
if last_reply is None:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
cooldown = self.get_cooldown(chat_id)
|
|
35
|
+
elapsed = time.time() - last_reply
|
|
36
|
+
on_cooldown = elapsed < cooldown
|
|
37
|
+
|
|
38
|
+
if on_cooldown:
|
|
39
|
+
remaining = cooldown - elapsed
|
|
40
|
+
logger.debug(f"Chat {chat_id} on cooldown: {remaining:.1f}s remaining")
|
|
41
|
+
|
|
42
|
+
return on_cooldown
|
|
43
|
+
|
|
44
|
+
def record_reply(self, chat_id: str) -> None:
|
|
45
|
+
"""Record that a reply was sent to a chat."""
|
|
46
|
+
self._last_reply[chat_id] = time.time()
|
|
47
|
+
logger.debug(f"Recorded reply to {chat_id}")
|
|
48
|
+
|
|
49
|
+
def get_remaining_cooldown(self, chat_id: str) -> float:
|
|
50
|
+
"""Get seconds remaining in cooldown, or 0 if not on cooldown."""
|
|
51
|
+
last_reply = self._last_reply.get(chat_id)
|
|
52
|
+
if last_reply is None:
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
cooldown = self.get_cooldown(chat_id)
|
|
56
|
+
elapsed = time.time() - last_reply
|
|
57
|
+
return max(0, cooldown - elapsed)
|
|
58
|
+
|
|
59
|
+
def clear_cooldown(self, chat_id: str) -> None:
|
|
60
|
+
"""Manually clear cooldown for a chat."""
|
|
61
|
+
if chat_id in self._last_reply:
|
|
62
|
+
del self._last_reply[chat_id]
|
|
63
|
+
logger.debug(f"Cleared cooldown for {chat_id}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Quiet hours enforcement."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from datetime import time as dt_time
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QuietHoursChecker:
|
|
11
|
+
"""
|
|
12
|
+
Enforces quiet hours during which the bot will not respond.
|
|
13
|
+
Default: midnight (0:00) to 6:00 AM.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, start_hour: int = 0, end_hour: int = 6, enabled: bool = True):
|
|
17
|
+
self.start_hour = start_hour
|
|
18
|
+
self.end_hour = end_hour
|
|
19
|
+
self.enabled = enabled
|
|
20
|
+
|
|
21
|
+
def is_quiet_time(self, check_time: datetime | None = None) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if the given time falls within quiet hours.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
check_time: Time to check (defaults to current time)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
True if currently in quiet hours
|
|
30
|
+
"""
|
|
31
|
+
if not self.enabled:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
if check_time is None:
|
|
35
|
+
check_time = datetime.now()
|
|
36
|
+
|
|
37
|
+
current_hour = check_time.hour
|
|
38
|
+
|
|
39
|
+
# Handle both same-day and overnight ranges
|
|
40
|
+
if self.start_hour <= self.end_hour:
|
|
41
|
+
# Same day range (e.g., 9 AM to 5 PM)
|
|
42
|
+
is_quiet = self.start_hour <= current_hour < self.end_hour
|
|
43
|
+
else:
|
|
44
|
+
# Overnight range (e.g., 10 PM to 6 AM)
|
|
45
|
+
is_quiet = current_hour >= self.start_hour or current_hour < self.end_hour
|
|
46
|
+
|
|
47
|
+
if is_quiet:
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Quiet hours active: {self.start_hour}:00 - {self.end_hour}:00, "
|
|
50
|
+
f"current hour: {current_hour}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return is_quiet
|
|
54
|
+
|
|
55
|
+
def get_end_time(self) -> dt_time:
|
|
56
|
+
"""Get the time when quiet hours end."""
|
|
57
|
+
return dt_time(hour=self.end_hour)
|
|
58
|
+
|
|
59
|
+
def set_hours(self, start: int, end: int) -> None:
|
|
60
|
+
"""Update quiet hours range."""
|
|
61
|
+
if not (0 <= start <= 23 and 0 <= end <= 23):
|
|
62
|
+
raise ValueError("Hours must be between 0 and 23")
|
|
63
|
+
self.start_hour = start
|
|
64
|
+
self.end_hour = end
|
|
65
|
+
logger.info(f"Quiet hours updated: {start}:00 - {end}:00")
|
|
66
|
+
|
|
67
|
+
def disable(self) -> None:
|
|
68
|
+
"""Disable quiet hours."""
|
|
69
|
+
self.enabled = False
|
|
70
|
+
logger.info("Quiet hours disabled")
|
|
71
|
+
|
|
72
|
+
def enable(self) -> None:
|
|
73
|
+
"""Enable quiet hours."""
|
|
74
|
+
self.enabled = True
|
|
75
|
+
logger.info("Quiet hours enabled")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Global rate limiting for bot replies."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RateLimiter:
|
|
11
|
+
"""
|
|
12
|
+
Implements a sliding window rate limiter.
|
|
13
|
+
Tracks replies in a 1-hour window and enforces max replies limit.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, max_replies_per_hour: int = 30):
|
|
17
|
+
self.max_replies = max_replies_per_hour
|
|
18
|
+
self.window_seconds = 3600 # 1 hour
|
|
19
|
+
self._timestamps: deque[float] = deque()
|
|
20
|
+
|
|
21
|
+
def _cleanup_old_entries(self) -> None:
|
|
22
|
+
"""Remove timestamps older than the window."""
|
|
23
|
+
cutoff = time.time() - self.window_seconds
|
|
24
|
+
while self._timestamps and self._timestamps[0] < cutoff:
|
|
25
|
+
self._timestamps.popleft()
|
|
26
|
+
|
|
27
|
+
def can_reply(self) -> bool:
|
|
28
|
+
"""Check if a reply is allowed within rate limits."""
|
|
29
|
+
self._cleanup_old_entries()
|
|
30
|
+
allowed = len(self._timestamps) < self.max_replies
|
|
31
|
+
|
|
32
|
+
if not allowed:
|
|
33
|
+
logger.warning(
|
|
34
|
+
f"Rate limit reached: {len(self._timestamps)}/{self.max_replies} "
|
|
35
|
+
f"replies in the last hour"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return allowed
|
|
39
|
+
|
|
40
|
+
def record_reply(self) -> None:
|
|
41
|
+
"""Record that a reply was sent."""
|
|
42
|
+
self._cleanup_old_entries()
|
|
43
|
+
self._timestamps.append(time.time())
|
|
44
|
+
logger.debug(
|
|
45
|
+
f"Reply recorded: {len(self._timestamps)}/{self.max_replies} " f"in current window"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_remaining(self) -> int:
|
|
49
|
+
"""Get number of remaining allowed replies."""
|
|
50
|
+
self._cleanup_old_entries()
|
|
51
|
+
return max(0, self.max_replies - len(self._timestamps))
|
|
52
|
+
|
|
53
|
+
def get_reset_time(self) -> float:
|
|
54
|
+
"""Get seconds until oldest reply expires from window."""
|
|
55
|
+
if not self._timestamps:
|
|
56
|
+
return 0
|
|
57
|
+
oldest = self._timestamps[0]
|
|
58
|
+
return max(0, (oldest + self.window_seconds) - time.time())
|