Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/line'
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jul 16, 2023
2 parents dac4191 + 4a1be74 commit 0f0537d
Show file tree
Hide file tree
Showing 11 changed files with 2,180 additions and 0 deletions.
33 changes: 33 additions & 0 deletions adapters/line/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@satorijs/adapter-line",
"description": "Line Adapter for Satorijs",
"version": "1.0.0",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"files": [
"lib"
],
"author": "LittleC <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/satorijs/satori.git",
"directory": "adapters/line"
},
"bugs": {
"url": "https://github.com/satorijs/satori/issues"
},
"homepage": "https://koishi.chat/plugins/adapter/line.html",
"keywords": [
"bot",
"line",
"adapter",
"chatbot",
"satori"
],
"peerDependencies": {
"@satorijs/satori": "^2.4.0"
},
"dependencies": {},
"devDependencies": {}
}
111 changes: 111 additions & 0 deletions adapters/line/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { Bot, Context, Logger, Quester, Schema, Universal } from '@satorijs/satori'
import { HttpServer } from './http'
import { Internal } from './types'
import { LineMessageEncoder } from './message'

const logger = new Logger('line')

export class LineBot extends Bot<LineBot.Config> {
static MessageEncoder = LineMessageEncoder
public http: Quester
public contentHttp: Quester
public internal: Internal
constructor(ctx: Context, config: LineBot.Config) {
super(ctx, config)
if (!ctx.root.config.selfUrl) {
logger.warn('selfUrl is not set, some features may not work')
}

ctx.plugin(HttpServer, this)
this.http = ctx.http.extend({
...config.api,
headers: {
Authorization: `Bearer ${config.token}`,
},
})
this.contentHttp = ctx.http.extend({
...config.content,
headers: {
Authorization: `Bearer ${config.token}`,
},
})
this.internal = new Internal(this.http)
}

// https://developers.line.biz/en/reference/messaging-api/#get-profile
async getSelf(): Promise<Universal.User> {
const { userId, displayName, pictureUrl } = await this.internal.getBotInfo()
return {
userId,
nickname: displayName,
avatar: pictureUrl,
}
}

async getFriendList(): Promise<Universal.User[]> {
let userIds: string[] = []
let start: string
do {
const res = await this.internal.getFollowers({
start,
limit: 1000,
})
userIds = userIds.concat(res.userIds)
start = res.next
} while (start)

return userIds.map(v => ({ userId: v }))
}

async getGuild(guildId: string): Promise<Universal.Guild> {
const res = await this.internal.getGroupSummary(guildId)
return {
guildId: res.groupId,
guildName: res.groupName,
}
}

async getGuildMemberList(guildId: string): Promise<Universal.GuildMember[]> {
let userIds: string[] = []
let start: string
do {
const res = await this.internal.getGroupMembersIds(guildId, { start })
userIds = userIds.concat(res.memberIds)
start = res.next
} while (start)

return userIds.map(v => ({ userId: v }))
}

async getGuildMember(guildId: string, userId: string): Promise<Universal.GuildMember> {
const res = await this.internal.getGroupMemberProfile(guildId, userId)
return ({
userId: res.userId,
nickname: res.displayName,
avatar: res.pictureUrl,
})
}
}

export namespace LineBot {
export interface Config extends Bot.Config {
token: string
api: Quester.Config
content: Quester.Config
secret: string
}
export const Config: Schema<Config> = Schema.intersect([
Schema.object({
api: Quester.createConfig('https://api.line.me/'),
}),
Schema.object({
content: Quester.createConfig('https://api-data.line.me/'),
}),
Schema.object({
token: Schema.string().required(),
secret: Schema.string().required(),
}),
] as const)
}

LineBot.prototype.platform = 'line'
49 changes: 49 additions & 0 deletions adapters/line/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Adapter, Context, Logger } from '@satorijs/satori'
import { LineBot } from './bot'
import crypto from 'node:crypto'
import { WebhookRequestBody } from './types'
import { adaptSessions } from './utils'
import internal from 'stream'

