v3.2.1: 카카오 자동매핑 + 채널링크 전송 + 날씨앱 개선

- OAuth 로그인 시 카카오 userId 자동 조회 → 현재 계정 자동 매핑
- 채널 링크 나에게 보내기 버튼 (OAuth 완료 후 표시)
- 카카오 채널 설정: 링크·등록방법 안내, 기본 탭으로 변경
- 날씨앱: 지역 선택 드롭다운, 🚨 기상특보 버튼, 오버레이 한 줄 배치, 폰트 굵기 개선
- 모바일 브레이크포인트 1024px 통일 (갤럭시 폴드 대응)
- 교통앱 마지막 지역 localStorage 저장/복원

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-26 15:48:44 +09:00
co-authored by Claude Sonnet 4.6
parent d74614cee1
commit 5a1ecfe36d
4 changed files with 105 additions and 8 deletions
+43 -1
View File
@@ -11603,13 +11603,15 @@ app.get('/api/kakao/auth/start', (req, res) => {
const conf = resolveChannelsConfig().kakao;
if (!conf.appKey) { res.status(400).send('kakao appKey가 설정되지 않았습니다. vault channels.kakao.appKey 를 먼저 설정하세요.'); return; }
const redirectUri = `${req.protocol}://${req.get('host')}/api/kakao/auth/callback`;
const url = `https://kauth.kakao.com/oauth/authorize?client_id=${encodeURIComponent(conf.appKey)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=talk_message`;
const username = (req as any).user?.username || '';
const url = `https://kauth.kakao.com/oauth/authorize?client_id=${encodeURIComponent(conf.appKey)}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=talk_message&state=${encodeURIComponent(username)}`;
res.redirect(url);
});
// OAuth 콜백 — 카카오가 인가코드를 이 URL로 전송
app.get('/api/kakao/auth/callback', async (req, res) => {
const code = String(req.query.code || '');
const stateUsername = String(req.query.state || '');
if (!code) { res.status(400).send('인가코드가 없습니다.'); return; }
const conf = resolveChannelsConfig().kakao;
const redirectUri = `${req.protocol}://${req.get('host')}/api/kakao/auth/callback`;
@@ -11630,12 +11632,52 @@ app.get('/api/kakao/auth/callback', async (req, res) => {
getVault(CONFIG_DIR_PATH).set('channels.kakao.refreshToken', data.refresh_token, 'kakao:token');
kakaoNotify.updateTokens(data.refresh_token, data.access_token);
console.log('[KakaoNotify] OAuth 완료 — 리프레시 토큰 vault 저장됨.');
// 카카오 userId 조회 → 현재 계정 자동 매핑
if (stateUsername) {
try {
const meResp = await fetch('https://kapi.kakao.com/v2/user/me', {
headers: { Authorization: `Bearer ${data.access_token}` },
});
if (meResp.ok) {
const me = await meResp.json() as { id: number };
const kakaoId = String(me.id);
const vault = getVault(CONFIG_DIR_PATH);
const rawMap = vault.get('channels.kakao.userMap', 'kakao:usermap')?.expose();
const userMap: Record<string, string> = rawMap ? JSON.parse(rawMap) : {};
if (!userMap[kakaoId]) {
userMap[kakaoId] = stateUsername;
vault.set('channels.kakao.userMap', JSON.stringify(userMap), 'kakao:usermap');
kakaoChannel.updateConfig({ userMap });
console.log(`[KakaoNotify] 자동 매핑: kakaoId=${kakaoId} → ${stateUsername}`);
}
}
} catch (e) { console.warn('[KakaoNotify] 자동 매핑 실패:', e); }
}
res.send('<html><body><h2>✅ 카카오 연동 완료!</h2><p>리프레시 토큰이 vault에 저장됐습니다. 이 창을 닫아도 됩니다.</p></body></html>');
} catch (err: any) {
res.status(500).send(`OAuth 오류: ${err.message}`);
}
});
// 채널 링크 나에게 보내기
app.post('/api/kakao/send-channel-link', async (req, res) => {
try {
const conf = resolveChannelsConfig().kakao;
if (!conf.appKey || !conf.refreshToken) {
res.json({ success: false, error: 'OAuth 토큰이 없습니다. 카카오 로그인을 먼저 완료하세요.' });
return;
}
const channelUrl = (req.body?.channelUrl as string) || '';
const notifier = new KakaoNotify({ appKey: conf.appKey, refreshToken: conf.refreshToken });
await notifier.send(`📲 SmallClaw 카카오톡 채널\n아래 링크로 채널을 추가하고 AI와 대화하세요!\n\n${channelUrl}`);
res.json({ success: true });
} catch (err: any) {
res.json({ success: false, error: err.message });
}
});
// 알림 테스트 — vault에서 최신 토큰으로 fresh 인스턴스 사용
app.post('/api/kakao/test', async (req, res) => {
try {
+19
View File
@@ -3906,6 +3906,8 @@ async function loadKakaoStatus() {
else setChannelStatus('kk', 'off', '미설정');
if (n.hasAppKey) document.getElementById('settings-kk-appkey').placeholder = '•••••••• (저장됨)';
document.getElementById('settings-kk-channel-enabled').checked = !!c.enabled;
const linkBtn = document.getElementById('kk-send-link-btn');
if (linkBtn) linkBtn.style.display = (n.hasRefreshToken) ? 'inline-block' : 'none';
} catch {}
}
@@ -3928,6 +3930,23 @@ async function kakaoOAuthStart() {
}, 1000);
}
async function kakaoSendChannelLink() {
const btn = document.getElementById('kk-send-link-btn');
const orig = btn?.textContent;
if (btn) btn.textContent = '전송 중...';
try {
const data = await api('/api/kakao/send-channel-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelUrl: 'http://pf.kakao.com/_KxjxexfX/chat' }),
});
if (btn) btn.textContent = data.success ? '✅ 전송됨!' : '❌ 실패';
} catch {
if (btn) btn.textContent = '❌ 실패';
}
setTimeout(() => { if (btn) btn.textContent = orig; }, 3000);
}
async function saveChannelSettings(channel) {
const payload = readChannelPayload(channel);
try {
+12 -1
View File
@@ -1346,8 +1346,8 @@
<div class="right-section-title" style="margin-bottom:8px">채널 연결</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">채널</label>
<select id="settings-channel-select" class="settings-input" onchange="onChannelTypeChange()" style="margin-bottom:10px">
<option value="telegram">Telegram</option>
<option value="kakao">카카오톡</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="whatsapp">WhatsApp</option>
<option value="email">Email</option>
@@ -1392,6 +1392,7 @@
<div style="font-size:11px;font-weight:700;color:var(--muted);margin-bottom:6px">OAuth 로그인 (나에게 보내기 알림용)</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">API 키 저장 후 아래 버튼으로 카카오 로그인하면 토큰이 자동 저장됩니다.</div>
<button onclick="kakaoOAuthStart()" style="background:#FEE500;border:none;border-radius:7px;padding:6px 14px;font-size:12px;font-weight:700;color:#3C1E1E;cursor:pointer">🟡 카카오 로그인</button>
<button id="kk-send-link-btn" onclick="kakaoSendChannelLink()" style="display:none;background:none;border:1px solid rgba(56,189,248,.4);border-radius:7px;padding:6px 12px;font-size:12px;font-weight:700;color:#38bdf8;cursor:pointer;margin-left:6px">📲 채널 링크 보내기</button>
<span id="kk-oauth-status" style="font-size:11px;color:var(--muted);margin-left:8px"></span>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">사용자 매핑 (선택, JSON)</label>
@@ -1400,6 +1401,16 @@
<input type="checkbox" id="settings-kk-channel-enabled" />
챗봇 채널 활성화 (오픈빌더 연동)
</label>
<div style="margin-top:14px;padding:10px 12px;border:1px solid rgba(254,229,0,.25);border-radius:8px;background:rgba(254,229,0,.04);font-size:11px;line-height:2;">
<div style="font-weight:700;color:#b8960a;margin-bottom:4px">📲 채널 접속 링크</div>
<a href="http://pf.kakao.com/_KxjxexfX/chat" target="_blank" style="color:#38bdf8;word-break:break-all;">pf.kakao.com/_KxjxexfX/chat</a>
<div style="margin-top:8px;font-weight:700;color:var(--muted)">등록 방법</div>
<div style="color:var(--muted)">
1. 카카오 로그인 → 본인 계정 자동 매핑됨<br>
2. 다른 유저: 위 링크로 채널 추가 후 메시지 전송<br>
3. 서버 로그에서 userId 확인 → 사용자 매핑에 추가
</div>
</div>
</div>
<div id="channel-form-discord" style="display:none">
+31 -6
View File
@@ -10,7 +10,7 @@
<style>
:root{--brand:#38bdf8;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;font-weight:500;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.wt-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
@@ -40,7 +40,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.wt-loc.active{background:rgba(56,189,248,.08);border-color:rgba(56,189,248,.25);}
.wt-loc-info{flex:1;min-width:0;}
.wt-loc-name{font-size:11px;font-weight:700;color:var(--text);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.wt-loc-sub{font-size:10px;color:var(--muted);display:block;}
.wt-loc-sub{font-size:10px;font-weight:500;color:var(--muted);display:block;}
.wt-loc.active .wt-loc-sub{color:#7dd3fc;}
.wt-loc-acts{display:flex;gap:1px;opacity:0;transition:.12s;flex-shrink:0;}
.wt-loc-act{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:2px 3px;border-radius:3px;transition:.12s;line-height:1;}
@@ -60,7 +60,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* Quick prompts */
.wt-quick{padding:0 6px 6px;}
.wt-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;width:100%;margin-bottom:3px;line-height:1.4;}
.wt-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;width:100%;margin-bottom:3px;line-height:1.4;}
.wt-qbtn:hover{border-color:#38bdf8;color:#38bdf8;background:rgba(56,189,248,.04);}
/* Alert badges */
@@ -80,7 +80,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.wt-go-btn:hover{background:#0e5a80;}
/* Overlay tabs */
.wt-overlays{display:flex;gap:4px;flex-wrap:wrap;}
.wt-overlays{display:flex;gap:4px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;}
.wt-overlays::-webkit-scrollbar{height:3px;}
.wt-overlays::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.wt-ov{padding:3px 9px;border:1.5px solid var(--line);border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;white-space:nowrap;}
.wt-ov:hover{border-color:#38bdf8;color:#38bdf8;}
.wt-ov.active{border-color:#0ea5e9;background:#082f49;color:#7dd3fc;}
@@ -202,6 +204,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="wt-divider"></div>
<div class="wt-sb-sec"><span>빠른 질문</span></div>
<div class="wt-quick">
<select id="quick-loc-sel" class="wt-lf-input" style="width:100%;margin-bottom:6px;font-size:11px;" onchange="localStorage.setItem('wt_quick_loc',this.value)"></select>
<button class="wt-qbtn" onclick="askWeather('alert')" style="color:#f87171;border-color:rgba(239,68,68,.3);">🚨 기상 특보</button>
<button class="wt-qbtn" onclick="askWeather('today')">☀️ 오늘 날씨</button>
<button class="wt-qbtn" onclick="askWeather('week')">📅 주간 예보</button>
<button class="wt-qbtn" onclick="askWeather('air')">🌫️ 미세먼지·대기질</button>
@@ -250,7 +254,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
<div class="wt-map-area">
<div class="wt-map-loading" id="map-loading">🌍 Windy 지도 로딩 중...</div>
<iframe class="wt-map-frame" id="windy-frame" src="about:blank" onload="document.getElementById('map-loading').classList.add('hidden')"></iframe>
<iframe class="wt-map-frame" id="windy-frame" src="about:blank" allow="geolocation" onload="document.getElementById('map-loading').classList.add('hidden')"></iframe>
</div>
</section>
@@ -369,6 +373,7 @@ function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
function renderLocList(){
updateQuickLocSel();
const locs=getLocs();
const container=document.getElementById('loc-list');
container.innerHTML=`<div class="wt-locs">${locs.map((l,i)=>`
@@ -492,7 +497,21 @@ function processWindyUrl(text){
}
// Quick ask helpers
const currentLoc=()=>{const locs=getLocs();return locs[0]?.name||'서울';};
function updateQuickLocSel(){
const sel=document.getElementById('quick-loc-sel');
if(!sel)return;
const locs=getLocs();
const saved=localStorage.getItem('wt_quick_loc');
const presets=['서울','부산','대구','인천','광주','대전','울산','수원','구미','강릉','제주'];
const locNames=locs.map(l=>l.name);
const all=[...new Set([...locNames,...presets])];
sel.innerHTML=all.map(n=>`<option value="${n}"${n===(saved||locNames[0]||'서울')?'selected':''}>${n}</option>`).join('');
}
const currentLoc=()=>{
const sel=document.getElementById('quick-loc-sel');
if(sel&&sel.value)return sel.value;
const locs=getLocs();return locs[0]?.name||'서울';
};
function askWeather(type){
const loc=currentLoc();
const msgs={
@@ -504,10 +523,16 @@ function askWeather(type){
uv:`${loc} 오늘 자외선 지수와 시간대별 변화를 알려주세요.`,
climate:`${loc}의 월별 기후 평균(기온·강수량)을 30년 기준으로 알려주세요.`,
past:`${loc} 오늘 날짜 기준 역대 같은 날 날씨 기록을 알려주세요.`,
alert:`현재 ${loc} 및 전국 기상 특보(태풍·호우·폭염·한파·강풍·풍랑·황사)가 발효 중인지 알려주세요. 특보 종류, 대상 지역, 해제 예상 시간을 포함해주세요.`,
};
sendToChat(msgs[type]||'날씨를 알려주세요.');
}
function fetchAlerts(){
document.getElementById('alert-content').textContent='💬 채팅창에서 확인하세요';
sendToChat('현재 한국 전국 기상 특보(태풍·호우·폭염·한파·강풍·황사 등)가 발효 중인지 알려주세요. 없으면 "현재 특보 없음"이라고만 답하세요.');
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');