@cartanova/qgrid-cli 2.6.5 → 2.6.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.
Files changed (34) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/qgrid/expired-token-reminder.js +61 -0
  4. package/bundle/dist/application/qgrid/slack-user-map.js +49 -0
  5. package/bundle/dist/application/qgrid/token-death.js +20 -4
  6. package/bundle/dist/sonamu.config.js +4 -4
  7. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +53 -0
  8. package/bundle/src/application/qgrid/expired-token-reminder.ts +74 -0
  9. package/bundle/src/application/qgrid/slack-user-map.ts +51 -0
  10. package/bundle/src/application/qgrid/token-death.ts +12 -3
  11. package/bundle/src/sonamu.config.ts +5 -5
  12. package/bundle/web-dist/client/assets/index-B4GlN1IX.css +1 -0
  13. package/bundle/web-dist/client/assets/{index-DTy-s79t.js → index-qs8K8bOv.js} +3 -3
  14. package/bundle/web-dist/client/assets/{logs-DYqUSqg-.js → logs-CvmI4KuC.js} +1 -1
  15. package/bundle/web-dist/client/assets/{monit-GnFS0_Xa.js → monit-CUueNzoL.js} +1 -1
  16. package/bundle/web-dist/client/assets/routes-CAfjRsMY.js +5 -0
  17. package/bundle/web-dist/client/assets/{show-DDgukGuY.js → show-C5vKpp5z.js} +1 -1
  18. package/bundle/web-dist/client/assets/tokens-C9KKP2Yq.js +1 -0
  19. package/bundle/web-dist/client/assets/use-oauth-login-flow-B3o4CXDW.js +1 -0
  20. package/bundle/web-dist/client/index.html +2 -2
  21. package/bundle/web-dist/server/assets/{routes-Sv_4FjtH.js → routes-BfoQmYud.js} +52 -3
  22. package/bundle/web-dist/server/assets/{tokens-h-2A3wfD.js → tokens-Dkd_uXEk.js} +1 -151
  23. package/bundle/web-dist/server/assets/use-oauth-login-flow-YM1nsd-H.js +161 -0
  24. package/bundle/web-dist/server/entry-server.generated.js +3 -3
  25. package/package.json +1 -1
  26. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -1
  27. package/bundle/dist/application/qgrid/expired-token-digest.js +0 -78
  28. package/bundle/src/application/qgrid/expired-token-digest.test.ts +0 -49
  29. package/bundle/src/application/qgrid/expired-token-digest.ts +0 -93
  30. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +0 -1
  31. package/bundle/web-dist/client/assets/routes-CBwpn4d-.js +0 -5
  32. package/bundle/web-dist/client/assets/tokens-Co1wPrXX.js +0 -1
  33. package/bundle/web-dist/client/assets/use-update-token-mutation-DqEpK7It.js +0 -1
  34. package/bundle/web-dist/server/assets/use-update-token-mutation-DHCaa7Ku.js +0 -7
@@ -1,7 +1,7 @@
1
1
  import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
2
  import { a as TokenService, n as QgridService } from "./services.generated-DqAKskAS.js";
3
3
  import { i as Q, o as lucideKeyRound } from "../entry-server.generated.js";
4
- import { t as useUpdateTokenMutation } from "./use-update-token-mutation-DHCaa7Ku.js";
4
+ import { n as OAuthCodeEntry, r as useUpdateTokenMutation, t as useOAuthLoginFlow } from "./use-oauth-login-flow-YM1nsd-H.js";
5
5
  //#region ~icons/lucide/plus.jsx
6
6
  var import_react = /* @__PURE__ */ __toESM(require_react());
7
7
  var import_jsx_runtime = require_jsx_runtime();
@@ -20,156 +20,6 @@ var lucidePlus = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
20
20
  })
21
21
  });
22
22
  //#endregion
