python-codex 0.3.3__py3-none-any.whl → 0.4.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.
- pycodex/__init__.py +3 -27
- pycodex/bootstrap.py +5 -45
- pycodex/cli.py +1 -14
- pycodex/context.py +1 -1
- pycodex/events.py +1 -1
- pycodex/model.py +2 -2
- pycodex/portable/__init__.py +45 -0
- pycodex/{portable.py → portable/client.py} +4 -7
- pycodex/{portable_server.py → portable/server.py} +3 -3
- pycodex/protocol.py +1 -1
- pycodex/runtime.py +1 -1
- pycodex/tools/base_tool.py +1 -1
- pycodex/tools/close_agent_tool.py +1 -1
- pycodex/tools/code_mode_manager.py +1 -1
- pycodex/tools/request_permissions_tool.py +1 -1
- pycodex/tools/resume_agent_tool.py +1 -1
- pycodex/{runtime_services.py → tools/runtime_services.py} +5 -5
- pycodex/tools/send_input_tool.py +1 -1
- pycodex/tools/spawn_agent_tool.py +1 -1
- pycodex/tools/unified_exec_manager.py +1 -1
- pycodex/tools/update_plan_tool.py +1 -1
- pycodex/tools/view_image_tool.py +5 -0
- pycodex/tools/wait_agent_tool.py +1 -1
- pycodex/utils/__init__.py +0 -4
- pycodex/utils/_aes_gcm/LICENSE +56 -0
- pycodex/utils/_aes_gcm/README.md +61 -0
- pycodex/utils/_aes_gcm/__init__.py +1 -0
- pycodex/utils/_aes_gcm/aes.py +175 -0
- pycodex/utils/_aes_gcm/gcm.py +73 -0
- pycodex/utils/_aes_gcm/source-sha256.json +4 -0
- pycodex/{compat.py → utils/compat.py} +2 -0
- pycodex/utils/crypto.py +35 -0
- pycodex/utils/debug.py +24 -0
- pycodex/{doctor.py → utils/doctor.py} +3 -3
- pycodex/utils/image_utils.py +3 -2
- pycodex/{model_metadata.py → utils/model_metadata.py} +2 -2
- {python_codex-0.3.3.dist-info → python_codex-0.4.0.dist-info}/METADATA +120 -42
- {python_codex-0.3.3.dist-info → python_codex-0.4.0.dist-info}/RECORD +42 -50
- {python_codex-0.3.3.dist-info → python_codex-0.4.0.dist-info}/entry_points.txt +0 -1
- responses_server/app.py +28 -15
- pycodex/feishu_card.py +0 -793
- pycodex/feishu_link.py +0 -440
- pycodex/tools/ipython_tool.py +0 -142
- workspace_server/__init__.py +0 -39
- workspace_server/__main__.py +0 -4
- workspace_server/app.py +0 -1906
- workspace_server/content_views.LICENSE.txt +0 -470
- workspace_server/content_views.css +0 -1
- workspace_server/content_views.mjs +0 -25
- workspace_server/editor.css +0 -1
- workspace_server/editor.mjs +0 -1260
- workspace_server/owned_process.py +0 -27
- workspace_server/workspace.html +0 -2267
- workspace_server/workspace_views.py +0 -533
- workspace_server/workspaces.html +0 -550
- workspace_server/workspaces.py +0 -632
- {python_codex-0.3.3.dist-info → python_codex-0.4.0.dist-info}/WHEEL +0 -0
- {python_codex-0.3.3.dist-info → python_codex-0.4.0.dist-info}/licenses/LICENSE +0 -0
pycodex/feishu_card.py
DELETED
|
@@ -1,793 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
import time
|
|
4
|
-
import typing
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import requests
|
|
8
|
-
|
|
9
|
-
from .events import (
|
|
10
|
-
DEFAULT_MAIN_PROMPT,
|
|
11
|
-
Event,
|
|
12
|
-
EventDisplay,
|
|
13
|
-
InputQueuedEvent,
|
|
14
|
-
SessionStateEvent,
|
|
15
|
-
TurnCompletedEvent,
|
|
16
|
-
TurnStartedEvent,
|
|
17
|
-
)
|
|
18
|
-
from .utils.event_helpers import completed_history
|
|
19
|
-
|
|
20
|
-
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
|
|
21
|
-
FEISHU_DOMAIN = "https://open.feishu.cn"
|
|
22
|
-
FEISHU_REFRESH_TOKEN_FILE = "~/.codex/.feishu_refresh_token"
|
|
23
|
-
CARD_OUTPUT_LIMIT = 6500
|
|
24
|
-
CARD_OUTPUT_MODE_MARKDOWN = "markdown"
|
|
25
|
-
CARD_OUTPUT_MODE_CODE = "code"
|
|
26
|
-
CARD_CONTENT_ERROR_MARKER = "Failed to create card content"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
class PycodexCard:
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
app_id: "typing.Union[str, None]" = None,
|
|
33
|
-
app_secret: "typing.Union[str, None]" = None,
|
|
34
|
-
api_base: str = FEISHU_API_BASE,
|
|
35
|
-
domain: str = FEISHU_DOMAIN,
|
|
36
|
-
verification_token: "typing.Union[str, None]" = None,
|
|
37
|
-
encrypt_key: "typing.Union[str, None]" = None,
|
|
38
|
-
session: "typing.Union[requests.Session, None]" = None,
|
|
39
|
-
) -> None:
|
|
40
|
-
self.app_id = app_id
|
|
41
|
-
self.app_secret = app_secret
|
|
42
|
-
self.api_base = api_base
|
|
43
|
-
self.domain = domain
|
|
44
|
-
self.verification_token = verification_token
|
|
45
|
-
self.encrypt_key = encrypt_key
|
|
46
|
-
self.session = session or requests.Session()
|
|
47
|
-
self.message_id = None
|
|
48
|
-
self.callback_token = None
|
|
49
|
-
self.session_key = None
|
|
50
|
-
self.status = None
|
|
51
|
-
self.prompt_text = DEFAULT_MAIN_PROMPT
|
|
52
|
-
self.model_name = "pycodex"
|
|
53
|
-
self.output_text = ""
|
|
54
|
-
self.activity_text = ""
|
|
55
|
-
self.user_prompt = ""
|
|
56
|
-
self.turn_history = []
|
|
57
|
-
self.history_index = None
|
|
58
|
-
self._active_turn_id = None
|
|
59
|
-
self._event_output = ""
|
|
60
|
-
self.display = EventDisplay(self._log, self._set_status, self._set_prompt)
|
|
61
|
-
self.detached = False
|
|
62
|
-
self.accepts_input = True
|
|
63
|
-
self._user_access_token = None
|
|
64
|
-
self._user_token_expires_at = 0.0
|
|
65
|
-
self._tenant_token = None
|
|
66
|
-
self._tenant_token_expires_at = 0.0
|
|
67
|
-
|
|
68
|
-
@classmethod
|
|
69
|
-
def from_env(cls) -> "PycodexCard":
|
|
70
|
-
api_base = os.environ.get("FEISHU_API_BASE", FEISHU_API_BASE)
|
|
71
|
-
return cls(
|
|
72
|
-
app_id=_env("FEISHU_APP_ID", "LARK_APP_ID"),
|
|
73
|
-
app_secret=_env("FEISHU_APP_SECRET", "LARK_APP_SECRET"),
|
|
74
|
-
api_base=api_base,
|
|
75
|
-
domain=os.environ.get("FEISHU_DOMAIN", _api_base_to_domain(api_base)),
|
|
76
|
-
verification_token=_env(
|
|
77
|
-
"FEISHU_VERIFICATION_TOKEN",
|
|
78
|
-
"LARK_VERIFICATION_TOKEN",
|
|
79
|
-
),
|
|
80
|
-
encrypt_key=_env("FEISHU_ENCRYPT_KEY", "LARK_ENCRYPT_KEY"),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
def configured(self) -> bool:
|
|
84
|
-
return bool(self.app_id and self.app_secret)
|
|
85
|
-
|
|
86
|
-
def send(self, target: str) -> None:
|
|
87
|
-
receive_id = str(target or "").strip()
|
|
88
|
-
if not receive_id:
|
|
89
|
-
raise ValueError("recipient target is required")
|
|
90
|
-
receive_id = self.resolve_name(receive_id)
|
|
91
|
-
if not receive_id:
|
|
92
|
-
raise ValueError(
|
|
93
|
-
"cannot resolve Feishu user from target: {0}".format(target)
|
|
94
|
-
)
|
|
95
|
-
if receive_id.startswith("oc_"):
|
|
96
|
-
resolved_type = "chat_id"
|
|
97
|
-
else:
|
|
98
|
-
resolved_type = "open_id"
|
|
99
|
-
|
|
100
|
-
self.session_key = "feishu:manual:{0}:{1}".format(resolved_type, receive_id)
|
|
101
|
-
|
|
102
|
-
def build_body(output_mode):
|
|
103
|
-
return {
|
|
104
|
-
"receive_id": receive_id,
|
|
105
|
-
"msg_type": "interactive",
|
|
106
|
-
"content": json.dumps(self.render(output_mode), ensure_ascii=False),
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
response = self._request_rendered_card(
|
|
110
|
-
"POST",
|
|
111
|
-
"/im/v1/messages",
|
|
112
|
-
{"receive_id_type": resolved_type},
|
|
113
|
-
build_body,
|
|
114
|
-
)
|
|
115
|
-
self.message_id = _extract_message_id(response)
|
|
116
|
-
|
|
117
|
-
def update(
|
|
118
|
-
self,
|
|
119
|
-
message_id: "typing.Union[str, None]" = None,
|
|
120
|
-
callback_token: "typing.Union[str, None]" = None,
|
|
121
|
-
) -> None:
|
|
122
|
-
if message_id:
|
|
123
|
-
self.message_id = message_id
|
|
124
|
-
if callback_token:
|
|
125
|
-
self.callback_token = callback_token
|
|
126
|
-
if not self.configured():
|
|
127
|
-
return
|
|
128
|
-
if self.message_id:
|
|
129
|
-
|
|
130
|
-
def build_body(output_mode):
|
|
131
|
-
return {
|
|
132
|
-
"content": json.dumps(
|
|
133
|
-
self.render(output_mode),
|
|
134
|
-
ensure_ascii=False,
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
self._request_rendered_card(
|
|
139
|
-
"PATCH",
|
|
140
|
-
"/im/v1/messages/{0}".format(self.message_id),
|
|
141
|
-
None,
|
|
142
|
-
build_body,
|
|
143
|
-
)
|
|
144
|
-
return
|
|
145
|
-
if self.callback_token:
|
|
146
|
-
|
|
147
|
-
def build_body(output_mode):
|
|
148
|
-
return {"token": self.callback_token, "card": self.render(output_mode)}
|
|
149
|
-
|
|
150
|
-
self._request_rendered_card(
|
|
151
|
-
"POST",
|
|
152
|
-
"/interactive/v1/card/update",
|
|
153
|
-
None,
|
|
154
|
-
build_body,
|
|
155
|
-
use_user_token=False,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
def detach(self) -> None:
|
|
159
|
-
self.detached = True
|
|
160
|
-
|
|
161
|
-
def apply_event(self, event: "Event") -> None:
|
|
162
|
-
self._event_output = ""
|
|
163
|
-
if isinstance(event, SessionStateEvent):
|
|
164
|
-
state = event.state
|
|
165
|
-
self.model_name = state["model"]
|
|
166
|
-
self.accepts_input = state["accepts_input"]
|
|
167
|
-
self.display.closed = state["closed"]
|
|
168
|
-
if event.reason in {"attach", "history"}:
|
|
169
|
-
self.output_text = ""
|
|
170
|
-
self.activity_text = ""
|
|
171
|
-
self.turn_history = list(completed_history(state))
|
|
172
|
-
self.history_index = None
|
|
173
|
-
active = state["active_turn"]
|
|
174
|
-
self._active_turn_id = active["turn_id"] if active is not None else None
|
|
175
|
-
self.user_prompt = (
|
|
176
|
-
"\n".join(active["user_texts"])
|
|
177
|
-
if active is not None
|
|
178
|
-
else self.turn_history[-1][0] if self.turn_history else ""
|
|
179
|
-
)
|
|
180
|
-
self.display.stream_buffer = ""
|
|
181
|
-
self.prompt_text = DEFAULT_MAIN_PROMPT
|
|
182
|
-
if state["busy"]:
|
|
183
|
-
self._set_status("working")
|
|
184
|
-
else:
|
|
185
|
-
self.display.set_idle_status(state["background_work_count"])
|
|
186
|
-
for _prompt, response in self.turn_history[-1:]:
|
|
187
|
-
self.output_text = response
|
|
188
|
-
if state["busy"]:
|
|
189
|
-
self._mark_last_turn_output()
|
|
190
|
-
elif isinstance(event, (InputQueuedEvent, TurnStartedEvent)):
|
|
191
|
-
self._mark_last_turn_output()
|
|
192
|
-
if isinstance(event, TurnStartedEvent):
|
|
193
|
-
prompt = event.visualize()
|
|
194
|
-
if event.turn_id == self._active_turn_id and self.user_prompt:
|
|
195
|
-
self.user_prompt += "\n" + prompt
|
|
196
|
-
else:
|
|
197
|
-
self.user_prompt = prompt
|
|
198
|
-
self._active_turn_id = event.turn_id
|
|
199
|
-
event.render(self.display)
|
|
200
|
-
if isinstance(event, TurnCompletedEvent):
|
|
201
|
-
if event.output_text:
|
|
202
|
-
self.output_text = event.output_text
|
|
203
|
-
self.turn_history.append((self.user_prompt, event.output_text))
|
|
204
|
-
self.activity_text = ""
|
|
205
|
-
self._active_turn_id = None
|
|
206
|
-
elif isinstance(event, TurnStartedEvent):
|
|
207
|
-
self.activity_text = ""
|
|
208
|
-
elif self._event_output:
|
|
209
|
-
self.activity_text = self._event_output
|
|
210
|
-
|
|
211
|
-
def _mark_last_turn_output(self) -> None:
|
|
212
|
-
prefix = "(*last turn)\n"
|
|
213
|
-
if self.output_text and not self.output_text.startswith(prefix):
|
|
214
|
-
self.output_text = prefix + self.output_text
|
|
215
|
-
|
|
216
|
-
def _log(self, text: str) -> None:
|
|
217
|
-
self._event_output = (
|
|
218
|
-
self._event_output + ("\n" if self._event_output else "") + text
|
|
219
|
-
)[-CARD_OUTPUT_LIMIT:]
|
|
220
|
-
|
|
221
|
-
def _set_status(self, text: "typing.Union[str, None]") -> None:
|
|
222
|
-
self.status = text
|
|
223
|
-
|
|
224
|
-
def _set_prompt(self, text: str) -> None:
|
|
225
|
-
if text != self.prompt_text:
|
|
226
|
-
self.activity_text = ""
|
|
227
|
-
self.prompt_text = text
|
|
228
|
-
|
|
229
|
-
def render(
|
|
230
|
-
self, output_mode: str = CARD_OUTPUT_MODE_MARKDOWN
|
|
231
|
-
) -> "typing.Dict[str, object]":
|
|
232
|
-
status = "Closed" if self.display.closed else self.status
|
|
233
|
-
input_disabled = not self.accepts_input or self.detached or self.display.closed
|
|
234
|
-
output = _truncate(self.output_text, CARD_OUTPUT_LIMIT) or "Ready."
|
|
235
|
-
working_output = _truncate(self.display.stream_buffer, CARD_OUTPUT_LIMIT)
|
|
236
|
-
color = _status_color("Detached" if self.detached else status or "Idle")
|
|
237
|
-
body_elements = [
|
|
238
|
-
_output_box(
|
|
239
|
-
"answer_box",
|
|
240
|
-
"answer_md",
|
|
241
|
-
_render_output_content(output, output_mode),
|
|
242
|
-
"grey-50",
|
|
243
|
-
),
|
|
244
|
-
]
|
|
245
|
-
if working_output:
|
|
246
|
-
body_elements.append(
|
|
247
|
-
_output_box(
|
|
248
|
-
"working_output_box",
|
|
249
|
-
"working_output_md",
|
|
250
|
-
_render_output_content(working_output, output_mode),
|
|
251
|
-
"green-50",
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
if self.activity_text:
|
|
255
|
-
body_elements.append(
|
|
256
|
-
{
|
|
257
|
-
"tag": "markdown",
|
|
258
|
-
"element_id": "activity_md",
|
|
259
|
-
"content": _render_output_content(self.activity_text, output_mode),
|
|
260
|
-
}
|
|
261
|
-
)
|
|
262
|
-
if self.user_prompt:
|
|
263
|
-
body_elements.insert(
|
|
264
|
-
0,
|
|
265
|
-
{
|
|
266
|
-
"tag": "markdown",
|
|
267
|
-
"element_id": "user_prompt_md",
|
|
268
|
-
"content": _render_output_content(
|
|
269
|
-
"user> " + _truncate(self.user_prompt, CARD_OUTPUT_LIMIT),
|
|
270
|
-
output_mode,
|
|
271
|
-
),
|
|
272
|
-
},
|
|
273
|
-
)
|
|
274
|
-
history = self.earlier_turns()
|
|
275
|
-
if history:
|
|
276
|
-
index = (
|
|
277
|
-
len(history) - 1 if self.history_index is None else self.history_index
|
|
278
|
-
)
|
|
279
|
-
body_elements.insert(
|
|
280
|
-
0,
|
|
281
|
-
_history_panel(
|
|
282
|
-
history,
|
|
283
|
-
index,
|
|
284
|
-
self.history_index is not None,
|
|
285
|
-
output_mode,
|
|
286
|
-
not self.detached,
|
|
287
|
-
),
|
|
288
|
-
)
|
|
289
|
-
card = {
|
|
290
|
-
"schema": "2.0",
|
|
291
|
-
"config": {"update_multi": True},
|
|
292
|
-
"header": {
|
|
293
|
-
"title": {
|
|
294
|
-
"tag": "plain_text",
|
|
295
|
-
"content": self.display.title or "pycodex",
|
|
296
|
-
},
|
|
297
|
-
"template": color,
|
|
298
|
-
},
|
|
299
|
-
"body": {
|
|
300
|
-
"elements": body_elements,
|
|
301
|
-
},
|
|
302
|
-
}
|
|
303
|
-
if not self.detached:
|
|
304
|
-
card["body"]["elements"].append(
|
|
305
|
-
{
|
|
306
|
-
"tag": "input",
|
|
307
|
-
"element_id": "prompt_input",
|
|
308
|
-
"name": "prompt",
|
|
309
|
-
"input_type": "text",
|
|
310
|
-
"width": "fill",
|
|
311
|
-
"disabled": input_disabled,
|
|
312
|
-
"placeholder": {
|
|
313
|
-
"tag": "plain_text",
|
|
314
|
-
"content": self.prompt_text + (status or self.model_name),
|
|
315
|
-
},
|
|
316
|
-
"behaviors": [{"type": "callback", "value": {"action": "send"}}],
|
|
317
|
-
"value": {"action": "send"},
|
|
318
|
-
},
|
|
319
|
-
)
|
|
320
|
-
return card
|
|
321
|
-
|
|
322
|
-
def earlier_turns(self):
|
|
323
|
-
if self._active_turn_id is not None:
|
|
324
|
-
return self.turn_history
|
|
325
|
-
return self.turn_history[:-1]
|
|
326
|
-
|
|
327
|
-
def show_history(self, index):
|
|
328
|
-
if type(index) is not int or not 0 <= index < len(self.earlier_turns()):
|
|
329
|
-
raise ValueError("This history turn is no longer available.")
|
|
330
|
-
self.history_index = index
|
|
331
|
-
|
|
332
|
-
def parse_action(self, sdk_event) -> "typing.Dict[str, object]":
|
|
333
|
-
event_data = getattr(sdk_event, "event", None)
|
|
334
|
-
action_payload = getattr(event_data, "action", None)
|
|
335
|
-
operator = getattr(event_data, "operator", None)
|
|
336
|
-
context = getattr(event_data, "context", None)
|
|
337
|
-
form_values = _sdk_form_values(action_payload)
|
|
338
|
-
value = getattr(action_payload, "value", None) or {}
|
|
339
|
-
if not isinstance(value, dict):
|
|
340
|
-
value = {}
|
|
341
|
-
prompt = str(
|
|
342
|
-
form_values.get("prompt")
|
|
343
|
-
or form_values.get("prompt_input")
|
|
344
|
-
or getattr(action_payload, "input_value", None)
|
|
345
|
-
or value.get("input_value")
|
|
346
|
-
or ""
|
|
347
|
-
).strip()
|
|
348
|
-
if not prompt and form_values:
|
|
349
|
-
prompt = str(next(iter(form_values.values()))).strip()
|
|
350
|
-
action = str(
|
|
351
|
-
value.get("action") or getattr(action_payload, "name", None) or "send"
|
|
352
|
-
).strip()
|
|
353
|
-
if action in {"send_button", "prompt_form"}:
|
|
354
|
-
action = "send"
|
|
355
|
-
message_id = (
|
|
356
|
-
str(getattr(context, "open_message_id", None) or "").strip() or None
|
|
357
|
-
)
|
|
358
|
-
callback_token = str(getattr(event_data, "token", None) or "").strip() or None
|
|
359
|
-
self.message_id = message_id or self.message_id
|
|
360
|
-
self.callback_token = callback_token or self.callback_token
|
|
361
|
-
if not self.session_key:
|
|
362
|
-
tenant_key = str(getattr(operator, "tenant_key", None) or "").strip()
|
|
363
|
-
open_id = str(getattr(operator, "open_id", None) or "").strip()
|
|
364
|
-
self.session_key = _default_session_key(tenant_key, open_id, message_id)
|
|
365
|
-
return {
|
|
366
|
-
"action": action,
|
|
367
|
-
"history_index": value.get("history_index"),
|
|
368
|
-
"prompt": prompt,
|
|
369
|
-
"sender": self.resolve_operator_name(operator),
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
def resolve_name(self, name: str) -> "typing.Union[str, None]":
|
|
373
|
-
normalized = str(name or "").strip()
|
|
374
|
-
if not normalized:
|
|
375
|
-
return None
|
|
376
|
-
aliases = _user_id_aliases()
|
|
377
|
-
if normalized in aliases:
|
|
378
|
-
return aliases[normalized]
|
|
379
|
-
if normalized.startswith("ou_"):
|
|
380
|
-
return normalized
|
|
381
|
-
if normalized.startswith("oc_"):
|
|
382
|
-
return normalized
|
|
383
|
-
if normalized.isnumeric():
|
|
384
|
-
return self._lookup_user({"mobiles": [normalized]})
|
|
385
|
-
if "@" not in normalized:
|
|
386
|
-
email_domain = _default_email_domain()
|
|
387
|
-
if not email_domain:
|
|
388
|
-
return None
|
|
389
|
-
normalized = normalized + "@" + email_domain
|
|
390
|
-
return self._lookup_user({"emails": [normalized]})
|
|
391
|
-
|
|
392
|
-
def _lookup_user(
|
|
393
|
-
self, body: "typing.Dict[str, object]"
|
|
394
|
-
) -> "typing.Union[str, None]":
|
|
395
|
-
payload = dict(body)
|
|
396
|
-
payload["include_resigned"] = False
|
|
397
|
-
response = self._request(
|
|
398
|
-
"POST",
|
|
399
|
-
"/contact/v3/users/batch_get_id",
|
|
400
|
-
params={"user_id_type": "open_id"},
|
|
401
|
-
json_body=payload,
|
|
402
|
-
use_user_token=False,
|
|
403
|
-
)
|
|
404
|
-
user_list = _dig(response, "data", "user_list")
|
|
405
|
-
if not isinstance(user_list, list) or not user_list:
|
|
406
|
-
return None
|
|
407
|
-
item = user_list[0] if isinstance(user_list[0], dict) else {}
|
|
408
|
-
value = item.get("user_id") or item.get("open_id") or item.get("union_id")
|
|
409
|
-
return str(value).strip() if value else None
|
|
410
|
-
|
|
411
|
-
def resolve_operator_name(self, operator) -> str:
|
|
412
|
-
for user_id_type in ("user_id", "open_id", "union_id"):
|
|
413
|
-
user_id = str(getattr(operator, user_id_type, None) or "").strip()
|
|
414
|
-
if not user_id:
|
|
415
|
-
continue
|
|
416
|
-
try:
|
|
417
|
-
response = self._request(
|
|
418
|
-
"GET",
|
|
419
|
-
"/contact/v3/users/{0}".format(user_id),
|
|
420
|
-
params={"user_id_type": user_id_type},
|
|
421
|
-
use_user_token=False,
|
|
422
|
-
)
|
|
423
|
-
except Exception:
|
|
424
|
-
continue
|
|
425
|
-
name = _display_user_name(_dig(response, "data", "user") or response)
|
|
426
|
-
if name:
|
|
427
|
-
return name
|
|
428
|
-
return "cli"
|
|
429
|
-
|
|
430
|
-
def _request(
|
|
431
|
-
self,
|
|
432
|
-
method: str,
|
|
433
|
-
path: str,
|
|
434
|
-
params: "typing.Union[typing.Dict[str, str], None]" = None,
|
|
435
|
-
json_body: "typing.Union[typing.Dict[str, object], None]" = None,
|
|
436
|
-
use_user_token: bool = True,
|
|
437
|
-
) -> "typing.Dict[str, object]":
|
|
438
|
-
user_token = self.user_access_token() if use_user_token else None
|
|
439
|
-
token = user_token or self.tenant_access_token()
|
|
440
|
-
response = self.session.request(
|
|
441
|
-
method,
|
|
442
|
-
self.api_base.rstrip("/") + path,
|
|
443
|
-
params=params,
|
|
444
|
-
json=json_body,
|
|
445
|
-
headers={"Authorization": "Bearer {0}".format(token)},
|
|
446
|
-
timeout=20,
|
|
447
|
-
)
|
|
448
|
-
return _checked_json_response(response)
|
|
449
|
-
|
|
450
|
-
def _request_rendered_card(
|
|
451
|
-
self,
|
|
452
|
-
method: str,
|
|
453
|
-
path: str,
|
|
454
|
-
params: "typing.Union[typing.Dict[str, str], None]",
|
|
455
|
-
json_body_builder: "typing.Callable[[str], typing.Dict[str, object]]",
|
|
456
|
-
use_user_token: bool = True,
|
|
457
|
-
) -> "typing.Dict[str, object]":
|
|
458
|
-
try:
|
|
459
|
-
return self._request(
|
|
460
|
-
method,
|
|
461
|
-
path,
|
|
462
|
-
params=params,
|
|
463
|
-
json_body=json_body_builder(CARD_OUTPUT_MODE_MARKDOWN),
|
|
464
|
-
use_user_token=use_user_token,
|
|
465
|
-
)
|
|
466
|
-
except Exception as exc:
|
|
467
|
-
if not _is_card_content_error(exc):
|
|
468
|
-
raise
|
|
469
|
-
return self._request(
|
|
470
|
-
method,
|
|
471
|
-
path,
|
|
472
|
-
params=params,
|
|
473
|
-
json_body=json_body_builder(CARD_OUTPUT_MODE_CODE),
|
|
474
|
-
use_user_token=use_user_token,
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
def user_access_token(self) -> "typing.Union[str, None]":
|
|
478
|
-
refresh_token = _read_refresh_token()
|
|
479
|
-
if not refresh_token:
|
|
480
|
-
return None
|
|
481
|
-
now = time.time()
|
|
482
|
-
if self._user_access_token and now < self._user_token_expires_at:
|
|
483
|
-
return self._user_access_token
|
|
484
|
-
if not self.configured():
|
|
485
|
-
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
|
|
486
|
-
response = self.session.post(
|
|
487
|
-
self.api_base.rstrip("/") + "/authen/v2/oauth/token",
|
|
488
|
-
json={
|
|
489
|
-
"grant_type": "refresh_token",
|
|
490
|
-
"client_id": self.app_id,
|
|
491
|
-
"client_secret": self.app_secret,
|
|
492
|
-
"refresh_token": refresh_token,
|
|
493
|
-
},
|
|
494
|
-
timeout=20,
|
|
495
|
-
)
|
|
496
|
-
payload = _checked_json_response(response)
|
|
497
|
-
token = payload.get("access_token")
|
|
498
|
-
if not token:
|
|
499
|
-
raise RuntimeError("user access_token missing from Feishu response")
|
|
500
|
-
refresh_token = payload.get("refresh_token")
|
|
501
|
-
if refresh_token:
|
|
502
|
-
_write_refresh_token(str(refresh_token))
|
|
503
|
-
self._user_access_token = str(token)
|
|
504
|
-
expires_in = payload.get("expires_in") or 0
|
|
505
|
-
self._user_token_expires_at = time.time() + max(60, int(expires_in) - 120)
|
|
506
|
-
return self._user_access_token
|
|
507
|
-
|
|
508
|
-
def tenant_access_token(self) -> str:
|
|
509
|
-
now = time.time()
|
|
510
|
-
if self._tenant_token and now < self._tenant_token_expires_at:
|
|
511
|
-
return self._tenant_token
|
|
512
|
-
response = self.session.post(
|
|
513
|
-
self.api_base.rstrip("/") + "/auth/v3/tenant_access_token/internal",
|
|
514
|
-
json={"app_id": self.app_id, "app_secret": self.app_secret},
|
|
515
|
-
timeout=20,
|
|
516
|
-
)
|
|
517
|
-
payload = _checked_json_response(response)
|
|
518
|
-
token = _dig(payload, "tenant_access_token") or _dig(
|
|
519
|
-
payload,
|
|
520
|
-
"data",
|
|
521
|
-
"tenant_access_token",
|
|
522
|
-
)
|
|
523
|
-
if not token:
|
|
524
|
-
raise RuntimeError("tenant_access_token missing from Feishu response")
|
|
525
|
-
expires_in = _dig(payload, "expire") or _dig(payload, "data", "expire") or 3600
|
|
526
|
-
self._tenant_token = str(token)
|
|
527
|
-
self._tenant_token_expires_at = now + max(60, int(expires_in) - 120)
|
|
528
|
-
return self._tenant_token
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
def _sdk_form_values(action_payload: "typing.Any") -> "typing.Dict[str, object]":
|
|
532
|
-
for name in ("form_value", "form_values", "input_values"):
|
|
533
|
-
value = getattr(action_payload, name, None)
|
|
534
|
-
if isinstance(value, dict):
|
|
535
|
-
return value
|
|
536
|
-
value = getattr(action_payload, "value", None)
|
|
537
|
-
if isinstance(value, dict):
|
|
538
|
-
for name in ("form_value", "form_values", "input_values"):
|
|
539
|
-
nested = value.get(name)
|
|
540
|
-
if isinstance(nested, dict):
|
|
541
|
-
return nested
|
|
542
|
-
return {}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def _checked_json_response(response) -> "typing.Dict[str, object]":
|
|
546
|
-
try:
|
|
547
|
-
payload = response.json()
|
|
548
|
-
except Exception as exc:
|
|
549
|
-
raise RuntimeError(
|
|
550
|
-
"Feishu API returned non-JSON response: status={0} body={1}".format(
|
|
551
|
-
getattr(response, "status_code", "?"),
|
|
552
|
-
getattr(response, "text", ""),
|
|
553
|
-
)
|
|
554
|
-
) from exc
|
|
555
|
-
if getattr(response, "status_code", 200) >= 400:
|
|
556
|
-
raise RuntimeError(
|
|
557
|
-
"Feishu API error: status={0} body={1}".format(
|
|
558
|
-
response.status_code,
|
|
559
|
-
json.dumps(payload, ensure_ascii=False),
|
|
560
|
-
)
|
|
561
|
-
)
|
|
562
|
-
code = payload.get("code")
|
|
563
|
-
if code not in (None, 0):
|
|
564
|
-
raise RuntimeError(
|
|
565
|
-
"Feishu API error: {0}".format(json.dumps(payload, ensure_ascii=False))
|
|
566
|
-
)
|
|
567
|
-
return payload
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def _extract_message_id(
|
|
571
|
-
response: "typing.Dict[str, object]",
|
|
572
|
-
) -> "typing.Union[str, None]":
|
|
573
|
-
for path in (
|
|
574
|
-
("data", "message_id"),
|
|
575
|
-
("data", "message", "message_id"),
|
|
576
|
-
("message_id",),
|
|
577
|
-
("open_message_id",),
|
|
578
|
-
):
|
|
579
|
-
value = _dig(response, *path)
|
|
580
|
-
if value:
|
|
581
|
-
return str(value)
|
|
582
|
-
return None
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def _default_session_key(
|
|
586
|
-
tenant_key: str,
|
|
587
|
-
open_id: str,
|
|
588
|
-
message_id: "typing.Union[str, None]",
|
|
589
|
-
) -> str:
|
|
590
|
-
user_key = open_id or "unknown"
|
|
591
|
-
if message_id:
|
|
592
|
-
return "feishu:{0}:{1}:{2}".format(tenant_key or "tenant", user_key, message_id)
|
|
593
|
-
return "feishu:{0}:{1}:default".format(tenant_key or "tenant", user_key)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
def _display_user_name(user: "typing.Any") -> "typing.Union[str, None]":
|
|
597
|
-
if not isinstance(user, dict):
|
|
598
|
-
return None
|
|
599
|
-
for key in ("name", "en_name", "nickname", "email", "enterprise_email"):
|
|
600
|
-
value = str(user.get(key) or "").strip()
|
|
601
|
-
if value:
|
|
602
|
-
return value
|
|
603
|
-
return None
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
def _status_color(status: str) -> str:
|
|
607
|
-
normalized = status.lower()
|
|
608
|
-
if normalized in {"detached", "closed"}:
|
|
609
|
-
return "grey"
|
|
610
|
-
if normalized in {"idle", "idle: sleeping"}:
|
|
611
|
-
return "green"
|
|
612
|
-
return "blue"
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def _truncate(text: str, limit: int) -> str:
|
|
616
|
-
value = str(text or "")
|
|
617
|
-
if len(value) <= limit:
|
|
618
|
-
return value
|
|
619
|
-
return value[: max(0, limit - 32)] + "\n...[truncated]"
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def _escape_code_block(text: str) -> str:
|
|
623
|
-
return str(text or "").replace("```", "'''")
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
def _render_output_content(output: str, output_mode: str) -> str:
|
|
627
|
-
if output_mode == CARD_OUTPUT_MODE_CODE:
|
|
628
|
-
return "```text\n{0}\n```".format(_escape_code_block(output))
|
|
629
|
-
return output
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def _history_panel(history, index, expanded, output_mode, interactive):
|
|
633
|
-
prompt, response = history[index]
|
|
634
|
-
content = _truncate(
|
|
635
|
-
"user> {0}\n\nassistant> {1}".format(prompt, response), CARD_OUTPUT_LIMIT
|
|
636
|
-
)
|
|
637
|
-
columns = []
|
|
638
|
-
for label, target, disabled in (
|
|
639
|
-
("Previous", index - 1, index == 0),
|
|
640
|
-
("Next", index + 1, index == len(history) - 1),
|
|
641
|
-
):
|
|
642
|
-
columns.append(
|
|
643
|
-
{
|
|
644
|
-
"tag": "column",
|
|
645
|
-
"width": "auto",
|
|
646
|
-
"elements": [
|
|
647
|
-
{
|
|
648
|
-
"tag": "button",
|
|
649
|
-
"text": {"tag": "plain_text", "content": label},
|
|
650
|
-
"type": "default",
|
|
651
|
-
"disabled": disabled or not interactive,
|
|
652
|
-
"behaviors": [
|
|
653
|
-
{
|
|
654
|
-
"type": "callback",
|
|
655
|
-
"value": {
|
|
656
|
-
"action": "history",
|
|
657
|
-
"history_index": target,
|
|
658
|
-
},
|
|
659
|
-
}
|
|
660
|
-
],
|
|
661
|
-
}
|
|
662
|
-
],
|
|
663
|
-
}
|
|
664
|
-
)
|
|
665
|
-
return {
|
|
666
|
-
"tag": "collapsible_panel",
|
|
667
|
-
"element_id": "history_panel",
|
|
668
|
-
"expanded": expanded,
|
|
669
|
-
"header": {
|
|
670
|
-
"icon": {
|
|
671
|
-
"tag": "standard_icon",
|
|
672
|
-
"token": "down-small-ccm_outlined",
|
|
673
|
-
"size": "16px 16px",
|
|
674
|
-
},
|
|
675
|
-
"title": {
|
|
676
|
-
"tag": "plain_text",
|
|
677
|
-
"content": "History · {0} / {1}".format(index + 1, len(history)),
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
"elements": [
|
|
681
|
-
{
|
|
682
|
-
"tag": "markdown",
|
|
683
|
-
"element_id": "history_md",
|
|
684
|
-
"content": _render_output_content(content, output_mode),
|
|
685
|
-
},
|
|
686
|
-
{"tag": "column_set", "columns": columns},
|
|
687
|
-
],
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def _output_box(
|
|
692
|
-
box_id: str,
|
|
693
|
-
markdown_id: str,
|
|
694
|
-
content: str,
|
|
695
|
-
background_style: str,
|
|
696
|
-
) -> "typing.Dict[str, object]":
|
|
697
|
-
return {
|
|
698
|
-
"tag": "column_set",
|
|
699
|
-
"element_id": box_id,
|
|
700
|
-
"background_style": background_style,
|
|
701
|
-
"horizontal_spacing": "8px",
|
|
702
|
-
"horizontal_align": "left",
|
|
703
|
-
"columns": [
|
|
704
|
-
{
|
|
705
|
-
"tag": "column",
|
|
706
|
-
"width": "auto",
|
|
707
|
-
"elements": [
|
|
708
|
-
{
|
|
709
|
-
"tag": "markdown",
|
|
710
|
-
"element_id": markdown_id,
|
|
711
|
-
"content": content,
|
|
712
|
-
},
|
|
713
|
-
],
|
|
714
|
-
"vertical_spacing": "8px",
|
|
715
|
-
"horizontal_align": "left",
|
|
716
|
-
"vertical_align": "top",
|
|
717
|
-
}
|
|
718
|
-
],
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
def _is_card_content_error(exc: "BaseException") -> bool:
|
|
723
|
-
return CARD_CONTENT_ERROR_MARKER in str(exc)
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
def _dig(value: "typing.Any", *path: str) -> "typing.Any":
|
|
727
|
-
current = value
|
|
728
|
-
for key in path:
|
|
729
|
-
if not isinstance(current, dict):
|
|
730
|
-
return None
|
|
731
|
-
current = current.get(key)
|
|
732
|
-
return current
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def _env(*names: str) -> "typing.Union[str, None]":
|
|
736
|
-
for name in names:
|
|
737
|
-
value = os.environ.get(name)
|
|
738
|
-
if value:
|
|
739
|
-
return value
|
|
740
|
-
return None
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def _refresh_token_path() -> Path:
|
|
744
|
-
return Path(FEISHU_REFRESH_TOKEN_FILE).expanduser()
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def _read_refresh_token() -> "typing.Union[str, None]":
|
|
748
|
-
path = _refresh_token_path()
|
|
749
|
-
if not path.exists():
|
|
750
|
-
return None
|
|
751
|
-
value = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
752
|
-
return value or None
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
def _write_refresh_token(value: str) -> None:
|
|
756
|
-
path = _refresh_token_path()
|
|
757
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
758
|
-
descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
759
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
760
|
-
path.chmod(0o600)
|
|
761
|
-
stream.write("{0}\n".format(value))
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
def _api_base_to_domain(api_base: str) -> str:
|
|
765
|
-
base = str(api_base or FEISHU_API_BASE).rstrip("/")
|
|
766
|
-
marker = "/open-apis"
|
|
767
|
-
if base.endswith(marker):
|
|
768
|
-
return base[: -len(marker)]
|
|
769
|
-
return base
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
def _default_email_domain() -> "typing.Union[str, None]":
|
|
773
|
-
value = _env(
|
|
774
|
-
"PYCODEX_FEISHU_DEFAULT_EMAIL_DOMAIN",
|
|
775
|
-
"FEISHU_DEFAULT_EMAIL_DOMAIN",
|
|
776
|
-
"LARK_DEFAULT_EMAIL_DOMAIN",
|
|
777
|
-
)
|
|
778
|
-
if not value:
|
|
779
|
-
return None
|
|
780
|
-
return str(value).strip().lstrip("@") or None
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
def _user_id_aliases() -> "typing.Dict[str, str]":
|
|
784
|
-
raw = os.environ.get("PYCODEX_FEISHU_USER_IDS")
|
|
785
|
-
if not raw:
|
|
786
|
-
return {}
|
|
787
|
-
try:
|
|
788
|
-
payload = json.loads(raw)
|
|
789
|
-
except Exception:
|
|
790
|
-
return {}
|
|
791
|
-
if not isinstance(payload, dict):
|
|
792
|
-
return {}
|
|
793
|
-
return {str(key): str(value) for key, value in payload.items() if key and value}
|