dsh-plugin-image-workbench 0.0.0-stage → 1.1.5

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +256 -0
  3. package/README.md +256 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +641 -0
  7. package/install.mjs +387 -0
  8. package/package.json +57 -4
  9. package/scripts/vendor-snapshot.py +154 -0
  10. package/server/access.py +26 -0
  11. package/server/app.py +1329 -0
  12. package/server/catalog.json +1 -0
  13. package/server/catalog.py +147 -0
  14. package/server/composer.py +391 -0
  15. package/server/drafts.py +158 -0
  16. package/server/history.py +575 -0
  17. package/server/imaging.py +172 -0
  18. package/server/pool.py +1335 -0
  19. package/server/pool_admin.py +921 -0
  20. package/server/pool_service.py +1073 -0
  21. package/server/requirements.txt +6 -0
  22. package/server/smoke_check.py +402 -0
  23. package/server/static/access.html +202 -0
  24. package/server/static/index.html +4725 -0
  25. package/vendor/chatgpt2api/README.md +13 -0
  26. package/vendor/chatgpt2api/SOURCE.json +11 -0
  27. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  28. package/vendor/chatgpt2api/source/.env.example +82 -0
  29. package/vendor/chatgpt2api/source/.python-version +1 -0
  30. package/vendor/chatgpt2api/source/LICENSE +21 -0
  31. package/vendor/chatgpt2api/source/README.md +391 -0
  32. package/vendor/chatgpt2api/source/VERSION +1 -0
  33. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  34. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  35. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  36. package/vendor/chatgpt2api/source/api/app.py +62 -0
  37. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  38. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  39. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  40. package/vendor/chatgpt2api/source/api/support.py +134 -0
  41. package/vendor/chatgpt2api/source/api/system.py +365 -0
  42. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  43. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  44. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  45. package/vendor/chatgpt2api/source/config.default.json +108 -0
  46. package/vendor/chatgpt2api/source/main.py +9 -0
  47. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  48. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  49. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  50. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  51. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  52. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  53. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  54. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  55. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  56. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  57. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  58. package/vendor/chatgpt2api/source/services/config.py +667 -0
  59. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  60. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  61. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  62. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  63. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  64. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  65. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  66. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  67. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  68. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  69. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  70. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  71. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  72. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  73. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  74. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  75. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  76. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  82. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  83. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  84. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  85. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  86. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  87. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  88. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  89. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  90. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  91. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  92. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  93. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  94. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  95. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  96. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  97. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  98. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  99. package/vendor/chatgpt2api/source/uv.lock +904 -0
  100. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  101. package/vendor/chatgpt2api/source/web/components.json +24 -0
  102. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  103. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  104. package/vendor/chatgpt2api/source/web/package.json +58 -0
  105. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  106. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  107. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  112. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  120. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  121. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  122. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  127. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  128. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  129. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  130. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  147. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  148. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  149. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  151. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  153. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  154. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  173. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  174. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  175. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  176. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  182. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  183. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  184. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  185. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import imaplib