23
- //#region src/components/qgrid/OAuthCodeEntry.tsx
24
- /**
25
- * 원격 접속(code 모드) Claude 로그인의 코드 입력 단계.
26
- * Add Token 모달과 재로그인이 같은 문구·에러·재시작 동선을 쓰도록 한 곳에 둔다.
27
- */
28
- function OAuthCodeEntry({ isPending, isError, onSubmit, onRestart }) {
29
- const [pastedCode, setPastedCode] = (0, import_react.useState)("");
30
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
31
- className: "space-y-2",
32
- children: [
33
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
34
- className: "text-[12px] text-sand-600 leading-relaxed",
35
- children: "새 탭에서 Claude 로그인을 완료하면 코드가 표시됩니다. 코드 전체를 아래에 붙여넣으세요."
36
- }),
37
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Q, {
38
- value: pastedCode,
39
- onChange: (e) => setPastedCode(e.target.value),
40
- placeholder: "code#state",
41
- className: "w-full border border-sand-200 rounded-md px-3 py-2 text-sm font-mono text-sand-900 bg-white placeholder:text-sand-300 focus:outline-none focus:border-sienna-300"
42
- }),
43
- isError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
44
- className: "text-[11px] text-red-500",
45
- children: "코드 확인에 실패했습니다 — 표시된 코드 전체를 다시 붙여넣거나 로그인을 다시 시작하세요."
46
- }),
47
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
48
- type: "button",
49
- className: "w-full py-2.5 text-sm font-medium rounded-md bg-sand-900 text-white hover:bg-sand-800 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
50
- disabled: !pastedCode.trim() || isPending,
51
- onClick: () => onSubmit(pastedCode),
52
- children: isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
53
- className: "flex items-center gap-1.5",
54
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" }), "Verifying..."]
55
- }) : "Complete login"
56
- }),
57
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
58
- type: "button",
59
- className: "w-full py-1.5 text-[12px] text-sand-500 hover:text-sand-700 transition-colors duration-150",
60
- onClick: () => {
61
- setPastedCode("");
62
- onRestart();
63
- },
64
- children: "처음부터 다시 시작"
65
- })
66
- ]
67
- });
68
- }
69
- //#endregion
70
- //#region src/components/qgrid/use-oauth-login-flow.ts
71
- var OPENAI_POLL_INTERVAL_MS = 3e3;
72
- var OPENAI_POLL_TIMEOUT_MS = 3e5;
73
- /** 캐시에 적재된 토큰 행 수. 폴링 조기 종료 판정에만 쓰는 근사치다. */
74
- function countTokens(queryClient) {
75
- let total = 0;
76
- for (const query of queryClient.getQueryCache().findAll({ queryKey: ["Token"] })) {
77
- const rows = query.state.data?.rows;
78
- if (Array.isArray(rows)) total = Math.max(total, rows.length);
79
- }
80
- return total;
81
- }
82
- /**
83
- * provider별 OAuth 로그인 시작과 코드 제출을 한 곳에 모은다.
84
- * Add Token 모달과 tokens 목록의 재로그인 버튼이 같은 플로우를 쓴다 — 두 진입점이
85
- * redirect/code 분기나 폴링 처리를 각자 구현하면 원격 접속 경험이 갈라진다.
86
- */
87
- function useOAuthLoginFlow() {
88
- const [loadingProvider, setLoadingProvider] = (0, import_react.useState)(null);
89
- const [codeEntry, setCodeEntry] = (0, import_react.useState)(false);
90
- const queryClient = useQueryClient();
91
- const oauthStartMutation = QgridService.useOauthStartMutation();
92
- const oauthStartOpenAIMutation = QgridService.useOauthStartOpenAIMutation();
93
- const oauthCompleteMutation = QgridService.useOauthCompleteMutation();
94
- const pollTimers = (0, import_react.useRef)({});
95
- const stopPolling = () => {
96
- if (pollTimers.current.interval !== void 0) clearInterval(pollTimers.current.interval);
97
- if (pollTimers.current.timeout !== void 0) clearTimeout(pollTimers.current.timeout);
98
- pollTimers.current = {};
99
- };
100
- (0, import_react.useEffect)(() => stopPolling, []);
101
- const invalidateTokens = () => Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
102
- const start = async (provider, name) => {
103
- const trimmed = name.trim();
104
- if (!trimmed) return;
105
- const popup = window.open("about:blank", "_blank");
106
- setLoadingProvider(provider);
107
- try {
108
- if (provider === "openai") {
109
- const { authUrl } = await oauthStartOpenAIMutation.mutateAsync({ name: trimmed });
110
- if (popup) popup.location.href = authUrl;
111
- else window.open(authUrl, "_blank");
112
- const before = countTokens(queryClient);
113
- stopPolling();
114
- pollTimers.current.interval = window.setInterval(() => {
115
- invalidateTokens().then(() => {
116
- if (countTokens(queryClient) > before) {
117
- stopPolling();
118
- setLoadingProvider(null);
119
- }
120
- });
121
- }, OPENAI_POLL_INTERVAL_MS);
122
- pollTimers.current.timeout = window.setTimeout(() => {
123
- stopPolling();
124
- setLoadingProvider(null);
125
- }, OPENAI_POLL_TIMEOUT_MS);
126
- return;
127
- }
128
- const { authUrl, mode } = await oauthStartMutation.mutateAsync({ name: trimmed });
129
- if (mode === "code") {
130
- if (popup) popup.location.href = authUrl;
131
- else window.open(authUrl, "_blank");
132
- setCodeEntry(true);
133
- setLoadingProvider(null);
134
- } else {
135
- popup?.close();
136
- window.location.href = authUrl;
137
- }
138
- } catch (e) {
139
- popup?.close();
140
- console.error("OAuth start failed:", e);
141
- setLoadingProvider(null);
142
- }
143
- };
144
- /** @returns 교환 성공 여부. 실패 시 `completeMutation.isError` 로 안내 문구를 띄운다. */
145
- const submitCode = async (pastedCode) => {
146
- const trimmed = pastedCode.trim();
147
- if (!trimmed) return false;
148
- try {
149
- await oauthCompleteMutation.mutateAsync({ pastedCode: trimmed });
150
- } catch (e) {
151
- console.error("OAuth complete failed:", e);
152
- return false;
153
- }
154
- await invalidateTokens();
155
- return true;
156
- };
157
- const reset = () => {
158
- stopPolling();
159
- setLoadingProvider(null);
160
- setCodeEntry(false);
161
- oauthCompleteMutation.reset();
162
- };
163
- return {
164
- loadingProvider,
165
- codeEntry,
166
- start,
167
- submitCode,
168
- reset,
169
- completeMutation: oauthCompleteMutation
170
- };
171
- }
172
- //#endregion
173
23
  //#region src/components/qgrid/AddTokenModal.tsx
