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.
Files changed (150) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +81 -0
  4. package/assets/logo.png +0 -0
  5. package/builtin-skills/_shared/_models_lib.py +227 -0
  6. package/builtin-skills/_shared/_profile_lib.py +98 -0
  7. package/builtin-skills/_shared/_schedules_lib.py +313 -0
  8. package/builtin-skills/_shared/_skill_settings_lib.py +153 -0
  9. package/builtin-skills/_shared/i18n.py +26 -0
  10. package/builtin-skills/memo-delete/main.py +155 -0
  11. package/builtin-skills/memo-delete/skill.yaml +57 -0
  12. package/builtin-skills/memo-index/main.py +178 -0
  13. package/builtin-skills/memo-index/skill.yaml +53 -0
  14. package/builtin-skills/memo-save/README.md +5 -0
  15. package/builtin-skills/memo-save/main.py +74 -0
  16. package/builtin-skills/memo-save/skill.yaml +52 -0
  17. package/builtin-skills/memo-search/README.md +10 -0
  18. package/builtin-skills/memo-search/main.py +97 -0
  19. package/builtin-skills/memo-search/skill.yaml +51 -0
  20. package/builtin-skills/memo-vector-search/main.py +121 -0
  21. package/builtin-skills/memo-vector-search/skill.yaml +53 -0
  22. package/builtin-skills/model-add/main.py +180 -0
  23. package/builtin-skills/model-add/skill.yaml +112 -0
  24. package/builtin-skills/model-list/main.py +93 -0
  25. package/builtin-skills/model-list/skill.yaml +48 -0
  26. package/builtin-skills/model-set/main.py +123 -0
  27. package/builtin-skills/model-set/skill.yaml +69 -0
  28. package/builtin-skills/profile-delete/_profile_lib.py +98 -0
  29. package/builtin-skills/profile-delete/main.py +98 -0
  30. package/builtin-skills/profile-delete/skill.yaml +64 -0
  31. package/builtin-skills/profile-edit/_profile_lib.py +98 -0
  32. package/builtin-skills/profile-edit/main.py +97 -0
  33. package/builtin-skills/profile-edit/skill.yaml +72 -0
  34. package/builtin-skills/profile-save/main.py +52 -0
  35. package/builtin-skills/profile-save/skill.yaml +69 -0
  36. package/builtin-skills/schedule-add/_schedules_lib.py +303 -0
  37. package/builtin-skills/schedule-add/main.py +137 -0
  38. package/builtin-skills/schedule-add/skill.yaml +94 -0
  39. package/builtin-skills/schedule-list/_schedules_lib.py +303 -0
  40. package/builtin-skills/schedule-list/main.py +86 -0
  41. package/builtin-skills/schedule-list/skill.yaml +45 -0
  42. package/builtin-skills/schedule-remove/_schedules_lib.py +303 -0
  43. package/builtin-skills/schedule-remove/main.py +69 -0
  44. package/builtin-skills/schedule-remove/skill.yaml +49 -0
  45. package/builtin-skills/schedule-toggle/_schedules_lib.py +303 -0
  46. package/builtin-skills/schedule-toggle/main.py +78 -0
  47. package/builtin-skills/schedule-toggle/skill.yaml +61 -0
  48. package/builtin-skills/skills-disable/main.py +63 -0
  49. package/builtin-skills/skills-disable/skill.yaml +52 -0
  50. package/builtin-skills/skills-enable/main.py +62 -0
  51. package/builtin-skills/skills-enable/skill.yaml +51 -0
  52. package/builtin-skills/todo-add/main.py +68 -0
  53. package/builtin-skills/todo-add/skill.yaml +53 -0
  54. package/builtin-skills/todo-delete/main.py +65 -0
  55. package/builtin-skills/todo-delete/skill.yaml +47 -0
  56. package/builtin-skills/todo-done/main.py +75 -0
  57. package/builtin-skills/todo-done/skill.yaml +47 -0
  58. package/builtin-skills/todo-list/main.py +91 -0
  59. package/builtin-skills/todo-list/skill.yaml +48 -0
  60. package/builtin-skills/todo-tick/main.py +125 -0
  61. package/builtin-skills/todo-tick/skill.yaml +48 -0
  62. package/dist/builder/build-action-classifier.d.ts +18 -0
  63. package/dist/builder/build-action-classifier.js +142 -0
  64. package/dist/builder/build-commands.d.ts +19 -0
  65. package/dist/builder/build-commands.js +133 -0
  66. package/dist/builder/build-flow.d.ts +72 -0
  67. package/dist/builder/build-flow.js +416 -0
  68. package/dist/builder/builder-session.d.ts +117 -0
  69. package/dist/builder/builder-session.js +1129 -0
  70. package/dist/builder/conversation-router.d.ts +22 -0
  71. package/dist/builder/conversation-router.js +69 -0
  72. package/dist/builder/intent-runtime-store.d.ts +7 -0
  73. package/dist/builder/intent-runtime-store.js +60 -0
  74. package/dist/builder/progress-format.d.ts +7 -0
  75. package/dist/builder/progress-format.js +46 -0
  76. package/dist/cli/index.d.ts +2 -0
  77. package/dist/cli/index.js +2835 -0
  78. package/dist/cli/spinner.d.ts +30 -0
  79. package/dist/cli/spinner.js +164 -0
  80. package/dist/core/ai-provider.d.ts +75 -0
  81. package/dist/core/ai-provider.js +678 -0
  82. package/dist/core/builtin-skills.d.ts +27 -0
  83. package/dist/core/builtin-skills.js +120 -0
  84. package/dist/core/chat-engine.d.ts +70 -0
  85. package/dist/core/chat-engine.js +812 -0
  86. package/dist/core/config.d.ts +127 -0
  87. package/dist/core/config.js +1379 -0
  88. package/dist/core/daily-memory-promotion.d.ts +21 -0
  89. package/dist/core/daily-memory-promotion.js +422 -0
  90. package/dist/core/i18n.d.ts +23 -0
  91. package/dist/core/i18n.js +167 -0
  92. package/dist/core/info-lines.d.ts +5 -0
  93. package/dist/core/info-lines.js +39 -0
  94. package/dist/core/input-schema.d.ts +2 -0
  95. package/dist/core/input-schema.js +156 -0
  96. package/dist/core/intent-router.d.ts +27 -0
  97. package/dist/core/intent-router.js +160 -0
  98. package/dist/core/logger.d.ts +60 -0
  99. package/dist/core/logger.js +240 -0
  100. package/dist/core/memory.d.ts +10 -0
  101. package/dist/core/memory.js +72 -0
  102. package/dist/core/message-utils.d.ts +13 -0
  103. package/dist/core/message-utils.js +104 -0
  104. package/dist/core/notifier.d.ts +17 -0
  105. package/dist/core/notifier.js +424 -0
  106. package/dist/core/output-writer.d.ts +13 -0
  107. package/dist/core/output-writer.js +100 -0
  108. package/dist/core/plan-decision.d.ts +16 -0
  109. package/dist/core/plan-decision.js +88 -0
  110. package/dist/core/profile-memory.d.ts +17 -0
  111. package/dist/core/profile-memory.js +142 -0
  112. package/dist/core/runtime.d.ts +50 -0
  113. package/dist/core/runtime.js +187 -0
  114. package/dist/core/scheduler.d.ts +28 -0
  115. package/dist/core/scheduler.js +155 -0
  116. package/dist/core/secrets.d.ts +31 -0
  117. package/dist/core/secrets.js +214 -0
  118. package/dist/core/service.d.ts +35 -0
  119. package/dist/core/service.js +479 -0
  120. package/dist/core/skill-planner.d.ts +24 -0
  121. package/dist/core/skill-planner.js +100 -0
  122. package/dist/core/skill-registry.d.ts +98 -0
  123. package/dist/core/skill-registry.js +319 -0
  124. package/dist/core/skill-scaffold.d.ts +33 -0
  125. package/dist/core/skill-scaffold.js +256 -0
  126. package/dist/core/skill-settings.d.ts +11 -0
  127. package/dist/core/skill-settings.js +63 -0
  128. package/dist/core/transfer.d.ts +31 -0
  129. package/dist/core/transfer.js +270 -0
  130. package/dist/core/update-notifier.d.ts +2 -0
  131. package/dist/core/update-notifier.js +140 -0
  132. package/dist/discord/bot.d.ts +96 -0
  133. package/dist/discord/bot.js +2424 -0
  134. package/dist/runtimes/codex-app-server.d.ts +53 -0
  135. package/dist/runtimes/codex-app-server.js +305 -0
  136. package/dist/runtimes/python-runner.d.ts +7 -0
  137. package/dist/runtimes/python-runner.js +302 -0
  138. package/dist/runtimes/typescript-runner.d.ts +5 -0
  139. package/dist/runtimes/typescript-runner.js +172 -0
  140. package/dist/skills-sdk/types.d.ts +38 -0
  141. package/dist/skills-sdk/types.js +1 -0
  142. package/dist/telegram/bot.d.ts +94 -0
  143. package/dist/telegram/bot.js +1219 -0
  144. package/install.ps1 +132 -0
  145. package/install.sh +130 -0
  146. package/package.json +60 -0
  147. package/templates/skill-python/main.py +74 -0
  148. package/templates/skill-python/skill.yaml +48 -0
  149. package/templates/skill-typescript/main.ts +87 -0
  150. package/templates/skill-typescript/skill.yaml +42 -0
