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