5
+ import json
6
+ import re
7
+ import sys
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from email import message_from_bytes, policy
13
+ from email.header import decode_header, make_header
14
+ from email.message import Message
15
+ from email.utils import parsedate_to_datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
21
+ GRAPH_MESSAGES_URL = "https://graph.microsoft.com/v1.0/me/messages"
22
+ GRAPH_SCOPE = "offline_access https://graph.microsoft.com/Mail.Read"
23
+ IMAP_SCOPE = "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"
24
+ DEFAULT_IMAP_HOST = "outlook.office365.com"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OutlookCredential:
29
+ email: str
30
+ password: str
31
+ client_id: str
32
+ refresh_token: str
33
+ line_number: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HttpResponse:
38
+ status_code: int
39
+ text: str
40
+
41
+ def json(self) -> Any:
42
+ return json.loads(self.text)
43
+
44
+
45
+ def _clean(value: str) -> str:
46
+ return value.replace("\ufeff", "").replace("\u00a0", " ").strip()
47
+
48
+
49
+ def _redact_email(email: str) -> str:
50
+ local, sep, domain = email.partition("@")
51
+ if not sep:
52
+ return "***"
53
+ if len(local) <= 2:
54
+ masked = local[:1] + "***"
55
+ else:
56
+ masked = local[:2] + "***" + local[-1:]
57
+ return f"{masked}@{domain}"
58
+
59
+
60
+ def parse_credentials(path: Path) -> list[OutlookCredential]:
61
+ credentials: list[OutlookCredential] = []
62
+ for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1):
63
+ line = _clean(raw_line)
64
+ if not line or "----" not in line:
65
+ continue
66
+ parts = [_clean(part) for part in line.split("----", 3)]
67
+ if len(parts) != 4:
68
+ continue
69
+ email, password, client_id, refresh_token = parts
70
+ if "@" not in email or not client_id or not refresh_token:
71
+ continue
72
+ credentials.append(
73
+ OutlookCredential(
74
+ email=email,
75
+ password=password,
76
+ client_id=client_id,
77
+ refresh_token=refresh_token,
78
+ line_number=line_number,
79
+ )
80
+ )
81
+ return credentials
82
+
83
+
84
+ def _http_request(
85
+ method: str,
86
+ url: str,
87
+ *,
88
+ headers: dict[str, str] | None = None,
89
+ data: dict[str, str] | None = None,
90
+ params: dict[str, Any] | None = None,
91
+ timeout: float = 30,
92
+ ) -> HttpResponse:
93
+ target = url
94
+ if params:
95
+ query = urllib.parse.urlencode(params)
96
+ target = f"{url}?{query}"
97
+
98
+ body: bytes | None = None
99
+ request_headers = dict(headers or {})
100
+ if data is not None:
101
+ body = urllib.parse.urlencode(data).encode("utf-8")
102
+ request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
103
+
104
+ request = urllib.request.Request(target, data=body, headers=request_headers, method=method.upper())
105
+ try:
106
+ with urllib.request.urlopen(request, timeout=timeout) as response:
107
+ return HttpResponse(
108
+ status_code=int(response.status),
109
+ text=response.read().decode("utf-8", errors="replace"),
110
+ )
111
+ except urllib.error.HTTPError as error:
112
+ return HttpResponse(
113
+ status_code=int(error.code),
114
+ text=error.read().decode("utf-8", errors="replace"),
115
+ )
116
+
117
+
118
+ def exchange_refresh_token(credential: OutlookCredential, scope: str, timeout: float) -> str:
119
+ response = _http_request(
120
+ "POST",
121
+ TOKEN_URL,
122
+ data={
123
+ "client_id": credential.client_id,
124
+ "grant_type": "refresh_token",
125
+ "refresh_token": credential.refresh_token,
126
+ "scope": scope,
127
+ },
128
+ timeout=timeout,
129
+ )
130
+ try:
131
+ data = response.json()
132
+ except Exception:
133
+ data = {}
134
+ if response.status_code != 200:
135
+ detail = data.get("error_description") or data.get("error") or response.text[:300]
136
+ raise RuntimeError(f"token refresh failed: HTTP {response.status_code}, {detail}")
137
+ access_token = str(data.get("access_token") or "").strip()
138
+ if not access_token:
139
+ raise RuntimeError("token refresh response did not include access_token")
140
+ return access_token
141
+
142
+
143
+ def _graph_sender(message: dict[str, Any]) -> str:
144
+ sender = message.get("from") or {}
145
+ if isinstance(sender, dict):
146
+ address = sender.get("emailAddress") or {}
147
+ if isinstance(address, dict):
148
+ return str(address.get("address") or address.get("name") or "")
149
+ return ""
150
+
151
+
152
+ def read_graph_messages(access_token: str, limit: int, timeout: float) -> list[dict[str, str]]:
153
+ response = _http_request(
154
+ "GET",
155
+ GRAPH_MESSAGES_URL,
156
+ headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
157
+ params={
158
+ "$top": max(1, min(limit, 50)),
159
+ "$orderby": "receivedDateTime desc",
160
+ "$select": "subject,receivedDateTime,from,bodyPreview",
161
+ },
162
+ timeout=timeout,
163
+ )
164
+ try:
165
+ data = response.json()
166
+ except Exception:
167
+ data = {}
168
+ if response.status_code != 200:
169
+ detail = data.get("error", {}).get("message") if isinstance(data.get("error"), dict) else response.text[:300]
170
+ raise RuntimeError(f"graph messages failed: HTTP {response.status_code}, {detail}")
171
+ items = data.get("value") if isinstance(data, dict) else None
172
+ if not isinstance(items, list):
173
+ raise RuntimeError("graph messages response did not include value[]")
174
+ return [
175
+ {
176
+ "received": str(item.get("receivedDateTime") or ""),
177
+ "from": _graph_sender(item),
178
+ "subject": str(item.get("subject") or ""),
179
+ "preview": str(item.get("bodyPreview") or ""),
180
+ }
181
+ for item in items
182
+ if isinstance(item, dict)
183
+ ]
184
+
185
+
186
+ def _decode_header(value: str | None) -> str:
187
+ if not value:
188
+ return ""
189
+ try:
190
+ return str(make_header(decode_header(value)))
191
+ except Exception:
192
+ return value
193
+
194
+
195
+ def _message_text_preview(message: Message, limit: int = 240) -> str:
196
+ text_parts: list[str] = []
197
+ parts = message.walk() if message.is_multipart() else [message]
198
+ for part in parts:
199
+ if part.get_content_maintype() == "multipart":
200
+ continue
201
+ content_type = part.get_content_type()
202
+ if content_type not in {"text/plain", "text/html"}:
203
+ continue
204
+ try:
205
+ payload = part.get_content()
206
+ except Exception:
207
+ continue
208
+ if payload:
209
+ text_parts.append(str(payload))
210
+ if content_type == "text/plain" and text_parts:
211
+ break
212
+ text = "\n".join(text_parts)
213
+ text = re.sub(r"<[^>]+>", " ", text)
214
+ text = re.sub(r"\s+", " ", text).strip()
215
+ return text[:limit]
216
+
217
+
218
+ def _parse_imap_message(raw: bytes, include_preview: bool) -> dict[str, str]:
219
+ message = message_from_bytes(raw, policy=policy.default)
220
+ received = ""
221
+ try:
222
+ parsed = parsedate_to_datetime(str(message.get("Date") or ""))
223
+ received = parsed.isoformat()
224
+ except Exception:
225
+ received = str(message.get("Date") or "")
226
+ return {
227
+ "received": received,
228
+ "from": _decode_header(str(message.get("From") or "")),
229
+ "subject": _decode_header(str(message.get("Subject") or "")),
230
+ "preview": _message_text_preview(message) if include_preview else "",
231
+ }
232
+
233
+
234
+ def read_imap_messages(access_token: str, email: str, host: str, limit: int, include_preview: bool) -> list[dict[str, str]]:
235
+ auth_string = f"user={email}\x01auth=Bearer {access_token}\x01\x01"
236
+ mailbox = imaplib.IMAP4_SSL(host)
237
+ try:
238
+ mailbox.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
239
+ status, _ = mailbox.select("INBOX", readonly=True)
240
+ if status != "OK":
241
+ raise RuntimeError("imap select INBOX failed")
242
+ status, data = mailbox.uid("search", None, "ALL")
243
+ if status != "OK" or not data or not data[0]:
244
+ return []
245
+ uids = data[0].split()[-max(1, limit) :]
246
+ messages: list[dict[str, str]] = []
247
+ for uid in reversed(uids):
248
+ status, fetched = mailbox.uid("fetch", uid, "(RFC822)")
249
+ if status != "OK":
250
+ continue
251
+ raw_payload = next((item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)), b"")
252
+ if raw_payload:
253
+ messages.append(_parse_imap_message(raw_payload, include_preview))
254
+ return messages
255
+ finally:
256
+ try:
257
+ mailbox.logout()
258
+ except Exception:
259
+ pass
260
+
261
+
262
+ def print_messages(messages: list[dict[str, str]], include_preview: bool) -> None:
263
+ if not messages:
264
+ print(" no recent messages")
265
+ return
266
+ for index, message in enumerate(messages, start=1):
267
+ print(f" {index}. {message['received']} | {message['from']} | {message['subject']}")
268
+ if include_preview and message.get("preview"):
269
+ print(f" preview: {message['preview']}")
270
+
271
+
272
+ def test_credential(
273
+ credential: OutlookCredential,
274
+ mode: str,
275
+ limit: int,
276
+ timeout: float,
277
+ imap_host: str,
278
+ include_preview: bool,
279
+ show_email: bool,
280
+ ) -> bool:
281
+ label = credential.email if show_email else _redact_email(credential.email)
282
+ print(f"[line {credential.line_number}] {label}")
283
+ errors: list[str] = []
284
+
285
+ if mode in {"graph", "auto"}:
286
+ try:
287
+ access_token = exchange_refresh_token(credential, GRAPH_SCOPE, timeout)
288
+ messages = read_graph_messages(access_token, limit, timeout)
289
+ print(" graph: ok")
290
+ print_messages(messages, include_preview)
291
+ return True
292
+ except Exception as error:
293
+ errors.append(f"graph: {error}")
294
+ if mode == "graph":
295
+ print(f" graph: failed - {error}")
296
+ return False
297
+
298
+ if mode in {"imap", "auto"}:
299
+ try:
300
+ access_token = exchange_refresh_token(credential, IMAP_SCOPE, timeout)
301
+ messages = read_imap_messages(access_token, credential.email, imap_host, limit, include_preview)
302
+ print(" imap: ok")
303
+ print_messages(messages, include_preview)
304
+ return True
305
+ except Exception as error:
306
+ errors.append(f"imap: {error}")
307
+ if mode == "imap":
308
+ print(f" imap: failed - {error}")
309
+ return False
310
+
311
+ print(" failed")
312
+ for error in errors:
313
+ print(f" - {error}")
314
+ return False
315
+
316
+
317
+ def build_parser() -> argparse.ArgumentParser:
318
+ parser = argparse.ArgumentParser(
319
+ description="Test Outlook/Hotmail mailbox access from lines formatted as email----password----client_id----refresh_token.",
320
+ )
321
+ parser.add_argument("--file", default=r"D:\Desktop\yx.txt", help="Credential text file path.")
322
+ parser.add_argument("--mode", choices=("auto", "graph", "imap"), default="auto", help="Mailbox read method.")
323
+ parser.add_argument("--limit-accounts", type=int, default=1, help="How many accounts to test.")
324
+ parser.add_argument("--message-limit", type=int, default=5, help="How many recent messages to list per account.")
325
+ parser.add_argument("--timeout", type=float, default=30, help="HTTP request timeout in seconds.")
326
+ parser.add_argument("--imap-host", default=DEFAULT_IMAP_HOST, help="IMAP host for XOAUTH2 mode.")
327
+ parser.add_argument("--preview", action="store_true", help="Print body preview/snippet. Disabled by default.")
328
+ parser.add_argument("--show-email", action="store_true", help="Print full email address. Secrets are never printed.")
329
+ parser.add_argument("--json", action="store_true", help="Only parse the file and print non-secret account metadata as JSON.")
330
+ return parser
331
+
332
+
333
+ def main() -> int:
334
+ args = build_parser().parse_args()
335
+ path = Path(args.file)
336
+ if not path.exists():
337
+ print(f"file not found: {path}", file=sys.stderr)
338
+ return 2
339
+
340
+ credentials = parse_credentials(path)
341
+ if not credentials:
342
+ print(f"no valid credentials parsed from: {path}", file=sys.stderr)
343
+ return 2
344
+
345
+ limit_accounts = max(1, int(args.limit_accounts or 1))
346
+ selected = credentials[:limit_accounts]
347
+
348
+ if args.json:
349
+ print(
350
+ json.dumps(
351
+ [
352
+ {
353
+ "line": item.line_number,
354
+ "email": item.email if args.show_email else _redact_email(item.email),
355
+ "client_id": item.client_id,
356
+ "has_password": bool(item.password),
357
+ "has_refresh_token": bool(item.refresh_token),
358
+ }
359
+ for item in selected
360
+ ],
361
+ ensure_ascii=False,
362
+ indent=2,
363
+ )
364
+ )
365
+ return 0
366
+
367
+ print(f"parsed {len(credentials)} credential(s), testing {len(selected)}")
368
+ success = 0
369
+ for credential in selected:
370
+ if test_credential(
371
+ credential=credential,
372
+ mode=str(args.mode),
373
+ limit=max(1, int(args.message_limit or 1)),
374
+ timeout=max(1.0, float(args.timeout or 30)),
375
+ imap_host=str(args.imap_host),
376
+ include_preview=bool(args.preview),
377
+ show_email=bool(args.show_email),
378
+ ):
379
+ success += 1
380
+ print(f"summary: {success}/{len(selected)} account(s) readable")
381
+ return 0 if success == len(selected) else 1
382
+
383
+
384
+ if __name__ == "__main__":
385
+ raise SystemExit(main())
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 存储后端测试脚本
4
+
5
+ 用法:
6
+ python scripts/test_storage.py
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # 添加项目根目录到 Python 路径
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+
16
+ DATA_DIR = Path(__file__).resolve().parents[1] / "data"
17
+
18
+ from services.storage.factory import create_storage_backend
19
+
20
+
21
+ def test_storage():
22
+ """测试当前配置的存储后端"""
23
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
24
+ print("=" * 60)
25
+ print("ChatGPT2API 存储后端测试")
26
+ print("=" * 60)
27
+
28
+ # 显示当前配置
29
+ backend_type = os.getenv("STORAGE_BACKEND", "json")
30
+ print(f"\n当前存储后端: {backend_type}")
31
+
32
+ if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
33
+ database_url = os.getenv("DATABASE_URL", "")
34
+ if database_url:
35
+ # 隐藏密码
36
+ if "://" in database_url and "@" in database_url:
37
+ protocol, rest = database_url.split("://", 1)
38
+ if "@" in rest:
39
+ credentials, host = rest.split("@", 1)
40
+ if ":" in credentials:
41
+ username, _ = credentials.split(":", 1)
42
+ database_url = f"{protocol}://{username}:****@{host}"
43
+ print(f"数据库连接: {database_url}")
44
+ else:
45
+ print(f"数据库连接: 本地 SQLite (data/accounts.db)")
46
+
47
+ elif backend_type == "git":
48
+ repo_url = os.getenv("GIT_REPO_URL", "")
49
+ branch = os.getenv("GIT_BRANCH", "main")
50
+ file_path = os.getenv("GIT_FILE_PATH", "accounts.json")
51
+ print(f"Git 仓库: {repo_url}")
52
+ print(f"Git 分支: {branch}")
53
+ print(f"文件路径: {file_path}")
54
+
55
+ print("\n" + "=" * 60)
56
+
57
+ try:
58
+ # 创建存储后端
59
+ print("\n[1/5] 创建存储后端...")
60
+ storage = create_storage_backend(DATA_DIR)
61
+ print("✅ 存储后端创建成功")
62
+
63
+ # 获取后端信息
64
+ print("\n[2/5] 获取后端信息...")
65
+ info = storage.get_backend_info()
66
+ print(f"✅ 后端类型: {info.get('type')}")
67
+ print(f" 描述: {info.get('description')}")
68
+ for key, value in info.items():
69
+ if key not in ('type', 'description'):
70
+ print(f" {key}: {value}")
71
+
72
+ # 健康检查
73
+ print("\n[3/5] 执行健康检查...")
74
+ health = storage.health_check()
75
+ status = health.get("status")
76
+ if status == "healthy":
77
+ print(f"✅ 健康状态: {status}")
78
+ else:
79
+ print(f"❌ 健康状态: {status}")
80
+ print(f" 错误: {health.get('error')}")
81
+ return False
82
+
83
+ # 读取数据
84
+ print("\n[4/5] 读取账号数据...")
85
+ accounts = storage.load_accounts()
86
+ print(f"✅ 成功读取 {len(accounts)} 个账号")
87
+
88
+ # 写入测试(可选)
89
+ print("\n[5/5] 测试写入功能...")
90
+ test_account = {
91
+ "access_token": "test_token_" + str(os.getpid()),
92
+ "type": "Free",
93
+ "status": "测试",
94
+ "quota": 0,
95
+ "email": "test@example.com",
96
+ }
97
+
98
+ # 添加测试账号
99
+ test_accounts = accounts + [test_account]
100
+ storage.save_accounts(test_accounts)
101
+ print("✅ 写入测试账号成功")
102
+
103
+ # 验证写入
104
+ reloaded = storage.load_accounts()
105
+ if len(reloaded) == len(test_accounts):
106
+ print("✅ 验证写入成功")
107
+ else:
108
+ print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个")
109
+ return False
110
+
111
+ # 恢复原始数据
112
+ storage.save_accounts(accounts)
113
+ print("✅ 恢复原始数据")
114
+
115
+ print("\n" + "=" * 60)
116
+ print("✅ 所有测试通过!")
117
+ print("=" * 60)
118
+ return True
119
+
120
+ except Exception as e:
121
+ print(f"\n❌ 测试失败: {e}")
122
+ import traceback
123
+ traceback.print_exc()
124
+ return False
125
+
126
+
127
+ if __name__ == "__main__":
128
+ success = test_storage()
129
+ sys.exit(0 if success else 1)
@@ -0,0 +1,92 @@
1
+ """验证 OAuth 账号的自动刷新是否生效。
2
+
3
+ 用法(在容器内,工作目录 /app):
4
+ uv run python scripts/verify_oauth_refresh.py # 只读诊断,安全
5
+ uv run python scripts/verify_oauth_refresh.py --force # 真正触发一次刷新
6
+
7
+ 只读模式:列出每个账号的 access_token 剩余有效期、是否带 refresh_token、
8
+ 上次刷新时间与上次刷新错误,判断"有没有料可刷"。
9
+ --force :对每个带 refresh_token 的账号强制走一次 refresh_access_token(force=True),
10
+ 直接验证 refresh_token + 本项目 client_id 能否从 OpenAI 换出新 access_token。
11
+ 这会真实轮换 access_token(新 token 有效,不损坏账号)。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+
17
+ from services.account_service import account_service
18
+
19
+
20
+ def _fmt_remaining(seconds: int | None) -> str:
21
+ if seconds is None:
22
+ return "无法解析 exp"
23
+ if seconds <= 0:
24
+ return f"已过期 {(-seconds) // 3600}h"
25
+ return f"{seconds // 3600}h{(seconds % 3600) // 60}m 后过期"
26
+
27
+
28
+ def diagnose() -> list[str]:
29
+ """只读:打印每个账号的刷新就绪状态,返回带 refresh_token 的 token 列表。"""
30
+ tokens = account_service.list_tokens()
31
+ print(f"账号总数: {len(tokens)}\n")
32
+ refreshable: list[str] = []
33
+ for token in tokens:
34
+ account = account_service.get_account(token) or {}
35
+ remaining = account_service._token_expires_in(token)
36
+ has_rt = bool(str(account.get("refresh_token") or "").strip())
37
+ if has_rt:
38
+ refreshable.append(token)
39
+ print(f"- email={account.get('email') or '(未知)'}")
40
+ print(f" access_token[:20] = {token[:20]}...")
41
+ print(f" 距过期 = {_fmt_remaining(remaining)}")
42
+ print(f" refresh_token = {'有 ✅' if has_rt else '无 ❌(无法自动刷新)'}")
43
+ print(f" last_token_refresh_at = {account.get('last_token_refresh_at')}")
44
+ print(f" last_token_refresh_error = {account.get('last_token_refresh_error')}")
45
+ print()
46
+ return refreshable
47
+
48
+
49
+ def force_refresh(tokens: list[str]) -> None:
50
+ """对每个账号 force 刷新一次,并对比前后状态判断成败。"""
51
+ if not tokens:
52
+ print("没有带 refresh_token 的账号,无法验证刷新。")
53
+ return
54
+ print("=" * 60)
55
+ print(f"开始对 {len(tokens)} 个账号 force 刷新(真实调用 OpenAI)...\n")
56
+ ok = 0
57
+ for token in tokens:
58
+ before = account_service.get_account(token) or {}
59
+ new_token = account_service.refresh_access_token(token, force=True, event="manual_verify")
60
+ after = account_service.get_account(new_token) or {}
61
+ err = str(after.get("last_token_refresh_error") or "").strip()
62
+ rotated = new_token != token
63
+ success = bool(new_token) and not err
64
+ if success:
65
+ ok += 1
66
+ print(f"- email={before.get('email') or '(未知)'}")
67
+ print(f" 旧 access_token[:20] = {token[:20]}...")
68
+ print(f" 新 access_token[:20] = {new_token[:20]}...")
69
+ print(f" token 是否轮换 = {'是' if rotated else '否(exp 未到刷新窗口时可能返回原值)'}")
70
+ print(f" last_token_refresh_at = {after.get('last_token_refresh_at')}")
71
+ print(f" last_token_refresh_error = {after.get('last_token_refresh_error') or '无'}")
72
+ print(f" >>> 刷新结果 = {'成功 ✅' if success else '失败 ❌'}")
73
+ print()
74
+ print("=" * 60)
75
+ print(f"汇总: {ok}/{len(tokens)} 个账号刷新成功")
76
+ if ok == len(tokens):
77
+ print("✅ 自动刷新机制对这些账号完全可用——refresh_token 与 client_id 匹配。")
78
+ else:
79
+ print("❌ 有账号刷新失败,看上面的 last_token_refresh_error,或 docker logs 里的 [oauth-login]/refresh 日志。")
80
+
81
+
82
+ def main() -> None:
83
+ do_force = "--force" in sys.argv[1:]
84
+ refreshable = diagnose()
85
+ if do_force:
86
+ force_refresh(refreshable)
87
+ else:
88
+ print("提示: 加 --force 参数可真正触发一次刷新以验证能否成功。")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
File without changes