netizen-cli 0.10.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 (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ """Channel transport operations used by the application and presenters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Protocol
6
+
7
+ from lark_channel import MediaSource
8
+
9
+
10
+ class ReplyChannel(Protocol):
11
+ @property
12
+ def bot_identity(self) -> Any: ...
13
+
14
+ async def reply(self, message: Any, content: Any, opts: Any = None) -> object: ...
15
+
16
+ async def send(self, to: str, content: Any, opts: Any = None) -> object: ...
17
+
18
+ async def add_reaction(self, message_id: str, emoji_type: str) -> object: ...
19
+
20
+ async def remove_reaction(
21
+ self,
22
+ message_id: str,
23
+ reaction_id: str,
24
+ ) -> object: ...
25
+
26
+ async def update_card(self, message_id: str, card: dict[str, Any]) -> object: ...
27
+
28
+ async def upload_media(
29
+ self,
30
+ source: MediaSource,
31
+ *,
32
+ kind: Literal["image", "file"],
33
+ file_name: str | None = None,
34
+ file_type: str | None = None,
35
+ ) -> str: ...
36
+
37
+ async def fetch_message(self, message_id: str) -> dict[str, Any]: ...
38
+
39
+ async def fetch_inbound_message(self, message_id: str) -> Any: ...
40
+
41
+ async def fetch_quoted_context(self, message_id: str) -> Any: ...
42
+
43
+ async def download_resource(
44
+ self,
45
+ file_key: str,
46
+ resource_type: str = "image",
47
+ message_id: str | None = None,
48
+ ) -> bytes | None: ...
49
+
50
+ async def get_chat_info(self, chat_id: str) -> Any: ...
51
+
52
+ async def get_chat_members(self, chat_id: str, *, id_type: str = "open_id") -> Any: ...
@@ -0,0 +1,51 @@
1
+ """A card submission's real operator and separate, bot-authored reply anchor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..prompt_projection import PromptProjectionError, project_identity
9
+ from ..user_questions import QuestionTarget
10
+ from .ports import ReplyChannel
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CardAnswerConversation:
15
+ chat_id: str
16
+ chat_type: str
17
+ thread_id: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CardAnswerOrigin:
22
+ app_id: str
23
+ chat_id: str
24
+ message_id: str
25
+ conversation: CardAnswerConversation
26
+ source_card_id: str
27
+ target: QuestionTarget
28
+
29
+ @property
30
+ def id(self) -> str:
31
+ return self.message_id
32
+
33
+
34
+ async def card_answer_sender(
35
+ channel: ReplyChannel, chat_id: str, operator: Any,
36
+ ) -> dict[str, Any]:
37
+ """Resolve attribution for the callback operator, never the card's author."""
38
+ sender = project_identity(operator)
39
+ open_id = sender.get("open_id")
40
+ if not open_id:
41
+ raise PromptProjectionError("卡片回调缺少回答者身份,本条回答未执行。")
42
+ if "display_name" not in sender:
43
+ members = await channel.get_chat_members(chat_id, id_type="open_id")
44
+ member = next((item for item in members if item.id == open_id), None)
45
+ name = getattr(member, "name", None)
46
+ if not isinstance(name, str) or not name.strip():
47
+ raise PromptProjectionError(
48
+ "无法获取回答者姓名,本条回答未执行;请检查群成员读取权限后重试。"
49
+ )
50
+ sender["display_name"] = name
51
+ return sender
@@ -0,0 +1,293 @@
1
+ """Best-effort lifecycle for exact Turn reactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass
8
+
9
+ from .ports import ReplyChannel
10
+
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _TYPING_REACTION = "Typing"
15
+ _THINKING_REACTION = "THINKING"
16
+ _THINKING_VISIBLE_SECONDS = 2.0
17
+ _THINKING_HIDDEN_SECONDS = 13.0
18
+ _REACTION_OPERATION_TIMEOUT_SECONDS = 3.0
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class _TurnReactionPulse:
23
+ turn_id: str
24
+ message_id: str
25
+ stopped: asyncio.Event
26
+ typing_reaction_id: str | None = None
27
+ thinking_reaction_id: str | None = None
28
+ task: asyncio.Task[None] | None = None
29
+
30
+
31
+ class _ReactionController:
32
+ """Best-effort, in-memory lifecycle for one exact Turn's reactions."""
33
+
34
+ def __init__(
35
+ self,
36
+ channel: ReplyChannel,
37
+ *,
38
+ visible_seconds: float = _THINKING_VISIBLE_SECONDS,
39
+ hidden_seconds: float = _THINKING_HIDDEN_SECONDS,
40
+ operation_timeout_seconds: float = _REACTION_OPERATION_TIMEOUT_SECONDS,
41
+ ) -> None:
42
+ if visible_seconds <= 0 or hidden_seconds <= 0:
43
+ raise ValueError("thinking reaction pulse intervals must be positive")
44
+ if operation_timeout_seconds <= 0:
45
+ raise ValueError("reaction operation timeout must be positive")
46
+ self._channel = channel
47
+ self._visible_seconds = visible_seconds
48
+ self._hidden_seconds = hidden_seconds
49
+ self._operation_timeout_seconds = operation_timeout_seconds
50
+ self._pulses: dict[str, _TurnReactionPulse] = {}
51
+ self._closed = False
52
+
53
+ async def start(
54
+ self,
55
+ turn_id: str,
56
+ message_id: str,
57
+ *,
58
+ pulse_enabled: bool,
59
+ ) -> bool:
60
+ if type(pulse_enabled) is not bool:
61
+ raise ValueError("reaction pulse setting must be a boolean")
62
+ if not turn_id or not message_id:
63
+ logger.error(
64
+ "failed to start turn reactions: missing identity",
65
+ extra={"turn_id": turn_id, "message_id": message_id},
66
+ )
67
+ return False
68
+ if self._closed:
69
+ logger.warning(
70
+ "turn reaction controller is closed",
71
+ extra={"turn_id": turn_id, "message_id": message_id},
72
+ )
73
+ return False
74
+ if turn_id in self._pulses:
75
+ await self.stop(turn_id)
76
+ pulse = _TurnReactionPulse(
77
+ turn_id=turn_id,
78
+ message_id=message_id,
79
+ stopped=asyncio.Event(),
80
+ )
81
+ self._pulses[turn_id] = pulse
82
+ reaction_id = await self._add_reaction(pulse, _TYPING_REACTION)
83
+ if self._pulses.get(turn_id) is not pulse:
84
+ if reaction_id is not None:
85
+ await self._remove_reaction(
86
+ pulse,
87
+ reaction_id,
88
+ _TYPING_REACTION,
89
+ )
90
+ return False
91
+ if reaction_id is None:
92
+ self._pulses.pop(turn_id, None)
93
+ return False
94
+ pulse.typing_reaction_id = reaction_id
95
+ if not pulse_enabled:
96
+ return True
97
+
98
+ reaction_id = await self._add_reaction(pulse, _THINKING_REACTION)
99
+ if self._pulses.get(turn_id) is not pulse:
100
+ if reaction_id is not None:
101
+ await self._remove_reaction(
102
+ pulse,
103
+ reaction_id,
104
+ _THINKING_REACTION,
105
+ )
106
+ return False
107
+ if reaction_id is None:
108
+ # Keep the stable Typing placeholder even when the optional pulse
109
+ # cannot start. Terminal/shutdown cleanup still owns its exact ID.
110
+ return True
111
+ pulse.thinking_reaction_id = reaction_id
112
+ pulse.task = asyncio.create_task(
113
+ self._pulse(pulse),
114
+ name=f"netizen-thinking-{turn_id}",
115
+ )
116
+ return True
117
+
118
+ async def freeze(self, turn_id: str) -> None:
119
+ """Stop future pulse operations without removing visible reactions."""
120
+
121
+ pulse = self._pulses.get(turn_id)
122
+ if pulse is None:
123
+ return
124
+ pulse.stopped.set()
125
+ await self._wait_for_task(pulse)
126
+
127
+ async def stop(self, turn_id: str) -> None:
128
+ pulse = self._pulses.pop(turn_id, None)
129
+ if pulse is None:
130
+ return
131
+ pulse.stopped.set()
132
+ await self._wait_for_task(pulse)
133
+ reaction_id = pulse.thinking_reaction_id
134
+ if reaction_id is not None:
135
+ if await self._remove_reaction(
136
+ pulse,
137
+ reaction_id,
138
+ _THINKING_REACTION,
139
+ ):
140
+ pulse.thinking_reaction_id = None
141
+ reaction_id = pulse.typing_reaction_id
142
+ if reaction_id is not None:
143
+ if await self._remove_reaction(
144
+ pulse,
145
+ reaction_id,
146
+ _TYPING_REACTION,
147
+ ):
148
+ pulse.typing_reaction_id = None
149
+
150
+ async def close(self) -> None:
151
+ if self._closed:
152
+ return
153
+ self._closed = True
154
+ await asyncio.gather(
155
+ *(self.stop(turn_id) for turn_id in tuple(self._pulses)),
156
+ return_exceptions=False,
157
+ )
158
+
159
+ async def _wait_for_task(self, pulse: _TurnReactionPulse) -> None:
160
+ task = pulse.task
161
+ if task is None or task is asyncio.current_task():
162
+ return
163
+ try:
164
+ await task
165
+ except asyncio.CancelledError:
166
+ raise
167
+ except Exception:
168
+ logger.exception(
169
+ "thinking reaction pulse failed while stopping",
170
+ extra={
171
+ "turn_id": pulse.turn_id,
172
+ "message_id": pulse.message_id,
173
+ },
174
+ )
175
+
176
+ async def _pulse(self, pulse: _TurnReactionPulse) -> None:
177
+ while not pulse.stopped.is_set():
178
+ if await self._wait(pulse.stopped, self._visible_seconds):
179
+ return
180
+ reaction_id = pulse.thinking_reaction_id
181
+ if reaction_id is None:
182
+ return
183
+ if not await self._remove_reaction(
184
+ pulse,
185
+ reaction_id,
186
+ _THINKING_REACTION,
187
+ ):
188
+ # Preserve the exact ID so terminal/shutdown cleanup gets one
189
+ # final best-effort removal attempt without a retry storm.
190
+ return
191
+ pulse.thinking_reaction_id = None
192
+ if await self._wait(pulse.stopped, self._hidden_seconds):
193
+ return
194
+ reaction_id = await self._add_reaction(pulse, _THINKING_REACTION)
195
+ if reaction_id is None:
196
+ return
197
+ pulse.thinking_reaction_id = reaction_id
198
+
199
+ @staticmethod
200
+ async def _wait(stopped: asyncio.Event, seconds: float) -> bool:
201
+ try:
202
+ await asyncio.wait_for(stopped.wait(), timeout=seconds)
203
+ except TimeoutError:
204
+ return False
205
+ return True
206
+
207
+ async def _add_reaction(
208
+ self,
209
+ pulse: _TurnReactionPulse,
210
+ emoji_type: str,
211
+ ) -> str | None:
212
+ try:
213
+ async with asyncio.timeout(self._operation_timeout_seconds):
214
+ result = await self._channel.add_reaction(
215
+ pulse.message_id,
216
+ emoji_type,
217
+ )
218
+ except asyncio.CancelledError:
219
+ raise
220
+ except Exception:
221
+ logger.exception(
222
+ "failed to add turn reaction",
223
+ extra={
224
+ "turn_id": pulse.turn_id,
225
+ "message_id": pulse.message_id,
226
+ "emoji_type": emoji_type,
227
+ },
228
+ )
229
+ return None
230
+ if getattr(result, "success", False) is not True:
231
+ logger.error(
232
+ "failed to add turn reaction: unsuccessful result",
233
+ extra={
234
+ "turn_id": pulse.turn_id,
235
+ "message_id": pulse.message_id,
236
+ "emoji_type": emoji_type,
237
+ },
238
+ )
239
+ return None
240
+ raw = getattr(result, "raw", None)
241
+ if raw is None and isinstance(result, dict):
242
+ raw = result
243
+ data = raw.get("data") if isinstance(raw, dict) else None
244
+ reaction_id = data.get("reaction_id") if isinstance(data, dict) else None
245
+ if not isinstance(reaction_id, str) or not reaction_id:
246
+ logger.error(
247
+ "failed to add turn reaction: missing reaction ID",
248
+ extra={
249
+ "turn_id": pulse.turn_id,
250
+ "message_id": pulse.message_id,
251
+ "emoji_type": emoji_type,
252
+ },
253
+ )
254
+ return None
255
+ return reaction_id
256
+
257
+ async def _remove_reaction(
258
+ self,
259
+ pulse: _TurnReactionPulse,
260
+ reaction_id: str,
261
+ emoji_type: str,
262
+ ) -> bool:
263
+ try:
264
+ async with asyncio.timeout(self._operation_timeout_seconds):
265
+ result = await self._channel.remove_reaction(
266
+ pulse.message_id,
267
+ reaction_id,
268
+ )
269
+ except asyncio.CancelledError:
270
+ raise
271
+ except Exception:
272
+ logger.exception(
273
+ "failed to remove turn reaction",
274
+ extra={
275
+ "turn_id": pulse.turn_id,
276
+ "message_id": pulse.message_id,
277
+ "reaction_id": reaction_id,
278
+ "emoji_type": emoji_type,
279
+ },
280
+ )
281
+ return False
282
+ if getattr(result, "success", False) is not True:
283
+ logger.error(
284
+ "failed to remove turn reaction: unsuccessful result",
285
+ extra={
286
+ "turn_id": pulse.turn_id,
287
+ "message_id": pulse.message_id,
288
+ "reaction_id": reaction_id,
289
+ "emoji_type": emoji_type,
290
+ },
291
+ )
292
+ return False
293
+ return True