diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 754fc07..392c69b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(awk:*)", "Bash(sed:*)", "Bash(deno task:*)", - "Bash(deno test:*)" + "Bash(deno test:*)", + "Bash(gh issue view:*)" ], "deny": [] } diff --git a/CHANGES.md b/CHANGES.md index 55c110b..8fb8bcd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,17 @@ To be released. - BotKit now supports Node.js alongside of Deno. The minimum required version of Node.js is 22.0.0. + - BotKit now supports publishing polls. [[#7], [#8]] + + - Added `Poll` interface. + - Added `Vote` interface. + - Added an overload of the `Session.publish()` method that accepts + `SessionPublishOptionsWithQuestion` as the second argument. + - Added `SessionPublishOptionsWithQuestion` interface. + - Added `Bot.onVote` event. + - Added `VoteEventHandler` type. + - Added `KvStoreRepositoryPrefixes.polls` option. + - Added `@fedify/botkit/repository` module that provides repository implementations for BotKit. @@ -22,7 +33,10 @@ To be released. - Added `Create` class. - Added `MemoryCachedRepository` class. - - Upgraded Fedify to 1.6.1. + - Upgraded Fedify to 1.8.0. + +[#7]: https://github.com/fedify-dev/botkit/issues/7 +[#8]: https://github.com/fedify-dev/botkit/pull/8 Version 0.2.0 diff --git a/deno.json b/deno.json index 3ed62a9..aaf1f84 100644 --- a/deno.json +++ b/deno.json @@ -13,13 +13,14 @@ "./events": "./src/events.ts", "./follow": "./src/follow.ts", "./message": "./src/message.ts", + "./poll": "./src/poll.ts", "./reaction": "./src/reaction.ts", "./repository": "./src/repository.ts", "./session": "./src/session.ts", "./text": "./src/text.ts" }, "imports": { - "@fedify/fedify": "jsr:@fedify/fedify@^1.6.1", + "@fedify/fedify": "jsr:@fedify/fedify@^1.8.0-dev.910+8a000b1c", "@fedify/markdown-it-hashtag": "jsr:@fedify/markdown-it-hashtag@^0.3.0", "@fedify/markdown-it-mention": "jsr:@fedify/markdown-it-mention@^0.3.0", "@logtape/logtape": "jsr:@logtape/logtape@^1.0.0", @@ -54,7 +55,11 @@ "test": "deno test --allow-env=NODE_V8_COVERAGE,JEST_WORKER_ID --allow-net=hollo.social --parallel", "test:node": "pnpm install && pnpm test", "test-all": { - "dependencies": ["check", "test", "test:node"] + "dependencies": [ + "check", + "test", + "test:node" + ] }, "coverage": "deno task test --coverage --clean && deno coverage --html", "hooks:install": "deno run --allow-read=deno.json,.git/hooks/ --allow-write=.git/hooks/ jsr:@hongminhee/deno-task-hooks", diff --git a/deno.lock b/deno.lock index 3bb8d97..e490ecb 100644 --- a/deno.lock +++ b/deno.lock @@ -1,15 +1,14 @@ { "version": "5", "specifiers": { - "jsr:@es-toolkit/es-toolkit@^1.38.0": "1.39.4", - "jsr:@fedify/fedify@^1.6.1": "1.6.2", + "jsr:@es-toolkit/es-toolkit@^1.39.5": "1.39.5", + "jsr:@fedify/fedify@^1.8.0-dev.910+8a000b1c": "1.8.0-dev.910+8a000b1c", "jsr:@fedify/markdown-it-hashtag@0.3": "0.3.0", "jsr:@fedify/markdown-it-mention@0.3": "0.3.0", "jsr:@hongminhee/x-forwarded-fetch@0.2": "0.2.0", - "jsr:@hono/hono@^4.8.2": "4.8.2", + "jsr:@hono/hono@^4.8.2": "4.8.3", "jsr:@hugoalh/http-header-link@^1.0.2": "1.0.3", - "jsr:@hugoalh/is-string-singleline@^1.0.4": "1.0.4", - "jsr:@logtape/logtape@0.11": "0.11.0", + "jsr:@hugoalh/is-string-singleline@^1.0.4": "1.0.5", "jsr:@logtape/logtape@1": "1.0.0", "jsr:@std/html@0.224": "0.224.2", "npm:@fedify/markdown-it-hashtag@0.3": "0.3.0", @@ -42,15 +41,15 @@ "npm:xss@^1.0.15": "1.0.15" }, "jsr": { - "@es-toolkit/es-toolkit@1.39.4": { - "integrity": "e1ee3e23dd146e0864dba3c74b9ff99bb02d33bfe76ef954c7f25aed60a0c51c" + "@es-toolkit/es-toolkit@1.39.5": { + "integrity": "f16bef4e1eff94f74981d8ad04dab27d35568bc7088454a0a0269d6c36b215c0" }, - "@fedify/fedify@1.6.2": { - "integrity": "d93fa3b3bf3d598137a9f39245897b24aeff2d40dd67e813e845d306b93d3634", + "@fedify/fedify@1.8.0-dev.910+8a000b1c": { + "integrity": "3c923975be17e42853fbfce7433ba6cd394a4fd164d1294b3f34d4a0322f535e", "dependencies": [ "jsr:@es-toolkit/es-toolkit", "jsr:@hugoalh/http-header-link", - "jsr:@logtape/logtape@0.11", + "jsr:@logtape/logtape@1", "npm:@multiformats/base-x", "npm:@opentelemetry/api", "npm:@opentelemetry/semantic-conventions", @@ -86,17 +85,17 @@ "@hono/hono@4.8.2": { "integrity": "74fe4fb5392ecc757a6e1d63bc9af9194f720a82ef741e4080a51d7a32799511" }, + "@hono/hono@4.8.3": { + "integrity": "f17509836610b97e9ec86bce5349387115c35ee72b7b29fd2b7944abe5f843ab" + }, "@hugoalh/http-header-link@1.0.3": { "integrity": "3372096a73d755e3351f7fbd7155db7725874c2682a594a655580e3866563024", "dependencies": [ "jsr:@hugoalh/is-string-singleline" ] }, - "@hugoalh/is-string-singleline@1.0.4": { - "integrity": "b283396a7b1a1c9777c42560f657a5b13b0dee486282cce87f3c41a9e5796a66" - }, - "@logtape/logtape@0.11.0": { - "integrity": "87f90b428c7a86f4d08b7701f54171fde1f15942e4e43846fa6fc0b016c10285" + "@hugoalh/is-string-singleline@1.0.5": { + "integrity": "3409f64eaad51e4afccea0f21853cedf7b0b5b147e3606e585e5e88ba2b03b32" }, "@logtape/logtape@1.0.0": { "integrity": "30e23091ed75daac9eaff83688a90ccfbb7e75e79d0344d5a6e9585e6f653f92" @@ -861,7 +860,7 @@ }, "workspace": { "dependencies": [ - "jsr:@fedify/fedify@^1.6.1", + "jsr:@fedify/fedify@^1.8.0-dev.910+8a000b1c", "jsr:@fedify/markdown-it-hashtag@0.3", "jsr:@fedify/markdown-it-mention@0.3", "jsr:@hongminhee/x-forwarded-fetch@0.2", diff --git a/docs/concepts/events.md b/docs/concepts/events.md index 58f614f..aebd68f 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -444,3 +444,78 @@ bot.onUnreact = async (session, reaction) => { } }; ~~~~ + + +Vote +---- + +*This API is available since BotKit 0.3.0.* + +The `~Bot.onVote` event handler is called when someone votes on a poll created +by your bot. It receives a `Vote` object, which represents the vote activity, +as the second argument. + +The following is an example of a vote event handler that sends a direct message +when someone votes on your bot's poll: + +~~~~ typescript twoslash +import { type Bot, text } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onVote = async (session, vote) => { + await session.publish( + text`Thanks for voting "${vote.option}" on my poll, ${vote.actor}!`, + { visibility: "direct" }, + ); +}; +~~~~ + +The `Vote` object contains the following properties: + +`~Vote.actor` +: The actor who voted. + +`~Vote.option` +: The option that was voted for (as a string). + +`~Vote.poll` +: Information about the poll including whether it allows `~Poll.multiple` + choices, all available `~Poll.options`, and the `~Poll.endTime`. + +`~Vote.message` +: The poll message that was voted on. + +> [!TIP] +> You can check if a poll allows multiple selections by accessing the +> `vote.poll.multiple` property: +> +> ~~~~ typescript twoslash +> import { type Bot, text } from "@fedify/botkit"; +> const bot = {} as unknown as Bot; +> // ---cut-before--- +> bot.onVote = async (session, vote) => { +> if (vote.poll.multiple) { +> await vote.message.reply( +> text`${vote.actor} selected "${vote.option}" in the multiple choice poll!` +> ); +> } else { +> await vote.message.reply( +> text`${vote.actor} voted for "${vote.option}"!` +> ); +> } +> }; +> ~~~~ + +> [!NOTE] +> The `~Bot.onVote` event handler is only called for votes on polls created by +> your bot. Votes on polls created by others will not trigger this event. + +> [!NOTE] +> The bot author cannot vote on their own pollsβ€”such votes are automatically +> ignored and will not trigger the `~Bot.onVote` event handler. + +> [!NOTE] +> On a poll with multiple options, each selection creates a separate `Vote` +> object, even if the same user selects multiple options. This means that if +> a user selects multiple options in a multiple-choice poll, the `~Bot.onVote` +> event handler will be called multiple times, once for each selected option. diff --git a/docs/concepts/message.md b/docs/concepts/message.md index b6f4817..eb957f8 100644 --- a/docs/concepts/message.md +++ b/docs/concepts/message.md @@ -257,6 +257,70 @@ bot.onMention = async (session, message) => { > while others like Mastodon might implement quotes differently or not support > them at all. +### Polls + +*This API is available since BotKit 0.3.0.* + +You can attach a poll to a message by providing +the `~SessionPublishOptionsWithQuestion.poll` option along with +the message class `Question`. The poll option allows users to vote on +different choices. For example: + +~~~~ typescript twoslash +import { type Session, Question, text } from "@fedify/botkit"; +import { Temporal } from "@js-temporal/polyfill"; +const session = {} as unknown as Session; +// ---cut-before--- +await session.publish(text`What's your favorite color?`, { + class: Question, + poll: { + multiple: false, // Single choice poll + options: ["Red", "Blue", "Green"], + endTime: Temporal.Now.instant().add({ hours: 24 }), + }, +}); +~~~~ + +For multiple choice polls, set `~Poll.multiple` to `true`: + +~~~~ typescript twoslash +import { type Session, Question, text } from "@fedify/botkit"; +import { Temporal } from "@js-temporal/polyfill"; +const session = {} as unknown as Session; +// ---cut-before--- +await session.publish(text`Which programming languages do you know?`, { + class: Question, + poll: { + multiple: true, // Multiple choice poll + options: ["JavaScript", "TypeScript", "Python", "Rust"], + endTime: Temporal.Now.instant().add({ hours: 24 * 7 }), + }, +}); +~~~~ + +The poll configuration includes: + +`~Poll.multiple` +: Whether the poll allows multiple selections (`true` for multiple + choice, `false` for single choice). + +`~Poll.options` +: An array of strings representing the poll options. Each option + must be unique and non-empty. + +`~Poll.endTime` +: A [`Temporal.Instant`] representing when the poll closes. + +> [!NOTE] +> Polls are represented as ActivityPub `Question` objects. Not all ActivityPub +> implementations support polls, and the behavior may vary between different +> platforms. + +> [!TIP] +> When someone votes on your bot's poll, the `~Bot.onVote` event handler will +> be called. See the [*Vote* section](./events.md#vote) in the *Events* concept +> document for more information. + Extracting information from a message ------------------------------------- diff --git a/docs/examples.md b/docs/examples.md index 40dadc1..b672cc0 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -25,6 +25,59 @@ BotKit. The bot performs the following actions: ::: +One-time passcode authentication bot +------------------------------------ + +This example demonstrates how to implement an emoji-based one-time passcode +authentication system using BotKit's poll functionality. The bot provides +a simple two-factor authentication mechanism through the fediverse. + +The authentication flow works as follows: + +1. *Initial setup*: The user visits the web interface and enters their fediverse + handle (e.g., `@username@server.com`). + +2. *Challenge generation*: The system generates a random set of emojis and sends + a direct message containing a poll with all available emoji options to + the user's fediverse account. + +3. *Web interface display*: The correct emoji sequence is displayed on the + web page. + +4. *User response*: The user votes for the matching emojis in the poll they + received via direct message. + +5. *Verification*: The system verifies that the user selected exactly + the same emojis shown on the web page. + +6. *Authentication result*: If the emoji selection matches, authentication is + successful. + +Key features: + + - Uses BotKit's [poll functionality](./concepts/message.md#polls) for secure + voting + - Implements a 15-minute expiration for both the challenge and authentication + attempts + - Provides a clean web interface using [Hono] framework and [Pico CSS] + - Stores temporary data using [Deno KV] for session management + - Supports both direct message delivery and real-time vote tracking + +This example showcases how to combine ActivityPub's social features with web +authentication, demonstrating BotKit's capability to bridge fediverse +interactions with traditional web applications. + +::: code-group + +<<< @/../examples/otp.tsx [otp.tsx] + +::: + +[Hono]: https://hono.dev/ +[Pico CSS]: https://picocss.com/ +[Deno KV]: https://deno.com/kv + + FediChatBot ----------- diff --git a/docs/package.json b/docs/package.json index 68cbcc5..660eacf 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,6 +4,7 @@ "@fedify/fedify": "catalog:", "@fedify/postgres": "^0.3.0", "@fedify/redis": "^0.4.0", + "@js-temporal/polyfill": "^0.5.1", "@shikijs/vitepress-twoslash": "^3.7.0", "@types/deno": "^2.3.0", "@types/node": "^24.0.3", diff --git a/examples/otp.tsx b/examples/otp.tsx new file mode 100644 index 0000000..74609d9 --- /dev/null +++ b/examples/otp.tsx @@ -0,0 +1,231 @@ +/** @jsx react-jsx */ +/** @jsxImportSource hono/jsx */ +import { createBot, isActor, Question, text } from "@fedify/botkit"; +import { DenoKvMessageQueue, DenoKvStore } from "@fedify/fedify/x/denokv"; +import { Hono } from "hono"; +import type { FC } from "hono/jsx"; +import { getXForwardedRequest } from "x-forwarded-fetch"; + +const kv = await Deno.openKv(); + +const bot = createBot({ + username: "otp", + name: "OTP Bot", + summary: + text`This bot provides a simple one-time passcode authentication using emojis.`, + icon: new URL("https://botkit.fedify.dev/favicon-192x192.png"), + kv: new DenoKvStore(kv), + queue: new DenoKvMessageQueue(kv), +}); + +bot.onVote = async (_session, vote) => { + const recipient = await kv.get(["recipients", vote.message.id.href]); + if (recipient?.value !== vote.actor.id?.href) return; + await kv.set(["votes", vote.message.id.href, vote.option], vote.option, { + expireIn: 15 * 60 * 1000, // 15 minutes + }); +}; + +const EMOJI_CODES = [ + "🌈", + "🌟", + "🌸", + "πŸ€", + "πŸ‰", + "🍦", + "🍿", + "🎈", + "πŸŽ‰", + "🎨", + "🐒", + "🐬", + "πŸ‘»", + "πŸ‘Ύ", + "πŸ’Ž", + "πŸ”₯", +]; + +function generateRandomEmojis(): readonly string[] { + // Generate a random 16-bit number (except for zero): + const randomBytes = new Uint8Array(2); + while (true) { + crypto.getRandomValues(randomBytes); + // Regenerate if the number is zero: + if (randomBytes[0] !== 0 || randomBytes[1] !== 0) break; + } + // Turn the 16-bit number into 16 emojis, e.g., + // 1000_1000_1001_0000 becomes ["🌟","πŸ‰", "πŸŽ‰", "🐬"]: + const emojis: string[] = []; + for (let i = 0; i < 16; i++) { + // Get the i-th bit from the random number: + const bit = (randomBytes[i >> 3] >> (7 - (i & 0b111))) & 1; + // If the bit is 1, add the corresponding emoji to the array: + if (bit === 1) emojis.push(EMOJI_CODES[i]); + } + return emojis; +} + +const Layout: FC = (props) => { + return ( + + + + OTP bot + + + +
+ {props.children} +
+ + + ); +}; + +const Form: FC = () => { + return ( + +
+

OTP Demo using BotKit

+

+ This demo shows how to create a simple emoji-based one-time passcode + authentication using BotKit. +

+
+
+
+ +
+ +
+
+ ); +}; + +const EmojiCode: FC< + { handle: string; emojis: readonly string[]; messageId: URL } +> = ( + props, +) => { + return ( + +
+

A direct message has been sent

+

+ A direct message has been sent to{" "} + {props.handle}. Please choose the emojis below to + authenticate: +

+
+
    + {props.emojis.map((emoji) => ( +
  • {emoji}
  • + ))} +
+
+ + +
+
+ ); +}; + +const Result: FC<{ authenticated: boolean }> = (props) => { + return ( + +
+

+ {props.authenticated ? "Authenticated" : "Authentication failed"} +

+ {props.authenticated + ?

You have successfully authenticated!

+ :

Authentication failed. Please try again.

} +
+
+ ); +}; + +const app = new Hono(); + +app.get("/", (c) => { + return c.html(
); +}); + +app.post("/otp", async (c) => { + const form = await c.req.formData(); + const handle = form.get("handle")?.toString(); + if (handle == null) return c.notFound(); + const emojis = generateRandomEmojis(); + const session = bot.getSession(c.req.url); + const recipient = await session.context.lookupObject(handle); + if (!isActor(recipient)) return c.notFound(); + const message = await session.publish( + text`${recipient} Please choose the only emojis you see in the web page to authenticate:`, + { + visibility: "direct", + class: Question, + poll: { + multiple: true, + options: EMOJI_CODES, + endTime: Temporal.Now.instant().add({ minutes: 15 }), + }, + }, + ); + await kv.set(["emojis", message.id.href], emojis, { + expireIn: 15 * 60 * 1000, // 15 minutes + }); + await kv.set(["recipients", message.id.href], recipient.id?.href, { + expireIn: 15 * 60 * 1000, // 15 minutes + }); + return c.html( + , + ); +}); + +app.post("/authenticate", async (c) => { + const form = await c.req.formData(); + const messageId = form.get("messageId")?.toString(); + if (messageId == null) return c.notFound(); + const key = await kv.get(["emojis", messageId]); + if (key?.value == null) return c.notFound(); + const emojis = new Set(key.value); + const answer = new Set(); + for await (const entry of kv.list({ prefix: ["votes", messageId] })) { + if (entry.key.length < 3 || typeof entry.key[2] !== "string") continue; + answer.add(entry.key[2]); + } + const authenticated = answer.size === emojis.size && + answer.difference(emojis).size === 0; + return c.html(); +}); + +export default { + async fetch(request: Request): Promise { + request = await getXForwardedRequest(request); + const url = new URL(request.url); + if ( + url.pathname.startsWith("/.well-known/") || + url.pathname.startsWith("/ap/") + ) { + return await bot.fetch(request); + } + return await app.fetch(request); + }, +}; diff --git a/package.json b/package.json index 3f54e38..cb1e4ca 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,14 @@ "import": "./dist/message.js", "require": "./dist/message.cjs" }, + "./poll": { + "types": { + "import": "./dist/poll.d.ts", + "require": "./dist/poll.d.cts" + }, + "import": "./dist/poll.js", + "require": "./dist/poll.cjs" + }, "./reaction": { "types": { "import": "./dist/reaction.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db2a74d..76849ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@fedify/fedify': - specifier: ^1.6.2 - version: 1.6.2 + specifier: ^1.8.0-dev.910 + version: 1.8.0-dev.910 importers: @@ -16,7 +16,7 @@ importers: dependencies: '@fedify/fedify': specifier: 'catalog:' - version: 1.6.2(web-streams-polyfill@3.3.3) + version: 1.8.0-dev.910(web-streams-polyfill@3.3.3) '@fedify/markdown-it-hashtag': specifier: ^0.3.0 version: 0.3.0 @@ -65,13 +65,16 @@ importers: version: link:.. '@fedify/fedify': specifier: 'catalog:' - version: 1.6.2(web-streams-polyfill@3.3.3) + version: 1.8.0-dev.910(web-streams-polyfill@3.3.3) '@fedify/postgres': specifier: ^0.3.0 version: 0.3.0(web-streams-polyfill@3.3.3) '@fedify/redis': specifier: ^0.4.0 version: 0.4.0(web-streams-polyfill@3.3.3) + '@js-temporal/polyfill': + specifier: ^0.5.1 + version: 0.5.1 '@shikijs/vitepress-twoslash': specifier: ^3.7.0 version: 3.7.0(typescript@5.8.3) @@ -407,8 +410,8 @@ packages: resolution: {integrity: sha512-gGD8+mwkLsavBoj/qyfhMD8Tnv9+hid59NrQ6ZrD5Zn5rWvq4LleU09GF78OqPWdSLY03ihxb6+EArcsb5VZCA==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=20.0.0'} - '@fedify/fedify@1.6.2': - resolution: {integrity: sha512-1MXn91B0EqPmkk+0idTAw4fZCpYG1E/6gxs2Brn+iaIEwR4vAvQc3PqHMAKQOK8vfc9R/TBHh5myZRpkoBkVaw==} + '@fedify/fedify@1.8.0-dev.910': + resolution: {integrity: sha512-m3b87E8e3z6r5eErhXUN6zhEko3bTTanO9v+91hUzOmGGhYq7UoLOSfQqmWbJEemG808/D1CoAy6aoRij7ivrQ==} engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} '@fedify/markdown-it-hashtag@0.3.0': @@ -487,9 +490,6 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@logtape/logtape@0.11.0': - resolution: {integrity: sha512-CV14Jf+gXCdgICvMZbkUOrVPJ2eBuLFaacEYJ3vI6ohv6n2mVepakxTfuZNhtWYVeIB1EblRRQNkFs/n5vFYqA==} - '@logtape/logtape@0.8.2': resolution: {integrity: sha512-KikaMHi64p0BHYrYOE2Lom4dOE3R8PGT+21QJ5Ql/SWy0CNOp69dkAlG9RXzENQ6PAMWtiU+4kelJYNvfUvHOQ==} @@ -1096,8 +1096,8 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - es-toolkit@1.39.4: - resolution: {integrity: sha512-hHqQ0yJERMNrJUyYHnf02qDuIxjRnnJlx1CFdR9Ia6tw6jPA7kXmb+tWzc7trJDHwMsc393hZ/m2XMxYXGAfqQ==} + es-toolkit@1.39.5: + resolution: {integrity: sha512-z9V0qU4lx1TBXDNFWfAASWk6RNU6c6+TJBKE+FLIg8u0XJ6Yw58Hi0yX8ftEouj6p1QARRlXLFfHbIli93BdQQ==} esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} @@ -2211,19 +2211,19 @@ snapshots: transitivePeerDependencies: - web-streams-polyfill - '@fedify/fedify@1.6.2(web-streams-polyfill@3.3.3)': + '@fedify/fedify@1.8.0-dev.910(web-streams-polyfill@3.3.3)': dependencies: '@cfworker/json-schema': 4.1.1 - '@es-toolkit/es-toolkit': es-toolkit@1.39.4 '@hugoalh/http-header-link': 1.0.3 '@js-temporal/polyfill': 0.5.1 - '@logtape/logtape': 0.11.0 + '@logtape/logtape': 1.0.0 '@multiformats/base-x': 4.0.1 '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.34.0 '@phensley/language-tag': 1.12.2 asn1js: 3.0.6 byte-encodings: 1.0.11 + es-toolkit: 1.39.5 json-canon: 1.0.1 jsonld: 8.3.3(web-streams-polyfill@3.3.3) multicodec: 3.2.1 @@ -2336,8 +2336,6 @@ snapshots: dependencies: jsbi: 4.3.2 - '@logtape/logtape@0.11.0': {} - '@logtape/logtape@0.8.2': {} '@logtape/logtape@0.9.2': {} @@ -2891,7 +2889,7 @@ snapshots: entities@4.5.0: {} - es-toolkit@1.39.4: {} + es-toolkit@1.39.5: {} esbuild@0.21.5: optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3db5ec9..6fc6c6c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,4 @@ packages: - docs catalog: - "@fedify/fedify": ^1.6.2 + "@fedify/fedify": ^1.8.0-dev.910 diff --git a/src/bot-impl.test.ts b/src/bot-impl.test.ts index a207276..d682ec7 100644 --- a/src/bot-impl.test.ts +++ b/src/bot-impl.test.ts @@ -20,6 +20,7 @@ import { type Actor, Announce, Article, + Collection, Create, CryptographicKey, Emoji, @@ -33,10 +34,12 @@ import { Place, PropertyValue, PUBLIC_COLLECTION, + Question, type Recipient, Reject, Service, Undo, + Update, } from "@fedify/fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; @@ -45,6 +48,7 @@ import { parseSemVer } from "./bot.ts"; import type { CustomEmoji } from "./emoji.ts"; import type { FollowRequest } from "./follow.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; +import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; import { MemoryRepository } from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; @@ -2513,4 +2517,304 @@ function createMockInboxContext( return ctx; } +test("BotImpl.onVote()", async (t) => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + + // Create a poll first + const pollId = "01950000-0000-7000-8000-000000000000"; + const poll = new Create({ + id: new URL(`https://example.com/ap/create/${pollId}`), + actor: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + object: new Question({ + id: new URL(`https://example.com/ap/question/${pollId}`), + attribution: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + content: "What's your favorite color?", + inclusiveOptions: [], + exclusiveOptions: [ + new Note({ name: "Red", replies: new Collection({ totalItems: 0 }) }), + new Note({ name: "Blue", replies: new Collection({ totalItems: 0 }) }), + new Note({ name: "Green", replies: new Collection({ totalItems: 0 }) }), + ], + endTime: Temporal.Now.instant().add({ hours: 24 }), + voters: 0, + }), + published: Temporal.Now.instant(), + }); + await repository.addMessage(pollId, poll); + + // Create a voter + const voter = new Person({ + id: new URL("https://hollo.social/@alice"), + preferredUsername: "alice", + }); + + let receivedVote: Vote | null = null; + bot.onVote = (_session, vote) => { + receivedVote = vote; + }; + + await t.test("vote on single choice poll", async () => { + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + receivedVote = null; + + // Create a vote + const voteCreate = new Create({ + id: new URL("https://example.com/ap/create/vote1"), + actor: voter, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://example.com/ap/note/vote1"), + attribution: voter, + to: PUBLIC_COLLECTION, + name: "Red", + replyTarget: poll.objectId, + content: "Red", + }), + published: Temporal.Now.instant(), + }); + + // Process the vote + await bot.onCreated(ctx, voteCreate); + + // Check that onVote was called + assert.ok(receivedVote != null, "onVote should have been called"); + const vote = receivedVote as Vote; + assert.deepStrictEqual(vote.actor.id, voter.id); + assert.deepStrictEqual(vote.option, "Red"); + assert.deepStrictEqual(vote.poll.multiple, false); + assert.deepStrictEqual(vote.poll.options, ["Red", "Blue", "Green"]); + + // Check that Update activity was sent + assert.ok(ctx.sentActivities.length > 0, "Update activity should be sent"); + const updateActivity = ctx.sentActivities.find( + ({ activity }: { activity: Activity }) => activity instanceof Update, + ); + assert.ok(updateActivity != null, "Update activity should be found"); + assert.ok(updateActivity.activity instanceof Update); + assert.deepStrictEqual( + updateActivity.activity.objectId, + poll.id, + ); + + // Check that vote count was updated in repository + const updatedPoll = await repository.getMessage(pollId); + assert.ok(updatedPoll instanceof Create); + const updatedQuestion = await updatedPoll.getObject(ctx); + assert.ok(updatedQuestion instanceof Question); + const updatedOptions = await Array.fromAsync( + updatedQuestion.getExclusiveOptions(ctx), + ); + const redOption = updatedOptions.find((opt) => + opt.name?.toString() === "Red" + ); + assert.ok(redOption != null); + const replies = await redOption.getReplies(ctx); + assert.deepStrictEqual(replies?.totalItems, 1); + }); + + await t.test("vote on multiple choice poll", async () => { + // Create a multiple choice poll + const multiPollId = "01950000-0000-7000-8000-000000000001"; + const multiPoll = new Create({ + id: new URL(`https://example.com/ap/create/${multiPollId}`), + actor: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + object: new Question({ + id: new URL(`https://example.com/ap/question/${multiPollId}`), + attribution: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + content: "Which languages do you know?", + inclusiveOptions: [ + new Note({ + name: "JavaScript", + replies: new Collection({ totalItems: 0 }), + }), + new Note({ + name: "TypeScript", + replies: new Collection({ totalItems: 0 }), + }), + new Note({ + name: "Python", + replies: new Collection({ totalItems: 0 }), + }), + ], + exclusiveOptions: [], + endTime: Temporal.Now.instant().add({ hours: 24 }), + voters: 0, + }), + published: Temporal.Now.instant(), + }); + await repository.addMessage(multiPollId, multiPoll); + + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + receivedVote = null; + + // Create a vote for JavaScript + const jsVoteCreate = new Create({ + id: new URL("https://example.com/ap/create/vote2"), + actor: voter, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://example.com/ap/note/vote2"), + attribution: voter, + to: PUBLIC_COLLECTION, + name: "JavaScript", + replyTarget: multiPoll.objectId, + content: "JavaScript", + }), + published: Temporal.Now.instant(), + }); + + // Process the vote + await bot.onCreated(ctx, jsVoteCreate); + + // Check that onVote was called + assert.ok(receivedVote != null, "onVote should have been called"); + const vote = receivedVote as Vote; + assert.deepStrictEqual(vote.actor.id, voter.id); + assert.deepStrictEqual(vote.option, "JavaScript"); + assert.deepStrictEqual(vote.poll.multiple, true); + assert.deepStrictEqual(vote.poll.options, [ + "JavaScript", + "TypeScript", + "Python", + ]); + }); + + await t.test("ignore vote from poll author", async () => { + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + receivedVote = null; + + // Create a vote from the bot itself + const selfVoteCreate = new Create({ + id: new URL("https://example.com/ap/create/vote3"), + actor: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://example.com/ap/note/vote3"), + attribution: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + name: "Red", + replyTarget: poll.objectId, + content: "Red", + }), + published: Temporal.Now.instant(), + }); + + // Process the vote + await bot.onCreated(ctx, selfVoteCreate); + + // Check that onVote was NOT called + assert.deepStrictEqual( + receivedVote, + null, + "onVote should not be called for poll author", + ); + }); + + await t.test("ignore vote on expired poll", async () => { + // Create an expired poll + const expiredPollId = "01950000-0000-7000-8000-000000000002"; + const expiredPoll = new Create({ + id: new URL(`https://example.com/ap/create/${expiredPollId}`), + actor: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + object: new Question({ + id: new URL(`https://example.com/ap/question/${expiredPollId}`), + attribution: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + content: "Expired poll?", + inclusiveOptions: [], + exclusiveOptions: [ + new Note({ name: "Yes", replies: new Collection({ totalItems: 0 }) }), + new Note({ name: "No", replies: new Collection({ totalItems: 0 }) }), + ], + endTime: Temporal.Now.instant().subtract({ hours: 1 }), // Expired + voters: 0, + }), + published: Temporal.Now.instant(), + }); + await repository.addMessage(expiredPollId, expiredPoll); + + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + receivedVote = null; + + // Create a vote on expired poll + const expiredVoteCreate = new Create({ + id: new URL("https://example.com/ap/create/vote4"), + actor: voter, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://example.com/ap/note/vote4"), + attribution: voter, + to: PUBLIC_COLLECTION, + name: "Yes", + replyTarget: expiredPoll.objectId, + content: "Yes", + }), + published: Temporal.Now.instant(), + }); + + // Process the vote + await bot.onCreated(ctx, expiredVoteCreate); + + // Check that onVote was NOT called + assert.deepStrictEqual( + receivedVote, + null, + "onVote should not be called for expired poll", + ); + }); + + await t.test("ignore vote with invalid option", async () => { + ctx.sentActivities = []; + ctx.forwardedRecipients = []; + receivedVote = null; + + // Create a vote with invalid option + const invalidVoteCreate = new Create({ + id: new URL("https://example.com/ap/create/vote5"), + actor: voter, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://example.com/ap/note/vote5"), + attribution: voter, + to: PUBLIC_COLLECTION, + name: "Purple", // Not a valid option + replyTarget: poll.objectId, + content: "Purple", + }), + published: Temporal.Now.instant(), + }); + + // Process the vote + await bot.onCreated(ctx, invalidVoteCreate); + + // Check that onVote was NOT called + assert.deepStrictEqual( + receivedVote, + null, + "onVote should not be called for invalid option", + ); + }); +}); + // cSpell: ignore thumbsup diff --git a/src/bot-impl.ts b/src/bot-impl.ts index aaf5b3b..4a6b75e 100644 --- a/src/bot-impl.ts +++ b/src/bot-impl.ts @@ -25,6 +25,7 @@ import { type Recipient, type RequestContext, type Software, + Update, } from "@fedify/fedify"; import { Accept, @@ -79,6 +80,7 @@ import type { UndoneReactionEventHandler, UnfollowEventHandler, UnlikeEventHandler, + VoteEventHandler, } from "./events.ts"; import { FollowRequestImpl } from "./follow-impl.ts"; import { @@ -90,6 +92,7 @@ import { } from "./message-impl.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; import { app } from "./pages.tsx"; +import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; import { KvRepository, type Repository, type Uuid } from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; @@ -134,6 +137,7 @@ export class BotImpl implements Bot { onUnlike?: UnlikeEventHandler; onReact?: ReactionEventHandler; onUnreact?: UndoneReactionEventHandler; + onVote?: VoteEventHandler; constructor(options: BotImplOptions) { this.identifier = options.identifier ?? "bot"; @@ -650,7 +654,8 @@ export class BotImpl implements Bot { const object = await create.getObject(ctx); if ( !(object instanceof Article || object instanceof ChatMessage || - object instanceof Note || object instanceof Question) + object instanceof Note || object instanceof Question) || + object.attributionId?.href !== create.actorId?.href ) { return; } @@ -661,6 +666,130 @@ export class BotImpl implements Bot { return messageCache = await createMessage(object, session, {}); }; const replyTarget = ctx.parseUri(object.replyTargetId); + if ( + this.onVote != null && + object instanceof Note && replyTarget?.type === "object" && + // @ts-ignore: replyTarget.class satisfies (typeof messageClasses)[number] + messageClasses.includes(replyTarget.class) && + object.name != null + ) { + if ( + create.actorId == null || create.actorId.href === session.actorId.href + ) { + return; + } + const actorId = create.actorId; + const actor = await create.getActor(ctx); + if (actor == null) return; + const messageId = replyTarget.values.id as Uuid; + const pollMessage = await this.repository.getMessage(messageId); + if (!(pollMessage instanceof Create)) return; + const question = await pollMessage.getObject(ctx); + if ( + !(question instanceof Question) || question.endTime == null || + Temporal.Instant.compare(question.endTime, Temporal.Now.instant()) < 0 + ) { + return; + } + const optionNotes: Note[] = []; + const options: string[] = []; + for await (const note of question.getInclusiveOptions(ctx)) { + if (!(note instanceof Note)) continue; + optionNotes.push(note); + if (note.name != null) options.push(note.name.toString()); + } + const multiple = options.length > 0; + for await (const note of question.getExclusiveOptions(ctx)) { + if (!(note instanceof Note)) continue; + optionNotes.push(note); + if (note.name != null) options.push(note.name.toString()); + } + const option = object.name.toString(); + if (!options.includes(option)) return; + let updatedQuestion: Question = question; + let updatedPollMessage = pollMessage; + await this.repository.vote(messageId, actorId, option); + await this.repository.updateMessage( + replyTarget.values.id as Uuid, + async () => { + const votes = await this.repository.countVotes(messageId); + const updatedOptionNotes: Note[] = [...optionNotes]; + let i = 0; + for (const note of updatedOptionNotes) { + if (note.name != null) { + const replies = await note.getReplies(ctx); + if (replies != null && replies.totalItems != null) { + updatedOptionNotes[i] = note.clone({ + replies: replies.clone({ + totalItems: votes[note.name.toString()], + }), + }); + } + } + i++; + } + updatedQuestion = question.clone({ + inclusiveOptions: multiple ? updatedOptionNotes : [], + exclusiveOptions: !multiple ? updatedOptionNotes : [], + voters: await this.repository.countVoters(messageId), + }); + return updatedPollMessage = pollMessage.clone({ + object: updatedQuestion, + }); + }, + ); + const message = await createMessage(updatedQuestion, session, {}); + const vote: Vote = { + raw: object, + actor, + message, + poll: { + multiple, + options, + endTime: question.endTime, + }, + option, + }; + await this.onVote(session, vote); + const update = new Update({ + id: new URL( + `#update-votes/${crypto.randomUUID()}`, + updatedQuestion.id ?? ctx.origin, + ), + actor: ctx.getActorUri(this.identifier), + object: updatedPollMessage.id, + tos: updatedPollMessage.toIds, + ccs: updatedPollMessage.ccIds, + }); + if (message.visibility === "direct") { + await ctx.forwardActivity(this, [...message.mentions], { + skipIfUnsigned: true, + excludeBaseUris: [new URL(ctx.origin)], + }); + await ctx.sendActivity( + this, + [...message.mentions], + update, + { excludeBaseUris: [new URL(ctx.origin)] }, + ); + } else { + await ctx.forwardActivity(this, "followers", { + skipIfUnsigned: true, + preferSharedInbox: true, + excludeBaseUris: [new URL(ctx.origin)], + }); + await ctx.sendActivity( + this, + "followers", + update, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(ctx.origin)], + }, + ); + } + return; + } if ( this.onReply != null && replyTarget?.type === "object" && diff --git a/src/bot.ts b/src/bot.ts index beadf36..e1b5be7 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -37,6 +37,7 @@ import type { UndoneReactionEventHandler, UnfollowEventHandler, UnlikeEventHandler, + VoteEventHandler, } from "./events.ts"; import type { Repository } from "./repository.ts"; import type { Session } from "./session.ts"; @@ -177,6 +178,21 @@ export interface Bot { * @since 0.2.0 */ onUnreact?: UndoneReactionEventHandler; + + /** + * An event handler for a vote in a poll. This event is only triggered when + * the bot is the author of the poll, and the vote is made by another actor. + * If the poll allows multiple selections, this event is triggered multiple + * times, once for each option selected by the actor. + * + * Note that this event can be triggered even if the voter vote an option + * multiple times or multiple options for a poll that disallows multiple + * selections. You should validate the vote in the event handler by storing + * the votes in a persistent store, and checking if the vote is valid. + * (This behavior can subject to change in the future.) + * @since 0.3.0 + */ + onVote?: VoteEventHandler; } /** @@ -474,6 +490,12 @@ export function createBot( set onUnreact(value) { bot.onUnreact = value; }, + get onVote() { + return bot.onVote; + }, + set onVote(value) { + bot.onVote = value; + }, } satisfies Bot & { impl: BotImpl }; // @ts-ignore: the wrapper implements BotWithVoidContextData return wrapper; diff --git a/src/events.ts b/src/events.ts index 2d137dc..a54e98f 100644 --- a/src/events.ts +++ b/src/events.ts @@ -16,6 +16,7 @@ import type { Actor } from "@fedify/fedify/vocab"; import type { FollowRequest } from "./follow.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; +import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; import type { Session } from "./session.ts"; @@ -166,3 +167,18 @@ export type UndoneReactionEventHandler = ( session: Session, reaction: Reaction, ) => void | Promise; + +/** + * An event handler for a vote in a poll. This event is only triggered when + * the bot is the author of the poll, and the vote is made by another actor. + * Note that if the poll allows multiple selections, this event is triggered + * multiple times, once for each option selected by the actor. + * @typeParam TContextData The type of the context data. + * @param session The session of the bot. + * @param vote The vote made by another actor in the poll. + * @since 0.3.0 + */ +export type VoteEventHandler = ( + session: Session, + vote: Vote, +) => void | Promise; diff --git a/src/mod.ts b/src/mod.ts index 58502e3..6df3de3 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -64,6 +64,7 @@ export type { MessageVisibility, SharedMessage, } from "./message.ts"; +export type { Poll, Vote } from "./poll.ts"; export { type AuthorizedLike, type AuthorizedReaction, diff --git a/src/poll.ts b/src/poll.ts new file mode 100644 index 0000000..7097dc8 --- /dev/null +++ b/src/poll.ts @@ -0,0 +1,75 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { Actor, Note, Question } from "@fedify/fedify/vocab"; +import type { Message } from "./message.ts"; + +/** + * An interface representing a poll. + * @since 0.3.0 + */ +export interface Poll { + /** + * Whether the poll allows multiple selections. + */ + readonly multiple: boolean; + + /** + * The options of the poll. Each option is a string. **Every option must be + * unique, and must not be empty.** + */ + readonly options: readonly string[]; + + /** + * The time when the poll ends. + */ + readonly endTime: Temporal.Instant; +} + +/** + * An interface representing a vote in a poll. Note that if the poll allows + * multiple selections, the options are represented as multiple {@link Vote} + * objects, each with a single option. + * @typeParam TContextData The type of the context data. + * @since 0.3.0 + */ +export interface Vote { + /** + * The underlying raw note object. + */ + readonly raw: Note; + + /** + * The actor who voted. + */ + readonly actor: Actor; + + /** + * The question to which the poll belongs. + */ + readonly message: Message; + + /** + * The poll to which the vote belongs. + */ + readonly poll: Poll; + + /** + * The options selected by the actor. Note that this is a string even + * if the poll allows multiple selections. If the poll allows multiple + * selections, the options are represented as multiple {@link Vote} objects. + */ + readonly option: string; +} diff --git a/src/repository.test.ts b/src/repository.test.ts index 2983a9a..93fa3a2 100644 --- a/src/repository.test.ts +++ b/src/repository.test.ts @@ -689,5 +689,85 @@ for (const name in factories) { await repo.removeFollowee(followeeId); assert.deepStrictEqual(await repo.getFollowee(followeeId), undefined); }); + + test("poll voting", async () => { + const messageId1 = "01945678-1234-7890-abcd-ef0123456789"; + const messageId2 = "01945678-5678-7890-abcd-ef0123456789"; + const voter1 = new URL("https://example.com/ap/actor/alice"); + const voter2 = new URL("https://example.com/ap/actor/bob"); + const voter3 = new URL("https://example.com/ap/actor/charlie"); + + // Initially, no votes exist + assert.deepStrictEqual(await repo.countVoters(messageId1), 0); + assert.deepStrictEqual(await repo.countVotes(messageId1), {}); + assert.deepStrictEqual(await repo.countVoters(messageId2), 0); + assert.deepStrictEqual(await repo.countVotes(messageId2), {}); + + // Single voter, single option + await repo.vote(messageId1, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters(messageId1), 1); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 1, + }); + + // Same voter votes for same option again (should be ignored) + await repo.vote(messageId1, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters(messageId1), 1); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 1, + }); + + // Same voter votes for different option (multiple choice) + await repo.vote(messageId1, voter1, "option2"); + assert.deepStrictEqual(await repo.countVoters(messageId1), 1); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 1, + "option2": 1, + }); + + // Different voter votes for same option + await repo.vote(messageId1, voter2, "option1"); + assert.deepStrictEqual(await repo.countVoters(messageId1), 2); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 2, + "option2": 1, + }); + + // Third voter votes for new option + await repo.vote(messageId1, voter3, "option3"); + assert.deepStrictEqual(await repo.countVoters(messageId1), 3); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 2, + "option2": 1, + "option3": 1, + }); + + // Votes for different message should be separate + await repo.vote(messageId2, voter1, "optionA"); + await repo.vote(messageId2, voter2, "optionB"); + assert.deepStrictEqual(await repo.countVoters(messageId2), 2); + assert.deepStrictEqual(await repo.countVotes(messageId2), { + "optionA": 1, + "optionB": 1, + }); + + // Original message votes should remain unchanged + assert.deepStrictEqual(await repo.countVoters(messageId1), 3); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 2, + "option2": 1, + "option3": 1, + }); + + // Test with empty options (edge case) + await repo.vote(messageId1, voter1, ""); + assert.deepStrictEqual(await repo.countVoters(messageId1), 3); + assert.deepStrictEqual(await repo.countVotes(messageId1), { + "option1": 2, + "option2": 1, + "option3": 1, + "": 1, + }); + }); }); } diff --git a/src/repository.ts b/src/repository.ts index 71d42db..f12ee94 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -24,9 +24,12 @@ import { isActor, Object, } from "@fedify/fedify/vocab"; +import { getLogger } from "@logtape/logtape"; export type { KvKey, KvStore } from "@fedify/fedify/federation"; export { Announce, Create } from "@fedify/fedify/vocab"; +const logger = getLogger(["botkit", "repository"]); + /** * A UUID (universally unique identifier). * @since 0.3.0 @@ -190,6 +193,43 @@ export interface Repository { * exist. */ getFollowee(followeeId: URL): Promise; + + /** + * Records a vote in a poll. If the same voter had already voted for the + * same option in a poll, the vote will be silently ignored. + * @param messageId The UUID of the poll message to vote on. + * @param voterId The ID of the voter. It should be a URL of the actor who is + * voting. + * @param option The option that the voter is voting for. It should be one of + * the options in the poll. If the poll allows multiple + * selections, this should be a single option that the voter is + * voting for, which is one of multiple calls to this method. + * @since 0.3.0 + */ + vote(messageId: Uuid, voterId: URL, option: string): Promise; + + /** + * Counts the number of voters in a poll. Even if the poll allows multiple + * selections, each voter is counted only once. + * @param messageId The UUID of the poll message to count voters for. + * @returns The number of voters in the poll. If the poll does not exist, + * 0 will be returned. + * @since 0.3.0 + */ + countVoters(messageId: Uuid): Promise; + + /** + * Counts the votes for each option in a poll. If the poll allows multiple + * selections, each option is counted separately, and the same voter can + * vote for multiple options. + * @param messageId The UUID of the poll message to count votes for. + * @returns A record where the keys are the options and the values are + * the number of votes for each option. If the poll does not exist, + * an empty record will be returned. Some options may not be + * present in the record if no votes were cast for them. + * @since 0.3.0 + */ + countVotes(messageId: Uuid): Promise>>; } /** @@ -279,6 +319,13 @@ export interface KvStoreRepositoryPrefixes { * @default `["_botkit", "follows"]` */ readonly follows: KvKey; + + /** + * The key prefix used for storing poll votes. + * @default `["_botkit", "polls"]` + * @since 0.3.0 + */ + readonly polls: KvKey; } /** @@ -294,6 +341,13 @@ export class KvRepository implements Repository { * @param prefixes The prefixes for key-value store keys. */ constructor(kv: KvStore, prefixes?: KvStoreRepositoryPrefixes) { + if (kv.cas == null) { + logger.warn( + "The given KvStore {kv} does not support CAS operations. " + + "This may cause issues with concurrent updates.", + { kv }, + ); + } this.kv = kv; this.prefixes = { keyPairs: ["_botkit", "keyPairs"], @@ -302,6 +356,7 @@ export class KvRepository implements Repository { followRequests: ["_botkit", "followRequests"], followees: ["_botkit", "followees"], follows: ["_botkit", "follows"], + polls: ["_botkit", "polls"], ...prefixes ?? {}, }; } @@ -596,6 +651,90 @@ export class KvRepository implements Repository { return undefined; } } + + async vote(messageId: Uuid, voterId: URL, option: string): Promise { + const key: KvKey = [...this.prefixes.polls, messageId, option]; + while (true) { + const prev = await this.kv.get(key); + if (prev != null && prev.includes(voterId.href)) return; + const next = prev == null ? [voterId.href] : [...prev, voterId.href]; + if (this.kv.cas == null) { + this.kv.set(key, next); + break; + } else { + const success = await this.kv.cas(key, prev, next); + if (success) break; + // If the CAS operation failed, we retry to ensure the vote is recorded. + logger.trace( + "CAS operation failed, retrying vote for {messageId} by {voterId} for option {option}.", + { + messageId, + voterId: voterId.href, + option, + }, + ); + } + } + const optionsKey: KvKey = [...this.prefixes.polls, messageId]; + while (true) { + const prevOptions = await this.kv.get(optionsKey); + if (prevOptions != null && prevOptions.includes(option)) return; + const nextOptions = prevOptions == null + ? [option] + : [...prevOptions, option]; + if (this.kv.cas == null) { + this.kv.set(optionsKey, nextOptions); + break; + } else { + const success = await this.kv.cas(optionsKey, prevOptions, nextOptions); + if (success) break; + // If the CAS operation failed, we retry to ensure the option is recorded. + logger.trace( + "CAS operation failed, retrying to add option {option} for message {messageId}.", + { + option, + messageId, + }, + ); + } + } + } + + async countVoters(messageId: Uuid): Promise { + const options = await this.kv.get([ + ...this.prefixes.polls, + messageId, + ]) ?? []; + const result = new Set(); + for (const option of options) { + const voters = await this.kv.get([ + ...this.prefixes.polls, + messageId, + option, + ]); + if (voters != null) { + for (const voter of voters) result.add(voter); + } + } + return result.size; + } + + async countVotes(messageId: Uuid): Promise>> { + const options = await this.kv.get([ + ...this.prefixes.polls, + messageId, + ]) ?? []; + const result: Record = {}; + for (const option of options) { + const voters = await this.kv.get([ + ...this.prefixes.polls, + messageId, + option, + ]); + result[option] = voters == null ? 0 : voters.length; + } + return result; + } } interface KeyPair { @@ -610,7 +749,7 @@ interface KeyPair { * @internal */ function extractTimestamp(uuid: string): number { - // UUIDv7 format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx + // UUIDv7 format: xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx // The timestamp is in the first 6 bytes (48 bits) of the UUID. if (uuid.length !== 36 || uuid[14] !== "7") { throw new TypeError("Invalid UUIDv7 format."); @@ -630,6 +769,7 @@ export class MemoryRepository implements Repository { followRequests: Record = {}; sentFollows: Record = {}; followees: Record = {}; + polls: Record>> = {}; setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { this.keyPairs = keyPairs; @@ -781,6 +921,33 @@ export class MemoryRepository implements Repository { getFollowee(followeeId: URL): Promise { return Promise.resolve(this.followees[followeeId.href]); } + + vote(messageId: Uuid, voterId: URL, option: string): Promise { + const poll = this.polls[messageId] ??= {}; + const voters = poll[option] ??= new Set(); + voters.add(voterId.href); + return Promise.resolve(); + } + + countVoters(messageId: Uuid): Promise { + const poll = this.polls[messageId]; + if (poll == null) return Promise.resolve(0); + let voters = new Set(); + for (const votersSet of globalThis.Object.values(poll)) { + voters = voters.union(votersSet); + } + return Promise.resolve(voters.size); + } + + countVotes(messageId: Uuid): Promise>> { + const poll = this.polls[messageId]; + if (poll == null) return Promise.resolve({}); + const counts: Record = {}; + for (const [option, voters] of globalThis.Object.entries(poll)) { + counts[option] = voters.size; + } + return Promise.resolve(counts); + } } /** @@ -970,4 +1137,21 @@ export class MemoryCachedRepository implements Repository { } return follow; } + + async vote(messageId: Uuid, voterId: URL, option: string): Promise { + await this.cache.vote(messageId, voterId, option); + await this.underlying.vote(messageId, voterId, option); + } + + async countVoters(messageId: Uuid): Promise { + const voters = await this.cache.countVoters(messageId); + if (voters > 0) return voters; + return this.underlying.countVoters(messageId); + } + + async countVotes(messageId: Uuid): Promise>> { + const votes = await this.cache.countVotes(messageId); + if (globalThis.Object.keys(votes).length > 0) return votes; + return await this.underlying.countVotes(messageId); + } } diff --git a/src/session-impl.test.ts b/src/session-impl.test.ts index cf958cf..e8f587a 100644 --- a/src/session-impl.test.ts +++ b/src/session-impl.test.ts @@ -21,6 +21,7 @@ import { Note, Person, PUBLIC_COLLECTION, + Question, type Recipient, Undo, } from "@fedify/fedify/vocab"; @@ -470,6 +471,197 @@ test("SessionImpl.publish()", async (t) => { assert.deepStrictEqual(quote.visibility, "public"); assert.deepStrictEqual(quote.quoteTarget?.id, originalMsg.id); }); + + await t.test("poll single choice", async () => { + ctx.sentActivities = []; + const endTime = Temporal.Now.instant().add({ hours: 24 }); + const poll = await session.publish(text`What's your favorite color?`, { + class: Question, + poll: { + multiple: false, + options: ["Red", "Blue", "Green"], + endTime, + }, + }); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, "followers"); + assert.ok(activity instanceof Create); + assert.deepStrictEqual(activity.actorId, ctx.getActorUri(bot.identifier)); + assert.deepStrictEqual(activity.toIds, [PUBLIC_COLLECTION]); + assert.deepStrictEqual(activity.ccIds, [ + ctx.getFollowersUri(bot.identifier), + ]); + const object = await activity.getObject(ctx); + assert.ok(object instanceof Question); + assert.deepStrictEqual( + object.attributionId, + ctx.getActorUri(bot.identifier), + ); + assert.deepStrictEqual(object.toIds, [PUBLIC_COLLECTION]); + assert.deepStrictEqual(object.ccIds, [ctx.getFollowersUri(bot.identifier)]); + assert.deepStrictEqual( + object.content, + "

What's your favorite color?

", + ); + assert.deepStrictEqual(object.endTime, endTime); + assert.deepStrictEqual(object.voters, 0); + assert.deepStrictEqual(object.inclusiveOptionIds, []); + + const exclusiveOptions = await Array.fromAsync( + object.getExclusiveOptions(ctx), + ); + assert.deepStrictEqual(exclusiveOptions.length, 3); + assert.ok(exclusiveOptions[0] instanceof Note); + assert.deepStrictEqual(exclusiveOptions[0].name?.toString(), "Red"); + assert.ok(exclusiveOptions[1] instanceof Note); + assert.deepStrictEqual(exclusiveOptions[1].name?.toString(), "Blue"); + assert.ok(exclusiveOptions[2] instanceof Note); + assert.deepStrictEqual(exclusiveOptions[2].name?.toString(), "Green"); + + for (const option of exclusiveOptions) { + const replies = await option.getReplies(ctx); + assert.deepStrictEqual(replies?.totalItems, 0); + } + + assert.deepStrictEqual(poll.id, object.id); + assert.deepStrictEqual(poll.text, "What's your favorite color?"); + assert.deepStrictEqual( + poll.html, + "

What's your favorite color?

", + ); + assert.deepStrictEqual(poll.visibility, "public"); + }); + + await t.test("poll multiple choice", async () => { + ctx.sentActivities = []; + const endTime = Temporal.Now.instant().add({ hours: 24 * 7 }); + const poll = await session.publish( + text`Which programming languages do you know?`, + { + class: Question, + poll: { + multiple: true, + options: ["JavaScript", "TypeScript", "Python", "Rust"], + endTime, + }, + visibility: "unlisted", + }, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, "followers"); + assert.ok(activity instanceof Create); + const object = await activity.getObject(ctx); + assert.ok(object instanceof Question); + assert.deepStrictEqual(object.endTime, endTime); + assert.deepStrictEqual(object.voters, 0); + assert.deepStrictEqual(object.exclusiveOptionIds, []); + + const inclusiveOptions = await Array.fromAsync( + object.getInclusiveOptions(ctx), + ); + assert.deepStrictEqual(inclusiveOptions.length, 4); + assert.ok(inclusiveOptions[0] instanceof Note); + assert.deepStrictEqual(inclusiveOptions[0].name?.toString(), "JavaScript"); + assert.ok(inclusiveOptions[1] instanceof Note); + assert.deepStrictEqual(inclusiveOptions[1].name?.toString(), "TypeScript"); + assert.ok(inclusiveOptions[2] instanceof Note); + assert.deepStrictEqual(inclusiveOptions[2].name?.toString(), "Python"); + assert.ok(inclusiveOptions[3] instanceof Note); + assert.deepStrictEqual(inclusiveOptions[3].name?.toString(), "Rust"); + + assert.deepStrictEqual(poll.visibility, "unlisted"); + assert.deepStrictEqual(activity.toIds, [ + ctx.getFollowersUri(bot.identifier), + ]); + assert.deepStrictEqual(activity.ccIds, [PUBLIC_COLLECTION]); + }); + + await t.test("poll with direct visibility", async () => { + const mentioned = new Person({ + id: new URL("https://example.com/ap/actor/alice"), + preferredUsername: "alice", + }); + ctx.sentActivities = []; + const endTime = Temporal.Now.instant().add({ hours: 12 }); + const poll = await session.publish( + text`Hey ${mention(mentioned)}, what do you think?`, + { + class: Question, + poll: { + multiple: false, + options: ["Good", "Bad", "Neutral"], + endTime, + }, + visibility: "direct", + }, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [mentioned]); + assert.ok(activity instanceof Create); + const object = await activity.getObject(ctx); + assert.ok(object instanceof Question); + assert.deepStrictEqual(object.toIds, [mentioned.id]); + assert.deepStrictEqual(object.ccIds, []); + assert.deepStrictEqual(poll.visibility, "direct"); + }); + + await t.test("poll end-to-end workflow", async () => { + // Create fresh repository and session for isolation + const freshRepository = new MemoryRepository(); + const freshBot = new BotImpl({ + kv: new MemoryKvStore(), + repository: freshRepository, + username: "testbot", + }); + const freshCtx = createMockContext(freshBot, "https://example.com"); + const freshSession = new SessionImpl(freshBot, freshCtx); + + const endTime = Temporal.Now.instant().add({ hours: 1 }); + + // 1. Create a poll + const poll = await freshSession.publish( + text`What should we have for lunch?`, + { + class: Question, + poll: { + multiple: false, + options: ["Pizza", "Burgers", "Salad"], + endTime, + }, + }, + ); + + // Verify poll was created correctly + assert.deepStrictEqual(freshCtx.sentActivities.length, 1); + const { activity: createActivity } = freshCtx.sentActivities[0]; + assert.ok(createActivity instanceof Create); + const pollObject = await createActivity.getObject(freshCtx); + assert.ok(pollObject instanceof Question); + assert.deepStrictEqual(pollObject.endTime, endTime); + + // Get poll options + const options = await Array.fromAsync( + pollObject.getExclusiveOptions(freshCtx), + ); + assert.deepStrictEqual(options.length, 3); + assert.deepStrictEqual(options[0].name?.toString(), "Pizza"); + assert.deepStrictEqual(options[1].name?.toString(), "Burgers"); + assert.deepStrictEqual(options[2].name?.toString(), "Salad"); + + // 2. Verify poll is accessible via getOutbox + const outbox = freshSession.getOutbox({ order: "newest" }); + const messages = await Array.fromAsync(outbox); + assert.deepStrictEqual(messages.length, 1); + assert.deepStrictEqual(messages[0].id, poll.id); + assert.deepStrictEqual(messages[0].text, "What should we have for lunch?"); + + // 3. Verify poll structure + assert.deepStrictEqual(poll.visibility, "public"); + assert.deepStrictEqual(poll.mentions, []); + }); }); test("SessionImpl.getOutbox()", async (t) => { diff --git a/src/session-impl.ts b/src/session-impl.ts index 837f2a5..0d898ee 100644 --- a/src/session-impl.ts +++ b/src/session-impl.ts @@ -24,19 +24,25 @@ import { type Object, PUBLIC_COLLECTION, } from "@fedify/fedify"; -import { Follow, Link, Undo } from "@fedify/fedify/vocab"; +import { Collection, Follow, Link, Undo } from "@fedify/fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { encode } from "html-entities"; import { v7 as uuidv7 } from "uuid"; import type { BotImpl } from "./bot-impl.ts"; import { createMessage, isMessageObject } from "./message-impl.ts"; -import type { AuthorizedMessage, Message, MessageClass } from "./message.ts"; +import { + type AuthorizedMessage, + type Message, + type MessageClass, + Question, +} from "./message.ts"; import type { Uuid } from "./repository.ts"; import type { Session, SessionGetOutboxOptions, SessionPublishOptions, SessionPublishOptionsWithClass, + SessionPublishOptionsWithQuestion, } from "./session.ts"; import type { Text } from "./text.ts"; @@ -55,6 +61,12 @@ export interface SessionImplPublishOptionsWithClass< SessionImplPublishOptions { } +export interface SessionImplPublishOptionsWithQuestion + extends + SessionPublishOptionsWithQuestion, + SessionImplPublishOptionsWithClass { +} + export class SessionImpl implements Session { readonly bot: BotImpl; readonly context: Context; @@ -206,11 +218,16 @@ export class SessionImpl implements Session { content: Text<"block", TContextData>, options: SessionImplPublishOptionsWithClass, ): Promise>; + async publish( + content: Text<"block", TContextData>, + options: SessionImplPublishOptionsWithQuestion, + ): Promise>; async publish( content: Text<"block", TContextData>, options: | SessionImplPublishOptions - | SessionImplPublishOptionsWithClass = {}, + | SessionImplPublishOptionsWithClass + | SessionImplPublishOptionsWithQuestion = {}, ): Promise> { const published = new Date(); const id = uuidv7({ msecs: +published }) as Uuid; @@ -243,6 +260,31 @@ export class SessionImpl implements Session { }), ); } + let inclusiveOptions: Note[] = []; + let exclusiveOptions: Note[] = []; + let voters: number | null = null; + let endTime: Temporal.Instant | null = null; + if ("class" in options && options.class === Question && "poll" in options) { + if (options.poll.options.length < 2) { + throw new TypeError("At least two options are required in a poll."); + } else if ( + new Set(options.poll.options).size != options.poll.options.length + ) { + throw new TypeError("Duplicate options are not allowed in a poll."); + } else if (options.poll.options.some((o) => o.trim() === "")) { + throw new TypeError("Poll options cannot be empty."); + } + const pollOptions = options.poll.options.map((option) => + new Note({ + name: option, + replies: new Collection({ totalItems: 0 }), + }) + ); + if (options.poll.multiple) inclusiveOptions = pollOptions; + else exclusiveOptions = pollOptions; + voters = 0; + endTime = options.poll.endTime; + } const msg = new cls({ id: this.context.getObjectUri(cls, { id }), contents: options.language == null @@ -253,6 +295,10 @@ export class SessionImpl implements Session { tags, attribution: this.context.getActorUri(this.bot.identifier), attachments: options.attachments ?? [], + inclusiveOptions, + exclusiveOptions, + voters, + endTime, tos: visibility === "public" ? [PUBLIC_COLLECTION, ...mentionedActorIds] : visibility === "unlisted" || visibility === "followers" diff --git a/src/session.ts b/src/session.ts index 1330010..73fccba 100644 --- a/src/session.ts +++ b/src/session.ts @@ -30,6 +30,7 @@ import type { MessageClass, MessageVisibility, } from "./message.ts"; +import type { Poll } from "./poll.ts"; import type { Text } from "./text.ts"; /** @@ -127,6 +128,18 @@ export interface Session { options: SessionPublishOptionsWithClass, ): Promise>; + /** + * Publishes a question attributed to the bot with a poll. + * @param content The content of the question. + * @param options The options for publishing the question. + * @returns The published question. + * @since 0.3.0 + */ + publish( + content: Text<"block", TContextData>, + options: SessionPublishOptionsWithQuestion, + ): Promise>; + /** * Gets messages from the bot's outbox. * @param options The options for getting messages. @@ -184,6 +197,20 @@ export interface SessionPublishOptionsWithClass< : never; } +/** + * Options for publishing a question with a poll. + * @typeParam TContextData The type of the context data. + * @since 0.3.0 + */ +export interface SessionPublishOptionsWithQuestion + extends SessionPublishOptionsWithClass { + /** + * The poll to attach to the question. + * @since 0.3.0 + */ + readonly poll: Poll; +} + /** * Options for getting messages from the bot's outbox. */