mirror of
https://github.com/ION606/bluesky-client.git
synced 2026-05-14 21:26:54 +00:00
initial code commit
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { ChatBskyConvoDefs } from "@atproto/api";
|
||||
import { convoHeader, getDID, initSession } from "./main.js";
|
||||
|
||||
|
||||
|
||||
export async function getDMs(cursor = undefined, limit = undefined) {
|
||||
try {
|
||||
const agent = await initSession(),
|
||||
convos = await agent.chat.bsky.convo.listConvos({ cursor, limit });
|
||||
return convos.data;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendPost(e, posturi, utag, dmid = undefined) {
|
||||
try {
|
||||
const agent = await initSession(),
|
||||
[, did, collection, rkey] = posturi.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
|
||||
post = await agent.getPost({ repo: did, rkey, collection }),
|
||||
|
||||
/** @type {ChatBskyConvoDefs.MessageInput} */
|
||||
msg = {
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
cid: post.cid,
|
||||
uri: post.uri
|
||||
}
|
||||
},
|
||||
text: ''
|
||||
}
|
||||
|
||||
return await sendMessage(utag, msg, dmid);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendMessage(utag, message, convoIdInp = undefined) {
|
||||
try {
|
||||
const agent = await initSession();
|
||||
let convoId;
|
||||
|
||||
if (convoIdInp) convoId = convoIdInp;
|
||||
else {
|
||||
const did = await getDID(utag);
|
||||
if (!did) return null;
|
||||
|
||||
const convos = (await getDMs()).convos;
|
||||
convoId = await agent.chat.bsky.convo.getConvo({ convoId: convos.find(o => o.members.find(o => o.did === did)) });
|
||||
}
|
||||
return (await agent.chat.bsky.convo.sendMessage({ convoId, message }, { headers: convoHeader })).data;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { dialog, ipcMain } from "electron";
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import { initSession } from "./main.js";
|
||||
import sharp from "sharp";
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import fs from 'fs';
|
||||
|
||||
|
||||
const fnamesToPosts = {};
|
||||
|
||||
// Store data in main process only
|
||||
ipcMain.handle('set-sensitive-data', (event, key, value) => {
|
||||
fnamesToPosts[key] = value;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-files', (event, key) => {
|
||||
return JSON.stringify(fnamesToPosts);
|
||||
});
|
||||
|
||||
export const clearPostFiles = () => {
|
||||
for (const key in fnamesToPosts) {
|
||||
delete fnamesToPosts[key];
|
||||
}
|
||||
}
|
||||
export const getPostFiles = () => Object.entries(fnamesToPosts);
|
||||
export const popPostFile = (fname) => {
|
||||
if (!fname || !(fname in fnamesToPosts)) return;
|
||||
const o = fnamesToPosts[fname];
|
||||
delete fnamesToPosts[fname];
|
||||
return o;
|
||||
}
|
||||
|
||||
ipcMain.handle('clear-sensitive-data', clearPostFiles);
|
||||
|
||||
export const isAnimated = (mimeType) => (mimeType.startsWith('video/') || mimeType.endsWith('/gif'));
|
||||
|
||||
|
||||
function resizeVideo(data, type) {
|
||||
// normal video, do not convert
|
||||
if (!type.endsWith('/gif')) {
|
||||
const readableStream = new Readable();
|
||||
readableStream.push(data);
|
||||
readableStream.push(null);
|
||||
return readableStream;
|
||||
}
|
||||
|
||||
const passThroughStream = new PassThrough();
|
||||
ffmpeg(data)
|
||||
// .size(`${width}x?`) // Resize to specified width, keeping aspect ratio
|
||||
.outputOptions('-c:v libx264', '-crf 28') // Set codec and compression level
|
||||
.format('mp4') // Set format to MP4
|
||||
.on('error', (err) => {
|
||||
console.error('Error processing video:', err);
|
||||
passThroughStream.destroy(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('Video processing completed.');
|
||||
})
|
||||
.pipe(passThroughStream, { end: true });
|
||||
|
||||
return passThroughStream;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function resizePhoto(data, type) {
|
||||
const outputStream = new PassThrough();
|
||||
sharp(data)
|
||||
.resize({ width: 800 }) // adjust width to reduce size REMOVEME?
|
||||
.toFormat(type)
|
||||
.pipe(outputStream);
|
||||
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
|
||||
export async function uploadFile(fobj) {
|
||||
// bluesky does not support gifs, so EVERYTHING needs to be an mp4
|
||||
const agent = await initSession(),
|
||||
anim = isAnimated(fobj.type),
|
||||
type = anim ? 'mp4' : fobj.type.split('/')?.at(1),
|
||||
typefull = anim ? 'video/mp4' : fobj.type;
|
||||
|
||||
let outputStream;
|
||||
|
||||
if (anim) outputStream = resizeVideo(fobj.data, fobj.type);
|
||||
else outputStream = resizePhoto(fobj.data, type);
|
||||
|
||||
// not reading through a stream ffs
|
||||
// if (compressedBuffer.length > 976 * 1024) {
|
||||
// console.error("File is still too large after compression. Try further resizing.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// upload the stream as a blob
|
||||
const { data } = await agent.uploadBlob(outputStream, { encoding: typefull });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {...{name: string, type: string, size: number, data: Uint8Array}} fobjs
|
||||
*/
|
||||
export async function handleFileOpen(event, ...fobjs) {
|
||||
const o = {};
|
||||
|
||||
for (const fobj of fobjs) {
|
||||
try {
|
||||
const blobData = await uploadFile(fobj);
|
||||
o[fobj.name] = blobData.blob.ref;
|
||||
fnamesToPosts[fobj.name] = blobData.blob;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
o[fobj.name] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Agent, AppBskyFeedGenerator, CredentialSession } from '@atproto/api';
|
||||
import fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import json from '../secrets/config.json' with { type: 'json' }
|
||||
import { ipcMain } from 'electron';
|
||||
import logger from '../logger.js';
|
||||
import { getHistory } from '../src/db.js';
|
||||
import convertAndServe, { clearCache } from './video.js';
|
||||
import { handleFileOpen } from './files.js';
|
||||
import post, { handlePostAction } from './post.js';
|
||||
import { sendPost } from './convoManager.js';
|
||||
|
||||
const { uname, upass } = json.bluesky;
|
||||
const sessionFilePath = './secrets/session.json'; // path to your session file
|
||||
export const convoHeader = { "Atproto-Proxy": "did:web:api.bsky.chat#bsky_chat" };
|
||||
|
||||
|
||||
// function to load session data from the file
|
||||
function loadSession() {
|
||||
try {
|
||||
if (fs.existsSync(sessionFilePath)) {
|
||||
const data = fs.readFileSync(sessionFilePath, 'utf-8');
|
||||
logger.info('session loaded successfully');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
logger.info('no existing session found');
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('failed to load session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// create a Bluesky session and agent
|
||||
const session = new CredentialSession(new URL('https://bsky.social'));
|
||||
const agent = new Agent(session);
|
||||
|
||||
// function to save session data to a file, with validation
|
||||
function saveSession(data) {
|
||||
if (!data) {
|
||||
logger.error('No session data to save.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(sessionFilePath, JSON.stringify(data), 'utf-8');
|
||||
logger.info('session saved successfully');
|
||||
} catch (error) {
|
||||
logger.error('failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// initialize and resume session if possible
|
||||
export async function initSession() {
|
||||
if (session?.session?.active) return agent;
|
||||
|
||||
const savedSession = loadSession();
|
||||
if (savedSession) {
|
||||
try {
|
||||
await session.resumeSession(savedSession);
|
||||
logger.info('session resumed successfully');
|
||||
} catch (resumeError) {
|
||||
logger.warn('failed to resume session, attempting to refresh:', resumeError);
|
||||
try {
|
||||
await session.refreshSession();
|
||||
if (session.session) {
|
||||
saveSession(session.session); // ensure session data exists before saving
|
||||
logger.info('session refreshed and saved successfully');
|
||||
} else {
|
||||
logger.error('refresh failed to retrieve valid session data.');
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
} catch (refreshError) {
|
||||
logger.error('session refresh failed, logging in again:', refreshError);
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
|
||||
// await agent.deleteRepost('at://did:plc:amhzdnxsvkcqjgwdh5kqmhk7/app.bsky.feed.post/3l6tg2zdph62n');
|
||||
return agent;
|
||||
}
|
||||
|
||||
// helper function to login and save the session
|
||||
async function loginAndSaveSession() {
|
||||
try {
|
||||
await session.login({ identifier: uname, password: upass });
|
||||
if (session.session) {
|
||||
saveSession(session.session);
|
||||
logger.info('logged in and session saved');
|
||||
} else {
|
||||
logger.error('login succeeded but no session data available to save');
|
||||
}
|
||||
} catch (loginError) {
|
||||
logger.error('login failed:', loginError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getDID = async (utag) => {
|
||||
try {
|
||||
let did;
|
||||
if (!utag || utag === '@me') {
|
||||
did = session.did || session?.session?.did;
|
||||
} else {
|
||||
const resolved = await agent.resolveHandle({ handle: utag });
|
||||
did = resolved.data.did;
|
||||
}
|
||||
return did;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getUserData(utag, allData = false) {
|
||||
try {
|
||||
await initSession(); // ensure session is fully initialized before proceeding
|
||||
const did = await getDID(utag);
|
||||
if (!did) return { err: 'DID not found!' };
|
||||
|
||||
const { data } = await agent.getProfile({ actor: did });
|
||||
const output = { profile: data };
|
||||
|
||||
if (allData) {
|
||||
const { data: { feed, cursor } } = await agent.getAuthorFeed({ actor: output.profile.did, limit: 20, includePins: true }),
|
||||
{ data: { feed: likes, cursor: likesCursor } } = await agent.getActorLikes({ actor: did });
|
||||
|
||||
output.likes = likes;
|
||||
output.likesCursor = likesCursor;
|
||||
output.posts = feed;
|
||||
output.postcursor = cursor;
|
||||
|
||||
output.replies = await getReplies(utag);
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (err) {
|
||||
logger.error('failed to fetch user data:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getPosts = async (utag, cursor = undefined, likesCursor = undefined) => {
|
||||
try {
|
||||
const did = await getDID(utag);
|
||||
if (!did) return { err: 'DID not found!' };
|
||||
|
||||
const likes = await agent.getActorLikes({ actor: did, cursor: likesCursor }),
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit: 20, includePins: true, cursor });
|
||||
return { posts, likes };
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return { err: 'internal server error!' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getReplies = async (utag, cursorInit, limit = 20) => {
|
||||
const did = await getDID(utag);
|
||||
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
|
||||
let cursor = posts.data.cursor;
|
||||
const replies = posts.data.feed.filter(o => o.reply);
|
||||
|
||||
while (cursor && replies.length < limit) {
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor });
|
||||
replies.push(...posts.data.feed.filter(o => o.reply));
|
||||
cursor = posts.data.cursor;
|
||||
}
|
||||
|
||||
return { replies, cursor };
|
||||
}
|
||||
|
||||
|
||||
export const getMedia = async (utag, cursorInit, limit = 20) => {
|
||||
const did = await getDID(utag);
|
||||
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
|
||||
let cursor = posts.data.cursor;
|
||||
const replies = posts.data.feed.filter(o => o.reply);
|
||||
|
||||
while (cursor && replies.length < limit) {
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit, includePins: true, cursor });
|
||||
replies.push(...posts.data.feed.filter(o => o.reply));
|
||||
cursor = posts.data.cursor;
|
||||
}
|
||||
|
||||
return { replies, cursor };
|
||||
}
|
||||
|
||||
|
||||
export const getConnections = async (e, utag, cursor, limit = 20) => {
|
||||
const did = await getDID(utag),
|
||||
{ data: { follows: followsRaw, cursor: followsCursor } } = await agent.getFollows({ actor: did, cursor, limit }),
|
||||
{ data: { followers: followersRaw, cursor: followersCursor } } = await agent.getFollowers({ actor: did, cursor, limit }),
|
||||
{ data: { convos } } = await agent.chat.bsky.convo.listConvos({}, { headers: convoHeader }),
|
||||
follows = followsRaw?.length ? (await agent.getProfiles({ actors: followsRaw.map(o => o.did) })).data.profiles : [],
|
||||
followers = followersRaw?.length ? (await agent.getProfiles({ actors: followersRaw.map(o => o.did) })).data.profiles : [];
|
||||
|
||||
return {
|
||||
follows: follows.map(f => {
|
||||
f.dm = convos.find(o => o.members.find(m => m.did === f.did));
|
||||
return f;
|
||||
}), followsCursor, followers, followersCursor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// export IPC setup function
|
||||
export async function setupIPC() {
|
||||
ipcMain.handle('getdata', async (event, utag, all = false) => {
|
||||
const data = await getUserData(utag, all);
|
||||
event.sender.send('udata', JSON.stringify(data));
|
||||
});
|
||||
|
||||
ipcMain.handle('getposts', async (e, utag, cursor, likescursor) => {
|
||||
if (!cursor) return e.sender.send(404);
|
||||
const data = await getPosts(utag, cursor, likescursor);
|
||||
if (data.code) return e.sender.send(data.code);
|
||||
e.sender.send('posts', JSON.stringify(data));
|
||||
clearCache();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-connections', getConnections);
|
||||
|
||||
ipcMain.handle('gethistory', (e, limit, offset) => e.sender.send('history', JSON.stringify(getHistory(limit, offset))));
|
||||
ipcMain.handle('getreplies', async (e, limit, offset) => e.sender.send('replies', JSON.stringify(await getReplies(limit, offset))));
|
||||
ipcMain.handle('getvideo', async (e, oldurl) => {
|
||||
const newURL = await convertAndServe(`${crypto.randomUUID()}.mp4`, oldurl);
|
||||
e.sender.send('video', oldurl, newURL);
|
||||
});
|
||||
|
||||
ipcMain.handle('send-post', sendPost);
|
||||
ipcMain.handle('new-post', post);
|
||||
ipcMain.handle('upload-file', handleFileOpen);
|
||||
ipcMain.handle('post-action', async (e, action, id, condition) => await handlePostAction(e, action, id, agent, condition));
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { Agent, RichText } from "@atproto/api";
|
||||
import { getPostFiles, isAnimated, popPostFile, uploadFile } from "./files.js";
|
||||
import { initSession } from "./main.js";
|
||||
|
||||
|
||||
async function createEmbed(imgs, vid, embed) {
|
||||
let embdData;
|
||||
|
||||
if (vid) {
|
||||
embdData = {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: vid[1]
|
||||
}
|
||||
}
|
||||
else if (vid) {
|
||||
embdData = {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images: imgs.map(f => ({ alt: 'image!', image: f[1] }))
|
||||
}
|
||||
}
|
||||
else if (embed) {
|
||||
const img = popPostFile(embed.Image);
|
||||
|
||||
return {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
description: embed.Description,
|
||||
uri: embed.Uri,
|
||||
title: embed.Title,
|
||||
thumb: img
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default async function post(e, postData) {
|
||||
try {
|
||||
const { text, embed } = JSON.parse(postData);
|
||||
|
||||
const agent = await initSession(),
|
||||
files = getPostFiles(),
|
||||
imgs = files.filter(o => !isAnimated(o[1].mimeType)),
|
||||
vid = files.find(o => isAnimated(o[1].mimeType)),
|
||||
rt = new RichText({ text });
|
||||
|
||||
agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
langs: ["en-US"],
|
||||
createdAt: new Date().toISOString(),
|
||||
embed: await createEmbed(imgs, vid, embed)
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} e
|
||||
* @param {*} action
|
||||
* @param {*} postid
|
||||
* @param {Agent} agent
|
||||
* @returns
|
||||
*/
|
||||
export async function handlePostAction(e, action, postid, agent, condition = false) {
|
||||
try {
|
||||
if (!postid) return 404;
|
||||
|
||||
let r;
|
||||
const [, did, collection, rkey] = postid.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
|
||||
post = await agent.getPost({ repo: did, rkey, collection });
|
||||
|
||||
if (action === 'delete') {
|
||||
await agent.deletePost(post.uri);
|
||||
return postid;
|
||||
}
|
||||
else if (action === 'like') return await condition ? agent.deleteLike(condition) : agent.like(post.uri, post.cid);
|
||||
else if (action === 'link') return `https://bsky.app/profile/${did}/post/${rkey}`;
|
||||
else if (action === 'repost' && condition) {
|
||||
await agent.deleteRepost(condition);
|
||||
return postid;
|
||||
}
|
||||
else if (action === 'repost') {
|
||||
const uri = (await agent.repost(post.uri, post.cid)).uri;
|
||||
// const author = await agent.app.bsky.feed.searchPosts({ url: post.uri });
|
||||
// console.log(author)
|
||||
return uri;
|
||||
}
|
||||
else if (action === 'pin') return null; //agent.app.bsky.feed.sendInteractions({interactions: [{event: ''}]})
|
||||
|
||||
|
||||
return JSON.stringify(r);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const baseVideoCachePath = path.resolve('cache', 'videos');
|
||||
if (!fs.existsSync(baseVideoCachePath)) fs.mkdirSync(baseVideoCachePath, { recursive: true });
|
||||
|
||||
// function to convert .m3u8 to .mp4
|
||||
function convertM3U8ToMP4(inpurl, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(inpurl)
|
||||
.outputOptions('-c copy') // copies the codec without re-encoding for faster processing
|
||||
.output(outputPath)
|
||||
.on('end', resolve)
|
||||
.on('error', reject)
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// clean up the cache
|
||||
export async function clearCache(...vids) {
|
||||
const arr = (vids?.length) ? vids : fs.readdirSync(baseVideoCachePath);
|
||||
await Promise.all(arr.map((p) => new Promise(resolve => fs.rm(path.resolve(baseVideoCachePath, p), resolve))));
|
||||
}
|
||||
|
||||
|
||||
export default async function convertAndServe(fname, m3u8url) {
|
||||
try {
|
||||
const newPath = path.resolve(baseVideoCachePath, fname);
|
||||
await convertM3U8ToMP4(m3u8url, newPath);
|
||||
return fname;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user