Add comments and licenses

This commit is contained in:
2024-12-29 08:28:29 +00:00
parent 26b4f0ef2c
commit c0a2287488
3 changed files with 118 additions and 28 deletions

104
index.ts
View File

@@ -15,6 +15,10 @@ const openai = new OpenAI({
apiKey: Bun.env["OPENAI_API_KEY"],
});
// #region util
/** shuffle the array */
function randomize<T>(array: T[]) {
for (let i = array.length - 1; i > 0; i--) {
const r = Math.floor(Math.random() * (i + 1));
@@ -25,58 +29,84 @@ function randomize<T>(array: T[]) {
return array;
}
/** sleep for N milliseconds */
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec));
// #endregion
// #region misskey
const me = await misskey.request("i", {});
async function getPosts() {
async function getLocalPosts() {
/** randomly sample some notes from the timeline */
async function getNotes() {
// randomly sample N local notes
async function getLocalNotes(count: number) {
const timeline = await misskey.request("notes/local-timeline", {
withFiles: false,
withRenotes: false,
limit: 100,
});
const posts = timeline.filter((p) => !p.user.isBot);
return randomize(posts).slice(0, 5);
// exclude bot notes
return randomize(timeline.filter((p) => !p.user.isBot)).slice(0, count);
}
async function getGlobalPosts() {
// randomly sample N global notes
async function getGlobalNotes(count: number) {
const timeline = await misskey.request("notes/global-timeline", {
withFiles: false,
withRenotes: false,
limit: 100,
});
const posts: Note[] = timeline.filter((p) => !p.user.isBot && !p.reply);
return randomize(posts).slice(0, 10);
// exclude bot notes and replies
return randomize(timeline.filter((p) => !p.user.isBot && !p.reply)).slice(0, count);
}
async function getPreviousPosts() {
const posts = await misskey.request("users/notes", {
// randomly sample N notes of mine
async function getMyNotes(count: number) {
const notes = await misskey.request("users/notes", {
userId: me.id,
limit: 100,
withRenotes: false,
});
return randomize(posts).slice(0, 2);
return randomize(notes).slice(0, count);
}
const posts = await Promise.all([
getLocalPosts(),
getPreviousPosts(),
getGlobalPosts(),
const notes = await Promise.all([
getLocalNotes(5),
getGlobalNotes(10),
getMyNotes(2),
]);
return randomize(posts.flat());
return randomize(notes.flat());
}
type Job = { type: "post" } | { type: "reply"; id: string, replyTree: Note[] };
/** fetch the whole reply tree */
async function expandReplyTree(note: Note, acc: Note[] = []): Promise<Note[]> {
if (!note.reply) return [...acc, note];
const reply = await misskey.request("notes/show", { noteId: note.reply.id });
return await expandReplyTree(reply, [...acc, note]);
}
/** convert a note to a chat message */
const noteToMessage = (note: Note): ChatCompletionMessageParam => ({
role: note.userId === me.id ? ("assistant" as const) : ("user" as const),
content: note.text?.replaceAll(`@${me.username}`, "") || "",
})
// #endregion
// #region job
type Job =
// read posts and post a note
| { type: "post" }
// reply to a specific note
| { type: "reply"; id: string, replyTree: Note[] };
/** create a prompt for the job */
async function preparePrompt(job: Job): Promise<ChatCompletionMessageParam[]> {
switch (job.type) {
case "post": {
const posts = await getPosts();
const notes = await getNotes();
return [
{
role: "system",
@@ -89,7 +119,7 @@ async function preparePrompt(job: Job): Promise<ChatCompletionMessageParam[]> {
4. まるでタイムラインにツイートしているかのように発言すること。
5. あるびのちゃん としてのキャラに徹し、超越的・非人間的な考え方や思想を持つこと。`,
},
...posts.map(noteToMessage),
...notes.map(noteToMessage),
];
}
case "reply": {
@@ -122,9 +152,12 @@ user が SNS 上で、あなたへのメッセージを送ってきています
}
}
/** execute a job */
async function processJob(job: Job) {
const messages = await preparePrompt(job);
const model = Bun.env["OPENAI_MODEL"] ?? "gpt-4o-mini";
// request chat completion
const stream = await openai.chat.completions.create({
model,
stream: true,
@@ -133,6 +166,8 @@ async function processJob(job: Job) {
max_completion_tokens: 400,
messages,
});
// display partial responses in realtime
const responses: string[] = [];
for await (const chunk of stream) {
const content = chunk.choices.pop()?.delta.content;
@@ -142,7 +177,11 @@ async function processJob(job: Job) {
}
}
console.log();
// concatenate the partial responses
const text = responses.join("").replaceAll(/(\r\n|\r|\n)\s+/g, "\n\n");;
// post a note
await misskey.request("notes/create", {
visibility: "public",
text,
@@ -156,12 +195,7 @@ const jobs: Job[] = [];
let stream: MisskeyStream;
let channel: ReturnType<typeof stream.useChannel<"main">>;
async function expandReplyTree(note: Note, acc: Note[] = []): Promise<Note[]> {
if (!note.reply) return [...acc, note];
const reply = await misskey.request("notes/show", { noteId: note.reply.id });
return await expandReplyTree(reply, [...acc, note]);
}
/** connect to streaming API and add handlers */
function initializeStream() {
stream = new Stream(
Bun.env["MISSKEY_ORIGIN"] ?? "https://misskey.cannorin.net",
@@ -170,13 +204,19 @@ function initializeStream() {
},
) as unknown as MisskeyStream;
channel = stream.useChannel("main");
// notify when connected
stream.on("_connected_", () => {
console.log("* connected");
});
// reconnect automatically
stream.on("_disconnected_", () => {
console.log("* disconnected, reconnecting");
initializeStream();
});
// push a reply job when receiving a mention
channel.on("mention", async (e) => {
if (e.text && e.userId !== me.id && !e.user.isBot) {
const replyTree = await expandReplyTree(e);
@@ -184,6 +224,8 @@ function initializeStream() {
jobs.push({ type: "reply", id: e.id, replyTree });
}
});
// follow back non-bot users
channel.on("followed", async (e) => {
if (!e.isBot) {
await misskey.request("following/create", { userId: e.id });
@@ -192,12 +234,11 @@ function initializeStream() {
}
initializeStream();
const sleep = (msec: number) =>
new Promise((resolve) => setTimeout(resolve, msec));
/** pop from the job queue and run it */
async function runJob() {
while (true) {
const job = jobs.pop();
if (job) {
console.log(`* pop: ${job.type}`);
try {
@@ -207,19 +248,26 @@ async function runJob() {
console.log(`* error: ${e}`);
}
}
await sleep(1000); // 1sec
}
}
/** push a job to the job queue */
async function pushJob() {
while (true) {
const now = new Date(Date.now());
// push a post job every 15 minutes (XX:00, XX:15, XX:30, XX:45)
if (now.getMinutes() % 15 < Number.EPSILON && !jobs.some((job) => job.type === "post")) {
console.log("* push: post");
jobs.push({ type: "post" });
}
await sleep(60 * 1000); // 1min
}
}
// #endregion
async function main() {
try {