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

21
LICENSE Normal file
View File

@@ -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.

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 {

21
misskey-js/LICENSE Normal file
View File

@@ -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.