agent-messenger 2.36.0 → 2.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +2 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.js +8 -6
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +21 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +272 -32
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +3 -3
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -2
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +48 -2
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +13 -1
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/telegram/tdlib.d.ts.map +1 -1
- package/dist/src/platforms/telegram/tdlib.js +5 -2
- package/dist/src/platforms/telegram/tdlib.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +7 -1
- package/docs/content/docs/sdk/kakaotalk.mdx +29 -1
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-imessage/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-kakaotalk/references/common-patterns.md +36 -0
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-webexbot/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/kakaotalk/chat-classifier.test.ts +1 -1
- package/src/platforms/kakaotalk/chat-classifier.ts +10 -7
- package/src/platforms/kakaotalk/client.test.ts +593 -24
- package/src/platforms/kakaotalk/client.ts +337 -35
- package/src/platforms/kakaotalk/index.test.ts +12 -0
- package/src/platforms/kakaotalk/index.ts +7 -1
- package/src/platforms/kakaotalk/types.ts +33 -2
- package/src/platforms/telegram/tdlib-node-test.ts +19 -0
- package/src/platforms/telegram/tdlib-node.test.ts +11 -0
- package/src/platforms/telegram/tdlib.ts +6 -2
|
@@ -8,6 +8,7 @@ import { getConfigDir } from '@/shared/utils/config-dir'
|
|
|
8
8
|
import { warn } from '@/shared/utils/stderr'
|
|
9
9
|
|
|
10
10
|
import { type AttachmentInput, type ResolvedAttachment, planAttachments } from './attachment-router'
|
|
11
|
+
import { isOpenKakaoChatType } from './chat-classifier'
|
|
11
12
|
import { detectImageDimensions } from './image-meta'
|
|
12
13
|
import { sha1Hex } from './media-upload'
|
|
13
14
|
import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
|
|
@@ -21,7 +22,9 @@ import {
|
|
|
21
22
|
type KakaoLeaveChatResult,
|
|
22
23
|
type KakaoMarkReadResult,
|
|
23
24
|
type KakaoMember,
|
|
25
|
+
type KakaoMemberSnapshot,
|
|
24
26
|
type KakaoMessage,
|
|
27
|
+
type KakaoMessagePage,
|
|
25
28
|
type KakaoMultiPhotoExtra,
|
|
26
29
|
type KakaoProfile,
|
|
27
30
|
type KakaoReplyExtra,
|
|
@@ -184,7 +187,7 @@ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameC
|
|
|
184
187
|
|
|
185
188
|
return {
|
|
186
189
|
chat_id: chatId,
|
|
187
|
-
type: chat.t as
|
|
190
|
+
type: chat.t as KakaoChat['type'],
|
|
188
191
|
display_name: displayName,
|
|
189
192
|
title,
|
|
190
193
|
active_members: chat.a as number,
|
|
@@ -207,10 +210,22 @@ interface ChannelMetaEntry {
|
|
|
207
210
|
content?: string
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
interface ChannelInfoData {
|
|
214
|
+
chatId?: unknown
|
|
215
|
+
type?: unknown
|
|
216
|
+
activeMembersCount?: unknown
|
|
217
|
+
newMessageCount?: unknown
|
|
218
|
+
invalidNewMessageCount?: unknown
|
|
219
|
+
lastLogId?: unknown
|
|
220
|
+
lastSeenLogId?: unknown
|
|
221
|
+
lastChatLog?: Record<string, unknown> | null
|
|
222
|
+
displayMembers?: Array<Record<string, unknown>>
|
|
223
|
+
chatMetas?: ChannelMetaEntry[]
|
|
224
|
+
li?: unknown
|
|
225
|
+
}
|
|
226
|
+
|
|
210
227
|
interface ChannelInfoResponse {
|
|
211
|
-
chatInfo?:
|
|
212
|
-
chatMetas?: ChannelMetaEntry[]
|
|
213
|
-
}
|
|
228
|
+
chatInfo?: ChannelInfoData
|
|
214
229
|
}
|
|
215
230
|
|
|
216
231
|
function extractTitle(body: Record<string, unknown>): string | null {
|
|
@@ -223,6 +238,69 @@ function extractTitle(body: Record<string, unknown>): string | null {
|
|
|
223
238
|
return typeof content === 'string' && content.length > 0 ? content : null
|
|
224
239
|
}
|
|
225
240
|
|
|
241
|
+
function extractChannelInfo(body: Record<string, unknown>, expectedChatId: string): ChannelInfoData {
|
|
242
|
+
const info = (body as ChannelInfoResponse).chatInfo
|
|
243
|
+
if (!info || typeof info !== 'object') {
|
|
244
|
+
throw new Error('CHATINFO response missing chatInfo')
|
|
245
|
+
}
|
|
246
|
+
const responseChatId = longToString(info.chatId)
|
|
247
|
+
if (responseChatId !== expectedChatId) {
|
|
248
|
+
throw new Error('CHATINFO response chatId mismatch')
|
|
249
|
+
}
|
|
250
|
+
return info
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function requiredNonNegativeInteger(value: unknown, field: string): number {
|
|
254
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
255
|
+
throw new Error(`CHATINFO response invalid ${field}`)
|
|
256
|
+
}
|
|
257
|
+
return value as number
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function requiredChatType(value: unknown): KakaoChat['type'] {
|
|
261
|
+
if (typeof value === 'string' && isOpenKakaoChatType(value)) {
|
|
262
|
+
return value
|
|
263
|
+
}
|
|
264
|
+
return requiredNonNegativeInteger(value, 'type')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function channelInfoToChatData(body: Record<string, unknown>, expectedChatId: string): ChatData {
|
|
268
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
269
|
+
const type = requiredChatType(info.type)
|
|
270
|
+
const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount')
|
|
271
|
+
const unreadCount =
|
|
272
|
+
info.invalidNewMessageCount === true ? 0 : requiredNonNegativeInteger(info.newMessageCount, 'newMessageCount')
|
|
273
|
+
const displayMembers = Array.isArray(info.displayMembers) ? info.displayMembers : []
|
|
274
|
+
const memberIds: unknown[] = []
|
|
275
|
+
const memberNames: string[] = []
|
|
276
|
+
|
|
277
|
+
for (const member of displayMembers) {
|
|
278
|
+
if (!member || typeof member !== 'object') continue
|
|
279
|
+
if (member.userId === undefined || typeof member.nickName !== 'string') continue
|
|
280
|
+
memberIds.push(member.userId)
|
|
281
|
+
memberNames.push(member.nickName)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
c: info.chatId,
|
|
286
|
+
t: type,
|
|
287
|
+
a: activeMembers,
|
|
288
|
+
n: unreadCount,
|
|
289
|
+
l: info.lastChatLog ?? null,
|
|
290
|
+
ll: info.lastLogId,
|
|
291
|
+
i: memberIds,
|
|
292
|
+
k: memberNames,
|
|
293
|
+
li: info.li,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractChannelMaxLogId(body: Record<string, unknown>, expectedChatId: string): Long {
|
|
298
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
299
|
+
const maxLogId = bsonToLong(info.lastLogId)
|
|
300
|
+
if (!maxLogId) throw new Error('CHATINFO response missing lastLogId')
|
|
301
|
+
return maxLogId
|
|
302
|
+
}
|
|
303
|
+
|
|
226
304
|
interface OpenLinkInfoResponse {
|
|
227
305
|
ols?: Array<{ ln?: unknown }>
|
|
228
306
|
}
|
|
@@ -234,10 +312,8 @@ function extractOpenLinkName(body: Record<string, unknown>): string | null {
|
|
|
234
312
|
return typeof ln === 'string' && ln.length > 0 ? ln : null
|
|
235
313
|
}
|
|
236
314
|
|
|
237
|
-
const OPEN_CHAT_TYPES = new Set(['OM', 'OD'])
|
|
238
|
-
|
|
239
315
|
function isOpenChat(chat: ChatData): boolean {
|
|
240
|
-
return
|
|
316
|
+
return isOpenKakaoChatType(chat.t)
|
|
241
317
|
}
|
|
242
318
|
|
|
243
319
|
function getOpenLinkId(chat: ChatData): Long | null {
|
|
@@ -452,9 +528,29 @@ function formatMember(member: Record<string, unknown>): KakaoMember {
|
|
|
452
528
|
}
|
|
453
529
|
|
|
454
530
|
function memberIdKey(member: Record<string, unknown>): string | null {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
531
|
+
const value = member.userId
|
|
532
|
+
if (typeof value === 'number') {
|
|
533
|
+
return Number.isSafeInteger(value) && value > 0 ? String(value) : null
|
|
534
|
+
}
|
|
535
|
+
if (!value || typeof value !== 'object' || !('low' in value) || !('high' in value)) {
|
|
536
|
+
return null
|
|
537
|
+
}
|
|
538
|
+
const { low, high } = value as {
|
|
539
|
+
low: unknown
|
|
540
|
+
high: unknown
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
!Number.isInteger(low) ||
|
|
544
|
+
!Number.isInteger(high) ||
|
|
545
|
+
(low as number) < -0x80000000 ||
|
|
546
|
+
(low as number) > 0xffffffff ||
|
|
547
|
+
(high as number) < -0x80000000 ||
|
|
548
|
+
(high as number) > 0xffffffff
|
|
549
|
+
) {
|
|
550
|
+
return null
|
|
551
|
+
}
|
|
552
|
+
const numeric = (BigInt((high as number) >>> 0) << 32n) | BigInt((low as number) >>> 0)
|
|
553
|
+
return numeric > 0n && numeric <= BigInt(Number.MAX_SAFE_INTEGER) ? numeric.toString() : null
|
|
458
554
|
}
|
|
459
555
|
|
|
460
556
|
function mergeMemberRecords(
|
|
@@ -484,19 +580,59 @@ function mergeMemberRecords(
|
|
|
484
580
|
return merged
|
|
485
581
|
}
|
|
486
582
|
|
|
487
|
-
function
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
583
|
+
function exactMemberRecords(
|
|
584
|
+
value: unknown,
|
|
585
|
+
source: string,
|
|
586
|
+
): {
|
|
587
|
+
records: Array<Record<string, unknown>>
|
|
588
|
+
ids: Set<string>
|
|
589
|
+
} {
|
|
590
|
+
if (!Array.isArray(value)) {
|
|
591
|
+
throw new Error(`${source} member snapshot missing`)
|
|
592
|
+
}
|
|
593
|
+
const records: Array<Record<string, unknown>> = []
|
|
594
|
+
const ids = new Set<string>()
|
|
595
|
+
for (const raw of value) {
|
|
596
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
597
|
+
throw new Error(`${source} member snapshot invalid`)
|
|
598
|
+
}
|
|
599
|
+
const record = raw as Record<string, unknown>
|
|
600
|
+
const id = memberIdKey(record)
|
|
601
|
+
if (!id || ids.has(id)) {
|
|
602
|
+
throw new Error(`${source} member snapshot invalid`)
|
|
603
|
+
}
|
|
604
|
+
ids.add(id)
|
|
605
|
+
records.push(record)
|
|
493
606
|
}
|
|
607
|
+
return { records, ids }
|
|
608
|
+
}
|
|
494
609
|
|
|
495
|
-
|
|
496
|
-
|
|
610
|
+
function sameIds(left: Set<string>, right: Set<string>): boolean {
|
|
611
|
+
return left.size === right.size && [...left].every((id) => right.has(id))
|
|
612
|
+
}
|
|
497
613
|
|
|
498
|
-
|
|
499
|
-
return
|
|
614
|
+
function subsetIds(subset: Set<string>, superset: Set<string>): boolean {
|
|
615
|
+
return [...subset].every((id) => superset.has(id))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function channelMemberView(
|
|
619
|
+
body: Record<string, unknown>,
|
|
620
|
+
expectedChatId: string,
|
|
621
|
+
): {
|
|
622
|
+
activeMembers: number
|
|
623
|
+
records: Array<Record<string, unknown>>
|
|
624
|
+
ids: Set<string>
|
|
625
|
+
} {
|
|
626
|
+
const info = extractChannelInfo(body, expectedChatId)
|
|
627
|
+
const activeMembers = requiredNonNegativeInteger(info.activeMembersCount, 'activeMembersCount')
|
|
628
|
+
const displayMembers = exactMemberRecords(info.displayMembers ?? [], 'CHATINFO')
|
|
629
|
+
if (displayMembers.records.length > activeMembers) {
|
|
630
|
+
throw new Error('CHATINFO member snapshot invalid')
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
activeMembers,
|
|
634
|
+
...displayMembers,
|
|
635
|
+
}
|
|
500
636
|
}
|
|
501
637
|
|
|
502
638
|
function formatMessages(
|
|
@@ -518,6 +654,50 @@ function formatMessages(
|
|
|
518
654
|
}))
|
|
519
655
|
}
|
|
520
656
|
|
|
657
|
+
function formatForwardMessage(log: Record<string, unknown>, chatId: string, nameCache: MemberNameCache): KakaoMessage {
|
|
658
|
+
return {
|
|
659
|
+
log_id: longToString(log.logId),
|
|
660
|
+
type: log.type as number,
|
|
661
|
+
author_id: log.authorId as number,
|
|
662
|
+
author_name: nameCache.lookup(chatId, log.authorId as number),
|
|
663
|
+
message: log.message as string,
|
|
664
|
+
attachment: parseAttachmentJson(log.attachment),
|
|
665
|
+
sent_at: log.sendAt as number,
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function buildMessagePage(
|
|
670
|
+
logs: Array<Record<string, unknown>>,
|
|
671
|
+
count: number,
|
|
672
|
+
cursor: Long,
|
|
673
|
+
protocolComplete: boolean,
|
|
674
|
+
chatId: string,
|
|
675
|
+
nameCache: MemberNameCache,
|
|
676
|
+
): KakaoMessagePage {
|
|
677
|
+
const cursorValue = BigInt(cursor.toString())
|
|
678
|
+
const unique = new Map<string, Record<string, unknown>>()
|
|
679
|
+
|
|
680
|
+
for (const log of logs) {
|
|
681
|
+
const logId = longToString(log.logId)
|
|
682
|
+
if (BigInt(logId) <= cursorValue) continue
|
|
683
|
+
if (!unique.has(logId)) unique.set(logId, log)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const ordered = [...unique.entries()].sort(([left], [right]) => {
|
|
687
|
+
const a = BigInt(left)
|
|
688
|
+
const b = BigInt(right)
|
|
689
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
690
|
+
})
|
|
691
|
+
const selected = ordered.slice(0, count)
|
|
692
|
+
const nextCursor = selected.at(-1)?.[0] ?? null
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
messages: selected.map(([, log]) => formatForwardMessage(log, chatId, nameCache)),
|
|
696
|
+
next_cursor: nextCursor,
|
|
697
|
+
complete: protocolComplete && ordered.length <= count,
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
521
701
|
function buildReplyExtra(target: KakaoReplyTarget): KakaoReplyExtra {
|
|
522
702
|
return {
|
|
523
703
|
attach_only: false,
|
|
@@ -810,6 +990,44 @@ export class KakaoTalkClient {
|
|
|
810
990
|
})
|
|
811
991
|
}
|
|
812
992
|
|
|
993
|
+
/**
|
|
994
|
+
* Fetch one chat directly by id via read-only CHATINFO.
|
|
995
|
+
*
|
|
996
|
+
* Unlike `getChats()`, this does not depend on the login-time snapshot or
|
|
997
|
+
* LCHATLIST pagination, so a room first observed from a live push can be
|
|
998
|
+
* resolved without interpreting protocol fields outside this SDK.
|
|
999
|
+
*/
|
|
1000
|
+
async getChat(chatId: string): Promise<KakaoChat> {
|
|
1001
|
+
const parsedChatId = parseChatId(chatId)
|
|
1002
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
1003
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
1004
|
+
try {
|
|
1005
|
+
const response = await session.getChannelInfo(parsedChatId)
|
|
1006
|
+
assertLocoOk(response, 'CHATINFO')
|
|
1007
|
+
const body = response.body as Record<string, unknown>
|
|
1008
|
+
const chat = channelInfoToChatData(body, normalizedChatId)
|
|
1009
|
+
this.nameCache.ingest([chat])
|
|
1010
|
+
|
|
1011
|
+
let title = extractTitle(body)
|
|
1012
|
+
if (!title && isOpenChat(chat)) {
|
|
1013
|
+
const linkId = getOpenLinkId(chat)
|
|
1014
|
+
if (linkId) {
|
|
1015
|
+
try {
|
|
1016
|
+
const openLinkResponse = await session.getOpenLinkInfo([linkId])
|
|
1017
|
+
title = extractOpenLinkName(openLinkResponse.body as Record<string, unknown>)
|
|
1018
|
+
} catch {
|
|
1019
|
+
title = null
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return formatChat(chat, title, this.nameCache)
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
throw wrapError(error, 'get_chat_failed')
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
|
|
813
1031
|
/**
|
|
814
1032
|
* Resolve the user-set room title via CHATINFO. Returns null on any error
|
|
815
1033
|
* (network, malformed response, or no TITLE meta present). Designed to be
|
|
@@ -851,6 +1069,60 @@ export class KakaoTalkClient {
|
|
|
851
1069
|
}
|
|
852
1070
|
}
|
|
853
1071
|
|
|
1072
|
+
/**
|
|
1073
|
+
* Fetch one forward-only, lossless history page after `from`.
|
|
1074
|
+
*
|
|
1075
|
+
* The page never keeps only the newest `count` entries. If the protocol
|
|
1076
|
+
* returns more entries than requested, the oldest entries are returned and
|
|
1077
|
+
* `complete` remains false so callers can continue from `next_cursor`.
|
|
1078
|
+
*/
|
|
1079
|
+
async getMessagePage(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessagePage> {
|
|
1080
|
+
const count = options?.count ?? 100
|
|
1081
|
+
if (!Number.isInteger(count) || count < 1 || count > 100) {
|
|
1082
|
+
throw new KakaoTalkError('Message page count must be an integer between 1 and 100', 'invalid_message_page_count')
|
|
1083
|
+
}
|
|
1084
|
+
const parsedChatId = parseChatId(chatId)
|
|
1085
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
1086
|
+
const cursor = options?.from ? parseLogId(options.from) : Long.fromNumber(0)
|
|
1087
|
+
|
|
1088
|
+
return this.executeWithReconnect(async ({ session }) => {
|
|
1089
|
+
try {
|
|
1090
|
+
const response = await session.getChatLogs([parsedChatId], [cursor])
|
|
1091
|
+
assertLocoOk(response, 'MCHATLOGS')
|
|
1092
|
+
const batch = ((response.body.chatLogs ?? []) as Array<Record<string, unknown>>).filter(
|
|
1093
|
+
(log) => longToString(log.chatId) === normalizedChatId,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
if (batch.length > 0) {
|
|
1097
|
+
return buildMessagePage(batch, count, cursor, response.body.eof === true, normalizedChatId, this.nameCache)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// CHATINFO is read-only and supplies a fresh lastLogId even for rooms
|
|
1101
|
+
// absent from the login snapshot. This avoids CHATONROOM, which can
|
|
1102
|
+
// advance server-side unread state.
|
|
1103
|
+
const chatInfoResponse = await session.getChannelInfo(parsedChatId)
|
|
1104
|
+
assertLocoOk(chatInfoResponse, 'CHATINFO')
|
|
1105
|
+
const maxLogId = extractChannelMaxLogId(chatInfoResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1106
|
+
if (maxLogId.lessThanOrEqual(cursor)) {
|
|
1107
|
+
return { messages: [], next_cursor: null, complete: true }
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const syncResponse = await session.syncMessages(parsedChatId, Math.min(count, 80), cursor, maxLogId)
|
|
1111
|
+
assertLocoOk(syncResponse, 'SYNCMSG')
|
|
1112
|
+
const syncBatch = ((syncResponse.body.chatLogs ?? []) as Array<Record<string, unknown>>).filter(
|
|
1113
|
+
(log) => longToString(log.chatId) === normalizedChatId,
|
|
1114
|
+
)
|
|
1115
|
+
const maxReturned = findMaxLogId(syncBatch, 'logId')
|
|
1116
|
+
const protocolComplete =
|
|
1117
|
+
syncResponse.body.isOK === true || (maxReturned !== null && maxReturned.greaterThanOrEqual(maxLogId))
|
|
1118
|
+
|
|
1119
|
+
return buildMessagePage(syncBatch, count, cursor, protocolComplete, normalizedChatId, this.nameCache)
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
throw wrapError(error, 'get_message_page_failed')
|
|
1122
|
+
}
|
|
1123
|
+
})
|
|
1124
|
+
}
|
|
1125
|
+
|
|
854
1126
|
async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
|
|
855
1127
|
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
856
1128
|
try {
|
|
@@ -949,30 +1221,60 @@ export class KakaoTalkClient {
|
|
|
949
1221
|
})
|
|
950
1222
|
}
|
|
951
1223
|
|
|
952
|
-
async
|
|
1224
|
+
async getMemberSnapshot(chatId: string): Promise<KakaoMemberSnapshot> {
|
|
953
1225
|
const parsedChatId = parseChatId(chatId)
|
|
1226
|
+
const normalizedChatId = longToString(parsedChatId)
|
|
954
1227
|
return this.executeWithReconnect(async ({ session }) => {
|
|
955
1228
|
try {
|
|
956
|
-
const
|
|
957
|
-
assertLocoOk(
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1229
|
+
const firstChatResponse = await session.getChannelInfo(parsedChatId)
|
|
1230
|
+
assertLocoOk(firstChatResponse, 'CHATINFO')
|
|
1231
|
+
const firstChat = channelMemberView(firstChatResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1232
|
+
const firstMemberResponse = await session.getAllMembers(parsedChatId)
|
|
1233
|
+
assertLocoOk(firstMemberResponse, 'GETMEM')
|
|
1234
|
+
const firstMembers = exactMemberRecords(firstMemberResponse.body.members ?? [], 'GETMEM')
|
|
1235
|
+
const secondChatResponse = await session.getChannelInfo(parsedChatId)
|
|
1236
|
+
assertLocoOk(secondChatResponse, 'CHATINFO')
|
|
1237
|
+
const secondChat = channelMemberView(secondChatResponse.body as Record<string, unknown>, normalizedChatId)
|
|
1238
|
+
const secondMemberResponse = await session.getAllMembers(parsedChatId)
|
|
1239
|
+
assertLocoOk(secondMemberResponse, 'GETMEM')
|
|
1240
|
+
const secondMembers = exactMemberRecords(secondMemberResponse.body.members ?? [], 'GETMEM')
|
|
1241
|
+
if (
|
|
1242
|
+
firstChat.activeMembers !== secondChat.activeMembers ||
|
|
1243
|
+
firstMembers.records.length !== firstChat.activeMembers ||
|
|
1244
|
+
secondMembers.records.length !== secondChat.activeMembers ||
|
|
1245
|
+
!sameIds(firstChat.ids, secondChat.ids) ||
|
|
1246
|
+
!sameIds(firstMembers.ids, secondMembers.ids) ||
|
|
1247
|
+
!subsetIds(secondChat.ids, secondMembers.ids)
|
|
1248
|
+
) {
|
|
1249
|
+
throw new Error('member_snapshot_changed_or_incomplete')
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
chat_id: normalizedChatId,
|
|
1253
|
+
active_members: secondChat.activeMembers,
|
|
1254
|
+
members: mergeMemberRecords(secondMembers.records, secondChat.records).map(formatMember),
|
|
1255
|
+
complete: true,
|
|
1256
|
+
consistency_basis: 'stable_double_read_chatinfo_getmem',
|
|
967
1257
|
}
|
|
968
|
-
|
|
969
|
-
return mergeMemberRecords(members, fallbackMembers).map(formatMember)
|
|
970
1258
|
} catch (error) {
|
|
971
|
-
throw wrapError(error, '
|
|
1259
|
+
throw wrapError(error, 'get_member_snapshot_failed')
|
|
972
1260
|
}
|
|
973
1261
|
})
|
|
974
1262
|
}
|
|
975
1263
|
|
|
1264
|
+
async getMembers(chatId: string): Promise<KakaoMember[]> {
|
|
1265
|
+
try {
|
|
1266
|
+
return (await this.getMemberSnapshot(chatId)).members
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
if (error instanceof KakaoTalkError && error.code === 'get_member_snapshot_failed') {
|
|
1269
|
+
throw new KakaoTalkError(error.message, 'get_members_failed', {
|
|
1270
|
+
cause: error,
|
|
1271
|
+
serverStatus: error.serverStatus,
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
throw error
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
976
1278
|
async getMembersByIds(chatId: string, userIds: string[]): Promise<KakaoMember[]> {
|
|
977
1279
|
if (userIds.length === 0) return []
|
|
978
1280
|
const parsedChatId = parseChatId(chatId)
|
|
@@ -10,10 +10,12 @@ import {
|
|
|
10
10
|
KakaoChatSchema,
|
|
11
11
|
KakaoConfigSchema,
|
|
12
12
|
KakaoLeaveChatResultSchema,
|
|
13
|
+
KakaoMessagePageSchema,
|
|
13
14
|
KakaoMessageSchema,
|
|
14
15
|
KakaoSendResultSchema,
|
|
15
16
|
KakaoTalkClient,
|
|
16
17
|
KakaoTalkError,
|
|
18
|
+
isOpenKakaoChatType,
|
|
17
19
|
KakaoTalkListener,
|
|
18
20
|
KakaoTalkPushMemberEventSchema,
|
|
19
21
|
KakaoTalkPushMessageEventSchema,
|
|
@@ -51,6 +53,10 @@ it('KakaoMessageSchema is exported from barrel', () => {
|
|
|
51
53
|
expect(typeof KakaoMessageSchema.parse).toBe('function')
|
|
52
54
|
})
|
|
53
55
|
|
|
56
|
+
it('KakaoMessagePageSchema is exported from barrel', () => {
|
|
57
|
+
expect(typeof KakaoMessagePageSchema.parse).toBe('function')
|
|
58
|
+
})
|
|
59
|
+
|
|
54
60
|
it('KakaoSendResultSchema is exported from barrel', () => {
|
|
55
61
|
expect(typeof KakaoSendResultSchema.parse).toBe('function')
|
|
56
62
|
})
|
|
@@ -83,6 +89,12 @@ it('classifyKakaoChat is exported from barrel', () => {
|
|
|
83
89
|
expect(typeof classifyKakaoChat).toBe('function')
|
|
84
90
|
})
|
|
85
91
|
|
|
92
|
+
it('isOpenKakaoChatType is exported from barrel', () => {
|
|
93
|
+
expect(isOpenKakaoChatType('OM')).toBe(true)
|
|
94
|
+
expect(isOpenKakaoChatType(13)).toBe(true)
|
|
95
|
+
expect(isOpenKakaoChatType('UNKNOWN')).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
86
98
|
it('KakaoLeaveChatResultSchema is exported from barrel', () => {
|
|
87
99
|
expect(typeof KakaoLeaveChatResultSchema.parse).toBe('function')
|
|
88
100
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { KakaoTalkClient, KakaoTalkError } from './client'
|
|
2
|
-
export { classifyKakaoChat } from './chat-classifier'
|
|
2
|
+
export { classifyKakaoChat, isOpenKakaoChatType } from './chat-classifier'
|
|
3
3
|
export type { KakaoChatKind } from './chat-classifier'
|
|
4
4
|
export { KakaoCredentialManager, CredentialManager } from './credential-manager'
|
|
5
5
|
export { KakaoTalkListener } from './listener'
|
|
@@ -9,6 +9,8 @@ export type {
|
|
|
9
9
|
KakaoAuthErrorCode,
|
|
10
10
|
KakaoAuthMethod,
|
|
11
11
|
KakaoChat,
|
|
12
|
+
KakaoChatType,
|
|
13
|
+
KakaoOpenChatType,
|
|
12
14
|
KakaoConfig,
|
|
13
15
|
KakaoDeviceType,
|
|
14
16
|
KakaoEmoticonKind,
|
|
@@ -18,7 +20,9 @@ export type {
|
|
|
18
20
|
KakaoLoginResult,
|
|
19
21
|
KakaoMarkReadResult,
|
|
20
22
|
KakaoMember,
|
|
23
|
+
KakaoMemberSnapshot,
|
|
21
24
|
KakaoMessage,
|
|
25
|
+
KakaoMessagePage,
|
|
22
26
|
KakaoMultiPhotoExtra,
|
|
23
27
|
KakaoPhotoExtra,
|
|
24
28
|
KakaoProfile,
|
|
@@ -44,7 +48,9 @@ export {
|
|
|
44
48
|
KakaoLeaveChatResultSchema,
|
|
45
49
|
KakaoMarkReadResultSchema,
|
|
46
50
|
KakaoMemberSchema,
|
|
51
|
+
KakaoMemberSnapshotSchema,
|
|
47
52
|
KakaoMessageSchema,
|
|
53
|
+
KakaoMessagePageSchema,
|
|
48
54
|
KakaoProfileSchema,
|
|
49
55
|
KakaoSendResultSchema,
|
|
50
56
|
KakaoTalkPushEmoticonEventSchema,
|
|
@@ -70,9 +70,12 @@ export const KAKAO_NEXT_ACTIONS: Record<string, { next_action: string; message:
|
|
|
70
70
|
},
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export type KakaoOpenChatType = 2 | 13 | 14 | 15 | 16 | 'OM' | 'OD'
|
|
74
|
+
export type KakaoChatType = number | Extract<KakaoOpenChatType, string>
|
|
75
|
+
|
|
73
76
|
export interface KakaoChat {
|
|
74
77
|
chat_id: string
|
|
75
|
-
type:
|
|
78
|
+
type: KakaoChatType
|
|
76
79
|
display_name: string | null
|
|
77
80
|
title: string | null
|
|
78
81
|
active_members: number
|
|
@@ -95,6 +98,12 @@ export interface KakaoMessage {
|
|
|
95
98
|
sent_at: number
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
export interface KakaoMessagePage {
|
|
102
|
+
messages: KakaoMessage[]
|
|
103
|
+
next_cursor: string | null
|
|
104
|
+
complete: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
export interface KakaoMember {
|
|
99
108
|
user_id: string
|
|
100
109
|
nickname: string
|
|
@@ -112,6 +121,14 @@ export interface KakaoMember {
|
|
|
112
121
|
open_permission: number | null
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
export interface KakaoMemberSnapshot {
|
|
125
|
+
chat_id: string
|
|
126
|
+
active_members: number
|
|
127
|
+
members: KakaoMember[]
|
|
128
|
+
complete: true
|
|
129
|
+
consistency_basis: 'stable_double_read_chatinfo_getmem'
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
export interface KakaoSendResult {
|
|
116
133
|
success: boolean
|
|
117
134
|
status_code: number
|
|
@@ -222,7 +239,7 @@ export interface KakaoTypingResult {
|
|
|
222
239
|
|
|
223
240
|
export const KakaoChatSchema = z.object({
|
|
224
241
|
chat_id: z.string(),
|
|
225
|
-
type: z.number(),
|
|
242
|
+
type: z.union([z.number(), z.literal('OM'), z.literal('OD')]),
|
|
226
243
|
display_name: z.string().nullable(),
|
|
227
244
|
title: z.string().nullable(),
|
|
228
245
|
active_members: z.number(),
|
|
@@ -247,6 +264,12 @@ export const KakaoMessageSchema = z.object({
|
|
|
247
264
|
sent_at: z.number(),
|
|
248
265
|
})
|
|
249
266
|
|
|
267
|
+
export const KakaoMessagePageSchema = z.object({
|
|
268
|
+
messages: z.array(KakaoMessageSchema),
|
|
269
|
+
next_cursor: z.string().nullable(),
|
|
270
|
+
complete: z.boolean(),
|
|
271
|
+
})
|
|
272
|
+
|
|
250
273
|
export const KakaoMemberSchema = z.object({
|
|
251
274
|
user_id: z.string(),
|
|
252
275
|
nickname: z.string(),
|
|
@@ -261,6 +284,14 @@ export const KakaoMemberSchema = z.object({
|
|
|
261
284
|
open_permission: z.number().nullable(),
|
|
262
285
|
})
|
|
263
286
|
|
|
287
|
+
export const KakaoMemberSnapshotSchema = z.object({
|
|
288
|
+
chat_id: z.string(),
|
|
289
|
+
active_members: z.number().int().nonnegative(),
|
|
290
|
+
members: z.array(KakaoMemberSchema),
|
|
291
|
+
complete: z.literal(true),
|
|
292
|
+
consistency_basis: z.literal('stable_double_read_chatinfo_getmem'),
|
|
293
|
+
})
|
|
294
|
+
|
|
264
295
|
export const KakaoSendResultSchema = z.object({
|
|
265
296
|
success: z.boolean(),
|
|
266
297
|
status_code: z.number(),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { TdjsonBinding } from './tdlib'
|
|
2
|
+
|
|
3
|
+
function fail(message: string): never {
|
|
4
|
+
console.error(message)
|
|
5
|
+
process.exit(1)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
|
|
9
|
+
fail('Expected Node.js runtime, but Bun was detected')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const binding = await TdjsonBinding.create()
|
|
13
|
+
const version = binding.execute({ '@type': 'getOption', name: 'version' })
|
|
14
|
+
|
|
15
|
+
if (version?.['@type'] !== 'optionValueString' || typeof version.value !== 'string') {
|
|
16
|
+
fail(`Expected TDLib version, got: ${JSON.stringify(version)}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('ok')
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { expect, it } from 'bun:test'
|
|
2
|
+
import { execSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
it('loads TDLib in Node.js', () => {
|
|
5
|
+
const result = execSync('bun tsx src/platforms/telegram/tdlib-node-test.ts', {
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
encoding: 'utf-8',
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
expect(result.trim()).toBe('ok')
|
|
11
|
+
})
|
|
@@ -7,6 +7,10 @@ interface TdjsonSymbols {
|
|
|
7
7
|
td_execute: (request: string) => string | null
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function unwrapDefaultExport<T>(moduleNamespace: T): T {
|
|
11
|
+
return (moduleNamespace as T & { default?: T }).default ?? moduleNamespace
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
let cachedKoffi: typeof import('koffi') | undefined
|
|
11
15
|
|
|
12
16
|
async function getKoffi(): Promise<typeof import('koffi')> {
|
|
@@ -15,7 +19,7 @@ async function getKoffi(): Promise<typeof import('koffi')> {
|
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
try {
|
|
18
|
-
cachedKoffi = await import('koffi')
|
|
22
|
+
cachedKoffi = unwrapDefaultExport(await import('koffi'))
|
|
19
23
|
return cachedKoffi
|
|
20
24
|
} catch {
|
|
21
25
|
throw new Error('koffi is required for Telegram support. Install it with: bun add koffi prebuilt-tdlib')
|
|
@@ -36,7 +40,7 @@ function getLibrarySuffix(): string {
|
|
|
36
40
|
|
|
37
41
|
async function getPrebuiltTdjsonPath(): Promise<string | undefined> {
|
|
38
42
|
try {
|
|
39
|
-
const mod = (await import('prebuilt-tdlib')) as { getTdjson?: () => string }
|
|
43
|
+
const mod = unwrapDefaultExport((await import('prebuilt-tdlib')) as { getTdjson?: () => string })
|
|
40
44
|
const tdjson = mod.getTdjson?.()
|
|
41
45
|
return typeof tdjson === 'string' && tdjson.length > 0 ? tdjson : undefined
|
|
42
46
|
} catch {
|