diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..520d03d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 cannorin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/index.ts b/index.ts index 42b39c7..fdf6443 100644 --- a/index.ts +++ b/index.ts @@ -15,6 +15,10 @@ const openai = new OpenAI({ apiKey: Bun.env["OPENAI_API_KEY"], }); + +// #region util + +/** shuffle the array */ function randomize(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(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 { + 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 { 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 { 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>; -async function expandReplyTree(note: Note, acc: Note[] = []): Promise { - 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 { diff --git a/misskey-js/LICENSE b/misskey-js/LICENSE new file mode 100644 index 0000000..63473fc --- /dev/null +++ b/misskey-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2024 syuilo and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file