guzli-cli 0.2.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.
Files changed (53) hide show
  1. guzli/__init__.py +3 -0
  2. guzli/__main__.py +4 -0
  3. guzli/auth.py +182 -0
  4. guzli/cli.py +84 -0
  5. guzli/commands/__init__.py +1 -0
  6. guzli/commands/admin.py +421 -0
  7. guzli/commands/auth.py +502 -0
  8. guzli/commands/campaign_launch.py +188 -0
  9. guzli/commands/campaigns.py +571 -0
  10. guzli/commands/common.py +42 -0
  11. guzli/commands/conversations.py +404 -0
  12. guzli/commands/integrations.py +48 -0
  13. guzli/commands/telephony.py +274 -0
  14. guzli/commands/voice_calls.py +270 -0
  15. guzli/config.py +137 -0
  16. guzli/config_store.py +218 -0
  17. guzli/errors.py +47 -0
  18. guzli/http_client.py +213 -0
  19. guzli/mcp/__init__.py +1 -0
  20. guzli/mcp/server.py +320 -0
  21. guzli/models.py +173 -0
  22. guzli/oauth_callback.py +126 -0
  23. guzli/output.py +79 -0
  24. guzli/runtime.py +159 -0
  25. guzli/services/__init__.py +29 -0
  26. guzli/services/api_keys.py +105 -0
  27. guzli/services/calls.py +48 -0
  28. guzli/services/campaigns.py +193 -0
  29. guzli/services/cli_auth.py +103 -0
  30. guzli/services/conversations.py +112 -0
  31. guzli/services/integrations.py +18 -0
  32. guzli/services/replies.py +21 -0
  33. guzli/services/telephony.py +80 -0
  34. guzli/services/voice_profiles.py +56 -0
  35. guzli/services/webhooks.py +169 -0
  36. guzli/types.py +15 -0
  37. guzli/validators.py +83 -0
  38. guzli/workflows/__init__.py +13 -0
  39. guzli/workflows/campaign_launch.py +501 -0
  40. guzli_cli-0.2.0.data/data/share/guzli/CHANGELOG.md +16 -0
  41. guzli_cli-0.2.0.data/data/share/guzli/PROVENANCE.md +23 -0
  42. guzli_cli-0.2.0.data/data/share/guzli/SECURITY.md +18 -0
  43. guzli_cli-0.2.0.data/data/share/guzli/SECURITY_AUDIT.md +47 -0
  44. guzli_cli-0.2.0.data/data/share/guzli/SKILL.md +101 -0
  45. guzli_cli-0.2.0.data/data/share/guzli/examples/compliance-advisory-us.json +15 -0
  46. guzli_cli-0.2.0.data/data/share/guzli/examples/pizza-order-extraction.json +65 -0
  47. guzli_cli-0.2.0.data/data/share/guzli/references/api.md +245 -0
  48. guzli_cli-0.2.0.dist-info/METADATA +341 -0
  49. guzli_cli-0.2.0.dist-info/RECORD +53 -0
  50. guzli_cli-0.2.0.dist-info/WHEEL +5 -0
  51. guzli_cli-0.2.0.dist-info/entry_points.txt +3 -0
  52. guzli_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
  53. guzli_cli-0.2.0.dist-info/top_level.txt +1 -0
