@sena-ai/platform-core 1.4.0

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 (158) hide show
  1. package/dist/app.d.ts +9 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +147 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/auth/handler.d.ts +19 -0
  6. package/dist/auth/handler.d.ts.map +1 -0
  7. package/dist/auth/handler.js +213 -0
  8. package/dist/auth/handler.js.map +1 -0
  9. package/dist/auth/session.d.ts +16 -0
  10. package/dist/auth/session.d.ts.map +1 -0
  11. package/dist/auth/session.js +54 -0
  12. package/dist/auth/session.js.map +1 -0
  13. package/dist/db/d1/index.d.ts +14 -0
  14. package/dist/db/d1/index.d.ts.map +1 -0
  15. package/dist/db/d1/index.js +252 -0
  16. package/dist/db/d1/index.js.map +1 -0
  17. package/dist/db/d1/schema.d.ts +610 -0
  18. package/dist/db/d1/schema.d.ts.map +1 -0
  19. package/dist/db/d1/schema.js +58 -0
  20. package/dist/db/d1/schema.js.map +1 -0
  21. package/dist/db/mysql/index.d.ts +14 -0
  22. package/dist/db/mysql/index.d.ts.map +1 -0
  23. package/dist/db/mysql/index.js +248 -0
  24. package/dist/db/mysql/index.js.map +1 -0
  25. package/dist/db/mysql/schema.d.ts +562 -0
  26. package/dist/db/mysql/schema.d.ts.map +1 -0
  27. package/dist/db/mysql/schema.js +61 -0
  28. package/dist/db/mysql/schema.js.map +1 -0
  29. package/dist/db/postgresql/index.d.ts +14 -0
  30. package/dist/db/postgresql/index.d.ts.map +1 -0
  31. package/dist/db/postgresql/index.js +246 -0
  32. package/dist/db/postgresql/index.js.map +1 -0
  33. package/dist/db/postgresql/schema.d.ts +591 -0
  34. package/dist/db/postgresql/schema.d.ts.map +1 -0
  35. package/dist/db/postgresql/schema.js +64 -0
  36. package/dist/db/postgresql/schema.js.map +1 -0
  37. package/dist/index.d.ts +6 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +3 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/relay/api-proxy.d.ts +10 -0
  42. package/dist/relay/api-proxy.d.ts.map +1 -0
  43. package/dist/relay/api-proxy.js +40 -0
  44. package/dist/relay/api-proxy.js.map +1 -0
  45. package/dist/runtime/cf/crypto.d.ts +7 -0
  46. package/dist/runtime/cf/crypto.d.ts.map +1 -0
  47. package/dist/runtime/cf/crypto.js +48 -0
  48. package/dist/runtime/cf/crypto.js.map +1 -0
  49. package/dist/runtime/cf/index.d.ts +20 -0
  50. package/dist/runtime/cf/index.d.ts.map +1 -0
  51. package/dist/runtime/cf/index.js +14 -0
  52. package/dist/runtime/cf/index.js.map +1 -0
  53. package/dist/runtime/cf/relay.d.ts +11 -0
  54. package/dist/runtime/cf/relay.d.ts.map +1 -0
  55. package/dist/runtime/cf/relay.js +57 -0
  56. package/dist/runtime/cf/relay.js.map +1 -0
  57. package/dist/runtime/cf/vault.d.ts +7 -0
  58. package/dist/runtime/cf/vault.d.ts.map +1 -0
  59. package/dist/runtime/cf/vault.js +68 -0
  60. package/dist/runtime/cf/vault.js.map +1 -0
  61. package/dist/runtime/node/crypto.d.ts +6 -0
  62. package/dist/runtime/node/crypto.d.ts.map +1 -0
  63. package/dist/runtime/node/crypto.js +26 -0
  64. package/dist/runtime/node/crypto.js.map +1 -0
  65. package/dist/runtime/node/index.d.ts +17 -0
  66. package/dist/runtime/node/index.d.ts.map +1 -0
  67. package/dist/runtime/node/index.js +14 -0
  68. package/dist/runtime/node/index.js.map +1 -0
  69. package/dist/runtime/node/relay.d.ts +6 -0
  70. package/dist/runtime/node/relay.d.ts.map +1 -0
  71. package/dist/runtime/node/relay.js +73 -0
  72. package/dist/runtime/node/relay.js.map +1 -0
  73. package/dist/runtime/node/vault.d.ts +7 -0
  74. package/dist/runtime/node/vault.d.ts.map +1 -0
  75. package/dist/runtime/node/vault.js +41 -0
  76. package/dist/runtime/node/vault.js.map +1 -0
  77. package/dist/slack/events.d.ts +15 -0
  78. package/dist/slack/events.d.ts.map +1 -0
  79. package/dist/slack/events.js +63 -0
  80. package/dist/slack/events.js.map +1 -0
  81. package/dist/slack/oauth.d.ts +13 -0
  82. package/dist/slack/oauth.d.ts.map +1 -0
  83. package/dist/slack/oauth.js +90 -0
  84. package/dist/slack/oauth.js.map +1 -0
  85. package/dist/slack/provisioner.d.ts +60 -0
  86. package/dist/slack/provisioner.d.ts.map +1 -0
  87. package/dist/slack/provisioner.js +156 -0
  88. package/dist/slack/provisioner.js.map +1 -0
  89. package/dist/types/crypto.d.ts +15 -0
  90. package/dist/types/crypto.d.ts.map +1 -0
  91. package/dist/types/crypto.js +2 -0
  92. package/dist/types/crypto.js.map +1 -0
  93. package/dist/types/index.d.ts +6 -0
  94. package/dist/types/index.d.ts.map +1 -0
  95. package/dist/types/index.js +2 -0
  96. package/dist/types/index.js.map +1 -0
  97. package/dist/types/platform.d.ts +25 -0
  98. package/dist/types/platform.d.ts.map +1 -0
  99. package/dist/types/platform.js +2 -0
  100. package/dist/types/platform.js.map +1 -0
  101. package/dist/types/relay.d.ts +16 -0
  102. package/dist/types/relay.d.ts.map +1 -0
  103. package/dist/types/relay.js +2 -0
  104. package/dist/types/relay.js.map +1 -0
  105. package/dist/types/repository.d.ts +78 -0
  106. package/dist/types/repository.d.ts.map +1 -0
  107. package/dist/types/repository.js +6 -0
  108. package/dist/types/repository.js.map +1 -0
  109. package/dist/types/vault.d.ts +9 -0
  110. package/dist/types/vault.d.ts.map +1 -0
  111. package/dist/types/vault.js +2 -0
  112. package/dist/types/vault.js.map +1 -0
  113. package/dist/web/api.d.ts +9 -0
  114. package/dist/web/api.d.ts.map +1 -0
  115. package/dist/web/api.js +144 -0
  116. package/dist/web/api.js.map +1 -0
  117. package/dist/web/pages.d.ts +4 -0
  118. package/dist/web/pages.d.ts.map +1 -0
  119. package/dist/web/pages.js +401 -0
  120. package/dist/web/pages.js.map +1 -0
  121. package/dist/web/setup.d.ts +5 -0
  122. package/dist/web/setup.d.ts.map +1 -0
  123. package/dist/web/setup.js +208 -0
  124. package/dist/web/setup.js.map +1 -0
  125. package/package.json +46 -0
  126. package/src/app.ts +221 -0
  127. package/src/auth/handler.ts +343 -0
  128. package/src/auth/session.ts +89 -0
  129. package/src/db/d1/index.ts +304 -0
  130. package/src/db/d1/schema.ts +62 -0
  131. package/src/db/mysql/index.ts +301 -0
  132. package/src/db/mysql/schema.ts +78 -0
  133. package/src/db/postgresql/index.ts +311 -0
  134. package/src/db/postgresql/schema.ts +82 -0
  135. package/src/index.ts +21 -0
  136. package/src/relay/api-proxy.ts +61 -0
  137. package/src/runtime/cf/crypto.ts +74 -0
  138. package/src/runtime/cf/index.ts +31 -0
  139. package/src/runtime/cf/relay.ts +74 -0
  140. package/src/runtime/cf/vault.ts +99 -0
  141. package/src/runtime/node/crypto.ts +33 -0
  142. package/src/runtime/node/index.ts +28 -0
  143. package/src/runtime/node/relay.ts +98 -0
  144. package/src/runtime/node/vault.ts +50 -0
  145. package/src/slack/events.ts +92 -0
  146. package/src/slack/oauth.ts +127 -0
  147. package/src/slack/provisioner.ts +256 -0
  148. package/src/types/crypto.ts +14 -0
  149. package/src/types/index.ts +14 -0
  150. package/src/types/platform.ts +31 -0
  151. package/src/types/relay.ts +16 -0
  152. package/src/types/repository.ts +93 -0
  153. package/src/types/vault.ts +8 -0
  154. package/src/web/api.ts +204 -0
  155. package/src/web/pages.ts +458 -0
  156. package/src/web/setup.ts +270 -0
  157. package/tsconfig.json +19 -0
  158. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,458 @@
