v3.2.1: 카카오 자동매핑 + 채널링크 전송 + 날씨앱 개선
- OAuth 로그인 시 카카오 userId 자동 조회 → 현재 계정 자동 매핑 - 채널 링크 나에게 보내기 버튼 (OAuth 완료 후 표시) - 카카오 채널 설정: 링크·등록방법 안내, 기본 탭으로 변경 - 날씨앱: 지역 선택 드롭다운, 🚨 기상특보 버튼, 오버레이 한 줄 배치, 폰트 굵기 개선 - 모바일 브레이크포인트 1024px 통일 (갤럭시 폴드 대응) - 교통앱 마지막 지역 localStorage 저장/복원 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,'&').replace(/</g,'<
|
||||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user