@cartanova/qgrid-cli 2.9.4 → 2.9.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.
- package/bundle/dist/application/qgrid/expired-token-reminder.js +6 -5
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
- package/bundle/dist/application/qgrid/qgrid-response-format.js +8 -6
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.types.js +31 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +3 -2
- package/bundle/dist/application/setting/setting.model.js +8 -2
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +41 -20
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +145 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
- package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
- package/bundle/dist/utils/slack-notify.js +8 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
- package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
- package/bundle/src/application/qgrid/qgrid-response-format.test.ts +26 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +21 -13
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.types.ts +32 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +18 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +3 -0
- package/bundle/src/application/setting/setting.model.test.ts +20 -0
- package/bundle/src/application/setting/setting.model.ts +10 -2
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +5 -1
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +9 -1
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +136 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +44 -18
- package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +135 -0
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +177 -0
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
- package/bundle/src/utils/slack-notify.test.ts +14 -0
- package/bundle/src/utils/slack-notify.ts +19 -3
- package/bundle/web-dist/client/assets/{index-fogJvajr.js → index-BcCytvFf.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-CsY2cqvt.js → logs-BkOilk5x.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-ChgaLEcD.js → monit-8oCSLCdL.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BvyiTnOZ.js → routes-DFIF4IoW.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-ZR2J9rWb.js → settings-nUI-DR3U.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CjwO4-So.js → show-Bzx6Xkoc.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-Bx-hG1gm.js → tokens-DFWCL7Ts.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Bqax_icS.js → use-oauth-login-flow-B5MAjpVc.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +2 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +8 -0
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
|
|
5
|
+
import {
|
|
6
|
+
CHATGPT_CODEX_RESPONSES_URL,
|
|
7
|
+
OpenAIProtocolError,
|
|
8
|
+
type OpenAINormalizedEvent,
|
|
9
|
+
type OpenAIResponsesOptions,
|
|
10
|
+
} from "./openai-backend-protocol";
|
|
11
|
+
|
|
12
|
+
const MAX_PNG_BYTES = 32 * 1024 * 1024;
|
|
13
|
+
const MAX_IMAGE_PIXELS = 4096 * 4096;
|
|
14
|
+
const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
16
|
+
|
|
17
|
+
export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
|
|
18
|
+
if (options.outputSchema || options.tools?.length) {
|
|
19
|
+
throw new OpenAIProtocolError(
|
|
20
|
+
"Transparent image generation does not support tools or structured output",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const controls = options.imageGeneration;
|
|
24
|
+
if (!controls || controls === true || controls.background !== "transparent") {
|
|
25
|
+
throw new OpenAIProtocolError(
|
|
26
|
+
"Standalone images require an explicit transparent background request",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const text: string[] = [];
|
|
30
|
+
const images: Array<{ image_url: string }> = [];
|
|
31
|
+
if (options.instructions) text.push(`System instructions:\n${options.instructions}`);
|
|
32
|
+
for (const item of options.history) {
|
|
33
|
+
if (item.type === "reasoning") continue;
|
|
34
|
+
if (item.type === "image_generation_call" && typeof item.result === "string") {
|
|
35
|
+
images.push({ image_url: `data:image/png;base64,${item.result}` });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (item.type !== "message" && typeof item.role !== "string") {
|
|
39
|
+
throw new OpenAIProtocolError(
|
|
40
|
+
"Unsupported conversation item for transparent image generation",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (typeof item.content === "string") {
|
|
44
|
+
text.push(`${item.role ?? "user"}:\n${item.content}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(item.content)) {
|
|
48
|
+
throw new OpenAIProtocolError("Unsupported message content for transparent image generation");
|
|
49
|
+
}
|
|
50
|
+
for (const part of item.content) {
|
|
51
|
+
if (!part || typeof part !== "object") {
|
|
52
|
+
throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
["input_text", "output_text", "text"].includes(part.type) &&
|
|
56
|
+
typeof part.text === "string"
|
|
57
|
+
) {
|
|
58
|
+
text.push(`${item.role ?? "user"}:\n${part.text}`);
|
|
59
|
+
} else if (part.type === "input_image" || part.type === "image_url") {
|
|
60
|
+
const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
|
|
61
|
+
if (typeof url !== "string" || !/^(https:\/\/|data:image\/)/i.test(url)) {
|
|
62
|
+
throw new OpenAIProtocolError("Image references must be HTTPS URLs or image data URLs");
|
|
63
|
+
}
|
|
64
|
+
images.push({ image_url: url });
|
|
65
|
+
} else {
|
|
66
|
+
throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (images.length > 5)
|
|
71
|
+
throw new OpenAIProtocolError("Transparent image editing supports at most 5 reference images");
|
|
72
|
+
if (!text.some((part) => part.trim())) throw new OpenAIProtocolError("Image prompt is empty");
|
|
73
|
+
return {
|
|
74
|
+
url: CHATGPT_CODEX_RESPONSES_URL.replace(
|
|
75
|
+
/\/responses$/,
|
|
76
|
+
`/images/${images.length ? "edits" : "generations"}`,
|
|
77
|
+
),
|
|
78
|
+
body: {
|
|
79
|
+
model: "gpt-image-2",
|
|
80
|
+
prompt: text.join("\n\n"),
|
|
81
|
+
background: "transparent",
|
|
82
|
+
...(controls.quality ? { quality: controls.quality } : {}),
|
|
83
|
+
...(controls.size ? { size: controls.size } : {}),
|
|
84
|
+
...(images.length ? { images } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Decode before accepting the result: an alpha channel alone can still be fully opaque.
|
|
90
|
+
export function inspectTransparentPng(base64: string): { width: number; height: number } {
|
|
91
|
+
if (base64.length > Math.ceil(MAX_PNG_BYTES / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
|
|
92
|
+
throw new OpenAIProtocolError("Invalid or oversized generated PNG");
|
|
93
|
+
}
|
|
94
|
+
const bytes = Buffer.from(base64, "base64");
|
|
95
|
+
if (
|
|
96
|
+
bytes.length < 33 ||
|
|
97
|
+
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
|
|
98
|
+
bytes.toString("ascii", 12, 16) !== "IHDR"
|
|
99
|
+
) {
|
|
100
|
+
throw new OpenAIProtocolError("Image generation did not return a PNG");
|
|
101
|
+
}
|
|
102
|
+
const width = bytes.readUInt32BE(16);
|
|
103
|
+
const height = bytes.readUInt32BE(20);
|
|
104
|
+
if (!width || !height || width * height > MAX_IMAGE_PIXELS) {
|
|
105
|
+
throw new OpenAIProtocolError("Generated PNG dimensions exceed the decoding limit");
|
|
106
|
+
}
|
|
107
|
+
let image: PNG;
|
|
108
|
+
try {
|
|
109
|
+
image = PNG.sync.read(bytes);
|
|
110
|
+
} catch {
|
|
111
|
+
throw new OpenAIProtocolError("Image generation returned an invalid PNG");
|
|
112
|
+
}
|
|
113
|
+
let transparent = false;
|
|
114
|
+
let visible = false;
|
|
115
|
+
for (let i = 3; i < image.data.length; i += 4) {
|
|
116
|
+
transparent ||= image.data[i] === 0;
|
|
117
|
+
visible ||= image.data[i]! > 0;
|
|
118
|
+
if (transparent && visible) return { width: image.width, height: image.height };
|
|
119
|
+
}
|
|
120
|
+
throw new OpenAIProtocolError(
|
|
121
|
+
"Image generation did not return visible content on a transparent background",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readStandaloneImageResponse(
|
|
126
|
+
response: Response,
|
|
127
|
+
): Promise<OpenAINormalizedEvent[]> {
|
|
128
|
+
if (!response.body) throw new OpenAIProtocolError("Image response had no body");
|
|
129
|
+
const reader = response.body.getReader();
|
|
130
|
+
const chunks: Uint8Array[] = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
try {
|
|
133
|
+
while (true) {
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
size += value.byteLength;
|
|
137
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
138
|
+
await reader.cancel();
|
|
139
|
+
throw new OpenAIProtocolError("Image response exceeded the size limit");
|
|
140
|
+
}
|
|
141
|
+
chunks.push(value);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
reader.releaseLock();
|
|
145
|
+
}
|
|
146
|
+
const schema = z.object({
|
|
147
|
+
data: z
|
|
148
|
+
.array(z.object({ b64_json: z.string().min(1), revised_prompt: z.string().nullish() }))
|
|
149
|
+
.min(1)
|
|
150
|
+
.max(16),
|
|
151
|
+
quality: z.string().optional(),
|
|
152
|
+
usage: ImageGenerationMetadata.shape.usage,
|
|
153
|
+
});
|
|
154
|
+
let parsed: z.infer<typeof schema>;
|
|
155
|
+
try {
|
|
156
|
+
parsed = schema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
157
|
+
} catch {
|
|
158
|
+
throw new OpenAIProtocolError("Invalid standalone image response");
|
|
159
|
+
}
|
|
160
|
+
return parsed.data.map((item, index) => {
|
|
161
|
+
const dimensions = inspectTransparentPng(item.b64_json);
|
|
162
|
+
return {
|
|
163
|
+
type: "image",
|
|
164
|
+
base64: item.b64_json,
|
|
165
|
+
mimeType: "image/png",
|
|
166
|
+
...(item.revised_prompt ? { revisedPrompt: item.revised_prompt } : {}),
|
|
167
|
+
generation: {
|
|
168
|
+
route: "codex-images",
|
|
169
|
+
model: "gpt-image-2",
|
|
170
|
+
background: "transparent",
|
|
171
|
+
size: `${dimensions.width}x${dimensions.height}`,
|
|
172
|
+
...(parsed.quality ? { quality: parsed.quality } : {}),
|
|
173
|
+
...(index === 0 && parsed.usage ? { usage: parsed.usage } : {}),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -186,4 +186,36 @@ describe("readOpenAIQuotaUsage", () => {
|
|
|
186
186
|
}),
|
|
187
187
|
).resolves.toEqual({ kind: "lookup_failed", reason: "OpenAI quota lookup failed: HTTP 503" });
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
it("does not refresh a cancelled 401 lookup", async () => {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const refreshCredentials = vi.fn();
|
|
193
|
+
const result = await readOpenAIQuotaUsage({
|
|
194
|
+
credentials: { accessToken: "old", accountId: "acct" },
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
refreshCredentials,
|
|
197
|
+
fetch: async () => {
|
|
198
|
+
controller.abort(new Error("cancelled"));
|
|
199
|
+
return new Response("", { status: 401 });
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
|
|
203
|
+
expect(refreshCredentials).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does not retry when cancelled while refreshing", async () => {
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
|
209
|
+
const result = await readOpenAIQuotaUsage({
|
|
210
|
+
credentials: { accessToken: "old", accountId: "acct" },
|
|
211
|
+
signal: controller.signal,
|
|
212
|
+
fetch: fetchMock,
|
|
213
|
+
refreshCredentials: async () => {
|
|
214
|
+
controller.abort(new Error("cancelled"));
|
|
215
|
+
return { accessToken: "new", accountId: "acct" };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
|
|
219
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
220
|
+
});
|
|
189
221
|
});
|
|
@@ -23,19 +23,26 @@ export type OpenAIQuotaUsageResult =
|
|
|
23
23
|
|
|
24
24
|
export interface OpenAIQuotaHttpOptions {
|
|
25
25
|
credentials: Pick<OpenAICredentials, "accessToken" | "accountId">;
|
|
26
|
+
refreshCredentials?: () => Promise<Pick<OpenAICredentials, "accessToken" | "accountId">>;
|
|
26
27
|
fetch?: typeof fetch;
|
|
27
28
|
signal?: AbortSignal;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
async function readDirect(options: OpenAIQuotaHttpOptions): Promise<OpenAIRateLimitsWithMeta> {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
options.
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
const read = (credentials: OpenAIQuotaHttpOptions["credentials"]) =>
|
|
33
|
+
(options.fetch ?? fetch)(CHATGPT_WHAM_USAGE_URL, {
|
|
34
|
+
method: "GET",
|
|
35
|
+
headers: buildCodexIdentityHeaders(credentials.accessToken, credentials.accountId),
|
|
36
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
37
|
+
});
|
|
38
|
+
let response = await read(options.credentials);
|
|
39
|
+
if (response.status === 401 && options.refreshCredentials) {
|
|
40
|
+
await response.body?.cancel();
|
|
41
|
+
options.signal?.throwIfAborted();
|
|
42
|
+
const credentials = await options.refreshCredentials();
|
|
43
|
+
options.signal?.throwIfAborted();
|
|
44
|
+
response = await read(credentials);
|
|
45
|
+
}
|
|
39
46
|
if (!response.ok) throw new Error(`OpenAI quota lookup failed: HTTP ${response.status}`);
|
|
40
47
|
const body = (await response.json()) as Record<string, unknown>;
|
|
41
48
|
const data = normalizeWhamUsage(body);
|
|
@@ -126,4 +126,22 @@ describe("OpenAI refresh", () => {
|
|
|
126
126
|
"openai:refresh_token_reused",
|
|
127
127
|
);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
it("recognizes nested permanent error codes without exposing the provider body", async () => {
|
|
131
|
+
findOneMock.mockResolvedValue(token(12));
|
|
132
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
133
|
+
error: { code: "refresh_token_invalidated", message: "private provider detail" },
|
|
134
|
+
}), { status: 401 })));
|
|
135
|
+
await expect(handleChatgptAuthTokensRefresh(12)).rejects.toThrow(
|
|
136
|
+
"OpenAI refresh failed: 401 refresh_token_invalidated",
|
|
137
|
+
);
|
|
138
|
+
expect(deactivateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 12 }), "openai:refresh_token_invalidated");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it.each([429, 500, 503])("does not deactivate a token on transient refresh HTTP %s", async (status) => {
|
|
142
|
+
findOneMock.mockResolvedValue(token(status));
|
|
143
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("upstream failure", { status })));
|
|
144
|
+
await expect(handleChatgptAuthTokensRefresh(status)).rejects.toThrow(`OpenAI refresh failed: ${status} unknown`);
|
|
145
|
+
expect(deactivateMock).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
129
147
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI chatgptAuthTokens refresh handler.
|
|
3
3
|
*
|
|
4
|
-
* - direct
|
|
4
|
+
* - direct transport와 quota 조회의 401에서 같은 refresh 경로를 사용함
|
|
5
5
|
* - qgrid 가 DB 의 refresh_token 으로 새 access_token 을 발급받아 응답
|
|
6
6
|
* - per-token inflight promise 로 concurrent refresh dedup
|
|
7
7
|
* - rotation 감지: 새 refresh_token 있으면 DB 즉시 업데이트
|
|
@@ -104,11 +104,18 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
104
104
|
// 위 3개는 모두 영구 실패. 재등록 외 복구 불가.
|
|
105
105
|
let errorCode: string | undefined;
|
|
106
106
|
try {
|
|
107
|
-
|
|
107
|
+
const error = (JSON.parse(body) as { error?: unknown }).error;
|
|
108
|
+
const code =
|
|
109
|
+
typeof error === "string"
|
|
110
|
+
? error
|
|
111
|
+
: error && typeof error === "object" && "code" in error
|
|
112
|
+
? error.code
|
|
113
|
+
: undefined;
|
|
114
|
+
if (typeof code === "string" && /^[a-z_]{1,80}$/.test(code)) errorCode = code;
|
|
108
115
|
} catch {}
|
|
109
116
|
logger.error(
|
|
110
117
|
`OpenAI refresh FAILED token=${token.name}(id=${tokenId}) status=${resp.status} ` +
|
|
111
|
-
`code=${errorCode ?? "
|
|
118
|
+
`code=${errorCode ?? "unknown"}`,
|
|
112
119
|
);
|
|
113
120
|
// 영구 실패는 errorCode 로만 판정한다 — status 를 함께 요구하면 provider 가
|
|
114
121
|
// 상태코드를 바꿀 때 판정이 조용히 멎는다.
|
|
@@ -118,7 +125,7 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
118
125
|
`openai:${errorCode}`,
|
|
119
126
|
);
|
|
120
127
|
}
|
|
121
|
-
throw new Error(`OpenAI refresh failed: ${resp.status} ${
|
|
128
|
+
throw new Error(`OpenAI refresh failed: ${resp.status} ${errorCode ?? "unknown"}`);
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
const data = (await resp.json()) as {
|
|
@@ -19,6 +19,20 @@ const noSettings = () => undefined;
|
|
|
19
19
|
const WORKING_HOURS = seoul("2026-08-05T14:00:00");
|
|
20
20
|
|
|
21
21
|
describe("notifySlack", () => {
|
|
22
|
+
it.each([
|
|
23
|
+
["not_in_channel", () => Promise.resolve(new Response(JSON.stringify({ ok: false, error: "not_in_channel" })))],
|
|
24
|
+
["HTTP 503", () => Promise.resolve(new Response("unavailable", { status: 503 }))],
|
|
25
|
+
["network", () => Promise.reject(new Error("private network detail"))],
|
|
26
|
+
] as const)("reports %s to manual send callers", async (_name, response) => {
|
|
27
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(response);
|
|
28
|
+
await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reports missing configuration to manual send callers", async () => {
|
|
32
|
+
settingMock.mockReturnValue(undefined);
|
|
33
|
+
await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
|
|
34
|
+
});
|
|
35
|
+
|
|
22
36
|
beforeEach(() => {
|
|
23
37
|
settingMock.mockImplementation((key: string) =>
|
|
24
38
|
key === "slack.botToken" ? "xoxb-test" : key === "slack.channelId" ? "C123" : undefined,
|
|
@@ -15,6 +15,8 @@ const logger = getLogger(["qgrid", "slack"]);
|
|
|
15
15
|
const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
|
|
16
16
|
const SLACK_TIMEOUT_MS = 5_000;
|
|
17
17
|
|
|
18
|
+
export class SlackNotificationError extends Error {}
|
|
19
|
+
|
|
18
20
|
/** 상태를 색으로 먼저 읽히게 하는 attachment 색상 바. */
|
|
19
21
|
export const SLACK_COLOR = {
|
|
20
22
|
good: "#2eb886",
|
|
@@ -34,6 +36,8 @@ export type SlackNotification = {
|
|
|
34
36
|
* 커지는 사건에만 쓴다 — 남용하면 조용 시간 자체가 무의미해진다.
|
|
35
37
|
*/
|
|
36
38
|
urgent?: boolean;
|
|
39
|
+
/** Manual sends must report delivery failures; background notifications remain fail-open. */
|
|
40
|
+
throwOnFailure?: boolean;
|
|
37
41
|
/** 조용 시간 판정 기준 시각. 테스트에서 시계를 고정할 때만 넘긴다. */
|
|
38
42
|
now?: Date;
|
|
39
43
|
};
|
|
@@ -47,6 +51,9 @@ export async function notifySlack(
|
|
|
47
51
|
const { title, subject, context, color, urgent, now } = notification;
|
|
48
52
|
if (!botToken || !channel) {
|
|
49
53
|
logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
|
|
54
|
+
if (notification.throwOnFailure) {
|
|
55
|
+
throw new SlackNotificationError("Slack 봇 토큰과 채널 ID를 설정해 주세요");
|
|
56
|
+
}
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -99,10 +106,19 @@ export async function notifySlack(
|
|
|
99
106
|
// bot 미초대(not_in_channel)·잘못된 채널 등 가장 흔한 오설정은 HTTP 200 + ok:false 로 온다.
|
|
100
107
|
// status 만 보면 조용히 성공 처리돼 알림이 통째로 사라진다.
|
|
101
108
|
const body = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
|
|
102
|
-
if (!body?.ok) {
|
|
103
|
-
|
|
109
|
+
if (!res.ok || body?.ok !== true) {
|
|
110
|
+
const code =
|
|
111
|
+
typeof body?.error === "string" && /^[a-z_]{1,80}$/.test(body.error)
|
|
112
|
+
? body.error
|
|
113
|
+
: `HTTP ${res.status}`;
|
|
114
|
+
throw new SlackNotificationError(`Slack 알림 발송 실패: ${code}`);
|
|
104
115
|
}
|
|
105
116
|
} catch (e) {
|
|
106
|
-
|
|
117
|
+
const error =
|
|
118
|
+
e instanceof SlackNotificationError
|
|
119
|
+
? e
|
|
120
|
+
: new SlackNotificationError("Slack 알림 발송 실패: 네트워크 오류 또는 응답 시간 초과");
|
|
121
|
+
logger.warn(error.message);
|
|
122
|
+
if (notification.throwOnFailure) throw error;
|
|
107
123
|
}
|
|
108
124
|
}
|