@mfukushim/map-traveler-mcp
Version:
Virtual traveler library for MCP
386 lines (385 loc) • 18.3 kB
JavaScript
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */
import { Effect, Layer, Option } from "effect";
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { anniversary, avatar_model, avatar_sns, env_kv, run_status, runAvatar, sns_posts } from "./db/schema.js";
import { and, desc, eq, inArray, or } from "drizzle-orm";
import dayjs from "dayjs";
import 'dotenv/config';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import * as path from "node:path";
import { logSync, McpLogService, McpLogServiceLive } from "./McpLogService.js";
import { practiceData } from "./RunnerService.js";
import { defaultBaseCharPrompt } from "./ImageService.js";
import * as fs from "node:fs";
import { bs_handle, bs_id, bs_pass, comfy_params, comfy_url, comfy_workflow_i2i, comfy_workflow_t2i, filter_tools, fixed_model_prompt, GoogleMapApi_key, mapApi_url, moveMode, no_sns_post, pixAi_key, rembg_path, rembgPath, remBgUrl, sd_key, ServerLog, sqlite_path } from "./EnvUtils.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const __pwd = __dirname.endsWith('src') ? path.join(__dirname, '..') : path.join(__dirname, '../..');
function expandPath(envPath) {
return envPath.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, name) => process.env[name] || match)
.replace(/%([a-zA-Z_][a-zA-Z0-9_]*)%/g, (match, name) => process.env[name] || match);
}
export function isValidFilePath(filePath) {
try {
const normalizedPath = path.normalize(filePath);
const invalidChars = /[<>"|?*\x00-\x1F]/g;
if (invalidChars.test(normalizedPath)) {
return false;
}
return normalizedPath.length <= 260;
}
catch (error) {
return false;
}
}
export const dbPath = sqlite_path && isValidFilePath(expandPath(sqlite_path)) ?
'file:' + path.normalize(expandPath(sqlite_path)).replaceAll('\\', '/') : ':memory:';
const db = drizzle(dbPath);
const MapEndpoint = ['directions', 'places', 'timezone', 'svMeta', 'streetView', 'nearby'];
export const env = {
travelerExist: true,
dbMode: 'memory',
dbFileExist: false,
isPractice: false,
anyImageAiExist: false,
anySnsExist: false,
personMode: 'third',
fixedModelPrompt: false,
promptChanged: false,
noSnsPost: false,
moveMode: 'realtime',
remBgUrl: undefined,
rembgPath: undefined,
loggingMode: false,
filterTools: [],
progressToken: undefined,
mapApis: new Map(),
};
export const scriptTables = new Map();
export class DbService extends Effect.Service()("traveler/DbService", {
accessors: true,
effect: Effect.gen(function* () {
const stub = (qy) => Effect.tryPromise({
try: () => qy,
catch: error => {
return new Error(`${error}`);
}
});
function init() {
return Effect.gen(function* () {
yield* stub(migrate(db, { migrationsFolder: path.join(__pwd, 'drizzle') }));
const created = dayjs().toDate();
yield* stub(db.select().from(avatar_model)).pipe(Effect.tap(a => {
if (a.length === 0) {
return stub(db.insert(avatar_model).values({
id: 1,
comment: '',
baseCharPrompt: defaultBaseCharPrompt,
created: created,
modelName: '',
}).returning()).pipe(Effect.onError(cause => McpLogService.logError(`error init avatar:${cause}`)));
}
}), Effect.andThen(a => McpLogService.logTrace(`init avatar:${JSON.stringify(a)}`)));
yield* stub(db.select().from(runAvatar)).pipe(Effect.tap(a => {
if (a.length === 0) {
return stub(db.insert(runAvatar).values({
name: 'traveler',
modelId: 1,
created: created,
enable: true,
nextStayTime: dayjs('9999-12-31').toDate(),
lang: 'JP',
currentRoute: ''
}).returning()).pipe(Effect.onError(cause => McpLogService.logError(`error init avatar:${cause}`)));
}
}), Effect.andThen(a => McpLogService.logTrace(`init avatar:${JSON.stringify(a)}`)));
yield* stub(db.select().from(avatar_sns)).pipe(Effect.tap(a => {
if (a.length === 0 && bs_id && bs_pass && bs_handle) {
return stub(db.insert(avatar_sns).values({
assignAvatarId: 1,
snsType: "bs",
snsHandleName: bs_handle,
snsId: bs_id,
feedSeenAt: 0,
mentionSeenAt: 0,
created: created,
enable: true,
}).returning()).pipe(Effect.onError(cause => McpLogService.logError(`init bs sns:${cause}`)));
}
}), Effect.andThen(a => McpLogService.logTrace(`init0:${JSON.stringify(a)}`)));
yield* stub(db.select().from(run_status)).pipe(Effect.tap(a => {
if (a.length === 0) {
return saveRunStatus({
id: 1,
status: 'stop',
from: '',
to: '',
destination: null,
startLat: 0,
startLng: 0,
endLat: 0,
endLng: 0,
durationSec: 0,
distanceM: 0,
avatarId: 1,
tripId: 0,
tilEndEpoch: 0,
startTime: new Date(0),
endTime: new Date(0),
startCountry: '',
endCountry: '',
startTz: '',
endTz: '',
currentPathNo: -1,
currentStepNo: -1,
});
}
}));
});
}
function updateRoute(avatarId, routeJson) {
return stub(db.update(runAvatar).set({ currentRoute: routeJson }).where(eq(runAvatar.id, avatarId)));
}
function getEnv(key) {
return stub(db.select().from(env_kv).where(eq(env_kv.key, key))).pipe(Effect.andThen(takeOne), Effect.andThen(a => a.value));
}
function getEnvOption(key) {
return stub(db.select().from(env_kv).where(eq(env_kv.key, key))).pipe(Effect.map(a => a.length === 1 && a[0].value ? Option.some(a[0].value) : Option.none()), Effect.orElseSucceed(() => Option.none()));
}
function saveEnv(key, value) {
const now = dayjs().toDate();
const save = {
key,
value,
created: now,
updated: now
};
return stub(db.insert(env_kv).values(save).onConflictDoUpdate({
target: env_kv.key,
set: {
key,
value,
updated: now
}
}).returning()).pipe(Effect.andThen(a => a && Array.isArray(a) && a.length === 1 ? Effect.succeed(a[0]) : Effect.fail(new Error(`saveEnv fail:${run_status.id}`))));
}
function getAvatar(avatarId) {
return stub(db.select().from(runAvatar).where(eq(runAvatar.id, avatarId))).pipe(Effect.andThen(takeOne));
}
const takeOne = (list) => {
return list.length === 1 ? Effect.succeed(list[0]) : Effect.fail(new Error(`no element`));
};
function saveRunStatus(runStatus) {
const save = {
...runStatus,
startTime: runStatus.startTime || dayjs().unix()
};
return stub(db.insert(run_status).values(save).onConflictDoUpdate({
target: run_status.id,
set: save
}).returning()).pipe(Effect.andThen(a => a && Array.isArray(a) && a.length === 1 ? Effect.succeed(a[0]) : Effect.fail(new Error(`saveRunStatus fail:${run_status.id}`))));
}
function getAvatarModel(avatarId) {
return stub(db.select().from(avatar_model).where(eq(avatar_model.id, avatarId))).pipe(Effect.andThen(takeOne));
}
function getTodayAnniversary(now) {
return stub(db.select().from(anniversary).where(and(eq(anniversary.del, false), eq(anniversary.month, now.month() + 1), eq(anniversary.day, now.date()), or(eq(anniversary.year, now.year()), eq(anniversary.year, 0)))));
}
function getRecentRunStatus() {
return stub(db.select().from(run_status).orderBy(desc(run_status.id))).pipe(Effect.andThen(takeOne));
}
function getEnvs(keys) {
return stub(db.select().from(env_kv).where(inArray(env_kv.key, keys))).pipe(Effect.andThen(a => a.reduce((p, c) => {
p[c.key] = c.value;
return p;
}, {})));
}
function saveSnsPost(snsPostId, sendUserId, postType = 0, snsType = 'bs') {
return stub(db.insert(sns_posts).values({
snsPostId,
snsType,
postType,
sendUserId,
createTime: dayjs().toDate(),
del: false
}).returning()).pipe(Effect.andThen(a => a.length === 1 ? Effect.succeed(a[0].id) : Effect.fail(new Error('saveSnsPost'))));
}
function getAvatarSns(avatarId, snsType) {
return stub(db.select().from(avatar_sns).where(and(eq(avatar_sns.assignAvatarId, avatarId), eq(avatar_sns.snsType, snsType)))).pipe(Effect.andThen(takeOne));
}
function updateSnsFeedSeenAt(avatarId, snsType, timeEpoch) {
return stub(db.update(avatar_sns).set({ feedSeenAt: timeEpoch })
.where(and(eq(avatar_sns.assignAvatarId, avatarId), eq(avatar_sns.snsType, snsType))).returning()).pipe(Effect.andThen(a => a.length === 1 ? Effect.succeed(a[0]) : Effect.fail(new Error('updateSnsFeedSeenAt'))), Effect.tap(a => McpLogService.logTrace(`update feedSeenAt:${a.feedSeenAt}`)));
}
function updateSnsMentionSeenAt(avatarId, snsType, timeEpoch) {
return stub(db.update(avatar_sns).set({ mentionSeenAt: timeEpoch })
.where(and(eq(avatar_sns.assignAvatarId, avatarId), eq(avatar_sns.snsType, snsType))).returning()).pipe(Effect.andThen(a => a.length === 1 ? Effect.succeed(a[0]) : Effect.fail(new Error('updateSnsMentionSeenAt'))), Effect.tap(a => McpLogService.logTrace(`update mentionSeenAt:${a.mentionSeenAt}`)));
}
function updateBasePrompt(avatarId, prompt) {
return stub(db.update(avatar_model).set({
baseCharPrompt: prompt
}).where(eq(avatar_model.id, avatarId)).returning()).pipe(Effect.andThen(a => a.length === 1 ? Effect.succeed(a[0].baseCharPrompt) : Effect.fail(new Error('updateBasePrompt'))));
}
function practiceRunStatus(run = false) {
return Effect.gen(function* () {
const recent = yield* getRecentRunStatus().pipe(Effect.orElseSucceed(() => undefined));
const practice = practiceData[Math.floor(Math.random() * practiceData.length)];
const now = dayjs();
const status = {
id: 1,
status: run ? "running" : "stop",
startTime: now.toDate(),
destination: "",
from: recent?.to || 'Hakata,Fukuoka,Japan',
to: practice.address,
startLat: 0,
startLng: 0,
endLat: 0,
endLng: 0,
durationSec: 0,
distanceM: 0,
startTz: "Asia/Tokyo",
tilEndEpoch: run ? practice.durationSec + now.unix() : 0,
endTz: "Asia/Tokyo"
};
yield* saveRunStatus(status);
return status;
});
}
const getVersion = () => {
const packageJsonPath = path.resolve(__pwd, 'package.json');
return Effect.async((resume) => {
fs.readFile(packageJsonPath, { encoding: "utf8" }, (err, data) => {
if (err)
resume(Effect.fail(err));
else
resume(Effect.succeed(data));
});
}).pipe(Effect.andThen(a => JSON.parse(a).version));
};
const initSystemMode = () => {
return Effect.gen(function* () {
yield* init();
env.progressToken = undefined;
if (dbPath !== ':memory:') {
env.dbMode = "file";
env.dbFileExist = true;
}
yield* getEnv('travelerExist').pipe(Effect.andThen(a => {
env.travelerExist = a !== '';
}), Effect.orElseSucceed(() => {
env.travelerExist = true;
}));
const setting = yield* getEnvs(['personMode', 'promptChanged']);
env.isPractice = !(GoogleMapApi_key || mapApi_url);
if (sd_key || pixAi_key || comfy_url) {
env.anyImageAiExist = true;
}
if ((bs_id && bs_pass && bs_handle)) {
env.anySnsExist = true;
}
if (no_sns_post) {
env.noSnsPost = true;
}
if (ServerLog) {
env.loggingMode = true;
}
if (moveMode === 'skip') {
env.moveMode = "skip";
}
if (remBgUrl) {
env.remBgUrl = remBgUrl;
}
if (rembg_path || rembgPath) {
env.rembgPath = rembgPath || rembg_path;
}
env.personMode = !setting.personMode ? 'third' : setting.personMode;
yield* saveEnv('personMode', env.personMode);
if (filter_tools) {
env.filterTools = filter_tools.trim().split(',').map(value => value.trim());
}
logSync('comfy_params:', comfy_params);
env.promptChanged = !!setting.promptChanged;
yield* saveEnv('promptChanged', env.promptChanged ? '1' : '');
if (fixed_model_prompt) {
env.fixedModelPrompt = true;
}
if (env.isPractice) {
yield* practiceRunStatus();
}
if (mapApi_url) {
const s = mapApi_url.split(',');
s.forEach(value => {
const match = value.match(/(\w+)=([\w:\/.\-_]+)/);
if (match) {
const key = match[1];
const val = match[2];
if (MapEndpoint.includes(key)) {
env.mapApis.set(key, val);
}
}
});
}
const files = yield* Effect.tryPromise(() => fs.promises.readdir(path.join(__pwd, `assets/comfy`)));
files.map(a => addScript(path.join(__pwd, `assets/comfy`, a)));
if (comfy_workflow_i2i) {
addScript(comfy_workflow_i2i, 'i2i');
}
if (comfy_workflow_t2i) {
addScript(comfy_workflow_t2i, 't2i');
}
yield* McpLogService.logTrace(`initSystemMode end:${JSON.stringify(env)}`);
}).pipe(Effect.provide(McpLogServiceLive));
};
function addScript(filePath, tag) {
const script = loadScript(filePath);
scriptTables.set(tag || script.name, { script: script.script, nodeNameToId: script.nodeNameToId });
}
function loadScript(filePath) {
const s = fs.readFileSync(filePath, { encoding: "utf8" });
const parse = JSON.parse(s);
const map = Object.keys(parse).flatMap(key => {
const node = parse[key];
const clsName = node.class_type;
const number = Number.parseInt(key);
return [[clsName, number], [`${clsName}:${key}`, number]];
});
const map1 = map.reduce((previousValue, currentValue) => {
if (previousValue.has(currentValue[0])) {
previousValue.set(currentValue[0], 0);
}
else {
previousValue.set(currentValue[0], currentValue[1]);
}
return previousValue;
}, new Map());
const name = path.basename(filePath, '.json');
return { name: name, script: parse, nodeNameToId: map1 };
}
return {
init,
initSystemMode,
updateRoute,
getAvatar,
getAvatarModel,
saveRunStatus,
getTodayAnniversary,
getRecentRunStatus,
practiceRunStatus,
saveSnsPost,
getAvatarSns,
updateSnsFeedSeenAt,
updateSnsMentionSeenAt,
updateBasePrompt,
getEnv,
getEnvOption,
saveEnv,
getVersion,
};
}),
dependencies: [McpLogServiceLive]
}) {
}
export const DbServiceLive = Layer.merge(DbService.Default, McpLogServiceLive);