174
24
  function AddTokenModal() {
175
25
  const [open, setOpen] = (0, import_react.useState)(false);
@@ -0,0 +1,161 @@
1
+ import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
+ import { n as QgridService, o as useMutation } from "./services.generated-DqAKskAS.js";
3
+ import { i as Q } from "../entry-server.generated.js";
4
+ //#region src/services/token/use-update-token-mutation.ts
5
+ function useUpdateTokenMutation() {
6
+ return useMutation({ mutationFn: ({ id, name, quotaThreshold, weight }) => QgridService.updateToken(id, name, quotaThreshold, weight) });
7
+ }
8
+ //#endregion
9
+ //#region src/components/qgrid/OAuthCodeEntry.tsx
10
+ var import_react = /* @__PURE__ */ __toESM(require_react(), 1);
11
+ var import_jsx_runtime = require_jsx_runtime();
12
+ /**
13
+ * 원격 접속(code 모드) Claude 로그인의 코드 입력 단계.
14
+ * Add Token 모달과 재로그인이 같은 문구·에러·재시작 동선을 쓰도록 한 곳에 둔다.
15
+ */
16
+ function OAuthCodeEntry({ isPending, isError, onSubmit, onRestart }) {
17
+ const [pastedCode, setPastedCode] = (0, import_react.useState)("");
18
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
19
+ className: "space-y-2",
20
+ children: [
21
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
22
+ className: "text-[12px] text-sand-600 leading-relaxed",
23
+ children: "새 탭에서 Claude 로그인을 완료하면 코드가 표시됩니다. 코드 전체를 아래에 붙여넣으세요."
24
+ }),
25
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Q, {
26
+ value: pastedCode,
27
+ onChange: (e) => setPastedCode(e.target.value),
28
+ placeholder: "code#state",
29
+ className: "w-full border border-sand-200 rounded-md px-3 py-2 text-sm font-mono text-sand-900 bg-white placeholder:text-sand-300 focus:outline-none focus:border-sienna-300"
30
+ }),
31
+ isError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
32
+ className: "text-[11px] text-red-500",
33
+ children: "코드 확인에 실패했습니다 — 표시된 코드 전체를 다시 붙여넣거나 로그인을 다시 시작하세요."
34
+ }),
35
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
36
+ type: "button",
37
+ className: "w-full py-2.5 text-sm font-medium rounded-md bg-sand-900 text-white hover:bg-sand-800 disabled:opacity-50 transition-colors duration-150 flex items-center justify-center gap-2",
38
+ disabled: !pastedCode.trim() || isPending,
39
+ onClick: () => onSubmit(pastedCode),
40
+ children: isPending ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
41
+ className: "flex items-center gap-1.5",
42
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" }), "Verifying..."]
43
+ }) : "Complete login"
44
+ }),
45
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
46
+ type: "button",
47
+ className: "w-full py-1.5 text-[12px] text-sand-500 hover:text-sand-700 transition-colors duration-150",
48
+ onClick: () => {
49
+ setPastedCode("");
50
+ onRestart();
51
+ },
52
+ children: "처음부터 다시 시작"
53
+ })
54
+ ]
55
+ });
56
+ }
57
+ //#endregion
58
+ //#region src/components/qgrid/use-oauth-login-flow.ts
59
+ var OPENAI_POLL_INTERVAL_MS = 3e3;
60
+ var OPENAI_POLL_TIMEOUT_MS = 3e5;
61
+ /** 캐시에 적재된 토큰 행 수. 폴링 조기 종료 판정에만 쓰는 근사치다. */
62
+ function countTokens(queryClient) {
63
+ let total = 0;
64
+ for (const query of queryClient.getQueryCache().findAll({ queryKey: ["Token"] })) {
65
+ const rows = query.state.data?.rows;
66
+ if (Array.isArray(rows)) total = Math.max(total, rows.length);
67
+ }
68
+ return total;
69
+ }
70
+ /**
71
+ * provider별 OAuth 로그인 시작과 코드 제출을 한 곳에 모은다.
72
+ * Add Token 모달과 tokens 목록의 재로그인 버튼이 같은 플로우를 쓴다 — 두 진입점이
73
+ * redirect/code 분기나 폴링 처리를 각자 구현하면 원격 접속 경험이 갈라진다.
74
+ */
75
+ function useOAuthLoginFlow() {
76
+ const [loadingProvider, setLoadingProvider] = (0, import_react.useState)(null);
77
+ const [codeEntry, setCodeEntry] = (0, import_react.useState)(false);
78
+ const queryClient = useQueryClient();
79
+ const oauthStartMutation = QgridService.useOauthStartMutation();
80
+ const oauthStartOpenAIMutation = QgridService.useOauthStartOpenAIMutation();
81
+ const oauthCompleteMutation = QgridService.useOauthCompleteMutation();
82
+ const pollTimers = (0, import_react.useRef)({});
83
+ const stopPolling = () => {
84
+ if (pollTimers.current.interval !== void 0) clearInterval(pollTimers.current.interval);
85
+ if (pollTimers.current.timeout !== void 0) clearTimeout(pollTimers.current.timeout);
86
+ pollTimers.current = {};
87
+ };
88
+ (0, import_react.useEffect)(() => stopPolling, []);
89
+ const invalidateTokens = () => Promise.all([queryClient.invalidateQueries({ queryKey: ["Token"] }), queryClient.invalidateQueries({ queryKey: ["Qgrid"] })]);
90
+ const start = async (provider, name) => {
91
+ const trimmed = name.trim();
92
+ if (!trimmed) return;
93
+ const popup = window.open("about:blank", "_blank");
94
+ setLoadingProvider(provider);
95
+ try {
96
+ if (provider === "openai") {
97
+ const { authUrl } = await oauthStartOpenAIMutation.mutateAsync({ name: trimmed });
98
+ if (popup) popup.location.href = authUrl;
99
+ else window.open(authUrl, "_blank");
100
+ const before = countTokens(queryClient);
101
+ stopPolling();
102
+ pollTimers.current.interval = window.setInterval(() => {
103
+ invalidateTokens().then(() => {
104
+ if (countTokens(queryClient) > before) {
105
+ stopPolling();
106
+ setLoadingProvider(null);
107
+ }
108
+ });
109
+ }, OPENAI_POLL_INTERVAL_MS);
110
+ pollTimers.current.timeout = window.setTimeout(() => {
111
+ stopPolling();
112
+ setLoadingProvider(null);
113
+ }, OPENAI_POLL_TIMEOUT_MS);
114
+ return;
115
+ }
116
+ const { authUrl, mode } = await oauthStartMutation.mutateAsync({ name: trimmed });
117
+ if (mode === "code") {
118
+ if (popup) popup.location.href = authUrl;
119
+ else window.open(authUrl, "_blank");
120
+ setCodeEntry(true);
121
+ setLoadingProvider(null);
122
+ } else {
123
+ popup?.close();
124
+ window.location.href = authUrl;
125
+ }
126
+ } catch (e) {
127
+ popup?.close();
128
+ console.error("OAuth start failed:", e);
129
+ setLoadingProvider(null);
130
+ }
131
+ };
132
+ /** @returns 교환 성공 여부. 실패 시 `completeMutation.isError` 로 안내 문구를 띄운다. */
133
+ const submitCode = async (pastedCode) => {
134
+ const trimmed = pastedCode.trim();
135
+ if (!trimmed) return false;
136
+ try {
137
+ await oauthCompleteMutation.mutateAsync({ pastedCode: trimmed });
138
+ } catch (e) {
139
+ console.error("OAuth complete failed:", e);
140
+ return false;
141
+ }
142
+ await invalidateTokens();
143
+ return true;
144
+ };
145
+ const reset = () => {
146
+ stopPolling();
147
+ setLoadingProvider(null);
148
+ setCodeEntry(false);
149
+ oauthCompleteMutation.reset();
150
+ };
151
+ return {
152
+ loadingProvider,
153
+ codeEntry,
154
+ start,
155
+ submitCode,
156
+ reset,
157
+ completeMutation: oauthCompleteMutation
158
+ };
159
+ }
160
+ //#endregion
161
+ export { OAuthCodeEntry as n, useUpdateTokenMutation as r, useOAuthLoginFlow as t };
@@ -22742,7 +22742,7 @@ function BottomNav() {
22742
22742
  }