guzli/mcp/server.py ADDED
@@ -0,0 +1,320 @@
1
+ """Curated MCP tools over the same Guzli services used by the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from functools import lru_cache
8
+ from typing import Annotated, Any, Literal
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+ from mcp.types import ToolAnnotations
12
+ from pydantic import Field
13
+
14
+ from guzli.errors import ConfigError
15
+ from guzli.runtime import RuntimeContext, build_context_for_profile
16
+ from guzli.workflows import CampaignLaunchError, CampaignLaunchInputs, CampaignLaunchWorkflow
17
+
18
+ mcp = FastMCP(
19
+ "Guzli",
20
+ instructions=(
21
+ "Operate Guzli conversations and outbound calls. launch_calls can place real, "
22
+ "billable external calls; inspect number pools and voices before launching when needed."
23
+ ),
24
+ )
25
+
26
+
27
+ @lru_cache(maxsize=1)
28
+ def _context() -> RuntimeContext:
29
+ return build_context_for_profile(os.getenv("GUZLI_PROFILE"))
30
+
31
+
32
+ def _mapping(payload: Any) -> dict[str, Any]:
33
+ if isinstance(payload, dict):
34
+ return payload
35
+ if isinstance(payload, list):
36
+ return {"items": payload}
37
+ return {"value": payload}
38
+
39
+
40
+ def _scope(context: RuntimeContext) -> tuple[str, str]:
41
+ if not context.agent_id:
42
+ raise ConfigError("GUZLI_AGENT_ID or a profile agent_id is required")
43
+ if not context.organization_id:
44
+ raise ConfigError("GUZLI_ORGANIZATION_ID or a profile organization_id is required")
45
+ return context.agent_id, context.organization_id
46
+
47
+
48
+ @mcp.tool(
49
+ annotations=ToolAnnotations(
50
+ title="Launch outbound calls",
51
+ readOnlyHint=False,
52
+ destructiveHint=True,
53
+ idempotentHint=False,
54
+ openWorldHint=True,
55
+ ),
56
+ structured_output=True,
57
+ )
58
+ def launch_calls(
59
+ recipients: Annotated[
60
+ list[str],
61
+ Field(
62
+ min_length=1,
63
+ max_length=1000,
64
+ description="One or more recipient phone numbers in E.164 format.",
65
+ ),
66
+ ],
67
+ instructions: Annotated[
68
+ str | None,
69
+ Field(description="How the voice agent should conduct the call."),
70
+ ] = None,
71
+ message: Annotated[
72
+ str | None,
73
+ Field(description="Literal opening message spoken when the call connects."),
74
+ ] = None,
75
+ objective: Annotated[
76
+ str | None,
77
+ Field(description="Short runtime objective for the call."),
78
+ ] = None,
79
+ name: Annotated[
80
+ str | None,
81
+ Field(description="Campaign launch name; generated when omitted."),
82
+ ] = None,
83
+ number_pool_id: Annotated[
84
+ str | None,
85
+ Field(description="Existing active number pool ID; profile/default resolution if omitted."),
86
+ ] = None,
87
+ caller_phone: Annotated[
88
+ str | None,
89
+ Field(description="Find an existing pool containing this caller E.164 number."),
90
+ ] = None,
91
+ voice_profile_id: Annotated[
92
+ str | None,
93
+ Field(description="Existing voice profile ID; agent default if omitted."),
94
+ ] = None,
95
+ voice_id: Annotated[
96
+ str | None,
97
+ Field(description="Provider voice ID used to create a profile when explicitly supplied."),
98
+ ] = None,
99
+ post_call_extraction: Annotated[
100
+ dict[str, Any] | None,
101
+ Field(description="Optional CampaignPostCallExtractionSettings object."),
102
+ ] = None,
103
+ background_ambience: Annotated[
104
+ Literal["none", "people_in_lounge", "food_court", "restaurant", "crowd_talking"],
105
+ Field(description="Ambient soundbed; defaults to people_in_lounge."),
106
+ ] = "people_in_lounge",
107
+ ambience_volume: Annotated[
108
+ float,
109
+ Field(ge=0.0, le=1.0, description="Ambient soundbed volume."),
110
+ ] = 0.10,
111
+ activate: Annotated[
112
+ bool,
113
+ Field(description="Activate after creation. Defaults to true."),
114
+ ] = True,
115
+ run: Annotated[
116
+ bool,
117
+ Field(description="Queue calls immediately after activation. Defaults to true."),
118
+ ] = True,
119
+ dry_run: Annotated[
120
+ bool,
121
+ Field(description="Resolve and describe the launch without mutations."),
122
+ ] = False,
123
+ ) -> dict[str, Any]:
124
+ """Launch one or many real outbound calls using existing Guzli engine endpoints.
125
+
126
+ By default this creates a campaign, auto-configures/approves the intentional
127
+ advisory launch compliance profile, activates it, and queues billable external
128
+ calls. It never creates a number pool implicitly and is not atomically idempotent.
129
+ """
130
+ context = _context()
131
+ agent_id, organization_id = _scope(context)
132
+ workflow = CampaignLaunchWorkflow(
133
+ telephony=context.telephony,
134
+ voice_profiles=context.voice_profiles,
135
+ campaigns=context.campaigns,
136
+ )
137
+ try:
138
+ result = workflow.run(
139
+ CampaignLaunchInputs(
140
+ name=name or "",
141
+ agent_id=agent_id,
142
+ organization_id=organization_id,
143
+ targets=recipients,
144
+ number_pool_id=number_pool_id,
145
+ default_number_pool_id=context.default_number_pool_id,
146
+ caller_phone_e164=caller_phone,
147
+ voice_profile_id=voice_profile_id,
148
+ voice_id=voice_id,
149
+ call_instructions=instructions,
150
+ initial_message=message,
151
+ runtime_objective=objective,
152
+ post_call_extraction=post_call_extraction,
153
+ background_ambience=(
154
+ None
155
+ if background_ambience == "none"
156
+ else {"mode": background_ambience, "volume": ambience_volume}
157
+ ),
158
+ activate=activate,
159
+ run=run,
160
+ dry_run=dry_run,
161
+ )
162
+ )
163
+ except CampaignLaunchError as exc:
164
+ failure = exc.result.to_summary()
165
+ failure["error"] = {
166
+ "code": "campaign_launch_failed",
167
+ "message": str(exc),
168
+ "retryable": False,
169
+ }
170
+ return failure
171
+ return result.to_summary()
172
+
173
+
174
+ _READ_ONLY = ToolAnnotations(
175
+ readOnlyHint=True,
176
+ destructiveHint=False,
177
+ idempotentHint=True,
178
+ openWorldHint=True,
179
+ )
180
+
181
+
182
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
183
+ def list_number_pools(active_only: bool = True, limit: int = 50) -> dict[str, Any]:
184
+ """List number pools so an agent can choose a caller pool before launch."""
185
+ return _mapping(
186
+ _context().telephony.list_pools(
187
+ {
188
+ "is_active": "true" if active_only else None,
189
+ "limit": max(1, min(limit, 200)),
190
+ }
191
+ )
192
+ )
193
+
194
+
195
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
196
+ def get_number_pool(number_pool_id: str) -> dict[str, Any]:
197
+ """Get a number pool, including strategy and active members."""
198
+ return _mapping(_context().telephony.get_pool(number_pool_id))
199
+
200
+
201
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
202
+ def list_phone_numbers(
203
+ telephony_account_id: str | None = None,
204
+ limit: int = 50,
205
+ ) -> dict[str, Any]:
206
+ """List provisioned caller numbers that can be added to number pools."""
207
+ params: dict[str, object] = {"limit": max(1, min(limit, 200))}
208
+ if telephony_account_id:
209
+ params["telephony_account_id"] = telephony_account_id
210
+ return _mapping(_context().telephony.list_phone_numbers(params))
211
+
212
+
213
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
214
+ def list_voice_profiles(limit: int = 50) -> dict[str, Any]:
215
+ """List available voice profiles and discover the agent default."""
216
+ context = _context()
217
+ params: dict[str, object] = {"limit": max(1, min(limit, 200))}
218
+ if context.agent_id:
219
+ params["agent_id"] = context.agent_id
220
+ return _mapping(context.voice_profiles.list_profiles(params))
221
+
222
+
223
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
224
+ def get_campaign(campaign_id: str) -> dict[str, Any]:
225
+ """Get the backing campaign created by Campaign Launch."""
226
+ return _mapping(_context().campaigns.get_campaign(campaign_id))
227
+
228
+
229
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
230
+ def list_campaign_runs(campaign_id: str, limit: int = 50) -> dict[str, Any]:
231
+ """List execution runs for a campaign."""
232
+ return _mapping(_context().campaigns.list_runs(campaign_id, {"limit": limit}))
233
+
234
+
235
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
236
+ def list_calls(
237
+ campaign_id: str,
238
+ campaign_run_id: str | None = None,
239
+ limit: int = 50,
240
+ ) -> dict[str, Any]:
241
+ """List call attempts for a campaign, optionally restricted to a run."""
242
+ params: dict[str, object] = {"limit": max(1, min(limit, 200))}
243
+ if campaign_run_id:
244
+ params["campaign_run_id"] = campaign_run_id
245
+ return _mapping(_context().calls.list_attempts(campaign_id, params))
246
+
247
+
248
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
249
+ def get_call(campaign_id: str, attempt_id: str) -> dict[str, Any]:
250
+ """Get one call attempt and its current outcome."""
251
+ return _mapping(_context().calls.get_attempt(campaign_id, attempt_id))
252
+
253
+
254
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
255
+ def get_call_result(campaign_id: str, result_id: str) -> dict[str, Any]:
256
+ """Get one structured post-call extraction result."""
257
+ return _mapping(_context().campaigns.get_result(campaign_id, result_id))
258
+
259
+
260
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
261
+ def list_conversations(status: str | None = None, limit: int = 20) -> dict[str, Any]:
262
+ """List inbox conversations for the configured agent."""
263
+ params: dict[str, object] = {"limit": max(1, min(limit, 200))}
264
+ if status:
265
+ params["status"] = [status]
266
+ return _mapping(_context().conversations.list_conversations(params))
267
+
268
+
269
+ @mcp.tool(annotations=_READ_ONLY, structured_output=True)
270
+ def get_conversation(conversation_id: str, include_transcript: bool = True) -> dict[str, Any]:
271
+ """Get one inbox conversation and optionally its transcript."""
272
+ return _mapping(_context().conversations.get_conversation(conversation_id, include_transcript))
273
+
274
+
275
+ @mcp.tool(
276
+ annotations=ToolAnnotations(
277
+ title="Send agent reply",
278
+ readOnlyHint=False,
279
+ destructiveHint=True,
280
+ idempotentHint=False,
281
+ openWorldHint=True,
282
+ ),
283
+ structured_output=True,
284
+ )
285
+ def send_agent_reply(
286
+ conversation_id: str,
287
+ message: Annotated[str, Field(min_length=1, max_length=4000)],
288
+ ) -> dict[str, Any]:
289
+ """Send a real external reply in an inbox conversation."""
290
+ return _mapping(
291
+ _context().replies.send_agent_reply(
292
+ conversation_id,
293
+ {"message": message, "metadata": {"source": "guzli_mcp"}},
294
+ )
295
+ )
296
+
297
+
298
+ @mcp.resource("guzli://capabilities")
299
+ def capabilities() -> str:
300
+ """Return non-secret local configuration capabilities for diagnostics."""
301
+ context = _context()
302
+ client = context.campaigns.client
303
+ auth = client.auth_selector
304
+ payload = {
305
+ "base_url": client.base_url,
306
+ "has_api_key": auth.api_key_auth is not None,
307
+ "has_bearer": auth.bearer_auth is not None,
308
+ "has_agent_id": bool(context.agent_id),
309
+ "has_organization_id": bool(context.organization_id),
310
+ "default_number_pool_id": context.default_number_pool_id,
311
+ }
312
+ return json.dumps(payload, sort_keys=True)
313
+
314
+
315
+ def main() -> None:
316
+ mcp.run(transport="stdio")
317
+
318
+
319
+ if __name__ == "__main__":
320
+ main()
guzli/models.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import List, Mapping, Sequence
5
+
6
+ from guzli.types import JsonDict, JsonValue
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ConversationAssignmentView:
11
+ assignment_type: str
12
+ assigned_to_id: str | None
13
+ queue_label: str | None
14
+
15
+ @staticmethod
16
+ def from_json(payload: Mapping[str, JsonValue] | None) -> "ConversationAssignmentView":
17
+ if not isinstance(payload, Mapping):
18
+ return ConversationAssignmentView("unknown", None, None)
19
+ assignment_type = _get_str(payload.get("type")) or "unknown"
20
+ assigned_to_id = _get_str(payload.get("assigned_to_id"))
21
+ queue_label = _get_str(payload.get("queue_label"))
22
+ return ConversationAssignmentView(assignment_type, assigned_to_id, queue_label)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ConversationPreviewView:
27
+ role: str
28
+ content: str | None
29
+ timestamp: str | None
30
+
31
+ @staticmethod
32
+ def from_json(payload: Mapping[str, JsonValue] | None) -> "ConversationPreviewView | None":
33
+ if not isinstance(payload, Mapping):
34
+ return None
35
+ role = _get_str(payload.get("role")) or "user"
36
+ content = _get_str(payload.get("content"))
37
+ timestamp = _get_str(payload.get("timestamp"))
38
+ return ConversationPreviewView(role=role, content=content, timestamp=timestamp)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TranscriptMessage:
43
+ role: str
44
+ content: str | None
45
+ timestamp: str | None
46
+
47
+ @staticmethod
48
+ def from_json(payload: Mapping[str, JsonValue]) -> "TranscriptMessage":
49
+ role = _get_str(payload.get("role")) or "user"
50
+ content = _get_str(payload.get("content"))
51
+ timestamp = _get_str(payload.get("timestamp"))
52
+ return TranscriptMessage(role=role, content=content, timestamp=timestamp)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ConversationSummaryView:
57
+ conversation_id: str
58
+ status: str | None
59
+ priority: str | None
60
+ channel: str | None
61
+ last_message_at: str | None
62
+ message_count: int
63
+ assignment: ConversationAssignmentView
64
+ preview: ConversationPreviewView | None
65
+ is_marked: bool
66
+ is_muted: bool
67
+ unread_count: int
68
+
69
+ @staticmethod
70
+ def from_json(payload: Mapping[str, JsonValue]) -> "ConversationSummaryView":
71
+ conversation_id = _get_str(payload.get("id")) or "unknown"
72
+ status = _get_str(payload.get("status"))
73
+ priority = _get_str(payload.get("priority"))
74
+ channel = _get_str(payload.get("channel"))
75
+ last_message_at = _get_str(payload.get("last_message_at"))
76
+ message_count = _get_int(payload.get("message_count"))
77
+ assignment = ConversationAssignmentView.from_json(_get_dict(payload.get("assignment")))
78
+ preview = ConversationPreviewView.from_json(_get_dict(payload.get("last_message_preview")))
79
+ is_marked = _get_bool(payload.get("is_marked"))
80
+ is_muted = _get_bool(payload.get("is_muted"))
81
+ unread_count = _extract_unread_count(payload.get("read_state"))
82
+ return ConversationSummaryView(
83
+ conversation_id=conversation_id,
84
+ status=status,
85
+ priority=priority,
86
+ channel=channel,
87
+ last_message_at=last_message_at,
88
+ message_count=message_count,
89
+ assignment=assignment,
90
+ preview=preview,
91
+ is_marked=is_marked,
92
+ is_muted=is_muted,
93
+ unread_count=unread_count,
94
+ )
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class ConversationDetailView:
99
+ summary: ConversationSummaryView
100
+ transcript: List[TranscriptMessage]
101
+
102
+ @staticmethod
103
+ def from_json(payload: Mapping[str, JsonValue]) -> "ConversationDetailView":
104
+ summary = ConversationSummaryView.from_json(payload)
105
+ transcript_items = _get_list(payload.get("transcript"))
106
+ transcript: List[TranscriptMessage] = []
107
+ for item in transcript_items:
108
+ if isinstance(item, Mapping):
109
+ transcript.append(TranscriptMessage.from_json(item))
110
+ return ConversationDetailView(summary=summary, transcript=transcript)
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class AgentReplyResult:
115
+ conversation_id: str
116
+ message_id: str
117
+ assignment_updated: bool
118
+
119
+ @staticmethod
120
+ def from_json(payload: Mapping[str, JsonValue]) -> "AgentReplyResult":
121
+ conversation_id = _get_str(payload.get("conversation_id")) or "unknown"
122
+ message_id = _get_str(payload.get("message_id")) or "unknown"
123
+ assignment_updated = _get_bool(payload.get("assignment_updated"))
124
+ return AgentReplyResult(
125
+ conversation_id=conversation_id,
126
+ message_id=message_id,
127
+ assignment_updated=assignment_updated,
128
+ )
129
+
130
+
131
+ def _get_str(value: JsonValue) -> str | None:
132
+ if isinstance(value, str):
133
+ return value
134
+ return None
135
+
136
+
137
+ def _get_int(value: JsonValue) -> int:
138
+ if isinstance(value, int):
139
+ return value
140
+ if isinstance(value, str) and value.isdigit():
141
+ return int(value)
142
+ return 0
143
+
144
+
145
+ def _get_bool(value: JsonValue) -> bool:
146
+ if isinstance(value, bool):
147
+ return value
148
+ return False
149
+
150
+
151
+ def _get_dict(value: JsonValue) -> JsonDict | None:
152
+ if isinstance(value, Mapping):
153
+ return dict(value)
154
+ return None
155
+
156
+
157
+ def _get_list(value: JsonValue) -> Sequence[JsonValue]:
158
+ if isinstance(value, list):
159
+ return value
160
+ return []
161
+
162
+
163
+ def _extract_unread_count(value: JsonValue) -> int:
164
+ if not isinstance(value, list):
165
+ return 0
166
+ max_unread = 0
167
+ for item in value:
168
+ if not isinstance(item, Mapping):
169
+ continue
170
+ unread = _get_int(item.get("unread_count"))
171
+ if unread > max_unread:
172
+ max_unread = unread
173
+ return max_unread
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import os
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Type
9
+ from urllib.parse import parse_qs, urlparse
10
+
11
+ from guzli.errors import ConfigError
12
+
13
+ DEFAULT_OAUTH_CALLBACK_HOST = "127.0.0.1"
14
+ DEFAULT_OAUTH_CALLBACK_PATH = "/oauth/callback"
15
+ DEFAULT_OAUTH_CALLBACK_PORT = 8765
16
+ DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECONDS = 300
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class OAuthCallbackConfig:
21
+ host: str = DEFAULT_OAUTH_CALLBACK_HOST
22
+ port: int = DEFAULT_OAUTH_CALLBACK_PORT
23
+ path: str = DEFAULT_OAUTH_CALLBACK_PATH
24
+ timeout_seconds: int = DEFAULT_OAUTH_CALLBACK_TIMEOUT_SECONDS
25
+
26
+ @classmethod
27
+ def from_inputs(cls, port: int | None = None) -> "OAuthCallbackConfig":
28
+ return cls(port=port if port is not None else _callback_port_from_env())
29
+
30
+ @property
31
+ def redirect_uri(self) -> str:
32
+ return f"http://{self.host}:{self.port}{self.path}"
33
+
34
+
35
+ @dataclass
36
+ class OAuthCallbackResult:
37
+ state: str | None = None
38
+ code: str | None = None
39
+ error: str | None = None
40
+ error_description: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class OAuthLoopbackCallbackServer:
45
+ config: OAuthCallbackConfig
46
+ success_html: str
47
+ _result: OAuthCallbackResult = field(default_factory=OAuthCallbackResult)
48
+ _done_event: threading.Event = field(default_factory=threading.Event)
49
+ _httpd: http.server.HTTPServer | None = None
50
+
51
+ @property
52
+ def redirect_uri(self) -> str:
53
+ return self.config.redirect_uri
54
+
55
+ def start(self) -> None:
56
+ handler = self._handler_class()
57
+ try:
58
+ self._httpd = http.server.HTTPServer((self.config.host, self.config.port), handler)
59
+ except OSError as exc:
60
+ raise ConfigError(
61
+ f"OAuth callback port {self.config.port} is unavailable. "
62
+ "Set GUZLI_OAUTH_CALLBACK_PORT to a registered callback port or free this port."
63
+ ) from exc
64
+ thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
65
+ thread.start()
66
+
67
+ def wait_for_result(self) -> OAuthCallbackResult:
68
+ deadline = time.monotonic() + self.config.timeout_seconds
69
+ while not self._done_event.is_set():
70
+ remaining = deadline - time.monotonic()
71
+ if remaining <= 0:
72
+ raise ConfigError(
73
+ "OAuth callback timed out. Re-run `guzli auth login --mode oauth`."
74
+ )
75
+ self._done_event.wait(timeout=min(remaining, 1.0))
76
+ return self._result
77
+
78
+ def close(self) -> None:
79
+ if self._httpd is None:
80
+ return
81
+ self._httpd.shutdown()
82
+ self._httpd.server_close()
83
+ self._httpd = None
84
+
85
+ def _handler_class(self) -> Type[http.server.BaseHTTPRequestHandler]:
86
+ owner = self
87
+
88
+ class CallbackHandler(http.server.BaseHTTPRequestHandler):
89
+ def do_GET(self) -> None:
90
+ parsed = urlparse(self.path)
91
+ if parsed.path != owner.config.path:
92
+ self.send_response(404)
93
+ self.end_headers()
94
+ return
95
+ qs = parse_qs(parsed.query)
96
+ if "error" in qs:
97
+ owner._result.error = qs.get("error", ["unknown"])[0]
98
+ owner._result.error_description = qs.get("error_description", [""])[0]
99
+ else:
100
+ owner._result.state = qs.get("state", [""])[0]
101
+ owner._result.code = qs.get("code", [""])[0]
102
+ owner._done_event.set()
103
+ self.send_response(200)
104
+ self.send_header("Content-Type", "text/html; charset=utf-8")
105
+ self.send_header("Cache-Control", "no-store, max-age=0")
106
+ self.send_header("Pragma", "no-cache")
107
+ self.end_headers()
108
+ self.wfile.write(owner.success_html.encode("utf-8"))
109
+
110
+ def log_message(self, format: str, *_args) -> None:
111
+ return
112
+
113
+ return CallbackHandler
114
+
115
+
116
+ def _callback_port_from_env() -> int:
117
+ raw_port = os.getenv("GUZLI_OAUTH_CALLBACK_PORT")
118
+ if raw_port is None or not raw_port.strip():
119
+ return DEFAULT_OAUTH_CALLBACK_PORT
120
+ try:
121
+ port = int(raw_port)
122
+ except ValueError as exc:
123
+ raise ConfigError("GUZLI_OAUTH_CALLBACK_PORT must be an integer") from exc
124
+ if port < 1024 or port > 65535:
125
+ raise ConfigError("GUZLI_OAUTH_CALLBACK_PORT must be between 1024 and 65535")
126
+ return port
guzli/output.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Iterable, Mapping
6
+
7
+ from guzli.models import AgentReplyResult, ConversationDetailView, ConversationSummaryView
8
+ from guzli.types import JsonValue
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ConsoleRenderer:
13
+ def print_conversation_list(self, items: Iterable[ConversationSummaryView]) -> None:
14
+ for item in items:
15
+ preview = item.preview.content if item.preview and item.preview.content else ""
16
+ preview_short = _shorten(preview, 80)
17
+ flags = []
18
+ if item.is_marked:
19
+ flags.append("marked")
20
+ if item.is_muted:
21
+ flags.append("muted")
22
+ if item.unread_count:
23
+ flags.append(f"unread:{item.unread_count}")
24
+ flag_text = f" [{', '.join(flags)}]" if flags else ""
25
+ assignment = item.assignment.assignment_type
26
+ if item.assignment.assigned_to_id:
27
+ assignment = f"{assignment}:{item.assignment.assigned_to_id}"
28
+ if item.assignment.queue_label:
29
+ assignment = f"{assignment}@{item.assignment.queue_label}"
30
+ print(
31
+ f"{item.conversation_id} | {item.status or 'unknown'} | {assignment} | {preview_short}{flag_text}"
32
+ )
33
+
34
+ def print_conversation_detail(
35
+ self, detail: ConversationDetailView, include_transcript: bool
36
+ ) -> None:
37
+ summary = detail.summary
38
+ print(f"Conversation: {summary.conversation_id}")
39
+ print(f"Status: {summary.status or 'unknown'}")
40
+ print(f"Priority: {summary.priority or 'normal'}")
41
+ if summary.channel:
42
+ print(f"Channel: {summary.channel}")
43
+ print(f"Messages: {summary.message_count}")
44
+ if summary.last_message_at:
45
+ print(f"Last message at: {summary.last_message_at}")
46
+ print(f"Assignment: {summary.assignment.assignment_type}")
47
+ if summary.assignment.assigned_to_id:
48
+ print(f"Assigned to: {summary.assignment.assigned_to_id}")
49
+ if summary.assignment.queue_label:
50
+ print(f"Queue: {summary.assignment.queue_label}")
51
+ if summary.preview and summary.preview.content:
52
+ print(f"Last preview: {_shorten(summary.preview.content, 200)}")
53
+ if not include_transcript:
54
+ return
55
+ if not detail.transcript:
56
+ print("Transcript: (empty)")
57
+ return
58
+ print("Transcript:")
59
+ for msg in detail.transcript:
60
+ timestamp = msg.timestamp or "unknown-time"
61
+ content = msg.content or ""
62
+ print(f"- [{timestamp}] {msg.role}: {_shorten(content, 500)}")
63
+
64
+ def print_reply_result(self, result: AgentReplyResult) -> None:
65
+ status = "assignment updated" if result.assignment_updated else "assignment unchanged"
66
+ print(f"Reply sent to {result.conversation_id} (message {result.message_id}, {status})")
67
+
68
+ def print_json(self, payload: JsonValue) -> None:
69
+ print(json.dumps(payload, indent=2, sort_keys=True))
70
+
71
+ def print_key_value(self, payload: Mapping[str, JsonValue]) -> None:
72
+ for key, value in payload.items():
73
+ print(f"{key}: {value}")
74
+
75
+
76
+ def _shorten(value: str, limit: int) -> str:
77
+ if len(value) <= limit:
78
+ return value
79
+ return f"{value[: limit - 1]}…"