Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions services/connector/src/adapters/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,15 @@ export class DiscordConnectorAdapter implements ConnectorAdapter {

private registerCommands(context: ConnectorAdapterContext): void {
context.commands.register('link', async ({ userId, reply }) => {
if (this.ownerUserId && this.ownerUserId !== userId) {
if (this.ownerUserId && this.ownerUserId !== userId) {
await reply('This connector is already linked to another account.')
return
}
this.ownerUserId = userId
await context.updateSettings({ ownerUserId: userId })
if (!this.ownerUserId) {
await reply('This connector requires the owner account to be configured in Connector settings before linking. First-come /link binding is disabled to prevent a takeover by whoever messages the bot first.')
return
}
await context.updateSettings({ ownerUserId: this.ownerUserId })
this.tracker.healthy(userId)
await reply('Discord is linked to this OpenAlice installation.')
})
Expand Down
2 changes: 1 addition & 1 deletion services/connector/src/adapters/feishu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe('Feishu owner chat', () => {
const adapter = new FeishuConnectorAdapter({ startupTimeoutMs: 200 })
await adapter.start({
enabled: true,
settings: { appId: APP_ID, appSecret: APP_SECRET },
settings: { appId: APP_ID, appSecret: APP_SECRET, ownerUserId: 'ou_owner' },
}, context({ updateSettings }))
await receiveHandler?.(p2pEvent('/link'))
expect(updateSettings).toHaveBeenCalledWith({ ownerUserId: 'ou_owner', chatId: 'oc_chat' })
Expand Down
6 changes: 5 additions & 1 deletion services/connector/src/adapters/feishu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,14 @@ export class FeishuConnectorAdapter implements ConnectorAdapter {

private registerCommands(context: ConnectorAdapterContext): void {
context.commands.register('link', async ({ userId, chatId, reply }) => {
if (this.ownerUserId && this.ownerUserId !== userId) {
if (this.ownerUserId && this.ownerUserId !== userId) {
await reply('This connector is already linked to another account.')
return
}
if (!this.ownerUserId) {
await reply('This connector requires the owner account to be configured in Connector settings before linking. First-come /link binding is disabled to prevent a takeover by whoever messages the bot first.')
return
}
if (!chatId) throw new Error('Feishu private chat ID is missing')
this.ownerUserId = userId
this.chatId = chatId
Expand Down
100 changes: 100 additions & 0 deletions services/connector/src/adapters/owner-link-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Regression tests for the /link owner-binding fix.
*
* Before: when the connector had no configured owner, the first person to
* message /link became owner (first-come takeover; whoever finds the bot
* first wins).
* After: /link only binds a chat for a caller that matches the owner
* configured in Connector settings. An unconfigured bot refuses to bind.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CommandRegistry } from '../core/adapter.js'
import { TelegramConnectorAdapter } from './telegram.js'

const startMock = vi.fn()
const getMe = vi.fn(async () => ({ id: 1, is_bot: true, first_name: 'OpenAlice', username: 'openalice_bot' }))
const setMyCommands = vi.fn(async () => undefined)

vi.mock('grammy', async (importOriginal) => {
const actual = await importOriginal<typeof import('grammy')>()
return {
...actual,
Bot: class {
api = { config: { use() {} }, getMe, setMyCommands }
command() {}
on() {}
start(options: { onStart?: () => void }) { return startMock(options) }
stop() { return Promise.resolve() }
},
InputFile: class {},
}
})
vi.mock('@grammyjs/auto-retry', () => ({ autoRetry: () => () => undefined }))

function freshContext(updates: Array<Record<string, unknown>>) {
return {
commands: new CommandRegistry('telegram'),
updateSettings: async (patch: Record<string, string | number | boolean>) => { updates.push(patch) },
getServiceStatus: () => 'healthy',
sendTest: async () => 'probe',
forwardOwnerText: async () => undefined,
enqueueArtifactRequest: async () => 'art',
enqueueUtaRequest: async () => 'uta',
} as unknown as Parameters<TelegramConnectorAdapter['start']>[1]
}

async function startAdapter(settings: Record<string, string>) {
startMock.mockImplementation((options: { onStart?: () => void }) => {
queueMicrotask(() => options.onStart?.())
return new Promise(() => undefined)
})
const updates: Array<Record<string, unknown>> = []
const ctx = freshContext(updates)
const adapter = new TelegramConnectorAdapter({ attemptTimeoutMs: 200, reconnectDelayMs: 20 })
await adapter.start({ enabled: true, settings }, ctx)
return { adapter, ctx, updates }
}

async function runLink(ctx: ReturnType<typeof freshContext>, userId: string, chatId: string): Promise<string> {
let replyText = ''
const handled = await ctx.commands.execute({
connectorId: 'telegram', command: 'link', userId, chatId,
reply: async (text: string) => { replyText = text },
})
expect(handled).toBe(true)
return replyText
}

describe('/link owner binding guard', () => {
beforeEach(() => {
startMock.mockReset()
getMe.mockReset(); getMe.mockResolvedValue({ id: 1, is_bot: true, first_name: 'OpenAlice', username: 'openalice_bot' })
setMyCommands.mockReset(); setMyCommands.mockResolvedValue(undefined)
})

it('refuses to bind an owner when none is configured (no first-come takeover)', async () => {
const { adapter, ctx, updates } = await startAdapter({ botToken: 'token' })
const replyText = await runLink(ctx, 'stranger-1', '111')
expect(replyText).toMatch(/owner account to be configured/i)
expect(updates.length).toBe(0) // nothing persisted
expect(adapter.health().owner).toBeUndefined()
await adapter.stop()
})

it('binds the chat only for the configured owner', async () => {
const { adapter, ctx, updates } = await startAdapter({ botToken: 'token', ownerUserId: '42' })
const replyText = await runLink(ctx, '42', '99')
expect(replyText).toMatch(/linked to this OpenAlice/i)
expect(updates).toContainEqual(expect.objectContaining({ ownerUserId: '42', chatId: '99' }))
expect(adapter.health().owner).toBe('42')
await adapter.stop()
})

it('still rejects a caller that is not the configured owner', async () => {
const { adapter, ctx, updates } = await startAdapter({ botToken: 'token', ownerUserId: '42' })
const replyText = await runLink(ctx, 'stranger-1', '111')
expect(replyText).toMatch(/already linked/i)
expect(updates.length).toBe(0)
await adapter.stop()
})
})
9 changes: 6 additions & 3 deletions services/connector/src/adapters/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,15 @@ export class SlackConnectorAdapter implements ConnectorAdapter {

private registerCommands(context: ConnectorAdapterContext): void {
context.commands.register('link', async ({ userId, reply }) => {
if (this.ownerUserId && this.ownerUserId !== userId) {
if (this.ownerUserId && this.ownerUserId !== userId) {
await reply('This connector is already linked to another account.')
return
}
this.ownerUserId = userId
await context.updateSettings({ ownerUserId: userId })
if (!this.ownerUserId) {
await reply('This connector requires the owner account to be configured in Connector settings before linking. First-come /link binding is disabled to prevent a takeover by whoever messages the bot first.')
return
}
await context.updateSettings({ ownerUserId: this.ownerUserId })
this.tracker.healthy(userId)
await reply('Slack is linked to this OpenAlice installation.')
})
Expand Down
6 changes: 5 additions & 1 deletion services/connector/src/adapters/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,10 +451,14 @@ export class TelegramConnectorAdapter implements ConnectorAdapter {

private registerCommands(context: ConnectorAdapterContext): void {
context.commands.register('link', async ({ userId, chatId, reply }) => {
if (this.ownerUserId && this.ownerUserId !== userId) {
if (this.ownerUserId && this.ownerUserId !== userId) {
await reply('This connector is already linked to another account.')
return
}
if (!this.ownerUserId) {
await reply('This connector requires the owner account to be configured in Connector settings before linking. First-come /link binding is disabled to prevent a takeover by whoever messages the bot first.')
return
}
if (!chatId) throw new Error('Telegram private chat ID is missing')
this.ownerUserId = userId
this.chatId = chatId
Expand Down