22743
22743
  //#endregion
22744
22744
  //#region src/components/Sidebar.tsx
22745
- var qgridVersion = "2.6.5";
22745
+ var qgridVersion = "2.6.6";
22746
22746
  function Sidebar({ className }) {
22747
22747
  const pathname = useRouterState({ select: (s) => s.location.pathname });
22748
22748
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
@@ -44623,7 +44623,7 @@ function RootComponent() {
44623
44623
  }
44624
44624
  //#endregion
44625
44625
  //#region src/routes/tokens.tsx
44626
- var $$splitComponentImporter$4 = () => import("./assets/tokens-h-2A3wfD.js");
44626
+ var $$splitComponentImporter$4 = () => import("./assets/tokens-Dkd_uXEk.js");
44627
44627
  var Route$4 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
44628
44628
  //#endregion
44629
44629
  //#region src/routes/monit.tsx
@@ -44654,7 +44654,7 @@ var Route$2 = createFileRoute("/logs")({
44654
44654
  });
44655
44655
  //#endregion
44656
44656
  //#region src/routes/index.tsx
44657
- var $$splitComponentImporter$1 = () => import("./assets/routes-Sv_4FjtH.js");
44657
+ var $$splitComponentImporter$1 = () => import("./assets/routes-BfoQmYud.js");
44658
44658
  var Route$1 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
44659
44659
  //#endregion
44660
44660
  //#region src/routes/requests/show.tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-cli",
3
- "version": "2.6.5",
3
+ "version": "2.6.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cartanova-ai/qgrid"
@@ -211,7 +211,8 @@ When a refresh fails in a way that only re-login can fix, qgrid removes the toke
211
211
  - The provider's last active token is never auto-deactivated. A systemic failure (client_id revocation, OAuth contract change) would otherwise empty the pool; qgrid logs an error, keeps the token, and sends its own Slack alert. That path leaves no state change behind, so every following request would re-alert — a per-provider 30-minute cooldown holds it to one. The cooldown is in-process, so N instances can each emit once per window.
212
212
  - The other notification is registration: every completed login sends one alert, whether the account is new or replacing an existing row through the account dedup. Manually toggling a token back to active is not a registration and sends nothing.
213
213
  - Notifications are fail-open: unset `SLACK_BOT_TOKEN`/`SLACK_CHANNEL_ID` is a silent no-op, and send failures (including HTTP 200 with `ok:false`) only warn. Payloads carry an internal reason code such as `anthropic:400`, never the raw provider response body.
214
- - A periodic digest (`expired-token-digest.ts`) re-announces tokens that are still inactive, because the one-shot death alert is lost on anyone not watching the channel at that moment and re-login only happens when a person does it. It runs every `SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES` (unset or 0 disables) and sends nothing when no token is inactive — a periodic "all clear" trains people to ignore the channel. Owners come from `SLACK_USER_MAP` (`tokenName:SlackUserId`), which is explicit rather than resolved through `users.list`: Slack handles diverge from token names often enough (`haze`→`haze.lee`, `byeongjun`→`potados`) that auto-matching drops the mention silently, and a digest without the mention has no one to act on it. Shared accounts are left out of the map on purpose and appear by name only. Each instance sends its own digest — unlike deactivation there is no state change to gate on, and a few duplicates a day cost less than a locking scheme.
214
+ - `expired-token-reminder.ts` repeats the session-expiry alert for tokens that are still inactive, because the one-shot alert is lost on anyone not watching the channel at that moment and re-login only happens when a person does it. It reuses the same `세션 만료` title rather than inventing a second name — there is one event, so a differently-named alert would read as a separate problem. It runs every `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` (unset or 0 disables), batches every inactive token into one message, and sends nothing when none are inactive — a periodic "all clear" trains people to ignore the channel.
215
+ - Owners are mentioned through `SLACK_USER_MAP` (`tokenName:SlackUserId`) on every token notification, not just the reminder. The map is explicit rather than resolved through `users.list`: Slack handles diverge from token names often enough (`haze`→`haze.lee`, `byeongjun`→`potados`) that auto-matching drops the mention silently, and an alert nobody is tagged in has no one to act on it. Shared accounts are left out of the map on purpose and appear by name only. Each instance sends its own reminder — unlike deactivation there is no state change to gate on, and a few duplicates a day cost less than a locking scheme.
215
216
 
216
217
  ## Quota Threshold Semantics
217
218
 
@@ -1,78 +0,0 @@
1
- import { SLACK_COLOR, notifySlack } from "../../utils/slack-notify.js";
2
- import { TokenModel } from "../token/token.model.js";
3
- import { getLogger } from "@logtape/logtape";
4
- //#region src/application/qgrid/expired-token-digest.ts
5
- /**
6
- * 비활성 토큰 주기 알림.
7
- *
8
- * 사망 순간의 단발 알림(`token-death.ts`)은 그 시점에 채널을 보지 않으면 놓친다. 재로그인은
9
- * 사람이 해야 하는 일이라 놓치면 토큰이 계속 빠진 채로 남는다. 그래서 남아 있는 비활성
10
- * 토큰을 주기적으로 다시 알리고, 소유자를 멘션해 개인에게 꽂는다.
11
- *
12
- * 비활성 토큰이 없으면 보내지 않는다 — "이상 없음"을 주기적으로 알리면 채널이 무뎌진다.
13
- */
14
- const logger = getLogger(["qgrid", "expired-digest"]);
15
- const MINUTE_MS = 6e4;
16
- /**
17
- * `토큰명:SlackUserId` 목록. 공용 계정(bysuco, dev-common 등)은 넣지 않는다 — 매핑이 없으면
18
- * 멘션 없이 이름만 나가고, 그게 맞는 동작이다.
19
- *
20
- * Slack 표시 이름과 토큰명이 자주 어긋나(haze→haze.lee, byeongjun→potados) 자동 매칭은
21
- * 조용히 실패한다. 멘션이 빠지면 알림의 목적 자체가 사라지므로 명시 매핑만 쓴다.
22
- */
23
- function parseUserMap(raw) {
24
- const map = /* @__PURE__ */ new Map();
25
- if (!raw) return map;
26
- for (const entry of raw.split(",")) {
27
- const [name, userId] = entry.split(":").map((s) => s.trim());
28
- if (name && userId) map.set(name, userId);
29
- }
30
- return map;
31
- }
32
- /** `anthropic/yds` → `yds`. provider prefix 없이 저장된 이름도 그대로 받는다. */
33
- function ownerOf(tokenName) {
34
- if (!tokenName) return "";
35
- const slash = tokenName.indexOf("/");
36
- return slash === -1 ? tokenName : tokenName.slice(slash + 1);
37
- }
38
- function buildDigestContext(tokens, userMap) {
39
- return tokens.map((token) => {
40
- const userId = userMap.get(ownerOf(token.name));
41
- const label = token.name ?? "unnamed";
42
- return userId ? `<@${userId}> ${token.provider}` : `${label} (${token.provider})`;
43
- }).join("\n");
44
- }
45
- async function sendDigest(userMap) {
46
- const inactive = await TokenModel.findInactive("A");
47
- if (inactive.length === 0) return;
48
- await notifySlack({
49
- title: `재로그인 필요 ${inactive.length}건`,
50
- context: buildDigestContext(inactive, userMap),
51
- color: SLACK_COLOR.bad
52
- });
53
- }
54
- let timer = null;
55
- /**
56
- * `SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
57
- *
58
- * 인스턴스가 여러 개면 각자 보낸다. 사망 알림과 달리 상태 변화가 없어 DB 로 억제할 수 없고,
59
- * 다이제스트는 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
60
- */
61
- function startExpiredTokenDigest() {
62
- const minutes = Number(process.env.SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES ?? 0);
63
- if (!Number.isFinite(minutes) || minutes <= 0) return;
64
- const userMap = parseUserMap(process.env.SLACK_USER_MAP);
65
- logger.info(`expired token digest every ${minutes}m (${userMap.size} user mappings)`);
66
- timer = setInterval(() => {
67
- sendDigest(userMap).catch((e) => logger.warn(`digest failed: ${e.message}`));
68
- }, minutes * MINUTE_MS);
69
- }
70
- function stopExpiredTokenDigest() {
71
- if (!timer) return;
72
- clearInterval(timer);
73
- timer = null;
74
- }
75
- //#endregion
76
- export { buildDigestContext, startExpiredTokenDigest, stopExpiredTokenDigest };
77
-
78
- //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXhwaXJlZC10b2tlbi1kaWdlc3QuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL2FwcGxpY2F0aW9uL3FncmlkL2V4cGlyZWQtdG9rZW4tZGlnZXN0LnRzIl0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICog67mE7Zmc7ISxIO2GoO2BsCDso7zquLAg7JWM66a8LlxuICpcbiAqIOyCrOunnSDsiJzqsITsnZgg64uo67CcIOyVjOumvChgdG9rZW4tZGVhdGgudHNgKeydgCDqt7gg7Iuc7KCQ7JeQIOyxhOuEkOydhCDrs7Tsp4Ag7JWK7Jy866m0IOuGk+y5nOuLpC4g7J6s66Gc6re47J247J2AXG4gKiDsgqzrnozsnbQg7ZW07JW8IO2VmOuKlCDsnbzsnbTrnbwg64aT7LmY66m0IO2GoO2BsOydtCDqs4Tsho0g67mg7KeEIOyxhOuhnCDrgqjripTri6QuIOq3uOuemOyEnCDrgqjslYQg7J6I64qUIOu5hO2ZnOyEsVxuICog7Yag7YGw7J2EIOyjvOq4sOyggeycvOuhnCDri6Tsi5wg7JWM66as6rOgLCDshozsnKDsnpDrpbwg66mY7IWY7ZW0IOqwnOyduOyXkOqyjCDqvYLripTri6QuXG4gKlxuICog67mE7Zmc7ISxIO2GoO2BsOydtCDsl4bsnLzrqbQg67O064K07KeAIOyViuuKlOuLpCDigJQgXCLsnbTsg4Eg7JeG7J2MXCLsnYQg7KO86riw7KCB7Jy866GcIOyVjOumrOuptCDssYTrhJDsnbQg66y0646M7KeE64ukLlxuICovXG5pbXBvcnQgeyBnZXRMb2dnZXIgfSBmcm9tIFwiQGxvZ3RhcGUvbG9ndGFwZVwiO1xuXG5pbXBvcnQgeyBub3RpZnlTbGFjaywgU0xBQ0tfQ09MT1IgfSBmcm9tIFwiLi4vLi4vdXRpbHMvc2xhY2stbm90aWZ5XCI7XG5pbXBvcnQgeyBUb2tlbk1vZGVsIH0gZnJvbSBcIi4uL3Rva2VuL3Rva2VuLm1vZGVsXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcImV4cGlyZWQtZGlnZXN0XCJdKTtcblxuY29uc3QgTUlOVVRFX01TID0gNjBfMDAwO1xuXG4vKipcbiAqIGDthqDtgbDrqoU6U2xhY2tVc2VySWRgIOuqqeuhnS4g6rO17JqpIOqzhOyglShieXN1Y28sIGRldi1jb21tb24g65OxKeydgCDrhKPsp4Ag7JWK64qU64ukIOKAlCDrp6TtlZHsnbQg7JeG7Jy866m0XG4gKiDrqZjshZgg7JeG7J20IOydtOumhOunjCDrgpjqsIDqs6AsIOq3uOqyjCDrp57ripQg64+Z7J6R7J2064ukLlxuICpcbiAqIFNsYWNrIO2RnOyLnCDsnbTrpoTqs7wg7Yag7YGw66qF7J20IOyekOyjvCDslrTquIvrgpgoaGF6ZeKGkmhhemUubGVlLCBieWVvbmdqdW7ihpJwb3RhZG9zKSDsnpDrj5kg66ek7Lmt7J2AXG4gKiDsobDsmqntnogg7Iuk7Yyo7ZWc64ukLiDrqZjshZjsnbQg67mg7KeA66m0IOyVjOumvOydmCDrqqnsoIEg7J6Q7LK06rCAIOyCrOudvOyngOuvgOuhnCDrqoXsi5wg66ek7ZWR66eMIOyTtOuLpC5cbiAqL1xuZnVuY3Rpb24gcGFyc2VVc2VyTWFwKHJhdzogc3RyaW5nIHwgdW5kZWZpbmVkKTogTWFwPHN0cmluZywgc3RyaW5nPiB7XG4gIGNvbnN0IG1hcCA9IG5ldyBNYXA8c3RyaW5nLCBzdHJpbmc+KCk7XG4gIGlmICghcmF3KSByZXR1cm4gbWFwO1xuXG4gIGZvciAoY29uc3QgZW50cnkgb2YgcmF3LnNwbGl0KFwiLFwiKSkge1xuICAgIGNvbnN0IFtuYW1lLCB1c2VySWRdID0gZW50cnkuc3BsaXQoXCI6XCIpLm1hcCgocykgPT4gcy50cmltKCkpO1xuICAgIGlmIChuYW1lICYmIHVzZXJJZCkgbWFwLnNldChuYW1lLCB1c2VySWQpO1xuICB9XG4gIHJldHVybiBtYXA7XG59XG5cbi8qKiBgYW50aHJvcGljL3lkc2Ag4oaSIGB5ZHNgLiBwcm92aWRlciBwcmVmaXgg7JeG7J20IOyggOyepeuQnCDsnbTrpoTrj4Qg6re464yA66GcIOuwm+uKlOuLpC4gKi9cbmZ1bmN0aW9uIG93bmVyT2YodG9rZW5OYW1lOiBzdHJpbmcgfCBudWxsKTogc3RyaW5nIHtcbiAgaWYgKCF0b2tlbk5hbWUpIHJldHVybiBcIlwiO1xuICBjb25zdCBzbGFzaCA9IHRva2VuTmFtZS5pbmRleE9mKFwiL1wiKTtcbiAgcmV0dXJuIHNsYXNoID09PSAtMSA/IHRva2VuTmFtZSA6IHRva2VuTmFtZS5zbGljZShzbGFzaCArIDEpO1xufVxuXG5leHBvcnQgZnVuY3Rpb24gYnVpbGREaWdlc3RDb250ZXh0KFxuICB0b2tlbnM6IHsgbmFtZTogc3RyaW5nIHwgbnVsbDsgcHJvdmlkZXI6IHN0cmluZyB9W10sXG4gIHVzZXJNYXA6IE1hcDxzdHJpbmcsIHN0cmluZz4sXG4pOiBzdHJpbmcge1xuICByZXR1cm4gdG9rZW5zXG4gICAgLm1hcCgodG9rZW4pID0+IHtcbiAgICAgIGNvbnN0IHVzZXJJZCA9IHVzZXJNYXAuZ2V0KG93bmVyT2YodG9rZW4ubmFtZSkpO1xuICAgICAgY29uc3QgbGFiZWwgPSB0b2tlbi5uYW1lID8/IFwidW5uYW1lZFwiO1xuICAgICAgLy8g66mY7IWY7J20IOyeiOycvOuptCDsnbTrpoQg64yA7IugIOupmOyFmOydhCDsk7Tri6Qg4oCUIOuRmCDri6Qg7JOw66m0IOqwmeydgCDrp5DsnbQg65GQIOuyiCDrgpjsmKjri6QuXG4gICAgICByZXR1cm4gdXNlcklkID8gYDxAJHt1c2VySWR9PiAke3Rva2VuLnByb3ZpZGVyfWAgOiBgJHtsYWJlbH0gKCR7dG9rZW4ucHJvdmlkZXJ9KWA7XG4gICAgfSlcbiAgICAuam9pbihcIlxcblwiKTtcbn1cblxuYXN5bmMgZnVuY3Rpb24gc2VuZERpZ2VzdCh1c2VyTWFwOiBNYXA8c3RyaW5nLCBzdHJpbmc+KTogUHJvbWlzZTx2b2lkPiB7XG4gIGNvbnN0IGluYWN0aXZlID0gYXdhaXQgVG9rZW5Nb2RlbC5maW5kSW5hY3RpdmUoXCJBXCIpO1xuICBpZiAoaW5hY3RpdmUubGVuZ3RoID09PSAwKSByZXR1cm47XG5cbiAgYXdhaXQgbm90aWZ5U2xhY2soe1xuICAgIHRpdGxlOiBg7J6s66Gc6re47J24IO2VhOyalCAke2luYWN0aXZlLmxlbmd0aH3qsbRgLFxuICAgIGNvbnRleHQ6IGJ1aWxkRGlnZXN0Q29udGV4dChpbmFjdGl2ZSwgdXNlck1hcCksXG4gICAgY29sb3I6IFNMQUNLX0NPTE9SLmJhZCxcbiAgfSk7XG59XG5cbmxldCB0aW1lcjogUmV0dXJuVHlwZTx0eXBlb2Ygc2V0SW50ZXJ2YWw+IHwgbnVsbCA9IG51bGw7XG5cbi8qKlxuICogYFNMQUNLX0VYUElSWV9ESUdFU1RfSU5URVJWQUxfTUlOVVRFU2Ag6rCAIOyWkeyImOydvCDrlYzrp4wg7Lyc7KeE64ukLiDrr7jshKTsoJXCtzAg7J2066m0IOu5hO2ZnOyEsS5cbiAqXG4gKiDsnbjsiqTthLTsiqTqsIAg7Jes65+sIOqwnOuptCDqsIHsnpAg67O064K464ukLiDsgqzrp50g7JWM66a86rO8IOuLrOumrCDsg4Htg5wg67OA7ZmU6rCAIOyXhuyWtCBEQiDroZwg7Ja17KCc7ZWgIOyImCDsl4bqs6AsXG4gKiDri6TsnbTsoJzsiqTtirjripQg7ZWY66OoIOuqhyDqsbQg7IiY7KSA7J206528IOykkeuzteydhCDqsJDsiJjtlZjripQg7Y647J20IOyeoOq4iCDsnqXsuZjrpbwg65GQ64qUIOqyg+uztOuLpCDrgqvri6QuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBzdGFydEV4cGlyZWRUb2tlbkRpZ2VzdCgpOiB2b2lkIHtcbiAgY29uc3QgbWludXRlcyA9IE51bWJlcihwcm9jZXNzLmVudi5TTEFDS19FWFBJUllfRElHRVNUX0lOVEVSVkFMX01JTlVURVMgPz8gMCk7XG4gIGlmICghTnVtYmVyLmlzRmluaXRlKG1pbnV0ZXMpIHx8IG1pbnV0ZXMgPD0gMCkgcmV0dXJuO1xuXG4gIGNvbnN0IHVzZXJNYXAgPSBwYXJzZVVzZXJNYXAocHJvY2Vzcy5lbnYuU0xBQ0tfVVNFUl9NQVApO1xuICBsb2dnZXIuaW5mbyhgZXhwaXJlZCB0b2tlbiBkaWdlc3QgZXZlcnkgJHttaW51dGVzfW0gKCR7dXNlck1hcC5zaXplfSB1c2VyIG1hcHBpbmdzKWApO1xuXG4gIHRpbWVyID0gc2V0SW50ZXJ2YWwoKCkgPT4ge1xuICAgIHNlbmREaWdlc3QodXNlck1hcCkuY2F0Y2goKGUpID0+IGxvZ2dlci53YXJuKGBkaWdlc3QgZmFpbGVkOiAkeyhlIGFzIEVycm9yKS5tZXNzYWdlfWApKTtcbiAgfSwgbWludXRlcyAqIE1JTlVURV9NUyk7XG59XG5cbmV4cG9ydCBmdW5jdGlvbiBzdG9wRXhwaXJlZFRva2VuRGlnZXN0KCk6IHZvaWQge1xuICBpZiAoIXRpbWVyKSByZXR1cm47XG4gIGNsZWFySW50ZXJ2YWwodGltZXIpO1xuICB0aW1lciA9IG51bGw7XG59XG4iXSwibWFwcGluZ3MiOiI7Ozs7Ozs7Ozs7Ozs7QUFjQSxNQUFNLFNBQVMsVUFBVSxDQUFDLFNBQVMsZ0JBQWdCLENBQUM7QUFFcEQsTUFBTSxZQUFZOzs7Ozs7OztBQVNsQixTQUFTLGFBQWEsS0FBOEM7Q0FDbEUsTUFBTSxzQkFBTSxJQUFJLElBQW9CO0NBQ3BDLElBQUksQ0FBQyxLQUFLLE9BQU87Q0FFakIsS0FBSyxNQUFNLFNBQVMsSUFBSSxNQUFNLEdBQUcsR0FBRztFQUNsQyxNQUFNLENBQUMsTUFBTSxVQUFVLE1BQU0sTUFBTSxHQUFHLENBQUMsQ0FBQyxLQUFLLE1BQU0sRUFBRSxLQUFLLENBQUM7RUFDM0QsSUFBSSxRQUFRLFFBQVEsSUFBSSxJQUFJLE1BQU0sTUFBTTtDQUMxQztDQUNBLE9BQU87QUFDVDs7QUFHQSxTQUFTLFFBQVEsV0FBa0M7Q0FDakQsSUFBSSxDQUFDLFdBQVcsT0FBTztDQUN2QixNQUFNLFFBQVEsVUFBVSxRQUFRLEdBQUc7Q0FDbkMsT0FBTyxVQUFVLEtBQUssWUFBWSxVQUFVLE1BQU0sUUFBUSxDQUFDO0FBQzdEO0FBRUEsU0FBZ0IsbUJBQ2QsUUFDQSxTQUNRO0NBQ1IsT0FBTyxPQUNKLEtBQUssVUFBVTtFQUNkLE1BQU0sU0FBUyxRQUFRLElBQUksUUFBUSxNQUFNLElBQUksQ0FBQztFQUM5QyxNQUFNLFFBQVEsTUFBTSxRQUFRO0VBRTVCLE9BQU8sU0FBUyxLQUFLLE9BQU8sSUFBSSxNQUFNLGFBQWEsR0FBRyxNQUFNLElBQUksTUFBTSxTQUFTO0NBQ2pGLENBQUMsQ0FBQyxDQUNELEtBQUssSUFBSTtBQUNkO0FBRUEsZUFBZSxXQUFXLFNBQTZDO0NBQ3JFLE1BQU0sV0FBVyxNQUFNLFdBQVcsYUFBYSxHQUFHO0NBQ2xELElBQUksU0FBUyxXQUFXLEdBQUc7Q0FFM0IsTUFBTSxZQUFZO0VBQ2hCLE9BQU8sV0FBVyxTQUFTLE9BQU87RUFDbEMsU0FBUyxtQkFBbUIsVUFBVSxPQUFPO0VBQzdDLE9BQU8sWUFBWTtDQUNyQixDQUFDO0FBQ0g7QUFFQSxJQUFJLFFBQStDOzs7Ozs7O0FBUW5ELFNBQWdCLDBCQUFnQztDQUM5QyxNQUFNLFVBQVUsT0FBTyxRQUFRLElBQUksd0NBQXdDLENBQUM7Q0FDNUUsSUFBSSxDQUFDLE9BQU8sU0FBUyxPQUFPLEtBQUssV0FBVyxHQUFHO0NBRS9DLE1BQU0sVUFBVSxhQUFhLFFBQVEsSUFBSSxjQUFjO0NBQ3ZELE9BQU8sS0FBSyw4QkFBOEIsUUFBUSxLQUFLLFFBQVEsS0FBSyxnQkFBZ0I7Q0FFcEYsUUFBUSxrQkFBa0I7RUFDeEIsV0FBVyxPQUFPLENBQUMsQ0FBQyxPQUFPLE1BQU0sT0FBTyxLQUFLLGtCQUFtQixFQUFZLFNBQVMsQ0FBQztDQUN4RixHQUFHLFVBQVUsU0FBUztBQUN4QjtBQUVBLFNBQWdCLHlCQUErQjtDQUM3QyxJQUFJLENBQUMsT0FBTztDQUNaLGNBQWMsS0FBSztDQUNuQixRQUFRO0FBQ1YifQ==
@@ -1,49 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { buildDigestContext } from "./expired-token-digest";
4
-
5
- const userMap = new Map([
6
- ["yds", "U09NAJUQSFQ"],
7
- ["haze", "U09N96NHZB7"],
8
- ]);
9
-
10
- describe("buildDigestContext", () => {
11
- it("매핑된 토큰은 멘션으로 개인에게 꽂는다", () => {
12
- const text = buildDigestContext([{ name: "anthropic/yds", provider: "anthropic" }], userMap);
13
-
14
- expect(text).toBe("<@U09NAJUQSFQ> anthropic");
15
- });
16
-
17
- it("공용 계정처럼 매핑이 없으면 멘션 없이 이름만 남긴다", () => {
18
- const text = buildDigestContext(
19
- [{ name: "anthropic/dev-common", provider: "anthropic" }],
20
- userMap,
21
- );
22
-
23
- expect(text).toBe("anthropic/dev-common (anthropic)");
24
- });
25
-
26
- it("같은 사람의 provider 별 토큰을 각각 줄로 나열한다", () => {
27
- const text = buildDigestContext(
28
- [
29
- { name: "anthropic/yds", provider: "anthropic" },
30
- { name: "openai/yds", provider: "openai" },
31
- ],
32
- userMap,
33
- );
34
-
35
- expect(text.split("\n")).toEqual(["<@U09NAJUQSFQ> anthropic", "<@U09NAJUQSFQ> openai"]);
36
- });
37
-
38
- it("provider prefix 없이 저장된 이름도 매핑한다", () => {
39
- const text = buildDigestContext([{ name: "haze", provider: "anthropic" }], userMap);
40
-
41
- expect(text).toBe("<@U09N96NHZB7> anthropic");
42
- });
43
-
44
- it("이름이 없는 토큰도 줄을 잃지 않는다", () => {
45
- const text = buildDigestContext([{ name: null, provider: "openai" }], userMap);
46
-
47
- expect(text).toBe("unnamed (openai)");
48
- });
49
- });
@@ -1,93 +0,0 @@
1
- /**
2
- * 비활성 토큰 주기 알림.
3
- *
4
- * 사망 순간의 단발 알림(`token-death.ts`)은 그 시점에 채널을 보지 않으면 놓친다. 재로그인은
5
- * 사람이 해야 하는 일이라 놓치면 토큰이 계속 빠진 채로 남는다. 그래서 남아 있는 비활성
6
- * 토큰을 주기적으로 다시 알리고, 소유자를 멘션해 개인에게 꽂는다.
7
- *
8
- * 비활성 토큰이 없으면 보내지 않는다 — "이상 없음"을 주기적으로 알리면 채널이 무뎌진다.
9
- */
10
- import { getLogger } from "@logtape/logtape";
11
-
12
- import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
13
- import { TokenModel } from "../token/token.model";
14
-
15
- const logger = getLogger(["qgrid", "expired-digest"]);
16
-
17
- const MINUTE_MS = 60_000;
18
-
19
- /**
20
- * `토큰명:SlackUserId` 목록. 공용 계정(bysuco, dev-common 등)은 넣지 않는다 — 매핑이 없으면
21
- * 멘션 없이 이름만 나가고, 그게 맞는 동작이다.
22
- *
23
- * Slack 표시 이름과 토큰명이 자주 어긋나(haze→haze.lee, byeongjun→potados) 자동 매칭은
24
- * 조용히 실패한다. 멘션이 빠지면 알림의 목적 자체가 사라지므로 명시 매핑만 쓴다.
25
- */
26
- function parseUserMap(raw: string | undefined): Map<string, string> {
27
- const map = new Map<string, string>();
28
- if (!raw) return map;
29
-
30
- for (const entry of raw.split(",")) {
31
- const [name, userId] = entry.split(":").map((s) => s.trim());
32
- if (name && userId) map.set(name, userId);
33
- }
34
- return map;
35
- }
36
-
37
- /** `anthropic/yds` → `yds`. provider prefix 없이 저장된 이름도 그대로 받는다. */
38
- function ownerOf(tokenName: string | null): string {
39
- if (!tokenName) return "";
40
- const slash = tokenName.indexOf("/");
41
- return slash === -1 ? tokenName : tokenName.slice(slash + 1);
42
- }
43
-
44
- export function buildDigestContext(
45
- tokens: { name: string | null; provider: string }[],
46
- userMap: Map<string, string>,
47
- ): string {
48
- return tokens
49
- .map((token) => {
50
- const userId = userMap.get(ownerOf(token.name));
51
- const label = token.name ?? "unnamed";
52
- // 멘션이 있으면 이름 대신 멘션을 쓴다 — 둘 다 쓰면 같은 말이 두 번 나온다.
53
- return userId ? `<@${userId}> ${token.provider}` : `${label} (${token.provider})`;
54
- })
55
- .join("\n");
56
- }
57
-
58
- async function sendDigest(userMap: Map<string, string>): Promise<void> {
59
- const inactive = await TokenModel.findInactive("A");
60
- if (inactive.length === 0) return;
61
-
62
- await notifySlack({
63
- title: `재로그인 필요 ${inactive.length}건`,
64
- context: buildDigestContext(inactive, userMap),
65
- color: SLACK_COLOR.bad,
66
- });
67
- }
68
-
69
- let timer: ReturnType<typeof setInterval> | null = null;
70
-
71
- /**
72
- * `SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
73
- *
74
- * 인스턴스가 여러 개면 각자 보낸다. 사망 알림과 달리 상태 변화가 없어 DB 로 억제할 수 없고,
75
- * 다이제스트는 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
76
- */
77
- export function startExpiredTokenDigest(): void {
78
- const minutes = Number(process.env.SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES ?? 0);
79
- if (!Number.isFinite(minutes) || minutes <= 0) return;
80
-
81
- const userMap = parseUserMap(process.env.SLACK_USER_MAP);
82
- logger.info(`expired token digest every ${minutes}m (${userMap.size} user mappings)`);
83
-
84
- timer = setInterval(() => {
85
- sendDigest(userMap).catch((e) => logger.warn(`digest failed: ${(e as Error).message}`));
86
- }, minutes * MINUTE_MS);
87
- }
88
-
89
- export function stopExpiredTokenDigest(): void {
90
- if (!timer) return;
91
- clearInterval(timer);
92
- timer = null;
93
- }