Skip to content

Commit

Permalink
fut: add inline cb handler
Browse files Browse the repository at this point in the history
  • Loading branch information
fulcanelly committed Nov 15, 2024
1 parent 62ecf1d commit 4f83b03
Show file tree
Hide file tree
Showing 4 changed files with 933 additions and 725 deletions.
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ currently only telegram API supported, further extension for discrod API possibl
# Content tree
- How to use
- [Adding new state](#adding-new-state)
- Set default / Main state (TODO)
- [Set default / Main state](#default--main-state)
- [Setup](#setup)
- [Ensure prisma config and file structure is right](#ensure-prisma-config-and-file-structure-is-right)
- [Generate models](#generate-models)
- [Apply prisma migrations](#apply-prisma-migrations)
- [Adjust code to supply events to handler](#adjust-code-to-supply-events-to-handler)
- How it works
- How changes in state handled
- Actions
- Avaliable actions
- Modifing actions
- Other features
- Notification / Interrupt state (TODO)
-
- [Inline keyboard handler](#inline-keyboard-handler)
- Adjusting global state

## Adding new state
Expand Down Expand Up @@ -163,6 +165,78 @@ bot.on('message', async ctx => {

# TODO

# Inline keyboard handler

Suppose case when you need inline keyboard for example [like, dislike]

then each button should execute some kind of logic

in this case you can get use of `createCallbackHandle`:

```ts
await bot.sendMessage('you liked that post?', {
reply_markup: {
inline_keyboard: [[
{
text: "Like",
callback_data: await recordingObject.like({post_id: post.id}),
},
{
text: "Dislike",
callback_data: await recordingObject.dislike({post_id: post.id}),
}
]]
}
})
```
where `recordingObject` would be defined like this:
```ts
const recordingObject = createCallbackHandle({
namespace: 'post-likes',
handler: ctx => ({
async like({post_id}: { post_id: number }) {
const user = await User.find(ctx.from.id)
await Post.find(post_id).likeBy(user)
await ctx.editMessageText //...
},

dislike({post_id}: { post_id: number }) {
const user = await User.find(ctx.from.id)
await Post.find(post_id).likeBy(user)
await ctx.editMessageText //...
}
})
})
```

To use this you need to enable handler
```ts
const redis = new Redis() // from ioredis npm
const bot = new Telegraf(process.env.TG_TOKEN)

/// ...

export const { setupCallbackHandler, createCallbackHandle }
= createRedisInlineKeyboardHandler({
redis,
projectName: 'mybot',
ivalidateIn: duration(1, 'day'),
})
// ...
setupCallbackHandler(bot)

```


Each call to recordingObject generates a UUID that is stored in `callback_data` on the Telegram side.
When a button is pressed, the corresponding function is invoked.

On our end, the arguments for the handler function are stored
in **Redis** under the key `${projectName}:callbackquery:${uuid}` for 24 hours
(this is the default duration and covers 99% of use cases).



### Adjusting global state


Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@prisma/client": "5.22.0",
"@types/ramda": "^0.30.2",
"dotenv": "^16.4.5",
"ioredis": "^5.4.1",
"moment": "^2.30.1",
"prisma": "^5.22.0",
"ramda": "^0.30.1",
Expand Down
83 changes: 83 additions & 0 deletions src/lib/inline-cb-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { randomUUID } from "crypto"
import { MiddlewareFn, Telegraf } from 'telegraf'
import { Duration, duration } from 'moment'
import { superjson } from "./superjson"
import Redis from "ioredis"


type Midlware = Parameters<Telegraf['start']>[0]
export type ExtractContext<R> = R extends MiddlewareFn<infer T> ? T : never
export type BotContext = ExtractContext<Midlware>
export type ActionCtx = Parameters<typeof Telegraf.action>[1]

const ivalidateIn = duration(1, 'day')



export function createRedisInlineKeyboardHandler({
redis,
projectName,
ivalidateIn = duration(1, 'day'),
}: {
redis: Redis,
ivalidateIn: Duration,
projectName: string
}) {

function createCallbackHandle<
H extends (ctx: ExtractContext<ActionCtx>) => { [k: (string | symbol)]: (args: any) => any }
>({ namespace, handler }: { namespace: string; handler: H }):
{ [M in keyof ReturnType<H>]: (arg: Parameters<ReturnType<H>[M]>[0]) => Promise<string> } {

if (handlerObjectByNamespace[namespace]) {
throw new Error(`namespace ${namespace} already exists`)
}

handlerObjectByNamespace[namespace] = handler;

type T = ReturnType<H>

return new Proxy({} as { [M in keyof T]: (arg: Parameters<T[M]>[0]) => Promise<string> }, {
get(_, method: keyof T) {
return async (args: Parameters<T[typeof method]>[0]) => {
const uuid = randomUUID();

const key = `${projectName}:callbackquery:${uuid}`;
const value = superjson.stringify({ method, namespace, args });

await redis.set(key, value, 'EX', ivalidateIn.asSeconds());

return uuid;
};
}
}) as any; // Using 'as any' at the end for type assertion
}

async function executeByUUID(uuid: string, ctx: ExtractContext<ActionCtx>, object: object) {
const data = await redis.get(`watermark:callbackquery:${uuid}`)
if (!data) {
return //TODO handler of unknown button
}
const parsedData = superjson.parse(data) as any
return object[parsedData.namespace](ctx)[parsedData.method](parsedData.args)
}


const handlerObjectByNamespace = {}

function setupCallbackHandler(bot: Telegraf) {

bot.action(/.*/, async ctx => {
// await ctx.answerCbQuery();

const data = (ctx.callbackQuery as any).data

await executeByUUID(data, ctx, handlerObjectByNamespace)
})
}

return {
createCallbackHandle,
setupCallbackHandler,
}
}
Loading

0 comments on commit 4f83b03

Please sign in to comment.