@@ -0,0 +1,2424 @@
1
+ import WebSocket from "ws";
2
+ import path from "node:path";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { loadConfig, loadModels } from "../core/config.js";
5
+ import { appendEventLog } from "../core/logger.js";
6
+ import { createIntentRuntime, renderBuildFooter, shouldShowBuildFooter, } from "../builder/build-flow.js";
7
+ import { routeConversationMessage, } from "../builder/conversation-router.js";
8
+ import { formatBuildProgress, progressIntervalMs } from "../builder/progress-format.js";
9
+ import { chunkText, cleanAttachmentText, formatAttachmentLabel, formatBytes, guessImageMimeType as guessImageMimeTypeFromName, indentAttachmentContent, isImageLikeFile, isTextLikeFile, } from "../core/message-utils.js";
10
+ import { isEmptyIntentRuntime, loadIntentRuntimeMap, saveIntentRuntimeMap, } from "../builder/intent-runtime-store.js";
11
+ import { inferLocaleFromText, l, lLines, withLocale } from "../core/i18n.js";
12
+ import { modelsLines, skillsLines } from "../core/info-lines.js";
13
+ import { consumeUpdateBanner, scheduleUpdateCheck } from "../core/update-notifier.js";
14
+ import { runSkill, SkillRunError } from "../core/runtime.js";
15
+ import { loadSkillMemory } from "../core/memory.js";
16
+ import { findSkillManifest, listSkillManifests, } from "../core/skill-registry.js";
17
+ const GATEWAY_VERSION = 10;
18
+ const GATEWAY_URL = `wss://gateway.discord.gg/?v=${GATEWAY_VERSION}&encoding=json`;
19
+ const REST_BASE = `https://discord.com/api/v${GATEWAY_VERSION}`;
20
+ const INTENT_GUILD_MESSAGES = 1 << 9;
21
+ const INTENT_DIRECT_MESSAGES = 1 << 12;
22
+ const INTENT_MESSAGE_CONTENT = 1 << 15;
23
+ const INTENTS_BASE = INTENT_GUILD_MESSAGES | INTENT_DIRECT_MESSAGES;
24
+ const MESSAGE_MAX = 2000;
25
+ const RECONNECT_DELAY_MS = 5000;
26
+ const TYPING_REFRESH_MS = 9000;
27
+ const DISCORD_CONTEXT_HISTORY_LIMIT = 20;
28
+ const ATTACHMENT_TEXT_MAX_BYTES = 96 * 1024;
29
+ const ATTACHMENT_TEXT_MAX_CHARS = 20_000;
30
+ const ATTACHMENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
31
+ const ATTACHMENT_SUMMARY_LIMIT = 8;
32
+ const STATUS_EMOJI = {
33
+ received: "\u{1F440}",
34
+ thinking: "\u{1F914}",
35
+ tool: "\u{1F6E0}\u{FE0F}",
36
+ done: "✅",
37
+ error: "⚠\u{FE0F}",
38
+ };
39
+ export function shouldRespond(ctx) {
40
+ if (!ctx.isAllowed)
41
+ return false;
42
+ return ctx.isDirect || ctx.isMentioned || ctx.isBotThread;
43
+ }
44
+ export function renderModeBadge(_intentRuntime) {
45
+ // Mode header badges have been removed in favor of in-line phrasing for
46
+ // pending confirmations and a trailing footer for active build mode.
47
+ return "";
48
+ }
49
+ export function withModeBadge(intentRuntime, lines, options = {}) {
50
+ if (!shouldShowBuildFooter({
51
+ intentRuntime,
52
+ userText: options.userText ?? "",
53
+ replyLines: lines,
54
+ isBuildEntry: options.isBuildEntry ?? false,
55
+ })) {
56
+ return lines;
57
+ }
58
+ const footer = renderBuildFooter(intentRuntime, {
59
+ exitPrefix: "!",
60
+ languageHint: [options.userText ?? "", ...lines],
61
+ });
62
+ if (!footer)
63
+ return lines;
64
+ return [...lines, "", footer];
65
+ }
66
+ const PROCESSED_MESSAGE_LIMIT = 500;
67
+ const CATCHUP_MAX_PER_CHANNEL = 50;
68
+ const CATCHUP_MAX_AGE_MS = 5 * 60 * 1000;
69
+ const DISCORD_EPOCH = 1420070400000n;
70
+ export async function runDiscordBot(config) {
71
+ const token = (process.env.AGENT_SIN_DISCORD_BOT_TOKEN || "").trim();
72
+ if (!token) {
73
+ console.error("AGENT_SIN_DISCORD_BOT_TOKEN is not set. Add it to ~/.agent-sin/.env or export it.");
74
+ return 1;
75
+ }
76
+ const allowedRaw = process.env.AGENT_SIN_DISCORD_ALLOWED_USER_IDS || "";
77
+ const allowedUserIds = parseSnowflakeList(allowedRaw);
78
+ if (allowedUserIds.size === 0) {
79
+ console.error("AGENT_SIN_DISCORD_ALLOWED_USER_IDS is empty. Set it to your Discord user ID(s) (comma separated) so the bot only replies to you.");
80
+ return 1;
81
+ }
82
+ const listenChannelIds = parseSnowflakeList(process.env.AGENT_SIN_DISCORD_LISTEN_CHANNEL_IDS || "");
83
+ const threadsFile = path.join(config.workspace, "discord", "bot-threads.json");
84
+ const botThreadIds = await loadBotThreadIds(threadsFile);
85
+ const lastSeenFile = path.join(config.workspace, "discord", "last-seen.json");
86
+ const { lastSeenIds, dmChannelIds } = await loadLastSeen(lastSeenFile);
87
+ const intentRuntimesFile = path.join(config.workspace, "discord", "intent-runtimes.json");
88
+ const persistedIntentRuntimes = await loadIntentRuntimes(intentRuntimesFile);
89
+ // Reading thread replies that don't @mention the bot requires the privileged Message Content intent.
90
+ const intents = listenChannelIds.size > 0 || botThreadIds.size > 0 ? INTENTS_BASE | INTENT_MESSAGE_CONTENT : INTENTS_BASE;
91
+ const state = {
92
+ config,
93
+ token,
94
+ allowedUserIds,
95
+ listenChannelIds,
96
+ botThreadIds,
97
+ threadsFile,
98
+ lastSeenFile,
99
+ lastSeenIds,
100
+ dmChannelIds,
101
+ processedMessageIds: new Set(),
102
+ catchUpDone: false,
103
+ intents,
104
+ botUserId: null,
105
+ histories: new Map(),
106
+ historiesLoaded: new Set(),
107
+ intentRuntimes: persistedIntentRuntimes,
108
+ intentRuntimesFile,
109
+ ws: null,
110
+ seq: null,
111
+ sessionId: null,
112
+ heartbeatTimer: null,
113
+ heartbeatAcked: true,
114
+ reconnect: true,
115
+ };
116
+ const shutdown = () => {
117
+ state.reconnect = false;
118
+ if (state.heartbeatTimer) {
119
+ clearInterval(state.heartbeatTimer);
120
+ state.heartbeatTimer = null;
121
+ }
122
+ state.ws?.close(1000, "shutdown");
123
+ console.log("agent-sin discord: shutting down");
124
+ };
125
+ process.once("SIGINT", shutdown);
126
+ process.once("SIGTERM", shutdown);
127
+ scheduleUpdateCheck(config.workspace);
128
+ await appendEventLog(config, {
129
+ level: "info",
130
+ source: "discord",
131
+ event: "bot_starting",
132
+ details: {
133
+ allowed_user_count: allowedUserIds.size,
134
+ listen_channel_count: listenChannelIds.size,
135
+ bot_thread_count: botThreadIds.size,
136
+ message_content_intent: (intents & INTENT_MESSAGE_CONTENT) !== 0,
137
+ },
138
+ });
139
+ console.log(`agent-sin discord: starting (allowed users: ${allowedUserIds.size}, listen channels: ${listenChannelIds.size}, known threads: ${botThreadIds.size}${(intents & INTENT_MESSAGE_CONTENT) !== 0 ? ", privileged Message Content intent required" : ""})`);
140
+ while (state.reconnect) {
141
+ try {
142
+ await connectAndRun(state);
143
+ }
144
+ catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ console.error(`agent-sin discord: gateway error: ${message}`);
147
+ await appendEventLog(config, {
148
+ level: "error",
149
+ source: "discord",
150
+ event: "gateway_error",
151
+ message,
152
+ });
153
+ }
154
+ if (state.reconnect) {
155
+ console.log(`agent-sin discord: reconnecting in ${Math.round(RECONNECT_DELAY_MS / 1000)}s…`);
156
+ await sleep(RECONNECT_DELAY_MS);
157
+ }
158
+ }
159
+ await appendEventLog(config, { level: "info", source: "discord", event: "bot_stopped" });
160
+ return 0;
161
+ }
162
+ export function parseSnowflakeList(raw) {
163
+ const ids = new Set();
164
+ for (const part of raw.split(/[,;\s]+/)) {
165
+ const trimmed = part.trim();
166
+ if (trimmed && /^\d+$/.test(trimmed)) {
167
+ ids.add(trimmed);
168
+ }
169
+ }
170
+ return ids;
171
+ }
172
+ /** @deprecated use parseSnowflakeList */
173
+ export const parseAllowedUserIds = parseSnowflakeList;
174
+ export function classifyMessage(message, botUserId, allowedUserIds, listenChannelIds = new Set(), botThreadIds = new Set()) {
175
+ const isDirect = !message.guild_id;
176
+ const isMentioned = botUserId
177
+ ? Array.isArray(message.mentions) && message.mentions.some((user) => user.id === botUserId)
178
+ : false;
179
+ const isAllowed = allowedUserIds.has(message.author.id);
180
+ const isListenChannel = listenChannelIds.has(message.channel_id);
181
+ const isBotThread = botThreadIds.has(message.channel_id);
182
+ return { isDirect, isMentioned, isAllowed, isListenChannel, isBotThread };
183
+ }
184
+ export function stripBotMention(content, botUserId) {
185
+ if (!botUserId) {
186
+ return content.trim();
187
+ }
188
+ const pattern = new RegExp(`<@!?${botUserId}>`, "g");
189
+ return content.replace(pattern, "").trim();
190
+ }
191
+ const BADGE_PREFIX_PATTERN = /^(💬|🔧|✏️|✏)/u;
192
+ const BUILD_FOOTER_FIRST_LINE = /^(?:(?:✏️|✏|🔧)\s*現在ビルドモードです|((?:現在:「[^」]*」の)?ビルドモード(?:です。抜けるには|を抜けるには).+?と返事してください))\s*$/u;
193
+ export function stripBadgePrefix(content) {
194
+ if (!content)
195
+ return "";
196
+ let lines = content.split("\n");
197
+ if (lines.length > 0 && BADGE_PREFIX_PATTERN.test(lines[0].trim())) {
198
+ // Skip legacy header badge + optional meta lines until the "----" separator (or up to 4 lines).
199
+ let i = 1;
200
+ for (; i < Math.min(lines.length, 5); i += 1) {
201
+ if (lines[i].trim() === "----") {
202
+ i += 1;
203
+ break;
204
+ }
205
+ }
206
+ lines = lines.slice(i);
207
+ }
208
+ // Strip trailing build-mode footer (icon + 現在ビルドモードです + 戻り方案内).
209
+ for (let j = lines.length - 1; j >= 0; j -= 1) {
210
+ if (BUILD_FOOTER_FIRST_LINE.test(lines[j].trim())) {
211
+ // Drop blank separators above the footer too.
212
+ let cut = j;
213
+ while (cut > 0 && lines[cut - 1].trim() === "")
214
+ cut -= 1;
215
+ lines = lines.slice(0, cut);
216
+ break;
217
+ }
218
+ }
219
+ return lines.join("\n").trim();
220
+ }
221
+ export function buildChatHistoryFromMessages(messages, botUserId, allowedUserIds) {
222
+ const history = [];
223
+ for (const message of messages) {
224
+ if (message.author?.bot) {
225
+ if (!message.content || !message.content.trim())
226
+ continue;
227
+ if (!botUserId || message.author.id !== botUserId)
228
+ continue;
229
+ const stripped = stripBadgePrefix(message.content).trim();
230
+ if (stripped)
231
+ history.push({ role: "assistant", content: stripped });
232
+ continue;
233
+ }
234
+ if (allowedUserIds.size > 0 && !allowedUserIds.has(message.author.id))
235
+ continue;
236
+ const cleaned = formatDiscordUserMessageForHistory(message, botUserId);
237
+ if (cleaned)
238
+ history.push({ role: "user", content: cleaned });
239
+ }
240
+ return history;
241
+ }
242
+ export function formatDiscordUserMessageForHistory(message, botUserId) {
243
+ const text = botUserId ? stripBotMention(message.content || "", botUserId) : (message.content || "").trim();
244
+ const attachments = formatDiscordAttachmentSummary(message.attachments);
245
+ return [text, attachments].filter(Boolean).join("\n\n").trim();
246
+ }
247
+ export function formatDiscordAttachmentSummary(attachments) {
248
+ const normalized = normalizeDiscordAttachments(attachments);
249
+ if (normalized.length === 0) {
250
+ return "";
251
+ }
252
+ const shown = normalized.slice(0, ATTACHMENT_SUMMARY_LIMIT);
253
+ const lines = [l("[Discord attachments]", "[Discord添付]")];
254
+ shown.forEach((attachment, index) => {
255
+ lines.push(`${index + 1}. ${formatDiscordAttachmentLabel(attachment)}`);
256
+ const url = attachment.url || attachment.proxy_url;
257
+ if (url) {
258
+ lines.push(` URL: ${url}`);
259
+ }
260
+ if (attachment.description) {
261
+ lines.push(l(` Description: ${cleanAttachmentText(attachment.description, 500)}`, ` 説明: ${cleanAttachmentText(attachment.description, 500)}`));
262
+ }
263
+ });
264
+ if (normalized.length > shown.length) {
265
+ lines.push(l(`...and ${normalized.length - shown.length} more`, `...他 ${normalized.length - shown.length} 件`));
266
+ }
267
+ return lines.join("\n");
268
+ }
269
+ async function formatDiscordUserMessageForChat(state, message, botUserId) {
270
+ const text = botUserId ? stripBotMention(message.content || "", botUserId) : (message.content || "").trim();
271
+ const attachments = await formatDiscordAttachmentDetails(state, message.attachments);
272
+ return {
273
+ text: [text, attachments.text].filter(Boolean).join("\n\n").trim(),
274
+ images: attachments.images,
275
+ };
276
+ }
277
+ async function formatDiscordAttachmentDetails(state, attachments) {
278
+ const normalized = normalizeDiscordAttachments(attachments);
279
+ if (normalized.length === 0) {
280
+ return { text: "", images: [] };
281
+ }
282
+ const shown = normalized.slice(0, ATTACHMENT_SUMMARY_LIMIT);
283
+ const lines = [l("[Discord attachments]", "[Discord添付]")];
284
+ const images = [];
285
+ for (let index = 0; index < shown.length; index += 1) {
286
+ const attachment = shown[index];
287
+ lines.push(`${index + 1}. ${formatDiscordAttachmentLabel(attachment)}`);
288
+ const url = attachment.url || attachment.proxy_url;
289
+ if (url) {
290
+ lines.push(` URL: ${url}`);
291
+ }
292
+ if (attachment.description) {
293
+ lines.push(l(` Description: ${cleanAttachmentText(attachment.description, 500)}`, ` 説明: ${cleanAttachmentText(attachment.description, 500)}`));
294
+ }
295
+ if (isImageAttachment(attachment)) {
296
+ const image = await readDiscordAttachmentImage(state, attachment);
297
+ if (image.kind === "image") {
298
+ images.push(image.part);
299
+ if (image.savedPath) {
300
+ lines.push(l(` Content: image attached as AI input. Saved at: ${image.savedPath}`, ` 内容: 画像を AI 入力として添付しました。保存先: ${image.savedPath}`));
301
+ lines.push(l(` To keep this image in a saving skill such as memo-save, reference this path as Markdown: ![](${image.savedPath})`, ` memo-saveなど保存系スキルで本文に画像を残すときは、Markdown記法 ![](${image.savedPath}) でこのパスを参照してください。`));
302
+ }
303
+ else {
304
+ lines.push(l(" Content: image attached as AI input.", " 内容: 画像を AI 入力として添付しました。"));
305
+ }
306
+ }
307
+ else if (image.reason) {
308
+ lines.push(l(` Content: ${image.reason}`, ` 内容: ${image.reason}`));
309
+ }
310
+ }
311
+ else {
312
+ const content = await readDiscordAttachmentText(state, attachment);
313
+ if (content.kind === "text") {
314
+ lines.push(l(" Content:", " 内容:"));
315
+ lines.push(indentAttachmentContent(content.text));
316
+ }
317
+ else if (content.reason) {
318
+ lines.push(l(` Content: ${content.reason}`, ` 内容: ${content.reason}`));
319
+ }
320
+ }
321
+ }
322
+ if (normalized.length > shown.length) {
323
+ lines.push(l(`...and ${normalized.length - shown.length} more`, `...他 ${normalized.length - shown.length} 件`));
324
+ }
325
+ return { text: lines.join("\n"), images };
326
+ }
327
+ async function readDiscordAttachmentText(state, attachment) {
328
+ if (!isTextLikeAttachment(attachment)) {
329
+ return { kind: "skipped", reason: l("This file type is not auto-extracted.", "この形式は本文を自動抽出していません。") };
330
+ }
331
+ const url = attachment.url || attachment.proxy_url;
332
+ if (!url) {
333
+ return { kind: "skipped", reason: l("No URL, so text could not be fetched.", "URL がないため本文を取得できません。") };
334
+ }
335
+ if (typeof attachment.size === "number" && attachment.size > ATTACHMENT_TEXT_MAX_BYTES) {
336
+ return {
337
+ kind: "skipped",
338
+ reason: l(`Text attachment is too large and was skipped (${formatBytes(attachment.size)}).`, `テキスト添付が大きすぎるため読み取りを省略しました(${formatBytes(attachment.size)})。`),
339
+ };
340
+ }
341
+ try {
342
+ const response = await fetch(url, { headers: { authorization: `Bot ${state.token}` } });
343
+ if (!response.ok) {
344
+ return { kind: "skipped", reason: l(`Text fetch failed (HTTP ${response.status}).`, `本文取得に失敗しました(HTTP ${response.status})。`) };
345
+ }
346
+ const length = Number.parseInt(response.headers.get("content-length") || "", 10);
347
+ if (Number.isFinite(length) && length > ATTACHMENT_TEXT_MAX_BYTES) {
348
+ return {
349
+ kind: "skipped",
350
+ reason: l(`Text attachment is too large and was skipped (${formatBytes(length)}).`, `テキスト添付が大きすぎるため読み取りを省略しました(${formatBytes(length)})。`),
351
+ };
352
+ }
353
+ const contentType = response.headers.get("content-type") || attachment.content_type;
354
+ if (!isTextLikeAttachment({ ...attachment, content_type: contentType })) {
355
+ return { kind: "skipped", reason: l("This file type is not auto-extracted.", "この形式は本文を自動抽出していません。") };
356
+ }
357
+ const buffer = Buffer.from(await response.arrayBuffer());
358
+ if (buffer.length > ATTACHMENT_TEXT_MAX_BYTES) {
359
+ return {
360
+ kind: "skipped",
361
+ reason: l(`Text attachment is too large and was skipped (${formatBytes(buffer.length)}).`, `テキスト添付が大きすぎるため読み取りを省略しました(${formatBytes(buffer.length)})。`),
362
+ };
363
+ }
364
+ const text = cleanAttachmentText(buffer.toString("utf8"), ATTACHMENT_TEXT_MAX_CHARS);
365
+ if (!text) {
366
+ return { kind: "skipped", reason: l("The text body was empty.", "本文が空でした。") };
367
+ }
368
+ return { kind: "text", text };
369
+ }
370
+ catch (error) {
371
+ const message = error instanceof Error ? error.message : String(error);
372
+ await appendEventLog(state.config, {
373
+ level: "warn",
374
+ source: "discord",
375
+ event: "attachment_read_failed",
376
+ message: message.slice(0, 200),
377
+ details: {
378
+ filename: attachment.filename || attachment.title,
379
+ content_type: attachment.content_type,
380
+ },
381
+ });
382
+ return { kind: "skipped", reason: l("An error occurred while fetching text.", "本文取得でエラーになりました。") };
383
+ }
384
+ }
385
+ async function readDiscordAttachmentImage(state, attachment) {
386
+ const url = attachment.url || attachment.proxy_url;
387
+ if (!url) {
388
+ return { kind: "skipped", reason: l("No URL, so the image could not be fetched.", "URL がないため画像を取得できません。") };
389
+ }
390
+ if (typeof attachment.size === "number" && attachment.size > ATTACHMENT_IMAGE_MAX_BYTES) {
391
+ return {
392
+ kind: "skipped",
393
+ reason: l(`Image is too large and was skipped (${formatBytes(attachment.size)}).`, `画像が大きすぎるため添付を省略しました(${formatBytes(attachment.size)})。`),
394
+ };
395
+ }
396
+ try {
397
+ const response = await fetch(url, { headers: { authorization: `Bot ${state.token}` } });
398
+ if (!response.ok) {
399
+ return { kind: "skipped", reason: l(`Image fetch failed (HTTP ${response.status}).`, `画像取得に失敗しました(HTTP ${response.status})。`) };
400
+ }
401
+ const length = Number.parseInt(response.headers.get("content-length") || "", 10);
402
+ if (Number.isFinite(length) && length > ATTACHMENT_IMAGE_MAX_BYTES) {
403
+ return {
404
+ kind: "skipped",
405
+ reason: l(`Image is too large and was skipped (${formatBytes(length)}).`, `画像が大きすぎるため添付を省略しました(${formatBytes(length)})。`),
406
+ };
407
+ }
408
+ const contentType = response.headers.get("content-type") || attachment.content_type || guessImageMimeType(attachment);
409
+ if (!contentType.toLowerCase().startsWith("image/")) {
410
+ return { kind: "skipped", reason: l("Could not read it as an image.", "画像形式として取得できませんでした。") };
411
+ }
412
+ const buffer = Buffer.from(await response.arrayBuffer());
413
+ if (buffer.length > ATTACHMENT_IMAGE_MAX_BYTES) {
414
+ return {
415
+ kind: "skipped",
416
+ reason: l(`Image is too large and was skipped (${formatBytes(buffer.length)}).`, `画像が大きすぎるため添付を省略しました(${formatBytes(buffer.length)})。`),
417
+ };
418
+ }
419
+ const mimeType = contentType.split(";")[0].trim() || "image/png";
420
+ const savedPath = await persistDiscordAttachmentBuffer(state, attachment, buffer, mimeType);
421
+ return {
422
+ kind: "image",
423
+ part: {
424
+ type: "image",
425
+ image_url: `data:${mimeType};base64,${buffer.toString("base64")}`,
426
+ mime_type: mimeType,
427
+ filename: attachment.filename || attachment.title,
428
+ },
429
+ savedPath,
430
+ };
431
+ }
432
+ catch (error) {
433
+ const message = error instanceof Error ? error.message : String(error);
434
+ await appendEventLog(state.config, {
435
+ level: "warn",
436
+ source: "discord",
437
+ event: "attachment_image_read_failed",
438
+ message: message.slice(0, 200),
439
+ details: {
440
+ filename: attachment.filename || attachment.title,
441
+ content_type: attachment.content_type,
442
+ },
443
+ });
444
+ return { kind: "skipped", reason: l("An error occurred while fetching the image.", "画像取得でエラーになりました。") };
445
+ }
446
+ }
447
+ async function persistDiscordAttachmentBuffer(state, attachment, buffer, mimeType) {
448
+ try {
449
+ const now = new Date();
450
+ const yyyy = String(now.getFullYear());
451
+ const MM = String(now.getMonth() + 1).padStart(2, "0");
452
+ const dd = String(now.getDate()).padStart(2, "0");
453
+ const HH = String(now.getHours()).padStart(2, "0");
454
+ const mm = String(now.getMinutes()).padStart(2, "0");
455
+ const ss = String(now.getSeconds()).padStart(2, "0");
456
+ const dir = path.join(state.config.notes_dir, "attachments", yyyy, MM);
457
+ await mkdir(dir, { recursive: true });
458
+ const ext = pickAttachmentExtension(attachment.filename || attachment.title, mimeType);
459
+ const random = Math.random().toString(36).slice(2, 8);
460
+ const filename = `${yyyy}${MM}${dd}-${HH}${mm}${ss}-${random}${ext}`;
461
+ const fullPath = path.join(dir, filename);
462
+ await writeFile(fullPath, buffer);
463
+ return fullPath;
464
+ }
465
+ catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ await appendEventLog(state.config, {
468
+ level: "warn",
469
+ source: "discord",
470
+ event: "attachment_persist_failed",
471
+ message: message.slice(0, 200),
472
+ details: {
473
+ filename: attachment.filename || attachment.title,
474
+ content_type: attachment.content_type,
475
+ },
476
+ });
477
+ return undefined;
478
+ }
479
+ }
480
+ function pickAttachmentExtension(filename, mimeType) {
481
+ if (filename) {
482
+ const ext = path.extname(filename);
483
+ if (ext && ext.length <= 8) {
484
+ return ext.toLowerCase();
485
+ }
486
+ }
487
+ const fromMime = mimeType.split("/")[1]?.split(";")[0]?.trim().toLowerCase();
488
+ if (fromMime) {
489
+ if (fromMime === "jpeg")
490
+ return ".jpg";
491
+ if (/^[a-z0-9]{1,6}$/.test(fromMime)) {
492
+ return `.${fromMime}`;
493
+ }
494
+ }
495
+ return ".bin";
496
+ }
497
+ function normalizeDiscordAttachments(attachments) {
498
+ if (!Array.isArray(attachments)) {
499
+ return [];
500
+ }
501
+ return attachments.filter((attachment) => attachment && typeof attachment === "object");
502
+ }
503
+ function firstAttachmentName(attachments) {
504
+ const attachment = normalizeDiscordAttachments(attachments)[0];
505
+ return attachment ? attachment.filename || attachment.title || l("attachment", "添付ファイル") : "";
506
+ }
507
+ function formatDiscordAttachmentLabel(attachment) {
508
+ return formatAttachmentLabel({
509
+ name: attachment.filename || attachment.title,
510
+ fallback: l("attachment", "添付ファイル"),
511
+ contentType: attachment.content_type,
512
+ size: attachment.size,
513
+ });
514
+ }
515
+ function isTextLikeAttachment(attachment) {
516
+ return isTextLikeFile(attachment.content_type, attachment.filename || attachment.title);
517
+ }
518
+ function isImageAttachment(attachment) {
519
+ return isImageLikeFile(attachment.content_type, attachment.filename || attachment.title);
520
+ }
521
+ function guessImageMimeType(attachment) {
522
+ return guessImageMimeTypeFromName(attachment.filename || attachment.title);
523
+ }
524
+ export function shouldResetDiscordHistory(ctx) {
525
+ return ctx.isMentioned && !ctx.isDirect && !ctx.isBotThread;
526
+ }
527
+ export function chunkMessage(text, max = MESSAGE_MAX) {
528
+ return chunkText(text, max);
529
+ }
530
+ function connectAndRun(state) {
531
+ return new Promise((resolve) => {
532
+ const ws = new WebSocket(GATEWAY_URL);
533
+ state.ws = ws;
534
+ state.heartbeatAcked = true;
535
+ ws.on("open", () => {
536
+ console.log("agent-sin discord: gateway open");
537
+ });
538
+ ws.on("message", (data) => {
539
+ let payload;
540
+ try {
541
+ const text = typeof data === "string" ? data : data.toString("utf8");
542
+ payload = JSON.parse(text);
543
+ }
544
+ catch {
545
+ console.error("agent-sin discord: invalid JSON from gateway");
546
+ return;
547
+ }
548
+ handleGatewayPayload(state, payload).catch((error) => {
549
+ const message = error instanceof Error ? error.message : String(error);
550
+ console.error(`agent-sin discord: handler error: ${message}`);
551
+ });
552
+ });
553
+ ws.on("close", (code, reason) => {
554
+ if (state.heartbeatTimer) {
555
+ clearInterval(state.heartbeatTimer);
556
+ state.heartbeatTimer = null;
557
+ }
558
+ const reasonStr = reason ? reason.toString() : "";
559
+ console.log(`agent-sin discord: gateway closed (code ${code}${reasonStr ? `, ${reasonStr}` : ""})`);
560
+ void appendEventLog(state.config, {
561
+ level: code === 1000 ? "info" : "warn",
562
+ source: "discord",
563
+ event: "gateway_closed",
564
+ message: reasonStr || undefined,
565
+ details: { code, will_reconnect: state.reconnect },
566
+ }).catch(() => { });
567
+ if (code === 4014) {
568
+ console.error("agent-sin discord: disallowed intent. The bot needs the privileged 'Message Content' intent to read replies inside threads (and listen channels) without an @mention. Enable it in the Discord developer portal under Bot → Privileged Gateway Intents.");
569
+ }
570
+ if (code === 4004 || code === 4014 || code === 4013 || code === 4011 || code === 4012) {
571
+ // Authentication / disallowed intent / sharding errors are fatal for our use case.
572
+ state.reconnect = false;
573
+ }
574
+ resolve();
575
+ });
576
+ ws.on("error", (error) => {
577
+ console.error(`agent-sin discord: socket error: ${error.message}`);
578
+ void appendEventLog(state.config, {
579
+ level: "error",
580
+ source: "discord",
581
+ event: "gateway_socket_error",
582
+ message: error.message,
583
+ }).catch(() => { });
584
+ });
585
+ });
586
+ }
587
+ async function handleGatewayPayload(state, payload) {
588
+ if (typeof payload.s === "number") {
589
+ state.seq = payload.s;
590
+ }
591
+ switch (payload.op) {
592
+ case 10: // Hello
593
+ handleHello(state, payload.d?.heartbeat_interval ?? 41250);
594
+ sendIdentify(state);
595
+ break;
596
+ case 11: // Heartbeat ACK
597
+ state.heartbeatAcked = true;
598
+ break;
599
+ case 1: // Server requested heartbeat
600
+ sendHeartbeat(state);
601
+ break;
602
+ case 7: // Reconnect
603
+ state.ws?.close(4000, "reconnect requested");
604
+ break;
605
+ case 9: // Invalid Session
606
+ setTimeout(() => sendIdentify(state), 5000);
607
+ break;
608
+ case 0: // Dispatch
609
+ if (payload.t === "READY") {
610
+ state.botUserId = payload.d?.user?.id ?? null;
611
+ state.sessionId = payload.d?.session_id ?? null;
612
+ const username = payload.d?.user?.username || "?";
613
+ console.log(`agent-sin discord: ready as ${username} (${state.botUserId})`);
614
+ await appendEventLog(state.config, {
615
+ level: "info",
616
+ source: "discord",
617
+ event: "ready",
618
+ details: { bot_user_id: state.botUserId },
619
+ });
620
+ if (!state.catchUpDone) {
621
+ state.catchUpDone = true;
622
+ catchUpMissedMessages(state).catch((error) => {
623
+ const message = error instanceof Error ? error.message : String(error);
624
+ console.error(`agent-sin discord: catch-up failed: ${message}`);
625
+ });
626
+ }
627
+ void registerSlashCommands(state);
628
+ }
629
+ else if (payload.t === "MESSAGE_CREATE") {
630
+ await handleMessage(state, payload.d);
631
+ }
632
+ else if (payload.t === "INTERACTION_CREATE") {
633
+ await handleInteraction(state, payload.d);
634
+ }
635
+ break;
636
+ default:
637
+ // ignore other ops
638
+ break;
639
+ }
640
+ }
641
+ function handleHello(state, intervalMs) {
642
+ if (state.heartbeatTimer) {
643
+ clearInterval(state.heartbeatTimer);
644
+ }
645
+ // Initial jitter per Discord docs.
646
+ setTimeout(() => sendHeartbeat(state), Math.random() * intervalMs);
647
+ state.heartbeatTimer = setInterval(() => {
648
+ if (!state.heartbeatAcked) {
649
+ void appendEventLog(state.config, {
650
+ level: "warn",
651
+ source: "discord",
652
+ event: "heartbeat_missed",
653
+ details: { interval_ms: intervalMs },
654
+ }).catch(() => { });
655
+ state.ws?.close(4000, "missed heartbeat ack");
656
+ return;
657
+ }
658
+ sendHeartbeat(state);
659
+ }, intervalMs);
660
+ if (typeof state.heartbeatTimer.unref === "function") {
661
+ state.heartbeatTimer.unref();
662
+ }
663
+ }
664
+ function sendHeartbeat(state) {
665
+ state.heartbeatAcked = false;
666
+ state.ws?.send(JSON.stringify({ op: 1, d: state.seq }));
667
+ }
668
+ function sendIdentify(state) {
669
+ const identify = {
670
+ op: 2,
671
+ d: {
672
+ token: state.token,
673
+ intents: state.intents,
674
+ properties: {
675
+ os: process.platform,
676
+ browser: "agent-sin",
677
+ device: "agent-sin",
678
+ },
679
+ },
680
+ };
681
+ state.ws?.send(JSON.stringify(identify));
682
+ }
683
+ async function handleMessage(state, message) {
684
+ if (!message || !message.author || message.author.bot) {
685
+ return;
686
+ }
687
+ if (message.id) {
688
+ if (state.processedMessageIds.has(message.id)) {
689
+ return;
690
+ }
691
+ state.processedMessageIds.add(message.id);
692
+ if (state.processedMessageIds.size > PROCESSED_MESSAGE_LIMIT) {
693
+ const first = state.processedMessageIds.values().next().value;
694
+ if (first)
695
+ state.processedMessageIds.delete(first);
696
+ }
697
+ }
698
+ const ctx = classifyMessage(message, state.botUserId, state.allowedUserIds, state.listenChannelIds, state.botThreadIds);
699
+ rememberLastSeen(state, message, ctx).catch((error) => {
700
+ const errMessage = error instanceof Error ? error.message : String(error);
701
+ console.error(`agent-sin discord: failed to persist last-seen: ${errMessage}`);
702
+ });
703
+ if (!shouldRespond(ctx)) {
704
+ if ((ctx.isDirect || ctx.isMentioned || ctx.isBotThread) && !ctx.isAllowed) {
705
+ await appendEventLog(state.config, {
706
+ level: "warn",
707
+ source: "discord",
708
+ event: "blocked_user",
709
+ details: { user_id: message.author.id, channel_id: message.channel_id },
710
+ });
711
+ }
712
+ else if (ctx.isListenChannel || ctx.isBotThread || ctx.isDirect) {
713
+ await appendEventLog(state.config, {
714
+ level: "info",
715
+ source: "discord",
716
+ event: "message_ignored",
717
+ message: (message.content || "").slice(0, 120),
718
+ details: {
719
+ channel_id: message.channel_id,
720
+ guild_id: message.guild_id,
721
+ author_id: message.author.id,
722
+ message_id: message.id,
723
+ is_direct: ctx.isDirect,
724
+ is_mentioned: ctx.isMentioned,
725
+ is_allowed: ctx.isAllowed,
726
+ is_listen_channel: ctx.isListenChannel,
727
+ is_bot_thread: ctx.isBotThread,
728
+ mention_count: Array.isArray(message.mentions) ? message.mentions.length : 0,
729
+ mention_user_ids: Array.isArray(message.mentions)
730
+ ? message.mentions.map((user) => user.id).slice(0, 5)
731
+ : [],
732
+ bot_user_id: state.botUserId,
733
+ },
734
+ });
735
+ }
736
+ return;
737
+ }
738
+ const cleanText = state.botUserId
739
+ ? stripBotMention(message.content || "", state.botUserId)
740
+ : (message.content || "").trim();
741
+ if (cleanText === "!help") {
742
+ await sendChannelMessage(state, message.channel_id, helpText());
743
+ return;
744
+ }
745
+ if (cleanText === "!reset") {
746
+ state.histories.delete(message.channel_id);
747
+ state.historiesLoaded.delete(message.channel_id);
748
+ await sendChannelMessage(state, message.channel_id, l("Chat history reset.", "会話履歴をリセットしました。"));
749
+ return;
750
+ }
751
+ if (cleanText === "/skills" || cleanText === "/skills --all") {
752
+ const lines = await skillsLines(state.config);
753
+ await sendChannelMessage(state, message.channel_id, lines.join("\n"));
754
+ return;
755
+ }
756
+ if (cleanText === "/models") {
757
+ const lines = await modelsLines(state.config);
758
+ await sendChannelMessage(state, message.channel_id, lines.join("\n"));
759
+ return;
760
+ }
761
+ if (await tryRunTodoSlashCommand(state, message, cleanText)) {
762
+ return;
763
+ }
764
+ if (await tryRunModelSlashCommand(state, message, cleanText)) {
765
+ return;
766
+ }
767
+ const progressCommand = handleProgressCommand(state, message.channel_id, cleanText);
768
+ if (progressCommand) {
769
+ await sendChannelMessage(state, message.channel_id, progressCommand.join("\n"));
770
+ return;
771
+ }
772
+ const userMessage = await formatDiscordUserMessageForChat(state, message, state.botUserId);
773
+ const userText = userMessage.text;
774
+ if (!userText) {
775
+ await sendChannelMessage(state, message.channel_id, l("Please enter a message. Use `!help` for usage.", "メッセージを入力してください。`!help` で使い方を表示します。"));
776
+ return;
777
+ }
778
+ // Reactions go on the user's original message regardless of where we reply.
779
+ const status = createStatusReactor(state, message.channel_id, message.id);
780
+ await status.set("received");
781
+ // If this is a mention in a "listen" parent channel, spin up a fresh thread
782
+ // and run the conversation inside it. Subsequent replies in that thread no
783
+ // longer require @mentioning the bot.
784
+ let replyChannelId = message.channel_id;
785
+ const shouldSpawnThread = !ctx.isDirect && !ctx.isBotThread && ctx.isMentioned && ctx.isListenChannel;
786
+ if (shouldSpawnThread) {
787
+ const threadName = makeThreadName(cleanText || firstAttachmentName(message.attachments));
788
+ const thread = await createThreadFromMessage(state, message.channel_id, message.id, threadName);
789
+ if (thread) {
790
+ replyChannelId = thread.id;
791
+ state.botThreadIds.add(thread.id);
792
+ await saveBotThreadIds(state);
793
+ await appendEventLog(state.config, {
794
+ level: "info",
795
+ source: "discord",
796
+ event: "thread_created",
797
+ details: { thread_id: thread.id, parent_channel_id: message.channel_id, name: threadName },
798
+ });
799
+ }
800
+ // If thread creation failed, fall back to replying in the parent channel.
801
+ }
802
+ // Fresh @mention outside a bot thread = new conversation. Inside a bot
803
+ // thread, even explicit @mentions should keep the thread context.
804
+ const treatAsNewConversation = shouldResetDiscordHistory(ctx);
805
+ let history;
806
+ if (treatAsNewConversation) {
807
+ // Explicit fresh @mention starts clean so a new request does not inherit a
808
+ // previous build mode by accident.
809
+ history = [];
810
+ state.histories.set(replyChannelId, history);
811
+ state.historiesLoaded.add(replyChannelId);
812
+ if (state.intentRuntimes.delete(replyChannelId)) {
813
+ void saveIntentRuntimes(state);
814
+ }
815
+ }
816
+ else {
817
+ // Pull the latest Discord thread/channel contents on every turn. This keeps
818
+ // AI context aligned with the actual thread even after restarts, missed
819
+ // gateway events, or user-side @mentions inside the thread.
820
+ const refreshed = await refreshDiscordHistoryBeforeCurrentMessage(state, replyChannelId, message.id);
821
+ if (!refreshed.ok) {
822
+ await status.set("error");
823
+ await sendChannelMessage(state, replyChannelId, l("I could not reload the Discord thread history, so I stopped this reply. Wait a moment and send it again.", "Discord スレッドの履歴を読み直せなかったため、応答を止めました。少し待ってからもう一度送ってください。"));
824
+ return;
825
+ }
826
+ history = refreshed.history;
827
+ }
828
+ let intentRuntime = state.intentRuntimes.get(replyChannelId);
829
+ if (!intentRuntime) {
830
+ intentRuntime = createIntentRuntime(true);
831
+ state.intentRuntimes.set(replyChannelId, intentRuntime);
832
+ }
833
+ await refreshStateConfig(state);
834
+ await withLocale(inferLocaleFromText(userText), async () => {
835
+ const typing = startTypingKeepalive(state, replyChannelId);
836
+ const prevMode = intentRuntime.mode;
837
+ try {
838
+ const lines = await routeDiscordMessage(state, userText, history, intentRuntime, status, replyChannelId, userMessage.images);
839
+ typing.stop();
840
+ void saveIntentRuntimes(state);
841
+ const isBuildEntry = prevMode !== "build" && intentRuntime.mode === "build";
842
+ const decorated = withModeBadge(intentRuntime, lines, { userText, isBuildEntry });
843
+ scheduleUpdateCheck(state.config.workspace);
844
+ const banner = await consumeUpdateBanner(state.config.workspace);
845
+ const finalLines = banner ? [banner, "", ...decorated] : decorated;
846
+ const reply = finalLines.filter((line) => line !== undefined && line !== null).join("\n").trim();
847
+ if (reply) {
848
+ await sendChannelMessage(state, replyChannelId, reply);
849
+ }
850
+ else {
851
+ await sendChannelMessage(state, replyChannelId, l("(no response)", "(応答なし)"));
852
+ }
853
+ }
854
+ catch (error) {
855
+ typing.stop();
856
+ await status.set("error");
857
+ const errMessage = error instanceof Error ? error.message : String(error);
858
+ console.error(`agent-sin discord: routeDiscordMessage failed: ${errMessage}`);
859
+ await sendChannelMessage(state, replyChannelId, l(`Error: ${errMessage}`, `エラー: ${errMessage}`));
860
+ }
861
+ });
862
+ }
863
+ async function refreshStateConfig(state) {
864
+ try {
865
+ state.config = await loadConfig();
866
+ }
867
+ catch (error) {
868
+ const message = error instanceof Error ? error.message : String(error);
869
+ await appendEventLog(state.config, {
870
+ level: "warn",
871
+ source: "discord",
872
+ event: "config_refresh_failed",
873
+ message,
874
+ });
875
+ }
876
+ }
877
+ async function refreshDiscordHistoryBeforeCurrentMessage(state, channelId, beforeMessageId) {
878
+ try {
879
+ const past = await fetchChannelHistoryBefore(state, channelId, beforeMessageId, DISCORD_CONTEXT_HISTORY_LIMIT);
880
+ const history = buildChatHistoryFromMessages(past, state.botUserId, state.allowedUserIds);
881
+ state.histories.set(channelId, history);
882
+ state.historiesLoaded.add(channelId);
883
+ return { ok: true, history };
884
+ }
885
+ catch (error) {
886
+ const message = error instanceof Error ? error.message : String(error);
887
+ console.error(`agent-sin discord: history refresh failed: ${message}`);
888
+ await appendEventLog(state.config, {
889
+ level: "error",
890
+ source: "discord",
891
+ event: "history_refresh_failed",
892
+ message,
893
+ details: { channel_id: channelId, before_message_id: beforeMessageId },
894
+ });
895
+ return { ok: false, error: message };
896
+ }
897
+ }
898
+ async function routeDiscordMessage(state, text, history, intentRuntime, status, replyChannelId, images = []) {
899
+ let modelFailed = false;
900
+ const lines = await routeConversationMessage({
901
+ config: state.config,
902
+ text,
903
+ history,
904
+ intentRuntime,
905
+ eventSource: "discord",
906
+ images,
907
+ createBuildProgress: () => createDiscordBuildProgressReporter(state, replyChannelId, status),
908
+ onBuildStart: () => status.set("tool"),
909
+ onBuildDone: () => status.set("done"),
910
+ onChatProgress: (event) => {
911
+ void onChatProgressForReactions(event, status);
912
+ if (event.kind === "model_failed") {
913
+ modelFailed = true;
914
+ }
915
+ },
916
+ });
917
+ await status.set(modelFailed ? "error" : "done");
918
+ return lines;
919
+ }
920
+ async function onChatProgressForReactions(event, status) {
921
+ switch (event.kind) {
922
+ case "thinking":
923
+ await status.set("thinking");
924
+ break;
925
+ case "tool_running":
926
+ case "tool_repairing":
927
+ await status.set("tool");
928
+ break;
929
+ case "tool_done":
930
+ // Return to thinking so the next iteration's update is visible if it changes again.
931
+ await status.set("thinking");
932
+ break;
933
+ case "model_failed":
934
+ await status.set("error");
935
+ break;
936
+ }
937
+ }
938
+ function createDiscordBuildProgressReporter(state, channelId, status) {
939
+ const minIntervalMs = discordProgressIntervalMs();
940
+ let lastSentAt = 0;
941
+ let lastText = "";
942
+ let sent = 0;
943
+ let pending = Promise.resolve();
944
+ const enqueue = (text, kind) => {
945
+ pending = pending
946
+ .then(async () => {
947
+ await status.set(kind);
948
+ await sendChannelMessage(state, channelId, text);
949
+ })
950
+ .catch(async (error) => {
951
+ const message = error instanceof Error ? error.message : String(error);
952
+ await appendEventLog(state.config, {
953
+ level: "warn",
954
+ source: "discord",
955
+ event: "build_progress_failed",
956
+ message: message.slice(0, 200),
957
+ details: { channel_id: channelId },
958
+ });
959
+ });
960
+ };
961
+ return {
962
+ onProgress(event) {
963
+ void status.set(statusKindForBuildProgress(event));
964
+ const text = formatDiscordBuildProgress(event, {
965
+ detail: isDiscordProgressDetailEnabled(state, channelId),
966
+ });
967
+ if (!text) {
968
+ return;
969
+ }
970
+ const now = Date.now();
971
+ if (text === lastText) {
972
+ return;
973
+ }
974
+ if (sent > 0 && now - lastSentAt < minIntervalMs) {
975
+ return;
976
+ }
977
+ lastText = text;
978
+ lastSentAt = now;
979
+ sent += 1;
980
+ enqueue(text, statusKindForBuildProgress(event));
981
+ },
982
+ async flush() {
983
+ await pending;
984
+ },
985
+ };
986
+ }
987
+ function isDiscordProgressDetailEnabled(state, channelId) {
988
+ if (process.env.AGENT_SIN_DISCORD_PROGRESS_DETAIL === "1") {
989
+ return true;
990
+ }
991
+ return state.intentRuntimes.get(channelId)?.progress_detail === true;
992
+ }
993
+ function discordProgressIntervalMs() {
994
+ return progressIntervalMs("AGENT_SIN_DISCORD_PROGRESS_INTERVAL_MS");
995
+ }
996
+ export function formatDiscordBuildProgress(event, options = {}) {
997
+ return formatBuildProgress(event, options);
998
+ }
999
+ function statusKindForBuildProgress(event) {
1000
+ return event.kind === "tool" || event.kind === "stderr" ? "tool" : "thinking";
1001
+ }
1002
+ function createStatusReactor(state, channelId, messageId) {
1003
+ let current = null;
1004
+ let pending = Promise.resolve();
1005
+ return {
1006
+ set(kind) {
1007
+ // Serialize reaction updates so a fast burst of progress events doesn't race.
1008
+ pending = pending.then(async () => {
1009
+ if (current === kind)
1010
+ return;
1011
+ const prev = current;
1012
+ current = kind;
1013
+ const nextEmoji = STATUS_EMOJI[kind];
1014
+ try {
1015
+ await addReaction(state, channelId, messageId, nextEmoji);
1016
+ }
1017
+ catch {
1018
+ // network blip — keep going
1019
+ }
1020
+ if (prev) {
1021
+ const prevEmoji = STATUS_EMOJI[prev];
1022
+ if (prevEmoji !== nextEmoji) {
1023
+ try {
1024
+ await removeOwnReaction(state, channelId, messageId, prevEmoji);
1025
+ }
1026
+ catch {
1027
+ // ignore
1028
+ }
1029
+ }
1030
+ }
1031
+ });
1032
+ return pending;
1033
+ },
1034
+ };
1035
+ }
1036
+ async function addReaction(state, channelId, messageId, emoji) {
1037
+ const url = `${REST_BASE}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`;
1038
+ const response = await fetch(url, {
1039
+ method: "PUT",
1040
+ headers: { authorization: `Bot ${state.token}` },
1041
+ });
1042
+ if (!response.ok && response.status !== 204) {
1043
+ const detail = await response.text().catch(() => "");
1044
+ if (response.status !== 403 && response.status !== 404) {
1045
+ console.error(`agent-sin discord: addReaction failed: HTTP ${response.status} ${detail.slice(0, 120)}`);
1046
+ }
1047
+ }
1048
+ }
1049
+ async function removeOwnReaction(state, channelId, messageId, emoji) {
1050
+ const url = `${REST_BASE}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`;
1051
+ await fetch(url, {
1052
+ method: "DELETE",
1053
+ headers: { authorization: `Bot ${state.token}` },
1054
+ });
1055
+ }
1056
+ function startTypingKeepalive(state, channelId) {
1057
+ let stopped = false;
1058
+ let timer = null;
1059
+ const tick = () => {
1060
+ if (stopped)
1061
+ return;
1062
+ void sendTypingIndicator(state, channelId);
1063
+ };
1064
+ tick();
1065
+ timer = setInterval(tick, TYPING_REFRESH_MS);
1066
+ if (typeof timer.unref === "function")
1067
+ timer.unref();
1068
+ return {
1069
+ stop() {
1070
+ stopped = true;
1071
+ if (timer) {
1072
+ clearInterval(timer);
1073
+ timer = null;
1074
+ }
1075
+ },
1076
+ };
1077
+ }
1078
+ export function parseTodoSlashCommand(text) {
1079
+ const trimmed = (text || "").trim();
1080
+ if (trimmed !== "/todo" && !/^\/todo\s/.test(trimmed)) {
1081
+ return null;
1082
+ }
1083
+ const rest = trimmed === "/todo" ? "" : trimmed.replace(/^\/todo\s+/, "").trim();
1084
+ if (!rest || rest === "help" || rest === "--help" || rest === "-h") {
1085
+ return { kind: "help", lines: todoSlashHelpLines() };
1086
+ }
1087
+ const firstSpace = rest.search(/\s/);
1088
+ const sub = (firstSpace >= 0 ? rest.slice(0, firstSpace) : rest).toLowerCase();
1089
+ const remainder = firstSpace >= 0 ? rest.slice(firstSpace + 1).trim() : "";
1090
+ if (sub === "add") {
1091
+ if (!remainder) {
1092
+ return {
1093
+ kind: "error",
1094
+ lines: [
1095
+ l("Usage: /todo add <text> [--due 2026-05-17T18:00:00+09:00]", "使い方: /todo add <本文> [--due 2026-05-17T18:00:00+09:00]"),
1096
+ ],
1097
+ };
1098
+ }
1099
+ const { text: body, due } = extractTodoDueFlag(remainder);
1100
+ if (!body) {
1101
+ return {
1102
+ kind: "error",
1103
+ lines: [l("ToDo text is required.", "ToDoの本文を指定してください。")],
1104
+ };
1105
+ }
1106
+ const args = { text: body };
1107
+ if (due)
1108
+ args.due = due;
1109
+ return { kind: "run", skillId: "todo-add", args };
1110
+ }
1111
+ if (sub === "list") {
1112
+ const filterRaw = (remainder.split(/\s+/)[0] || "open").toLowerCase();
1113
+ const allowed = new Set(["open", "done", "all"]);
1114
+ if (!allowed.has(filterRaw)) {
1115
+ return {
1116
+ kind: "error",
1117
+ lines: [
1118
+ l(`Unknown filter: ${filterRaw}. Use open / done / all.`, `未対応のフィルタです: ${filterRaw}。open / done / all のいずれかにしてください。`),
1119
+ ],
1120
+ };
1121
+ }
1122
+ return { kind: "run", skillId: "todo-list", args: { status: filterRaw } };
1123
+ }
1124
+ if (sub === "done") {
1125
+ const id = (remainder.split(/\s+/)[0] || "").trim();
1126
+ if (!id) {
1127
+ return { kind: "error", lines: [l("Usage: /todo done <id>", "使い方: /todo done <id>")] };
1128
+ }
1129
+ return { kind: "run", skillId: "todo-done", args: { id } };
1130
+ }
1131
+ if (sub === "delete" || sub === "remove" || sub === "del") {
1132
+ const id = (remainder.split(/\s+/)[0] || "").trim();
1133
+ if (!id) {
1134
+ return { kind: "error", lines: [l("Usage: /todo delete <id>", "使い方: /todo delete <id>")] };
1135
+ }
1136
+ return { kind: "run", skillId: "todo-delete", args: { id } };
1137
+ }
1138
+ return {
1139
+ kind: "error",
1140
+ lines: [
1141
+ l(`Unknown subcommand: ${sub}.`, `未対応のサブコマンドです: ${sub}。`),
1142
+ ...todoSlashHelpLines(),
1143
+ ],
1144
+ };
1145
+ }
1146
+ function extractTodoDueFlag(rest) {
1147
+ const match = rest.match(/(^|\s)--due\s+(\S+)(?=\s|$)/);
1148
+ if (!match)
1149
+ return { text: rest.trim() };
1150
+ const start = match.index ?? 0;
1151
+ const before = rest.slice(0, start);
1152
+ const after = rest.slice(start + match[0].length);
1153
+ const text = [before, after].map((segment) => segment.trim()).filter(Boolean).join(" ").trim();
1154
+ return { text, due: match[2] };
1155
+ }
1156
+ function todoSlashHelpLines() {
1157
+ return lLines([
1158
+ "Quick ToDo commands:",
1159
+ "/todo add <text> [--due 2026-05-17T18:00:00+09:00] — add a ToDo",
1160
+ "/todo list [open|done|all] — list ToDos (default: open)",
1161
+ "/todo done <id> — mark a ToDo as done",
1162
+ "/todo delete <id> — delete a ToDo",
1163
+ ], [
1164
+ "ToDoのショートカット:",
1165
+ "/todo add <本文> [--due 2026-05-17T18:00:00+09:00] — ToDoを追加",
1166
+ "/todo list [open|done|all] — ToDoを一覧表示(既定: open)",
1167
+ "/todo done <id> — ToDoを完了にする",
1168
+ "/todo delete <id> — ToDoを削除",
1169
+ ]);
1170
+ }
1171
+ async function tryRunTodoSlashCommand(state, message, cleanText) {
1172
+ const parsed = await withLocale(inferLocaleFromText(cleanText), () => Promise.resolve(parseTodoSlashCommand(cleanText)));
1173
+ if (!parsed)
1174
+ return false;
1175
+ await withLocale(inferLocaleFromText(cleanText), async () => {
1176
+ const status = createStatusReactor(state, message.channel_id, message.id);
1177
+ await status.set("received");
1178
+ if (parsed.kind === "help") {
1179
+ await status.set("done");
1180
+ await sendChannelMessage(state, message.channel_id, parsed.lines.join("\n"));
1181
+ return;
1182
+ }
1183
+ if (parsed.kind === "error") {
1184
+ await status.set("error");
1185
+ await sendChannelMessage(state, message.channel_id, parsed.lines.join("\n"));
1186
+ return;
1187
+ }
1188
+ await status.set("tool");
1189
+ try {
1190
+ const response = await runSkill(state.config, parsed.skillId, parsed.args);
1191
+ const display = response.result.summary ||
1192
+ response.result.title ||
1193
+ l("(no response)", "(応答なし)");
1194
+ await status.set(response.result.status === "ok" ? "done" : "error");
1195
+ await sendChannelMessage(state, message.channel_id, display);
1196
+ await appendEventLog(state.config, {
1197
+ level: "info",
1198
+ source: "discord",
1199
+ event: "todo_slash_ran",
1200
+ message: response.result.title || undefined,
1201
+ details: {
1202
+ skill_id: parsed.skillId,
1203
+ status: response.result.status,
1204
+ channel_id: message.channel_id,
1205
+ },
1206
+ });
1207
+ }
1208
+ catch (error) {
1209
+ await status.set("error");
1210
+ const detail = error instanceof SkillRunError
1211
+ ? error.originalMessage
1212
+ : error instanceof Error
1213
+ ? error.message
1214
+ : String(error);
1215
+ await sendChannelMessage(state, message.channel_id, l(`Error: ${detail}`, `エラー: ${detail}`));
1216
+ await appendEventLog(state.config, {
1217
+ level: "error",
1218
+ source: "discord",
1219
+ event: "todo_slash_failed",
1220
+ message: detail.slice(0, 200),
1221
+ details: { skill_id: parsed.skillId, channel_id: message.channel_id },
1222
+ });
1223
+ }
1224
+ });
1225
+ return true;
1226
+ }
1227
+ export function parseModelSlashCommand(text) {
1228
+ const trimmed = (text || "").trim();
1229
+ if (trimmed !== "/model" && !/^\/model\s/.test(trimmed)) {
1230
+ return null;
1231
+ }
1232
+ const rest = trimmed === "/model" ? "" : trimmed.replace(/^\/model\s+/, "").trim();
1233
+ if (!rest)
1234
+ return { kind: "list" };
1235
+ if (rest === "help" || rest === "--help" || rest === "-h") {
1236
+ return { kind: "help", lines: modelSlashHelpLines() };
1237
+ }
1238
+ const id = rest.split(/\s+/)[0]?.trim() || "";
1239
+ if (!id)
1240
+ return { kind: "list" };
1241
+ return { kind: "set", id };
1242
+ }
1243
+ function modelSlashHelpLines() {
1244
+ return lLines([
1245
+ "Chat model commands:",
1246
+ "/model — show current chat model and candidates",
1247
+ "/model <id> — switch the chat model to <id>",
1248
+ ], [
1249
+ "チャットモデルのショートカット:",
1250
+ "/model — 現在のチャットモデルと候補を表示",
1251
+ "/model <id> — チャットモデルを <id> に切り替え",
1252
+ ]);
1253
+ }
1254
+ function modelEntrySummary(entry) {
1255
+ const parts = [];
1256
+ parts.push(entry.provider || entry.type || "-");
1257
+ if (entry.model)
1258
+ parts.push(String(entry.model));
1259
+ if (entry.effort)
1260
+ parts.push(`effort=${entry.effort}`);
1261
+ if (entry.enabled === false)
1262
+ parts.push(l("disabled", "無効"));
1263
+ return parts.join(" ");
1264
+ }
1265
+ function formatChatModelView(data) {
1266
+ const obj = data || {};
1267
+ const roles = obj.roles || {};
1268
+ const chatId = typeof roles.chat === "string" ? roles.chat : "";
1269
+ const rawEntries = Array.isArray(obj.models) ? obj.models : [];
1270
+ const current = rawEntries.find((m) => m.id === chatId);
1271
+ const others = rawEntries.filter((m) => m.id !== chatId);
1272
+ const lines = [];
1273
+ if (current) {
1274
+ lines.push(l(`chat: ${current.id} (${modelEntrySummary(current)})`, `chat: ${current.id}(${modelEntrySummary(current)})`));
1275
+ }
1276
+ else {
1277
+ lines.push(`chat: ${chatId || "-"}`);
1278
+ }
1279
+ if (others.length === 0) {
1280
+ lines.push(l("No other models registered.", "他に登録されたモデルはありません。"));
1281
+ }
1282
+ else {
1283
+ const ids = others.map((m) => m.id).filter(Boolean).join(", ");
1284
+ lines.push(l(`Candidates: ${ids}`, `候補: ${ids}`));
1285
+ }
1286
+ return lines.join("\n");
1287
+ }
1288
+ async function tryRunModelSlashCommand(state, message, cleanText) {
1289
+ const parsed = await withLocale(inferLocaleFromText(cleanText), () => Promise.resolve(parseModelSlashCommand(cleanText)));
1290
+ if (!parsed)
1291
+ return false;
1292
+ await withLocale(inferLocaleFromText(cleanText), async () => {
1293
+ const status = createStatusReactor(state, message.channel_id, message.id);
1294
+ await status.set("received");
1295
+ if (parsed.kind === "help") {
1296
+ await status.set("done");
1297
+ await sendChannelMessage(state, message.channel_id, parsed.lines.join("\n"));
1298
+ return;
1299
+ }
1300
+ const skillId = parsed.kind === "list" ? "model-list" : "model-set";
1301
+ const args = parsed.kind === "list" ? {} : { role: "chat", id: parsed.id };
1302
+ await status.set("tool");
1303
+ try {
1304
+ const response = await runSkill(state.config, skillId, args);
1305
+ let display;
1306
+ if (parsed.kind === "list" && response.result.status === "ok") {
1307
+ display = formatChatModelView(response.result.data);
1308
+ }
1309
+ else {
1310
+ display =
1311
+ response.result.summary ||
1312
+ response.result.title ||
1313
+ l("(no response)", "(応答なし)");
1314
+ }
1315
+ await status.set(response.result.status === "ok" ? "done" : "error");
1316
+ await sendChannelMessage(state, message.channel_id, display);
1317
+ await appendEventLog(state.config, {
1318
+ level: "info",
1319
+ source: "discord",
1320
+ event: "model_slash_ran",
1321
+ message: response.result.title || undefined,
1322
+ details: {
1323
+ skill_id: skillId,
1324
+ status: response.result.status,
1325
+ channel_id: message.channel_id,
1326
+ },
1327
+ });
1328
+ }
1329
+ catch (error) {
1330
+ await status.set("error");
1331
+ const detail = error instanceof SkillRunError
1332
+ ? error.originalMessage
1333
+ : error instanceof Error
1334
+ ? error.message
1335
+ : String(error);
1336
+ await sendChannelMessage(state, message.channel_id, l(`Error: ${detail}`, `エラー: ${detail}`));
1337
+ await appendEventLog(state.config, {
1338
+ level: "error",
1339
+ source: "discord",
1340
+ event: "model_slash_failed",
1341
+ message: detail.slice(0, 200),
1342
+ details: { skill_id: skillId, channel_id: message.channel_id },
1343
+ });
1344
+ }
1345
+ });
1346
+ return true;
1347
+ }
1348
+ const TODO_SLASH_COMMAND_DEFINITION = {
1349
+ name: "todo",
1350
+ description: "ToDo shortcut commands",
1351
+ description_localizations: { ja: "ToDoのショートカット" },
1352
+ type: 1,
1353
+ dm_permission: true,
1354
+ options: [
1355
+ {
1356
+ type: 1,
1357
+ name: "add",
1358
+ description: "Add a ToDo",
1359
+ description_localizations: { ja: "ToDoを追加" },
1360
+ options: [
1361
+ {
1362
+ type: 3,
1363
+ name: "text",
1364
+ description: "ToDo text",
1365
+ description_localizations: { ja: "ToDoの本文" },
1366
+ required: true,
1367
+ },
1368
+ {
1369
+ type: 3,
1370
+ name: "due",
1371
+ description: "Optional ISO8601 date-time, e.g. 2026-05-17T18:00:00+09:00",
1372
+ description_localizations: { ja: "ISO8601の期限(任意、例 2026-05-17T18:00:00+09:00)" },
1373
+ required: false,
1374
+ },
1375
+ ],
1376
+ },
1377
+ {
1378
+ type: 1,
1379
+ name: "list",
1380
+ description: "List ToDos",
1381
+ description_localizations: { ja: "ToDoを一覧表示" },
1382
+ options: [
1383
+ {
1384
+ type: 3,
1385
+ name: "filter",
1386
+ description: "Which ToDos to show",
1387
+ description_localizations: { ja: "表示する範囲" },
1388
+ required: false,
1389
+ choices: [
1390
+ { name: "open", value: "open" },
1391
+ { name: "done", value: "done" },
1392
+ { name: "all", value: "all" },
1393
+ ],
1394
+ },
1395
+ ],
1396
+ },
1397
+ {
1398
+ type: 1,
1399
+ name: "done",
1400
+ description: "Mark a ToDo as done",
1401
+ description_localizations: { ja: "ToDoを完了にする" },
1402
+ options: [
1403
+ {
1404
+ type: 3,
1405
+ name: "id",
1406
+ description: "Pick a ToDo",
1407
+ description_localizations: { ja: "ToDoを選択" },
1408
+ required: true,
1409
+ autocomplete: true,
1410
+ },
1411
+ ],
1412
+ },
1413
+ {
1414
+ type: 1,
1415
+ name: "delete",
1416
+ description: "Delete a ToDo",
1417
+ description_localizations: { ja: "ToDoを削除する" },
1418
+ options: [
1419
+ {
1420
+ type: 3,
1421
+ name: "id",
1422
+ description: "Pick a ToDo",
1423
+ description_localizations: { ja: "ToDoを選択" },
1424
+ required: true,
1425
+ autocomplete: true,
1426
+ },
1427
+ ],
1428
+ },
1429
+ ],
1430
+ };
1431
+ const MODEL_SLASH_COMMAND_DEFINITION = {
1432
+ name: "model",
1433
+ description: "Show or switch the chat model",
1434
+ description_localizations: { ja: "チャットモデルの表示・切り替え" },
1435
+ type: 1,
1436
+ dm_permission: true,
1437
+ options: [
1438
+ {
1439
+ type: 3,
1440
+ name: "id",
1441
+ description: "Model ID to switch to (omit to list)",
1442
+ description_localizations: { ja: "切り替え先のモデルID(省略で一覧表示)" },
1443
+ required: false,
1444
+ autocomplete: true,
1445
+ },
1446
+ ],
1447
+ };
1448
+ const BUILTIN_SLASH_COMMAND_DEFINITIONS = [
1449
+ TODO_SLASH_COMMAND_DEFINITION,
1450
+ MODEL_SLASH_COMMAND_DEFINITION,
1451
+ ];
1452
+ const DISCORD_SLASH_OPTION_TYPE_CODE = {
1453
+ string: 3,
1454
+ integer: 4,
1455
+ boolean: 5,
1456
+ number: 10,
1457
+ };
1458
+ export function manifestToSlashDefinition(manifest) {
1459
+ const slash = manifest.invocation?.discord_slash;
1460
+ if (!slash)
1461
+ return null;
1462
+ const description = (slash.description && slash.description.trim()) ||
1463
+ (manifest.description && manifest.description.trim()) ||
1464
+ manifest.name ||
1465
+ manifest.id;
1466
+ const definition = {
1467
+ name: manifest.id,
1468
+ description: description.slice(0, 100),
1469
+ type: 1,
1470
+ dm_permission: true,
1471
+ };
1472
+ if (slash.description_ja) {
1473
+ definition.description_localizations = { ja: slash.description_ja.slice(0, 100) };
1474
+ }
1475
+ if (slash.options && slash.options.length > 0) {
1476
+ definition.options = slash.options.map((option) => {
1477
+ const optDef = {
1478
+ type: DISCORD_SLASH_OPTION_TYPE_CODE[option.type],
1479
+ name: option.name,
1480
+ description: ((option.description && option.description.trim()) || option.name).slice(0, 100),
1481
+ required: option.required === true,
1482
+ };
1483
+ if (option.description_ja) {
1484
+ optDef.description_localizations = { ja: option.description_ja.slice(0, 100) };
1485
+ }
1486
+ if (option.choices && option.choices.length > 0) {
1487
+ optDef.choices = option.choices.map((choice) => ({
1488
+ name: choice.name,
1489
+ value: choice.value,
1490
+ }));
1491
+ }
1492
+ return optDef;
1493
+ });
1494
+ }
1495
+ return definition;
1496
+ }
1497
+ export async function composeSlashCommandDefinitions(config) {
1498
+ const builtin = BUILTIN_SLASH_COMMAND_DEFINITIONS;
1499
+ const builtinNames = new Set(builtin.map((d) => d.name));
1500
+ let skillManifests = [];
1501
+ try {
1502
+ skillManifests = await listSkillManifests(config.skills_dir);
1503
+ }
1504
+ catch (error) {
1505
+ const message = error instanceof Error ? error.message : String(error);
1506
+ console.error(`agent-sin discord: listSkillManifests failed: ${message}`);
1507
+ return builtin;
1508
+ }
1509
+ const fromSkills = [];
1510
+ const seen = new Set();
1511
+ for (const manifest of skillManifests) {
1512
+ if (manifest.enabled === false)
1513
+ continue;
1514
+ if (!manifest.invocation?.discord_slash)
1515
+ continue;
1516
+ if (builtinNames.has(manifest.id)) {
1517
+ console.warn(`agent-sin discord: skill "${manifest.id}" collides with a builtin slash command — ignoring discord_slash`);
1518
+ continue;
1519
+ }
1520
+ if (seen.has(manifest.id))
1521
+ continue;
1522
+ const definition = manifestToSlashDefinition(manifest);
1523
+ if (definition) {
1524
+ fromSkills.push(definition);
1525
+ seen.add(manifest.id);
1526
+ }
1527
+ }
1528
+ return [...builtin, ...fromSkills];
1529
+ }
1530
+ const BUILTIN_SLASH_COMMAND_NAMES = new Set(BUILTIN_SLASH_COMMAND_DEFINITIONS.map((d) => d.name));
1531
+ async function registerSlashCommands(state) {
1532
+ if (!state.botUserId)
1533
+ return;
1534
+ const url = `${REST_BASE}/applications/${state.botUserId}/commands`;
1535
+ const definitions = await composeSlashCommandDefinitions(state.config);
1536
+ for (const definition of definitions) {
1537
+ try {
1538
+ const response = await fetch(url, {
1539
+ method: "POST",
1540
+ headers: {
1541
+ authorization: `Bot ${state.token}`,
1542
+ "content-type": "application/json",
1543
+ },
1544
+ body: JSON.stringify(definition),
1545
+ });
1546
+ if (!response.ok) {
1547
+ const detail = await response.text().catch(() => "");
1548
+ console.error(`agent-sin discord: register /${definition.name} failed: HTTP ${response.status} ${detail.slice(0, 300)}`);
1549
+ await appendEventLog(state.config, {
1550
+ level: "warn",
1551
+ source: "discord",
1552
+ event: "slash_register_failed",
1553
+ message: `HTTP ${response.status}: ${detail.slice(0, 200)}`,
1554
+ details: { command: definition.name },
1555
+ });
1556
+ continue;
1557
+ }
1558
+ await appendEventLog(state.config, {
1559
+ level: "info",
1560
+ source: "discord",
1561
+ event: "slash_registered",
1562
+ details: { command: definition.name },
1563
+ });
1564
+ console.log(`agent-sin discord: /${definition.name} slash command registered (global)`);
1565
+ }
1566
+ catch (error) {
1567
+ const message = error instanceof Error ? error.message : String(error);
1568
+ console.error(`agent-sin discord: register /${definition.name} error: ${message}`);
1569
+ await appendEventLog(state.config, {
1570
+ level: "warn",
1571
+ source: "discord",
1572
+ event: "slash_register_error",
1573
+ message,
1574
+ details: { command: definition.name },
1575
+ });
1576
+ }
1577
+ }
1578
+ }
1579
+ async function handleInteraction(state, interaction) {
1580
+ if (!interaction)
1581
+ return;
1582
+ const name = interaction.data?.name;
1583
+ if (name === "todo") {
1584
+ if (interaction.type === 4) {
1585
+ await handleTodoAutocomplete(state, interaction);
1586
+ return;
1587
+ }
1588
+ if (interaction.type === 2) {
1589
+ await handleTodoInteraction(state, interaction);
1590
+ }
1591
+ return;
1592
+ }
1593
+ if (name === "model") {
1594
+ if (interaction.type === 4) {
1595
+ await handleModelAutocomplete(state, interaction);
1596
+ return;
1597
+ }
1598
+ if (interaction.type === 2) {
1599
+ await handleModelInteraction(state, interaction);
1600
+ }
1601
+ return;
1602
+ }
1603
+ if (!name || BUILTIN_SLASH_COMMAND_NAMES.has(name))
1604
+ return;
1605
+ if (interaction.type !== 2)
1606
+ return;
1607
+ try {
1608
+ await findSkillManifest(state.config.skills_dir, name);
1609
+ }
1610
+ catch {
1611
+ await respondInteraction(state, interaction, {
1612
+ content: l(`Unknown command: /${name}`, `未対応のコマンドです: /${name}`),
1613
+ ephemeral: true,
1614
+ });
1615
+ return;
1616
+ }
1617
+ await handleSkillSlashInteraction(state, interaction, name);
1618
+ }
1619
+ async function handleSkillSlashInteraction(state, interaction, skillId) {
1620
+ const userId = await ensureInteractionUserAllowed(state, interaction, skillId);
1621
+ if (!userId)
1622
+ return;
1623
+ const args = {};
1624
+ const localeParts = [];
1625
+ for (const opt of interaction.data?.options || []) {
1626
+ if (opt.value !== undefined) {
1627
+ args[opt.name] = opt.value;
1628
+ if (typeof opt.value === "string")
1629
+ localeParts.push(opt.value);
1630
+ }
1631
+ }
1632
+ await withLocale(inferLocaleFromText(localeParts.join(" ")), async () => {
1633
+ const ack = await deferInteraction(state, interaction);
1634
+ if (!ack)
1635
+ return;
1636
+ try {
1637
+ const response = await runSkill(state.config, skillId, args);
1638
+ const display = response.result.summary ||
1639
+ response.result.title ||
1640
+ l("(no response)", "(応答なし)");
1641
+ await editInteractionOriginal(state, interaction, display);
1642
+ await appendEventLog(state.config, {
1643
+ level: "info",
1644
+ source: "discord",
1645
+ event: "skill_slash_ran",
1646
+ message: response.result.title || undefined,
1647
+ details: {
1648
+ skill_id: skillId,
1649
+ status: response.result.status,
1650
+ kind: "interaction",
1651
+ },
1652
+ });
1653
+ }
1654
+ catch (error) {
1655
+ const detail = error instanceof SkillRunError
1656
+ ? error.originalMessage
1657
+ : error instanceof Error
1658
+ ? error.message
1659
+ : String(error);
1660
+ await editInteractionOriginal(state, interaction, l(`Error: ${detail}`, `エラー: ${detail}`));
1661
+ await appendEventLog(state.config, {
1662
+ level: "error",
1663
+ source: "discord",
1664
+ event: "skill_slash_failed",
1665
+ message: detail.slice(0, 200),
1666
+ details: { skill_id: skillId, kind: "interaction" },
1667
+ });
1668
+ }
1669
+ });
1670
+ }
1671
+ async function ensureInteractionUserAllowed(state, interaction, commandName) {
1672
+ const userId = interaction.member?.user?.id || interaction.user?.id;
1673
+ if (!userId || !state.allowedUserIds.has(userId)) {
1674
+ await appendEventLog(state.config, {
1675
+ level: "warn",
1676
+ source: "discord",
1677
+ event: "interaction_blocked_user",
1678
+ details: { user_id: userId || null, command: commandName },
1679
+ });
1680
+ await respondInteraction(state, interaction, {
1681
+ content: l("You are not allowed to use this command.", "このコマンドを使う権限がありません。"),
1682
+ ephemeral: true,
1683
+ });
1684
+ return null;
1685
+ }
1686
+ return userId;
1687
+ }
1688
+ async function handleTodoInteraction(state, interaction) {
1689
+ const userId = await ensureInteractionUserAllowed(state, interaction, "todo");
1690
+ if (!userId)
1691
+ return;
1692
+ const sub = interaction.data?.options?.[0];
1693
+ const subName = sub?.name || "";
1694
+ const optMap = new Map();
1695
+ for (const opt of sub?.options || []) {
1696
+ optMap.set(opt.name, opt.value);
1697
+ }
1698
+ await withLocale(inferLocaleFromText(extractInteractionLocaleHint(sub)), async () => {
1699
+ let skillId;
1700
+ let args;
1701
+ if (subName === "add") {
1702
+ const text = String(optMap.get("text") || "").trim();
1703
+ if (!text) {
1704
+ await respondInteraction(state, interaction, {
1705
+ content: l("ToDo text is required.", "ToDoの本文を指定してください。"),
1706
+ ephemeral: true,
1707
+ });
1708
+ return;
1709
+ }
1710
+ skillId = "todo-add";
1711
+ args = { text };
1712
+ const due = optMap.get("due");
1713
+ if (due)
1714
+ args.due = String(due);
1715
+ }
1716
+ else if (subName === "list") {
1717
+ skillId = "todo-list";
1718
+ args = { status: String(optMap.get("filter") || "open") };
1719
+ }
1720
+ else if (subName === "done" || subName === "delete") {
1721
+ const id = String(optMap.get("id") || "").trim();
1722
+ if (!id) {
1723
+ await respondInteraction(state, interaction, {
1724
+ content: l("ToDo ID is required.", "ToDoのIDを指定してください。"),
1725
+ ephemeral: true,
1726
+ });
1727
+ return;
1728
+ }
1729
+ skillId = subName === "done" ? "todo-done" : "todo-delete";
1730
+ args = { id };
1731
+ }
1732
+ else {
1733
+ await respondInteraction(state, interaction, {
1734
+ content: l(`Unknown subcommand: ${subName || "(none)"}.`, `未対応のサブコマンドです: ${subName || "(なし)"}。`),
1735
+ ephemeral: true,
1736
+ });
1737
+ return;
1738
+ }
1739
+ const ack = await deferInteraction(state, interaction);
1740
+ if (!ack)
1741
+ return;
1742
+ try {
1743
+ const response = await runSkill(state.config, skillId, args);
1744
+ const display = response.result.summary ||
1745
+ response.result.title ||
1746
+ l("(no response)", "(応答なし)");
1747
+ await editInteractionOriginal(state, interaction, display);
1748
+ await appendEventLog(state.config, {
1749
+ level: "info",
1750
+ source: "discord",
1751
+ event: "todo_slash_ran",
1752
+ message: response.result.title || undefined,
1753
+ details: {
1754
+ skill_id: skillId,
1755
+ status: response.result.status,
1756
+ kind: "interaction",
1757
+ },
1758
+ });
1759
+ }
1760
+ catch (error) {
1761
+ const detail = error instanceof SkillRunError
1762
+ ? error.originalMessage
1763
+ : error instanceof Error
1764
+ ? error.message
1765
+ : String(error);
1766
+ await editInteractionOriginal(state, interaction, l(`Error: ${detail}`, `エラー: ${detail}`));
1767
+ await appendEventLog(state.config, {
1768
+ level: "error",
1769
+ source: "discord",
1770
+ event: "todo_slash_failed",
1771
+ message: detail.slice(0, 200),
1772
+ details: { skill_id: skillId, kind: "interaction" },
1773
+ });
1774
+ }
1775
+ });
1776
+ }
1777
+ async function handleModelInteraction(state, interaction) {
1778
+ const userId = await ensureInteractionUserAllowed(state, interaction, "model");
1779
+ if (!userId)
1780
+ return;
1781
+ const optMap = new Map();
1782
+ for (const opt of interaction.data?.options || []) {
1783
+ optMap.set(opt.name, opt.value);
1784
+ }
1785
+ const id = String(optMap.get("id") || "").trim();
1786
+ const localeHint = id || "";
1787
+ await withLocale(inferLocaleFromText(localeHint), async () => {
1788
+ const skillId = id ? "model-set" : "model-list";
1789
+ const args = id ? { role: "chat", id } : {};
1790
+ const ack = await deferInteraction(state, interaction);
1791
+ if (!ack)
1792
+ return;
1793
+ try {
1794
+ const response = await runSkill(state.config, skillId, args);
1795
+ let display;
1796
+ if (!id && response.result.status === "ok") {
1797
+ display = formatChatModelView(response.result.data);
1798
+ }
1799
+ else {
1800
+ display =
1801
+ response.result.summary ||
1802
+ response.result.title ||
1803
+ l("(no response)", "(応答なし)");
1804
+ }
1805
+ await editInteractionOriginal(state, interaction, display);
1806
+ await appendEventLog(state.config, {
1807
+ level: "info",
1808
+ source: "discord",
1809
+ event: "model_slash_ran",
1810
+ message: response.result.title || undefined,
1811
+ details: {
1812
+ skill_id: skillId,
1813
+ status: response.result.status,
1814
+ kind: "interaction",
1815
+ },
1816
+ });
1817
+ }
1818
+ catch (error) {
1819
+ const detail = error instanceof SkillRunError
1820
+ ? error.originalMessage
1821
+ : error instanceof Error
1822
+ ? error.message
1823
+ : String(error);
1824
+ await editInteractionOriginal(state, interaction, l(`Error: ${detail}`, `エラー: ${detail}`));
1825
+ await appendEventLog(state.config, {
1826
+ level: "error",
1827
+ source: "discord",
1828
+ event: "model_slash_failed",
1829
+ message: detail.slice(0, 200),
1830
+ details: { skill_id: skillId, kind: "interaction" },
1831
+ });
1832
+ }
1833
+ });
1834
+ }
1835
+ async function handleModelAutocomplete(state, interaction) {
1836
+ const userId = interaction.member?.user?.id || interaction.user?.id;
1837
+ if (!userId || !state.allowedUserIds.has(userId)) {
1838
+ await respondAutocomplete(state, interaction, []);
1839
+ return;
1840
+ }
1841
+ let focusedValue = "";
1842
+ for (const opt of interaction.data?.options || []) {
1843
+ if (opt.focused) {
1844
+ focusedValue = typeof opt.value === "string" ? opt.value : "";
1845
+ break;
1846
+ }
1847
+ }
1848
+ let entries = [];
1849
+ let chatId = "";
1850
+ try {
1851
+ const models = await loadModels(state.config.workspace);
1852
+ chatId = state.config.chat_model_id || "";
1853
+ entries = Object.entries(models.models);
1854
+ }
1855
+ catch {
1856
+ await respondAutocomplete(state, interaction, []);
1857
+ return;
1858
+ }
1859
+ const query = focusedValue.trim().toLowerCase();
1860
+ const filtered = query
1861
+ ? entries.filter(([id, entry]) => {
1862
+ const haystack = [
1863
+ id,
1864
+ entry.provider || "",
1865
+ entry.model || "",
1866
+ entry.type || "",
1867
+ ]
1868
+ .join(" ")
1869
+ .toLowerCase();
1870
+ return haystack.includes(query);
1871
+ })
1872
+ : entries;
1873
+ filtered.sort(([aId], [bId]) => {
1874
+ if (aId === chatId && bId !== chatId)
1875
+ return -1;
1876
+ if (bId === chatId && aId !== chatId)
1877
+ return 1;
1878
+ return aId.localeCompare(bId);
1879
+ });
1880
+ const choices = filtered.slice(0, 25).map(([id, entry]) => {
1881
+ const provider = entry.provider || entry.type || "-";
1882
+ const modelName = entry.model || "";
1883
+ const effort = entry.effort ? ` effort=${entry.effort}` : "";
1884
+ const current = id === chatId ? " ← chat" : "";
1885
+ const detail = [provider, modelName].filter(Boolean).join(" ");
1886
+ const label = `${id} (${detail}${effort})${current}`;
1887
+ return {
1888
+ name: label.length > 100 ? label.slice(0, 97) + "…" : label,
1889
+ value: id,
1890
+ };
1891
+ });
1892
+ await respondAutocomplete(state, interaction, choices);
1893
+ }
1894
+ function extractInteractionLocaleHint(option) {
1895
+ if (!option)
1896
+ return "";
1897
+ const parts = [];
1898
+ for (const child of option.options || []) {
1899
+ if (typeof child.value === "string")
1900
+ parts.push(child.value);
1901
+ }
1902
+ return parts.join(" ");
1903
+ }
1904
+ async function loadTodoItems(state) {
1905
+ try {
1906
+ const manifest = await findSkillManifest(state.config.skills_dir, "todo-list");
1907
+ const memory = await loadSkillMemory(state.config, manifest);
1908
+ const items = memory.items;
1909
+ if (!Array.isArray(items))
1910
+ return [];
1911
+ return items.filter((item) => item !== null && typeof item === "object");
1912
+ }
1913
+ catch {
1914
+ return [];
1915
+ }
1916
+ }
1917
+ async function handleTodoAutocomplete(state, interaction) {
1918
+ const userId = interaction.member?.user?.id || interaction.user?.id;
1919
+ if (!userId || !state.allowedUserIds.has(userId)) {
1920
+ await respondAutocomplete(state, interaction, []);
1921
+ return;
1922
+ }
1923
+ const sub = interaction.data?.options?.[0];
1924
+ const subName = sub?.name || "";
1925
+ if (subName !== "done" && subName !== "delete") {
1926
+ await respondAutocomplete(state, interaction, []);
1927
+ return;
1928
+ }
1929
+ let focusedValue = "";
1930
+ for (const opt of sub?.options || []) {
1931
+ if (opt.focused) {
1932
+ focusedValue = typeof opt.value === "string" ? opt.value : "";
1933
+ break;
1934
+ }
1935
+ }
1936
+ const items = await loadTodoItems(state);
1937
+ let candidates = subName === "done"
1938
+ ? items.filter((item) => item.status === "open")
1939
+ : items.slice();
1940
+ candidates.sort((a, b) => {
1941
+ const aOpen = a.status === "open" ? 0 : 1;
1942
+ const bOpen = b.status === "open" ? 0 : 1;
1943
+ if (aOpen !== bOpen)
1944
+ return aOpen - bOpen;
1945
+ const aTime = a.completed_at || a.created_at || "";
1946
+ const bTime = b.completed_at || b.created_at || "";
1947
+ return bTime.localeCompare(aTime);
1948
+ });
1949
+ const query = focusedValue.trim().toLowerCase();
1950
+ if (query) {
1951
+ candidates = candidates.filter((item) => {
1952
+ const id = (item.id || "").toLowerCase();
1953
+ const text = (item.text || "").toLowerCase();
1954
+ return id.includes(query) || text.includes(query);
1955
+ });
1956
+ }
1957
+ const choices = candidates.slice(0, 25).map((item) => {
1958
+ const mark = item.status === "done" ? "✔" : "・";
1959
+ const text = (item.text || "").trim() || (item.id || "");
1960
+ const idTag = item.id ? ` [${item.id}]` : "";
1961
+ const label = `${mark} ${text}${idTag}`;
1962
+ return {
1963
+ name: label.length > 100 ? label.slice(0, 97) + "…" : label,
1964
+ value: item.id || "",
1965
+ };
1966
+ }).filter((choice) => choice.value);
1967
+ await respondAutocomplete(state, interaction, choices);
1968
+ }
1969
+ async function respondAutocomplete(state, interaction, choices) {
1970
+ const url = `${REST_BASE}/interactions/${interaction.id}/${interaction.token}/callback`;
1971
+ try {
1972
+ const response = await fetch(url, {
1973
+ method: "POST",
1974
+ headers: { "content-type": "application/json" },
1975
+ body: JSON.stringify({ type: 8, data: { choices } }),
1976
+ });
1977
+ if (!response.ok) {
1978
+ const detail = await response.text().catch(() => "");
1979
+ console.error(`agent-sin discord: autocomplete respond failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
1980
+ }
1981
+ }
1982
+ catch (error) {
1983
+ const message = error instanceof Error ? error.message : String(error);
1984
+ console.error(`agent-sin discord: autocomplete respond error: ${message}`);
1985
+ }
1986
+ }
1987
+ async function respondInteraction(state, interaction, options) {
1988
+ const url = `${REST_BASE}/interactions/${interaction.id}/${interaction.token}/callback`;
1989
+ const body = {
1990
+ type: 4,
1991
+ data: {
1992
+ content: options.content.slice(0, MESSAGE_MAX),
1993
+ ...(options.ephemeral ? { flags: 64 } : {}),
1994
+ },
1995
+ };
1996
+ try {
1997
+ const response = await fetch(url, {
1998
+ method: "POST",
1999
+ headers: { "content-type": "application/json" },
2000
+ body: JSON.stringify(body),
2001
+ });
2002
+ if (!response.ok) {
2003
+ const detail = await response.text().catch(() => "");
2004
+ console.error(`agent-sin discord: interaction respond failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
2005
+ }
2006
+ }
2007
+ catch (error) {
2008
+ const message = error instanceof Error ? error.message : String(error);
2009
+ console.error(`agent-sin discord: interaction respond error: ${message}`);
2010
+ }
2011
+ }
2012
+ async function deferInteraction(state, interaction) {
2013
+ const url = `${REST_BASE}/interactions/${interaction.id}/${interaction.token}/callback`;
2014
+ try {
2015
+ const response = await fetch(url, {
2016
+ method: "POST",
2017
+ headers: { "content-type": "application/json" },
2018
+ body: JSON.stringify({ type: 5 }),
2019
+ });
2020
+ if (!response.ok) {
2021
+ const detail = await response.text().catch(() => "");
2022
+ console.error(`agent-sin discord: interaction defer failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
2023
+ return false;
2024
+ }
2025
+ return true;
2026
+ }
2027
+ catch (error) {
2028
+ const message = error instanceof Error ? error.message : String(error);
2029
+ console.error(`agent-sin discord: interaction defer error: ${message}`);
2030
+ return false;
2031
+ }
2032
+ }
2033
+ async function editInteractionOriginal(state, interaction, content) {
2034
+ const chunks = chunkMessage(content);
2035
+ const first = chunks[0] || l("(no response)", "(応答なし)");
2036
+ const editUrl = `${REST_BASE}/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`;
2037
+ try {
2038
+ const response = await fetch(editUrl, {
2039
+ method: "PATCH",
2040
+ headers: { "content-type": "application/json" },
2041
+ body: JSON.stringify({ content: first }),
2042
+ });
2043
+ if (!response.ok) {
2044
+ const detail = await response.text().catch(() => "");
2045
+ console.error(`agent-sin discord: interaction edit failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
2046
+ }
2047
+ }
2048
+ catch (error) {
2049
+ const message = error instanceof Error ? error.message : String(error);
2050
+ console.error(`agent-sin discord: interaction edit error: ${message}`);
2051
+ }
2052
+ if (chunks.length <= 1)
2053
+ return;
2054
+ const followupUrl = `${REST_BASE}/webhooks/${interaction.application_id}/${interaction.token}`;
2055
+ for (let i = 1; i < chunks.length; i += 1) {
2056
+ try {
2057
+ const response = await fetch(followupUrl, {
2058
+ method: "POST",
2059
+ headers: { "content-type": "application/json" },
2060
+ body: JSON.stringify({ content: chunks[i] }),
2061
+ });
2062
+ if (!response.ok) {
2063
+ const detail = await response.text().catch(() => "");
2064
+ console.error(`agent-sin discord: interaction followup failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
2065
+ }
2066
+ }
2067
+ catch (error) {
2068
+ const message = error instanceof Error ? error.message : String(error);
2069
+ console.error(`agent-sin discord: interaction followup error: ${message}`);
2070
+ }
2071
+ }
2072
+ }
2073
+ function handleProgressCommand(state, channelId, text) {
2074
+ if (text !== "!progress" && !text.startsWith("!progress ")) {
2075
+ return null;
2076
+ }
2077
+ const mode = text.trim().split(/\s+/)[1]?.toLowerCase() || "status";
2078
+ const current = state.intentRuntimes.get(channelId);
2079
+ if (["detail", "detailed", "verbose", "on"].includes(mode)) {
2080
+ const runtime = current || createIntentRuntime(true);
2081
+ runtime.progress_detail = true;
2082
+ state.intentRuntimes.set(channelId, runtime);
2083
+ void saveIntentRuntimes(state);
2084
+ return [l("Progress details are enabled for this thread. Use `!progress quiet` to switch back.", "このスレッドの進捗通知を詳細表示にしました。`!progress quiet` で戻せます。")];
2085
+ }
2086
+ if (["quiet", "summary", "off"].includes(mode)) {
2087
+ const runtime = current || createIntentRuntime(true);
2088
+ runtime.progress_detail = false;
2089
+ if (isEmptyIntentRuntime(runtime)) {
2090
+ state.intentRuntimes.delete(channelId);
2091
+ }
2092
+ else {
2093
+ state.intentRuntimes.set(channelId, runtime);
2094
+ }
2095
+ void saveIntentRuntimes(state);
2096
+ return [l("Progress is now quiet for this thread. Internal logs will not be sent to Discord.", "このスレッドの進捗通知を静音表示にしました。内部ログはDiscordに流しません。")];
2097
+ }
2098
+ if (mode === "status") {
2099
+ return [
2100
+ current?.progress_detail
2101
+ ? l("Progress details are enabled for this thread.", "このスレッドの進捗通知は詳細表示です。")
2102
+ : l("Progress is quiet for this thread.", "このスレッドの進捗通知は静音表示です。"),
2103
+ ];
2104
+ }
2105
+ return [l("Usage: !progress status | quiet | detail", "使い方: !progress status | quiet | detail")];
2106
+ }
2107
+ function helpText() {
2108
+ return lLines([
2109
+ "Welcome to the Agent-Sin Discord bot.",
2110
+ "It responds in DMs, mentions, and bot-created threads. Registered skills are called automatically when useful.",
2111
+ "Mention the bot in configured channels to create a new thread. Mentions are not needed inside that thread.",
2112
+ "",
2113
+ "Status reactions:",
2114
+ ` ${STATUS_EMOJI.received} received ${STATUS_EMOJI.thinking} thinking ${STATUS_EMOJI.tool} running skill ${STATUS_EMOJI.done} done ${STATUS_EMOJI.error} error`,
2115
+ "",
2116
+ "Mode display:",
2117
+ " In build/edit mode, replies start with `\u{1F527} build · <id>` / `✏\u{FE0F} edit · <id>`.",
2118
+ " Send `!back`, `cancel`, or `stop` to leave build/edit mode.",
2119
+ "",
2120
+ "Quick commands:",
2121
+ " /todo add <text> [--due ISO] · /todo list [open|done|all] · /todo done <id> · /todo delete <id>",
2122
+ ], [
2123
+ "Agent-Sin Discord bot へようこそ。",
2124
+ "DM、メンション、もしくは bot が作ったスレッドの中の発言に反応します。登録済みスキルも自動で呼び出されます。",
2125
+ "指定チャンネルで bot をメンションすると、そのメッセージから新しいスレッドを作って会話を続けます。スレッド内ではメンション不要です。",
2126
+ "",
2127
+ "状態の見方(あなたのメッセージへのリアクション):",
2128
+ ` ${STATUS_EMOJI.received} 受信 ${STATUS_EMOJI.thinking} 思考中 ${STATUS_EMOJI.tool} スキル実行中 ${STATUS_EMOJI.done} 完了 ${STATUS_EMOJI.error} エラー`,
2129
+ "",
2130
+ "モード表示:",
2131
+ " build / edit 中だけ、返信の先頭に `\u{1F527} build · <id>` / `✏\u{FE0F} edit · <id>` が出ます。",
2132
+ " build / edit から抜けるときは `!back`、または「中止」「やめる」と送ってください。",
2133
+ "",
2134
+ "ショートカット:",
2135
+ " /todo add <本文> [--due ISO] · /todo list [open|done|all] · /todo done <id> · /todo delete <id>",
2136
+ ]).join("\n");
2137
+ }
2138
+ async function sendChannelMessage(state, channelId, content) {
2139
+ const chunks = chunkMessage(content);
2140
+ for (const chunk of chunks) {
2141
+ try {
2142
+ const response = await fetch(`${REST_BASE}/channels/${channelId}/messages`, {
2143
+ method: "POST",
2144
+ headers: {
2145
+ authorization: `Bot ${state.token}`,
2146
+ "content-type": "application/json",
2147
+ },
2148
+ body: JSON.stringify({ content: chunk }),
2149
+ });
2150
+ if (!response.ok) {
2151
+ const detail = await response.text().catch(() => "");
2152
+ console.error(`agent-sin discord: send failed: HTTP ${response.status}: ${detail.slice(0, 200)}`);
2153
+ await appendEventLog(state.config, {
2154
+ level: "error",
2155
+ source: "discord",
2156
+ event: "send_failed",
2157
+ message: `HTTP ${response.status}`,
2158
+ details: { channel_id: channelId },
2159
+ });
2160
+ return;
2161
+ }
2162
+ }
2163
+ catch (error) {
2164
+ const message = error instanceof Error ? error.message : String(error);
2165
+ console.error(`agent-sin discord: send error: ${message}`);
2166
+ await appendEventLog(state.config, {
2167
+ level: "error",
2168
+ source: "discord",
2169
+ event: "send_error",
2170
+ message,
2171
+ details: { channel_id: channelId },
2172
+ });
2173
+ return;
2174
+ }
2175
+ }
2176
+ }
2177
+ async function sendTypingIndicator(state, channelId) {
2178
+ try {
2179
+ await fetch(`${REST_BASE}/channels/${channelId}/typing`, {
2180
+ method: "POST",
2181
+ headers: { authorization: `Bot ${state.token}` },
2182
+ });
2183
+ }
2184
+ catch {
2185
+ // typing indicator is a hint; ignore failures
2186
+ }
2187
+ }
2188
+ async function createThreadFromMessage(state, channelId, messageId, name) {
2189
+ const url = `${REST_BASE}/channels/${channelId}/messages/${messageId}/threads`;
2190
+ try {
2191
+ const response = await fetch(url, {
2192
+ method: "POST",
2193
+ headers: {
2194
+ authorization: `Bot ${state.token}`,
2195
+ "content-type": "application/json",
2196
+ },
2197
+ body: JSON.stringify({ name, auto_archive_duration: 1440 }),
2198
+ });
2199
+ if (!response.ok) {
2200
+ const detail = await response.text().catch(() => "");
2201
+ console.error(`agent-sin discord: createThread failed: HTTP ${response.status} ${detail.slice(0, 200)}`);
2202
+ await appendEventLog(state.config, {
2203
+ level: "error",
2204
+ source: "discord",
2205
+ event: "thread_create_failed",
2206
+ message: `HTTP ${response.status}`,
2207
+ details: { channel_id: channelId, message_id: messageId },
2208
+ });
2209
+ return null;
2210
+ }
2211
+ const channel = (await response.json());
2212
+ return channel;
2213
+ }
2214
+ catch (error) {
2215
+ const message = error instanceof Error ? error.message : String(error);
2216
+ console.error(`agent-sin discord: createThread error: ${message}`);
2217
+ return null;
2218
+ }
2219
+ }
2220
+ export function makeThreadName(text) {
2221
+ const condensed = (text || "").replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
2222
+ if (!condensed)
2223
+ return "agent-sin chat";
2224
+ // Discord thread name limit is 100 chars; keep some headroom for trailing ellipsis.
2225
+ if (condensed.length <= 95)
2226
+ return condensed;
2227
+ return `${condensed.slice(0, 95).trimEnd()}…`;
2228
+ }
2229
+ async function loadBotThreadIds(filePath) {
2230
+ try {
2231
+ const raw = await readFile(filePath, "utf8");
2232
+ const data = JSON.parse(raw);
2233
+ if (Array.isArray(data?.thread_ids)) {
2234
+ return new Set(data.thread_ids.filter((value) => typeof value === "string" && /^\d+$/.test(value)));
2235
+ }
2236
+ }
2237
+ catch {
2238
+ // missing or unreadable — start fresh
2239
+ }
2240
+ return new Set();
2241
+ }
2242
+ async function saveBotThreadIds(state) {
2243
+ try {
2244
+ await mkdir(path.dirname(state.threadsFile), { recursive: true });
2245
+ const payload = JSON.stringify({ thread_ids: [...state.botThreadIds].sort(), saved_at: new Date().toISOString() }, null, 2);
2246
+ await writeFile(state.threadsFile, payload, "utf8");
2247
+ }
2248
+ catch (error) {
2249
+ const message = error instanceof Error ? error.message : String(error);
2250
+ console.error(`agent-sin discord: failed to persist thread list: ${message}`);
2251
+ }
2252
+ }
2253
+ export async function loadIntentRuntimes(filePath) {
2254
+ return loadIntentRuntimeMap(filePath, "channels");
2255
+ }
2256
+ async function saveIntentRuntimes(state) {
2257
+ try {
2258
+ await saveIntentRuntimeMap(state.intentRuntimesFile, "channels", state.intentRuntimes);
2259
+ }
2260
+ catch (error) {
2261
+ const message = error instanceof Error ? error.message : String(error);
2262
+ console.error(`agent-sin discord: failed to persist intent runtimes: ${message}`);
2263
+ }
2264
+ }
2265
+ async function loadLastSeen(filePath) {
2266
+ try {
2267
+ const raw = await readFile(filePath, "utf8");
2268
+ const data = JSON.parse(raw);
2269
+ const lastSeenIds = new Map();
2270
+ if (data.channels && typeof data.channels === "object") {
2271
+ for (const [ch, id] of Object.entries(data.channels)) {
2272
+ if (/^\d+$/.test(ch) && typeof id === "string" && /^\d+$/.test(id)) {
2273
+ lastSeenIds.set(ch, id);
2274
+ }
2275
+ }
2276
+ }
2277
+ const dmChannelIds = new Set();
2278
+ if (Array.isArray(data.dm_channel_ids)) {
2279
+ for (const value of data.dm_channel_ids) {
2280
+ if (typeof value === "string" && /^\d+$/.test(value)) {
2281
+ dmChannelIds.add(value);
2282
+ }
2283
+ }
2284
+ }
2285
+ return { lastSeenIds, dmChannelIds };
2286
+ }
2287
+ catch {
2288
+ return { lastSeenIds: new Map(), dmChannelIds: new Set() };
2289
+ }
2290
+ }
2291
+ async function saveLastSeen(state) {
2292
+ await mkdir(path.dirname(state.lastSeenFile), { recursive: true });
2293
+ const channels = {};
2294
+ for (const [ch, id] of state.lastSeenIds) {
2295
+ channels[ch] = id;
2296
+ }
2297
+ const payload = JSON.stringify({
2298
+ channels,
2299
+ dm_channel_ids: [...state.dmChannelIds].sort(),
2300
+ saved_at: new Date().toISOString(),
2301
+ }, null, 2);
2302
+ await writeFile(state.lastSeenFile, payload, "utf8");
2303
+ }
2304
+ async function rememberLastSeen(state, message, ctx) {
2305
+ if (!message.id || !message.channel_id)
2306
+ return;
2307
+ const tracked = ctx.isListenChannel || ctx.isBotThread || ctx.isDirect;
2308
+ if (!tracked)
2309
+ return;
2310
+ const previous = state.lastSeenIds.get(message.channel_id);
2311
+ if (previous && compareSnowflake(previous, message.id) >= 0)
2312
+ return;
2313
+ state.lastSeenIds.set(message.channel_id, message.id);
2314
+ if (ctx.isDirect) {
2315
+ state.dmChannelIds.add(message.channel_id);
2316
+ }
2317
+ await saveLastSeen(state);
2318
+ }
2319
+ function compareSnowflake(a, b) {
2320
+ if (a === b)
2321
+ return 0;
2322
+ if (a.length !== b.length)
2323
+ return a.length - b.length;
2324
+ return a < b ? -1 : 1;
2325
+ }
2326
+ function snowflakeToDate(id) {
2327
+ try {
2328
+ return Number((BigInt(id) >> 22n) + DISCORD_EPOCH);
2329
+ }
2330
+ catch {
2331
+ return 0;
2332
+ }
2333
+ }
2334
+ async function catchUpMissedMessages(state) {
2335
+ const channels = new Set([
2336
+ ...state.listenChannelIds,
2337
+ ...state.botThreadIds,
2338
+ ...state.dmChannelIds,
2339
+ ]);
2340
+ if (channels.size === 0)
2341
+ return;
2342
+ const cutoff = Date.now() - CATCHUP_MAX_AGE_MS;
2343
+ let total = 0;
2344
+ for (const channelId of channels) {
2345
+ try {
2346
+ const after = state.lastSeenIds.get(channelId);
2347
+ const isDm = state.dmChannelIds.has(channelId);
2348
+ const fetched = await fetchMessagesAfter(state, channelId, after, isDm);
2349
+ const fresh = fetched.filter((message) => {
2350
+ const ts = snowflakeToDate(message.id);
2351
+ return ts === 0 || ts >= cutoff;
2352
+ });
2353
+ if (fresh.length === 0)
2354
+ continue;
2355
+ total += fresh.length;
2356
+ for (const message of fresh) {
2357
+ await handleMessage(state, message);
2358
+ }
2359
+ }
2360
+ catch (error) {
2361
+ const message = error instanceof Error ? error.message : String(error);
2362
+ console.error(`agent-sin discord: catch-up channel ${channelId} failed: ${message}`);
2363
+ }
2364
+ }
2365
+ if (total > 0) {
2366
+ await appendEventLog(state.config, {
2367
+ level: "info",
2368
+ source: "discord",
2369
+ event: "catchup_replayed",
2370
+ details: { count: total, channels: channels.size },
2371
+ });
2372
+ }
2373
+ }
2374
+ async function fetchMessagesAfter(state, channelId, afterId, isDm) {
2375
+ const params = new URLSearchParams();
2376
+ params.set("limit", String(CATCHUP_MAX_PER_CHANNEL));
2377
+ if (afterId) {
2378
+ params.set("after", afterId);
2379
+ }
2380
+ const url = `${REST_BASE}/channels/${channelId}/messages?${params.toString()}`;
2381
+ const response = await fetch(url, {
2382
+ headers: { authorization: `Bot ${state.token}` },
2383
+ });
2384
+ if (!response.ok) {
2385
+ if (response.status === 403 || response.status === 404) {
2386
+ return [];
2387
+ }
2388
+ const detail = await response.text().catch(() => "");
2389
+ throw new Error(`HTTP ${response.status} ${detail.slice(0, 200)}`);
2390
+ }
2391
+ const messages = (await response.json());
2392
+ if (!Array.isArray(messages))
2393
+ return [];
2394
+ // API returns newest first; replay oldest first.
2395
+ // /channels/{id}/messages does not include guild_id, so backfill it for guild
2396
+ // channels/threads so classifyMessage doesn't treat them as DMs.
2397
+ return messages
2398
+ .filter((message) => message && message.id && message.author && !message.author.bot)
2399
+ .map((message) => (isDm ? message : { ...message, guild_id: message.guild_id || "0", channel_id: channelId }))
2400
+ .sort((a, b) => compareSnowflake(a.id, b.id));
2401
+ }
2402
+ async function fetchChannelHistoryBefore(state, channelId, beforeId, limit) {
2403
+ const params = new URLSearchParams();
2404
+ params.set("limit", String(Math.max(1, Math.min(100, limit))));
2405
+ params.set("before", beforeId);
2406
+ const url = `${REST_BASE}/channels/${channelId}/messages?${params.toString()}`;
2407
+ const response = await fetch(url, {
2408
+ headers: { authorization: `Bot ${state.token}` },
2409
+ });
2410
+ if (!response.ok) {
2411
+ const detail = await response.text().catch(() => "");
2412
+ throw new Error(`HTTP ${response.status} ${detail.slice(0, 200)}`);
2413
+ }
2414
+ const messages = (await response.json());
2415
+ if (!Array.isArray(messages))
2416
+ return [];
2417
+ // API returns newest first → return oldest first.
2418
+ return messages
2419
+ .filter((message) => message && message.id && message.author)
2420
+ .sort((a, b) => compareSnowflake(a.id, b.id));
2421
+ }
2422
+ function sleep(ms) {
2423
+ return new Promise((resolve) => setTimeout(resolve, ms));
2424
+ }