@mfukushim/map-traveler-mcp
Version:
Virtual traveler library for MCP
203 lines (202 loc) • 11.2 kB
JavaScript
/*! map-traveler-mcp | MIT License | https://github.com/mfukushim/map-traveler-mcp */
import { Effect, Schedule } from "effect";
import { RichText } from '@atproto/api';
import { AtpAgent } from '@atproto/api';
import dayjs from "dayjs";
import { McpLogService } from "./McpLogService.js";
import { DbService } from "./DbService.js";
import { AnswerError } from "./mapTraveler.js";
import { bs_handle, bs_id, bs_pass } from "./EnvUtils.js";
const agent = new AtpAgent({ service: 'https://bsky.social' });
let isLogin = false;
export class SnsService extends Effect.Service()("traveler/SnsService", {
accessors: true,
effect: Effect.gen(function* () {
function reLogin() {
return Effect.gen(function* () {
if (!(bs_id && bs_pass && bs_handle))
return yield* Effect.fail(new AnswerError('no bluesky account'));
if (isLogin)
return yield* Effect.succeed(true);
yield* Effect.tryPromise({
try: () => {
return agent.login({
identifier: bs_id || '',
password: bs_pass || '',
});
},
catch: error => new Error(`${error}`)
}).pipe(Effect.tap(a => !a.success && Effect.fail(new Error("bs login fail"))), Effect.andThen(() => {
isLogin = true;
return 'true';
}), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))));
});
}
function uploadBlob(image, mime = "image/png") {
return Effect.tryPromise(() => agent.uploadBlob(image, { encoding: mime, })).pipe(Effect.tap(a => !a.success && Effect.fail(new Error(`bs uploadBlob error:${a.headers}`))), Effect.andThen(a => a.data.blob), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))));
}
function bsPost(message, reply, image) {
const replyData = reply && {
root: {
uri: reply.uri,
cid: reply.cid
},
parent: {
uri: reply.uri,
cid: reply.cid
}
};
return reLogin().pipe(Effect.andThen(() => {
const rt = new RichText({ text: message });
const post = {
$type: "app.bsky.feed.post",
text: rt.text,
facets: rt.facets || [],
createdAt: dayjs().toISOString(),
};
return Effect.tryPromise(() => rt.detectFacets(agent)).pipe(Effect.andThen(() => Effect.succeed(post)));
}), Effect.andThen(post => {
return image ? uploadBlob(image.buf, image.mime).pipe(Effect.andThen(blob => ({
$type: "app.bsky.embed.images",
images: [{
image: blob,
alt: ''
}]
})), Effect.andThen(a => ({ ...post, embed: a }))) : Effect.succeed(post);
}), Effect.andThen(post => {
return replyData ? ({ ...post, reply: replyData }) : post;
}), Effect.andThen(a => {
return Effect.tryPromise({
try: () => agent.post(a),
catch: error => new Error(`${error}`)
});
}), Effect.tapError(e => McpLogService.logError(`bsPost ${e}`)), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))));
}
function addBsLike(uri, cid) {
return reLogin().pipe(Effect.andThen(() => {
return Effect.tryPromise({
try: () => agent.like(uri, cid),
catch: error => new Error(`${error}`)
});
}), Effect.tapError(e => McpLogService.logError(`addBsLike ${e}`)), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))));
}
function getOwnProfile() {
return bs_handle ? getProfile(bs_handle) : Effect.fail(new Error('no bs handle'));
}
function getProfile(handle) {
return reLogin().pipe(Effect.andThen(Effect.tryPromise(() => agent.getProfile({ actor: handle }))), Effect.tap(a => !a.success && Effect.fail(new Error('getProfile error'))), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))), Effect.andThen(a => a.data));
}
function getActorLikes(handle) {
return reLogin().pipe(Effect.andThen(Effect.tryPromise(() => agent.getActorLikes({ actor: handle }))), Effect.tap(a => !a.success && Effect.fail(new Error('getActorLikes error'))), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("10 seconds")))), Effect.andThen(a => a.data));
}
function getAuthorFeed(handle, length) {
return reLogin().pipe(Effect.andThen(Effect.tryPromise(() => agent.getAuthorFeed({
actor: handle,
filter: 'posts_no_replies',
limit: length || 10,
}))), Effect.tap(a => !a.success && Effect.fail(new Error('getActorLikes error'))), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("10 seconds")))), Effect.andThen(a => a.data));
}
function getFeed(feed, length) {
return Effect.gen(function* () {
yield* reLogin();
const snsInfo = yield* DbService.getAvatarSns(1, 'bs');
yield* McpLogService.logTrace(`getFeed:feedSeenAt:${dayjs.unix(snsInfo.feedSeenAt).toISOString()}`);
const feedData = yield* Effect.tryPromise(() => agent.app.bsky.feed.getFeed({
feed: feed,
limit: length || 10
})).pipe(Effect.tap(a => !a.success && Effect.fail(new Error('getFeed error'))), Effect.tap(a => a.data.feed.map(v => McpLogService.logTrace(`getFeed post:${dayjs(v.post.indexedAt).toISOString()}`))), Effect.andThen(a => {
return a.data.feed.filter(v => (dayjs(v.post.indexedAt).unix() > snsInfo.feedSeenAt) && v.post.author.handle !== bs_handle);
}), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("10 seconds")))));
const max = feedData.reduce((p, c) => Math.max(p, dayjs(c.post.indexedAt).unix()), snsInfo.feedSeenAt);
yield* DbService.updateSnsFeedSeenAt(1, 'bs', max);
return feedData;
});
}
function getPost(uri) {
return reLogin().pipe(Effect.andThen(Effect.tryPromise(() => agent.getPosts({ uris: [uri] }))), Effect.tap(a => !a.success && Effect.fail(new Error('getPost fail'))), Effect.retry(Schedule.recurs(2).pipe(Schedule.intersect(Schedule.spaced("10 seconds")))), Effect.andThen(a => a.data));
}
function snsPost(message, appendNeedText, image) {
const sliceLen = appendNeedText.length + 1;
return Effect.gen(function* () {
const postIds = [];
yield* reLogin();
const bsPostId = yield* bsPost([message.slice(0, 300 - sliceLen), appendNeedText].join('\n'), undefined, image);
postIds.push({
snsType: 'bs',
id: yield* DbService.saveSnsPost(JSON.stringify(bsPostId), bs_handle)
});
return postIds;
});
}
function snsReply(message, appendNeedText, replyId) {
const sliceLen = appendNeedText.length + 1;
return Effect.gen(function* () {
const postIds = [];
yield* reLogin();
const split = replyId.split('-');
const bsPostId = yield* bsPost([message.slice(0, 300 - sliceLen), appendNeedText].join('\n'), { uri: split[0], cid: split[1] });
postIds.push({
snsType: 'bs',
id: yield* DbService.saveSnsPost(JSON.stringify(bsPostId), bs_handle)
});
return postIds;
});
}
function addLike(id) {
const split = id.split('-');
return addBsLike(split[0], split[1]).pipe(Effect.andThen(a => DbService.saveSnsPost(JSON.stringify(a), bs_handle)));
}
function getNotification(seenAtEpoch) {
return Effect.gen(function* () {
yield* reLogin();
const snsInfo = yield* DbService.getAvatarSns(1, 'bs');
const notification = yield* Effect.tryPromise(() => agent.listNotifications()).pipe(Effect.tap(a => !a.success && Effect.fail(new Error('getNotification fail'))), Effect.tap(a => McpLogService.logTrace(`notification num:${a.data.length}`)), Effect.retry(Schedule.recurs(1).pipe(Schedule.intersect(Schedule.spaced("5 seconds")))));
const max = notification.data.notifications.reduce((p, c) => Math.max(p, dayjs(c.indexedAt).unix()), snsInfo.mentionSeenAt);
yield* DbService.updateSnsMentionSeenAt(1, 'bs', max);
const seedEpoch = seenAtEpoch || snsInfo.mentionSeenAt;
return notification.data.notifications.filter(v => (dayjs(v.indexedAt).unix() > seedEpoch && v.reason !== "follow") && v.author.handle !== bs_handle).map(value => {
if (value.reason === "reply") {
return {
uri: value.uri,
cid: value.cid,
rootUri: value.record.reply.root.uri,
parentUri: value.record.reply.parent.uri,
mentionType: value.reason,
name: value.author.displayName || value.author.handle,
handle: value.author.handle,
createdAt: value.record.createdAt,
detectEpoch: dayjs(value.indexedAt).unix(),
};
}
else {
return {
uri: value.record.subject.uri,
cid: value.record.subject.cid,
mentionType: value.reason,
name: value.author.displayName || value.author.handle,
handle: value.author.handle,
createdAt: value.record.createdAt,
detectEpoch: dayjs(value.indexedAt).unix(),
};
}
});
});
}
return {
uploadBlob,
bsPost,
snsPost,
getProfile,
getOwnProfile,
getActorLikes,
getAuthorFeed,
getNotification,
getFeed,
getPost,
snsReply,
addLike
};
})
}) {
}
export const SnsServiceLive = SnsService.Default;