1
+ import { Hono } from 'hono'
2
+ import type { BotRepository } from '../types/repository.js'
3
+
4
+ function layout(title: string, body: string): string {
5
+ return `<!DOCTYPE html>
6
+ <html lang="ko">
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>${title} - Sena Platform</title>
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <style>
13
+ body { font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
14
+ </style>
15
+ </head>
16
+ <body class="bg-gray-50 min-h-screen">
17
+ <nav class="bg-white border-b border-gray-200">
18
+ <div class="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
19
+ <a href="/" class="text-xl font-bold text-gray-900">Sena Platform</a>
20
+ <div class="flex items-center gap-4">
21
+ <span class="text-sm text-gray-500">Slack Bot Provisioning</span>
22
+ <form method="POST" action="/auth/logout" class="inline">
23
+ <button type="submit" class="text-sm text-gray-500 hover:text-gray-700">로그아웃</button>
24
+ </form>
25
+ </div>
26
+ </div>
27
+ </nav>
28
+ <main class="max-w-5xl mx-auto px-4 py-8">
29
+ ${body}
30
+ </main>
31
+ </body>
32
+ </html>`
33
+ }
34
+
35
+ export function createPages(botRepo: BotRepository, platformBaseUrl: string) {
36
+ const app = new Hono()
37
+
38
+ // GET / - Landing/dashboard
39
+ app.get('/', async (c) => {
40
+ const allBots = await botRepo.findAllSummary()
41
+
42
+ const botRows = allBots
43
+ .map(
44
+ (bot) => `
45
+ <tr class="border-b border-gray-100 hover:bg-gray-50">
46
+ <td class="py-3 px-4">
47
+ <div class="flex items-center gap-3">
48
+ ${
49
+ bot.profileImageUrl
50
+ ? `<img src="${bot.profileImageUrl}" alt="${bot.name}" class="w-8 h-8 rounded-full object-cover">`
51
+ : `<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-semibold text-sm">${bot.name.charAt(0).toUpperCase()}</div>`
52
+ }
53
+ <span class="font-medium text-gray-900">${bot.name}</span>
54
+ </div>
55
+ </td>
56
+ <td class="py-3 px-4">
57
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
58
+ bot.status === 'active'
59
+ ? 'bg-green-100 text-green-800'
60
+ : bot.status === 'pending'
61
+ ? 'bg-yellow-100 text-yellow-800'
62
+ : 'bg-gray-100 text-gray-800'
63
+ }">
64
+ ${bot.status}
65
+ </span>
66
+ </td>
67
+ <td class="py-3 px-4 text-sm text-gray-500">${bot.slackAppId || '-'}</td>
68
+ <td class="py-3 px-4 text-sm text-gray-500">${bot.createdAt ? new Date(bot.createdAt).toLocaleDateString('ko-KR') : '-'}</td>
69
+ <td class="py-3 px-4">
70
+ <div class="flex items-center gap-3">
71
+ ${
72
+ bot.status === 'pending'
73
+ ? `<a href="/bots/${bot.id}/setup" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">설정 계속하기</a>`
74
+ : bot.status === 'active'
75
+ ? `<a href="/bots/${bot.id}/complete" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">스크립트 보기</a>`
76
+ : ''
77
+ }
78
+ ${
79
+ bot.slackAppId
80
+ ? `<a href="https://api.slack.com/apps/${bot.slackAppId}" target="_blank" rel="noopener" class="text-gray-400 hover:text-gray-600 text-sm" title="Slack 앱 설정">&#x2699;&#xFE0F;</a>`
81
+ : ''
82
+ }
83
+ </div>
84
+ </td>
85
+ </tr>`,
86
+ )
87
+ .join('')
88
+
89
+ const html = layout(
90
+ '대시보드',
91
+ `
92
+ <div class="flex items-center justify-between mb-6">
93
+ <div>
94
+ <h1 class="text-2xl font-bold text-gray-900">Slack Bots</h1>
95
+ <p class="text-gray-500 mt-1">등록된 봇을 관리하고 새 봇을 추가하세요.</p>
96
+ </div>
97
+ <a href="/bots/new"
98
+ class="inline-flex items-center px-4 py-2.5 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition-colors">
99
+ + 슬랙 봇 추가하기
100
+ </a>
101
+ </div>
102
+
103
+ ${
104
+ allBots.length > 0
105
+ ? `
106
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
107
+ <table class="w-full">
108
+ <thead>
109
+ <tr class="bg-gray-50 border-b border-gray-200">
110
+ <th class="text-left py-3 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">이름</th>
111
+ <th class="text-left py-3 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">상태</th>
112
+ <th class="text-left py-3 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">App ID</th>
113
+ <th class="text-left py-3 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">생성일</th>
114
+ <th class="text-left py-3 px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider">액션</th>
115
+ </tr>
116
+ </thead>
117
+ <tbody>
118
+ ${botRows}
119
+ </tbody>
120
+ </table>
121
+ </div>`
122
+ : `
123
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
124
+ <div class="text-gray-400 text-5xl mb-4">&#x1f916;</div>
125
+ <h3 class="text-lg font-semibold text-gray-700 mb-2">아직 봇이 없어요</h3>
126
+ <p class="text-gray-500 mb-6">"슬랙 봇 추가하기" 버튼을 눌러 첫 번째 봇을 만들어 보세요.</p>
127
+ <a href="/bots/new"
128
+ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition-colors">
129
+ + 슬랙 봇 추가하기
130
+ </a>
131
+ </div>`
132
+ }
133
+ `,
134
+ )
135
+
136
+ return c.html(html)
137
+ })
138
+
139
+ // GET /bots/new - Bot creation form
140
+ app.get('/bots/new', (c) => {
141
+ const html = layout(
142
+ '새 봇 만들기',
143
+ `
144
+ <div class="max-w-lg mx-auto">
145
+ <div class="mb-6">
146
+ <a href="/" class="text-sm text-gray-500 hover:text-gray-700">&larr; 대시보드로 돌아가기</a>
147
+ </div>
148
+
149
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
150
+ <h1 class="text-xl font-bold text-gray-900 mb-1">새 슬랙 봇 만들기</h1>
151
+ <p class="text-gray-500 text-sm mb-6">봇의 이름을 설정하세요.</p>
152
+
153
+ <form id="create-bot-form" class="space-y-5">
154
+ <div>
155
+ <label for="name" class="block text-sm font-medium text-gray-700 mb-1">표시 이름 <span class="text-red-500">*</span></label>
156
+ <input type="text" id="name" name="name" required
157
+ placeholder="예: 릴리"
158
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-colors">
159
+ <p class="mt-1 text-xs text-gray-400">Slack에서 보이는 봇 이름이에요. 한글도 사용할 수 있어요.</p>
160
+ </div>
161
+
162
+ <div>
163
+ <label for="botUsername" class="block text-sm font-medium text-gray-700 mb-1">유저네임 <span class="text-red-500">*</span></label>
164
+ <input type="text" id="botUsername" name="botUsername" required
165
+ pattern="[a-z0-9][a-z0-9-]*"
166
+ placeholder="예: lily-bot"
167
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-colors">
168
+ <p class="mt-1 text-xs text-gray-400">영문 소문자, 숫자, 하이픈만 가능. Slack 내부에서 사용되는 @username이에요.</p>
169
+ </div>
170
+
171
+ <div>
172
+ <label for="profileImage" class="block text-sm font-medium text-gray-700 mb-1">프로필 이미지 (선택)</label>
173
+ <input type="file" id="profileImage" accept="image/png,image/jpeg,image/gif"
174
+ class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-colors text-sm text-gray-500 file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
175
+ <p class="mt-1 text-xs text-gray-400">512x512px 권장. PNG, JPEG, GIF 지원.</p>
176
+ </div>
177
+
178
+ <div id="error-msg" class="hidden p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700"></div>
179
+
180
+ <button type="submit" id="submit-btn"
181
+ class="w-full py-2.5 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
182
+ 만들기
183
+ </button>
184
+ </form>
185
+ </div>
186
+ </div>
187
+
188
+ <script>
189
+ document.getElementById('create-bot-form').addEventListener('submit', async function(e) {
190
+ e.preventDefault();
191
+ const btn = document.getElementById('submit-btn');
192
+ const errDiv = document.getElementById('error-msg');
193
+ btn.disabled = true;
194
+ btn.textContent = '생성 중...';
195
+ errDiv.classList.add('hidden');
196
+
197
+ try {
198
+ const name = document.getElementById('name').value.trim();
199
+ if (!name) throw new Error('봇 이름을 입력해주세요.');
200
+ const botUsername = document.getElementById('botUsername').value.trim();
201
+ if (!botUsername) throw new Error('봇 유저네임을 입력해주세요.');
202
+
203
+ let profileImage = null;
204
+ const fileInput = document.getElementById('profileImage');
205
+ if (fileInput.files && fileInput.files.length > 0) {
206
+ profileImage = await new Promise(function(resolve, reject) {
207
+ const reader = new FileReader();
208
+ reader.onload = function() { resolve(reader.result); };
209
+ reader.onerror = function() { reject(new Error('이미지 읽기에 실패했습니다.')); };
210
+ reader.readAsDataURL(fileInput.files[0]);
211
+ });
212
+ }
213
+
214
+ const res = await fetch('/api/bots', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ name, botUsername, profileImage }),
218
+ });
219
+ const data = await res.json();
220
+ if (!res.ok) throw new Error(data.error || '봇 생성에 실패했습니다.');
221
+
222
+ window.location.href = '/bots/' + data.botId + '/setup';
223
+ } catch (err) {
224
+ errDiv.textContent = err.message;
225
+ errDiv.classList.remove('hidden');
226
+ btn.disabled = false;
227
+ btn.textContent = '만들기';
228
+ }
229
+ });
230
+ </script>
231
+ `,
232
+ )
233
+
234
+ return c.html(html)
235
+ })
236
+
237
+ // GET /bots/:botId/setup - Setup progress page
238
+ app.get('/bots/:botId/setup', async (c) => {
239
+ const botId = c.req.param('botId')
240
+ const bot = await botRepo.findById(botId)
241
+
242
+ if (!bot) {
243
+ return c.html(
244
+ layout('오류', '<p class="text-red-600">봇을 찾을 수 없습니다.</p>'),
245
+ 404,
246
+ )
247
+ }
248
+
249
+ const hasSlackApp = !!bot.slackAppId
250
+ const hasOAuth = bot.status === 'active'
251
+
252
+ const html = layout(
253
+ `${bot.name} 설정`,
254
+ `
255
+ <div class="max-w-lg mx-auto">
256
+ <div class="mb-6">
257
+ <a href="/" class="text-sm text-gray-500 hover:text-gray-700">&larr; 대시보드로 돌아가기</a>
258
+ </div>
259
+
260
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
261
+ <h1 class="text-xl font-bold text-gray-900 mb-1">${bot.name} 설정</h1>
262
+ <p class="text-gray-500 text-sm mb-6">아래 단계를 순서대로 진행하세요.</p>
263
+
264
+ <div class="space-y-4">
265
+ <!-- Step 1: Bot created -->
266
+ <div class="flex items-start gap-3">
267
+ <div class="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">&#x2713;</div>
268
+ <div>
269
+ <p class="font-medium text-gray-900">봇 생성 완료</p>
270
+ <p class="text-sm text-gray-500">이름: ${bot.name}</p>
271
+ </div>
272
+ </div>
273
+
274
+ <!-- Step 2: Slack app provisioned -->
275
+ <div class="flex items-start gap-3">
276
+ ${
277
+ hasSlackApp
278
+ ? `<div class="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">&#x2713;</div>
279
+ <div>
280
+ <p class="font-medium text-gray-900">Slack 앱 생성 완료</p>
281
+ <p class="text-sm text-gray-500">App ID: <a href="https://api.slack.com/apps/${bot.slackAppId}" target="_blank" rel="noopener" class="text-indigo-600 hover:text-indigo-800">${bot.slackAppId} &#x2197;</a></p>
282
+ <p class="text-xs text-gray-400 mt-1">프로필 아이콘은 <a href="https://api.slack.com/apps/${bot.slackAppId}/general" target="_blank" rel="noopener" class="text-indigo-500 hover:text-indigo-700 underline">Slack 앱 설정</a>에서 직접 변경하세요.</p>
283
+ </div>`
284
+ : `<div class="w-6 h-6 rounded-full bg-yellow-400 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">2</div>
285
+ <div>
286
+ <p class="font-medium text-gray-900">Slack 앱 생성 중...</p>
287
+ <p class="text-sm text-gray-500">잠시만 기다려주세요.</p>
288
+ </div>`
289
+ }
290
+ </div>
291
+
292
+ <!-- Step 3: OAuth -->
293
+ <div class="flex items-start gap-3">
294
+ ${
295
+ hasOAuth
296
+ ? `<div class="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">&#x2713;</div>
297
+ <div>
298
+ <p class="font-medium text-gray-900">OAuth 승인 완료</p>
299
+ <p class="text-sm text-gray-500">워크스페이스에 설치되었습니다.</p>
300
+ </div>`
301
+ : hasSlackApp
302
+ ? `<div class="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">3</div>
303
+ <div>
304
+ <p class="font-medium text-gray-900">Slack 워크스페이스에 앱 설치</p>
305
+ <p class="text-sm text-gray-500 mb-3">아래 버튼을 클릭하여 앱을 설치하세요.</p>
306
+ <a href="/oauth/start/${bot.id}"
307
+ class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors">
308
+ Slack에 설치하기
309
+ </a>
310
+ </div>`
311
+ : `<div class="w-6 h-6 rounded-full bg-gray-300 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">3</div>
312
+ <div>
313
+ <p class="font-medium text-gray-400">Slack 워크스페이스에 앱 설치</p>
314
+ <p class="text-sm text-gray-400">이전 단계를 먼저 완료하세요.</p>
315
+ </div>`
316
+ }
317
+ </div>
318
+
319
+ <!-- Step 4: Complete -->
320
+ <div class="flex items-start gap-3">
321
+ ${
322
+ hasOAuth
323
+ ? `<div class="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">&#x2713;</div>
324
+ <div>
325
+ <p class="font-medium text-gray-900">설정 완료!</p>
326
+ <a href="/bots/${bot.id}/complete" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">부트스트랩 스크립트 보기 &rarr;</a>
327
+ </div>`
328
+ : `<div class="w-6 h-6 rounded-full bg-gray-300 flex items-center justify-center text-white text-xs mt-0.5 shrink-0">4</div>
329
+ <div>
330
+ <p class="font-medium text-gray-400">부트스트랩 스크립트</p>
331
+ <p class="text-sm text-gray-400">모든 설정이 완료되면 스크립트를 받을 수 있습니다.</p>
332
+ </div>`
333
+ }
334
+ </div>
335
+ </div>
336
+ </div>
337
+
338
+ ${
339
+ !hasSlackApp
340
+ ? `<script>
341
+ let pollCount = 0;
342
+ let retried = false;
343
+ (async function poll() {
344
+ try {
345
+ pollCount++;
346
+ const res = await fetch('/api/bots/${bot.id}');
347
+ const data = await res.json();
348
+ if (data.bot && data.bot.slackAppId) {
349
+ window.location.reload();
350
+ } else {
351
+ // After 10s of polling with no result, retry provisioning once
352
+ if (pollCount >= 5 && !retried) {
353
+ retried = true;
354
+ await fetch('/api/bots/${bot.id}/provision', { method: 'POST' });
355
+ }
356
+ setTimeout(poll, 2000);
357
+ }
358
+ } catch { setTimeout(poll, 3000); }
359
+ })();
360
+ </script>`
361
+ : ''
362
+ }
363
+ </div>
364
+ `,
365
+ )
366
+
367
+ return c.html(html)
368
+ })
369
+
370
+ // GET /bots/:botId/complete - Bootstrap script page
371
+ app.get('/bots/:botId/complete', async (c) => {
372
+ const botId = c.req.param('botId')
373
+ const bot = await botRepo.findById(botId)
374
+
375
+ if (!bot) {
376
+ return c.html(
377
+ layout('오류', '<p class="text-red-600">봇을 찾을 수 없습니다.</p>'),
378
+ 404,
379
+ )
380
+ }
381
+
382
+ const bootstrapScript = `curl -fsSL ${platformBaseUrl}/install.sh | sh -s -- \\
383
+ --name "${bot.name}" \\
384
+ --bot-username "${bot.botUsername}" \\
385
+ --connect-key "${bot.connectKey}" \\
386
+ --platform-url "${platformBaseUrl}"`
387
+
388
+ const html = layout(
389
+ `${bot.name} - 설정 완료`,
390
+ `
391
+ <div class="max-w-2xl mx-auto">
392
+ <div class="mb-6">
393
+ <a href="/" class="text-sm text-gray-500 hover:text-gray-700">&larr; 대시보드로 돌아가기</a>
394
+ </div>
395
+
396
+ <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
397
+ <div class="text-center mb-6">
398
+ <div class="inline-flex items-center justify-center w-12 h-12 bg-green-100 rounded-full mb-3">
399
+ <span class="text-green-600 text-xl">&#x2713;</span>
400
+ </div>
401
+ <h1 class="text-xl font-bold text-gray-900">${bot.name} 설정 완료!</h1>
402
+ <p class="text-gray-500 text-sm mt-1">아래 스크립트를 복사하여 로컬 터미널에서 실행하세요.</p>
403
+ </div>
404
+
405
+ <div class="relative">
406
+ <div class="bg-gray-900 rounded-lg p-4 pr-12 overflow-x-auto">
407
+ <pre class="text-green-400 text-sm font-mono whitespace-pre" id="script-content">${bootstrapScript}</pre>
408
+ </div>
409
+ <button onclick="copyScript()" id="copy-btn"
410
+ class="absolute top-3 right-3 px-2 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded transition-colors"
411
+ title="복사">
412
+ 복사
413
+ </button>
414
+ </div>
415
+
416
+ <div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
417
+ <h3 class="text-sm font-semibold text-blue-800 mb-2">참고 사항</h3>
418
+ <ul class="text-sm text-blue-700 space-y-1">
419
+ <li>- Node.js 18+ 및 pnpm이 설치되어 있어야 합니다.</li>
420
+ <li>- 스크립트 실행 후 <code class="bg-blue-100 px-1 py-0.5 rounded">pnpm start</code>로 봇을 시작하세요.</li>
421
+ <li>- connect_key는 외부에 노출하지 마세요.</li>
422
+ </ul>
423
+ </div>
424
+
425
+ <div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg">
426
+ <h3 class="text-sm font-semibold text-gray-700 mb-2">봇 정보</h3>
427
+ <dl class="grid grid-cols-2 gap-2 text-sm">
428
+ <dt class="text-gray-500">이름</dt>
429
+ <dd class="text-gray-900 font-medium">${bot.name}</dd>
430
+ <dt class="text-gray-500">상태</dt>
431
+ <dd><span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bot.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}">${bot.status}</span></dd>
432
+ <dt class="text-gray-500">Slack App ID</dt>
433
+ <dd class="text-gray-900 font-mono text-xs">${bot.slackAppId ? `<a href="https://api.slack.com/apps/${bot.slackAppId}" target="_blank" rel="noopener" class="text-indigo-600 hover:text-indigo-800">${bot.slackAppId} &#x2197;</a>` : '-'}</dd>
434
+ <dt class="text-gray-500">Connect Key</dt>
435
+ <dd class="text-gray-900 font-mono text-xs">${bot.connectKey}</dd>
436
+ </dl>
437
+ </div>
438
+ </div>
439
+ </div>
440
+
441
+ <script>
442
+ function copyScript() {
443
+ const text = document.getElementById('script-content').textContent;
444
+ navigator.clipboard.writeText(text).then(() => {
445
+ const btn = document.getElementById('copy-btn');
446
+ btn.textContent = '복사됨!';
447
+ setTimeout(() => { btn.textContent = '복사'; }, 2000);
448
+ });
449
+ }
450
+ </script>
451
+ `,
452
+ )
453
+
454
+ return c.html(html)
455
+ })
456
+
457
+ return app
458
+ }