UNPKG

@mfukushim/map-traveler-mcp

Version:
203 lines (202 loc) 11.2 kB
/*! 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;