companionbot 0.4.3 → 0.5.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.
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Sub-agent 시스템 - 복잡한 작업을 독립적인 agent에게 위임
3
3
  */
4
- export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent } from "./manager.js";
4
+ export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent, abortAllAgents, stopCleanup } from "./manager.js";
@@ -198,6 +198,14 @@ export function cleanupOldAgents() {
198
198
  // running 상태도 1시간 지나면 정리 (stuck agent 방지)
199
199
  if (agent.status === "running" && agent.createdAt.getTime() < oneHourAgo) {
200
200
  console.log(`[Agent ${id}] Cleaning up stuck agent (running > 1h)`);
201
+ // 실행 중인 API 호출 취소
202
+ const controller = abortControllers.get(id);
203
+ if (controller) {
204
+ controller.abort();
205
+ abortControllers.delete(id);
206
+ }
207
+ agent.status = "cancelled";
208
+ agent.completedAt = new Date();
201
209
  agents.delete(id);
202
210
  }
203
211
  }
@@ -223,5 +231,27 @@ export function stopCleanup() {
223
231
  console.log("[AgentManager] Cleanup interval stopped");
224
232
  }
225
233
  }
234
+ /**
235
+ * 모든 진행 중인 agent abort 및 정리 (shutdown 시 사용)
236
+ */
237
+ export function abortAllAgents() {
238
+ console.log("[AgentManager] Aborting all running agents...");
239
+ // 모든 AbortController abort
240
+ for (const [id, controller] of abortControllers) {
241
+ console.log(`[Agent ${id}] Aborting`);
242
+ controller.abort();
243
+ }
244
+ abortControllers.clear();
245
+ // 모든 running agent 상태 업데이트
246
+ for (const [id, agent] of agents) {
247
+ if (agent.status === "running") {
248
+ agent.status = "cancelled";
249
+ agent.completedAt = new Date();
250
+ }
251
+ }
252
+ // cleanup interval 중지
253
+ stopCleanup();
254
+ console.log("[AgentManager] All agents aborted");
255
+ }
226
256
  // 자동 시작
227
257
  startCleanup();
package/dist/ai/claude.js CHANGED
@@ -12,9 +12,10 @@ export const MODELS = {
12
12
  opus: { id: "claude-opus-4-20250514", name: "Claude Opus 4" },
13
13
  haiku: { id: "claude-haiku-3-5-20241022", name: "Claude Haiku 3.5" },
14
14
  };
15
- export async function chat(messages, systemPrompt, modelId = "sonnet") {
15
+ export async function chat(messages, systemPrompt, modelId = "sonnet", options) {
16
16
  const client = getClient();
17
17
  const model = MODELS[modelId].id;
18
+ const signal = options?.signal;
18
19
  // 메시지를 API 형식으로 변환
19
20
  const apiMessages = messages.map((m) => ({
20
21
  role: m.role,
@@ -28,7 +29,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
28
29
  system: systemPrompt,
29
30
  messages: apiMessages,
30
31
  tools: tools,
31
- });
32
+ }, { signal });
32
33
  }
33
34
  catch (error) {
34
35
  if (error instanceof Anthropic.APIError) {
@@ -79,7 +80,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
79
80
  system: systemPrompt,
80
81
  messages: apiMessages,
81
82
  tools: tools,
82
- });
83
+ }, { signal });
83
84
  }
