Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions lib/cli.js
Original file line number Diff line number Diff line change
@@ -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]) {
Expand Down
134 changes: 131 additions & 3 deletions lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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エージェント)から任意のメッセージを
Expand Down Expand Up @@ -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]];
Expand Down Expand Up @@ -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;
14 changes: 13 additions & 1 deletion lib/sqlite-logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
31 changes: 30 additions & 1 deletion test/sqlite_logger_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, [], "空配列が返る");
});
});
});
Loading