diff --git a/lib/cli.js b/lib/cli.js index e14aaf1..7ab95ff 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,15 +1,36 @@ var readline = require("readline"); -function Cli(handler) { +function Cli(handler, opts) { this.handler = handler; + this.opts = opts || {}; } Cli.prototype.run = function() { var self = this; - var rli = readline.createInterface(process.stdin, process.stdout); + var rliOpts = { input: process.stdin, output: process.stdout }; + + // # 入力時にチャンネル名候補を Tab 補完する + if (typeof self.opts.completer === "function") { + rliOpts.completer = self.opts.completer; + } + + var rli = readline.createInterface(rliOpts); rli.setPrompt("> "); rli.on("line", function(line) { + var trimmed = line.trim(); + + // "#channel" で始まる入力は該当チャンネルの直近ログ表示にルーティングする + if (trimmed.charAt(0) === "#" && typeof self.handler.channelRecent === "function") { + self.handler.channelRecent.call(rli, trimmed, function(err) { + if (err) { + console.error("エラー発生: " + err.message); + } + rli.prompt(); + }); + return; + } + var args = line.split(/\s+/), cmd = args.shift(); if (self.handler[cmd]) { diff --git a/lib/core.js b/lib/core.js index 721fe86..4c39e61 100644 --- a/lib/core.js +++ b/lib/core.js @@ -9,10 +9,12 @@ let cli = require("./cli.js"); const path = require("path"); const fs = require("fs"); const exec = require("child_process").exec; -const { initSqliteDb, logMessageSqlite, getLastSlackTsPerChannel, updateAppHeartbeat, getLastAppHeartbeat } = require("./sqlite-logger"); +const { initSqliteDb, logMessageSqlite, getLastSlackTsPerChannel, updateAppHeartbeat, getLastAppHeartbeat, getInjectedChannelLabels } = require("./sqlite-logger"); const { startMcpServer } = require("./mcp-server"); let sqliteDb = null; +// 起動時に SQLite から読み込む、MCP等の注入チャンネルラベル一覧(Tab補完候補用) +let injectedChannelLabels = []; let getLogger = (filePath) => { return winston.createLogger({ @@ -111,8 +113,10 @@ core.display = (data, options) => { // Claude(AI)など、Slack外のソースから注入されたメッセージ。 // data.channel / data.user は Slack ID ではなく任意のラベル文字列なので // util.users / resolveChannelLabelDisplay による ID 解決はバイパスする。 + // [MCP] マーカーを付けて Slack 由来のメッセージと区別する(ログにも残る)。 + let channelLabel = typeof data.channel == "string" ? data.channel : "-"; name = chalk.cyan(typeof data.user == "string" ? data.user : "-"); - channel = chalk.cyan(typeof data.channel == "string" ? data.channel : "-"); + channel = chalk.cyan("[MCP] " + channelLabel); } else { if (util.users[data.user]) { name = chalk[util.users[data.user].color](util.users[data.user].name); @@ -236,6 +240,13 @@ core.start = async (commander) => { } updateAppHeartbeat(sqliteDb); setInterval(() => updateAppHeartbeat(sqliteDb), 60 * 1000); + + // 過去セッションで注入された MCP 等のチャンネルも Tab 補完候補に含める + try { + injectedChannelLabels = getInjectedChannelLabels(sqliteDb); + } catch (e) { + injectedChannelLabels = []; + } } // Slack 以外のソース(Claude等のAIエージェント)から任意のメッセージを @@ -793,9 +804,126 @@ core.start = async (commander) => { rtm.start(); }; +// "#" 入力時のチャンネル名 Tab 補完 +let channelCompleter = (line) => { + // "#" で始まる入力のみ補完対象 + if (line.charAt(0) !== "#") { + return [[], line]; + } + + let prefix = line.slice(1).toLowerCase(); + let names = []; + + Object.keys(util.channels).forEach((id) => { + let ch = util.channels[id]; + if (ch.is_im) { + return; // DM は対象外 + } + let name = resolveChannelName(id); + if (name) { + names.push("#" + name); + } + }); + + // MCP(post_to_stream)由来など、Slack外から注入されたチャンネルラベルも候補に含める。 + // これらは util.channels には存在せず、バッファキー("#claude"等)としてのみ現れる。 + Object.keys(util.buffer || {}).forEach((key) => { + if (key.charAt(0) === "#") { + names.push(key); + } + }); + + // 今セッションでまだ受信していない、過去の注入チャンネルも SQLite から候補に含める + injectedChannelLabels.forEach((label) => { + if (label && label.charAt(0) === "#") { + names.push(label); + } + }); + + names = Array.from(new Set(names)).sort(); + + let hits = names.filter((n) => n.toLowerCase().indexOf("#" + prefix) === 0); + + return [hits.length ? hits : names, line]; +}; + +// 指定チャンネルの直近ログを再表示する。SQLite を優先し、無効時はメモリバッファにフォールバック。 +core.showRecent = (labelKey, channelId, limit) => { + let label = { + lines: [`--- Show recent (${labelKey}) ---`], + time: moment() + }; + core.display(label); + + let shown = false; + + // Slackチャンネルは解決済みID、MCP等の注入チャンネルは labelKey("#claude") が + // そのまま channel_id として記録されているため、未解決時は labelKey で引く。 + let queryChannelId = channelId || labelKey; + + if (sqliteDb && queryChannelId) { + try { + let rows = sqliteDb.prepare(` + SELECT logged_at, channel, user, message + FROM messages + WHERE channel_id = ? + ORDER BY id DESC + LIMIT ? + `).all(queryChannelId, limit); + + rows.reverse().forEach((row) => { + core.display({ + lines: [row.message || ""], + time: moment(row.logged_at, "YYYY-MM-DD HH:mm:ss"), + channel: row.channel, + user: row.user + }); + }); + + shown = rows.length > 0; + } catch (e) { + // SQLite 取得に失敗した場合はメモリバッファへフォールバック + } + } + + if (!shown && util.buffer[labelKey]) { + util.buffer[labelKey].forEach((data) => { + core.display(data); + }); + shown = true; + } + + label.lines = shown ? ["--- finish ---"] : ["(ログがありません)"]; + core.display(label); +}; + // Declare cli-handler function handler() {} +handler.prototype.channelRecent = function(line, fn) { + // line 例: "#general" または "#general 50" (件数指定) + let parts = line.split(/\s+/); + let labelKey = parts[0]; // "#general" + let limit = parseInt(parts[1], 10); + if (isNaN(limit) || limit <= 0) { + limit = 20; + } + + let name = labelKey.slice(1); // "general" + let channelId = null; + + if (name.length > 0) { + Object.keys(util.channels).forEach((id) => { + if (channelId === null && resolveChannelName(id) === name) { + channelId = id; + } + }); + } + + core.showRecent(labelKey, channelId, limit); + fn(null, line); +}; + handler.prototype.recent = function(args, fn) { if (util.buffer[args[0]]) { let messageBuffer = util.buffer[args[0]]; @@ -828,6 +956,6 @@ handler.prototype.exit = function(args, fn) { this.emit("close"); }; -(new cli(new handler())).run(); +(new cli(new handler(), { completer: channelCompleter })).run(); module.exports = core; diff --git a/lib/sqlite-logger.js b/lib/sqlite-logger.js index 66ce727..0509368 100644 --- a/lib/sqlite-logger.js +++ b/lib/sqlite-logger.js @@ -80,4 +80,16 @@ const getLastSlackTsPerChannel = (db) => { return map; }; -module.exports = { initSqliteDb, logMessageSqlite, getLastSlackTsPerChannel, updateAppHeartbeat, getLastAppHeartbeat }; +// MCP(post_to_stream)等、Slack外から注入されたチャンネルは channel_id が +// "#label" 形式で記録される。それらのラベル一覧を取得する(Tab補完候補用)。 +const getInjectedChannelLabels = (db) => { + const rows = db.prepare(` + SELECT DISTINCT channel_id + FROM messages + WHERE channel_id LIKE '#%' + ORDER BY channel_id + `).all(); + return rows.map((row) => row.channel_id); +}; + +module.exports = { initSqliteDb, logMessageSqlite, getLastSlackTsPerChannel, updateAppHeartbeat, getLastAppHeartbeat, getInjectedChannelLabels }; diff --git a/test/sqlite_logger_test.js b/test/sqlite_logger_test.js index bd4bae1..254b16a 100644 --- a/test/sqlite_logger_test.js +++ b/test/sqlite_logger_test.js @@ -2,7 +2,7 @@ let assert = require("chai").assert; let fs = require("fs"); let os = require("os"); let path = require("path"); -let { initSqliteDb, logMessageSqlite } = require("../lib/sqlite-logger"); +let { initSqliteDb, logMessageSqlite, getInjectedChannelLabels } = require("../lib/sqlite-logger"); describe("SQLiteロガーのテスト", () => { let db; @@ -182,4 +182,33 @@ describe("SQLiteロガーのテスト", () => { assert.equal(rows.length, 0, "0件ヒットする"); }); }); + + describe("getInjectedChannelLabels", () => { + it("注入チャンネル(channel_idが#始まり)のラベルが取得できること", () => { + logMessageSqlite(db, "2026-04-05 12:00:00", "#claude", "claude", "progress", "#claude", "claude", null, null); + logMessageSqlite(db, "2026-04-05 12:00:01", "#agent", "claude", "done", "#agent", "claude", null, null); + const labels = getInjectedChannelLabels(db); + assert.deepEqual(labels, ["#agent", "#claude"], "#始まりのchannel_idがソートされて返る"); + }); + + it("Slackチャンネル(channel_idがID形式)は含まれないこと", () => { + logMessageSqlite(db, "2026-04-05 12:00:00", "#general", "alice", "Hello", "C0123", "U0001", "100.000", null); + logMessageSqlite(db, "2026-04-05 12:00:01", "#claude", "claude", "progress", "#claude", "claude", null, null); + const labels = getInjectedChannelLabels(db); + assert.deepEqual(labels, ["#claude"], "Slackチャンネルは除外される"); + }); + + it("同一ラベルは重複排除されること", () => { + logMessageSqlite(db, "2026-04-05 12:00:00", "#claude", "claude", "line1", "#claude", "claude", null, null); + logMessageSqlite(db, "2026-04-05 12:00:01", "#claude", "claude", "line2", "#claude", "claude", null, null); + const labels = getInjectedChannelLabels(db); + assert.deepEqual(labels, ["#claude"], "DISTINCTで1件になる"); + }); + + it("注入チャンネルがない場合は空配列が返ること", () => { + logMessageSqlite(db, "2026-04-05 12:00:00", "#general", "alice", "Hello", "C0123", "U0001", "100.000", null); + const labels = getInjectedChannelLabels(db); + assert.deepEqual(labels, [], "空配列が返る"); + }); + }); });