84
85
  catch (error) {
85
86
  if (error instanceof Anthropic.APIError) {
@@ -130,30 +130,163 @@ export function parseCronExpression(expr) {
130
130
  };
131
131
  }
132
132
  /**
133
- * Calculate the next run time for a cron expression
133
+ * Get time components in a specific timezone using Intl API
134
+ */
135
+ function getTimeInTimezone(date, timezone) {
136
+ try {
137
+ const formatter = new Intl.DateTimeFormat("en-US", {
138
+ timeZone: timezone,
139
+ year: "numeric",
140
+ month: "2-digit",
141
+ day: "2-digit",
142
+ hour: "2-digit",
143
+ minute: "2-digit",
144
+ weekday: "short",
145
+ hour12: false,
146
+ });
147
+ const parts = formatter.formatToParts(date);
148
+ const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
149
+ const dayOfWeekMap = {
150
+ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
151
+ };
152
+ return {
153
+ year: parseInt(getValue("year"), 10),
154
+ month: parseInt(getValue("month"), 10),
155
+ day: parseInt(getValue("day"), 10),
156
+ hour: parseInt(getValue("hour"), 10),
157
+ minute: parseInt(getValue("minute"), 10),
158
+ dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
159
+ };
160
+ }
161
+ catch {
162
+ // Fallback to local time if timezone is invalid
163
+ return {
164
+ year: date.getFullYear(),
165
+ month: date.getMonth() + 1,
166
+ day: date.getDate(),
167
+ hour: date.getHours(),
168
+ minute: date.getMinutes(),
169
+ dayOfWeek: date.getDay(),
170
+ };
171
+ }
172
+ }
173
+ /**
174
+ * Create a Date object for a specific time in a timezone
175
+ */
176
+ function createDateInTimezone(year, month, day, hour, minute, timezone) {
177
+ try {
178
+ const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
179
+ const formatter = new Intl.DateTimeFormat("en-US", {
180
+ timeZone: timezone,
181
+ year: "numeric",
182
+ month: "2-digit",
183
+ day: "2-digit",
184
+ hour: "2-digit",
185
+ minute: "2-digit",
186
+ hour12: false,
187
+ });
188
+ // Binary search for the correct UTC time
189
+ let guess = new Date(dateStr);
190
+ for (let i = 0; i < 3; i++) {
191
+ const parts = formatter.formatToParts(guess);
192
+ const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
193
+ const guessHour = getValue("hour");
194
+ const guessMinute = getValue("minute");
195
+ const guessDay = getValue("day");
196
+ let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
197
+ if (day !== guessDay) {
198
+ if (day > guessDay || (day === 1 && guessDay > 20)) {
199
+ diffMinutes += 24 * 60;
200
+ }
201
+ else {
202
+ diffMinutes -= 24 * 60;
203
+ }
204
+ }
205
+ if (diffMinutes === 0)
206
+ break;
207
+ guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
208
+ }
209
+ return guess;
210
+ }
211
+ catch {
212
+ return new Date(year, month - 1, day, hour, minute, 0, 0);
213
+ }
214
+ }
215
+ /**
216
+ * Get number of days in a month
217
+ */
218
+ function getDaysInMonth(year, month) {
219
+ return new Date(year, month, 0).getDate();
220
+ }
221
+ /**
222
+ * Calculate the next run time for a cron expression with timezone support
134
223
  */
135
224
  export function getNextCronRun(expression, fromDate = new Date(), timezone) {
136
225
  const parsed = parseCronExpression(expression);
137
- const from = new Date(fromDate);
138
- // Start from the next minute
139
- from.setSeconds(0);
140
- from.setMilliseconds(0);
141
- from.setMinutes(from.getMinutes() + 1);
142
- // Search for the next matching time (max 2 years ahead)
143
- const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
226
+ const now = fromDate;
227
+ const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
228
+ // Get current time in target timezone
229
+ const current = getTimeInTimezone(now, tz);
230
+ // Search for next valid time (up to 366 days ahead)
231
+ const searchDate = { ...current };
232
+ const maxIterations = 366 * 24 * 60;
144
233
  for (let i = 0; i < maxIterations; i++) {
145
- const candidate = new Date(from.getTime() + i * 60000);
146
- const minute = candidate.getMinutes();
147
- const hour = candidate.getHours();
148
- const dayOfMonth = candidate.getDate();
149
- const month = candidate.getMonth() + 1;
150
- const dayOfWeek = candidate.getDay();
151
- if (parsed.minute.values.includes(minute) &&
152
- parsed.hour.values.includes(hour) &&
153
- parsed.month.values.includes(month) &&
154
- (parsed.dayOfMonth.values.includes(dayOfMonth) ||
155
- parsed.dayOfWeek.values.includes(dayOfWeek))) {
156
- return candidate;
234
+ // Advance by one minute each iteration
235
+ if (i > 0) {
236
+ searchDate.minute++;
237
+ if (searchDate.minute > 59) {
238
+ searchDate.minute = 0;
239
+ searchDate.hour++;
240
+ if (searchDate.hour > 23) {
241
+ searchDate.hour = 0;
242
+ searchDate.day++;
243
+ searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
244
+ const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
245
+ if (searchDate.day > daysInMonth) {
246
+ searchDate.day = 1;
247
+ searchDate.month++;
248
+ if (searchDate.month > 12) {
249
+ searchDate.month = 1;
250
+ searchDate.year++;
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ // Check if this time matches all constraints
257
+ if (!parsed.month.values.includes(searchDate.month))
258
+ continue;
259
+ // For dayOfMonth and dayOfWeek: if both are restricted, match either (OR logic)
260
+ // If only one is restricted, use that one
261
+ const domRestricted = parsed.dayOfMonth.type !== "wildcard";
262
+ const dowRestricted = parsed.dayOfWeek.type !== "wildcard";
263
+ if (domRestricted && dowRestricted) {
264
+ // Both restricted: match either
265
+ if (!parsed.dayOfMonth.values.includes(searchDate.day) &&
266
+ !parsed.dayOfWeek.values.includes(searchDate.dayOfWeek)) {
267
+ continue;
268
+ }
269
+ }
270
+ else if (domRestricted) {
271
+ if (!parsed.dayOfMonth.values.includes(searchDate.day))
272
+ continue;
273
+ }
274
+ else if (dowRestricted) {
275
+ if (!parsed.dayOfWeek.values.includes(searchDate.dayOfWeek))
276
+ continue;
277
+ }
278
+ if (!parsed.hour.values.includes(searchDate.hour))
279
+ continue;
280
+ if (!parsed.minute.values.includes(searchDate.minute))
281
+ continue;
282
+ // Skip if this is the current minute (must be in the future)
283
+ if (i === 0)
284
+ continue;
285
+ // Create the date in the target timezone
286
+ const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
287
+ // Verify it's in the future
288
+ if (nextRun > now) {
289
+ return nextRun;
157
290
  }
158
291
  }
159
292
  throw new Error(`Could not find next run time for: ${expression}`);
@@ -7,6 +7,7 @@
7
7
  import { getDueJobs, markJobExecuted, loadJobs, addJob, removeJob, updateJob, getJobsByChat } from "./store.js";
8
8
  import { chat } from "../ai/claude.js";
9
9
  import { buildSystemPrompt } from "../telegram/utils/prompt.js";
10
+ import { runWithChatId } from "../session/state.js";
10
11
  // Scheduler state
11
12
  let schedulerInterval = null;
12
13
  let botInstance = null;
@@ -145,7 +146,8 @@ async function executeSystemEvent(job, payload, bot) {
145
146
  */
146
147
  async function executeAgentTurn(job, payload, bot) {
147
148
  const { message: inputMessage, context } = payload;
148
- try {
149
+ // Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
150
+ const response = await runWithChatId(job.chatId, async () => {
149
151
  // Build a fresh conversation for this job (separate from main chat)
150
152
  const messages = [
151
153
  {
@@ -165,8 +167,10 @@ async function executeAgentTurn(job, payload, bot) {
165
167
  - Run Count: ${(job.runCount || 0) + 1}
166
168
  - This is a scheduled task, not a direct user message.`;
167
169
  // Call Claude API
168
- const response = await chat(messages, systemPrompt, "sonnet");
169
- // Send the response to the chat
170
+ return await chat(messages, systemPrompt, "sonnet");
171
+ });
172
+ // Send the response to the chat
173
+ try {
170
174
  if (response && response.trim()) {
171
175
  // Split long messages (Telegram limit is 4096 characters)
172
176
  const maxLength = 4000;
@@ -4,6 +4,7 @@ import { getWorkspacePath } from "../workspace/index.js";
4
4
  import { chat } from "../ai/claude.js";
5
5
  import { isCalendarConfigured, getTodayEvents, formatEvent } from "../calendar/index.js";
6
6
  import { getSecret } from "../config/secrets.js";
7
+ import { runWithChatId } from "../session/state.js";
7
8
  // 활성 타이머
8
9
  const activeTimers = new Map();
9
10
  // 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
@@ -120,7 +121,10 @@ ${context}
120
121
  ];
121
122
  let messageSent = false;
122
123
  try {
123
- const response = await chat(messages, systemPrompt, "haiku");
124
+ // Wrap in runWithChatId so tools can access chatId via getCurrentChatId()
125
+ const response = await runWithChatId(config.chatId, async () => {
126
+ return await chat(messages, systemPrompt, "haiku");
127
+ });
124
128
  if (!response.trim().includes("HEARTBEAT_OK")) {
125
129
  await botInstance.api.sendMessage(config.chatId, response);
126
130
  console.log(`[Heartbeat] Sent message to ${config.chatId}`);
@@ -27,12 +27,12 @@ export function createBot(token) {
27
27
  // Cron 시스템 초기화
28
28
  setCronBot(bot);
29
29
  restoreCronJobs().catch((err) => console.error("Failed to restore cron jobs:", err));
30
- // Rate limiting - 1분에 10개 메시지
30
+ // Rate limiting - 1분에 20개 메시지
31
31
  bot.use(limit({
32
32
  timeFrame: 60000, // 1분
33
- limit: 10,
33
+ limit: 20,
34
34
  onLimitExceeded: async (ctx) => {
35
- await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 잠시 후 다시 시도해주세요.");
35
+ await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 30초 후 다시 시도해주세요.");
36
36
  },
37
37
  }));
38
38
  // 에러 핸들링
@@ -1,3 +1,4 @@
1
+ import { InlineKeyboard } from "grammy";
1
2
  import { randomBytes } from "crypto";
2
3
  import { chat, MODELS } from "../../ai/claude.js";
3
4
  // Reset 토큰 관리 (1분 만료)
@@ -74,16 +75,48 @@ export function registerCommands(bot) {
74
75
  `/reset - 페르소나 리셋`);
75
76
  }
76
77
  });
77
- // /reset 명령어 - 페르소나 리셋 (토큰 기반)
78
+ // /reset 명령어 - 페르소나 리셋 (인라인 버튼 + 토큰 기반)
78
79
  bot.command("reset", async (ctx) => {
79
80
  const chatId = ctx.chat.id;
80
81
  const token = generateResetToken(chatId);
81
- await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n" +
82
- "모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n\n" +
83
- `확인하려면 /confirm_reset_${token} 을 입력하세요.\n` +
84
- "(1분 만료)");
82
+ const keyboard = new InlineKeyboard()
83
+ .text(" 예, 리셋합니다", `reset_confirm_${token}`)
84
+ .text("❌ 취소", "reset_cancel");
85
+ await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n\n" +
86
+ "모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n" +
87
+ "(1분 후 버튼 만료)", { reply_markup: keyboard });
85
88
  });
86
- // /confirm_reset_<token> 패턴 매칭
89
+ // Reset 확인 버튼 콜백
90
+ bot.callbackQuery(/^reset_confirm_([a-f0-9]+)$/, async (ctx) => {
91
+ const chatId = ctx.chat.id;
92
+ const token = ctx.match[1];
93
+ if (!validateResetToken(chatId, token)) {
94
+ await ctx.answerCallbackQuery({ text: "❌ 만료된 요청입니다. /reset 으로 다시 시도하세요." });
95
+ await ctx.editMessageText("❌ 만료된 요청입니다.\n/reset 으로 다시 시도하세요.");
96
+ return;
97
+ }
98
+ const { initWorkspace } = await import("../../workspace/index.js");
99
+ const { rm } = await import("fs/promises");
100
+ try {
101
+ await ctx.answerCallbackQuery({ text: "리셋 중..." });
102
+ await rm(getWorkspacePath(), { recursive: true, force: true });
103
+ await initWorkspace();
104
+ invalidateWorkspaceCache();
105
+ clearHistory(chatId);
106
+ await ctx.editMessageText("✓ 페르소나가 리셋되었습니다.\n\n" +
107
+ "/start 를 눌러 온보딩을 시작하세요.");
108
+ }
109
+ catch (error) {
110
+ console.error("Reset error:", error);
111
+ await ctx.editMessageText("❌ 리셋 중 오류가 발생했습니다.");
112
+ }
113
+ });
114
+ // Reset 취소 버튼 콜백
115
+ bot.callbackQuery("reset_cancel", async (ctx) => {
116
+ await ctx.answerCallbackQuery({ text: "취소됨" });
117
+ await ctx.editMessageText("✓ 리셋이 취소되었습니다.");
118
+ });
119
+ // /confirm_reset_<token> 패턴 매칭 (레거시 - 텍스트 입력 지원)
87
120
  bot.hears(/^\/confirm_reset_([a-f0-9]+)$/, async (ctx) => {
88
121
  const chatId = ctx.chat.id;
89
122
  const token = ctx.match[1];
@@ -1,7 +1,70 @@
1
1
  import { chat } from "../../ai/claude.js";
2
2
  import { getHistory, getModel, runWithChatId, } from "../../session/state.js";
3
3
  import { updateLastMessageTime } from "../../heartbeat/index.js";
4
- import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
4
+ import { extractUrls, fetchWebContent, buildSystemPrompt, detectSecrets, } from "../utils/index.js";
5
+ // 채팅별 AbortController 관리 (race condition 방지)
6
+ const chatAbortControllers = new Map();
7
+ /**
8
+ * 이전 요청을 취소하고 새 AbortController 생성
9
+ */
10
+ function getNewAbortController(chatId) {
11
+ // 이전 요청 취소
12
+ const previous = chatAbortControllers.get(chatId);
13
+ if (previous) {
14
+ previous.abort();
15
+ }
16
+ // 새 controller 생성
17
+ const controller = new AbortController();
18
+ chatAbortControllers.set(chatId, controller);
19
+ return controller;
20
+ }
21
+ /**
22
+ * 완료된 요청의 controller 정리
23
+ */
24
+ function cleanupAbortController(chatId, controller) {
25
+ // 현재 저장된 controller와 같은 경우에만 삭제 (새 요청이 없을 때)
26
+ if (chatAbortControllers.get(chatId) === controller) {
27
+ chatAbortControllers.delete(chatId);
28
+ }
29
+ }
30
+ /**
31
+ * 모든 진행 중인 요청 취소 (shutdown 시 사용)
32
+ */
33
+ export function abortAllChatRequests() {
34
+ for (const [chatId, controller] of chatAbortControllers) {
35
+ controller.abort();
36
+ }
37
+ chatAbortControllers.clear();
38
+ }
39
+ /**
40
+ * API 키가 포함된 메시지를 처리합니다.
41
+ * 메시지를 삭제하고 사용자에게 경고합니다.
42
+ * @returns true if secret was detected and handled
43
+ */
44
+ async function handleSecretDetection(ctx, message) {
45
+ const result = detectSecrets(message);
46
+ if (!result.detected) {
47
+ return false;
48
+ }
49
+ // 원본 메시지 삭제 시도
50
+ try {
51
+ if (ctx.message?.message_id) {
52
+ await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
53
+ }
54
+ }
55
+ catch (error) {
56
+ // 삭제 실패해도 계속 진행 (권한 없을 수 있음)
57
+ console.warn("Failed to delete message with secret:", error);
58
+ }
59
+ // 경고 메시지 전송
60
+ const typeList = result.types.join(", ");
61
+ await ctx.reply(`⚠️ **API 키 감지됨!**\n\n` +
62
+ `방금 보낸 메시지에서 ${typeList} 키가 감지되어 삭제했어.\n\n` +
63
+ `🔐 API 키는 채팅에 절대 입력하면 안 돼!\n` +
64
+ `CLI에서 \`companionbot config\` 명령어로 안전하게 설정해줘.`, { parse_mode: "Markdown" });
65
+ console.log(`[Security] Blocked API key exposure: ${typeList}`);
66
+ return true;
67
+ }
5
68
  /**
6
69
  * 메시지 핸들러들을 봇에 등록합니다.
7
70
  */
@@ -9,6 +72,13 @@ export function registerMessageHandlers(bot) {
9
72
  // 사진 메시지 처리
10
73
  bot.on("message:photo", async (ctx) => {
11
74
  const chatId = ctx.chat.id;
75
+ const caption = ctx.message.caption || "";
76
+ // 캡션에서 API 키 감지
77
+ if (caption && await handleSecretDetection(ctx, caption)) {
78
+ return; // 메시지 삭제됨, 처리 중단
79
+ }
80
+ // 이전 요청 취소하고 새 controller 생성 (race condition 방지)
81
+ const controller = getNewAbortController(chatId);
12
82
  await runWithChatId(chatId, async () => {
13
83
  const history = getHistory(chatId);
14
84
  const modelId = getModel(chatId);
@@ -33,7 +103,7 @@ export function registerMessageHandlers(bot) {
33
103
  const buffer = await response.arrayBuffer();
34
104
  const base64 = Buffer.from(buffer).toString("base64");
35
105
  // 캡션이 있으면 사용, 없으면 기본 질문
36
- const caption = ctx.message.caption || "이 사진에 뭐가 있어?";
106
+ const photoCaption = caption || "이 사진에 뭐가 있어?";
37
107
  // 이미지와 텍스트를 함께 전송
38
108
  const imageContent = [
39
109
  {
@@ -46,12 +116,17 @@ export function registerMessageHandlers(bot) {
46
116
  },
47
117
  {
48
118
  type: "text",
49
- text: caption,
119
+ text: photoCaption,
50
120
  },
51
121
  ];
52
122
  history.push({ role: "user", content: imageContent });
53
123
  const systemPrompt = await buildSystemPrompt(modelId);
54
- const result = await chat(history, systemPrompt, modelId);
124
+ const result = await chat(history, systemPrompt, modelId, { signal: controller.signal });
125
+ // abort된 경우 히스토리 롤백
126
+ if (controller.signal.aborted) {
127
+ history.pop();
128
+ return;
129
+ }
55
130
  history.push({ role: "assistant", content: result });
56
131
  // 히스토리 제한
57
132
  if (history.length > 20) {
@@ -60,9 +135,21 @@ export function registerMessageHandlers(bot) {
60
135
  await ctx.reply(result);
61
136
  }
62
137
  catch (error) {
138
+ // abort로 인한 에러는 무시
139
+ if (controller.signal.aborted) {
140
+ console.log(`[Photo] Request aborted for chat ${chatId}`);
141
+ // 유저 메시지 롤백 (이미 추가된 경우)
142
+ if (history.length > 0 && history[history.length - 1].role === "user") {
143
+ history.pop();
144
+ }
145
+ return;
146
+ }
63
147
  console.error("Photo error:", error);
64
148
  await ctx.reply("사진 분석 중 오류가 발생했어.");
65
149
  }
150
+ finally {
151
+ cleanupAbortController(chatId, controller);
152
+ }
66
153
  });
67
154
  });
68
155
  // 일반 메시지 처리
@@ -72,6 +159,12 @@ export function registerMessageHandlers(bot) {
72
159
  // 빈 메시지 무시
73
160
  if (!userMessage.trim())
74
161
  return;
162
+ // API 키 감지 - 히스토리에 저장하지 않고 메시지 삭제
163
+ if (await handleSecretDetection(ctx, userMessage)) {
164
+ return; // 메시지 삭제됨, 처리 중단
165
+ }
166
+ // 이전 요청 취소하고 새 controller 생성 (race condition 방지)
167
+ const controller = getNewAbortController(chatId);
75
168
  await runWithChatId(chatId, async () => {
76
169
  // Heartbeat 마지막 대화 시간 업데이트
77
170
  updateLastMessageTime(chatId);
@@ -99,7 +192,12 @@ export function registerMessageHandlers(bot) {
99
192
  history.push({ role: "user", content: enrichedMessage });
100
193
  try {
101
194
  const systemPrompt = await buildSystemPrompt(modelId);
102
- const response = await chat(history, systemPrompt, modelId);
195
+ const response = await chat(history, systemPrompt, modelId, { signal: controller.signal });
196
+ // abort된 경우 히스토리 롤백
197
+ if (controller.signal.aborted) {
198
+ history.pop();
199
+ return;
200
+ }
103
201
  history.push({ role: "assistant", content: response });
104
202
  // 히스토리 제한 (최근 20개 메시지만 유지)
105
203
  if (history.length > 20) {
@@ -108,9 +206,21 @@ export function registerMessageHandlers(bot) {
108
206
  await ctx.reply(response);
109
207
  }
110
208
  catch (error) {
209
+ // abort로 인한 에러는 무시
210
+ if (controller.signal.aborted) {
211
+ console.log(`[Chat] Request aborted for chat ${chatId}`);
212
+ // 유저 메시지 롤백 (이미 추가된 경우)
213
+ if (history.length > 0 && history[history.length - 1].role === "user") {
214
+ history.pop();
215
+ }
216
+ return;
217
+ }
111
218
  console.error("Chat error:", error);
112
219
  await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
113
220
  }
221
+ finally {
222
+ cleanupAbortController(chatId, controller);
223
+ }
114
224
  });
115
225
  });
116
226
  }
@@ -4,3 +4,5 @@ export { extractUrls, fetchWebContent, isSafeUrl } from "./url.js";
4
4
  export { buildSystemPrompt, extractName } from "./prompt.js";
5
5
  // Cache utilities
6
6
  export { getWorkspace, invalidateWorkspaceCache } from "./cache.js";
7
+ // Secret detection utilities
8
+ export { detectSecrets, containsSecret } from "./secrets.js";
@@ -53,18 +53,21 @@ export async function buildSystemPrompt(modelId) {
53
53
  parts.push("---");
54
54
  parts.push(workspace.agents);
55
55
  }
56
- // 최근 기억 로드
56
+ // 장기 기억 (MEMORY.md) - 먼저 로드하여 중요한 맥락 제공
57
+ if (workspace.memory) {
58
+ parts.push("---");
59
+ parts.push("# 장기 기억 (MEMORY.md)");
60
+ parts.push("이것은 사용자와의 관계, 중요한 정보, 누적된 맥락입니다. 대화할 때 항상 참고하세요.");
61
+ parts.push(workspace.memory);
62
+ }
63
+ // 최근 일일 기억 로드 (오늘과 어제 우선)
57
64
  const recentMemories = await loadRecentMemories(3);
58
65
  if (recentMemories.trim()) {
59
66
  parts.push("---");
60
- parts.push("# 최근 기억");
67
+ parts.push("# 최근 일일 기록");
68
+ parts.push("최근 대화와 활동 기록입니다. '오늘'과 '어제'의 맥락을 파악하는 데 활용하세요.");
61
69
  parts.push(recentMemories);
62
70
  }
63
- if (workspace.memory) {
64
- parts.push("---");
65
- parts.push("# 장기 기억");
66
- parts.push(workspace.memory);
67
- }
68
71
  }
69
72
  // 도구 설명
70
73
  parts.push("---");