agentirc 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.
agentirc/history.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class HistoryStore:
12
+ """In-memory history per room and user with system prompt support.
13
+
14
+ Optionally persists to an encrypted file when *store_path* and
15
+ *encryption_key* are both provided.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ prompt_prefix: str = "you are ",
21
+ prompt_suffix: str = ".",
22
+ personality: str = "",
23
+ *,
24
+ prompt_suffix_extra: str = "",
25
+ max_items: int = 24,
26
+ history_size: Optional[int] = None,
27
+ system_prompt: Optional[str] = None,
28
+ store_path: Optional[str] = None,
29
+ encryption_key: Optional[str] = None,
30
+ ) -> None:
31
+ if system_prompt is not None:
32
+ self.prompt_prefix = ""
33
+ self.prompt_suffix = ""
34
+ self.prompt_suffix_extra = ""
35
+ self.personality = ""
36
+ self._fixed_system_prompt = system_prompt
37
+ else:
38
+ self.prompt_prefix = prompt_prefix
39
+ self.prompt_suffix = prompt_suffix
40
+ self.prompt_suffix_extra = prompt_suffix_extra
41
+ self.personality = personality
42
+ self._fixed_system_prompt = None
43
+ self.max_items = history_size or max_items
44
+ self._include_extra = True
45
+ self._messages: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
46
+ self._locations: Dict[str, str] = {}
47
+ self.user_models: Dict[str, Dict[str, str]] = {}
48
+
49
+ # Encrypted persistence setup
50
+ self._fernet = None
51
+ self._store_file: Optional[Path] = None
52
+ if store_path and encryption_key:
53
+ try:
54
+ from cryptography.fernet import Fernet
55
+ self._fernet = Fernet(encryption_key.encode() if isinstance(encryption_key, str) else encryption_key)
56
+ self._store_file = Path(store_path) / "history.enc"
57
+ self._store_file.parent.mkdir(parents=True, exist_ok=True)
58
+ self._load()
59
+ except Exception:
60
+ log.exception("Failed to initialize encrypted persistence")
61
+ self._fernet = None
62
+ self._store_file = None
63
+
64
+ @property
65
+ def messages(self) -> Dict[str, Dict[str, List[Dict[str, str]]]]:
66
+ return self._messages
67
+
68
+ def set_verbose(self, verbose: bool) -> None:
69
+ self._include_extra = not bool(verbose)
70
+
71
+ def _full_suffix(self) -> str:
72
+ extra = self.prompt_suffix_extra if self._include_extra and self.prompt_suffix_extra else ""
73
+ return f"{self.prompt_suffix}{extra}"
74
+
75
+ def _location_suffix(self, user: str) -> str:
76
+ loc = self._locations.get(user, "")
77
+ if loc:
78
+ return f" The user has indicated they are located in {loc}. Use when needed, Do not adopt this as part of your personality."
79
+ return ""
80
+
81
+ def _system_for(self, room: str, user: str) -> str:
82
+ del room
83
+ if self._fixed_system_prompt is not None:
84
+ return self._fixed_system_prompt + self._location_suffix(user)
85
+ return f"{self.prompt_prefix}{self.personality}{self._full_suffix()}{self._location_suffix(user)}"
86
+
87
+ def _ensure(self, room: str, user: str) -> None:
88
+ if room not in self._messages:
89
+ self._messages[room] = {}
90
+ if user not in self._messages[room]:
91
+ self._messages[room][user] = [{"role": "system", "content": self._system_for(room, user)}]
92
+
93
+ def init_prompt(
94
+ self,
95
+ room: str,
96
+ user: str,
97
+ persona: Optional[str] = None,
98
+ custom: Optional[str] = None,
99
+ ) -> None:
100
+ self._ensure(room, user)
101
+ loc_suffix = self._location_suffix(user)
102
+ if custom:
103
+ self._messages[room][user] = [{"role": "system", "content": custom + loc_suffix}]
104
+ else:
105
+ p = persona if (persona is not None and persona != "") else self.personality
106
+ self._messages[room][user] = [
107
+ {"role": "system", "content": f"{self.prompt_prefix}{p}{self._full_suffix()}{loc_suffix}"}
108
+ ]
109
+ self._save()
110
+
111
+ def add(self, room: str, user: str, role: str, content: str) -> None:
112
+ self._ensure(room, user)
113
+ self._messages[room][user].append({"role": role, "content": content})
114
+ self._trim(room, user)
115
+ self._save()
116
+
117
+ def get(self, room: str, user: str) -> List[Dict[str, str]]:
118
+ self._ensure(room, user)
119
+ return list(self._messages[room][user])
120
+
121
+ def reset(self, room: str, user: str, stock: bool = False) -> None:
122
+ if room not in self._messages:
123
+ self._messages[room] = {}
124
+ self._messages[room][user] = []
125
+ if not stock:
126
+ self.init_prompt(room, user, persona=self.personality)
127
+ self._save()
128
+
129
+ def clear(self, room: str, user: str) -> None:
130
+ self.reset(room, user, stock=True)
131
+
132
+ def clear_all(self) -> None:
133
+ self._messages.clear()
134
+ # Locations are user preferences, not conversation state — preserve them
135
+ self._save()
136
+
137
+ def set_location(self, user: str, location: str) -> None:
138
+ """Set or clear a user's location. Updates system prompts in all existing threads."""
139
+ old_suffix = self._location_suffix(user)
140
+ if location:
141
+ self._locations[user] = location
142
+ else:
143
+ self._locations.pop(user, None)
144
+ new_suffix = self._location_suffix(user)
145
+
146
+ # Update system prompts in all existing threads for this user
147
+ for room in self._messages:
148
+ if user in self._messages[room]:
149
+ msgs = self._messages[room][user]
150
+ if msgs and msgs[0].get("role") == "system":
151
+ content = msgs[0]["content"]
152
+ if old_suffix:
153
+ content = content.replace(old_suffix, "")
154
+ content = content + new_suffix
155
+ msgs[0]["content"] = content
156
+ self._save()
157
+
158
+ def get_location(self, user: str) -> Optional[str]:
159
+ return self._locations.get(user) or None
160
+
161
+ def _trim(self, room: str, user: str) -> None:
162
+ msgs = self._messages[room][user]
163
+ while len(msgs) > self.max_items:
164
+ if msgs and msgs[0].get("role") == "system":
165
+ if len(msgs) > 1:
166
+ msgs.pop(1)
167
+ else:
168
+ break
169
+ else:
170
+ msgs.pop(0)
171
+
172
+ def _save(self) -> None:
173
+ if not self._fernet or not self._store_file:
174
+ return
175
+ try:
176
+ data = json.dumps({
177
+ "messages": self._messages,
178
+ "locations": self._locations,
179
+ })
180
+ encrypted = self._fernet.encrypt(data.encode())
181
+ self._store_file.write_bytes(encrypted)
182
+ except Exception:
183
+ log.exception("Failed to save encrypted history")
184
+
185
+ def _load(self) -> None:
186
+ if not self._fernet or not self._store_file or not self._store_file.exists():
187
+ return
188
+ try:
189
+ encrypted = self._store_file.read_bytes()
190
+ decrypted = self._fernet.decrypt(encrypted)
191
+ data = json.loads(decrypted.decode())
192
+ if isinstance(data, dict):
193
+ if "messages" in data:
194
+ self._messages = data["messages"]
195
+ self._locations = data.get("locations", {})
196
+ else:
197
+ # Old format: bare messages dict
198
+ self._messages = data
199
+ except Exception:
200
+ log.exception("Failed to load encrypted history (wrong key?), starting fresh")
201
+ self._messages = {}
202
+ self._locations = {}
agentirc/models.py ADDED
@@ -0,0 +1,156 @@
1
+ """Model discovery and provider resolution helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from urllib.error import URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ KNOWN_PROVIDERS = ("openai", "xai", "lmstudio")
14
+
15
+
16
+ def _models_url(api_base: str) -> str:
17
+ base = api_base.rstrip("/")
18
+ return f"{base}/models" if base.endswith("/v1") else f"{base}/v1/models"
19
+
20
+
21
+ def _is_chat_model(provider: str, model_id: str) -> bool:
22
+ lowered = model_id.lower()
23
+ if provider == "lmstudio":
24
+ blocked_models = {
25
+ "text-embedding-nomic-embed-text-v1.5",
26
+ }
27
+ if lowered in blocked_models:
28
+ return False
29
+ return bool(model_id.strip())
30
+
31
+ if provider == "xai":
32
+ if not lowered.startswith("grok-"):
33
+ return False
34
+ blocked_fragments = ("imagine", "image", "video", "voice", "vision")
35
+ return not any(fragment in lowered for fragment in blocked_fragments)
36
+
37
+ prefixes = ("gpt-", "o1", "o3", "o4")
38
+ if not model_id.startswith(prefixes):
39
+ return False
40
+
41
+ blocked_fragments = (
42
+ "preview",
43
+ "audio",
44
+ "computer-use",
45
+ "transcribe",
46
+ "tts",
47
+ "image",
48
+ )
49
+ if any(fragment in lowered for fragment in blocked_fragments):
50
+ return False
51
+
52
+ if re.search(r"-\d{4}-\d{2}-\d{2}$", lowered):
53
+ return False
54
+
55
+ return True
56
+
57
+
58
+ def provider_for_model(model: str, models: dict[str, list[str]]) -> str | None:
59
+ selected = str(model or "").strip()
60
+ if not selected:
61
+ return None
62
+
63
+ for provider, provider_models in models.items():
64
+ if selected in provider_models:
65
+ return provider
66
+
67
+ lowered = selected.lower()
68
+ if lowered.startswith("grok-"):
69
+ return "xai"
70
+ if lowered.startswith(("gpt-", "o1", "o3", "o4")):
71
+ return "openai"
72
+ return None
73
+
74
+
75
+ def fetch_models(api_base: str, api_key: str = "", provider: str = "openai") -> list[str]:
76
+ """GET models and return filtered chat-capable model IDs."""
77
+ url = _models_url(api_base)
78
+ req = Request(url, method="GET")
79
+ if api_key:
80
+ req.add_header("Authorization", f"Bearer {api_key}")
81
+
82
+ try:
83
+ with urlopen(req, timeout=30) as resp:
84
+ body = json.loads(resp.read())
85
+ except (URLError, OSError, json.JSONDecodeError) as exc:
86
+ log.error("Failed to fetch models from %s: %s", url, exc)
87
+ return []
88
+
89
+ models = [m["id"] for m in body.get("data", []) if isinstance(m, dict) and "id" in m]
90
+ models = [m for m in models if _is_chat_model(provider, m)]
91
+ models.sort()
92
+ return models
93
+
94
+
95
+ def refresh_model_catalog(
96
+ configured_models: dict[str, list[str]],
97
+ *,
98
+ base_urls: dict[str, str],
99
+ api_keys: dict[str, str],
100
+ server_models: bool = True,
101
+ ) -> dict[str, list[str]]:
102
+ """Build a provider->models map from config and optional server discovery."""
103
+ merged: dict[str, list[str]] = {
104
+ provider: sorted(dict.fromkeys(configured_models.get(provider, [])))
105
+ for provider in KNOWN_PROVIDERS
106
+ }
107
+
108
+ if not server_models:
109
+ return merged
110
+
111
+ for provider in KNOWN_PROVIDERS:
112
+ api_base = str(base_urls.get(provider, "") or "").strip()
113
+ if not api_base:
114
+ continue
115
+
116
+ api_key = str(api_keys.get(provider, "") or "").strip()
117
+
118
+ fetched = fetch_models(api_base, api_key, provider=provider)
119
+ if not fetched:
120
+ continue
121
+
122
+ merged[provider] = sorted(dict.fromkeys([*merged[provider], *fetched]))
123
+
124
+ return merged
125
+
126
+
127
+ def pick_default_model(models: dict[str, list[str]], preferred: str = "") -> str:
128
+ """Select a default model from preferred value or the catalog."""
129
+ preferred = preferred.strip()
130
+ if preferred:
131
+ return preferred
132
+
133
+ for provider in KNOWN_PROVIDERS:
134
+ items = models.get(provider, [])
135
+ if items:
136
+ return items[0]
137
+ raise RuntimeError("No models available from configured providers")
138
+
139
+
140
+ def pick_model(api_base: str, api_key: str = "", preferred: str = "", provider: str = "openai") -> str:
141
+ """Legacy helper: pick a model from a single provider endpoint."""
142
+ available = fetch_models(api_base, api_key, provider=provider)
143
+
144
+ if available:
145
+ log.debug("Available models: %s", ", ".join(available))
146
+ if preferred and preferred in available:
147
+ return preferred
148
+ if preferred:
149
+ log.warning("Preferred model %r not found, using %s", preferred, available[0])
150
+ return available[0]
151
+
152
+ if preferred:
153
+ log.warning("Could not fetch models from %s, using preferred model %r", api_base, preferred)
154
+ return preferred
155
+
156
+ raise RuntimeError(f"No models available from {api_base}")
agentirc/tools.py ADDED
@@ -0,0 +1,81 @@
1
+ """Tool definitions for OpenAI-compatible Responses APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ XAI_HOSTED_TOOL_TYPES = {"web_search", "x_search", "code_interpreter", "mcp"}
8
+
9
+
10
+ def build_tools(
11
+ enabled: list[str],
12
+ provider: str = "openai",
13
+ *,
14
+ web_search_country: str = "",
15
+ ) -> list[dict[str, Any]]:
16
+ """Build the tools list for the Responses API request.
17
+
18
+ Supported tools:
19
+ - web_search (openai, xai)
20
+ - x_search (xai)
21
+ - code_interpreter (openai, xai)
22
+ """
23
+ tool_builders: dict[str, tuple[set[str], Any]] = {
24
+ "web_search": ({"openai", "xai"}, _web_search_tool),
25
+ "x_search": ({"xai"}, _x_search_tool),
26
+ "code_interpreter": ({"openai", "xai"}, _code_interpreter_tool),
27
+ }
28
+
29
+ tools = []
30
+ for name in enabled:
31
+ spec = tool_builders.get(name)
32
+ if not spec:
33
+ continue
34
+ providers, builder = spec
35
+ if provider in providers:
36
+ if name == "web_search":
37
+ tools.append(builder(provider, country=web_search_country))
38
+ else:
39
+ tools.append(builder(provider))
40
+ return tools
41
+
42
+
43
+ def _web_search_tool(_provider: str, *, country: str = "") -> dict[str, Any]:
44
+ tool: dict[str, Any] = {"type": "web_search"}
45
+ if country:
46
+ tool["user_location"] = {"type": "approximate", "country": country}
47
+ return tool
48
+
49
+
50
+ def strip_search_country(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
51
+ """Remove user_location from web_search tool dicts."""
52
+ result = []
53
+ for tool in tools:
54
+ if isinstance(tool, dict) and tool.get("type") == "web_search" and "user_location" in tool:
55
+ tool = {k: v for k, v in tool.items() if k != "user_location"}
56
+ result.append(tool)
57
+ return result
58
+
59
+
60
+ def _x_search_tool(_provider: str) -> dict[str, Any]:
61
+ return {"type": "x_search"}
62
+
63
+
64
+ def _code_interpreter_tool(provider: str) -> dict[str, Any]:
65
+ tool = {"type": "code_interpreter"}
66
+ if provider == "openai":
67
+ tool["container"] = {"type": "auto"}
68
+ return tool
69
+
70
+
71
+ def xai_model_supports_hosted_tools(model: str) -> bool:
72
+ lowered = str(model or "").strip().lower()
73
+ return lowered.startswith("grok-4")
74
+
75
+
76
+ def tools_for_model(enabled: list[str], provider: str, model: str) -> list[str]:
77
+ if provider != "xai":
78
+ return list(enabled)
79
+ if xai_model_supports_hosted_tools(model):
80
+ return list(enabled)
81
+ return [name for name in enabled if name not in XAI_HOSTED_TOOL_TYPES]
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentirc
3
+ Version: 1.0.0
4
+ Summary: AI-powered IRC agent with multi-provider LLM support
5
+ Project-URL: Homepage, https://github.com/h1ddenpr0cess20/agentirc
6
+ Project-URL: Repository, https://github.com/h1ddenpr0cess20/agentirc
7
+ Project-URL: Issues, https://github.com/h1ddenpr0cess20/agentirc/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,ai,bot,chatbot,irc,llm,ollama,openai
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Chat
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.27
18
+ Provides-Extra: crypto
19
+ Requires-Dist: cryptography>=43; extra == 'crypto'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # agentirc
23
+
24
+ An AI-powered IRC agent built on a minimal async IRC bot framework. Supports multiple LLM providers (OpenAI, xAI, LM Studio) with per-user conversation history, tool use, and encrypted persistence.
25
+
26
+ ## Table of Contents
27
+
28
+ - [Quick Start](#quick-start)
29
+ - [CLI Options](#cli-options)
30
+ - [Commands](#commands)
31
+ - [Documentation](#documentation)
32
+ - [License](#license)
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ git clone <repo-url>
38
+ cd agentirc
39
+ cp .env.example .env
40
+ # Edit .env with your IRC server, nick, and at least one API provider
41
+ pip install .
42
+ agentirc
43
+ ```
44
+
45
+ Or with Docker:
46
+
47
+ ```bash
48
+ cp .env.example .env
49
+ # Edit .env
50
+ docker compose up -d
51
+ ```
52
+
53
+ > **Requirements:** Python 3.10+, `httpx`, and optionally `cryptography` for encrypted history persistence.
54
+
55
+ ## CLI Options
56
+
57
+ ```
58
+ agentirc [options]
59
+ ```
60
+
61
+ | Flag | Description |
62
+ |---|---|
63
+ | `--env-file PATH` | Path to .env file (default: `.env`) |
64
+ | `--debug` | Enable debug logging |
65
+ | `--host HOST` | IRC server hostname (overrides `IRC_HOST`) |
66
+ | `--port PORT` | IRC server port (overrides `IRC_PORT`) |
67
+ | `--nick NICK` | Bot nickname (overrides `IRC_NICK`) |
68
+ | `--channels CHANS` | Comma-separated channels (overrides `IRC_CHANNELS`) |
69
+ | `--tls` | Connect with TLS (overrides `IRC_USE_TLS`) |
70
+ | `--model MODEL` | Default model (overrides `DEFAULT_MODEL`) |
71
+ | `--generate-key` | Generate a Fernet encryption key and exit |
72
+
73
+ CLI flags override their corresponding environment variables.
74
+
75
+ ## Commands
76
+
77
+ ### User Commands
78
+
79
+ | Command | Aliases | Description |
80
+ |---|---|---|
81
+ | `!ai <message>` | `!chat`, `!ask` | Talk to the AI |
82
+ | `!persona <text>` | | Set a persona and reintroduce |
83
+ | `!custom <prompt>` | | Set a custom system prompt |
84
+ | `!reset` | | Reset conversation to defaults |
85
+ | `!stock` | | Reset conversation with no system prompt |
86
+ | `!mymodel [name]` | | Show or set your model |
87
+ | `!location <place>` | | Set your location for contextual answers |
88
+ | `!x <nick> <message>` | | Talk as another user |
89
+
90
+ ### Admin Commands
91
+
92
+ | Command | Description |
93
+ |---|---|
94
+ | `!model [name\|reset]` | Show/set global default model |
95
+ | `!tools [on\|off\|toggle\|status]` | Enable/disable tool use |
96
+ | `!verbose [on\|off\|toggle]` | Toggle verbose mode |
97
+ | `!clear` | Clear all conversation state |
98
+ | `!country [on\|off\|status]` | Toggle search country filtering |
99
+ | `!join <#channel>` | Join a channel |
100
+ | `!part [#channel] [reason]` | Leave a channel |
101
+
102
+ Built-in IRC commands (`!ping`, `!time`, `!help`) are also available.
103
+
104
+ ## Documentation
105
+
106
+ - [docs/configuration.md](docs/configuration.md) -- IRC and AI provider configuration
107
+ - [docs/commands.md](docs/commands.md) -- command registry and decorator API
108
+ - [docs/extending.md](docs/extending.md) -- subclassing IRCBot, event hooks
109
+ - [docs/ai-agent.md](docs/ai-agent.md) -- AI agent architecture, providers, tools, and history
110
+ - [docs/ai-output-disclaimer.md](docs/ai-output-disclaimer.md) -- AI output disclaimer and conditions of use
111
+ - [docs/not-a-companion.md](docs/not-a-companion.md) -- project scope and intended use
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,20 @@
1
+ agentirc/__init__.py,sha256=vS8FZZv7cTGYip0sDOB5R3AKQ7n9YFed-wpMhpoX2tI,184
2
+ agentirc/__main__.py,sha256=_55sP49N9gEe5frnCdRP8XFl69MeGPvLmqUIPvo1LYQ,2519
3
+ agentirc/api.py,sha256=R8vDxuNZDw57FE1F78Ffuxit6v85JfiS2zAEQO9Iaic,13353
4
+ agentirc/bot.py,sha256=GqRw6HmT-ldxcyJQI5I1znI7kx6ObVvE2agUuJ6N4Sg,18636
5
+ agentirc/config.py,sha256=h1gH2GFTk9l0XCIW36aEnoWcKBIgGDbsZIUzNDH-7UE,4853
6
+ agentirc/history.py,sha256=GVAU6HtINVw4plS5QCDUIWC3R4IBw1rOMS5-_T8n8yo,7712
7
+ agentirc/models.py,sha256=tOo-CgwxjG2hVrliEZF-c974sHuKQNw1bQSLJXvylP8,4749
8
+ agentirc/tools.py,sha256=tX7z0dCDJNlxOHMtKsoKZZNIPm_9s--m-mCRZYzIq0g,2513
9
+ ircbot/__init__.py,sha256=jOY4I9BbKkn3jxao1ntiY_0q8D60jQT4gb9mGY4r95s,317
10
+ ircbot/__main__.py,sha256=I_VQkjDLvL4w5lFrMUAdfL94mK86vwNjHwE2PGy9EhU,1011
11
+ ircbot/bot.py,sha256=KQhANH06TkSBetOthlOiT9Y0fuaOzGCgg2oI7ZKANCY,9265
12
+ ircbot/commands.py,sha256=nyPmFgJceRuZKNKP7bD45DcwiLRlPY7QNWGD5wJnqCI,1789
13
+ ircbot/config.py,sha256=4GL5EXjHJlMd9yQX-yAK2vBJwAuab-7X-R8hsrn9cx8,2078
14
+ ircbot/connection.py,sha256=yMvpALcTxAySWiSEMOGB8jOdHxq3O5EN9_2rOZFBtRA,4066
15
+ ircbot/protocol.py,sha256=_ytYwsLS56FHVbXxn1HsO5-I9yFtsykbSSo7bztJxqw,2725
16
+ agentirc-1.0.0.dist-info/METADATA,sha256=WxIezdIRn87Fg2h_cCwvoVm64O9jUdRsLjF7VXslfnQ,3862
17
+ agentirc-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
18
+ agentirc-1.0.0.dist-info/entry_points.txt,sha256=4NGgML7IAdbX-kAWU-sbDnIGr6GBZmABuFQAcJXW2cA,52
19
+ agentirc-1.0.0.dist-info/licenses/LICENSE,sha256=Z7RUccYKYBtqgZ6na1vsF5vZO7NYP9EoD9Km9pGE_Xo,1069
20
+ agentirc-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentirc = agentirc.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dustin Whyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ircbot/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """ircbot -- async IRC bot framework, stdlib only."""
2
+
3
+ from .bot import IRCBot
4
+ from .config import BotConfig, load_env
5
+ from .protocol import IRCMessage, parse
6
+ from .commands import register_builtins
7
+
8
+ __all__ = [
9
+ "IRCBot",
10
+ "BotConfig",
11
+ "IRCMessage",
12
+ "load_env",
13
+ "parse",
14
+ "register_builtins",
15
+ ]
ircbot/__main__.py ADDED
@@ -0,0 +1,41 @@
1
+ """Entry point: python -m ircbot"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import sys
8
+
9
+ from .config import load_env, BotConfig
10
+ from .bot import IRCBot
11
+ from .commands import register_builtins
12
+
13
+
14
+ def setup_logging() -> None:
15
+ """Configure stdlib logging with a clean format."""
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
19
+ datefmt="%Y-%m-%d %H:%M:%S",
20
+ stream=sys.stderr,
21
+ )
22
+ # Quiet down the debug-level line logging unless DEBUG is set
23
+ logging.getLogger("ircbot.connection").setLevel(
24
+ logging.DEBUG if "--debug" in sys.argv else logging.INFO
25
+ )
26
+
27
+
28
+ async def main() -> None:
29
+ load_env()
30
+ config = BotConfig.from_env()
31
+ bot = IRCBot(config)
32
+ register_builtins(bot)
33
+ await bot.run()
34
+
35
+
36
+ if __name__ == "__main__":
37
+ setup_logging()
38
+ try:
39
+ asyncio.run(main())
40
+ except KeyboardInterrupt:
41
+ logging.getLogger(__name__).info("Shutting down.")