agent-messenger 2.19.5 → 2.20.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/line/client.d.ts +4 -0
- package/dist/src/platforms/line/client.d.ts.map +1 -1
- package/dist/src/platforms/line/client.js +109 -17
- package/dist/src/platforms/line/client.js.map +1 -1
- package/dist/src/platforms/line/e2ee-storage.d.ts +16 -0
- package/dist/src/platforms/line/e2ee-storage.d.ts.map +1 -0
- package/dist/src/platforms/line/e2ee-storage.js +93 -0
- package/dist/src/platforms/line/e2ee-storage.js.map +1 -0
- package/dist/src/platforms/teams/cli.d.ts.map +1 -1
- package/dist/src/platforms/teams/cli.js +2 -1
- package/dist/src/platforms/teams/cli.js.map +1 -1
- package/dist/src/platforms/teams/client.d.ts +4 -1
- package/dist/src/platforms/teams/client.d.ts.map +1 -1
- package/dist/src/platforms/teams/client.js +84 -0
- package/dist/src/platforms/teams/client.js.map +1 -1
- package/dist/src/platforms/teams/commands/chat.d.ts +13 -0
- package/dist/src/platforms/teams/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/teams/commands/chat.js +111 -0
- package/dist/src/platforms/teams/commands/chat.js.map +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts +1 -0
- package/dist/src/platforms/teams/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/teams/commands/index.js +1 -0
- package/dist/src/platforms/teams/commands/index.js.map +1 -1
- package/dist/src/platforms/teams/types.d.ts +24 -0
- package/dist/src/platforms/teams/types.d.ts.map +1 -1
- package/dist/src/platforms/teams/types.js +8 -0
- package/dist/src/platforms/teams/types.js.map +1 -1
- package/dist/src/tui/adapters/line-adapter.js +1 -1
- package/dist/src/tui/adapters/line-adapter.js.map +1 -1
- package/docs/content/docs/cli/line.mdx +13 -11
- 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-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -1
- package/skills/agent-line/SKILL.md +7 -5
- package/skills/agent-line/references/common-patterns.md +5 -2
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +20 -2
- package/skills/agent-teams/references/common-patterns.md +28 -0
- 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-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/line/client.test.ts +168 -16
- package/src/platforms/line/client.ts +127 -23
- package/src/platforms/line/e2ee-storage.test.ts +154 -0
- package/src/platforms/line/e2ee-storage.ts +119 -0
- package/src/platforms/teams/cli.ts +2 -0
- package/src/platforms/teams/client.test.ts +96 -0
- package/src/platforms/teams/client.ts +133 -0
- package/src/platforms/teams/commands/chat.test.ts +100 -0
- package/src/platforms/teams/commands/chat.ts +131 -0
- package/src/platforms/teams/commands/index.ts +1 -0
- package/src/platforms/teams/types.ts +20 -0
- package/src/tui/adapters/line-adapter.ts +1 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface E2EEKeyStore {
|
|
2
|
+
get(key: string): Promise<unknown> | unknown
|
|
3
|
+
set(key: string, value: string): Promise<void> | void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface E2EEKeyData {
|
|
7
|
+
keyId: number | string
|
|
8
|
+
privKey: string
|
|
9
|
+
pubKey: string
|
|
10
|
+
e2eeVersion?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type E2EEPublicKey = { keyId?: number | string } | ReadonlyArray<unknown>
|
|
14
|
+
|
|
15
|
+
const SELF_KEY_PREFIX = 'e2eeKeys:'
|
|
16
|
+
|
|
17
|
+
function selfKey(id: number | string): string {
|
|
18
|
+
return `${SELF_KEY_PREFIX}${id}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isE2EEKeyData(value: unknown): value is E2EEKeyData {
|
|
22
|
+
if (!value || typeof value !== 'object') return false
|
|
23
|
+
const data = value as Partial<E2EEKeyData>
|
|
24
|
+
return typeof data.privKey === 'string' && typeof data.pubKey === 'string'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseKeyData(raw: unknown): E2EEKeyData | null {
|
|
28
|
+
if (typeof raw !== 'string') return isE2EEKeyData(raw) ? raw : null
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(raw)
|
|
31
|
+
return isE2EEKeyData(parsed) ? parsed : null
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The stored payload carries its own keyId. Requiring it to equal the slot it was
|
|
38
|
+
// read from rejects entries mislabeled under an advertised keyId, so only a payload
|
|
39
|
+
// genuinely belonging to that keyId is ever trusted.
|
|
40
|
+
function keyDataMatchesSlot(data: E2EEKeyData, keyId: number | string): boolean {
|
|
41
|
+
return String(data.keyId) === String(keyId)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyIdOf(key: E2EEPublicKey): number | string | undefined {
|
|
45
|
+
if (Array.isArray(key)) {
|
|
46
|
+
const id = key[2]
|
|
47
|
+
return typeof id === 'number' || typeof id === 'string' ? id : undefined
|
|
48
|
+
}
|
|
49
|
+
const id = (key as { keyId?: number | string }).keyId
|
|
50
|
+
return typeof id === 'number' || typeof id === 'string' ? id : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function advertisedKeyIds(keys: ReadonlyArray<E2EEPublicKey> | undefined): Array<number | string> {
|
|
54
|
+
if (!keys) return []
|
|
55
|
+
const ids: Array<number | string> = []
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const id = keyIdOf(key)
|
|
58
|
+
if (id !== undefined) ids.push(id)
|
|
59
|
+
}
|
|
60
|
+
return ids
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Ensures the account's own self-key is addressable by MID for getE2EESelfKeyData,
|
|
64
|
+
// which checks `e2eeKeys:<mid>` before falling back to a live Thrift channel. The
|
|
65
|
+
// keyId-addressed entry is only trusted when the server advertises that exact keyId
|
|
66
|
+
// for this account, so a contaminated store (another account's key copied in) can
|
|
67
|
+
// never promote a foreign private key to this MID. Returns true when a self-key
|
|
68
|
+
// becomes available under the MID.
|
|
69
|
+
export async function ensureSelfKeyForMid(
|
|
70
|
+
storage: E2EEKeyStore,
|
|
71
|
+
mid: string,
|
|
72
|
+
advertisedKeys: ReadonlyArray<E2EEPublicKey> | undefined,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
if (!mid) return false
|
|
75
|
+
|
|
76
|
+
const existing = parseKeyData(await storage.get(selfKey(mid)))
|
|
77
|
+
if (existing) return true
|
|
78
|
+
|
|
79
|
+
for (const keyId of advertisedKeyIds(advertisedKeys)) {
|
|
80
|
+
const candidate = parseKeyData(await storage.get(selfKey(keyId)))
|
|
81
|
+
if (candidate && keyDataMatchesSlot(candidate, keyId)) {
|
|
82
|
+
await storage.set(selfKey(mid), JSON.stringify(candidate))
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Copies only this account's E2EE key material out of a shared (default) store into
|
|
91
|
+
// an isolated per-account store. Trust is anchored to the server-advertised keyIds
|
|
92
|
+
// for `mid`, so foreign-account keys present in the shared store are never carried
|
|
93
|
+
// over. Used right after a fresh login resolves the account MID.
|
|
94
|
+
export async function migrateOwnE2EEKeys(
|
|
95
|
+
source: E2EEKeyStore,
|
|
96
|
+
target: E2EEKeyStore,
|
|
97
|
+
mid: string,
|
|
98
|
+
advertisedKeys: ReadonlyArray<E2EEPublicKey> | undefined,
|
|
99
|
+
): Promise<number> {
|
|
100
|
+
if (!mid) return 0
|
|
101
|
+
|
|
102
|
+
let migrated = 0
|
|
103
|
+
const own = parseKeyData(await source.get(selfKey(mid)))
|
|
104
|
+
if (own) {
|
|
105
|
+
await target.set(selfKey(mid), JSON.stringify(own))
|
|
106
|
+
migrated++
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const keyId of advertisedKeyIds(advertisedKeys)) {
|
|
110
|
+
const data = parseKeyData(await source.get(selfKey(keyId)))
|
|
111
|
+
if (!data || !keyDataMatchesSlot(data, keyId)) continue
|
|
112
|
+
await target.set(selfKey(keyId), JSON.stringify(data))
|
|
113
|
+
migrated++
|
|
114
|
+
const publicKey = await source.get(`e2eePublicKeys:${keyId}`)
|
|
115
|
+
if (typeof publicKey === 'string') await target.set(`e2eePublicKeys:${keyId}`, publicKey)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return migrated
|
|
119
|
+
}
|
|
@@ -7,6 +7,7 @@ import pkg from '../../../package.json' with { type: 'json' }
|
|
|
7
7
|
import {
|
|
8
8
|
authCommand,
|
|
9
9
|
channelCommand,
|
|
10
|
+
chatCommand,
|
|
10
11
|
fileCommand,
|
|
11
12
|
messageCommand,
|
|
12
13
|
reactionCommand,
|
|
@@ -49,6 +50,7 @@ program.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
|
49
50
|
program.addCommand(authCommand)
|
|
50
51
|
program.addCommand(teamCommand)
|
|
51
52
|
program.addCommand(channelCommand)
|
|
53
|
+
program.addCommand(chatCommand)
|
|
52
54
|
program.addCommand(fileCommand)
|
|
53
55
|
program.addCommand(messageCommand)
|
|
54
56
|
program.addCommand(reactionCommand)
|
|
@@ -169,6 +169,102 @@ describe('TeamsClient', () => {
|
|
|
169
169
|
})
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
+
describe('listChats', () => {
|
|
173
|
+
it('classifies chats and excludes teams', async () => {
|
|
174
|
+
mockResponse({
|
|
175
|
+
conversations: [
|
|
176
|
+
{
|
|
177
|
+
id: '19:team@thread.tacv2',
|
|
178
|
+
threadProperties: { groupId: '111', spaceThreadTopic: 'Team One', threadType: 'space' },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: '48:notes',
|
|
182
|
+
threadProperties: { threadType: 'streamofnotes', productThreadType: 'StreamOfNotes' },
|
|
183
|
+
lastMessage: { content: 'Hi', composetime: '2024-01-03T00:00:00.000Z' },
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: '19:1on1@unq.gbl.spaces',
|
|
187
|
+
lastMessage: { content: '<p>Hi there</p>', composetime: '2024-01-01T00:00:00.000Z' },
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: '19:group@thread.tacv2',
|
|
191
|
+
threadProperties: { topic: 'Group Chat', threadType: 'chat' },
|
|
192
|
+
lastMessage: { content: 'Hello group', composetime: '2024-01-02T00:00:00.000Z' },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
198
|
+
const chats = await client.listChats()
|
|
199
|
+
|
|
200
|
+
expect(chats).toHaveLength(3)
|
|
201
|
+
expect(chats[0]).toMatchObject({ id: '48:notes', type: 'self', last_message: 'Hi' })
|
|
202
|
+
expect(chats[1]).toMatchObject({ id: '19:1on1@unq.gbl.spaces', type: 'oneOnOne', last_message: 'Hi there' })
|
|
203
|
+
expect(chats[2]).toMatchObject({ id: '19:group@thread.tacv2', type: 'group', topic: 'Group Chat' })
|
|
204
|
+
expect(fetchCalls[0].url).toBe(
|
|
205
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations?view=msnp24Equivalent&pageSize=500',
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('getChatMessages', () => {
|
|
211
|
+
it('returns user messages and filters system events', async () => {
|
|
212
|
+
mockResponse({
|
|
213
|
+
messages: [
|
|
214
|
+
{
|
|
215
|
+
id: 'm1',
|
|
216
|
+
content: '<p>Hello</p>',
|
|
217
|
+
from: 'host/users/ME/contacts/8:alice',
|
|
218
|
+
imdisplayname: 'Alice',
|
|
219
|
+
composetime: '2024-01-01T00:00:00.000Z',
|
|
220
|
+
messagetype: 'RichText/Html',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'm2',
|
|
224
|
+
content: 'Bob joined',
|
|
225
|
+
imdisplayname: 'System',
|
|
226
|
+
composetime: '2024-01-01T00:01:00.000Z',
|
|
227
|
+
messagetype: 'ThreadActivity/AddMember',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
233
|
+
const messages = await client.getChatMessages('19:1on1@unq.gbl.spaces', 30)
|
|
234
|
+
|
|
235
|
+
expect(messages).toHaveLength(1)
|
|
236
|
+
expect(messages[0].id).toBe('m1')
|
|
237
|
+
expect(messages[0].content).toBe('Hello')
|
|
238
|
+
expect(messages[0].author.displayName).toBe('Alice')
|
|
239
|
+
expect(messages[0].channel_id).toBe('19:1on1@unq.gbl.spaces')
|
|
240
|
+
expect(fetchCalls[0].url).toBe(
|
|
241
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages?startTime=0&view=msnp24Equivalent&pageSize=30',
|
|
242
|
+
)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('sendChatMessage', () => {
|
|
247
|
+
it('sends an HTML-escaped message to a chat', async () => {
|
|
248
|
+
mockResponse({ OriginalArrivalTime: 1704067200000 })
|
|
249
|
+
|
|
250
|
+
const client = await new TeamsClient().login({ token: 'test-token', accountType: 'personal' })
|
|
251
|
+
const message = await client.sendChatMessage('19:1on1@unq.gbl.spaces', 'a <b> & c')
|
|
252
|
+
|
|
253
|
+
expect(message.content).toBe('a <b> & c')
|
|
254
|
+
expect(fetchCalls[0].url).toBe(
|
|
255
|
+
'https://msgapi.teams.live.com/v1/users/ME/conversations/19%3A1on1%40unq.gbl.spaces/messages',
|
|
256
|
+
)
|
|
257
|
+
expect(fetchCalls[0].options?.method).toBe('POST')
|
|
258
|
+
expect(fetchCalls[0].options?.body).toBe(
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
content: 'a <b> & c',
|
|
261
|
+
messagetype: 'RichText/Html',
|
|
262
|
+
contenttype: 'text',
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
172
268
|
describe('getTeam', () => {
|
|
173
269
|
it('returns team info', async () => {
|
|
174
270
|
mockResponse({ id: '111', name: 'Test Team', description: 'A test team' })
|
|
@@ -5,6 +5,8 @@ import { TeamsCredentialManager } from './credential-manager'
|
|
|
5
5
|
import type {
|
|
6
6
|
TeamsAccountType,
|
|
7
7
|
TeamsChannel,
|
|
8
|
+
TeamsChat,
|
|
9
|
+
TeamsChatType,
|
|
8
10
|
TeamsFile,
|
|
9
11
|
TeamsMessage,
|
|
10
12
|
TeamsRegion,
|
|
@@ -25,6 +27,41 @@ const BASE_BACKOFF_MS = 100
|
|
|
25
27
|
const DEFAULT_REGION: TeamsRegion = 'amer'
|
|
26
28
|
const REGIONS: TeamsRegion[] = ['amer', 'emea', 'apac']
|
|
27
29
|
|
|
30
|
+
function stripHtml(content: string | undefined): string | undefined {
|
|
31
|
+
if (content === undefined) return undefined
|
|
32
|
+
const stripped = content.replace(/<[^>]*>/g, '')
|
|
33
|
+
return stripped
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, "'")
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeHtml(value: string): string {
|
|
44
|
+
return value
|
|
45
|
+
.replaceAll('&', '&')
|
|
46
|
+
.replaceAll('<', '<')
|
|
47
|
+
.replaceAll('>', '>')
|
|
48
|
+
.replaceAll('"', '"')
|
|
49
|
+
.replaceAll("'", ''')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// groupId => Teams/channel thread (handled by listTeams). "48:notes"/
|
|
53
|
+
// streamofnotes => the user's self ("to me") chat. Anything else without a
|
|
54
|
+
// non-chat threadType is a normal 1:1 (no topic) or group (has topic) chat.
|
|
55
|
+
function classifyChat(
|
|
56
|
+
id: string,
|
|
57
|
+
tp?: { topic?: string; threadType?: string; groupId?: string },
|
|
58
|
+
): TeamsChatType | null {
|
|
59
|
+
if (tp?.groupId) return null
|
|
60
|
+
if (id === '48:notes' || tp?.threadType === 'streamofnotes') return 'self'
|
|
61
|
+
if (tp?.threadType && tp.threadType !== 'chat') return null
|
|
62
|
+
return tp?.topic ? 'group' : 'oneOnOne'
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
export class TeamsClient {
|
|
29
66
|
private token: string | null = null
|
|
30
67
|
private tokenExpiresAt?: Date
|
|
@@ -324,6 +361,102 @@ export class TeamsClient {
|
|
|
324
361
|
return Array.from(teamsMap.values())
|
|
325
362
|
}
|
|
326
363
|
|
|
364
|
+
async listChats(): Promise<TeamsChat[]> {
|
|
365
|
+
interface ConversationMessage {
|
|
366
|
+
content?: string
|
|
367
|
+
composetime?: string
|
|
368
|
+
originalarrivaltime?: string
|
|
369
|
+
}
|
|
370
|
+
interface Conversation {
|
|
371
|
+
id: string
|
|
372
|
+
threadProperties?: {
|
|
373
|
+
topic?: string
|
|
374
|
+
threadType?: string
|
|
375
|
+
groupId?: string
|
|
376
|
+
}
|
|
377
|
+
lastMessage?: ConversationMessage
|
|
378
|
+
}
|
|
379
|
+
interface ConversationsResponse {
|
|
380
|
+
conversations: Conversation[]
|
|
381
|
+
}
|
|
382
|
+
const data = await this.request<ConversationsResponse>(
|
|
383
|
+
'GET',
|
|
384
|
+
'/users/ME/conversations?view=msnp24Equivalent&pageSize=500',
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const chats: TeamsChat[] = []
|
|
388
|
+
for (const conv of data.conversations ?? []) {
|
|
389
|
+
const type = classifyChat(conv.id, conv.threadProperties)
|
|
390
|
+
if (!type) continue
|
|
391
|
+
|
|
392
|
+
chats.push({
|
|
393
|
+
id: conv.id,
|
|
394
|
+
type,
|
|
395
|
+
topic: conv.threadProperties?.topic,
|
|
396
|
+
last_message: stripHtml(conv.lastMessage?.content),
|
|
397
|
+
last_message_at: conv.lastMessage?.composetime ?? conv.lastMessage?.originalarrivaltime,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return chats
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async getChatMessages(chatId: string, limit: number = 50): Promise<TeamsMessage[]> {
|
|
405
|
+
interface ChatMessage {
|
|
406
|
+
id: string
|
|
407
|
+
content?: string
|
|
408
|
+
from?: string
|
|
409
|
+
imdisplayname?: string
|
|
410
|
+
composetime?: string
|
|
411
|
+
originalarrivaltime?: string
|
|
412
|
+
messagetype?: string
|
|
413
|
+
}
|
|
414
|
+
interface MessagesResponse {
|
|
415
|
+
messages: ChatMessage[]
|
|
416
|
+
}
|
|
417
|
+
const encodedChatId = encodeURIComponent(chatId)
|
|
418
|
+
const data = await this.request<MessagesResponse>(
|
|
419
|
+
'GET',
|
|
420
|
+
`/users/ME/conversations/${encodedChatId}/messages?startTime=0&view=msnp24Equivalent&pageSize=${limit}`,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
const userMessageTypes = new Set(['Text', 'RichText/Html', 'RichText/Media_CallRecording'])
|
|
424
|
+
return (data.messages ?? [])
|
|
425
|
+
.filter((msg) => !msg.messagetype || userMessageTypes.has(msg.messagetype))
|
|
426
|
+
.slice(0, limit)
|
|
427
|
+
.map((msg) => ({
|
|
428
|
+
id: msg.id,
|
|
429
|
+
channel_id: chatId,
|
|
430
|
+
author: {
|
|
431
|
+
id: msg.from ?? '',
|
|
432
|
+
displayName: msg.imdisplayname ?? 'Unknown',
|
|
433
|
+
},
|
|
434
|
+
content: stripHtml(msg.content) ?? '',
|
|
435
|
+
timestamp: msg.composetime ?? msg.originalarrivaltime ?? '',
|
|
436
|
+
}))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async sendChatMessage(chatId: string, content: string): Promise<TeamsMessage> {
|
|
440
|
+
interface SendResponse {
|
|
441
|
+
OriginalArrivalTime?: number
|
|
442
|
+
}
|
|
443
|
+
const encodedChatId = encodeURIComponent(chatId)
|
|
444
|
+
const response = await this.request<SendResponse>('POST', `/users/ME/conversations/${encodedChatId}/messages`, {
|
|
445
|
+
content: escapeHtml(content),
|
|
446
|
+
messagetype: 'RichText/Html',
|
|
447
|
+
contenttype: 'text',
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
const arrivalTime = response?.OriginalArrivalTime
|
|
451
|
+
return {
|
|
452
|
+
id: arrivalTime ? String(arrivalTime) : '',
|
|
453
|
+
channel_id: chatId,
|
|
454
|
+
author: { id: 'ME', displayName: 'Me' },
|
|
455
|
+
content,
|
|
456
|
+
timestamp: arrivalTime ? new Date(arrivalTime).toISOString() : new Date().toISOString(),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
327
460
|
async getTeam(teamId: string): Promise<TeamsTeam> {
|
|
328
461
|
return this.request<TeamsTeam>('GET', `/csa/api/v1/teams/${teamId}`, undefined, CSA_API_BASE)
|
|
329
462
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { TeamsClient } from '../client'
|
|
4
|
+
import { TeamsCredentialManager } from '../credential-manager'
|
|
5
|
+
import { historyAction, listAction, sendAction } from './chat'
|
|
6
|
+
|
|
7
|
+
let clientListChatsSpy: ReturnType<typeof spyOn>
|
|
8
|
+
let clientGetChatMessagesSpy: ReturnType<typeof spyOn>
|
|
9
|
+
let clientSendChatMessageSpy: ReturnType<typeof spyOn>
|
|
10
|
+
let credManagerLoadSpy: ReturnType<typeof spyOn>
|
|
11
|
+
const originalConsoleLog = console.log
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
clientListChatsSpy = spyOn(TeamsClient.prototype, 'listChats').mockResolvedValue([
|
|
15
|
+
{ id: '19:1on1@unq.gbl.spaces', type: 'oneOnOne', last_message: 'Hi', last_message_at: '2025-01-29T10:00:00Z' },
|
|
16
|
+
{ id: '19:group@thread.tacv2', type: 'group', topic: 'Group Chat' },
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
clientGetChatMessagesSpy = spyOn(TeamsClient.prototype, 'getChatMessages').mockResolvedValue([
|
|
20
|
+
{
|
|
21
|
+
id: 'msg_123',
|
|
22
|
+
channel_id: '19:1on1@unq.gbl.spaces',
|
|
23
|
+
author: { id: 'user_789', displayName: 'Alice' },
|
|
24
|
+
content: 'Hello world',
|
|
25
|
+
timestamp: '2025-01-29T10:00:00Z',
|
|
26
|
+
},
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
clientSendChatMessageSpy = spyOn(TeamsClient.prototype, 'sendChatMessage').mockResolvedValue({
|
|
30
|
+
id: '1704067200000',
|
|
31
|
+
channel_id: '19:1on1@unq.gbl.spaces',
|
|
32
|
+
author: { id: 'ME', displayName: 'Me' },
|
|
33
|
+
content: 'Hello world',
|
|
34
|
+
timestamp: '2025-01-29T10:00:00Z',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
credManagerLoadSpy = spyOn(TeamsCredentialManager.prototype, 'loadConfig').mockResolvedValue({
|
|
38
|
+
current_account: 'personal',
|
|
39
|
+
accounts: {
|
|
40
|
+
personal: {
|
|
41
|
+
token: 'test_token',
|
|
42
|
+
account_type: 'personal' as const,
|
|
43
|
+
current_team: null,
|
|
44
|
+
teams: {},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
clientListChatsSpy?.mockRestore()
|
|
52
|
+
clientGetChatMessagesSpy?.mockRestore()
|
|
53
|
+
clientSendChatMessageSpy?.mockRestore()
|
|
54
|
+
credManagerLoadSpy?.mockRestore()
|
|
55
|
+
console.log = originalConsoleLog
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('list: returns array of chats', async () => {
|
|
59
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
60
|
+
console.log = consoleSpy
|
|
61
|
+
|
|
62
|
+
await listAction({ pretty: false })
|
|
63
|
+
|
|
64
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
65
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
66
|
+
expect(output).toContain('19:1on1@unq.gbl.spaces')
|
|
67
|
+
expect(output).toContain('19:group@thread.tacv2')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('history: returns array of messages', async () => {
|
|
71
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
72
|
+
console.log = consoleSpy
|
|
73
|
+
|
|
74
|
+
await historyAction('19:1on1@unq.gbl.spaces', { limit: 50, pretty: false })
|
|
75
|
+
|
|
76
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
77
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
78
|
+
expect(output).toContain('msg_123')
|
|
79
|
+
expect(output).toContain('Alice')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('history: falls back to default limit when given a non-positive value', async () => {
|
|
83
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
84
|
+
console.log = consoleSpy
|
|
85
|
+
|
|
86
|
+
await historyAction('19:1on1@unq.gbl.spaces', { limit: -5, pretty: false })
|
|
87
|
+
|
|
88
|
+
expect(clientGetChatMessagesSpy).toHaveBeenCalledWith('19:1on1@unq.gbl.spaces', 50)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('send: returns sent message', async () => {
|
|
92
|
+
const consoleSpy = mock((_msg: string) => {})
|
|
93
|
+
console.log = consoleSpy
|
|
94
|
+
|
|
95
|
+
await sendAction('19:1on1@unq.gbl.spaces', 'Hello world', { pretty: false })
|
|
96
|
+
|
|
97
|
+
expect(consoleSpy).toHaveBeenCalled()
|
|
98
|
+
const output = consoleSpy.mock.calls[0][0]
|
|
99
|
+
expect(output).toContain('Hello world')
|
|
100
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
|
|
3
|
+
import { handleError } from '@/shared/utils/error-handler'
|
|
4
|
+
import { formatOutput } from '@/shared/utils/output'
|
|
5
|
+
|
|
6
|
+
import { TeamsClient } from '../client'
|
|
7
|
+
import { TeamsCredentialManager } from '../credential-manager'
|
|
8
|
+
|
|
9
|
+
export async function listAction(options: { pretty?: boolean }): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
const credManager = new TeamsCredentialManager()
|
|
12
|
+
const cred = await credManager.getTokenWithExpiry()
|
|
13
|
+
|
|
14
|
+
if (!cred) {
|
|
15
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const client = await new TeamsClient().login({
|
|
20
|
+
token: cred.token,
|
|
21
|
+
tokenExpiresAt: cred.tokenExpiresAt,
|
|
22
|
+
accountType: cred.accountType,
|
|
23
|
+
region: cred.region,
|
|
24
|
+
})
|
|
25
|
+
const chats = await client.listChats()
|
|
26
|
+
|
|
27
|
+
const output = chats.map((chat) => ({
|
|
28
|
+
id: chat.id,
|
|
29
|
+
type: chat.type,
|
|
30
|
+
topic: chat.topic,
|
|
31
|
+
last_message: chat.last_message,
|
|
32
|
+
last_message_at: chat.last_message_at,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
console.log(formatOutput(output, options.pretty))
|
|
36
|
+
} catch (error) {
|
|
37
|
+
handleError(error as Error)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function historyAction(chatId: string, options: { limit?: number; pretty?: boolean }): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const credManager = new TeamsCredentialManager()
|
|
44
|
+
const cred = await credManager.getTokenWithExpiry()
|
|
45
|
+
|
|
46
|
+
if (!cred) {
|
|
47
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const client = await new TeamsClient().login({
|
|
52
|
+
token: cred.token,
|
|
53
|
+
tokenExpiresAt: cred.tokenExpiresAt,
|
|
54
|
+
accountType: cred.accountType,
|
|
55
|
+
region: cred.region,
|
|
56
|
+
})
|
|
57
|
+
const limit = options.limit && options.limit > 0 ? options.limit : 50
|
|
58
|
+
const messages = await client.getChatMessages(chatId, limit)
|
|
59
|
+
|
|
60
|
+
const output = messages.map((msg) => ({
|
|
61
|
+
id: msg.id,
|
|
62
|
+
author: msg.author.displayName,
|
|
63
|
+
content: msg.content,
|
|
64
|
+
timestamp: msg.timestamp,
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
console.log(formatOutput(output, options.pretty))
|
|
68
|
+
} catch (error) {
|
|
69
|
+
handleError(error as Error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function sendAction(chatId: string, content: string, options: { pretty?: boolean }): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
const credManager = new TeamsCredentialManager()
|
|
76
|
+
const cred = await credManager.getTokenWithExpiry()
|
|
77
|
+
|
|
78
|
+
if (!cred) {
|
|
79
|
+
console.log(formatOutput({ error: 'Not authenticated. Run "auth extract" first.' }, options.pretty))
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const client = await new TeamsClient().login({
|
|
84
|
+
token: cred.token,
|
|
85
|
+
tokenExpiresAt: cred.tokenExpiresAt,
|
|
86
|
+
accountType: cred.accountType,
|
|
87
|
+
region: cred.region,
|
|
88
|
+
})
|
|
89
|
+
const message = await client.sendChatMessage(chatId, content)
|
|
90
|
+
|
|
91
|
+
const output = {
|
|
92
|
+
id: message.id,
|
|
93
|
+
content: message.content,
|
|
94
|
+
timestamp: message.timestamp,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(formatOutput(output, options.pretty))
|
|
98
|
+
} catch (error) {
|
|
99
|
+
handleError(error as Error)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const chatCommand = new Command('chat')
|
|
104
|
+
.description('Chat commands (1:1, group, and self chats)')
|
|
105
|
+
.addCommand(
|
|
106
|
+
new Command('list')
|
|
107
|
+
.description('List 1:1, group, and self chats')
|
|
108
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
109
|
+
.action(listAction),
|
|
110
|
+
)
|
|
111
|
+
.addCommand(
|
|
112
|
+
new Command('history')
|
|
113
|
+
.description('Get chat message history')
|
|
114
|
+
.argument('<chat-id>', 'Chat ID')
|
|
115
|
+
.option('--limit <n>', 'Number of messages to fetch', '50')
|
|
116
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
117
|
+
.action((chatId, options) => {
|
|
118
|
+
return historyAction(chatId, {
|
|
119
|
+
limit: parseInt(options.limit, 10),
|
|
120
|
+
pretty: options.pretty,
|
|
121
|
+
})
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
.addCommand(
|
|
125
|
+
new Command('send')
|
|
126
|
+
.description('Send a message to a chat')
|
|
127
|
+
.argument('<chat-id>', 'Chat ID')
|
|
128
|
+
.argument('<content>', 'Message content')
|
|
129
|
+
.option('--pretty', 'Pretty print JSON output')
|
|
130
|
+
.action(sendAction),
|
|
131
|
+
)
|
|
@@ -35,6 +35,16 @@ export interface TeamsUser {
|
|
|
35
35
|
userPrincipalName?: string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export type TeamsChatType = 'oneOnOne' | 'group' | 'self'
|
|
39
|
+
|
|
40
|
+
export interface TeamsChat {
|
|
41
|
+
id: string
|
|
42
|
+
type: TeamsChatType
|
|
43
|
+
topic?: string
|
|
44
|
+
last_message?: string
|
|
45
|
+
last_message_at?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export interface TeamsReaction {
|
|
39
49
|
emoji: string
|
|
40
50
|
count: number
|
|
@@ -124,6 +134,16 @@ export const TeamsUserSchema = z.object({
|
|
|
124
134
|
userPrincipalName: z.string().optional(),
|
|
125
135
|
})
|
|
126
136
|
|
|
137
|
+
export const TeamsChatTypeSchema = z.enum(['oneOnOne', 'group', 'self'])
|
|
138
|
+
|
|
139
|
+
export const TeamsChatSchema = z.object({
|
|
140
|
+
id: z.string(),
|
|
141
|
+
type: TeamsChatTypeSchema,
|
|
142
|
+
topic: z.string().optional(),
|
|
143
|
+
last_message: z.string().optional(),
|
|
144
|
+
last_message_at: z.string().optional(),
|
|
145
|
+
})
|
|
146
|
+
|
|
127
147
|
export const TeamsReactionSchema = z.object({
|
|
128
148
|
emoji: z.string(),
|
|
129
149
|
count: z.number(),
|
|
@@ -37,7 +37,7 @@ export class LineAdapter implements PlatformAdapter {
|
|
|
37
37
|
return messages.map((msg) => ({
|
|
38
38
|
id: msg.message_id,
|
|
39
39
|
channelId,
|
|
40
|
-
author: msg.author_id ?? 'unknown',
|
|
40
|
+
author: msg.author_name ?? msg.author_id ?? 'unknown',
|
|
41
41
|
content: msg.text ?? '',
|
|
42
42
|
timestamp: msg.sent_at,
|
|
43
43
|
}))
|