Skip to content

Commit

Permalink
feat(telegram): telegram v1 (#322)
Browse files Browse the repository at this point in the history
* delete old

* use legacy

* remove deps

* telegram start

* gg

* telegram progress

* stream

* use channels 0.1.0

* callback query

* readme

* support sending file

* audio video

* locations

* fix

* comment

* bring changes

* readme

* gif

* bring changes

* fix

* fix

* better quick_reply

* bring changes

* revert quick reply change

* doc

* rewire comment

* fix

* fix

* bring changes

* bump

* fix merge

* add telegram channel to server

* fix
  • Loading branch information
samuelmasse committed Jan 29, 2022
1 parent b4dbc00 commit 626ffb9
Show file tree
Hide file tree
Showing 25 changed files with 578 additions and 20 deletions.
5 changes: 4 additions & 1 deletion packages/channels/example/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import clc from 'cli-color'
import { Router } from 'express'
import Joi from 'joi'
import { Channel } from '../src/base/channel'
import { TelegramChannel } from '../src/telegram/channel'
import payloads from './payloads.json'

export class App {
constructor(private router: Router, private config: any) {}

async setup() {}
async setup() {
await this.setupChannel('telegram', new TelegramChannel())
}

async setupChannel(name: string, channel: Channel) {
await channel.setup(this.router, {
Expand Down
1 change: 1 addition & 0 deletions packages/channels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"lodash": "^4.17.21",
"lru-cache": "^6.0.0",
"ms": "^2.1.3",
"telegraf": "^4.6.0",
"uuid": "^8.3.2",
"yn": "^4.0.0"
}
Expand Down
1 change: 1 addition & 0 deletions packages/channels/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './base/channel'
export * from './base/endpoint'
export * from './telegram/channel'
29 changes: 29 additions & 0 deletions packages/channels/src/telegram/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Sending

| Channels | Telegram |
| -------- | :------: |
| Text ||
| Image ||
| Choice ||
| Dropdown ||
| Card ||
| Carousel ||
| File ||
| Audio ||
| Video ||
| Location ||

### Receiving

| Channels | Telegram |
| ------------- | :------: |
| Text ||
| Quick Reply | N/A |
| Postback ||
| Say Something ||
| Voice ||
| Image ||
| File ||
| Audio ||
| Video ||
| Location ||
106 changes: 106 additions & 0 deletions packages/channels/src/telegram/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Response } from 'express'
import { Context, NarrowedContext } from 'telegraf'
import { Update } from 'telegraf/typings/core/types/typegram'
import yn from 'yn'
import { ChannelApi, ChannelApiManager, ChannelApiRequest } from '../base/api'
import { ChannelInitializeEvent, ChannelStartEvent, ChannelStopEvent } from '../base/service'
import { POSTBACK_PREFIX, SAY_PREFIX } from './renderers/carousel'
import { TelegramService } from './service'

export class TelegramApi extends ChannelApi<TelegramService> {
async setup(router: ChannelApiManager) {
router.post('/telegram/:token', this.handleRequest.bind(this))

this.service.on('start', this.handleStart.bind(this))
this.service.on('initialize', this.handleInitialize.bind(this))
this.service.on('stop', this.handleStop.bind(this))
}

private async handleRequest(req: ChannelApiRequest, res: Response) {
const { config, callback } = this.service.get(req.scope)

if (req.params.token === config.botToken) {
req.url = '/' // by-passes verification in telegraf since we do it here instead
callback!(req, res)
} else {
res.sendStatus(401)
}
}

private async handleInitialize({ scope }: ChannelInitializeEvent) {
if (this.useWebhook()) {
const { telegraf, config } = this.service.get(scope)
const webhook = `${await this.urlCallback!(scope)}/${config.botToken}`
await telegraf.telegram.setWebhook(webhook)
}
}

private async handleStart({ scope }: ChannelStartEvent) {
const { telegraf } = this.service.get(scope)

telegraf.on('message', this.asyncCallback(scope, this.handleTelegrafMessage.bind(this)))
telegraf.on('callback_query', this.asyncCallback(scope, this.handleTelegrafCallbackQuery.bind(this)))

if (this.useWebhook()) {
this.service.get(scope).callback = telegraf.webhookCallback('/')
} else {
await telegraf.telegram.deleteWebhook()
await telegraf.launch()
}
}

private async handleStop({ scope }: ChannelStopEvent) {
if (!this.useWebhook()) {
this.service.get(scope).telegraf.stop()
}
}

private async handleTelegrafMessage(scope: string, ctx: NarrowedContext<Context<Update>, Update.MessageUpdate>) {
if ('text' in ctx.message) {
await this.service.receive(scope, this.extractEndpoint(ctx), { type: 'text', text: ctx.message.text })
}
}

private async handleTelegrafCallbackQuery(
scope: string,
ctx: NarrowedContext<Context<Update>, Update.CallbackQueryUpdate>
) {
if ('data' in ctx.callbackQuery) {
const data = ctx.callbackQuery.data

if (data.startsWith(SAY_PREFIX)) {
await this.service.receive(scope, this.extractEndpoint(ctx), {
type: 'say_something',
text: data.replace(SAY_PREFIX, '')
})
} else if (data.startsWith(POSTBACK_PREFIX)) {
await this.service.receive(scope, this.extractEndpoint(ctx), {
type: 'postback',
payload: data.replace(POSTBACK_PREFIX, '')
})
}
}

await ctx.answerCbQuery()
}

private extractEndpoint(ctx: Context) {
const chatId = ctx.chat?.id
const userId = ctx.from?.id

return { identity: '*', sender: userId!.toString(), thread: chatId!.toString() }
}

private asyncCallback(scope: string, fn: (scope: string, ctx: any) => Promise<void>) {
return (ctx: any) => {
fn(scope, ctx).catch((e) => {
this.service.logger?.error('Error occurred in telegram callback', e)
})
}
}

private useWebhook() {
// TODO: remove this dependency on server env vars
return !yn(process.env.SPINNED) || yn(process.env.CLUSTER_ENABLED)
}
}
23 changes: 23 additions & 0 deletions packages/channels/src/telegram/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChannelTemplate } from '../base/channel'
import { TelegramApi } from './api'
import { TelegramConfig, TelegramConfigSchema } from './config'
import { TelegramService } from './service'
import { TelegramStream } from './stream'

export class TelegramChannel extends ChannelTemplate<TelegramConfig, TelegramService, TelegramApi, TelegramStream> {
get meta() {
return {
id: 'e578723f-ab57-463c-bc13-b483db9bf547',
name: 'telegram',
version: '1.0.0',
schema: TelegramConfigSchema,
initiable: true,
lazy: true
}
}

constructor() {
const service = new TelegramService()
super(service, new TelegramApi(service), new TelegramStream(service))
}
}
9 changes: 9 additions & 0 deletions packages/channels/src/telegram/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Joi from 'joi'

export interface TelegramConfig {
botToken: string
}

export const TelegramConfigSchema = {
botToken: Joi.string().required()
}
23 changes: 23 additions & 0 deletions packages/channels/src/telegram/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChatAction, InputFile } from 'telegraf/typings/core/types/typegram'
import { ChannelContext } from '../base/context'
import { TelegramState } from './service'

export type TelegramContext = ChannelContext<TelegramState> & {
messages: TelegramMessage[]
}

export interface TelegramMessage {
text?: string
animation?: string
photo?: InputFile
markdown?: boolean
action?: ChatAction
document?: InputFile
audio?: InputFile
video?: InputFile
location?: {
latitude: number
longitude: number
}
extra?: any
}
13 changes: 13 additions & 0 deletions packages/channels/src/telegram/renderers/audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import path from 'path'
import { AudioRenderer } from '../../base/renderers/audio'
import { AudioContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramAudioRenderer extends AudioRenderer {
renderAudio(context: TelegramContext, payload: AudioContent) {
context.messages.push({
document: { url: payload.audio, filename: path.basename(payload.audio) },
extra: { caption: payload.title }
})
}
}
51 changes: 51 additions & 0 deletions packages/channels/src/telegram/renderers/carousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import path from 'path'
import { Markup } from 'telegraf'
import { InlineKeyboardButton } from 'telegraf/typings/core/types/typegram'
import { CarouselContext, CarouselRenderer } from '../../base/renderers/carousel'
import { ActionOpenURL, ActionPostback, ActionSaySomething, CardContent } from '../../content/types'
import { TelegramContext } from '../context'

type Context = CarouselContext<TelegramContext> & {
buttons: InlineKeyboardButton[]
}

export const POSTBACK_PREFIX = 'postback::'
export const SAY_PREFIX = 'say::'

export class TelegramCarouselRenderer extends CarouselRenderer {
startRenderCard(context: Context, _card: CardContent) {
context.buttons = []
}

renderButtonUrl(context: Context, button: ActionOpenURL) {
context.buttons.push(Markup.button.url(button.title, button.url))
}

renderButtonPostback(context: Context, button: ActionPostback) {
context.buttons.push(Markup.button.callback(button.title, `${POSTBACK_PREFIX}${button.payload}`))
}

renderButtonSay(context: Context, button: ActionSaySomething) {
context.buttons.push(Markup.button.callback(button.title, `${SAY_PREFIX}${button.text}`))
}

endRenderCard(context: Context, card: CardContent) {
const text = `*${card.title}*${card.subtitle ? '\n' + card.subtitle : ''}`

if (card.image) {
context.channel.messages.push({ action: 'upload_photo' })
context.channel.messages.push({
photo: {
url: card.image,
filename: path.basename(card.image)
},
extra: { caption: text, parse_mode: 'Markdown', ...Markup.inlineKeyboard(context.buttons) }
})
} else {
context.channel.messages.push({
text,
extra: Markup.inlineKeyboard(context.buttons)
})
}
}
}
15 changes: 15 additions & 0 deletions packages/channels/src/telegram/renderers/choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Markup } from 'telegraf'
import { ChoicesRenderer } from '../../base/renderers/choices'
import { ChoiceContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramChoicesRenderer extends ChoicesRenderer {
renderChoice(context: TelegramContext, payload: ChoiceContent) {
if (!context.messages.length) {
context.messages.push({})
}

const buttons = payload.choices.map((x) => Markup.button.callback(x.title, x.value))
context.messages[0].extra = Markup.keyboard(buttons).oneTime()
}
}
13 changes: 13 additions & 0 deletions packages/channels/src/telegram/renderers/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import path from 'path'
import { FileRenderer } from '../../base/renderers/file'
import { FileContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramFileRenderer extends FileRenderer {
renderFile(context: TelegramContext, payload: FileContent) {
context.messages.push({
document: { url: payload.file, filename: path.basename(payload.file) },
extra: { caption: payload.title }
})
}
}
17 changes: 17 additions & 0 deletions packages/channels/src/telegram/renderers/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import path from 'path'
import { ImageRenderer } from '../../base/renderers/image'
import { ImageContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramImageRenderer extends ImageRenderer {
renderImage(context: TelegramContext, payload: ImageContent) {
if (payload.image.toLowerCase().endsWith('.gif')) {
context.messages.push({ animation: payload.image, extra: { caption: payload.title } })
} else {
context.messages.push({
photo: { url: payload.image, filename: path.basename(payload.image) },
extra: { caption: payload.title }
})
}
}
}
19 changes: 19 additions & 0 deletions packages/channels/src/telegram/renderers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { TelegramAudioRenderer } from './audio'
import { TelegramCarouselRenderer } from './carousel'
import { TelegramChoicesRenderer } from './choices'
import { TelegramFileRenderer } from './file'
import { TelegramImageRenderer } from './image'
import { TelegramLocationRenderer } from './location'
import { TelegramTextRenderer } from './text'
import { TelegramVideoRenderer } from './video'

export const TelegramRenderers = [
new TelegramTextRenderer(),
new TelegramImageRenderer(),
new TelegramCarouselRenderer(),
new TelegramChoicesRenderer(),
new TelegramFileRenderer(),
new TelegramAudioRenderer(),
new TelegramVideoRenderer(),
new TelegramLocationRenderer()
]
22 changes: 22 additions & 0 deletions packages/channels/src/telegram/renderers/location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { LocationRenderer } from '../../base/renderers/location'
import { LocationContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramLocationRenderer extends LocationRenderer {
renderLocation(context: TelegramContext, payload: LocationContent) {
context.messages.push({
location: { latitude: payload.latitude, longitude: payload.longitude }
// For some reason this does not work, so we need to send a seperate text message
// extra: { caption: payload.title }
})

let text = payload.title
if (payload.address) {
text = (text ? `*${text}*\n` : '') + payload.address
}

if (payload.title) {
context.messages.push({ text, extra: { parse_mode: 'Markdown' } })
}
}
}
13 changes: 13 additions & 0 deletions packages/channels/src/telegram/renderers/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { TextRenderer } from '../../base/renderers/text'
import { TextContent } from '../../content/types'
import { TelegramContext } from '../context'

export class TelegramTextRenderer extends TextRenderer {
renderText(context: TelegramContext, payload: TextContent) {
context.messages.push({
text: payload.text,
markdown: payload.markdown,
extra: payload.markdown ? { parse_mode: 'Markdown' } : {}
})
}
}

0 comments on commit 626ffb9

Please sign in to comment.