export class HttpServer extends Adapter.Server<LineBot> {
logger = new Logger('line')
constructor(ctx: Context, bot: LineBot) {
super()
}

async start(bot: LineBot) {
const { userId } = await bot.internal.getBotInfo()
bot.selfId = userId
bot.online()
bot.ctx.router.post('/line', async (ctx) => {
const sign = ctx.headers['x-line-signature']?.toString()
const hash = crypto.createHmac('SHA256', bot.config.secret).update(ctx.request.rawBody || '').digest('base64')
if (hash !== sign) {
return ctx.status = 403
}
const parsed = ctx.request.body as WebhookRequestBody
this.logger.debug(require('util').inspect(parsed, false, null, true))
for (const event of parsed.events) {
const sessions = await adaptSessions(bot, event)
if (sessions.length) sessions.forEach(bot.dispatch.bind(bot))
this.logger.debug(require('util').inspect(sessions, false, null, true))
}
ctx.status = 200
ctx.body = 'ok'
})
bot.ctx.router.get('/line/assets/:self_id/:message_id', async (ctx) => {
const messageId = ctx.params.message_id
const selfId = ctx.params.self_id
const localBot = this.bots.find((bot) => bot.selfId === selfId)
if (!localBot) return ctx.status = 404
const resp = await localBot.contentHttp.axios<internal.Readable>(`/v2/bot/message/${messageId}/content`, {
method: 'GET',
responseType: 'stream',
})
ctx.type = resp.headers['content-type']
ctx.set('cache-control', resp.headers['cache-control'])
ctx.response.body = resp.data
ctx.status = 200
})
}
}
9 changes: 9 additions & 0 deletions adapters/line/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LineBot } from './bot'

export * from './bot'
export * from './utils'
export * from './types'
export * from './http'
export * from './message'

export default LineBot
103 changes: 103 additions & 0 deletions adapters/line/src/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { h, MessageEncoder } from '@satorijs/satori'
import { LineBot } from './bot'
import { Definitions } from './types'

export const escape = (val: string) =>
val
.replace(/(?<!\u200b)[\*_~`]/g, '\u200B$&')

export const unescape = (val: string) =>
val
.replace(/\u200b([\*_~`])/g, '$1')

export class LineMessageEncoder extends MessageEncoder<LineBot> {
buffer = ''
blocks: Definitions.Message[] = []
block: Definitions.Message = null
sender: Definitions.Sender = {}
emojis: Definitions.Emoji[] = []
async flush(): Promise<void> {
await this.insertBlock()
// https://developers.line.biz/en/reference/messaging-api/#send-push-message
for (let i = 0; i < this.blocks.length; i += 5) {
await this.bot.internal.pushMessage({
to: this.channelId,
messages: this.blocks.slice(i, i + 5),
})
}
}

async insertBlock() {
if (this.buffer.length) {
this.blocks.push({
...{
type: 'text',
text: escape(this.buffer),
sender: { ...this.sender },
},
...this.emojis.length ? { emojis: this.emojis } : {},
})
this.buffer = ''
this.emojis = []
}
}

async visit(element: h) {
const { type, attrs, children } = element

if (type === 'text') {
this.buffer += attrs.content
} else if (type === 'image' && attrs.url) {
await this.insertBlock()
this.blocks.push({
type: 'image',
originalContentUrl: attrs.url,
previewImageUrl: attrs.url,
})
} else if (type === 'video' && attrs.url) {
await this.insertBlock()
this.blocks.push({
type: 'video',
originalContentUrl: attrs.url,
previewImageUrl: attrs.url,
})
} else if (type === 'audio' && attrs.url) {
await this.insertBlock()
this.blocks.push({
type: 'audio',
originalContentUrl: attrs.url,
duration: 1145,
})
} else if (type === 'face' && attrs.id) {
if (attrs.id.startsWith('s')) {
// https://developers.line.biz/en/reference/messaging-api/#sticker-message
await this.insertBlock()
this.blocks.push({
type: 'sticker',
packageId: attrs.id.split(':')[1],
stickerId: attrs.id.split(':')[2],
})
} else {
// https://developers.line.biz/en/reference/messaging-api/#text-message
this.emojis.push({
index: this.buffer.length,
productId: attrs.id.split(':')[1],
emojiId: attrs.id.split(':')[2],
})
this.buffer += '$'
}
} else if (type === 'author') {
this.sender.name = attrs.nickname
this.sender.iconUrl = attrs.avatar
} else if (type === 'message') {
// let childAuthor = h.select(children, 'author')
const sender = { ...this.sender }
await this.insertBlock()
await this.render(children)
await this.insertBlock()
if (this.sender.iconUrl || this.sender.name) {
this.sender = { ...sender }
}
}
}
}
Loading

0 comments on commit 0f0537d

Please sign in to comment.