agent-sin 0.1.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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/assets/logo.png +0 -0
- package/builtin-skills/_shared/_models_lib.py +227 -0
- package/builtin-skills/_shared/_profile_lib.py +98 -0
- package/builtin-skills/_shared/_schedules_lib.py +313 -0
- package/builtin-skills/_shared/_skill_settings_lib.py +153 -0
- package/builtin-skills/_shared/i18n.py +26 -0
- package/builtin-skills/memo-delete/main.py +155 -0
- package/builtin-skills/memo-delete/skill.yaml +57 -0
- package/builtin-skills/memo-index/main.py +178 -0
- package/builtin-skills/memo-index/skill.yaml +53 -0
- package/builtin-skills/memo-save/README.md +5 -0
- package/builtin-skills/memo-save/main.py +74 -0
- package/builtin-skills/memo-save/skill.yaml +52 -0
- package/builtin-skills/memo-search/README.md +10 -0
- package/builtin-skills/memo-search/main.py +97 -0
- package/builtin-skills/memo-search/skill.yaml +51 -0
- package/builtin-skills/memo-vector-search/main.py +121 -0
- package/builtin-skills/memo-vector-search/skill.yaml +53 -0
- package/builtin-skills/model-add/main.py +180 -0
- package/builtin-skills/model-add/skill.yaml +112 -0
- package/builtin-skills/model-list/main.py +93 -0
- package/builtin-skills/model-list/skill.yaml +48 -0
- package/builtin-skills/model-set/main.py +123 -0
- package/builtin-skills/model-set/skill.yaml +69 -0
- package/builtin-skills/profile-delete/_profile_lib.py +98 -0
- package/builtin-skills/profile-delete/main.py +98 -0
- package/builtin-skills/profile-delete/skill.yaml +64 -0
- package/builtin-skills/profile-edit/_profile_lib.py +98 -0
- package/builtin-skills/profile-edit/main.py +97 -0
- package/builtin-skills/profile-edit/skill.yaml +72 -0
- package/builtin-skills/profile-save/main.py +52 -0
- package/builtin-skills/profile-save/skill.yaml +69 -0
- package/builtin-skills/schedule-add/_schedules_lib.py +303 -0
- package/builtin-skills/schedule-add/main.py +137 -0
- package/builtin-skills/schedule-add/skill.yaml +94 -0
- package/builtin-skills/schedule-list/_schedules_lib.py +303 -0
- package/builtin-skills/schedule-list/main.py +86 -0
- package/builtin-skills/schedule-list/skill.yaml +45 -0
- package/builtin-skills/schedule-remove/_schedules_lib.py +303 -0
- package/builtin-skills/schedule-remove/main.py +69 -0
- package/builtin-skills/schedule-remove/skill.yaml +49 -0
- package/builtin-skills/schedule-toggle/_schedules_lib.py +303 -0
- package/builtin-skills/schedule-toggle/main.py +78 -0
- package/builtin-skills/schedule-toggle/skill.yaml +61 -0
- package/builtin-skills/skills-disable/main.py +63 -0
- package/builtin-skills/skills-disable/skill.yaml +52 -0
- package/builtin-skills/skills-enable/main.py +62 -0
- package/builtin-skills/skills-enable/skill.yaml +51 -0
- package/builtin-skills/todo-add/main.py +68 -0
- package/builtin-skills/todo-add/skill.yaml +53 -0
- package/builtin-skills/todo-delete/main.py +65 -0
- package/builtin-skills/todo-delete/skill.yaml +47 -0
- package/builtin-skills/todo-done/main.py +75 -0
- package/builtin-skills/todo-done/skill.yaml +47 -0
- package/builtin-skills/todo-list/main.py +91 -0
- package/builtin-skills/todo-list/skill.yaml +48 -0
- package/builtin-skills/todo-tick/main.py +125 -0
- package/builtin-skills/todo-tick/skill.yaml +48 -0
- package/dist/builder/build-action-classifier.d.ts +18 -0
- package/dist/builder/build-action-classifier.js +142 -0
- package/dist/builder/build-commands.d.ts +19 -0
- package/dist/builder/build-commands.js +133 -0
- package/dist/builder/build-flow.d.ts +72 -0
- package/dist/builder/build-flow.js +416 -0
- package/dist/builder/builder-session.d.ts +117 -0
- package/dist/builder/builder-session.js +1129 -0
- package/dist/builder/conversation-router.d.ts +22 -0
- package/dist/builder/conversation-router.js +69 -0
- package/dist/builder/intent-runtime-store.d.ts +7 -0
- package/dist/builder/intent-runtime-store.js +60 -0
- package/dist/builder/progress-format.d.ts +7 -0
- package/dist/builder/progress-format.js +46 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2835 -0
- package/dist/cli/spinner.d.ts +30 -0
- package/dist/cli/spinner.js +164 -0
- package/dist/core/ai-provider.d.ts +75 -0
- package/dist/core/ai-provider.js +678 -0
- package/dist/core/builtin-skills.d.ts +27 -0
- package/dist/core/builtin-skills.js +120 -0
- package/dist/core/chat-engine.d.ts +70 -0
- package/dist/core/chat-engine.js +812 -0
- package/dist/core/config.d.ts +127 -0
- package/dist/core/config.js +1379 -0
- package/dist/core/daily-memory-promotion.d.ts +21 -0
- package/dist/core/daily-memory-promotion.js +422 -0
- package/dist/core/i18n.d.ts +23 -0
- package/dist/core/i18n.js +167 -0
- package/dist/core/info-lines.d.ts +5 -0
- package/dist/core/info-lines.js +39 -0
- package/dist/core/input-schema.d.ts +2 -0
- package/dist/core/input-schema.js +156 -0
- package/dist/core/intent-router.d.ts +27 -0
- package/dist/core/intent-router.js +160 -0
- package/dist/core/logger.d.ts +60 -0
- package/dist/core/logger.js +240 -0
- package/dist/core/memory.d.ts +10 -0
- package/dist/core/memory.js +72 -0
- package/dist/core/message-utils.d.ts +13 -0
- package/dist/core/message-utils.js +104 -0
- package/dist/core/notifier.d.ts +17 -0
- package/dist/core/notifier.js +424 -0
- package/dist/core/output-writer.d.ts +13 -0
- package/dist/core/output-writer.js +100 -0
- package/dist/core/plan-decision.d.ts +16 -0
- package/dist/core/plan-decision.js +88 -0
- package/dist/core/profile-memory.d.ts +17 -0
- package/dist/core/profile-memory.js +142 -0
- package/dist/core/runtime.d.ts +50 -0
- package/dist/core/runtime.js +187 -0
- package/dist/core/scheduler.d.ts +28 -0
- package/dist/core/scheduler.js +155 -0
- package/dist/core/secrets.d.ts +31 -0
- package/dist/core/secrets.js +214 -0
- package/dist/core/service.d.ts +35 -0
- package/dist/core/service.js +479 -0
- package/dist/core/skill-planner.d.ts +24 -0
- package/dist/core/skill-planner.js +100 -0
- package/dist/core/skill-registry.d.ts +98 -0
- package/dist/core/skill-registry.js +319 -0
- package/dist/core/skill-scaffold.d.ts +33 -0
- package/dist/core/skill-scaffold.js +256 -0
- package/dist/core/skill-settings.d.ts +11 -0
- package/dist/core/skill-settings.js +63 -0
- package/dist/core/transfer.d.ts +31 -0
- package/dist/core/transfer.js +270 -0
- package/dist/core/update-notifier.d.ts +2 -0
- package/dist/core/update-notifier.js +140 -0
- package/dist/discord/bot.d.ts +96 -0
- package/dist/discord/bot.js +2424 -0
- package/dist/runtimes/codex-app-server.d.ts +53 -0
- package/dist/runtimes/codex-app-server.js +305 -0
- package/dist/runtimes/python-runner.d.ts +7 -0
- package/dist/runtimes/python-runner.js +302 -0
- package/dist/runtimes/typescript-runner.d.ts +5 -0
- package/dist/runtimes/typescript-runner.js +172 -0
- package/dist/skills-sdk/types.d.ts +38 -0
- package/dist/skills-sdk/types.js +1 -0
- package/dist/telegram/bot.d.ts +94 -0
- package/dist/telegram/bot.js +1219 -0
- package/install.ps1 +132 -0
- package/install.sh +130 -0
- package/package.json +60 -0
- package/templates/skill-python/main.py +74 -0
- package/templates/skill-python/skill.yaml +48 -0
- package/templates/skill-typescript/main.ts +87 -0
- package/templates/skill-typescript/skill.yaml +42 -0
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { loadConfig } from "../core/config.js";
|
|
4
|
+
import { appendEventLog } from "../core/logger.js";
|
|
5
|
+
import { createIntentRuntime, renderBuildFooter, shouldShowBuildFooter, } from "../builder/build-flow.js";
|
|
6
|
+
import { routeConversationMessage, } from "../builder/conversation-router.js";
|
|
7
|
+
import { cleanProgressText, formatBuildProgress, progressIntervalMs } from "../builder/progress-format.js";
|
|
8
|
+
import { chunkText, cleanAttachmentText, formatAttachmentLabel, formatBytes, guessImageMimeType, indentAttachmentContent, isImageLikeFile, isTextLikeFile, } from "../core/message-utils.js";
|
|
9
|
+
import { isEmptyIntentRuntime, loadIntentRuntimeMap, saveIntentRuntimeMap, } from "../builder/intent-runtime-store.js";
|
|
10
|
+
import { inferLocaleFromText, l, lLines, withLocale } from "../core/i18n.js";
|
|
11
|
+
import { consumeUpdateBanner, scheduleUpdateCheck } from "../core/update-notifier.js";
|
|
12
|
+
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
13
|
+
const TELEGRAM_FILE_BASE = "https://api.telegram.org/file";
|
|
14
|
+
const POLL_TIMEOUT_SECONDS = 50;
|
|
15
|
+
const POLL_ERROR_DELAY_MS = 5000;
|
|
16
|
+
const TYPING_REFRESH_MS = 4500;
|
|
17
|
+
const MESSAGE_MAX = 4096;
|
|
18
|
+
const DRAFT_MESSAGE_MAX = 4096;
|
|
19
|
+
const DRAFT_PREVIEW_MAX = 1800;
|
|
20
|
+
const TELEGRAM_CONTEXT_HISTORY_LIMIT = 20;
|
|
21
|
+
const ATTACHMENT_TEXT_MAX_BYTES = 96 * 1024;
|
|
22
|
+
const ATTACHMENT_TEXT_MAX_CHARS = 20_000;
|
|
23
|
+
const ATTACHMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
const ATTACHMENT_SUMMARY_LIMIT = 8;
|
|
25
|
+
export async function runTelegramBot(config) {
|
|
26
|
+
const token = (process.env.AGENT_SIN_TELEGRAM_BOT_TOKEN || "").trim();
|
|
27
|
+
if (!token) {
|
|
28
|
+
console.error("AGENT_SIN_TELEGRAM_BOT_TOKEN is not set. Add it to ~/.agent-sin/.env or export it.");
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
const allowedRaw = process.env.AGENT_SIN_TELEGRAM_ALLOWED_USER_IDS || "";
|
|
32
|
+
const allowedUserIds = parseTelegramIdList(allowedRaw);
|
|
33
|
+
if (allowedUserIds.size === 0) {
|
|
34
|
+
console.error("AGENT_SIN_TELEGRAM_ALLOWED_USER_IDS is empty. Set it to your Telegram user ID so the bot only replies to you.");
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
const listenChatIds = parseTelegramIdList(process.env.AGENT_SIN_TELEGRAM_LISTEN_CHAT_IDS || "");
|
|
38
|
+
const offsetFile = path.join(config.workspace, "telegram", "offset.json");
|
|
39
|
+
const intentRuntimesFile = path.join(config.workspace, "telegram", "intent-runtimes.json");
|
|
40
|
+
const historiesFile = path.join(config.workspace, "telegram", "histories.json");
|
|
41
|
+
const persistedIntentRuntimes = await loadTelegramIntentRuntimes(intentRuntimesFile);
|
|
42
|
+
const persistedHistories = await loadTelegramHistories(historiesFile);
|
|
43
|
+
const offset = await loadTelegramOffset(offsetFile);
|
|
44
|
+
const state = {
|
|
45
|
+
config,
|
|
46
|
+
token,
|
|
47
|
+
allowedUserIds,
|
|
48
|
+
listenChatIds,
|
|
49
|
+
botUserId: null,
|
|
50
|
+
botUsername: null,
|
|
51
|
+
histories: persistedHistories,
|
|
52
|
+
historiesFile,
|
|
53
|
+
intentRuntimes: persistedIntentRuntimes,
|
|
54
|
+
intentRuntimesFile,
|
|
55
|
+
offset,
|
|
56
|
+
offsetFile,
|
|
57
|
+
running: true,
|
|
58
|
+
};
|
|
59
|
+
const shutdown = () => {
|
|
60
|
+
state.running = false;
|
|
61
|
+
console.log("agent-sin telegram: shutting down");
|
|
62
|
+
};
|
|
63
|
+
process.once("SIGINT", shutdown);
|
|
64
|
+
process.once("SIGTERM", shutdown);
|
|
65
|
+
scheduleUpdateCheck(config.workspace);
|
|
66
|
+
let me;
|
|
67
|
+
try {
|
|
68
|
+
me = await telegramApi(state, "getMe", {});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
console.error(`agent-sin telegram: getMe failed: ${message}`);
|
|
73
|
+
await appendEventLog(config, { level: "error", source: "telegram", event: "get_me_failed", message });
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
state.botUserId = String(me.id);
|
|
77
|
+
state.botUsername = me.username || null;
|
|
78
|
+
if (state.offset === null) {
|
|
79
|
+
await skipPendingUpdates(state);
|
|
80
|
+
}
|
|
81
|
+
await appendEventLog(config, {
|
|
82
|
+
level: "info",
|
|
83
|
+
source: "telegram",
|
|
84
|
+
event: "bot_starting",
|
|
85
|
+
details: {
|
|
86
|
+
allowed_user_count: allowedUserIds.size,
|
|
87
|
+
listen_chat_count: listenChatIds.size,
|
|
88
|
+
bot_user_id: state.botUserId,
|
|
89
|
+
bot_username: state.botUsername,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
console.log(`agent-sin telegram: starting as @${state.botUsername || "unknown"} (allowed users: ${allowedUserIds.size}, listen chats: ${listenChatIds.size})`);
|
|
93
|
+
while (state.running) {
|
|
94
|
+
try {
|
|
95
|
+
const updates = await getTelegramUpdates(state, POLL_TIMEOUT_SECONDS);
|
|
96
|
+
for (const update of updates) {
|
|
97
|
+
if (!state.running)
|
|
98
|
+
break;
|
|
99
|
+
if (typeof update.update_id === "number") {
|
|
100
|
+
state.offset = update.update_id + 1;
|
|
101
|
+
}
|
|
102
|
+
if (update.message) {
|
|
103
|
+
await handleTelegramMessage(state, update.message);
|
|
104
|
+
}
|
|
105
|
+
await saveTelegramOffset(state);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
110
|
+
console.error(`agent-sin telegram: polling error: ${message}`);
|
|
111
|
+
await appendEventLog(config, {
|
|
112
|
+
level: "error",
|
|
113
|
+
source: "telegram",
|
|
114
|
+
event: "polling_error",
|
|
115
|
+
message,
|
|
116
|
+
});
|
|
117
|
+
if (state.running) {
|
|
118
|
+
await sleep(POLL_ERROR_DELAY_MS);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await appendEventLog(config, { level: "info", source: "telegram", event: "bot_stopped" });
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
export function parseTelegramIdList(raw) {
|
|
126
|
+
const ids = new Set();
|
|
127
|
+
for (const part of raw.split(/[,;\s]+/)) {
|
|
128
|
+
const trimmed = part.trim();
|
|
129
|
+
if (trimmed && /^-?\d+$/.test(trimmed)) {
|
|
130
|
+
ids.add(trimmed);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return ids;
|
|
134
|
+
}
|
|
135
|
+
export function classifyTelegramMessage(message, botUserId, botUsername, allowedUserIds, listenChatIds = new Set()) {
|
|
136
|
+
const isPrivate = message.chat?.type === "private";
|
|
137
|
+
const isMentioned = hasTelegramBotMention(message, botUserId, botUsername);
|
|
138
|
+
const isReplyToBot = Boolean(botUserId && message.reply_to_message?.from && String(message.reply_to_message.from.id) === botUserId);
|
|
139
|
+
const isAllowed = Boolean(message.from && allowedUserIds.has(String(message.from.id)));
|
|
140
|
+
const isListenChat = Boolean(message.chat && listenChatIds.has(String(message.chat.id)));
|
|
141
|
+
return { isPrivate, isMentioned, isReplyToBot, isAllowed, isListenChat };
|
|
142
|
+
}
|
|
143
|
+
export function shouldRespond(ctx) {
|
|
144
|
+
if (!ctx.isAllowed)
|
|
145
|
+
return false;
|
|
146
|
+
return ctx.isPrivate || ctx.isMentioned || ctx.isReplyToBot;
|
|
147
|
+
}
|
|
148
|
+
export function extractTelegramIdentityCandidates(updates) {
|
|
149
|
+
const byUserId = new Map();
|
|
150
|
+
for (const update of updates) {
|
|
151
|
+
const message = update.message;
|
|
152
|
+
const user = message?.from;
|
|
153
|
+
if (!message || !user || user.is_bot) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const userId = String(user.id);
|
|
157
|
+
const displayName = [user.first_name, user.username ? `@${user.username}` : ""].filter(Boolean).join(" ");
|
|
158
|
+
const candidate = {
|
|
159
|
+
updateId: update.update_id,
|
|
160
|
+
userId,
|
|
161
|
+
chatId: String(message.chat.id),
|
|
162
|
+
chatType: message.chat.type,
|
|
163
|
+
displayName: displayName || userId,
|
|
164
|
+
username: user.username,
|
|
165
|
+
};
|
|
166
|
+
const existing = byUserId.get(userId);
|
|
167
|
+
if (!existing || (existing.chatType !== "private" && candidate.chatType === "private")) {
|
|
168
|
+
byUserId.set(userId, candidate);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return [...byUserId.values()].sort((a, b) => {
|
|
172
|
+
if (a.chatType === "private" && b.chatType !== "private")
|
|
173
|
+
return -1;
|
|
174
|
+
if (a.chatType !== "private" && b.chatType === "private")
|
|
175
|
+
return 1;
|
|
176
|
+
return b.updateId - a.updateId;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
export function telegramChatKey(message) {
|
|
180
|
+
return `${message.chat.id}:${message.message_thread_id || 0}`;
|
|
181
|
+
}
|
|
182
|
+
export function stripTelegramBotMention(content, botUsername) {
|
|
183
|
+
const trimmed = content.trim();
|
|
184
|
+
if (!botUsername) {
|
|
185
|
+
return trimmed;
|
|
186
|
+
}
|
|
187
|
+
const username = botUsername.replace(/^@/, "");
|
|
188
|
+
return trimmed
|
|
189
|
+
.replace(new RegExp(`@${escapeRegExp(username)}\\b`, "gi"), "")
|
|
190
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
191
|
+
.trim();
|
|
192
|
+
}
|
|
193
|
+
export function formatTelegramUserMessageForHistory(message, botUsername, botUserId = null) {
|
|
194
|
+
const text = stripTelegramBotMention(messageText(message), botUsername);
|
|
195
|
+
const attachments = formatTelegramAttachmentSummary(message);
|
|
196
|
+
const replyContext = formatTelegramReplyContext(message, botUsername, botUserId);
|
|
197
|
+
return [replyContext, text, attachments].filter(Boolean).join("\n\n").trim();
|
|
198
|
+
}
|
|
199
|
+
export function formatTelegramReplyContext(message, botUsername, botUserId) {
|
|
200
|
+
const reply = message.reply_to_message;
|
|
201
|
+
if (!reply)
|
|
202
|
+
return "";
|
|
203
|
+
const rawText = stripTelegramBotMention(messageText(reply), botUsername);
|
|
204
|
+
const attachmentSummary = formatTelegramAttachmentSummary(reply);
|
|
205
|
+
const body = [rawText, attachmentSummary].filter(Boolean).join("\n").trim();
|
|
206
|
+
if (!body)
|
|
207
|
+
return "";
|
|
208
|
+
const quoted = body
|
|
209
|
+
.split("\n")
|
|
210
|
+
.map((line) => `> ${line}`)
|
|
211
|
+
.join("\n");
|
|
212
|
+
const author = formatTelegramReplyAuthor(reply, botUserId);
|
|
213
|
+
return l(`[Reply to: ${author}]\n${quoted}`, `[返信元: ${author}]\n${quoted}`);
|
|
214
|
+
}
|
|
215
|
+
function formatTelegramReplyAuthor(message, botUserId) {
|
|
216
|
+
const from = message.from;
|
|
217
|
+
if (!from)
|
|
218
|
+
return l("unknown", "不明");
|
|
219
|
+
if (botUserId && String(from.id) === botUserId)
|
|
220
|
+
return l("me (bot)", "自分(bot)");
|
|
221
|
+
if (from.is_bot)
|
|
222
|
+
return from.first_name || from.username || "bot";
|
|
223
|
+
return from.first_name || from.username || String(from.id);
|
|
224
|
+
}
|
|
225
|
+
export function buildChatHistoryFromTelegramMessages(messages, botUserId, botUsername, allowedUserIds) {
|
|
226
|
+
const history = [];
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
if (message.from?.is_bot) {
|
|
229
|
+
if (!botUserId || String(message.from.id) !== botUserId)
|
|
230
|
+
continue;
|
|
231
|
+
const stripped = stripBadgePrefix(messageText(message)).trim();
|
|
232
|
+
if (stripped)
|
|
233
|
+
history.push({ role: "assistant", content: stripped });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (allowedUserIds.size > 0 && (!message.from || !allowedUserIds.has(String(message.from.id))))
|
|
237
|
+
continue;
|
|
238
|
+
const cleaned = formatTelegramUserMessageForHistory(message, botUsername, botUserId);
|
|
239
|
+
if (cleaned)
|
|
240
|
+
history.push({ role: "user", content: cleaned });
|
|
241
|
+
}
|
|
242
|
+
return history.slice(-TELEGRAM_CONTEXT_HISTORY_LIMIT);
|
|
243
|
+
}
|
|
244
|
+
export function chunkTelegramMessage(text, max = MESSAGE_MAX) {
|
|
245
|
+
return chunkText(text, max);
|
|
246
|
+
}
|
|
247
|
+
async function skipPendingUpdates(state) {
|
|
248
|
+
const updates = await getTelegramUpdates(state, 0);
|
|
249
|
+
const maxUpdateId = updates.reduce((max, update) => Math.max(max, update.update_id), -1);
|
|
250
|
+
state.offset = maxUpdateId >= 0 ? maxUpdateId + 1 : 0;
|
|
251
|
+
await saveTelegramOffset(state);
|
|
252
|
+
if (updates.length > 0) {
|
|
253
|
+
await appendEventLog(state.config, {
|
|
254
|
+
level: "info",
|
|
255
|
+
source: "telegram",
|
|
256
|
+
event: "pending_updates_skipped",
|
|
257
|
+
details: { count: updates.length },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function getTelegramUpdates(state, timeoutSeconds) {
|
|
262
|
+
const payload = {
|
|
263
|
+
timeout: timeoutSeconds,
|
|
264
|
+
allowed_updates: ["message"],
|
|
265
|
+
};
|
|
266
|
+
if (state.offset !== null) {
|
|
267
|
+
payload.offset = state.offset;
|
|
268
|
+
}
|
|
269
|
+
const updates = await telegramApi(state, "getUpdates", payload);
|
|
270
|
+
return Array.isArray(updates) ? updates : [];
|
|
271
|
+
}
|
|
272
|
+
async function handleTelegramMessage(state, message) {
|
|
273
|
+
if (!message || !message.chat || !message.from || message.from.is_bot) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const ctx = classifyTelegramMessage(message, state.botUserId, state.botUsername, state.allowedUserIds, state.listenChatIds);
|
|
277
|
+
if (!shouldRespond(ctx)) {
|
|
278
|
+
if ((ctx.isPrivate || ctx.isMentioned || ctx.isReplyToBot) && !ctx.isAllowed) {
|
|
279
|
+
await appendEventLog(state.config, {
|
|
280
|
+
level: "warn",
|
|
281
|
+
source: "telegram",
|
|
282
|
+
event: "blocked_user",
|
|
283
|
+
details: { user_id: message.from.id, chat_id: message.chat.id },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else if (ctx.isPrivate || ctx.isMentioned || ctx.isReplyToBot || ctx.isListenChat) {
|
|
287
|
+
await appendEventLog(state.config, {
|
|
288
|
+
level: "info",
|
|
289
|
+
source: "telegram",
|
|
290
|
+
event: "message_ignored",
|
|
291
|
+
message: messageText(message).slice(0, 120),
|
|
292
|
+
details: {
|
|
293
|
+
chat_id: message.chat.id,
|
|
294
|
+
message_id: message.message_id,
|
|
295
|
+
author_id: message.from.id,
|
|
296
|
+
is_private: ctx.isPrivate,
|
|
297
|
+
is_mentioned: ctx.isMentioned,
|
|
298
|
+
is_reply_to_bot: ctx.isReplyToBot,
|
|
299
|
+
is_allowed: ctx.isAllowed,
|
|
300
|
+
is_listen_chat: ctx.isListenChat,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const cleanText = normalizeTelegramCommand(stripTelegramBotMention(messageText(message), state.botUsername), state.botUsername);
|
|
307
|
+
const chatKey = telegramChatKey(message);
|
|
308
|
+
const chatId = String(message.chat.id);
|
|
309
|
+
const threadId = message.message_thread_id;
|
|
310
|
+
const sendOptions = { threadId, replyToMessageId: message.message_id };
|
|
311
|
+
if (cleanText === "!help" || cleanText === "/help" || cleanText === "/start") {
|
|
312
|
+
await sendTelegramMessage(state, chatId, helpText(), sendOptions);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (cleanText === "!reset" || cleanText === "/reset") {
|
|
316
|
+
if (state.histories.delete(chatKey)) {
|
|
317
|
+
void saveTelegramHistories(state);
|
|
318
|
+
}
|
|
319
|
+
if (state.intentRuntimes.delete(chatKey)) {
|
|
320
|
+
void saveTelegramIntentRuntimes(state);
|
|
321
|
+
}
|
|
322
|
+
await sendTelegramMessage(state, chatId, l("Chat history reset.", "会話履歴をリセットしました。"), sendOptions);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const progressCommand = handleProgressCommand(state, chatKey, cleanText);
|
|
326
|
+
if (progressCommand) {
|
|
327
|
+
await sendTelegramMessage(state, chatId, progressCommand.join("\n"), sendOptions);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const userMessage = await formatTelegramUserMessageForChat(state, message);
|
|
331
|
+
const userText = normalizeTelegramCommand(userMessage.text, state.botUsername);
|
|
332
|
+
if (!userText) {
|
|
333
|
+
await sendTelegramMessage(state, chatId, l("Please enter a message. Use `/help` for usage.", "メッセージを入力してください。`/help` で使い方を表示します。"), sendOptions);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let history = state.histories.get(chatKey);
|
|
337
|
+
if (!history) {
|
|
338
|
+
history = [];
|
|
339
|
+
state.histories.set(chatKey, history);
|
|
340
|
+
}
|
|
341
|
+
let intentRuntime = state.intentRuntimes.get(chatKey);
|
|
342
|
+
if (!intentRuntime) {
|
|
343
|
+
intentRuntime = createIntentRuntime(true);
|
|
344
|
+
state.intentRuntimes.set(chatKey, intentRuntime);
|
|
345
|
+
}
|
|
346
|
+
await refreshTelegramStateConfig(state);
|
|
347
|
+
await withLocale(inferLocaleFromText(userText), async () => {
|
|
348
|
+
const typing = startTypingKeepalive(state, chatId, threadId);
|
|
349
|
+
const draft = createTelegramDraftStreamer(state, message);
|
|
350
|
+
draft.update(l("Thinking", "考えています"), { force: true });
|
|
351
|
+
const prevMode = intentRuntime.mode;
|
|
352
|
+
try {
|
|
353
|
+
const lines = await routeTelegramMessage(state, userText, history, intentRuntime, chatKey, chatId, threadId, message.message_id, draft, userMessage.images);
|
|
354
|
+
typing.stop();
|
|
355
|
+
trimHistory(history);
|
|
356
|
+
void saveTelegramHistories(state);
|
|
357
|
+
void saveTelegramIntentRuntimes(state);
|
|
358
|
+
const isBuildEntry = prevMode !== "build" && intentRuntime.mode === "build";
|
|
359
|
+
const decorated = withTelegramModeBadge(intentRuntime, lines, { userText, isBuildEntry });
|
|
360
|
+
scheduleUpdateCheck(state.config.workspace);
|
|
361
|
+
const banner = await consumeUpdateBanner(state.config.workspace);
|
|
362
|
+
const finalLines = banner ? [banner, "", ...decorated] : decorated;
|
|
363
|
+
const reply = finalLines.filter((line) => line !== undefined && line !== null).join("\n").trim();
|
|
364
|
+
draft.update(l("Sending reply", "応答を送信しています"), { force: true });
|
|
365
|
+
await draft.finish();
|
|
366
|
+
await sendTelegramMessage(state, chatId, reply || l("(no response)", "(応答なし)"), sendOptions);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
typing.stop();
|
|
370
|
+
draft.update(l("Error occurred", "エラーになりました"), { force: true });
|
|
371
|
+
await draft.finish();
|
|
372
|
+
const errMessage = error instanceof Error ? error.message : String(error);
|
|
373
|
+
console.error(`agent-sin telegram: routeTelegramMessage failed: ${errMessage}`);
|
|
374
|
+
await appendEventLog(state.config, {
|
|
375
|
+
level: "error",
|
|
376
|
+
source: "telegram",
|
|
377
|
+
event: "route_failed",
|
|
378
|
+
message: errMessage.slice(0, 200),
|
|
379
|
+
details: { chat_id: chatId, message_id: message.message_id },
|
|
380
|
+
});
|
|
381
|
+
await sendTelegramMessage(state, chatId, l(`Error: ${errMessage}`, `エラー: ${errMessage}`), sendOptions);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async function refreshTelegramStateConfig(state) {
|
|
386
|
+
try {
|
|
387
|
+
state.config = await loadConfig();
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
391
|
+
await appendEventLog(state.config, {
|
|
392
|
+
level: "warn",
|
|
393
|
+
source: "telegram",
|
|
394
|
+
event: "config_refresh_failed",
|
|
395
|
+
message,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function routeTelegramMessage(state, text, history, intentRuntime, chatKey, chatId, threadId, replyToMessageId, draft, images = []) {
|
|
400
|
+
return routeConversationMessage({
|
|
401
|
+
config: state.config,
|
|
402
|
+
text,
|
|
403
|
+
history,
|
|
404
|
+
intentRuntime,
|
|
405
|
+
eventSource: "telegram",
|
|
406
|
+
images,
|
|
407
|
+
createBuildProgress: () => createTelegramBuildProgressReporter(state, chatKey, chatId, threadId, replyToMessageId, draft),
|
|
408
|
+
onChatProgress: (event) => draft.onChatProgress(event),
|
|
409
|
+
onAiProgress: (event) => draft.onAiProgress(event),
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function createTelegramBuildProgressReporter(state, chatKey, chatId, threadId, replyToMessageId, draft) {
|
|
413
|
+
const minIntervalMs = telegramProgressIntervalMs();
|
|
414
|
+
let lastSentAt = 0;
|
|
415
|
+
let lastText = "";
|
|
416
|
+
let sent = 0;
|
|
417
|
+
let pending = Promise.resolve();
|
|
418
|
+
const enqueue = (text) => {
|
|
419
|
+
pending = pending
|
|
420
|
+
.then(async () => {
|
|
421
|
+
await sendTelegramMessage(state, chatId, text, { threadId, replyToMessageId });
|
|
422
|
+
})
|
|
423
|
+
.catch(async (error) => {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
await appendEventLog(state.config, {
|
|
426
|
+
level: "warn",
|
|
427
|
+
source: "telegram",
|
|
428
|
+
event: "build_progress_failed",
|
|
429
|
+
message: message.slice(0, 200),
|
|
430
|
+
details: { chat_id: chatId },
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
onProgress(event) {
|
|
436
|
+
draft.onBuildProgress(event);
|
|
437
|
+
const text = formatTelegramBuildProgress(event, {
|
|
438
|
+
detail: isTelegramProgressDetailEnabled(state, chatKey),
|
|
439
|
+
});
|
|
440
|
+
if (!text) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
if (text === lastText) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (sent > 0 && now - lastSentAt < minIntervalMs) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
lastText = text;
|
|
451
|
+
lastSentAt = now;
|
|
452
|
+
sent += 1;
|
|
453
|
+
enqueue(text);
|
|
454
|
+
},
|
|
455
|
+
async flush() {
|
|
456
|
+
await pending;
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function isTelegramProgressDetailEnabled(state, chatKey) {
|
|
461
|
+
if (process.env.AGENT_SIN_TELEGRAM_PROGRESS_DETAIL === "1") {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
return state.intentRuntimes.get(chatKey)?.progress_detail === true;
|
|
465
|
+
}
|
|
466
|
+
function telegramProgressIntervalMs() {
|
|
467
|
+
return progressIntervalMs("AGENT_SIN_TELEGRAM_PROGRESS_INTERVAL_MS");
|
|
468
|
+
}
|
|
469
|
+
export function formatTelegramBuildProgress(event, options = {}) {
|
|
470
|
+
return formatBuildProgress(event, options);
|
|
471
|
+
}
|
|
472
|
+
function withTelegramModeBadge(intentRuntime, lines, options = {}) {
|
|
473
|
+
if (!shouldShowBuildFooter({
|
|
474
|
+
intentRuntime,
|
|
475
|
+
userText: options.userText ?? "",
|
|
476
|
+
replyLines: lines,
|
|
477
|
+
isBuildEntry: options.isBuildEntry ?? false,
|
|
478
|
+
})) {
|
|
479
|
+
return lines;
|
|
480
|
+
}
|
|
481
|
+
const footer = renderBuildFooter(intentRuntime, {
|
|
482
|
+
exitPrefix: "/",
|
|
483
|
+
languageHint: [options.userText ?? "", ...lines],
|
|
484
|
+
});
|
|
485
|
+
if (!footer)
|
|
486
|
+
return lines;
|
|
487
|
+
return [...lines, "", footer];
|
|
488
|
+
}
|
|
489
|
+
function handleProgressCommand(state, chatKey, text) {
|
|
490
|
+
if (text !== "!progress" &&
|
|
491
|
+
!text.startsWith("!progress ") &&
|
|
492
|
+
text !== "/progress" &&
|
|
493
|
+
!text.startsWith("/progress ")) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const mode = text.trim().split(/\s+/)[1]?.toLowerCase() || "status";
|
|
497
|
+
const current = state.intentRuntimes.get(chatKey);
|
|
498
|
+
if (["detail", "detailed", "verbose", "on"].includes(mode)) {
|
|
499
|
+
const runtime = current || createIntentRuntime(true);
|
|
500
|
+
runtime.progress_detail = true;
|
|
501
|
+
state.intentRuntimes.set(chatKey, runtime);
|
|
502
|
+
void saveTelegramIntentRuntimes(state);
|
|
503
|
+
return [l("Progress details are enabled for this chat. Use `/progress quiet` to switch back.", "このチャットの進捗通知を詳細表示にしました。`/progress quiet` で戻せます。")];
|
|
504
|
+
}
|
|
505
|
+
if (["quiet", "summary", "off"].includes(mode)) {
|
|
506
|
+
const runtime = current || createIntentRuntime(true);
|
|
507
|
+
runtime.progress_detail = false;
|
|
508
|
+
if (isEmptyIntentRuntime(runtime)) {
|
|
509
|
+
state.intentRuntimes.delete(chatKey);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
state.intentRuntimes.set(chatKey, runtime);
|
|
513
|
+
}
|
|
514
|
+
void saveTelegramIntentRuntimes(state);
|
|
515
|
+
return [l("Progress is now quiet for this chat. Internal logs will not be sent to Telegram.", "このチャットの進捗通知を静音表示にしました。内部ログはTelegramに流しません。")];
|
|
516
|
+
}
|
|
517
|
+
if (mode === "status") {
|
|
518
|
+
return [
|
|
519
|
+
current?.progress_detail
|
|
520
|
+
? l("Progress details are enabled for this chat.", "このチャットの進捗通知は詳細表示です。")
|
|
521
|
+
: l("Progress is quiet for this chat.", "このチャットの進捗通知は静音表示です。"),
|
|
522
|
+
];
|
|
523
|
+
}
|
|
524
|
+
return [l("Usage: /progress status | quiet | detail", "使い方: /progress status | quiet | detail")];
|
|
525
|
+
}
|
|
526
|
+
function helpText() {
|
|
527
|
+
return lLines([
|
|
528
|
+
"Welcome to the Agent-Sin Telegram bot.",
|
|
529
|
+
"It responds in DMs, mentions, and replies to the bot. Registered skills are called automatically when useful.",
|
|
530
|
+
"",
|
|
531
|
+
"Commands:",
|
|
532
|
+
" /help Show this help",
|
|
533
|
+
" /reset Reset this chat history",
|
|
534
|
+
" /progress quiet|detail Toggle progress messages",
|
|
535
|
+
" /back Return from build/edit mode to chat mode",
|
|
536
|
+
" /build [skill-id] [request] Create a new skill",
|
|
537
|
+
' /build chat <id> "message" Revise through conversation',
|
|
538
|
+
" /build list List drafts",
|
|
539
|
+
" /build test <id> Test a draft",
|
|
540
|
+
" /build status <id> Show status",
|
|
541
|
+
], [
|
|
542
|
+
"Agent-Sin Telegram bot へようこそ。",
|
|
543
|
+
"DM、メンション、bot への返信に反応します。登録済みスキルも自動で呼び出されます。",
|
|
544
|
+
"",
|
|
545
|
+
"コマンド:",
|
|
546
|
+
" /help この使い方を表示",
|
|
547
|
+
" /reset このチャットの会話履歴をリセット",
|
|
548
|
+
" /progress quiet|detail 進捗通知の量を切り替え",
|
|
549
|
+
" /back build / edit モードから chat モードに戻る",
|
|
550
|
+
" /build [skill-id] [要望] 新規スキルを作成",
|
|
551
|
+
' /build chat <id> "メッセージ" 会話しながら修正',
|
|
552
|
+
" /build list 作成中の一覧",
|
|
553
|
+
" /build test <id> 動作確認",
|
|
554
|
+
" /build status <id> 状態を見る",
|
|
555
|
+
]).join("\n");
|
|
556
|
+
}
|
|
557
|
+
async function formatTelegramUserMessageForChat(state, message) {
|
|
558
|
+
const text = stripTelegramBotMention(messageText(message), state.botUsername);
|
|
559
|
+
const attachments = await formatTelegramAttachmentDetails(state, message);
|
|
560
|
+
const replyContext = formatTelegramReplyContext(message, state.botUsername, state.botUserId);
|
|
561
|
+
return {
|
|
562
|
+
text: [replyContext, text, attachments.text].filter(Boolean).join("\n\n").trim(),
|
|
563
|
+
images: attachments.images,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function formatTelegramAttachmentSummary(message) {
|
|
567
|
+
const normalized = normalizeTelegramAttachments(message);
|
|
568
|
+
if (normalized.length === 0) {
|
|
569
|
+
return "";
|
|
570
|
+
}
|
|
571
|
+
const shown = normalized.slice(0, ATTACHMENT_SUMMARY_LIMIT);
|
|
572
|
+
const lines = [l("[Telegram attachments]", "[Telegram添付]")];
|
|
573
|
+
shown.forEach((attachment, index) => {
|
|
574
|
+
lines.push(`${index + 1}. ${formatTelegramAttachmentLabel(attachment)}`);
|
|
575
|
+
});
|
|
576
|
+
if (normalized.length > shown.length) {
|
|
577
|
+
lines.push(l(`...and ${normalized.length - shown.length} more`, `...他 ${normalized.length - shown.length} 件`));
|
|
578
|
+
}
|
|
579
|
+
return lines.join("\n");
|
|
580
|
+
}
|
|
581
|
+
async function formatTelegramAttachmentDetails(state, message) {
|
|
582
|
+
const normalized = normalizeTelegramAttachments(message);
|
|
583
|
+
if (normalized.length === 0) {
|
|
584
|
+
return { text: "", images: [] };
|
|
585
|
+
}
|
|
586
|
+
const shown = normalized.slice(0, ATTACHMENT_SUMMARY_LIMIT);
|
|
587
|
+
const lines = [l("[Telegram attachments]", "[Telegram添付]")];
|
|
588
|
+
const images = [];
|
|
589
|
+
for (let index = 0; index < shown.length; index += 1) {
|
|
590
|
+
const attachment = shown[index];
|
|
591
|
+
lines.push(`${index + 1}. ${formatTelegramAttachmentLabel(attachment)}`);
|
|
592
|
+
if (attachment.kind === "photo" || isImageTelegramDocument(attachment)) {
|
|
593
|
+
const image = await readTelegramAttachmentImage(state, attachment);
|
|
594
|
+
if (image.kind === "image") {
|
|
595
|
+
images.push(image.part);
|
|
596
|
+
if (image.savedPath) {
|
|
597
|
+
lines.push(l(` Content: image attached as AI input. Saved at: ${image.savedPath}`, ` 内容: 画像を AI 入力として添付しました。保存先: ${image.savedPath}`));
|
|
598
|
+
lines.push(l(` To keep this image in a saving skill such as memo-save, reference this path as Markdown: `, ` memo-saveなど保存系スキルで本文に画像を残すときは、Markdown記法  でこのパスを参照してください。`));
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
lines.push(l(" Content: image attached as AI input.", " 内容: 画像を AI 入力として添付しました。"));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
else if (image.reason) {
|
|
605
|
+
lines.push(l(` Content: ${image.reason}`, ` 内容: ${image.reason}`));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
const content = await readTelegramAttachmentText(state, attachment);
|
|
610
|
+
if (content.kind === "text") {
|
|
611
|
+
lines.push(l(" Content:", " 内容:"));
|
|
612
|
+
lines.push(indentAttachmentContent(content.text));
|
|
613
|
+
}
|
|
614
|
+
else if (content.reason) {
|
|
615
|
+
lines.push(l(` Content: ${content.reason}`, ` 内容: ${content.reason}`));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (normalized.length > shown.length) {
|
|
620
|
+
lines.push(l(`...and ${normalized.length - shown.length} more`, `...他 ${normalized.length - shown.length} 件`));
|
|
621
|
+
}
|
|
622
|
+
return { text: lines.join("\n"), images };
|
|
623
|
+
}
|
|
624
|
+
function normalizeTelegramAttachments(message) {
|
|
625
|
+
const attachments = [];
|
|
626
|
+
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
627
|
+
const photo = [...message.photo].sort((a, b) => (b.file_size || 0) - (a.file_size || 0))[0];
|
|
628
|
+
if (photo?.file_id) {
|
|
629
|
+
attachments.push({
|
|
630
|
+
kind: "photo",
|
|
631
|
+
file_id: photo.file_id,
|
|
632
|
+
file_size: photo.file_size,
|
|
633
|
+
filename: "photo.jpg",
|
|
634
|
+
mime_type: "image/jpeg",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (message.document?.file_id) {
|
|
639
|
+
attachments.push({
|
|
640
|
+
kind: "document",
|
|
641
|
+
file_id: message.document.file_id,
|
|
642
|
+
file_size: message.document.file_size,
|
|
643
|
+
filename: message.document.file_name,
|
|
644
|
+
mime_type: message.document.mime_type,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return attachments;
|
|
648
|
+
}
|
|
649
|
+
function formatTelegramAttachmentLabel(attachment) {
|
|
650
|
+
return formatAttachmentLabel({
|
|
651
|
+
name: attachment.filename,
|
|
652
|
+
fallback: attachment.kind === "photo" ? l("photo", "写真") : l("attachment", "添付ファイル"),
|
|
653
|
+
contentType: attachment.mime_type,
|
|
654
|
+
size: attachment.file_size,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
async function readTelegramAttachmentText(state, attachment) {
|
|
658
|
+
if (!isTextLikeTelegramDocument(attachment)) {
|
|
659
|
+
return { kind: "skipped", reason: l("This file type is not auto-extracted.", "この形式は本文を自動抽出していません。") };
|
|
660
|
+
}
|
|
661
|
+
if (typeof attachment.file_size === "number" && attachment.file_size > ATTACHMENT_TEXT_MAX_BYTES) {
|
|
662
|
+
return {
|
|
663
|
+
kind: "skipped",
|
|
664
|
+
reason: l(`Text attachment is too large and was skipped (${formatBytes(attachment.file_size)}).`, `テキスト添付が大きすぎるため読み取りを省略しました(${formatBytes(attachment.file_size)})。`),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const buffer = await fetchTelegramFileBuffer(state, attachment.file_id, ATTACHMENT_TEXT_MAX_BYTES);
|
|
669
|
+
const text = cleanAttachmentText(buffer.toString("utf8"), ATTACHMENT_TEXT_MAX_CHARS);
|
|
670
|
+
if (!text) {
|
|
671
|
+
return { kind: "skipped", reason: l("The text body was empty.", "本文が空でした。") };
|
|
672
|
+
}
|
|
673
|
+
return { kind: "text", text };
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
677
|
+
await appendEventLog(state.config, {
|
|
678
|
+
level: "warn",
|
|
679
|
+
source: "telegram",
|
|
680
|
+
event: "attachment_read_failed",
|
|
681
|
+
message: message.slice(0, 200),
|
|
682
|
+
details: {
|
|
683
|
+
filename: attachment.filename,
|
|
684
|
+
mime_type: attachment.mime_type,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
return { kind: "skipped", reason: l("An error occurred while fetching text.", "本文取得でエラーになりました。") };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function readTelegramAttachmentImage(state, attachment) {
|
|
691
|
+
if (typeof attachment.file_size === "number" && attachment.file_size > ATTACHMENT_IMAGE_MAX_BYTES) {
|
|
692
|
+
return {
|
|
693
|
+
kind: "skipped",
|
|
694
|
+
reason: l(`Image is too large and was skipped (${formatBytes(attachment.file_size)}).`, `画像が大きすぎるため添付を省略しました(${formatBytes(attachment.file_size)})。`),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const buffer = await fetchTelegramFileBuffer(state, attachment.file_id, ATTACHMENT_IMAGE_MAX_BYTES);
|
|
699
|
+
const mimeType = attachment.mime_type || guessImageMimeType(attachment.filename || "");
|
|
700
|
+
const savedPath = await persistTelegramAttachmentBuffer(state, attachment, buffer, mimeType);
|
|
701
|
+
return {
|
|
702
|
+
kind: "image",
|
|
703
|
+
part: {
|
|
704
|
+
type: "image",
|
|
705
|
+
image_url: `data:${mimeType};base64,${buffer.toString("base64")}`,
|
|
706
|
+
mime_type: mimeType,
|
|
707
|
+
filename: attachment.filename,
|
|
708
|
+
},
|
|
709
|
+
savedPath,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
714
|
+
await appendEventLog(state.config, {
|
|
715
|
+
level: "warn",
|
|
716
|
+
source: "telegram",
|
|
717
|
+
event: "attachment_image_read_failed",
|
|
718
|
+
message: message.slice(0, 200),
|
|
719
|
+
details: {
|
|
720
|
+
filename: attachment.filename,
|
|
721
|
+
mime_type: attachment.mime_type,
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
return { kind: "skipped", reason: l("An error occurred while fetching the image.", "画像取得でエラーになりました。") };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function persistTelegramAttachmentBuffer(state, attachment, buffer, mimeType) {
|
|
728
|
+
try {
|
|
729
|
+
const now = new Date();
|
|
730
|
+
const yyyy = String(now.getFullYear());
|
|
731
|
+
const MM = String(now.getMonth() + 1).padStart(2, "0");
|
|
732
|
+
const dd = String(now.getDate()).padStart(2, "0");
|
|
733
|
+
const HH = String(now.getHours()).padStart(2, "0");
|
|
734
|
+
const mm = String(now.getMinutes()).padStart(2, "0");
|
|
735
|
+
const ss = String(now.getSeconds()).padStart(2, "0");
|
|
736
|
+
const dir = path.join(state.config.notes_dir, "attachments", yyyy, MM);
|
|
737
|
+
await mkdir(dir, { recursive: true });
|
|
738
|
+
const ext = pickAttachmentExtension(attachment.filename, mimeType);
|
|
739
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
740
|
+
const filename = `${yyyy}${MM}${dd}-${HH}${mm}${ss}-${random}${ext}`;
|
|
741
|
+
const fullPath = path.join(dir, filename);
|
|
742
|
+
await writeFile(fullPath, buffer);
|
|
743
|
+
return fullPath;
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
747
|
+
await appendEventLog(state.config, {
|
|
748
|
+
level: "warn",
|
|
749
|
+
source: "telegram",
|
|
750
|
+
event: "attachment_persist_failed",
|
|
751
|
+
message: message.slice(0, 200),
|
|
752
|
+
details: {
|
|
753
|
+
filename: attachment.filename,
|
|
754
|
+
mime_type: attachment.mime_type,
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function pickAttachmentExtension(filename, mimeType) {
|
|
761
|
+
if (filename) {
|
|
762
|
+
const ext = path.extname(filename);
|
|
763
|
+
if (ext && ext.length <= 8) {
|
|
764
|
+
return ext.toLowerCase();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const fromMime = mimeType.split("/")[1]?.split(";")[0]?.trim().toLowerCase();
|
|
768
|
+
if (fromMime) {
|
|
769
|
+
if (fromMime === "jpeg")
|
|
770
|
+
return ".jpg";
|
|
771
|
+
if (/^[a-z0-9]{1,6}$/.test(fromMime)) {
|
|
772
|
+
return `.${fromMime}`;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return ".bin";
|
|
776
|
+
}
|
|
777
|
+
async function fetchTelegramFileBuffer(state, fileId, maxBytes) {
|
|
778
|
+
const file = await telegramApi(state, "getFile", { file_id: fileId });
|
|
779
|
+
if (!file.file_path) {
|
|
780
|
+
throw new Error("file_path is missing");
|
|
781
|
+
}
|
|
782
|
+
if (typeof file.file_size === "number" && file.file_size > maxBytes) {
|
|
783
|
+
throw new Error(`file too large: ${formatBytes(file.file_size)}`);
|
|
784
|
+
}
|
|
785
|
+
const response = await fetch(`${TELEGRAM_FILE_BASE}/bot${state.token}/${file.file_path}`);
|
|
786
|
+
if (!response.ok) {
|
|
787
|
+
throw new Error(`HTTP ${response.status}`);
|
|
788
|
+
}
|
|
789
|
+
const length = Number.parseInt(response.headers.get("content-length") || "", 10);
|
|
790
|
+
if (Number.isFinite(length) && length > maxBytes) {
|
|
791
|
+
throw new Error(`file too large: ${formatBytes(length)}`);
|
|
792
|
+
}
|
|
793
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
794
|
+
if (buffer.length > maxBytes) {
|
|
795
|
+
throw new Error(`file too large: ${formatBytes(buffer.length)}`);
|
|
796
|
+
}
|
|
797
|
+
return buffer;
|
|
798
|
+
}
|
|
799
|
+
function isTextLikeTelegramDocument(attachment) {
|
|
800
|
+
if (attachment.kind !== "document") {
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
return isTextLikeFile(attachment.mime_type, attachment.filename);
|
|
804
|
+
}
|
|
805
|
+
function isImageTelegramDocument(attachment) {
|
|
806
|
+
if (attachment.kind === "photo") {
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
return isImageLikeFile(attachment.mime_type, attachment.filename);
|
|
810
|
+
}
|
|
811
|
+
function messageText(message) {
|
|
812
|
+
return (message.text || message.caption || "").trim();
|
|
813
|
+
}
|
|
814
|
+
function messageEntities(message) {
|
|
815
|
+
return [...(message.entities || []), ...(message.caption_entities || [])];
|
|
816
|
+
}
|
|
817
|
+
function hasTelegramBotMention(message, botUserId, botUsername) {
|
|
818
|
+
const text = messageText(message);
|
|
819
|
+
const username = botUsername ? botUsername.replace(/^@/, "").toLowerCase() : "";
|
|
820
|
+
for (const entity of messageEntities(message)) {
|
|
821
|
+
if (entity.type === "text_mention" && botUserId && entity.user && String(entity.user.id) === botUserId) {
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
if (entity.type === "mention" && username) {
|
|
825
|
+
const mention = text.slice(entity.offset, entity.offset + entity.length).toLowerCase();
|
|
826
|
+
if (mention === `@${username}`) {
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (!username) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
return new RegExp(`(^|\\s)@${escapeRegExp(username)}\\b`, "i").test(text);
|
|
835
|
+
}
|
|
836
|
+
function normalizeTelegramCommand(text, botUsername) {
|
|
837
|
+
const trimmed = text.trim();
|
|
838
|
+
if (!botUsername) {
|
|
839
|
+
return trimmed;
|
|
840
|
+
}
|
|
841
|
+
const username = botUsername.replace(/^@/, "");
|
|
842
|
+
return trimmed.replace(new RegExp(`^/(\\w+)@${escapeRegExp(username)}\\b`, "i"), "/$1").trim();
|
|
843
|
+
}
|
|
844
|
+
const BADGE_PREFIX_PATTERN = /^(💬|🔧|✏️|✏)/u;
|
|
845
|
+
const BUILD_FOOTER_FIRST_LINE = /^(?:(?:✏️|✏|🔧)\s*現在ビルドモードです|((?:現在:「[^」]*」の)?ビルドモード(?:です。抜けるには|を抜けるには)\s*\/back\s*と返事してください))\s*$/u;
|
|
846
|
+
function stripBadgePrefix(content) {
|
|
847
|
+
if (!content)
|
|
848
|
+
return "";
|
|
849
|
+
let lines = content.split("\n");
|
|
850
|
+
if (lines.length > 0 && BADGE_PREFIX_PATTERN.test(lines[0].trim())) {
|
|
851
|
+
let i = 1;
|
|
852
|
+
for (; i < Math.min(lines.length, 5); i += 1) {
|
|
853
|
+
if (lines[i].trim() === "----") {
|
|
854
|
+
i += 1;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
lines = lines.slice(i);
|
|
859
|
+
}
|
|
860
|
+
for (let j = lines.length - 1; j >= 0; j -= 1) {
|
|
861
|
+
if (BUILD_FOOTER_FIRST_LINE.test(lines[j].trim())) {
|
|
862
|
+
let cut = j;
|
|
863
|
+
while (cut > 0 && lines[cut - 1].trim() === "")
|
|
864
|
+
cut -= 1;
|
|
865
|
+
lines = lines.slice(0, cut);
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return lines.join("\n").trim();
|
|
870
|
+
}
|
|
871
|
+
export function shouldUseTelegramDraftStream(message) {
|
|
872
|
+
if (process.env.AGENT_SIN_TELEGRAM_DRAFT_STREAM === "0") {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
return message.chat?.type === "private" && Number.isSafeInteger(message.chat.id);
|
|
876
|
+
}
|
|
877
|
+
export function telegramSendPayload(chatId, content, options = {}) {
|
|
878
|
+
const payload = {
|
|
879
|
+
chat_id: chatId,
|
|
880
|
+
text: content,
|
|
881
|
+
disable_web_page_preview: true,
|
|
882
|
+
};
|
|
883
|
+
if (options.threadId) {
|
|
884
|
+
payload.message_thread_id = options.threadId;
|
|
885
|
+
}
|
|
886
|
+
if (options.replyToMessageId && process.env.AGENT_SIN_TELEGRAM_REPLY_TO_MESSAGE !== "0") {
|
|
887
|
+
payload.reply_parameters = {
|
|
888
|
+
message_id: options.replyToMessageId,
|
|
889
|
+
allow_sending_without_reply: true,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
return payload;
|
|
893
|
+
}
|
|
894
|
+
export function telegramDraftPayload(message, draftId, text) {
|
|
895
|
+
const payload = {
|
|
896
|
+
chat_id: message.chat.id,
|
|
897
|
+
draft_id: draftId,
|
|
898
|
+
text: cleanDraftText(text),
|
|
899
|
+
};
|
|
900
|
+
if (message.message_thread_id) {
|
|
901
|
+
payload.message_thread_id = message.message_thread_id;
|
|
902
|
+
}
|
|
903
|
+
return payload;
|
|
904
|
+
}
|
|
905
|
+
export function formatTelegramDraftProgress(event) {
|
|
906
|
+
switch (event.kind) {
|
|
907
|
+
case "message":
|
|
908
|
+
return event.text ? cleanDraftText(event.text) : null;
|
|
909
|
+
case "thinking": {
|
|
910
|
+
const detail = cleanProgressText(event.text || "");
|
|
911
|
+
return detail ? l(`Thinking: ${detail}`, `考えています: ${detail}`) : l("Thinking", "考えています");
|
|
912
|
+
}
|
|
913
|
+
case "tool": {
|
|
914
|
+
const name = cleanProgressText(event.name || "");
|
|
915
|
+
return name ? l(`Running tool: ${name}`, `ツール実行中: ${name}`) : l("Running tool", "ツール実行中");
|
|
916
|
+
}
|
|
917
|
+
case "info": {
|
|
918
|
+
const detail = cleanProgressText(event.text || "");
|
|
919
|
+
return detail || null;
|
|
920
|
+
}
|
|
921
|
+
case "stderr":
|
|
922
|
+
return l("Checking tool output", "ツール出力を確認中");
|
|
923
|
+
default:
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function createTelegramDraftStreamer(state, message) {
|
|
928
|
+
if (!shouldUseTelegramDraftStream(message)) {
|
|
929
|
+
return noopTelegramDraftStreamer();
|
|
930
|
+
}
|
|
931
|
+
const draftId = createTelegramDraftId();
|
|
932
|
+
const minIntervalMs = telegramDraftIntervalMs();
|
|
933
|
+
let lastSentAt = 0;
|
|
934
|
+
let lastText = "";
|
|
935
|
+
let disabled = false;
|
|
936
|
+
let warningLogged = false;
|
|
937
|
+
let pending = Promise.resolve();
|
|
938
|
+
const enqueue = (text, force = false) => {
|
|
939
|
+
if (disabled)
|
|
940
|
+
return;
|
|
941
|
+
const cleaned = cleanDraftText(text);
|
|
942
|
+
if (!cleaned || cleaned === lastText)
|
|
943
|
+
return;
|
|
944
|
+
const now = Date.now();
|
|
945
|
+
if (!force && lastSentAt > 0 && now - lastSentAt < minIntervalMs) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
lastText = cleaned;
|
|
949
|
+
lastSentAt = now;
|
|
950
|
+
pending = pending
|
|
951
|
+
.then(async () => {
|
|
952
|
+
await telegramApi(state, "sendMessageDraft", telegramDraftPayload(message, draftId, cleaned));
|
|
953
|
+
})
|
|
954
|
+
.catch(async (error) => {
|
|
955
|
+
disabled = true;
|
|
956
|
+
if (warningLogged)
|
|
957
|
+
return;
|
|
958
|
+
warningLogged = true;
|
|
959
|
+
const errMessage = error instanceof Error ? error.message : String(error);
|
|
960
|
+
await appendEventLog(state.config, {
|
|
961
|
+
level: "warn",
|
|
962
|
+
source: "telegram",
|
|
963
|
+
event: "draft_stream_failed",
|
|
964
|
+
message: errMessage.slice(0, 200),
|
|
965
|
+
details: { chat_id: message.chat.id, message_thread_id: message.message_thread_id },
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
};
|
|
969
|
+
return {
|
|
970
|
+
update(text, options = {}) {
|
|
971
|
+
enqueue(text, options.force === true);
|
|
972
|
+
},
|
|
973
|
+
onChatProgress(event) {
|
|
974
|
+
switch (event.kind) {
|
|
975
|
+
case "thinking":
|
|
976
|
+
enqueue(l("Thinking", "考えています"));
|
|
977
|
+
break;
|
|
978
|
+
case "tool_running":
|
|
979
|
+
enqueue(l(`Running skill: ${event.skill_id}`, `スキルを実行しています: ${event.skill_id}`), true);
|
|
980
|
+
break;
|
|
981
|
+
case "tool_repairing":
|
|
982
|
+
enqueue(l(`Repairing and rerunning skill: ${event.skill_id}`, `スキルを修正して再実行しています: ${event.skill_id}`), true);
|
|
983
|
+
break;
|
|
984
|
+
case "tool_done":
|
|
985
|
+
enqueue(l("Checking result", "結果を確認しています"));
|
|
986
|
+
break;
|
|
987
|
+
case "model_failed":
|
|
988
|
+
enqueue(l("Error occurred", "エラーになりました"), true);
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
onAiProgress(event) {
|
|
993
|
+
const text = formatTelegramDraftProgress(event);
|
|
994
|
+
if (text)
|
|
995
|
+
enqueue(text, event.kind === "message");
|
|
996
|
+
},
|
|
997
|
+
onBuildProgress(event) {
|
|
998
|
+
const text = formatTelegramDraftProgress(event);
|
|
999
|
+
if (text)
|
|
1000
|
+
enqueue(text, event.kind === "message" || event.kind === "tool");
|
|
1001
|
+
},
|
|
1002
|
+
async finish() {
|
|
1003
|
+
await pending;
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
function noopTelegramDraftStreamer() {
|
|
1008
|
+
return {
|
|
1009
|
+
update() { },
|
|
1010
|
+
onChatProgress() { },
|
|
1011
|
+
onAiProgress() { },
|
|
1012
|
+
onBuildProgress() { },
|
|
1013
|
+
async finish() { },
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function createTelegramDraftId() {
|
|
1017
|
+
return Math.floor(Date.now() % 1_000_000_000) + Math.floor(Math.random() * 1_000_000);
|
|
1018
|
+
}
|
|
1019
|
+
function telegramDraftIntervalMs() {
|
|
1020
|
+
const raw = Number.parseInt(process.env.AGENT_SIN_TELEGRAM_DRAFT_INTERVAL_MS || "", 10);
|
|
1021
|
+
if (Number.isFinite(raw) && raw >= 750) {
|
|
1022
|
+
return raw;
|
|
1023
|
+
}
|
|
1024
|
+
return 1500;
|
|
1025
|
+
}
|
|
1026
|
+
function cleanDraftText(text) {
|
|
1027
|
+
const cleaned = text
|
|
1028
|
+
.replace(/```/g, "")
|
|
1029
|
+
.replace(/\r\n/g, "\n")
|
|
1030
|
+
.replace(/\r/g, "\n")
|
|
1031
|
+
.replace(/@/g, "@\u200b")
|
|
1032
|
+
.trim();
|
|
1033
|
+
if (!cleaned)
|
|
1034
|
+
return "";
|
|
1035
|
+
const capped = cleaned.length > DRAFT_PREVIEW_MAX ? `${cleaned.slice(0, DRAFT_PREVIEW_MAX).trimEnd()}...` : cleaned;
|
|
1036
|
+
return capped.slice(0, DRAFT_MESSAGE_MAX);
|
|
1037
|
+
}
|
|
1038
|
+
function startTypingKeepalive(state, chatId, threadId) {
|
|
1039
|
+
let stopped = false;
|
|
1040
|
+
let timer = null;
|
|
1041
|
+
const tick = () => {
|
|
1042
|
+
if (stopped)
|
|
1043
|
+
return;
|
|
1044
|
+
void sendTelegramChatAction(state, chatId, threadId);
|
|
1045
|
+
};
|
|
1046
|
+
tick();
|
|
1047
|
+
timer = setInterval(tick, TYPING_REFRESH_MS);
|
|
1048
|
+
if (typeof timer.unref === "function")
|
|
1049
|
+
timer.unref();
|
|
1050
|
+
return {
|
|
1051
|
+
stop() {
|
|
1052
|
+
stopped = true;
|
|
1053
|
+
if (timer) {
|
|
1054
|
+
clearInterval(timer);
|
|
1055
|
+
timer = null;
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
async function sendTelegramMessage(state, chatId, content, options = {}) {
|
|
1061
|
+
const chunks = chunkTelegramMessage(content);
|
|
1062
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
1063
|
+
const chunk = chunks[index];
|
|
1064
|
+
const payload = telegramSendPayload(chatId, chunk, index === 0 ? options : { threadId: options.threadId });
|
|
1065
|
+
try {
|
|
1066
|
+
await telegramApi(state, "sendMessage", payload);
|
|
1067
|
+
}
|
|
1068
|
+
catch (error) {
|
|
1069
|
+
if (index === 0 && options.replyToMessageId) {
|
|
1070
|
+
try {
|
|
1071
|
+
await telegramApi(state, "sendMessage", telegramSendPayload(chatId, chunk, { threadId: options.threadId }));
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
// Keep the original error for logging.
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1079
|
+
console.error(`agent-sin telegram: send error: ${message}`);
|
|
1080
|
+
await appendEventLog(state.config, {
|
|
1081
|
+
level: "error",
|
|
1082
|
+
source: "telegram",
|
|
1083
|
+
event: "send_error",
|
|
1084
|
+
message,
|
|
1085
|
+
details: { chat_id: chatId },
|
|
1086
|
+
});
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async function sendTelegramChatAction(state, chatId, threadId) {
|
|
1092
|
+
try {
|
|
1093
|
+
const payload = {
|
|
1094
|
+
chat_id: chatId,
|
|
1095
|
+
action: "typing",
|
|
1096
|
+
};
|
|
1097
|
+
if (threadId) {
|
|
1098
|
+
payload.message_thread_id = threadId;
|
|
1099
|
+
}
|
|
1100
|
+
await telegramApi(state, "sendChatAction", payload);
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
// typing indicator is a hint; ignore failures
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
async function telegramApi(state, method, payload) {
|
|
1107
|
+
const response = await fetch(`${TELEGRAM_API_BASE}/bot${state.token}/${method}`, {
|
|
1108
|
+
method: "POST",
|
|
1109
|
+
headers: { "content-type": "application/json" },
|
|
1110
|
+
body: JSON.stringify(payload),
|
|
1111
|
+
});
|
|
1112
|
+
if (!response.ok) {
|
|
1113
|
+
const detail = await response.text().catch(() => "");
|
|
1114
|
+
throw new Error(`HTTP ${response.status} ${detail.slice(0, 200)}`);
|
|
1115
|
+
}
|
|
1116
|
+
const data = (await response.json());
|
|
1117
|
+
if (!data.ok) {
|
|
1118
|
+
throw new Error(data.description || "Telegram API error");
|
|
1119
|
+
}
|
|
1120
|
+
return data.result;
|
|
1121
|
+
}
|
|
1122
|
+
async function loadTelegramOffset(filePath) {
|
|
1123
|
+
try {
|
|
1124
|
+
const raw = await readFile(filePath, "utf8");
|
|
1125
|
+
const data = JSON.parse(raw);
|
|
1126
|
+
if (typeof data.offset === "number" && Number.isInteger(data.offset) && data.offset >= 0) {
|
|
1127
|
+
return data.offset;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// missing or unreadable: start fresh and skip stale updates
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
async function saveTelegramOffset(state) {
|
|
1136
|
+
if (state.offset === null)
|
|
1137
|
+
return;
|
|
1138
|
+
try {
|
|
1139
|
+
await mkdir(path.dirname(state.offsetFile), { recursive: true });
|
|
1140
|
+
const payload = JSON.stringify({ offset: state.offset, saved_at: new Date().toISOString() }, null, 2);
|
|
1141
|
+
await writeFile(state.offsetFile, payload, "utf8");
|
|
1142
|
+
}
|
|
1143
|
+
catch (error) {
|
|
1144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1145
|
+
console.error(`agent-sin telegram: failed to persist offset: ${message}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
export async function loadTelegramHistories(filePath) {
|
|
1149
|
+
try {
|
|
1150
|
+
const raw = await readFile(filePath, "utf8");
|
|
1151
|
+
const data = JSON.parse(raw);
|
|
1152
|
+
const map = new Map();
|
|
1153
|
+
if (data.chats && typeof data.chats === "object") {
|
|
1154
|
+
for (const [chatKey, value] of Object.entries(data.chats)) {
|
|
1155
|
+
if (!Array.isArray(value))
|
|
1156
|
+
continue;
|
|
1157
|
+
const turns = [];
|
|
1158
|
+
for (const entry of value) {
|
|
1159
|
+
if (!entry || typeof entry !== "object")
|
|
1160
|
+
continue;
|
|
1161
|
+
const role = entry.role;
|
|
1162
|
+
const content = entry.content;
|
|
1163
|
+
if ((role === "user" || role === "assistant" || role === "tool") &&
|
|
1164
|
+
typeof content === "string") {
|
|
1165
|
+
turns.push({ role, content });
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (turns.length > 0) {
|
|
1169
|
+
map.set(chatKey, turns.slice(-TELEGRAM_CONTEXT_HISTORY_LIMIT));
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return map;
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
return new Map();
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async function saveTelegramHistories(state) {
|
|
1180
|
+
try {
|
|
1181
|
+
await mkdir(path.dirname(state.historiesFile), { recursive: true });
|
|
1182
|
+
const chats = {};
|
|
1183
|
+
for (const [chatKey, history] of state.histories) {
|
|
1184
|
+
if (history.length === 0)
|
|
1185
|
+
continue;
|
|
1186
|
+
chats[chatKey] = history;
|
|
1187
|
+
}
|
|
1188
|
+
const payload = JSON.stringify({ chats, saved_at: new Date().toISOString() }, null, 2);
|
|
1189
|
+
await writeFile(state.historiesFile, payload, "utf8");
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1193
|
+
console.error(`agent-sin telegram: failed to persist histories: ${message}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
export async function loadTelegramIntentRuntimes(filePath) {
|
|
1197
|
+
return loadIntentRuntimeMap(filePath, "chats");
|
|
1198
|
+
}
|
|
1199
|
+
async function saveTelegramIntentRuntimes(state) {
|
|
1200
|
+
try {
|
|
1201
|
+
await saveIntentRuntimeMap(state.intentRuntimesFile, "chats", state.intentRuntimes);
|
|
1202
|
+
}
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1205
|
+
console.error(`agent-sin telegram: failed to persist intent runtimes: ${message}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function trimHistory(history) {
|
|
1209
|
+
if (history.length <= TELEGRAM_CONTEXT_HISTORY_LIMIT) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
history.splice(0, history.length - TELEGRAM_CONTEXT_HISTORY_LIMIT);
|
|
1213
|
+
}
|
|
1214
|
+
function escapeRegExp(value) {
|
|
1215
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1216
|
+
}
|
|
1217
|
+
function sleep(ms) {
|
|
1218
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1219
|
+
}
|