@cartanova/qgrid-cli 2.6.6 → 2.6.8
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/monit/monit.frame.js +2 -2
- package/bundle/dist/application/qgrid/expired-token-reminder.js +37 -16
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +3 -3
- package/bundle/dist/application/qgrid/slack-user-map.js +5 -14
- package/bundle/dist/application/qgrid/token-death.js +3 -2
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/queries.generated.js +8 -2
- package/bundle/dist/application/request-log/request-log.model.js +2 -2
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/setting/setting.model.js +236 -0
- package/bundle/dist/application/setting/setting.schema.js +157 -0
- package/bundle/dist/application/setting/setting.store.js +66 -0
- package/bundle/dist/application/setting/setting.types.js +48 -0
- package/bundle/dist/application/sonamu.generated.js +32 -2
- package/bundle/dist/application/sonamu.generated.sso.js +12 -2
- package/bundle/dist/application/token/token.model.js +1 -1
- package/bundle/dist/i18n/en.js +3 -1
- package/bundle/dist/i18n/ko.js +3 -1
- package/bundle/dist/i18n/sd.generated.js +9 -1
- package/bundle/dist/migrations/20260808194708_create__settings.js +24 -0
- package/bundle/dist/sonamu.config.js +8 -2
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +21 -6
- package/bundle/dist/utils/quiet-hours.js +43 -0
- package/bundle/dist/utils/slack-notify.js +12 -5
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +135 -13
- package/bundle/src/application/qgrid/expired-token-reminder.ts +60 -21
- package/bundle/src/application/qgrid/slack-user-map.test.ts +23 -0
- package/bundle/src/application/qgrid/slack-user-map.ts +4 -14
- package/bundle/src/application/qgrid/token-death.ts +2 -0
- package/bundle/src/application/queries.generated.ts +21 -1
- package/bundle/src/application/setting/setting.entity.json +59 -0
- package/bundle/src/application/setting/setting.model.ts +263 -0
- package/bundle/src/application/setting/setting.schema.test.ts +80 -0
- package/bundle/src/application/setting/setting.schema.ts +174 -0
- package/bundle/src/application/setting/setting.store.test.ts +87 -0
- package/bundle/src/application/setting/setting.store.ts +85 -0
- package/bundle/src/application/setting/setting.types.ts +51 -0
- package/bundle/src/application/sonamu.generated.http +47 -0
- package/bundle/src/application/sonamu.generated.sso.ts +21 -0
- package/bundle/src/application/sonamu.generated.ts +51 -0
- package/bundle/src/i18n/en.ts +2 -0
- package/bundle/src/i18n/ko.ts +2 -0
- package/bundle/src/i18n/sd.generated.ts +8 -0
- package/bundle/src/migrations/20260808194708_create__settings.ts +19 -0
- package/bundle/src/sonamu.config.ts +10 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +43 -5
- package/bundle/src/utils/quiet-hours.ts +40 -0
- package/bundle/src/utils/slack-notify.test.ts +88 -20
- package/bundle/src/utils/slack-notify.ts +24 -4
- package/bundle/web-dist/client/assets/index-7SZyiqAx.css +1 -0
- package/bundle/web-dist/client/assets/index-DrsXE13r.js +78 -0
- package/bundle/web-dist/client/assets/{logs-CvmI4KuC.js → logs-e4-6aYnO.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CUueNzoL.js → monit-eOAlYTK9.js} +1 -1
- package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
- package/bundle/web-dist/client/assets/{routes-CAfjRsMY.js → routes-Qie4ogtJ.js} +2 -2
- package/bundle/web-dist/client/assets/{sd.generated-DYdwSB2I.js → sd.generated-Bwa7rses.js} +1 -1
- package/bundle/web-dist/client/assets/services.generated-T4ctVcia.js +1 -0
- package/bundle/web-dist/client/assets/settings-CTVZpAAg.js +1 -0
- package/bundle/web-dist/client/assets/{show-C5vKpp5z.js → show-60iOOM-e.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-DdDccGlX.js +1 -0
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-B3o4CXDW.js → use-oauth-login-flow-0ZGq74bZ.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BaZaveNc.js → lazyRouteComponent-Ws7I6fL-.js} +2 -1
- package/bundle/web-dist/server/assets/{logs-DMOw3SjD.js → logs-DmkzGxGU.js} +4 -4
- package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-DyKLJX4s.js} +3 -2
- package/bundle/web-dist/server/assets/plus-BISXNqRU.js +19 -0
- package/bundle/web-dist/server/assets/react-dom-D7duWOwf.js +462 -0
- package/bundle/web-dist/server/assets/{routes-BfoQmYud.js → routes-g62E9xmC.js} +5 -4
- package/bundle/web-dist/server/assets/{sd.generated-B0xqnHEo.js → sd.generated-LO-h1_16.js} +13 -461
- package/bundle/web-dist/server/assets/{services.generated-DqAKskAS.js → services.generated-DADr9OGH.js} +111 -2
- package/bundle/web-dist/server/assets/settings-CMJKayff.js +332 -0
- package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BGAiCPGq.js} +4 -4
- package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DmhIfVct.js} +6 -21
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-B6MY65IN.js} +2 -2
- package/bundle/web-dist/server/entry-server.generated.js +54 -18
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +14 -2
- package/skills/qgrid/references/openai-codex-runtime.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -1
- package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
- package/bundle/web-dist/client/assets/index-qs8K8bOv.js +0 -78
- package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
- package/bundle/web-dist/client/assets/tokens-C9KKP2Yq.js +0 -1
- /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/utils/quiet-hours.ts
|
|
2
|
+
/**
|
|
3
|
+
* Slack 알림 조용 시간.
|
|
4
|
+
*
|
|
5
|
+
* 토큰 만료는 사람이 재로그인해야 풀리는데, 그 사람이 자거나 쉬는 동안 알려도 할 수 있는
|
|
6
|
+
* 일이 없다. 평일 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
|
|
7
|
+
* 주기적으로 반복되므로 조용 시간에 버려도 아침 첫 주기에 다시 온다.
|
|
8
|
+
*
|
|
9
|
+
* 다만 provider 의 마지막 토큰이 죽는 것은 서비스 정지 신호라 이 규칙에서 제외한다
|
|
10
|
+
* (호출부가 `notifySlack` 을 그냥 호출하면 된다).
|
|
11
|
+
*/
|
|
12
|
+
/** 이 시각부터(포함) 조용해진다. */
|
|
13
|
+
const QUIET_FROM_HOUR = 20;
|
|
14
|
+
/** 이 시각부터(포함) 다시 알린다. */
|
|
15
|
+
const QUIET_UNTIL_HOUR = 8;
|
|
16
|
+
/**
|
|
17
|
+
* 서버가 UTC 로 돌아도 한국 시간으로 판정한다. dev0 는 UTC 이므로 `getHours()` 를 그대로
|
|
18
|
+
* 쓰면 "저녁 8시"가 한국 시간 새벽 5시가 된다.
|
|
19
|
+
*/
|
|
20
|
+
function seoulParts(at) {
|
|
21
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
22
|
+
timeZone: "Asia/Seoul",
|
|
23
|
+
hour: "numeric",
|
|
24
|
+
hour12: false,
|
|
25
|
+
weekday: "short"
|
|
26
|
+
}).formatToParts(at);
|
|
27
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
|
|
28
|
+
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "";
|
|
29
|
+
return {
|
|
30
|
+
hour: hour % 24,
|
|
31
|
+
weekday
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** 주말이거나 20시~익일 8시면 조용 시간. */
|
|
35
|
+
function isQuietHours(at = /* @__PURE__ */ new Date()) {
|
|
36
|
+
const { hour, weekday } = seoulParts(at);
|
|
37
|
+
if (weekday === "Sat" || weekday === "Sun") return true;
|
|
38
|
+
return hour >= QUIET_FROM_HOUR || hour < QUIET_UNTIL_HOUR;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { isQuietHours };
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicXVpZXQtaG91cnMuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiLi4vLi4vc3JjL3V0aWxzL3F1aWV0LWhvdXJzLnRzIl0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogU2xhY2sg7JWM66a8IOyhsOyaqSDsi5zqsIQuXG4gKlxuICog7Yag7YGwIOunjOujjOuKlCDsgqzrnozsnbQg7J6s66Gc6re47J247ZW07JW8IO2SgOumrOuKlOuNsCwg6re4IOyCrOuejOydtCDsnpDqsbDrgpgg7Ims64qUIOuPmeyViCDslYzroKTrj4Qg7ZWgIOyImCDsnojripRcbiAqIOydvOydtCDsl4bri6QuIO2PieydvCDsl4XrrLQg7Iuc6rCE7JeQ66eMIOyVjOumrOqzoCDrgpjrqLjsp4DripQg64uk7J2MIOq3vOustCDsi5zqsITsnLzroZwg66+466Os64ukIOKAlCDrp4zro4wg7JWM66a87J2AXG4gKiDso7zquLDsoIHsnLzroZwg67CY67O165CY66+A66GcIOyhsOyaqSDsi5zqsITsl5Ag67KE66Ck64+EIOyVhOy5qCDssqsg7KO86riw7JeQIOuLpOyLnCDsmKjri6QuXG4gKlxuICog64uk66eMIHByb3ZpZGVyIOydmCDrp4jsp4Drp4kg7Yag7YGw7J20IOyjveuKlCDqsoPsnYAg7ISc67mE7IqkIOygleyngCDsi6DtmLjrnbwg7J20IOq3nOy5meyXkOyEnCDsoJzsmbjtlZzri6RcbiAqICjtmLjstpzrtoDqsIAgYG5vdGlmeVNsYWNrYCDsnYQg6re464OlIO2YuOy2nO2VmOuptCDrkJzri6QpLlxuICovXG5cbi8qKiDsnbQg7Iuc6rCB67aA7YSwKO2PrO2VqCkg7KGw7Jqp7ZW07KeE64ukLiAqL1xuY29uc3QgUVVJRVRfRlJPTV9IT1VSID0gMjA7XG4vKiog7J20IOyLnOqwgeu2gO2EsCjtj6ztlagpIOuLpOyLnCDslYzrprDri6QuICovXG5jb25zdCBRVUlFVF9VTlRJTF9IT1VSID0gODtcblxuLyoqXG4gKiDshJzrsoTqsIAgVVRDIOuhnCDrj4zslYTrj4Qg7ZWc6rWtIOyLnOqwhOycvOuhnCDtjJDsoJXtlZzri6QuIGRldjAg64qUIFVUQyDsnbTrr4DroZwgYGdldEhvdXJzKClgIOulvCDqt7jrjIDroZxcbiAqIOyTsOuptCBcIuyggOuFgSA47IucXCLqsIAg7ZWc6rWtIOyLnOqwhCDsg4jrsr0gNeyLnOqwgCDrkJzri6QuXG4gKi9cbmZ1bmN0aW9uIHNlb3VsUGFydHMoYXQ6IERhdGUpOiB7IGhvdXI6IG51bWJlcjsgd2Vla2RheTogc3RyaW5nIH0ge1xuICBjb25zdCBmb3JtYXR0ZXIgPSBuZXcgSW50bC5EYXRlVGltZUZvcm1hdChcImVuLVVTXCIsIHtcbiAgICB0aW1lWm9uZTogXCJBc2lhL1Nlb3VsXCIsXG4gICAgaG91cjogXCJudW1lcmljXCIsXG4gICAgaG91cjEyOiBmYWxzZSxcbiAgICB3ZWVrZGF5OiBcInNob3J0XCIsXG4gIH0pO1xuICBjb25zdCBwYXJ0cyA9IGZvcm1hdHRlci5mb3JtYXRUb1BhcnRzKGF0KTtcbiAgY29uc3QgaG91ciA9IE51bWJlcihwYXJ0cy5maW5kKChwKSA9PiBwLnR5cGUgPT09IFwiaG91clwiKT8udmFsdWUgPz8gXCIwXCIpO1xuICBjb25zdCB3ZWVrZGF5ID0gcGFydHMuZmluZCgocCkgPT4gcC50eXBlID09PSBcIndlZWtkYXlcIik/LnZhbHVlID8/IFwiXCI7XG4gIC8vIEludGwg7J2AIOyekOygleydhCAyNCDroZwg7KSEIOyImCDsnojri6QuIDAuLjIzIOycvOuhnCDrp57stpjri6QuXG4gIHJldHVybiB7IGhvdXI6IGhvdXIgJSAyNCwgd2Vla2RheSB9O1xufVxuXG4vKiog7KO866eQ7J206rGw64KYIDIw7IucfuydteydvCA47Iuc66m0IOyhsOyaqSDsi5zqsIQuICovXG5leHBvcnQgZnVuY3Rpb24gaXNRdWlldEhvdXJzKGF0OiBEYXRlID0gbmV3IERhdGUoKSk6IGJvb2xlYW4ge1xuICBjb25zdCB7IGhvdXIsIHdlZWtkYXkgfSA9IHNlb3VsUGFydHMoYXQpO1xuICBpZiAod2Vla2RheSA9PT0gXCJTYXRcIiB8fCB3ZWVrZGF5ID09PSBcIlN1blwiKSByZXR1cm4gdHJ1ZTtcbiAgcmV0dXJuIGhvdXIgPj0gUVVJRVRfRlJPTV9IT1VSIHx8IGhvdXIgPCBRVUlFVF9VTlRJTF9IT1VSO1xufVxuIl0sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7QUFZQSxNQUFNLGtCQUFrQjs7QUFFeEIsTUFBTSxtQkFBbUI7Ozs7O0FBTXpCLFNBQVMsV0FBVyxJQUE2QztDQU8vRCxNQUFNLFFBQVEsSUFOUSxLQUFLLGVBQWUsU0FBUztFQUNqRCxVQUFVO0VBQ1YsTUFBTTtFQUNOLFFBQVE7RUFDUixTQUFTO0NBQ1gsQ0FDYyxDQUFBLENBQVUsY0FBYyxFQUFFO0NBQ3hDLE1BQU0sT0FBTyxPQUFPLE1BQU0sTUFBTSxNQUFNLEVBQUUsU0FBUyxNQUFNLENBQUMsRUFBRSxTQUFTLEdBQUc7Q0FDdEUsTUFBTSxVQUFVLE1BQU0sTUFBTSxNQUFNLEVBQUUsU0FBUyxTQUFTLENBQUMsRUFBRSxTQUFTO0NBRWxFLE9BQU87RUFBRSxNQUFNLE9BQU87RUFBSTtDQUFRO0FBQ3BDOztBQUdBLFNBQWdCLGFBQWEscUJBQVcsSUFBSSxLQUFLLEdBQVk7Q0FDM0QsTUFBTSxFQUFFLE1BQU0sWUFBWSxXQUFXLEVBQUU7Q0FDdkMsSUFBSSxZQUFZLFNBQVMsWUFBWSxPQUFPLE9BQU87Q0FDbkQsT0FBTyxRQUFRLG1CQUFtQixPQUFPO0FBQzNDIn0=
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { getSetting } from "../application/setting/setting.store.js";
|
|
2
|
+
import { isQuietHours } from "./quiet-hours.js";
|
|
1
3
|
import { getLogger } from "@logtape/logtape";
|
|
2
4
|
//#region src/utils/slack-notify.ts
|
|
3
5
|
/**
|
|
4
6
|
* Slack 알림 (chat.postMessage).
|
|
5
7
|
*
|
|
6
8
|
* fail-open: env 미설정이면 조용히 no-op 이고, 전송 실패는 warn 로그로만 남긴다.
|
|
9
|
+
* 조용 시간(주말·20~8시)에는 `urgent` 가 아닌 알림을 보내지 않는다.
|
|
7
10
|
* 알림 경로가 토큰 라우팅이나 오류 전파를 지연·차단해서는 안 된다.
|
|
8
11
|
*/
|
|
9
12
|
const logger = getLogger(["qgrid", "slack"]);
|
|
@@ -14,14 +17,18 @@ const SLACK_COLOR = {
|
|
|
14
17
|
good: "#2eb886",
|
|
15
18
|
bad: "#e01e5a"
|
|
16
19
|
};
|
|
17
|
-
async function notifySlack(notification) {
|
|
18
|
-
const botToken =
|
|
19
|
-
const channel =
|
|
20
|
-
const { title, subject, context, color } = notification;
|
|
20
|
+
async function notifySlack(notification, readSetting = getSetting) {
|
|
21
|
+
const botToken = readSetting("slack.botToken", "SLACK_BOT_TOKEN");
|
|
22
|
+
const channel = readSetting("slack.channelId", "SLACK_CHANNEL_ID");
|
|
23
|
+
const { title, subject, context, color, urgent, now } = notification;
|
|
21
24
|
if (!botToken || !channel) {
|
|
22
25
|
logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
|
|
23
26
|
return;
|
|
24
27
|
}
|
|
28
|
+
if (!urgent && isQuietHours(now)) {
|
|
29
|
+
logger.debug(`quiet hours, skipping notification: ${title} ${subject ?? ""}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
25
32
|
const blocks = [{
|
|
26
33
|
type: "section",
|
|
27
34
|
text: {
|
|
@@ -62,4 +69,4 @@ async function notifySlack(notification) {
|
|
|
62
69
|
//#endregion
|
|
63
70
|
export { SLACK_COLOR, notifySlack };
|
|
64
71
|
|
|
65
|
-
//# sourceMappingURL=data:application/json;charset=utf-8;base64,
|
|
72
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xhY2stbm90aWZ5LmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy91dGlscy9zbGFjay1ub3RpZnkudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBTbGFjayDslYzrprwgKGNoYXQucG9zdE1lc3NhZ2UpLlxuICpcbiAqIGZhaWwtb3BlbjogZW52IOuvuOyEpOygleydtOuptCDsobDsmqntnoggbm8tb3Ag7J206rOgLCDsoITshqEg7Iuk7Yyo64qUIHdhcm4g66Gc6re466Gc66eMIOuCqOq4tOuLpC5cbiAqIOyhsOyaqSDsi5zqsIQo7KO866eQwrcyMH447IucKeyXkOuKlCBgdXJnZW50YCDqsIAg7JWE64uMIOyVjOumvOydhCDrs7TrgrTsp4Ag7JWK64qU64ukLlxuICog7JWM66a8IOqyveuhnOqwgCDthqDtgbAg65287Jqw7YyF7J2064KYIOyYpOulmCDsoITtjIzrpbwg7KeA7JewwrfssKjri6jtlbTshJzripQg7JWIIOuQnOuLpC5cbiAqL1xuaW1wb3J0IHsgZ2V0TG9nZ2VyIH0gZnJvbSBcIkBsb2d0YXBlL2xvZ3RhcGVcIjtcblxuaW1wb3J0IHsgZ2V0U2V0dGluZyB9IGZyb20gXCIuLi9hcHBsaWNhdGlvbi9zZXR0aW5nL3NldHRpbmcuc3RvcmVcIjtcbmltcG9ydCB7IGlzUXVpZXRIb3VycyB9IGZyb20gXCIuL3F1aWV0LWhvdXJzXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcInNsYWNrXCJdKTtcblxuY29uc3QgU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCA9IFwiaHR0cHM6Ly9zbGFjay5jb20vYXBpL2NoYXQucG9zdE1lc3NhZ2VcIjtcbmNvbnN0IFNMQUNLX1RJTUVPVVRfTVMgPSA1XzAwMDtcblxuLyoqIOyDge2DnOulvCDsg4nsnLzroZwg66i87KCAIOydve2eiOqyjCDtlZjripQgYXR0YWNobWVudCDsg4nsg4Eg67CULiAqL1xuZXhwb3J0IGNvbnN0IFNMQUNLX0NPTE9SID0ge1xuICBnb29kOiBcIiMyZWI4ODZcIixcbiAgYmFkOiBcIiNlMDFlNWFcIixcbn0gYXMgY29uc3Q7XG5cbmV4cG9ydCB0eXBlIFNsYWNrTm90aWZpY2F0aW9uID0ge1xuICAvKiog7ZWcIOykhCDsoJzrqqkuIOustOyKqCDsnbzsnbQg7J287Ja064Ks64qU7KeA66eMIOuLtOuKlOuLpC4gKi9cbiAgdGl0bGU6IHN0cmluZztcbiAgLyoqIOygnOuqqSDsmIYg7L2U65OcIOyKpO2DgOydvOuhnCDrtpnripQg64yA7IOBIOyLneuzhOyekC4gKi9cbiAgc3ViamVjdD86IHN0cmluZztcbiAgLyoqIOygnOuqqSDslYTrnpgg7J6R7J2AIOq4gOyUqOuhnCDrgrTroKTqsIDripQg67aA6rCAIOygleuztCDigJQg7J297KeAIOyViuyVhOuPhCDrkJjripQg6rKD65OkLiAqL1xuICBjb250ZXh0Pzogc3RyaW5nO1xuICBjb2xvcj86ICh0eXBlb2YgU0xBQ0tfQ09MT1IpW2tleW9mIHR5cGVvZiBTTEFDS19DT0xPUl07XG4gIC8qKlxuICAgKiDsobDsmqkg7Iuc6rCEKOyjvOunkMK3MjB+OOyLnCnsl5Drj4Qg67O064K464ukLiDshJzruYTsiqTqsIAg66mI7LaYIOyDge2DnOyymOufvCDsp4DquIgg7JWM7KeAIOyViuycvOuptCDshpDtlbTqsIBcbiAgICog7Luk7KeA64qUIOyCrOqxtOyXkOunjCDsk7Tri6Qg4oCUIOuCqOyaqe2VmOuptCDsobDsmqkg7Iuc6rCEIOyekOyytOqwgCDrrLTsnZjrr7jtlbTsp4Tri6QuXG4gICAqL1xuICB1cmdlbnQ/OiBib29sZWFuO1xuICAvKiog7KGw7JqpIOyLnOqwhCDtjJDsoJUg6riw7KSAIOyLnOqwgS4g7YWM7Iqk7Yq47JeQ7IScIOyLnOqzhOulvCDqs6DsoJXtlaAg65WM66eMIOuEmOq4tOuLpC4gKi9cbiAgbm93PzogRGF0ZTtcbn07XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBub3RpZnlTbGFjayhcbiAgbm90aWZpY2F0aW9uOiBTbGFja05vdGlmaWNhdGlvbixcbiAgcmVhZFNldHRpbmc6IHR5cGVvZiBnZXRTZXR0aW5nID0gZ2V0U2V0dGluZyxcbik6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBib3RUb2tlbiA9IHJlYWRTZXR0aW5nKFwic2xhY2suYm90VG9rZW5cIiwgXCJTTEFDS19CT1RfVE9LRU5cIik7XG4gIGNvbnN0IGNoYW5uZWwgPSByZWFkU2V0dGluZyhcInNsYWNrLmNoYW5uZWxJZFwiLCBcIlNMQUNLX0NIQU5ORUxfSURcIik7XG4gIGNvbnN0IHsgdGl0bGUsIHN1YmplY3QsIGNvbnRleHQsIGNvbG9yLCB1cmdlbnQsIG5vdyB9ID0gbm90aWZpY2F0aW9uO1xuICBpZiAoIWJvdFRva2VuIHx8ICFjaGFubmVsKSB7XG4gICAgbG9nZ2VyLmRlYnVnKGBzbGFjayBub3QgY29uZmlndXJlZCwgc2tpcHBpbmcgbm90aWZpY2F0aW9uOiAke3RpdGxlfSAke3N1YmplY3QgPz8gXCJcIn1gKTtcbiAgICByZXR1cm47XG4gIH1cblxuICAvLyDrp4zro4wg7JWM66a87J2AIOyjvOq4sOyggeycvOuhnCDrsJjrs7XrkJjrr4DroZwg7Jes6riw7IScIOuyhOugpOuPhCDri6TsnYwg6re866y0IOyLnOqwhCDssqsg7KO86riw7JeQIOuLpOyLnCDsmKjri6QuXG4gIGlmICghdXJnZW50ICYmIGlzUXVpZXRIb3Vycyhub3cpKSB7XG4gICAgbG9nZ2VyLmRlYnVnKGBxdWlldCBob3Vycywgc2tpcHBpbmcgbm90aWZpY2F0aW9uOiAke3RpdGxlfSAke3N1YmplY3QgPz8gXCJcIn1gKTtcbiAgICByZXR1cm47XG4gIH1cblxuICBjb25zdCBibG9ja3M6IHVua25vd25bXSA9IFtcbiAgICB7XG4gICAgICB0eXBlOiBcInNlY3Rpb25cIixcbiAgICAgIHRleHQ6IHsgdHlwZTogXCJtcmtkd25cIiwgdGV4dDogc3ViamVjdCA/IGAqJHt0aXRsZX0qICBcXGAke3N1YmplY3R9XFxgYCA6IGAqJHt0aXRsZX0qYCB9LFxuICAgIH0sXG4gIF07XG4gIGlmIChjb250ZXh0KSB7XG4gICAgYmxvY2tzLnB1c2goeyB0eXBlOiBcImNvbnRleHRcIiwgZWxlbWVudHM6IFt7IHR5cGU6IFwibXJrZHduXCIsIHRleHQ6IGNvbnRleHQgfV0gfSk7XG4gIH1cblxuICB0cnkge1xuICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKFNMQUNLX1BPU1RfTUVTU0FHRV9VUkwsIHtcbiAgICAgIG1ldGhvZDogXCJQT1NUXCIsXG4gICAgICBoZWFkZXJzOiB7XG4gICAgICAgIFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOFwiLFxuICAgICAgICBBdXRob3JpemF0aW9uOiBgQmVhcmVyICR7Ym90VG9rZW59YCxcbiAgICAgIH0sXG4gICAgICBib2R5OiBKU09OLnN0cmluZ2lmeSh7XG4gICAgICAgIGNoYW5uZWwsXG4gICAgICAgIC8vIGZhbGxiYWNrIOydgCBhdHRhY2htZW50IOyViOyXkCDrkZTri6QuIOy1nOyDgeychCB0ZXh0IOuhnCDrs7TrgrTrqbQgYmxvY2tzIOqwgCDsoJXsg4Eg66CM642U65CY64qUXG4gICAgICAgIC8vIO2ZlOuptOyXkOyEnOuPhCDtlajqu5gg7Lac66Cl64+8IOygnOuqqeydtCDrkZAg67KIIOuztOyduOuLpC4gYXR0YWNobWVudCBmYWxsYmFjayDsnYAgYmxvY2tzIOulvFxuICAgICAgICAvLyDrqrsg6re466as64qUIO2BtOudvOydtOyWuO2KuOyZgCDtkbjsi5wg7JWM66a87JeQ7ISc66eMIOyTsOyduOuLpC5cbiAgICAgICAgYXR0YWNobWVudHM6IFtcbiAgICAgICAgICB7XG4gICAgICAgICAgICBmYWxsYmFjazogc3ViamVjdCA/IGAke3RpdGxlfSDigJQgJHtzdWJqZWN0fWAgOiB0aXRsZSxcbiAgICAgICAgICAgIC4uLihjb2xvciA/IHsgY29sb3IgfSA6IHt9KSxcbiAgICAgICAgICAgIGJsb2NrcyxcbiAgICAgICAgICB9LFxuICAgICAgICBdLFxuICAgICAgfSksXG4gICAgICBzaWduYWw6IEFib3J0U2lnbmFsLnRpbWVvdXQoU0xBQ0tfVElNRU9VVF9NUyksXG4gICAgfSk7XG5cbiAgICAvLyBib3Qg66+47LSI64yAKG5vdF9pbl9jaGFubmVsKcK37J6Y66q765CcIOyxhOuEkCDrk7Eg6rCA7J6lIO2dlO2VnCDsmKTshKTsoJXsnYAgSFRUUCAyMDAgKyBvazpmYWxzZSDroZwg7Jio64ukLlxuICAgIC8vIHN0YXR1cyDrp4wg67O066m0IOyhsOyaqe2eiCDshLHqs7Ug7LKY66as64+8IOyVjOumvOydtCDthrXsp7jroZwg7IKs65287KeE64ukLlxuICAgIGNvbnN0IGJvZHkgPSAoYXdhaXQgcmVzLmpzb24oKS5jYXRjaCgoKSA9PiBudWxsKSkgYXMgeyBvaz86IGJvb2xlYW47IGVycm9yPzogc3RyaW5nIH0gfCBudWxsO1xuICAgIGlmICghYm9keT8ub2spIHtcbiAgICAgIGxvZ2dlci53YXJuKGBzbGFjayBub3RpZmljYXRpb24gcmVqZWN0ZWQ6ICR7Ym9keT8uZXJyb3IgPz8gYGh0dHAgJHtyZXMuc3RhdHVzfWB9YCk7XG4gICAgfVxuICB9IGNhdGNoIChlKSB7XG4gICAgbG9nZ2VyLndhcm4oYHNsYWNrIG5vdGlmaWNhdGlvbiBmYWlsZWQ6ICR7KGUgYXMgRXJyb3IpLm1lc3NhZ2V9YCk7XG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7QUFZQSxNQUFNLFNBQVMsVUFBVSxDQUFDLFNBQVMsT0FBTyxDQUFDO0FBRTNDLE1BQU0seUJBQXlCO0FBQy9CLE1BQU0sbUJBQW1COztBQUd6QixNQUFhLGNBQWM7Q0FDekIsTUFBTTtDQUNOLEtBQUs7QUFDUDtBQW1CQSxlQUFzQixZQUNwQixjQUNBLGNBQWlDLFlBQ2xCO0NBQ2YsTUFBTSxXQUFXLFlBQVksa0JBQWtCLGlCQUFpQjtDQUNoRSxNQUFNLFVBQVUsWUFBWSxtQkFBbUIsa0JBQWtCO0NBQ2pFLE1BQU0sRUFBRSxPQUFPLFNBQVMsU0FBUyxPQUFPLFFBQVEsUUFBUTtDQUN4RCxJQUFJLENBQUMsWUFBWSxDQUFDLFNBQVM7RUFDekIsT0FBTyxNQUFNLGdEQUFnRCxNQUFNLEdBQUcsV0FBVyxJQUFJO0VBQ3JGO0NBQ0Y7Q0FHQSxJQUFJLENBQUMsVUFBVSxhQUFhLEdBQUcsR0FBRztFQUNoQyxPQUFPLE1BQU0sdUNBQXVDLE1BQU0sR0FBRyxXQUFXLElBQUk7RUFDNUU7Q0FDRjtDQUVBLE1BQU0sU0FBb0IsQ0FDeEI7RUFDRSxNQUFNO0VBQ04sTUFBTTtHQUFFLE1BQU07R0FBVSxNQUFNLFVBQVUsSUFBSSxNQUFNLE9BQU8sUUFBUSxNQUFNLElBQUksTUFBTTtFQUFHO0NBQ3RGLENBQ0Y7Q0FDQSxJQUFJLFNBQ0YsT0FBTyxLQUFLO0VBQUUsTUFBTTtFQUFXLFVBQVUsQ0FBQztHQUFFLE1BQU07R0FBVSxNQUFNO0VBQVEsQ0FBQztDQUFFLENBQUM7Q0FHaEYsSUFBSTtFQUNGLE1BQU0sTUFBTSxNQUFNLE1BQU0sd0JBQXdCO0dBQzlDLFFBQVE7R0FDUixTQUFTO0lBQ1AsZ0JBQWdCO0lBQ2hCLGVBQWUsVUFBVTtHQUMzQjtHQUNBLE1BQU0sS0FBSyxVQUFVO0lBQ25CO0lBSUEsYUFBYSxDQUNYO0tBQ0UsVUFBVSxVQUFVLEdBQUcsTUFBTSxLQUFLLFlBQVk7S0FDOUMsR0FBSSxRQUFRLEVBQUUsTUFBTSxJQUFJLENBQUM7S0FDekI7SUFDRixDQUNGO0dBQ0YsQ0FBQztHQUNELFFBQVEsWUFBWSxRQUFRLGdCQUFnQjtFQUM5QyxDQUFDO0VBSUQsTUFBTSxPQUFRLE1BQU0sSUFBSSxLQUFLLENBQUMsQ0FBQyxZQUFZLElBQUk7RUFDL0MsSUFBSSxDQUFDLE1BQU0sSUFDVCxPQUFPLEtBQUssZ0NBQWdDLE1BQU0sU0FBUyxRQUFRLElBQUksVUFBVTtDQUVyRixTQUFTLEdBQUc7RUFDVixPQUFPLEtBQUssOEJBQStCLEVBQVksU0FBUztDQUNsRTtBQUNGIn0=
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const { findInactiveMock, getSettingMock, notifySlackMock } = vi.hoisted(() => ({
|
|
4
|
+
findInactiveMock: vi.fn(),
|
|
5
|
+
getSettingMock: vi.fn(),
|
|
6
|
+
notifySlackMock: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildReminderContext,
|
|
11
|
+
type ExpiredTokenReminderDeps,
|
|
12
|
+
rescheduleExpiredTokenReminder,
|
|
13
|
+
startExpiredTokenReminder,
|
|
14
|
+
stopExpiredTokenReminder,
|
|
15
|
+
} from "./expired-token-reminder";
|
|
16
|
+
import { getSlackUserMap } from "./slack-user-map";
|
|
4
17
|
|
|
5
18
|
const userMap = new Map([
|
|
6
19
|
["yds", "U09NAJUQSFQ"],
|
|
@@ -8,22 +21,22 @@ const userMap = new Map([
|
|
|
8
21
|
]);
|
|
9
22
|
|
|
10
23
|
describe("buildReminderContext", () => {
|
|
11
|
-
it("
|
|
24
|
+
it("1건이면 제목에 있는 토큰명을 본문에서 반복하지 않는다", () => {
|
|
12
25
|
const text = buildReminderContext([{ name: "anthropic/yds", provider: "anthropic" }], userMap);
|
|
13
26
|
|
|
14
|
-
expect(text).toBe("<@U09NAJUQSFQ
|
|
27
|
+
expect(text).toBe("<@U09NAJUQSFQ>\n재로그인이 필요합니다");
|
|
15
28
|
});
|
|
16
29
|
|
|
17
|
-
it("
|
|
30
|
+
it("1건이고 매핑이 없으면 안내만 남는다", () => {
|
|
18
31
|
const text = buildReminderContext(
|
|
19
32
|
[{ name: "anthropic/dev-common", provider: "anthropic" }],
|
|
20
33
|
userMap,
|
|
21
34
|
);
|
|
22
35
|
|
|
23
|
-
expect(text).toBe("
|
|
36
|
+
expect(text).toBe("재로그인이 필요합니다");
|
|
24
37
|
});
|
|
25
38
|
|
|
26
|
-
it("
|
|
39
|
+
it("여러 건이면 제목이 건수라 본문에 토큰명을 남긴다", () => {
|
|
27
40
|
const text = buildReminderContext(
|
|
28
41
|
[
|
|
29
42
|
{ name: "anthropic/yds", provider: "anthropic" },
|
|
@@ -32,22 +45,131 @@ describe("buildReminderContext", () => {
|
|
|
32
45
|
userMap,
|
|
33
46
|
);
|
|
34
47
|
|
|
35
|
-
// 멘션만 두 번 나오면 어느 토큰인지 알 수 없다 — 토큰명이 함께 있어야 한다.
|
|
36
48
|
expect(text.split("\n")).toEqual([
|
|
37
49
|
"<@U09NAJUQSFQ> anthropic/yds",
|
|
38
50
|
"<@U09NAJUQSFQ> openai/yds",
|
|
51
|
+
"재로그인이 필요합니다",
|
|
39
52
|
]);
|
|
40
53
|
});
|
|
41
54
|
|
|
42
|
-
it("
|
|
43
|
-
const text = buildReminderContext(
|
|
55
|
+
it("여러 건 중 매핑 없는 공용 계정은 이름만 남긴다", () => {
|
|
56
|
+
const text = buildReminderContext(
|
|
57
|
+
[
|
|
58
|
+
{ name: "anthropic/haze", provider: "anthropic" },
|
|
59
|
+
{ name: "anthropic/dev-common", provider: "anthropic" },
|
|
60
|
+
],
|
|
61
|
+
userMap,
|
|
62
|
+
);
|
|
44
63
|
|
|
45
|
-
expect(text
|
|
64
|
+
expect(text.split("\n")).toEqual([
|
|
65
|
+
"<@U09N96NHZB7> anthropic/haze",
|
|
66
|
+
"anthropic/dev-common",
|
|
67
|
+
"재로그인이 필요합니다",
|
|
68
|
+
]);
|
|
46
69
|
});
|
|
47
70
|
|
|
48
71
|
it("이름이 없는 토큰도 줄을 잃지 않는다", () => {
|
|
49
|
-
const text = buildReminderContext(
|
|
72
|
+
const text = buildReminderContext(
|
|
73
|
+
[
|
|
74
|
+
{ name: null, provider: "openai" },
|
|
75
|
+
{ name: "anthropic/haze", provider: "anthropic" },
|
|
76
|
+
],
|
|
77
|
+
userMap,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(text.split("\n")).toEqual([
|
|
81
|
+
"unnamed",
|
|
82
|
+
"<@U09N96NHZB7> anthropic/haze",
|
|
83
|
+
"재로그인이 필요합니다",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("expired token reminder scheduling", () => {
|
|
89
|
+
let minutes = "10";
|
|
90
|
+
let rawUserMap = "yds:U-OLD";
|
|
91
|
+
let deps: ExpiredTokenReminderDeps;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
minutes = "10";
|
|
96
|
+
rawUserMap = "yds:U-OLD";
|
|
97
|
+
findInactiveMock.mockReset();
|
|
98
|
+
getSettingMock.mockReset();
|
|
99
|
+
notifySlackMock.mockReset();
|
|
100
|
+
findInactiveMock.mockResolvedValue([{ name: "anthropic/yds", provider: "anthropic" }]);
|
|
101
|
+
getSettingMock.mockImplementation((key: string) => {
|
|
102
|
+
if (key === "slack.expiryReminderMinutes") return minutes;
|
|
103
|
+
if (key === "slack.userMap") return rawUserMap;
|
|
104
|
+
return undefined;
|
|
105
|
+
});
|
|
106
|
+
notifySlackMock.mockResolvedValue(undefined);
|
|
107
|
+
deps = {
|
|
108
|
+
findInactive: findInactiveMock,
|
|
109
|
+
getSlackUserMap: () => getSlackUserMap(getSettingMock),
|
|
110
|
+
readSetting: getSettingMock,
|
|
111
|
+
sendSlack: notifySlackMock,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
stopExpiredTokenReminder();
|
|
117
|
+
vi.useRealTimers();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("부팅 시 만료 토큰을 즉시 한 번 알린다", async () => {
|
|
121
|
+
startExpiredTokenReminder(deps);
|
|
122
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
123
|
+
|
|
124
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("start를 다시 호출해도 interval은 하나만 남는다", async () => {
|
|
128
|
+
startExpiredTokenReminder(deps);
|
|
129
|
+
startExpiredTokenReminder(deps);
|
|
130
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
131
|
+
notifySlackMock.mockClear();
|
|
132
|
+
|
|
133
|
+
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
134
|
+
|
|
135
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("설정 변경은 즉시 알리지 않고 새 주기로 재예약한다", async () => {
|
|
139
|
+
startExpiredTokenReminder(deps);
|
|
140
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
141
|
+
notifySlackMock.mockClear();
|
|
142
|
+
minutes = "20";
|
|
143
|
+
|
|
144
|
+
rescheduleExpiredTokenReminder(deps);
|
|
145
|
+
await vi.advanceTimersByTimeAsync(19 * 60_000);
|
|
146
|
+
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
149
|
+
expect(notifySlackMock).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each(["0", "-1", "not-a-number"])("%s로 재설정하면 기존 타이머를 멈춘다", async (next) => {
|
|
153
|
+
startExpiredTokenReminder(deps);
|
|
154
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
155
|
+
notifySlackMock.mockClear();
|
|
156
|
+
minutes = next;
|
|
157
|
+
|
|
158
|
+
rescheduleExpiredTokenReminder(deps);
|
|
159
|
+
await vi.advanceTimersByTimeAsync(20 * 60_000);
|
|
160
|
+
|
|
161
|
+
expect(notifySlackMock).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("각 실행 시점의 최신 Slack 사용자 매핑을 사용한다", async () => {
|
|
165
|
+
startExpiredTokenReminder(deps);
|
|
166
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
167
|
+
expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-OLD>\n재로그인이 필요합니다" });
|
|
168
|
+
notifySlackMock.mockClear();
|
|
169
|
+
rawUserMap = "yds:U-NEW";
|
|
170
|
+
|
|
171
|
+
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
|
50
172
|
|
|
51
|
-
expect(
|
|
173
|
+
expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-NEW>\n재로그인이 필요합니다" });
|
|
52
174
|
});
|
|
53
175
|
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { getLogger } from "@logtape/logtape";
|
|
14
14
|
|
|
15
15
|
import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
|
|
16
|
+
import { getSetting } from "../setting/setting.store";
|
|
16
17
|
import { TokenModel } from "../token/token.model";
|
|
17
18
|
import { getSlackUserMap, mentionFor } from "./slack-user-map";
|
|
18
19
|
|
|
@@ -20,51 +21,89 @@ const logger = getLogger(["qgrid", "expired-reminder"]);
|
|
|
20
21
|
|
|
21
22
|
const MINUTE_MS = 60_000;
|
|
22
23
|
|
|
24
|
+
export type ExpiredTokenReminderDeps = {
|
|
25
|
+
findInactive: () => Promise<{ name: string | null; provider: string }[]>;
|
|
26
|
+
getSlackUserMap: () => Map<string, string>;
|
|
27
|
+
readSetting: typeof getSetting;
|
|
28
|
+
sendSlack: typeof notifySlack;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const defaultDeps: ExpiredTokenReminderDeps = {
|
|
32
|
+
findInactive: () => TokenModel.findInactive("A"),
|
|
33
|
+
getSlackUserMap,
|
|
34
|
+
readSetting: getSetting,
|
|
35
|
+
sendSlack: notifySlack,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 1건이면 제목(subject)에 토큰명이 이미 있으므로 본문에서는 뺀다 — 같은 이름을 두 번 쓰면
|
|
40
|
+
* 읽는 사람이 다른 정보인 줄 알고 한 번 더 본다. 여러 건이면 제목이 건수라 본문에 남긴다.
|
|
41
|
+
*/
|
|
23
42
|
export function buildReminderContext(
|
|
24
43
|
tokens: { name: string | null; provider: string }[],
|
|
25
44
|
userMap: Map<string, string>,
|
|
26
45
|
): string {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
const showLabel = tokens.length > 1;
|
|
47
|
+
const lines = tokens.map((token) => {
|
|
48
|
+
const mention = mentionFor(token.name, userMap);
|
|
49
|
+
const label = token.name ?? "unnamed";
|
|
50
|
+
if (!showLabel) return mention;
|
|
51
|
+
return mention ? `${mention} ${label}` : label;
|
|
52
|
+
});
|
|
53
|
+
return [...lines, "재로그인이 필요합니다"].filter(Boolean).join("\n");
|
|
35
54
|
}
|
|
36
55
|
|
|
37
|
-
async function sendReminder(
|
|
38
|
-
const inactive = await
|
|
56
|
+
async function sendReminder(deps: ExpiredTokenReminderDeps): Promise<void> {
|
|
57
|
+
const inactive = await deps.findInactive();
|
|
39
58
|
if (inactive.length === 0) return;
|
|
40
59
|
|
|
41
|
-
|
|
60
|
+
const userMap = deps.getSlackUserMap();
|
|
61
|
+
|
|
62
|
+
await deps.sendSlack({
|
|
42
63
|
// 만료 순간 알림과 같은 제목을 쓴다. 같은 사건이므로 다른 이름을 붙이면 별개 문제로 읽힌다.
|
|
43
64
|
title: "세션 만료",
|
|
44
65
|
subject: inactive.length > 1 ? `${inactive.length}건` : (inactive[0]!.name ?? "unnamed"),
|
|
45
|
-
context:
|
|
66
|
+
context: buildReminderContext(inactive, userMap),
|
|
46
67
|
color: SLACK_COLOR.bad,
|
|
47
68
|
});
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
51
72
|
|
|
73
|
+
function scheduleExpiredTokenReminder(runNow: boolean, deps: ExpiredTokenReminderDeps): void {
|
|
74
|
+
stopExpiredTokenReminder();
|
|
75
|
+
|
|
76
|
+
const minutes = Number(
|
|
77
|
+
deps.readSetting("slack.expiryReminderMinutes", "SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES") ?? 0,
|
|
78
|
+
);
|
|
79
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return;
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
`expired token reminder every ${minutes}m (${deps.getSlackUserMap().size} user mappings)`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const run = () =>
|
|
86
|
+
sendReminder(deps).catch((e) => logger.warn(`reminder failed: ${(e as Error).message}`));
|
|
87
|
+
|
|
88
|
+
if (runNow) void run();
|
|
89
|
+
timer = setInterval(run, minutes * MINUTE_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
|
|
54
94
|
*
|
|
55
95
|
* 인스턴스가 여러 개면 각자 보낸다. 만료 순간 알림과 달리 상태 변화가 없어 DB 로 억제할 수
|
|
56
96
|
* 없고, 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
|
|
57
97
|
*/
|
|
58
|
-
export function startExpiredTokenReminder(): void {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
logger.info(`expired token reminder every ${minutes}m (${userMap.size} user mappings)`);
|
|
98
|
+
export function startExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
|
|
99
|
+
// 기동 직후에도 한 번 보낸다. 재기동은 배포나 장애 복구 때 일어나는데, 그 시점에 이미
|
|
100
|
+
// 만료된 토큰이 있으면 첫 주기가 돌 때까지 조용한 것이 이상하다.
|
|
101
|
+
scheduleExpiredTokenReminder(true, deps);
|
|
102
|
+
}
|
|
64
103
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
104
|
+
/** 설정 변경 시 기존 타이머만 새 주기로 교체한다. 저장 자체가 알림을 보내지는 않는다. */
|
|
105
|
+
export function rescheduleExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
|
|
106
|
+
scheduleExpiredTokenReminder(false, deps);
|
|
68
107
|
}
|
|
69
108
|
|
|
70
109
|
export function stopExpiredTokenReminder(): void {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { getSlackUserMap } from "./slack-user-map";
|
|
4
|
+
|
|
5
|
+
const getSettingMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
describe("getSlackUserMap", () => {
|
|
8
|
+
let raw = "yds:U-OLD";
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
raw = "yds:U-OLD";
|
|
12
|
+
getSettingMock.mockReset();
|
|
13
|
+
getSettingMock.mockImplementation(() => raw);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("설정이 바뀌면 다음 조회부터 새 매핑을 사용한다", () => {
|
|
17
|
+
expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-OLD");
|
|
18
|
+
|
|
19
|
+
raw = "yds:U-NEW";
|
|
20
|
+
|
|
21
|
+
expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-NEW");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* 맞는 동작이다.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { getSetting } from "../setting/setting.store";
|
|
13
|
+
|
|
12
14
|
/** `토큰명:SlackUserId` 목록을 파싱한다. */
|
|
13
15
|
export function parseSlackUserMap(raw: string | undefined): Map<string, string> {
|
|
14
16
|
const map = new Map<string, string>();
|
|
@@ -34,18 +36,6 @@ export function mentionFor(tokenName: string | null, userMap: Map<string, string
|
|
|
34
36
|
return userId ? `<@${userId}>` : "";
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* 호출되므로 캐시해 둔다.
|
|
40
|
-
*/
|
|
41
|
-
let cached: Map<string, string> | null = null;
|
|
42
|
-
|
|
43
|
-
export function getSlackUserMap(): Map<string, string> {
|
|
44
|
-
cached ??= parseSlackUserMap(process.env.SLACK_USER_MAP);
|
|
45
|
-
return cached;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 테스트에서 env 를 바꿔 끼울 때 쓴다. */
|
|
49
|
-
export function resetSlackUserMapCache(): void {
|
|
50
|
-
cached = null;
|
|
39
|
+
export function getSlackUserMap(readSetting: typeof getSetting = getSetting): Map<string, string> {
|
|
40
|
+
return parseSlackUserMap(readSetting("slack.userMap", "SLACK_USER_MAP"));
|
|
51
41
|
}
|
|
@@ -89,6 +89,8 @@ function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): vo
|
|
|
89
89
|
` · 풀이 비지 않도록 유지 중입니다. ` +
|
|
90
90
|
`전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
|
|
91
91
|
color: SLACK_COLOR.bad,
|
|
92
|
+
// provider 전체가 죽었다는 신호다. 조용 시간에 묻히면 다음 근무일까지 서비스가 멈춘다.
|
|
93
|
+
urgent: true,
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -19,7 +19,13 @@ function createSSRQuery(
|
|
|
19
19
|
|
|
20
20
|
import { RequestLogStepListParams } from "./request-log-step/request-log-step.types";
|
|
21
21
|
import { RequestLogListParams } from "./request-log/request-log.types";
|
|
22
|
-
import {
|
|
22
|
+
import { SettingListParams } from "./setting/setting.types";
|
|
23
|
+
import {
|
|
24
|
+
TokenSubsetKey,
|
|
25
|
+
SettingSubsetKey,
|
|
26
|
+
RequestLogStepSubsetKey,
|
|
27
|
+
RequestLogSubsetKey,
|
|
28
|
+
} from "./sonamu.generated";
|
|
23
29
|
import { TokenListParams } from "./token/token.types";
|
|
24
30
|
|
|
25
31
|
export namespace TokenService {
|
|
@@ -33,6 +39,20 @@ export namespace TokenService {
|
|
|
33
39
|
createSSRQuery("TokenModel", "findMany", [subset, rawParams], ["Token", "getTokens"]);
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
export namespace SettingService {
|
|
43
|
+
export const getSetting = <T extends SettingSubsetKey>(subset: T, id: number): SSRQuery =>
|
|
44
|
+
createSSRQuery("SettingModel", "findById", [subset, id], ["Setting", "getSetting"]);
|
|
45
|
+
|
|
46
|
+
export const getSettings = <T extends SettingSubsetKey, LP extends SettingListParams>(
|
|
47
|
+
subset: T,
|
|
48
|
+
rawParams?: LP,
|
|
49
|
+
): SSRQuery =>
|
|
50
|
+
createSSRQuery("SettingModel", "findMany", [subset, rawParams], ["Setting", "getSettings"]);
|
|
51
|
+
|
|
52
|
+
export const getSettingList = (): SSRQuery =>
|
|
53
|
+
createSSRQuery("SettingModel", "listSettings", [], ["Setting", "getSettingList"]);
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
export namespace RequestLogStepService {
|
|
37
57
|
export const getRequestLogStep = <T extends RequestLogStepSubsetKey>(
|
|
38
58
|
subset: T,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "Setting",
|
|
3
|
+
"table": "settings",
|
|
4
|
+
"title": "Setting",
|
|
5
|
+
"props": [
|
|
6
|
+
{
|
|
7
|
+
"name": "id",
|
|
8
|
+
"type": "integer",
|
|
9
|
+
"desc": "ID"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "created_at",
|
|
13
|
+
"desc": "등록일시",
|
|
14
|
+
"dbDefault": "CURRENT_TIMESTAMP",
|
|
15
|
+
"type": "date"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "key",
|
|
19
|
+
"type": "string",
|
|
20
|
+
"length": 100,
|
|
21
|
+
"desc": "설정 키 (예: openai.maxWorkersPerToken)",
|
|
22
|
+
"toFilter": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "value",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"desc": "설정 값 (문자열 저장, 앱에서 파싱·검증)"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "updated_at",
|
|
31
|
+
"type": "date",
|
|
32
|
+
"nullable": true,
|
|
33
|
+
"desc": "최종 수정일시"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"indexes": [
|
|
37
|
+
{
|
|
38
|
+
"type": "unique",
|
|
39
|
+
"name": "settings_key_unique",
|
|
40
|
+
"columns": [
|
|
41
|
+
{
|
|
42
|
+
"name": "key"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"using": "btree"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"subsets": {
|
|
49
|
+
"A": ["id", "created_at", "key", "value", "updated_at"]
|
|
50
|
+
},
|
|
51
|
+
"enums": {
|
|
52
|
+
"SettingOrderBy": {
|
|
53
|
+
"key-asc": "키순"
|
|
54
|
+
},
|
|
55
|
+
"SettingSearchField": {
|
|
56
|
+
"key": "키"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|