dsh-plugin-image-workbench 0.0.0-stage → 1.1.6
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.
- package/LICENSE +21 -0
- package/README.en.md +258 -0
- package/README.md +260 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +692 -0
- package/install.mjs +333 -0
- package/package.json +58 -4
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- 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
|