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.
- guzli/__init__.py +3 -0
- guzli/__main__.py +4 -0
- guzli/auth.py +182 -0
- guzli/cli.py +84 -0
- guzli/commands/__init__.py +1 -0
- guzli/commands/admin.py +421 -0
- guzli/commands/auth.py +502 -0
- guzli/commands/campaign_launch.py +188 -0
- guzli/commands/campaigns.py +571 -0
- guzli/commands/common.py +42 -0
- guzli/commands/conversations.py +404 -0
- guzli/commands/integrations.py +48 -0
- guzli/commands/telephony.py +274 -0
- guzli/commands/voice_calls.py +270 -0
- guzli/config.py +137 -0
- guzli/config_store.py +218 -0
- guzli/errors.py +47 -0
- guzli/http_client.py +213 -0
- guzli/mcp/__init__.py +1 -0
- guzli/mcp/server.py +320 -0
- guzli/models.py +173 -0
- guzli/oauth_callback.py +126 -0
- guzli/output.py +79 -0
- guzli/runtime.py +159 -0
- guzli/services/__init__.py +29 -0
- guzli/services/api_keys.py +105 -0
- guzli/services/calls.py +48 -0
- guzli/services/campaigns.py +193 -0
- guzli/services/cli_auth.py +103 -0
- guzli/services/conversations.py +112 -0
- guzli/services/integrations.py +18 -0
- guzli/services/replies.py +21 -0
- guzli/services/telephony.py +80 -0
- guzli/services/voice_profiles.py +56 -0
- guzli/services/webhooks.py +169 -0
- guzli/types.py +15 -0
- guzli/validators.py +83 -0
- guzli/workflows/__init__.py +13 -0
- guzli/workflows/campaign_launch.py +501 -0
- guzli_cli-0.2.0.data/data/share/guzli/CHANGELOG.md +16 -0
- guzli_cli-0.2.0.data/data/share/guzli/PROVENANCE.md +23 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY.md +18 -0
- guzli_cli-0.2.0.data/data/share/guzli/SECURITY_AUDIT.md +47 -0
- guzli_cli-0.2.0.data/data/share/guzli/SKILL.md +101 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/compliance-advisory-us.json +15 -0
- guzli_cli-0.2.0.data/data/share/guzli/examples/pizza-order-extraction.json +65 -0
- guzli_cli-0.2.0.data/data/share/guzli/references/api.md +245 -0
- guzli_cli-0.2.0.dist-info/METADATA +341 -0
- guzli_cli-0.2.0.dist-info/RECORD +53 -0
- guzli_cli-0.2.0.dist-info/WHEEL +5 -0
- guzli_cli-0.2.0.dist-info/entry_points.txt +3 -0
- guzli_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
- 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
|
guzli/oauth_callback.py
ADDED
|
@@ -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]}…"
|