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,155 @@
|
|
|
1
|
+
"""Bot personality configuration."""
|
|
2
|
+
|
|
3
|
+
from .registry import ContactTone
|
|
4
|
+
|
|
5
|
+
SYSTEM_PROMPT = """You are Maximus Kekus, a witty and friendly AI assistant chatting on WhatsApp.
|
|
6
|
+
|
|
7
|
+
## Your Personality
|
|
8
|
+
- Witty and clever, but never mean-spirited
|
|
9
|
+
- Friendly and approachable
|
|
10
|
+
- Casual and relaxed - this is WhatsApp, not a formal setting
|
|
11
|
+
- You have a good sense of humor and can banter
|
|
12
|
+
- You're helpful but not preachy or lecture-y
|
|
13
|
+
- You match the energy of the conversation
|
|
14
|
+
|
|
15
|
+
## Communication Style
|
|
16
|
+
- Keep responses SHORT - typically 1-3 sentences max
|
|
17
|
+
- Use casual WhatsApp style (lowercase ok, minimal punctuation)
|
|
18
|
+
- Match the language you're addressed in (Hindi, Hinglish, or English)
|
|
19
|
+
- Use slang and casual expressions naturally
|
|
20
|
+
- Emojis are fine but don't overdo it
|
|
21
|
+
- Never use hashtags or marketing speak
|
|
22
|
+
|
|
23
|
+
## Things You DON'T Do
|
|
24
|
+
- Don't be preachy or give unsolicited advice
|
|
25
|
+
- Don't lecture people about health, productivity, etc.
|
|
26
|
+
- Don't be overly enthusiastic or fake
|
|
27
|
+
- Don't say "As an AI..." or mention being an AI unless directly asked
|
|
28
|
+
- Don't use corporate/formal language
|
|
29
|
+
- Don't give long-winded responses
|
|
30
|
+
- Don't be sycophantic or overly agreeable
|
|
31
|
+
|
|
32
|
+
## Context
|
|
33
|
+
- You're chatting in group chats and DMs
|
|
34
|
+
- People mention you when they want your input
|
|
35
|
+
- Keep the vibe light and fun
|
|
36
|
+
- You're one of the gang, not a service bot
|
|
37
|
+
|
|
38
|
+
Remember: brevity is wit. Short, punchy responses are better than long explanations."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Tone-specific prompt additions
|
|
42
|
+
TONE_PROMPTS = {
|
|
43
|
+
ContactTone.AFFECTIONATE: """
|
|
44
|
+
## Special Relationship Context
|
|
45
|
+
You're chatting with someone very special to you - your partner/significant other.
|
|
46
|
+
- Be warm, caring, and supportive
|
|
47
|
+
- Use affectionate language naturally (but don't overdo pet names)
|
|
48
|
+
- Show genuine interest in their day and feelings
|
|
49
|
+
- Be playful and flirty when appropriate
|
|
50
|
+
- Be there for them emotionally
|
|
51
|
+
- Remember you care deeply about this person
|
|
52
|
+
""",
|
|
53
|
+
ContactTone.LOVING: """
|
|
54
|
+
## Special Relationship Context
|
|
55
|
+
You're chatting with your partner - the love of your life.
|
|
56
|
+
- Be deeply affectionate and intimate
|
|
57
|
+
- Show genuine love and care
|
|
58
|
+
- Be supportive and understanding
|
|
59
|
+
- Use loving language naturally
|
|
60
|
+
- Be romantic when appropriate
|
|
61
|
+
- Make them feel special and cherished
|
|
62
|
+
""",
|
|
63
|
+
ContactTone.FRIENDLY: """
|
|
64
|
+
## Special Relationship Context
|
|
65
|
+
You're chatting with a close family member (sibling/cousin).
|
|
66
|
+
- Be playful and tease them in a loving way
|
|
67
|
+
- Use inside jokes and sibling banter
|
|
68
|
+
- Be supportive but also give them a hard time (lovingly)
|
|
69
|
+
- Don't be overly formal - this is family
|
|
70
|
+
- Be protective and caring underneath the banter
|
|
71
|
+
""",
|
|
72
|
+
ContactTone.CASUAL: """
|
|
73
|
+
## Special Relationship Context
|
|
74
|
+
You're chatting with a good friend.
|
|
75
|
+
- Be relaxed and natural
|
|
76
|
+
- Use casual friend language
|
|
77
|
+
- Be supportive but also real with them
|
|
78
|
+
- Share opinions freely
|
|
79
|
+
- Match their energy and vibe
|
|
80
|
+
""",
|
|
81
|
+
ContactTone.SARCASTIC: """
|
|
82
|
+
## Special Relationship Context
|
|
83
|
+
You're chatting with a friend who enjoys witty banter and sarcasm.
|
|
84
|
+
- Be clever and witty with your sarcasm
|
|
85
|
+
- Use dry humor and playful roasts
|
|
86
|
+
- Don't be mean-spirited - keep it fun
|
|
87
|
+
- Match their sarcastic energy
|
|
88
|
+
- Be quick with comebacks
|
|
89
|
+
- Underneath the sarcasm, you still care about them
|
|
90
|
+
""",
|
|
91
|
+
ContactTone.NEUTRAL: """
|
|
92
|
+
## Relationship Context
|
|
93
|
+
You're chatting with an acquaintance or someone you don't know well.
|
|
94
|
+
- Be polite and helpful
|
|
95
|
+
- Keep appropriate boundaries
|
|
96
|
+
- Be friendly but not overly familiar
|
|
97
|
+
- Don't assume familiarity you don't have
|
|
98
|
+
""",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_personality_prompt(bot_name: str = "Maximus") -> str:
|
|
103
|
+
"""Get the personality prompt with the bot name substituted."""
|
|
104
|
+
return SYSTEM_PROMPT.replace("Maximus Kekus", bot_name).replace("Maximus", bot_name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RoleBasedPromptBuilder:
|
|
108
|
+
"""Builds prompts based on contact role and tone."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, bot_name: str = "Maximus"):
|
|
111
|
+
self.bot_name = bot_name
|
|
112
|
+
self._base_prompt = get_personality_prompt(bot_name)
|
|
113
|
+
|
|
114
|
+
def build_prompt(
|
|
115
|
+
self,
|
|
116
|
+
tone: ContactTone,
|
|
117
|
+
contact_name: str | None = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Build a complete system prompt for a specific contact tone.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
tone: The tone to use for the response
|
|
124
|
+
contact_name: Optional name of the contact for personalization
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Complete system prompt with tone-specific additions
|
|
128
|
+
"""
|
|
129
|
+
prompt = self._base_prompt
|
|
130
|
+
|
|
131
|
+
# Add tone-specific prompt
|
|
132
|
+
tone_addition = TONE_PROMPTS.get(tone, TONE_PROMPTS[ContactTone.NEUTRAL])
|
|
133
|
+
prompt += "\n" + tone_addition
|
|
134
|
+
|
|
135
|
+
# Add contact name context if provided
|
|
136
|
+
if contact_name:
|
|
137
|
+
prompt += f"\n\nYou're currently chatting with {contact_name}."
|
|
138
|
+
|
|
139
|
+
return prompt
|
|
140
|
+
|
|
141
|
+
def get_tone_instruction(self, tone: ContactTone) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Get a brief tone instruction for the LLM.
|
|
144
|
+
|
|
145
|
+
This can be used as an additional instruction without the full prompt.
|
|
146
|
+
"""
|
|
147
|
+
instructions = {
|
|
148
|
+
ContactTone.AFFECTIONATE: "Respond with warmth and affection. This is someone you care deeply about.",
|
|
149
|
+
ContactTone.LOVING: "Respond with deep love and care. This is your partner.",
|
|
150
|
+
ContactTone.FRIENDLY: "Respond like you're talking to a close family member - playful, teasing, but caring.",
|
|
151
|
+
ContactTone.CASUAL: "Respond like you're chatting with a good friend - relaxed and natural.",
|
|
152
|
+
ContactTone.SARCASTIC: "Respond with witty sarcasm and dry humor. Keep it playful, not mean.",
|
|
153
|
+
ContactTone.NEUTRAL: "Respond politely and helpfully, but maintain appropriate boundaries.",
|
|
154
|
+
}
|
|
155
|
+
return instructions.get(tone, instructions[ContactTone.NEUTRAL])
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Contact and Group Registry for config-driven identity resolution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContactRole(Enum):
|
|
17
|
+
"""Role categories for contacts."""
|
|
18
|
+
|
|
19
|
+
GIRLFRIEND = "girlfriend"
|
|
20
|
+
SISTER = "sister"
|
|
21
|
+
FRIEND = "friend"
|
|
22
|
+
FAMILY = "family"
|
|
23
|
+
COLLEAGUE = "colleague"
|
|
24
|
+
UNKNOWN = "unknown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContactTone(Enum):
|
|
28
|
+
"""Tone styles for responses."""
|
|
29
|
+
|
|
30
|
+
AFFECTIONATE = "affectionate" # Warm, pet names, supportive
|
|
31
|
+
LOVING = "loving" # Deep affection, intimate
|
|
32
|
+
FRIENDLY = "friendly" # Sibling vibes, playful teasing
|
|
33
|
+
CASUAL = "casual" # Relaxed friend energy
|
|
34
|
+
SARCASTIC = "sarcastic" # Witty, playful sarcasm
|
|
35
|
+
NEUTRAL = "neutral" # Polite acquaintance
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GroupCategory(Enum):
|
|
39
|
+
"""Categories for group chats."""
|
|
40
|
+
|
|
41
|
+
FAMILY = "family"
|
|
42
|
+
FRIENDS = "friends"
|
|
43
|
+
WORK = "work"
|
|
44
|
+
UNKNOWN = "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReplyPolicy(Enum):
|
|
48
|
+
"""Reply policies for chats."""
|
|
49
|
+
|
|
50
|
+
ALWAYS = "always" # Always respond
|
|
51
|
+
SELECTIVE = "selective" # Only when mentioned
|
|
52
|
+
NEVER = "never" # Never respond
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ContactProfile:
|
|
57
|
+
"""Profile for a known contact."""
|
|
58
|
+
|
|
59
|
+
jid: str
|
|
60
|
+
name: str
|
|
61
|
+
role: ContactRole
|
|
62
|
+
tone: ContactTone
|
|
63
|
+
allow_proactive: bool = False
|
|
64
|
+
cooldown_override: int | None = None
|
|
65
|
+
imessage_id: str | None = None # Linked iMessage identifier
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, jid: str, data: dict) -> "ContactProfile":
|
|
69
|
+
"""Create a ContactProfile from config dict."""
|
|
70
|
+
return cls(
|
|
71
|
+
jid=jid,
|
|
72
|
+
name=data.get("name", "Unknown"),
|
|
73
|
+
role=ContactRole(data.get("role", "unknown")),
|
|
74
|
+
tone=ContactTone(data.get("tone", "neutral")),
|
|
75
|
+
allow_proactive=data.get("allow_proactive", False),
|
|
76
|
+
cooldown_override=data.get("cooldown_override"),
|
|
77
|
+
imessage_id=data.get("imessage_id"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class GroupConfig:
|
|
83
|
+
"""Configuration for a group chat."""
|
|
84
|
+
|
|
85
|
+
jid: str
|
|
86
|
+
name: str
|
|
87
|
+
category: GroupCategory
|
|
88
|
+
reply_policy: ReplyPolicy
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, jid: str, data: dict) -> "GroupConfig":
|
|
92
|
+
"""Create a GroupConfig from config dict."""
|
|
93
|
+
return cls(
|
|
94
|
+
jid=jid,
|
|
95
|
+
name=data.get("name", "Unknown Group"),
|
|
96
|
+
category=GroupCategory(data.get("category", "unknown")),
|
|
97
|
+
reply_policy=ReplyPolicy(data.get("reply_policy", "selective")),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ContactDefaults:
|
|
103
|
+
"""Default values for unknown contacts."""
|
|
104
|
+
|
|
105
|
+
role: ContactRole = ContactRole.UNKNOWN
|
|
106
|
+
tone: ContactTone = ContactTone.NEUTRAL
|
|
107
|
+
allow_proactive: bool = False
|
|
108
|
+
cooldown_override: int | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class GroupDefaults:
|
|
113
|
+
"""Default values for unknown groups."""
|
|
114
|
+
|
|
115
|
+
category: GroupCategory = GroupCategory.UNKNOWN
|
|
116
|
+
reply_policy: ReplyPolicy = ReplyPolicy.SELECTIVE
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ContactRegistry:
|
|
120
|
+
"""Registry for resolving contact JIDs to profiles."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, config_path: Path | None = None, auto_reload: bool = True):
|
|
123
|
+
self._contacts: dict[str, ContactProfile] = {}
|
|
124
|
+
self._imessage_lookup: dict[str, str] = {} # iMessage ID -> primary JID
|
|
125
|
+
self._defaults = ContactDefaults()
|
|
126
|
+
self._config_path = config_path
|
|
127
|
+
self._last_modified: float = 0
|
|
128
|
+
self._watcher_thread: threading.Thread | None = None
|
|
129
|
+
self._stop_watcher = threading.Event()
|
|
130
|
+
|
|
131
|
+
if config_path and config_path.exists():
|
|
132
|
+
self._load_config(config_path)
|
|
133
|
+
if auto_reload:
|
|
134
|
+
self._start_watcher()
|
|
135
|
+
|
|
136
|
+
def _load_config(self, config_path: Path) -> None:
|
|
137
|
+
"""Load contact configuration from YAML file."""
|
|
138
|
+
try:
|
|
139
|
+
with open(config_path) as f:
|
|
140
|
+
config = yaml.safe_load(f) or {}
|
|
141
|
+
|
|
142
|
+
# Load contacts
|
|
143
|
+
contacts_data = config.get("contacts", {})
|
|
144
|
+
for jid, data in contacts_data.items():
|
|
145
|
+
try:
|
|
146
|
+
profile = ContactProfile.from_dict(jid, data)
|
|
147
|
+
self._contacts[jid] = profile
|
|
148
|
+
logger.debug(f"Loaded contact: {profile.name} ({jid})")
|
|
149
|
+
|
|
150
|
+
# Build iMessage lookup table
|
|
151
|
+
if profile.imessage_id:
|
|
152
|
+
imessage_key = f"imessage:{profile.imessage_id}"
|
|
153
|
+
self._imessage_lookup[imessage_key] = jid
|
|
154
|
+
logger.debug(f"Linked iMessage {profile.imessage_id} to {jid}")
|
|
155
|
+
except (ValueError, KeyError) as e:
|
|
156
|
+
logger.warning(f"Invalid contact config for {jid}: {e}")
|
|
157
|
+
|
|
158
|
+
# Load defaults
|
|
159
|
+
defaults_data = config.get("defaults", {})
|
|
160
|
+
if defaults_data:
|
|
161
|
+
self._defaults = ContactDefaults(
|
|
162
|
+
role=ContactRole(defaults_data.get("role", "unknown")),
|
|
163
|
+
tone=ContactTone(defaults_data.get("tone", "neutral")),
|
|
164
|
+
allow_proactive=defaults_data.get("allow_proactive", False),
|
|
165
|
+
cooldown_override=defaults_data.get("cooldown_override"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self._last_modified = os.path.getmtime(config_path)
|
|
169
|
+
logger.info(f"Loaded {len(self._contacts)} contacts from {config_path}")
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.error(f"Failed to load contacts config: {e}")
|
|
173
|
+
|
|
174
|
+
def _start_watcher(self) -> None:
|
|
175
|
+
"""Start background thread to watch for config changes."""
|
|
176
|
+
if self._watcher_thread is not None:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
def watch():
|
|
180
|
+
while not self._stop_watcher.is_set():
|
|
181
|
+
try:
|
|
182
|
+
if self._config_path and self._config_path.exists():
|
|
183
|
+
mtime = os.path.getmtime(self._config_path)
|
|
184
|
+
if mtime > self._last_modified:
|
|
185
|
+
logger.info("Contacts config changed, reloading...")
|
|
186
|
+
self._contacts.clear()
|
|
187
|
+
self._imessage_lookup.clear()
|
|
188
|
+
self._load_config(self._config_path)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Error watching contacts config: {e}")
|
|
191
|
+
time.sleep(2) # Check every 2 seconds
|
|
192
|
+
|
|
193
|
+
self._watcher_thread = threading.Thread(target=watch, daemon=True)
|
|
194
|
+
self._watcher_thread.start()
|
|
195
|
+
logger.debug("Started contacts config watcher")
|
|
196
|
+
|
|
197
|
+
def stop_watcher(self) -> None:
|
|
198
|
+
"""Stop the config watcher thread."""
|
|
199
|
+
self._stop_watcher.set()
|
|
200
|
+
|
|
201
|
+
def resolve(self, jid: str) -> ContactProfile:
|
|
202
|
+
"""
|
|
203
|
+
Resolve a JID or iMessage identifier to a contact profile.
|
|
204
|
+
|
|
205
|
+
Supports:
|
|
206
|
+
- WhatsApp JIDs: "+14155551234@s.whatsapp.net"
|
|
207
|
+
- iMessage identifiers: "imessage:+14155551234" or "imessage:user@icloud.com"
|
|
208
|
+
|
|
209
|
+
Returns the configured profile if known, or a default profile if unknown.
|
|
210
|
+
"""
|
|
211
|
+
# Direct lookup
|
|
212
|
+
if jid in self._contacts:
|
|
213
|
+
return self._contacts[jid]
|
|
214
|
+
|
|
215
|
+
# Try iMessage lookup (maps iMessage ID to WhatsApp contact)
|
|
216
|
+
if jid in self._imessage_lookup:
|
|
217
|
+
primary_jid = self._imessage_lookup[jid]
|
|
218
|
+
return self._contacts[primary_jid]
|
|
219
|
+
|
|
220
|
+
# For iMessage identifiers without prefix, try with prefix
|
|
221
|
+
if not jid.startswith("imessage:") and "@" not in jid:
|
|
222
|
+
imessage_key = f"imessage:{jid}"
|
|
223
|
+
if imessage_key in self._contacts:
|
|
224
|
+
return self._contacts[imessage_key]
|
|
225
|
+
if imessage_key in self._imessage_lookup:
|
|
226
|
+
primary_jid = self._imessage_lookup[imessage_key]
|
|
227
|
+
return self._contacts[primary_jid]
|
|
228
|
+
|
|
229
|
+
# Return default profile for unknown contacts
|
|
230
|
+
return ContactProfile(
|
|
231
|
+
jid=jid,
|
|
232
|
+
name="Unknown",
|
|
233
|
+
role=self._defaults.role,
|
|
234
|
+
tone=self._defaults.tone,
|
|
235
|
+
allow_proactive=self._defaults.allow_proactive,
|
|
236
|
+
cooldown_override=self._defaults.cooldown_override,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def is_known(self, jid: str) -> bool:
|
|
240
|
+
"""Check if a contact is in the registry."""
|
|
241
|
+
return jid in self._contacts
|
|
242
|
+
|
|
243
|
+
def get_all_contacts(self) -> list[ContactProfile]:
|
|
244
|
+
"""Get all registered contacts."""
|
|
245
|
+
return list(self._contacts.values())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class GroupRegistry:
|
|
249
|
+
"""Registry for resolving group JIDs to configurations."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, config_path: Path | None = None, auto_reload: bool = True):
|
|
252
|
+
self._groups: dict[str, GroupConfig] = {}
|
|
253
|
+
self._defaults = GroupDefaults()
|
|
254
|
+
self._config_path = config_path
|
|
255
|
+
self._last_modified: float = 0
|
|
256
|
+
self._watcher_thread: threading.Thread | None = None
|
|
257
|
+
self._stop_watcher = threading.Event()
|
|
258
|
+
|
|
259
|
+
if config_path and config_path.exists():
|
|
260
|
+
self._load_config(config_path)
|
|
261
|
+
if auto_reload:
|
|
262
|
+
self._start_watcher()
|
|
263
|
+
|
|
264
|
+
def _load_config(self, config_path: Path) -> None:
|
|
265
|
+
"""Load group configuration from YAML file."""
|
|
266
|
+
try:
|
|
267
|
+
with open(config_path) as f:
|
|
268
|
+
config = yaml.safe_load(f) or {}
|
|
269
|
+
|
|
270
|
+
# Load groups
|
|
271
|
+
groups_data = config.get("groups", {})
|
|
272
|
+
for jid, data in groups_data.items():
|
|
273
|
+
try:
|
|
274
|
+
group_config = GroupConfig.from_dict(jid, data)
|
|
275
|
+
self._groups[jid] = group_config
|
|
276
|
+
logger.debug(f"Loaded group: {group_config.name} ({jid})")
|
|
277
|
+
except (ValueError, KeyError) as e:
|
|
278
|
+
logger.warning(f"Invalid group config for {jid}: {e}")
|
|
279
|
+
|
|
280
|
+
# Load defaults
|
|
281
|
+
defaults_data = config.get("defaults", {})
|
|
282
|
+
if defaults_data:
|
|
283
|
+
self._defaults = GroupDefaults(
|
|
284
|
+
category=GroupCategory(defaults_data.get("category", "unknown")),
|
|
285
|
+
reply_policy=ReplyPolicy(defaults_data.get("reply_policy", "selective")),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
self._last_modified = os.path.getmtime(config_path)
|
|
289
|
+
logger.info(f"Loaded {len(self._groups)} groups from {config_path}")
|
|
290
|
+
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Failed to load groups config: {e}")
|
|
293
|
+
|
|
294
|
+
def _start_watcher(self) -> None:
|
|
295
|
+
"""Start background thread to watch for config changes."""
|
|
296
|
+
if self._watcher_thread is not None:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
def watch():
|
|
300
|
+
while not self._stop_watcher.is_set():
|
|
301
|
+
try:
|
|
302
|
+
if self._config_path and self._config_path.exists():
|
|
303
|
+
mtime = os.path.getmtime(self._config_path)
|
|
304
|
+
if mtime > self._last_modified:
|
|
305
|
+
logger.info("Groups config changed, reloading...")
|
|
306
|
+
self._groups.clear()
|
|
307
|
+
self._load_config(self._config_path)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error(f"Error watching groups config: {e}")
|
|
310
|
+
time.sleep(2) # Check every 2 seconds
|
|
311
|
+
|
|
312
|
+
self._watcher_thread = threading.Thread(target=watch, daemon=True)
|
|
313
|
+
self._watcher_thread.start()
|
|
314
|
+
logger.debug("Started groups config watcher")
|
|
315
|
+
|
|
316
|
+
def stop_watcher(self) -> None:
|
|
317
|
+
"""Stop the config watcher thread."""
|
|
318
|
+
self._stop_watcher.set()
|
|
319
|
+
|
|
320
|
+
def resolve(self, jid: str) -> GroupConfig:
|
|
321
|
+
"""
|
|
322
|
+
Resolve a group JID to its configuration.
|
|
323
|
+
|
|
324
|
+
Returns the configured settings if known, or defaults if unknown.
|
|
325
|
+
"""
|
|
326
|
+
if jid in self._groups:
|
|
327
|
+
return self._groups[jid]
|
|
328
|
+
|
|
329
|
+
# Return default config for unknown groups
|
|
330
|
+
return GroupConfig(
|
|
331
|
+
jid=jid,
|
|
332
|
+
name="Unknown Group",
|
|
333
|
+
category=self._defaults.category,
|
|
334
|
+
reply_policy=self._defaults.reply_policy,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def is_known(self, jid: str) -> bool:
|
|
338
|
+
"""Check if a group is in the registry."""
|
|
339
|
+
return jid in self._groups
|
|
340
|
+
|
|
341
|
+
def get_all_groups(self) -> list[GroupConfig]:
|
|
342
|
+
"""Get all registered groups."""
|
|
343
|
+
return list(self._groups.values())
|