-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
3237 lines (2729 loc) · 132 KB
/
main.py
File metadata and controls
3237 lines (2729 loc) · 132 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""미팅 에이전트 — Slack Bolt 앱 진입점"""
import json
import os
import logging
import re
import threading
from datetime import datetime
from dotenv import load_dotenv
# 반드시 가장 먼저 호출 (override=True: 시스템 환경변수보다 .env 우선)
load_dotenv(override=True)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR)
log = logging.getLogger(__name__)
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk.errors import SlackApiError
from apscheduler.schedulers.background import BackgroundScheduler
import uvicorn
from agents import before as before_agent
from agents.before import (
run_briefing,
create_meeting_from_text,
update_meeting_from_text,
update_company_knowledge,
research_company,
research_person,
generate_text,
handle_company_confirmation,
handle_email_selection,
cancel_meeting_from_text,
suggest_meeting_slots,
handle_meeting_cancel_confirm,
handle_meeting_cancel_abort,
handle_meeting_cancel_with_room,
handle_meeting_cancel_event_only,
handle_meeting_cancel_abort_both,
handle_slot_create_meeting,
handle_create_confirm,
handle_create_abort,
handle_room_offer_show,
handle_room_offer_skip,
_pending_agenda,
_meeting_drafts,
_pending_create_confirm,
_pending_room_offer,
)
from agents.during import (
start_session,
add_note,
end_session,
generate_minutes_now,
check_transcripts,
get_minutes_list,
finalize_minutes,
cancel_minutes,
request_minutes_edit,
handle_minutes_edit_reply,
handle_minutes_source_select,
handle_recover_meeting_minutes_button,
handle_event_selection,
handle_event_title_reply,
start_document_based_minutes,
post_pending_drafts,
handle_pending_view_button,
handle_pending_review_button,
handle_pending_discard_button,
handle_pending_cleanup_all_button,
handle_pending_cleanup_confirm_button,
handle_pending_cleanup_cancel_button,
_active_sessions,
_pending_minutes,
_pending_inputs,
_find_draft_for_user,
find_draft_by_thread_ts,
get_session_thread,
)
from agents import during as during_agent
from agents import after
from agents import minutes_normalizer
from agents import card as card_agent
from agents import dreamplus as dreamplus_agent
from agents import feedback as feedback_agent
from agents import proposal as proposal_agent
from agents import todo as todo_agent
from agents import trello_report as trello_report_agent
from store import user_store
from server import oauth as oauth_server
from tools import stt
from tools import text_extract
from tools import calendar as cal_tools
app = App(token=os.getenv("SLACK_BOT_TOKEN"))
@app.error
def global_error_handler(error, body, logger):
logger.exception(f"에러 발생: {error}")
logger.error(f"요청 body: {body}")
# ── 등록 확인 헬퍼 ──────────────────────────────────────────────
def _check_registered(client, user_id: str, channel: str = None) -> bool:
"""미등록 사용자에게 안내 메시지 전송. 등록된 경우 True 반환."""
if user_store.is_registered(user_id):
return True
auth_url = oauth_server.build_auth_url(user_id)
client.chat_postMessage(
channel=channel or user_id,
text=f"⚠️ 먼저 Google 계정을 연결해주세요. 아래 링크에서 인증을 완료하면 자동으로 등록됩니다.",
blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": f"⚠️ 먼저 Google 계정을 연결해주세요.\n<{auth_url}|🔗 인증을 완료하면 자동으로 등록됩니다.>"}}],
)
return False
# ── 버튼 권한 검증 헬퍼 (I5) ─────────────────────────────────
def _ensure_creator(client, body: dict, expected_user_id: str | None) -> bool:
"""채널/스레드에 노출된 버튼을 요청자(생성자) 본인만 누를 수 있도록 가드.
expected_user_id가 None이거나 클릭한 사용자와 일치하면 True.
불일치 시 ephemeral 안내 후 False."""
clicker = body.get("user", {}).get("id")
if not expected_user_id or expected_user_id == clicker:
return True
try:
client.chat_postEphemeral(
channel=body.get("container", {}).get("channel_id") or clicker,
user=clicker,
text="⚠️ 이 작업은 요청자 본인만 진행할 수 있습니다.",
)
except Exception as e:
log.warning(f"권한 거부 ephemeral 발송 실패: {e}")
return False
# ── 매일 09:00 자동 브리핑 ───────────────────────────────────
def scheduled_briefing():
log.info("자동 브리핑 시작")
for row in user_store.all_users():
user_id = row["slack_user_id"]
# briefing_enabled가 0인 사용자는 건너뜀 (NULL/1은 수신)
enabled = row.get("briefing_enabled")
if enabled is not None and not enabled:
log.info(f"자동 브리핑 비활성화로 건너뜀: {user_id}")
continue
try:
run_briefing(app.client, user_id=user_id)
except Exception as e:
log.error(f"자동 브리핑 실패 ({user_id}): {e}")
def scheduled_transcript_check():
log.info("트랜스크립트 폴링 시작")
check_transcripts(app.client)
def scheduled_action_item_reminder():
log.info("액션아이템 리마인더 실행")
after.action_item_reminder(app.client)
def scheduled_feedback_digest():
log.info("피드백 다이제스트 실행")
feedback_agent.send_feedback_digest(app.client)
def scheduled_trello_weekly():
log.info("Trello 주간 보고서 실행")
try:
trello_report_agent.send_weekly_report(app.client)
except Exception as e:
log.exception(f"Trello 주간 보고서 실패: {e}")
def scheduled_meeting_alarm():
"""매분 실행 — 약 5분 뒤 시작하는 미팅을 찾아 알람 + 자동 세션 시작."""
try:
_check_and_send_meeting_alarms(app.client)
except Exception as e:
log.exception(f"미팅 시작 알람 폴링 실패: {e}")
def scheduled_fast_transcript_check():
"""2분 주기 — 캘린더 종료시각이 지난 활성 세션이 있으면 즉시 트랜스크립트 탐색.
`check_transcripts`(10분 주기)와 별개로, 자동 바인딩된 세션이 끝나자마자
회의록을 만들 수 있도록 빠른 경로 제공.
"""
try:
_fast_transcript_check_for_ended_sessions(app.client)
except Exception as e:
log.exception(f"빠른 트랜스크립트 폴링 실패: {e}")
def _fast_transcript_check_for_ended_sessions(slack_client):
from zoneinfo import ZoneInfo
kst = ZoneInfo("Asia/Seoul")
now_kst = datetime.now(kst)
# 종료시각이 지난 활성 세션을 가진 사용자만 추림 — 불필요한 캘린더 호출 회피
target_users: set[str] = set()
for user_id, sess in list(during_agent._active_sessions.items()):
end_iso = sess.get("event_end_iso") or ""
if not end_iso:
continue
try:
end_dt = datetime.fromisoformat(end_iso)
except Exception:
continue
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=kst)
if now_kst >= end_dt:
target_users.add(user_id)
if not target_users:
return
for user_id in target_users:
try:
during_agent._check_transcripts_for_user(
slack_client, user_id, min_minutes_ago=0,
)
except Exception as e:
if user_store.is_token_expired_error(e):
log.info(f"빠른 폴링 — 토큰 만료, 건너뜀: {user_id}")
else:
log.exception(f"빠른 폴링 실패 ({user_id}): {e}")
# 알람 발송 윈도우 — 매분 폴링이라 ±30초 여유를 둠 (4.5~5.5분 후 시작)
_ALARM_WINDOW_MIN_S = 4 * 60 + 30
_ALARM_WINDOW_MAX_S = 5 * 60 + 30
def _check_and_send_meeting_alarms(slack_client):
"""전체 사용자 대상 미팅 시작 알람 발송 + 자동 세션 바인딩."""
from zoneinfo import ZoneInfo
kst = ZoneInfo("Asia/Seoul")
now_kst = datetime.now(kst)
# 24시간에 한 번 수준으로 오래된 알람 기록 정리 (분당 폴링 부하 회피)
if now_kst.hour == 0 and now_kst.minute == 0:
try:
removed = user_store.cleanup_old_meeting_alarms(days=14)
if removed:
log.info(f"오래된 미팅 알람 기록 {removed}건 정리")
except Exception as e:
log.warning(f"미팅 알람 기록 정리 실패: {e}")
for row in user_store.all_users():
user_id = row["slack_user_id"]
# 알람 비활성 사용자는 건너뜀 (NULL/1은 수신)
enabled = row.get("meeting_start_alarm_enabled")
if enabled is not None and not enabled:
continue
try:
_check_user_meeting_alarm(slack_client, user_id, now_kst)
except Exception as e:
if user_store.is_token_expired_error(e):
log.info(f"미팅 알람 — 토큰 만료, 건너뜀: {user_id}")
else:
log.exception(f"미팅 알람 실패 ({user_id}): {e}")
def _check_user_meeting_alarm(slack_client, user_id: str, now_kst: datetime):
"""사용자 1명 — 5분 뒤 시작 이벤트 알람 후보 검사."""
creds = user_store.get_credentials(user_id)
events = cal_tools.get_upcoming_meetings(creds, days=1, from_now=True)
for ev in events:
ev_id = ev.get("id")
start_str = (ev.get("start") or {}).get("dateTime")
if not ev_id or not start_str:
continue # 종일 이벤트 등은 스킵
try:
start_dt = datetime.fromisoformat(start_str)
except Exception:
continue
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=now_kst.tzinfo)
delta_s = (start_dt - now_kst).total_seconds()
if not (_ALARM_WINDOW_MIN_S <= delta_s <= _ALARM_WINDOW_MAX_S):
continue
if user_store.was_meeting_alarm_sent(user_id, ev_id):
continue
try:
_send_meeting_start_alarm(slack_client, user_id, ev, start_dt)
user_store.mark_meeting_alarm_sent(user_id, ev_id)
except Exception as e:
log.exception(f"미팅 알람 발송 실패 ({user_id}, {ev_id}): {e}")
def _send_meeting_start_alarm(slack_client, user_id: str, event_raw: dict,
start_dt: datetime):
"""단일 미팅 알람 DM 발송 + 활성 세션 없으면 자동 바인딩."""
parsed = cal_tools.parse_event(event_raw)
title = parsed.get("summary") or "(제목 없음)"
meet_link = parsed.get("meet_link") or ""
location = (parsed.get("location") or "").strip()
end_str = (event_raw.get("end") or {}).get("dateTime", "")
time_line = start_dt.strftime("%H:%M")
if end_str:
try:
end_dt = datetime.fromisoformat(end_str)
time_line = f"{start_dt.strftime('%H:%M')} ~ {end_dt.strftime('%H:%M')}"
except Exception:
pass
lines = [f"🔔 *5분 뒤 미팅 시작*", f"\n*{title}*", f"🕐 {time_line}"]
if location:
lines.append(f"📍 {location}")
if meet_link:
lines.append(f"🎥 <{meet_link}|Google Meet 참여>")
lines.append("") # 빈 줄
# 자동 세션 바인딩 (이미 활성 세션 있으면 건너뜀)
bound = False
try:
bound = during_agent.bind_event_session(user_id, event_raw)
except Exception as e:
log.exception(f"미팅 알람 — 세션 자동 바인딩 실패 ({user_id}): {e}")
if bound:
lines.append("회의록 자동 생성을 위해 세션을 시작했어요. 미팅이 끝나면 아래 버튼을 누르거나, 트랜스크립트 도착 시 자동 처리됩니다.")
else:
lines.append("_이미 진행 중인 세션이 있어 자동 바인딩은 건너뛰었어요._")
fallback = f"🔔 5분 뒤 미팅 시작 — {title} ({time_line})"
blocks: list[dict] = [{"type": "section",
"text": {"type": "mrkdwn", "text": "\n".join(lines)}}]
# "지금 미팅 끝남" 버튼 — 사용자가 미팅 끝나자마자 누르면 즉시 회의록 흐름 진입
blocks.append({
"type": "actions",
"elements": [{
"type": "button",
"action_id": "meeting_end_now",
"text": {"type": "plain_text", "text": "🛑 지금 미팅 끝남", "emoji": True},
"style": "primary",
"value": parsed.get("id", ""),
}],
})
slack_client.chat_postMessage(channel=user_id, text=fallback, blocks=blocks,
unfurl_links=False, unfurl_media=False)
from datetime import datetime as _dt
scheduler = BackgroundScheduler(timezone="Asia/Seoul")
scheduler.add_job(scheduled_briefing, "cron", hour=9, minute=0)
scheduler.add_job(scheduled_transcript_check, "interval", minutes=10,
next_run_time=_dt.now())
scheduler.add_job(scheduled_action_item_reminder, "cron", hour=8, minute=0)
scheduler.add_job(scheduled_feedback_digest, "cron", hour=22, minute=0)
scheduler.add_job(scheduled_trello_weekly, "cron",
day_of_week="fri", hour=21, minute=0)
# 미팅 시작 5분 전 알람 — 매분 폴링
scheduler.add_job(scheduled_meeting_alarm, "interval", minutes=1)
# 캘린더 종료 후 빠른 회의록 생성 — 2분 주기로 종료된 세션만 탐색
scheduler.add_job(scheduled_fast_transcript_check, "interval", minutes=2)
def _extract_text_from_blocks(blocks: list) -> str:
"""Slack rich_text 블록 트리에서 사람이 읽을 텍스트만 평탄화 추출.
text 필드가 비어있는 메시지(블록 형식 전용)에 대비. text/element 키를 재귀적으로
수집하여 줄바꿈으로 연결.
"""
out: list[str] = []
def _walk(node):
if isinstance(node, dict):
t = node.get("text")
if isinstance(t, str) and t:
out.append(t)
elif isinstance(t, dict):
_walk(t)
for key in ("elements", "blocks"):
children = node.get(key)
if isinstance(children, list):
for c in children:
_walk(c)
elif isinstance(node, list):
for c in node:
_walk(c)
_walk(blocks)
return "\n".join(s for s in out if s.strip())
# ── @멘션 처리 ───────────────────────────────────────────────
@app.event("app_mention")
def handle_mention(event, say, client):
user_id = event.get("user")
text = event.get("text", "")
text = " ".join(word for word in text.split() if not word.startswith("<@")).strip()
channel = event.get("channel")
parent_ts = event.get("thread_ts") # 스레드 답장이면 부모 메시지 ts
thread_ts = parent_ts or event.get("ts")
# 스레드 답장 → 일정 업데이트 (브리핑·일정생성 공통)
log.info(f"handle_mention: parent_ts={parent_ts} draft_keys={list(_meeting_drafts.keys())[:5]}")
if parent_ts and parent_ts in _meeting_drafts:
if _check_registered(client, user_id, channel):
threading.Thread(
target=update_meeting_from_text,
args=(client,),
kwargs=dict(user_id=user_id, user_message=text,
channel=channel, thread_ts=parent_ts),
daemon=True,
).start()
return
# 스레드 답장 → 미팅 세션 (미팅종료 외 모든 입력은 메모로 처리)
if parent_ts:
session_thread = get_session_thread(user_id)
if session_thread and session_thread == (channel, parent_ts):
if _check_registered(client, user_id, channel):
_end_keywords = {"미팅종료", "미팅 종료", "회의 끝", "회의 종료", "미팅 마무리"}
if any(text.strip().startswith(kw) for kw in _end_keywords):
end_session(client, user_id=user_id,
channel=channel, thread_ts=parent_ts)
else:
add_note(client, user_id=user_id, note_text=text.strip(),
channel=channel, thread_ts=parent_ts)
return
if not text:
client.chat_postMessage(
channel=channel,
thread_ts=thread_ts,
text="안녕하세요!\n• 브리핑: '브리핑 해줘'\n• 미팅 생성: '오늘 15시에 김민환 미팅 잡아줘'",
)
return
if not _check_registered(client, user_id, channel):
return
# 스레드 답장이면 부모 메시지 본문을 읽어 트렐로 등록 등 컨텍스트 의존 인텐트에 사용
parent_text = ""
parent_author_id = ""
parent_msg_ts = ""
parent_fetch_error = ""
if parent_ts:
try:
resp = client.conversations_replies(
channel=channel, ts=parent_ts, limit=1, inclusive=True
)
msgs = resp.get("messages", [])
log.info(f"부모 메시지 조회: ok=True msgs_len={len(msgs)} "
f"text_len={len((msgs[0].get('text') or '')) if msgs else 0}")
if msgs:
parent_msg = msgs[0]
parent_text = (parent_msg.get("text") or "").strip()
# 텍스트가 없는 경우 blocks/attachments에서 추출 시도 (rich text 메시지 대응)
if not parent_text:
blocks = parent_msg.get("blocks") or []
parent_text = _extract_text_from_blocks(blocks).strip()
log.info(f"부모 메시지 text 비어있어 blocks에서 추출: {len(parent_text)}자")
# 작성자·작성 시각 메타 — Trello 코멘트 헤더에 사용
parent_author_id = parent_msg.get("user", "") or ""
parent_msg_ts = parent_msg.get("ts", "") or ""
except SlackApiError as e:
err_code = (e.response.get("error") if hasattr(e, "response") else "") or str(e)
parent_fetch_error = err_code
log.warning(f"부모 메시지 조회 실패 (Slack API): {err_code}")
except Exception as e:
parent_fetch_error = type(e).__name__
log.warning(f"부모 메시지 조회 실패: {e}")
_route_message(text, client, user_id=user_id, channel=channel,
thread_ts=thread_ts, parent_text=parent_text,
parent_fetch_error=parent_fetch_error,
parent_author_id=parent_author_id,
parent_msg_ts=parent_msg_ts)
# ── DM 처리 ─────────────────────────────────────────────────
@app.event("message")
def handle_message(event, client):
if event.get("bot_id"):
return
subtype = event.get("subtype")
user_id = event.get("user")
# ── 파일 업로드 (이미지: 명함 OCR / 음성: STT 메모 / 텍스트: 회의 메모) ──
if subtype == "file_share" and event.get("channel_type") == "im" and user_id:
if _check_registered(client, user_id):
for f in event.get("files", []):
mime = f.get("mimetype", "")
if mime.startswith("image/"):
log.info(f"명함 이미지 업로드 감지: user={user_id} file={f.get('id')}")
card_agent.handle_image_upload(client, user_id, f)
elif stt.is_audio(mime):
log.info(f"음성 파일 업로드 감지: user={user_id} file={f.get('name')} mime={mime}")
threading.Thread(
target=_handle_audio_upload,
args=(client, user_id, f),
daemon=True,
).start()
elif text_extract.is_text_document(mime):
log.info(f"텍스트 문서 업로드 감지: user={user_id} file={f.get('name')} mime={mime}")
threading.Thread(
target=_handle_text_upload,
args=(client, user_id, f),
daemon=True,
).start()
return
if subtype:
return
thread_ts = event.get("thread_ts")
text = event.get("text", "").strip()
channel = event.get("channel")
channel_type = event.get("channel_type")
log.info(f"handle_message: channel_type={channel_type} thread_ts={thread_ts} draft_keys={list(_meeting_drafts.keys())[:5]}")
# 스레드 답글 → 일정 업데이트 (브리핑·일정생성 공통)
if thread_ts and thread_ts in _meeting_drafts:
if not _check_registered(client, user_id):
return
threading.Thread(
target=update_meeting_from_text,
args=(client,),
kwargs=dict(user_id=user_id, user_message=text,
channel=channel, thread_ts=thread_ts),
daemon=True,
).start()
return
if channel_type == "im":
if not _check_registered(client, user_id):
return
# 회의록 수정 요청 스레드 답글 감지 — thread_ts로 정확한 초안 매칭 (B3)
if thread_ts and user_id:
found = find_draft_by_thread_ts(user_id, thread_ts)
if found:
threading.Thread(
target=handle_minutes_edit_reply,
args=(client, user_id, text),
kwargs=dict(thread_ts=thread_ts),
daemon=True,
).start()
return
# 제안서 개요/초안 수정 스레드 답글 감지 (Phase 2.4)
if thread_ts and user_id:
proposal_state = proposal_agent.get_pending_proposal(user_id)
if proposal_state:
# 개요 수정 스레드
if proposal_state.get("outline_ts") == thread_ts:
threading.Thread(
target=proposal_agent.handle_proposal_outline_edit_reply,
args=(client, user_id, text),
daemon=True,
).start()
return
# 제안서 초안 수정 스레드
if proposal_state.get("draft_ts") == thread_ts:
threading.Thread(
target=proposal_agent.handle_proposal_edit_reply,
args=(client, user_id, text),
daemon=True,
).start()
return
# 이벤트 선택 대기 중 제목 입력 스레드 답글 감지
if thread_ts and user_id:
pending = _pending_inputs.get(user_id)
if pending and pending.get("prompt_ts") == thread_ts and text:
handle_event_title_reply(client, user_id, text)
return
# Trello 토큰 입력 대기 중이면 토큰으로 처리 (return_url 실패 시 폴백)
if text and oauth_server.is_pending_trello_token(user_id):
token = text.strip()
if len(token) > 30 and " " not in token:
if oauth_server.save_trello_token_from_dm(user_id, token):
client.chat_postMessage(
channel=user_id,
text="✅ Trello 계정이 연결되었습니다! 이제 브리핑에서 Trello 카드 정보를 볼 수 있습니다.",
)
else:
client.chat_postMessage(
channel=user_id,
text="❌ Trello 토큰 저장에 실패했습니다. `/trello` 로 다시 시도해주세요.",
)
return
_route_message(text, client, user_id=user_id, user_msg_ts=event.get("ts"))
_HELP_TEXT = """*🤖 ParaMee 사용 가이드*
*📅 일정 관리*
• `내일 3시에 한국은행 미팅 잡아줘` — 일정 생성
└ 업체 지정: _"업체는 한국은행이야"_ 처럼 명시 (없으면 내부 회의)
└ 생성 후 스레드 답글로 제목·참석자·시간·장소·어젠다 수정 가능
└ 생성 결과 메시지의 `[👥 참석자 추가]` 버튼으로 참석자만 빠르게 추가
• `/미팅편집` or `/미팅수정` or `/미팅변경` — 향후 미팅 편집 UI
└ 자연어도 가능: _"카카오 미팅 편집해줘"_, _"내일 KISA 회의 시간 변경"_
└ 브리핑 헤더의 `[✏️ 편집]` 버튼으로 해당 미팅 바로 편집
• `/브리핑` or `브리핑 해줘` — 오늘 미팅 브리핑
*🎙️ 회의 진행*
• `/미팅시작 [제목]` or `미팅 시작해줘` — 회의 시작 (메모 세션 시작)
└ 후보 일정 있으면 선택 UI 표시 — `📝 새 미팅 추가` 버튼으로 캘린더 밖 미팅도 시작 가능
• `/메모 [내용]` or `메모: [내용]` — 회의 중 메모 추가 (세션 자동 시작)
└ 캘린더 일정 자동 감지, 여러 개면 선택 UI 제공
• 🎙️ 음성 파일 업로드 — STT 변환 후 메모로 자동 등록
• 📄 텍스트 문서 업로드 — 세션 중엔 메모로 추가, 세션 없이 업로드 시 **트랜스크립트로 간주해 회의록 초안 즉시 생성** (저장 시 업체 Wiki 자동 갱신)
• `/미팅종료` or `미팅 종료` — 회의 종료 및 회의록 자동 생성
└ 5가지 소스 중 선택: 🎙️ 트랜스크립트 탐색 / 📎 트랜스크립트 첨부 / 📝 노트만 / 🕐 트랜스크립트 대기 / ❌ 취소
└ `📎 트랜스크립트 첨부` — 개인 녹음·외부 STT 결과 텍스트 파일을 그대로 트랜스크립트로 사용 (한글 cp949·euc-kr 자동 디코딩)
└ Google Meet 트랜스크립트는 *원문 Transcript* 를 우선 사용 — Gemini 요약본은 원문이 없을 때만 폴백
• `/회의록작성` or `회의록 작성해줘` — 현재 세션 기반 회의록 즉시 생성
• 📝 *회의록 초안에서 수정 요청 했을 때* — 답글 입력 → 새 초안 카드 재발송. 이전 카드는 무시하고 *새 카드에서* 저장/편집
• 🔄 *회의록을 처음부터 다시 만들기* — 초안 카드의 ❌ 취소 → `/미팅종료` 재실행 또는 `📎 트랜스크립트 첨부`로 텍스트 직접 업로드
• `/회의록` or `회의록 보여줘` — 저장된 회의록 목록 조회
└ `/회의록 카카오` — 업체 기반 검색 | `/회의록 2026-03` — 기간 기반 검색
└ `카카오 지난달 회의록 찾아줘` — 자연어 검색
└ 양식 깨진 파일 옆에 `[🔧 양식 보정]` 버튼이 자동 노출됨
• `/회의록정리` or `/회의록보정` — 저장된 회의록 양식·구조 보정
└ 자연어도 가능: _"회의록 양식 깨진 거 고쳐줘"_, _"지난 회의록 정리해줘"_
• `/대기회의록` or `대기 회의록` — 검토 대기(저장 전 초안) 목록 + 항목별 검토/버리기 버튼
└ 새 회의록 생성 시 `[📋 대기 목록 자세히] [🗑️ 모두 정리]` 버튼이 함께 안내됨
*📝 할 일 (Todo)*
• `/할일추가 [내용]` or `할 일 추가 [내용]` — 개인 Todo 추가 (DM·@멘션 모두 지원)
└ 자연어 마감일: _"내일까지 AIA 제안서 이슈 작성"_, _"다음주 금요일까지 …"_
└ 카테고리: 해시태그(#업무/#개인/#AI) 또는 LLM 자동 추론 (기본 업무)
• `/할일` or `할 일` or `투두 보여줘` — 활성 목록 + 최근 완료 5건
• `[제목] 완료` / `[제목] 취소` / `[제목] 삭제` — 자연어 종료
└ 또는 조회 결과의 `[✅ 완료] [🚫 취소] [🗑️ 삭제]` 버튼
└ 09:00 브리핑에 마감 임박 항목 색상 강조하여 자동 노출
*🏢 드림플러스 회의실*
• `/회의실예약 [시간]` or `내일 2시에 회의실 잡아줘` — 회의실 예약
└ 층수·수용인원·시간 지정 가능: _"오늘 3시 2시간 8층 6인실"_
• `/회의실조회` or `내 회의실 예약 현황` — 예약 목록 조회
• `/회의실취소` or `회의실 예약 취소해줘` — 예약 취소
• `/크레딧조회` — 드림플러스 잔여 포인트 조회
• `/드림플러스` — 드림플러스 계정 등록/변경
*🔍 리서치*
• `한국은행 알아봐줘` — 업체 정보 및 최근 동향 조사
• `홍길동 한국은행 인물 조사해줘` — 담당자 정보 조사
*📋 Trello 연동*
• `/trello` — Trello 계정 연결
└ 브리핑 시 업체 카드의 미완료 액션아이템 표시
└ 회의록 완료 후 액션아이템 + 회의록 요약을 카드에 자동 등록 제안
• `/트렐로조회` or `트렐로 카드 보여줘` — Trello 카드 목록 조회
└ `/트렐로조회 삼성` or `삼성 트렐로 카드` — 업체명으로 카드 검색
*📝 업체 메모*
• `카카오 메모 — PoC 예산 확보` — 업체 파일에 메모 추가
└ 업체명 + "메모" 키워드 + 내용으로 자동 분류
*⚙️ 설정*
• `/등록` — Google 계정 연결 (최초 1회)
• `/재등록` — Google 계정 재연결 (스코프 갱신)
• `/trello` — Trello 계정 연결
• `/드림플러스` — 드림플러스 계정 등록/변경
• `/업데이트` — 내부 서비스 지식 갱신
*📝 피드백*
• `~기능 추가해줘` / `~개선해줘` / `~버그 같아` — 피드백 접수 (매일 아침 관리자에게 전달)
*💡 도움말*
• `/도움말` or `도움말` or `help` — 이 메시지 표시"""
_QUESTION_ANSWER_PROMPT = """당신은 ParaMee(파라메타 AI 미팅 어시스턴트) Slack 봇입니다.
사용자가 봇 사용법이나 기능에 대해 자연어로 질문했습니다. 아래 기능 가이드만 근거로, 한국어로 간결하고 구체적으로 답해주세요.
규칙:
- 핵심 답변을 먼저 1~2문장으로 제시하고, 필요하면 명령어 예시(백틱 포함)를 bullet로 덧붙이세요.
- 기능 가이드에 없는 내용은 추측하지 말고 "해당 기능은 제가 지원하지 않거나 아직 모릅니다."라고 답하세요.
- 질문과 관계없는 기능은 언급하지 마세요.
- 전체 명령 목록을 길게 나열하지 마세요 (그건 /도움말 전용).
--- 기능 가이드 ---
{help_text}
--- 사용자 질문 ---
{question}
답변:"""
def _handle_question(client, text: str, user_id: str,
channel: str | None, thread_ts: str | None):
"""사용법·기능에 대한 자연어 질문에 LLM으로 답변."""
from agents.before import generate_text
prompt = _QUESTION_ANSWER_PROMPT.format(help_text=_HELP_TEXT, question=text)
try:
answer = generate_text(prompt)
except Exception:
log.exception("question 인텐트 답변 생성 실패")
answer = ("답변을 생성하지 못했어요. `/도움말`에서 사용 가능한 명령어를 확인해주세요.")
client.chat_postMessage(channel=channel or user_id, thread_ts=thread_ts, text=answer)
def _send_trello_setup_link(client, user_id: str, *,
channel: str = None, thread_ts: str = None) -> None:
"""Slash command 미등록 환경에서도 자연어로 Trello 연결 링크를 발송."""
if not _check_registered(client, user_id):
return
try:
auth_url = oauth_server.build_trello_auth_url(user_id)
client.chat_postMessage(
channel=channel or user_id,
thread_ts=thread_ts,
text="Trello 계정 연결",
blocks=[{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"🔗 <{auth_url}|Trello 계정 연결하기>를 클릭하여 접근을 허용하세요.",
},
}],
)
except Exception as e:
client.chat_postMessage(
channel=channel or user_id,
thread_ts=thread_ts,
text=f"❌ Trello 인증 URL 생성 실패: {e}",
)
def _is_trello_setup_text(text: str) -> bool:
normalized = re.sub(r"\s+", "", (text or "").lower())
return normalized in {
"trello",
"트렐로",
"트렐로연동",
"트렐로연결",
"trello연동",
"trello연결",
"trello연결하기",
"트렐로연결하기",
}
_INTENT_PROMPT = """사용자의 Slack 메시지를 분석해서 의도(intent)를 분류해줘.
메시지: "{text}"
가능한 intent 목록:
- briefing: 브리핑 요청 (예: "브리핑 해줘", "오늘 미팅 현황", "brief", "이번주 일정 브리핑", "앞으로 3일 일정")
- create_meeting: 미팅/일정 생성 (예: "내일 3시에 KISA 미팅 잡아줘", "오늘 15시 홍길동 회의 만들어줘")
- cancel_meeting: 캘린더 일정 취소 — 드림플러스 회의실 예약 취소가 아니라 *Google Calendar 미팅*을 취소 (예: "내일 3시 카카오 미팅 취소해줘", "오늘 KISA 회의 삭제", "4/18 회의 지워줘")
- edit_meeting: 이미 생성된 캘린더 미팅을 편집·수정·변경 (예: "카카오 미팅 편집", "내일 미팅 수정할래", "내일 KISA 회의 시간 변경", "미팅 변경하고 싶어"). 새 미팅을 잡는 것이 아니라 *기존 미팅* 을 고칠 때만. 단순 답글로 처리되는 변경 메시지가 아니라 명시적인 편집/수정/변경 의도일 때.
- suggest_slots: 여러 참석자의 빈 시간대 추천 (예: "김민환, 홍길동이랑 다음주에 1시간 미팅 가능한 시간 찾아줘", "이번주 중에 팀 전체 2시간 비는 시간 알려줘")
- start_session: 미팅 시작 (예: "미팅 시작", "회의 시작해줘", "지금부터 KISA 회의 시작")
- add_note: 메모 추가 — 현재 진행 중인 회의에 내용 기록 (예: "메모: 예산 협의됨", "기록해줘 다음달 계약 예정", "노트 추가")
- end_session: 미팅 종료 (예: "미팅 종료", "회의 끝났어", "미팅 마무리해줘")
- generate_minutes: 회의록 작성 요청 (예: "회의록 작성해줘", "회의록 만들어줘", "회의록 생성")
- get_minutes: 회의록 조회·검색 (예: "회의록 보여줘", "회의록 목록", "지난 목요일 회의록", "4월 13일 회의록", "카카오 회의록 찾아줘", "지난달 회의록", "삼성전자 3월 회의록", "이번주 회의록")
- pending_minutes_list: 검토 대기 회의록(아직 저장 안 한 초안) 목록 조회 (예: "대기 회의록", "검토 대기 목록", "초안 보여줘", "회의록 대기열", "검토 대기 회의록 보여줘", "대기 중인 회의록", "회의록 초안 목록")
- normalize_minutes: 저장된 회의록 양식·구조 보정 (예: "회의록 정리해줘", "회의록 양식 보정", "회의록 깨진 거 고쳐줘", "지난 회의록 양식 정리", "회의록 프론트매터 다시 만들어줘")
- research_company: 특정 업체 정보 조사 (예: "KISA 알아봐줘", "삼성전자 정보 검색해줘", "카카오 최근 동향")
- research_person: 특정 인물 정보 조사 (예: "홍길동 인물 정보", "김민환 누구야", "이준호 카카오 담당자 조사해줘")
- update_knowledge: 내부 서비스 지식 갱신 (예: "knowledge 업데이트", "서비스 정보 갱신")
- dreamplus_book: 드림플러스 회의실 예약 (예: "회의실 예약해줘", "내일 2시에 회의실 잡아줘", "드림플러스 3시간 예약", "회의실 오늘 오후 3시부터 5시 2명")
- dreamplus_list: 드림플러스 예약 현황 조회 (예: "예약 현황 보여줘", "회의실 예약 목록", "드림플러스 예약 확인", "내 회의실 예약")
- dreamplus_cancel: 드림플러스 예약 취소 (예: "회의실 예약 취소", "드림플러스 예약 취소해줘")
- dreamplus_credits: 드림플러스 크레딧/포인트 조회 (예: "크레딧 얼마나 남았어", "포인트 확인", "드림플러스 크레딧 조회")
- dreamplus_settings: 드림플러스 계정 설정 (예: "드림플러스 설정", "드림플러스 로그인 정보 등록", "드림플러스 계정 등록")
- trello_search: Trello 카드 조회/검색 (예: "트렐로 카드 보여줘", "트렐로 조회", "KISA 트렐로 카드", "트렐로에서 삼성 찾아줘", "Trello 카드 목록")
- trello_register_from_thread: 스레드 답장에서 부모 메시지(회의록·노트·요약)를 Trello 카드에 등록 요청 (예: "위 회의록 트렐로에 등록해줘", "이 회의록 트렐로 등록해줘", "위 내용 트렐로 카드로 등록", "트렐로에 등록해줘", "위 회의 내용 트렐로에 올려줘", "이거 트렐로 카드로 만들어줘"). "조회/검색"이 아니라 *등록·올리기·만들기* 의도일 때만.
* params: {{"company": "메시지에서 명시된 업체명 힌트 (없으면 빈 문자열)"}}
- trello_weekly_report: Trello 워크스페이스 주간 보고서 생성 (예: "주간보고서", "주간 보고", "트렐로 주간 보고", "이번주 트렐로 요약", "주간 트렐로 업데이트", "지난 2주 트렐로 보고서", "트렐로 주간보고 생성해줘", "weekly trello report")
- search_minutes: (사용하지 않음 — get_minutes로 통합됨)
- company_memo: 업체 관련 메모 저장 (예: "카카오 메모 — PoC 예산 확보", "삼성 메모: 담당자 변경됨", "KISA 관련 메모 저장: 내달 계약 예정")
- todo_add: 개인 할 일 추가 (예: "할 일 추가 AIA 제안서 이슈 작성", "할일 추가 내일까지 병원 예약", "todo: meeting-agent Todo 기능 설계 #AI", "투두 추가 다음주 금요일까지 카카오 PoC 운영안 검토")
- todo_list: 활성 할 일 목록 조회 (예: "할 일", "할 일 보여줘", "todo 목록", "투두", "내 할일", "오늘 할 일 뭐야")
- todo_complete: 할 일 완료 처리 (예: "AIA 제안서 이슈 완료", "병원 예약 끝냈어", "1번 완료", "#3 완료")
- todo_cancel: 할 일 취소 처리 (예: "워드프레스 이전 취소", "그 항목 취소해줘")
- todo_delete: 할 일 삭제 (예: "병원 예약 삭제", "1번 삭제해줘", "그 todo 지워줘")
- todo_update: 할 일 수정 — 마감일·카테고리·제목 (예: "AIA 제안서 마감 5/3로 변경", "병원 예약 마감을 다음주 월요일로", "그 항목 카테고리 개인으로")
- settings: 사용자 설정 화면 — 알람 on/off 토글 (예: "설정", "내 설정", "브리핑 알람 켜줘", "브리핑 알람 꺼줘", "9시 브리핑 받기 싫어", "미팅 시작 알람 꺼줘", "5분전 알람 끄기", "미팅 알람 받을래", "settings")
* params: {{"target": "briefing" | "start_alarm" | "show", "action": "show" | "enable" | "disable"}}
- target="briefing": 매일 09:00 브리핑 알람 (예: "브리핑 알람", "9시 브리핑", "아침 브리핑")
- target="start_alarm": 미팅 시작 5분 전 알람 (예: "미팅 시작 알람", "5분 전 알람", "미팅 알람")
- target="show": 설정 화면만 보여주기 (예: "설정", "내 설정 보여줘")
- action: "켜줘"·"받을래"는 enable, "꺼줘"·"받기 싫어"·"끄기"는 disable, 단순 화면 요청이면 show. target="show"면 action도 "show".
- feedback: 기능 요청·개선 제안·버그 리포트 (예: "~기능 추가해줘", "~이렇게 개선해줘", "~가 안 돼 버그 같아", "~기능 넣어줘", "~가 불편해", "~해줬으면 좋겠어", "~도 지원해줘")
* 주의: 질문 형태(~어떻게 해?, ~방법 있어?, ~가능해?, ~하려면 뭘 하면 돼?)는 feedback이 아니라 question. 요구/불만/제안의 단정형일 때만 feedback.
* 주의: 위 todo_* 인텐트(특히 todo_add/todo_complete)와 혼동 금지. 사용자가 자기 할 일을 추가·종료·수정하는 메시지면 todo_*.
* 주의: settings 인텐트와도 구분 — "브리핑 알람 꺼줘"는 직접 토글 가능한 설정이므로 settings.
- question: 봇 사용법·기능·방법에 대한 자연어 질문 (예: "구글 밋 회의록은 자동 생성하고 싶으면 어떻게 하면 됨?", "업체 메모는 어떻게 추가해?", "트렐로 주간 보고는 어떻게 봐?", "회의실 예약 방법 알려줘", "이거 어떻게 써?", "~가능해?")
- help: 명시적 도움말/전체 명령어 안내 요청 (예: "도움말", "help", "사용법 보여줘", "뭘 할 수 있어", "명령어 목록")
- unknown: 위 중 해당 없음
params 추출 규칙:
- briefing: {{"start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "period_text": "표시용 기간 텍스트"}} — 자연어 기간을 날짜 범위로 변환
- 오늘 날짜: {today} (요일: {weekday})
- 어떤 자연어 기간 표현이든 start_date/end_date 날짜 범위로 변환
- 주(week)의 기준: 일요일~토요일
- "이번주"는 이번주 토요일까지, "일주일"/"7일간"은 오늘 기준 7일간 (다름!)
- 기간 언급 없으면 → start_date와 end_date 모두 null, period_text="향후 24시간" (기본값)
- create_meeting: params 없음 (원본 메시지 전체를 그대로 사용)
- cancel_meeting: params 없음 (원본 메시지 전체를 그대로 사용)
- edit_meeting: {{"keyword": "미팅 제목 키워드 (없으면 빈 문자열)"}} — 예: "카카오 미팅 편집" → "카카오"
- suggest_slots: params 없음 (원본 메시지 전체를 그대로 사용)
- start_session: {{"title": "미팅 제목 (없으면 빈 문자열)"}}
- add_note: {{"note": "메모 내용 ('메모:', '기록해줘' 등 트리거 단어 제거 후)"}}
- research_company: {{"company": "업체명"}}
- research_person: {{"person": "이름", "company": "소속 업체명 (없으면 빈 문자열)"}}
- trello_search: {{"query": "검색할 업체명 키워드 (전체 목록 조회 시 빈 문자열)"}}
- trello_weekly_report: {{"days": 정수 (수집 기간 일수; 명시 없으면 7)}}
- get_minutes: {{"query": "검색 키워드 (업체명, 날짜, 기간 등 원본 그대로 — 단순 목록 조회면 빈 문자열)"}}
- normalize_minutes: {{"keyword": "필터 키워드 (업체명·회의명; 없으면 빈 문자열)"}}
- company_memo: {{"company": "업체명", "memo": "메모 내용"}}
- dreamplus_book: {{"text": "원본 메시지 그대로"}}
- dreamplus_cancel: {{"text": "원본 메시지 그대로"}}
- todo_add: {{"raw": "트리거 단어('할 일 추가', '할일 추가', 'todo:', '투두 추가') 제거 후의 본문 — 날짜·해시태그·카테고리 키워드는 포함 유지"}}
- todo_list: params 없음
- todo_complete: {{"target": "완료 대상 — 번호('1', '#3') 또는 제목 부분"}}
- todo_cancel: {{"target": "취소 대상 (위와 동일)", "reason": "사유 (없으면 빈 문자열)"}}
- todo_delete: {{"target": "삭제 대상 (위와 동일)"}}
- todo_update: {{"target": "수정 대상", "field": "task|due_date|category", "value": "새 값 (due_date는 YYYY-MM-DD)"}}
JSON으로만 반환 (설명 없이):
{{"intent": "...", "params": {{}}}}"""
def _classify_intent(text: str) -> dict:
"""LLM으로 사용자 메시지 의도 분류. 실패 시 unknown 반환."""
try:
from zoneinfo import ZoneInfo
_now = datetime.now(ZoneInfo("Asia/Seoul"))
_weekday_names = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"]
result = generate_text(_INTENT_PROMPT.format(
text=text.replace('"', "'"),
today=_now.strftime("%Y-%m-%d"),
weekday=_weekday_names[_now.weekday()],
))
# JSON 파싱 — 마크다운 코드블록 제거
cleaned = result.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
return json.loads(cleaned)
except Exception as e:
log.warning(f"인텐트 분류 실패: {e} / 원문: {result if 'result' in dir() else '?'}")
return {"intent": "unknown", "params": {}}
# ── 회의록 검색 헬퍼 (FR-D11/D12) ─────────────────────────────
def _post_token_expired_message(client, *, user_id: str,
channel: str = None, thread_ts: str = None) -> None:
"""OAuth 토큰 만료 시 친화적 안내 — `/재등록` 유도."""
client.chat_postMessage(
channel=channel or user_id, thread_ts=thread_ts,
text="🔐 Google 인증이 만료되었어요.\n`/재등록` 명령으로 다시 인증해주세요.",
)
def _handle_credentials_error(e: BaseException, client, *, user_id: str,
channel: str = None, thread_ts: str = None) -> bool:
"""OAuth 토큰 만료 예외라면 친화적 안내 후 True 반환. 아니면 False.
호출자는 True가 반환되면 즉시 return 해야 한다.
"""
if user_store.is_token_expired_error(e):
_post_token_expired_message(client, user_id=user_id,
channel=channel, thread_ts=thread_ts)
return True
return False
def _search_minutes(client, *, user_id: str, query: str,
channel: str = None, thread_ts: str = None):
"""업체명·회의명·기간 기반 회의록 검색 (Drive 파일명 기반)"""
from tools import drive as _drive
try:
creds = user_store.get_credentials(user_id)
except Exception as e:
if _handle_credentials_error(e, client, user_id=user_id,
channel=channel, thread_ts=thread_ts):
return
client.chat_postMessage(
channel=channel or user_id, thread_ts=thread_ts,
text=f"⚠️ 인증 오류: {e}")
return
if not creds:
client.chat_postMessage(
channel=channel or user_id, thread_ts=thread_ts,
text="⚠️ Google 인증이 필요합니다. `/등록`으로 먼저 인증해주세요.")
return
user = user_store.get_user(user_id)
minutes_folder_id = user.get("minutes_folder_id") if user else None
if not minutes_folder_id:
client.chat_postMessage(
channel=channel or user_id, thread_ts=thread_ts,
text="⚠️ Minutes 폴더가 설정되지 않았습니다. `/재등록`으로 재인증해주세요.")
return
# 기간 파싱
date_from = date_to = None
keyword = query
from zoneinfo import ZoneInfo
_now = datetime.now(ZoneInfo("Asia/Seoul"))
def _weekday_kr(name: str) -> int:
"""요일 이름 → 0=월 ... 6=일"""
return {"월": 0, "화": 1, "수": 2, "목": 3, "금": 4, "토": 5, "일": 6}.get(name, -1)
def _last_weekday(weekday: int) -> str:
"""가장 최근 해당 요일의 날짜 (YYYY-MM-DD)"""
days_ago = (_now.weekday() - weekday) % 7
if days_ago == 0:
days_ago = 7 # "지난 월요일"이면 오늘이 월요일이라도 지난주
from datetime import timedelta
return (_now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
# YYYY-MM-DD ~ YYYY-MM-DD 범위
range_match = re.search(r'(\d{4}-\d{2}-\d{2})\s*[~\-]\s*(\d{4}-\d{2}-\d{2})', query)
if range_match:
date_from = range_match.group(1)
date_to = range_match.group(2)
keyword = query[:range_match.start()].strip() + " " + query[range_match.end():].strip()
# YYYY-MM 월 단위
elif month_match := re.search(r'(\d{4})-(\d{2})(?!\d)', query):
year, month = month_match.group(1), month_match.group(2)
import calendar as _cal_mod
last_day = _cal_mod.monthrange(int(year), int(month))[1]
date_from = f"{year}-{month}-01"
date_to = f"{year}-{month}-{last_day:02d}"
keyword = query[:month_match.start()].strip() + " " + query[month_match.end():].strip()
# M/D 또는 M월 D일 (올해 기준)
elif md_match := re.search(r'(\d{1,2})/(\d{1,2})', query):
m, d = int(md_match.group(1)), int(md_match.group(2))
if 1 <= m <= 12 and 1 <= d <= 31:
target = f"{_now.year}-{m:02d}-{d:02d}"
date_from = date_to = target
keyword = query[:md_match.start()].strip() + " " + query[md_match.end():].strip()
elif md_match2 := re.search(r'(\d{1,2})월\s*(\d{1,2})일', query):
m, d = int(md_match2.group(1)), int(md_match2.group(2))
if 1 <= m <= 12 and 1 <= d <= 31:
target = f"{_now.year}-{m:02d}-{d:02d}"
date_from = date_to = target
keyword = query[:md_match2.start()].strip() + " " + query[md_match2.end():].strip()
# "지난 월요일", "지난 화요일" 등
elif wd_match := re.search(r'지난\s*(월|화|수|목|금|토|일)요일', query):
wd = _weekday_kr(wd_match.group(1))
if wd >= 0:
target = _last_weekday(wd)
date_from = date_to = target
keyword = query[:wd_match.start()].strip() + " " + query[wd_match.end():].strip()
# "오늘", "어제"
elif "어제" in query:
from datetime import timedelta
target = (_now - timedelta(days=1)).strftime("%Y-%m-%d")
date_from = date_to = target
keyword = query.replace("어제", "").strip()
elif "오늘" in query:
target = _now.strftime("%Y-%m-%d")
date_from = date_to = target
keyword = query.replace("오늘", "").strip()
# "지난주"
elif "지난주" in query or "저번주" in query:
from datetime import timedelta
# 지난주 월~일
last_mon = _now - timedelta(days=_now.weekday() + 7)
last_sun = last_mon + timedelta(days=6)
date_from = last_mon.strftime("%Y-%m-%d")
date_to = last_sun.strftime("%Y-%m-%d")
keyword = query.replace("지난주", "").replace("저번주", "").strip()
elif "이번주" in query:
from datetime import timedelta
this_mon = _now - timedelta(days=_now.weekday())
this_sun = this_mon + timedelta(days=6)
date_from = this_mon.strftime("%Y-%m-%d")
date_to = this_sun.strftime("%Y-%m-%d")
keyword = query.replace("이번주", "").strip()
# "지난달", "이번달"
elif "지난달" in query or "저번달" in query:
from dateutil.relativedelta import relativedelta
prev = _now - relativedelta(months=1)
import calendar as _cal_mod
last_day = _cal_mod.monthrange(prev.year, prev.month)[1]